Foundry/attention/spec.md

413 lines
17 KiB
Markdown

# TurboQuant KV Cache Compression — Spec
Module: `foundry/attention/`
Prefix: `fa_tq_` (Foundry Attention — TurboQuant)
Based on: [TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate](https://arxiv.org/abs/2504.19874) (Zandieh et al., Google Research / DeepMind, April 2025)
---
## Overview
TurboQuant compresses the KV cache from FP32 to 2-4 bits per channel with near-zero quality loss. The algorithm is:
1. **Rotate** the vector by a fixed random orthogonal matrix (spreads information uniformly)
2. **Scalar quantize** each coordinate independently using precomputed Lloyd-Max levels for the Beta distribution
3. **Dequantize** by reversing: look up reconstruction values, then rotate back
No codebook training. No preprocessing. No iterative optimization. Just a rotation matrix and a lookup table. The entire quantizer is deterministic given the dimension and bit width.
### Why not QJL?
The paper proposes QJL (1-bit quantized Johnson-Lindenstrauss) on residuals for unbiased inner products. Six independent community implementations confirmed that for softmax attention, **MSE-only quantization without QJL outperforms the full method**. Softmax exponentially amplifies variance, making low MSE more valuable than unbiasedness. We skip QJL entirely.
### Asymmetric bit allocation
Keys determine attention routing — errors in keys misroute attention to the wrong tokens. Values just get averaged — errors cancel during the weighted sum. This gives us:
| Component | Bits | Levels | Compression vs FP32 |
|-----------|------|--------|---------------------|
| Keys | 4 | 16 | 8x |
| Values | 2 | 4 | 16x |
| Average | 3 | — | ~10.7x |
Conservative default: K4/V2. Optional K3/V2 for aggressive compression (~12.8x).
---
## As-Built Notes (implementation vs this spec)
The shipped `turboquant.{h,c}` follows this design with two deliberate,
correctness-driven deviations from the text below:
1. **SRHT signs before the Hadamard transform.** The rotation applies the random
`+-1` sign diagonal `D` before the FWHT rather than after (`H * (D * x)`).
This spreads the DC component across coordinates and gave measurably better
roundtrip error. The transform is still orthogonal and self-inverse up to the
`1/sqrt(d)` scaling.
2. **L2-norm normalization, not per-head min/max.** Step 2 below describes mapping
rotated coordinates into `[0,1]` via per-head min/max. The implementation
instead normalizes by the vector L2 norm, which is the true `Beta(d/2, d/2)`
mapping the codebooks are optimized for. The stored per-head range metadata
carries the norm-based scale accordingly.
### Measured quality: K4/V2 is lossy
The default K4/V2 is **not** near-lossless in practice. 4-bit keys are
near-lossless, but 2-bit values are the error driver. Attention-output cosine
similarity (FP vs compressed):
| Config | Synthetic (Gaussian) | Realistic (correlated) |
|--------|----------------------|------------------------|
| K4/V2 | ~0.94 | ~0.975 |
| K4/V3 | ~0.98 | - |
| K4/V4 | ~0.99 | ~0.99 |
Treat the value bit width as a per-model knob: validate on real activations and
raise it to V3 or V4 where output fidelity matters. The "attention score
preservation >0.999" target in the Testing Strategy below is aspirational for
K4/V2 and is reported by `validate_tq_attention.c` rather than gated. The 11
unit tests in `test_turboquant.c` (codebook, rotation orthogonality, roundtrip
MSE, dot preservation, determinism, packing, cache) all pass.
---
## Algorithm Detail
### Step 1: Random Orthogonal Rotation
Generate a fixed `d x d` orthogonal matrix `R` where `d = head_dim`. This is generated once at init from a seed and reused for all tokens and layers.
**Implementation**: Use a randomized Hadamard transform (fast `O(d log d)`) instead of dense matrix multiply (`O(d^2)`):
```
rotate(x) = D * H * x / sqrt(d)
```
Where:
- `H` is the Walsh-Hadamard matrix (applied in-place via butterfly ops, no storage)
- `D` is a diagonal of random +1/-1 signs (one `d`-element array, generated from seed)
This is `O(d log d)` per vector vs `O(d^2)` for a full orthogonal matrix. For `head_dim=128`, that's ~896 FLOPs vs ~16,384.
The rotation spreads information across all coordinates, making each coordinate approximately follow a `Beta(d/2, d/2)` distribution centered at 0.5 (after shifting to [0,1]). In high dimensions this concentrates sharply around the mean, making simple scalar quantization nearly optimal.
### Step 2: Lloyd-Max Scalar Quantization
After rotation, each coordinate is quantized independently using optimal decision boundaries and reconstruction levels for `Beta(d/2, d/2)`.
These are **precomputed offline** for each (d, bits) pair and stored as constant lookup tables:
```c
typedef struct {
int bits; // 2, 3, or 4
int levels; // 2^bits
float boundaries[15]; // Decision boundaries (levels - 1 values)
float centroids[16]; // Reconstruction centroids (levels values)
} fa_tq_codebook;
```
For quantization: binary search into `boundaries[]` to get the level index.
For dequantization: index into `centroids[]` to get the reconstruction value.
The Beta distribution parameters depend only on `head_dim`, which is constant per model. So we need one codebook per (head_dim, bits) pair — precomputed at compile time or at `fa_tq_init()`.
### Step 3: Packing
Quantized values are packed into bytes:
| Bits | Values per byte | Packing |
|------|-----------------|-----------------|
| 2 | 4 | `[v0:2][v1:2][v2:2][v3:2]` |
| 3 | 2 (+ 2 waste) | `[v0:3][v1:3][__:2]` |
| 4 | 2 | `[v0:4][v1:4]` |
3-bit packing wastes 25% of bits. Alternative: pack 8 values into 3 bytes (no waste, more complex addressing). Start with the simple per-byte packing; optimize later if memory-bound.
---
## Memory Layout
### Compressed cache entry (one token, one layer)
```
key_packed: [ceil(head_dim * k_bits / 8)] bytes per head, n_heads total
value_packed: [ceil(head_dim * v_bits / 8)] bytes per head, n_heads total
key_range: [2 floats per head] — min/max before rotation (for denormalization)
value_range: [2 floats per head] — min/max before rotation
```
The min/max per head are needed to map the rotated coordinates into [0, 1] for the Beta quantizer.
### Memory per token per layer
| Config | Keys | Values | Range metadata | Total | vs FP32 (`2 * n_heads * head_dim * 4`) |
|--------|------|--------|----------------|-------|-----------------------------------------|
| K4/V2, 16 heads, dim 128 | 1024 B | 512 B | 256 B | 1792 B | vs 16,384 B → **9.1x** |
| K4/V2, 32 heads, dim 128 | 2048 B | 1024 B | 512 B | 3584 B | vs 32,768 B → **9.1x** |
| K3/V2, 16 heads, dim 128 | 768 B | 512 B | 256 B | 1536 B | vs 16,384 B → **10.7x** |
### Total cache savings
RWKV-X 0.2B (4 attention layers, 16 heads, dim 128, max_cache 2048):
| | FP32 | TurboQuant K4/V2 |
|---|---|---|
| Per layer | 2 * 2048 * 2048 * 4 = **32 MB** | 2048 * 1792 = **3.5 MB** |
| Total (4 layers) | **128 MB** | **14 MB** |
Llama-style 3.6B (24 attention layers, 32 heads, dim 128, max_seq 4096):
| | FP32 | TurboQuant K4/V2 |
|---|---|---|
| Per layer | 2 * 4096 * 4096 * 4 = **128 MB** | 4096 * 3584 = **14.3 MB** |
| Total (24 layers) | **3 GB** | **343 MB** |
---
## API
### Types
```c
/* Bit allocation configuration */
typedef struct {
int key_bits; /* 2, 3, or 4 (default: 4) */
int value_bits; /* 2, 3, or 4 (default: 2) */
} fa_tq_config;
/* Opaque quantizer state (holds rotation signs + codebooks) */
typedef struct fa_tq_state fa_tq_state;
/* Compressed KV cache (replaces fa_context for quantized mode) */
typedef struct fa_tq_cache fa_tq_cache;
```
### Lifecycle
```c
/* Initialize quantizer for a given head_dim and bit config.
* seed determines the random rotation (deterministic, reproducible).
* Call once at model load time. */
fa_tq_state* fa_tq_init(int head_dim, fa_tq_config config, uint64_t seed);
void fa_tq_free(fa_tq_state *state);
/* Default config: K4/V2 */
fa_tq_config fa_tq_config_default(void);
```
### Cache
```c
/* Create compressed KV cache.
* Same shape semantics as fa_context but stores quantized data. */
fa_tq_cache* fa_tq_cache_new(fa_tq_state *state, int num_layers,
int num_heads, int max_seq_len);
void fa_tq_cache_free(fa_tq_cache *cache);
void fa_tq_cache_clear(fa_tq_cache *cache);
int fa_tq_cache_seq_len(fa_tq_cache *cache);
```
### Quantize / Dequantize
```c
/* Quantize a single K or V vector into the cache at position pos.
* vec: [num_heads * head_dim] float input
* is_key: true for key, false for value (determines bit width) */
void fa_tq_quantize(fa_tq_state *state, fa_tq_cache *cache,
int layer, int pos, const float *vec, bool is_key);
/* Dequantize a range of cached vectors back to float.
* out: [count * num_heads * head_dim] float output
* Returns pointer to out for convenience. */
float* fa_tq_dequantize(fa_tq_state *state, fa_tq_cache *cache,
int layer, int start, int count,
bool is_key, float *out);
```
### Attention with TurboQuant cache
```c
/* Drop-in replacement for fa_attention_cached.
* Quantizes K, V into cache, dequantizes cached K, V for attention.
* Q is always full precision (current token only, never cached). */
tm_tensor* fa_tq_attention_cached(tm_tensor *Q, tm_tensor *K, tm_tensor *V,
fa_tq_state *tq, fa_tq_cache *cache,
int layer_idx, int pos, fa_config *config);
```
---
## Integration Points
### RWKV-X (`foundry/rwkvx/rwkvx.c`)
The sparse attention blocks (MOBA layers) maintain per-layer `rx_attn_state` with flat `k_cache`/`v_cache` buffers. Integration:
1. Add `fa_tq_state*` and `fa_tq_cache*` to `rx_model` or `rx_state`
2. In the attention block forward pass (~line 1163), after computing `k_vec`/`v_vec`:
- Call `fa_tq_quantize()` to store into compressed cache
- Call `fa_tq_dequantize()` to reconstruct K/V for the attention dot products
3. Remove the FP32 `k_cache`/`v_cache` from `rx_attn_state` when TQ is enabled
4. Cache eviction (line 1181) works the same — just operates on packed bytes instead of floats, `memmove` still works
### Pantheon / Llama (`Pantheon/src/models/llama.c`)
The per-head `[n_kv_head, max_seq, head_dim]` layout maps directly:
1. Add `fa_tq_state*` to `pn_llama_model`, `fa_tq_cache*` to `pn_llama_kv_cache`
2. In `pn_llama_forward()` (~line 1484), replace the `memcpy` into K/V cache with `fa_tq_quantize()`
3. Before the attention head loop (~line 1370), `fa_tq_dequantize()` the needed range
4. Circular buffer addressing: `cache_pos = pos % max_seq` still works — the compressed cache just stores packed bytes at each position
### Runtime toggle
Both engines already have a `--quantize` flag for INT8 weights. Add `--tq-cache` (or `--kv-compress`) to enable TurboQuant on the KV cache independently. The two are orthogonal: INT8 weights + TQ cache is the highest compression combo.
---
## File Plan
```
foundry/attention/
├── attention.h # Existing — add fa_tq_* declarations
├── attention.c # Existing — unchanged
├── turboquant.h # TurboQuant types + API (public header)
├── turboquant.c # Implementation
├── tq_codebooks.h # Precomputed Lloyd-Max tables (generated, static const)
├── spec.md # This file
└── tests/
└── test_turboquant.c
```
`turboquant.h/c` are self-contained. They depend only on `<math.h>`, `<string.h>`, `<stdint.h>`. No tensor library dependency — they operate on raw `float*` and `uint8_t*` buffers. This keeps them usable from both the foundry attention module and the hand-rolled attention loops in RWKV-X and Pantheon.
---
## Codebook Generation
Lloyd-Max optimal levels for `Beta(d/2, d/2)` can be computed offline via iterative Lloyd's algorithm:
1. Start with uniform boundaries in [0, 1]
2. Compute centroids as conditional expectations `E[X | boundary_i < X < boundary_{i+1}]` under `Beta(d/2, d/2)`
3. Update boundaries as midpoints between adjacent centroids
4. Repeat until converged (~20 iterations)
Ship a Python script `tools/gen_codebooks.py` that generates `tq_codebooks.h` for all needed (d, bits) pairs. The output is a static C header — no Python dependency at runtime.
Target dimensions: 64, 96, 128, 160 (covers RWKV-X 0.2B through 3.6B and common Llama configs). Bit widths: 2, 3, 4.
That's 4 * 3 = 12 codebooks, each under 200 bytes. Total: ~2 KB of static const data.
---
## Hadamard Transform Detail
The Walsh-Hadamard transform of dimension `d` (must be power of 2) is:
```c
/* In-place Fast Walsh-Hadamard Transform, O(d log d) */
static void fwht(float *x, int d) {
for (int len = 1; len < d; len <<= 1) {
for (int i = 0; i < d; i += len << 1) {
for (int j = 0; j < len; j++) {
float u = x[i + j];
float v = x[i + j + len];
x[i + j] = u + v;
x[i + j + len] = u - v;
}
}
}
}
```
Full rotation: multiply each element by the random sign (`D`), apply FWHT, scale by `1/sqrt(d)`. Inverse: same operation (FWHT is its own inverse up to scaling, and `D` is its own inverse).
For `head_dim` not a power of 2: pad to the next power of 2, apply FWHT, truncate. The extra coordinates are discarded — they don't affect the quantized output. Alternatively, use a dense random orthogonal matrix for non-power-of-2 dims (rare in practice).
---
## Performance Budget
Target: TurboQuant overhead < 5% of total inference time.
| Operation | FLOPs per token per layer | Notes |
|-----------|--------------------------|-------|
| Hadamard rotate K | `d * log2(d)` = 896 | d=128 |
| Hadamard rotate V | 896 | |
| Quantize K (binary search) | `d * log2(levels)` = 512 | 4-bit, 4 comparisons |
| Quantize V | `d * log2(levels)` = 256 | 2-bit, 2 comparisons |
| Pack K into bytes | `d` = 128 | Shift + OR |
| Pack V into bytes | `d` = 128 | |
| **Total quantize** | **~2,816** | Per head |
| | | |
| Dequantize K (all cached) | `seq * d` = 262,144 | seq=2048, lookup + unpack |
| Hadamard inverse K | `seq * d * log2(d)` = 1,835,008 | |
| Dequantize V (all cached) | `seq * d` = 262,144 | |
| Hadamard inverse V | `seq * d * log2(d)` = 1,835,008 | |
| **Total dequantize** | **~4.2M** | Dominates |
| | | |
| Attention Q@K^T + softmax + @V | `~2 * seq * d` = 524,288 | Standard attention |
Dequantize is ~8x the attention cost per head, which is too expensive if done naively for the full cache every token.
### Optimization: Fused dequant-dot
Instead of materializing the full dequantized K/V matrix, fuse dequantization into the attention loop:
```
For each cached position t:
k_packed = cache[t]
k_quant = unpack(k_packed) // byte ops
k_rotated = centroids[k_quant] // lookup, no multiply
score[t] = dot(Q_rotated, k_rotated) // dot in rotated space
```
**Key insight**: `dot(Q, R^T * k_rotated) = dot(R * Q, k_rotated)`. Pre-rotate Q once (`O(d log d)`), then compute dots directly in the rotated domain. **No inverse Hadamard on cached vectors at all.**
Same for values: accumulate `sum(attn[t] * v_rotated[t])` in rotated space, then inverse-rotate the final output once.
This reduces dequant to just unpack + table lookup per element — essentially free compared to the dot product itself.
**Revised cost with fused dequant-dot:**
| Operation | FLOPs | Notes |
|-----------|-------|-------|
| Rotate Q once | 896 | Hadamard |
| Quantize K, V (new token) | ~2,816 | Per head |
| Attention loop (unpack + lookup + dot) | `~2 * seq * d` | Same as FP32 attention |
| Inverse rotate output | 896 | Hadamard |
| **Overhead vs FP32 attention** | **~0.7%** | Just two Hadamard transforms |
---
## Testing Strategy
### Unit tests (`test_turboquant.c`)
1. **Roundtrip accuracy**: Quantize random vectors, dequantize, measure MSE. Assert MSE < threshold for each bit width.
2. **Rotation orthogonality**: Verify `R * R^T = I` (within float tolerance).
3. **Dot product preservation**: `|dot(q, k) - dot(q_dequant, k_dequant)| < epsilon` for random q, k.
4. **Attention score preservation**: Full attention with FP32 cache vs TQ cache, compare output cosine similarity. Target: >0.999 for K4/V2.
5. **Determinism**: Same seed produces identical quantization across runs.
6. **Edge cases**: All-zero vectors, all-same vectors, very large/small magnitudes.
7. **Cache eviction**: Verify memmove on packed data preserves correct values after dequant.
8. **Bit packing**: Verify pack/unpack roundtrip for all bit widths (2, 3, 4) with all possible level values.
### Integration test
Run RWKV-X 0.2B inference with and without TQ cache on the same prompt. Compare:
- Output token agreement (should match for short sequences)
- Perplexity difference on a held-out text (target: <0.5% increase)
---
## Implementation Order
1. **`tq_codebooks.h`** — Generate Lloyd-Max tables with Python script, ship as static header
2. **`turboquant.c`** — Core: Hadamard, quantize, dequantize, pack/unpack, cache struct
3. **`test_turboquant.c`** — Unit tests for roundtrip, accuracy, dot preservation
4. **RWKV-X integration** — Wire into `rwkvx.c` attention blocks behind `--tq-cache` flag
5. **Pantheon integration** — Wire into `llama.c` behind same flag
6. **Benchmarks** — tok/s with and without, memory usage comparison