Files
f30ec292fd Support the wasm SIMD proposal with native asm lowering (arm64/amd64) (#40)
* Support the wasm SIMD proposal with native asm lowering (arm64/amd64)

One revision spanning the SIMD (0xfd v128) proposal end to end: SSA
representation, lowering, scalar reference helpers, and a gcasm asm
backend that turns the hot paths into native SIMD — measured on a
full llama.cpp build transpiled to pure Go, which this makes run at
V8-class speed (prompt eval beats V8 on arm64).

Frontend and SSA:
- v128 values are [2]uint64 pairs in little-endian lane order; pure
  ops lower to CSE-able helper calls, memory ops keep statement
  order, v128.const/shuffle ride as [2]uint64 aux. The full scalar
  helper set is the reference semantics (saturation, NaN
  propagation, round-to-even, swizzle OOB, shift masking).
- Exception handling lowers to plain branches instead of
  panic/recover — a check-and-branch after each may-raise call, a
  per-try dispatch block running the catch-clause chain — covering
  the C++-shaped patterns (multi-clause catch, delegate targets,
  rethrow depths) without runtime unwinding, so EH bodies stay
  eligible for the asm backend. This replaces the closure/recover
  lowering that #39 extended; its tests are retained unchanged.
- Giant functions outline into tractable units (whole loops
  included, packed u64 boundaries for v128 values), keeping gc
  compile times and register pressure sane on llama.cpp-sized
  modules.

gcasm backend:
- SIMD helper calls splice inline as native NEON/SSE instead of
  marshalled calls; v128 SSA values travel register-to-register in
  pair form. Call-tree windows fuse into single splices with region
  invariant hoisting, bounds-check coalescing, store sinks and
  scalar-chain internalization; whole countdown loops fuse with an
  in-splice unroll lane.
- Superinstruction selection inside fused regions: 8-bit dot
  patterns (SMULL+SADALP), SDOT for full 16-byte dots, FMUL by
  element, scale-lookup addressing, inlined integer division, the
  software fp32->fp16 idiom lowered to FCVT (both the table-gather
  and the packed store form), and ggml q8_0 row/column pairing via
  the traits nrows patch with an SMMLA 2x2 tile kernel.
- Runtime CPU-feature dispatch gates spliced bodies (dotprod on
  arm64, x86-64-v2 SSE on amd64); WASM2GO_FAST_MATH opts into
  raw-SDOT/FMLA/dual-accumulator forms that trade wasm bit-exactness
  for native-build-style rounding, validated by token-level
  equivalence against native llama.cpp.
- Guard-page linear memory eliminates per-access bounds checks;
  jump tables replace the compare tree for br_table.

Tooling and CI:
- WASM2GO_F16_TABLE asserts the address of ggml's runtime-built f16
  table; the transpiler warns when the asserted address matches no
  gather site (the address moves with every data-layout shift, and a
  stale assertion silently costs ~7% decode throughput).
- The coverage gate runs with -count=1: a cached package result
  merges incomplete coverage data into the -coverpkg profile,
  deflating the total by 10+ points depending on what the runner's
  restored cache contained — the gate must never consume cached
  results.
- Coverage threshold moves to 83% for the enlarged tree; transpile
  output is deterministic and outline rejections are explained in
  the build log.

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

* Replace environment-variable switches with typed Options; drop debug code

Configuration now flows through one channel: transpile.Options gains
OutlineMinValues, SIMDUnroll, FuseLoops, FuseLoopUnroll, F16TableAddr
and FastMath (with matching CLI flags), the gcasm backend receives
them as a Config threaded through ModuleOffsets, and no code path
branches on an environment variable anymore. Integrator entry points
(the wasmify protoc plugin, the CLI) convert their external
configuration into these options exactly once.

Deleted outright, per the no-env rule and because they were
diagnostics rather than build modes:
- every WASM2GO_NO_* kill switch and bisection knob (SSA pass
  disabling, fallback ranges, fuse-window caps, outline limits,
  inliner windows) — git history keeps them for the next hunt;
- every env-gated debug printer (fuse/inline/outline/nrc2/f16/SSA
  dumps and the near-miss tracers) and the capture-tree keepers;
- the dormant multi-exit loop structuring (opt-in, measured slower)
  and the SSA f32->f16 rewrite entry superseded by the gcasm
  peephole (its shared idiom matchers stay for the f16 store pass);
- the runtime WASM2GO_CPU_PORTABLE override in the generated CPU
  feature probes.

The nil-offsets error paths exposed by the threading get nil-safe
Config accessors, and the outline share/v128-min/inliner caps become
plain constants at their measured production values.

Behavior under default options is unchanged: transpiled output for
the non-SIMD and EH fixtures is byte-identical to the previous
commit, and the only SIMD-bundle difference is the removed env check
in the emitted cpufeat runtime files.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-08 23:28:14 +09:00

324 lines
12 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 pass
import (
"testing"
"github.com/goccy/wasm2go/internal/ssa"
)
// simdLoad builds an OpSimdMemCall v128.load of base with the given
// memarg offset, the exact shape the wasm lowering produces (offset as
// a Const32 second argument).
func simdLoad(b *ssa.FuncBuilder, base *ssa.Value, off int32) *ssa.Value {
return b.NewValueAux(ssa.OpSimdMemCall, ssa.TypeV128, "simd_v128_load", base, b.Const32(off))
}
// countAux counts block values carrying the given OpSimdMemCall aux.
func countAux(vals []*ssa.Value, aux string) int {
n := 0
for _, v := range vals {
if v != nil && v.Op == ssa.OpSimdMemCall && v.Aux == aux {
n++
}
}
return n
}
// findAux returns the first block value carrying the given aux.
func findAux(vals []*ssa.Value, aux string) *ssa.Value {
for _, v := range vals {
if v != nil && v.Op == ssa.OpSimdMemCall && v.Aux == aux {
return v
}
}
return nil
}
// rngWindow extracts the (rlo, span) constant arguments of a rewritten
// group-leading load.
func rngWindow(t *testing.T, v *ssa.Value) (int64, int64) {
t.Helper()
if len(v.Args) != 4 {
t.Fatalf("load_rng arg count = %d, want 4 (addr, offset, rlo, span)", len(v.Args))
}
if v.Args[2].Op != ssa.OpConst32 || v.Args[3].Op != ssa.OpConst32 {
t.Fatalf("load_rng rlo/span are %v/%v, want Const32", v.Args[2].Op, v.Args[3].Op)
}
return int64(int32(v.Args[2].AuxInt)), int64(int32(v.Args[3].AuxInt))
}
// TestCoalesceSimdBoundsGroup collapses same-base loads at small
// constant offsets — the ggml kernel inner-loop shape — into one
// range-checked leader plus unchecked members, and verifies the check
// covers exactly [lo, hi+16).
func TestCoalesceSimdBoundsGroup(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
l0 := simdLoad(b, base, 0)
l1 := simdLoad(b, base, 16)
l2 := simdLoad(b, base, 48)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
sum = b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", sum, l2)
b.FinishRet(sum)
f := b.Func()
if !CoalesceSimdBounds(f) {
t.Fatalf("CoalesceSimdBounds returned false; expected the 3-load group to coalesce")
}
vals := f.Entry.Values
if got := countAux(vals, "simd_v128_load_rng"); got != 1 {
t.Fatalf("load_rng count = %d, want 1", got)
}
if got := countAux(vals, "simd_v128_load_nc"); got != 2 {
t.Errorf("unchecked load count = %d, want 2", got)
}
if got := countAux(vals, "simd_v128_load"); got != 0 {
t.Errorf("checked load count = %d, want 0", got)
}
rng := findAux(vals, "simd_v128_load_rng")
if rng != l0 {
t.Errorf("group leader is v%d, want the FIRST load v%d", rng.ID, l0.ID)
}
if rlo, span := rngWindow(t, rng); rlo != 0 || span != 64 {
t.Errorf("check window = [%d, %d+%d), want [0, 0+64)", rlo, rlo, span)
}
if err := ssa.Verify(f); err != nil {
t.Fatalf("verify after coalesce: %v", err)
}
}
// TestCoalesceSimdBoundsPeelsAddend groups loads whose addresses are
// OpAdd32(base, Const32) at different non-negative addends with the
// shared base, rebasing the window onto the leader's own address.
func TestCoalesceSimdBoundsPeelsAddend(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
a1 := b.NewValue(ssa.OpAdd32, ssa.TypeI32, base, b.Const32(32))
l0 := simdLoad(b, base, 0)
l1 := simdLoad(b, a1, 8)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
b.FinishRet(sum)
f := b.Func()
if !CoalesceSimdBounds(f) {
t.Fatalf("CoalesceSimdBounds returned false; expected addend peel to group both loads")
}
rng := findAux(f.Entry.Values, "simd_v128_load_rng")
if rng == nil {
t.Fatal("no load_rng emitted")
}
if rng != l0 {
t.Errorf("group leader is v%d, want v%d", rng.ID, l0.ID)
}
// Leader's peeled addend is 0, group covers [0, 40+16): rlo 0,
// span 56.
if rlo, span := rngWindow(t, rng); rlo != 0 || span != 32+8+16 {
t.Errorf("check window = [%d, +%d), want [0, +%d)", rlo, span, 32+8+16)
}
}
// TestCoalesceSimdBoundsLeaderNotLowest rebases the window when the
// group's FIRST load is not its lowest address: rlo goes negative,
// which the signed helper accepts.
func TestCoalesceSimdBoundsLeaderNotLowest(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
hiAddr := b.NewValue(ssa.OpAdd32, ssa.TypeI32, base, b.Const32(48))
l0 := simdLoad(b, hiAddr, 0) // total 48, leader
l1 := simdLoad(b, base, 0) // total 0
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
b.FinishRet(sum)
f := b.Func()
if !CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds returned false; expected out-of-order group to coalesce")
}
rng := findAux(f.Entry.Values, "simd_v128_load_rng")
if rng != l0 {
t.Fatalf("group leader is not the first load")
}
// Window relative to the leader's addr (base+48): [−48, −48+80).
if rlo, span := rngWindow(t, rng); rlo != -48 || span != 48+16 {
t.Errorf("check window = [%d, +%d), want [-48, +64)", rlo, span)
}
}
// TestCoalesceSimdBoundsBarrier keeps groups from crossing a store:
// the store's own trap must stay ordered against the loads' checks.
func TestCoalesceSimdBoundsBarrier(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
l0 := simdLoad(b, base, 0)
b.NewValueAux(ssa.OpSimdMemCall, ssa.TypeI32, "simd_v128_store", base, b.Const32(64), l0)
l1 := simdLoad(b, base, 16)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
b.FinishRet(sum)
f := b.Func()
if CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds coalesced across a v128.store barrier")
}
if got := countAux(f.Entry.Values, "simd_v128_load"); got != 2 {
t.Errorf("checked load count = %d, want 2 (both must keep their own checks)", got)
}
}
// TestCoalesceSimdBoundsInterleaved groups two independent streams
// whose loads alternate — the ggml dot-kernel shape (x+2, y+2, x+18,
// y+18): a different-base load must not close the other stream's
// group.
func TestCoalesceSimdBoundsInterleaved(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32, ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
x := b.Param(0, ssa.TypeI32)
y := b.Param(1, ssa.TypeI32)
x0 := simdLoad(b, x, 2)
y0 := simdLoad(b, y, 2)
x1 := simdLoad(b, x, 18)
y1 := simdLoad(b, y, 18)
s0 := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", x0, y0)
s1 := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", x1, y1)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", s0, s1)
b.FinishRet(sum)
f := b.Func()
if !CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds returned false; expected both interleaved streams to coalesce")
}
vals := f.Entry.Values
if got := countAux(vals, "simd_v128_load_rng"); got != 2 {
t.Fatalf("load_rng count = %d, want 2 (one per stream)", got)
}
if got := countAux(vals, "simd_v128_load_nc"); got != 2 {
t.Errorf("unchecked load count = %d, want 2", got)
}
if x0.Aux != "simd_v128_load_rng" || y0.Aux != "simd_v128_load_rng" {
t.Errorf("stream leaders are (%v, %v), want the first load of each stream", x0.Aux, y0.Aux)
}
// The window is relative to the leader's addr argument (x): the
// lowest access is x+2 and the group covers [x+2, x+18+16).
if rlo, span := rngWindow(t, x0); rlo != 2 || span != 32 {
t.Errorf("x window = [%d, +%d), want [2, +32)", rlo, span)
}
if err := ssa.Verify(f); err != nil {
t.Fatalf("verify after coalesce: %v", err)
}
}
// TestCoalesceSimdBoundsMultipleGroups exercises two barrier-separated
// group regions in one block: the second region's collected indices
// must survive the first region's insertions, including when its base
// is defined after the first region (a stale insertion index would
// plant constants before the definition and fail the verifier).
func TestCoalesceSimdBoundsMultipleGroups(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32, ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
p0 := b.Param(0, ssa.TypeI32)
p1 := b.Param(1, ssa.TypeI32)
a0 := simdLoad(b, p0, 0)
a1 := simdLoad(b, p0, 16)
s0 := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", a0, a1)
b.NewValueAux(ssa.OpSimdMemCall, ssa.TypeI32, "simd_v128_store", p0, b.Const32(256), s0)
base2 := b.NewValue(ssa.OpAdd32, ssa.TypeI32, p0, p1)
b0 := simdLoad(b, base2, 0)
b1 := simdLoad(b, base2, 16)
s1 := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", b0, b1)
b.FinishRet(s1)
f := b.Func()
if !CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds returned false; expected both groups to coalesce")
}
vals := f.Entry.Values
if got := countAux(vals, "simd_v128_load_rng"); got != 2 {
t.Fatalf("load_rng count = %d, want 2", got)
}
if got := countAux(vals, "simd_v128_load_nc"); got != 2 {
t.Errorf("unchecked load count = %d, want 2", got)
}
// Def-before-use over the whole block: every value's args must
// appear earlier (params live outside the slice).
seen := map[*ssa.Value]bool{p0: true, p1: true}
for _, v := range vals {
if v == nil {
continue
}
for _, a := range v.Args {
if a.Block == f.Entry && !seen[a] && a.Op != ssa.OpParam {
t.Fatalf("v%d uses v%d before its definition", v.ID, a.ID)
}
}
seen[v] = true
}
}
// TestCoalesceSimdBoundsWindow refuses to group loads whose constant
// spread exceeds the wrap-safety window.
func TestCoalesceSimdBoundsWindow(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
l0 := simdLoad(b, base, 0)
l1 := simdLoad(b, base, 1<<17)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
b.FinishRet(sum)
f := b.Func()
if CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds grouped loads 128 KiB apart; window must refuse")
}
}
// TestCoalesceSimdBoundsNegativePeel refuses to peel a negative
// addend: member displacements must stay non-negative for the wrap
// exactness argument.
func TestCoalesceSimdBoundsNegativePeel(t *testing.T) {
sig := ssa.FuncSig{Params: []ssa.Type{ssa.TypeI32}, Results: []ssa.Type{ssa.TypeV128}}
b := ssa.NewFuncBuilder("f", sig)
entry := b.NewBlock(ssa.BlockRet)
b.SetEntry(entry)
b.SetCurrent(entry)
base := b.Param(0, ssa.TypeI32)
below := b.NewValue(ssa.OpAdd32, ssa.TypeI32, base, b.Const32(-16))
l0 := simdLoad(b, base, 0)
l1 := simdLoad(b, below, 0)
sum := b.NewValueAux(ssa.OpSimdCall, ssa.TypeV128, "simd_i8x16_add", l0, l1)
b.FinishRet(sum)
f := b.Func()
// The negative-addend load keeps its own base identity (no peel),
// so the two loads have different bases: two singleton groups,
// nothing coalesces.
if CoalesceSimdBounds(f) {
t.Fatal("CoalesceSimdBounds grouped across a negative peeled addend")
}
if got := countAux(f.Entry.Values, "simd_v128_load"); got != 2 {
t.Errorf("checked load count = %d, want 2", got)
}
}