Skip to content

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

Description

@neomantra

In working through various issues of getting BubbleTea to work with WASM (navigating a dependency tree I'm still upstreaming), I was also testing it against tinygo. Once I "got it working" initially, I hit a snag with some of BubbleTea's widgets ("bubbles"). The way they use the delegate pattern causes a diabolical parameter explosion. The stock Go compiler optimizes it away, but tinygo doesn't. In practice, this makes a small List widget unable to fit within a WASM runtime.

After discovering this, I worked through it with LLMs. I've sat on it ensuring I understood it better, sorry I didn't at least open the issue but I also didn't want to hand you garbage. Interestingly over time (a couple months), TinyGo added support for maxDirectAggregateValues spilling machinery (#5526), so I since reworked to use that. I hope that discussion and that well-worked change makes this easier to digest.

I have a PR forthcoming. The text below is LLM-generated, but I reviewed it all. These edges are very interesting, making me appreciate compiler underpinnings more and more, but also are not my forte.


What happened

The WebAssembly core spec places no limit on function parameter counts, but
the JS embedding caps them at 1,000 (https://webassembly.github.io/spec/js-api/#limits)
— browsers enforce this at WebAssembly.Module compile time, and
wasmparser-based tooling (wasm-tools, and runtimes built on wasmparser) rejects
such modules too.

TinyGo's wasm output can exceed this cap: Go structs passed by value are
re-scalarized by LLVM's WebAssembly backend into one wasm param per scalar
leaf, and nothing bounds the per-function total.

Minimal reproduction (verified on dev @ f71b630 and 86d58db)

package main

import "os"

// 600 scalar leaves per value: below the compiler's 1024 per-aggregate
// indirect threshold, so each parameter is passed by value.
type s10 struct{ f0, f1, f2, f3, f4, f5, f6, f7, f8, f9 int32 }
type s100 struct{ a0, a1, a2, a3, a4, a5, a6, a7, a8, a9 s10 }
type s600 struct{ b0, b1, b2, b3, b4, b5 s100 }

//go:noinline
func f(x, y s600) int32 { return x.b0.a0.f0 + y.b5.a9.f9 }

func main() {
	var x, y s600
	x.b0.a0.f0 = int32(len(os.Args))
	y.b5.a9.f9 = int32(len(os.Environ()))
	println(f(x, y))
}
$ GOOS=js GOARCH=wasm tinygo build -o repro.wasm .
Some VMs may not accept this binary because it has a large number of
parameters in function main.f.

$ wasm-tools print repro.wasm
error: function params size is out of bounds (at offset 0x85)

main.f gets a wasm function type with 1,200 i32 parameters (2 × 600 leaves).
Binaryen only warns; browsers and wasm-tools reject the module outright.

Where this bites in practice

This was found compiling Bubble Tea / charm TUIs to wasm
(charmbracelet/bubbletea#1698). A lipgloss.Style flattens to ~95 scalar
leaves and bubbles/list.Model transitively holds ~40 of them, so
value-receiver methods used to produce types like (list.Model).Update with
3,730 params and (list.DefaultDelegate).Render with 4,316 — Safari:

CompileError: WebAssembly.Module doesn't parse at byte 103:
argument count of Type at index 14 is too big 3730 maximum 1000

Current dev improved this: maxDirectAggregateValues = 1024
(compiler/func.go, added by #5526 to keep giant single aggregates from
overflowing SelectionDAG's 65,535-value representation limit, cf. #5477)
passes any single aggregate above 1,024 leaves
indirectly. That pulled the bubbles examples down a lot — but it bounds
per-aggregate values, not the per-function total the JS embedding actually
limits. Measured on dev (both f71b630 and current 86d58db): the bubbles
list-fancy example's largest function type has 995 params — five below
the browser cap
. One more lipgloss.Style anywhere in a model (≈95 leaves)
pushes a real app back over the limit, and the repro above shows two modest
600-leaf params already fail.

(maxFieldsPerParam = 3 in compiler/calls.go doesn't help here: a struct
that flattens to more than 3 fields is passed as a single LLVM struct-by-value
parameter, which the wasm backend then expands anyway. Return values are
fine — they're already lowered through sret out-params, cf. #2512. Only the
parameter side is affected.)

Proposed fix

Extend #5526's approach — preemptively indirect aggregates before LLVM sees
them — with a lower threshold for this stricter, external limit: spill
aggregate parameters above a small leaf threshold (16) through backing
storage in the Go-internal ABI, reusing dev's existing indirect-value
machinery (copyToIndirectStorage, goroutine argument handling), keeping the
exported/C ABI unchanged (the same scope decision #5526 made). This bounds each parameter's contribution to 16
scalars rather than enforcing a whole-signature budget — a practical
mitigation: exceeding 1,000 then requires a function with more than 62
parameters, which real Go signatures don't approach (the offenders here are
single receivers with thousands of leaves). Exported/C-ABI functions are out
of scope, since their externally visible ABI cannot be respilled. I have a
branch implementing this with golden-IR and GC-stressed behavioral tests;
with it, the bubbles list-fancy example's largest function type drops from
995 params to 17, and the repro above builds a module that wasm-tools validate accepts. PR incoming.

Environment

  • TinyGo: tinygo version 0.42.0-dev darwin/arm64 (using go version go1.26.7 and LLVM version 22.1.8) — built from dev at f71b630, re-verified at
    86d58db
  • Target: GOOS=js GOARCH=wasm (the scalarization is target-independent for
    wasm; wasip1 modules hit the same rejection in wasmparser-based runtimes)
  • wasm-tools 1.257.1 (homebrew), Binaryen bundled with TinyGo

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions