Skip to content

compiler: pass large aggregate parameters indirectly (#5615) - #5618

Open
neomantra wants to merge 7 commits into
tinygo-org:devfrom
neomantra:param-spill
Open

compiler: pass large aggregate parameters indirectly (#5615)#5618
neomantra wants to merge 7 commits into
tinygo-org:devfrom
neomantra:param-spill

Conversation

@neomantra

Copy link
Copy Markdown
Contributor

As described in #5615, we need to spill aggregate parameters. This has been worked over and reviewed by humans and LLMs. I tested it against some BubbleTea programs, as well as the tests described below. I finally got BubbleTea Lists working with WASM, but there's another issue that needs resolved.

The below is LLM-generated. I have read and reviewed it as well as the code.


Summary

Pass aggregate parameters with more than 16 scalar leaves through backing storage in the Go-internal ABI, so each parameter contributes at most 16 scalars after WebAssembly scalarization. This is a practical mitigation of the JS embedding's 1,000-parameter limit rather than whole-signature enforcement: exceeding the cap now requires a function with more than 62 parameters, far beyond any signature observed in practice (the worst real Bubble Tea offender was a single receiver with thousands of leaves). Exported/C-ABI signatures are deliberately unchanged — their ABI is externally visible and cannot be respilled — so an exported function with huge aggregate parameters can still exceed the cap.

Fixes #5615.

Problem

LLVM's WebAssembly backend recursively scalarizes struct-by-value parameters. A single lipgloss.Style has roughly 95 scalar leaves, and value receivers such as bubbles/list.Model contain many Styles. Before maxDirectAggregateValues, real Bubble Tea functions produced wasm types with thousands of parameters (3730 / 4316 measured); browsers reject any function type above 1000 parameters, and wasmparser-based tooling (wasm-tools et al.) rejects such modules too.

Current dev already has a generalized indirect-aggregate engine (maxDirectAggregateValues = 1024, from #5526). That threshold was chosen to protect LLVM itself — SelectionDAG's 65,535-value representation limit and the compile-time cliff before it (#5477) — but it does not protect the stricter whole-function JavaScript embedding limit: several individually smaller aggregate parameters can still exceed 1000 in total. This PR extends #5526's approach (preemptive indirection before LLVM, exported types unchanged) with a lower threshold on the parameter side for that external limit. Measured on dev (f71b630 and current 86d58db): the bubbles list-fancy example's largest function type is at 995 params — five below the cap — and a 25-line program with two 600-leaf struct params (see the issue) emits a 1200-param type that wasm-tools refuses to parse.

Implementation

  • Count scalar leaves through nested structs and arrays and spill a Go-ABI parameter above 16 leaves.
  • Reuse current dev's indirect-value storage, copy, GC tracking, result, defer, and goroutine machinery rather than restoring the branch's older parallel implementation.
  • Keep exported/C ABI parameters unchanged.
  • Make interface invoke wrappers use the same indirect receiver decision while preserving exported method ABI. When an exported method's result or a non-receiver parameter is a large aggregate, the C ABI and the interface invoke thunk's Go-internal ABI place the value incompatibly; that case now produces a clear compile error instead of miscompiling. For results (and parameters above 1024 leaves) this was already broken on current dev — an exported method with a [1025]byte result through an interface crashes dev's compiler with a slice-bounds panic in getInterfaceInvokeWrapper. For parameters between 17 and 1024 leaves the error is a knowing trade-off: dev compiles and runs that narrow combination correctly (it doesn't spill below 1024), and restoring it under the lower threshold would require a full C/Go ABI bridge in the wrapper, which can be added separately if the combination matters in practice. The error path is covered by the compiler-errors test, whose harness now also fails on missing expected errors.
  • Materialize indirect phi inputs in their predecessor blocks before the terminator. The lower threshold exposed late phi copies that otherwise landed after a branch/return in real bubbles/list functions ("Basic Block ... does not have terminator").
  • Add unit coverage for the 16/17 boundary and arrays, golden IR coverage for direct calls, function values, interface receivers, exported ABI, goroutines, and phis, plus a GC-stressed behavioral test for direct/func-value/interface/defer/goroutine paths. The function-value coverage is a genuine indirect call: the callee is hidden behind a //go:noinline picker so StaticCallee() is nil, and the golden pins the func-value decode (code-pointer extraction, nil check, indirect call with the spilled parameter). The phi golden pins real phi ptr nodes for both an if/else merge and a loop back edge, with incoming values (a constant and a plain load) that require predecessor materialization — reverting the phi placement fix makes this test fail with the same malformed-IR error seen in the real Bubble Tea build.

Verification

  • go test -tags llvm22 ./compiler -run '^TestParamNeedsSpill$' -count=1 -v
  • go test -tags llvm22 ./compiler -run '^TestCompiler/paramspill.go$' -count=1 -v
  • go test -tags llvm22 ./compiler -run '^TestCompilerErrors$' -count=1 -v — includes the new exported-method-through-interface diagnostics; the strengthened harness fails if an expected // ERROR: is not produced.
  • go test -tags llvm22 . -run '^TestBuild/Host/paramspill.go$' -short -count=1 -v
  • go test -tags llvm22 . -run '^TestBuild/WebAssembly/paramspill.go$' -count=1 -v
  • go test -tags llvm22 ./compiler -count=1
  • Exported-method probes: a method with a [1025]byte result called through an interface now fails with the clear diagnostic (the same program crashes current dev's compiler); a supported exported method (17-leaf value receiver, small result) called through an interface returns correct values at runtime.
  • Built Bubble Tea examples/list-fancy with GOOS=js GOARCH=wasm: success with no Binaryen large-parameter warning.
  • wasm-tools validate: success.
  • Type-section inspection: 74 function types; maximum parameter count 17 (type 43), down from 995 on current dev (and 3730/4316 before maxDirectAggregateValues), safely below 1000.
  • The minimal two-param repro from the issue builds a module that wasm-tools parses and validates.

All of the above were run against the branch head after the final rebase onto dev @ 86d58db.

Note on goroutine arguments

This PR deliberately contains no goroutine-specific spill code: dev's existing getGoroutineCallArgument + copyToIndirectStorage path already gives spilled goroutine arguments a GC-safe heap lifetime, and the spill predicate plugs into it unchanged. The behavioral test still covers the goroutine path (a spilled argument surviving the spawner's return, under GC stress) to pin that reuse.

Signed-off-by: Evan Wies <evan@neomantra.net>
LLVM's WebAssembly backend flattens aggregate parameters into individual
scalar parameters, and the WebAssembly JS embedding rejects function
types with more than 1000 parameters. Large value structs (for example
lipgloss.Style with ~95 scalar leaves, or list.Model which contains ~40
of them) therefore produced functions with thousands of wasm parameters
that browsers refuse to instantiate:

    argument count of Type ... is too big 3730 maximum 1000

With this change, parameters with more than 16 flattened scalar leaves
(arrays counted per element) are passed by pointer to a caller-owned
copy in the Go-internal calling convention: the caller stores the value
into a temporary alloca and passes the pointer, and the callee loads it
once at function entry. The pointer is non-null, read-only, properly
aligned, and does not escape, and is annotated accordingly.

Exported and external functions (//export, cgo, C ABI) deliberately
keep the previous behavior, selected via a new abiKind threaded through
the shared parameter expansion helpers. Deferred calls inherit the new
convention automatically because defer stores unexpanded values and
re-issues the call through createCall at rundefers time.

Signed-off-by: Evan Wies <evan@neomantra.net>
The invoke wrapper unpacks the receiver from the interface box and
calls the real method, so a >16-leaf value receiver must be re-spilled
to match the wrapped signature. Pass the raw receiver to createCallABI
with the ABI of the wrapped function: Go-ABI methods get the spill,
while exported (C ABI) methods keep by-value receiver expansion, also
in their wrapper.

Signed-off-by: Evan Wies <evan@neomantra.net>
Pins the new convention: the 16/17-leaf boundary, array leaf counting,
the unchanged C ABI of exported functions (including methods invoked
through an interface), caller-side spill allocas, the heap spill for go
statements, and the interface invoke wrapper.

This also changes the golden test harness to run instcombine with
no-verify-fixpoint, affecting all golden tests: standalone textual
instcombine fatally aborts when it needs more than one iteration (an
LLVM 18+ testing aid), which the interface-boxing code in this test
triggers. Real pass pipelines (see transform/optimizer.go) already run
instcombine with no-verify-fixpoint, falling back to plain instcombine
before LLVM 18 where the flag does not exist; the harness mirrors that.
All other golden files produce byte-identical output.

Signed-off-by: Evan Wies <evan@neomantra.net>
Checks value semantics and GC safety of by-pointer passing of large
aggregate parameters across all call paths: direct calls, func values,
interface method calls (kept dynamic with a second implementation),
defer (capturing the value at defer time), and goroutines (where the
spilled copy must outlive the spawning frame). The callee forces
allocation churn and collections before dereferencing the spilled
copy's pointer fields. Skipped on AVR like gc.go, for the same
conservative-GC flakiness.

Signed-off-by: Evan Wies <evan@neomantra.net>
Resolving a phi may need to materialize storage for an incoming value:
a constant or a register-form load has no backing allocation yet, so
the phi edge emits an alloc and a copy. These were emitted at the
builder's current position, after the function body, appending them
behind the last block's terminator and producing malformed IR ("Basic
Block does not have terminator") in real bubbles/list functions once
the 16-leaf threshold made mid-size aggregates spill. Emit each edge's
materialization in its predecessor block, before that block's
terminator. The golden test pins both shapes: an if/else merge and a
loop back edge, each with a constant and a plain-load incoming value.

Signed-off-by: Evan Wies <evan@neomantra.net>
Passing aggregate parameters through backing storage makes call-heavy
code use more stack: spill copies up to 1024 bytes are promoted from
the heap to the stack by the allocation optimizer, and without lifetime
markers each spilled call site keeps its own frame slot. A real Bubble
Tea list application (bubbles/list with go-booba browser I/O) overflows
the previous 64KB default at startup; measurement brackets its need
between 64KB and 96KB. Double the default on the wasm, wasip1, and
wasip2 targets, which is where large Go applications like TUIs run.
Regenerate the goldens that embed the goroutine stack size constant.

Signed-off-by: Evan Wies <evan@neomantra.net>
@jakebailey

Copy link
Copy Markdown
Member

I'm not sure I like this fix (seems a bit too AI-special-case-y); I'm looking into this problem separately just to convince myself of that, though.

@neomantra

Copy link
Copy Markdown
Contributor Author

Thank you for your patience in reviewing. While I can understand code diffs, I have limited experience in compiler/LLVM domain. Chopping and shaping trees, without traveling the forest.

It did seem natural to me to extend spilling to more circumstances and I only really worked to release the PR after the other spilling work (it was a much more complicated changeset last month).

I pushed on this further since yesterday, particularly I didn't like that we still had to bump the stack size (targets: raise wasm default goroutine stack size to 128KB commit). That ends up being related to slot allocation lifetime management: "transform.OptimizeAllocs promotes small non-escaping heap allocations to entry-block allocas but emits no llvm.lifetime intrinsics".

If there's anything you want me to explore on this, I'm happy to put the energy in to advance it, while trying to be considerate of the review/integration effort.

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.

wasm: struct-by-value parameters scalarize into >1000-param function types, rejected by browsers and wasm-tools

2 participants