feat(kv): activation rotation + packed 4-bit KV cache (#61)#65
Conversation
Rotate Q/K/V by an orthonormal Walsh-Hadamard transform before INT8
quantization to suppress activation outliers, per llama.cpp#21038/QuaRot.
The transform is self-inverse (one H serves forward Q/K/V and backward
output rotation) and dot-preserving (Q and K rotated by the same H leave
QK scores intact). Gated behind GEIST_KV_ROT=1; INT8 path only; head_dim
a power of two. FWHT is O(n log n), no n*n matmul.
Adds fwht.{c,h} + a unit test covering the three properties the trick
relies on.
Run FP32/INT8/INT8+ROT/KIVI in one invocation (reload per mode so the GEIST_KV_* env resolution stays untouched) and print the fraction of the INT8->KIVI quality gap that the Hadamard rotation recovers. Also fall back to the GGUF-embedded tokenizer so Gemma/BitNet models work, not just external tokenizer.bin ones.
Reuse the INT8 storage path with a scale = amax/(2^(N-1)-1) grid to simulate a symmetric low-bit cache without packing/kernels — a quality probe for whether rotation rescues low-bit KV. GEIST_KV_INT4=1 (4-bit) or GEIST_KV_QBITS=N. bench_kv_quality sweeps FP32/INT8/INT4/INT2 (+ROT) and KIVI in one run. Finding: rotation's benefit scales inversely with bit-width (nil at 8b, recovers the 4b penalty to ~INT8, recovers most of the 2b collapse) but symmetric-2b+ROT still trails asymmetric KIVI. The usable win is a rotated 4-bit cache: ~INT8 quality at half the bytes, no per-channel bookkeeping.
top-1 accuracy saturated and couldn't resolve sub-1% quality differences. Switch to the public session API (prefill_tokens + peek_logits) and score each mode by mean KL(P_fp32 || P_mode) over all positions, teacher-forced in lockstep against an FP32 reference session. Model loads once; each mode is a session. KL over the shared finite support so BitNet's non-finite dead-vocab slots don't poison it. KL cleanly shows what top-1 hid: rotation reduces divergence at low bit- widths (BitNet: INT4 -33%, INT2 -44%; ~nil at INT8), but symmetric 2-bit +ROT still trails asymmetric KIVI. INT4+ROT is near-lossless.
Real packed INT4: two 4-bit values per byte, halving the K/V data buffers vs INT8, with per-token per-head scale and the optional Hadamard rotation (GEIST_KV_ROT). Rides the INT8 storage path — same buffer slots (allocated half-size), scale buffers, and ctx wiring — with a packing append branch and an unpacking attention kernel (attention_int4_via_buffers). New public mode GEIST_KV_INT4; GEIST_KV_QBITS keeps the N-bit quality-sim for probes. Validation: packed KL is bit-identical to the INT8-container sim (BitNet INT4 0.01275, +ROT 0.00861), so pack/unpack is correct; INT8 + KIVI unregressed. int4_kv.h pack/unpack has a unit test (sign-extension).
Keep the geist.h diff to just the GEIST_KV_INT4 enum value; revert the whole-file reformat a stray clang-format run introduced.
Long-context decode: packed INT4 crosses over and beats INT8Follow-up measurement (the PR notes flagged this as open). Decode ms/tok on the Pi 5 (A76, BitNet-2b4t, GEIST_BENCH_TG=64, min-of-3, quiesced) across growing KV length:
The crossover sits between kv_len 256 and 1024, and the gap widens with context. By ~3072, packed INT4 is 7.4% faster than INT8 — and even INT4+ROT (rotation cost included) is 6.3% faster than plain INT8 (190.2 vs 202.9). Mechanism, confirmed: at short context decode is weight-bandwidth-bound, so the halved KV reads are invisible and only the unpack compute shows (+0.9%). As kv_len grows, KV-cache streaming comes to dominate decode bandwidth; halving it then outweighs the unpack, which overlaps the memory stalls. Takeaway: the earlier "+1–3% decode cost" is the short-context worst case. In the regime packed INT4 is actually for — long chats, RAG, agents — it delivers half the KV memory, near-lossless quality, AND faster decode simultaneously. INT4+ROT is the sweet spot at long context. |
Summary
Adds activation rotation and a packed 4-bit KV cache to the transformer KV path, following llama.cpp#21038 / QuaRot. Closes #61.
Two new opt-in, per-session KV features (INT8 stays the default — nothing changes unless you ask):
GEIST_KV_ROT=1— Hadamard (Fast Walsh–Hadamard) rotation of Q/K/V before quantization, output rotated back. Suppresses activation outliers so low-bit KV quantizes better. Backend-agnostic, O(n·log n), self-inverse (one transform serves forward + backward), dot-preserving (QK scores unchanged).GEIST_KV_INT4=1(newGEIST_KV_INT4mode) — packed symmetric 4-bit KV cache: two values/byte, half the INT8 data footprint, per-token per-head scale. Rides the INT8 storage path (same buffer slots allocated half-size, same scales + ctx wiring) with a packing append branch and an unpacking attention kernel. Rotation is default-on for this mode (INT4-without-rotation is a quality cliff and the rotation is ~free / a net win at long context);GEIST_KV_ROT=0opts out. Plain INT8 keeps rotation opt-in.Why (measured, not asserted)
The whole change is data-driven; every claim below is from
bench_kv_quality(extended here with a KL-divergence vs FP32 metric) andbench_session_throughputon a quiesced Pi 5 (Cortex-A76, mean-of-5).Quality — mean KL(P_fp32 ‖ P_mode), nats, BitNet-2b4t (trustworthy absolute scale; Gemma inflated by un-applied logit softcap but same ordering):
Cost (A76 decode, BitNet, ms/tok, min-of-3) — depends on context length. At short context decode is weight-bandwidth-bound so the halved KV reads are invisible and only the unpack shows; as KV length grows it comes to dominate decode bandwidth and INT4 crosses over to faster than INT8:
The crossover sits between 256 and 1024 tokens; by ~3072 packed INT4 is 7.4% faster than INT8 and even INT4+ROT (rotation included) is 6.3% faster.
So: in the regime packed INT4 is for — long chats, RAG, agents — it delivers half the KV footprint, near-lossless quality, AND faster decode at once; INT4+ROT is the sweet spot. Attractive on the 4 GB Pi. These are different operating points from KIVI, not competitors → kept as an explicit mode (INT8 default), no per-environment auto-arbitration.
What's in here
fwht.{c,h}int4_kv.hkv_store.cattention.cattention_int4_via_buffersunpacking kernelarch_state.{c,h}GEIST_KV_ROT/GEIST_KV_INT4flags, half-size buffer allocgeist.hGEIST_KV_INT4public modebench_kv_quality.cARCHITECTURE.mdVerification
test_fwht_unit,test_int4_kv_unit); packed-INT4 KL is bit-identical to the INT8-container simulation, confirming pack/unpack correctness.-Werroron macOS (clang) and Pi 5 (gcc).Follow-ups (not in this PR)
🤖 Generated with Claude Code