mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
Replace the legacy direct-opcode compiler with an SSA pipeline that
emits per-function amd64.s and arm64.s for every wasm function. A
pure-Go body is also emitted (build-constrained on other GOOS/GOARCH)
and shares the same New() / exported-method surface, so callers never
see the path distinction.
Asm pipeline
- SSA always on; unsupported wasm features are now hard errors.
Translate now drives Parse -> SSA build -> passes (Simplify, DCE,
CSE) -> arch-specific emit.
- Per-arch block-local linear-scan regalloc with slot reuse and
m-pointer caching (the *Module pointer is pinned in a reserved
register across the function body — R11 on amd64, R4 on arm64 —
so per-access reloads from the FP frame are gone).
- Cross-block stack allocation via interference-graph slot sharing
reduces the typical per-function frame to 8-16 bytes.
- Peephole + branch fusion: drop dead spills, elide empty else
fall-through, fold constant displacement into indexed load/store,
fuse AND+NE+BlockIf into BTL+JCC.
- A new cross-block linear-scan allocator with Belady eviction
lives in internal/regalloc/ and is gated behind
WASM2GO_NEWREGALLOC for measurement.
Output shape
- Bulk-dispatch exports: when -bulk-export-prefix matches at least
one export, each <prefix><svc>_<mt> becomes a standalone
Inv_<svc>_<mt> function with inlined trap-recovery (defer +
recover + mutable-global snapshot/restore). The Go linker drops
whichever Inv_* the consumer never calls.
- Cross-package asm CALL: when every byte of an import path maps to
a plan-9 identifier rune ("/" -> U+2215, "." -> U+00B7), the CALL
target emits the direct cross-package symbol; otherwise asm falls
back to a per-chunk Go-body trampoline.
- Multi-package layout for large modules: bodies split across
p0..p<N> with go:linkname bridges in alias.go and per-chunk
amd64.s / arm64.s sidecars. Single-file mode remains supported
for modules under the internal multi-file threshold.
Coverage and tests
- internal/asmgen, internal/codegen, internal/regalloc, internal/ssa,
internal/wasm pass with -race. Total cross-package statement
coverage 85.8%, above the enforced 85% threshold.
- A new TestRecoverTrapsAreSurfacedAsErrors end-to-end test
(testdata/cg_recover.wat) pins the Inv_<svc>_<mt> trap-recovery
contract: wasm traps surface as errors (no Go panic), mutable
globals restore to wrapper-entry values, and 5000 trap+recover
cycles leak no goroutines and keep the Go heap delta under 1 MiB
after a forced GC.
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
277 lines
8.7 KiB
Go
277 lines
8.7 KiB
Go
package ssa
|
|
|
|
// AliasClass classifies where a memory access lands. It is the
|
|
// observability backbone of memory promotion: every wasm
|
|
// load / store is tagged so the transpiler can report — and act on —
|
|
// how much of the linear-memory traffic is provably function-local.
|
|
type AliasClass uint8
|
|
|
|
const (
|
|
// AliasUnknown: not yet classified.
|
|
AliasUnknown AliasClass = iota
|
|
// AliasSlab: a generic linear-memory access through the shared
|
|
// m.Memory byte slab. Not promotable — the address provenance is
|
|
// dynamic, escapes, or could not be proven local.
|
|
AliasSlab
|
|
// AliasFrame: the access targets a function-local stack frame
|
|
// (the `g0 -= N` prologue allocation) whose frame pointer provably
|
|
// does not escape the function. Promotable to a Go `[N]byte`.
|
|
AliasFrame
|
|
// AliasRodata: the access reads a compile-time-constant address
|
|
// inside a data segment that is never written. Promotable to a
|
|
// shared readonly byte slice.
|
|
AliasRodata
|
|
)
|
|
|
|
func (c AliasClass) String() string {
|
|
switch c {
|
|
case AliasSlab:
|
|
return "slab"
|
|
case AliasFrame:
|
|
return "frame"
|
|
case AliasRodata:
|
|
return "rodata"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// MemClass is the per-function result of ClassifyMemory: a class for
|
|
// every memory-op value, plus the detected stack-frame facts.
|
|
type MemClass struct {
|
|
// Class maps each load/store value ID to its AliasClass.
|
|
Class map[ValueID]AliasClass
|
|
// FramePtr is the SSA value identified as the stack-frame pointer
|
|
// (`g0 - N`), or nil if no frame prologue was found.
|
|
FramePtr *Value
|
|
// FrameSize is the byte size N of the detected frame (0 if none).
|
|
FrameSize int64
|
|
// FrameEscapes is true when a frame-pointer-derived value is used
|
|
// in a way that lets the frame outlive / leave the function (passed
|
|
// to a call, stored to memory, used in opaque arithmetic). When
|
|
// true, frame accesses are demoted to AliasSlab.
|
|
FrameEscapes bool
|
|
// EscapeReason, when FrameEscapes, names why (for the JSON / debug
|
|
// report).
|
|
EscapeReason string
|
|
}
|
|
|
|
// ClassifyMemory analyses one SSA function and classifies every memory
|
|
// access. It is intraprocedural and conservative: anything it cannot
|
|
// prove function-local stays AliasSlab.
|
|
//
|
|
// Frame detection: the standard LLVM/Emscripten prologue is
|
|
//
|
|
// g0' = g0 - N ; OpSub32(OpGlobalGet[0], OpConst32[N])
|
|
// (local.tee fp) ; fp = g0'
|
|
// global.set 0 g0' ; OpGlobalSet[0]
|
|
//
|
|
// The result of that subtraction is the frame pointer. Loads/stores
|
|
// whose address is `fp` or `fp + const` are frame accesses. The frame
|
|
// is promotable only if the frame pointer never escapes.
|
|
func ClassifyMemory(f *Func) *MemClass {
|
|
mc := &MemClass{Class: map[ValueID]AliasClass{}}
|
|
|
|
// 1. Locate the frame pointer.
|
|
mc.FramePtr, mc.FrameSize = findFramePointer(f)
|
|
|
|
// 2. Compute the set of frame-pointer-derived values: fp itself,
|
|
// plus fp+const (any number of constant offsets), plus copies.
|
|
frameDerived := map[ValueID]bool{}
|
|
if mc.FramePtr != nil {
|
|
frameDerived[mc.FramePtr.ID] = true
|
|
// Fixpoint: a value is frame-derived if it is OpAdd32 /
|
|
// OpSub32 of a frame-derived value and a constant, or a copy.
|
|
changed := true
|
|
for changed {
|
|
changed = false
|
|
for _, v := range f.Values {
|
|
if v == nil || frameDerived[v.ID] {
|
|
continue
|
|
}
|
|
if isFrameOffset(v, frameDerived) {
|
|
frameDerived[v.ID] = true
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Escape analysis: walk every use of every frame-derived value.
|
|
if mc.FramePtr != nil {
|
|
users := buildUseMap(f)
|
|
mc.FrameEscapes, mc.EscapeReason = frameEscapes(f, frameDerived, users)
|
|
}
|
|
|
|
// 4. Classify each memory op.
|
|
for _, v := range f.Values {
|
|
if v == nil || !isMemoryAccess(v.Op) {
|
|
continue
|
|
}
|
|
addr := constFoldAddr(memAddr(v))
|
|
switch {
|
|
case mc.FramePtr != nil && addr != nil && frameDerived[addr.ID] && !mc.FrameEscapes:
|
|
mc.Class[v.ID] = AliasFrame
|
|
case addr != nil && addr.Op == OpConst32 && isLoadOp(v.Op):
|
|
// A load from a compile-time-constant linear-memory address
|
|
// is a rodata-promotion candidate: such addresses point at
|
|
// module data segments (string tables, vtables, ...).
|
|
// Whether the segment is provably never written is a
|
|
// whole-module property the rewrite stage must confirm;
|
|
// for observability this is the candidate set.
|
|
mc.Class[v.ID] = AliasRodata
|
|
default:
|
|
mc.Class[v.ID] = AliasSlab
|
|
}
|
|
}
|
|
return mc
|
|
}
|
|
|
|
// isLoadOp reports whether op is a memory load (as opposed to a store).
|
|
func isLoadOp(op Op) bool {
|
|
switch op {
|
|
case OpLoad8U, OpLoad8S, OpLoad16U, OpLoad16S, OpLoad32,
|
|
OpLoad32U, OpLoad32S, OpLoad64, OpLoadF32, OpLoadF64:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// constFoldAddr peels OpCopy and folds `OpConst + OpConst` /
|
|
// `OpAdd(OpConst, OpConst)` so a memory address that is constant after
|
|
// trivial arithmetic is recognised as such. Returns a synthetic
|
|
// const-typed value when it folds, else the original.
|
|
func constFoldAddr(v *Value) *Value {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
if v.Op == OpCopy && len(v.Args) == 1 {
|
|
return constFoldAddr(v.Args[0])
|
|
}
|
|
if v.Op == OpAdd32 && len(v.Args) == 2 {
|
|
a := constFoldAddr(v.Args[0])
|
|
b := constFoldAddr(v.Args[1])
|
|
if a != nil && b != nil && a.Op == OpConst32 && b.Op == OpConst32 {
|
|
// Return a stand-in const value (not registered on the
|
|
// Func — used only for classification, never emitted).
|
|
return &Value{Op: OpConst32, Type: TypeI32, AuxInt: a.AuxInt + b.AuxInt}
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// findFramePointer scans f for the prologue subtraction
|
|
// `OpSub32(OpGlobalGet[0], OpConst32[N])` and returns that value and N.
|
|
func findFramePointer(f *Func) (*Value, int64) {
|
|
for _, v := range f.Values {
|
|
if v == nil || v.Op != OpSub32 || len(v.Args) != 2 {
|
|
continue
|
|
}
|
|
base, off := v.Args[0], v.Args[1]
|
|
if base == nil || off == nil {
|
|
continue
|
|
}
|
|
if base.Op == OpGlobalGet && base.AuxInt == 0 && off.Op == OpConst32 {
|
|
return v, off.AuxInt
|
|
}
|
|
}
|
|
return nil, 0
|
|
}
|
|
|
|
// isFrameOffset reports whether v is `frameDerived ± const` or a copy
|
|
// of a frame-derived value.
|
|
func isFrameOffset(v *Value, frameDerived map[ValueID]bool) bool {
|
|
switch v.Op {
|
|
case OpCopy:
|
|
return len(v.Args) == 1 && v.Args[0] != nil && frameDerived[v.Args[0].ID]
|
|
case OpAdd32, OpSub32:
|
|
if len(v.Args) != 2 || v.Args[0] == nil || v.Args[1] == nil {
|
|
return false
|
|
}
|
|
a, b := v.Args[0], v.Args[1]
|
|
// frameDerived + const (or const + frameDerived for Add)
|
|
if frameDerived[a.ID] && b.Op == OpConst32 {
|
|
return true
|
|
}
|
|
if v.Op == OpAdd32 && frameDerived[b.ID] && a.Op == OpConst32 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// buildUseMap returns, per value ID, the list of values that reference
|
|
// it as an argument.
|
|
func buildUseMap(f *Func) map[ValueID][]*Value {
|
|
users := map[ValueID][]*Value{}
|
|
for _, v := range f.Values {
|
|
if v == nil {
|
|
continue
|
|
}
|
|
for _, a := range v.Args {
|
|
if a != nil {
|
|
users[a.ID] = append(users[a.ID], v)
|
|
}
|
|
}
|
|
}
|
|
return users
|
|
}
|
|
|
|
// frameEscapes returns whether any frame-derived value is used in a way
|
|
// that lets the frame escape the function.
|
|
func frameEscapes(f *Func, frameDerived map[ValueID]bool, users map[ValueID][]*Value) (bool, string) {
|
|
for _, v := range f.Values {
|
|
if v == nil || !frameDerived[v.ID] {
|
|
continue
|
|
}
|
|
for _, u := range users[v.ID] {
|
|
switch u.Op {
|
|
case OpGlobalSet:
|
|
// Frame management (prologue set / epilogue restore).
|
|
case OpAdd32, OpSub32:
|
|
// `fp ± const` stays frame-derived; non-const operands
|
|
// were already excluded from frameDerived, but a use
|
|
// of fp as `fp - dynamicValue` is opaque → escape.
|
|
if !frameDerived[u.ID] {
|
|
return true, "frame pointer used in non-constant arithmetic"
|
|
}
|
|
case OpCopy:
|
|
// A copy of a frame value is itself frame-derived.
|
|
case OpStore8, OpStore16, OpStore32, OpStore64, OpStoreF32, OpStoreF64:
|
|
// Address operand (Args[0]) is fine — a frame store.
|
|
// Value operand (Args[1]) means the pointer itself is
|
|
// written into memory → escape.
|
|
if len(u.Args) >= 2 && u.Args[1] == v {
|
|
return true, "frame pointer stored to memory"
|
|
}
|
|
case OpLoad8U, OpLoad8S, OpLoad16U, OpLoad16S, OpLoad32,
|
|
OpLoad32U, OpLoad32S, OpLoad64, OpLoadF32, OpLoadF64:
|
|
// Address operand — a frame load. Fine.
|
|
case OpCallDirect, OpCallIndirect, OpCallImport:
|
|
return true, "frame pointer passed to a call"
|
|
default:
|
|
return true, "frame pointer used by " + u.Op.String()
|
|
}
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
// isMemoryAccess reports whether op is a load or store.
|
|
func isMemoryAccess(op Op) bool {
|
|
switch op {
|
|
case OpLoad8U, OpLoad8S, OpLoad16U, OpLoad16S, OpLoad32,
|
|
OpLoad32U, OpLoad32S, OpLoad64, OpLoadF32, OpLoadF64,
|
|
OpStore8, OpStore16, OpStore32, OpStore64, OpStoreF32, OpStoreF64:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// memAddr returns the address operand of a memory-access value.
|
|
func memAddr(v *Value) *Value {
|
|
if len(v.Args) == 0 {
|
|
return nil
|
|
}
|
|
return v.Args[0]
|
|
}
|