Files
wasm2go/internal/codegen/membase_hoist_unit_test.go
75d47cae28 codegen: per-function linear-memory base hoist + memoryFill fast paths (#13)
* codegen: hoist the linear-memory base into a per-function local

Every pure-mode memory access read the base pointer through the
`m.M` FIELD: `*(*T)(unsafe.Add(m.M, addr))`. Because any unsafe
store may alias a struct field, the Go compiler must discard its
cached copy of m.M after EVERY store (and call) and reload it —
and with it, every address computation derived from it. Disassembly
of the hot varint loop (p9.Fn58) showed 2 dependent m.M reloads per
iteration feeding the store-address chain, and interpreter-shaped
code (CPython's eval loop: thousands of memops interleaved with
stores) pays this on virtually every operation.

The generator knows an invariant the compiler cannot see: the base
only changes when memory.grow relocates the backing array, and grow
only ever runs inside calls (direct/indirect/import) or the
memory.grow op itself — never a plain load/store. Encode that:

    func FnN(m *base.Module, ...) {
        mBase := m.M                  // hoisted once
        ... unsafe.Add(mBase, ...)    // every memop
        vK := Fn123(m, ...)           // can grow
        mBase = m.M                   // refresh ONLY here
    }

`mBase` is a local whose address never escapes, so stores cannot
alias it: the compiler keeps it (and the address web computed from
it) in registers across stores, and the refresh set shrinks from
"every store + every call" to "every call". This is sound TODAY,
with the relocating grow, on every GOOS/GOARCH — no non-moving
memory requirement. Functions without memops don't declare the
local; both the structured and goto emitters insert the refreshes.

Measured (pure-mode bundles regenerated from this commit vs main,
-benchtime=3x -count=5, go-python bench where generated code
dominates the profile):

  | workload            | amd64 (Rosetta) | arm64 (native M5) |
  |---------------------|-----------------|-------------------|
  | fib(28)             |  427ms -> 297ms (-30.6%) | 579ms -> 189ms (-67.4%) |
  | loop-sum 200k       |   92ms ->  48ms (-47.5%) | 124ms ->  35ms (-72.0%) |

  googlesqlite window suite (pure, 200x count=5): -0.3..-0.7% —
  within noise, as expected: those queries spend most of their time
  in SQLite/driver code and their generated fraction is dominated by
  short-span shapes the compiler already handled.

Disassembly of the post-change Fn58 confirms the mechanism: the loop
body contains ZERO base reloads (one prologue load total, base lives
in a register through the loop).

Correctness (all exit 0, judged by exit code):
  - wasm2go go test ./... on host amd64 AND GOARCH=arm64; make lint.
  - googlesqlite full pure-mode suite (-tags purego, go.mod replace
    to a bundle regenerated from this commit): 35 packages ok.
  - go-python pure-mode suites: amd64, arm64, and arm64 -race — all
    green against the regenerated python bundle.
  - Per-package amd64.s byte-identical to the pre-change bundle
    (pure-only change).
  - New emission pins: TestMemBaseHoistEmitted /
    TestMemBaseHoistAbsentWithoutMemops (fixture level) and
    TestMemBaseRefreshAfterGrow (emitter level: decl-before-grow-
    before-refresh order, no m.M bypass, post-grow load reads the
    refreshed base).

Co-Authored-By: Claude Fable 5 <[email protected]>

* helpers: memoryFill fast paths (memclr zero fill, copy-doubling non-zero)

base.MemoryFill showed up at 4-5% flat in the sum_running_total
profile on BOTH backends. The old body used the range-fill idiom
with a VARIABLE byte — but the compiler's range-fill-to-memclr
rewrite only fires for a constant zero, so every fill ran as a plain
~1 byte/cycle loop.

Dispatch explicitly: the overwhelmingly common zero fill (calloc
paths) takes the constant-zero range loop the compiler rewrites to
runtime.memclrNoHeapPointers, and the rare non-zero fill seeds one
byte and doubles it with copy() — O(log n) memmoves at memset speed
instead of n single-byte stores.

TestMemoryFillPaths pins both paths across sizes that exercise the
doubling loop's partial final chunk, byte-exact content, neighbour
preservation, and the out-of-bounds panic.

Verification: go test ./... green on host amd64 and GOARCH=arm64;
make lint 0 issues. Consumer-level effect is measured together with
the base-hoist commit in the bench round recorded in
bench-metrics.md.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 23:18:50 +09:00

74 lines
2.4 KiB
Go

package codegen
import (
"bytes"
"go/format"
"go/token"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/ssa"
)
// TestMemBaseRefreshAfterGrow pins the exact soundness contract of the
// base hoist at the emitter level: in a function that both touches
// memory and can grow it, the emitted body must (1) declare the
// hoisted base first, (2) route every load/store through it, and
// (3) re-read m.M immediately after each grow-capable value — the
// only points where the backing array can relocate.
func TestMemBaseRefreshAfterGrow(t *testing.T) {
fsig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeI32}}
b := ssa.NewFuncBuilder("growmix", fsig)
b0 := b.NewBlock(ssa.BlockRet)
b.SetEntry(b0)
b.SetCurrent(b0)
addr := b.Param(0, ssa.TypeI32)
before := b.NewValueAuxInt(ssa.OpLoad32, ssa.TypeI32, 0, addr)
pages := b.Const32(1)
grown := b.NewValue(ssa.OpMemGrow, ssa.TypeI32, pages)
after := b.NewValueAuxInt(ssa.OpLoad32, ssa.TypeI32, 4, addr)
sum := b.NewValue(ssa.OpAdd32, ssa.TypeI32, before, after)
sum2 := b.NewValue(ssa.OpAdd32, ssa.TypeI32, sum, grown)
b.FinishRet(sum2)
if err := ssa.Verify(b.Func()); err != nil {
t.Fatalf("SSA verify: %v", err)
}
body, err := emitSSAFuncBody(b.Func())
if err != nil {
t.Fatalf("emit: %v", err)
}
var buf bytes.Buffer
if err := format.Node(&buf, token.NewFileSet(), body); err != nil {
t.Fatalf("format: %v", err)
}
src := buf.String()
declIdx := strings.Index(src, "mBase := m.M")
if declIdx < 0 {
t.Fatalf("no hoisted declaration:\n%s", src)
}
growIdx := strings.Index(src, "memoryGrow(m")
if growIdx < 0 {
// nil-translator emission spells the helper differently only
// if helperRef changes; pin failure loudly either way.
t.Fatalf("no grow site found:\n%s", src)
}
refreshIdx := strings.Index(src, "mBase = m.M")
if refreshIdx < 0 {
t.Fatalf("no refresh after grow:\n%s", src)
}
if declIdx >= growIdx || growIdx >= refreshIdx {
t.Fatalf("order wrong (decl=%d grow=%d refresh=%d):\n%s", declIdx, growIdx, refreshIdx, src)
}
if strings.Contains(src, "unsafe.Add(m.M") {
t.Fatalf("memop bypassed hoisted base:\n%s", src)
}
// The load AFTER the grow must read through the refreshed base —
// i.e. appear after the refresh in statement order.
lastLoad := strings.LastIndex(src, "unsafe.Add(mBase")
if lastLoad < refreshIdx {
t.Fatalf("post-grow load precedes the refresh:\n%s", src)
}
}