Files
f57f53361e Replace the engine-specific body retargets with project-supplied assembly overrides (#67)
* codegen: keep fused-tree operands ahead of their consumers after the f16 gather rewrite

rewriteF16GatherGroup appends the collapsed v128_load16x4_u at the end
of the node list and points the chain's tail (now f16x4_cvt) at it, a
forward reference. The asm splicers walk the tree recursively and never
noticed, but the pure twin body emits nodes in list order, so any tree
that reached it through this rewrite declared the load after its use:
"undefined: n6 / declared and not used: n6" from the gcasm capture
build. It surfaced once ggml's wasm f16 vector loads started producing
the gather idiom everywhere (vec_dot_f16, vec_mad_f16, flash attention)
instead of only in the repack kernels.

compactDeadNodes, which every rewrite already runs, now places a node's
operands before the node while otherwise keeping list order — the
collapsed load lands where the chain's last lane load used to be.

* helpers: detect FEAT_I8MM on linux/arm64 (HWCAP2 bit 13)

The kernel-override dispatch and the SMMLA splices gate on it; darwin
already reported it through hw.optional.arm.FEAT_I8MM.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Replace the engine-specific body retargets with project-supplied kernel overrides

wasm2go's job is to read a wasm module and emit Go and, where it can do
better than gc, assembly derived from that wasm. Two features had grown
past that line: the repack GEMM retarget swapped the body of an export
named dbg_gemm_q8_0_4x4 for a hand-written kernel that knows one
engine's quantized block layout, and the vec_dot row/column pairing
recognized one engine's trait-table layout and handed the paired
companion to another hand-written kernel of the same kind. Neither body
came from the wasm, and both tied the transpiler to that engine. On the
current engine build the pairing also no longer measures (prompt and
decode throughput within noise with it off), because the repack path
carries the hot matmuls.

Both are removed: the retarget and its -no-repack-gemm switch, the
pairing and its -vec-dot-pair-entry / -vec-dot-rows switches, the
kernels and the trait-table recognizer. The passive-segment placement
scan the f16-table detection shares moves to its own file.

In their place, a generic mechanism: -kernel-overrides <manifest>. The
project that produced the wasm may supply the assembly body of an
exported leaf function, per architecture and CPU feature level.
wasm2go validates the manifest against the module (export present,
signature and pointer width as declared), wraps each body in a fixed
ABI — the linear-memory base and size in named registers, arguments by
the ABI0 layout rule, a kov_oob label that reaches the module's
out-of-bounds trap — emits a feature-dispatch stub (i8mm > dotprod >
neon; avx512vnni > avx2 > sse4) with the lowered body as the portable
fallback, and pins the exports out of line. It never inspects what a
body computes; the body must stay inside the wasm execution model
(linear memory through the given base and size, bounds-checked, no
calls), and the manifest loader rejects TEXT, calls, system
instructions and foreign symbol references. docs/kernel-overrides.md
states the contract and the verification the project owes.

Tests: manifest validation, ABI text and dispatch shape, and an
end-to-end transpile of a module with and without an override body on
the host architecture — same sums, and the override traps past the end
of memory.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* helpers: detect FEAT_I8MM on darwin/arm64 too

The linux and fallback twins gained CPUI8MM; a darwin build of a bundle
whose kernel overrides declare an i8mm body needs the same symbol in
the base package.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* Rename kernel overrides to assembly overrides

"Kernel" is numeric-computing vocabulary; the mechanism itself knows
nothing about what the function computes — it replaces the body of an
exported function with project-supplied assembly. The name now says
that: -asm-overrides / Options.AsmOverrides, docs/asm-overrides.md,
the manifest's "functions" array, the ovr_oob label and ovr_ data
prefix, and Fn<idx>ovr<feature> body symbols.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-04 00:10:00 +09:00

377 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lower
import (
"fmt"
"sync"
"github.com/goccy/wasm2go/internal/ssa"
"github.com/goccy/wasm2go/internal/wasm"
)
// This file implements the wasm-level function inliner: small leaf
// callees are spliced into their callers during lowering instead of
// emitting an OpCallDirect.
//
// Why wasm2go needs its own inliner when gc already has one: in the
// multi-package layout, cross-chunk calls go through //go:linkname
// forwarders, which are OPAQUE to gc's inliner — a 35-line leaf in p7
// called from the interpreter loop in p0 can never be inlined by gc,
// no matter how small. And on the gcasm backend every surviving call
// additionally pays the ABI0 marshalling the capture transform
// inserts (measured: 1.38× on the SpiderMonkey cpubench) plus, for
// cross-chunk sites, a gcasmFwd trampoline. Inlining at the wasm
// level deletes the call BEFORE chunking, so the marshalling and the
// trampoline disappear with it, on both backends.
//
// Mechanism: the callee body is lowered inline as if it were a
// `block <callee-results> ... end` — a ctrlBlock frame whose post
// block is the join. Callee params become the popped argument values;
// non-param locals are fresh zero constants; `return` inside the
// callee lowers as `br` to the inline frame (identical semantics:
// wasm return IS a branch to the function-body block). The existing
// block/br/phi machinery handles multi-path joins, early returns,
// traps and loops inside the callee with no new CFG code.
//
// v1 policy (deliberately conservative):
// - leaf callees only (no call / call_indirect in the body): bounds
// code growth structurally and makes recursion impossible;
// - no try (EH) in the callee, and the CALLER must not be in
// mutable-locals mode (its local indices would collide);
// - ≤1 result (same limit as handleCall);
// - size caps, all env-tunable for experiments (see below).
// inline policy caps. Chosen from the SpiderMonkey cpubench hot set
// (Fn1466: ~60 B x 36 sites; Fn2120: ~1 KiB x 16 sites).
const (
// Max callee body size in wasm bytes.
inlineMaxBody = 4096
// Max bodyBytes x staticCallSites product: bounds the total code
// growth any single callee can cause module-wide.
inlineMaxProduct = 131072
// Max total wasm bytes inlined into one caller.
inlineCallerBudget = 65536
// Non-leaf callees qualify only when SMALL (the expf/erf class of
// math kernels with cold error-path calls) — opening this wider
// inflates module-wide code growth and compile time out of
// proportion.
inlineNonLeafMaxBody = 512
)
// fnInlineInfo is the per-defined-function summary the inline decision
// consults. Keyed by ABSOLUTE function index (imports first).
type fnInlineInfo struct {
bodyBytes int
leaf bool // no call / call_indirect anywhere in the body
hasTry bool
// noInline pins the function out-of-line regardless of size: it
// is an export whose body the downstream asm bundle replaces (a
// assembly override). Inlining it into every caller would leave the
// exported body dead and the replacement landing on unreachable
// code.
noInline bool
sites int // static `call <idx>` sites referencing this function
// innerCalls lists the direct callees inside the body (dedup'd);
// hasIndirect marks a call_indirect. A non-leaf body may still
// inline when every inner call is direct and non-throwing: the
// inner calls stay ordinary calls inside the inline frame, which
// keeps recursion impossible and code growth capped, and the
// non-throwing requirement keeps the post-call exception check
// (whose edge would need the CALLER's locals) out of the frame.
innerCalls []uint32
hasIndirect bool
}
type inlineAnalysis struct {
fns map[uint32]*fnInlineInfo
}
// inlineAnalysisCache maps *wasm.Module → *inlineAnalysis. LowerFunction
// is called once per function of the same module; the scan runs once.
var inlineAnalysisCache sync.Map
// noInlineExports maps *wasm.Module → map[string]bool of export names
// pinned out of line (see fnInlineInfo.noInline). SetNoInlineExports
// registers them; it must run before the module's first LowerFunction,
// since the inline analysis is computed once and cached.
var noInlineExports sync.Map
// SetNoInlineExports pins the named exports of mod out of line for
// inlining decisions. Call it before lowering any function of mod.
func SetNoInlineExports(mod *wasm.Module, names []string) {
set := map[string]bool{}
for _, n := range names {
set[n] = true
}
noInlineExports.Store(mod, set)
inlineAnalysisCache.Delete(mod)
}
// analyzeModuleForInline scans every defined function body once,
// recording size, leafness, try-presence and per-callee static call
// site counts.
func analyzeModuleForInline(mod *wasm.Module) *inlineAnalysis {
if a, ok := inlineAnalysisCache.Load(mod); ok {
return a.(*inlineAnalysis)
}
a := &inlineAnalysis{fns: make(map[uint32]*fnInlineInfo, len(mod.Functions))}
for i := range mod.Functions {
idx := mod.NumImportedFuncs + uint32(i)
a.fns[idx] = &fnInlineInfo{bodyBytes: len(mod.Functions[i].Body), leaf: true}
}
// Pinned exports: the asm bundle replaces their bodies (assembly
// overrides), so every call must reach the exported body itself.
if pinned, ok := noInlineExports.Load(mod); ok {
for _, e := range mod.Exports {
if e.Kind == wasm.ExportFunc && pinned.(map[string]bool)[e.Name] {
if info, ok := a.fns[e.Index]; ok {
info.noInline = true
}
}
}
}
for i := range mod.Functions {
idx := mod.NumImportedFuncs + uint32(i)
info := a.fns[idx]
if err := scanBodyForInline(mod.Functions[i].Body, a, info); err != nil {
// Unparseable body: mark it never-inlinable. The regular
// lowering will surface the real error with full context.
info.leaf = false
}
}
actual, _ := inlineAnalysisCache.LoadOrStore(mod, a)
return actual.(*inlineAnalysis)
}
// scanBodyForInline walks one function body, filling info (leafness,
// try-presence) and bumping the static-call-site counters of every
// direct callee it references.
func scanBodyForInline(body []byte, a *inlineAnalysis, info *fnInlineInfo) error {
r := wasm.NewInstrReader(body)
if err := skipLocalDecls(r); err != nil {
return err
}
for !r.EOF() {
op, err := r.ReadByte()
if err != nil {
return err
}
switch op {
case wasm.OpCall:
info.leaf = false
callee, err := r.ReadU32()
if err != nil {
return err
}
if ci, ok := a.fns[callee]; ok {
ci.sites++
}
seen := false
for _, c := range info.innerCalls {
if c == callee {
seen = true
break
}
}
if !seen {
info.innerCalls = append(info.innerCalls, callee)
}
case wasm.OpCallIndirect:
info.leaf = false
info.hasIndirect = true
if err := r.SkipImmediates(op); err != nil {
return err
}
case wasm.OpTry:
info.hasTry = true
if err := r.SkipImmediates(op); err != nil {
return err
}
default:
if err := r.SkipImmediates(op); err != nil {
return err
}
}
}
return nil
}
// shouldInline reports whether the direct call to funcIdx should be
// inlined at this call site.
func (ls *lowerState) shouldInline(funcIdx uint32, ft wasm.FuncType) bool {
// Depth 1 only: a call inside an inline frame stays a call. This
// is what makes non-leaf inlining terminate — without it, two
// small functions calling each other expand forever.
if len(ls.inlineFrames) > 0 {
return false
}
if funcIdx < ls.mod.NumImportedFuncs {
return false // imports have no body
}
// A callee that can leave an exception pending needs a post-call check
// whose exception edge carries the CALLER's locals; inside an inline
// frame the local set is the callee's, so the edge would be malformed.
// Refusing such callees also keeps the check-and-branch out of spliced
// bodies entirely.
if ls.mayThrowCall(funcIdx) {
return false
}
if len(ft.Results) > 1 {
return false
}
if ls.inlineInfo == nil {
return false
}
info, ok := ls.inlineInfo.fns[funcIdx]
if !ok || info.hasTry || info.noInline {
return false
}
// A non-leaf body may inline when every inner call is direct,
// defined, and non-throwing: the inner calls stay ordinary calls
// inside the inline frame (no recursion, growth still capped by
// the body-size products), and no post-call exception check is
// needed inside the frame.
if !info.leaf {
// Bound the relaxation tightly: only SMALL non-leaf bodies
// (the expf/erf class of math kernels with cold error-path
// calls) qualify — opening it wider inflates module-wide
// code growth and compile time out of proportion.
if info.bodyBytes > inlineNonLeafMaxBody {
return false
}
if info.hasIndirect {
return false
}
for _, c := range info.innerCalls {
if c < ls.mod.NumImportedFuncs || ls.mayThrowCall(c) {
return false
}
}
}
if info.bodyBytes > inlineMaxBody {
return false
}
if info.bodyBytes*info.sites > inlineMaxProduct {
return false
}
if ls.inlinedBytes+info.bodyBytes > inlineCallerBudget {
return false
}
return true
}
// inlineFrame tracks one in-progress inline expansion. `return` inside
// the spliced body branches to ctrl[ctrlIdx].target instead of sealing
// a BlockRet.
type inlineFrame struct {
ctrlIdx int
savedFt wasm.FuncType
savedLocals []*ssa.Value
savedLocalTypes []ssa.Type
}
// lowerInlineCall splices the body of funcIdx at the current cursor.
// Preconditions: shouldInline returned true (leaf, no-EH, size-capped
// callee; non-mutable-locals caller).
func (ls *lowerState) lowerInlineCall(funcIdx uint32, ft wasm.FuncType) error {
if len(ls.stack) < len(ft.Params) {
return fmt.Errorf("inline call fn%d: stack underflow (need %d, have %d)", funcIdx, len(ft.Params), len(ls.stack))
}
argStart := len(ls.stack) - len(ft.Params)
args := append([]*ssa.Value(nil), ls.stack[argStart:]...)
ls.stack = ls.stack[:argStart]
fn := ls.mod.Functions[funcIdx-ls.mod.NumImportedFuncs]
// Callee locals: params = the argument values, non-param locals =
// fresh zero constants (wasm zero-initialises declared locals).
locals := append([]*ssa.Value(nil), args...)
types := make([]ssa.Type, 0, len(ft.Params))
for i, p := range ft.Params {
t := toSSAType(p)
if t == ssa.TypeInvalid {
return fmt.Errorf("%w: inline fn%d: param %d type %v", ErrSSAUnsupported, funcIdx, i, p)
}
types = append(types, t)
}
r := wasm.NewInstrReader(fn.Body)
nDecls, err := r.ReadU32()
if err != nil {
return err
}
for i := uint32(0); i < nDecls; i++ {
count, err := r.ReadU32()
if err != nil {
return err
}
tb, err := r.ReadByte()
if err != nil {
return err
}
st := toSSAType(wasm.ValType(tb))
if st == ssa.TypeInvalid {
return fmt.Errorf("%w: inline fn%d: local type 0x%02x", ErrSSAUnsupported, funcIdx, tb)
}
for j := uint32(0); j < count; j++ {
locals = append(locals, zeroValueOf(ls.b, st))
types = append(types, st)
}
}
// Open the inline frame: semantically `block <callee-results>`.
postBlk := ls.b.NewBlock(ssa.BlockPlain)
ls.ctrl = append(ls.ctrl, &ctrlFrame{
kind: ctrlBlock,
target: postBlk,
fallthrough_: postBlk,
resultCount: len(ft.Results),
stackHeightAtEntry: len(ls.stack),
})
ls.inlineFrames = append(ls.inlineFrames, inlineFrame{
ctrlIdx: len(ls.ctrl) - 1,
savedFt: ls.ft,
savedLocals: ls.locals,
savedLocalTypes: ls.localTypes,
})
baseCtrl := len(ls.ctrl) - 1 // inline body finished once ctrl pops back to this depth
ls.ft = ft
ls.locals = locals
ls.localTypes = types
ls.inlinedBytes += len(fn.Body)
if err := ls.lowerBodyUntil(r, baseCtrl); err != nil {
return fmt.Errorf("inline fn%d: %w", funcIdx, err)
}
// Restore the caller's view. The operand stack already holds the
// callee's result (pushed by the inline frame's end join) — or the
// cursor is unreachable if the callee provably never returns.
inf := ls.inlineFrames[len(ls.inlineFrames)-1]
ls.inlineFrames = ls.inlineFrames[:len(ls.inlineFrames)-1]
ls.ft = inf.savedFt
ls.locals = inf.savedLocals
ls.localTypes = inf.savedLocalTypes
if len(ls.ctrl) != baseCtrl {
return fmt.Errorf("%w: inline fn%d: control stack imbalance (%d != %d)", ErrSSAUnsupported, funcIdx, len(ls.ctrl), baseCtrl)
}
return nil
}
// returnAsInlineBr lowers `return` inside an inline body: a branch to
// the innermost inline frame's post block, carrying the callee's
// results — exactly the semantics of `br` to the function-body block.
func (ls *lowerState) returnAsInlineBr() error {
inf := ls.inlineFrames[len(ls.inlineFrames)-1]
frame := ls.ctrl[inf.ctrlIdx]
nRes := frame.resultCount
if len(ls.stack) < nRes {
return fmt.Errorf("inline return with %d stack values, expected %d", len(ls.stack), nRes)
}
ls.recordIncoming(frame.target, nRes)
cur := ls.b.Current()
cur.Kind = ssa.BlockPlain
ssa.AddEdge(cur, frame.target)
ls.unreachable = true
return nil
}