Skip to content

compiler: preemptively indirect large params/results instead of letting LLVM do it#5526

Open
jakebailey wants to merge 8 commits into
tinygo-org:devfrom
jakebailey:large-aggregate-abi
Open

compiler: preemptively indirect large params/results instead of letting LLVM do it#5526
jakebailey wants to merge 8 commits into
tinygo-org:devfrom
jakebailey:large-aggregate-abi

Conversation

@jakebailey

@jakebailey jakebailey commented Jul 14, 2026

Copy link
Copy Markdown
Member

#5477 revealed a preexisting bug(?) with large types.

When there are too many parameters or results (common! it's not like every result can fit in registers), LLVM indirects things. It turns out that the result value it makes is a flattened struct, one element per struct field and array element. So then each of those elements then gets its own LLVM value to construct it, which means that it scales up worse and worse the more array elements there are. In SelectionDAG this also hits a hard representation limit: SDNode::NumValues is an unsigned short, and overflows at 65,536 flattened values.

https://go.dev/cl/707355 added a struct with a massive array that's passed by value:

type tokens struct {
	extraHist [32]uint16  // codes 256->maxnumlit
	offHist   [32]uint16  // offset codes
	litHist   [256]uint16 // codes 0->255
	nFilled   int
	n         uint16 // Must be able to contain maxStoreBlockSize
	tokens    [65536]token
}

This makes the compiler very unhappy. In this case, tokens has 65,858 scalar leaves, so direct lowering can crash LLVM in addition to being very slow.

The only way I can think to avoid this is to just do the indirection ourselves before handing it over to LLVM so that it doesn't hit this limit. TinyGo now does this for internal aggregate params/results above 1024 flattened scalar leaves, which is intentionally well below LLVM's 65,535-value representation limit to avoid the compile-time/memory cliff before the crash boundary. Exported function types are unchanged.

This is a pretty invasive change. I tried to split this stack into enough individual (and I think reasonable) commits with abstractions to make this reviewable one commit at a time.

Temporary backing storage goes through TinyGo's normal allocation path: non-escaping bounded allocations may still be stack-promoted by the allocation optimizer, while escaping storage remains heap-backed. It's unlikely anyone will actually hit this code, though, given before they would have been very much timing things out?

@jakebailey

Copy link
Copy Markdown
Member Author

FYI @deadprogram, this is the "horrorshow" from #5477 (comment), though it's less bad when split apart.

Comment thread compiler/defer.go
// Extract the params from the struct.
forwardParams := []llvm.Value{}
zero := llvm.ConstInt(b.ctx.Int32Type(), 0, false)
for i := range getParams(callback.Signature) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to be at least a bit amused by the loops I'm replacing, which are all written completely differently despite being identical.

@deadprogram

Copy link
Copy Markdown
Member

@jakebailey can you pull the latest dev into this branch to run the tests, please?

createDefer builds an LLVM struct containing the deferred function and
its arguments. createRunDefers separately reconstructs the same struct
type before loading the fields.

Keep each LLVM value together with its type while building a deferred
call record, and load all argument fields through one helper. This
removes the duplicate lists of field types and field loads.
Map, channel, index, and goroutine lowering each create allocas, store
SSA values into them, load results, and emit lifetime intrinsics.

Add helpers for these operations and use runtimeValueResult for runtime
calls that write a value and an optional comma-ok result. The generated
LLVM IR is unchanged.
getFunction and getLLVMFunctionType duplicate the construction of LLVM
result types for functions with zero, one, or multiple results.

Move this code to getLLVMResultType. Also split the existing parameter
expansion code into expandDirectFormalParamType so callers can request
the current flattened parameter types directly.
createFunctionCall and createGo currently lower arguments before they
determine whether the call is direct, an interface invoke, or through a
function value.

Resolve the callee, function type, and context first, then append the
arguments in the same order as before. This does not change the
generated LLVM IR.
createInstruction records each LLVM value in locals, emits stack object
tracking, and constructs function returns inline.

Move these operations to setValue and createReturn. This keeps the
instruction switch from having to know how an SSA value or function
result is emitted.
Add compiler coverage for large aggregate parameters and results,
including function values, interfaces, maps, channels, selects, deferred
calls, goroutines, phis, and multiple results.
LLVM ComputeValueVTs recursively expands arrays and structs into one
value type per scalar leaf. SelectionDAG call lowering allocates data
structures proportional to this count, which makes very large values
exhaust memory or crash LLVM.

Count scalar leaves and use pointers for internal parameters and results
when the count exceeds 1024. A result pointer is the first parameter,
and aggregate parameters point to read-only memory. Exported function
types are unchanged.

Keep these SSA values in memory and copy them with memcpy when needed.
Handle calls, interfaces, maps, channels, selects, defers, goroutines,
phis, and multiple results. Update the expected compiler IR and re-enable
the native compress/flate tests.
@jakebailey
jakebailey force-pushed the large-aggregate-abi branch from 90d5bcd to 12d73ea Compare July 18, 2026 15:44
@deadprogram

Copy link
Copy Markdown
Member

Here is edited version of the useful parts of some generated feedback:

createIndirectStorage calls runtime.alloc (GC-tracked heap allocation) rather than using stack allocas. For frequently-called functions with large params, this adds GC pressure. The comment in the PR description says "The indirection happens on the stack where possible" but looking at the code, createIndirectStorage always calls runtime.alloc. The getValueStorage helper does use createTemporaryAlloca for small types but falls through to getValuePointer -> createIndirectStorage for large ones. Is there a reason stack allocation (alloca) wasn't used for the indirect storage? It seems like for values that don't escape, a stack alloca would be more efficient.

isLLVMValueType filtering determines whether a Go type maps to a "pure" LLVM value type. Types containing interfaces, maps, slices etc. are excluded from indirect treatment because their LLVM representation is already a small struct of pointers. However, a struct containing a large array AND an interface field would return false from isLLVMValueType and skip indirection entirely, even though the array component is huge. Is this intentional?

prependIndirectResult in defer replay, when replaying deferred calls, prependIndirectResult allocates storage for the result, but deferred calls discard their return values. This allocates memory that's never read. It's harmless but wasteful.

getGoroutineCallArgument correctly copies indirect params to new storage (since the goroutine outlives the caller), but uses createIndirectStorage (heap alloc). For go readLargeValue(value), this means an extra 1025-byte heap allocation + memcpy. This is correct but worth noting for performance.

@jakebailey

Copy link
Copy Markdown
Member Author

createIndirectStorage calls runtime.alloc (GC-tracked heap allocation) rather than using stack allocas. For frequently-called functions with large params, this adds GC pressure. The comment in the PR description says "The indirection happens on the stack where possible" but looking at the code, createIndirectStorage always calls runtime.alloc. The getValueStorage helper does use createTemporaryAlloca for small types but falls through to getValuePointer -> createIndirectStorage for large ones. Is there a reason stack allocation (alloca) wasn't used for the indirect storage? It seems like for values that don't escape, a stack alloca would be more efficient.

So, yes, but these are allocs that would normally have crashed the compiler, so I'm not sure that I'm actually regressing much?

isLLVMValueType filtering determines whether a Go type maps to a "pure" LLVM value type. Types containing interfaces, maps, slices etc. are excluded from indirect treatment because their LLVM representation is already a small struct of pointers. However, a struct containing a large array AND an interface field would return false from isLLVMValueType and skip indirection entirely, even though the array component is huge. Is this intentional?

I don't think this is true, but maybe I can add a test.

prependIndirectResult in defer replay, when replaying deferred calls, prependIndirectResult allocates storage for the result, but deferred calls discard their return values. This allocates memory that's never read. It's harmless but wasteful.

Yes, but I don't think there's a way around this one.

getGoroutineCallArgument correctly copies indirect params to new storage (since the goroutine outlives the caller), but uses createIndirectStorage (heap alloc). For go readLargeValue(value), this means an extra 1025-byte heap allocation + memcpy. This is correct but worth noting for performance.

IIRC this happened before, it's just that I'm doing it earlier, right?

@jakebailey

Copy link
Copy Markdown
Member Author

I have some fixes I'll push in, but, I have to compile LLVM 22 first so that'll take me a bit 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants