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

180 lines
5.0 KiB
Go

package codegen
import (
"encoding/binary"
"fmt"
"os"
"sort"
)
// The f16 load-convert selection (see the scalarizer's gather rewrite)
// replaces reads of a module-resident f16->f32 lookup table with the
// FCVTL instruction. That is only sound when the table IS the IEEE
// conversion: the transpiler verifies the module's initial memory
// image byte-for-byte before enabling the rewrite. No pattern of
// table CONTENT is assumed — a module with any other table keeps the
// literal loads.
// f16BitsToF32Bits is the IEEE binary16 -> binary32 conversion,
// bit-exact including subnormals, infinities and NaN payloads
// (payload shifts left by 13; the quiet bit rides along). This is
// what FCVTL computes per lane and what such a table holds.
func f16BitsToF32Bits(h uint16) uint32 {
sign := uint32(h>>15) << 31
exp := uint32(h>>10) & 0x1F
man := uint32(h) & 0x3FF
switch exp {
case 0:
if man == 0 {
return sign // ±0
}
// Subnormal: value = man * 2^-24. Normalize into a binary32
// exponent.
e := uint32(113)
for man&0x400 == 0 {
man <<= 1
e--
}
return sign | e<<23 | (man&0x3FF)<<13
case 0x1F:
return sign | 0xFF<<23 | man<<13 // ±inf / NaN
}
return sign | (exp+112)<<23 | man<<13
}
// hasIEEEF16TableAt reports whether the module's data segments
// (active, or passive with a recovered start-section placement)
// fully populate [base, base+65536*4) with table[i] == the IEEE
// conversion of i. Bytes not covered by any active segment default to
// zero in the initial image; the table has almost no zero entries, so
// coverage gaps fail the comparison naturally.
func (t *translator) hasIEEEF16TableAt(base uint32) bool {
const tableLen = 65536 * 4
img := make([]byte, tableLen)
covered := 0
placements := t.passiveSegmentPlacements()
for segIdx, ds := range t.mod.Datas {
var off int64
if ds.Passive {
// Shared-memory (threads) links make every segment
// passive; the recovered start-section placement stands in
// for the active-segment offset (see
// passiveSegmentPlacements).
po, ok := placements[segIdx]
if !ok {
continue
}
off = po
} else {
eo, err := evalConstExprI64(ds.Offset, t.mod)
if err != nil {
continue
}
off = eo
}
segEnd := off + int64(len(ds.Bytes))
lo, hi := int64(base), int64(base)+tableLen
if segEnd <= lo || off >= hi {
continue
}
s, e := max64(off, lo), min64(segEnd, hi)
copy(img[s-lo:e-lo], ds.Bytes[s-off:e-off])
covered += int(e - s)
}
if covered == 0 {
return false
}
for i := 0; i < 65536; i++ {
if binary.LittleEndian.Uint32(img[i*4:]) != f16BitsToF32Bits(uint16(i)) {
return false
}
}
return true
}
// checkStaleF16Table warns, once translation is done, about f16
// gather sites whose base verified NEITHER statically nor through
// init-loop detection: the table-keyed rewrites stayed off there — a
// pure performance loss that is otherwise invisible. The warning is
// unconditional (no configuration gates it) so a detection miss
// after a module change is loud in every pipeline log.
func (t *translator) checkStaleF16Table() error {
if msg := unverifiedF16GatherMsg(t.opts.DisableF16Table, t.f16TablesOK); msg != "" {
fmt.Fprintln(os.Stderr, msg)
}
return nil
}
// unverifiedF16GatherMsg is checkStaleF16Table's decision: empty
// means the rewrites are disabled, no gather sites exist, or every
// queried base verified.
func unverifiedF16GatherMsg(disabled bool, tables map[uint32]bool) string {
if disabled {
return ""
}
var unverified []uint32
for base, ok := range tables {
if !ok {
unverified = append(unverified, base)
}
}
if len(unverified) == 0 {
return ""
}
sort.Slice(unverified, func(i, j int) bool { return unverified[i] < unverified[j] })
return fmt.Sprintf(
"wasm2go: f16 gather sites at bases %v were not verified (no static table image, no init-loop coverage) — the table-keyed rewrites stayed OFF there",
unverified)
}
func max64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func min64(a, b int64) int64 {
if a < b {
return a
}
return b
}
// f16TableOK is the cached table check, shaped as the callback the
// SSA gather pass takes. Two ways a table qualifies:
//
// - the module's initial data image holds the IEEE map at base
// (verified byte-for-byte), or
// - the module's own code provably initializes the whole range at
// base (an init loop streaming constant-strided stores — the
// ggml_init shape; see detectRuntimeTables).
//
// There is no external address input: verification is derived from
// the module alone, and Options.DisableF16Table turns the whole
// mechanism off.
func (t *translator) f16TableOK(base uint32) bool {
if t.opts.DisableF16Table {
return false
}
if t.f16TablesOK == nil {
t.f16TablesOK = map[uint32]bool{}
}
ok, seen := t.f16TablesOK[base]
if seen {
return ok
}
switch {
case t.hasIEEEF16TableAt(base):
ok = true
default:
t.detectRuntimeTables()
if t.runtimeInitCovered(base) {
ok = true
t.noteF16TableDetection(base)
}
}
t.f16TablesOK[base] = ok
return ok
}