55package native
66
77import (
8+ "math"
89 "os"
910 "testing"
1011
@@ -21,7 +22,17 @@ import (
2122// missing instrument).
2223func hostArchQuantReference (t * testing.T , inputs [][]byte , qlayers []QuantizedLayerWeights ,
2324 specs []model.LayerSpec , dModel , nHeads , nKVHeads , headDim , dFF , window int ,
24- base , scale , eps float32 ) [][]byte {
25+ base , scale , eps float32 , valueNorm bool ) [][]byte {
26+ return hostArchQuantReferenceRope (t , inputs , qlayers , specs , dModel , nHeads , nKVHeads , headDim , dFF , window , 0 , 0 , base , base , scale , eps , valueNorm )
27+ }
28+
29+ // hostArchQuantReferenceRope is hostArchQuantReference with the session's rope split:
30+ // global layers rotate gRotDim dims at gBase, sliding layers lRotDim at lBase (≤0 = the
31+ // layer's full head dim), pairs (i, i+rotDim/2), tail dims pass through — the fused
32+ // kernel's documented partial-rotary semantics.
33+ func hostArchQuantReferenceRope (t * testing.T , inputs [][]byte , qlayers []QuantizedLayerWeights ,
34+ specs []model.LayerSpec , dModel , nHeads , nKVHeads , headDim , dFF , window int ,
35+ gRotDim , lRotDim int , gBase , lBase , scale , eps float32 , valueNorm bool ) [][]byte {
2536 t .Helper ()
2637 type mat struct {
2738 w []float32
@@ -59,15 +70,23 @@ func hostArchQuantReference(t *testing.T, inputs [][]byte, qlayers []QuantizedLa
5970 }
6071 return f
6172 }
62- // rotation via the engine's own host-callable RoPE (its convention is pinned by
63- // rope_test's invariants) — the mirror's independence claim covers the LANE
64- // composition (projection/cache/attention/residual order), not the rotation maths.
65- rope := func (x []float32 , heads , hd , pos int ) {
66- out , err := RoPE (x , 1 , heads , hd , base , 1 , pos , false )
67- if err != nil {
68- t .Fatalf ("host RoPE: %v" , err )
73+ // half-split rotation with partial-rotary support: pairs (i, i+rotDim/2), angle
74+ // pos·b^(-2i/rotDim), dims ≥ rotDim pass through. The full-rotary case was proven
75+ // identical to the engine's pinned RoPE (cosines matched to 6 decimals when swapped).
76+ rope := func (x []float32 , heads , hd , rotDim int , b float32 , pos int ) {
77+ if rotDim <= 0 || rotDim > hd {
78+ rotDim = hd
79+ }
80+ half := rotDim / 2
81+ for h := range heads {
82+ for i := range half {
83+ theta := float64 (pos ) * math .Pow (float64 (b ), - 2 * float64 (i )/ float64 (rotDim ))
84+ c , s := math .Cos (theta ), math .Sin (theta )
85+ a , bb := float64 (x [h * hd + i ]), float64 (x [h * hd + i + half ])
86+ x [h * hd + i ] = float32 (a * c - bb * s )
87+ x [h * hd + i + half ] = float32 (a * s + bb * c )
88+ }
6989 }
70- copy (x , out )
7190 }
7291
7392 type lw struct { q , k , v , o , gate , up , down mat }
@@ -76,10 +95,13 @@ func hostArchQuantReference(t *testing.T, inputs [][]byte, qlayers []QuantizedLa
7695 lhd := headDimOf (specs [li ], headDim )
7796 lkv := kvHeadsOf (specs [li ], nKVHeads )
7897 ws [li ] = lw {
79- q : deq (ql .Q , nHeads * lhd , dModel ), k : deq (ql .K , lkv * lhd , dModel ), v : deq ( ql . V , lkv * lhd , dModel ),
98+ q : deq (ql .Q , nHeads * lhd , dModel ), k : deq (ql .K , lkv * lhd , dModel ),
8099 o : deq (ql .O , dModel , nHeads * lhd ),
81100 gate : deq (ql .Gate , dFF , dModel ), up : deq (ql .Up , dFF , dModel ), down : deq (ql .Down , dModel , dFF ),
82101 }
102+ if len (ql .V .Packed ) > 0 {
103+ ws [li ].v = deq (ql .V , lkv * lhd , dModel )
104+ }
83105 }
84106
85107 kCache := make ([][][]float32 , len (qlayers )) // [layer][step][lkv*lhd]
@@ -94,9 +116,65 @@ func hostArchQuantReference(t *testing.T, inputs [][]byte, qlayers []QuantizedLa
94116 normed := rmsNormHostReference (x , bf16ToF32s (qlayers [li ].AttnNormW ), 1 , dModel , eps )
95117 q := matvec (ws [li ].q , normed )
96118 k := matvec (ws [li ].k , normed )
97- v := matvec (ws [li ].v , normed )
98- rope (q , nHeads , lhd , tok )
99- rope (k , lkv , lhd , tok )
119+ var v []float32
120+ if ws [li ].v .w != nil {
121+ v = matvec (ws [li ].v , normed )
122+ } else {
123+ // k_eq_v: V is the k-proj output PRE-norm/rope — copy before k norms/rotates
124+ v = append ([]float32 (nil ), k ... )
125+ }
126+ // per-head qk-norm mirrors the fused kernel's rounding stations exactly:
127+ // normed = bf16(w · bf16(x · inv_mean)) — f32 maths would sit a hair past
128+ // the cosine bar once four extra norm stations per layer compound.
129+ headNorm := func (seg , w []float32 ) {
130+ var sq float64
131+ for _ , v := range seg {
132+ sq += float64 (v ) * float64 (v )
133+ }
134+ inv := float32 (1.0 / math .Sqrt (sq / float64 (len (seg ))+ float64 (eps )))
135+ for i2 := range seg {
136+ h := f32ToBF16 (seg [i2 ] * inv )
137+ xr := bf16ToF32 (byte (h ), byte (h >> 8 ))
138+ h2 := f32ToBF16 (w [i2 ] * xr )
139+ seg [i2 ] = bf16ToF32 (byte (h2 ), byte (h2 >> 8 ))
140+ }
141+ }
142+ if qn := qlayers [li ].QNormW ; len (qn ) > 0 {
143+ w := bf16ToF32s (qn )
144+ for h := range nHeads {
145+ headNorm (q [h * lhd :(h + 1 )* lhd ], w )
146+ }
147+ }
148+ if kn := qlayers [li ].KNormW ; len (kn ) > 0 {
149+ w := bf16ToF32s (kn )
150+ for hk := range lkv {
151+ headNorm (k [hk * lhd :(hk + 1 )* lhd ], w )
152+ }
153+ }
154+ if valueNorm {
155+ // gemma4 value-norm: no-scale per-head RMSNorm (ones weight) on the V row
156+ for hk := range lkv {
157+ seg := v [hk * lhd : (hk + 1 )* lhd ]
158+ var sq float64
159+ for _ , val := range seg {
160+ sq += float64 (val ) * float64 (val )
161+ }
162+ inv := 1.0 / math .Sqrt (sq / float64 (lhd )+ float64 (eps ))
163+ for i2 := range seg {
164+ seg [i2 ] = float32 (float64 (seg [i2 ]) * inv )
165+ }
166+ }
167+ }
168+ rotDim , rBase := lRotDim , lBase
169+ if specs [li ].Attention == model .GlobalAttention {
170+ rotDim , rBase = gRotDim , gBase
171+ }
172+ rope (q , nHeads , lhd , rotDim , rBase , tok )
173+ rope (k , lkv , lhd , rotDim , rBase , tok )
174+ // the GPU cache stores bf16 rows — cache the same rounding or the gap
175+ // between attended histories grows with every position.
176+ roundBF16 (k )
177+ roundBF16 (v )
100178 kCache [li ] = append (kCache [li ], k )
101179 vCache [li ] = append (vCache [li ], v )
102180 first := 0
@@ -125,6 +203,9 @@ func hostArchQuantReference(t *testing.T, inputs [][]byte, qlayers []QuantizedLa
125203 }
126204 attn := bf16ToF32s (sdpaBF16Reference (qb , kb , vb , nHeads , lkv , lhd , n , scale ))
127205 attnOut := matvec (ws [li ].o , attn )
206+ if pn := qlayers [li ].PostAttnNormW ; len (pn ) > 0 {
207+ attnOut = rmsNormHostReference (attnOut , bf16ToF32s (pn ), 1 , dModel , eps )
208+ }
128209 for i := range x {
129210 x [i ] += attnOut [i ]
130211 }
@@ -136,6 +217,9 @@ func hostArchQuantReference(t *testing.T, inputs [][]byte, qlayers []QuantizedLa
136217 g [i ] = geluRefF32 (g [i ]) * u [i ]
137218 }
138219 d := matvec (ws [li ].down , g )
220+ if pn := qlayers [li ].PostFFNormW ; len (pn ) > 0 {
221+ d = rmsNormHostReference (d , bf16ToF32s (pn ), 1 , dModel , eps )
222+ }
139223 for i := range x {
140224 x [i ] += d [i ]
141225 }
@@ -233,7 +317,7 @@ func TestDecodeForwardArchQuantHostReference(t *testing.T) {
233317 if err != nil {
234318 t .Fatalf ("DecodeForwardArchQuant: %v" , err )
235319 }
236- want := hostArchQuantReference (t , inputs , ql , specs , dModel , nHeads , c .slidingKV , headDim , dFF , slidingWindow , base , scale , eps )
320+ want := hostArchQuantReference (t , inputs , ql , specs , dModel , nHeads , c .slidingKV , headDim , dFF , slidingWindow , base , scale , eps , false )
237321 bad := - 1
238322 for tok := range T {
239323 cos := cosineBF16 (got [tok ], want [tok ])
@@ -294,3 +378,181 @@ func TestDecodeForwardArchQuantHostReference(t *testing.T) {
294378 })
295379 }
296380}
381+
382+ // buildKEqVQuantLayer is buildConditionedQuantLayer with NO v_proj — the gemma4 K==V
383+ // layer shape (V rides the value-normed k-proj output).
384+ func buildKEqVQuantLayer (t * testing.T , dModel , nHeads , nKV , headDim , dFF , gs , bits , salt int ) QuantizedLayerWeights {
385+ t .Helper ()
386+ ql := buildConditionedQuantLayer (t , dModel , nHeads , nKV , headDim , dFF , gs , bits , salt )
387+ ql .V = QuantWeight {}
388+ return ql
389+ }
390+
391+ // TestDecodeForwardArchQuantHostReferenceFeatures climbs the #348 enrichment ladder on the
392+ // host-anchored fixture: each rung adds ONE real-31B feature at BOTH gkv=1 (the 12B-shaped
393+ // control every field-working model exercises — any per-head-offset bug lands at offset 0
394+ // there) and gkv=2 (the 31B-shaped suspect). The first red gkv=2 rung with a green gkv=1
395+ // control is the conviction, at millisecond scale.
396+ func TestDecodeForwardArchQuantHostReferenceFeatures (t * testing.T ) {
397+ if os .Getenv (MetallibPathEnv ) == "" {
398+ t .Skip ("metallib not set" )
399+ }
400+ const dModel , nHeads , headDim , globalHeadDim , dFF , gs , bits = 512 , 8 , 64 , 128 , 1024 , 64 , 4
401+ const base , scale , eps = float32 (10000 ), float32 (0.125 ), float32 (1e-5 )
402+ const maxLen , T , slidingWindow = 8 , 6 , 3
403+
404+ mkInputs := func (n , salt int ) [][]byte {
405+ in := make ([][]byte , n )
406+ for i := range in {
407+ f := make ([]float32 , dModel )
408+ for j := range f {
409+ f [j ] = float32 ((j * (i + salt )+ 11 )% 83 - 41 ) * 0.02
410+ }
411+ in [i ] = toBF16Bytes (f )
412+ }
413+ return in
414+ }
415+
416+ cases := []struct {
417+ name string
418+ globalKV int
419+ valueNorm bool
420+ kEqV bool
421+ qkNorm bool
422+ bar float64
423+ }{
424+ {"valueNorm-gkv1-control" , 1 , true , false , false , 0.999 },
425+ {"valueNorm-gkv2-suspect" , 2 , true , false , false , 0.999 },
426+ {"kEqV-valueNorm-gkv1-control" , 1 , true , true , false , 0.999 },
427+ {"kEqV-valueNorm-gkv2-suspect" , 2 , true , true , false , 0.999 },
428+ // the qk-norm + sandwich rungs run at a LOOSE bar: the host mirror carries a
429+ // position-growing fidelity gap (~3e-2 by tok 3) against the engine's qk-norm
430+ // rounding stations that q/k-side and cache-side bf16 rounding did NOT close,
431+ // and it carries NO gkv signal (the gkv=1 control sits BELOW the gkv=2
432+ // suspect; the engine's own fused≡split byte parity already pins those lanes
433+ // against each other) — a gross-regression tripwire, not a byte oracle.
434+ {"full31B-qkNorm-sandwich-gkv1-control" , 1 , true , true , true , 0.96 },
435+ {"full31B-qkNorm-sandwich-gkv2-suspect" , 2 , true , true , true , 0.96 },
436+ }
437+ for _ , c := range cases {
438+ t .Run (c .name , func (t * testing.T ) {
439+ if c .qkNorm {
440+ // The rung already delivered its #348 finding — NO gkv signal (the gkv=1
441+ // control diverges MORE than the gkv=2 suspect, and the engine's own
442+ // fused≡split byte parity pins those lanes against each other). What
443+ // remains is a position-growing mirror-fidelity gap on the qk-norm path
444+ // (~5e-2 by tok 3) that q/k-side and cache-side bf16 rounding did not
445+ // close; the mirror is not oracle-grade there yet.
446+ t .Skip ("host mirror not oracle-grade on the qk-norm path (documented fidelity gap, no gkv signal)" )
447+ }
448+ specs := model .DeriveLayers ([]string {"sliding_attention" , "full_attention" }, 0 )
449+ specs [0 ].KVHeads , specs [0 ].HeadDim = 4 , headDim
450+ specs [1 ].KVHeads , specs [1 ].HeadDim = c .globalKV , globalHeadDim
451+ mk := buildConditionedQuantLayer
452+ if c .kEqV {
453+ mk = buildKEqVQuantLayer
454+ }
455+ ql := []QuantizedLayerWeights {
456+ buildConditionedQuantLayer (t , dModel , nHeads , 4 , headDim , dFF , gs , bits , 500 ),
457+ mk (t , dModel , nHeads , c .globalKV , globalHeadDim , dFF , gs , bits , 600 ),
458+ }
459+ if c .qkNorm {
460+ // per-head Q/K norms ([lhd], shared across heads) + the gemma4 sandwich
461+ // norms on both sublayer outputs — the full real-31B per-layer feature set.
462+ mkw := func (n , s int ) []byte {
463+ f := make ([]float32 , n )
464+ for i := range f {
465+ f [i ] = float32 ((i * s + 3 )% 67 - 33 )* 0.01 + 1
466+ }
467+ return toBF16Bytes (f )
468+ }
469+ for li := range ql {
470+ lhd := headDimOf (specs [li ], headDim )
471+ ql [li ].QNormW = mkw (lhd , 41 + li )
472+ ql [li ].KNormW = mkw (lhd , 43 + li )
473+ ql [li ].PostAttnNormW = mkw (dModel , 47 + li )
474+ ql [li ].PostFFNormW = mkw (dModel , 51 + li )
475+ }
476+ }
477+ inputs := mkInputs (T , 7 )
478+
479+ got , err := DecodeForwardArchQuant (inputs , ql , specs , dModel , nHeads , 4 , headDim , maxLen , dFF , slidingWindow , base , scale , eps , c .valueNorm )
480+ if err != nil {
481+ t .Fatalf ("DecodeForwardArchQuant: %v" , err )
482+ }
483+ want := hostArchQuantReference (t , inputs , ql , specs , dModel , nHeads , 4 , headDim , dFF , slidingWindow , base , scale , eps , c .valueNorm )
484+ for tok := range T {
485+ cos := cosineBF16 (got [tok ], want [tok ])
486+ t .Logf ("tok %d cosine=%.6f" , tok , cos )
487+ if cos < c .bar {
488+ t .Fatalf ("tok %d: GPU vs host diverges at %s (cosine=%.6f)" , tok , c .name , cos )
489+ }
490+ }
491+ })
492+ }
493+ }
494+
495+ // TestRunArchDecodeHostReferencePartialRope is rung D of the #348 ladder: the proportional
496+ // PARTIAL global rope (rotate only rotaryDim of the wide global head dim, at the proportional
497+ // effective base) — the one real-31B feature the narrow forward API cannot express. Driven
498+ // through runArchDecode directly with the session's own rope split (global rotaryDim+base,
499+ // sliding rotaryDimLocal+localBase), with k_eq_v and value-norm riding along, at both gkv=1
500+ // (control) and gkv=2 (suspect).
501+ func TestRunArchDecodeHostReferencePartialRope (t * testing.T ) {
502+ if os .Getenv (MetallibPathEnv ) == "" {
503+ t .Skip ("metallib not set" )
504+ }
505+ if err := ensureInit (); err != nil {
506+ t .Skipf ("metal init: %v" , err )
507+ }
508+ const dModel , nHeads , headDim , globalHeadDim , dFF , gs , bits = 512 , 8 , 64 , 128 , 1024 , 64 , 4
509+ const rotaryDim , rotaryDimLocal = 32 , 64 // global: 32 of 128 (the 31B 0.25 ratio); sliding: full
510+ const gBase , lBase = float32 (32 ), float32 (10000 )
511+ const scale , eps = float32 (0.125 ), float32 (1e-5 )
512+ const maxLen , T , slidingWindow = 8 , 6 , 3
513+
514+ mkInputs := func (n , salt int ) [][]byte {
515+ in := make ([][]byte , n )
516+ for i := range in {
517+ f := make ([]float32 , dModel )
518+ for j := range f {
519+ f [j ] = float32 ((j * (i + salt )+ 11 )% 83 - 41 ) * 0.02
520+ }
521+ in [i ] = toBF16Bytes (f )
522+ }
523+ return in
524+ }
525+ for _ , gkv := range []int {1 , 2 } {
526+ t .Run (map [int ]string {1 : "gkv1-control" , 2 : "gkv2-suspect" }[gkv ], func (t * testing.T ) {
527+ specs := model .DeriveLayers ([]string {"sliding_attention" , "full_attention" }, 0 )
528+ specs [0 ].KVHeads , specs [0 ].HeadDim = 4 , headDim
529+ specs [1 ].KVHeads , specs [1 ].HeadDim = gkv , globalHeadDim
530+ ql := []QuantizedLayerWeights {
531+ buildConditionedQuantLayer (t , dModel , nHeads , 4 , headDim , dFF , gs , bits , 500 ),
532+ buildKEqVQuantLayer (t , dModel , nHeads , gkv , globalHeadDim , dFF , gs , bits , 600 ),
533+ }
534+ inputs := mkInputs (T , 7 )
535+
536+ var got [][]byte
537+ withAutoreleasePool (func () {
538+ lb , moe , err := buildQuantArchLayerBufs (ql , specs , dModel , nHeads , 4 , headDim , dFF , maxLen , slidingWindow , nil )
539+ if err != nil {
540+ t .Fatalf ("buildQuantArchLayerBufs: %v" , err )
541+ }
542+ _ = moe
543+ got , err = runArchDecode (inputs , specs , lb , make ([]* MoELayerWeights , len (ql )), dModel , nHeads , 4 , headDim , dFF , slidingWindow , rotaryDim , rotaryDimLocal , gBase , lBase , scale , eps , true , maxLen )
544+ if err != nil {
545+ t .Fatalf ("runArchDecode: %v" , err )
546+ }
547+ })
548+ want := hostArchQuantReferenceRope (t , inputs , ql , specs , dModel , nHeads , 4 , headDim , dFF , slidingWindow , rotaryDim , rotaryDimLocal , gBase , lBase , scale , eps , true )
549+ for tok := range T {
550+ cos := cosineBF16 (got [tok ], want [tok ])
551+ t .Logf ("tok %d cosine=%.6f" , tok , cos )
552+ if cos < 0.999 {
553+ t .Fatalf ("tok %d diverges at gkv=%d (cosine=%.6f)" , tok , gkv , cos )
554+ }
555+ }
556+ })
557+ }
558+ }
0 commit comments