Files
7ba07e64d2 ssa,lower,gcasm: leaf inliner, memaddr fold, arm64 flag-replay port, memopt pass, pure-only backend (#32)
* 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]>
2026-07-22 09:32:05 +09:00

321 lines
9.8 KiB
Go

package pass
import (
"github.com/goccy/wasm2go/internal/ssa"
)
// MemOpt performs intra-block memory optimization the Go compiler cannot:
// redundant-load elimination and store-to-load forwarding across the
// unsafe linear-memory accesses wasm2go emits.
//
// Why gc can't do this. Every wasm load/store lowers to an *unsafe*
// pointer access (`*(*T)(unsafe.Add(mBase, off))`). gc's alias analysis
// must assume any unsafe store may alias any unsafe load, so it reloads
// every value after every store and every call and never forwards a
// stored value to a later load. wasmtime/Cranelift recovers exactly this
// with its last-store alias analysis (redundant-load elimination +
// store-to-load forwarding); MemOpt is the wasm2go analogue.
//
// Ordering model. The SSA has no memory token: loads carry only
// [base] and stores [base, value] (see internal/ssa/op.go). Memory
// ordering is preserved purely by emission order — the emitter
// force-hoists every load (internal/emit/hoist.go) so it renders as a
// `vN = <load>` statement at the value's position in blk.Values, and
// stores render as statements at theirs. So two memory ops execute in
// their blk.Values index order. MemOpt therefore reasons in that same
// linear order and never moves an access across a barrier.
//
// Scope is intentionally intra-block: forwarding a value across a block
// boundary needs a meet over predecessors (available-expressions
// dataflow) and is deferred. Even intra-block, this beats gc because gc
// gives up at the first unsafe store or call regardless of block.
//
// Soundness. A candidate load L is replaced by a value X (a prior
// load's result, or a store's value operand) only when X is defined
// earlier in the same block and no barrier sits between X's producer and
// L. X therefore dominates L and every value L dominates, so the
// substitution is SSA-valid. Barriers (calls, atomics, bulk-memory ops,
// memory.grow) clear all availability; a store clears only the cells it
// may alias.
//
// Returns true if anything changed.
func MemOpt(f *ssa.Func) bool {
// canon maps a replaced load to its surviving equivalent value.
canon := make(map[ssa.ValueID]*ssa.Value)
changed := false
for _, b := range f.Blocks {
// avail holds, per address, the value currently known to be
// resident there since the last barrier that could have
// invalidated it. Reset at each block entry (intra-block scope).
avail := map[addrKey]*memCell{}
for _, v := range b.Values {
if v == nil || v.Op == ssa.OpInvalid {
continue
}
switch {
case isLinearLoad(v.Op):
k, ok := memKeyOf(v)
if !ok {
continue
}
cell := avail[k]
if cell != nil {
if x := cell.readableAs(v); x != nil {
canon[v.ID] = x
changed = true
continue
}
// Address is live but this exact width/op isn't
// available yet; record this load so a later
// identical load can reuse it.
cell.addLoad(v)
continue
}
avail[k] = newLoadCell(v)
case isLinearStore(v.Op):
k, ok := memKeyOf(v)
if !ok {
// Unknown address: a store we can't key may alias
// anything → full barrier.
avail = map[addrKey]*memCell{}
continue
}
w := byteWidth(v.Op)
// Invalidate every cell this store may alias. A cell at a
// different address only survives when both addresses share
// the same base SSA value and their [off,off+width) byte
// ranges are provably disjoint — sound because identical
// base values denote identical runtime addresses and the
// offsets/widths are compile-time constants.
for ck, cc := range avail {
if ck == k {
continue
}
if !mayAlias(ck.base, ck.off, cc.width, k.base, k.off, w) {
continue
}
delete(avail, ck)
}
// The bytes at k now hold this store's value operand.
avail[k] = newStoreCell(v)
case isMemBarrier(v):
avail = map[addrKey]*memCell{}
}
}
}
if !changed {
return false
}
// Resolve chains (a replacement may itself have been replaced) and
// rewrite every reference, then bury the replaced loads.
resolve := func(v *ssa.Value) *ssa.Value {
for v != nil {
r, ok := canon[v.ID]
if !ok || r == v {
return v
}
v = r
}
return v
}
for _, b := range f.Blocks {
for _, v := range b.Values {
if v == nil || v.Op == ssa.OpInvalid {
continue
}
for i, a := range v.Args {
if a == nil {
continue
}
if r := resolve(a); r != a {
v.Args[i] = r
}
}
}
if b.Control != nil {
b.Control = resolve(b.Control)
}
}
for _, b := range f.Blocks {
for _, v := range b.Values {
if v == nil || v.Op == ssa.OpInvalid {
continue
}
if _, ok := canon[v.ID]; ok {
v.Op = ssa.OpInvalid
v.Args = nil
}
}
}
return true
}
// addrKey identifies a memory address as base-value + constant offset.
// Two accesses with the same key touch the same runtime address.
type addrKey struct {
base ssa.ValueID
off int64
}
// loadKey identifies a load flavour by op AND result type. The type is
// load-bearing: a single op can carry two result types — i32.load8_s and
// i64.load8_s both lower to OpLoad8S but yield i32 vs i64 — so two loads
// are interchangeable only when their op and type both match.
type loadKey struct {
op ssa.Op
typ ssa.Type
}
// memCell records what is known to be resident at one address since the
// last invalidating event. `storeVal`, when set, is the value operand of
// the most recent store to this address (usable for store-to-load
// forwarding); `loads` maps each (op,type) load flavour already performed
// at this address to its result value (usable for redundant-load
// elimination).
type memCell struct {
width int64
storeVal *ssa.Value
storeOp ssa.Op
loads map[loadKey]*ssa.Value
}
func newLoadCell(v *ssa.Value) *memCell {
return &memCell{
width: byteWidth(v.Op),
loads: map[loadKey]*ssa.Value{{v.Op, v.Type}: v},
}
}
func newStoreCell(v *ssa.Value) *memCell {
return &memCell{
width: byteWidth(v.Op),
storeVal: v.Args[1],
storeOp: v.Op,
}
}
func (c *memCell) addLoad(v *ssa.Value) {
if c.loads == nil {
c.loads = map[loadKey]*ssa.Value{}
}
c.loads[loadKey{v.Op, v.Type}] = v
if w := byteWidth(v.Op); w > c.width {
c.width = w
}
}
// readableAs returns the value that satisfies load v from this cell, or
// nil if the cell holds nothing v can consume without a conversion.
func (c *memCell) readableAs(v *ssa.Value) *ssa.Value {
// Prior identical load (same op AND type ⇒ same width, signedness and
// result type ⇒ same value): reuse its result directly.
if c.loads != nil {
if prev, ok := c.loads[loadKey{v.Op, v.Type}]; ok {
return prev
}
}
// A store whose width matches the load exactly and whose value type
// equals the load's result type: forward the stored value. Requiring
// an exact full-width pair avoids any truncation/extension mismatch
// (e.g. i64.store32 feeding i32.load, or i32.store8 feeding
// i32.load8_u, both of which would need a runtime conversion).
if c.storeVal != nil && forwardable(c.storeOp, v.Op) && c.storeVal.Type == v.Type {
return c.storeVal
}
return nil
}
// memKeyOf returns the (base, offset) key of a linear load/store, or
// ok=false when the base operand is missing.
func memKeyOf(v *ssa.Value) (addrKey, bool) {
if len(v.Args) == 0 || v.Args[0] == nil {
return addrKey{}, false
}
return addrKey{base: v.Args[0].ID, off: v.AuxInt}, true
}
// mayAlias reports whether two accesses might touch overlapping bytes.
// When the base SSA values are identical the addresses are identical
// runtime pointers, so overlap is decided exactly by the constant byte
// ranges; otherwise the bases are unknown and must be assumed to alias.
func mayAlias(baseA ssa.ValueID, offA, widthA int64, baseB ssa.ValueID, offB, widthB int64) bool {
if baseA != baseB {
return true
}
return offA < offB+widthB && offB < offA+widthA
}
// forwardable reports whether a store of op storeOp can have its value
// operand forwarded, without conversion, to a load of op loadOp. Only
// exact full-width, same-type pairs qualify.
func forwardable(storeOp, loadOp ssa.Op) bool {
switch storeOp {
case ssa.OpStore32:
return loadOp == ssa.OpLoad32
case ssa.OpStore64:
return loadOp == ssa.OpLoad64
case ssa.OpStoreF32:
return loadOp == ssa.OpLoadF32
case ssa.OpStoreF64:
return loadOp == ssa.OpLoadF64
}
return false
}
// byteWidth returns the number of bytes a linear memory op reads/writes.
func byteWidth(op ssa.Op) int64 {
switch op {
case ssa.OpLoad8U, ssa.OpLoad8S, ssa.OpStore8:
return 1
case ssa.OpLoad16U, ssa.OpLoad16S, ssa.OpStore16:
return 2
case ssa.OpLoad32, ssa.OpLoad32U, ssa.OpLoad32S, ssa.OpStore32,
ssa.OpLoadF32, ssa.OpStoreF32:
return 4
case ssa.OpLoad64, ssa.OpStore64, ssa.OpLoadF64, ssa.OpStoreF64:
return 8
}
return 0
}
func isLinearLoad(op ssa.Op) bool {
switch op {
case ssa.OpLoad8U, ssa.OpLoad8S, ssa.OpLoad16U, ssa.OpLoad16S,
ssa.OpLoad32, ssa.OpLoad32U, ssa.OpLoad32S, ssa.OpLoad64,
ssa.OpLoadF32, ssa.OpLoadF64:
return true
}
return false
}
func isLinearStore(op ssa.Op) bool {
switch op {
case ssa.OpStore8, ssa.OpStore16, ssa.OpStore32, ssa.OpStore64,
ssa.OpStoreF32, ssa.OpStoreF64:
return true
}
return false
}
// isMemBarrier reports whether evaluating v may write linear memory at
// an unknown location — clearing all availability. Real calls, atomics
// and bulk-memory ops qualify. Pure helper calls (clz, sqrt, div, ...)
// do NOT touch linear memory and are deliberately not barriers, so
// arithmetic-heavy loops keep forwarding across them. Globals live in a
// separate address space and are ignored here.
func isMemBarrier(v *ssa.Value) bool {
switch v.Op {
case ssa.OpCallDirect, ssa.OpCallIndirect, ssa.OpCallImport,
ssa.OpAtomicCall,
ssa.OpMemoryCopy, ssa.OpMemoryFill, ssa.OpMemoryInit, ssa.OpDataDrop,
ssa.OpMemGrow:
return true
}
return false
}