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

184 lines
5.2 KiB
Go

package codegen
import (
"encoding/binary"
"math"
"strings"
"testing"
"github.com/goccy/wasm2go/internal/wasm"
)
func TestF16BitsToF32Bits(t *testing.T) {
cases := []struct {
h uint16
want uint32
}{
{0x0000, 0x00000000}, // +0
{0x8000, 0x80000000}, // -0
{0x3C00, 0x3F800000}, // 1.0
{0xBC00, 0xBF800000}, // -1.0
{0x4000, 0x40000000}, // 2.0
{0x3555, 0x3EAAA000}, // ~0.3333
{0x7BFF, 0x477FE000}, // max normal 65504
{0x0400, 0x38800000}, // min normal 2^-14
{0x0001, 0x33800000}, // min subnormal 2^-24
{0x03FF, 0x387FC000}, // max subnormal
{0x7C00, 0x7F800000}, // +inf
{0xFC00, 0xFF800000}, // -inf
{0x7E00, 0x7FC00000}, // canonical qNaN
{0x7E01, 0x7FC02000}, // qNaN payload rides along
{0xFDFF, 0xFFBFE000}, // -sNaN, payload preserved
}
for _, c := range cases {
if got := f16BitsToF32Bits(c.h); got != c.want {
t.Errorf("f16 %#04x: got %#08x want %#08x", c.h, got, c.want)
}
}
// Cross-check finite values against the float64 route: the binary16
// value converts exactly to binary32, so going through float64 is
// also exact for non-NaN inputs.
for i := 0; i < 65536; i++ {
h := uint16(i)
exp := (h >> 10) & 0x1F
if exp == 0x1F {
continue // inf/NaN checked above
}
sign := float64(1)
if h>>15 == 1 {
sign = -1
}
var v float64
if exp == 0 {
v = sign * float64(h&0x3FF) * math.Pow(2, -24)
} else {
v = sign * (1 + float64(h&0x3FF)/1024) * math.Pow(2, float64(int(exp)-15))
}
if got := f16BitsToF32Bits(h); got != math.Float32bits(float32(v)) {
t.Fatalf("f16 %#04x: got %#08x want %#08x", h, got, math.Float32bits(float32(v)))
}
}
}
// i32ConstExpr encodes an `i32.const v; end` const expression.
func i32ConstExpr(v int32) []byte {
out := []byte{0x41}
u := uint32(v)
for {
b := byte(u & 0x7F)
u >>= 7
if (u == 0 && b&0x40 == 0) || (u == 0x1FFFFFF && b&0x40 != 0) {
out = append(out, b)
break
}
out = append(out, b|0x80)
}
return append(out, 0x0B)
}
func ieeeF16TableBytes() []byte {
tab := make([]byte, 65536*4)
for i := 0; i < 65536; i++ {
binary.LittleEndian.PutUint32(tab[i*4:], f16BitsToF32Bits(uint16(i)))
}
return tab
}
func TestHasIEEEF16TableAt(t *testing.T) {
const base = 4096
tab := ieeeF16TableBytes()
tr := &translator{mod: &wasm.Module{Datas: []wasm.DataSegment{
{Offset: i32ConstExpr(base), Bytes: tab},
}}}
if !tr.hasIEEEF16TableAt(base) {
t.Fatal("exact IEEE table not accepted")
}
if tr.hasIEEEF16TableAt(base + 4) {
t.Fatal("shifted base must not verify")
}
// One corrupted byte anywhere fails the comparison.
bad := make([]byte, len(tab))
copy(bad, tab)
bad[100*4] ^= 1
tr = &translator{mod: &wasm.Module{Datas: []wasm.DataSegment{
{Offset: i32ConstExpr(base), Bytes: bad},
}}}
if tr.hasIEEEF16TableAt(base) {
t.Fatal("corrupted table accepted")
}
// Split across two segments: still verifies (the image assembles
// from every active segment overlapping the range).
half := len(tab) / 2
tr = &translator{mod: &wasm.Module{Datas: []wasm.DataSegment{
{Offset: i32ConstExpr(base), Bytes: tab[:half]},
{Offset: i32ConstExpr(base + int32(half)), Bytes: tab[half:]},
}}}
if !tr.hasIEEEF16TableAt(base) {
t.Fatal("split IEEE table not accepted")
}
// Passive segments do not populate the initial image.
tr = &translator{mod: &wasm.Module{Datas: []wasm.DataSegment{
{Passive: true, Bytes: tab},
}}}
if tr.hasIEEEF16TableAt(base) {
t.Fatal("passive segment must not count")
}
// No coverage at all: reject without comparing.
tr = &translator{mod: &wasm.Module{}}
if tr.hasIEEEF16TableAt(base) {
t.Fatal("empty module accepted")
}
}
func TestF16TableOKCacheAndAssertion(t *testing.T) {
const base = 8192
tr := &translator{mod: &wasm.Module{Datas: []wasm.DataSegment{
{Offset: i32ConstExpr(base), Bytes: ieeeF16TableBytes()},
}}}
if !tr.f16TableOK(base) {
t.Fatal("verified table rejected")
}
if !tr.f16TableOK(base) {
t.Fatal("cached result flipped")
}
if tr.f16TableOK(base + 8) {
t.Fatal("unverified base accepted")
}
// The opt-out switch turns verification off wholesale, even for a
// statically present table.
tr = &translator{opts: Options{DisableF16Table: true}, mod: &wasm.Module{Datas: []wasm.DataSegment{
{Offset: i32ConstExpr(base), Bytes: ieeeF16TableBytes()},
}}}
if tr.f16TableOK(base) {
t.Fatal("disabled verification still accepted a table")
}
}
func TestUnverifiedF16GatherMsg(t *testing.T) {
for _, tc := range []struct {
disabled bool
tables map[uint32]bool
warn bool
}{
{false, nil, false}, // no gather sites
{false, map[uint32]bool{9013200: true}, false}, // everything verified
{false, map[uint32]bool{4096: false}, true}, // unverified base: warn
{true, map[uint32]bool{4096: false}, false}, // rewrites disabled: silent
{false, map[uint32]bool{4096: false, 8192: true}, true},
} {
msg := unverifiedF16GatherMsg(tc.disabled, tc.tables)
if got := msg != ""; got != tc.warn {
t.Errorf("unverifiedF16GatherMsg(disabled=%v, %v): warn=%v, want %v (msg %q)", tc.disabled, tc.tables, got, tc.warn, msg)
}
}
// The warning names the unverified bases.
if msg := unverifiedF16GatherMsg(false, map[uint32]bool{4096: false}); !strings.Contains(msg, "4096") {
t.Errorf("warning should list unverified bases: %q", msg)
}
}