Files
8b87a233f0 Auto-detect runtime-built f16 tables; fail the build on stale asserts (#53)
* codegen: auto-detect runtime-built f16 tables; fail on stale asserts

The f16->f32 table's address had to be asserted from outside
(-f16-table): ggml-style engines build the table at runtime, so the
static byte-check cannot see it. That external magic number proved a
liability twice over — the address moves with any layout shift
(source changes, even build-cache state), a stale value silently
disables every table-keyed rewrite (the llama-wasm v0.2.1 release
shipped ~35% slower decode this way), and the existing staleness
warning never ran for multi-package modules because that layout
returns before the check.

Derive the address from the module's own initialization code instead:
an init loop that streams constant-strided stores from a constant
base and provably covers the table's whole 256KiB range. Detection is
structural over the lowered SSA — a loop-carried pointer phi with
constant start and advance, a counter phi with a computable trip
count, stores through the pointer — so it does not depend on how the
compiler vectorized the conversion arithmetic inside the loop. The
scan runs lazily on the first gather-site query.

-f16-table remains as an explicit override, now cross-checked: a set
address that contradicts a module-verified base FAILS the build,
naming the address to adopt; the no-evidence case keeps the old
warning, which now also runs on the multi-package path.

Verified against the llama engine module: flagless transpile
auto-detects the table and produces a byte-identical bundle to the
correctly-asserted build (121 FCVT conversions intact); a stale
assert aborts with the actionable error.

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

* codegen: drop the f16-table address input; keep only a boolean opt-out

With auto-detection proven equivalent (flagless transpile of the
engine module is byte-identical to the previously asserted build),
an address-carrying override has no remaining legitimate use — it
only reintroduces the stale-value hazard. -f16-table is gone;
-no-f16-table disables the table-keyed rewrites outright. The
end-of-translation diagnostic is now unconditional: gather sites
whose base verified neither statically nor through init-loop
coverage are reported, so a detection miss after a module change is
loud in every pipeline log.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-24 16:57:54 +09:00

343 lines
8.9 KiB
Go

package codegen
// Runtime-table detection: derive the f16->f32 table's address from
// the module's own initialization code instead of trusting an external
// assertion.
//
// ggml-style engines build the 65536-entry f16->f32 lookup table at
// runtime (ggml_init), so the module's data image holds zeros and the
// static byte-check in hasIEEEF16TableAt can never see it. An
// externally-asserted address used to bridge that gap, but proved a
// liability: the table MOVES with every layout shift (source
// changes, even build-cache state), and a stale assertion silently
// disabled the table-verified rewrites — or worse, blessed whatever
// now lived at the old address.
//
// The initialization loop itself is the in-module proof this pass
// extracts: a loop that streams constant-strided stores from a
// constant base, covering the table's whole range before any use.
// Detection is structural over the SSA form — a loop-carried pointer
// phi with a constant start and constant per-iteration advance, a
// loop-carried counter phi with a computable trip count, and stores
// through the pointer — so it does not depend on how the compiler
// vectorized the conversion arithmetic inside the loop.
import (
"fmt"
"os"
"github.com/goccy/wasm2go/internal/lower"
"github.com/goccy/wasm2go/internal/ssa"
)
// initStoreRegion is one byte interval a detected init loop provably
// writes: [lo, hi).
type initStoreRegion struct {
lo, hi uint64
}
// f16TableBytes is the size of an IEEE f16->f32 table: 65536 entries
// of 4 bytes.
const f16TableBytes = 1 << 18
// detectRuntimeTables scans every function body for init-loop store
// regions. Lowering failures are skipped silently here — the main
// translation pass surfaces them as build errors with full context.
func (t *translator) detectRuntimeTables() {
if t.runtimeTables != nil {
return
}
t.runtimeTables = []initStoreRegion{}
for i := range t.mod.Functions {
idx := uint32(len(t.mod.Imports)) + uint32(i)
fn, err := lower.LowerFunction(t.mod, idx, t.funcName(idx), t.throwSet)
if err != nil || fn == nil {
continue
}
t.runtimeTables = append(t.runtimeTables, detectInitStoreRegions(fn)...)
}
}
// runtimeInitCovered reports whether some detected init loop writes
// the entire f16 table range starting at base.
func (t *translator) runtimeInitCovered(base uint32) bool {
lo, hi := uint64(base), uint64(base)+f16TableBytes
for _, r := range t.runtimeTables {
if r.lo <= lo && hi <= r.hi {
return true
}
}
return false
}
// detectInitStoreRegions finds the store intervals of one function's
// constant-based streaming loops.
func detectInitStoreRegions(f *ssa.Func) []initStoreRegion {
var out []initStoreRegion
for _, header := range f.Blocks {
// Loop-carried linear variables present themselves as phis in
// one header block: pointer candidates (constant init) and
// counter candidates (computable trip count) live side by side.
type linear struct {
phi *ssa.Value
init, step int64
}
var ptrs, ctrs []linear
for _, v := range header.Values {
if v.Op != ssa.OpPhi {
continue
}
init, step, ok := linearPhi(v)
if !ok || step == 0 {
continue
}
ptrs = append(ptrs, linear{v, init, step})
ctrs = append(ctrs, linear{v, init, step})
}
if len(ptrs) == 0 {
continue
}
// Trip count from any counter phi whose next value feeds a
// zero compare that controls a branch (clang's canonical
// countdown: q starts negative, steps up, exits at zero).
trips := int64(0)
for _, c := range ctrs {
if n := tripCount(f, c.phi, c.init, c.step); n > 0 {
trips = n
break
}
}
if trips <= 0 {
continue
}
for _, p := range ptrs {
if p.init <= 0 || p.step <= 0 {
continue
}
minOff, maxEnd, found := storeExtent(f, p.phi)
if !found {
continue
}
lo := uint64(p.init) + uint64(minOff)
hi := uint64(p.init) + uint64(trips-1)*uint64(p.step) + uint64(maxEnd)
if hi > lo {
out = append(out, initStoreRegion{lo: lo, hi: hi})
}
}
}
return out
}
// linearPhi matches phi(init=const, next=phi+const...) in either arg
// order, folding a chain of constant adds (a pointer bumped twice per
// iteration still resolves, with the step totalled).
func linearPhi(p *ssa.Value) (init, step int64, ok bool) {
if len(p.Args) != 2 {
return 0, 0, false
}
for i := 0; i < 2; i++ {
c, cok := constOf(p.Args[i])
if !cok {
continue
}
s, sok := addChainStep(p.Args[1-i], p)
if !sok {
continue
}
return c, s, true
}
return 0, 0, false
}
// addChainStep resolves v as root+const (through a chain of adds with
// constant operands) and returns the summed constant.
func addChainStep(v, root *ssa.Value, hops ...int) (int64, bool) {
if len(hops) > 0 && hops[0] > 8 {
return 0, false
}
depth := 0
if len(hops) > 0 {
depth = hops[0]
}
if v == root {
return 0, true
}
if v.Op != ssa.OpAdd32 && v.Op != ssa.OpAdd64 {
return 0, false
}
if len(v.Args) != 2 {
return 0, false
}
for i := 0; i < 2; i++ {
c, cok := constOf(v.Args[i])
if !cok {
continue
}
rest, rok := addChainStep(v.Args[1-i], root, depth+1)
if !rok {
continue
}
return c + rest, true
}
return 0, false
}
func constOf(v *ssa.Value) (int64, bool) {
if v == nil {
return 0, false
}
switch v.Op {
case ssa.OpConst32, ssa.OpConst64:
return v.AuxInt, true
}
return 0, false
}
// tripCount derives how many iterations execute before the loop-
// carried counter's next value reaches its bound. Two shapes cover
// the emitted countdowns: exit on zero (init and step of opposite
// sign) via eq/ne-with-zero controls, and exit on an unsigned
// less-than bound.
func tripCount(f *ssa.Func, q *ssa.Value, init, step int64) int64 {
if step == 0 {
return 0
}
// The "next" value: the phi arg that is not the constant init.
var next *ssa.Value
for _, a := range q.Args {
if _, isConst := constOf(a); !isConst {
next = a
}
}
if next == nil {
return 0
}
for _, b := range f.Blocks {
ctl := b.Control
if ctl == nil || len(ctl.Args) != 2 {
continue
}
var other *ssa.Value
switch {
case ctl.Args[0] == next || ctl.Args[0] == q:
other = ctl.Args[1]
case ctl.Args[1] == next || ctl.Args[1] == q:
other = ctl.Args[0]
default:
continue
}
bound, ok := constOf(other)
if !ok {
continue
}
switch ctl.Op {
case ssa.OpNe32, ssa.OpNe64, ssa.OpEq32, ssa.OpEq64:
// Exit when next == bound: iterations = (bound-init)/step.
d := bound - init
if step != 0 && d%step == 0 && d/step > 0 {
return d / step
}
case ssa.OpLtU32, ssa.OpLtU64, ssa.OpLtS32, ssa.OpLtS64:
// Count-up i < N with unit-ish steps.
d := bound - init
if step > 0 && d > 0 {
n := d / step
if d%step != 0 {
n++
}
return n
}
}
}
return 0
}
// storeExtent scans the function for stores addressed through ptr
// (directly or ptr+const) and returns the smallest store offset and
// the largest offset+width per iteration.
func storeExtent(f *ssa.Func, ptr *ssa.Value) (minOff, maxEnd int64, found bool) {
consider := func(base *ssa.Value, off int64, width int64) {
rel, ok := addChainStep(base, ptr)
if !ok {
return
}
o := rel + off
if !found || o < minOff {
minOff = o
}
if e := o + width; !found || e > maxEnd {
maxEnd = e
}
found = true
}
for _, b := range f.Blocks {
for _, v := range b.Values {
switch v.Op {
case ssa.OpStore8, ssa.OpStore16, ssa.OpStore32, ssa.OpStore64,
ssa.OpStoreF32, ssa.OpStoreF64:
if len(v.Args) < 1 {
continue
}
consider(v.Args[0], v.AuxInt, storeWidth(v.Op))
case ssa.OpSimdMemCall:
name, _ := v.Aux.(string)
w := simdStoreWidth(name)
if w == 0 || len(v.Args) < 2 {
continue
}
off, ok := constOf(v.Args[1])
if !ok {
continue
}
consider(v.Args[0], off, w)
}
}
}
return minOff, maxEnd, found
}
func storeWidth(op ssa.Op) int64 {
switch op {
case ssa.OpStore8:
return 1
case ssa.OpStore16:
return 2
case ssa.OpStore32, ssa.OpStoreF32:
return 4
case ssa.OpStore64, ssa.OpStoreF64:
return 8
}
return 0
}
// simdStoreWidth maps the SIMD store helper names to bytes written;
// zero means "not a store".
func simdStoreWidth(helper string) int64 {
switch helper {
case "simd_v128_store", "simd_m64_v128_store":
return 16
case "simd_v128_store64_lane", "simd_m64_v128_store64_lane":
return 8
case "simd_v128_store32_lane", "simd_m64_v128_store32_lane":
return 4
case "simd_v128_store16_lane", "simd_m64_v128_store16_lane":
return 2
case "simd_v128_store8_lane", "simd_m64_v128_store8_lane":
return 1
}
return 0
}
// noteF16TableDetection logs the auto-detected verification once per
// base, so pipelines see which address the rewrites keyed on.
func (t *translator) noteF16TableDetection(base uint32) {
if t.f16Announced == nil {
t.f16Announced = map[uint32]bool{}
}
if t.f16Announced[base] {
return
}
t.f16Announced[base] = true
fmt.Fprintf(os.Stderr, "wasm2go: f16 table auto-detected at %d (runtime init-loop coverage)\n", base)
}