This is the stricttools fork of
github.com/ncruces/wasm2go,
with the optimization passes described below.
Its module path is github.com/stricttools/wasm-to-go.
To translate a Wasm module to Go, use the following command:
wasm2go < input.wasm > output.go
The input is a Wasm module, and the output is Go source
with no dependencies beyond the standard library:
a single file, or two with -unsafe,
or, for a large module, several packages.
stack-weight, a command of this repository, prepares a module
for running both translated and directly on a JavaScript engine
with the same outcome when it recurses deeply
(see the stack-weight pass).
The generated Go file forms a self-contained package
(with its internal packages, for a large module),
that exports a structure called Module
and a New function to initialize it.
The methods of the Module structure are the Wasm module's exports,
whereas imports are interfaces that New consumes.
A Module must be created by New, and must not be copied,
and code outside the translated module must not change its unexported fields
(code in the same package can reach them).
The optimization passes rely on this:
calls through a function table are compiled into direct calls on the receiver,
so a zero or copied Module would make calls where the Wasm module traps,
or call the functions of another Module,
and functions load an imported memory, which New sets up, when they start.
When a pass relies on it, the generated Module type says so in its doc comment.
We assume the input Wasm modules can be trusted. At a minimum, you should run Wasm modules through a verifier before attempting to convert an untrusted module.
The current target is a useful subset of Wasm produced by clang,
including the following features:
- bulk memory instructions and reference types;
- non-trapping float-to-int conversions;
- sign-extension instructions;
- multi-value results;
- 64-bit address space;
- extended constant expressions;
- tail calls 1;
- threads and atomics;
- wide arithmetic;
- fixed-width SIMD (see SIMD; relaxed SIMD is refused).
This includes full support for the Lime1 target.
There's minimal support for dynamic linking, and DWARF.
Generating human-readable Go code is a non-goal:
- Wasm names must be mangled into Go identifiers;
- Wasm control flow is implemented with
gotoand labels; - Go's distinction between
boolandint32requires spurious if statements and type conversions; - Go's distinction between signed and unsigned integers requires frequent type conversions;
- Go's untyped numeric literals require explicit type conversions;
- Go's constant evaluator does not match Wasm semantics,
requiring workarounds to avoid constant folding/propagation:
float constants are written as their bits (
math.Float64frombits(0x...)); - float operations require type conversions to avoid being combined;
- Go forbids unused variables/labels/etc.
Many of these introduce unnecessary verbosity, but they're necessary for semantic correctness.
Judge the output by the assembly generated by the Go compiler, not by how a human would read it.
For some CPUs, you can generate faster code by using unsafe.
Despite the scary name, the generated code abides by the
rules of unsafe,
and every memory access is still bounds checked,
unless an earlier check on every path to it already proves it in bounds
(see bounds checks).
Float operations follow the WebAssembly deterministic profile (see determinism).
Translated code computes the same bits wherever it runs, within the limits below: the same calls give the same results, NaNs included, whatever the CPU. There is no option to turn this off.
- NaN results. Float operations follow the
deterministic profile
of WebAssembly 3.0: whenever the result of
add,sub,mul,div,sqrt,ceil,floor,trunc,nearest,min,max,promote, ordemoteis a NaN, it is the positive canonical NaN (0x7fc00000,0x7ff8000000000000), whatever NaN the CPU produced. Each such operation is wrapped inf32_canonorf64_canon(or is a helper that returns the canonical NaN, forminandmax); where its result is consumed directly by another such operation, a comparison, or a conversion to integer, the inner wrapper is left out, since it could only change NaN bits the consumer ignores. A float variable assigned only such results, constants that are not other NaNs,minandmax, integers converted to floats, or other such variables holds the raw results instead, and is canonicalized where it is used other than by such a consumer or another such variable (a store, a reinterpretation, a call, a return, a global,neg,abs,copysign): seeDeferCanonin internal/passes. - Bitwise operations.
abs,neg, andcopysignare integer bit operations, and loads, stores, and reinterpretations move bits, so they keep a NaN's bits (Go compiles-xandmath.Absto instructions that are arithmetic on legacy MIPS). - No fused multiply-add. Every float operation's result is converted to its type
(
float64(x*y)), and Go never fuses across an explicit conversion, so the translated code needs no compiler flag for this. - Constants. Float constants are written as their bits, never as Go constants, which Go's constant evaluator would combine with exact arithmetic.
- Conversions.
f32.convert_i64_sandf32.convert_i64_uround once, in helpers: Go's ownfloat32(int64)rounds twice on 386, arm, mips, and mipsle. The float-to-integer conversions (truncandtrunc_sat) check or clamp their operand and then assemble the integer from the float's bits (trunc_u64); none is Go's conversionint32(f), whose result outside the range the Go specification defines is the CPU's, so strictgo, which refuses that conversion, accepts them. - C math functions.
libc-gen'ssin,exp,pow, and the othermath.hfunctions that are not compiler builtins are musl's libm, compiled into the module with the C code that calls them, so the guarantee covers them. A NaN they return is the canonical NaN, its negation, or a NaN operand's bits, unchanged or with another sign, as musl's code decides, and the same on every CPU.fmais the exception: it is a host function calling Go'smath.FMA. IEEE 754 defines its result exactly (x*y+zrounded once), so every CPU computes the same bits, with an instruction or in software, and a NaN result is the positive canonical NaN. musl'sfmacompiled into the module was about 15 times slower on amd64, since WebAssembly has no fused multiply-add, and it returns a NaN third operand unchanged (before musl 1.2.6 it also returned +0 instead of -0 for a negative product that rounds to zero plus +0).
How this is tested:
- The spec tests check the deterministic profile: where the spec allows a canonical or an arithmetic NaN, the result must be the positive canonical NaN.
Test_determinism_expectedcalls every float instruction, and chains of them, directly and through float locals whose value then reaches each kind of use, with every kind of operand, as raw bits, and requires the results in testdata/determinism/expected.txt bit for bit; they are identical to wasmtime's with NaN canonicalization, trap messages included (scripts/determinism-reference.sh checks this).Test_determinism_libmcalls everylibc-genmath function with edge and random operands and requires the results in libc-gen/test_math/expected.txt bit for bit; they are identical to wasmtime's with NaN canonicalization (scripts/libm-reference.sh checks this, withfmalinked to musl's, corrected for the canonical NaN).- scripts/cross-targets.sh runs these, with the regression
tests, the floating-point spec tests, and all the SIMD spec tests, on each target
it lists: amd64 (also with fused multiply-add available, without SSE4.1, and with
math.FMAin software), 386, wasip1 and js wasm, and the other Linux architectures under qemu-user.
Limits:
- The guarantee covers the translated module and
libc-gen'sfma. Other imports, host functions, and-providedGo code compute what they compute. - On MIPS the positive canonical NaN is a signaling NaN in the legacy NaN encoding; the results are the same anyway, since canonicalization follows every operation. MIPS was checked under qemu-user, which emulates the legacy encoding, not on hardware.
- Software floating point (
GOARM=5,GO386=softfloat) is not checked. - With threads, the results depend on how the threads interleave.
The cost, measured on amd64 when canonicalization was added (medians of interleaved runs):
QuickJS running a game for 1000 frames took 9.37 s before and 9.13 s after, within noise;
in microbenchmarks, a polynomial evaluated one step per local variable was 68% slower,
the same polynomial as one expression 20% slower, a float32 dot product 14% slower,
and a loop of sqrt, min, max, floor, and promote 40% faster
(the min and max helpers replaced Go's builtins).
Deferring the canonicalization of float variables to their uses took an
inference of onnxruntime's basic-pitch build from 1,098 to 781 ms
(the same results, bit for bit).
Compiling libc-gen's math functions into the module instead of calling Go's math
package changed their speed on amd64 as follows (nanoseconds per call in a loop,
medians of interleaved runs, Go's math then musl's):
sin 9.0 and 16.1, cos 10.7 and 16.2, tan 9.7 and 24.7, atan2 12.3 and 18.0,
tanh 15.0 and 22.9, exp 10.9 and 7.9, log 9.7 and 9.1, pow 55.4 and 23.1,
and fmod 19.0 and 7.1.
fma calls Go's math.FMA instead of musl's fma, which took 34.8 ns per call
where math.FMA takes 2.3 ns, and 14.1 ns with math.FMA in software
(GODEBUG=cpu.fma=off, as on CPUs without the instruction).
QuickJS built with wasi-sdk is not affected: it links wasi-libc, whose libm is musl's
compiled into the module, and imports no math functions.
A module built with -msimd128 has its v128 values in Go variables of the
type vec128, which each build defines (an alias), and its SIMD instructions
written for the build's target, in files of their own beside the output file
(so the output needs -o): each function that uses v128 values is in each
of the files below, and the module's other code only in the output file.
| file | build constraint | code |
|---|---|---|
output_simd_amd64.go |
goexperiment.simd && amd64.v3 && go1.27 && !go1.28 |
calls of simd/archsimd's methods |
output_simd_arm64.go |
goexperiment.simd && arm64 && go1.27 && !go1.28 |
the same |
output_simd_wasm.go |
goexperiment.simd && wasm && go1.27 && !go1.28 |
the same |
output_simd.go |
every other build | inline scalar code over the lanes |
output_simd_untested.go |
goexperiment.simd on amd64.v3, arm64, or wasm with another Go version |
a build error |
simd/archsimd exists only with GOEXPERIMENT=simd and has no compatibility
promise (its API changed between Go 1.26 and 1.27), so its code is written for
the Go version it was tested with, 1.27, and a build calling it with another
fails, naming the way out (undefined: wasm2go_simd_needs_go1_27_or_GOEXPERIMENT_simd_off):
build with Go 1.27, or without GOEXPERIMENT=simd, which uses the portable code.
On amd64 the archsimd code needs AVX (archsimd's 128-bit operations are AVX
instructions) and uses nothing past AVX2, so it is for GOAMD64=v3 builds;
an amd64 build below v3 uses the portable code.
With -unsafe, the portable file is an expanded and a generic file, as the
other code is; the archsimd targets are among the expanded file's platforms.
The archsimd code calls the method of each operation where the target has one
that computes what WebAssembly defines. Where it has none, it composes a few
methods that do (archsimdComposed in internal/passes):
amd64's float-to-integer conversions do not saturate, so its trunc_sat
zeroes the NaN lanes and corrects the lanes out of range; its 64-bit
multiplication is AVX-512, so i64x2.mul adds 32-bit products; and it has
no byte shifts, so the i8x16 shifts shift 16-bit lanes and mask the bits
each byte took from its neighbor. It writes the other operations lane by lane
(through GetElem and SetElem).
The portable code is written inline, operation by operation, never as calls
of helper functions taking vectors, which the Go compiler would not inline
(measured 3.4 times as slow as the module built without SIMD). It holds a
vector as four uint32 words, or, in a variable whose every value is the
result of a float operation of one lane shape, as four float32 or two
float64 lanes (vec128f32 and vec128f64), so a chain of float operations
through variables, a loop's accumulator, stays in float registers instead of
moving each lane between integer and float registers at every operation;
other uses convert.
Its i8x16.shr_s shifts each byte lane as an int32, the same bits as
an int8 shift: Go's mips and mipsle compilers sign-extend an int8
shifted by a variable from 16 bits instead of 8 (the Rsh8x32 rules of
MIPS.rules, in Go 1.26, 1.27, and tip), so a lane took bits of its
neighbor (Test_regression_simd_shr_s).
v128 values cross the Module's exports and imports, and live in its
globals, as their 16 bytes in memory order, [16]byte.
Float lanes follow the deterministic profile, canonicalized
without branches: the portable code masks a NaN lane's bits, and the archsimd
code blends the canonical NaN into the lanes that compare unordered.
WebAssembly's f32x4.min and max order -0 below +0; amd64's MINPS and
MAXPS return their second operand for two zeros (or a NaN), and the Go
compiler takes a.Min(b) and b.Min(a) for one instruction
(bugs/go-archsimd-min-commutative.md),
so where the lanes compare equal the code takes the or (min) or the and (max)
of their bits. Vector variables are deferred like float ones (DeferCanon in
internal/passes): a vector of raw f32x4 (or f64x2)
results is canonicalized where a use reads its lanes in another way.
Measured on onnxruntime's basic-pitch build (inference, medians of
interleaved runs on linux/amd64, built with Go 1.27.1 at GOAMD64=v3;
outputs identical, bit for bit, to the build without SIMD and to Node.js
running either module): 778 ms without SIMD, 255 ms with SIMD with archsimd,
and 944 ms with SIMD in the portable code. Built with Go 1.26.8 at
GOAMD64=v1, the portable code took 1,060 ms where the build without SIMD
took 815 ms (in another round, on a busier machine); before it held float
vectors as float lanes, the portable code took 1,896 ms.
The SIMD spec tests (internal/spectest/simd) run on the portable code, and
scripts/cross-targets.sh runs them with the
determinism tests, which include the float SIMD operations and vector locals,
on every target it lists: in the portable code on each target without archsimd
(big-endian ones included), and in the archsimd code on each archsimd target
(amd64-simd, arm64-simd under qemu-user, wasip1-simd, and js-simd, with Go 1.27).
A trap is a Go panic raised in the translated code:
panic("unreachable"), "integer overflow", "invalid conversion to integer",
and "call stack exhausted" where the translation checks for the trap itself,
and the Go runtime's own panic where a Go check catches it
(an index or slice out of range for a memory, table, or segment access,
an integer divide by zero, a failed type assertion for an indirect call
of an empty slot or of a function of another type).
The instance stays usable after a trap, as a Wasm instance does
(see the Go stack).
A host in plain Go contains a trap with recover.
A strictgo program, where recover is refused,
calls the module through strictgo.Contain(m, func() { ... }),
which recovers only the panics raised in the translation's own packages
and returns the trap's kind, the translated function it was raised in, and its message;
a panic of the host's own code, a host function the module called included,
goes on and ends the program.
strictgo checks the translation as a library of the program,
and it passes as wasm2go writes it (without -unsafe).
A module that defines its memory (neither imports it nor shares it)
keeps it in the Module as a slice whose capacity is its length,
so no slice of the memory reaches past its end:
memory accesses (see memory accesses), the bulk memory
operations, and code of -provided files trap there.
The slice is a prefix of a backing array, also in the Module,
whose bytes past the memory's end are zero.
memory.grow within the backing array is in place:
it lengthens the slice, with no copy and no allocation.
Past it, the memory moves to a new array in the Go heap
of twice the old array's size (at least what the growth needs,
at most the module's maximum), and the old array becomes garbage.
In the Go heap, a memory that grows costs the host more than the memory:
moving it needs the old and the new array at once,
the collector frees the old arrays only at its next cycle,
Go zeroes a large allocation that reuses memory it freed (touching all of it),
and translated code cannot release anything itself
(the packages that could, runtime and syscall, are ones strictgo refuses).
Doubling the backing array moves the memory less often than append
did, which halves the peak of a memory that grows a page at a time
(see the test below), but the game engine module of strictgame
(QuickJS-ng, 71 MB of memory at the end of its memory script)
peaked at 247 MB with either.
So a host outside strictgo's checks can place the memory in address space
the operating system commits page by page when each page is first touched,
through two methods of Module and the package
reserve of this repository:
m := translated.New()
m.UseMemory(reserve.Memory(m, m.MemoryMax()))MemoryMax is the memory's declared maximum in bytes (4 GiB for a
32-bit memory that declares none); UseMemory moves the memory to the start of
the given array, where it then grows in place up to the array's capacity.
reserve.Memory maps that much private anonymous memory on Linux, macOS, and the BSDs
(on Linux with MAP_NORESERVE, so it is not counted against the commit limit)
and unmaps it when its owner, the Module, becomes unreachable,
so no slice of the memory may outlive the Module.
It returns nil, and UseMemory then leaves the memory in the Go heap,
where it cannot reserve: on js/wasm and wasip1 (the Go program's own
linear memory is all the memory there is), on Windows (reserving and
committing on touch needs VirtualAlloc, which syscall lacks), and when
the operating system refuses (a 32-bit address space, Linux with
vm.overcommit_memory=2). With the memory reserved, the memory script peaked
at 77 MB. The results of memory.grow, the memory's contents, and its bounds
are the same wherever the memory is.
An imported memory is the host's, grown by its Grow; a shared memory is
created with its maximum capacity and grown in place, atomically.
The bulk memory operations slice both up to their length, not their capacity.
Test_memory_peak grows a module's memory to 64 MiB one page at a time,
filling each page, in a child process, and bounds the child's peak above an
idle one's just above what was measured: 64.1 MiB with the memory reserved
(bound: the memory and 1 MiB more), and 129.6 MiB in the Go heap, the last
move's old and new arrays (bound: twice the memory and 4 MiB more).
Growing by append, before the backing array doubled, the same module
took 270 to 290 MiB.
Every Wasm call is a Go call, so a module's recursion grows the goroutine's stack,
and Go ends the whole program, with an error no recover catches,
when a goroutine's stack outgrows its maximum
(1 GB on 64-bit platforms, 250 MB on 32-bit ones).
A C program's own stack limit does not prevent this:
it measures the shadow stack in linear memory,
which a function whose variables all live in Wasm locals does not use.
So translated code bounds its own recursion, and traps before Go's limit,
with the same result on every platform.
The functions in a cycle of the module's call graph
(calls through tables count as calls of every function of the called type
a table may hold) keep a count in the Module:
each adds its charge when it starts, subtracts it when it returns,
and panics with call stack exhausted when the count exceeds
maxStack (192 MiB, in stackbound.go).
A function's charge is an estimate of its Go frame,
8 bytes for each of its parameters, results, and variables (16 for a v128) plus 64
(an upper estimate: the Go compiler shares slots between variables),
and at least maxStack / maxFrames (maxFrames is 200,000),
so no more than 200,000 of these frames are live at once.
Functions outside every cycle charge nothing:
a chain of them is no deeper than the call graph.
A function that recursion reaches keeps its Module receiver,
where the translator would otherwise have removed it as unused.
How the limits were chosen:
maxStackkeeps the estimate under Go's smallest maximum, 250 MB. The regression test lowers the maximum to that and recurses without end through a function, a table, and a function that uses nothing of its module: each traps.maxFrameskeeps the frames within what WebAssembly engines running Go allow: Node.js ended Go's js/wasm programs with a segmentation fault when a panic unwound about 600,000 frames (500,000 unwound), and wasmtime, with the 8 MiB stack Go'sgo_wasip1_wasm_execgives it, exhausted its stack at about 600,000 frames.- QuickJS-ng with its default 1 MiB limit on its (shadow) stack
ends every recursion with its own catchable error before the bound:
measured over plain calls, getters, callbacks of
Array.prototype.map,sort, andreplace, theJSON.parsereviver, Proxy traps, bound functions, constructors, generators, deeply nested JSON, regular expressions, and source text, the most any needed was 116 MiB (deeply nested parentheses in source text, whose parser frames are small and many).
An export that reaches recursion sets the count back to its value at the call when the call returns or traps (a trap unwinds frames that never subtract their charge), so the module stays usable after a trap, as a Wasm instance does; such an export is a method of its own even where the translator would otherwise have given the exported function the export's name. The count costs 1 to 4 percent on QuickJS's benchmark workloads. Recursion through the host (an import that calls an export) is not counted; the host's code bounds it.
Tail calls (return_call) do not reuse their caller's frame,
so a chain of them counts like any recursion:
the spec tests' tail calls deeper than the bound
(beyondStackBound in internal/spectest/runner.go)
are checked to trap.
A C program built with clang keeps its stack in linear memory
(the shadow stack, through the __stack_pointer global),
and a limit it puts on its own stack (QuickJS's, for example)
measures only that stack, while the calls themselves use
the native stack of whatever runs the module:
the Go stack of the translation, or a JavaScript engine's own stack
when a browser runs the module directly.
A function that keeps its variables in Wasm locals uses no shadow stack,
so its recursion can overflow a browser's native stack
(an error the module cannot catch, and a different outcome from the Go host's)
long before the program's own limit.
stack-weight rewrites a module so that each function on a cycle
of its call graph (the same analysis as the Go stack)
charges an estimate of its native frame to the shadow stack:
stack-weight -stack-limit BYTES -native-stack BYTES -o out.wasm [-report file.tsv] in.wasm
- At entry a charging function lowers
__stack_pointerby its charge, and traps (unreachable) when the result is below the stack's initial value less-stack-limit; on every way out it raises it by the same charge (a block around its code makes every branch to its own label pass there). Nothing is ever written in the charged bytes. - The charge is the frame estimate times
-stack-limitover-native-stack, rounded up to 16 bytes, so at the pass's own bound the estimated frames add up to at most-native-stackbytes. - The module must export
__stack_pointer(link with-Wl,--export=__stack_pointer), whose initial value must be a constant. Tail calls, exception handling, typed function references, and garbage collection instructions are refused: a tail call or an exception would leave frames without giving their charge back. - The pass adds a custom section named
stack-weightrecording its options and its estimate, and refuses a module that has one: a second run would charge every function twice. -reportwrites each function's slots, whether it is on a cycle, its estimate, and its charge.
The charges are part of the module, so a limit the program measures from
__stack_pointer is crossed at the same call wherever the module runs:
in the translation on every Go target, and on every JavaScript engine.
What makes that limit also keep an engine within its native stack is
an assumption about the engine, which the estimate was measured against:
- Frames within the estimate. The estimate
(internal/stackweight/estimate.go)
is the larger of 272 plus 2 per slot and 48 plus 8 per slot
(parameters and declared locals, a
v128two slots), plus 8 per parameter an x86-64 engine passes on the stack. For every function on a cycle of QuickJS-ng's call graph, the frame V8's two compilers give it (Liftoff and TurboFan, in Node 22's V8 12.4, read from--print-wasm-code) is at most 0.975 of the estimate. QuickJS-ng has no SIMD: the two slots of av128follow its 16 bytes, and no frame of a function withv128values was measured. A large constant per frame makes recursion through small frames that keep no shadow stack (deeply nested JSON, arrays walked byflat) and recursion through QuickJS's interpreter, whose frames also take shadow stack, reach similar native stacks at the limit, so the second goes deepest for a given worst case; 8 per slot keeps the estimate growing with a frame's slots beyond the frames it was fitted to. - Room for the rest. Frames outside every cycle charge nothing:
a chain of them is no deeper than the call graph. They, the frames
of the host that calls the module, and the program's own work after
its limit (throwing its error) must fit in what the engine's stack
has beyond
-native-stack. - Engines not measured. SpiderMonkey and JavaScriptCore were not available where the pass was measured; their frames per wasm frame are not known to be within the estimate.
Measured on QuickJS-ng v0.17.0 (1 MiB limit on its own stack, a 2 MiB
shadow stack placed first), weighted with -stack-limit 1179648
(128 KiB past QuickJS's limit) and -native-stack 786432, over
recursion through 25 different paths in the engine, 60 random mixes of
them, and nesting too deep for the parser, JSON.parse, regular
expressions, String, join, flat, and a recursive clone:
| engine | default stack | most any case needed | every case ended in QuickJS's error, at the same depth |
|---|---|---|---|
| Node 22.23 (V8 12.4) | 984 KiB | 464 KiB (Liftoff), 348 KiB (TurboFan only) | yes |
| Deno 2.6.6 (V8 14.2) | 1,024 KiB | 508 KiB (Liftoff), 500 KiB (TurboFan only) | yes |
| Chromium 149, page | 984 KiB | 464 KiB | yes |
| Chromium 149, worker | set by the browser | not measurable with --stack-size |
yes |
| the translation, Go | 1 GB (250 MB on 32-bit) | under the Go stack bound | yes |
Plain recursion (function f() { return f() + 1; } called through a
dispatch table, three JavaScript calls per level) reaches 310 levels;
unweighted it overflows V8's stack. An earlier estimate, 128 plus 16
per local charged by every function that calls, reached 252 levels
with its worst case needing 492 KiB. The charges cost about 2 to 4 percent
of a 300-frame game workload's time in Node (medians of interleaved
runs on a loaded machine).
Besides simplifying each function's control flow and temporaries,
wasm2go optimizes the translated code with passes that need facts
about the whole module, which the translator knows while it translates:
what each table holds, and which functions can reach memory.grow.
Every pass preserves behavior: the same results, the same memory contents,
and the same traps (only a trap's message can differ, where Go leaves unspecified
which of two failing checks in one statement fails first);
each pass's argument for this is a comment beside its code
in internal/passes.
-noopt disables all of them.
A table is closed when the module defines it, does not import it,
does not export it, and no instruction mutates it:
no table.set, table.grow, table.fill, or table.init on it,
and no table.copy into it.
Its contents are then what the active element segments New applies put there,
and nothing can change them afterwards.
A call_indirect through a closed table is compiled into a switch
over the slots holding a function of the called type,
each calling that function directly,
with the original indirect call as the default (for null slots,
slots of other types, and indexes out of range, which trap as before).
Calls through tables that are not closed are left alone, and so are calls
that can reach more than 16 functions (maxDispatchTargets in
closed.go): the switch is written at every call site,
and C++ code has signatures that hundreds of virtual functions share.
In onnxruntime's basic-pitch build, 2,538 calls through its closed table
made 642,209 cases, in a translation of 2.25 million lines;
neither that inference nor QuickJS-ng's game workload changed measurably
with the bound, from no dispatch at all to all of it.
When linking with wasm-ld, -Wl,--export-table exports the table,
so it is not closed; leave it out unless the host needs the table.
A WebAssembly local that the code reuses for unrelated values (as a
compiler reuses a register) becomes a Go variable per web: the
definitions of the local that reach common uses, found by SSA construction
on the function's control flow (see splitlocals.go and
internal/locals). The web holding the local's value at
entry keeps the local's variable, v3; the others are v3_1, v3_2, and so
on. Each variable is then assigned only the values one group of uses reads.
The Go stack bound counts the local once, as before.
A function that reads the linear memory loads its slice into a local
variable when it starts, and reloads it after every call that can grow memory
(if each such call is a statement of its own, as the translator emits them).
A call can grow memory if it reaches memory.grow directly,
through a closed table (only the functions of the called type it holds),
through a table that is not closed, through an import,
or through a provided function (analyzed from its source).
Shared memories are left alone, since other goroutines may grow them.
An integer constant is a call of the i32 or i64 helper, i32(5),
which keeps it out of Go's constant evaluator, only where it could become
an operand of a Go constant expression, or is a memory access's address or
stored value (which become an index, or shifted bytes); where it is combined
with a variable, passed to a function, assigned, or returned, it is a Go
constant, int32(5), which the compiler handles without an inlined call.
Each load and store of more than one byte goes through a pointer to an
array of its size, converted from a slice of the memory:
(*[4]byte)(mem[a : a+4]), with the address as a uint64.
The slice holds the access's whole bounds check, two compares,
and the constant indexes into the array need none;
a slice from the address, mem[a:], also keeps a check of the last byte's
index and masks the new slice's pointer, and holds the memory's length and
capacity live. Slicing checks against the capacity, so this relies on an
owned memory's capacity being its length (see linear memory);
an imported or a shared memory, whose slice its host may hold with spare
capacity, is first cut to its length, mem[:len(mem):len(mem)].
In most functions an access is a call of encoding/binary's little-endian
functions on the array, binary.LittleEndian.Uint32((*[4]byte)(mem[a : a+4])[:]),
which the compiler inlines, not a call of a helper function: the Go compiler
gives every inlined call its own copies of the callee's parameters, named
variables with debug information, and a module makes tens of thousands of
memory accesses. Into a function it considers big (about 6,400 AST nodes
and more; bigFunctionSize in translate.go is below that)
it inlines only callees of cost 20 or less, and encoding/binary's 32- and
64-bit functions cost more, so there the translator writes each access as
the byte operations those functions are, which the compiler combines into
single loads and stores. The array pointer of such an access is assigned
once, to a variable of the function, and its bytes index the variable,
as does the value of a store:
a4 = (*[4]byte)(mem[uint64(v1) : uint64(v1)+4])
t2 := int32(uint32(a4[3])<<24 | uint32(a4[0]) | uint32(a4[1])<<8 | uint32(a4[2])<<16)(an access inside a larger expression, which the translator rarely writes,
repeats the pointer at each byte instead). The compiler emits the same
instructions either way; the variables made QuickJS-ng's interpreter loop
compile in half the memory. With -unsafe, output.go writes the accesses
inline with unsafe instead (see below).
On QuickJS-ng without -unsafe, where the interpreter loop is a big
function, the game frame of the benchmark took 36.3 million cycles
(9.1 ms) with the bytes of mem[a:] and 24.1 million (6.0 ms) with the
array pointer (medians of interleaved runs on linux/amd64, under load);
calls there instead of bytes took the game and Octane workloads 1.4 to 2.2
times as long as the bytes of mem[a:]. The bytes cost compile memory:
see compile cost. Writing a third index, mem[a : a+4 : a+4],
is no faster and made the largest compile take a quarter more memory.
With -unsafe, in output.go, a load or store whose bounds check an earlier check
on every path to it already covers (the same memory, the same address,
and at least as far) uses an unchecked access instead.
Every access that would trap still traps, at the check that covers it.
output_generic.go checks every access.
With -unsafe, wasm2go writes two files, and so requires -o:
-o output.go writes output.go and output_generic.go.
In output.go, built only on the little-endian platforms with unaligned
memory access (its //go:build line lists them), loads and stores are written
inline with unsafe; output_generic.go holds the same code
with the portable accesses of encoding/binary, and is built on every other platform.
-tags combine with the build constraint of each file.
With -noopt, the two files differ only in their build constraints.
-provided file.go names a Go file whose methods of Module implement
imported functions (libc-gen writes such files). The translator reads it,
and writes its declarations into its own output, with the imports they use,
so the file itself must not be built: it begins with //go:build ignore,
as libc-gen's output does. The file's imports cannot be renamed.
A module with more code than one Go package may hold
(maxPackageSize in packages.go, in AST nodes)
is written as several packages, and -importpath must name the import path
of the directory of -o:
- the output package:
Module,New, and the exports, as for one package;Moduleholds an instance of the module's state; internal/instance: that state, with its fields exported to the other packages, and the imported interfaces and memory types (the output package declares aliases of them);internal/functions1tointernal/functionsN: the module's functions, as functions taking the instance (a method's receiver becomes their first parameter, under the receiver's name), the provided functions in the last. Functions are ordered callers first (the strongly connected components of the call graph in topological order) and filled into packages in that order, so a call to a function in a later package is a direct call, and a call to one in an earlier package goes through a function variable ofinternal/instance, which the output package sets when it is initialized.
The output package imports the others as wasm2go_instance and
wasm2go_functions1 to wasm2go_functionsN, names its other files
(yours, or a generator's) must not declare.
Writing again into the same directory removes the files of packages
an earlier translation wrote there and this one does not;
a file there that wasm2go did not write is an error.
-dwarfline is not supported for a module written as several packages.
See compile cost for why.
The Go compiler keeps every compiled function's SSA form and machine
instructions reachable until it writes the package: the DWARF location list
of each function holds a callback that refers to them
(GetPC, set in cmd/compile/internal/ssagen's genssa).
So a compile process's memory grows with its package's code,
whatever the size of each function.
The translator therefore bounds each package's code (maxPackageSize,
in AST nodes of translated code), and writes a larger module as
several packages, each compiled by its own process.
It also keeps the code small where the compiler's memory goes:
memory accesses are written as encoding/binary calls, not as calls
of helper functions, except in big functions (see memory accesses),
and integer constants as Go constants where Go cannot fold them
(see constants).
A function is never split: one larger than the package limit has a package of its own, and its compile's memory grows with it (measured at about 1.7 KB per AST node), so the largest function sets the least a module's largest compile can take. Splitting it would copy its variables at every part it is split into, which costs most where a function is largest and hottest: the interpreter loop of QuickJS.
The package limit was chosen from the peak memory of the largest compile
process when building QuickJS-ng (without and with -unsafe) and
tree-sitter's runtime, with every access a call of encoding/binary
(big functions included), with go build -p 1 on 16 cores,
Go 1.26 on linux/amd64 (MiB; arm64 and js/wasm peak in the same ranges;
in one package, QuickJS's compile took 1,906 MiB):
| package limit (AST nodes) | QuickJS | QuickJS -unsafe |
tree-sitter | QuickJS build time |
|---|---|---|---|---|
| 12,500 | 232 | 184 | 78 | 13.6 s |
| 25,000 | 230 | 180 | 78 | 11.5 s |
| 50,000 | 174 | 184 | 91 | 10.1 s |
| 100,000 | 235 | 183 | 125 | 9.5 s |
| 200,000 | 389 | 255 | 175 | 8.8 s |
| 400,000 | 611 | 344 | 287 | 8.8 s |
QuickJS's largest function, its interpreter loop (about 99,000 AST nodes), alone took 170 to 245 MiB from 100,000 nodes down (the run-to-run variation of the Go compiler's collector, which in a rare build of the translation reached 321 MiB); the limit, 25,000, is where smaller packages stopped lowering tree-sitter's largest compile while build time kept rising. The limit does not change runtime speed measurably: calls between packages are direct calls, except calls to an earlier package, which go through a function variable.
The interpreter loop is a big function, so its accesses are written as bytes
(see memory accesses), through the variables holding each
access's array pointer: its compile, the largest, takes 237 to 273 MiB
on linux/amd64 and 262 to 281 MiB on js/wasm (four runs of the test below).
With the pointer written at each byte, it took 540 to 604 MiB and 526 to
595 MiB, the bytes of mem[a:] 444 to 552 MiB, and calls 170 to 245 MiB.
Test_quickjs translates QuickJS-ng (testdata/quickjs), compiles it for
linux/amd64 and js/wasm one package at a time with GOMAXPROCS=4,
and fails if any compile process of its packages takes more than
compileMemoryBound (in compilemem_test.go), just above the largest
it has measured, three times: a compile over the bound is measured twice
more and the least of its peaks counts, since the collector's timing moves
one compile's peak by a tenth (the interpreter loop's took 244 to 301 MiB
across repeated compiles). It then runs testdata/quickjs/test.js on it and
compares the result with a native build's.
The translator itself holds the module's code as Go syntax trees, which
take tens of times the module's size, so it keeps one tree of each function
(a package's copy replaces the function's tree when the module is written
as several packages, and the copy shares the identifiers and literals,
the tree's most frequent nodes), and lowers each package's memory accesses
only when it writes the package, dropping the package's trees once written.
Once a function is translated, the translator also drops the state it
translated it with (its stacks of operands and blocks), whose backing arrays
would otherwise keep the function's first tree alive after a package's copy
replaces it.
Translating onnxruntime's basic-pitch build (a module of 1.6 MB) peaked at
2.8 GB before these and the dispatch bound (see
closed tables), and peaks at about
280 MiB with them, less than the largest compile of its translation, which
takes 304 MiB (GOAMD64=v3, with archsimd); its translation went from 84 MB
of Go to 22 MB.
The output package's New writes each element segment of more than
maxElementsPerFunc entries (in module.go) as chunks, each
returned by a function literal called in place: the Go compiler's memory
grows faster than the function it compiles, and basic-pitch's segment of
2,295 entries, each a function literal binding a function to the instance,
took the output package's compile to 632 MiB in one literal and to
145 MiB in chunks of 128. Test_elements_compile_memory (in
elementsmem_test.go) compiles the output package of a module whose segment
holds 2,400 such entries three times, and fails if the least of the peaks
is above elementsCompileBound, above the 141 to 145 MiB measured by the
margin a loaded machine needs (710 to 734 MiB in one literal).
Translating QuickJS-ng went from 454 MB to 157 to 173 MiB.
Test_translate_memory (in translatemem_test.go) runs the translator on
QuickJS-ng three times and fails if the least of the peaks is above
translateMemoryBound, just above the least peaks measured, 157 and 159 MiB
(the collector's timing moves a single run's peak by up to a seventh).
Usage: wasm2go [option]... [input.wasm]
-dwarfline
use line numbers from DWARF metadata
-embed
go:embed data sections from a .dat file
-importpath string
import path of the directory of -o; required when the module is written as several packages
-nohost
don't generate interfaces for imports
-noopt
disable all optimization passes
-o string
output file (default stdout)
-pkg string
package name (default module name, or wasm2go)
-provided value
Go file providing imported functions, as methods of Module; the output holds its declarations, so the file must not be built itself (//go:build ignore)
-tags string
go:build tags to include in the generated file
-unsafe
allow importing unsafe (requires -o: writes output.go and output_generic.go)
-version
print version and exit
If you suspect wasm2go is producing invalid code,
please try noopt before submitting a bug report.
Projects using code generated by wasm2go are listed in USERS.md.
Footnotes
-
wasm2godoes not guarantee tail behavior; judge for yourself if using tail calls generates better code. ↩