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

349 lines
12 KiB
Go

package codegen
import (
"bytes"
"go/ast"
"go/format"
"go/token"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/emit"
"github.com/goccy/wasm2go/internal/ssa"
)
// formatExpr / formatStmt render an AST node through go/format so the
// tests can assert on the canonical Go source representation rather
// than chase ast.Node pointer chains.
func formatExpr(t *testing.T, e ast.Expr) string {
t.Helper()
fset := token.NewFileSet()
var buf bytes.Buffer
if err := format.Node(&buf, fset, e); err != nil {
t.Fatalf("format expr: %v", err)
}
return buf.String()
}
func formatStmt(t *testing.T, s ast.Stmt) string {
t.Helper()
fset := token.NewFileSet()
var buf bytes.Buffer
if err := format.Node(&buf, fset, s); err != nil {
t.Fatalf("format stmt: %v", err)
}
return buf.String()
}
// TestLoadSpec covers every wasm load shape and asserts elemType /
// outerCast pick.
func TestLoadSpec(t *testing.T) {
// (op, type, elemType, outerCast) — the i64 variants of sub-word
// loads pick the int64 outer cast while still reading the narrow
// sub-word elem.
type tc struct {
op ssa.Op
typ ssa.Type
elem string
cast string
}
cases := []tc{
{ssa.OpLoad8U, ssa.TypeI32, "uint8", "int32"},
{ssa.OpLoad8S, ssa.TypeI32, "int8", "int32"},
{ssa.OpLoad8U, ssa.TypeI64, "uint8", "int64"},
{ssa.OpLoad16U, ssa.TypeI32, "uint16", "int32"},
{ssa.OpLoad16S, ssa.TypeI32, "int16", "int32"},
{ssa.OpLoad32, ssa.TypeI32, "int32", ""},
{ssa.OpLoad32U, ssa.TypeI64, "uint32", "int64"},
{ssa.OpLoad32S, ssa.TypeI64, "int32", "int64"},
{ssa.OpLoad64, ssa.TypeI64, "int64", ""},
{ssa.OpLoadF32, ssa.TypeF32, "float32", ""},
{ssa.OpLoadF64, ssa.TypeF64, "float64", ""},
}
for _, c := range cases {
t.Run(c.op.String(), func(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
v := b.NewValueAuxInt(c.op, c.typ, 0, b.Const32(0))
spec, ok := loadSpec(v)
if !ok {
t.Fatalf("loadSpec returned !ok for %v", c.op)
}
if spec.elemType != c.elem {
t.Errorf("elemType = %q, want %q", spec.elemType, c.elem)
}
if spec.outerCast != c.cast {
t.Errorf("outerCast = %q, want %q", spec.outerCast, c.cast)
}
})
}
}
// TestStoreSpec covers every wasm store shape. Sub-word stores must
// pick UNSIGNED elemTypes (uint8/uint16/uint32) to honour the wasm
// "store low N bits unsigned" semantics; non-narrowing stores set
// valSrcType == elemType so the emitter skips the cast.
func TestStoreSpec(t *testing.T) {
type tc struct {
op ssa.Op
valTyp ssa.Type
elem string
valSrc string
}
cases := []tc{
{ssa.OpStore8, ssa.TypeI32, "uint8", "int32"},
{ssa.OpStore8, ssa.TypeI64, "uint8", "int64"},
{ssa.OpStore16, ssa.TypeI32, "uint16", "int32"},
{ssa.OpStore16, ssa.TypeI64, "uint16", "int64"},
{ssa.OpStore32, ssa.TypeI32, "int32", "int32"}, // i32.store: no cast
{ssa.OpStore32, ssa.TypeI64, "uint32", "int64"}, // i64.store32: narrowing
{ssa.OpStore64, ssa.TypeI64, "int64", "int64"},
{ssa.OpStoreF32, ssa.TypeF32, "float32", "float32"},
{ssa.OpStoreF64, ssa.TypeF64, "float64", "float64"},
}
for _, c := range cases {
t.Run(c.op.String()+"/"+c.valTyp.String(), func(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
var val *ssa.Value
switch c.valTyp {
case ssa.TypeI32:
val = b.Const32(0)
case ssa.TypeI64:
val = b.Const64(0)
default:
// Use a constant 0 of the right type via a CallDirect stub.
// Simpler: just synthesise with NewValue and a placeholder.
val = b.NewValueAuxInt(ssa.OpCallDirect, c.valTyp, 0)
}
v := b.NewValueAuxInt(c.op, ssa.TypeMem, 0, b.Const32(0), val)
spec, ok := storeSpec(v)
if !ok {
t.Fatalf("storeSpec returned !ok for %v", c.op)
}
if spec.elemType != c.elem {
t.Errorf("elemType = %q, want %q", spec.elemType, c.elem)
}
if spec.valSrcType != c.valSrc {
t.Errorf("valSrcType = %q, want %q", spec.valSrcType, c.valSrc)
}
})
}
}
// TestEmitMemLoadExpr_Load32 renders the OpLoad32 inline form and
// asserts it matches the documented one-liner shape. Catches any
// regression in unsafeDerefExpr's pointer-arithmetic chain.
func TestEmitMemLoadExpr_Load32(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
v := b.NewValueAuxInt(ssa.OpLoad32, ssa.TypeI32, 8, addr)
em := newSSAEmitter(nil)
emit := func(arg *ssa.Value) (ast.Expr, error) { return constArgExpr(arg), nil }
expr, err := em.emitMemLoadExpr(v, emit)
if err != nil {
t.Fatalf("emitMemLoadExpr: %v", err)
}
got := formatExpr(t, expr)
// New compact form: the unsafe.Pointer(unsafe.SliceData(m.memory))
// chain is cached into the function-entry local `_m`, so the access
// site references just `_m`. unsafe.Add accepts IntegerType so the
// outer uintptr() wrap is gone; the int32 constant operand collapses
// to a bare untyped literal (positive int32 constants are passed as
// uint64 fitting into Go's untyped-constant rules).
want := "*(*int32)(unsafe.Add(m.M, 8))"
if got != want {
t.Errorf("Load32 inline form:\n got: %s\n want: %s", got, want)
}
}
// TestEmitMemLoadExpr_Load8U checks the outer cast wraps the inner
// unsafe deref when sign/zero extension is needed.
func TestEmitMemLoadExpr_Load8U(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
v := b.NewValueAuxInt(ssa.OpLoad8U, ssa.TypeI32, 0, addr)
em := newSSAEmitter(nil)
emit := func(arg *ssa.Value) (ast.Expr, error) { return constArgExpr(arg), nil }
expr, err := em.emitMemLoadExpr(v, emit)
if err != nil {
t.Fatalf("emitMemLoadExpr: %v", err)
}
got := formatExpr(t, expr)
if !strings.HasPrefix(got, "int32(*(*uint8)") {
t.Errorf("Load8U should be int32-cast over *(*uint8)(...); got: %s", got)
}
// New compact form: the unsafe.Pointer(unsafe.SliceData(m.memory))
// chain is cached into the function-entry local `_m`, so the access
// site only references it. Verify the cache var name shows up; the
// SliceData lookup itself lives in the prologue emitted by
// emitFuncBody, not in unsafeDerefExpr's output.
if !strings.Contains(got, "unsafe.Add(m.M,") {
t.Errorf("Load8U missing m.M cache reference; got: %s", got)
}
}
// TestEmitMemStoreStmt_Store32 — non-narrowing store renders as a
// plain `*(*int32)(...) = val` one-liner; no cast around the RHS.
func TestEmitMemStoreStmt_Store32(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
val := b.Const32(7)
v := b.NewValueAuxInt(ssa.OpStore32, ssa.TypeMem, 0, addr, val)
em := newSSAEmitter(nil)
emit := func(arg *ssa.Value) (ast.Expr, error) { return constArgExpr(arg), nil }
stmt, err := em.emitMemStoreStmt(v, emit)
if err != nil {
t.Fatalf("emitMemStoreStmt: %v", err)
}
got := formatStmt(t, stmt)
// New compact form: see TestEmitMemLoadExpr_Load32 for the
// motivation. The destination references the cached _m base and
// the offset is a bare untyped literal.
want := "*(*int32)(unsafe.Add(m.M, 0)) = int32(7)"
if got != want {
t.Errorf("Store32 inline form:\n got: %s\n want: %s", got, want)
}
// RHS must be the bare valExpr, NOT wrapped in `int32(int32(7))`.
// (Non-narrowing stores skip the cast entirely — see emitMemStoreStmt.)
if strings.Count(got, "= int32(int32(") > 0 {
t.Errorf("Store32 must NOT wrap RHS in a no-op int32 cast; got: %s", got)
}
}
// TestEmitMemStoreStmt_Store8 — narrowing store renders the uint8
// cast on the RHS (the value side is force-hoisted by emit.ComputeHoist
// so the emit form is a runtime conversion).
func TestEmitMemStoreStmt_Store8(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
val := b.Const32(255) // out of int8 range — must NOT be inlined into the cast
v := b.NewValueAuxInt(ssa.OpStore8, ssa.TypeMem, 0, addr, val)
em := newSSAEmitter(nil)
// Simulate the post-hoist state by emitting val as the bare `vN`
// identifier the real emitter would substitute.
emit := func(arg *ssa.Value) (ast.Expr, error) {
if arg == val {
return newID("v9"), nil
}
return constArgExpr(arg), nil
}
stmt, err := em.emitMemStoreStmt(v, emit)
if err != nil {
t.Fatalf("emitMemStoreStmt: %v", err)
}
got := formatStmt(t, stmt)
want := "*(*uint8)(unsafe.Add(m.M, 0)) = uint8(v9)"
if got != want {
t.Errorf("Store8 narrowing form:\n got: %s\n want: %s", got, want)
}
}
// TestComputeHoistForceHoistsNarrowingStoreValue: when args[1] of a
// narrowing store is a constant-producing op (OpConst32), emit.ComputeHoist
// must mark it for hoisting so emitMemStoreStmt's runtime cast lands
// on a variable instead of a literal.
func TestComputeHoistForceHoistsNarrowingStoreValue(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
// val has usage = 1 (only the store), so without force-hoist it
// would be folded inline.
val := b.Const32(255)
b.NewValueAuxInt(ssa.OpStore8, ssa.TypeMem, 0, addr, val)
f := b.Func()
usage := emit.ComputeValueUsage(f)
hoist := emit.ComputeHoist(f, usage)
if !hoist[val.ID] {
t.Errorf("ComputeHoist did not force-hoist OpStore8 value arg (usage=%d, hoist=%v)",
usage[val.ID], hoist[val.ID])
}
}
// TestComputeHoistSkipsNonNarrowingStore: when elemType == valSrcType
// the cast is absent, so we don't need the force-hoist either.
// (Without this guard we'd needlessly bloat hoisted-var declarations
// on every i32.store and i64.store.)
func TestComputeHoistSkipsNonNarrowingStore(t *testing.T) {
b := ssa.NewFuncBuilder("t", ssa.FuncSig{})
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
addr := b.Const32(0)
val := b.Const32(7)
b.NewValueAuxInt(ssa.OpStore32, ssa.TypeMem, 0, addr, val)
f := b.Func()
usage := emit.ComputeValueUsage(f)
hoist := emit.ComputeHoist(f, usage)
if hoist[val.ID] {
t.Errorf("ComputeHoist wrongly force-hoisted OpStore32 value arg (no narrowing cast needed)")
}
}
// constArgExpr renders a constant arg for the test fixtures' emit
// closure. Matches what emitOp would return for OpConst32 / OpConst64.
func constArgExpr(v *ssa.Value) ast.Expr {
switch v.Op {
case ssa.OpConst32:
return goConstI32(int32(v.AuxInt))
case ssa.OpConst64:
return goConstI64(v.AuxInt)
}
return newID("?")
}
// TestMemOffsetExpr_RuntimeBaseLargeOffset guards the fix for the gc
// ARM64 "FLDPD ... constant is not in pool" assembly failure. A large
// static offset added to a RUNTIME index must be routed through the
// _consts table (a runtime memory load) rather than emitted as a bare
// immediate: the ARM64 backend fuses adjacent f64 loads at a common base
// into a load-pair and bakes the offset into its tiny immediate field,
// so a multi-MB offset otherwise fails to assemble. Small offsets stay
// inline. (The constant-base path was already covered; this pins the
// runtime-base path that regressed arm64 for wasmify's simplelib bundle.)
func TestMemOffsetExpr_RuntimeBaseLargeOffset(t *testing.T) {
em := newSSAEmitter(&translator{})
large := formatExpr(t, em.memOffsetExpr(newID("v160"), 33571920, nil))
if !strings.Contains(large, "_consts[") {
t.Errorf("large runtime-base offset must route through _consts, got: %s", large)
}
if strings.Contains(large, "33571920") {
t.Errorf("large offset must not appear as a bare immediate, got: %s", large)
}
small := formatExpr(t, em.memOffsetExpr(newID("v160"), 8, nil))
if strings.Contains(small, "_consts") {
t.Errorf("small runtime-base offset must stay inline, got: %s", small)
}
if !strings.Contains(small, "8") {
t.Errorf("small offset should be an inline literal, got: %s", small)
}
}