Skip to content

Commit 4bf0314

Browse files
committed
test(enginetest): quant-parity harness — pure-Go affine reference vs BackendQuant (rocm #13)
engine/enginetest had session/textmodel conformance suites but no quant-parity check. Add one, runnable in CI with no accelerator present (arithmetic, not throughput): - quant_reference.go: ReferenceAffineMatVec, a pure-Go, engine-free reimplementation of the group-affine dequant+matvec contract (model.QuantMatVec.MatVec, model/quant.go) — LSB-first bit-packed codes, one bf16 scale+bias per group, float64 accumulation. Imports no engine package, so agreement is evidence of correctness, not self-consistency. - quant_parity.go: QuantParity(t, backend) fetches model.BackendQuant(backend, "affine"), runs it against a small deterministic fixture, and compares to the reference (byte-identical fast path, else a documented per-element bf16 tolerance for accumulation-order differences). Absent registration skips and reports, matching SessionHandle/TextModel's optional-capability shape. - quant_reference_test.go: self-test of the reference alone — a hand-checked single-group case (dot product lands on an exactly-bf16-representable value, so the expected bytes are pinned exactly), validation-surface and zero-sized cases, and a fixture/ codec round-trip. - engine/metal/model_quant_parity_test.go wires engine/metal as the proving consumer: TestNativeAffineQuantParity calls enginetest.QuantParity(t, "native") against affineQMV.MatVec (model_quant.go) — real GPU dispatch via QMVBF16, not a mock. Metal proof, run on this Mac (MLX_METALLIB_PATH pointed at a pre-built mlx.metallib from a sibling checkout of this repo; no special build tag needed — plain darwin/arm64 + the existing requireNativeRuntime skip-guard, matching qmv_test.go/gemv_test.go): MLX_METALLIB_PATH=/Users/snider/Code/core/go-inference/build/dist/lib/mlx.metallib \ go test -v -count=1 -run TestNativeAffineQuantParity ./engine/metal/... === RUN TestNativeAffineQuantParity --- PASS: TestNativeAffineQuantParity (0.05s) PASS ok dappco.re/go/inference/engine/metal 0.482s go test ./engine/enginetest/...: 36 passed, 0 failed. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 243e6bd commit 4bf0314

4 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package enginetest
4+
5+
import (
6+
"bytes"
7+
"math"
8+
"testing"
9+
10+
"dappco.re/go/inference/model"
11+
)
12+
13+
// affineParityTol bounds the per-element float32 gap QuantParity accepts when a
14+
// backend's MatVec is not byte-identical to ReferenceAffineMatVec. Two correct
15+
// implementations of the same group-affine dot product can still land on
16+
// different bf16 output bytes: a GPU kernel reduces inDim terms with a
17+
// parallel/tree accumulation order, this reference sums serially in float64 —
18+
// the two orders round differently at the final bf16 store even when every
19+
// intermediate is exact. The tolerance is documented, not tuned to pass: it is
20+
// a small multiple of one bf16 ULP (~2^-7 relative) at the fixture's O(1)
21+
// output magnitude, not a value picked after the fact to make a divergent
22+
// implementation pass.
23+
const affineParityTol = 0.05
24+
25+
// quantAffineFixture builds a small, fully deterministic group-affine
26+
// fixture — every value is a formula, never randomness, so a divergence
27+
// between two runs (or two backends) is always a real behavioural
28+
// difference, never fixture noise. outDim=8, inDim=128, groupSize=64, bits=4
29+
// mirrors the (groupSize, bits) pairing engine/metal's own real-dispatch
30+
// quant tests already exercise (qgemv_test.go, arch_session_bench_test.go) —
31+
// a combination the metallib's compiled kernel templates are known to
32+
// instantiate — while staying a unit-scale, no-accelerator-required check.
33+
func quantAffineFixture() (x, packed, scales, biases []byte, outDim, inDim, groupSize, bits int) {
34+
outDim, inDim, groupSize, bits = 8, 128, 64, 4
35+
groupsPerRow := inDim / groupSize
36+
rowPacked := inDim * bits / 8
37+
rowSB := groupsPerRow * 2
38+
maxCode := uint32(1)<<uint(bits) - 1
39+
40+
packed = make([]byte, outDim*rowPacked)
41+
scales = make([]byte, outDim*rowSB)
42+
biases = make([]byte, outDim*rowSB)
43+
for r := 0; r < outDim; r++ {
44+
pRow := packed[r*rowPacked : (r+1)*rowPacked]
45+
sRow := scales[r*rowSB : (r+1)*rowSB]
46+
bRow := biases[r*rowSB : (r+1)*rowSB]
47+
for g := 0; g < groupsPerRow; g++ {
48+
scale := 0.25 + 0.125*float32(g+r)
49+
bias := -1 + 0.5*float32((g+r)%3)
50+
sh, bh := bf16Encode(scale), bf16Encode(bias)
51+
sRow[g*2], sRow[g*2+1] = byte(sh), byte(sh>>8)
52+
bRow[g*2], bRow[g*2+1] = byte(bh), byte(bh>>8)
53+
for j := 0; j < groupSize; j++ {
54+
c := g*groupSize + j
55+
code := uint32(c*5+r*3+1) % (maxCode + 1)
56+
affineSetCode(pRow, c*bits, bits, code)
57+
}
58+
}
59+
}
60+
x = make([]byte, inDim*2)
61+
for i := 0; i < inDim; i++ {
62+
v := 0.1 * float32((i%7)-3)
63+
h := bf16Encode(v)
64+
x[i*2], x[i*2+1] = byte(h), byte(h>>8)
65+
}
66+
return x, packed, scales, biases, outDim, inDim, groupSize, bits
67+
}
68+
69+
// QuantParity validates a backend's registered "affine" quant compute
70+
// (model.BackendQuant(backend, "affine"), model/quant.go) against the
71+
// pure-Go ReferenceAffineMatVec on a small deterministic fixture: it checks
72+
// arithmetic correctness, not throughput, so it runs in CI with no
73+
// accelerator present. A backend that has not registered the "affine" kind
74+
// is reported and skipped — present ⇒ exercised, absent ⇒ skipped and
75+
// reported, the same optional-capability shape SessionHandle/TextModel use
76+
// for their own probed capabilities (session.go, textmodel.go).
77+
func QuantParity(t *testing.T, backend string) {
78+
t.Helper()
79+
q, ok := model.BackendQuant(backend, "affine")
80+
if !ok {
81+
t.Skipf("no backend quant registered for (%q, %q) — nothing to check", backend, "affine")
82+
}
83+
84+
x, packed, scales, biases, outDim, inDim, groupSize, bits := quantAffineFixture()
85+
86+
got, err := q.MatVec(x, packed, scales, biases, outDim, inDim, groupSize, bits)
87+
if err != nil {
88+
t.Fatalf("%s/affine MatVec: %v", backend, err)
89+
}
90+
want, err := ReferenceAffineMatVec(x, packed, scales, biases, outDim, inDim, groupSize, bits)
91+
if err != nil {
92+
t.Fatalf("ReferenceAffineMatVec (harness bug): %v", err)
93+
}
94+
if len(got) != len(want) {
95+
t.Fatalf("%s/affine MatVec output length = %d, want %d", backend, len(got), len(want))
96+
}
97+
if bytes.Equal(got, want) {
98+
return // byte-identical — the strongest receipt
99+
}
100+
for i := 0; i < outDim; i++ {
101+
gv := float64(bf16Decode(got[i*2], got[i*2+1]))
102+
wv := float64(bf16Decode(want[i*2], want[i*2+1]))
103+
if d := math.Abs(gv - wv); d > affineParityTol {
104+
t.Fatalf("%s/affine MatVec[%d] = %v, reference = %v (diff %v > tol %v)",
105+
backend, i, gv, wv, d, affineParityTol)
106+
}
107+
}
108+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package enginetest
4+
5+
import (
6+
"math"
7+
8+
core "dappco.re/go"
9+
)
10+
11+
// quant_reference.go is the pure-Go, engine-independent reference for the
12+
// group-affine weight-quant decode projection every backend registers against
13+
// model.BackendQuant(backend, "affine") (model/quant.go): dequantise a packed
14+
// row — LSB-first bit-packed codes, one bf16 scale+bias per group — to
15+
// float32, then dot with the bf16 activation vector. It imports no engine
16+
// package: the group-affine format (MLX's packing scheme) is reimplemented
17+
// independently here rather than borrowed from any backend's own dequantizer,
18+
// so agreement between a backend's MatVec and ReferenceAffineMatVec is
19+
// evidence the backend's arithmetic is correct, not merely self-consistent
20+
// with itself.
21+
22+
// bf16Decode reads one little-endian bfloat16 (2 bytes: lo, hi) as float32.
23+
func bf16Decode(lo, hi byte) float32 {
24+
return math.Float32frombits(uint32(uint16(lo)|uint16(hi)<<8) << 16)
25+
}
26+
27+
// bf16Encode converts a float32 to bfloat16 bits with round-to-nearest-even.
28+
func bf16Encode(v float32) uint16 {
29+
bits := math.Float32bits(v)
30+
if bits&0x7fffffff > 0x7f800000 { // NaN: keep it quiet, non-zero mantissa
31+
return uint16(bits>>16) | 0x0040
32+
}
33+
rounding := (bits>>16)&1 + 0x7fff
34+
return uint16((bits + rounding) >> 16)
35+
}
36+
37+
// affineExtractCode reads the bits-wide affine code at bit offset bitOff from a
38+
// packed row, LSB-first contiguous — MLX's group-affine packing (for 4-bit this
39+
// is the familiar low-nibble-then-high-nibble layout; other widths span byte
40+
// boundaries the same way).
41+
func affineExtractCode(p []byte, bitOff, bits int) uint32 {
42+
var v uint32
43+
for got := 0; got < bits; {
44+
bi := (bitOff + got) / 8
45+
off := (bitOff + got) % 8
46+
take := min(8-off, bits-got)
47+
chunk := (uint32(p[bi]) >> uint(off)) & ((1 << uint(take)) - 1)
48+
v |= chunk << uint(got)
49+
got += take
50+
}
51+
return v
52+
}
53+
54+
// affineSetCode writes a bits-wide code at bit offset bitOff within p,
55+
// LSB-first across byte boundaries — the exact inverse of affineExtractCode.
56+
// Used only to build this package's own deterministic fixtures (quant_parity.go).
57+
func affineSetCode(p []byte, bitOff, bits int, code uint32) {
58+
for got := 0; got < bits; {
59+
bi := (bitOff + got) / 8
60+
off := (bitOff + got) % 8
61+
take := min(8-off, bits-got)
62+
mask := byte((1<<uint(take))-1) << uint(off)
63+
shifted := byte((code >> uint(got)) << uint(off))
64+
p[bi] = (p[bi] &^ mask) | (shifted & mask)
65+
got += take
66+
}
67+
}
68+
69+
// ReferenceAffineMatVec is the pure-Go reference for the group-affine quant
70+
// decode projection: out = x @ Wᵀ for a group-affine quantised (outDim x inDim)
71+
// weight — bf16 activations in, bf16 result out — matching model.QuantMatVec's
72+
// MatVec contract (model/quant.go) exactly, so a backend's registered
73+
// implementation and this function are directly comparable on the same
74+
// fixture. packed/scales/biases follow MLX's group-affine layout: packed is
75+
// outDim*inDim*bits/8 LSB-first bit-packed codes; scales and biases are each
76+
// outDim*(inDim/groupSize) bf16 values, one pair per group per row; the
77+
// dequantised weight element is scale*code+bias. The row/activation dot
78+
// product accumulates in float64 for a stable, order-independent reference sum.
79+
func ReferenceAffineMatVec(x, packed, scales, biases []byte, outDim, inDim, groupSize, bits int) ([]byte, error) {
80+
if outDim < 0 || inDim < 0 {
81+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: outDim/inDim must be non-negative")
82+
}
83+
if outDim == 0 || inDim == 0 {
84+
return make([]byte, outDim*2), nil
85+
}
86+
if bits <= 0 || bits > 8 {
87+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: bits must be in 1..8")
88+
}
89+
if groupSize <= 0 || inDim%groupSize != 0 {
90+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: groupSize must be > 0 and divide inDim")
91+
}
92+
if inDim*bits%8 != 0 {
93+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: inDim*bits must be byte-aligned")
94+
}
95+
if len(x) != inDim*2 {
96+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: len(x) must equal inDim bf16 bytes")
97+
}
98+
rowPacked := inDim * bits / 8
99+
rowSB := (inDim / groupSize) * 2
100+
if len(packed) != outDim*rowPacked || len(scales) != outDim*rowSB || len(biases) != outDim*rowSB {
101+
return nil, core.NewError("enginetest.ReferenceAffineMatVec: packed/scales/biases size mismatch")
102+
}
103+
104+
xf := make([]float64, inDim)
105+
for i := range xf {
106+
xf[i] = float64(bf16Decode(x[i*2], x[i*2+1]))
107+
}
108+
109+
out := make([]byte, outDim*2)
110+
for r := 0; r < outDim; r++ {
111+
pRow := packed[r*rowPacked : (r+1)*rowPacked]
112+
sRow := scales[r*rowSB : (r+1)*rowSB]
113+
bRow := biases[r*rowSB : (r+1)*rowSB]
114+
var acc float64
115+
for c := 0; c < inDim; c++ {
116+
g := c / groupSize
117+
scale := bf16Decode(sRow[g*2], sRow[g*2+1])
118+
bias := bf16Decode(bRow[g*2], bRow[g*2+1])
119+
code := affineExtractCode(pRow, c*bits, bits)
120+
w := float64(scale)*float64(code) + float64(bias)
121+
acc += w * xf[c]
122+
}
123+
h := bf16Encode(float32(acc))
124+
out[r*2], out[r*2+1] = byte(h), byte(h>>8)
125+
}
126+
return out, nil
127+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package enginetest
4+
5+
import "testing"
6+
7+
// TestReferenceAffineMatVec_Good pins the reference's arithmetic on a
8+
// hand-checkable single-group case: 1 row, 2 columns, one group of 2,
9+
// scale=2, bias=1, codes [3,5] → dequantised weight [7,11]; x=[1.0,0.5] →
10+
// dot=12.5, which bf16 represents exactly (its mantissa bits beyond bf16's 7
11+
// are all zero), so the expected output bytes are pinned exactly rather than
12+
// tolerance-compared.
13+
func TestReferenceAffineMatVec_Good(t *testing.T) {
14+
const outDim, inDim, groupSize, bits = 1, 2, 2, 4
15+
packed := make([]byte, outDim*inDim*bits/8)
16+
affineSetCode(packed, 0*bits, bits, 3)
17+
affineSetCode(packed, 1*bits, bits, 5)
18+
if packed[0] != 0x53 {
19+
t.Fatalf("fixture packing: packed[0] = %#x, want 0x53", packed[0])
20+
}
21+
sh := bf16Encode(2.0)
22+
bh := bf16Encode(1.0)
23+
scales := []byte{byte(sh), byte(sh >> 8)}
24+
biases := []byte{byte(bh), byte(bh >> 8)}
25+
xh0, xh1 := bf16Encode(1.0), bf16Encode(0.5)
26+
x := []byte{byte(xh0), byte(xh0 >> 8), byte(xh1), byte(xh1 >> 8)}
27+
28+
got, err := ReferenceAffineMatVec(x, packed, scales, biases, outDim, inDim, groupSize, bits)
29+
if err != nil {
30+
t.Fatalf("ReferenceAffineMatVec: %v", err)
31+
}
32+
want := bf16Encode(12.5)
33+
if got[0] != byte(want) || got[1] != byte(want>>8) {
34+
gv := bf16Decode(got[0], got[1])
35+
t.Fatalf("ReferenceAffineMatVec = %v (bytes %#x %#x), want 12.5 (bytes %#x %#x)",
36+
gv, got[0], got[1], byte(want), byte(want>>8))
37+
}
38+
}
39+
40+
// TestReferenceAffineMatVec_Bad pins the validation surface: a malformed
41+
// fixture is rejected with an error, never a panic or a silently wrong answer.
42+
func TestReferenceAffineMatVec_Bad(t *testing.T) {
43+
x, packed, scales, biases, outDim, inDim, groupSize, bits := quantAffineFixture()
44+
45+
if _, err := ReferenceAffineMatVec(x[:len(x)-1], packed, scales, biases, outDim, inDim, groupSize, bits); err == nil {
46+
t.Error("short x must be rejected")
47+
}
48+
if _, err := ReferenceAffineMatVec(x, packed[:len(packed)-1], scales, biases, outDim, inDim, groupSize, bits); err == nil {
49+
t.Error("short packed must be rejected")
50+
}
51+
if _, err := ReferenceAffineMatVec(x, packed, scales, biases, outDim, inDim, 3, bits); err == nil {
52+
t.Error("groupSize not dividing inDim must be rejected")
53+
}
54+
if _, err := ReferenceAffineMatVec(x, packed, scales, biases, outDim, inDim, groupSize, 0); err == nil {
55+
t.Error("bits <= 0 must be rejected")
56+
}
57+
if _, err := ReferenceAffineMatVec(x, packed, scales, biases, outDim, inDim, groupSize, 9); err == nil {
58+
t.Error("bits > 8 must be rejected")
59+
}
60+
}
61+
62+
// TestReferenceAffineMatVec_Ugly pins the zero-sized surprising-but-valid
63+
// case: outDim or inDim of 0 returns a clean empty result, mirroring the
64+
// backends' own zero-sized MatVec fast path (e.g. engine/metal's
65+
// QMVBF16Into), never an error.
66+
func TestReferenceAffineMatVec_Ugly(t *testing.T) {
67+
got, err := ReferenceAffineMatVec(nil, nil, nil, nil, 0, 0, 64, 4)
68+
if err != nil {
69+
t.Fatalf("zero-sized ReferenceAffineMatVec: %v", err)
70+
}
71+
if len(got) != 0 {
72+
t.Fatalf("zero-sized ReferenceAffineMatVec length = %d, want 0", len(got))
73+
}
74+
}
75+
76+
// TestQuantAffineFixtureRoundTrips_Good checks quant_parity.go's own fixture
77+
// builder against affineExtractCode — the packer and the reference's own
78+
// unpacker must agree on every code, or QuantParity would be comparing two
79+
// backends against a fixture that doesn't mean what it claims to.
80+
func TestQuantAffineFixtureRoundTrips_Good(t *testing.T) {
81+
_, packed, _, _, outDim, inDim, _, bits := quantAffineFixture()
82+
rowPacked := inDim * bits / 8
83+
maxCode := uint32(1)<<uint(bits) - 1
84+
for r := 0; r < outDim; r++ {
85+
pRow := packed[r*rowPacked : (r+1)*rowPacked]
86+
for c := 0; c < inDim; c++ {
87+
want := uint32(c*5+r*3+1) % (maxCode + 1)
88+
if got := affineExtractCode(pRow, c*bits, bits); got != want {
89+
t.Fatalf("row %d col %d: affineExtractCode = %d, want %d", r, c, got, want)
90+
}
91+
}
92+
}
93+
}
94+
95+
// TestBF16RoundTrip_Good pins the codec pair this whole file leans on: every
96+
// value bf16Encode produces, bf16Decode must read back losslessly (bf16
97+
// encoding is already the lossy step; decoding a valid bf16 value is exact).
98+
func TestBF16RoundTrip_Good(t *testing.T) {
99+
for _, v := range []float32{0, 1, -1, 12.5, 0.25, -3.75, 100, -0.03125} {
100+
h := bf16Encode(v)
101+
got := bf16Decode(byte(h), byte(h>>8))
102+
// bf16 keeps the top 8 bits of mantissa context (7 explicit + implicit
103+
// leading 1); values here are chosen exactly representable in bf16, so
104+
// the round trip must be exact.
105+
if got != v {
106+
t.Errorf("bf16 round trip: encode/decode(%v) = %v, want exact", v, got)
107+
}
108+
}
109+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
//go:build darwin && arm64
4+
5+
package native
6+
7+
import (
8+
"testing"
9+
10+
"dappco.re/go/inference/engine/enginetest"
11+
)
12+
13+
// TestNativeAffineQuantParity is engine/metal's proving consumer of
14+
// enginetest.QuantParity (rocm design #13): it fetches this backend's
15+
// registered "native"/"affine" quant compute (model_quant.go, affineQMV.MatVec)
16+
// and checks it against the pure-Go group-affine reference on a small
17+
// deterministic fixture — real GPU dispatch through QMVBF16, not a mock.
18+
// Skips cleanly without MLX_METALLIB_PATH, exactly like this package's other
19+
// real-dispatch quant tests (requireNativeRuntime, test_helpers_test.go;
20+
// e.g. TestQMVBF16AllocationBudget, qmv_test.go).
21+
func TestNativeAffineQuantParity(t *testing.T) {
22+
requireNativeRuntime(t)
23+
enginetest.QuantParity(t, "native")
24+
}

0 commit comments

Comments
 (0)