Files
9d2559e75d asmgen: SSA-based dual-arch asm emit (amd64 + arm64) (#7)
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]>
2026-06-10 10:54:22 +09:00

297 lines
8.7 KiB
Go

package codegen
import (
"fmt"
"sort"
"github.com/goccy/wasm2go/internal/wasm"
)
// MultiPackagePlan describes how the defined wasm functions are distributed
// across a chain of Go packages. Each chunk imports all earlier chunks so the
// Go build system compiles them strictly serially — bounding peak compiler
// memory to one chunk's SSA cost regardless of total module size.
type MultiPackagePlan struct {
// Chunks in topological order. Chunks[0] has no chunk dependencies;
// Chunks[i] (i>0) depends on Chunks[0..i-1] via Go imports.
Chunks []MultiPackageChunk
// FuncToChunk maps a defined-function index (NumImportedFuncs..end) to
// the chunk index that holds it.
FuncToChunk map[uint32]int
}
// MultiPackageChunk lists the function indices owned by a single chunk
// package, plus the size estimate used during packing.
type MultiPackageChunk struct {
FuncIdxs []uint32
Bytes int
}
// PlanLinknamePackages builds a chunk plan for the linkname-split layout
// (Options.LinknameSplit = true). Mutually-recursive functions are still kept
// in the same SCC (so they don't have to pay a linkname hop to call each
// other), but the SCC-level topological order is NOT used to bound chunk
// dependencies — cross-chunk calls are wired by //go:linkname at emit time,
// so chunks can call any other chunk regardless of ordering. The partitioner
// uses first-fit-decreasing bin packing on SCC byte size to balance chunks
// without the "later chunks must only call earlier ones" constraint.
// PlanLinknamePackages builds the chunk plan for linkname-split mode.
// reachable, when non-nil, is the whole-function dead-code-elimination
// result keyed by local index; SCCs whose functions are all dead are
// skipped so they neither consume chunk budget nor get emitted.
func PlanLinknamePackages(mod *wasm.Module, chunkBytes int, reachable map[uint32]bool) (*MultiPackagePlan, error) {
return planLinknamePackagesWith(mod, chunkBytes, reachable, nil)
}
// planLinknamePackagesWith is the implementation accepting a precomputed
// callees adjacency list. callees may be nil — the function will build
// the graph itself in that case. Exposed only inside the package so
// the codegen translator can share its cached call graph with the
// planner.
func planLinknamePackagesWith(mod *wasm.Module, chunkBytes int, reachable map[uint32]bool, callees [][]uint32) (*MultiPackagePlan, error) {
if chunkBytes <= 0 {
chunkBytes = 1024 * 1024
}
nDefined := uint32(len(mod.Functions))
nImports := mod.NumImportedFuncs
if callees == nil {
var err error
callees, err = buildCallGraph(mod)
if err != nil {
return nil, err
}
}
sccs := tarjanSCC(nDefined, callees)
// Compute SCC sizes (sum of function-body bytes for that SCC).
// An SCC is mutually-reachable, so it is uniformly reachable or
// uniformly dead — testing one member suffices.
type sccBin struct {
funcs []uint32
bytes int
}
bins := make([]sccBin, 0, len(sccs))
for _, scc := range sccs {
if reachable != nil && len(scc) > 0 && !reachable[scc[0]] {
continue // entire SCC is dead — drop it
}
size := 0
for _, f := range scc {
size += len(mod.Functions[f].Body)
}
bins = append(bins, sccBin{funcs: scc, bytes: size})
}
// First-fit-decreasing: pack the biggest SCCs first. SCCs that exceed
// chunkBytes alone get their own chunk (we cannot split an SCC further
// without breaking call closure within the SCC).
sort.SliceStable(bins, func(i, j int) bool {
return bins[i].bytes > bins[j].bytes
})
type packedChunk struct {
funcs []uint32
bytes int
}
var chunks []packedChunk
for _, b := range bins {
if b.bytes >= chunkBytes {
// Oversized SCC → its own chunk. Inserted at front so subsequent
// fitting decisions for smaller SCCs don't accidentally probe it.
chunks = append(chunks, packedChunk(b))
continue
}
// Try to fit into an existing chunk.
fit := -1
for i := range chunks {
if chunks[i].bytes+b.bytes <= chunkBytes {
fit = i
break
}
}
if fit < 0 {
chunks = append(chunks, packedChunk(b))
} else {
chunks[fit].funcs = append(chunks[fit].funcs, b.funcs...)
chunks[fit].bytes += b.bytes
}
}
plan := &MultiPackagePlan{FuncToChunk: map[uint32]int{}}
plan.Chunks = make([]MultiPackageChunk, len(chunks))
for i, c := range chunks {
plan.Chunks[i] = MultiPackageChunk{FuncIdxs: make([]uint32, 0, len(c.funcs)), Bytes: c.bytes}
// Stable order within a chunk: ascending function index. This makes
// the generated output deterministic and easier to diff across runs.
sortedFuncs := append([]uint32(nil), c.funcs...)
sort.Slice(sortedFuncs, func(a, b int) bool { return sortedFuncs[a] < sortedFuncs[b] })
for _, f := range sortedFuncs {
plan.Chunks[i].FuncIdxs = append(plan.Chunks[i].FuncIdxs, nImports+f)
plan.FuncToChunk[nImports+f] = i
}
}
return plan, nil
}
// buildCallGraph returns, for each defined-function local index, the set of
// defined-function local indices it directly calls. Imported-function calls
// are excluded (they're handled by the host-import struct, not by Go imports).
func buildCallGraph(mod *wasm.Module) ([][]uint32, error) {
out := make([][]uint32, len(mod.Functions))
for i, fn := range mod.Functions {
seen := map[uint32]bool{}
callees, err := scanDirectCalls(fn.Body, mod.NumImportedFuncs)
if err != nil {
return nil, fmt.Errorf("fn%d: %w", uint32(i)+mod.NumImportedFuncs, err)
}
for _, c := range callees {
if c < mod.NumImportedFuncs {
continue
}
localIdx := c - mod.NumImportedFuncs
if seen[localIdx] {
continue
}
seen[localIdx] = true
out[i] = append(out[i], localIdx)
}
}
return out, nil
}
// scanDirectCalls walks a function body and collects every operand of a
// `call <idx>` instruction. Skips locals, types, and other LEB128 fields
// via wasm.InstrReader.SkipImmediates.
func scanDirectCalls(body []byte, _ uint32) ([]uint32, error) {
r := wasm.NewInstrReader(body)
// Skip locals header.
nDecls, err := r.ReadU32()
if err != nil {
return nil, fmt.Errorf("locals header: %w", err)
}
for i := uint32(0); i < nDecls; i++ {
if _, err := r.ReadU32(); err != nil {
return nil, err
}
if _, err := r.ReadByte(); err != nil {
return nil, err
}
}
var out []uint32
for !r.EOF() {
op, err := r.ReadByte()
if err != nil {
return nil, err
}
if op == wasm.OpCall {
idx, err := r.ReadU32()
if err != nil {
return nil, err
}
out = append(out, idx)
continue
}
// ref.func names a function whose address is taken — the named
// function must stay live across whole-function DCE, so treat
// it as a call-graph edge alongside wasm.OpCall.
if op == wasm.OpRefFunc {
idx, err := r.ReadU32()
if err != nil {
return nil, err
}
out = append(out, idx)
continue
}
if err := r.SkipImmediates(op); err != nil {
return nil, err
}
}
return out, nil
}
// tarjanSCC computes the strongly connected components of the call graph
// using Tarjan's algorithm. Returns SCCs in REVERSE topological order
// (callees before callers), which is the order in which chunks should be
// packed.
func tarjanSCC(n uint32, callees [][]uint32) [][]uint32 {
const unvisited = -1
indexCounter := 0
stack := make([]uint32, 0, n)
onStack := make([]bool, n)
indices := make([]int, n)
lowlinks := make([]int, n)
for i := range indices {
indices[i] = unvisited
}
var sccs [][]uint32
// Iterative DFS to avoid blowing the Go goroutine stack for huge call
// graphs (the recursive form of Tarjan trips the runtime's stack growth
// for graphs in the tens of thousands of nodes).
type frame struct {
v uint32
i int
init bool
}
visit := func(start uint32) {
dfs := []frame{{v: start}}
for len(dfs) > 0 {
top := &dfs[len(dfs)-1]
v := top.v
if !top.init {
indices[v] = indexCounter
lowlinks[v] = indexCounter
indexCounter++
stack = append(stack, v)
onStack[v] = true
top.init = true
}
progressed := false
for top.i < len(callees[v]) {
w := callees[v][top.i]
top.i++
if indices[w] == unvisited {
dfs = append(dfs, frame{v: w})
progressed = true
break
}
if onStack[w] {
if indices[w] < lowlinks[v] {
lowlinks[v] = indices[w]
}
}
}
if progressed {
continue
}
// All successors visited; finalize.
if lowlinks[v] == indices[v] {
var scc []uint32
for {
w := stack[len(stack)-1]
stack = stack[:len(stack)-1]
onStack[w] = false
scc = append(scc, w)
if w == v {
break
}
}
sccs = append(sccs, scc)
}
dfs = dfs[:len(dfs)-1]
if len(dfs) > 0 {
parent := &dfs[len(dfs)-1]
if lowlinks[v] < lowlinks[parent.v] {
lowlinks[parent.v] = lowlinks[v]
}
}
}
}
for v := uint32(0); v < n; v++ {
if indices[v] == unvisited {
visit(v)
}
}
return sccs
}