mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
* 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]>
74 lines
2.5 KiB
Go
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")
|
|
}
|
|
}
|