mirror of
https://github.com/hanzoai/wasm2go.git
synced 2026-10-30 15:06:48 +00:00
* gcasm: fix cross-chunk symbol gaps in the direct-call path Follow-ups to the direct cross-chunk asm calls change (#61): - The direct-asm resolvers (CalleeSymbol / MemHelperSymbol) trimmed calleeSig's leading "·" and let asmgen's goAsmSymbol re-derive the CALL operand. A spelled remote symbol ("github·com∕...∕p2·Fn0") has no ASCII "." for goAsmSymbol to split on, so it was re-wrapped as a current-package reference ("CALL ·github·com∕...(SB)") that can never link — any direct-asm-retained function making a resolved cross-chunk call produced an undefined symbol at consumer link time. The resolver contract now carries the exact plan 9 operand (without the "(SB)" suffix) and asmgen emits it verbatim. - plan9AsmPathSafe accepted digit-leading import paths ("4d63.com/...", "0xacab.org/..."), but the plan 9 asm lexer accepts digits only after the first identifier rune, so the spelled CALL would fail to assemble. The predicate now requires a letter or "_" at position 0. - That predicate existed as two byte-for-byte copies (codegen's isPlan9AsmSafe, gcasm's plan9AsmPathSafe) under a comment claiming an import cycle prevents sharing; no such cycle exists. Both packages now consult one exported asmgen.Plan9AsmPathSafe, so the linkname alias shape and the direct-CALL / gcasmABI0Keep anchor gates can never disagree. - buildPkg now verifies every captured fn leaves the emission loop either with a TEXT body or registered as a pure-Go fallback. A future degrade path that forgets fallbackNames would otherwise surface only as a link error in the consumer build, with no Build-time diagnostic; it is a Build error naming the fn now. The new cg_crosscall_directasm fixture keeps every op native (no rotl/rotr, which lower to helper CALLs the direct-asm emitter's ForbidCalls refuses) so the retained functions actually emit via asmgen; its regression test fails against the pre-fix resolvers with the mangled current-package spelling in the emitted bundle. Co-Authored-By: Claude Fable 5 <[email protected]> * asmgen: restructure the first-rune check as a switch to satisfy staticcheck golangci-lint's staticcheck (QF1001) flagged the negated compound condition in Plan9AsmPathSafe; the positive-switch form matches the per-byte loop below it. No behavior change. Co-Authored-By: Claude Fable 5 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]>
3627 lines
136 KiB
Go
3627 lines
136 KiB
Go
// Package asmgen lowers an ssa.Func to plan9 assembly so the generated
|
||
// body can be compiled by the Go toolchain without ever appearing as
|
||
// Go source on the host's GOARCH. The companion Go file produced
|
||
// alongside the .s contains only the function declaration — go/types
|
||
// reads it in microseconds and golangci-lint's SSA-based analyses
|
||
// have no body to walk. On a GOARCH the asm emitter doesn't target,
|
||
// the project falls back to the original Go-source emitter at
|
||
// build-tag dispatch time.
|
||
//
|
||
// This first cut targets amd64 and supports the opcode set needed by
|
||
// the project's arith / control-flow / memory-op fixtures: integer
|
||
// binary/shift/compare ops at i32 and i64, helper calls (rotl, div_s,
|
||
// rem, eqz, etc.), single- and multi-block CFGs with Plain / If /
|
||
// Ret / Unreachable terminators, OpPhi via predecessor-edge copies,
|
||
// and linear-memory loads/stores via Module.M.
|
||
package asmgen
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/goccy/wasm2go/internal/emit"
|
||
"github.com/goccy/wasm2go/internal/simdfuse"
|
||
"github.com/goccy/wasm2go/internal/ssa"
|
||
"github.com/goccy/wasm2go/internal/wasm"
|
||
)
|
||
|
||
// FuncOptionsFromDriver builds FuncOptions whose Module and naming
|
||
// callbacks delegate to the given emit.Driver. modulePkgRef is the
|
||
// Go-side spelling of the host's *Module parameter type, picked by
|
||
// the caller to match its packaging ("*Module" same-package,
|
||
// "*base.Module" multi-package). HelperPrefix is taken from
|
||
// MultiPackage: "" same-package, "base" multi-package outside base
|
||
// itself (where the helpers live alongside).
|
||
//
|
||
// The Driver returns Go-side qualified names (e.g. "base.Fn42").
|
||
// asmgen translates those into plan9 asm symbols ("base·Fn42(SB)")
|
||
// inside goAsmSymbol — callers don't need to construct .vs·.
|
||
// themselves.
|
||
func FuncOptionsFromDriver(d emit.Driver, modulePkgRef string) FuncOptions {
|
||
prefix := ""
|
||
if d.MultiPackage() {
|
||
prefix = "base"
|
||
}
|
||
return FuncOptions{
|
||
ModulePkgRef: modulePkgRef,
|
||
HelperPrefix: prefix,
|
||
Module: d.Module(),
|
||
FuncSymbol: func(idx uint32) string {
|
||
return d.FuncRefName(idx)
|
||
},
|
||
}
|
||
}
|
||
|
||
// FuncOptions bundles the per-function emit knobs that have a
|
||
// chance of changing per-host (single-package vs multi-package,
|
||
// same-vs-cross chunk callees, etc).
|
||
type FuncOptions struct {
|
||
// ModulePkgRef is the qualified Go expression for the host's
|
||
// `*Module` parameter type — "*Module" for single-package
|
||
// output, "*base.Module" for the multi-package + linkname-split
|
||
// layout. The asm body never dereferences the type; the
|
||
// qualifier is purely a decl-side detail.
|
||
ModulePkgRef string
|
||
// HelperPrefix is the asm-symbol prefix for helper CALLs. Empty
|
||
// string means the helper lives in the same package
|
||
// (CALL ·foo(SB)); "base" means cross-package
|
||
// (CALL base·Foo(SB)).
|
||
HelperPrefix string
|
||
// Module is the parsed wasm module the function belongs to.
|
||
// Used to look up callee signatures during OpCallDirect
|
||
// emission. Required when the function contains direct calls.
|
||
Module *wasm.Module
|
||
// FuncSymbol returns the asm symbol for a direct-call target by
|
||
// wasm function index. The returned string is the bare name
|
||
// (without ·); the emitter wraps it in the appropriate package-
|
||
// qualifier form. nil means "use Fn<idx>" — the default name
|
||
// produced by the codegen translator's multi-package mode.
|
||
FuncSymbol func(funcIdx uint32) string
|
||
// GlobalOffsets is the byte offset of each WASM global within
|
||
// the generated Module struct, indexed by the WASM global index
|
||
// (imported globals at the low indices keep a -1 sentinel — the
|
||
// Module struct doesn't carry them, so OpGlobalGet/Set for those
|
||
// indices still routes through the loadGlobal_N / storeGlobal_N
|
||
// CALL path). When non-nil, OpGlobalGet/OpGlobalSet on
|
||
// module-defined globals lower to an inline MOV against the
|
||
// `*Module` parameter — skipping the wrapper CALL and the
|
||
// caller-side BX clobber that comes with it. ComputeGlobalOffsets
|
||
// produces a slice with the layout this field expects.
|
||
GlobalOffsets []int
|
||
// Exc is the byte offset of each exception-state field within the
|
||
// generated Module struct. When non-nil, the branch-based EH ops
|
||
// (OpExcPending / OpExcTag / OpExcVal / OpExcRaise / OpExcRearm /
|
||
// OpExcClear) lower to inline MOVs against the `*Module`
|
||
// parameter. When nil, any function containing an exc op fails
|
||
// emission (per-function fallback). ComputeExcOffsets produces
|
||
// the layout this field expects; the codegen translator's
|
||
// generated compile-time pins assert it.
|
||
Exc *ExcOffsets
|
||
// Windows lists the fused regions the codegen fusion pass claimed
|
||
// in this function's retained SSA. Emission replaces each window's
|
||
// member per-op splices with one Splicer-provided fused body; a
|
||
// window the plan cannot validate (or a nil Splicer) falls back to
|
||
// per-op emission for its members — never an error.
|
||
Windows []FusedWindow
|
||
// Splicer, when non-nil, supplies inline bodies for SIMD helper
|
||
// call sites (OpSimdCall / OpSimdMemCall) in place of the
|
||
// marshalled CALL. Functions containing SIMD calls then emit in
|
||
// slot-only mode (no register homes, no m-cache, no loop-carry
|
||
// coalesce) because splice bodies clobber registers freely.
|
||
Splicer SimdSplicer
|
||
// CalleeSymbol, when non-nil, resolves a direct-call target to
|
||
// the plan 9 symbol operand to CALL, without the "(SB)" suffix:
|
||
// a package-local "·name" (possibly a host-emitted forward
|
||
// wrapper) or a spelled cross-package "pkg·name" reference. The
|
||
// operand is emitted verbatim — goAsmSymbol is NOT applied, so
|
||
// the host controls the exact spelling. Calls resolved this way
|
||
// are exempt from ForbidCalls — the host guarantees the symbol
|
||
// links in the package the body lands in. ok=false falls back to
|
||
// the FuncSymbol spelling and the ForbidCalls consequence.
|
||
CalleeSymbol func(funcIdx uint32) (string, bool)
|
||
// MemHelperSymbol likewise resolves the memory-op helper family
|
||
// (memorySize / memoryGrow / memoryCopy / memoryFill and their 64
|
||
// variants) to a CALLable symbol operand (same shape as
|
||
// CalleeSymbol: no "(SB)" suffix, emitted verbatim).
|
||
MemHelperSymbol func(name string) (string, bool)
|
||
// PackedParams, when non-nil, marks the packed outlined-boundary
|
||
// form: the Go-side signature carries only the module pointer and
|
||
// the parameter VALUES ride the Module's outline-pack scratch
|
||
// (one uint64 word per scalar, two per v128), in the order given
|
||
// here. The emitter loads them into the params' frame slots in a
|
||
// prologue, and parameter reads resolve to those slots instead of
|
||
// FP offsets. The sig passed to EmitFunc* must then be
|
||
// results-only.
|
||
PackedParams []ssa.Type
|
||
// ForbidCalls makes emission fail for any function that would
|
||
// CALL another symbol (helpers, direct/indirect/import callees,
|
||
// memory ops, global wrappers). Hosts that embed asmgen bodies
|
||
// into a bundle whose callee symbols asmgen cannot resolve (e.g.
|
||
// the gcasm bundle's per-chunk wrapper scheme) set this so such
|
||
// functions fall back to the host's own backend instead of
|
||
// emitting asm that fails to link.
|
||
ForbidCalls bool
|
||
}
|
||
|
||
// arch abstracts the per-architecture pieces of asm emission so the
|
||
// block-iteration, phi-edge-copy, and frame-layout logic can be
|
||
// shared across amd64 and arm64. The interface methods all write
|
||
// into the caller's strings.Builder.
|
||
//
|
||
// The branch / phi / return methods take *ssa.Value (not a pre-
|
||
// resolved operand string) so each arch can resolve operands
|
||
// against its own SP-pseudo-register convention — amd64 plan9
|
||
// uses `(SP)` for stack-slot reads, arm64 uses `(RSP)` — and inline
|
||
// OpConst* / OpParam / OpCopy producers that don't have a slot.
|
||
type arch interface {
|
||
// EmitValue lowers one SSA value to per-arch asm.
|
||
EmitValue(b *strings.Builder, v *ssa.Value, plan *funcPlan, frame argFrame) error
|
||
// EmitJmp emits an unconditional branch to the named label. When
|
||
// `fallthroughLabel` matches `label`, the branch is a no-op (the
|
||
// next block emitted is the target) so nothing is emitted. An
|
||
// empty `fallthroughLabel` disables the optimisation.
|
||
EmitJmp(b *strings.Builder, label, fallthroughLabel string)
|
||
// EmitIfBranch tests cond for non-zero, branches to thenLabel
|
||
// when set, else jumps to elseLabel. When `fallthroughLabel`
|
||
// names one of the two destinations the implementation may skip
|
||
// the JMP to that side (using JCC inversion when the
|
||
// fall-through is the then side) so that control "falls through"
|
||
// to the matching block. An empty `fallthroughLabel` disables
|
||
// the optimisation; arches that ignore the hint produce the
|
||
// classic `JCC thenLabel; JMP elseLabel` pair.
|
||
EmitIfBranch(b *strings.Builder, cond *ssa.Value, thenLabel, elseLabel, fallthroughLabel string, plan *funcPlan, frame argFrame)
|
||
// EmitReturn moves the function's K return values into their
|
||
// FP-relative result locations, then RET.
|
||
EmitReturn(b *strings.Builder, blk *ssa.Block, sig wasm.FuncType, plan *funcPlan, frame argFrame) error
|
||
// EmitUnreachable emits a deliberate trap (UD2 / UNDEF).
|
||
EmitUnreachable(b *strings.Builder)
|
||
// EmitPackedPrologue loads a packed boundary's parameter values
|
||
// from the Module's outline-pack scratch into their frame slots.
|
||
EmitPackedPrologue(b *strings.Builder, f *ssa.Func, plan *funcPlan) error
|
||
// EmitBrTable emits a BlockBrTable dispatch: materialize the i32
|
||
// selector once, compare it against each case list's values
|
||
// branching to that successor's label, and fall to defaultLabel.
|
||
// Correct for any table; emitted as a linear compare chain (the
|
||
// hot asmgen targets carry no br_table, so simplicity wins).
|
||
EmitBrTable(b *strings.Builder, sel *ssa.Value, cases [][]int32, labels []string, defaultLabel string, plan *funcPlan, frame argFrame)
|
||
// EmitPhiCopyValue copies an SSA value into a phi destination
|
||
// slot. Used on each predecessor's outgoing edge.
|
||
EmitPhiCopyValue(b *strings.Builder, src *ssa.Value, dstOff int, t ssa.Type, plan *funcPlan, frame argFrame) error
|
||
// EmitPhiCopySlot copies a staging slot into the real phi slot
|
||
// during the second phase of a staged parallel copy.
|
||
EmitPhiCopySlot(b *strings.Builder, srcOff, dstOff int, t ssa.Type) error
|
||
// EmitPhiCopyValueToReg copies an SSA value into a register that
|
||
// the loop-carry coalesce pass reserved. Called instead of
|
||
// EmitPhiCopyValue on the forward (entry) edge of a coalesced
|
||
// loop, so the loop body sees the right initial value in the
|
||
// reserved register. arm64 returns an error — the coalesce pass
|
||
// only fires behind SupportsRegHome().
|
||
EmitPhiCopyValueToReg(b *strings.Builder, src *ssa.Value, dstReg string, t ssa.Type, plan *funcPlan, frame argFrame) error
|
||
// SkipValue reports whether the arch's operandSrc helpers handle
|
||
// v inline at every consumer, so the materialise instruction in
|
||
// EmitValue would write a slot nobody reads. amd64 returns true
|
||
// for OpConst32/64 (every ALU instruction accepts a 32-bit imm);
|
||
// arm64 returns false (no compact form to spell a 32-bit
|
||
// immediate operand, so the slot must hold the constant).
|
||
SkipValue(v *ssa.Value) bool
|
||
// SupportsRegHome reports whether the arch's operandSrc helpers
|
||
// and per-op emit functions honour the block-local regalloc's
|
||
// plan.regHome. amd64 opted in; arm64 has not yet, so its
|
||
// operandSrcFloat would emit
|
||
// amd64-only X<n> register names that arm64's assembler rejects.
|
||
SupportsRegHome() bool
|
||
// RegHomeEligibleOp narrows the regalloc eligibility list per
|
||
// arch. The default for amd64 is "every op whose producer can
|
||
// write directly to a register" — that's the existing
|
||
// regHomeEligibleOp set. arm64 starts narrower because only
|
||
// some of its per-op emits honour plan.regHome on the write
|
||
// side; the rest still need a slot. Returning false here makes
|
||
// the regalloc skip the value so its slot store survives.
|
||
RegHomeEligibleOp(op ssa.Op) bool
|
||
// GPRegPool returns the GP register pool the block-local regalloc
|
||
// hands out to int / pointer SSA values. The order matters only
|
||
// for determinism — first-fit during linear scan. Each entry
|
||
// must NOT collide with any scratch register the arch's per-op
|
||
// emits use, with MCacheReg() (the function-wide m-pointer
|
||
// cache), or with anything Go's runtime reserves (g, FP, LR).
|
||
// Returning nil disables block-local regalloc for the arch.
|
||
GPRegPool() []string
|
||
// SSERegPool is the float-register counterpart of GPRegPool.
|
||
// Returning nil disables float-side block-local regalloc for the arch.
|
||
SSERegPool() []string
|
||
// EmitMCachePrime stages the function-wide `m` pointer into the
|
||
// arch's mCacheReg. Called from the prologue and again after every
|
||
// real CALL (Go's ABI0 treats every register as caller-save, so
|
||
// the cache is invalidated at every call boundary). Receives the
|
||
// already-arch-resolved register name — the asmgen layer knows
|
||
// which register the arch uses for the cache.
|
||
//
|
||
// archAMD64 emits `MOVQ m+0(FP), <reg>`; archARM64 emits the
|
||
// arm64 `MOVD m+0(FP), <reg>` equivalent.
|
||
EmitMCachePrime(b *strings.Builder, reg string)
|
||
// MCacheReg returns the name of the GP register that stages the
|
||
// function-wide `m` pointer. Returns "" when the arch has not
|
||
// yet enabled the optimisation. amd64 returns "R11";
|
||
// arm64 returns "R4" (caller-save, refreshed after every CALL,
|
||
// not clobbered by any per-op scratch — emitBin uses R0/R1,
|
||
// emitLoad/Store uses R0/R2/R3, callee-save start at R19).
|
||
MCacheReg() string
|
||
// CallArgBias is the SP-relative offset where the OUTGOING call
|
||
// argument area begins in the CALLER's frame. On amd64 the caller
|
||
// stores arg `i` at SP+paramOffsets[i]; on arm64 the first 8 bytes
|
||
// at SP+0 are reserved for the callee's saved-LR slot, so the
|
||
// caller must store arg `i` at SP+8+paramOffsets[i] and read the
|
||
// result at SP+8+resultOffsets[i]. The Go arm64 assembler bakes
|
||
// the same +8 into its FP-relative offset resolution (`m+0(FP)`
|
||
// for a frame=$F function resolves to SP+autosize+8), so caller
|
||
// and callee agree on the address only when both sides include
|
||
// the +8.
|
||
//
|
||
// Returns 0 on amd64, 8 on arm64. The asmgen frame planner adds
|
||
// the bias to maxCallee so the local-slot region starts above
|
||
// the bias-shifted call-arg region.
|
||
CallArgBias() int
|
||
}
|
||
|
||
// EmitFuncAMD64 produces the amd64 plan9 asm body and the matching
|
||
// Go-side declaration for one SSA function.
|
||
//
|
||
// name is the bare identifier used both in the Go decl and as the
|
||
// asm symbol. sig is the wasm function signature. f is the lowered
|
||
// SSA function. opts carries the host-dependent knobs (Module
|
||
// reference type, helper prefix, sibling-function name lookup).
|
||
func EmitFuncAMD64(name string, sig wasm.FuncType, f *ssa.Func, opts FuncOptions) (asm, goDecl string, err error) {
|
||
return emitFunc(name, sig, f, opts, archAMD64{})
|
||
}
|
||
|
||
// EmitFuncARM64 is the arm64 counterpart of EmitFuncAMD64. It
|
||
// produces plan9 arm64 asm and a matching Go decl for the same SSA
|
||
// function. The two emitters share the block-iteration scaffolding
|
||
// (frame plan, phi staging, control-flow walk); only the per-op
|
||
// lowering differs by arch.
|
||
func EmitFuncARM64(name string, sig wasm.FuncType, f *ssa.Func, opts FuncOptions) (asm, goDecl string, err error) {
|
||
return emitFunc(name, sig, f, opts, archARM64{})
|
||
}
|
||
|
||
func emitFunc(name string, sig wasm.FuncType, f *ssa.Func, opts FuncOptions, a arch) (asm, goDecl string, err error) {
|
||
frame, err := computeArgFrame(sig)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("%s: %w", name, err)
|
||
}
|
||
|
||
// constsNeedSlot: archs whose per-op emit re-reads OpConst operands
|
||
// from the slot need Const uses tracked. Probe with a synthetic
|
||
// OpConst32 value — every arch's SkipValue returns the same answer
|
||
// for OpConst32 regardless of which specific value is passed.
|
||
var probe ssa.Value
|
||
probe.Op = ssa.OpConst32
|
||
constsNeedSlot := !a.SkipValue(&probe)
|
||
plan, err := planFunc(f, opts, sig, a.CallArgBias(), constsNeedSlot)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("%s: %w", name, err)
|
||
}
|
||
if opts.ForbidCalls && plan.hasNonSimdCall {
|
||
return "", "", fmt.Errorf("%s: function calls out (%s) and ForbidCalls is set", name, strings.Join(plan.callDescs, "; "))
|
||
}
|
||
plan.gpRegPool = a.GPRegPool()
|
||
plan.sseRegPool = a.SSERegPool()
|
||
plan.regHomeEligibleOpFn = a.RegHomeEligibleOp
|
||
|
||
// m-pointer caching: stage `m` into a function-wide register
|
||
// so every memop / global access / call-site arg-staging that
|
||
// would have done `MOV m+0(FP), Rtmp` reads from the cache
|
||
// register directly. Both archs implement this now — the
|
||
// MOV mnemonic, register name, and per-op read shape are all
|
||
// arch-specific (amd64 stages into R11 via MOVQ, arm64 stages
|
||
// into R4 via MOVD).
|
||
plan.splicer = opts.Splicer
|
||
plan.spliceMode = opts.Splicer != nil && plan.hasSimdCall
|
||
if plan.spliceMode {
|
||
// Splice bodies clobber the default m-cache register and the
|
||
// block-local pools freely (they were written for gc call
|
||
// sites, where everything is dead), so the default m-cache is
|
||
// off. With a hoisting splicer, m and the module state the
|
||
// splices consume ride the splice-safe registers instead:
|
||
// R24 = m (the m-cache mechanism with a safe register),
|
||
// R23/R22 = the splicer's hoist prologue.
|
||
plan.mCacheCandidate = false
|
||
if _, isARM64 := a.(archARM64); isARM64 && plan.hasMem {
|
||
// The hoist prologue reads m off R24, so the two prime
|
||
// together; memory-less functions need neither (their
|
||
// splices have no memory preambles).
|
||
if hoist := opts.Splicer.HoistPrologue(); hoist != "" {
|
||
plan.spliceHoist = hoist
|
||
plan.mCacheReg = "R24"
|
||
}
|
||
}
|
||
}
|
||
if plan.mCacheCandidate {
|
||
plan.mCacheReg = a.MCacheReg()
|
||
}
|
||
|
||
// Hand the function to the block-local regalloc only on archs
|
||
// whose operandSrc / per-op emit code knows how to honour
|
||
// plan.regHome. SupportsRegHome gates this — archAMD64 returns
|
||
// true; archARM64 returns false until its emit side learns the
|
||
// same trick.
|
||
//
|
||
// Frame compaction: once we know which values went to a
|
||
// register, walk the original slot map and reclaim every slot
|
||
// whose only owner is a regHome'd value. The remaining live
|
||
// slots get re-packed densely starting at calleeArea, shrinking
|
||
// plan.frameSize from "one slot per SSA value with a type" to
|
||
// "one slot per actually-spilled value". On the hot integration
|
||
// corpus functions this collapses a 3.7 KB frame to the
|
||
// neighbourhood of what Pure-Go produces (~100 bytes).
|
||
if plan.spliceMode {
|
||
if _, isARM64 := a.(archARM64); isARM64 && plan.fusedAt == nil {
|
||
// Fused windows and the splice coalesce cannot coexist:
|
||
// the fused pool (v16..v30, plus R20-R24 in its prologue)
|
||
// clobbers the coalesce's carry homes, and a coalesced
|
||
// v128 phi never materializes the slot the window's
|
||
// deferred operand reads. Window-bearing bodies keep every
|
||
// carry in slots; the windows themselves carry the hot
|
||
// regions in registers.
|
||
runSpliceCoalescePass(f, plan)
|
||
}
|
||
}
|
||
if a.SupportsRegHome() && !plan.spliceMode {
|
||
// Route through the cross-block Belady allocator's bridge.
|
||
// For functions outside the safe shape
|
||
// (isSafeForNewRegalloc returns false) the bridge falls
|
||
// back to computeRegHomes internally, so the call is
|
||
// correct for every function — the safety gate lives in
|
||
// applyNewRegalloc, not here.
|
||
//
|
||
// The cross-block allocator's stackalloc subsumes
|
||
// compactFrame: applyNewRegalloc runs the
|
||
// interference-aware slot allocator inline and rewrites
|
||
// plan.offsets / plan.frameSize itself. Running
|
||
// compactFrame after would re-shuffle the offsets via
|
||
// its own (simpler) re-packing, which can mis-handle
|
||
// shared slots — compactFrame dedupes by offset without
|
||
// an interference test, so it would treat each
|
||
// shared-slot occupant as a fresh pool entry and grow
|
||
// the frame again. The bridge skips compactFrame when
|
||
// its stackalloc fired; for functions where it didn't
|
||
// (no shrinkage opportunity), compactFrame still runs.
|
||
didStackalloc := applyNewRegalloc(f, plan, a)
|
||
if !didStackalloc {
|
||
compactFrame(f, plan)
|
||
}
|
||
}
|
||
|
||
var b strings.Builder
|
||
// Per-function header comment dropped — the corresponding Go
|
||
// decl in decls_<arch>.go documents the signature and the asm
|
||
// reader can find it from the TEXT line. Across ~40k generated
|
||
// functions × 2 archs this comment alone added ~6MB to the
|
||
// bundle for no semantic value.
|
||
flag := "NOSPLIT"
|
||
if plan.hasCall {
|
||
// Functions that CALL a non-NOSPLIT Go helper can't be
|
||
// NOSPLIT themselves — the runtime's stack-growth check has
|
||
// to run on the way in.
|
||
flag = "0"
|
||
} else if plan.frameSize > 700 {
|
||
// NOSPLIT frames are capped at ~792 bytes (the runtime's
|
||
// stack-overflow trip-wire). Any function whose own frame
|
||
// alone is close to that limit must keep the prologue's
|
||
// growth check so the runtime can extend the stack when
|
||
// needed. A 700-byte cutoff leaves a safety margin for the
|
||
// caller's frame.
|
||
flag = "0"
|
||
}
|
||
fmt.Fprintf(&b, "TEXT ·%s(SB), %s, $%d-%d\n", name, flag, plan.frameSize, frame.argSize)
|
||
// NO_LOCAL_POINTERS is the runtime's "this frame's locals hold
|
||
// no pointers" marker, required for non-NOSPLIT functions so
|
||
// the stack scanner can walk them across a CALL. NOSPLIT
|
||
// functions never trigger stack copy and don't need the
|
||
// marker — emitting it there is just dead bytes.
|
||
if flag != "NOSPLIT" {
|
||
fmt.Fprintf(&b, "\tNO_LOCAL_POINTERS\n")
|
||
}
|
||
// Prime the m-cache register. Subsequent emits that
|
||
// would have read `m+0(FP)` instead read mCacheReg directly,
|
||
// saving one memory load per memop / global access / direct-
|
||
// call arg-staging site. Every CALL invalidates the cache
|
||
// (Go ABI0 = all caller-save) so the emitBlock loop reissues
|
||
// this same MOV after each opEmitsCall site.
|
||
if plan.mCacheReg != "" {
|
||
a.EmitMCachePrime(&b, plan.mCacheReg)
|
||
}
|
||
if plan.spliceHoist != "" {
|
||
b.WriteString(plan.spliceHoist)
|
||
}
|
||
|
||
// Packed boundary: load the parameter values from the Module's
|
||
// outline-pack scratch into their frame slots before any block
|
||
// runs.
|
||
if plan.packed {
|
||
if len(sig.Params) != 0 {
|
||
return "", "", fmt.Errorf("%s: packed emission requires a results-only signature", name)
|
||
}
|
||
if err := a.EmitPackedPrologue(&b, f, plan); err != nil {
|
||
return "", "", fmt.Errorf("%s: packed prologue: %w", name, err)
|
||
}
|
||
}
|
||
|
||
// Per-block fall-through label. `f.Blocks[i+1]`'s label
|
||
// is the natural next destination after `f.Blocks[i]` finishes —
|
||
// emitBlock hands this to the arch's EmitJmp / EmitIfBranch so a
|
||
// terminal JMP / JCC-pair that names the fall-through label can
|
||
// drop the jump. For the last block there is no fall-through, so
|
||
// the hint stays empty and the arch emits the classic shape.
|
||
for i, blk := range f.Blocks {
|
||
fallthroughLabel := ""
|
||
if i+1 < len(f.Blocks) {
|
||
fallthroughLabel = labelFor(f.Blocks[i+1])
|
||
}
|
||
if err := emitBlock(&b, blk, f, plan, sig, frame, a, fallthroughLabel); err != nil {
|
||
return "", "", fmt.Errorf("%s: block %d: %w", name, blk.ID, err)
|
||
}
|
||
}
|
||
|
||
// Shared out-of-bounds stub for spliced SIMD memory ops: their
|
||
// bodies branch to the splicer's trap label; the stub is appended
|
||
// once after the last block (control never returns from it).
|
||
if plan.wantsTrapStub && opts.Splicer != nil {
|
||
b.WriteString(opts.Splicer.TrapStub())
|
||
}
|
||
|
||
goDecl = fmt.Sprintf("//go:noescape\nfunc %s%s\n", name, goSignature(sig, opts.ModulePkgRef))
|
||
// Pipeline order:
|
||
// dedupMemMReload — collapses adjacent `m+0(FP)→reg; reg.M→reg`
|
||
// pairs that the per-op emitter naively re-
|
||
// emits at every memop and global access.
|
||
// peepholeOpt — line-local reductions (skip-reload, LEAQ
|
||
// fold, imm-store fold, fallthrough-JMP
|
||
// drop, adjacent slot forward).
|
||
// regTrackPass — non-adjacent register tracking. Walks the
|
||
// post-peephole asm with a per-register
|
||
// model of which slot's value is currently
|
||
// cached, and rewrites later slot reads to
|
||
// take their source from the still-live
|
||
// register instead of round-tripping to
|
||
// memory. Safe to run last because it never
|
||
// introduces patterns that the earlier
|
||
// passes would have matched.
|
||
// Pipeline:
|
||
// dedupMemMReload — collapse the `m+0(FP) -> R11 -> m.M`
|
||
// pair into a single load when m is the
|
||
// same across uses.
|
||
// peepholeOpt — line-local reductions (skip-reload,
|
||
// LEAQ fold, imm-store fold, fallthrough-
|
||
// JMP drop, adjacent slot forward,
|
||
// adjacent dead-store).
|
||
// regTrackPass — multi-line register-mirror tracking,
|
||
// including the D-2 slot-immediate
|
||
// forwarding that lets a later
|
||
// `MOV{L,Q} slot, reg` be rewritten to
|
||
// `MOV{L,Q} $imm, reg` when the slot
|
||
// was last written by an immediate of
|
||
// matching width.
|
||
// peepholeOpt — run again: regTrackPass's $imm-load
|
||
// rewrites unlock a fresh round of
|
||
// peepholeImmStore folds (the
|
||
// `MOV $imm, reg; MOV reg, slot(SP)`
|
||
// pair collapses to a single
|
||
// `MOV $imm, slot(SP)` and the imm-
|
||
// loaded register is no longer needed).
|
||
// eliminateDeadStoresMultiLine — drop SP-slot writes that
|
||
// the next same-width write to the
|
||
// same slot silently overwrites, with no
|
||
// intervening read. D-1.
|
||
emitted := dedupMemMReload(b.String())
|
||
emitted = peepholeOpt(emitted)
|
||
emitted = regTrackPass(emitted)
|
||
emitted = peepholeOpt(emitted)
|
||
emitted = eliminateDeadStoresMultiLine(emitted)
|
||
return emitted, goDecl, nil
|
||
}
|
||
|
||
// eliminateDeadStoresMultiLine drops stores to stack slots that are
|
||
// silently overwritten by a later store to the SAME slot within the
|
||
// same basic block, with no intervening read of that slot.
|
||
//
|
||
// Extends peepholeDeadStore (which only catches adjacent same-slot
|
||
// stores) to the case where some non-conflicting work — typically an
|
||
// AX-staging load for the next arg of a phi-edge copy, or an unrelated
|
||
// stash through a different slot — sits between the two writes. The
|
||
// SSA phi-edge lowering routinely produces this shape when two phi
|
||
// destinations end up in the same slot via cross-block stackalloc but
|
||
// the per-edge `MOVL src, AX; MOVL AX, dst` copies are issued back-
|
||
// to-back: the load for the second copy clobbers AX between the two
|
||
// store lines, so the older peephole's strict-adjacency match misses
|
||
// the dead store.
|
||
//
|
||
// Scope: one basic block. A label, JMP, conditional branch, CALL or
|
||
// RET flushes the pending-write map — anything that ends a basic
|
||
// block destroys the linear assumption. Inside a block, an SP-slot
|
||
// read (slot used as a source operand of any instruction) clears the
|
||
// pending entry for that slot; non-SP operands are ignored, since
|
||
// stack slots cannot alias them.
|
||
//
|
||
// Conservative: a multi-write store (e.g. MOVQ <hi>:<lo>, slot, which
|
||
// the emitter does not produce) would defeat this; the per-line
|
||
// parser would simply not recognise it and fall through to the "read
|
||
// of all referenced SP slots" branch, leaving pending state intact in
|
||
// the best case.
|
||
func eliminateDeadStoresMultiLine(asm string) string {
|
||
lines := strings.Split(asm, "\n")
|
||
pending := map[string]int{} // slot -> line index of pending dead-candidate write
|
||
pendingInstr := map[string]string{} // slot -> MOVL / MOVQ / MOVSS / MOVSD that wrote it
|
||
dead := map[int]bool{}
|
||
|
||
flush := func() {
|
||
for k := range pending {
|
||
delete(pending, k)
|
||
delete(pendingInstr, k)
|
||
}
|
||
}
|
||
|
||
for i, line := range lines {
|
||
trim := strings.TrimLeft(line, " \t")
|
||
// Block boundaries — anything that can be jumped to or that
|
||
// ends linear control flow flushes the tracking.
|
||
if isLabelLine(trim) {
|
||
flush()
|
||
continue
|
||
}
|
||
if trim == "" || strings.HasPrefix(trim, "//") || strings.HasPrefix(trim, "TEXT ") ||
|
||
strings.HasPrefix(trim, "#") || trim == "NO_LOCAL_POINTERS" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(trim, "JMP ") || strings.HasPrefix(trim, "CALL ") || trim == "RET" {
|
||
flush()
|
||
continue
|
||
}
|
||
if isControlFlowBranch(trim) {
|
||
flush()
|
||
continue
|
||
}
|
||
|
||
// Recognise SP-slot writes: MOV{L,Q,SS,SD} <src>, <slot>(SP).
|
||
instr, src, dst, ok := parseMOV(line)
|
||
if ok && isMemSPOperand(dst) {
|
||
switch instr {
|
||
case "MOVL", "MOVQ", "MOVSS", "MOVSD":
|
||
// src side may reference an SP slot — clear its
|
||
// pending entry first (a read invalidates the
|
||
// dead-candidate).
|
||
if isMemSPOperand(src) {
|
||
delete(pending, src)
|
||
delete(pendingInstr, src)
|
||
}
|
||
// If we already had a pending write to dst of the
|
||
// same width, that previous store is dead — nothing
|
||
// between then and now read it.
|
||
if prevIdx, has := pending[dst]; has && pendingInstr[dst] == instr {
|
||
dead[prevIdx] = true
|
||
}
|
||
pending[dst] = i
|
||
pendingInstr[dst] = instr
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Anything else: every SP-slot reference in this line counts
|
||
// as a read of that slot.
|
||
for _, s := range extractSPSlots(line) {
|
||
delete(pending, s)
|
||
delete(pendingInstr, s)
|
||
}
|
||
}
|
||
|
||
if len(dead) == 0 {
|
||
return asm
|
||
}
|
||
var b strings.Builder
|
||
b.Grow(len(asm))
|
||
first := true
|
||
for i, line := range lines {
|
||
if dead[i] {
|
||
continue
|
||
}
|
||
if !first {
|
||
b.WriteByte('\n')
|
||
}
|
||
first = false
|
||
b.WriteString(line)
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// isLabelLine reports whether the (left-trimmed) line is a label of
|
||
// the `L<n>:` shape that the per-block emit writes.
|
||
func isLabelLine(trim string) bool {
|
||
if !strings.HasSuffix(trim, ":") {
|
||
return false
|
||
}
|
||
if len(trim) < 3 || trim[0] != 'L' {
|
||
return false
|
||
}
|
||
for i := 1; i < len(trim)-1; i++ {
|
||
if trim[i] < '0' || trim[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isControlFlowBranch reports whether trim is a conditional jump
|
||
// (amd64: J<cc>) or the arm64 equivalent (B<cc>, BL, CBZ/CBNZ). These
|
||
// terminate the current straight-line region for the purpose of the
|
||
// dead-store tracker.
|
||
func isControlFlowBranch(trim string) bool {
|
||
if len(trim) < 2 {
|
||
return false
|
||
}
|
||
switch trim[0] {
|
||
case 'J':
|
||
// J<letter>... family (JE, JNE, JLE, JGE, JA, JB, ...).
|
||
return trim[1] >= 'A' && trim[1] <= 'Z'
|
||
case 'B':
|
||
// arm64 B<cc> and CBZ/CBNZ — see isBranchInstr above for the
|
||
// full mnemonic list; here we treat any leading-B mnemonic as
|
||
// a branch.
|
||
return trim[1] >= 'A' && trim[1] <= 'Z'
|
||
}
|
||
return false
|
||
}
|
||
|
||
// extractSPSlots scans line for every `<digits>(SP)` operand and
|
||
// returns the literal slot string ("<digits>(SP)") for each match.
|
||
// Used by the dead-store tracker as a coarse "this line reads N
|
||
// stack slots" probe — every match is treated as a potential read of
|
||
// that slot, so the tracker stays correct even for instructions the
|
||
// MOV parser does not recognise (ADD / SUB / CMP / SHL / TEST / ...).
|
||
func extractSPSlots(line string) []string {
|
||
var result []string
|
||
i := 0
|
||
for i < len(line) {
|
||
if line[i] >= '0' && line[i] <= '9' {
|
||
j := i
|
||
for j < len(line) && line[j] >= '0' && line[j] <= '9' {
|
||
j++
|
||
}
|
||
if j+4 <= len(line) && line[j:j+4] == "(SP)" {
|
||
result = append(result, line[i:j+4])
|
||
i = j + 4
|
||
continue
|
||
}
|
||
i = j
|
||
continue
|
||
}
|
||
i++
|
||
}
|
||
return result
|
||
}
|
||
|
||
// dedupMemMReload drops redundant `MOVQ m+0(FP), BX; MOVQ 32(BX), BX`
|
||
// (amd64) and `MOVD m+0(FP), R0; MOVD 32(R0), R0` (arm64) pairs when
|
||
// the target register still holds m.M from a previous load. The
|
||
// per-value emitter has no shared state, so every load / store
|
||
// emits its own preamble — but between two const-base loads or
|
||
// stores in the same straight-line span the second preamble is
|
||
// dead code (the first preamble's BX / R0 is still live, the
|
||
// `MOVx imm, disp(BX)` store form does not write BX, and no
|
||
// other generated instruction between them touches BX as a
|
||
// destination).
|
||
//
|
||
// Reset conditions for the "register holds m.M" bit:
|
||
//
|
||
// - Label declaration (control-flow merge — predecessors may
|
||
// not have visited the preamble, so the register's value at
|
||
// the label is undefined).
|
||
// - CALL (clobbers all caller-save registers including BX / R0).
|
||
// - JMP / Jcc / B<cond> (control flow leaves; the next block
|
||
// enters via a label that will reset on its own, but resetting
|
||
// on the branch keeps the analysis straight even if a later
|
||
// pass interleaves fall-through code).
|
||
// - Any instruction whose destination operand is exactly the
|
||
// target register (bare BX / R0 — `MOVQ src, BX`, `LEAQ ..., BX`,
|
||
// `ADDQ src, BX`, `MOVD src, R0`, etc.). CMP / TEST do not
|
||
// write either operand and are excluded.
|
||
//
|
||
// The pass is a separate post-step from peepholeOpt because it
|
||
// needs multi-line state — peepholeOpt only looks at adjacent
|
||
// pairs. Output passes through peepholeOpt afterward so any
|
||
// store/reload pairs created in the dedup wake stay covered.
|
||
func dedupMemMReload(asm string) string {
|
||
if asm == "" {
|
||
return asm
|
||
}
|
||
lines := strings.Split(asm, "\n")
|
||
out := make([]string, 0, len(lines))
|
||
bxValid := false
|
||
r0Valid := false
|
||
for i := 0; i < len(lines); i++ {
|
||
line := lines[i]
|
||
// amd64 m.M load pair?
|
||
if i+1 < len(lines) &&
|
||
strings.TrimRight(line, " \t") == "\tMOVQ m+0(FP), BX" &&
|
||
strings.TrimRight(lines[i+1], " \t") == "\tMOVQ 32(BX), BX" {
|
||
if bxValid {
|
||
i++ // skip both
|
||
continue
|
||
}
|
||
out = append(out, line, lines[i+1])
|
||
i++
|
||
bxValid = true
|
||
continue
|
||
}
|
||
// amd64 m.M load via the R11 m-cache?
|
||
// Pattern: `MOVQ 32(R11), BX` (single line). R11 holds the m
|
||
// pointer for the whole function (m-pointer caching), and
|
||
// `+32` is the m.M field offset. Dedup the same way as the
|
||
// two-line `m+0(FP) -> BX -> 32(BX)` pair above: if BX is
|
||
// already known to hold m.M from a previous load and nothing
|
||
// between the loads has clobbered BX or moved control, the
|
||
// reload is dead.
|
||
if strings.TrimRight(line, " \t") == "\tMOVQ 32(R11), BX" {
|
||
if bxValid {
|
||
continue
|
||
}
|
||
out = append(out, line)
|
||
bxValid = true
|
||
continue
|
||
}
|
||
// arm64 m.M load pair?
|
||
if i+1 < len(lines) &&
|
||
strings.TrimRight(line, " \t") == "\tMOVD m+0(FP), R0" &&
|
||
strings.TrimRight(lines[i+1], " \t") == "\tMOVD 32(R0), R0" {
|
||
if r0Valid {
|
||
i++
|
||
continue
|
||
}
|
||
out = append(out, line, lines[i+1])
|
||
i++
|
||
r0Valid = true
|
||
continue
|
||
}
|
||
// arm64 m.M load via the R26 m-cache?
|
||
// Pattern: `MOVD 32(R26), R0`. Same logic as amd64's
|
||
// `MOVQ 32(R11), BX`. The arm64 m-cache reservation places m
|
||
// in R26.
|
||
if strings.TrimRight(line, " \t") == "\tMOVD 32(R26), R0" {
|
||
if r0Valid {
|
||
continue
|
||
}
|
||
out = append(out, line)
|
||
r0Valid = true
|
||
continue
|
||
}
|
||
// State transitions for the current line.
|
||
switch {
|
||
case isAsmLabel(line):
|
||
bxValid = false
|
||
r0Valid = false
|
||
case isAsmCall(line):
|
||
bxValid = false
|
||
r0Valid = false
|
||
case isAsmUnconditionalOrCondBranch(line):
|
||
bxValid = false
|
||
r0Valid = false
|
||
default:
|
||
if writesRegister(line, "BX") {
|
||
bxValid = false
|
||
}
|
||
if writesRegister(line, "R0") {
|
||
r0Valid = false
|
||
}
|
||
}
|
||
out = append(out, line)
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// isAsmLabel reports whether line is a plan9-asm label declaration
|
||
// (e.g. `L42:`). The generated bodies never use indented labels.
|
||
func isAsmLabel(line string) bool {
|
||
if line == "" || line[0] == '\t' || line[0] == ' ' {
|
||
return false
|
||
}
|
||
colon := strings.IndexByte(line, ':')
|
||
if colon <= 0 {
|
||
return false
|
||
}
|
||
for i := 0; i < colon; i++ {
|
||
c := line[i]
|
||
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') &&
|
||
(c < '0' || c > '9') && c != '_' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// isAsmCall reports whether line is a `CALL ...` instruction.
|
||
func isAsmCall(line string) bool {
|
||
s := strings.TrimLeft(line, " \t")
|
||
return strings.HasPrefix(s, "CALL ")
|
||
}
|
||
|
||
// isAsmUnconditionalOrCondBranch reports whether line is a
|
||
// control-flow transfer (JMP / Jcc on amd64, JMP / B<cond> on arm64,
|
||
// CB[N]Z[W] on arm64). RET is not handled here — it never has a
|
||
// successor in the same function body to confuse the state.
|
||
func isAsmUnconditionalOrCondBranch(line string) bool {
|
||
s := strings.TrimLeft(line, " \t")
|
||
if strings.HasPrefix(s, "JMP ") || strings.HasPrefix(s, "JMP\t") {
|
||
return true
|
||
}
|
||
// amd64 conditional jumps: JE, JNE, JLT, JLE, JGT, JGE, JCS, JCC,
|
||
// JLS, JHI, JS, JP, JNP, etc. All start with "J" and are followed
|
||
// by a short condition code then whitespace.
|
||
if len(s) > 1 && s[0] == 'J' && (s[1] >= 'A' && s[1] <= 'Z') {
|
||
// Skip JMP (already handled). Anything else starting "J<X>..."
|
||
// where the rest looks like a JCC mnemonic is a conditional
|
||
// branch.
|
||
sp := strings.IndexAny(s, " \t")
|
||
if sp > 0 && sp <= 5 {
|
||
return true
|
||
}
|
||
}
|
||
// arm64 conditional branches: BEQ, BNE, BLT, BLE, BLO, BLS, etc.
|
||
// Also CBZ, CBNZ, CBZW, CBNZW.
|
||
if strings.HasPrefix(s, "B") && len(s) > 2 {
|
||
sp := strings.IndexAny(s, " \t")
|
||
// Avoid matching unrelated opcodes that start with B
|
||
// (BSF/BSR on amd64). amd64's BSF/BSR have a destination
|
||
// operand; we want to clobber tracking for the operand
|
||
// elsewhere — so we still return false here and let
|
||
// writesRegister catch the BX-destination case. The arm64
|
||
// B<cond> instructions never have BX/R0 as the operand
|
||
// (they branch to labels), so the only confusion is the
|
||
// false-negative on JMP-style — which is fine because
|
||
// writesRegister handles BSF/BSR's clobber.
|
||
if sp > 0 && sp <= 5 {
|
||
switch s[:sp] {
|
||
case "BEQ", "BNE", "BLT", "BLE", "BGT", "BGE",
|
||
"BLO", "BLS", "BHI", "BHS", "BCC", "BCS",
|
||
"BMI", "BPL", "BVC", "BVS",
|
||
"BL", "B":
|
||
return true
|
||
}
|
||
if strings.HasPrefix(s[:sp], "CBZ") || strings.HasPrefix(s[:sp], "CBNZ") {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// writesRegister reports whether line writes the named register as
|
||
// its destination operand. Bare-register destination is detected;
|
||
// memory operands of the form `<disp>(REG)` reference but do not
|
||
// write REG (so e.g. `MOVQ 32(BX), AX` does not clobber BX). Comparison
|
||
// and test instructions (CMP / TEST family) read both operands and
|
||
// are explicitly excluded.
|
||
func writesRegister(line, reg string) bool {
|
||
s := strings.TrimLeft(line, " \t")
|
||
sp := strings.IndexAny(s, " \t")
|
||
if sp < 0 {
|
||
return false
|
||
}
|
||
op := s[:sp]
|
||
switch op {
|
||
case "CMPB", "CMPW", "CMPL", "CMPQ",
|
||
"TESTB", "TESTW", "TESTL", "TESTQ",
|
||
"CMP", "CMN", "CMNW",
|
||
"TST", "TSTW":
|
||
return false
|
||
}
|
||
// arm64 single-source compare-and-branch: CBZ / CBNZ / CBZW /
|
||
// CBNZW — read R0 but don't write.
|
||
if strings.HasPrefix(op, "CBZ") || strings.HasPrefix(op, "CBNZ") {
|
||
return false
|
||
}
|
||
// Strip trailing comment.
|
||
rest := s[sp:]
|
||
if i := strings.Index(rest, "//"); i >= 0 {
|
||
rest = rest[:i]
|
||
}
|
||
rest = strings.TrimRight(rest, " \t")
|
||
if rest == "" {
|
||
return false
|
||
}
|
||
// Last operand is the dst for the two-operand forms emit_*.go
|
||
// produces (MOVQ src, dst), and for single-operand RMW forms
|
||
// (NEG / NOT / INC / DEC / POP) the single operand is dst. The
|
||
// reads-only single-operand forms (PUSH / DIV / IDIV / MUL /
|
||
// IMUL / SETxx / JMP / Jcc / JCC etc.) we either don't emit at
|
||
// all or are caught by the CMP/TEST/CB[N]Z list above.
|
||
parts := strings.Split(rest, ",")
|
||
last := strings.TrimSpace(parts[len(parts)-1])
|
||
return last == reg
|
||
}
|
||
|
||
// peepholeOpt runs a tiny set of arch-agnostic line-level reductions
|
||
// over an emitted asm body. It is line-local — never reorders, never
|
||
// looks past a label, a CALL, or any instruction other than the one
|
||
// it is matching — so it can't change observable behaviour. The
|
||
// reductions remove patterns the emitter produces by construction
|
||
// (every SSA value emits a `MOV reg, slot(SP)` store followed at the
|
||
// next consumer by a `MOV slot(SP), reg` reload, even when the value
|
||
// is still live in the register); removing them shrinks the bundle's
|
||
// machine code without changing semantics.
|
||
//
|
||
// Patterns reduced:
|
||
//
|
||
// 1. Adjacent self-cancelling store/reload pairs, e.g.
|
||
// MOVL AX, 92(SP)
|
||
// MOVL 92(SP), AX ← redundant; value is in AX already
|
||
// Variants: MOVQ, MOVL with any GP register, MOVSS / MOVSD with
|
||
// SSE registers. The store stays; the reload is dropped.
|
||
//
|
||
// 2. amd64 `MOVL $imm, AX; ADDQ AX, BX` collapses to
|
||
// LEAQ imm(BX), BX
|
||
// when imm is a non-negative literal. Negative literals would
|
||
// differ in the upper 32 bits (the MOVL form zero-extends imm to
|
||
// RAX; LEAQ sign-extends the displacement) so we leave those
|
||
// alone.
|
||
func peepholeOpt(asm string) string {
|
||
if asm == "" {
|
||
return asm
|
||
}
|
||
lines := strings.Split(asm, "\n")
|
||
out := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
if n := len(out); n > 0 {
|
||
prev := out[n-1]
|
||
if peepholeSkipReload(prev, line) {
|
||
// Drop `line` — value still live in the register.
|
||
continue
|
||
}
|
||
if rewritten, ok := peepholeSlotForward(prev, line); ok {
|
||
// Replace the slot reload with a register-to-register
|
||
// move so the next consumer reads from the producing
|
||
// register directly instead of round-tripping through
|
||
// memory.
|
||
out = append(out, rewritten)
|
||
continue
|
||
}
|
||
if combined, ok := peepholeLEAQ(prev, line); ok {
|
||
out[n-1] = combined
|
||
continue
|
||
}
|
||
if combined, ok := peepholeLEALReg(prev, line); ok {
|
||
out[n-1] = combined
|
||
continue
|
||
}
|
||
if combined, ok := peepholeImmStore(prev, line); ok {
|
||
out[n-1] = combined
|
||
continue
|
||
}
|
||
if peepholeDeadStore(prev, line) {
|
||
// Replace `prev` (the dead store) with the new
|
||
// store on `line`. Nothing between the two
|
||
// reads the slot, so prev's contribution is
|
||
// invisible.
|
||
out[n-1] = line
|
||
continue
|
||
}
|
||
if peepholeFallthroughJmp(prev, line) {
|
||
// Drop the unconditional jump in `prev`; the label
|
||
// on `line` is its target and immediately follows.
|
||
out[n-1] = line
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, line)
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// peepholeSkipReload reports whether `cur` is a load that exactly
|
||
// undoes the store in `prev`. Matches:
|
||
//
|
||
// prev: \tMOV{L,Q} <REG>, <off>(SP)
|
||
// cur: \tMOV{L,Q} <off>(SP), <REG>
|
||
//
|
||
// and the SSE float pair (MOVSS / MOVSD with X<n>). The store side
|
||
// is left in place — only the reload is dropped — so the value is
|
||
// still observable in memory for any non-adjacent consumer that may
|
||
// follow later in the same function. Anything between the two lines
|
||
// (a label declaration, another instruction, a CALL) breaks the
|
||
// adjacency and the peephole declines to fire.
|
||
func peepholeSkipReload(prev, cur string) bool {
|
||
pInstr, pSrc, pDst, ok := parseMOV(prev)
|
||
if !ok {
|
||
return false
|
||
}
|
||
cInstr, cSrc, cDst, ok := parseMOV(cur)
|
||
if !ok || pInstr != cInstr {
|
||
return false
|
||
}
|
||
// Store form: src is a register, dst is a memory operand.
|
||
// Reload form: src is the same memory operand, dst is the same register.
|
||
return isRegOperand(pSrc) && isMemSPOperand(pDst) && pSrc == cDst && pDst == cSrc
|
||
}
|
||
|
||
// peepholeSlotForward rewrites a `MOV<W> <REG1>, <slot>(SP); MOV<W>
|
||
// <slot>(SP), <REG2>` pair where REG1 != REG2 into the same store
|
||
// followed by a register-to-register move:
|
||
//
|
||
// MOV<W> REG1, slot(SP)
|
||
// MOV<W> REG1, REG2 ← rewritten reload, reads from REG1 not memory
|
||
//
|
||
// The store is kept intact because the slot's value may still be
|
||
// read non-adjacently later in the function (linear-scan slot reuse
|
||
// only retires a slot at its last consumer, so a later block can
|
||
// still read it). Removing only the memory round-trip on the
|
||
// adjacent reload is observable as a reg-to-reg move with no
|
||
// semantic change.
|
||
//
|
||
// Same-register case (REG1 == REG2) is left to peepholeSkipReload
|
||
// which drops the redundant reload entirely.
|
||
func peepholeSlotForward(prev, cur string) (string, bool) {
|
||
pInstr, pSrc, pDst, ok := parseMOV(prev)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
cInstr, cSrc, cDst, ok := parseMOV(cur)
|
||
if !ok || pInstr != cInstr {
|
||
return "", false
|
||
}
|
||
// prev must be a register store; cur must be a load from the
|
||
// same slot into a different register of the same family.
|
||
if !isRegOperand(pSrc) || !isMemSPOperand(pDst) {
|
||
return "", false
|
||
}
|
||
if !isRegOperand(cDst) || !isMemSPOperand(cSrc) {
|
||
return "", false
|
||
}
|
||
if pDst != cSrc {
|
||
return "", false
|
||
}
|
||
if pSrc == cDst {
|
||
// Identical-register reload — peepholeSkipReload will drop
|
||
// `cur` entirely; emitting a reg-to-reg copy from REG1 to
|
||
// REG1 would be a useless instruction.
|
||
return "", false
|
||
}
|
||
// SSE vs general-purpose register families must not mix; the
|
||
// MOVL / MOVQ widths only apply to GP regs, MOVSS / MOVSD to
|
||
// SSE. parseMOV's instr already pins the family so this is
|
||
// just a safety belt for any future width additions.
|
||
if isSSEReg(pSrc) != isSSEReg(cDst) {
|
||
return "", false
|
||
}
|
||
return fmt.Sprintf("\t%s %s, %s", pInstr, pSrc, cDst), true
|
||
}
|
||
|
||
// isSSEReg reports whether s is a plan9 SSE register name.
|
||
func isSSEReg(s string) bool {
|
||
switch s {
|
||
case "X0", "X1", "X2", "X3", "X4", "X5", "X6", "X7":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// peepholeImmStore folds a `MOV{L,Q} $<imm>, AX; MOV{L,Q} AX, <slot>(SP)`
|
||
// sequence into a single `MOV{L,Q} $<imm>, <slot>(SP)` instruction.
|
||
// Plan 9 amd64 supports MOV imm → memory directly for the L width
|
||
// for any 32-bit immediate, and for the Q width only when the
|
||
// immediate fits the sign-extended-int32 range (−2^31 .. 2^31−1) —
|
||
// outside that range the assembler rejects the direct form with
|
||
// "invalid instruction" and the value has to be staged through a
|
||
// register first. The peephole gates the Q form accordingly so we
|
||
// never produce something the assembler rejects.
|
||
//
|
||
// On amd64 this halves the cost of the "store a const to a slot"
|
||
// idiom that the SSA emitter produces in bulk — every zero-init of
|
||
// an unused-but-allocated SSA slot, every constant assignment in
|
||
// generated code, every immediate-base address computation that did
|
||
// not get folded by the LEAQ peephole. Reports the combined line and
|
||
// true if the pattern matched, otherwise returns false.
|
||
func peepholeImmStore(prev, cur string) (string, bool) {
|
||
// prev shape: \tMOV{L,Q} $<imm>, <reg>
|
||
// cur shape: \tMOV{L,Q} <reg>, <off>(SP)
|
||
pInstr, pSrc, pDst, ok := parseMOV(prev)
|
||
if !ok || (pInstr != "MOVL" && pInstr != "MOVQ") {
|
||
return "", false
|
||
}
|
||
if !strings.HasPrefix(pSrc, "$") {
|
||
return "", false
|
||
}
|
||
if !isRegOperand(pDst) {
|
||
return "", false
|
||
}
|
||
cInstr, cSrc, cDst, ok := parseMOV(cur)
|
||
if !ok || cInstr != pInstr {
|
||
return "", false
|
||
}
|
||
if cSrc != pDst || !isMemSPOperand(cDst) {
|
||
return "", false
|
||
}
|
||
if pInstr == "MOVQ" {
|
||
// MOVQ $imm, mem only accepts immediates in
|
||
// sign-extended-int32 range; anything bigger must be
|
||
// staged through a register.
|
||
raw := strings.TrimPrefix(pSrc, "$")
|
||
raw = strings.TrimPrefix(raw, "-") // allow leading "-"
|
||
// Accept only pure decimal digits; reject "0x...", hex, etc.
|
||
// The emitter only produces decimal literals here.
|
||
if raw == "" {
|
||
return "", false
|
||
}
|
||
for i := 0; i < len(raw); i++ {
|
||
if raw[i] < '0' || raw[i] > '9' {
|
||
return "", false
|
||
}
|
||
}
|
||
v, err := strconv.ParseInt(strings.TrimPrefix(pSrc, "$"), 10, 64)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
if v < math.MinInt32 || v > math.MaxInt32 {
|
||
return "", false
|
||
}
|
||
}
|
||
return fmt.Sprintf("\t%s %s, %s", pInstr, pSrc, cDst), true
|
||
}
|
||
|
||
// peepholeDeadStore reports whether `prev` is a store to a stack
|
||
// slot that `cur` (an adjacent store to the SAME slot of the same
|
||
// width) immediately overwrites. The first store is dead because
|
||
// nothing between the two lines can observe its bytes — the slot
|
||
// is written, then re-written, with no read in between.
|
||
//
|
||
// Matches the shapes:
|
||
//
|
||
// prev: \tMOV{L,Q,SS,SD} <src1>, <off>(SP)
|
||
// cur: \tMOV{L,Q,SS,SD} <src2>, <off>(SP)
|
||
//
|
||
// The sources may differ — only the destination slot and the
|
||
// instruction width matter for dead-store detection. The
|
||
// wasm-to-SSA layer's per-edge phi copies regularly emit
|
||
// back-to-back writes to the same slot when cross-block stackalloc
|
||
// shares the slot between multiple phis whose lifetimes don't
|
||
// overlap; the last write is the one any downstream consumer
|
||
// reads, and every earlier write to that slot in the same edge is
|
||
// silently overwritten.
|
||
func peepholeDeadStore(prev, cur string) bool {
|
||
pInstr, _, pDst, ok := parseMOV(prev)
|
||
if !ok {
|
||
return false
|
||
}
|
||
cInstr, _, cDst, ok := parseMOV(cur)
|
||
if !ok || pInstr != cInstr {
|
||
return false
|
||
}
|
||
if !isMemSPOperand(pDst) || pDst != cDst {
|
||
return false
|
||
}
|
||
switch pInstr {
|
||
case "MOVL", "MOVQ", "MOVSS", "MOVSD":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// peepholeFallthroughJmp reports whether `prev` is an unconditional
|
||
// jump to a label that is declared on the very next line. Both arch
|
||
// emitters use the same `\tJMP L<n>` / `\tB L<n>` shape and place the
|
||
// target label on its own line as `L<n>:`. When the two are
|
||
// adjacent, the jump is a fall-through and can be dropped: the
|
||
// branch on `prev` becomes the label line in `cur`. The label is
|
||
// kept (something else may still target it).
|
||
func peepholeFallthroughJmp(prev, cur string) bool {
|
||
var target string
|
||
switch {
|
||
case strings.HasPrefix(prev, "\tJMP L"):
|
||
target = prev[len("\tJMP "):]
|
||
case strings.HasPrefix(prev, "\tB L"):
|
||
target = prev[len("\tB "):]
|
||
default:
|
||
return false
|
||
}
|
||
if target == "" {
|
||
return false
|
||
}
|
||
for i := 1; i < len(target); i++ {
|
||
if target[i] < '0' || target[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return cur == target+":"
|
||
}
|
||
|
||
// peepholeLEAQ folds a `MOVL $<imm>, AX; ADDQ AX, BX` pair into a
|
||
// single `LEAQ <imm>(BX), BX`. Returns the combined line and true if
|
||
// the pattern matched. Only fires when the immediate is a non-
|
||
// negative decimal literal — for negatives, MOVL zero-extends through
|
||
// RAX while LEAQ would sign-extend the displacement, so the two
|
||
// forms diverge and we leave them alone.
|
||
func peepholeLEAQ(prev, cur string) (string, bool) {
|
||
// We accept ANY GP register pair as long as MOV's dst register
|
||
// matches ADD's src register, but in practice the emitter only
|
||
// uses AX as the scratch so we keep the matcher narrow.
|
||
const (
|
||
movPrefix = "\tMOVL $"
|
||
movSuffix = ", AX"
|
||
addLine = "\tADDQ AX, BX"
|
||
)
|
||
if !strings.HasPrefix(prev, movPrefix) || !strings.HasSuffix(prev, movSuffix) {
|
||
return "", false
|
||
}
|
||
if cur != addLine {
|
||
return "", false
|
||
}
|
||
imm := prev[len(movPrefix) : len(prev)-len(movSuffix)]
|
||
if imm == "" || imm[0] == '-' {
|
||
return "", false
|
||
}
|
||
// Reject anything that isn't a plain decimal literal.
|
||
for i := 0; i < len(imm); i++ {
|
||
if imm[i] < '0' || imm[i] > '9' {
|
||
return "", false
|
||
}
|
||
}
|
||
return fmt.Sprintf("\tLEAQ %s(BX), BX", imm), true
|
||
}
|
||
|
||
// peepholeLEALReg folds a `MOV<W> <REG_SRC>, <REG_DST>; <ADD|SUB><W>
|
||
// $<imm>, <REG_DST>` pair into a single `LEA<W> <signed_imm>(<REG_SRC>),
|
||
// <REG_DST>` instruction. The pair fires every time the emitter
|
||
// computes "value + constant" via the canonical regalloc emit path
|
||
// (MOVL src0, home; ADDL src1, home where src1 is a const), saving
|
||
// roughly half of every "base + offset" address build sequence in
|
||
// the wasm load/store path. LEA also has the side-benefit of NOT
|
||
// setting flags — the wasm semantics never read flags out of an
|
||
// integer add, so eliminating the flag write is a free latency
|
||
// improvement on modern OoO cores.
|
||
//
|
||
// Only the REG-SRC form is matched: LEAL's memory operand must use
|
||
// a register base, so a MOV from a slot or an FP-relative param
|
||
// can't be folded directly here. (A MOV-from-slot can be combined
|
||
// AFTER the regtrack pass forwards the slot read to a reg-to-reg
|
||
// move, but that interleaving is left to a future pass.)
|
||
func peepholeLEALReg(prev, cur string) (string, bool) {
|
||
pInstr, pSrc, pDst, ok := parseMOV(prev)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if pInstr != "MOVL" && pInstr != "MOVQ" {
|
||
return "", false
|
||
}
|
||
if !isRegOperand(pSrc) || !isRegOperand(pDst) {
|
||
return "", false
|
||
}
|
||
// cur shape: `\t(ADDL|SUBL|ADDQ|SUBQ) $<imm>, <reg>`
|
||
s := strings.TrimLeft(cur, " \t")
|
||
var op string
|
||
var widthW int // L = 32-bit, Q = 64-bit
|
||
switch {
|
||
case strings.HasPrefix(s, "ADDL $"):
|
||
op = "ADDL"
|
||
widthW = 32
|
||
case strings.HasPrefix(s, "SUBL $"):
|
||
op = "SUBL"
|
||
widthW = 32
|
||
case strings.HasPrefix(s, "ADDQ $"):
|
||
op = "ADDQ"
|
||
widthW = 64
|
||
case strings.HasPrefix(s, "SUBQ $"):
|
||
op = "SUBQ"
|
||
widthW = 64
|
||
default:
|
||
return "", false
|
||
}
|
||
// Match widths: a 32-bit MOV can only fold with a 32-bit add, etc.
|
||
if (widthW == 32) != (pInstr == "MOVL") {
|
||
return "", false
|
||
}
|
||
rest := s[len(op)+len(" $"):]
|
||
comma := strings.Index(rest, ", ")
|
||
if comma < 0 {
|
||
return "", false
|
||
}
|
||
imm := rest[:comma]
|
||
addDst := strings.TrimRight(rest[comma+2:], " \t")
|
||
if addDst != pDst {
|
||
return "", false
|
||
}
|
||
// Imm must parse as a signed decimal literal. LEA's displacement
|
||
// is sign-extended to address width, so we keep the sign verbatim
|
||
// and flip it for SUB.
|
||
if imm == "" {
|
||
return "", false
|
||
}
|
||
signedImm := imm
|
||
if op == "SUBL" || op == "SUBQ" {
|
||
if signedImm[0] == '-' {
|
||
signedImm = signedImm[1:]
|
||
} else {
|
||
signedImm = "-" + signedImm
|
||
}
|
||
}
|
||
// Validate the (possibly-negated) immediate is a clean decimal.
|
||
check := signedImm
|
||
if check[0] == '-' {
|
||
check = check[1:]
|
||
}
|
||
if check == "" {
|
||
return "", false
|
||
}
|
||
for i := 0; i < len(check); i++ {
|
||
if check[i] < '0' || check[i] > '9' {
|
||
return "", false
|
||
}
|
||
}
|
||
leaOp := "LEAL"
|
||
if widthW == 64 {
|
||
leaOp = "LEAQ"
|
||
}
|
||
return fmt.Sprintf("\t%s %s(%s), %s", leaOp, signedImm, pSrc, pDst), true
|
||
}
|
||
|
||
// parseMOV decomposes a "\tMOV<suffix> <src>, <dst>" line. Returns
|
||
// the bare opcode (e.g. "MOVL"), the source operand, the destination
|
||
// operand, and whether the line matched the shape. Anything that
|
||
// isn't an indented MOV with exactly one ", " separating two
|
||
// operands is rejected, which is sufficient to keep peephole
|
||
// matching to the emitter's canonical output.
|
||
func parseMOV(line string) (instr, src, dst string, ok bool) {
|
||
if len(line) < 5 || line[0] != '\t' {
|
||
return "", "", "", false
|
||
}
|
||
body := line[1:]
|
||
// Match the four MOV opcodes the emitter produces.
|
||
var prefix string
|
||
switch {
|
||
case strings.HasPrefix(body, "MOVL "):
|
||
prefix = "MOVL"
|
||
case strings.HasPrefix(body, "MOVQ "):
|
||
prefix = "MOVQ"
|
||
case strings.HasPrefix(body, "MOVSS "):
|
||
prefix = "MOVSS"
|
||
case strings.HasPrefix(body, "MOVSD "):
|
||
prefix = "MOVSD"
|
||
default:
|
||
return "", "", "", false
|
||
}
|
||
rest := body[len(prefix)+1:]
|
||
i := strings.Index(rest, ", ")
|
||
if i < 0 {
|
||
return "", "", "", false
|
||
}
|
||
return prefix, rest[:i], rest[i+2:], true
|
||
}
|
||
|
||
func isRegOperand(s string) bool {
|
||
switch s {
|
||
case "AX", "BX", "CX", "DX", "SI", "DI", "BP", "R8", "R9", "R10", "R11", "R12", "R13", "R14", "R15":
|
||
return true
|
||
case "X0", "X1", "X2", "X3", "X4", "X5", "X6", "X7":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isMemSPOperand(s string) bool {
|
||
// "<digits>(SP)" — exactly the stack-slot syntax the emitter
|
||
// produces for every SSA-value store. Negative offsets are
|
||
// rejected; the frame planner never emits them.
|
||
if !strings.HasSuffix(s, "(SP)") {
|
||
return false
|
||
}
|
||
core := s[:len(s)-len("(SP)")]
|
||
if core == "" {
|
||
return false
|
||
}
|
||
for i := 0; i < len(core); i++ {
|
||
if core[i] < '0' || core[i] > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// bitTestInfo carries the operand and bit position for
|
||
// a `(base & 1<<bit) <eq/ne> 0` test that was lowered to a single
|
||
// BTL/BTQ instruction by the BlockIf-control fusion. EmitIfBranch
|
||
// reads it back from plan.branchFusedBit at the matching cond ID.
|
||
type bitTestInfo struct {
|
||
base *ssa.Value
|
||
bit int
|
||
}
|
||
|
||
// isZeroConst reports whether v is an OpConst{32,64} (resolving
|
||
// through OpCopy) whose value is exactly 0. Used by the bit-test
|
||
// fusion to recognise `... <eq/ne> 0` shapes.
|
||
func isZeroConst(v *ssa.Value) bool {
|
||
v = resolveCopy(v)
|
||
if v == nil {
|
||
return false
|
||
}
|
||
switch v.Op {
|
||
case ssa.OpConst32, ssa.OpConst64:
|
||
return v.AuxInt == 0
|
||
}
|
||
return false
|
||
}
|
||
|
||
// powerOfTwoBit reports whether v is an OpConst{32,64} whose value
|
||
// is exactly 1<<bit for some 0 <= bit < W (W = 32 or 64 depending on
|
||
// the `is64` flag). Returns the bit position when so. Negative
|
||
// constants and zero return false.
|
||
func powerOfTwoBit(v *ssa.Value, is64 bool) (int, bool) {
|
||
v = resolveCopy(v)
|
||
if v == nil {
|
||
return 0, false
|
||
}
|
||
switch v.Op {
|
||
case ssa.OpConst32:
|
||
if !is64 {
|
||
x := uint32(v.AuxInt)
|
||
if x == 0 || x&(x-1) != 0 {
|
||
return 0, false
|
||
}
|
||
for i := 0; i < 32; i++ {
|
||
if x>>i == 1 {
|
||
return i, true
|
||
}
|
||
}
|
||
}
|
||
case ssa.OpConst64:
|
||
if is64 {
|
||
x := uint64(v.AuxInt)
|
||
if x == 0 || x&(x-1) != 0 {
|
||
return 0, false
|
||
}
|
||
for i := 0; i < 64; i++ {
|
||
if x>>i == 1 {
|
||
return i, true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
// passthroughTarget walks an empty BlockPlain chain and
|
||
// returns the first block in the chain that is NOT a pure pass-through.
|
||
// A pass-through is a BlockPlain with no emittable values (only Phis
|
||
// or inlineable values, which produce no asm), exactly one successor,
|
||
// and no phi edge-copies to run on its outgoing edge. Branching to
|
||
// such a block is observationally identical to branching to its
|
||
// successor, so this lets the caller name the chain's effective
|
||
// destination in JMP / JCC instructions. The intermediate block stays
|
||
// in the asm as an (now-dead) label.
|
||
//
|
||
// wasm-to-SSA emits a 4-block "diamond" for `if x { foo }` (no else
|
||
// clause) — `b_test`, `b_then` (the body), `b_else` (empty, jumps
|
||
// straight to merge), `b_merge` — and the lone JMP coming out of the
|
||
// BlockIf would unconditionally route through the empty `b_else`.
|
||
// Resolving `b_else` → `b_merge` here turns that into a direct branch
|
||
// to `b_merge`, which combined with the fall-through hint lets the
|
||
// arch invert the JCC and drop the JMP entirely.
|
||
//
|
||
// A small hop limit (8) catches degenerate cases (cyclic empty chains
|
||
// shouldn't exist in well-formed SSA but we guard anyway).
|
||
func passthroughTarget(blk *ssa.Block, plan *funcPlan) *ssa.Block {
|
||
cur := blk
|
||
for hop := 0; hop < 8; hop++ {
|
||
if cur == nil || cur.Kind != ssa.BlockPlain || len(cur.Succs) != 1 {
|
||
break
|
||
}
|
||
if !blockIsPassthrough(cur, plan) {
|
||
break
|
||
}
|
||
next := cur.Succs[0].Block
|
||
if next == cur {
|
||
break
|
||
}
|
||
// Don't redirect across an edge that needs phi edge-copies —
|
||
// those copies live in the pass-through block's emit (called
|
||
// from its BlockPlain terminator) and would be lost if the
|
||
// branch skipped the block.
|
||
if plan.hasPhi[next.ID] {
|
||
break
|
||
}
|
||
cur = next
|
||
}
|
||
return cur
|
||
}
|
||
|
||
// blockIsPassthrough reports whether `blk` has no emittable values —
|
||
// every value is either a Phi (slot placeholder, no body emitted), an
|
||
// inlineable Op (operandSrc resolves it at consumer sites), or — when
|
||
// regHome decided it could stay in a register — a value whose arch
|
||
// emitter chose to skip. Frame compaction has already reclaimed regHome'd
|
||
// slots, so a Phi-only block that started life as an "empty else"
|
||
// in the source emits nothing here.
|
||
func blockIsPassthrough(blk *ssa.Block, plan *funcPlan) bool {
|
||
if plan.hasPhi[blk.ID] {
|
||
// Block has phis to emit on its incoming edges; even if its
|
||
// body is otherwise empty we'd lose those edge-copies by
|
||
// skipping it. Conservatively keep the block as a real target.
|
||
return false
|
||
}
|
||
for _, v := range blk.Values {
|
||
if v.Op == ssa.OpPhi {
|
||
continue
|
||
}
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// emitBlock emits one block: its label, its values, the phi
|
||
// edge-copies to each successor, and the terminator. The arch
|
||
// argument supplies the per-architecture asm strings.
|
||
//
|
||
// `fallthroughLabel` (when non-empty) names the label of the block
|
||
// that emits immediately after this one — the natural fall-through
|
||
// destination. emitBlock passes it down to EmitJmp / EmitIfBranch so a
|
||
// terminal jump to that label can be omitted (or a JCC inverted to put
|
||
// the fall-through side on the no-jump path). It also drives the
|
||
// passthrough-redirection logic for empty BlockPlain successors.
|
||
func emitBlock(b *strings.Builder, blk *ssa.Block, f *ssa.Func, plan *funcPlan, sig wasm.FuncType, frame argFrame, a arch, fallthroughLabel string) error {
|
||
fmt.Fprintf(b, "%s:\n", labelFor(blk))
|
||
|
||
for _, v := range blk.Values {
|
||
if v.Op == ssa.OpPhi {
|
||
// Phi nodes are just slot placeholders; the copies are
|
||
// emitted on the predecessor edges.
|
||
continue
|
||
}
|
||
// Arch-specific skip: amd64's operandSrc inlines OpConst as
|
||
// `$imm` at every consumer, so the materialise to the slot
|
||
// is dead. arm64 cannot encode arbitrary 32-bit immediates
|
||
// in the MOV form used most call sites, so it still needs
|
||
// the materialise — its SkipValue returns false for Const.
|
||
if a.SkipValue(v) {
|
||
continue
|
||
}
|
||
// branchFusedSkip holds the SUPPORT values that the
|
||
// BlockIf control fusion absorbs (e.g. the OpAnd32 feeding an
|
||
// OpNe32 in the `v & K != 0` bit-test pattern). The cond
|
||
// itself is also in branchFused so its own emit short-
|
||
// circuits; this map covers the "produces a flag the cond
|
||
// reads, no slot store needed" case for the support op.
|
||
if plan.branchFusedSkip[v.ID] {
|
||
continue
|
||
}
|
||
if err := a.EmitValue(b, v, plan, frame); err != nil {
|
||
return fmt.Errorf("v%d (%s): %w", v.ID, v.Op, err)
|
||
}
|
||
// A callee can have triggered memory.grow which moves the
|
||
// memory backing array and refreshes m.M on the Module —
|
||
// so any CALL invalidates the cached m.M slot. Refresh
|
||
// here unless this function never touches memory.
|
||
//
|
||
// Every real CALL clobbers mCacheReg (caller-save).
|
||
// Re-prime it so the next memop / global access / arg
|
||
// staging keeps using the cached register. Skip the refresh
|
||
// for ops whose emit produced no CALL: globalInline lowers
|
||
// OpGlobalGet/Set to a plain MOV against the Module struct,
|
||
// and branchFused values' helper emit is short-circuited at
|
||
// the source site (the BlockIf terminator inlines the
|
||
// compare). Without these guards every inlined access would
|
||
// cost the refresh pair it was supposed to save.
|
||
// plan.emittedCall is set by exactly the emit paths that
|
||
// produced a real CALL (marshalled helpers, direct/indirect/
|
||
// import callees, memory-op helpers, unspliced SIMD calls).
|
||
// Inline lowerings — spliced SIMD, branch-fused compares,
|
||
// inlined globals and helpers, the div/rem family (whose trap
|
||
// CALLs never return) — leave it unset, so the refresh cost
|
||
// lands only where a callee could actually have clobbered
|
||
// the cached state.
|
||
if plan.emittedCall {
|
||
plan.emittedCall = false
|
||
if plan.mCacheReg != "" {
|
||
a.EmitMCachePrime(b, plan.mCacheReg)
|
||
}
|
||
if plan.spliceHoist != "" {
|
||
b.WriteString(plan.spliceHoist)
|
||
}
|
||
}
|
||
}
|
||
|
||
switch blk.Kind {
|
||
case ssa.BlockPlain:
|
||
if len(blk.Succs) != 1 {
|
||
return fmt.Errorf("BlockPlain expects 1 successor, got %d", len(blk.Succs))
|
||
}
|
||
// Phi edge-copies for the (single) outgoing edge — must run
|
||
// BEFORE the jump so the successor sees the right phi values.
|
||
succ := blk.Succs[0]
|
||
if err := emitPhiEdgeCopies(b, blk, succ.Block, succ.Index, plan, frame, a); err != nil {
|
||
return err
|
||
}
|
||
// Redirect through empty pass-through blocks. The
|
||
// emit path will still emit the (now-dead) intermediate label,
|
||
// but no JMP references it after redirection. If the resolved
|
||
// label IS the fall-through, EmitJmp drops the jump entirely.
|
||
succLabel := labelFor(passthroughTarget(succ.Block, plan))
|
||
a.EmitJmp(b, succLabel, fallthroughLabel)
|
||
case ssa.BlockIf:
|
||
if len(blk.Succs) != 2 || blk.Control == nil {
|
||
return fmt.Errorf("BlockIf needs 2 successors and a Control value")
|
||
}
|
||
thenSucc := blk.Succs[0]
|
||
elseSucc := blk.Succs[1]
|
||
// Passthrough redirection. When the immediate
|
||
// successor is a no-value BlockPlain that just jumps to its
|
||
// own successor (and there is no phi work on its outgoing
|
||
// edge), we can branch straight to the chain's effective
|
||
// target — the intermediate block stays in the asm as a
|
||
// dead label, but the branch instruction names the real
|
||
// destination. This collapses the empty-`else` pattern that
|
||
// wasm-to-SSA emits for `if x { foo }` (no else clause).
|
||
thenLabel := labelFor(passthroughTarget(thenSucc.Block, plan))
|
||
elseLabel := labelFor(passthroughTarget(elseSucc.Block, plan))
|
||
// CRITICAL: phi edge-copies must be per-branch, not block-end.
|
||
// A BlockIf has two outgoing edges; the phi values destined for
|
||
// each successor differ (per the phi.Args[predIdx] selection).
|
||
// Emitting both edges' copies unconditionally at block end
|
||
// would overwrite each other AND clobber the current block's
|
||
// own phi-targets (e.g. a self-loop where the loop header
|
||
// phi reads v62, and one edge has phi.next = v_loaded; emitting
|
||
// the back-edge copy before testing the loop condition mutates
|
||
// v62 even when the fall-through is taken). Pure-Go emits this
|
||
// correctly by placing phi assigns INSIDE the if/else branches.
|
||
// We mirror that by intercepting the branch and emitting the
|
||
// edge copies on the side that's actually taken.
|
||
thenHasPhi := plan.hasPhi[thenSucc.Block.ID]
|
||
elseHasPhi := plan.hasPhi[elseSucc.Block.ID]
|
||
if !thenHasPhi && !elseHasPhi {
|
||
// No phis to copy on either edge — direct branch is fine.
|
||
// Pass the fall-through hint so the arch can drop the
|
||
// JMP / invert the JCC when the natural-next-block is
|
||
// the same as one of the branch targets.
|
||
a.EmitIfBranch(b, blk.Control, thenLabel, elseLabel, fallthroughLabel, plan, frame)
|
||
break
|
||
}
|
||
// Insert intermediate labels for the branches that need phi
|
||
// copies. The condition test branches to the intermediate,
|
||
// which copies phis then jumps to the real successor.
|
||
// Pass an empty fall-through hint to the arch — the PHI_<id>
|
||
// intermediates emit immediately after the conditional, so
|
||
// neither side is a natural fall-through and we want the
|
||
// classic `JCC then; JMP else` shape.
|
||
thenJmp := thenLabel
|
||
elseJmp := elseLabel
|
||
var thenInter, elseInter string
|
||
if thenHasPhi {
|
||
thenInter = fmt.Sprintf("PHI_%d_then", blk.ID)
|
||
thenJmp = thenInter
|
||
}
|
||
if elseHasPhi {
|
||
elseInter = fmt.Sprintf("PHI_%d_else", blk.ID)
|
||
elseJmp = elseInter
|
||
}
|
||
a.EmitIfBranch(b, blk.Control, thenJmp, elseJmp, "", plan, frame)
|
||
if elseHasPhi {
|
||
fmt.Fprintf(b, "%s:\n", elseInter)
|
||
if err := emitPhiEdgeCopies(b, blk, elseSucc.Block, elseSucc.Index, plan, frame, a); err != nil {
|
||
return err
|
||
}
|
||
a.EmitJmp(b, elseLabel, "")
|
||
}
|
||
if thenHasPhi {
|
||
fmt.Fprintf(b, "%s:\n", thenInter)
|
||
if err := emitPhiEdgeCopies(b, blk, thenSucc.Block, thenSucc.Index, plan, frame, a); err != nil {
|
||
return err
|
||
}
|
||
a.EmitJmp(b, thenLabel, "")
|
||
}
|
||
case ssa.BlockRet:
|
||
if err := a.EmitReturn(b, blk, sig, plan, frame); err != nil {
|
||
return err
|
||
}
|
||
case ssa.BlockUnreachable:
|
||
a.EmitUnreachable(b)
|
||
case ssa.BlockBrTable:
|
||
if blk.Control == nil || len(blk.Succs) == 0 {
|
||
return fmt.Errorf("BlockBrTable needs a selector and successors")
|
||
}
|
||
// Per-edge phi copies would need one intermediate label per
|
||
// successor; no interpreter-dispatch shape produces them, so
|
||
// reject and let the per-function fallback keep the gc body.
|
||
for _, s := range blk.Succs {
|
||
if plan.hasPhi[s.Block.ID] {
|
||
return fmt.Errorf("BlockBrTable successor L%d carries phis", s.Block.ID)
|
||
}
|
||
}
|
||
labels := make([]string, len(blk.Succs))
|
||
for i, s := range blk.Succs {
|
||
labels[i] = labelFor(passthroughTarget(s.Block, plan))
|
||
}
|
||
if blk.TableDefault < 0 || blk.TableDefault >= len(labels) {
|
||
return fmt.Errorf("BlockBrTable default %d out of range", blk.TableDefault)
|
||
}
|
||
a.EmitBrTable(b, blk.Control, blk.TableCases, labels, labels[blk.TableDefault], plan, frame)
|
||
default:
|
||
return fmt.Errorf("unsupported block kind %v", blk.Kind)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// emitPhiEdgeCopies emits MOV instructions that copy each phi's
|
||
// pred-side operand to the phi's slot. When the staged-phi analysis
|
||
// marked the successor's phis as needing temps, edge copies go via
|
||
// per-phi scratch slots to break parallel-assignment cycles (back-
|
||
// edge swap hazard). The MOV itself is per-arch (delegated to
|
||
// arch.EmitPhiCopy).
|
||
func emitPhiEdgeCopies(b *strings.Builder, pred, succ *ssa.Block, predIdx int, plan *funcPlan, frame argFrame, a arch) error {
|
||
if !plan.hasPhi[succ.ID] {
|
||
return nil
|
||
}
|
||
staged := plan.staged[succ.ID]
|
||
// Pass 1: write each phi's incoming value to either the phi's
|
||
// real slot (no staging needed) or to its staging slot.
|
||
//
|
||
// Loop-carry coalesce short-circuit: when the phi destination has
|
||
// a regHome (set only by the loop-carry coalesce pass —
|
||
// block-local regalloc never sets a regHome on a phi) AND the src
|
||
// value resolves to the same
|
||
// register, the value is already in the register from the
|
||
// producer's emit and the next iteration's reads pick it up
|
||
// via operandSrc. Emit nothing in that case. This is the actual
|
||
// "loop carry lives in a register across iterations" payoff.
|
||
for _, phi := range plan.phisOf[succ.ID] {
|
||
if predIdx >= len(phi.Args) {
|
||
return fmt.Errorf("phi v%d in block %d has no arg for pred index %d", phi.ID, succ.ID, predIdx)
|
||
}
|
||
src := phi.Args[predIdx]
|
||
if src == nil {
|
||
return fmt.Errorf("phi v%d arg %d is nil", phi.ID, predIdx)
|
||
}
|
||
if reg := plan.coalescedPhi[phi.ID]; reg != "" {
|
||
actual := resolveCopy(src)
|
||
if actual != nil && plan.regHome[actual.ID] == reg {
|
||
// Coalesced loop carry — src already lives in the
|
||
// phi's reserved register. No copy needed.
|
||
continue
|
||
}
|
||
// Forward (non-back-edge) entry into the loop. Copy the
|
||
// source into the phi's reserved register so the loop
|
||
// body sees the right initial value. Delegated to the
|
||
// arch — only amd64 implements coalescing today; arm64
|
||
// never sees plan.coalescedPhi populated because the
|
||
// coalesce pass runs behind arch.SupportsRegHome.
|
||
if err := a.EmitPhiCopyValueToReg(b, src, reg, phi.Type, plan, frame); err != nil {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
var dstOff int
|
||
if staged {
|
||
dstOff = plan.phiTemp[phi.ID]
|
||
} else {
|
||
dstOff = plan.offsets[phi.ID]
|
||
}
|
||
if err := a.EmitPhiCopyValue(b, src, dstOff, phi.Type, plan, frame); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Pass 2: when staged, copy each temp into its phi's real slot.
|
||
// Coalesced phis skip this — their Pass 1 wrote
|
||
// directly to the reserved register OR was a no-op, neither of
|
||
// which produced a staging-slot value to copy out of. Touching
|
||
// the temp slot here would read uninitialised bytes and clobber
|
||
// the phi's real slot for any downstream consumer that still
|
||
// goes through it.
|
||
if staged {
|
||
for _, phi := range plan.phisOf[succ.ID] {
|
||
if plan.coalescedPhi[phi.ID] != "" {
|
||
continue
|
||
}
|
||
if err := a.EmitPhiCopySlot(b, plan.phiTemp[phi.ID], plan.offsets[phi.ID], phi.Type); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func labelFor(b *ssa.Block) string { return fmt.Sprintf("L%d", b.ID) }
|
||
|
||
// opEmitsCall reports whether emitting v results in a runtime
|
||
// CALL instruction (helper, direct, indirect, import, global
|
||
// accessor, memory builtin). Used to schedule m.M cache
|
||
// refreshes — any callee may have invoked memory.grow.
|
||
func opEmitsCall(op ssa.Op) bool {
|
||
switch op {
|
||
case ssa.OpHelperCall,
|
||
ssa.OpCallDirect, ssa.OpCallImport, ssa.OpCallIndirect,
|
||
ssa.OpGlobalGet, ssa.OpGlobalSet,
|
||
ssa.OpMemSize, ssa.OpMemGrow,
|
||
ssa.OpMemoryCopy, ssa.OpMemoryFill,
|
||
// SIMD helper calls are real CALLs unless a splicer inlines
|
||
// them; consumers that model call clobbers (m-cache
|
||
// re-prime, loop-carry coalesce) must treat them as such.
|
||
// The splice-mode coalesce pass exempts them explicitly and
|
||
// enforces the splice at emit time (plan.mustSplice).
|
||
ssa.OpSimdCall, ssa.OpSimdMemCall:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// resolveSlotArg walks OpCopy chains and returns the underlying
|
||
// value if (a) the chain terminates and (b) the terminal value is
|
||
// one whose slot a downstream operandSrc{32,64,Float} would read
|
||
// — i.e. not an OpConst{32,64,F32,F64} or an OpParam (those have
|
||
// no consumer-visible slot read; constants inline as $imm and
|
||
// parameters resolve to FP+offset). Returns nil otherwise so
|
||
// callers can skip the use cleanly.
|
||
//
|
||
// Mirrors emit_amd64.go's resolveCopy / operandSrc resolution so
|
||
// the Pass 2 lifetime analysis agrees with what the emitter will
|
||
// actually access. Without this, an OpCopy-aliased use was being
|
||
// charged to the OpCopy's index instead of the underlying
|
||
// value's real reader, causing the linear-scan slot reuse to
|
||
// reclaim the underlying's slot before its last reader ran.
|
||
// arm64ReuseUnsafe reports whether v.Op should be excluded from
|
||
// slot reuse on archs where arch.SkipValue(OpConst32) is
|
||
// false (currently arm64 only). The bisect proceeds by starting
|
||
// with EVERY op marked unsafe and re-enabling op classes one
|
||
// group at a time, running a real-module spec suite after each.
|
||
// Each entry in the unsafe set is therefore a "haven't checked
|
||
// yet" / "verified to break" marker — once resolveSlotArg learns
|
||
// the corresponding use site, the entry here can be removed.
|
||
//
|
||
// The amd64 path doesn't consult this — its operandSrc inlines
|
||
// OpConst32 as $imm, OpParam as FP-relative, and OpCopy walks
|
||
// through, so the existing exclude list in Pass 2 is already
|
||
// complete for amd64.
|
||
func arm64ReuseUnsafe(op ssa.Op) bool {
|
||
// Bisect bucket A — arithmetic / cmp / shift / bit ops.
|
||
// Currently every op except Const32 is in this bucket; the
|
||
// bisect log will halve this list once a failing test set is
|
||
// in hand.
|
||
switch op {
|
||
case ssa.OpConst32:
|
||
return false // verified by recursive_init.wasm fix
|
||
// Verified safe by bisect against a real-module spec suite:
|
||
// enabling these ops for arm64 slot reuse keeps every
|
||
// downstream test green. Each op's emit shape on arm64
|
||
// matches what the lifetime analyzer can model (the consumer
|
||
// reads the operand slot at the same SSA index that
|
||
// resolveSlotArg records).
|
||
// i32 ops (size 4) — the only width Pass 6 currently reuses
|
||
// (the `if size >= 8` gate excludes i64/f64 slots). Every op
|
||
// here has had its arm64 emit shape audited and runs the
|
||
// spec suite clean.
|
||
case ssa.OpAdd32, ssa.OpSub32, ssa.OpMul32,
|
||
ssa.OpAnd32, ssa.OpOr32, ssa.OpXor32,
|
||
ssa.OpShl32, ssa.OpShrU32, ssa.OpShrS32,
|
||
ssa.OpEq32, ssa.OpNe32,
|
||
ssa.OpLtS32, ssa.OpLeS32, ssa.OpLtU32, ssa.OpLeU32,
|
||
ssa.OpEq64, ssa.OpNe64,
|
||
ssa.OpLtS64, ssa.OpLeS64, ssa.OpLtU64, ssa.OpLeU64,
|
||
ssa.OpTrunc64To32, ssa.OpCopy:
|
||
return false
|
||
// Arithmetic (Add/Sub/Mul) on arm64 stayed UNSAFE through
|
||
// the initial slot-reuse rollout because enabling slot reuse
|
||
// for any of them triggered a "wasm trap: interface
|
||
// conversion: nil func" in the deep call_indirect chain
|
||
// (p9.Fn15018 -> p9.Fn37659). After the block-local regalloc was
|
||
// extended to cover these ops on arm64, the *typical*
|
||
// arithmetic value lives in a register and never gets a
|
||
// slot, so the failing reuse path almost never fires. The
|
||
// underlying lifetime-tracking miss in Pass 6 still exists
|
||
// for the cross-block / cross-call arith result case, so
|
||
// keeping these in the unsafe set is a correctness choice:
|
||
// the regalloc has dropped them down to the cases that
|
||
// ALWAYS need a slot anyway, and reuse there is the part
|
||
// that breaks. The slot-allocation delta is small enough
|
||
// (block-local regalloc picks up the bulk of them) that the
|
||
// correctness margin is the better trade.
|
||
}
|
||
return true
|
||
}
|
||
|
||
func resolveSlotArg(v *ssa.Value, constsNeedSlot bool) *ssa.Value {
|
||
for i := 0; v != nil && v.Op == ssa.OpCopy && len(v.Args) == 1 && v.Args[0] != nil && v.Args[0] != v && i < 16; i++ {
|
||
v = v.Args[0]
|
||
}
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
switch v.Op {
|
||
case ssa.OpConst32, ssa.OpConst64, ssa.OpConstF32, ssa.OpConstF64:
|
||
// amd64 inlines these as `$imm`, so the slot has no consumer
|
||
// and there is nothing to lifetime-track — return nil.
|
||
//
|
||
// arm64's per-op emit (operandSrc32ARM64 etc.) re-reads the
|
||
// const FROM its slot at every consumer, so the slot stays
|
||
// live until the constant's last user. If we return nil here
|
||
// the Pass 6 reuse allocator sees zero uses, releases the
|
||
// slot the instant it is written, and a later block-local
|
||
// def grabs that slot from the free pool — overwriting the
|
||
// constant before the original constant's reader executes.
|
||
// recursive_init.wasm's `i32.add (load 128) (const 1)` makes
|
||
// this concrete: Const(128)'s slot was reused for Const(1),
|
||
// so the increment computed counter+128 instead of counter+1
|
||
// and `run` returned 128 instead of 6.
|
||
if constsNeedSlot {
|
||
return v
|
||
}
|
||
return nil
|
||
case ssa.OpParam:
|
||
// operandSrc reads from FP+offset, never from a slot.
|
||
return nil
|
||
}
|
||
return v
|
||
}
|
||
|
||
// directCall captures the per-OpCallDirect / per-OpCallImport info
|
||
// computed during planFunc: the callee's wasm signature (for ABI0
|
||
// frame layout), the argument-frame layout, and the asm symbol the
|
||
// emitter should CALL. For import calls the symbol points at a Go-
|
||
// side wrapper (`·callImport_<funcIdx>(SB)`) that performs the
|
||
// interface dispatch; the wrapper is registered for the caller to
|
||
// emit as Go source alongside the asm.
|
||
type directCall struct {
|
||
sig wasm.FuncType
|
||
frame argFrame
|
||
symbol string // already wrapped — `·name(SB)` or `pkg·Name(SB)`
|
||
}
|
||
|
||
// funcPlan is the per-function layout state computed before code
|
||
// emission. It captures stack-slot offsets, the per-block phi list,
|
||
// the staging-temp map, the hoist set (values that need a slot vs
|
||
// values inlined at the use site), and the chosen NOSPLIT / non-
|
||
// NOSPLIT flag.
|
||
type funcPlan struct {
|
||
// gpRegPool / sseRegPool are the arch's block-local regalloc
|
||
// register pools. emitFunc seeds them from arch.GPRegPool() /
|
||
// arch.SSERegPool() so the regalloc machinery picks
|
||
// arch-appropriate names without threading arch everywhere.
|
||
gpRegPool []string
|
||
sseRegPool []string
|
||
// regHomeEligibleOp narrows the set of ops the regalloc gives
|
||
// a register home to. emitFunc seeds it from
|
||
// arch.RegHomeEligibleOp so the arm64 emit path can opt in
|
||
// op-by-op as each per-op emit learns to honour plan.regHome
|
||
// on the write side.
|
||
regHomeEligibleOpFn func(ssa.Op) bool
|
||
offsets map[ssa.ValueID]int
|
||
hoist map[ssa.ValueID]bool
|
||
phiTemp map[ssa.ValueID]int
|
||
phisOf map[ssa.BlockID][]*ssa.Value
|
||
hasPhi map[ssa.BlockID]bool
|
||
staged map[ssa.BlockID]bool
|
||
hasCall bool
|
||
// hasSimdCall / spliceMode / wantsTrapStub drive SIMD splicing:
|
||
// hasSimdCall marks OpSimdCall / OpSimdMemCall presence,
|
||
// spliceMode is set when a Splicer is available for them (slot-
|
||
// only emission), and wantsTrapStub records that some splice
|
||
// branched to the OOB trap label so the stub must be appended
|
||
// once at body end. splicer is the FuncOptions.Splicer handle
|
||
// for the per-op emitters.
|
||
hasSimdCall bool
|
||
spliceMode bool
|
||
wantsTrapStub bool
|
||
splicer SimdSplicer
|
||
// packed / packedParams mirror FuncOptions.PackedParams: the
|
||
// packed outlined-boundary form whose parameter values live in
|
||
// frame slots filled by the pack prologue rather than FP offsets.
|
||
packed bool
|
||
packedParams []ssa.Type
|
||
// trapDivZero / trapIntOvf are the CALL targets of the inline
|
||
// div/rem trap branches ("·wasm_trap_div_zero" style, resolved to
|
||
// forward wrappers in multi-package chunks). divRemInline gates
|
||
// the inline lowering: false when a resolver was available but
|
||
// could not resolve the trap helpers — emitting the default
|
||
// spelling there would fail to link.
|
||
trapDivZero string
|
||
trapIntOvf string
|
||
divRemInline bool
|
||
// spliceHoist is the splicer's module-state staging text (m.M /
|
||
// memSize pointer into splice-safe registers), emitted at entry
|
||
// and re-issued after every value that actually emitted a CALL.
|
||
// emittedCall is the per-value flag those emitters set.
|
||
spliceHoist string
|
||
emittedCall bool
|
||
// fusedAt maps a window's FIRST member value to the planned
|
||
// window; fusedMember marks every member so emission skips them
|
||
// (the window body at the first member covers all of them).
|
||
// Populated only for windows the plan validated end to end —
|
||
// anything else keeps per-op emission.
|
||
fusedAt map[ssa.ValueID]*plannedFusedWindow
|
||
fusedMember map[ssa.ValueID]bool
|
||
// fusedArgPinned marks values a fused window reads at its
|
||
// deferred emission point: their slots must never join the
|
||
// block-local reuse pool (see prescanFusedWindows).
|
||
fusedArgPinned map[ssa.ValueID]bool
|
||
// mustSplice marks SIMD call values inside register-coalesced
|
||
// loops: their inline splice is load-bearing (a fallback CALL
|
||
// would clobber the carry registers), so a splice-table miss
|
||
// there fails the function to the host's fallback instead.
|
||
mustSplice map[ssa.ValueID]bool
|
||
// hasNonSimdCall narrows hasCall to non-SIMD callees (scalar
|
||
// helpers, direct/indirect/import calls, memory ops, global
|
||
// wrappers) — the set FuncOptions.ForbidCalls rejects. SIMD
|
||
// helper symbols ship with every SIMD-using bundle, so their
|
||
// CALLs stay allowed under ForbidCalls.
|
||
hasNonSimdCall bool
|
||
// callDescs names the distinct non-SIMD callees planFunc saw
|
||
// (capped), for the ForbidCalls diagnostic — listing all blockers
|
||
// lets one transpile show the full widening work list.
|
||
callDescs []string
|
||
frameSize int
|
||
calleeArea int // bytes reserved at low SP for callee-arg staging
|
||
helperPfx string
|
||
helperRefs map[ssa.ValueID]string
|
||
directs map[ssa.ValueID]*directCall
|
||
// hasMem records whether the function performs at least one
|
||
// load / store / mem-size / mem-grow op. Drives the
|
||
// `mCacheCandidate` decision — only memory-touching functions
|
||
// need m staged into mCacheReg.
|
||
hasMem bool
|
||
// mem64 reports whether the module's linear memory uses 64-bit
|
||
// addressing. Memory accesses then take i64 bases with no
|
||
// mod-2^32 wrap, and the mem-size / mem-grow / copy / fill ops
|
||
// route to the 64-bit helper family.
|
||
mem64 bool
|
||
// branchFused holds value IDs for OpHelperCall("i32_eqz" /
|
||
// "i64_eqz") whose single use is a BlockIf control. The eqz
|
||
// helper body and slot store are skipped; the BlockIf emits a
|
||
// TEST + JE directly on the eqz argument (inverted polarity)
|
||
// instead of the 5-instruction TEST + SETEQ + MOVBLZX + store
|
||
// + reload + TEST + JNE / JMP pair. branchFusedKind[v.ID] is
|
||
// the helper name ("i32_eqz" / "i64_eqz") so the BlockIf
|
||
// emitter can pick the right operand width.
|
||
branchFused map[ssa.ValueID]bool
|
||
branchFusedKind map[ssa.ValueID]string
|
||
// branchFusedSkip marks SUPPORT values that the
|
||
// BlockIf control absorbs. For the `v & K != 0` → BTL pattern
|
||
// it holds the OpAnd32's ID: the cond is OpNe32 (already in
|
||
// branchFused), and the BTL instruction reads the original
|
||
// `v` directly without needing the AND's slot store. emitBlock
|
||
// skips emission for any value in this map.
|
||
branchFusedSkip map[ssa.ValueID]bool
|
||
// branchFusedBit records, per BlockIf cond value, the base
|
||
// operand that BTL/BTQ should test and which bit to examine.
|
||
// Populated only for the `(x & 1<<bit) <eq/ne> 0` pattern
|
||
// detected in Pass 4; nil otherwise.
|
||
branchFusedBit map[ssa.ValueID]bitTestInfo
|
||
// globalInline holds OpGlobalGet/OpGlobalSet values that should
|
||
// be lowered to an inline MOV against the Module struct instead
|
||
// of a CALL to the loadGlobal_N / storeGlobal_N wrapper. The map
|
||
// entry carries the byte offset of the global field within
|
||
// Module and the wasm value type (so the emitter picks the
|
||
// right MOV width). Only set when FuncOptions.GlobalOffsets is
|
||
// populated AND the global is module-defined (imported globals
|
||
// keep the CALL path because they live behind the host iface).
|
||
globalInline map[ssa.ValueID]globalInlineInfo
|
||
// exc carries FuncOptions.Exc for the emitters: the Module-struct
|
||
// byte offsets the inline OpExc* lowerings read/write. Non-nil
|
||
// exactly when the plan admitted a function containing exc ops.
|
||
exc *ExcOffsets
|
||
// regHome holds the block-local register assignment
|
||
// for SSA values whose entire lifetime fits inside one block and
|
||
// does not cross a CALL boundary. When a value has an entry
|
||
// here, operandSrc{32,64,Float} resolves it to the register
|
||
// name instead of its stack slot, and the producer's emit
|
||
// function writes the result directly to that register rather
|
||
// than storing to the slot. computeRegHomes populates this in
|
||
// planFunc's last pass; the slot still gets allocated (the
|
||
// reuse pool keeps it cheap) so the existing peephole / regtrack
|
||
// passes stay simple and the conservative fallback "produce to
|
||
// slot" path remains correct for any producer that hasn't yet
|
||
// been taught to honour regHome.
|
||
regHome map[ssa.ValueID]string
|
||
// reservedRegs records, per block, the set of GP
|
||
// registers that the cross-block coalescing pre-pass has
|
||
// reserved for a loop-carry value passing through this block.
|
||
// Per-block linear scan must subtract these from the free pool
|
||
// so a transient value in this block does not steal a reg that
|
||
// is "really" still holding a coalesced loop carry. When the
|
||
// pre-pass declines a coalesce (loop body contains a CALL, no
|
||
// regHome-eligible carry, etc.), this stays nil and the per-
|
||
// block scan runs unchanged.
|
||
reservedRegs map[ssa.BlockID]map[string]bool
|
||
// coalescedPhi records loop-carry phi destinations
|
||
// that have been merged with their back-edge args into a
|
||
// shared register. When emitPhiCopyValue sees a phi-edge-copy
|
||
// whose phi destination AND src value resolve to the same
|
||
// register, it emits nothing — the value is already in the
|
||
// register from the producer's emit, and the next iteration's
|
||
// reads pick it up via operandSrc → regHome. The map value is
|
||
// the shared register name (same as plan.regHome[phi.ID] and
|
||
// plan.regHome[v.ID] for any coalesced arg).
|
||
coalescedPhi map[ssa.ValueID]string
|
||
// mCacheReg is the GP register the per-function
|
||
// prologue loads `m` (the *Module argument at FP+0) into so
|
||
// subsequent emits can dereference it without re-reading FP
|
||
// every time. Empty if the optimization is off for this
|
||
// function — typically because the function has no memops, no
|
||
// inlined-global accesses, no direct CALLs (nothing to amortise
|
||
// the per-CALL refresh against). When non-empty, every emit
|
||
// that previously wrote `MOVQ m+0(FP), <scratch>` writes
|
||
// `MOVQ <mCacheReg>, <scratch>` instead, the per-CALL emit
|
||
// path inserts a `MOVQ m+0(FP), <mCacheReg>` refresh (Go ABI0
|
||
// classes every GP register as caller-save, so the cache is
|
||
// invalidated by every CALL), and the per-block linear scan in
|
||
// assignBlockRegHomes excludes mCacheReg from its free pool.
|
||
mCacheReg string
|
||
// mCacheCandidate is the planFunc-level "this function would
|
||
// benefit from caching m" hint. emitFunc consults it together
|
||
// with arch.SupportsRegHome to decide whether to actually
|
||
// populate mCacheReg.
|
||
mCacheCandidate bool
|
||
// unusedResult marks SSA values whose result is
|
||
// never consumed — no other value's Args refer to them (after
|
||
// resolving OpCopy chains), no block.Control reads them, and
|
||
// they are not among the function's BlockRet outputs. The emit
|
||
// path uses it to skip the post-CALL `MOVL retOff(SP), AX;
|
||
// MOVL AX, dstOff(SP)` pair for OpCallDirect / OpCallImport /
|
||
// OpCallIndirect / OpHelperCall sites — Pure-Go's compiler
|
||
// drops these entirely, and on functions like Fn31214 (where
|
||
// most helper calls' return values are discarded) this is a
|
||
// pure two-instructions-per-call win.
|
||
unusedResult map[ssa.ValueID]bool
|
||
}
|
||
|
||
// globalInlineInfo describes one inlined global access: where it
|
||
// sits in Module (byte offset) and what type to read/write (so the
|
||
// emitter picks MOVL/MOVQ/MOVSS/MOVSD).
|
||
type globalInlineInfo struct {
|
||
offset int
|
||
vtype wasm.ValType
|
||
}
|
||
|
||
// FusedParamSrc is one fused-signature parameter's source in the
|
||
// retained SSA: a compile-time constant staged as an immediate, or a
|
||
// member value's argument (Val.Args[ArgIdx]).
|
||
type FusedParamSrc struct {
|
||
IsConst bool
|
||
Const int64
|
||
Val *ssa.Value
|
||
ArgIdx int
|
||
}
|
||
|
||
// FusedWindow describes one fused region inside a retained function:
|
||
// the interned tree, the member call values it replaces (in scheduled
|
||
// order), the root values per Tree.RootList(), and the fused
|
||
// signature's parameter sources. The emitter stages the signature
|
||
// from the sources, asks the Splicer for the fused body, skips the
|
||
// member values, and routes the roots into their slots.
|
||
type FusedWindow struct {
|
||
Tree *simdfuse.Tree
|
||
Members []*ssa.Value
|
||
Roots []*ssa.Value
|
||
ScalarSrc []FusedParamSrc
|
||
FloatSrc []FusedParamSrc
|
||
PairSrc []FusedParamSrc
|
||
}
|
||
|
||
// ExcOffsets is the byte offset of each exception-state field within
|
||
// the generated Module struct (see FuncOptions.Exc). Vals is the base
|
||
// of operand slot 0; slot i lives at Vals + 8*i.
|
||
type ExcOffsets struct {
|
||
Pending int // excPending int32
|
||
Tag int // excTag uint32
|
||
Vals int // excVals [n]uint64
|
||
}
|
||
|
||
// planFunc walks f and builds the layout plan. The walk happens once
|
||
// up-front so emitValueAMD64 can stay a simple op-by-op switch with
|
||
// O(1) lookups.
|
||
//
|
||
// callArgBias is the SP-offset where the outgoing-call argument
|
||
// block starts (0 on amd64, 8 on arm64 — see arch.CallArgBias).
|
||
//
|
||
// constsNeedSlot reflects whether the arch's per-op emit reads
|
||
// OpConst* operands from their slot (true on arm64 — operandSrc32ARM64
|
||
// returns `slot(RSP)` for every Const) or inlines them as immediates
|
||
// (false on amd64 — operandSrc32 returns `$imm`). When true, the
|
||
// Pass 2 lifetime analysis MUST track Const uses; otherwise the
|
||
// linear-scan slot-reuse releases each Const's slot before its
|
||
// real reader runs, producing a stale-data read at the consumer.
|
||
// resolveSlotArg honours the flag at use-tracking time so the same
|
||
// allocator code path works for both archs.
|
||
// noteNonSimdCall records a non-SIMD callee for the ForbidCalls
|
||
// diagnostic and sets the flag.
|
||
func (p *funcPlan) noteNonSimdCall(v *ssa.Value) {
|
||
p.hasNonSimdCall = true
|
||
aux, _ := v.Aux.(string)
|
||
if aux == "" {
|
||
aux = fmt.Sprintf("#%d", v.AuxInt)
|
||
}
|
||
desc := fmt.Sprintf("%v %s", v.Op, aux)
|
||
for _, d := range p.callDescs {
|
||
if d == desc {
|
||
return
|
||
}
|
||
}
|
||
if len(p.callDescs) < 8 {
|
||
p.callDescs = append(p.callDescs, desc)
|
||
}
|
||
}
|
||
|
||
func planFunc(f *ssa.Func, opts FuncOptions, sig wasm.FuncType, callArgBias int, constsNeedSlot bool) (*funcPlan, error) {
|
||
p := &funcPlan{
|
||
offsets: map[ssa.ValueID]int{},
|
||
phiTemp: map[ssa.ValueID]int{},
|
||
phisOf: map[ssa.BlockID][]*ssa.Value{},
|
||
hasPhi: map[ssa.BlockID]bool{},
|
||
staged: map[ssa.BlockID]bool{},
|
||
helperPfx: opts.HelperPrefix,
|
||
helperRefs: map[ssa.ValueID]string{},
|
||
directs: map[ssa.ValueID]*directCall{},
|
||
branchFused: map[ssa.ValueID]bool{},
|
||
branchFusedKind: map[ssa.ValueID]string{},
|
||
branchFusedSkip: map[ssa.ValueID]bool{},
|
||
globalInline: map[ssa.ValueID]globalInlineInfo{},
|
||
regHome: map[ssa.ValueID]string{},
|
||
coalescedPhi: map[ssa.ValueID]string{},
|
||
unusedResult: map[ssa.ValueID]bool{},
|
||
}
|
||
p.mem64 = opts.Module != nil && opts.Module.Memory64()
|
||
p.trapDivZero, p.trapIntOvf = "·wasm_trap_div_zero", "·wasm_trap_int_overflow"
|
||
p.divRemInline = true
|
||
p.packed = opts.PackedParams != nil
|
||
p.packedParams = opts.PackedParams
|
||
|
||
// Pass 1: scan calls (helper + direct), compute the max
|
||
// callee-arg/ret frame, resolve each direct call's symbol,
|
||
// and detect whether the function touches wasm memory at all
|
||
// (so the m.M cache slot can be elided in pure-arithmetic
|
||
// leaves).
|
||
maxCallee := 0
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
switch v.Op {
|
||
case ssa.OpLoad8U, ssa.OpLoad8S, ssa.OpLoad16U, ssa.OpLoad16S,
|
||
ssa.OpLoad32, ssa.OpLoad32U, ssa.OpLoad32S, ssa.OpLoad64,
|
||
ssa.OpLoadF32, ssa.OpLoadF64,
|
||
ssa.OpStore8, ssa.OpStore16, ssa.OpStore32, ssa.OpStore64,
|
||
ssa.OpStoreF32, ssa.OpStoreF64,
|
||
// SIMD memory helpers take m; staging it per call
|
||
// benefits from the m-cache exactly like inline memops.
|
||
ssa.OpSimdMemCall:
|
||
p.hasMem = true
|
||
}
|
||
switch v.Op {
|
||
case ssa.OpHelperCall:
|
||
name, ok := v.Aux.(string)
|
||
if !ok {
|
||
return nil, fmt.Errorf("OpHelperCall v%d has non-string Aux", v.ID)
|
||
}
|
||
p.helperRefs[v.ID] = name
|
||
if helperAlwaysInline(name) {
|
||
// Both arches emit these as one or two native
|
||
// instructions — no CALL, no callee frame, and
|
||
// no ForbidCalls consequence.
|
||
break
|
||
}
|
||
if divRemHelper(name) {
|
||
// Inline div/rem: native divide plus trap-branch
|
||
// CALLs (zero-arg, no callee frame). With a
|
||
// resolver, the trap symbols must resolve for the
|
||
// inline form to link; unresolved (or resolver-
|
||
// less) hosts keep the current behavior.
|
||
p.hasCall = true
|
||
if opts.MemHelperSymbol != nil {
|
||
z, ok1 := opts.MemHelperSymbol("wasm_trap_div_zero")
|
||
ovf, ok2 := opts.MemHelperSymbol("wasm_trap_int_overflow")
|
||
if ok1 && ok2 {
|
||
p.trapDivZero, p.trapIntOvf = z, ovf
|
||
break
|
||
}
|
||
p.divRemInline = false
|
||
}
|
||
p.noteNonSimdCall(v)
|
||
spec, ok := helperSig(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("OpHelperCall v%d: unknown helper %q", v.ID, name)
|
||
}
|
||
if sz := helperCallFrameSize(spec); sz > maxCallee {
|
||
maxCallee = sz
|
||
}
|
||
break
|
||
}
|
||
p.hasCall = true
|
||
p.noteNonSimdCall(v)
|
||
spec, ok := helperSig(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("OpHelperCall v%d: unknown helper %q", v.ID, name)
|
||
}
|
||
if sz := helperCallFrameSize(spec); sz > maxCallee {
|
||
maxCallee = sz
|
||
}
|
||
case ssa.OpCallDirect:
|
||
p.hasCall = true
|
||
if opts.Module == nil {
|
||
return nil, fmt.Errorf("OpCallDirect v%d: FuncOptions.Module is required", v.ID)
|
||
}
|
||
if v.AuxInt < 0 {
|
||
// Synthetic call to an extracted sibling (the
|
||
// outline pass names the callee in Aux and has no
|
||
// wasm index). The packed call-site protocol
|
||
// (staging the boundary through the module's
|
||
// outline pack) is not emitted here yet — count
|
||
// it as an unresolvable callee so ForbidCalls
|
||
// falls the function back to the host's backend.
|
||
p.noteNonSimdCall(v)
|
||
break
|
||
}
|
||
calleeIdx := uint32(v.AuxInt)
|
||
csig := opts.Module.FuncTypeOf(calleeIdx)
|
||
cframe, err := computeArgFrame(csig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("OpCallDirect v%d: callee %d frame: %w", v.ID, calleeIdx, err)
|
||
}
|
||
symbol := ""
|
||
resolved := false
|
||
if opts.CalleeSymbol != nil {
|
||
var operand string
|
||
if operand, resolved = opts.CalleeSymbol(calleeIdx); resolved {
|
||
// The resolver hands back the exact operand
|
||
// (local "·name" or spelled cross-package
|
||
// "pkg·name") — use it verbatim; goAsmSymbol
|
||
// would mis-split a spelled name (its dots
|
||
// are U+00B7, not ASCII ".").
|
||
symbol = operand + "(SB)"
|
||
}
|
||
}
|
||
if !resolved {
|
||
// Unresolved callees count against ForbidCalls.
|
||
p.noteNonSimdCall(v)
|
||
bareName := fmt.Sprintf("Fn%d", calleeIdx)
|
||
if opts.FuncSymbol != nil {
|
||
bareName = opts.FuncSymbol(calleeIdx)
|
||
}
|
||
symbol = goAsmSymbol(bareName)
|
||
}
|
||
p.directs[v.ID] = &directCall{
|
||
sig: csig,
|
||
frame: cframe,
|
||
symbol: symbol,
|
||
}
|
||
if cframe.argSize > maxCallee {
|
||
maxCallee = cframe.argSize
|
||
}
|
||
case ssa.OpCallImport:
|
||
p.hasCall = true
|
||
p.noteNonSimdCall(v)
|
||
if opts.Module == nil {
|
||
return nil, fmt.Errorf("OpCallImport v%d: FuncOptions.Module is required", v.ID)
|
||
}
|
||
calleeIdx := uint32(v.AuxInt)
|
||
csig := opts.Module.FuncTypeOf(calleeIdx)
|
||
cframe, err := computeArgFrame(csig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("OpCallImport v%d: callee %d frame: %w", v.ID, calleeIdx, err)
|
||
}
|
||
// The asm CALLs a Go-side wrapper named
|
||
// `callImport_<funcIdx>` (in the same package). The
|
||
// host is responsible for emitting the wrapper as
|
||
// Go source; the asm side just needs the symbol.
|
||
// We use the same name regardless of which wasm
|
||
// import-module the function belongs to so the
|
||
// wrapper-emit loop on the host side can be driven
|
||
// from a single `mod.Imports[]` walk rather than
|
||
// per-call introspection.
|
||
p.directs[v.ID] = &directCall{
|
||
sig: csig,
|
||
frame: cframe,
|
||
symbol: wrapperSymbol(opts.HelperPrefix, fmt.Sprintf("callImport_%d", calleeIdx)),
|
||
}
|
||
if cframe.argSize > maxCallee {
|
||
maxCallee = cframe.argSize
|
||
}
|
||
case ssa.OpCallIndirect:
|
||
p.hasCall = true
|
||
p.noteNonSimdCall(v)
|
||
if opts.Module == nil {
|
||
return nil, fmt.Errorf("OpCallIndirect v%d: FuncOptions.Module is required", v.ID)
|
||
}
|
||
typeIdx := uint32(v.AuxInt)
|
||
if typeIdx >= uint32(len(opts.Module.Types)) {
|
||
return nil, fmt.Errorf("OpCallIndirect v%d: type idx %d out of range", v.ID, typeIdx)
|
||
}
|
||
ft := opts.Module.Types[typeIdx]
|
||
// Wrapper signature prepends i32 for the table-index
|
||
// argument. The wrapper does `t0[idx].(funcType)(m,
|
||
// params...)` so it absorbs the type-assertion and
|
||
// the function-pointer call in one Go expression.
|
||
csig := wasm.FuncType{
|
||
Params: append([]wasm.ValType{wasm.ValI32}, ft.Params...),
|
||
Results: ft.Results,
|
||
}
|
||
cframe, err := computeArgFrame(csig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("OpCallIndirect v%d: frame: %w", v.ID, err)
|
||
}
|
||
p.directs[v.ID] = &directCall{
|
||
sig: csig,
|
||
frame: cframe,
|
||
symbol: wrapperSymbol(opts.HelperPrefix, fmt.Sprintf("callIndirect_type%d", typeIdx)),
|
||
}
|
||
if cframe.argSize > maxCallee {
|
||
maxCallee = cframe.argSize
|
||
}
|
||
case ssa.OpSimdCall, ssa.OpSimdMemCall:
|
||
// SIMD helper CALL: signature derived from the SSA
|
||
// value itself (see simdCallSpecOf); only the callee
|
||
// frame size matters at planning time.
|
||
p.hasCall = true // NOT hasNonSimdCall: allowed under ForbidCalls
|
||
p.hasSimdCall = true
|
||
sp, err := simdCallSpecOf(v, p)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if sp.frame > maxCallee {
|
||
maxCallee = sp.frame
|
||
}
|
||
case ssa.OpMemoryCopy, ssa.OpMemoryFill, ssa.OpMemSize, ssa.OpMemGrow:
|
||
// Route to the codegen-emitted helpers
|
||
// (memorySize / memoryGrow / memoryCopy /
|
||
// memoryFill). The codegen translator copies these
|
||
// from internal/codegen/helpers/helpers.go into the
|
||
// generated package; the asm side just CALLs them
|
||
// rather than re-implementing the slice grow / copy
|
||
// logic. In multi-package mode the helpers live in
|
||
// base/ and are capitalized for cross-package use.
|
||
p.hasCall = true
|
||
var csig wasm.FuncType
|
||
helperName := ""
|
||
switch v.Op {
|
||
case ssa.OpMemSize:
|
||
csig = wasm.FuncType{Results: []wasm.ValType{wasm.ValI32}}
|
||
helperName = "memorySize"
|
||
case ssa.OpMemGrow:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI32}, Results: []wasm.ValType{wasm.ValI32}}
|
||
helperName = "memoryGrow"
|
||
case ssa.OpMemoryCopy:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI32, wasm.ValI32, wasm.ValI32}}
|
||
helperName = "memoryCopy"
|
||
case ssa.OpMemoryFill:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI32, wasm.ValI32, wasm.ValI32}}
|
||
helperName = "memoryFill"
|
||
}
|
||
if p.mem64 {
|
||
// Memory64 modules route to the 64-bit helper family
|
||
// (i64 page counts / addresses / lengths). Sig shapes
|
||
// mirror internal/codegen/helpers: memorySize64() i64,
|
||
// memoryGrow64(i64) i64, memoryFill64(i64, i32, i64),
|
||
// memoryCopy64(i64, i64, i64).
|
||
helperName += "64"
|
||
switch v.Op {
|
||
case ssa.OpMemSize:
|
||
csig = wasm.FuncType{Results: []wasm.ValType{wasm.ValI64}}
|
||
case ssa.OpMemGrow:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI64}, Results: []wasm.ValType{wasm.ValI64}}
|
||
case ssa.OpMemoryCopy:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI64, wasm.ValI64, wasm.ValI64}}
|
||
case ssa.OpMemoryFill:
|
||
csig = wasm.FuncType{Params: []wasm.ValType{wasm.ValI64, wasm.ValI32, wasm.ValI64}}
|
||
}
|
||
}
|
||
cframe, err := computeArgFrame(csig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%v v%d: frame: %w", v.Op, v.ID, err)
|
||
}
|
||
// OpMem* lowerings CALL the operand the resolver
|
||
// hands back — `·memorySize(SB)` same-package, or in
|
||
// multi-package mode the chunk-local //go:linkname
|
||
// trampoline the host registers to bridge to the
|
||
// canonical implementation living in base.
|
||
sym := ""
|
||
if opts.MemHelperSymbol != nil {
|
||
if operand, ok := opts.MemHelperSymbol(helperName); ok {
|
||
sym = operand + "(SB)"
|
||
}
|
||
}
|
||
if sym == "" {
|
||
// Unresolved helpers count against ForbidCalls.
|
||
p.noteNonSimdCall(v)
|
||
sym = fmt.Sprintf("·%s(SB)", helperName)
|
||
}
|
||
p.directs[v.ID] = &directCall{sig: csig, frame: cframe, symbol: sym}
|
||
if cframe.argSize > maxCallee {
|
||
maxCallee = cframe.argSize
|
||
}
|
||
case ssa.OpExcPending, ssa.OpExcTag, ssa.OpExcVal,
|
||
ssa.OpExcRaise, ssa.OpExcRearm, ssa.OpExcClear:
|
||
// Branch-based EH state lives in fixed Module fields;
|
||
// without the host-supplied offsets there is no CALL
|
||
// fallback (the pure-Go emitter inlines these too), so
|
||
// the function must fall back as a whole.
|
||
if opts.Exc == nil {
|
||
return nil, fmt.Errorf("%v v%d: FuncOptions.Exc offsets required", v.Op, v.ID)
|
||
}
|
||
p.exc = opts.Exc
|
||
case ssa.OpGlobalGet, ssa.OpGlobalSet:
|
||
if opts.Module == nil {
|
||
return nil, fmt.Errorf("%v v%d: FuncOptions.Module is required", v.Op, v.ID)
|
||
}
|
||
gidx := int(v.AuxInt)
|
||
gtype, err := globalValType(opts.Module, uint32(v.AuxInt))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%v v%d: %w", v.Op, v.ID, err)
|
||
}
|
||
// When the host supplied GlobalOffsets and
|
||
// this is a module-defined global (offset >= 0),
|
||
// lower the access to a direct MOV against the
|
||
// Module struct — no wrapper CALL, no hasCall bump.
|
||
// Imported globals (offset == -1) keep the CALL path
|
||
// below because they live behind the host iface.
|
||
if opts.GlobalOffsets != nil && gidx >= 0 && gidx < len(opts.GlobalOffsets) && opts.GlobalOffsets[gidx] >= 0 {
|
||
p.globalInline[v.ID] = globalInlineInfo{
|
||
offset: opts.GlobalOffsets[gidx],
|
||
vtype: gtype,
|
||
}
|
||
break
|
||
}
|
||
p.hasCall = true
|
||
p.noteNonSimdCall(v)
|
||
var csig wasm.FuncType
|
||
var sym string
|
||
if v.Op == ssa.OpGlobalGet {
|
||
csig = wasm.FuncType{Results: []wasm.ValType{gtype}}
|
||
sym = wrapperSymbol(opts.HelperPrefix, fmt.Sprintf("loadGlobal_%d", v.AuxInt))
|
||
} else {
|
||
csig = wasm.FuncType{Params: []wasm.ValType{gtype}}
|
||
sym = wrapperSymbol(opts.HelperPrefix, fmt.Sprintf("storeGlobal_%d", v.AuxInt))
|
||
}
|
||
cframe, err := computeArgFrame(csig)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%v v%d: frame: %w", v.Op, v.ID, err)
|
||
}
|
||
p.directs[v.ID] = &directCall{sig: csig, frame: cframe, symbol: sym}
|
||
if cframe.argSize > maxCallee {
|
||
maxCallee = cframe.argSize
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// arm64's Go ABI reserves the first 8 bytes at caller SP+0 for the
|
||
// callee's saved-LR slot, so the outgoing call-arg area spans
|
||
// SP+bias..SP+bias+maxCallee on arm64 and SP+0..SP+maxCallee on
|
||
// amd64. Pad calleeArea by the bias so the local-slot allocator's
|
||
// floor sits above the call-arg region for both archs without the
|
||
// arch-specific arms needing to remember the offset twice.
|
||
maxCallee += callArgBias
|
||
p.calleeArea = maxCallee
|
||
|
||
// Pin fused-window operand sources before slot assignment: their
|
||
// reads happen at the window's deferred emission point, past the
|
||
// reuse model's lifetimes.
|
||
if _, fusedOK := opts.Splicer.(FusedSplicer); fusedOK {
|
||
p.prescanFusedWindows(opts.Windows)
|
||
}
|
||
|
||
// Pass 2: assign slot to every value. emit.ComputeHoist is
|
||
// captured for the per-op emitter to use as a tie-breaker
|
||
// (e.g., short-circuiting redundant materialisations) but the
|
||
// slot map covers every storable scalar so any consumer can
|
||
// safely read plan.offsets[v.ID]. operandSrc{32,64,Float} is
|
||
// what shortens the binary-op MOV pair into one MOV + memory/
|
||
// immediate operand — that win lands without touching slot
|
||
// allocation.
|
||
//
|
||
// Slot REUSE: a value whose entire lifetime is inside its
|
||
// def block can share its slot with a later value defined
|
||
// after its last use (in the same block or any subsequent
|
||
// block — the def block's execution is over by the time the
|
||
// next block starts, so the slot's contents are dead). The
|
||
// allocator maintains a free pool keyed by (size, align) and
|
||
// pops from it before falling back to the high-water mark.
|
||
// Cross-block values — anything used outside its def block,
|
||
// including phi sources — keep dedicated, non-reusable slots
|
||
// since their lifetimes can overlap arbitrarily with each
|
||
// other under branching control flow. This is the main lever
|
||
// against the per-value slot growth that pushed Fn18113 in
|
||
// the googlesql bundle to a 680 KB frame; with reuse, only
|
||
// the cross-block residual + the per-block peak survive.
|
||
off := maxCallee
|
||
usage := emit.ComputeValueUsage(f)
|
||
hoist := emit.ComputeHoist(f, usage)
|
||
p.hoist = hoist
|
||
|
||
// 2a. Identify cross-block values. A value is cross-block iff
|
||
// any use is in a different block than its def, OR it is the
|
||
// Control of a different block.
|
||
//
|
||
// Use sites are computed by walking OpCopy chains all the way
|
||
// to the underlying value: operandSrc{32,64,Float} resolves
|
||
// `OpCopy → X` at every consumer and reads from X's slot
|
||
// directly, so a use of an OpCopy through resolveCopy is a
|
||
// REAL use of the underlying X — not of the intermediate
|
||
// OpCopy. Without this resolution the cross-block / last-use
|
||
// analyses miss the actual reader and the linear-scan slot
|
||
// reuse can mark X's slot dead while X is still being read
|
||
// through an OpCopy alias from another block (or later in the
|
||
// same block past the OpCopy itself). That bug — observed as
|
||
// a hang during downstream module initialisation in a real
|
||
// wasm bundle — is what gates the rest of the algorithm.
|
||
defBlock := make(map[ssa.ValueID]ssa.BlockID, 0)
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
defBlock[v.ID] = blk.ID
|
||
}
|
||
}
|
||
crossBlock := make(map[ssa.ValueID]bool, 0)
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
for _, arg := range v.Args {
|
||
r := resolveSlotArg(arg, constsNeedSlot)
|
||
if r == nil {
|
||
continue
|
||
}
|
||
if db, ok := defBlock[r.ID]; ok && db != blk.ID {
|
||
crossBlock[r.ID] = true
|
||
}
|
||
}
|
||
}
|
||
if blk.Control != nil {
|
||
r := resolveSlotArg(blk.Control, constsNeedSlot)
|
||
if r != nil {
|
||
if db, ok := defBlock[r.ID]; ok && db != blk.ID {
|
||
crossBlock[r.ID] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2b. Walk blocks; for each block, compute lastUseInBlock for
|
||
// the block-local values, then allocate slots in linear order
|
||
// with free-pool reuse.
|
||
type slotKey struct {
|
||
size, align int
|
||
}
|
||
pool := map[slotKey][]int{}
|
||
type activeSlot struct {
|
||
vid ssa.ValueID
|
||
expiresAt int // last block-internal index where the slot is still live
|
||
key slotKey
|
||
offset int
|
||
}
|
||
for _, blk := range f.Blocks {
|
||
// Compute lastUseInBlock for block-local values. Args are
|
||
// resolved through OpCopy chains so the underlying value
|
||
// (the one whose slot operandSrc actually reads) gets its
|
||
// last-use index extended to the real consumer's index,
|
||
// not the index of an intermediate OpCopy.
|
||
blkLen := len(blk.Values)
|
||
lastUseInBlock := map[ssa.ValueID]int{}
|
||
recordUse := func(arg *ssa.Value, atIdx int) {
|
||
r := resolveSlotArg(arg, constsNeedSlot)
|
||
if r == nil {
|
||
return
|
||
}
|
||
if crossBlock[r.ID] {
|
||
return
|
||
}
|
||
if defBlock[r.ID] != blk.ID {
|
||
return
|
||
}
|
||
if atIdx > lastUseInBlock[r.ID] {
|
||
lastUseInBlock[r.ID] = atIdx
|
||
}
|
||
}
|
||
for i, v := range blk.Values {
|
||
if v.Op == ssa.OpPhi {
|
||
// Phi args are read by the PREDECESSOR block's
|
||
// edge-copy emission (in emitPhiEdgeCopies, called
|
||
// from the predecessor's terminator). For args
|
||
// from other blocks the crossBlock pass already
|
||
// pins a dedicated slot — but the missed case is
|
||
// the SELF-LOOP back-edge: `phi.Args[predIdx]`
|
||
// can reference a value defined LATER in THIS
|
||
// block, and that value's "use via edge-copy"
|
||
// happens at this block's terminator, not at the
|
||
// phi's value-index. The default
|
||
// `recordUse(arg, i)` would charge the use to
|
||
// the phi's index, which is < the arg's def
|
||
// index — so my reuse allocator sees
|
||
// lastUseInBlock < def_idx, decides the value is
|
||
// unused, and reclaims its slot the instant it's
|
||
// written. The edge-copy at the terminator then
|
||
// reads stale data. Record self-loop phi args at
|
||
// blkLen so the slot stays live through the
|
||
// whole block.
|
||
for _, arg := range v.Args {
|
||
r := resolveSlotArg(arg, constsNeedSlot)
|
||
if r == nil {
|
||
continue
|
||
}
|
||
if crossBlock[r.ID] {
|
||
continue
|
||
}
|
||
if defBlock[r.ID] != blk.ID {
|
||
continue
|
||
}
|
||
if blkLen > lastUseInBlock[r.ID] {
|
||
lastUseInBlock[r.ID] = blkLen
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
for _, arg := range v.Args {
|
||
recordUse(arg, i)
|
||
}
|
||
}
|
||
// blk.Control is consumed by the terminator that follows
|
||
// every value in this block — its lifetime extends to
|
||
// blkLen (sentinel for "after the last value").
|
||
if blk.Control != nil {
|
||
r := resolveSlotArg(blk.Control, constsNeedSlot)
|
||
if r != nil && !crossBlock[r.ID] && defBlock[r.ID] == blk.ID {
|
||
if blkLen > lastUseInBlock[r.ID] {
|
||
lastUseInBlock[r.ID] = blkLen
|
||
}
|
||
}
|
||
}
|
||
// BlockRet: archAMD64.EmitReturn / archARM64.EmitReturn
|
||
// take the LAST k values of blk.Values (where k =
|
||
// len(sig.Results)) as the return values to move into the
|
||
// FP-relative result locations. Those values are "used"
|
||
// at the terminator — exactly the same shape as
|
||
// blk.Control above — so extend their lastUseInBlock to
|
||
// blkLen. Without this, a return value that has no
|
||
// in-block consumers gets released back to the pool the
|
||
// moment it is defined; a later block's local picks up
|
||
// the slot, and EmitReturn reads garbage.
|
||
if blk.Kind == ssa.BlockRet {
|
||
k := len(sig.Results)
|
||
if k > 0 && k <= blkLen {
|
||
for j := blkLen - k; j < blkLen; j++ {
|
||
rv := blk.Values[j]
|
||
r := resolveSlotArg(rv, constsNeedSlot)
|
||
if r == nil || crossBlock[r.ID] || defBlock[r.ID] != blk.ID {
|
||
continue
|
||
}
|
||
if blkLen > lastUseInBlock[r.ID] {
|
||
lastUseInBlock[r.ID] = blkLen
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var active []activeSlot
|
||
for i, v := range blk.Values {
|
||
size, align := slotSize(v.Type)
|
||
if size == 0 {
|
||
continue
|
||
}
|
||
// Release any block-local slot whose lifetime ended
|
||
// strictly before this index.
|
||
if len(active) > 0 {
|
||
keep := active[:0]
|
||
for _, e := range active {
|
||
if e.expiresAt < i {
|
||
pool[e.key] = append(pool[e.key], e.offset)
|
||
} else {
|
||
keep = append(keep, e)
|
||
}
|
||
}
|
||
active = keep
|
||
}
|
||
key := slotKey{size: size, align: align}
|
||
// Slot reuse covers only op classes whose
|
||
// lifetime my use-def walk can compute precisely.
|
||
// The bisect against the full downstream test
|
||
// suite localised three sources of missed use sites:
|
||
//
|
||
// - OpPhi: predecessor edge-copies write the slot
|
||
// at terminator time, which the
|
||
// "lastUseInBlock counts v.Args in own
|
||
// block" model misses for the destination
|
||
// phi itself.
|
||
// - OpHelperCall result: the helper return may be
|
||
// consumed through OpCopy chains that
|
||
// cross block boundaries in ways
|
||
// resolveSlotArg's single-block walk does
|
||
// not catch.
|
||
// - OpLoad* / OpStore*: wasm-memory operations
|
||
// surface addresses whose consumers (the
|
||
// arithmetic feeding the next load /
|
||
// store) are sometimes in different
|
||
// blocks, again past the same-block
|
||
// analysis. Bisect bullet: enabling
|
||
// OpLoad reuse alone triggers SIGSEGV in
|
||
// a catalog-init helper
|
||
// (Fn18584 → bad m.M index); enabling
|
||
// OpStore alone hangs an inner scan in
|
||
// p8.Fn39813 (loop variable shares a
|
||
// slot with the byte-test temp).
|
||
// - 8-byte slots (i64 / f64): the NUMERIC /
|
||
// BIGNUMERIC arithmetic in TestQuery
|
||
// produces wrong digits when i64-typed
|
||
// ops participate in reuse. The same
|
||
// same-block lifetime model that handles
|
||
// i32 correctly mis-tracks an i64 value
|
||
// somewhere in the multi-word add /
|
||
// mul / shr chain, but the specific
|
||
// miss site is not yet isolated. Pinning
|
||
// size >= 8 slots to dedicated offsets
|
||
// dodges the corruption without giving up
|
||
// the i32 reuse, which is where the bulk
|
||
// of the frame-size win sits anyway.
|
||
// - OpCall* / OpGlobal* / OpMem*: same kind of
|
||
// cross-block-via-helper aliasing as
|
||
// OpHelperCall.
|
||
//
|
||
// What remains eligible: pure-arithmetic and bit ops
|
||
// at i32 width (Add / Sub / Mul / Div / And / Or /
|
||
// Xor / Shl / Shr / Eq / Ne / Lt / Le / Extend32To32
|
||
// / TruncTo32 / Const32). The self-loop phi back-edge
|
||
// arg fix below (recording phi.Args[i] uses at blkLen
|
||
// for args defined in this same block) is required
|
||
// for even this subset to be correct — without it
|
||
// the loop-carried scalar in a self-loop dies the
|
||
// instant it is written.
|
||
// Slot reuse — eligibility per op class.
|
||
// The list below excludes op classes whose downstream
|
||
// uses the lifetime model can't track precisely. amd64
|
||
// reaches this with most i32 arithmetic enabled. arm64
|
||
// adds extra exclusions in `arm64ReuseUnsafe` because
|
||
// its per-op emit reads operands from slots in cases
|
||
// where amd64 inlines them (no large-imm ALU on arm64).
|
||
opEligible := true
|
||
switch v.Op {
|
||
// OpParam: FP-resident normally (slot unread), but the
|
||
// packed prologue writes the slot and every read uses it;
|
||
// reuse would clobber the parameter. Dedicated always.
|
||
case ssa.OpParam:
|
||
opEligible = false
|
||
case ssa.OpPhi, ssa.OpHelperCall,
|
||
ssa.OpLoad8U, ssa.OpLoad8S, ssa.OpLoad16U, ssa.OpLoad16S,
|
||
ssa.OpLoad32, ssa.OpLoad32U, ssa.OpLoad32S, ssa.OpLoad64,
|
||
ssa.OpLoadF32, ssa.OpLoadF64,
|
||
ssa.OpStore8, ssa.OpStore16, ssa.OpStore32, ssa.OpStore64,
|
||
ssa.OpStoreF32, ssa.OpStoreF64,
|
||
ssa.OpCallDirect, ssa.OpCallImport, ssa.OpCallIndirect,
|
||
ssa.OpGlobalGet, ssa.OpGlobalSet,
|
||
ssa.OpMemSize, ssa.OpMemGrow,
|
||
ssa.OpMemoryCopy, ssa.OpMemoryFill,
|
||
// Exc reads feed BlockIf controls and cross-block
|
||
// dispatches the same-block lifetime model does not
|
||
// track; keep their slots dedicated like OpGlobalGet.
|
||
ssa.OpExcPending, ssa.OpExcTag, ssa.OpExcVal,
|
||
ssa.OpCatchArg:
|
||
opEligible = false
|
||
}
|
||
if size >= 8 {
|
||
opEligible = false
|
||
}
|
||
if p.fusedArgPinned[v.ID] {
|
||
// A fused window reads this value's slot at its
|
||
// deferred emission point, past the lifetime the
|
||
// reuse model computes.
|
||
opEligible = false
|
||
}
|
||
if constsNeedSlot && arm64ReuseUnsafe(v.Op) {
|
||
opEligible = false
|
||
}
|
||
if crossBlock[v.ID] || !opEligible {
|
||
off = alignUp(off, align)
|
||
p.offsets[v.ID] = off
|
||
off += size
|
||
continue
|
||
}
|
||
// Block-local: try the pool first.
|
||
if free := pool[key]; len(free) > 0 {
|
||
n := len(free) - 1
|
||
p.offsets[v.ID] = free[n]
|
||
pool[key] = free[:n]
|
||
} else {
|
||
off = alignUp(off, align)
|
||
p.offsets[v.ID] = off
|
||
off += size
|
||
}
|
||
expires := lastUseInBlock[v.ID]
|
||
if expires < i {
|
||
// Unused value (no consumers); slot is dead the
|
||
// moment it is written. Releasing now keeps the
|
||
// pool tight without the active list churn.
|
||
pool[key] = append(pool[key], p.offsets[v.ID])
|
||
continue
|
||
}
|
||
active = append(active, activeSlot{
|
||
vid: v.ID,
|
||
expiresAt: expires,
|
||
key: key,
|
||
offset: p.offsets[v.ID],
|
||
})
|
||
}
|
||
// End of block: every still-active block-local value's
|
||
// lifetime is over (it cannot be referenced outside its
|
||
// def block by construction).
|
||
for _, e := range active {
|
||
pool[e.key] = append(pool[e.key], e.offset)
|
||
}
|
||
}
|
||
|
||
// Pass 3: phi index + staged-phi analysis.
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
if v.Op == ssa.OpPhi {
|
||
p.phisOf[blk.ID] = append(p.phisOf[blk.ID], v)
|
||
p.hasPhi[blk.ID] = true
|
||
}
|
||
}
|
||
}
|
||
staged := emit.ComputeStagedPhis(f, hoist)
|
||
for _, blk := range f.Blocks {
|
||
for _, phi := range p.phisOf[blk.ID] {
|
||
if !staged[phi.ID] {
|
||
continue
|
||
}
|
||
p.staged[blk.ID] = true
|
||
size, align := slotSize(phi.Type)
|
||
if size == 0 {
|
||
continue
|
||
}
|
||
off = alignUp(off, align)
|
||
p.phiTemp[phi.ID] = off
|
||
off += size
|
||
}
|
||
}
|
||
|
||
// Pass 4: branch-fusion. When a value is consumed solely as a
|
||
// BlockIf control, the SET<cc> + MOVBLZX + slot store + slot
|
||
// reload + TEST + JCC chain (5–6 instructions) collapses to a
|
||
// single direct conditional branch — the comparison's flags
|
||
// are still live at the branch point, so we can JCC straight
|
||
// off them. Candidate ops:
|
||
//
|
||
// OpHelperCall("i32_eqz" / "i64_eqz") — TEST → JE
|
||
// OpEq{32,64}, OpNe{32,64} — CMP → JE / JNE
|
||
// OpLtS / OpLeS / OpLtU / OpLeU — CMP → JLT / JLE / JCS / JLS
|
||
//
|
||
// branchFusedKind[v.ID] is a stable identifier per fused
|
||
// flavour — eqz helpers reuse the helper name as-is; for the
|
||
// SSA comparison ops we use a parallel "i{32,64}_<rel>" naming
|
||
// so each arch's EmitIfBranch can translate one map to its
|
||
// native mnemonics. Detection runs once here so the emitters
|
||
// stay simple lookups.
|
||
for _, blk := range f.Blocks {
|
||
if blk.Kind != ssa.BlockIf || blk.Control == nil {
|
||
continue
|
||
}
|
||
cond := blk.Control
|
||
if usage[cond.ID] != 1 {
|
||
continue
|
||
}
|
||
var kind string
|
||
switch cond.Op {
|
||
case ssa.OpHelperCall:
|
||
name, _ := cond.Aux.(string)
|
||
if name == "i32_eqz" || name == "i64_eqz" {
|
||
kind = name
|
||
}
|
||
case ssa.OpEq32:
|
||
kind = "i32_eq"
|
||
case ssa.OpNe32:
|
||
kind = "i32_ne"
|
||
case ssa.OpLtS32:
|
||
kind = "i32_lt_s"
|
||
case ssa.OpLtU32:
|
||
kind = "i32_lt_u"
|
||
case ssa.OpLeS32:
|
||
kind = "i32_le_s"
|
||
case ssa.OpLeU32:
|
||
kind = "i32_le_u"
|
||
case ssa.OpEq64:
|
||
kind = "i64_eq"
|
||
case ssa.OpNe64:
|
||
kind = "i64_ne"
|
||
case ssa.OpLtS64:
|
||
kind = "i64_lt_s"
|
||
case ssa.OpLtU64:
|
||
kind = "i64_lt_u"
|
||
case ssa.OpLeS64:
|
||
kind = "i64_le_s"
|
||
case ssa.OpLeU64:
|
||
kind = "i64_le_u"
|
||
}
|
||
// Single-bit BTL/BTQ fusion. Two shapes match:
|
||
//
|
||
// (a) cond is OpEq/OpNe against zero, other arg is
|
||
// OpAnd<W>(x, const_pow2) — explicit `(x & K) <op> 0`.
|
||
// (b) cond is OpAnd<W>(x, const_pow2) directly, and the
|
||
// BlockIf treats the value as a boolean ("non-zero").
|
||
// This is the wasm-lowering shape for `if x & K {...}`:
|
||
// no explicit OpNe/OpEq, the And's result IS the cond.
|
||
//
|
||
// Both collapse to
|
||
//
|
||
// BTL $k, x ; JCC <label> ; "bit is clear"
|
||
// BTL $k, x ; JCS <label> ; "bit is set"
|
||
//
|
||
// (and the matching BTQ / TBZ-style mnemonic on arm64).
|
||
// Dropping the AND's emit AND replacing the comparison's
|
||
// SET<cc>+TEST+JCC+JMP with the two-op BTL sequence saves
|
||
// ~4–6 instructions per occurrence.
|
||
if kind == "" {
|
||
// Shape (b) — fall through to the bit-test detection
|
||
// below. We do NOT set branchFused here because we may
|
||
// reject the bit-test pattern and need the default
|
||
// BlockIf emit (which reads the cond's slot) to fire.
|
||
} else {
|
||
p.branchFused[cond.ID] = true
|
||
p.branchFusedKind[cond.ID] = kind
|
||
}
|
||
var andVal *ssa.Value
|
||
var is64 bool
|
||
var wantEq bool
|
||
switch kind {
|
||
case "i32_eq", "i32_ne", "i64_eq", "i64_ne":
|
||
if len(cond.Args) != 2 {
|
||
continue
|
||
}
|
||
andArg, zeroArg := cond.Args[0], cond.Args[1]
|
||
if !isZeroConst(zeroArg) {
|
||
if isZeroConst(andArg) {
|
||
andArg, zeroArg = cond.Args[1], cond.Args[0]
|
||
} else {
|
||
continue
|
||
}
|
||
}
|
||
_ = zeroArg
|
||
andVal = resolveCopy(andArg)
|
||
is64 = kind == "i64_eq" || kind == "i64_ne"
|
||
wantEq = kind == "i32_eq" || kind == "i64_eq"
|
||
case "":
|
||
// Direct shape (b): the cond IS the And. usage[cond] == 1
|
||
// is already guaranteed by the outer check.
|
||
switch cond.Op {
|
||
case ssa.OpAnd32:
|
||
andVal = cond
|
||
is64 = false
|
||
wantEq = false
|
||
case ssa.OpAnd64:
|
||
andVal = cond
|
||
is64 = true
|
||
wantEq = false
|
||
default:
|
||
continue
|
||
}
|
||
default:
|
||
continue
|
||
}
|
||
if andVal == nil {
|
||
continue
|
||
}
|
||
if (is64 && andVal.Op != ssa.OpAnd64) || (!is64 && andVal.Op != ssa.OpAnd32) {
|
||
continue
|
||
}
|
||
// For shape (a) the And's only user is the cond; for shape
|
||
// (b) the And IS the cond, and `usage[cond.ID] == 1` was
|
||
// already enforced. Both cases require the same check.
|
||
if usage[andVal.ID] != 1 {
|
||
continue
|
||
}
|
||
if len(andVal.Args) != 2 {
|
||
continue
|
||
}
|
||
base, mask := andVal.Args[0], andVal.Args[1]
|
||
bit, ok := powerOfTwoBit(mask, is64)
|
||
if !ok {
|
||
base, mask = andVal.Args[1], andVal.Args[0]
|
||
bit, ok = powerOfTwoBit(mask, is64)
|
||
if !ok {
|
||
continue
|
||
}
|
||
}
|
||
newKind := "i32_bittest_ne"
|
||
if is64 {
|
||
newKind = "i64_bittest_ne"
|
||
}
|
||
if wantEq {
|
||
newKind = "i32_bittest_eq"
|
||
if is64 {
|
||
newKind = "i64_bittest_eq"
|
||
}
|
||
}
|
||
// Shape (a): cond.ID already in branchFused; rewrite kind
|
||
// and add the And to branchFusedSkip. Shape (b): cond IS
|
||
// the And; add both branchFused entries and the bit info.
|
||
p.branchFused[cond.ID] = true
|
||
p.branchFusedKind[cond.ID] = newKind
|
||
if cond != andVal {
|
||
p.branchFusedSkip[andVal.ID] = true
|
||
}
|
||
// Note: for shape (b) we don't set branchFusedSkip on the
|
||
// And because cond == andVal and emit already short-circuits
|
||
// when branchFused[cond.ID] is true at the helper-call /
|
||
// comparison emit site... but OpAnd32's emit DOESN'T check
|
||
// branchFused. We need the branchFusedSkip entry on cond
|
||
// in shape (b) so emitBlock's loop skips it.
|
||
p.branchFusedSkip[cond.ID] = true
|
||
if p.branchFusedBit == nil {
|
||
p.branchFusedBit = map[ssa.ValueID]bitTestInfo{}
|
||
}
|
||
p.branchFusedBit[cond.ID] = bitTestInfo{base: base, bit: bit}
|
||
}
|
||
|
||
// Pad to 8-byte alignment so the callee-side prologue's SP is
|
||
// aligned to the platform's stack-alignment expectation.
|
||
off = alignUp(off, 8)
|
||
p.frameSize = off
|
||
|
||
// Decide whether to keep `m` in a register across the
|
||
// function lifetime. Pure-Go does this by virtue of the Go
|
||
// compiler's regalloc — every `m.G0` / `m.M` / `m.someField`
|
||
// access dereferences AX (or whichever GPR holds the receiver)
|
||
// in a single instruction. The asm bundle previously read
|
||
// `m+0(FP)` from the arg slot at every memop / global access /
|
||
// direct-call arg-staging site, costing one redundant memory
|
||
// load per use. Caching `m` in a function-wide reserved
|
||
// register (R11) collapses that to a register move and a single
|
||
// post-CALL refresh.
|
||
//
|
||
// Eligibility: the function must touch `m` enough times to
|
||
// amortise the prologue load + per-CALL refresh. Concretely we
|
||
// require either a memop (always needs m to deref m.M) or at
|
||
// least one direct-call site (the staged m at SP+0 is the most
|
||
// common use). Helper-only / pure-arithmetic leaves stay unset
|
||
// — for them the optimisation is dead weight.
|
||
wantsMCache := p.hasMem
|
||
if !wantsMCache {
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
switch v.Op {
|
||
case ssa.OpCallDirect, ssa.OpCallImport, ssa.OpCallIndirect,
|
||
ssa.OpGlobalGet, ssa.OpGlobalSet,
|
||
ssa.OpMemSize, ssa.OpMemGrow,
|
||
ssa.OpMemoryCopy, ssa.OpMemoryFill,
|
||
ssa.OpExcPending, ssa.OpExcTag, ssa.OpExcVal,
|
||
ssa.OpExcRaise, ssa.OpExcRearm, ssa.OpExcClear:
|
||
wantsMCache = true
|
||
}
|
||
if wantsMCache {
|
||
break
|
||
}
|
||
}
|
||
if wantsMCache {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
// Record the candidacy on the plan; emitFunc decides whether to
|
||
// actually populate mCacheReg per-arch (only amd64 currently
|
||
// implements the use side — arm64 emit code still reads
|
||
// `m+0(FP)` everywhere, and a non-empty mCacheReg would have
|
||
// the prologue emit `MOVQ ..., R11` which arm64 rejects).
|
||
p.mCacheCandidate = wantsMCache
|
||
|
||
// Fused windows: validate the codegen-claimed regions against the
|
||
// final slot map and index them for emission. Quiet per-op
|
||
// fallback on any mismatch, and no indexing at all unless the
|
||
// splicer can emit whole-window bodies (per-op-only splicers and
|
||
// splicer-less arches keep the plain path).
|
||
if _, fusedOK := opts.Splicer.(FusedSplicer); fusedOK {
|
||
if fr, ferr := computeArgFrame(sig); ferr == nil {
|
||
p.planFusedWindows(f, opts.Windows, fr)
|
||
}
|
||
}
|
||
|
||
// computeRegHomes is intentionally deferred to emitFunc — the
|
||
// arch instance is the gate for block-local regalloc, and only
|
||
// archAMD64.SupportsRegHome currently returns true. Running it
|
||
// here unconditionally would assign amd64-named registers
|
||
// (X2..X7 etc.) that the arm64 operandSrcFloat helper would
|
||
// then emit verbatim into arm64 asm, which the assembler
|
||
// rejects.
|
||
|
||
// Detect SSA values whose result has zero downstream
|
||
// consumers. The asm emitter currently follows every CALL with a
|
||
// `MOVL retOff(SP), AX; MOVL AX, dstOff(SP)` pair to land the
|
||
// return value in the producing value's slot — but on call sites
|
||
// like `if v != 0 { _ = Fn31236(m, v) }` the slot is never read
|
||
// and both moves are dead. Pure-Go's compiler drops them entirely.
|
||
//
|
||
// "Used" here means: at least one other value's Args (after
|
||
// OpCopy resolution), at least one block.Control, or one of the
|
||
// BlockRet output positions references this value. Anything else
|
||
// can have its return-value store elided. We currently only
|
||
// honour the flag for OpCall{Direct,Import,Indirect} and
|
||
// OpHelperCall — the OpGlobalGet / OpMemSize / OpMemGrow emit
|
||
// paths might use it later but their result load is wrapped in
|
||
// other state that's not worth touching for the same pass.
|
||
computeUnusedResults(f, p, sig)
|
||
|
||
return p, nil
|
||
}
|
||
|
||
// computeUnusedResults populates plan.unusedResult — CALL-class
|
||
// values whose result has zero LIVE downstream consumers.
|
||
//
|
||
// A naive "any other value references me" check is wrong for the
|
||
// wasm-to-SSA shape: every wasm `local.set N` after a `call` becomes
|
||
// an OpCopy of the CALL result into local N's wire, which keeps the
|
||
// CALL "used" even when local N itself is never read. To handle that
|
||
// we run a proper liveness pass:
|
||
//
|
||
// 1. Seed `live` with sinks — side-effecting ops (CALLs are seeds
|
||
// here so they STAY in the asm even if their result is unused;
|
||
// OpStore* / OpGlobalSet / OpMem*; etc.), block.Control values,
|
||
// and the BlockRet output positions.
|
||
// 2. Propagate backward through Args (resolving OpCopy chains so the
|
||
// producer is what becomes live, not the alias). A value is live
|
||
// iff at least one live consumer chains back to it.
|
||
// 3. For each CALL V, ask "does any LIVE value other than V reference
|
||
// V?". If not, V's result has no real consumer — Pure-Go drops
|
||
// the readback and so do we. Note V itself is always live (it's
|
||
// a side-effecting seed); what we test is whether V's RESULT
|
||
// reaches a real use.
|
||
//
|
||
// Non-CALL ops are deliberately left alone — their result store is
|
||
// already covered by slot-reuse and frame-compaction passes and the
|
||
// emit-time savings are smaller than the cost of widening the rule
|
||
// to every emitter site.
|
||
func computeUnusedResults(f *ssa.Func, p *funcPlan, sig wasm.FuncType) {
|
||
resolveCopy := func(v *ssa.Value) *ssa.Value {
|
||
for hop := 0; v != nil && v.Op == ssa.OpCopy && len(v.Args) == 1 && hop < 16; hop++ {
|
||
if v.Args[0] == v {
|
||
break
|
||
}
|
||
v = v.Args[0]
|
||
}
|
||
return v
|
||
}
|
||
// Build a value-by-ID index so the backward walk can find a value
|
||
// by its producer ID without re-scanning the function each step.
|
||
byID := map[ssa.ValueID]*ssa.Value{}
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
byID[v.ID] = v
|
||
}
|
||
}
|
||
live := map[ssa.ValueID]bool{}
|
||
var worklist []ssa.ValueID
|
||
mark := func(id ssa.ValueID) {
|
||
if !live[id] {
|
||
live[id] = true
|
||
worklist = append(worklist, id)
|
||
}
|
||
}
|
||
// Step 1: seed sinks.
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
if isSideEffectingOp(v.Op) {
|
||
mark(v.ID)
|
||
}
|
||
}
|
||
if blk.Control != nil {
|
||
if r := resolveCopy(blk.Control); r != nil {
|
||
mark(r.ID)
|
||
}
|
||
}
|
||
}
|
||
k := len(sig.Results)
|
||
for _, blk := range f.Blocks {
|
||
if blk.Kind != ssa.BlockRet {
|
||
continue
|
||
}
|
||
n := len(blk.Values)
|
||
if k == 0 || k > n {
|
||
continue
|
||
}
|
||
for j := n - k; j < n; j++ {
|
||
if r := resolveCopy(blk.Values[j]); r != nil {
|
||
mark(r.ID)
|
||
}
|
||
}
|
||
}
|
||
// Step 2: backward propagate.
|
||
for len(worklist) > 0 {
|
||
id := worklist[0]
|
||
worklist = worklist[1:]
|
||
v := byID[id]
|
||
if v == nil {
|
||
continue
|
||
}
|
||
for _, arg := range v.Args {
|
||
if arg == nil {
|
||
continue
|
||
}
|
||
if r := resolveCopy(arg); r != nil {
|
||
mark(r.ID)
|
||
}
|
||
}
|
||
}
|
||
// Step 3: a CALL value's result is unused iff no LIVE value
|
||
// (other than the CALL itself, which is live as a sink)
|
||
// references it through its Args / Control / BlockRet outputs.
|
||
// We compute hasLiveConsumer by re-walking the live set and
|
||
// noting every Arg they touch.
|
||
hasLiveConsumer := map[ssa.ValueID]bool{}
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
if !live[v.ID] {
|
||
continue
|
||
}
|
||
for _, arg := range v.Args {
|
||
if arg == nil {
|
||
continue
|
||
}
|
||
if r := resolveCopy(arg); r != nil && r.ID != v.ID {
|
||
hasLiveConsumer[r.ID] = true
|
||
}
|
||
}
|
||
}
|
||
if blk.Control != nil {
|
||
if r := resolveCopy(blk.Control); r != nil {
|
||
hasLiveConsumer[r.ID] = true
|
||
}
|
||
}
|
||
}
|
||
for _, blk := range f.Blocks {
|
||
if blk.Kind != ssa.BlockRet {
|
||
continue
|
||
}
|
||
n := len(blk.Values)
|
||
if k == 0 || k > n {
|
||
continue
|
||
}
|
||
for j := n - k; j < n; j++ {
|
||
if r := resolveCopy(blk.Values[j]); r != nil {
|
||
hasLiveConsumer[r.ID] = true
|
||
}
|
||
}
|
||
}
|
||
calls, dead := 0, 0
|
||
for _, blk := range f.Blocks {
|
||
for _, v := range blk.Values {
|
||
switch v.Op {
|
||
case ssa.OpCallDirect, ssa.OpCallImport, ssa.OpCallIndirect, ssa.OpHelperCall:
|
||
default:
|
||
continue
|
||
}
|
||
calls++
|
||
if hasLiveConsumer[v.ID] {
|
||
continue
|
||
}
|
||
p.unusedResult[v.ID] = true
|
||
dead++
|
||
}
|
||
}
|
||
_ = calls
|
||
_ = dead
|
||
}
|
||
|
||
// isSideEffectingOp reports whether an SSA op has externally visible
|
||
// side effects that pin it in the program even when its result has no
|
||
// downstream consumer. Used by computeUnusedResults as the seed set
|
||
// for liveness — these ops are "always live" and propagate liveness
|
||
// to their args, so a chain of OpCopy aliases that terminates only at
|
||
// a dead local.set still leaves the producer CALL marked live.
|
||
func isSideEffectingOp(op ssa.Op) bool {
|
||
switch op {
|
||
case ssa.OpCallDirect, ssa.OpCallImport, ssa.OpCallIndirect, ssa.OpHelperCall,
|
||
ssa.OpStore8, ssa.OpStore16, ssa.OpStore32, ssa.OpStore64,
|
||
ssa.OpStoreF32, ssa.OpStoreF64,
|
||
ssa.OpGlobalSet,
|
||
ssa.OpMemoryCopy, ssa.OpMemoryFill, ssa.OpMemGrow,
|
||
// Exc writes mutate the module's exception state; a raise whose
|
||
// pending flag nobody reads in THIS function is still observed
|
||
// by every caller up the propagation chain.
|
||
ssa.OpExcRaise, ssa.OpExcRearm, ssa.OpExcClear:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// capitalizeHelperName matches codegen's helper-name capitalisation
|
||
// for multi-package emission: lowercase first letter becomes
|
||
// uppercase; names already exported pass through. Used to spell the
|
||
// asm CALL target for codegen-emitted helpers like
|
||
// `base·MemorySize(SB)`.
|
||
func capitalizeHelperName(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
r := []rune(s)
|
||
if r[0] >= 'a' && r[0] <= 'z' {
|
||
r[0] = r[0] - 'a' + 'A'
|
||
return string(r)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// wrapperSymbol renders the asm CALL target for one of the
|
||
// asmgen-emitted dispatch wrappers (callImport_N, callIndirect_typeN,
|
||
// loadGlobal_N, storeGlobal_N). Every chunk now carries its own copy
|
||
// of these wrappers (see BuildPackageOptions.SkipWrappers), so the
|
||
// CALL is always a same-package `·name(SB)` reference. Plan9 asm
|
||
// can't CALL a function in another Go package directly — the
|
||
// linker rejects the relocation — and the wrappers are tiny
|
||
// enough that per-chunk duplication is cheaper than the alternative.
|
||
func wrapperSymbol(helperPfx, name string) string {
|
||
_ = helperPfx
|
||
return fmt.Sprintf("·%s(SB)", name)
|
||
}
|
||
|
||
// goAsmSymbol renders a plan9 asm symbol from a Go-side qualified
|
||
// name. "Fn42" (same-package) becomes "·Fn42(SB)"; "base.Fn42"
|
||
// (single-name cross-package) becomes "base·Fn42(SB)";
|
||
// "github.com/foo/bar.Fn42" (full import path) becomes
|
||
// "github·com∕foo∕bar·Fn42(SB)".
|
||
//
|
||
// The remapping is forced by two pieces of the plan 9 asm lexer:
|
||
//
|
||
// - src/cmd/asm/internal/lex/tokenizer.go: isIdentRune accepts
|
||
// only unicode.IsLetter, digits (after position 0), '_',
|
||
// U+00B7 ("·"), and U+2215 ("∕") as identifier runes. The
|
||
// ASCII "/" is reserved as the division operator and ASCII
|
||
// "." is reserved for offset / displacement syntax, so they
|
||
// cannot appear directly in a symbol-reference token.
|
||
//
|
||
// - src/cmd/asm/internal/lex/lex.go: lex.Make post-processes
|
||
// every identifier token by `strings.ReplaceAll`-ing "·" back
|
||
// to "." and "∕" back to "/", so the symbol name the linker
|
||
// finally sees is the canonical Go form
|
||
// (e.g. "github.com/foo/bar.Fn42"). The Unicode runes are
|
||
// purely a source-level escape, not a separate name space.
|
||
//
|
||
// Together those two passes give the lexer exactly one way to
|
||
// embed a "/" or "." inside a symbol operand — the U+00B7 /
|
||
// U+2215 substitution we apply here. Any other punctuation that
|
||
// Go module paths permit ("-", "+", "~", ...) has no plan 9
|
||
// counterpart, which is why the cross-package asm-CALL shapes in
|
||
// internal/codegen and internal/gcasm are gated behind
|
||
// Plan9AsmPathSafe (plan9path.go) and fall back to per-chunk
|
||
// Go-body wrappers for hyphenated host paths.
|
||
//
|
||
// The split between package and symbol uses the LAST dot, which
|
||
// is the only dot that can legitimately separate path from symbol
|
||
// — any earlier dots are part of a path component such as
|
||
// "github.com".
|
||
func goAsmSymbol(qualified string) string {
|
||
if i := strings.LastIndexByte(qualified, '.'); i >= 0 {
|
||
pkg := qualified[:i]
|
||
pkg = strings.ReplaceAll(pkg, "/", "∕")
|
||
pkg = strings.ReplaceAll(pkg, ".", "·")
|
||
return fmt.Sprintf("%s·%s(SB)", pkg, qualified[i+1:])
|
||
}
|
||
return fmt.Sprintf("·%s(SB)", qualified)
|
||
}
|
||
|
||
// ComputeGlobalOffsets returns the byte offsets of each WASM global
|
||
// field within the Module struct. The slice is indexed by the WASM
|
||
// global index (mod.NumImportedGlobals + i for the i-th defined
|
||
// global). Imported globals receive a -1 sentinel because the Module
|
||
// struct never carries them — they live behind the host iface and
|
||
// keep the CALL path.
|
||
//
|
||
// The layout mirrors codegen.translator.emitModuleStruct exactly:
|
||
// Memory []byte (24) + MaxMem uint64 (8) + M unsafe.Pointer (8) when
|
||
// the module has memories, then the exception state (excPending int32
|
||
// + excTag uint32 + excVals [n]uint64) when the module has tags, then
|
||
// 24 bytes per Table ([]any slice header), then each defined global
|
||
// in the order it appears in mod.Globals, aligned to its Go type's
|
||
// natural alignment. (The codegen outline-pack field is absent here:
|
||
// this model serves the standalone package build, which runs without
|
||
// outlining.)
|
||
func ComputeGlobalOffsets(mod *wasm.Module) []int {
|
||
off := 0
|
||
if len(mod.Memories) > 0 {
|
||
off += 24 // Memory []byte slice header (data, len, cap on amd64/arm64)
|
||
off += 8 // MaxMem uint64
|
||
off += 8 // M unsafe.Pointer
|
||
}
|
||
if n := excSlotCount(mod); n > 0 {
|
||
off += 8 + 8*n // excPending int32 + excTag uint32 + excVals [n]uint64
|
||
}
|
||
off += 24 * len(mod.Tables) // T_i []any per table
|
||
n := int(mod.NumImportedGlobals) + len(mod.Globals)
|
||
offs := make([]int, n)
|
||
for i := 0; i < int(mod.NumImportedGlobals); i++ {
|
||
offs[i] = -1
|
||
}
|
||
for i, g := range mod.Globals {
|
||
a, s := goTypeSizeAlign(g.Type.Type)
|
||
off = (off + a - 1) &^ (a - 1)
|
||
offs[int(mod.NumImportedGlobals)+i] = off
|
||
off += s
|
||
}
|
||
return offs
|
||
}
|
||
|
||
// excSlotCount mirrors codegen's maxTagArity: the operand-slot count
|
||
// of the module's exception state, or 0 when the module has no tags.
|
||
// A module that declares only nullary tags still needs the flag and
|
||
// tag fields, and the generated struct gives excVals one unused slot
|
||
// rather than special-casing an empty array — mirror that too.
|
||
func excSlotCount(mod *wasm.Module) int {
|
||
n := 0
|
||
consider := func(typeIdx uint32) {
|
||
if int(typeIdx) >= len(mod.Types) {
|
||
return
|
||
}
|
||
if c := len(mod.Types[typeIdx].Params); c > n {
|
||
n = c
|
||
}
|
||
}
|
||
for _, imp := range mod.Imports {
|
||
if imp.Kind == wasm.ImportTag {
|
||
consider(imp.Tag.TypeIdx)
|
||
}
|
||
}
|
||
for _, tg := range mod.Tags {
|
||
consider(tg.TypeIdx)
|
||
}
|
||
if n == 0 && (mod.NumImportedTags > 0 || len(mod.Tags) > 0) {
|
||
n = 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// ComputeExcOffsets returns the exception-state field offsets within
|
||
// the Module struct, following the same layout model as
|
||
// ComputeGlobalOffsets (standalone build: no outline pack). Nil when
|
||
// the module has no tags — FuncOptions.Exc then stays unset and any
|
||
// function containing an exc op falls back.
|
||
func ComputeExcOffsets(mod *wasm.Module) *ExcOffsets {
|
||
if excSlotCount(mod) == 0 {
|
||
return nil
|
||
}
|
||
off := 0
|
||
if len(mod.Memories) > 0 {
|
||
off += 24 + 8 + 8 // Memory + MaxMem + M
|
||
}
|
||
return &ExcOffsets{Pending: off, Tag: off + 4, Vals: off + 8}
|
||
}
|
||
|
||
// goTypeSizeAlign returns the Go-side (align, size) of the
|
||
// generated Module field for a WASM value type.
|
||
func goTypeSizeAlign(t wasm.ValType) (align, size int) {
|
||
switch t {
|
||
case wasm.ValI32, wasm.ValF32:
|
||
return 4, 4
|
||
case wasm.ValI64, wasm.ValF64:
|
||
return 8, 8
|
||
}
|
||
return 8, 8
|
||
}
|
||
|
||
// globalValType returns the wasm value type of the global at the
|
||
// given module-level global index, accounting for imported globals.
|
||
// The lookup matches FuncTypeOf's pattern: walk imports counting
|
||
// ImportGlobal entries, then fall through to the defined-globals
|
||
// slice for indices beyond the imported range.
|
||
func globalValType(mod *wasm.Module, idx uint32) (wasm.ValType, error) {
|
||
if idx < mod.NumImportedGlobals {
|
||
var i uint32
|
||
for _, imp := range mod.Imports {
|
||
if imp.Kind != wasm.ImportGlobal {
|
||
continue
|
||
}
|
||
if i == idx {
|
||
return imp.Global.Type, nil
|
||
}
|
||
i++
|
||
}
|
||
return 0, fmt.Errorf("imported global %d not found", idx)
|
||
}
|
||
local := idx - mod.NumImportedGlobals
|
||
if local >= uint32(len(mod.Globals)) {
|
||
return 0, fmt.Errorf("global %d out of range (have %d)", idx, len(mod.Globals))
|
||
}
|
||
return mod.Globals[local].Type.Type, nil
|
||
}
|
||
|
||
// slotSize returns (size, alignment) for an SSA value type, or
|
||
// (0, 1) for non-storable kinds (Mem state token, Tuple, Invalid).
|
||
func slotSize(t ssa.Type) (int, int) {
|
||
switch t {
|
||
case ssa.TypeI32, ssa.TypeF32, ssa.TypeBool:
|
||
return 4, 4
|
||
case ssa.TypeI64, ssa.TypeF64:
|
||
return 8, 8
|
||
case ssa.TypeV128:
|
||
// [2]uint64 pair; 8-byte alignment matches the Go-side value.
|
||
return 16, 8
|
||
}
|
||
return 0, 1
|
||
}
|