Skip to content

AProjQ4: imatrix-guided Q4_K dense attention projections - #952

Open
GiorgioOppo wants to merge 190 commits into
antirez:mainfrom
GiorgioOppo:aprojq4-dense-attention
Open

AProjQ4: imatrix-guided Q4_K dense attention projections#952
GiorgioOppo wants to merge 190 commits into
antirez:mainfrom
GiorgioOppo:aprojq4-dense-attention

Conversation

@GiorgioOppo

@GiorgioOppo GiorgioOppo commented Sep 2, 2026

Copy link
Copy Markdown

Summary

This PR supersedes #621.

PR #621 remains the development, investigation, and benchmark archive. This PR
contains only the production-ready changes required to support the DeepSeek V4
Flash AProjQ4 layout, together with focused correctness tests,
backend-specific production paths, and the documentation needed to reproduce
the model.

AProjQ4 changes the 215 dense attention-projection tensors from Q8_0 to
Q4_K, while preserving:

  • the original -0731 checkpoint;
  • routed-expert quantization;
  • shared-expert quantization;
  • output-head quantization;
  • tokenizer and GGUF metadata;
  • all tensors outside the selected attention-projection families.

The result is a model that is 2.14 GiB smaller and substantially faster in
decode on Metal, CUDA, and ROCm.


1. Imatrix-guided requantization

Method

The AProjQ4 artifact is produced directly from the matching AProjQ8 GGUF. The
original Hugging Face safetensors are not required.

matched AProjQ8 -0731 GGUF
        +
routed-and-dense DS4 activation imatrix
        +
215 selected dense attention projections
        |
        v
Q8_0 -> Q4_K requantization
        |
        v
matched AProjQ4 -0731 GGUF

The conversion selects five tensor families in each of the 43 transformer
layers:

Tensor family Layers Converted tensors
attn_q_a 43 43
attn_q_b 43 43
attn_kv 43 43
attn_output_a 43 43
attn_output_b 43 43
Total 215

All unselected tensors are copied from the source GGUF byte for byte.

How the imatrix is used

The conversion uses the published DS4 activation-importance matrix:

DeepSeek-V4-Flash-chat-v2-routed-and-dense-ds4-220k.dat

The imatrix supplies a per-input-column importance vector for each selected
tensor. The Q4_K quantizer uses these values to weight quantization error while
choosing block scales, minima, and quantized codes.

As a result, errors affecting frequently used activation columns receive more
weight than errors on columns that are rarely activated by the calibration
workload.

The tensor type remains Q4_K; the imatrix changes how the available Q4_K
representation is selected, not its runtime format.

--imatrix-strict is used so that conversion fails if a selected tensor has:

  • no matching imatrix entry;
  • an incompatible number of columns;
  • an invalid or incomplete importance vector.

This prevents a selected projection from silently falling back to
non-imatrix quantization.

Reproduction

First validate the complete conversion plan without writing an output file:

gguf-tools/deepseek4-quantize \
  --source-gguf /path/to/DeepSeek-V4-Flash-AProjQ8-0731.gguf \
  --attention-proj q4_k \
  --imatrix /path/to/DeepSeek-V4-Flash-chat-v2-routed-and-dense-ds4-220k.dat \
  --imatrix-strict \
  --dry-run

Then produce the AProjQ4 GGUF:

gguf-tools/deepseek4-quantize \
  --source-gguf /path/to/DeepSeek-V4-Flash-AProjQ8-0731.gguf \
  --out /path/to/DeepSeek-V4-Flash-AProjQ4-0731.gguf \
  --attention-proj q4_k \
  --imatrix /path/to/DeepSeek-V4-Flash-chat-v2-routed-and-dense-ds4-220k.dat \
  --imatrix-strict

The source and output paths must be different. The output should first be
written to a temporary filename and renamed only after checksum, tensor-layout,
and quality validation.

Published artifact

DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ4-SExpQ8-OutQ8-chat-v2-imatrix-0731.gguf
SHA-256:
413cf0a68ca8d084e89f3f810eef5046b5308174d441a80017a0ff388933c767
Layout Size Difference
AProjQ8 86,720,111,488 bytes — 80.76 GiB Baseline
AProjQ4 84,420,584,288 bytes — 78.62 GiB −2.14 GiB
Saving 2,299,527,200 bytes −2.65%

The saved memory can be used as additional KV/context capacity on resident
systems or as a larger routed-expert cache under SSD streaming.

Quality validation

The original same-checkpoint comparison on the 100-case official-continuation
fixture showed no measured quality regression:

Model avg_nll First-token match Average greedy LCP API top-1
AProjQ8 0.404811251 55/100 5.150 0.858625
AProjQ4 with imatrix 0.396432403 60/100 6.090 0.860355

After the later main realignment, the absolute Q4 figures changed slightly
with the engine:

avg_nll:     0.395906185
avg_lcp:     6.170
first_match: 60/100
top1_rate:   0.859057501

The complete 100-case TSV remained byte-identical across 9a448bb1,
35b87dda, and the final development head 6a20b131. The final grouped CUDA
quantizer therefore introduced no numerical movement.

Note

The quality fixture contains 100 cases and 2,313 target tokens. It establishes
no measured regression on this fixture; it is not a claim that Q4 is
universally more accurate than Q8.


2. Metal results

Test platform

MacBook Pro Mac17,6
Apple M5 Max
40-core GPU, Metal 4
128 GB unified memory
112 GiB configured Metal working set
Fully resident execution
No SSD streaming

The final development commit 6a20b131 was tested twice, using three
interleaved Q4/Q8 sweeps from 2,048 to 65,536 context tokens.

Decode

Metric Result
Three-repetition paired median Q4/Q8 1.155 / +15.5%
Independent hot-start paired median 1.147 / +14.7%
Context frontiers won by Q4 32 / 32
Q4/Q8 at 2,048 context 1.173 / +17.3%
Q4/Q8 at 65,536 context 1.129 / +12.9%

Selected final-head results:

Context Q4 decode Q8 decode Q4/Q8
2,048 48.80 tok/s 41.62 tok/s 1.173
18,432 1.154
34,816 1.145
51,200 1.135
65,536 1.129

The advantage is largest at short context and decreases gradually as context
grows, but it never reverses.

The 1.155 paired median reproduces the earlier result to three decimal places
despite 226 intervening development commits.

Prefill

Test state Q4/Q8 prefill
Final-head cold-start run 1.003
Final-head hot-start run 1.021
Earlier independent run 0.998

The measured range in the final cold-start run was 0.980–1.027. Taken
together, the three independent measurements show that full-model Metal
prefill is currently indistinguishable between Q4 and Q8.

The production claim for Metal is therefore:

AProjQ4 provides a repeatable 15%+ decode improvement with no demonstrated
steady-state prefill regression.

It should not be described as a proven Metal prefill win until a balanced test
shows a repeatable difference above the session-order noise floor.

Startup and first token

An earlier isolated matched run on the same M5 Max measured:

Metric AProjQ8 AProjQ4 Q4 change
First generated token 26.86 ms 23.32 ms −13.2% latency
Steady decode at 2K 45.69 tok/s 53.35 tok/s +16.8%

This supports a lower time-to-first-token claim, but it should not be described
as faster thermal warm-up.

The final temperature-logged test showed that the machine reached its peak
temperature within roughly two minutes and then became cooler while throughput
continued to decline. The longer-session drift is therefore not explained by
die temperature alone.

A cold single repetition can report a Q4/Q8 decode ratio close to 1.19, while
the more representative three-repetition paired median is 1.155.

Metal conclusion

  • Decode: clear and repeatable Q4 win.
  • First-token latency: lower with Q4 in the isolated matched test.
  • Prefill: practical parity.
  • All 32 tested decode frontiers favor Q4.

Development evidence:
final M5 Max test.


3. CUDA results

Test platform

NVIDIA DGX Spark
GB10 / sm_121
128 GB unified memory
CUDA 13
Fully resident execution
No SSD streaming

The final development head 6a20b131 was tested with balanced model ordering.
This is important because the first arm in a benchmark pair has a measured
advantage of approximately 0.38–0.53 percentage points on this machine.

The final prefill experiment therefore alternated:

round 1: Q4 -> Q8
round 2: Q8 -> Q4

It used 10 windows, 20 paired samples per context, 160 total runs, and pure
prefill with --gen-tokens 0.

Full-model Q4 versus Q8 prefill

Context Mean Q4 vs Q8 Standard deviation 95% confidence interval
8,192 +0.33% 0.47% [+0.13%, +0.54%]
16,384 −0.34% 0.29% [−0.47%, −0.21%]
32,768 −0.22% 0.30% [−0.35%, −0.09%]
65,536 −0.29% 0.29% [−0.41%, −0.16%]

The signs are statistically stable, but every difference is only a fraction of
one percent.

The defensible full-model conclusion is:

Q4 and Q8 are at practical prefill parity on CUDA: Q4 is slightly ahead at
8K and approximately 0.2–0.3% behind at longer contexts.

This balanced result supersedes the earlier +2.39% Q4/Q8 figure at 8K, which
used a different prompt, generation enabled, and an unbalanced measurement
order.

Gain from the new grouped prefill path

The production grouped Q4 attention-A path was also compared directly with its
rollback path.

Context range Grouped path vs rollback
8K–64K +1.98% to +2.20%
Standard deviation 0.18–0.35%
Numerical result Byte-identical

This is a separate result from Q4-versus-Q8:

  • Q4 versus Q8 measures the final model layouts.
  • Grouped versus rollback measures the improvement delivered by the new CUDA
    implementation inside the Q4 path.

The grouped optimization is a solid approximately 2% implementation win,
even though the complete Q4 and Q8 models are already close to the same prefill
throughput.

Decode

The grouped quantizer is a prefill-only change. In the matched decode sweep,
AProjQ4 retained an advantage of:

+13.9% to +18.0%

over AProjQ8 across the tested short-context frontiers.

Numerical validation

The final grouped Q8_1 quantizer was verified as actually active through
fail-closed path controls.

Its output is byte-identical across:

9a448bb1
35b87dda
6a20b131

for all 100 quality cases. This is a scheduling and throughput optimization,
not a numerical approximation.

CUDA conclusion

  • Decode: approximately 14–18% faster with Q4.
  • Full-model long-context prefill: practical parity.
  • Grouped Q4 prefill implementation: approximately 2% faster than rollback.
  • Quality output: byte-identical across the final optimization commits.

Development evidence:


4. ROCm results

Test platform

AMD Ryzen AI Max+ 395
Radeon 8060S / gfx1151
128 GB unified memory
ROCm 7.1.1
Fedora 44
Fully resident execution
No SSD streaming

Important

No complete model-backed Q4/Q8 A/B for the final development head
6a20b131 has been published yet.

The table below is the latest complete, clean, and reproducible ROCm result.
It was measured at 5c5b1d5, before the later K128 staging change.

The 2K row is excluded from the steady-state prefill conclusion because Q8
showed a repeatable first-frontier cold-start artefact.

Context Q8 prefill Q4 prefill Q4 delta Q8 decode Q4 decode Q4 delta
4,096 228.42 tok/s 221.54 tok/s −3.0% 14.53 tok/s 16.22 tok/s +11.6%
6,144 224.81 tok/s 217.99 tok/s −3.0% 14.47 tok/s 16.15 tok/s +11.6%
8,192 221.61 tok/s 214.81 tok/s −3.1% 14.32 tok/s 15.97 tok/s +11.5%

Startup

Metric AProjQ8 AProjQ4 Q4 change
Model preparation 19.700 s 17.583 s −10.7%
Resident model 80.76 GiB 78.62 GiB −2.14 GiB
Total planned at 32K 81.79 GiB 79.65 GiB −2.14 GiB

Correctness

The latest safe-path correctness follow-up confirmed:

  • default and explicitly enabled safe paths produce bit-identical logits;
  • attention-output B remains on the exact Q8_K + TILE8 path;
  • unsupported strict diagnostic requests fail closed;
  • no GPU hangs occurred during the build, unit tests, consistency tests, or
    speed sweep.

An earlier experimental compounded-WMMA path produced a +5.3% prefill
improvement, but also introduced a real decode-versus-prefill mismatch. That
path is not part of the safe production result and should not be included in
this clean PR.

K128 status

A later development commit added K128/P144 staging for eligible 256-row
direct-WMMA launches.

That implementation has not yet received a complete final-head model-backed
Q4/Q8 A/B in the public discussion. This PR therefore intentionally reports
the verified approximately 3% prefill gap rather than the informal
approximately 2% estimate.

The ROCm table should be updated after the cleaned branch receives a balanced
K128-enabled test.

ROCm conclusion

  • Decode: approximately 11.5–12.6% faster with Q4.
  • Model preparation: 10.7% shorter.
  • Memory: 2.14 GiB lower.
  • Verified steady-state prefill: approximately 3% behind Q8.
  • Final K128/current-branch result: pending validation.

Development evidence:


Overall result

Area Result
Model size 2.14 GiB smaller
Metal decode +15.5% paired median, 32/32 frontiers
Metal prefill Parity
CUDA decode +13.9% to +18.0%
CUDA full-model prefill Parity within approximately ±0.3% at 8K–64K
CUDA grouped Q4 implementation Approximately +2% vs rollback
ROCm decode +11.5% to +12.6%
ROCm prefill Approximately −3% in the latest verified table
ROCm model preparation −10.7%
Quality No measured regression on the tracked fixture

Scope of this clean PR

This PR includes:

  • recognition of the explicit AProjQ4 layout;
  • direct AProjQ8-to-AProjQ4 requantization;
  • imatrix-strict dense-attention conversion;
  • tensor-type-aware CPU execution;
  • the validated production Metal path;
  • the validated production CUDA path;
  • the validated safe ROCm path;
  • focused correctness and regression tests;
  • minimal user-facing documentation.

This PR intentionally excludes:

  • superseded implementation experiments;
  • raw intermediate benchmark files;
  • compiled benchmark or test executables;
  • diagnostic-only unsafe kernel combinations;
  • the CUDA 16-warp experiment that is slower on GB10;
  • unrelated DSpark work;
  • unrelated DeepSeek Vision work;
  • broad refactors and quality-of-life changes not required by AProjQ4;
  • unrelated environment-variable documentation changes.

Validation required before merge

The cleaned branch should be frozen at one SHA and tested again before merge.

  • Record the final clean-PR SHA.
  • Confirm that no generated executable or binary artifact is tracked.
  • Run the CPU reference tests.
  • Run the Metal model and kernel tests.
  • Repeat the Metal Q4/Q8 sweep.
  • Run the CUDA parity and model tests.
  • Repeat CUDA prefill with balanced Q4/Q8 ordering.
  • Repeat the CUDA decode sweep.
  • Build and test ROCm on gfx1151.
  • Run the final ROCm K128 Q4/Q8 comparison.
  • Run the same-checkpoint quality fixture.
  • Compare AProjQ8 on this PR against AProjQ8 on main.
  • Record all model hashes, commands, machines, and environment overrides.

For throughput differences below 1%, benchmark order must be counterbalanced:

round 1: Q4 -> Q8
round 2: Q8 -> Q4

Fixed-order measurements are not sufficient for sub-percent performance claims.

GiorgioOppo and others added 30 commits August 31, 2026 16:01
The AProjQ4 DeepSeek V4 Flash GGUFs quantize the five dense attention
projections per layer (attn_q_a, attn_q_b, attn_kv, attn_output_a,
attn_output_b) as Q4_K instead of Q8_0. Loading already accepted them
(tensor_expect_dense_quant_layout), but the decode graph read the Q4_K
blocks through the hardcoded Q8_0 kernels and generated garbage (BOS
loops), and the CPU reference died with "expected a 2D Q8_0 tensor".

- Metal decode graph: gate the fused Q8_0 q_a/kv pair kernel and the
  plain Q8_0 matvec fallbacks on the actual tensor type, dispatching
  through the existing generic dense-quant path for Q4_K. Q8_0 models
  keep the exact kernels they used before, so their output stays
  bit-identical.
- CPU reference: add a dense Q4_K matvec/matmul family (activations
  prequantized to Q8_K, rows reduced with ds4_vec_dot_q4_K_q8_K), with
  grouped, decode-scratch and prefill-batch variants, and dispatch the
  attention projection call sites on tensor type.
- CUDA decode-TP attention output split now refuses non-Q8_0 output
  projections with a clear error instead of computing garbage.

Verified on Apple M1 Pro 16 GB with --metal --ssd-streaming on
DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ4-SExpQ8-OutQ8-chat-v2-imatrix:
coherent greedy output, --decode-consistency max_abs=0 rms=0, and no
regression on the AProjQ8 gguf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DS4_METAL_STREAMING_EXPERT_NOCACHE=1 serves the streaming expert preads
from a second F_NOCACHE descriptor (reopened by path: a dup would share
the file description with the mmap-backed fd) and turns off the
F_RDADVISE readahead hints, which only warm page cache the preads no
longer consume.

On tight-RAM machines the ~1 GB/token of routed-expert churn was flowing
through the page cache and kept evicting the ~7 GiB of mapped dense
weights that decode re-reads every token; once the dense set fell out,
generation collapsed to SSD-fault speed and could never recover because
the next run's expert traffic flushed it again.

A/B on Apple M1 Pro 16 GB, AProjQ4 gguf, greedy 32 tokens, cold page
cache: baseline 0.29/0.29 tok/s (stuck across runs); with the flag the
dense set survives the expert traffic and warms across runs — 0.29,
2.09, 1.93, 1.91 tok/s. Generated tokens bit-identical to baseline in
all runs; --decode-consistency max_abs=0 rms=0.

Opt-in because on the >=96 GB target machines everything fits in RAM
and cached preads are strictly better (second touch is free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DS4_METAL_STREAMING_EXPERT_PREAD_SPLIT=N expands every expert slab pread
into up to N disjoint 16 KB-aligned ranges read concurrently by the
existing pread pool. Decode misses queue only a handful of slabs per
layer (~4 experts x 3 slabs) while NVMe drives reach their random-read
ceiling around ~24 requests in flight: splitting deepens the queue at
identical bytes. Results are folded back per original slab so callers
keep per-task ok/bytes/ms. Default 1 = historical path.

Interleaved A/B on Apple M1 Pro 16 GB, AProjQ4 gguf, greedy 32 tokens,
warm state, with DS4_METAL_STREAMING_EXPERT_NOCACHE=1:
split=1 1.94/1.92/1.93 tok/s, split=4 2.24/2.20/2.24 tok/s (+16%).
Tokens bit-identical in all runs; --decode-consistency max_abs=0 rms=0.

Same trick as the Swift port's DS4_PREAD_SPLIT, where 4 also measured
best.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The AProjQ4 GGUFs load fine on CUDA but every dense Q4_K projection hit
the "matmul_quant: unsupported type 12" error. Add the missing path:

- matmul_q4_K_dense_kernel + cuda_matmul_q4_K_tensor: activations are
  quantized to Q8_K rows with the existing routed-MoE quantizer and each
  weight row is reduced with the shared dev_dot_q4_K_q8_K_block, so the
  numerics match the already-validated MoE Q4_K path. 8 lanes per row,
  same idiom as the MoE decode kernels. Wired into
  ds4_gpu_matmul_quant_tensor as type 12. Every token re-reads the
  weight rows from device memory: fine for decode, correct but
  unoptimized for prefill chunks (a dequant+GEMM path can follow if it
  shows up in profiles). ROCm compiles the same source through HIP.
- ds4.c: the specialized Q4_K attention-output low projection now falls
  through to the generic per-group dense-quant loop when the backend
  returns 0 (the CUDA/ROCm stub), instead of failing the layer. Metal
  keeps its fast path; a quick A/B confirms bit-identical tokens.

Still unsupported with Q4_K projections on CUDA: the decode-TP
attention-output split and the kslice fused paths — both already refuse
loudly via the type guards instead of computing garbage.

NOT compile-tested on a CUDA machine (authored on a Mac): testers with
NVIDIA/ROCm hardware, please build and run the AProjQ4 gguf with
--temp 0 plus --decode-consistency, and confirm AProjQ8 stays
bit-identical to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reduce Metal SSD paging and verifier cache churn, remove CUDA/ROCm mid-token fences, and add CUDA DSpark verifier/proposer fast paths with guarded fallbacks.

Validation: Apple M1 Pro, Metal/CPU, no model-backed quant run for this final patch; make -B, make cpu, make ds4_test, ./ds4_test --server, 106/106 engine placement checks, ROCm C syntax check, and git diff --check passed. Target model is Flash 0731 AProjQ4. CUDA/ROCm hardware validation remains pending; Metal kernel tests were unavailable because the Codex process exposed no Metal device.
Port antirez#766 commit d99de5c while preserving the local Metal/CUDA DSpark exact-N, tiny-batch, and Q4 paths. Add fail-closed diagnostics, parity tests, and GB10 benchmark documentation.
Reuse canonical Q8_1 scratch for Q4 MMVQ, add grouped attention-A and the exact K1024 persistent path, and cover the new dispatches with rollback gates and parity tests.
@GiorgioOppo

Copy link
Copy Markdown
Author

I would like to thank @adamlawi, @iammac2, @trueimage, @kyuz0, @evandhoffman and @OPS-NeoRetro for their help.

@evandhoffman

Copy link
Copy Markdown

Third-party Metal validation of this branch, for the record — no action needed on this PR.

Measured on an M5 Max 128 GB at 6a20b13, which is this PR's head. Two independent runs, three reps each, arms interleaved:

  • decode q4/q8 = 1.155 (3-rep paired median), 32/32 frontiers in every rep
  • prefill 1.003 — indistinguishable, so the gain is decode and only decode
  • reproduces the 2026-08-30 figure of 1.155 across 226 commits

One caveat worth having if numbers from different people are compared: within a session the ratio narrows from ~1.19 (first rep, cold) to ~1.13 (third), because q4 loses more to drift than q8 (−10.0% vs −4.1%). A cold single-rep run reporting ~1.19 is consistent with these results, not a disagreement. Die temperature was logged throughout and the decline is not thermal — the machine peaks at 78 °C after four minutes and then runs cooler while throughput keeps falling.

Full method, per-rep tables, the complete temperature log and raw CSVs:
evanwtf/local-llm#91

…tion

# Conflicts:
#	Makefile
#	ds4.c
#	ds4_gpu.h
#	ds4_metal.m
@GiorgioOppo
GiorgioOppo marked this pull request as ready for review September 3, 2026 10:57
@adamlawi

adamlawi commented Sep 3, 2026

Copy link
Copy Markdown

@GiorgioOppo You asked in #621 whether our extended-range CUDA result was noise or solid. Answering here, since this is where the work continues, and carrying over the two findings from that thread that are still useful — one of them for anyone benchmarking this branch, not just for us.

Noise or solid: it is solid, and it is negligible. Those are different questions.

Statistically solid. 10 measurement windows over 2 hours, 20 paired samples per context point, 160 runs, zero failures, on GB10 / sm_121 at 6a20b131:

ctx Q4 vs Q8, prefill SE 95% CI
8192 +0.33% 0.11% [+0.13%, +0.54%]
16384 −0.34% 0.07% [−0.47%, −0.21%]
32768 −0.22% 0.07% [−0.35%, −0.09%]
65536 −0.29% 0.06% [−0.41%, −0.16%]

All four intervals exclude zero (3.2–5.1σ), and the series had converged: the tenth window moved every mean by ≤0.02 pp. It is not noise.

Practically negligible. The effects are two to three tenths of a percent. Nobody will feel that, and no decision about this PR should turn on it. If you need one sentence for the PR: on CUDA/GB10, Q4 and Q8 prefill are at parity from 8k to 64k, with Q4 marginally ahead at 8k and marginally behind above it.

The honest framing is that we can now resolve an effect too small to matter — which is a fine place to end up, and better than the earlier −1.0% figure that turned out to be an artefact of our own method.

The finding worth carrying into this PR: measurement order biases the comparison

On this box, throughput declines monotonically over a measurement window. In two windows, all 24 arm-to-arm comparisons drifted downward. If the arms always run in the same order, whichever model is loaded later is penalised. Measured directly:

ctx Q4 measured first Q8 measured first positional bias
8192 +0.60% +0.07% 0.53 pp
16384 −0.13% −0.54% 0.41 pp
32768 +0.00% −0.44% 0.44 pp
65536 −0.10% −0.48% 0.38 pp

The bias is larger than three of the four effects being measured. At 32768 the sign of the result depends only on which model you load first.

This is not thermal on our side: SM clock held 2405 MHz for the full two hours, no throttling flags, 31–61 °C. @evandhoffman reports something consistent above — his ratio narrows from ~1.19 on the first rep to ~1.13 by the third within a session. Same class of effect, different platform.

The fix is free: alternate the order between rounds (Q4→Q8, then Q8→Q4) and average the pair ratios. It costs nothing and removes the bias entirely. Worth doing for any comparison below ~1%, which on CUDA is now all of them.

What carried over from #621 and still holds at 6a20b131

  • Grouped CUDA Q4 prefill on GB10 is worth +2.0% (+1.98…+2.20% across 8k–64k, sd 0.18–0.35%, ~10σ), measured against its own NO_ rollback with adjacent arms, so positional bias does not touch it.
  • Quality is byte-identical across 9a448bb1, 35b87dda and 6a20b131 — 100 cases, same avg_nll, avg_lcp, first_match, top1_rate. The specialized grouped Q4 quantizer is numerically exact on our shapes, not merely close.
  • It genuinely engages here. DS4_CUDA_REQUIRE_Q4_GROUPED_ATTN_A_Q81=1 alone returns rc=0 silently; adding DS4_CUDA_NO_Q4_GROUPED_ATTN_A_Q81=1 returns rc=1 with required CUDA Q4 grouped attention-A K4096/G8 Q8_1 quantizer is not eligible. The control failing is what proves the silence in the first arm means "selected" rather than "never consulted".

One caveat on scope: all of the above is measured at 6a20b131. This branch has since taken b0a147a7 and a main merge in 6cae5b81. A main merge moved our quality numbers once before — small, stable, reproducible across three runs, but real — so I would not assume the current head reproduces these figures without checking. Happy to re-run on 6cae5b81 if that is useful before review.

Full detail, raw CSVs and per-run clock/temperature logs are behind the #621 comments; say the word if you want anything attached here instead.

@GiorgioOppo

Copy link
Copy Markdown
Author

@GiorgioOppo You asked in #621 whether our extended-range CUDA result was noise or solid. Answering here, since this is where the work continues, and carrying over the two findings from that thread that are still useful — one of them for anyone benchmarking this branch, not just for us.

Noise or solid: it is solid, and it is negligible. Those are different questions.

Statistically solid. 10 measurement windows over 2 hours, 20 paired samples per context point, 160 runs, zero failures, on GB10 / sm_121 at 6a20b131:

ctx Q4 vs Q8, prefill SE 95% CI
8192 +0.33% 0.11% [+0.13%, +0.54%]
16384 −0.34% 0.07% [−0.47%, −0.21%]
32768 −0.22% 0.07% [−0.35%, −0.09%]
65536 −0.29% 0.06% [−0.41%, −0.16%]
All four intervals exclude zero (3.2–5.1σ), and the series had converged: the tenth window moved every mean by ≤0.02 pp. It is not noise.

Practically negligible. The effects are two to three tenths of a percent. Nobody will feel that, and no decision about this PR should turn on it. If you need one sentence for the PR: on CUDA/GB10, Q4 and Q8 prefill are at parity from 8k to 64k, with Q4 marginally ahead at 8k and marginally behind above it.

The honest framing is that we can now resolve an effect too small to matter — which is a fine place to end up, and better than the earlier −1.0% figure that turned out to be an artefact of our own method.

The finding worth carrying into this PR: measurement order biases the comparison

On this box, throughput declines monotonically over a measurement window. In two windows, all 24 arm-to-arm comparisons drifted downward. If the arms always run in the same order, whichever model is loaded later is penalised. Measured directly:

ctx Q4 measured first Q8 measured first positional bias
8192 +0.60% +0.07% 0.53 pp
16384 −0.13% −0.54% 0.41 pp
32768 +0.00% −0.44% 0.44 pp
65536 −0.10% −0.48% 0.38 pp
The bias is larger than three of the four effects being measured. At 32768 the sign of the result depends only on which model you load first.

This is not thermal on our side: SM clock held 2405 MHz for the full two hours, no throttling flags, 31–61 °C. @evandhoffman reports something consistent above — his ratio narrows from ~1.19 on the first rep to ~1.13 by the third within a session. Same class of effect, different platform.

The fix is free: alternate the order between rounds (Q4→Q8, then Q8→Q4) and average the pair ratios. It costs nothing and removes the bias entirely. Worth doing for any comparison below ~1%, which on CUDA is now all of them.

What carried over from #621 and still holds at 6a20b131

  • Grouped CUDA Q4 prefill on GB10 is worth +2.0% (+1.98…+2.20% across 8k–64k, sd 0.18–0.35%, ~10σ), measured against its own NO_ rollback with adjacent arms, so positional bias does not touch it.
  • Quality is byte-identical across 9a448bb1, 35b87dda and 6a20b131 — 100 cases, same avg_nll, avg_lcp, first_match, top1_rate. The specialized grouped Q4 quantizer is numerically exact on our shapes, not merely close.
  • It genuinely engages here. DS4_CUDA_REQUIRE_Q4_GROUPED_ATTN_A_Q81=1 alone returns rc=0 silently; adding DS4_CUDA_NO_Q4_GROUPED_ATTN_A_Q81=1 returns rc=1 with required CUDA Q4 grouped attention-A K4096/G8 Q8_1 quantizer is not eligible. The control failing is what proves the silence in the first arm means "selected" rather than "never consulted".

One caveat on scope: all of the above is measured at 6a20b131. This branch has since taken b0a147a7 and a main merge in 6cae5b81. A main merge moved our quality numbers once before — small, stable, reproducible across three runs, but real — so I would not assume the current head reproduces these figures without checking. Happy to re-run on 6cae5b81 if that is useful before review.

Full detail, raw CSVs and per-run clock/temperature logs are behind the #621 comments; say the word if you want anything attached here instead.

can you also post the decode speed?

@adamlawi

adamlawi commented Sep 4, 2026

Copy link
Copy Markdown

@GiorgioOppo Decode numbers below. One caveat first, because it matters for how much weight to put on them: these are not from the balanced series. That series measured pure prefill (--gen-tokens 0) so that a 64k sweep would fit in a maintenance window. Decode comes from two earlier windows that used the fixed arm order I later warned about.

Decode, steady-state t/s, GB10 / sm_121, --gen-tokens 128, mean of two rounds:

ctx 9a448bb1 Q4 / Q8 Q4 vs Q8 35b87dda Q4 / Q8 Q4 vs Q8
2048 23.37 / 19.89 +17.47% 23.07 / 19.56 +17.97%
4096 19.11 / 16.75 +14.09% 18.84 / 16.42 +14.77%
6144 19.02 / 16.65 +14.20% 18.79 / 16.42 +14.34%
8192 18.81 / 16.48 +14.10% 18.45 / 16.21 +13.85%

Q4 decode leads Q8 by roughly 14% from 4k to 8k, and by about 17.5% at 2048.

Two reasons to trust this despite the weaker design:

  1. Two independent commits, measured a day apart, agree within 0.5 pp at every context point. That is a real cross-check, not a repeat of one run.
  2. The positional bias I flagged is ~0.4–0.5 pp. Against a 14–18% effect it is noise. It mattered for prefill only because there the effect was itself a few tenths of a percent. Incidentally, decode does not show the same monotonic within-window decline that prefill does — the drift is mixed in sign (Q4 +0.6…+1.9%, Q8 −1.6…+0.4% between rounds), which is another reason the fixed order does not distort it here.

Scope limits, stated plainly: ctx only up to 8192 (these windows predate the extended sweep), prompt file was the 135 kB one rather than the 405 kB used later, and the figures are from 6a20b131 and 35b87dda — I have not measured decode on the current head.

So, combining with the prefill series: on CUDA/GB10, Q4 is at parity with Q8 on prefill and roughly 14% ahead on decode. That matches what @evandhoffman reports on Metal, where the advantage is also decode-only (q4/q8 = 1.155, prefill 1.003).

If you want decode measured with the balanced design — alternating order, paired ratios, confidence intervals like the prefill table — say so and I will run it. It is about 40 minutes of windows and would give decode the same statistical footing as the prefill numbers.

evandhoffman added a commit to evanwtf/local-llm that referenced this pull request Sep 4, 2026
Throughput declines across a measurement window, so a fixed arm order
penalises whichever arm always runs last. @adamlawi measured this on
antirez/ds4#952: the positional bias was larger than three of the four
effects being compared, and at one frontier the SIGN of the result depended
only on which model loaded first. Their figures are quoted as reported and
are not verified here -- the reason to act is the method.

Three places, matching the issue's three items.

**The suite runner** ran `backends.items()` in the same order every trial,
so one backend was always last. `trial_order` now reverses on even trials.
Extracted rather than left inline in main(), so the property that matters --
no backend holds the last position in every trial -- is a test rather than a
comment.

**decode_ab.sh** had the same fixed order. decode_ab_engine.sh has
alternated since it was written; this script predates the finding. It now
alternates and appends a run-order.txt beside the CSVs.

**The row** records `run_position` and `run_arms`. Alternating removes the
bias going forward; it does nothing for the 979 rows already written, and
those cannot be retro-corrected for a bias nobody wrote down. Absent means
the order was not recorded -- deliberately not defaulted to 1, which would
claim every existing row ran first, which is the bias being looked for.

Neither field is in REQUIRED, for the same reason as client_version: every
existing row predates them and `validate` runs on read.

With an odd trial count the split is uneven -- 2 of 3 in first position, not
1.5 -- which is better than 3 of 3 and is why the position is worth
recording rather than assumed to cancel.

629 passed, 3 skipped. The 5 ruff findings in run.py predate this change.
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.

4 participants