mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
Adds the wasm threads proposal to the codegen and, on top of it, a
copy-on-write memory-sharing facility for embedders that stand up many
instances of the same module.
Threads
- Lower the 0xFE atomic family (load/store/rmw/cmpxchg across widths and
sub-word lanes, plus wait/notify) to sync/atomic-based module helpers.
- wasi_thread_spawn runs each guest thread on a goroutine. A shared memory
is allocated at its declared ceiling once and never relocates, so every
agent's cached base pointer stays valid; growth is a lone atomic store.
- Passive data segments, per-agent globals, coherent plain access for
shared memories, and multi-package output for threaded modules.
- A shared memory's ceiling is a runtime value the embedder chooses, not
the binary's declared maximum.
Copy-on-write shared memory
- NewWithMemory lets the caller supply the linear memory, and memory.init
compares before it writes — so a copy-on-write map of a pre-initialized
image survives the start section instead of being faulted private.
- NewSharedImage builds an initialized image once per process and maps it
copy-on-write into every instance: the data segments (identical in every
instance, read-mostly) then cost almost nothing per instance.
- NewSharedSnapshot / NewFromSnapshot share a WHOLE initialized instance —
memory and wasm globals both — so instances re-run nothing at all.
SaveGlobals/RestoreGlobals carry the initialized state that lives outside
linear memory (the stack pointer and other mutable globals).
Also: coverage for the threads/atomics helpers, a WASM2GO_SSA_PASSES_OFF
diagnostic knob, an AtomicWait timeout margin, and thread-trap visibility.
Co-authored-by: Claude Opus 4.8 <[email protected]>
112 lines
3.4 KiB
Go
112 lines
3.4 KiB
Go
package helpers
|
||
|
||
import (
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
)
|
||
|
||
// TestMemoryGrowGeometric is the regression test for the O(n^2) startup
|
||
// stall: a C++ heap issues a long run of small memory.grow calls during
|
||
// static initialization. The old memoryGrow did make+copy of the whole
|
||
// linear memory on every call, so N grows copied O(N^2) bytes. With
|
||
// geometric backing-array growth, N grows must reallocate O(log N) times.
|
||
// newTestModule mirrors what generated New() does for a plain (non-shared)
|
||
// memory: memSize — the single source of truth for sizes and bounds since the
|
||
// shared-memory work — starts at len(memory). Hand-built Modules that skip it
|
||
// see size 0 and every helper traps.
|
||
func newTestModule(memory []byte) *Module {
|
||
m := &Module{
|
||
memory: memory,
|
||
memMu: &sync.Mutex{},
|
||
memSize: &atomic.Uint64{},
|
||
threads: &threadPool{},
|
||
}
|
||
m.memSize.Store(uint64(len(memory)))
|
||
return m
|
||
}
|
||
|
||
func TestMemoryGrowGeometric(t *testing.T) {
|
||
m := newTestModule(make([]byte, 65536)) // 1 page
|
||
|
||
reallocs := 0
|
||
prevCap := cap(m.memory)
|
||
const grows = 1000
|
||
for i := 0; i < grows; i++ {
|
||
before := uint64(len(m.memory))
|
||
ret := memoryGrow(m, 1)
|
||
if uint64(ret)*65536 != before {
|
||
t.Fatalf("grow %d: prev page count %d, want %d", i, ret, before/65536)
|
||
}
|
||
if uint64(len(m.memory)) != before+65536 {
|
||
t.Fatalf("grow %d: len=%d want %d", i, len(m.memory), before+65536)
|
||
}
|
||
if cap(m.memory) != prevCap {
|
||
reallocs++
|
||
prevCap = cap(m.memory)
|
||
}
|
||
}
|
||
if want := (grows + 1) * 65536; len(m.memory) != want {
|
||
t.Fatalf("final len=%d want %d", len(m.memory), want)
|
||
}
|
||
// 1000 one-page grows under geometric growth: ~log2(1000) ≈ 10
|
||
// reallocations. The old O(n^2) implementation reallocated 1000×.
|
||
if reallocs > 20 {
|
||
t.Errorf("memoryGrow reallocated %d× for %d grows — not geometric", reallocs, grows)
|
||
}
|
||
t.Logf("%d one-page grows -> %d reallocations", grows, reallocs)
|
||
}
|
||
|
||
// TestMemoryGrowCorrectness checks the wasm semantics memoryGrow must
|
||
// preserve regardless of the backing-array strategy.
|
||
func TestMemoryGrowCorrectness(t *testing.T) {
|
||
m := newTestModule(make([]byte, 65536))
|
||
m.memory[0] = 0xAB
|
||
m.memory[65535] = 0xCD
|
||
|
||
if got := memoryGrow(m, 3); got != 1 {
|
||
t.Fatalf("grow: prev pages %d, want 1", got)
|
||
}
|
||
if len(m.memory) != 4*65536 {
|
||
t.Fatalf("len=%d want %d", len(m.memory), 4*65536)
|
||
}
|
||
if m.memory[0] != 0xAB || m.memory[65535] != 0xCD {
|
||
t.Fatal("existing data not preserved across grow")
|
||
}
|
||
for i := 65536; i < 4*65536; i++ {
|
||
if m.memory[i] != 0 {
|
||
t.Fatalf("newly grown byte %d is %#x, want 0", i, m.memory[i])
|
||
}
|
||
}
|
||
|
||
// Reslice path: write into a grown page, grow again, ensure the
|
||
// freshly exposed pages are still zero (capacity reuse must not
|
||
// surface stale bytes).
|
||
m.memory[3*65536] = 0xEE
|
||
memoryGrow(m, 2)
|
||
for i := 4 * 65536; i < 6*65536; i++ {
|
||
if m.memory[i] != 0 {
|
||
t.Fatalf("reslice-exposed byte %d is %#x, want 0", i, m.memory[i])
|
||
}
|
||
}
|
||
if m.memory[3*65536] != 0xEE {
|
||
t.Fatal("data lost across reslice grow")
|
||
}
|
||
|
||
if got := memoryGrow(m, 0); got != 6 {
|
||
t.Fatalf("n=0: %d, want 6", got)
|
||
}
|
||
if got := memoryGrow(m, -1); got != -1 {
|
||
t.Fatalf("n<0: %d, want -1", got)
|
||
}
|
||
|
||
lim := newTestModule(make([]byte, 65536))
|
||
lim.maxMem = 2 * 65536
|
||
if got := memoryGrow(lim, 5); got != -1 {
|
||
t.Fatalf("grow past maxMem: %d, want -1", got)
|
||
}
|
||
if len(lim.memory) != 65536 {
|
||
t.Fatalf("failed grow mutated memory: len=%d", len(lim.memory))
|
||
}
|
||
}
|