Files
wasm2go/internal/codegen/membase_hoist_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.5 KiB
Go

package codegen_test
import (
"bytes"
"regexp"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/codegen"
"github.com/goccy/wasm2go/internal/testfixture"
"github.com/goccy/wasm2go/internal/wasm"
)
// TestMemBaseHoistEmitted pins the linear-memory base hoist: every
// function that touches memory declares `mBase := m.M` exactly once
// at the top, all load/store sites deref mBase (never m.M), and a
// grow-capable value (a call or memory.grow) is followed by a
// `mBase = m.M` refresh. Functions without memops must not declare
// the local (it would be an unused variable).
func TestMemBaseHoistEmitted(t *testing.T) {
bin := testfixture.Wasm(t, "cg_memops")
mod, err := wasm.Parse(bytes.NewReader(bin))
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
res, err := codegen.Translate(&buf, mod, codegen.Options{Package: "memmod", OutputImportPath: "gentest/memmod"})
if err != nil {
t.Fatalf("translate: %v", err)
}
// Function bodies with memops live in the pure-Go fallback aux
// files (asm emission is always on; the main file only carries
// wrappers and runtime plumbing). Concatenate everything so the
// assertions see the real memop emission.
var sb strings.Builder
sb.Write(buf.Bytes())
for _, data := range res.AuxFiles {
sb.Write(data)
}
for _, data := range res.Files {
sb.Write(data)
}
src := sb.String()
if !strings.Contains(src, "mBase := m.M") {
t.Fatalf("no hoisted mBase declaration in output")
}
// Every unsafe.Add memop must go through the hoisted local; the
// only remaining `m.M` reads are the hoist/refresh assignments.
if re := regexp.MustCompile(`unsafe\.Add\(m\.M`); re.MatchString(src) {
t.Errorf("found unsafe.Add(m.M, ...) — memop bypassed the hoisted base")
}
// The grow-refresh interaction is pinned separately by
// TestMemBaseRefreshAfterGrow (the fixture has no function that
// combines memops with memory.grow; a grow-only function
// correctly skips the hoist entirely).
}
// TestMemBaseHoistAbsentWithoutMemops: a module whose functions never
// touch linear memory must not declare the local.
func TestMemBaseHoistAbsentWithoutMemops(t *testing.T) {
bin := testfixture.Wasm(t, "arith")
mod, err := wasm.Parse(bytes.NewReader(bin))
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if _, err := codegen.Translate(&buf, mod, codegen.Options{Package: "arithmod", OutputImportPath: "gentest/arithmod"}); err != nil {
t.Fatalf("translate: %v", err)
}
if strings.Contains(buf.String(), "mBase") {
t.Errorf("memory-less module declared mBase")
}
}