mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
* ssa,lower,gcasm: mid-end memopt + pure backend + eqz fusion + wasm-level leaf inliner
Optimization series targeting the wasm2go-vs-wasmtime gap on the
spidermonkey cpubench workload (fib(20)+array, the go-spidermonkey /
tests-cpubench.cc benchmark). Measured on native arm64 M5, interleaved
same-session A/B, verified across all consumers via the wasmify pipeline.
Phase A — internal/ssa/pass/memopt.go: intra-block redundant-load
elimination + store-to-load forwarding over the unsafe linear-memory
accesses gc cannot reason across. Runtime NEUTRAL (input wasm is already
clang -O2; gc re-optimizes emitted pure values), kept for the small size
win and cleaner IR. Gated OFF for shared-memory (threads) modules, where
eliding a non-atomic re-read would drop a peer agent's store — defensive,
pinned by cg_memopt_shared_test.go.
PureOnly (-pure flag / WASM2GO_PURE): emit the pure-Go backend with no
asm bundle and no arch build tags — the ABIInternal reference for
benchmarking codegen quality without the gcasm ABI0 marshalling. Pinned
by transpile/pure_only_test.go (multi-package e2e builds+runs natively).
1e — eqz->comparison fusion (internal/lower): i32/i64.eqz lower to
first-class OpEq{32,64}(x,0) TypeBool so branches fuse to a direct Go
comparison instead of an i32_eqz helper round-trip, and eqz joins
ConstProp/CSE. Runtime NEUTRAL (gc already folded the old inlined shape);
kept for -5% pure binary size and IR cleanliness.
1b — wasm-level leaf inliner (internal/lower/inline.go): small leaf
callees (no call/call_indirect) are spliced into callers during lowering
instead of emitting OpCallDirect. gc cannot do this — cross-chunk calls
go through //go:linkname forwarders opaque to its inliner, and on gcasm
every surviving call pays ABI0 marshalling + a gcasmFwd trampoline that
inlining deletes with the call. Leaf-only, no-EH, size-capped, all
env-tunable (WASM2GO_INLINE*). This is the real win: gcasm cpubench
-12.9% (150.2->130.8 ms), pure -5.3%; wasmtime gap 1.91x -> ~1.66x.
Two latent gcasm-transform bugs the inliner exposed (the inliner lowering
itself is correct — the pure inline bundle passed every consumer):
- internal/codegen/emit_memops.go: an SSA-constant memory-access base
hoisted into a local escaped the AST-shape _consts guard; gc re-folded
the multi-MB constant into an arm64 addressing-mode immediate
("constant is not in pool"). Detect SSA-constant bases (ssaConstBase)
and route large totals through _consts regardless of AST shape.
- internal/gcasm/transform.go: jump-table flag-replay had false-negative
holes — gc keeps a pre-dispatch compare's EFLAGS live across its
flag-preserving table jump and lets case bodies consume them, but the
compare tree clobbers flags; an undetected flag-consuming target got
no replay and leaked the tree's flags into a downstream branch (a
go-python `print(1+1)` SIGSEGV). Now: when a clean replayable compare
sits just before the dispatch, ALWAYS replay it at the leaves
(harmless when unread), eliminating the detection dependency. Pinned
by jumptable_flags_test.go.
Verified (all exit 0): wasm2go go test ./... (9 pkgs); go-python full
suite; go-spidermonkey full suite + -race (shared memory); googlesqlite
35 pkgs / 0 FAIL. Design/analysis in docs/optimization-plan-v2.md.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* docs: handoff notes — depth-2 inlining attempted and reverted; follow-up bug recorded
Add the handoff section (branch state, verified −14% committed win, the
verification discipline, disk caveats, saved artifacts) and document the
bounded depth-2 inlining attempt: it reached wasm2go 9pkg/0FAIL + e2e but
exposed a latent arm64 literal-pool emit bug (a memory base of the form
OpAdd32(runtime, OpConst32[large]) leaves the large constant as an
addressing-mode immediate — memOffsetExpr's _consts routing covers only
the AuxInt offset and a pure-constant base, not "runtime + large-const").
Reverted depth-2 (low predicted ROI ≤1-2%, the post-1b hot set is
dominated by four functions far over the inline size cap; and the robust
fix needs a non-trivial emit refactor). Leaf-only 1b stands as the final
form. Next steps and the follow-up bug's fix sketch are recorded for the
handoff.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* ssa,codegen: fold large constant base addends into the memory-access offset
A memory-access base of the form Add32(x, Const32[large]) bypassed the
emitter's _consts literal-pool guard: memOffsetExpr's routing only
covers the AuxInt offset and a PURE-constant base (ssaConstBase), so
the large addend stayed inline in the base expression and gc folded it
into an arm64 addressing-mode immediate — failing to assemble inside
very large functions ("LDPSW 27325896(R2): constant is not in pool",
the follow-up bug recorded in the depth-2 inlining postmortem).
Fix: a new SSA pass, pass.FoldMemAddend, rewrites
Load/Store [off] (Add32 x (Const32 [c]))
=> Load/Store [off + uint32(c)] x
for addends that would need the _consts detour (positive totals >=
largeConstThreshold, negative addends of threshold magnitude — those
sign-extend into the same out-of-range immediate). The folded total
lands on the emitter's existing runtime-base large-offset path, which
already routes through _consts, so no emit changes are needed. Small
addends stay in the base sum where they fold into an in-range
immediate.
The pass runs ONCE after the optimization fixpoint, never inside it:
the fold assumes the remaining base stays a runtime value, whose emit
path wraps the address to uint32 (mod-2^32, exact for wasm). If a
later ConstProp iteration could still constify the base, the emitter's
pure-constant path — which computes the unwrapped u33 total like a
genuine memarg offset — would mis-address folded sums that relied on
i32 wraparound.
Pinned by pass unit tests (positive/negative/commuted/copy-peeled/
sub-threshold/const-const shapes) and testdata/cg_memaddr.wat: wazero-
oracle runtime equivalence including the mod-2^32 wrap of a negative
addend, plus an emit-shape assertion that no access site keeps the
addend as an inline int32 literal.
The pass fires at scale on real inputs (~2,700 additional _consts-
routed sites on python.wasm) and measured a large runtime WIN on the
spidermonkey cpubench (pure lane −18%, gcasm lane part of −24%; see
bench-metrics.md memaddr entry for the interleaved A/B).
Co-Authored-By: Claude Fable 5 <[email protected]>
* gcasm: port the jump-table flag-replay fix to arm64
The amd64 findJumpTables fix (d4b6ca3) - replay the pre-dispatch
flag-setter at every compare-tree leaf, or fall back to pure when
flags might be consumed and no clean setter exists - was never ported
to a64FindJumpTables. The arm64 compare tree (CMPW/BHS) clobbers NZCV
exactly like the amd64 one clobbers EFLAGS, and gc's captured dispatch
preserves it, so a dispatch target consuming the pre-dispatch bounds-
check flags read the tree's leftovers instead: a downstream branch
went the wrong way and corrupted linear memory.
Observed: deterministic go-python arm64 gcasm SIGSEGV at the previous
commit - Fn3705's dispatch corrupted a list header, and the victim
Fn3708 stored through a ~4 GB wild offset. Isolated by pure-arm64
being green, GCASM_FALLBACK_RANGE halving, and the fix regen going
green.
The port also hardens the replayability rule relative to amd64: a
compare whose operands include the jump-table base or target register
is NOT cleanly replayable - the captured dispatch clobbers both
between the compare and the leaf - and such a site falls back to pure
instead of replaying a clobbered operand.
Pinned by TestA64JumpTableFlagReplayDetect (synthetic-instruction
subtests for replay-at-leaves, consume-without-setter fallback,
clobbered-operand fallback, and flag-neutral pass-through) and
TestA64JumpTableFlagReplayRun (capture, transform, assemble, and
native-run parity against the pure reference, mirroring the amd64
gate).
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: handoff update — memaddr fold shipped, arm64 flag-replay miscompile found+fixed, gap 1.66x->1.25x
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: retract the -24%/-18% perf claim - cross-arch A/B artifact; both fixes are runtime-neutral
The benchmark deltas claimed in the previous two commits (4a77ed9's
"large runtime WIN", eca0e2f's "gap 1.66x->1.25x") are RETRACTED. The
saved baseline cpubench binaries were Mach-O x86_64 running under
Rosetta, while the new binaries were native arm64 - the A/B compared
architectures, not wasm2go versions. Discovered during the pprof
attribution pass, when the "hot function" disassembly came out as
amd64 instructions.
Clean re-measurement (input wasm sha256-pinned before and after each
generation, both lanes arm64-native, only wasm2go differing, five
interleaved pairs, idle machine):
gcasm: baseline 96.82 ms vs fix 97.66 ms (+0.9%, noise)
pure: baseline 83.12 ms vs fix 82.44 ms (-0.8%, noise)
The memaddr fold and the arm64 flag-replay port are correctness fixes
with NEUTRAL runtime - the fourth confirmation that source-shape
changes to pure computation are redundant with gc.
The real finding is about the measurement history: the true native
arm64 wasmtime gap at the previous commit was ALREADY gcasm ~1.24x /
pure ~1.05x (96.8 ms / 83.1 ms vs wasmtime 78.3 ms, wasmtime 46.0.1).
The recorded 1.91x -> 1.66x gap trajectory was measured with Rosetta
Go lanes against native wasmtime and is systematically inflated; pure
is effectively at wasmtime parity, and the remaining gcasm gap is the
ABI0 call-marshalling tax.
The handoff notes now carry the corrected numbers, the revised
next-step ranking, and the new methodology rules (record every bench
binary's architecture via `file`; pin the input wasm sha across any
generation used in an A/B - it was observed rebuilding mid-session,
shifting function indices and invalidating cross-boundary pprof
diffs).
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs: untrack the working plan documents
The optimization plan / handoff notes are session-working documents,
not part of the library. Keep them as local untracked files (like
bench-metrics.md and the other local docs) instead of committing them.
Co-Authored-By: Claude Fable 5 <[email protected]>
* lower: parse WASM2GO_INLINE_ONLY_CALLEES entries with ParseUint
strconv.Atoi yields a platform-width int that was converted to uint32
without a bounds check (CodeQL go/incorrect-integer-conversion).
ParseUint with bitSize 32 rejects negatives and out-of-range values up
front, so the conversion cannot truncate; bad entries keep the existing
warn-and-skip behavior.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
146 lines
4.1 KiB
Go
146 lines
4.1 KiB
Go
package transpile_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/wasm2go/transpile"
|
|
)
|
|
|
|
// TestInlineBrTableJumpTreeEndToEnd reproduces the go-python Fn71
|
|
// corruption shape: a leaf callee whose body is a dense br_table gets
|
|
// inlined into a caller; gc then emits a JUMP TABLE inside the caller
|
|
// and the gcasm transform rewrites it into a compare tree
|
|
// (findJumpTables/emitJumpTree). The test executes EVERY selector
|
|
// value through the full gcasm pipeline and checks the returned
|
|
// values, so a single wrong edge in the tree fails loudly.
|
|
//
|
|
// The selector is biased (x-3) before the table and each case returns
|
|
// a distinct value, preventing gc from merging cases.
|
|
func TestInlineBrTableJumpTreeEndToEnd(t *testing.T) {
|
|
// SPARSE table mirroring the CPython Fn71 shape that crashed: the
|
|
// br_table's entries have HOLES (runs of default interleaved with
|
|
// real cases) so the transform's run compression and the compare
|
|
// tree's hole edges are exercised, not just a dense 0..N table.
|
|
const tableLen = 55
|
|
sparse := map[int]bool{}
|
|
for _, v := range []int{0, 1, 2, 5, 6, 7, 8, 11, 14, 16, 17, 18, 20, 21, 22, 23, 26, 29, 32, 34, 38, 41, 44, 46, 49, 52, 54} {
|
|
sparse[v] = true
|
|
}
|
|
caseVal := func(i int) int { return 1000 + i*7 }
|
|
// Build the leaf: nested blocks + br_table; entry i br's to $b<i>
|
|
// when sparse[i], else to the default $d. Selector = x - 3.
|
|
var sb strings.Builder
|
|
sb.WriteString("(module\n (func $leaf (param $x i32) (result i32)\n")
|
|
sb.WriteString(" (block $d\n")
|
|
for i := tableLen - 1; i >= 0; i-- {
|
|
if sparse[i] {
|
|
fmt.Fprintf(&sb, " (block $b%d\n", i)
|
|
}
|
|
}
|
|
sb.WriteString(" (br_table")
|
|
for i := 0; i < tableLen; i++ {
|
|
if sparse[i] {
|
|
fmt.Fprintf(&sb, " $b%d", i)
|
|
} else {
|
|
sb.WriteString(" $d")
|
|
}
|
|
}
|
|
sb.WriteString(" $d (i32.sub (local.get $x) (i32.const 3)))\n")
|
|
for i := 0; i < tableLen; i++ {
|
|
if sparse[i] {
|
|
fmt.Fprintf(&sb, " ) (return (i32.const %d))\n", caseVal(i))
|
|
}
|
|
}
|
|
sb.WriteString(" )\n (i32.const -1))\n")
|
|
// Caller: passes through, so the leaf inlines into it.
|
|
sb.WriteString(" (func (export \"run\") (param $x i32) (result i32)\n")
|
|
sb.WriteString(" (call $leaf (local.get $x))))\n")
|
|
|
|
dir := t.TempDir()
|
|
watPath := filepath.Join(dir, "t.wat")
|
|
wasmPath := filepath.Join(dir, "t.wasm")
|
|
if err := os.WriteFile(watPath, []byte(sb.String()), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.Command("wat2wasm", "--output="+wasmPath, watPath).CombinedOutput(); err != nil {
|
|
t.Fatalf("wat2wasm: %v\n%s", err, out)
|
|
}
|
|
bin, err := os.ReadFile(wasmPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
m, err := transpile.Parse(bytes.NewReader(bin))
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
restore := transpile.SetMultiPackageThreshold(1 << 30)
|
|
defer restore()
|
|
var buf bytes.Buffer
|
|
res, err := transpile.Translate(&buf, m, transpile.Options{
|
|
Package: "pkg",
|
|
OutputImportPath: "jttest/pkg",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Translate: %v", err)
|
|
}
|
|
|
|
writeFile := func(rel string, data []byte) {
|
|
p := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeFile("go.mod", []byte("module jttest\n\ngo 1.25.0\n"))
|
|
writeFile("pkg/gen.go", buf.Bytes())
|
|
for rel, data := range res.Files {
|
|
writeFile("pkg/"+rel, data)
|
|
}
|
|
for name, data := range res.Sidecars {
|
|
writeFile("pkg/"+name, data)
|
|
}
|
|
writeFile("main.go", []byte(`package main
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"jttest/pkg"
|
|
)
|
|
|
|
func main() {
|
|
m := pkg.New()
|
|
for x := int32(0); x < 70; x++ {
|
|
fmt.Println(m.Run(x))
|
|
}
|
|
}
|
|
`))
|
|
cmd := exec.Command("go", "run", ".")
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("go run failed: %v\n%s", err, out)
|
|
}
|
|
lines := strings.Fields(strings.TrimSpace(string(out)))
|
|
if len(lines) != 70 {
|
|
t.Fatalf("expected 70 outputs, got %d:\n%s", len(lines), out)
|
|
}
|
|
for x := 0; x < 70; x++ {
|
|
want := "-1"
|
|
if sel := x - 3; sel >= 0 && sel < tableLen && sparse[sel] {
|
|
want = fmt.Sprint(caseVal(sel))
|
|
}
|
|
if lines[x] != want {
|
|
t.Errorf("run(%d) = %s, want %s", x, lines[x], want)
|
|
}
|
|
}
|
|
}
|