Winery-Strata / src /kernels /s_gemv_parity.cpp
Zandy-Wandy's picture
🍷 Winery Strata: fork of Niko1221/Strata (Winery Cuvée family, wine-red UI)
bbb6388 verified
Raw History Blame Contribute Delete
34.5 kB
// 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;
}