Skip to content

Commit 610e7a4

Browse files
committed
docs: README; v1 GPU config, report script, runbook; store raw_output; widen CI lint
1 parent a66c263 commit 610e7a4

7 files changed

Lines changed: 185 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jobs:
88
- uses: actions/setup-python@v5
99
with: { python-version: "3.11" }
1010
- run: pip install -e ".[dev]"
11-
- run: ruff check kvcanary
11+
- run: ruff check kvcanary tests scripts
1212
- run: pytest -q
1313
- run: pip install -e ".[dev,ml]"
1414
- run: pytest -q -m slow # CPU end-to-end smoke (downloads distilgpt2)

README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# kv-canary
2+
3+
**A canary for *silent* KV-cache-compression failures.**
4+
5+
Lossy KV-cache compression (quantization, token eviction) is benchmarked almost entirely on
6+
**speed and memory** — and on token-level quality (perplexity). But token-level metrics can stay
7+
flat while **functional** outputs silently break: generated code stops passing its tests, tool
8+
calls stop being valid JSON. kv-canary measures that gap.
9+
10+
> The wedge: everyone measures how *fast* KV compression is. Almost nobody measures whether it
11+
> silently breaks your code and tool calls while perplexity says everything is fine.
12+
13+
This is a measurement harness + methodology, motivated by VeriCache
14+
([arXiv:2605.17613](https://arxiv.org/html/2605.17613)), which showed lossy-KV bias accumulates
15+
linearly and functional tasks fail while token-level metrics look unchanged. kv-canary makes that
16+
effect reproducible and quantifiable across compression methods.
17+
18+
---
19+
20+
## The idea in one chart
21+
22+
For each compression method, kv-canary plots two lines against **KV memory retained**:
23+
24+
- **functional accuracy** (solid) — does generated code pass its tests / is the tool call correct?
25+
- **perplexity-implied quality** (dashed) — the token-level metric, normalized to the full cache.
26+
27+
The finding is the **divergence**: functional accuracy cliffing while the perplexity line hugs 1.0.
28+
That visual gap is the whole point — the token metric tells you nothing is wrong while real
29+
functional quality collapses.
30+
31+
To make it a single number, kv-canary defines the **Silent Degradation Score (SDS)**:
32+
33+
```
34+
SDS = (relative functional drop) / (relative perplexity rise)
35+
```
36+
37+
High SDS = functional accuracy craters while perplexity barely moves (the metric "lies"). SDS ≈ 1
38+
is graceful degradation (perplexity warns you in step). Perplexity *improving* while function
39+
breaks is the most deceptive case of all, and SDS reports it as maximally silent — by design.
40+
41+
## What it measures
42+
43+
| Compression family | Methods | How `budget` maps |
44+
|---|---|---|
45+
| baseline | full (fp16) | 1.0 |
46+
| quantization | int8 / int4 / int2 KV | bits / 16 |
47+
| token eviction | StreamingLLM, SnapKV | fraction of tokens kept |
48+
49+
Against two objective, KV-eviction-sensitive functional tasks — **code execution** (pass@1) and
50+
**tool/JSON calling** (valid + correct-function + arg-match) — contrasted with **perplexity** on a
51+
held-out set. All on a shared *KV-memory-retained* x-axis, so quantization and eviction are directly
52+
comparable (and the fact that equal memory budgets degrade them differently is the finding).
53+
54+
## Quickstart (CPU, no GPU)
55+
56+
Verifies the whole pipeline end-to-end on a tiny model:
57+
58+
```bash
59+
pip install -e ".[dev,ml]"
60+
python -m kvcanary run configs/smoke.yaml --out results/raw/smoke.jsonl
61+
python scripts/aggregate_and_report.py results/raw/smoke.jsonl
62+
# -> report/divergence.png, report/RESULTS.md
63+
```
64+
65+
> The smoke run uses `distilgpt2` and proves the harness runs. See the note on the v1 deferral below
66+
> for why the *finding* itself needs a GPU.
67+
68+
Run the fast test suite:
69+
70+
```bash
71+
pip install -e ".[dev]"
72+
pytest -q # model-free unit tests
73+
pytest -q -m slow # + end-to-end smoke on distilgpt2 (downloads weights)
74+
```
75+
76+
## How it works
77+
78+
Three small, independently-tested seams compose into a resumable experiment runner:
79+
80+
- **`KVCompressor`**`full`, `quantized`, `streamingllm`, `snapkv` behind one `budget` knob; the
81+
eviction selection logic is a pure function of attention scores (unit-tested on synthetic tensors).
82+
- **`Backend`**`HFBackend` (HuggingFace causal LM: greedy generation + teacher-forced
83+
perplexity) and `FakeBackend` (a deterministic double, so the runner and tasks test with no model).
84+
- **`Task`**`CodeExecTask` (sandboxed subprocess, pass@1), `ToolCallTask` (JSON parse + function
85+
+ arg match), `PerplexityTask`.
86+
87+
The runner sweeps `models × compressors × tasks`, writes one JSON line per sample, and **resumes**
88+
by skipping already-completed cells — so a killed spot-GPU run restarts cheaply. `metrics/` and
89+
`report/` turn the JSONL into the divergence chart and the SDS table.
90+
91+
```
92+
configs/*.yaml ─▶ runner ─▶ results/raw/*.jsonl ─▶ aggregate ─▶ report/{divergence.png, RESULTS.md}
93+
94+
KVCompressor · Backend · Task
95+
```
96+
97+
## Status & honest caveats
98+
99+
**v1 is the harness.** 16 build steps, ~38 tests, green CI. The real run is documented in
100+
[`docs/RUNBOOK.md`](docs/RUNBOOK.md) and needs a single 24GB GPU.
101+
102+
- **The headline finding is not in this repo yet.** One piece is intentionally deferred to the GPU
103+
run: wiring the eviction selection into HuggingFace's live KV cache (a custom `DynamicCache`
104+
subclass). Until that lands, `HFBackend` records *how much* KV each method would retain but runs
105+
normal generation — so **all methods produce identical output and the divergence is NULL.** This
106+
is called out in the `HFBackend` docstring and the runbook. Any numbers you see in a freshly
107+
generated `report/` from the CPU smoke are pipeline-shape placeholders, **not** a result.
108+
- The gap kv-canary targets rests on a single recent paper (VeriCache); the contribution here is
109+
making it *measurable and reproducible*, not claiming novelty of the phenomenon.
110+
- The wedge is **functional-vs-token-level divergence**, not "reproducible benchmarking" — local
111+
inference benchmarking is fragmented (a coverage gap), but that is a different problem.
112+
113+
## Layout
114+
115+
```
116+
kvcanary/ compressors/ · backends/ · tasks/ · runner/ · metrics/ · report/
117+
configs/ smoke.yaml (CPU) · v1.yaml (GPU matrix)
118+
scripts/ aggregate_and_report.py
119+
docs/ RUNBOOK.md · superpowers/specs/ (design) · superpowers/plans/ (build plan)
120+
```

configs/v1.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# configs/v1.yaml — full experiment matrix (run on a rented 24GB spot GPU)
2+
models: [Qwen/Qwen2.5-7B-Instruct]
3+
tasks: [code, tool, perplexity]
4+
compressors:
5+
- {method: full, budget: 1.0}
6+
- {method: quantized, budget: 0.5}
7+
- {method: quantized, budget: 0.25}
8+
- {method: quantized, budget: 0.125}
9+
- {method: streamingllm, budget: 0.75}
10+
- {method: streamingllm, budget: 0.5}
11+
- {method: streamingllm, budget: 0.25}
12+
- {method: streamingllm, budget: 0.125}
13+
- {method: snapkv, budget: 0.75}
14+
- {method: snapkv, budget: 0.5}
15+
- {method: snapkv, budget: 0.25}
16+
- {method: snapkv, budget: 0.125}

docs/RUNBOOK.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# kv-canary — GPU run runbook
2+
3+
The CPU smoke (`configs/smoke.yaml`, distilgpt2) proves the pipeline end-to-end. The real finding
4+
needs a GPU. Steps:
5+
6+
1. **Rent a 24GB spot GPU** (4090 / A10 / L4). `pip install -e ".[dev,ml]"`.
7+
2. **Wire real eviction into HF's KV cache.** This is the one deferred piece. `HFBackend` currently
8+
records `kv_bytes_retained` but does NOT yet apply eviction/quantization during the forward pass
9+
(see the class docstring in `kvcanary/backends/hf.py`): until this lands, ALL compressors produce
10+
identical text/perplexity and the divergence will be NULL. Implement a custom `DynamicCache`
11+
subclass whose `update()` calls `compressor.kept_indices(...)` (the pure selection logic is
12+
already tested in Tasks 4–6) and pass it into `model.generate(..., past_key_values=...)`.
13+
Validate on `Qwen2.5-0.5B` first.
14+
3. **Run the matrix** (resumable — safe to re-run after a spot kill):
15+
`python -m kvcanary run configs/v1.yaml --out results/raw/v1.jsonl`
16+
4. **Aggregate + report:** `python scripts/aggregate_and_report.py results/raw/v1.jsonl`
17+
5. **Inspect `report/divergence.png`:** the eviction methods' functional (solid) lines should cliff
18+
while their perplexity-quality (dashed) lines hug 1.0 — that gap is the finding. The SDS column in
19+
`report/RESULTS.md` quantifies it per (method, budget).
20+
6. **CONTINUE** if the divergence reproduces on >=1 eviction method + task (independently confirming
21+
the VeriCache effect). **KILL / pivot** to the benchmark-coverage harness if functional
22+
degradation tracks perplexity 1:1 (no divergence) — see spec §8.
23+
24+
## Caveats (carry into the write-up)
25+
- The wedge is functional-vs-token-level DIVERGENCE, not "reproducible benchmarking" (that gap is
26+
coverage, not reproducibility — see spec §9).
27+
- Quant and eviction share a memory-retained x-axis but are different mechanisms; the differing
28+
degradation is the point.

kvcanary/runner/runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@ def run_cells(cells, backend, compressor_factory, tasks, out_path: str) -> int:
3636
if task.needs_generation:
3737
gen = backend.generate(sample.prompt, compressor=comp)
3838
res = task.evaluate(sample, output=gen.text)
39-
n_tok, kv = gen.n_tokens, gen.kv_bytes_retained
39+
raw, n_tok, kv = gen.text, gen.n_tokens, gen.kv_bytes_retained
4040
else:
4141
ppl = backend.score(sample.prompt, compressor=comp)
4242
res = task.evaluate(sample, output="", ppl=ppl)
4343
full_bytes = getattr(backend, "full_bytes", 1000.0)
44-
n_tok, kv = 0, comp.kv_bytes_retained(full_bytes)
44+
raw, n_tok, kv = "", 0, comp.kv_bytes_retained(full_bytes)
4545
row = CellResult(
4646
spec=spec,
4747
sample_id=sample.id,
4848
scores=res.scores,
49-
raw_output="",
49+
raw_output=raw,
5050
n_tokens=n_tok,
5151
latency_s=time.perf_counter() - t0,
5252
kv_bytes_retained=kv,

scripts/aggregate_and_report.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import json
2+
import os
3+
import sys
4+
5+
from kvcanary.metrics.aggregate import aggregate_rows
6+
from kvcanary.report.chart import render_divergence_chart
7+
from kvcanary.report.markdown import render_results_md
8+
9+
with open(sys.argv[1]) as f:
10+
rows = [json.loads(line) for line in f if line.strip()]
11+
agg = aggregate_rows(rows)
12+
os.makedirs("report", exist_ok=True)
13+
render_divergence_chart(agg, "report/divergence.png")
14+
with open("report/RESULTS.md", "w") as f:
15+
f.write(render_results_md(agg))
16+
print("wrote report/divergence.png and report/RESULTS.md")

tests/test_runner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def test_runner_writes_rows_and_resumes(tmp_path):
2727
assert n1 == 2 # 2 cells x 1 sample
2828
rows = [json.loads(line) for line in out.read_text().splitlines()]
2929
assert {r["cell_id"] for r in rows} == {"toy|full|1.0|tool", "toy|streamingllm|0.5|tool"}
30+
assert all(r["raw_output"] == '{"name": "f", "arguments": {}}' for r in rows) # generated text stored
3031
n2 = run_cells(cells, backend, _factory, tasks, str(out)) # resume
3132
assert n2 == 0 # everything already done
3233
assert len(out.read_text().splitlines()) == 2 # no duplicate rows

0 commit comments

Comments
 (0)