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.4 KiB
Go
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)
|
|
}
|
|
}
|