Skip to content

feat(qtip): V=4/L=12 decode family — K as a parametric seam, bit-exact CPU parity - #168

Merged
heydryft merged 5 commits into
masterfrom
feat/qtip-k8v4l12
Aug 20, 2026
Merged

heydryft merged 5 commits into
masterfrom
feat/qtip-k8v4l12

Conversation

@heydryft

@heydryft heydryft commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Stage 1 of 3. The V=4/L=12 decode family — its table, its serving kernel, and the CPU reference the GPU is held to — with K as a parametric seam. No UQFF format (#170), no dispatch (#172).

⚠️ Read this before quoting any instruction count

Every absolute inst/weight figure attached to this family is currently PROVISIONAL, including the ones that justified it.

  • The isolated micro-census that produced per-symbol route costs was withdrawn as invalid: its K=8 aligned control measured 100% non-linear, because K=8's contiguous-byte symbols let ptxas merge loads while K=9's (9t)>>3 indices cannot. Every "vs K=8" delta from it was against a moving baseline.
  • The full-kernel rig that replaced it has not yet reproduced the K=8 control's published 5.375 / 4.375, so it is not validated end to end.
  • Therefore 5.375, 4.375, and the 15.125-for-the-shipped-rung they are compared against are all unreproduced, and the "3.46× cheaper" framing this family was originally justified by is on hold pending run 32264842287.
  • Nothing in this PR has been compiled with nvcc or run on a GPU by its author, at any K.

Exactly one measured number here is trusted, and the format decision below rests on it alone.

Why the family

L=12 and V=4 fix the table at 2^12 × 4 bf16 = 32,768 B, under the 48 KiB static __shared__ limit, so a kernel stages it per block and every lookup is an LDS. The shipped rung's table is 2^16 × 2 f32 = 524,288 B — it does not fit shared memory at any occupancy, so every decoded symbol pays a dependent, scattered L2 load. That is the shipped default path (QtipCodebook::DEFAULT is Gaussian, which gathers). Killing that gather is what the 32 KiB table is for.

The table does not read K at all. So K changes symbol extraction, the packed row length, the state shift width and the warmup depth — nothing else. Rung and QtipSymExtract<K> are the only K-dependent code.

K=8 is the control, not the shipping candidate — quality-closed at Δw_cos −0.00698 / −0.00307 against a ±0.0008 band. K=9 measured +0.00402, 5× better than the shipped control. That evidence is CPU-side and is not in doubt.

The format decision: rows are padded, the clamp is gone

row_stride = align_up(data_bytes + MAX_BYTES − 1, 4).

Taken on the one number that survives scrutiny: compiled at sm_90 by full-kernel differencing, the tail clamp cost +1.126 inst/weight at K=9 and +1.125 at K=10 — two independent geometries agreeing to 0.1%. It is trustworthy where the absolutes are not because it is a difference between two kernels in the same mode, so it does not ride on a baseline. Through this kernel's own rates: +1.193 inst/weight @ in_features=4096, +1.397 @ 1024.

Padding costs at most 4 bytes per row — 0.35% at 4096, 0.20% at 7168.

The round-up to 4 is not tail safety (which needs only MAX_BYTES − 1); it makes every row base 4-byte aligned, which the cheapest measured route (funnel) requires. The stride is format, and changing it twice is the expensive outcome, so it is chosen once to support both.

K=8 is untouched: MAX_BYTES == 1 ⇒ no pad, so the control keeps byte-for-byte the layout it was probed with and this decision cannot perturb its replay.

packed_bytes is split into data_bytes (bit-rate governed) and row_stride (allocated, validated), because conflating them is how a bpw claim ends up 4 bytes wrong.

Two other operation-count fixes

One warp owns a row, not one block. Warmup replay makes extractions/weight (S+W)/(S·V) — a tax of 1 + W/S set by slice length. 128 lanes/row gave 1.25× at 4096 and 2.98× at 512; 32 lanes/row gives 1.06× and 1.48×. It also deletes the cross-warp butterfly, warp_sums, and both __syncthreads from the row loop.

⚠️ The census cannot see that second half. Its inst/weight is a differential over unroll depth, which cancels prologue and epilogue exactly and is therefore blind to per-row cost. A flat census number does not mean the reshape did nothing. structural_per_row_overhead() gives it a separate accounting — (0,1,0) vs (2,2,2) — as structural counts from source, never instruction counts.

Two bytes is exact, not merely sufficient. Offsets are multiples of gcd(K,8), so the bound is ceil((8 − gcd(K,8) + K)/8): K=8→1, K=9→2, K=10→2. The 3-byte route is unreachable for every K anyone has, now enforced by static_assert.

Parity: no tolerance

gemv_row_gpu_model models the arithmetic and the reduction tree, so the CUDA gate compares bits with ==. Bit layout is pinned: [tK, tK+K) LSB-first, verified against the shipped K=4 nibble order.

Guards shown red (M1–M15, E1–E15, N1–N9, C1–C5, P1–P6)

Eight holes found this way. The newest: my reassociation guard had the same shape as the failure that invalidated the census rig — |a−b| ≤ 1e-5·L1 is satisfied by 0 == 0, so an all-zero decoder passed it comfortably. (Their rig reported 0.000 inst/weight with verdict "OK" because |d2−d1|/d1 is 0.00% when all counts are equal: steadiness checked without growth.) It now asserts non-degeneracy before agreement.

Earlier ones: the table's contents were unpinned; every fixture divided evenly by the thread count; nothing tied KERNEL_THREADS to the .cu; nothing tied a case k: label to its template argument; a barrier guard counted the word __syncthreads in a comment and reported 2 against a kernel with 1 — whose tempting "fix" would have blinded it permanently.

Scoped clippy clean; cargo test -p mistralrs-quant 323 + 5 passing.

What remains

…reference

Stage 1 of making K8/V4/L12 a selectable rung: the decode geometry, its
reproduction table, the serving kernel, and the reference the GPU is held to.
No format, no dispatch, no quantizer yet — those are stages 2 and 3.

`bpw = K/V`, so this is 2 bits per weight, the same rate the shipped
K=4/V=2/L=16 rung produces. Nothing here compresses harder. What changes is
decode cost: the table is 2^12 x 4 bf16 = 32,768 B instead of 2^16 x 2 f32 =
524,288 B, which fits static __shared__ with no cudaFuncSetAttribute opt-in,
and K=8 makes a symbol exactly one byte so the nibble unpack disappears.

Added:
  * mistralrs-quant/src/qtip/k8v4l12.rs — geometry, bf16 table, CPU decode
    reference, and `gemv_row_gpu_model`: a bit-exact model of the kernel
    INCLUDING its reduction tree (per-thread slicing, warmup seeding, warp
    XOR butterfly, cross-warp butterfly). Modelling the tree is what lets the
    CUDA gate compare bits with `==` instead of a tolerance — a tolerance
    there would be where a mis-seeded thread hides behind benign
    reassociation.
  * kernels/qtip/qtip_gemv_k8v4l12.cu — the serving kernel, grid-strided over
    rows so the 32 KiB stage is amortised per block rather than per row.
  * FFI + `fused_gemv_k8v4l12_cuda`, which refuses a K=4/V=2 artifact three
    ways (F32 table, 2^16x2 table, nibble-packed rows) rather than decoding it
    into plausible garbage.

RowScaleHoist is its own switch because it is the only lever that costs
bit-exactness: it reassociates the sum. Parity runs with it Off.

NOT MEASURED: the 5.375 / 4.375 inst-per-weight figures came from a standalone
compiled probe, not from this kernel. This kernel has never been compiled with
nvcc and never run. Its instruction count is unestablished.

Guards were each shown red before being trusted (mutations M1-M15). Three
holes found and closed that way: the table's contents were unpinned (a mutation
rounding every value to the nearest integer passed all 16 tests — now a
digest), every fixture divided evenly by the thread count so a floored
sym_per_thread dropped row tails undetected, and nothing tied KERNEL_THREADS
to the .cu (now a whitespace-normalised source guard that catches drift from
either side).

kernels/EXPECTED_KERNEL_COUNT 39 -> 40, as its own guard instructed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
@github-actions

github-actions Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Code Metrics Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 C Header                  5          305          210           52           43
 CSS                       2         1181         1036           34          111
 CUDA                     73        25129        18043         4319         2767
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     74         4600         4597            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  145        15139        12482          811         1846
 Shell                    39         9777         6538         2596          643
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1498         1294           54          150
 YAML                      3           25           23            2            0
─────────────────────────────────────────────────────────────────────────────────
 HTML                      4         2687         2604           43           40
 |- CSS                    2          543          479           37           27
 |- JavaScript             1         1233         1215           12            6
 (Total)                             4463         4298           92           73
─────────────────────────────────────────────────────────────────────────────────
 Jupyter Notebooks         4          122           83           23           16
 |- Markdown               1           60           30           22            8
 |- Python                 1          122          113            1            8
 (Total)                              304          226           46           32
─────────────────────────────────────────────────────────────────────────────────
 Markdown                203        44777            0        34735        10042
 |- BASH                  72         1654         1202          331          121
 |- C                      3           17           17            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  18          708          708            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  66         2051         1716           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   5           41           36            5            0
 (Total)                            50548         4687        35277        10584
─────────────────────────────────────────────────────────────────────────────────
 Rust                    672       328294       283104        16426        28764
 |- Markdown             490        28532          471        24619         3442
 (Total)                           356826       283575        41045        32206
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1320       490405       350026        88474        51905
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

heydryft and others added 2 commits August 19, 2026 14:58
…raction

K=8/V=4/L=12 is quality-closed: CPU sweeps put it at Delta w_cos -0.00698
(random codebook) and -0.00307 (converged trellis-Lloyd) against a +/-0.0008
ship threshold, and six codebook designs failed to recover it. K=9 at 2.25 bpw
measured +0.00402. So K has to move, and this restructures the rung so that it
can move as a specialisation rather than a rewrite.

The seam is symbol extraction, and only symbol extraction. L=12 and V=4 fix the
table at 32,768 B, and the table does not read K at all — so K changes the
bit-extract, the packed row length, the state shift width and the warmup depth,
and touches nothing else. `Rung` (Rust) and `QtipSymExtract<K>` (CUDA) are the
only K-dependent code.

  k8v4l12.rs          -> trellis_v4l12.rs
  qtip_gemv_k8v4l12.cu -> qtip_gemv_v4l12.cu
  kernel is now template <typename T, int THREADS, int K, bool ROW_SCALE_HOIST>
  launchers take a runtime `k`, dispatching to K=8/9/10 instantiations

K=8 stays as the byte-aligned CONTROL with its bit-exact gate intact: a symbol
IS a byte, so `QtipSymExtract<8>` is a real specialisation (one LDG, no shift,
no mask, no clamp) and is the floor the others are measured against. K=9 and
K=10 are carried so the alignment penalty can be measured on TWO
non-byte-aligned points, which is what separates "9 is unlucky" from
"non-alignment costs C".

Bit layout is now stated and pinned: symbol t is bits [tK, tK+K) LSB-first,
which at K=4 reproduces the shipped rung's "sym 2b is the low nibble of byte b"
exactly. `pack`/`extract` round-trip at every K, and a fixture-free test drives
the K=4 rule directly against hand-packed bytes.

NOT COMMITTING TO K=9 CODEGEN. The K=9/K=10 instantiations exist for
correctness coverage and to give the SASS pass a real serving kernel to measure
instead of a probe. No performance claim: this kernel has never been compiled
with nvcc or run, at any K.

One thing the SASS measurement must not be misread on: the multi-byte extract
reads a COMPILE-TIME byte count and clamps the index so it cannot run past the
last row. The clamp is correct (bytes beyond ceil((off+K)/8) land above the
K-bit mask window, so a substituted in-row byte cannot change the symbol) but
it costs a `min` per byte read and would DISAPPEAR if the row stride were
padded by MAX_BYTES-1. A K=9 number that includes it is an upper bound on the
alignment penalty, not the penalty. Padding is a format decision deliberately
not taken while K is unsettled. Flagged to the SASS agent.

Also states plainly, per the coordinator, what the 32 KiB table is FOR: the
shipped default (`QtipCodebook::DEFAULT = Gaussian`) gathers from a 524,288 B
f32 table that does not fit shared memory at any occupancy, so every decoded
symbol pays a dependent scattered L2 load — measured 388 GB/s, ~8% of H200 HBM,
and it is the decode limiter.

Guards shown red before being trusted (E1-E15). Two holes found: nothing tied a
`case k:` label to the template argument it dispatches to (`case 9:
LAUNCH_K(T, 8, ..)` would decode K=9 at K=8, in bounds and no fault), and
nothing refused a kernel case for a K with no CPU model. One of my own
mutations was again a no-op — a `default: return;` placed before `case 9:` does
not make it unreachable in C++ — and was redone as a real deletion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
Two findings from the K9 alignment census, acted on before its counts land.
Both are UNMEASURED as speedups — they reduce operation counts that can be
derived exactly, and the derivations are pinned as tests.

1. THE WARMUP TAX WAS THE BIGGER PROBLEM, AND IT IS K-INDEPENDENT

Every lane replays `warmup_syms` symbols before decoding its own slice, so
extractions per weight are (S + W)/(S*V) against an ideal of 1/V — a tax of
1 + W/S, set entirely by slice length S = num_symbols / lanes_per_row. The
kernel split a row across all 128 threads, which makes S short:

    in_features   128 lanes/row     32 lanes/row
    4096          S=8,  1.25x       S=32, 1.06x
    1024          S=2,  1.99x       S=8,  1.24x
     512          S=1,  2.98x       S=4,  1.48x

So a WARP now owns a row and a block owns THREADS/32 rows at once. Same
parallelism, four times the slice. It also deletes the cross-warp butterfly,
the `warp_sums` shared array and BOTH `__syncthreads` from the row loop, since
a warp reduces with shuffles alone — and it simplifies the bit-exact CPU model
to a single 32-lane butterfly.

This multiplies the extraction count at every K, so it scales the K=9 alignment
penalty by the same factor rather than competing with it.

Correcting one number in the census: it assumed THREADS=256 and reported 1.5x
at in_features=4096. This kernel is THREADS=128, so it was 1.25x, now 1.06x.
The direction and the argument are unchanged.

2. K=10 NEEDS TWO BYTES, NOT THREE

The bit offset of symbol t is (t*K) mod 8, which only ever takes the multiples
of gcd(K, 8). The worst REACHABLE offset is 8 - gcd(K,8), not 7. At K=10 the
offsets are {0, 2, 4, 6}, so off+K <= 16 and two bytes always suffice; the
naive ceil((7+K)/8) said three and had every symbol read, shift and discard a
wasted byte. The bound is now gcd-based on both sides, and the test proves it
by enumerating reachable offsets rather than trusting the formula.

Still not committing to K=9 codegen; still no compiled measurement of this
kernel at any K. The tail clamp remains and remains removable by padding the
row stride — the census is pricing it separately, which is the right call.

Guards shown red (N1-N9), including three that would let the model and the
kernel drift apart: a model that splits by block size, a kernel that reverts to
splitting a row across the block, and a kernel that reintroduces a cross-warp
reduction the model no longer has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
@heydryft heydryft changed the title feat(qtip): K=8/V=4/L=12 decode rung — kernel + bit-exact CPU parity reference feat(qtip): V=4/L=12 decode family — K as a parametric seam, bit-exact CPU parity Aug 19, 2026
… separately

The census measures inst/weight as a DIFFERENTIAL OVER UNROLL DEPTH. That
cancels prologue and epilogue exactly — which is what makes it robust, and is
also why it is structurally blind to per-row and per-block cost.

So the warp-per-row reshape's reduction half is invisible to it: the deleted
cross-warp butterfly, the `warp_sums` array and both `__syncthreads` can
neither be credited nor charged by that instrument. **A flat census number
therefore does not mean the reshape did nothing**, and this commit makes that
impossible to misread six weeks from now:

  * The module docs state the blindness and where the reshape DOES show up
    (indirectly, through the extraction rate).
  * `structural_per_row_overhead()` gives that half an accounting of its own —
    (barriers, warp butterflies, shared round-trips) = (0, 1, 0), against
    (2, 2, 2) before. STRUCTURAL COUNTS FROM SOURCE, never presented as
    instruction counts; nothing here has been compiled.
  * `route_cost_per_weight_x10000()` pins the arithmetic the incoming census
    result gets run through, so substituting one of its illustrative endpoints
    (0.25 / 0.375 / 0.50) for this kernel's real rate cannot happen silently.
    This kernel runs at 0.2651 at in_features=4096 and 0.3105 at 1024.

Also acts on the census's confirmation that the gcd bound makes two bytes
EXACT, not merely sufficient, for K=9 and K=10 — so the 3-byte extraction route
is unreachable for every K anyone has. That is now enforced at compile time
(`static_assert(QtipSymExtract<K>::MAX_BYTES <= 2)`) so a future K that needs
three is a deliberate decision rather than something a new `case` quietly
turns on.

MY OWN BUG, CAUGHT BY THE GUARD I WAS WRITING: the new "exactly one
__syncthreads" check counted raw occurrences, which also counts the two places
the header *describes* barriers — it reported 2 against a kernel that has 1.
The obvious "fix" is to assert 2, after which it would never catch a real
barrier again. Fixed properly by stripping comments first, and pinned by a
mutation pair: a real barrier in the row loop goes red, a barrier merely
MENTIONED in a new comment stays green.

Guards shown red (C1, C3, C4, C5) with C2 verified green-by-design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
…solutes

FORMAT DECISION, TAKEN ON THE ONE NUMBER THAT SURVIVES SCRUTINY

Rows are now allocated at `row_stride = align_up(data_bytes + MAX_BYTES-1, 4)`
and the kernel's tail clamp is gone.

Measured at sm_90 by FULL-KERNEL differencing, the clamp cost +1.126
inst/weight at K=9 and +1.125 at K=10 (padded 6.312 -> clamped 7.438, and
6.250 -> 7.375) — about +4.50 per extraction. Two independent geometries
agreeing to 0.1%. It is trustworthy where the absolute figures are not, because
it is a DIFFERENCE BETWEEN TWO KERNELS IN THE SAME MODE and therefore does not
depend on a baseline. Run through this kernel's own rates that is +1.193
inst/weight at in_features=4096 and +1.397 at 1024.

Cost of padding: at most 4 bytes per row — 0.35% at 4096, 0.20% at 7168.

The round-up to 4 is NOT tail safety, which needs only MAX_BYTES-1. It makes
every row base 4-byte aligned, which the cheapest measured route (the funnel
route) requires. The stride is format; changing it twice is the expensive
outcome, so it is chosen once to support both.

K=8 IS UNTOUCHED. `max_bytes_per_symbol() == 1` means no tail overrun to pad
against and no multi-byte funnel to align for, so the control keeps byte-for-
byte the layout it was probed with and the padding decision cannot perturb it.

`packed_bytes` is split into `data_bytes` (governed by the bit rate) and
`row_stride` (what a tensor is allocated at and what the UQFF validates),
because conflating them is how a bpw claim ends up 4 bytes wrong.

ABSOLUTE INSTRUCTION COUNTS ARE NOW MARKED PROVISIONAL — INCLUDING OURS

The isolated micro-census was withdrawn as invalid: its K=8 control measured
100% non-linear, because K=8's contiguous-byte symbols let ptxas merge loads
while K=9's (9t)>>3 indices cannot, so every "vs K=8" per-symbol delta was
against a moving baseline. The full-kernel rig has not yet reproduced the K=8
control's published 5.375 / 4.375 either. So this family's own headline figures
— 5.375, 4.375, and the 15.125 they are compared against — are now labelled
unreproduced in the module and kernel docs, not just the new route figures.

APPLYING THEIR FAILURE TO MY OWN GUARDS

Their rig reported 0.000 inst/weight with verdict "OK", including the control
that exists to prove it: nvcc re-rolled the loops, and the linearity check read
|d2-d1|/d1, which is 0.00% when all three counts are equal. The guard passed
HARDEST when the instrument was most broken.

My reassociation guard had the same shape — `|a-b| <= 1e-5 * L1` is satisfied
by `0 == 0`, so an all-zero decoder would have passed it comfortably. It now
asserts non-degeneracy BEFORE agreement: there must be something before it can
agree.

Guards shown red (P1-P6), including a stride that pads for the tail but drops
alignment, one that aligns but forgets the tail (an out-of-bounds read), and a
kernel that reintroduces the clamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpVNMpb13HkUXqSqbN1o9H
@heydryft
heydryft merged commit 17aeb49 into master Aug 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant