File size: 34,518 Bytes
bbb6388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
// src/kernels/s_gemv_parity.cpp - P2.S2's parity test for the S-family GEMV.
//
// Four source types, chosen to cover every branch the kernel has:
//
//   S2  Q2_0   2-bit, bias  -1, group 64   affine
//   S4  Q4_0   4-bit, bias  -8, group 32   affine
//   S4  IQ4_NL 4-bit, bias   0, group 32   CODEBOOK  (non-linear, no bias can express it)
//   S8  Q8_0   8-bit, bias -128, group 32  affine
//
// That is all three template instantiations, both codebooks, and four different biases.  The offset branch
// (`has_offset`, only Q4_K and Q5_K) is NOT exercised here and the test prints that fact rather than leaving a
// green result to imply otherwise - Q4_K's canonicalisation needs the packed 6-bit scale/min pairs, and the
// honest move is to say so than to half-build it.
//
// THE REFERENCE IS THE CHAIN: for each type the CPU side reconstructs the RAW GGUF block from the canonical
// planes and decodes it with that type's scalar dequantizer from include/strata/artifact/dequant.hpp - the
// ones `bench/micro/dequant_xcheck` checks against **ggml's own** dequantizers.  Reconstructing raw from
// canonical is the inverse of what the packer does, and it is written here independently of the packer so the
// two are not the same code.
#include "strata/artifact/dequant.hpp"
#include "strata/kernels/s_gemv.hpp"

#include <cuda_runtime.h>

#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <algorithm>
#include <chrono>
#include <random>
#include <string>
#include <vector>

namespace {

void check(cudaError_t e, const char* what) {
    if (e != cudaSuccess) {
        std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e));
        std::exit(1);
    }
}

// Finite, exactly representable fp16 patterns WITH full mantissas.  Not powers of two: with only powers of
// two every product is exact and the comparison cannot round, which is how the first version of s2_gemv's test
// managed a worst error of exactly zero without proving anything.
const uint16_t kScales[] = {0x3E00, 0x3555, 0x3C01, 0x4248, 0x4123, 0x2AAA, 0x4A2B, 0x3800,
                            0xBE00, 0xB555, 0xC248, 0x2AAB, 0x4A2C, 0x2AAB, 0xB800};

struct Case {
    const char* name;
    strata::kernels::SForm form;
    int raw_bytes;        // one block
    // Reconstruct the raw GGUF block for one group from the canonical codes/scales, and return the number of
    // elements the block decodes to (which may exceed group_elems: Q8_0's block IS one group, Q2_0's too).
    int (*to_raw)(const uint8_t* codes, size_t code_off, const uint16_t* scale_bits, size_t scale_off, uint8_t* raw);
    double (*dequant)(const uint8_t* raw, float* out);
};

// ---- Q2_0: raw = { fp16 d ; uint8 qs[16] }, 64 elements, code i at byte i/4 bits (i%4)*2 - identical to the
// canonical plane, so this is a straight copy plus the scale.
int q2_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) {
    const uint16_t d = scale_bits[so];
    raw[0] = (uint8_t) (d & 0xFF);
    raw[1] = (uint8_t) (d >> 8);
    // `codes` is one code PER ELEMENT (64 of them); a Q2_0 block stores them PACKED, 4 per byte, element i at
    // byte i/4 bits (i%4)*2.  The two coincide as a byte layout only if you confuse them, which is exactly the
    // mistake the first version of this function made by copying 16 bytes verbatim.
    for (int b = 0; b < 16; ++b) {
        raw[2 + b] = (uint8_t) ((codes[co + 4 * b + 0] & 3) | ((codes[co + 4 * b + 1] & 3) << 2) |
                                ((codes[co + 4 * b + 2] & 3) << 4) | ((codes[co + 4 * b + 3] & 3) << 6));
    }
    return 64;
}
double q2_0_deq(const uint8_t* raw, float* out) {
    strata::dequantize_q2_0(raw, out);
    return 64;
}

// ---- Q4_0: raw = { fp16 d ; uint8 qs[16] }, 32 elements; element j is the LOW nibble of byte j and element
// j+16 the HIGH nibble.  The canonical plane stores one code per element, so this re-packs them.
int q4_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) {
    const uint16_t d = scale_bits[so];
    raw[0] = (uint8_t) (d & 0xFF);
    raw[1] = (uint8_t) (d >> 8);
    for (int j = 0; j < 16; ++j) raw[2 + j] = (uint8_t) ((codes[co + j] & 0x0F) | (codes[co + 16 + j] << 4));
    return 32;
}
double q4_0_deq(const uint8_t* raw, float* out) {
    strata::dequantize_q4_0(raw, out);
    return 32;
}

// ---- IQ4_NL: identical packing to Q4_0, different decode (a table, not a bias).
int iq4_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) {
    return q4_0_to_raw(codes, co, scale_bits, so, raw);
}
double iq4_deq(const uint8_t* raw, float* out) {
    strata::dequantize_iq4_nl(raw, out);
    return 32;
}

// ---- Q8_0: raw = { fp16 d ; int8 qs[32] }, 32 elements.  The canonical code plane is UNSIGNED with bias
// -128 (round 154's fix), so the raw int8 byte is exactly the canonical code reinterpreted - a straight copy,
// which is why this type is the cheapest one to check and the one whose sign bug was found by the pack.
int q8_0_to_raw(const uint8_t* codes, size_t co, const uint16_t* scale_bits, size_t so, uint8_t* raw) {
    const uint16_t d = scale_bits[so];
    raw[0] = (uint8_t) (d & 0xFF);
    raw[1] = (uint8_t) (d >> 8);
    // NOT a straight copy.  The canonical code plane is UNSIGNED with bias -128, so canonical code c means the
    // value (c - 128); the raw block stores that as an `int8_t`, whose reinterpretation as an unsigned byte is
    // `c ^ 0x80` - a SIGN-BIT FLIP, not the identity.  Assuming identity is the same family of mistake as the
    // round-154 Q8_0 bug that the pack found, and it is why this reconstruction is written out rather than
    // memcpy'd.
    for (int j = 0; j < 32; ++j) raw[2 + j] = (uint8_t) (codes[co + j] ^ 0x80);
    return 32;
}
double q8_0_deq(const uint8_t* raw, float* out) {
    strata::dequantize_q8_0(raw, out);
    return 32;
}

// ---- S4/Q4_K: the ONLY canonical form with an offset, and the only one whose scale is an FP32 PRODUCT
// (`fl(d*sc)`) rather than a widened fp16.  Both facts make it the case worth having.
//
// There is no `to_raw` for it in the table above because its canonical planes are DERIVED from the raw
// parameters, not the other way round: the packer computes `scale = d*sc` and `offset = -(dmin*m)`, so this
// generates d, dmin and the eight (sc, m) pairs, derives the planes exactly as the packer does, and writes a
// raw block from the SAME parameters.  Inverting those products instead would be ambiguous.
void test_q4k(long long n_in, long long n_out, double tol, int* total_bad) {
    using strata::kernels::Codebook;
    using strata::kernels::SForm;
    const SForm form{4, 0, 32, Codebook::Affine, true};

    std::mt19937 rng(777);
    const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0]));
    const long long n_groups = n_in / 32;
    const long long codes_per_row = n_in / 2;

    std::vector<uint16_t> x((size_t) n_in);
    for (long long i = 0; i < n_in; ++i) x[(size_t) i] = kScales[rng() % n_scales];

    std::vector<uint8_t> codes_el((size_t) n_out * n_in);
    for (size_t i = 0; i < codes_el.size(); ++i) codes_el[i] = (uint8_t) (rng() & 0x0F);
    std::vector<uint8_t> codes((size_t) n_out * codes_per_row, 0);
    for (size_t i = 0; i < codes_el.size(); ++i) {
        const size_t o = i / (size_t) n_in, k = i % (size_t) n_in;
        codes[o * (size_t) codes_per_row + k / 2] |= (uint8_t) (codes_el[i] << ((k % 2) * 4));
    }
    std::vector<float> scales((size_t) n_out * n_groups), offsets((size_t) n_out * n_groups);

    struct Row {
        uint16_t d, dmin;
        uint8_t sc[8], m[8];
    };
    std::vector<Row> rows((size_t) n_out);
    for (long long o = 0; o < n_out; ++o) {
        Row& r = rows[(size_t) o];
        r.d = kScales[rng() % n_scales];
        r.dmin = kScales[rng() % n_scales];
        for (int g = 0; g < 8; ++g) {
            r.sc[g] = (uint8_t) (rng() & 0x3F);
            r.m[g] = (uint8_t) (rng() & 0x3F);
            for (long long sb = 0; sb < n_in / 256; ++sb) {
                scales[(size_t) (o * n_groups + sb * 8 + g)] = strata::fp16_to_fp32(r.d) * (float) r.sc[g];
                offsets[(size_t) (o * n_groups + sb * 8 + g)] = -(strata::fp16_to_fp32(r.dmin) * (float) r.m[g]);
            }
        }
    }

    std::vector<float> ref((size_t) n_out, 0.0f);
    std::vector<double> cond((size_t) n_out, 0.0);
    double worst_plane_ref = 0.0;   // max |planes - raw block| over every element, on the CPU alone
    std::vector<uint8_t> blk(144);
    std::vector<float> dec(256);
    // A Q4_K raw block covers 256 elements, so a 2560-element row is TEN superblocks, each with its own
    // 144-byte block.  The first version of this loop ran `step < 4` - one superblock, 256 of the row's 2560
    // elements - while the kernel accumulated all of them, and the resulting 29x relative error was the
    // reference being short, not the kernel being wrong.
    for (long long o = 0; o < n_out; ++o) {
        const Row& r = rows[(size_t) o];
        float acc = 0.0f;
        double sum_abs = 0.0;      // the natural scale of this row's dot product (see the metric below)
        double plane_ref_diff = 0.0;   // (a) vs (b): planes against the raw block, both on the CPU
        for (long long sb = 0; sb < n_in / 256; ++sb) {
            // ONE 144-byte block per 256-element SUPERBLOCK, with all four steps' qs bytes filled BEFORE the
            // decode.  The previous version decoded a fresh block per STEP and then read all 256 elements
            // from each, so three quarters of every decode came from zeroed qs - four decodes of a
            // quarter-filled block per superblock, which is the whole of the 1.17e4 disagreement.
            // `scripts/probe_q4k_groups.cpp` builds it this way and agrees exactly, group by group.
            blk.assign(144, 0);
            blk[0] = (uint8_t) (r.d & 0xFF);
            blk[1] = (uint8_t) (r.d >> 8);
            blk[2] = (uint8_t) (r.dmin & 0xFF);
            blk[3] = (uint8_t) (r.dmin >> 8);
            for (int j = 0; j < 4; ++j) {          // the exact inverse of get_scale_min_k4
                blk[4 + j] = (uint8_t) ((r.sc[j] & 0x3F) | ((r.sc[j + 4] >> 4) << 6));
                blk[8 + j] = (uint8_t) ((r.m[j] & 0x3F) | ((r.m[j + 4] >> 4) << 6));
                blk[12 + j] = (uint8_t) ((r.sc[j + 4] & 0x0F) | ((r.m[j + 4] & 0x0F) << 4));
            }
            for (int step = 0; step < 4; ++step) {
                for (int l = 0; l < 32; ++l) {     // elements 0..31 LOW nibbles, 32..63 HIGH
                    const long long e0 = o * n_in + sb * 256 + step * 64 + l;
                    const long long e1 = e0 + 32;
                    blk[16 + step * 32 + l] =
                        (uint8_t) ((codes_el[(size_t) e0] & 0x0F) | (codes_el[(size_t) e1] << 4));
                }
            }
            strata::dequantize_q4_K(blk.data(), dec.data());
            for (int j = 0; j < 256; ++j) {
                const float term = dec[(size_t) j] * strata::fp16_to_fp32(x[(size_t) (sb * 256 + j)]);
                acc += term;
                sum_abs += std::fabs((double) term);      // the CONDITION of this dot product

                // THE THIRD DECODE.  (a) the raw block through the scalar dequantizer, (b) the canonical
                // PLANES through `code*scale + offset`, (c) the GPU.  If (a) != (b) then this test built
                // planes that disagree with its own raw block and the kernel is innocent; if (a) == (b) and
                // (c) differs, the kernel is wrong.  Without (b) there is no way to tell which.
                const long long elem = sb * 256 + j;
                const long long g = elem / 32;
                const int code = (int) codes_el[(size_t) (o * n_in + elem)];
                const float b_dec = (float) code * scales[(size_t) (o * n_groups + g)] +
                                    offsets[(size_t) (o * n_groups + g)];
                if (std::fabs((double) (b_dec - dec[(size_t) j])) > plane_ref_diff) {
                    plane_ref_diff = std::fabs((double) (b_dec - dec[(size_t) j]));
                }
            }
        }
        ref[(size_t) o] = acc;
        cond[(size_t) o] = sum_abs;
        if (!(plane_ref_diff <= worst_plane_ref)) worst_plane_ref = plane_ref_diff;
    }

    uint16_t* d_x = nullptr;
    uint8_t* d_codes = nullptr;
    float *d_scales = nullptr, *d_offsets = nullptr, *d_y = nullptr;
    check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "cudaMalloc x");
    check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes");
    check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales");
    check(cudaMalloc(&d_offsets, offsets.size() * sizeof(float)), "cudaMalloc offsets");
    check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "cudaMalloc y");
    check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x");
    check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes");
    check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice),
          "copy scales");
    check(cudaMemcpy(d_offsets, offsets.data(), offsets.size() * sizeof(float), cudaMemcpyHostToDevice),
          "copy offsets");
    strata::kernels::s_gemv(d_x, d_codes, d_scales, d_offsets, d_y, n_in, n_out, form);
    std::vector<float> got((size_t) n_out);
    check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back");

    // THE ERROR IS MEASURED AGAINST sum|term|, NOT AGAINST THE RESULT.  A dot product of 2560 signed terms
    // cancels, so `|ref-got| / |ref|` is a statement about the condition number, not about the kernel: a row
    // whose terms are ~1e7 and whose result is ~1e3 reports a relative error of 50 for an absolute error of
    // 1e-6 of the terms.  `sum_abs` is the natural scale of the computation, and comparing to it is what
    // makes a tolerance mean "agrees to within rounding" instead of "did not cancel".
    long long bad = 0;
    double worst = 0.0, worst_res = 0.0;
    for (long long o = 0; o < n_out; ++o) {
        const double a = ref[(size_t) o], b = got[(size_t) o];
        const double scale = cond[(size_t) o] > 1e-30 ? cond[(size_t) o] : 1e-30;
        const double rel = std::fabs(a - b) / scale;
        const double rel_res = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30);
        if (!(rel <= worst)) worst = rel;
        if (!(rel_res <= worst_res)) worst_res = rel_res;
        if (!(rel <= tol)) ++bad;
    }
    double lo = ref[0], hi = ref[0];
    for (float v : ref) {
        lo = std::fmin(lo, v);
        hi = std::fmax(hi, v);
    }
    std::printf("  %-10s %lld rows, %lld over tol, worst rel-to-terms %.3e (rel-to-result %.3e)\n",
                "S4/Q4_K", n_out, bad, worst, worst_res);
    std::printf("             ref spread [%.4g, %.4g], condition (sum|term|) up to %.4g\n", lo, hi,
                *std::max_element(cond.begin(), cond.end()));
    std::printf("             CPU planes vs CPU raw block: max |diff| = %.3e -> %s\n", worst_plane_ref,
                worst_plane_ref == 0.0
                    ? "the test is SELF-CONSISTENT"
                    : "*** THE TEST DISAGREES WITH ITSELF - fix the reference before believing any GPU result ***");
    if (hi - lo <= 0.0) {
        std::fprintf(stderr, "VACUOUS: S4/Q4_K reference is constant\n");
        std::exit(1);
    }
    *total_bad += (int) bad;
    cudaFree(d_x);
    cudaFree(d_codes);
    cudaFree(d_scales);
    cudaFree(d_offsets);
    cudaFree(d_y);
}

}  // namespace


// ------------------------------------------------------------------ benchmark
//
// P2.S10 wants a first measurement and a ledger entry, and the objective is stated in tokens per second, so
// the useful output is not GiB/s but "milliseconds of expert matvec per token".  Warm-up runs are discarded
// (the first launch pays module load and page faults) and the MINIMUM is reported alongside the mean, because
// the minimum is the one not polluted by another process on the machine.
void bench_s2_gemv(long long n_in, long long n_out, int iters, int* split_bad) {
    using strata::kernels::Codebook;
    using strata::kernels::SForm;
    const SForm form{2, -1, 64, Codebook::Affine, false};
    const long long n_groups = n_in / 64;
    const long long codes_per_row = n_in / 4;

    std::vector<uint8_t> codes((size_t) n_out * codes_per_row, 0xA5);
    std::vector<float> scales((size_t) n_out * n_groups, 0.001f);
    std::vector<uint16_t> x((size_t) n_in, 0x3C00);            // 1.0 in fp16

    uint16_t* d_x = nullptr;
    uint8_t* d_codes = nullptr;
    float *d_scales = nullptr, *d_y = nullptr;
    check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "bench x");
    check(cudaMalloc(&d_codes, codes.size()), "bench codes");
    check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "bench scales");
    check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "bench y");
    check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "bench copy x");
    check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "bench copy codes");
    check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice),
          "bench copy scales");

    for (int i = 0; i < 3; ++i) strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form);

    std::vector<double> ms;
    for (int i = 0; i < iters; ++i) {
        const auto t0 = std::chrono::steady_clock::now();
        strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form);
        const auto t1 = std::chrono::steady_clock::now();
        ms.push_back(std::chrono::duration<double, std::milli>(t1 - t0).count());
    }
    std::sort(ms.begin(), ms.end());
    double mean = 0;
    for (double v : ms) mean += v;
    mean /= (double) ms.size();

    const double weights = (double) n_in * (double) n_out;
    const double bytes = (double) n_out * (double) codes_per_row + (double) n_out * n_groups * 4.0;
    std::printf("s2_gemv bench  [%lld x %lld]  %d iters\n", n_in, n_out, iters);
    std::printf("  ms per gemv: min %.4f  median %.4f  mean %.4f\n", ms.front(), ms[ms.size() / 2], mean);
    std::printf("  min -> %.1f G weights/s, %.1f GiB/s of S2 streamed\n", weights / ms.front() / 1e6,
                bytes / (1024.0 * 1024 * 1024) / (ms.front() / 1000.0));

    // The projection the objective is stated against: 48 layers x 10 experts x 3 roles, all of one token's
    // experts, at the measured per-role rate.  This is the VRAM-resident path only - the CPU expert path and
    // the KV/attention work are separate and are NOT included.
    const double roles_per_token = 48.0 * 10.0 * 3.0;
    const double tok_ms = roles_per_token * ms.front();
    std::printf("  per token: %.0f roles x %.4f ms = %.1f ms  ->  %.1f tok/s (expert matvec only)\n",
                roles_per_token, ms.front(), tok_ms, tok_ms > 0 ? 1000.0 / tok_ms : 0.0);
    std::printf("  NOTE: expert matvec ONLY. No attention, no KV, no norm, no router, no sampling, and it\n"
                "        assumes every expert is already resident in VRAM at zero cost.\n");

    // The NAIVE kernel's output is the reference for the split one.  `s_gemv` is checked against the scalar
    // dequantizer by this file's parity cases, so its result here is a validated value, and the split kernel
    // sums in a different order - so this is a relative comparison, not bit equality.
    strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form);
    std::vector<float> ref_naive((size_t) n_out);
    check(cudaMemcpy(ref_naive.data(), d_y, ref_naive.size() * sizeof(float), cudaMemcpyDeviceToHost),
          "copy naive reference");

    // ---- the row-split variant, same planes, same process -------------------
    // Swept rather than guessed: more threads per row buys parallelism and costs a deeper reduction.
    for (int tpr : {32, 64, 128, 256}) {
        if (tpr > n_in) continue;
        for (int i = 0; i < 3; ++i) {
            strata::kernels::s_gemv_split(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form, tpr);
        }
        std::vector<double> sms;
        for (int i = 0; i < iters; ++i) {
            const auto t0 = std::chrono::steady_clock::now();
            strata::kernels::s_gemv_split(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, form, tpr);
            sms.push_back(std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0)
                              .count());
        }
        std::sort(sms.begin(), sms.end());

        // correctness of the thing being timed
        std::vector<float> got((size_t) n_out);
        check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost),
              "copy split result");
        long long bad = 0;
        double worst = 0.0;
        for (long long o = 0; o < n_out; ++o) {
            const double a = ref_naive[(size_t) o], b = got[(size_t) o];
            const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30);
            if (!(rel <= worst)) worst = rel;
            if (!(rel <= 1e-4)) ++bad;
        }
        if (bad) {
            std::printf("  split tpr=%-4d *** WRONG *** %lld of %lld rows differ, worst rel %.3e\n", tpr,
                        bad, n_out, worst);
            *split_bad += (int) bad;
        }

        // ---- the quad-amortised S2 kernel, timed AND checked against the naive reference ----
        // Only meaningful for S2: it assumes four elements share one code byte, which is true of 2-bit codes.
        if (form.code_bits == 2 && (n_in % 64) == 0) {
            for (int t2 : {32, 64, 128}) {
                for (int i = 0; i < 3; ++i) {
                    strata::kernels::s2_gemv_quads(d_x, d_codes, d_scales, d_y, n_in, n_out, t2);
                }
                std::vector<double> qms;
                for (int i = 0; i < iters; ++i) {
                    const auto q0 = std::chrono::steady_clock::now();
                    strata::kernels::s2_gemv_quads(d_x, d_codes, d_scales, d_y, n_in, n_out, t2);
                    qms.push_back(std::chrono::duration<double, std::milli>(
                                      std::chrono::steady_clock::now() - q0).count());
                }
                std::sort(qms.begin(), qms.end());
                check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost),
                      "copy quads result");
                long long qbad = 0;
                double qworst = 0.0;
                for (long long o = 0; o < n_out; ++o) {
                    const double a = ref_naive[(size_t) o], b = got[(size_t) o];
                    const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30);
                    if (rel > qworst) qworst = rel;
                    if (!(rel <= 1e-4)) ++qbad;
                }
                if (qbad) *split_bad += (int) qbad;
                std::printf("  quads tpr=%-4d min %.4f ms  -> %6.1f G weights/s   vs naive %.2fx   %s\n", t2,
                            qms.front(), weights / qms.front() / 1e6, ms.front() / qms.front(),
                            qbad ? "*** WRONG ***" : "checked ok");
            }
        }

        // ---- the two-lever kernel, both configurations, timed AND checked ----
        if (form.code_bits == 2 && (n_in % 64) == 0) {
            for (int staged = 0; staged < 2; ++staged) {
                for (int t3 : {32, 64, 128}) {
                    for (int i = 0; i < 3; ++i) {
                        strata::kernels::s2_gemv_fast(d_x, d_codes, d_scales, d_y, n_in, n_out, t3, staged != 0);
                    }
                    std::vector<double> fms;
                    for (int i = 0; i < iters; ++i) {
                        const auto q0 = std::chrono::steady_clock::now();
                        strata::kernels::s2_gemv_fast(d_x, d_codes, d_scales, d_y, n_in, n_out, t3, staged != 0);
                        fms.push_back(std::chrono::duration<double, std::milli>(
                                          std::chrono::steady_clock::now() - q0).count());
                    }
                    std::sort(fms.begin(), fms.end());
                    check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost),
                          "copy fast result");
                    long long fbad = 0;
                    for (long long o = 0; o < n_out; ++o) {
                        const double a = ref_naive[(size_t) o], b = got[(size_t) o];
                        const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30);
                        if (!(rel <= 1e-4)) ++fbad;
                    }
                    if (fbad) *split_bad += (int) fbad;
                    std::printf("  fast %s tpr=%-4d min %.4f ms  -> %6.1f G weights/s   vs naive %.2fx   %s\n",
                                staged ? "staged" : "global", t3, fms.front(),
                                weights / fms.front() / 1e6, ms.front() / fms.front(),
                                fbad ? "*** WRONG ***" : "checked ok");
                }
            }
        }

        const double sp = ms.front() / sms.front();
        std::printf("  split tpr=%-4d min %.4f ms  -> %6.1f G weights/s   speedup vs naive %.2fx\n", tpr,
                    sms.front(), weights / sms.front() / 1e6, sp);
    }

    cudaFree(d_x);
    cudaFree(d_codes);
    cudaFree(d_scales);
    cudaFree(d_y);
}

int main(int argc, char** argv) {
    long long n_in = 2560;
    long long n_out = 128;
    // 1e-4, not 1e-5: the measured worst is ~1e-5 (FMA contraction of a 2560-term f32 accumulation), so a
    // 1e-5 threshold would pass by 4% and fail on a different n_in.  The phase spec allows 1e-3 for FP16
    // paths; this is 10x tighter than the spec and 10x looser than the measurement.
    double tol = 1e-4;
    bool selftest = false;
    bool bench = false;
    int iters = 200;
    for (int i = 1; i < argc; ++i) {
        const std::string a = argv[i];
        if (a == "--selftest") selftest = true;
        else if (a == "--bench") bench = true;
        else if (a == "--iters" && i + 1 < argc) iters = std::atoi(argv[++i]);
        else if (a == "--n-in" && i + 1 < argc) n_in = std::atoll(argv[++i]);
        else if (a == "--n-out" && i + 1 < argc) n_out = std::atoll(argv[++i]);
        else {
            std::fprintf(stderr, "usage: s_gemv_parity [--selftest] [--n-in N] [--n-out N]\n");
            return 2;
        }
    }

    using strata::kernels::Codebook;
    using strata::kernels::SForm;
    std::vector<Case> cases;
    cases.push_back({"S2/Q2_0", SForm{2, -1, 64, Codebook::Affine, false}, 18, q2_0_to_raw, q2_0_deq});
    cases.push_back({"S4/Q4_0", SForm{4, -8, 32, Codebook::Affine, false}, 18, q4_0_to_raw, q4_0_deq});
    cases.push_back({"S4/IQ4_NL", SForm{4, 0, 32, Codebook::Iq4Nl, false}, 18, iq4_to_raw, iq4_deq});
    cases.push_back({"S8/Q8_0", SForm{8, -128, 32, Codebook::Affine, false}, 34, q8_0_to_raw, q8_0_deq});

    std::mt19937 rng(4242);
    const int n_scales = (int) (sizeof(kScales) / sizeof(kScales[0]));
    int total_bad = 0;

    for (const Case& tc : cases) {
        const int bits = tc.form.code_bits;
        const int per_byte = 8 / bits;
        const int G = tc.form.group_elems;
        const long long n_groups = n_in / G;
        const long long codes_per_row = n_in / per_byte;

        // activations
        std::vector<uint16_t> x((size_t) n_in);
        for (long long i = 0; i < n_in; ++i) x[(size_t) i] = kScales[rng() % n_scales];

        // Codes are generated ONE PER ELEMENT and then packed into the plane the kernel reads.
        //
        // The first version of this test generated a random BYTE ARRAY and used it as both, which is the bug
        // that made S2 pass and the other three fail by 50x-500x.  The two are not the same thing: the kernel
        // reads element i from byte i/per_byte at bit (i%per_byte)*bits, while the reference needs one code per
        // element to rebuild a raw GGUF block.  For S2 (per_byte = 4, which IS Q2_0's own raw layout) treating
        // one random array as both happened to agree - and that coincidence is exactly what a test must not
        // depend on.
        std::vector<uint8_t> codes_el((size_t) n_out * n_in);
        for (size_t i = 0; i < codes_el.size(); ++i) codes_el[i] = (uint8_t) (rng() & ((1u << bits) - 1));
        std::vector<uint8_t> codes((size_t) n_out * codes_per_row, 0);
        for (long long o = 0; o < n_out; ++o) {
            for (long long i = 0; i < n_in; ++i) {
                const long long byi = o * codes_per_row + i / per_byte;
                codes[(size_t) byi] |= (uint8_t) (codes_el[(size_t) (o * n_in + i)]
                                                  << ((i % per_byte) * bits));
            }
        }
        std::vector<float> scales((size_t) n_out * n_groups);
        std::vector<uint16_t> scale_bits((size_t) n_out * n_groups);
        for (size_t i = 0; i < scales.size(); ++i) {
            scale_bits[i] = kScales[rng() % n_scales];
            scales[i] = strata::fp16_to_fp32(scale_bits[i]);
        }

        // CPU reference
        std::vector<float> ref((size_t) n_out, 0.0f);
        std::vector<uint8_t> raw((size_t) tc.raw_bytes);
        std::vector<float> dec((size_t) G);
        for (long long o = 0; o < n_out; ++o) {
            float acc = 0.0f;
            for (long long g = 0; g < n_groups; ++g) {
                const size_t co = (size_t) o * n_in + (size_t) g * G;   // the UNPACKED code array
                const size_t so = (size_t) o * n_groups + (size_t) g;
                // The raw block carries the scale as fp16 and the canonical plane carries it widened, so the
                // block is rebuilt from the PATTERN the plane came from.  No float-to-fp16 conversion is
                // needed anywhere, and the decoder therefore sees bit-identically what the kernel reads.
                const int n = tc.to_raw(codes_el.data(), co, scale_bits.data(), so, raw.data());
                tc.dequant(raw.data(), dec.data());
                for (long long j = 0; j < (long long) n; ++j) {
                    acc += dec[(size_t) j] * strata::fp16_to_fp32(x[(size_t) (g * G + j)]);
                }
            }
            ref[(size_t) o] = acc;
        }

        // GPU
        uint16_t* d_x = nullptr;
        uint8_t* d_codes = nullptr;
        float *d_scales = nullptr, *d_y = nullptr;
        check(cudaMalloc(&d_x, x.size() * sizeof(uint16_t)), "cudaMalloc x");
        check(cudaMalloc(&d_codes, codes.size()), "cudaMalloc codes");
        check(cudaMalloc(&d_scales, scales.size() * sizeof(float)), "cudaMalloc scales");
        check(cudaMalloc(&d_y, (size_t) n_out * sizeof(float)), "cudaMalloc y");
        check(cudaMemcpy(d_x, x.data(), x.size() * sizeof(uint16_t), cudaMemcpyHostToDevice), "copy x");
        check(cudaMemcpy(d_codes, codes.data(), codes.size(), cudaMemcpyHostToDevice), "copy codes");
        check(cudaMemcpy(d_scales, scales.data(), scales.size() * sizeof(float), cudaMemcpyHostToDevice),
              "copy scales");
        strata::kernels::s_gemv(d_x, d_codes, d_scales, nullptr, d_y, n_in, n_out, tc.form);
        std::vector<float> got((size_t) n_out);
        check(cudaMemcpy(got.data(), d_y, got.size() * sizeof(float), cudaMemcpyDeviceToHost), "copy back");

        long long bad = 0;
        double worst = 0.0;
        for (long long o = 0; o < n_out; ++o) {
            const double a = ref[(size_t) o], b = got[(size_t) o];
            const double rel = std::fabs(a - b) / (std::fabs(a) > 1e-30 ? std::fabs(a) : 1e-30);
            if (!(rel <= worst)) worst = rel;
            if (!(rel <= tol)) ++bad;
        }
        double lo = ref[0], hi = ref[0];
        for (float v : ref) {
            lo = std::fmin(lo, v);
            hi = std::fmax(hi, v);
        }
        std::printf("  %-10s %lld rows, %lld over tol, worst rel %.3e   ref spread [%.4g, %.4g]\n", tc.name,
                    n_out, bad, worst, lo, hi);
        if (hi - lo <= 0.0) {
            std::fprintf(stderr, "VACUOUS: %s reference is constant\n", tc.name);
            return 1;
        }
        total_bad += (int) bad;

        cudaFree(d_x);
        cudaFree(d_codes);
        cudaFree(d_scales);
        cudaFree(d_y);
    }

    std::printf("s_gemv: %zu type cases, %d rows over tolerance (tol %.1e)\n", cases.size(), total_bad, tol);
    // Q4_K IS NOT PART OF THE DEFAULT RUN.  It FAILS: 128 of 128 rows, worst error 0.586 of sum|term| - a
    // real difference, not cancellation (the condition number is 2.8e7 and the error is 1.6e7).  Either the
    // kernel's offset handling or this test's reconstruction of a Q4_K block is wrong and the two have not
    // been separated yet.  Running it by default would turn ctest red and hide regressions in the four cases
    // that ARE verified; leaving it out silently would be worse.  It is one flag away, and it is recorded as
    // OPEN in the project state.
    // Q4_K is IN the default run as of round 171.  Rounds 169-170 gated it because it FAILED, and the
    // failure was this test's own reconstruction of a Q4_K block - four decodes of a quarter-filled block per
    // superblock - not the kernel.  It now agrees to 2.8e-07 of sum|term|, so the gate goes rather than
    // staying as a habit: a gate that outlives its reason is a check that quietly stopped running.
    test_q4k(n_in, n_out, tol, &total_bad);
    std::printf("s_gemv total: %d rows over tolerance\n", total_bad);
    if (bench) {
        // the real expert shapes: gate/up are [2560 x 640], down is [640 x 2560]
        int split_bad = 0;
        bench_s2_gemv(2560, 640, iters, &split_bad);
        bench_s2_gemv(640, 2560, iters, &split_bad);
        std::printf("split kernel rows disagreeing with the naive reference: %d\n", split_bad);
        // accumulated HERE, inside the block that declares it, and it makes the binary FAIL: a kernel that is
        // benchmarked but never compared is an unverified kernel being quoted for performance.
        total_bad += split_bad;
    }
    if (total_bad) return 1;
    if (selftest) std::printf("s_gemv_parity OK\n");
    return 0;
}