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

213 lines
7.7 KiB
Go

// Package transpile is the public library API of the wasm2go transpiler:
// parse a WebAssembly binary and translate it to standalone Go source.
//
// It is the importable counterpart of the `wasm2go` command. External
// tools that need to drive translation programmatically (for example a
// code generator that emits a Go bridge over a transpiled module) call
// Translate directly instead of shelling out to the CLI.
//
// Usage:
//
// m, err := transpile.Parse(bytes.NewReader(wasmBytes))
// if err != nil { ... }
// res, err := transpile.Translate(out, m, transpile.Options{
// Package: "genwasm",
// OutputImportPath: "example.com/myproj/internal/genwasm",
// })
//
// The SSA pipeline, data sidecar layout, native wasip1 implementation,
// and (for large modules) the multi-package + linkname-split layout
// are auto-derived from the input and have no caller-visible knobs.
package transpile
import (
"bytes"
"fmt"
"io"
"os"
"sort"
"github.com/goccy/wasm2go/internal/asmgen"
"github.com/goccy/wasm2go/internal/codegen"
"github.com/goccy/wasm2go/internal/gcasm"
"github.com/goccy/wasm2go/internal/wasm"
)
// Module is a parsed WebAssembly module — an opaque handle produced by
// Parse and consumed by Translate.
type Module = wasm.Module
// Options configures Translate. Package and OutputImportPath are
// required; the remaining fields are described in the field
// documentation on the type. See the package doc above for the list
// of behaviors wasm2go auto-derives — none of them are configurable.
type Options = codegen.Options
// Result holds the auxiliary outputs of Translate:
//
// - Sidecars: data files (e.g. the data.bin //go:embed blob).
// - Files: in multi-package mode, the relative-path → contents map of
// every generated Go file; nil in single-file mode.
// - AuxFiles: extra Go-source files that must land next to the main
// output (e.g. future runtime companions). Currently always empty —
// the WASI runtime is a single self-contained file with no
// per-platform splits — but the field is retained so callers can
// keep handling it generically.
type Result = codegen.Result
// Parse decodes a WebAssembly binary module from r.
func Parse(r io.Reader) (*Module, error) {
return wasm.Parse(r)
}
// Translate emits Go source for module m. When the wasm fits the
// single-file budget, source is written to w and Result.Files is nil.
// When the wasm exceeds the budget, w is unused and Result.Files
// holds the relative-path → bytes for the multi-package layout. It
// is the library entry point that the wasm2go command itself wraps.
//
// The amd64 fast path is the gcasm backend: the pure-Go bodies are
// compiled by the host Go toolchain at GENERATION time, the -S
// listings are captured and mechanically transformed into ABI0 .s
// (see internal/gcasm). Functions the transform cannot register-map
// keep their pure bodies per-function. Every non-amd64 GOARCH builds
// the pure code, which outperforms the retired own-emitter asm in
// every measured configuration.
func Translate(w io.Writer, m *Module, opts Options) (Result, error) {
var mainBuf bytes.Buffer
// Assembly overrides: validate the project's manifest against the
// module up front, and pin its exports out of line before lowering.
var ovr *gcasm.AsmOverrides
if opts.AsmOverrides != "" {
var err error
if ovr, err = gcasm.LoadAsmOverrides(opts.AsmOverrides, m); err != nil {
return Result{}, err
}
names := make([]string, 0, len(ovr.Functions))
for name := range ovr.Functions {
names = append(names, name)
}
sort.Strings(names)
opts.NoInlineExports = append(append([]string(nil), opts.NoInlineExports...), names...)
}
res, err := codegen.Translate(&mainBuf, m, opts)
if err != nil {
return res, err
}
// PureOnly: the pure bodies were emitted untagged, so they compile
// on every GOARCH and no asm bundle is wanted. This is the
// ABIInternal reference backend for benchmarking.
if opts.PureOnly {
if _, err := w.Write(mainBuf.Bytes()); err != nil {
return res, err
}
return res, nil
}
// gcasm backend: replace the own-emitter asm bundle.
treeIn := map[string][]byte{}
for name, data := range res.Sidecars {
treeIn[name] = data
}
for name, data := range res.Files {
treeIn[name] = data
}
synthSigs := map[string]gcasm.SynthSig{}
for name, sig := range res.OutlinedSigs {
synthSigs[name] = gcasm.SynthSig{Params: sig.Params, Result: sig.Result, Packed: sig.Packed}
}
var directAsm map[string]gcasm.DirectAsmFn
for name, df := range res.DirectAsmSSA {
if directAsm == nil {
directAsm = map[string]gcasm.DirectAsmFn{}
}
var windows []asmgen.FusedWindow
for _, w := range df.Windows {
conv := func(srcs []codegen.DirectAsmParamSrc) []asmgen.FusedParamSrc {
out := make([]asmgen.FusedParamSrc, len(srcs))
for i, s := range srcs {
out[i] = asmgen.FusedParamSrc{IsConst: s.IsConst, Const: s.Const, Val: s.Val, ArgIdx: s.ArgIdx}
}
return out
}
windows = append(windows, asmgen.FusedWindow{
Tree: w.Tree, Members: w.Members, Roots: w.Roots,
ScalarSrc: conv(w.ScalarSrc), FloatSrc: conv(w.FloatSrc), PairSrc: conv(w.PairSrc),
})
}
directAsm[name] = gcasm.DirectAsmFn{Fn: df.Fn, Sig: df.Sig, Packed: df.Packed, PackedParams: df.PackedParams, Windows: windows}
}
var directAsmExc *asmgen.ExcOffsets
if res.DirectAsmExc != nil {
directAsmExc = &asmgen.ExcOffsets{
Pending: res.DirectAsmExc.Pending,
Tag: res.DirectAsmExc.Tag,
Vals: res.DirectAsmExc.Vals,
}
}
gcasmFiles, gstats, err := gcasm.Build(m, mainBuf.Bytes(), treeIn, opts.OutputImportPath, res.FusedSimd, res.FusedLoops, res.Outlined, synthSigs, gcasm.Config{
FastMath: opts.FastMath,
AsmOverrides: ovr,
FuseLoopUnroll: opts.FuseLoopUnroll,
DirectAsm: directAsm,
DirectAsmGlobals: res.DirectAsmGlobals,
DirectAsmExc: directAsmExc,
})
if err != nil {
return res, fmt.Errorf("gcasm backend: %w", err)
}
if n := gstats.SimdSpliced + gstats.SimdKept; n > 0 {
fmt.Fprintf(os.Stderr, "wasm2go: gcasm SIMD splice: %d call sites inlined, %d kept as calls\n",
gstats.SimdSpliced, gstats.SimdKept)
}
if n := gstats.DirectAsm + gstats.DirectAsmFallback; n > 0 {
fmt.Fprintf(os.Stderr, "wasm2go: direct-asm: %d bodies emitted, %d fell back to the transform\n",
gstats.DirectAsm, gstats.DirectAsmFallback)
}
if res.Files == nil {
res.Files = map[string][]byte{}
}
for name, data := range gcasmFiles {
if data == nil {
delete(res.Files, name)
continue
}
res.Files[name] = data
}
if _, err := w.Write(mainBuf.Bytes()); err != nil {
return res, err
}
return res, nil
}
// Transpile is the one-shot convenience wrapper that runs Parse followed
// by Translate, returning the same Result Translate would. It is the
// path most callers want when they have the wasm bytes in hand and do
// not need to inspect the parsed Module.
func Transpile(r io.Reader, w io.Writer, opts Options) (Result, error) {
m, err := Parse(r)
if err != nil {
return Result{}, err
}
return Translate(w, m, opts)
}
// SetMultiPackageThreshold overrides the auto-multi-package decision
// threshold to b bytes for the rest of the current process. Pass 0 to
// always select the multi-package + linkname-split layout regardless
// of wasm size; pass -1 to restore the default (auto-derived from
// wasm size). The returned closure restores the previous value and
// should be invoked via defer:
//
// defer transpile.SetMultiPackageThreshold(0)()
//
// //
// The defaults are auto-derived from wasm size and most callers
// should not touch this. The override is supported for diagnostics,
// build-time memory tuning, and exercising the multi-package path on
// small fixtures in tests.
func SetMultiPackageThreshold(b int) func() {
return codegen.SetMultiPackageThreshold(b)
}