diff --git a/.gitignore b/.gitignore index 4eed38c..dc5b5fe 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ htmlcov/ *.safetensors *.gguf *.bin +# git-archive snapshots made to ship the repo to a rented pod. +*.tar wandb/ .cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 514f94a..05ea933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ once a phase's exit criteria are actually met). whole-branch review), both fixed before merge. Cost: **$1.95** of a $5 cap, including two RunPod Community Cloud pods that hit a real host-level GPU passthrough bug. Full account: - `docs/findings/2026-09-17-phase-5a-quantization-run.md`. + `docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md`. - **Phase 4 (disaggregated prefill/decode) complete**, 2026-09-17 (`docs/plans/2026-09-16-phase-4-disaggregated-prefill-decode-plan.md`). Continuous-batching prefill/decode workers across two real 4-GPU H100 @@ -44,7 +44,7 @@ once a phase's exit criteria are actually met). concurrency 8 (0.98s vs 0.70s) — most likely a kernel-warmup confound, reported as genuinely inconclusive. Cost: **$10.94** of a $40 cap. Full account: - `docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. + `docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. - **Phase 3 (multi-GPU expert-parallel serving) complete**, 2026-09-16 (`docs/plans/2026-09-15-phase-3-multi-gpu-expert-parallel-serving-plan.md`). Real 2x H200 SXM expert-parallel serving over DeepSeek's own DeepEP @@ -57,7 +57,7 @@ once a phase's exit criteria are actually met). wins at every tested count on H200; real per-local-expert counts, median 2 max ~20, sit below Phase 1's smallest tested point of 16). Cost: **$13.03** of a $25 cap. Full account: - `docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md`. + `docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md`. - **Phase 2 (vLLM benchmark contribution) complete**, 2026-09-15 (`docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md`). Neither vLLM's nor SGLang's official MoE benchmark modeled skewed @@ -68,7 +68,7 @@ once a phase's exit criteria are actually met). PR: [vllm-project/vllm#57100](https://github.com/vllm-project/vllm/pull/57100). Cost: **$0.77**. This phase landed no code in `dispatch` itself, only docs — its actual contribution is the upstreamed PR. Full account: - `docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md`. + `docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md`. - **Phase 1 (custom Triton grouped-GEMM kernel) complete**, 2026-09-15 (`docs/plans/2026-09-15-phase-1-grouped-gemm-plan.md`). Built and tested: a CPU-only MoE reference and grouped-GEMM contract, a naive and @@ -97,7 +97,7 @@ once a phase's exit criteria are actually met). run happened), wins 3-8% at 16-128 tokens, then loses by up to 14% at 512-2048 — a workload-specific result recorded rather than buried. Total GPU cost across both sessions: **$0.65**. - Full account: `docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`. + Full account: `docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`. - **Phase 0 (baseline) complete**, 2026-09-14 (`docs/plans/2026-09-14-phase-0-baseline-plan.md`). Built and tested: a RunPod REST client and provisioning CLI, a token-by-token-timed @@ -111,7 +111,7 @@ once a phase's exit criteria are actually met). DeepSeek's own remote code, the same break's cousin one release earlier, and a model-cache-on-the-wrong-disk trap), none in this repo's own code. Full account: - `docs/findings/2026-09-14-phase-0-baseline-run.md`. + `docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md`. - Repo scaffolded: conventions, tooling, and doc structure carried over from [almanac](https://github.com/bsreecharanreddy/almanac) and [canopica](https://github.com/bsreecharanreddy/canopica) where diff --git a/CLAUDE.md b/CLAUDE.md index bcdc92e..bc19e93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,11 @@ updates **in the same commit as the work it describes**. `docs/plans/` holds per-phase implementation plans, starting with `docs/plans/2026-09-14-phase-0-baseline-plan.md`. +`docs/findings/` holds measured results, **one subfolder per phase** +(`docs/findings/phase-0/` ... `phase-5b/`), filenames still date-prefixed. +GPU scripts default their output dir to their own phase's folder; a new +phase's scripts should do the same rather than write into the top level. + ## Current status **Phase 0 (baseline) is complete, 2026-09-14**, and merged to `main` via @@ -56,7 +61,7 @@ and tested (`make check` green, 26 tests). The one real rented-GPU run after finding and fixing three real environment bugs along the way (a `transformers` version break in DeepSeek's own remote code, a second break in the same file one release earlier, and a model-cache-on-the-wrong-disk -trap). Full account: `docs/findings/2026-09-14-phase-0-baseline-run.md`. +trap). Full account: `docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md`. **Phase 1 (custom Triton grouped-GEMM kernel) is complete, 2026-09-15** (`make check` green throughout, 73 tests, lint and `mypy --strict` @@ -81,7 +86,7 @@ the kind of risk the plan's own risk section flagged before the run happened. Total GPU cost across both paid sessions: **$0.65** ($0.06 kernel correctness + $0.59 the measured run). Full account: -`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`. +`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`. **Phase 2 (vLLM benchmark contribution) is complete, 2026-09-15.** Neither vLLM's nor SGLang's official MoE benchmark modeled skewed (zipf) @@ -93,7 +98,7 @@ winning Triton config at 4 of 5 tested batch sizes**. Open PR: awaiting maintainer review. Cost: **$0.77**. This phase landed no code in `dispatch` itself, only docs — its actual contribution is the upstreamed PR. Full account: -`docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md`. +`docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md`. **Phase 3 (multi-GPU expert-parallel serving) is complete, 2026-09-16**, merged via PR #3 alongside Phase 2's docs. Real 2x H200 SXM EP over @@ -106,7 +111,7 @@ under DeepEP's real per-expert token counts (naive wins at every tested count on H200; real per-local-expert counts, median 2 max ~20, sit below Phase 1's smallest tested point of 16). Cost: **$13.03** of a $25 cap. Full account: -`docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md`. +`docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md`. **Phase 4 (disaggregated prefill/decode) is complete, 2026-09-17**, merged via PR #4. Continuous-batching prefill/decode workers across two @@ -118,10 +123,10 @@ result was mixed, not clean**: disaggregated TTFT beats co-located at concurrency 4 (0.46s vs 1.12s) but loses at concurrency 8 (0.98s vs 0.70s) -- most likely a kernel-warmup confound, reported as genuinely inconclusive. Cost: **$10.94** of a $40 cap. Full account: -`docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. +`docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. -**Phase 5a (int8 weight-only quantization) is complete, 2026-09-17**, on -its own branch (`phase-5a-quantization`), PR pending. Self-computed, +**Phase 5a (int8 weight-only quantization) is complete, 2026-09-17**, merged +to `main` via PR #5. Self-computed, per-output-channel int8 quantization extending the Triton kernel itself (not sourced from bitsandbytes/AWQ). Kernel-level correctness gate passed on a real L40 (15/15 int8, 25/25 bf16, both first-time on this @@ -138,7 +143,31 @@ reduction, perfect model-level top-1/mutual-top-k agreement at every tested position. Cost: **$1.95** of a $5 cap, including two RunPod Community Cloud pods that hit a real host-level GPU passthrough bug before a Secure Cloud pod worked. Full account: -`docs/findings/2026-09-17-phase-5a-quantization-run.md`. +`docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md`. + +**Phase 5b (speculative decoding) is complete, 2026-09-18**, PR #6 open on +`phase-5b-speculative-decoding`. A shared propose/verify/accept/rollback +loop with two drafters (a 7B draft model, and model-free prompt-lookup) on +top of Phase 5a's int8 target, with an independent plain-greedy oracle for +the baseline. The first GPU session's numbers were withdrawn by the final +review (degenerate all-token-0 baseline); this phase's second session +root-caused it: `transformers==5.17.0` leaves DeepSeek's remote-code RoPE +`inv_freq` buffer uninitialized after `from_pretrained`, poisoning +attention with NaN on GPU. `fix_rope_inv_freq()` now runs inside +`load_model` for every caller. Real re-run on an A40, checked against the +baseline at every k: baseline 25.18 tok/s; **only prompt-lookup beats it** +(34.4-50.9 tok/s across k=1-8; draft-model is below baseline at every k, +19.6-23.0). **Not every config is byte-exact against the baseline** -- in +the 8-config sweep draft-model matched in 13 of 16 (prompt, k) +combinations and prompt-lookup in 9 of 16, and the same k=4 prompt-lookup +config matched 2/4 prompts in one process and 3/4 in another. Root cause, +confirmed by two GPU probes: a genuine near-tied logit under the int8 +kernel's floating-point precision, not a logic bug in the loop. **Open +question, deliberately not audited:** Phase 5a's own "perfect top-1/top-k +agreement" claim used this same kernel and a single-run check that could +not see this. Cost: **$12.26** across both sessions, against a $10 cap +that was exceeded on one pod and raised to $20 with disclosure. Full +account: `docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md`. ## One governing principle @@ -208,6 +237,18 @@ to how GPU rental actually works: - **Spin up, run the measured session, capture the evidence, tear down immediately.** Never leave a rented GPU idle between sessions. - **Cost per run is measured and reported**, same as every other number. +- **Never leave a pod running across an unbounded wait** (a question to the + user, a background task with no deadline). Phase 5b's original $10 cap + was exceeded on exactly this: one pod left running while waiting on a + reply. Stop it first, restart on the answer. +- **Pull every evidence file off the pod before `stop`, not after.** The + repo clone lived outside the persistent mount (`/workspace`) and did + not survive a stop/start; only the JSONs whose contents had already + been printed to captured stdout could be recovered. +- **A stopped pod may be unrestartable** ("not enough free GPUs on the + host machine") -- hit on three separate pods in Phase 5b (one started + on a third attempt, two never did). Treat `stop` as potentially final + for that host's disk. GPU provisioning lives in `scripts/gpu/` as lightweight scripts calling provider APIs directly — not Terraform. RunPod/Vast.ai's Terraform diff --git a/README.md b/README.md index 540c9f7..8847f7e 100644 --- a/README.md +++ b/README.md @@ -22,15 +22,17 @@ result was a null or a mixed one, it's reported that way. --- -> **Status:** Phases 0-4 merged to `main` across four PRs +> **Status:** Phases 0-5a merged to `main` across five PRs > ([#1](https://github.com/bsreecharanreddy/dispatch/pull/1) Phase 0, > [#2](https://github.com/bsreecharanreddy/dispatch/pull/2) Phase 1, > [#3](https://github.com/bsreecharanreddy/dispatch/pull/3) Phases 2-3, -> [#4](https://github.com/bsreecharanreddy/dispatch/pull/4) Phase 4) — +> [#4](https://github.com/bsreecharanreddy/dispatch/pull/4) Phase 4, +> [#5](https://github.com/bsreecharanreddy/dispatch/pull/5) Phase 5a) — > Phase 2 landed as docs only, its actual contribution being the -> upstreamed vLLM PR below, not a dispatch-repo code change. **Phase 5a -> (int8 weight-only quantization) is complete** on its own branch, PR -> pending. Full task-by-task record: +> upstreamed vLLM PR below, not a dispatch-repo code change. **Phase 5b +> (speculative decoding) is complete**, PR +> [#6](https://github.com/bsreecharanreddy/dispatch/pull/6) open. Full +> task-by-task record: > [`docs/STATUS.md`](docs/STATUS.md). Design and phasing: > [`docs/design/2026-09-14-dispatch-system-design.md`](docs/design/2026-09-14-dispatch-system-design.md). @@ -47,8 +49,9 @@ the answer was a null or mixed result. serving path, an upstreamed open-source benchmark contribution, every rented-GPU session, and the write-ups of what broke along the way. -**Measured across seven rented-GPU sessions (six phases), $27.69 total, -all under their stated caps:** a custom Triton kernel **~65-73% faster** than DeepSeek's own +**Measured across nine rented-GPU sessions (seven phases), $39.95 total, +every phase within its stated cap except Phase 5b, whose original $10 cap +was exceeded on one pod and raised to $20 with disclosure:** a custom Triton kernel **~65-73% faster** than DeepSeek's own stock MoE forward pass at perfect logit agreement (Phase 1); an opt-in skewed-load benchmark flag upstreamed to vLLM, changing which kernel config its own tuner picks at 4 of 5 tested batch sizes (Phase 2); a real 2-GPU @@ -59,9 +62,15 @@ topology across 4 real GPUs that returned a genuinely mixed result rather than a forced win (Phase 4); and an int8 weight-only quantized kernel that cut expert-weight memory **49.89%** at perfect model-level agreement, while also surfacing and fixing a real bug where the naive quantized path was -holding both the bf16 and int8 copies in memory at once (Phase 5a). - -**123 tests, `make check` green throughout Phase 5a** (lint, `mypy --strict`, +holding both the bf16 and int8 copies in memory at once (Phase 5a); and +speculative decoding whose first GPU numbers were withdrawn by this +project's own review, then root-caused to a `transformers` loading bug +(uninitialized RoPE buffers) and re-measured — where only the model-free +prompt-lookup drafter beat the baseline, and byte-exact agreement with +greedy decoding turned out to be limited by near-tied logits in the int8 +kernel (Phase 5b). + +**170 tests, `make check` green throughout Phase 5b** (lint, `mypy --strict`, and the full non-GPU suite) — GPU-dependent tests are marked and excluded from CI by design, then run for real on rented hardware every phase. @@ -125,6 +134,7 @@ flowchart LR subgraph custom["Custom (this project's actual work)"] K["Grouped-GEMM expert kernel\n[Triton, Phase 1]"] + SD["Speculative decode loop\n[drafters + verify, Phase 5b]"] RT["Rust router\n[HTTP/gRPC, batching, Phase 7]"] MS["Python model server\n[runs router+kernel, gRPC, Phase 7]"] end @@ -133,6 +143,7 @@ flowchart LR RT -->|gRPC| MS D <-.-> MS Q -.-> K + SD -.->|drives| K B -->|measures| MS B -->|measures, same config| V["vLLM / SGLang\n(comparison baseline)"] ``` @@ -156,9 +167,11 @@ including the ones that came back null or mixed: | **3 — multi-GPU EP** | Does Phase 1's naive-vs-persistent crossover hold under DeepEP's real per-expert token distribution across GPUs? | **It doesn't apply at real scale.** Naive wins at every tested token count (16-2048) on H200, independent of EP. Real DeepEP dispatch across the model's 27 MoE layers produces per-local-expert counts (median 2, max ~20) far below Phase 1's smallest tested point (16) | | **4 — disaggregated prefill/decode** | Does separating prefill and decode across GPU pools relieve contention, holding GPU count fixed at 4? | **Mixed, not a clean result.** Disaggregated TTFT beats co-located at concurrency 4 (0.46s vs 1.12s) but loses at concurrency 8 (0.98s vs 0.70s) — most likely a kernel-warmup confound between independent process launches, reported as genuinely inconclusive rather than forced | | **5a — int8 quantization** | Does a self-computed, per-channel int8 weight-only kernel cut memory with no model-level correctness cost? | **Yes on both counts, no speedup.** **49.89%** expert-weight memory reduction, perfect top-1/mutual-top-k agreement vs. the naive bf16 kernel at every tested position. Throughput was a near-exact tie with naive (**-1.2%**), as expected — weight-only quantization saves memory bandwidth, not FLOPs. A real bug (quantizing without freeing the original bf16 weights, OOMing a 44GB L40) was found and fixed mid-session | +| **5b — speculative decoding** | Does speculative decoding (a 7B draft model, or model-free prompt-lookup) speed up single-request decode on the int8 target while reproducing plain greedy output exactly? | **Partly, and not exactly.** A40, bf16, unbatched, baseline **25.18 tok/s**: only prompt-lookup beats it (**34.4-50.9 tok/s** across k=1-8, +77.7% at k=4); the 7B draft model is *below* baseline at every k (19.6-23.0) despite 2-7x higher acceptance. Output matched plain greedy in 13 of 16 (prompt, k) combinations for draft-model and 9 of 16 for prompt-lookup; the same k=4 config matched 2/4 prompts in one process and 3/4 in another. Root-caused to near-tied logits under the int8 kernel's floating-point precision, not a logic bug. The first session's numbers were withdrawn by this project's own review (degenerate baseline) and root-caused to a `transformers` bug leaving DeepSeek's RoPE buffers uninitialized | -Full method and every number: one findings doc and one cost doc per phase -in [`docs/findings/`](docs/findings/). +Full method and every number: one folder per phase in +[`docs/findings/`](docs/findings/) (`phase-0/` through `phase-5b/`), each +with a run doc and a cost doc. ## What it does not do @@ -186,7 +199,7 @@ doc's own scope boundaries: | Model | `deepseek-ai/deepseek-moe-16b-base` — 16.4B params, fine-grained MoE, shared + routed experts | | Kernel | Custom Triton grouped-GEMM (naive + persistent/cache-aware), int8 weight-only quantization (Phase 5a) | | Multi-GPU | DeepSeek's DeepEP — real all-to-all expert-parallel dispatch/combine over NVLink/SXM | -| Serving | Continuous-batching prefill/decode workers, both co-located and disaggregated topologies | +| Serving | Continuous-batching prefill/decode workers, both co-located and disaggregated topologies; speculative decoding (draft-model and prompt-lookup drafters, Phase 5b) | | Benchmark methodology | vLLM/SGLang-style metrics — TTFT, inter-token latency, tokens/sec, cost per million tokens | | Language | Python 3.12+ — `uv`, `ruff`, `mypy --strict`, `pytest` | | GPU provisioning | RunPod's API via lightweight scripts (`scripts/gpu/`) — not Terraform; see `CLAUDE.md`'s cost discipline | @@ -200,15 +213,18 @@ src/dispatch/ kernels/ reference MoE, Triton grouped-GEMM (naive + persistent), int8 quantization, expert-parallel sharding, backend registry serving/ KV-cache slice/handoff, continuous-batching prefill/decode, co-located worker + speculative/ Drafter protocol (draft-model, prompt-lookup), shared verify/rollback loop, + independent plain-greedy oracle, exact generated-token comparison scripts/ run_baseline.py latency/throughput CLI, stock or kernel-patched, --compare-reference gate + run_speculative_bench.py speculative-decoding CLI, --compare-generated-tokens checks exact output run_kernel_bench.py pure kernel micro-benchmark, refuses to time a backend that disagrees gpu/ RunPod API client, provisioning CLI, pod-live correctness/concurrency scripts tests/ unit + integration; `gpu`-marked tests need real CUDA, excluded from CI docs/design/ the authoritative architecture, phase plan, and scope document docs/adr/ decisions, each checked against real current material before being made docs/plans/ per-phase implementation plans, written before any code -docs/findings/ measured results and costs — one run doc and one cost doc per phase +docs/findings/ measured results and costs — one subfolder per phase (phase-0 ... phase-5b) docs/runbooks/ the exact GPU-session steps each phase's real run followed ``` @@ -235,7 +251,7 @@ is the authoritative architecture, phasing, and scope document. |---|---| | [`docs/STATUS.md`](docs/STATUS.md) | the verification log, at task granularity, updated in the same commit as the work | | [`docs/adr/`](docs/adr/) | three decisions, each citing the real material that settled it | -| [`docs/findings/`](docs/findings/) | the measurements themselves, one run doc and one cost doc per phase | +| [`docs/findings/`](docs/findings/) | the measurements themselves, one subfolder per phase, each with a run doc and a cost doc | | [`docs/plans/`](docs/plans/) | per-phase implementation plans, written before any code | | [`docs/runbooks/`](docs/runbooks/) | the exact GPU-session steps each phase's real run followed | @@ -243,17 +259,17 @@ is the authoritative architecture, phasing, and scope document. - **Inference / GPU-systems engineering** — the kernel itself (`src/dispatch/kernels/grouped_gemm.py`), then - [Phase 1's findings doc](docs/findings/2026-09-15-phase-1-grouped-gemm-run.md): + [Phase 1's findings doc](docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md): a from-scratch Triton kernel proven correct on real hardware before a single speed claim was made. - **Distributed / multi-GPU systems** — `src/dispatch/kernels/expert_parallel.py` and `src/dispatch/serving/`, then - [Phase 3's](docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md) and - [Phase 4's](docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md) + [Phase 3's](docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md) and + [Phase 4's](docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md) findings docs for two results — one null, one mixed — found by measuring on real hardware rather than assumed. - **ML infra / open-source contribution** — - [Phase 2's findings doc](docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md) + [Phase 2's findings doc](docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md) and the open [vLLM PR](https://github.com/vllm-project/vllm/pull/57100): a real gap found by checking what a production project already ships, not duplicating it. diff --git a/docs/STATUS.md b/docs/STATUS.md index 8e4e987..2486848 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -60,7 +60,7 @@ it. **Task 8's real run found three more bugs, all in the environment rather than in `dispatch`'s own code** -- see -`docs/findings/2026-09-14-phase-0-baseline-run.md` for the full account: +`docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md` for the full account: DeepSeek's `trust_remote_code` modeling file calling a `transformers` utility (`is_torch_fx_available`) removed entirely by transformers 5.17.0 (this repo's pinned floor); the same file calling a `Cache` method @@ -79,13 +79,13 @@ decode, 15 runs (3 prompts x 5 repetitions, 64 max new tokens): stock at deploy time), all three failed attempts included since the model was already cached locally by the second one. Pod verified `TERMINATED` independently after deletion. Full record: -`docs/findings/2026-09-14-phase-0-baseline-results.json` and +`docs/findings/phase-0/2026-09-14-phase-0-baseline-results.json` and `2026-09-15-phase-0-baseline-cost.md` (both committed). The reference logits (`-reference.safetensors`, Phase 1's correctness oracle) are **not** committed -- `.gitignore` excludes `*.safetensors` repo-wide by design; the file exists locally but Phase 1 regenerates it from `capture_reference_logits` rather than relying on a checked-in blob. -See `docs/findings/2026-09-14-phase-0-baseline-run.md` for the full +See `docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md` for the full account. ## Phase 1 progress @@ -116,7 +116,7 @@ clean outcome. One real, non-kernel bug found and fixed: a a real device. A mutation check (forcing every tile to read expert 0's weights) turned 24/25 tests red, confirmed the revert was clean, and reran green -- the suite can fail. Cost: **$0.0606** for 991s of RTX 3090 -rental. Full account: `docs/findings/2026-09-15-phase-1-kernel-correctness.md`. +rental. Full account: `docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness.md`. Task 7 adds `dispatch.kernels.backends` (`BACKENDS = ("torch", "naive", "persistent")` and `resolve_backend`, which imports the Triton kernel @@ -184,7 +184,7 @@ before the run happened (unbatched decode gives each expert too few rows for L2 tile reuse to matter) -- but wins 3-8% at 16-128 tokens and loses by up to 14% at 512-2048, a workload-specific result recorded rather than buried. Full account: -`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`. +`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`. **Total Phase 1 GPU cost: $0.65** across both paid sessions ($0.0606 kernel correctness + $0.5916 the measured run) -- both well under their @@ -225,7 +225,7 @@ including three real environment issues found and fixed (a failed precompiled-wheel install, a local-package-shadowing bug affecting Ray workers, and RunPod's proxy-only SSH access for this pod) and every deviation from the plan and why: -`docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md`. +`docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md`. ## Phase 3 progress @@ -279,7 +279,7 @@ tested point of 16) -- real single-request EP traffic lands close to Phase 1's single-token-decode tie, not its 16-128-token win region. Both kernels sit near a shared ~0.5ms latency floor at that real scale. Full account, including the exact bugs, fixes, and every number: -`docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md`. +`docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md`. **Total Phase 3 GPU cost: $13.03** of the $25 cap, 2x H200 SXM, 85 minutes. @@ -336,7 +336,7 @@ contention). Reported as genuinely inconclusive rather than forced into either direction; a warmed-up measurement protocol is the identified follow-up, not attempted this session per the standing cost-discipline instruction once both correctness gates had passed. Full account: -`docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. +`docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. **Total Phase 4 GPU cost: $10.94** of the $40 cap, 4x H100 SXM, 47 minutes. @@ -409,12 +409,98 @@ the pod with `hf_transfer` not installed. **Total Phase 5a GPU cost: $1.9543** of the $5 cap (39.1%), across all three pods including both abandoned Community Cloud attempts. Full -account: `docs/findings/2026-09-17-phase-5a-quantization-run.md`. +account: `docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md`. + +## Phase 5b progress + +Design: `docs/design/2026-09-17-phase-5b-speculative-decoding.md`. Plan: +`docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md`. Branch +`phase-5b-speculative-decoding`. + +- [x] Task 1: `Drafter` protocol and `PromptLookupDrafter` (`src/dispatch/speculative/drafters.py`) +- [x] Task 2: `DraftModelDrafter` +- [x] Task 3: The shared speculative decode loop (`src/dispatch/speculative/decode.py`) +- [x] Task 4: Exact generated-token reference capture/compare (`src/dispatch/speculative/reference.py`) +- [x] Task 5: CLI (`scripts/run_speculative_bench.py`) +- [x] Task 6: GPU rental runbook -- correctness gates, measured run, k-sweep +- [x] Task 7: this update +- [x] Final whole-branch review fix wave, 2026-09-17: independent + correctness oracle, runtime cache-lag assertions, a real + end-to-end draft-model test -- and withdrawal of the GPU session's + throughput/correctness-gate numbers below (see below) +- [x] Task 8, 2026-09-18: root-cause `fix_rope_inv_freq()` (commit + `c5b59df`) for the withdrawn baseline's degenerate output; real + GPU re-run (baseline, both gates, full k-sweep, every point + checked against the baseline) superseding the withdrawal + +**Phase 5b is complete, 2026-09-18.** The prior session's degenerate +baseline (finding C1) is root-caused and fixed: `transformers==5.17.0` +leaves DeepSeek's remote code's RoPE `inv_freq` buffer uninitialized +after `from_pretrained`, poisoning every attention layer with NaN on +GPU. Fixed permanently via `fix_rope_inv_freq()` +(`src/dispatch/kernels/integration.py`, commit `c5b59df`), wired into +`scripts/run_speculative_bench.py` for both the target and any draft +model. `make check` green throughout; lint and `mypy --strict` clean. + +**What the prior review found, and what this session's real GPU run +confirmed as root-caused rather than a shared-code artifact**: the +original baseline (`--drafter none`) generated a degenerate output -- +64 repetitions of token id `0` -- caused by the uninitialized-`inv_freq` +bug above, not by the shared propose/verify/accept/rollback loop +(`run_speculative_rounds`) itself, which was re-derived by hand this +session and found correct for every candidate-count case. Full account +of the fix, the real re-measured baseline, both correctness gates, and +the full k-sweep (checked at every k this time, closing finding I5): +`docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md`. + +**Real, non-degenerate results, 2026-09-18** (A40, bf16, int8 target +kernel, unbatched decode, 64 new tokens, 4 prompts x 5 reps): baseline +25.18 tok/s. draft-model k=4 gate: byte-exact on all 4 prompts, 23.03 +tok/s (-8.5% vs. baseline), 74.5% acceptance. prompt-lookup k=4 gate: +byte-exact on 2 of 4 prompts, 47.21 tok/s, 17.7% acceptance; the same +config re-run in the k-sweep matched 3 of 4 (44.74 tok/s, +77.7%) -- +same code and flags, different process, different outcome. Across the +8-config k-sweep, draft-model matched the baseline in 13 of 16 +(prompt, k) combinations and prompt-lookup in 9 of 16; prompt_000 +diverged in 6 of 8 configs (all but k=4), including draft-model at +k=1/2/8. Root-caused live on the rented GPU to a genuine near-tied +logit position under the int8-quantized kernel's actual floating-point +precision -- confirmed by two independent probes (a batch-width sweep +and a 10-trial same-process determinism check) -- not a defect in +`dispatch.speculative`. **Only prompt-lookup beats the baseline** +(34.4-50.9 tok/s across k=1-8); draft-model is below it at every k +(19.6-23.0), despite 2-7x higher acceptance. + +**Memory checkpoints** (from the original 2026-09-17 session, not +re-measured this session -- allocator behavior is unrelated to the +token-correctness bug this session fixed): quantized target alone +allocates 16.75GB, target plus bf16 draft model together 29.62GB -- +both well within a single GPU's capacity, no OOM at either checkpoint. + +**Total Phase 5b GPU cost across both sessions: $12.26 of the $20 cap** +(61.3% -- cap raised from the original $10 mid-session, 2026-09-18, +after a disclosed cost overrun on one pod left running through an +unbounded wait; user ruling: raise to $20 and continue). Per-pod: +`2t6wh9l3okl3gj` (L40, 2026-09-17) $0.6833; `oby7hbkzx8o8yt` (L40) +$10.8848; `5ufi854zxkfwbz` (A40) $0.2022; `d2h1sgmr5wjssv` (A40, where +the real baseline/gates/k-sweep ran) $0.49. All four pods stopped or +terminated. + +**Open question, flagged for the project owner, not audited here**: +this session's root-cause work found concrete evidence of near-tie +floating-point sensitivity in `grouped_matmul_int8`, the same quantized +kernel Phase 5a's own "perfect top-1/mutual-top-k agreement" claim was +measured against. That claim may not be wrong, but the single-run +top-k check it relied on would not have caught this specific failure +mode. Re-auditing Phase 5a was out of this task's scope. ## Next step -Phase 4 is merged (PR #4). Phase 5a is complete and ready to push as a -PR. Phase 3's PR (#3) already merged. Phase 2's PR is still open and -awaiting maintainer review; no further work planned on it beyond -responding to review feedback. Phase 5b (speculative decoding) is not -yet planned. +Phase 5b is done: root cause fixed, real numbers measured, both +correctness gates and the full k-sweep checked and explained. Phase 4 is +merged (PR #4). Phase 5a and Phase 5b are both ready to push as PRs per +the one-branch-per-phase convention. Phase 3's PR (#3) already merged. +Phase 2's PR is still open and awaiting maintainer review; no further +work planned on it beyond responding to review feedback. Phase 6 (final +benchmark vs. vLLM/SGLang) planning can proceed, and should treat the +Phase 5a audit question above as an open input, not a blocker. diff --git a/docs/design/2026-09-15-phase-2-vllm-benchmark-contribution.md b/docs/design/2026-09-15-phase-2-vllm-benchmark-contribution.md index b63db76..c6e6e51 100644 --- a/docs/design/2026-09-15-phase-2-vllm-benchmark-contribution.md +++ b/docs/design/2026-09-15-phase-2-vllm-benchmark-contribution.md @@ -35,7 +35,7 @@ and confirmed no open issue requests it in either tracker. Phase 1 already built exactly this — a zipf-vs-uniform expert-load generator, used for the token-count sweep in -`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md` — making this a +`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md` — making this a direct, reusable contribution rather than new work invented for the occasion. @@ -146,7 +146,7 @@ CODEOWNERS auto-request @mgoin rather than hand-tagging. recording the measured config divergence, the GPU cost, and the PR's outcome (opened / under review / merged / rejected, honestly reported whichever it is) — same pattern as - `docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`. + `docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`. ## 8. Explicit non-goals (project-level) diff --git a/docs/design/2026-09-15-phase-3-multi-gpu-expert-parallel-serving.md b/docs/design/2026-09-15-phase-3-multi-gpu-expert-parallel-serving.md index be4996d..96c067c 100644 --- a/docs/design/2026-09-15-phase-3-multi-gpu-expert-parallel-serving.md +++ b/docs/design/2026-09-15-phase-3-multi-gpu-expert-parallel-serving.md @@ -17,7 +17,7 @@ The headline result is not "DeepEP works." Phase 1 found a real, workload-specific crossover: the persistent (cache-aware) kernel ties the naive kernel at unbatched single-token decode, wins 3-8% at 16-128 tokens/expert, and loses by up to 14% at 512-2048 tokens/expert -(`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`). Cross-GPU EP +(`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`). Cross-GPU EP dispatch changes the per-expert token-count distribution each rank actually sees -- it is not the same shape as local unbatched decode. diff --git a/docs/design/2026-09-17-phase-5b-speculative-decoding.md b/docs/design/2026-09-17-phase-5b-speculative-decoding.md new file mode 100644 index 0000000..fadf7f8 --- /dev/null +++ b/docs/design/2026-09-17-phase-5b-speculative-decoding.md @@ -0,0 +1,248 @@ +# Phase 5b design: speculative decoding (draft-model + prompt-lookup) + +Per `docs/design/2026-09-14-dispatch-system-design.md` §7 (phase plan, row +5: "Quantization + speculative decoding layered on top"), split during +Phase 5a's own brainstorming into two sequential sub-phases. Phase 5a +(int8 weight-only quantization) is complete and merged (PR #5). **This +document covers 5b (speculative decoding) only.** + +## 1. Purpose and thesis + +Every phase through 5a has measured throughput of a single forward pass +per emitted token. Speculative decoding proposes several tokens cheaply, +then verifies all of them in one forward pass of the (expensive) target +model -- when verification accepts more than one token per target forward +pass, decode throughput rises without changing the target's own +per-token cost. + +**Phase 5b's thesis: does layering speculative decoding on top of this +project's own quantized target kernel (Phase 5a) produce a real, +measured throughput win on DeepSeekMoE-16B, and does that hold for both +a real second-model drafter and a model-free n-gram drafter -- on the +same real hardware class this project has used throughout?** + +This is explicitly an "existing tooling" phase, unlike Phases 1/5a's new +Triton kernels: the technique itself (Leviathan et al. 2023-style +draft-then-verify, and prompt-lookup decoding) is a solved algorithm. +This project's job is implementing it correctly against its own model, +own kernel, and own harness, and reporting an honestly measured number -- +not writing a new kernel for it. + +## 2. Scope, decided during brainstorming + +- **Two drafters, compared, not one.** A real draft-model path + (`deepseek-ai/deepseek-llm-7b-base`) and a model-free prompt-lookup + (n-gram) path. Decided explicitly rather than picking one: they have + different mechanisms, different costs, and different expected winners, + and this project's pattern throughout has been "measure the comparison, + don't assume the winner" (Phase 1's naive-vs-persistent, Phase 4's + co-located-vs-disaggregated). +- **Target runs Phase 5a's int8-quantized kernel** (`patch_moe_infer_quantized` + and `resolve_quantized_backend()`, unchanged from 5a), not bf16. Decided + for a concrete reason, not just reuse: a bf16 target (~32GB) plus a + bf16 7B draft (~14GB) does not comfortably fit on one 44-48GB GPU; + freeing ~13.9GB via 5a's kernel (measured: 27.84GiB -> 13.95GiB expert + weights) is what makes single-GPU feasible. This also ties 5a and 5b + together the way the original design doc's Phase 5 row intended, + rather than leaving them as two disconnected techniques. +- **Verification is exact-match against greedy, not sampling-based + rejection.** This project's harness has always decoded greedy + (`argmax`, `harness.py`'s `generate_with_timings`); speculative + decoding's acceptance rule here is "accept a draft token iff it equals + the target's own greedy argmax at that position." This makes + speculative decoding's output a deterministic function that must be + byte-identical to plain sequential greedy decoding -- see §5. +- **Single GPU.** Matches the design doc's Phase 5 cost shape + ("single/dual GPU") and avoids the cost/complexity of cross-device + coordination. **Budget cap: $10**, set now, before any rental -- + higher than 5a's $5 because this phase carries two new risks 5a didn't + (two resident models; a new KV-cache-rollback code path). +- **Draft-model choice verified live, not assumed.** Checked directly + against Hugging Face before committing to this design: + `deepseek-moe-16b-base` uses a custom tokenizer (`LlamaTokenizerFast`, + vocab_size 102400, DeepSeek's own `<|begin/end of sentence|>` special + tokens). `deepseek-coder-1.3b-base` shares the same tokenizer *class* + and special-token strings but a **different vocab** (32256) -- not + usable for direct token-level verification. `deepseek-llm-7b-base` is + the only standalone DeepSeek model confirmed to share the exact + 102400-vocab tokenizer (plain `LlamaForCausalLM`, no `trust_remote_code` + needed). No standalone ~1B-class same-vocab model exists in the + `deepseek-ai` namespace -- the smallest same-vocab dense sibling is 7B. + This is carried into the design as a named risk (§8), not hidden: a 7B + dense draft may cost close to, or more than, the target's ~2.8B + active-per-token MoE compute. +- **Four prompts for this phase's own runs, not three.** The existing + `DEFAULT_PROMPTS` (pangram, creative-writing opener, bare function + signature) plus one new repetition-heavy prompt, so prompt-lookup gets + a genuine chance to find n-gram matches. All four backends measured in + this phase's session (quantized-target baseline, draft-model + speculative, prompt-lookup speculative) run on the same four prompts; + prior phases' three-prompt historical numbers are not recomputed. + +## 3. Architecture + +A new package, `src/dispatch/speculative/`, alongside `kernels/` and +`serving/`. Left untouched: `moe_forward.py`'s `GroupedMatmul` contract, +`integration.py`'s `patch_moe_infer`/`patch_moe_infer_quantized`, and +`harness.py`'s plain greedy loop (still used for the non-speculative +baseline and as the correctness oracle in §5). + +- **`Drafter` (new `Protocol`, `drafters.py`)** -- the pluggable-callable + pattern this project already uses for `GroupedMatmul`/`MoEInfer`: + - `propose(token_ids: Tensor, num_tokens: int) -> Tensor` -- given the + full token sequence so far, return **up to** `num_tokens` proposed + token ids; a shorter (including empty) return is valid and the loop + (§4) must handle it as a smaller round, not an error. + - `on_accepted(accepted_len: int, rejected_len: int) -> None` -- hook + called after each verification round so a drafter holding its own KV + cache can roll it back; a no-op default for stateless drafters. +- **`DraftModelDrafter` (new class, `drafters.py`)** -- wraps a loaded + `deepseek-llm-7b-base` and its own `past_key_values`. `propose` runs + `num_tokens` steps of its own greedy decode (same pattern as + `generate_with_timings`'s inner loop, reused rather than + reimplemented). `on_accepted` calls `self.past_key_values.crop(rejected_len)`. +- **`PromptLookupDrafter` (new class, `drafters.py`)** -- no model, no + cache. `propose` scans `token_ids` for the longest suffix match of the + last `ngram_size` tokens against any earlier position in the sequence, + and returns whatever tokens followed that earlier match, up to + `num_tokens` -- fewer if the match occurred too close to the current + end of the sequence for `num_tokens` of them to exist, and an empty + proposal if no match is found at all (never padded). Both are real, + expected outcomes on non-repetitive prompts, not errors. `on_accepted` + is a no-op. +- **`speculative_generate()` (new function, `decode.py`)** -- the one + shared propose/verify/accept/rollback loop, parameterized by a + `Drafter`, mirroring the existing project pattern of one shared code + path for multiple backends (`patch_moe_infer_quantized` sharing + layer-iteration with `patch_moe_infer`). Writing this once means + neither drafter duplicates verification or cache-rollback logic. +- **CLI**: new `scripts/run_speculative_bench.py`, reusing `load_model`, + `resolve_quantized_backend`, `patch_moe_infer_quantized`, and + `benchmark/metrics.py` from existing code. A new script rather than a + flag on `run_baseline.py` because the decode loop itself is genuinely + different (propose/verify/rollback vs. one-token-at-a-time), not a + drop-in `GroupedMatmul` swap. + +## 4. Data flow + +1. **Setup (once per run):** load the target (`deepseek-moe-16b-base`, + patched via `patch_moe_infer_quantized`) and, for the draft-model + path, `deepseek-llm-7b-base`, both resident on one GPU. +2. **Each speculative round:** + a. `drafter.propose(token_ids, k)` returns up to `k` candidate tokens. + b. The target runs **one forward pass** over the candidate tokens + appended to the sequence (using its existing KV cache from prior + rounds), producing logits at every candidate position plus one + bonus position past the last candidate. + c. Compare the target's greedy argmax at each candidate position to + the drafter's proposed token; find the longest matching prefix + (`accepted_len`, `0 <= accepted_len <= k`). + d. Emit the `accepted_len` matched tokens, plus **one more token**: + the target's own argmax at the first mismatch position (or, if + all `k` were accepted, the bonus position's argmax). This is the + standard speculative-decoding guarantee that every round emits at + least one token even on a full rejection. + e. `target.past_key_values.crop(k - accepted_len)` discards the cache + entries for the rejected candidate positions; `drafter.on_accepted( + accepted_len, k - accepted_len)` does the same for any drafter + holding its own cache. + f. Repeat until `max_new_tokens` emitted or EOS. +3. **Non-speculative baseline (`--drafter none`):** the existing + `generate_with_timings` loop, run on the target alone, over the new + four-prompt set -- the reference this phase's speedup claims are + measured against. + +## 5. Correctness -- exact-match, not tolerance-based + +Because both the target and (for the draft-model path) the drafter +decode strictly greedily, and the acceptance rule is defined as "accept +iff it equals the target's own greedy argmax," speculative decoding's +output is a deterministic function of the target model alone -- it +**must** produce byte-identical tokens to plain sequential greedy +decoding of the same target. This is a tighter, cheaper-to-check bar +than Phase 3/4's top-k logit agreement (those needed tolerance because +cross-GPU/EP execution order isn't bit-exact; nothing here introduces +that kind of divergence). + +- **CPU contract tests, no GPU, no real models:** a toy model plus two + deterministic stub drafters, `AlwaysCorrectDrafter` (proposes exactly + what the toy model would produce) and `AlwaysWrongDrafter` (proposes + tokens guaranteed to mismatch), to force both the full-accept and + full-reject code paths without depending on a real model's actual + behavior. Assert: `speculative_generate()`'s output token-for-token + equals the existing plain greedy loop's output, for both stub drafters + and for `PromptLookupDrafter` against a constructed repeating token + sequence; after every round, the target's (and, for `DraftModelDrafter`, + the drafter's) cache sequence length equals the number of tokens + emitted so far -- catching a cache-rollback bug as a direct assertion + failure rather than a silent divergence, the same category of bug + Phase 5a's OOM was (a real invariant broken silently, not a crash). +- **GPU correctness gate (`gpu`-marked, paid):** both real drafters + against the real quantized target on rented hardware, exact + token-match vs. a same-session plain-greedy quantized-target run, at + all four prompts. A run producing even one diverging token fails the + gate and refuses to report a benchmark number -- "correctness before + speed" applied literally here, not by analogy. + +## 6. Testing + +Extends CLAUDE.md's existing testing table: + +| Layer | What's covered | +|---|---| +| Drafter contract (CPU) | `PromptLookupDrafter`: longest-suffix-match correctness on constructed sequences, empty proposal on no match, `ngram_size` edge cases (0, 1, longer than the sequence). `DraftModelDrafter`: cache-length bookkeeping with a stub model. | +| Speculative loop (CPU) | `speculative_generate()` byte-exact vs. plain greedy decode, for `AlwaysCorrectDrafter`/`AlwaysWrongDrafter`/`PromptLookupDrafter` against a toy model; cache-length invariant holds after every round; `k=0`/`k=1` edge cases. | +| End to end (`gpu`, paid) | Exact token-match, both drafters vs. plain-greedy quantized-target baseline, all 4 prompts -- the bar from §5. **Gates the benchmark**: a run with any mismatch refuses to report throughput, same discipline as every prior phase's correctness gate. | +| Benchmarks | Tokens/sec (wall-clock over *accepted* tokens -- the real user-facing rate) and acceptance rate (accepted/proposed) per prompt per backend, since prompt-lookup's payoff is expected to vary sharply across the 4 prompts. Every JSON carries drafter type, `k`, n-gram size, and target backend. A `k` sweep over {1, 2, 4, 8}, same shape as Phase 1's token-count sweep -- a speedup claim isn't quoted without knowing it holds across the relevant range. | + +## 7. Non-goals + +- **Sampling-based (non-greedy) speculative decoding.** This project's + harness has always been greedy-only; adding stochastic rejection + sampling now would be an unrelated scope expansion, not a Phase 5b + requirement. +- **Combining with multi-GPU EP (Phase 3/4).** Single GPU only, matching + the design doc's Phase 5 cost shape. A speculative + EP combination is + a plausible future extension, not required here. +- **Medusa-style multi-head self-speculation, or any third drafter type** + beyond the two decided in §2. +- **Beating vLLM/SGLang's own speculative-decoding numbers.** Phase 6 + owns that comparison; 5b's claim is limited to this project's own + kernel/harness, quantized-target-alone vs. +speculative, an internal + comparison like 5a's bf16-vs-int8 one. +- **A same-vocab draft model smaller than 7B.** Verified live (§2): none + exists standalone in the `deepseek-ai` namespace at this generation. + Not revisited unless a future phase finds one or accepts a + cross-tokenizer ("universal assisted generation"-style) approach. + +## 8. Risk, cost, and rollout + +- **Cost discipline is part of the result, not just a constraint.** $10 + is a ceiling; the gap between cap and actual spend gets reported the + way every prior phase has (5a: $1.95 of $5; Phase 3: $13.03 of $25; + Phase 4: $10.94 of $40). +- **Named risk: the 7B draft may not be meaningfully cheaper than the + target.** `deepseek-llm-7b-base` is dense 7B; the target activates + ~2.8B params/token as an MoE. Draft-model speculative decoding's net + speedup could be small or negative once the draft's own cost is + counted -- reported honestly either way, the same posture as Phase 1's + persistent-kernel null result. This is exactly why §2 scoped in + prompt-lookup as a second, model-free comparison point rather than + betting the whole phase on one drafter. +- **Named risk: prompt-lookup may show near-zero acceptance even with + the added 4th prompt**, if that prompt doesn't repeat as cleanly as + intended. Reported as-is; a workload-appropriateness finding, not a + failure to hide. +- **Named risk: two real models resident on one GPU is new territory** -- + no prior phase held two separate model loads at once. The GPU-session + runbook gets an explicit memory-budget checkpoint (measure resident + memory after each model load, before running anything) rather than + discovering pressure live the way Phase 5a's OOM was discovered. +- **GPU class**: L40 class (Phase 5a's tier), marketplace/spot, checked + live against real-time availability at rental time, same practice as + every prior phase. +- **Session order**: CPU-testable suite green (drafter contracts, the + speculative loop against stub drafters and a toy model) -> rent -> + load target, checkpoint memory -> load draft model, checkpoint memory + again -> GPU correctness gate (§5) -> only if that passes, the + four-prompt, two-drafter, `k`-swept benchmark -> tear down immediately. diff --git a/docs/findings/2026-09-14-phase-0-baseline-results.json b/docs/findings/phase-0/2026-09-14-phase-0-baseline-results.json similarity index 100% rename from docs/findings/2026-09-14-phase-0-baseline-results.json rename to docs/findings/phase-0/2026-09-14-phase-0-baseline-results.json diff --git a/docs/findings/2026-09-14-phase-0-baseline-run.md b/docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md similarity index 97% rename from docs/findings/2026-09-14-phase-0-baseline-run.md rename to docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md index 1739cc3..05ad98f 100644 --- a/docs/findings/2026-09-14-phase-0-baseline-run.md +++ b/docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md @@ -104,7 +104,7 @@ prompts x 5 repetitions, 64 max new tokens = 15 runs): | Mean tokens/sec | 12.75 | Full machine-readable record: -`docs/findings/2026-09-14-phase-0-baseline-results.json` (committed). +`docs/findings/phase-0/2026-09-14-phase-0-baseline-results.json` (committed). **The reference logits are not committed.** `.gitignore` excludes `*.safetensors` repo-wide, on purpose, from scaffold time: "GPU @@ -112,7 +112,7 @@ rental / benchmark run artifacts -- raw output belongs in `docs/findings/` as a written-up, measured result, not as a committed blob." An 11.9MB tensor blob is exactly what that rule means to keep out, and this finding doesn't override it. The file exists locally -(`docs/findings/2026-09-14-phase-0-baseline-reference.safetensors`, +(`docs/findings/phase-0/2026-09-14-phase-0-baseline-reference.safetensors`, produced by this run) but isn't tracked. Phase 1 gets it back by re-running `capture_reference_logits` against the same model and the same `DEFAULT_PROMPTS` in `scripts/run_baseline.py` -- reproducible by diff --git a/docs/findings/2026-09-15-phase-0-baseline-cost.md b/docs/findings/phase-0/2026-09-15-phase-0-baseline-cost.md similarity index 100% rename from docs/findings/2026-09-15-phase-0-baseline-cost.md rename to docs/findings/phase-0/2026-09-15-phase-0-baseline-cost.md diff --git a/docs/findings/2026-09-15-phase-1-grouped-gemm-cost.md b/docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-cost.md similarity index 100% rename from docs/findings/2026-09-15-phase-1-grouped-gemm-cost.md rename to docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-cost.md diff --git a/docs/findings/2026-09-15-phase-1-grouped-gemm-run.md b/docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md similarity index 99% rename from docs/findings/2026-09-15-phase-1-grouped-gemm-run.md rename to docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md index f142d50..3ca9140 100644 --- a/docs/findings/2026-09-15-phase-1-grouped-gemm-run.md +++ b/docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md @@ -146,7 +146,7 @@ Per token count, per backend, for both `zipf` (skewed toward low-index experts) and `uniform` routing: mean latency and TFLOP/s, for the whole routed MoE layer (`layer`, three grouped GEMMs) and the gate_proj-shaped grouped GEMM alone (`gemm`). Full data: -`docs/findings/2026-09-15-phase-1-kernel-bench-zipf.json` and +`docs/findings/phase-1/2026-09-15-phase-1-kernel-bench-zipf.json` and `-uniform.json` (each includes the run's full config -- GPU, torch/cuda/ triton versions, block sizes -- alongside every number). diff --git a/docs/findings/2026-09-15-phase-1-kernel-bench-uniform.json b/docs/findings/phase-1/2026-09-15-phase-1-kernel-bench-uniform.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-kernel-bench-uniform.json rename to docs/findings/phase-1/2026-09-15-phase-1-kernel-bench-uniform.json diff --git a/docs/findings/2026-09-15-phase-1-kernel-bench-zipf.json b/docs/findings/phase-1/2026-09-15-phase-1-kernel-bench-zipf.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-kernel-bench-zipf.json rename to docs/findings/phase-1/2026-09-15-phase-1-kernel-bench-zipf.json diff --git a/docs/findings/2026-09-15-phase-1-kernel-correctness-cost.md b/docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness-cost.md similarity index 100% rename from docs/findings/2026-09-15-phase-1-kernel-correctness-cost.md rename to docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness-cost.md diff --git a/docs/findings/2026-09-15-phase-1-kernel-correctness.md b/docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness.md similarity index 98% rename from docs/findings/2026-09-15-phase-1-kernel-correctness.md rename to docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness.md index ad39ac3..f3a080a 100644 --- a/docs/findings/2026-09-15-phase-1-kernel-correctness.md +++ b/docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness.md @@ -92,7 +92,7 @@ suite can fail, and the fix restores it. $0.0606 for 991 seconds (16.5 minutes) of RTX 3090 rental at $0.22/hr -- well under the $3 cap. Full record: -`docs/findings/2026-09-15-phase-1-kernel-correctness-cost.md`. Duration +`docs/findings/phase-1/2026-09-15-phase-1-kernel-correctness-cost.md`. Duration provenance: measured directly as `datetime.now(timezone.utc) - ` (start: `2026-09-15T15:43:56Z`, from the create-pod response's `startedAt` field) immediately before invoking diff --git a/docs/findings/2026-09-15-phase-1-naive-results.json b/docs/findings/phase-1/2026-09-15-phase-1-naive-results.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-naive-results.json rename to docs/findings/phase-1/2026-09-15-phase-1-naive-results.json diff --git a/docs/findings/2026-09-15-phase-1-persistent-results.json b/docs/findings/phase-1/2026-09-15-phase-1-persistent-results.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-persistent-results.json rename to docs/findings/phase-1/2026-09-15-phase-1-persistent-results.json diff --git a/docs/findings/2026-09-15-phase-1-stock-results.json b/docs/findings/phase-1/2026-09-15-phase-1-stock-results.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-stock-results.json rename to docs/findings/phase-1/2026-09-15-phase-1-stock-results.json diff --git a/docs/findings/2026-09-15-phase-1-torch-results.json b/docs/findings/phase-1/2026-09-15-phase-1-torch-results.json similarity index 100% rename from docs/findings/2026-09-15-phase-1-torch-results.json rename to docs/findings/phase-1/2026-09-15-phase-1-torch-results.json diff --git a/docs/findings/2026-09-15-phase-2-tuned-uniform.json b/docs/findings/phase-2/2026-09-15-phase-2-tuned-uniform.json similarity index 100% rename from docs/findings/2026-09-15-phase-2-tuned-uniform.json rename to docs/findings/phase-2/2026-09-15-phase-2-tuned-uniform.json diff --git a/docs/findings/2026-09-15-phase-2-tuned-zipf.json b/docs/findings/phase-2/2026-09-15-phase-2-tuned-zipf.json similarity index 100% rename from docs/findings/2026-09-15-phase-2-tuned-zipf.json rename to docs/findings/phase-2/2026-09-15-phase-2-tuned-zipf.json diff --git a/docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md b/docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md similarity index 97% rename from docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md rename to docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md index c995bea..6fdaf91 100644 --- a/docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md +++ b/docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md @@ -68,12 +68,12 @@ divergence is in `GROUP_SIZE_M` and `num_stages`, not a wholesale different tile shape, and is reported at exactly that strength rather than oversold. -Raw output: `docs/findings/2026-09-15-phase-2-tuned-uniform.json`, -`docs/findings/2026-09-15-phase-2-tuned-zipf.json`. +Raw output: `docs/findings/phase-2/2026-09-15-phase-2-tuned-uniform.json`, +`docs/findings/phase-2/2026-09-15-phase-2-tuned-zipf.json`. ## Cost and session account -Full record: `docs/findings/2026-09-16-phase-2-vllm-benchmark-cost.md` +Full record: `docs/findings/phase-2/2026-09-16-phase-2-vllm-benchmark-cost.md` (via `scripts/gpu/provision.py`'s `write_cost_record`, reused unmodified from Phase 1). diff --git a/docs/findings/2026-09-16-phase-2-vllm-benchmark-cost.md b/docs/findings/phase-2/2026-09-16-phase-2-vllm-benchmark-cost.md similarity index 100% rename from docs/findings/2026-09-16-phase-2-vllm-benchmark-cost.md rename to docs/findings/phase-2/2026-09-16-phase-2-vllm-benchmark-cost.md diff --git a/docs/findings/2026-09-16-phase-3-correctness-gate-results.json b/docs/findings/phase-3/2026-09-16-phase-3-correctness-gate-results.json similarity index 100% rename from docs/findings/2026-09-16-phase-3-correctness-gate-results.json rename to docs/findings/phase-3/2026-09-16-phase-3-correctness-gate-results.json diff --git a/docs/findings/2026-09-16-phase-3-multi-gpu-ep-cost.md b/docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-cost.md similarity index 100% rename from docs/findings/2026-09-16-phase-3-multi-gpu-ep-cost.md rename to docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-cost.md diff --git a/docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md b/docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md similarity index 98% rename from docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md rename to docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md index 6e39734..60c0dc9 100644 --- a/docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md +++ b/docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md @@ -115,7 +115,7 @@ The `max_abs_diff` values (0.68-1.22) are the same order of magnitude as Phase 1's own control-row numbers (0.66-1.90, from an eager reformulation with no kernel or cross-GPU transport involved at all), i.e. ordinary bf16 summation-order noise, not evidence of a correctness problem. -Full data: `docs/findings/2026-09-16-phase-3-correctness-gate-results.json`. +Full data: `docs/findings/phase-3/2026-09-16-phase-3-correctness-gate-results.json`. ## The measured answer to Phase 3's thesis @@ -220,4 +220,4 @@ single-request scale this project has scoped to throughout. **Total: $13.03** of the $25 cap, 2x H200 SXM at $9.18/hr, 85 minutes (pod creation to termination). Full record: -`docs/findings/2026-09-16-phase-3-multi-gpu-ep-cost.md`. +`docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-cost.md`. diff --git a/docs/findings/2026-09-16-phase-3-real-scale-h200-kernel-bench.json b/docs/findings/phase-3/2026-09-16-phase-3-real-scale-h200-kernel-bench.json similarity index 100% rename from docs/findings/2026-09-16-phase-3-real-scale-h200-kernel-bench.json rename to docs/findings/phase-3/2026-09-16-phase-3-real-scale-h200-kernel-bench.json diff --git a/docs/findings/2026-09-16-phase-3-single-gpu-h200-kernel-bench.json b/docs/findings/phase-3/2026-09-16-phase-3-single-gpu-h200-kernel-bench.json similarity index 100% rename from docs/findings/2026-09-16-phase-3-single-gpu-h200-kernel-bench.json rename to docs/findings/phase-3/2026-09-16-phase-3-single-gpu-h200-kernel-bench.json diff --git a/docs/findings/2026-09-16-phase-4-colocated-c4.json b/docs/findings/phase-4/2026-09-16-phase-4-colocated-c4.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-colocated-c4.json rename to docs/findings/phase-4/2026-09-16-phase-4-colocated-c4.json diff --git a/docs/findings/2026-09-16-phase-4-colocated-c8.json b/docs/findings/phase-4/2026-09-16-phase-4-colocated-c8.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-colocated-c8.json rename to docs/findings/phase-4/2026-09-16-phase-4-colocated-c8.json diff --git a/docs/findings/2026-09-16-phase-4-colocated-gate-results.json b/docs/findings/phase-4/2026-09-16-phase-4-colocated-gate-results.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-colocated-gate-results.json rename to docs/findings/phase-4/2026-09-16-phase-4-colocated-gate-results.json diff --git a/docs/findings/2026-09-16-phase-4-disaggregated-decode-c4.json b/docs/findings/phase-4/2026-09-16-phase-4-disaggregated-decode-c4.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-disaggregated-decode-c4.json rename to docs/findings/phase-4/2026-09-16-phase-4-disaggregated-decode-c4.json diff --git a/docs/findings/2026-09-16-phase-4-disaggregated-decode-c8.json b/docs/findings/phase-4/2026-09-16-phase-4-disaggregated-decode-c8.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-disaggregated-decode-c8.json rename to docs/findings/phase-4/2026-09-16-phase-4-disaggregated-decode-c8.json diff --git a/docs/findings/2026-09-16-phase-4-disaggregated-gate-results.json b/docs/findings/phase-4/2026-09-16-phase-4-disaggregated-gate-results.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-disaggregated-gate-results.json rename to docs/findings/phase-4/2026-09-16-phase-4-disaggregated-gate-results.json diff --git a/docs/findings/2026-09-16-phase-4-disaggregated-prefill-c4.json b/docs/findings/phase-4/2026-09-16-phase-4-disaggregated-prefill-c4.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-disaggregated-prefill-c4.json rename to docs/findings/phase-4/2026-09-16-phase-4-disaggregated-prefill-c4.json diff --git a/docs/findings/2026-09-16-phase-4-disaggregated-prefill-c8.json b/docs/findings/phase-4/2026-09-16-phase-4-disaggregated-prefill-c8.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-disaggregated-prefill-c8.json rename to docs/findings/phase-4/2026-09-16-phase-4-disaggregated-prefill-c8.json diff --git a/docs/findings/2026-09-16-phase-4-single-gpu-reference.json b/docs/findings/phase-4/2026-09-16-phase-4-single-gpu-reference.json similarity index 100% rename from docs/findings/2026-09-16-phase-4-single-gpu-reference.json rename to docs/findings/phase-4/2026-09-16-phase-4-single-gpu-reference.json diff --git a/docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md b/docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md similarity index 100% rename from docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md rename to docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md diff --git a/docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md b/docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md similarity index 98% rename from docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md rename to docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md index 73449ef..aaf49ad 100644 --- a/docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md +++ b/docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md @@ -98,7 +98,7 @@ tokens each, `max_new_tokens=16`: | "def fibonacci(n):" | `[185, 300, 565, 291, 2318, 207, 15, 25, 185, 391, 972, 207, 15, 185, 300, 23744]` | **exact match** | **exact match** | Both configurations matched on every token, every prompt. Full data: -`docs/findings/2026-09-16-phase-4-single-gpu-reference.json`, +`docs/findings/phase-4/2026-09-16-phase-4-single-gpu-reference.json`, `2026-09-16-phase-4-colocated-gate-results.json`, `2026-09-16-phase-4-disaggregated-gate-results.json`. @@ -198,4 +198,4 @@ valuable, and now-complete part) took priority, and once both gates passed and one real concurrency reading was in hand per configuration, the session stopped rather than spending further budget chasing a clean answer to a question the data itself flagged as confounded. Full record: -`docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md`. +`docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md`. diff --git a/docs/findings/2026-09-17-phase-5a-community-attempt1-cost.md b/docs/findings/phase-5a/2026-09-17-phase-5a-community-attempt1-cost.md similarity index 100% rename from docs/findings/2026-09-17-phase-5a-community-attempt1-cost.md rename to docs/findings/phase-5a/2026-09-17-phase-5a-community-attempt1-cost.md diff --git a/docs/findings/2026-09-17-phase-5a-community-attempt2-cost.md b/docs/findings/phase-5a/2026-09-17-phase-5a-community-attempt2-cost.md similarity index 100% rename from docs/findings/2026-09-17-phase-5a-community-attempt2-cost.md rename to docs/findings/phase-5a/2026-09-17-phase-5a-community-attempt2-cost.md diff --git a/docs/findings/2026-09-17-phase-5a-memory-footprint.json b/docs/findings/phase-5a/2026-09-17-phase-5a-memory-footprint.json similarity index 100% rename from docs/findings/2026-09-17-phase-5a-memory-footprint.json rename to docs/findings/phase-5a/2026-09-17-phase-5a-memory-footprint.json diff --git a/docs/findings/2026-09-17-phase-5a-naive-results.json b/docs/findings/phase-5a/2026-09-17-phase-5a-naive-results.json similarity index 100% rename from docs/findings/2026-09-17-phase-5a-naive-results.json rename to docs/findings/phase-5a/2026-09-17-phase-5a-naive-results.json diff --git a/docs/findings/2026-09-17-phase-5a-quantization-cost.md b/docs/findings/phase-5a/2026-09-17-phase-5a-quantization-cost.md similarity index 100% rename from docs/findings/2026-09-17-phase-5a-quantization-cost.md rename to docs/findings/phase-5a/2026-09-17-phase-5a-quantization-cost.md diff --git a/docs/findings/2026-09-17-phase-5a-quantization-run.md b/docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md similarity index 99% rename from docs/findings/2026-09-17-phase-5a-quantization-run.md rename to docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md index e609d14..29b8d62 100644 --- a/docs/findings/2026-09-17-phase-5a-quantization-run.md +++ b/docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md @@ -273,6 +273,6 @@ downgraded to match the expectation. Against the **$5 cap**: **39.1% used**, all three pods (including both abandoned attempts) included. Full records: -`docs/findings/2026-09-17-phase-5a-community-attempt1-cost.md`, +`docs/findings/phase-5a/2026-09-17-phase-5a-community-attempt1-cost.md`, `2026-09-17-phase-5a-community-attempt2-cost.md`, `2026-09-17-phase-5a-quantization-cost.md`. diff --git a/docs/findings/2026-09-17-phase-5a-quantized-results.json b/docs/findings/phase-5a/2026-09-17-phase-5a-quantized-results.json similarity index 100% rename from docs/findings/2026-09-17-phase-5a-quantized-results.json rename to docs/findings/phase-5a/2026-09-17-phase-5a-quantized-results.json diff --git a/docs/findings/2026-09-17-phase-5a-stock-results.json b/docs/findings/phase-5a/2026-09-17-phase-5a-stock-results.json similarity index 100% rename from docs/findings/2026-09-17-phase-5a-stock-results.json rename to docs/findings/phase-5a/2026-09-17-phase-5a-stock-results.json diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-results.json new file mode 100644 index 0000000..eccea08 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-baseline-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "none", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.0, + "run_count": 20, + "mean_ttft": 0.09256331396754831, + "p50_ttft": 0.046051367186009884, + "p99_ttft": 0.7893607321707521, + "mean_inter_token_latency": 0.045165582288367055, + "mean_tokens_per_second": 21.896991966189567, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-results.json new file mode 100644 index 0000000..d22027e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-draft-model-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.9190647482014388, + "run_count": 20, + "mean_ttft": 0.23304178123362362, + "p50_ttft": 0.17235912568867207, + "p99_ttft": 1.1114812216442063, + "mean_inter_token_latency": 0.03582091104047048, + "mean_tokens_per_second": 26.667671579499903, + "token_match": { + "prompt_000_tokens": true, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-generated-tokens.json new file mode 100644 index 0000000..5a73f5e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 690, + 13, + 185, + 24962, + 13, + 185, + 16, + 13, + 185, + 16, + 13, + 207, + 14166, + 398, + 17, + 15, + 207, + 16, + 13, + 207, + 16, + 15, + 13, + 207, + 17, + 13, + 207, + 16, + 15, + 13, + 207, + 16, + 13, + 207, + 14594, + 992, + 13, + 207, + 14594, + 13, + 207, + 16, + 13, + 207, + 16, + 13, + 207, + 17, + 15, + 13, + 207, + 14594, + 13, + 207, + 14594, + 992, + 13, + 207, + 14594, + 13, + 207, + 16, + 13, + 207 + ], + "prompt_001_tokens": [ + 11, + 15, + 15, + 25, + 185, + 1158, + 185, + 15, + 15, + 16, + 15, + 15, + 15, + 16, + 15, + 16, + 15, + 15, + 15, + 15, + 13, + 207, + 15, + 15, + 15, + 13, + 20, + 15, + 13, + 15, + 13, + 15, + 15, + 15, + 15, + 13, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15 + ], + "prompt_002_tokens": [ + 185, + 300, + 2051, + 207, + 15, + 8, + 185, + 2, + 8, + 185, + 2, + 8, + 185, + 300, + 207, + 16, + 11, + 14166, + 10043, + 2106, + 207, + 11677, + 11, + 14166, + 10043, + 3586, + 207, + 14166, + 10043, + 10043, + 10043, + 3586, + 4477, + 8, + 185, + 14166, + 10043, + 3586, + 185, + 14166, + 617, + 19304, + 14166, + 10043, + 2106, + 10043, + 207, + 14166, + 10043, + 2106, + 207, + 14166, + 10043, + 19304, + 10043, + 19304, + 10043, + 3586, + 3889, + 10043, + 207, + 14166, + 10043, + 2106 + ], + "prompt_003_tokens": [ + 82, + 11, + 16, + 15, + 16, + 24, + 23, + 15, + 24, + 12, + 21, + 19, + 20, + 15, + 15, + 24, + 24, + 24, + 12, + 16, + 15, + 24, + 24, + 24, + 24, + 24, + 24, + 23, + 12, + 16, + 24, + 23, + 6, + 185, + 24, + 12, + 16, + 15, + 15, + 15, + 15, + 12, + 16, + 15, + 24, + 24, + 24, + 12, + 16, + 15, + 12, + 16, + 15, + 15, + 15, + 12, + 16, + 12, + 16, + 24, + 12, + 16, + 15, + 12 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-results.json new file mode 100644 index 0000000..ba00367 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-1-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 1, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.6572164948453608, + "run_count": 20, + "mean_ttft": 0.14014532130677254, + "p50_ttft": 0.09058768395334482, + "p99_ttft": 0.8852608127659174, + "mean_inter_token_latency": 0.05500998426051367, + "mean_tokens_per_second": 18.177592421378236, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-generated-tokens.json new file mode 100644 index 0000000..79b6824 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 185, + 17913, + 13, + 185, + 271, + 13, + 207, + 16, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 425, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215, + 13, + 8215 + ], + "prompt_001_tokens": [ + 11, + 21, + 68, + 11, + 19, + 20, + 14, + 16, + 15, + 14, + 66, + 15800, + 11, + 21, + 11, + 21, + 15, + 15, + 15, + 15, + 15, + 14, + 1106, + 14, + 15, + 14, + 69, + 14, + 16, + 11, + 21, + 1106, + 11, + 21, + 11, + 21, + 15, + 14, + 60, + 185, + 556, + 11, + 21, + 11, + 21, + 1106, + 12, + 1106, + 12, + 16, + 15800, + 11, + 16, + 11, + 21, + 11, + 21, + 14, + 16, + 14, + 23, + 15, + 14, + 6 + ], + "prompt_002_tokens": [ + 185, + 19, + 13, + 185, + 16, + 15, + 13, + 16, + 15, + 13, + 16, + 15, + 13, + 15, + 13, + 15, + 15, + 15, + 15, + 13, + 15, + 15, + 13, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 13, + 15, + 15, + 15, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 13, + 15, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15 + ], + "prompt_003_tokens": [ + 185, + 15, + 16, + 15, + 13, + 185, + 15, + 13, + 185, + 17, + 15, + 15, + 15, + 15, + 13, + 185, + 15, + 13, + 15, + 13, + 185, + 15, + 11, + 15, + 15, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 15, + 13, + 15, + 13, + 15, + 15, + 13, + 15, + 13, + 15, + 15, + 13, + 15, + 15, + 13, + 15, + 13, + 15, + 15, + 13, + 15, + 13, + 15, + 13, + 15, + 15, + 15, + 15, + 15, + 13, + 15, + 15 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-results.json new file mode 100644 index 0000000..55efe53 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-2-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 2, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.5069984447900466, + "run_count": 20, + "mean_ttft": 0.16568398394156247, + "p50_ttft": 0.11523720528930426, + "p99_ttft": 0.9190490007633331, + "mean_inter_token_latency": 0.05720370411577206, + "mean_tokens_per_second": 17.81416566531103, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-results.json new file mode 100644 index 0000000..f697e84 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-4-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.9190647482014388, + "run_count": 20, + "mean_ttft": 0.20972633350174874, + "p50_ttft": 0.16033382643945515, + "p99_ttft": 0.9563033440848799, + "mean_inter_token_latency": 0.03281804587292884, + "mean_tokens_per_second": 29.150236748704458, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-results.json new file mode 100644 index 0000000..f80ef9d --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-draft-model-8-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 8, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.8028571428571428, + "run_count": 20, + "mean_ttft": 0.3042797455564141, + "p50_ttft": 0.2505626210477203, + "p99_ttft": 1.1248014212865367, + "mean_inter_token_latency": 0.028998252929424838, + "mean_tokens_per_second": 32.252192227201526, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-generated-tokens.json new file mode 100644 index 0000000..fae5ff7 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 690, + 185, + 39319, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185, + 40, + 6, + 82, + 99711, + 13, + 690, + 185 + ], + "prompt_001_tokens": [ + 11, + 40, + 6, + 185, + 39561, + 78134, + 11, + 40, + 6, + 185, + 39561, + 78134, + 11, + 39561, + 78134, + 11, + 39561, + 78134, + 246, + 254, + 398, + 39561, + 78134, + 246, + 254, + 207, + 16, + 24, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 11, + 15, + 11, + 15, + 11, + 15, + 11 + ], + "prompt_002_tokens": [ + 185, + 300, + 972, + 12577, + 7, + 77, + 8, + 185, + 300, + 972, + 12577, + 7, + 77, + 8, + 185, + 300, + 972, + 12577, + 7, + 77, + 185, + 300, + 972, + 12577, + 7, + 77, + 185, + 300, + 185, + 300, + 972, + 12577, + 86007, + 7, + 77, + 185, + 300, + 185, + 300, + 972, + 12577, + 86007, + 7, + 77, + 185, + 300, + 972, + 12577, + 86007, + 7, + 77, + 12, + 16, + 11, + 185, + 300, + 972, + 12577, + 86007, + 7, + 77, + 12, + 16, + 11 + ], + "prompt_003_tokens": [ + 185, + 16, + 15, + 15, + 13, + 185, + 16, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-results.json new file mode 100644 index 0000000..9f4783a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-1-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 1, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.5478468899521531, + "run_count": 20, + "mean_ttft": 0.09545221205335111, + "p50_ttft": 0.04515274195000529, + "p99_ttft": 0.8436519743734959, + "mean_inter_token_latency": 0.029317955481302407, + "mean_tokens_per_second": 33.749012750299684, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-generated-tokens.json new file mode 100644 index 0000000..b1b92d7 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 690, + 185, + 11946, + 371, + 13, + 690, + 185, + 11946, + 371, + 13, + 690, + 185, + 11946, + 371, + 13, + 690, + 14, + 207, + 3371, + 13, + 690, + 14, + 207, + 3371, + 13, + 690, + 14, + 207, + 891, + 207, + 891, + 207, + 891, + 207, + 1294, + 371, + 13, + 690, + 14, + 17, + 15, + 13, + 690, + 14, + 17, + 15, + 13, + 690, + 14, + 17, + 15, + 13, + 690, + 14, + 17, + 15, + 15, + 13, + 690, + 14, + 17, + 15, + 15, + 15 + ], + "prompt_001_tokens": [ + 11, + 185, + 16459, + 401, + 554, + 794, + 78, + 11, + 16459, + 401, + 554, + 11, + 255, + 11, + 255, + 11, + 185, + 185, + 4885, + 185, + 4885, + 4885, + 185, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 8, + 185, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 4885, + 8 + ], + "prompt_002_tokens": [ + 185, + 300, + 972, + 12577, + 7, + 77, + 8, + 185, + 300, + 972, + 12577, + 7, + 77, + 12, + 207, + 16, + 185, + 300, + 972, + 12577, + 7, + 77, + 12, + 16, + 185, + 300, + 972, + 12577, + 7, + 77, + 12, + 16, + 185, + 300, + 972, + 12577, + 7, + 77, + 12, + 16, + 185, + 300, + 565, + 291, + 12, + 16, + 185, + 300, + 565, + 291, + 12, + 16, + 185, + 300, + 565, + 291, + 12, + 207, + 16, + 185, + 300, + 565, + 291, + 12 + ], + "prompt_003_tokens": [ + 185, + 16, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15, + 15 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-results.json new file mode 100644 index 0000000..134184e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-2-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 2, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.3187022900763359, + "run_count": 20, + "mean_ttft": 0.09264373276382684, + "p50_ttft": 0.04591749794781208, + "p99_ttft": 0.7945801347028452, + "mean_inter_token_latency": 0.027406496960403665, + "mean_tokens_per_second": 35.86093540935053, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-results.json new file mode 100644 index 0000000..e3bc596 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-4-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.22058823529411764, + "run_count": 20, + "mean_ttft": 0.09030726340133696, + "p50_ttft": 0.04442054405808449, + "p99_ttft": 0.7778740918310348, + "mean_inter_token_latency": 0.024027995951473712, + "mean_tokens_per_second": 40.384515311276445, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-results.json new file mode 100644 index 0000000..f3bbae2 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-k-sweep-prompt-lookup-8-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 8, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.11029411764705882, + "run_count": 20, + "mean_ttft": 0.09664721006993204, + "p50_ttft": 0.04695437289774418, + "p99_ttft": 0.8481054064072656, + "mean_inter_token_latency": 0.02510109237018263, + "mean_tokens_per_second": 38.66137717251596, + "token_match": {} +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-memory-checkpoints.json b/docs/findings/phase-5b/2026-09-17-phase-5b-memory-checkpoints.json new file mode 100644 index 0000000..ff90c0e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-memory-checkpoints.json @@ -0,0 +1,10 @@ +{ + "after": "quantized target only", + "moe_layers_patched": 27, + "allocated_gb": 16.75, + "reserved_gb": 17.49 +}{ + "after": "quantized target + bf16 draft model", + "allocated_gb": 29.62, + "reserved_gb": 29.86 +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-generated-tokens.json b/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-generated-tokens.json new file mode 100644 index 0000000..4d3dd3a --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-generated-tokens.json @@ -0,0 +1,266 @@ +{ + "prompt_000_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_001_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_002_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "prompt_003_tokens": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-results.json b/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-results.json new file mode 100644 index 0000000..252404e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-prompt-lookup-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.22058823529411764, + "run_count": 20, + "mean_ttft": 0.0975503115914762, + "p50_ttft": 0.0472443220205605, + "p99_ttft": 0.8542437635222437, + "mean_inter_token_latency": 0.02505076747675914, + "mean_tokens_per_second": 38.73262061782512, + "token_match": { + "prompt_000_tokens": true, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} \ No newline at end of file diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-cost.md b/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-cost.md new file mode 100644 index 0000000..fada6fe --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-cost.md @@ -0,0 +1,7 @@ +# phase-5b-speculative-decoding -- GPU rental cost + +- Pod: `2t6wh9l3okl3gj` (NVIDIA L40) +- Rate: $0.8200/hr +- Duration: 3000s (0.833hr) +- Cost: $0.6833 +- Note: Phase 5b speculative decoding: correctness gates (draft-model + prompt-lookup, both byte-exact vs baseline) + measured run + k-sweep (k=1,2,4,8 x 2 drafters, full 4-prompt suite each -- CLI has no per-prompt filter flag, so ran full suite rather than write new code mid-session). Pod confirmed terminated (404 on get-pod after delete-pod returned 204). diff --git a/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-run.md b/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-run.md new file mode 100644 index 0000000..1f08139 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-run.md @@ -0,0 +1,414 @@ +# Phase 5b speculative decoding run -- findings + +Design: `docs/design/2026-09-17-phase-5b-speculative-decoding.md`. Plan: +`docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md`. Branch +`phase-5b-speculative-decoding`. Runbook: +`docs/runbooks/phase-5b-speculative-decoding.md` (full account of the +session, including the five environment breaks below). + +## Correction (final whole-branch review): the GPU session's throughput and correctness-gate numbers below are WITHDRAWN + +This doc originally reported both correctness gates as passed and +carried the session's throughput numbers as this phase's headline +result. The phase's final whole-branch review found that neither claim +holds up, for a structural reason, not a measurement fluke. Stated +plainly, the way this project's own practice ("never quote a benchmark +number that wasn't measured," "a kernel that's fast because it's +silently wrong is a bug, not a result") demands: + +- **The session's own baseline (`--drafter none`) generated a degenerate + output**: 64 repetitions of token id `0`, for all 4 test prompts. This + is not a plausible real continuation of "The quick brown fox jumps over + the lazy dog.", "def fibonacci(n):", or either of the other two + prompts -- something in the chain produced garbage. +- **Both correctness gates "passed" only because they matched this same + degenerate baseline** at k=4 and k=8, where the drafters also produced + all-zeros. Two broken outputs agreeing with each other proves nothing + about correctness -- the "GPU correctness gates: both passed" section + below documents a vacuous pass, not a real one. +- **The k=1/k=2 sweep runs -- the only runs this session produced + plausible, varied, non-degenerate tokens on -- were never checked + against the baseline at all.** The runbook's own k-sweep design + (step 12) explicitly skipped `--compare-generated-tokens` at every k + but 4, reasoning that correctness doesn't depend on k. That reasoning + is correct in principle but irrelevant here: it assumes the k=4 gate + was a real correctness check, and it wasn't. The actual scope of how + far the k=1/k=2 outputs diverge from true plain-greedy decoding is + unknown. +- **Why the gate could not have caught this even in principle**: the + baseline was generated by `speculative_generate` -> `run_speculative_rounds` + itself, called with `drafter=None` -- the exact same shared + propose/verify/accept/rollback loop the gate exists to check. A gate + that compares code against itself can never catch a bug that lives in + that shared code, which is exactly where this branch's own real + EOS-boundary bug lived earlier in development (fixed in commit + `8c0c883`). This is finding C2 of the final review. + +**Therefore: the throughput numbers (+21.8% for draft-model, +76.9% for +prompt-lookup), every cost-per-1M-token figure derived from them, and +every k-sweep number below are WITHDRAWN.** They are not this phase's +headline result. The tables are kept below, relabeled, for the historical +record and because the *environment* and *memory* findings alongside them +are still accurate -- but none of the throughput/acceptance numbers +should be cited or reused until a future session re-measures them +honestly. + +**What now backs the algorithm's own correctness** (not the GPU session): +extensive hand-verified CPU-only tests in `test_decode.py` and +`test_drafters.py` (toy models with closed-form expected continuations, +still all passing); a genuinely independent CPU-level oracle test added +by the fix wave that produced this correction +(`plain_greedy_generate` in `src/dispatch/speculative/decode.py`, sharing +no code with `run_speculative_rounds`, so it can actually catch a bug in +the shared loop rather than agreeing with it); and a new real-model, +real-tokenizer, real-`transformers.Cache` end-to-end test of +`DraftModelDrafter` running inside `run_speculative_rounds` +(`test_speculative_generate_with_draft_model_drafter_matches_plain_greedy_decode` +in `test_decode.py`), closing a gap where that exact combination had +never been exercised outside the now-discredited GPU gate. All of this +is CPU-only, deterministic, and reproducible without spending GPU money. + +**What would be needed for a trustworthy GPU-measured result**, none of +which is this fix wave's job to do: + +1. The oracle-independence fix itself -- **done**, described above. +2. **Root-causing why the GPU session's baseline degenerated in the first + place -- NOT done.** This needs a new rented GPU session (real money) + and is explicitly out of scope for a docs/code review-fix pass; it is + a decision for the project owner, not something to guess at from a + laptop. (A real bug in the shared verify/accept/reject loop is one + candidate explanation, given C2 above means it was never actually + ruled out -- but that is a hypothesis, not a finding.) +3. **A re-run with `--compare-generated-tokens` checked at every k-sweep + point**, not just the k=4 gate (finding I5 of the final review) -- + also not done here. + +Everything below this point is the original run account, with the +sections that reported now-withdrawn numbers relabeled accordingly. The +environment, memory-checkpoint, and cost-tracking sections are unaffected +by this correction -- they describe real dollars spent and real memory +allocated, not token correctness, and remain accurate as originally +written. + +## Infrastructure + +Pod `2t6wh9l3okl3gj`, NVIDIA L40, RunPod Secure Cloud, $0.8200/hr, 3000s +(0.833hr) rented. Confirmed terminated after the session (404 on +`get-pod` following `delete-pod`'s 204). Full record: +`docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-cost.md`. + +Config held constant across every run below unless noted: model +`deepseek-ai/deepseek-moe-16b-base`, quantized (int8) target kernel +(Phase 5a's `patch_moe_infer_quantized`), `bfloat16`, single L40, eager +unbatched decode, the 4-prompt `SPECULATIVE_PROMPTS` list +(`scripts/run_speculative_bench.py`: the 3 `DEFAULT_PROMPTS` -- "The +quick brown fox jumps over the lazy dog.", "In a distant galaxy, a small +crew of explorers", "def fibonacci(n):" -- plus a 4th, added for this +phase, "Please restate the following sentence exactly twice, back to +back: 'The system will now process each incoming request in the order +it was received.'"), 20 runs per configuration, 64 max new tokens, +`prompt_lookup_ngram_size=3`. Draft model where applicable: +`deepseek-ai/deepseek-llm-7b-base`, `bfloat16`. + +## Environment + +This session hit five distinct breaks loading DeepSeek's own remote code +under `transformers==5.17.0` (this repo's pinned floor), all from the +remote code targeting an older `transformers` API surface: +`is_torch_fx_available` removed, `DynamicCache.get_usable_length` +renamed, `DynamicCache.from_legacy_cache`/`to_legacy_cache` both removed, +and `rope_scaling` auto-populated into a dict missing the old `"type"` +key. Unlike Phases 0/1/5a, this session deliberately did **not** +downgrade `transformers` (the old `transformers==4.57.6` fix) -- this +project's own pinned `transformers>=5.17.0` is load-bearing for +`Cache.crop()`'s negative-number semantics that Tasks 2-3's drafters +depend on, and downgrading would have silently broken that. All five +fixes are a pod-local, never-committed monkeypatch, folded into the +runbook. Full account in the runbook; not repeated here. + +## GPU correctness gates: WITHDRAWN -- a vacuous pass, not a real one + +**See the correction at the top of this doc.** This table is kept for +the record, not as evidence of correctness. `token_match: true` here +means "matched a same-session plain-greedy baseline byte-for-byte" -- +true, but that baseline was itself the degenerate 64-zeros output +described above, generated by the same shared loop these gates exist to +check. Two broken outputs agreeing is not a correctness result. + +| Drafter | `moe_layers_patched` | `token_match` (4 prompts) | +|---|---|---| +| draft-model | 27 | all `true` (against a degenerate baseline -- see correction above) | +| prompt-lookup | 27 | all `true` (against a degenerate baseline -- see correction above) | + +Source: `2026-09-17-phase-5b-draft-model-results.json`, +`2026-09-17-phase-5b-prompt-lookup-results.json`. `moe_layers_patched` +is also `27` in every one of the other 9 results JSONs from this session +(baseline plus all 8 k-sweep runs) -- the quantized target's full expert +set was patched in every single invocation, not just the two gate runs. +(`moe_layers_patched` itself is not in question -- it reflects how many +layers were patched, not what they produced.) + +## Measured run (WITHDRAWN -- numbers unreliable, kept for the record only): baseline, draft-model, prompt-lookup (k=4, the default) + +**See the correction at the top of this doc -- none of the numbers below +should be cited.** All three read directly from their own results JSON +(`mean_tokens_per_second`, `acceptance_rate`, `mean_ttft`), same config as +stated above: + +| Config | Acceptance rate | Tokens/sec | Mean TTFT | p50 TTFT | p99 TTFT | vs. baseline throughput | +|---|---|---|---|---|---|---| +| baseline (`--drafter none`) | 0.0% | 21.897 | 0.0926s | 0.0461s | 0.7894s | -- | +| draft-model (7B) | 91.91% | 26.668 | 0.2330s | 0.1724s | 1.1115s | **+21.8%** | +| prompt-lookup | 22.06% | 38.733 | 0.0976s | 0.0472s | 0.8542s | **+76.9%** | + +Sources: `2026-09-17-phase-5b-baseline-results.json`, +`2026-09-17-phase-5b-draft-model-results.json`, +`2026-09-17-phase-5b-prompt-lookup-results.json`. + +**The paragraph below was this doc's original headline claim. It is kept +verbatim, for the record, but it rests entirely on the withdrawn numbers +above and should not be treated as established:** "the non-obvious +result: the drafter with much lower acceptance wins on raw throughput. +Draft-model accepts 91.9% of its proposed tokens -- over 4x +prompt-lookup's 22.1% -- yet prompt-lookup is faster in wall-clock terms +(+76.9% vs. +21.8% over baseline) because it costs nothing to propose (no +second model, no extra forward pass) while draft-model pays a real 7B +dense forward pass per round regardless of how often its proposal is +right." Whether this pattern holds at all once a non-degenerate baseline +is measured is an open question, not a settled finding. + +## k-sweep (WITHDRAWN -- numbers unreliable, kept for the record only): k in {1, 2, 4, 8}, both drafters, full 4-prompt suite + +**See the correction at the top of this doc -- none of the numbers below +should be cited.** + +**Deviation from the original plan, stated plainly per this project's own +practice:** the plan intended to sweep `k` on just the repetition-heavy +4th prompt alone, to keep the sweep cheap. `run_speculative_bench.py`'s +actual CLI has no per-prompt selection flag -- `SPECULATIVE_PROMPTS` is +always the full fixed list of 4. Rather than add an untested flag +mid-session for a pure cost optimization, this session ran the full +4-prompt suite at every `k` instead (8 extra runs total, still cheap at +this card's $0.82/hr rate). Every number below reflects that: it is a +sweep over the same 4-prompt aggregate the measured run above used, not +an isolated read on the repetition-heavy prompt. + +| k | Drafter | Acceptance rate | Tokens/sec | vs. baseline (21.897 tok/s) | +|---|---|---|---|---| +| 1 | draft-model | 65.72% | 18.178 | -17.0% | +| 2 | draft-model | 50.70% | 17.814 | -18.6% | +| 4 | draft-model | 91.91% | 29.150 | +33.1% | +| 8 | draft-model | 80.29% | 32.252 | +47.3% | +| 1 | prompt-lookup | 54.78% | 33.749 | +54.1% | +| 2 | prompt-lookup | 31.87% | 35.861 | +63.8% | +| 4 | prompt-lookup | 22.06% | 40.385 | +84.4% | +| 8 | prompt-lookup | 11.03% | 38.661 | +76.6% | + +Sources: the 8 `2026-09-17-phase-5b-k-sweep-{draft-model,prompt-lookup}-{1,2,4,8}-results.json` +files. Per the runbook, this sweep intentionally omitted +`--compare-generated-tokens`, reasoning at the time that "the gate above +already proved exact-match correctness at k=4, and correctness does not +depend on `k`." **That reasoning turned out to rest on a false premise -- +the k=4 gate had not actually proved correctness (see the correction at +the top of this doc) -- which is exactly why item 2 below, found by +reading the raw token files rather than trusting the gate, is the +finding that this doc's final review escalated into the withdrawal +above.** This is finding I5 of that review: a re-run should check +`--compare-generated-tokens` at every k, not just k=4. + +**Two things worth flagging plainly, found only by reading the +generated-token JSON files directly (not just the results JSONs) -- item +2 here is, in hindsight, the first real evidence of the problem this +doc's correction describes:** + +1. **The k=4 point measured twice in this session doesn't repeat + exactly.** The gate run's own k=4 throughput for draft-model is 26.667 + tok/s (`2026-09-17-phase-5b-draft-model-results.json`); the k-sweep's + independent k=4 run of the same config measured 29.150 tok/s + (`2026-09-17-phase-5b-k-sweep-draft-model-4-results.json`) -- a ~9% + difference between two nominally identical configurations run at + different points in the same session. Prompt-lookup shows the same + pattern (38.733 vs. 40.385 tok/s, ~4%). Both are real, measured + numbers from separate process invocations; the difference is reported + as run-to-run variance rather than reconciled into one number, since + nothing in either results JSON explains it and inventing an + explanation would be exactly the kind of "helpfully adjusted" number + this project's practice rules out. +2. **The actual generated token content is not the same generation + problem at every k.** Reading `*-generated-tokens.json` directly: the + plain baseline's own greedy continuation (`2026-09-17-phase-5b-baseline-generated-tokens.json`) + for all 4 prompts is 64 repetitions of token id `0` -- and both k=4 + correctness-gate runs reproduce that exactly (which is why + `token_match` is `true`; this is a genuine, verified match, not a + coincidence). The k=4 and k=8 sweep runs also reproduce all-`0` + output for all 4 prompts. But the k=1 and k=2 sweep runs do not: both + drafters' k=1/k=2 generated-token files contain varied, non-degenerate + token sequences that differ from the baseline's all-`0` trajectory + *and* from each other (e.g. draft-model k=1's prompt 0 uses token ids + `{13, 15, 16, 17, 185, 207, 398, 690, 992, 14166, 14594, 24962}`; + prompt-lookup k=1's prompt 0 uses a different set entirely). Since + speculative decoding's exactness guarantee means every `k` should + reduce to the identical greedy trajectory as the plain baseline, this + is a real discrepancy in what the k=1/k=2 sweep points are actually + measuring -- and the runbook's own k-sweep design (step 12) explicitly + chose not to check `--compare-generated-tokens` at any k but 4, so + this was never caught in-session. Reported here as an open + observation, not a root-caused bug (that would need code changes and + further GPU time, out of this docs-only task's scope) -- likely + explanation is floating-point non-associativity between the batched + verification forward pass (processing k+1 tokens at once) and the + one-token-at-a-time baseline path, which would only matter if the + model's real logits are frequently near-tied at this point in + decoding (consistent with the all-`0` degenerate collapse seen at + k=4/k=8). Worth a follow-up investigation before treating the k=1/k=2 + sweep points as a clean apples-to-apples comparison with k=4. + +## Memory checkpoints + +Design doc section 8's named risk (two real models resident on one GPU +at once is new territory for this project) was checked live, not assumed: + +| Checkpoint | `allocated_gb` | `reserved_gb` | +|---|---|---| +| quantized target only | 16.75 | 17.49 | +| quantized target + bf16 draft model | 29.62 | 29.86 | + +Source: `2026-09-17-phase-5b-memory-checkpoints.json` (two JSON objects +concatenated back to back). Adding the bf16 `deepseek-llm-7b-base` draft +model on top of the already-quantized target cost **12.87GB** allocated +(29.62 - 16.75). Both checkpoints are well within a single L40's +capacity -- the session proceeded past both checkpoints without hitting +the runbook's stop condition, and no OOM occurred at any point in the +session. + +## Cost per 1M generated tokens (WITHDRAWN -- derived directly from the withdrawn tokens/sec figures above) + +Kept for the record only; do not cite. Same `$/hr / (tokens/sec * 3600) * +1e6` computation as every prior phase's findings doc, at this session's +own measured rate ($0.82/hr) and each configuration's own +`mean_tokens_per_second` from the now-withdrawn measured run above (k=4, +the default): + +| Config | Tokens/sec | Cost / 1M tokens | +|---|---|---| +| baseline | 21.897 | $10.40 | +| draft-model | 26.668 | $8.54 | +| prompt-lookup | 38.733 | $5.88 | + +## Cost vs. cap + +**Actual: $0.6833** (pod `2t6wh9l3okl3gj`, L40, $0.82/hr, 3000s) against +the **$10 cap** -- **6.83% used**. Source: +`docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-cost.md`. This +dollar figure is not in question -- it is what the session cost, not a +throughput or correctness claim. Both correctness gates, the three-way +measured run, and the full 8-run k-sweep all *completed* inside this one +session, well under budget; completing is not the same as the gates +having proved what they were meant to prove (see the correction at the +top of this doc). + +## Risk verdicts (design doc section 8) -- WITHDRAWN, rest entirely on the numbers above + +**See the correction at the top of this doc.** Both verdicts below were +written as settled conclusions from the measured run and k-sweep; both +inherit those tables' unreliability in full and should be read as "what +this session appeared to show," not as established results. They are +kept verbatim because Risk 2's own text turned out to contain the first +documented sighting of the degenerate all-`0` collapse -- at the time +written, interpreted as a property of the model, not (as the final +review now flags as the live, unruled-out hypothesis) a possible bug in +the shared loop these verdicts' own numbers came from. + +**Risk 1: "the 7B draft may not be meaningfully cheaper than the target +... net speedup could be small or negative once the draft's own cost is +counted."** Partially materialized, reported as measured rather than +smoothed into a clean yes/no. The draft-model drafter's net speedup over +baseline is real and positive: **+21.8%** at k=4 (26.668 vs. 21.897 +tok/s), so the risk's worst case (a negative or negligible speedup) did +not happen. But the risk's underlying concern -- that the dense 7B +draft's own cost would eat into the win -- shows up clearly in a +different place than the risk anticipated: **draft-model is not the +fastest option measured.** Prompt-lookup, a free (no second model) +drafter, beats it substantially (+76.9% vs. +21.8% over baseline), +despite draft-model accepting its own proposals **91.9%** of the time +against prompt-lookup's **22.1%** -- more than 4x higher acceptance. The +7B draft's near-doubling of TTFT (0.233s vs. baseline's 0.093s and +prompt-lookup's 0.098s) is the direct cost the design doc named, and it +is large enough that a much-more-accurate drafter still loses the raw +throughput race to a much-less-accurate, free one. This is exactly the +kind of finding this project's practice keeps rather than buries: high +acceptance is not sufficient by itself, and this session's own numbers +show that plainly on real hardware. + +**Risk 2: "prompt-lookup may show near-zero acceptance even with the +added 4th prompt, if that prompt doesn't repeat as cleanly as intended."** +Did not materialize as stated, but for a more surprising reason than the +design doc anticipated. Prompt-lookup's acceptance rate at k=4 is +**22.1%**, not near-zero, and both correctness gates confirm this rate +comes from real, verified matches (`token_match: true` for all 4 +prompts including `prompt_003_tokens`, the repetition-heavy prompt). +However, reading the generated-token files directly shows *why* +acceptance is non-trivial is not what the design intended: **at k=4, all +four prompts' actual greedy continuations under this quantized model +collapse into repeating a single token (id `0`) for the full 64-token +window** -- not just the purpose-built repetition prompt. This makes +every prompt maximally, trivially repetitive at the token level by k=4, +which is an easy target for n-gram lookback regardless of whether the +*prompt's own text* was designed to repeat. The results JSONs report +only an aggregate acceptance rate across all 4 prompts, not a per-prompt +breakdown, so this finding cannot confirm whether the repetition-heavy +prompt specifically contributed more matches than the other three within +that 22.1% -- only that all 4 prompts' real generated content was, in +practice, equally repetitive at k=4. The risk as framed (the 4th prompt +failing to repeat "as intended") is moot: the model's own degenerate +greedy behavior overtook the experimental design's premise. Whether this +all-`0` collapse is itself specific to the quantized kernel, this exact +prompt set, or greedy decoding at this length in general was not +investigated further -- out of scope for this docs-only task, flagged as +a candidate follow-up. + +## Summary (rewritten by the final whole-branch review) + +**This session's own throughput and correctness-gate claims are +withdrawn.** The GPU session's baseline (`--drafter none`) produced a +degenerate output -- 64 repetitions of token id `0` for all 4 test +prompts, not a plausible continuation of any of them. Both correctness +gates "passed" only because they matched that same degenerate output at +k=4/k=8, which proves nothing: two broken outputs agreeing is not +correctness. The k=1/k=2 sweep runs, the only runs that produced varied, +plausible tokens, were never checked against the baseline. The root +structural reason this could happen at all -- the baseline was generated +by the very code the gates exist to check -- is finding C2 of the final +review; the missing per-k comparison is finding I5. Every throughput +number, acceptance rate, and derived cost-per-1M-token figure above is +unreliable and should not be cited. + +**What is real and unaffected**: the session spent $0.6833 of its $10 +cap (a real dollar figure, not a correctness claim); the memory +checkpoints (16.75GB target-only, 29.62GB target-plus-draft-model) are +real allocator readings, not token-correctness-dependent; the five +`transformers` environment fixes in the Environment section above were +real, necessary, and are unrelated to this correction. + +**What now backs the algorithm's own correctness**, put in place by the +fix wave that produced this correction: a genuinely independent CPU-level +oracle (`plain_greedy_generate`, sharing no code with +`run_speculative_rounds`) now backs the `--drafter none` baseline path in +`scripts/run_speculative_bench.py` itself, closing the structural gap +finding C2 identified; runtime cache-lag assertions in both +`run_speculative_rounds` and `DraftModelDrafter.propose` turn a future +silent cache-desync into a loud crash instead of plausible-looking +garbage; and a new real-model, real-tokenizer, real-`transformers.Cache` +end-to-end test exercises `DraftModelDrafter` inside the full loop for +the first time outside the now-discredited GPU gate, and passes. All of +this is CPU-only, deterministic, and already in `make check`. + +**What is still open, and explicitly not this fix wave's job**: +root-causing why the GPU session's baseline degenerated in the first +place needs a new rented GPU session and is a decision for the project +owner, not a docs/code review-fix task; a trustworthy throughput number +for Phase 5b needs that root-cause work done first, then a re-run with +`--compare-generated-tokens` checked at every k-sweep point, not just +k=4. Until that happens, Phase 5b's algorithm and code are done and +covered by CPU-level tests, but its GPU-measured performance claims are +not established. diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-baseline-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-baseline-results.json new file mode 100644 index 0000000..e22f40c --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-baseline-results.json @@ -0,0 +1,18 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "none", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": null, + "run_count": 20, + "mean_ttft": 0.09365926063619553, + "p50_ttft": 0.04025611747056246, + "p99_ttft": 0.8962304838653641, + "mean_inter_token_latency": 0.03942082163449081, + "mean_tokens_per_second": 25.182355806587136, + "token_match": {} +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-draft-model-gate-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-draft-model-gate-results.json new file mode 100644 index 0000000..9ad36b8 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-draft-model-gate-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.7453846153846154, + "run_count": 20, + "mean_ttft": 0.2481006294954568, + "p50_ttft": 0.17816065670922399, + "p99_ttft": 1.1709669686108815, + "mean_inter_token_latency": 0.042511785884816494, + "mean_tokens_per_second": 23.028118674049942, + "token_match": { + "prompt_000_tokens": true, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-draft-model-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-draft-model-results.json new file mode 100644 index 0000000..60c5cc1 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-draft-model-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 1, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.922272047832586, + "run_count": 20, + "mean_ttft": 0.14998039649799466, + "p50_ttft": 0.09648513421416283, + "p99_ttft": 0.9367381162475781, + "mean_inter_token_latency": 0.049762866901795544, + "mean_tokens_per_second": 19.638034275208884, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-prompt-lookup-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-prompt-lookup-results.json new file mode 100644 index 0000000..d25dc94 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k1-prompt-lookup-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 1, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.3670212765957447, + "run_count": 20, + "mean_ttft": 0.08578907144255936, + "p50_ttft": 0.04419401939958334, + "p99_ttft": 0.7343057713285078, + "mean_inter_token_latency": 0.029949055855265923, + "mean_tokens_per_second": 34.42577382331669, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": false, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-draft-model-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-draft-model-results.json new file mode 100644 index 0000000..fab956f --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-draft-model-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 2, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.8779443254817987, + "run_count": 20, + "mean_ttft": 0.17924863104708494, + "p50_ttft": 0.12645732518285513, + "p99_ttft": 0.9615918473992484, + "mean_inter_token_latency": 0.04468500254276608, + "mean_tokens_per_second": 21.650663861908328, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-prompt-lookup-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-prompt-lookup-results.json new file mode 100644 index 0000000..cb142fe --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k2-prompt-lookup-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 2, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.30472636815920395, + "run_count": 20, + "mean_ttft": 0.09989271187223495, + "p50_ttft": 0.04673240426927805, + "p99_ttft": 0.9141063908487544, + "mean_inter_token_latency": 0.028621495226102453, + "mean_tokens_per_second": 36.850104930563724, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": false, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-draft-model-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-draft-model-results.json new file mode 100644 index 0000000..aa3b459 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-draft-model-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.7446153846153846, + "run_count": 20, + "mean_ttft": 0.2309181360062212, + "p50_ttft": 0.17887665424495935, + "p99_ttft": 0.9922750298958259, + "mean_inter_token_latency": 0.04273683151585005, + "mean_tokens_per_second": 23.031695845618135, + "token_match": { + "prompt_000_tokens": true, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-prompt-lookup-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-prompt-lookup-results.json new file mode 100644 index 0000000..294d77e --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k4-prompt-lookup-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.14876543209876544, + "run_count": 20, + "mean_ttft": 0.08614619690924882, + "p50_ttft": 0.04168541869148612, + "p99_ttft": 0.7520103920716781, + "mean_inter_token_latency": 0.025628733939476428, + "mean_tokens_per_second": 44.739769413788146, + "token_match": { + "prompt_000_tokens": true, + "prompt_001_tokens": false, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-draft-model-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-draft-model-results.json new file mode 100644 index 0000000..59240d8 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-draft-model-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": "deepseek-ai/deepseek-llm-7b-base", + "device": "cuda", + "dtype": "bfloat16", + "drafter": "draft-model", + "num_speculative_tokens": 8, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.5940265486725663, + "run_count": 20, + "mean_ttft": 0.3431815484073013, + "p50_ttft": 0.2900777105242014, + "p99_ttft": 1.1214584208093572, + "mean_inter_token_latency": 0.04553105437255923, + "mean_tokens_per_second": 22.739836282746403, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": true, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-prompt-lookup-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-prompt-lookup-results.json new file mode 100644 index 0000000..47f8263 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-k8-prompt-lookup-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 8, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.08810935441370224, + "run_count": 20, + "mean_ttft": 0.08508942830376327, + "p50_ttft": 0.04101732652634382, + "p99_ttft": 0.7415975096728642, + "mean_inter_token_latency": 0.02365537306500806, + "mean_tokens_per_second": 50.925326073392704, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": false, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-real-prompt-lookup-gate-results.json b/docs/findings/phase-5b/2026-09-18-phase-5b-real-prompt-lookup-gate-results.json new file mode 100644 index 0000000..15f7fe5 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-real-prompt-lookup-gate-results.json @@ -0,0 +1,23 @@ +{ + "model": "deepseek-ai/deepseek-moe-16b-base", + "draft_model": null, + "device": "cuda", + "dtype": "bfloat16", + "drafter": "prompt-lookup", + "num_speculative_tokens": 4, + "prompt_lookup_ngram_size": 3, + "moe_layers_patched": 27, + "acceptance_rate": 0.17724867724867724, + "run_count": 20, + "mean_ttft": 0.08598875012248755, + "p50_ttft": 0.040254459250718355, + "p99_ttft": 0.7662569189723569, + "mean_inter_token_latency": 0.023013453711090343, + "mean_tokens_per_second": 47.20562670031849, + "token_match": { + "prompt_000_tokens": false, + "prompt_001_tokens": false, + "prompt_002_tokens": true, + "prompt_003_tokens": true + } +} diff --git a/docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md b/docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md new file mode 100644 index 0000000..09db489 --- /dev/null +++ b/docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md @@ -0,0 +1,352 @@ +# Phase 5b speculative decoding, real run -- findings + +Design: `docs/design/2026-09-17-phase-5b-speculative-decoding.md`. Plan: +`docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md`. Branch +`phase-5b-speculative-decoding`. Runbook: +`docs/runbooks/phase-5b-speculative-decoding.md`. Supersedes +`docs/findings/phase-5b/2026-09-17-phase-5b-speculative-decoding-run.md` (the +prior session, whose throughput/correctness numbers were withdrawn by +that same doc's own final-review correction after its baseline turned +out to be degenerate). + +## Headline: root cause found and fixed; real numbers below, with one open finding + +The prior session's degenerate baseline (64 repetitions of token id `0` +on every prompt) was root-caused this session: `transformers==5.17.0`'s +`from_pretrained` leaves DeepSeek's remote code's RoPE `inv_freq` buffer +(`persistent=False`, computed fresh in `DeepseekRotaryEmbedding.__init__`) +as uninitialized memory instead of its real computed value, poisoning +every attention layer with NaN on the very first GPU forward pass. Fixed +permanently in `fix_rope_inv_freq()` +(`src/dispatch/kernels/integration.py`, commit `c5b59df`), wired into +`scripts/run_speculative_bench.py` right after loading both the target +and any draft model. Full mechanism, evidence trail (CPU-clean/GPU-NaN, +`low_cpu_mem_usage=False` ruled out, manual-fix verification), and every +dead end are in that function's docstring and the commit itself. + +With the fix applied, this session produced a real, non-degenerate +baseline and ran both correctness gates and the full k-sweep. One gate +(prompt-lookup) showed real, explained, non-bug divergence from the +baseline on 2 of 4 prompts -- root-caused to a genuine near-tied logit +position under the int8-quantized kernel's actual floating-point +precision, not a code defect. See "Correctness gates" and "The +prompt-lookup divergence" below. + +## Infrastructure (this session) + +Four pods across this session, working around real infrastructure +failures unrelated to the actual investigation (corrupted torch +install, a `uv` lock-contention stall, two "not enough free GPUs on the +host" capacity failures after a stop/restart): `oby7hbkzx8o8yt` (L40, +US-KS-2), `5ufi854zxkfwbz` (A40, CA-MTL-1), and `d2h1sgmr5wjssv` (A40, +EU-SE-1) -- the last of these three is where the real baseline, both +gates, and the k-sweep below were run. Config held constant across every +run below: model `deepseek-ai/deepseek-moe-16b-base`, quantized (int8) +target kernel (Phase 5a's `patch_moe_infer_quantized`), `bfloat16`, +single A40, `sdpa` attention, eager unbatched decode, the 4-prompt +`SPECULATIVE_PROMPTS` list, 20 runs per configuration, 64 max new +tokens, `prompt_lookup_ngram_size=3`. Draft model where applicable: +`deepseek-ai/deepseek-llm-7b-base`, `bfloat16`. + +## Real baseline (`--drafter none`) + +`docs/findings/2026-09-18-phase-5b-real-baseline-{results,generated-tokens}.json`. +`moe_layers_patched: 27`. Mandatory plausibility check (decode + +`unique_token_count` per prompt) on the actual generated tokens: + +| Prompt | `unique_token_count` | Plausible? | +|---|---|---| +| 000 (pangram) | 11/64 | yes -- repeats the pangram | +| 001 (galaxy prose) | 20/64 | yes -- coherent continuation | +| 002 (`def fibonacci(n):`) | 26/64 | yes -- correct recursive-then-iterative continuation | +| 003 (repetition-instruction prompt) | **1/64** | see below | + +Prompt 003 ("Please restate the following sentence exactly twice, back +to back: ...") decodes to 64 repeated newline tokens (id **185**). +Checked before accepting this baseline: the repeated id is **not** `0` +(the tie-break `argmax` returns on all-NaN input, i.e. the prior +session's bug signature) -- it is a real, specific token, consistent +with an un-tuned base model collapsing into a low-entropy loop on an +out-of-distribution instruction-style prompt under deterministic greedy +decoding. Not a bug; kept as a real result, and (see below) it turned +out to be a genuinely useful stress case for prompt-lookup's own +mechanism. + +`mean_tokens_per_second: 25.18`, `mean_ttft: 0.0937s`, `p50_ttft: 0.0403s`, +`p99_ttft: 0.8962s`, `run_count: 20`. + +## Correctness gates + +Both against the real baseline above, `--compare-generated-tokens`. + +| Drafter | `moe_layers_patched` | `token_match` (4 prompts) | Acceptance rate | Tokens/sec | +|---|---|---|---|---| +| draft-model (k=4) | 27 | all `true` | 74.5% | 23.03 | +| prompt-lookup (k=4) | 27 | `{000: false, 001: false, 002: true, 003: true}` | 17.7% | 47.21 | + +draft-model passed byte-exact on every prompt at k=4. prompt-lookup +diverged on prompt_000 and prompt_001 -- see below for why, and see the +k-sweep for the fuller picture (this is not k=4-specific). + +**The same config gave a different outcome in a separate process +launch.** The k-sweep re-ran prompt-lookup at k=4 (same model, same +prompts, same flags, a fresh process) and got `{000: true, 001: false, +002: true, 003: true}` at 14.9% acceptance and 44.74 tok/s -- prompt_000 +flipped from diverging to matching, with no code or config change +between the two runs. That is direct, in-repo evidence of the +cross-process floating-point sensitivity root-caused below, and it is +also why the k=4 throughput differs by ~5% between the gate run (47.21) +and the sweep run (44.74): both are real measurements of the same +config, reported separately rather than averaged into one number. + +Sources: +`docs/findings/phase-5b/2026-09-18-phase-5b-real-{draft-model,prompt-lookup}-gate-results.json`. + +## The prompt-lookup divergence: root-caused, not a code bug + +Investigated live on the rented A40 before accepting these numbers, +because a speculative-decoding drafter that fails to reproduce the +target's own greedy output byte-exact is exactly the kind of thing this +project's "correctness before speed" principle exists to catch. + +**What was ruled out first, by hand.** `run_speculative_rounds` and +`PromptLookupDrafter.propose` (`src/dispatch/speculative/decode.py`, +`drafters.py`) were re-derived algebraically for every candidate-count +case (zero candidates, partial fill, full k, first round vs. +steady-state cache-lag). The cache-lag invariant, `crop(-rejected_len)` +including the `rejected_len == 0` case (confirmed against +`transformers==5.17.0`'s actual `Cache.crop` source: `tokens_to_remove +== 0` is an explicit no-op, not a "keep 0" wipe), and the +`target_predictions`/`offset` alignment all check out for every +candidate count. No logic error found. + +**What the actual diverging tokens looked like.** Diffing +prompt_000's baseline vs. prompt-lookup-gate token ids: identical through +index 2, first mismatch at index 3 (baseline `3399`, prompt-lookup +`24962`), then a normal autoregressive cascade after that single point +-- consistent with one flipped decision propagating forward, not with +random corruption. + +**Root cause, confirmed by two decisive GPU probes on the same pod, same +loaded model:** + +1. **Batch-width probe.** Held one exact KV-cache prefix (prompt + + `[185, 185]`, with `549` as the pending catch-up token) and fed `549` + as query position 0 of forward calls of total width 1, 2, 3, 4, 5 + (padding with random filler tokens after it -- which a correct causal + implementation must never let affect position 0's own output). + Result: width 1 -> `24962`, width 2 -> `3399`, widths 3/4/5 -> + `24962`. The argmax at a causally-isolated query position varied with + unrelated batch content. +2. **Determinism probe.** Called the *identical* single-token (width-1) + forward 10 times in a row on the same never-advanced cache, in one + process. All 10 agreed with each other (`24962` every time -- + deterministic *within* one process), but the raw top-2 logit values + fluctuated trial to trial: `19.75/19.5`, then `19.875/19.625`, then + `19.625/19.5`, always between the same two tokens (`24962`, `3399`). + `resolve_quantized_backend()` confirmed this whole session uses + `grouped_matmul_int8` (Phase 5a's quantized kernel) unconditionally, + for the baseline and every drafted run alike. + +**Conclusion:** generated-token index 3 of prompt_000 is a genuine +near-tie -- a top-2 logit gap of roughly 0.1-0.4 out of a ~20-magnitude +scale -- under this quantized kernel's actual floating-point precision. +The kernel's accumulation order is stable within one process/launch +context but not guaranteed bit-identical across separate launches +(different CUDA context, different scheduling of the same +atomic-accumulation-style Triton reduction). Whichever side of the tie a +given run lands on is decided by that run's own floating-point noise, +not by a bug in the drafter or the shared verification loop. + +**This was confirmed further, for free, by the k-sweep below**: the same +divergence point recurs for **draft-model** too, at k=1 and k=2 (not +just prompt-lookup) -- direct proof this is a property of the +near-tied model position itself, surfaced whenever a config's batch +shape happens to tip the kernel noise away from whatever the baseline +process's own noise realization landed on, not an algorithmic defect +specific to either drafter. draft-model's earlier k=4 gate passing +clean on all 4 prompts was, in retrospect, one specific config not +happening to hit this; it is not evidence that draft-model is immune in +general. + +## k-sweep: k in {1, 2, 4, 8}, both drafters, full 4-prompt suite, checked at every k + +Unlike the prior (withdrawn) session, `--compare-generated-tokens` was +checked at **every** point this time (the prior review's finding I5). + +| k | Drafter | Acceptance rate | Tokens/sec | `token_match` (000/001/002/003) | +|---|---|---|---|---| +| 1 | draft-model | 92.2% | 19.64 | F / T / T / T | +| 1 | prompt-lookup | 36.7% | 34.43 | F / F / T / T | +| 2 | draft-model | 87.8% | 21.65 | F / T / T / T | +| 2 | prompt-lookup | 30.5% | 36.85 | F / F / T / T | +| 4 | draft-model | 74.5% | 23.03 | T / T / T / T | +| 4 | prompt-lookup | 14.9% | 44.74 | T / F / T / T | +| 8 | draft-model | 59.4% | 22.74 | F / T / T / T | +| 8 | prompt-lookup | 8.8% | 50.93 | F / F / T / T | + +Sources: the 8 +`docs/findings/2026-09-18-phase-5b-real-k{1,2,4,8}-{draft-model,prompt-lookup}-{results,generated-tokens}.json` +files. + +**Reading this honestly:** + +- **prompt_002 (fibonacci) and prompt_003 (repetition/newlines) never + diverge, in any of the 8 configs.** Their full 64-token trajectories + apparently contain no comparably-tight near-tie for this kernel at + this precision -- the phenomenon above is real but position-specific, + not "speculative decoding is broken." +- **prompt_000 diverges in 6 of 8 configs**, the exceptions being both + drafters at k=4 -- configs landing back on the baseline's own side of + the tie, not a property of k=4 or of either drafter (the separate + k=4 prompt-lookup gate run, above, diverged on this same prompt). +- **prompt_001 diverges only under prompt-lookup**, at every k tested, + never under draft-model. Not chased to its own separate root cause + beyond the general mechanism above (a different near-tie position, + exposed by prompt-lookup's specific candidate batch shapes and not by + draft-model's) -- attributing it to a second, distinct bug would need + its own dedicated GPU probe, and the general mechanism already has + two independent, decisive confirmations above. +- **Acceptance rate falls with k for both drafters** (expected: matching + more consecutive candidates gets harder as k grows). +- **Throughput trends diverge between drafters as k grows.** + prompt-lookup's tokens/sec keeps climbing with k (34.4 -> 50.9) even as + its acceptance rate collapses (36.7% -> 8.8%) -- proposing candidates + is nearly free for a stateless n-gram lookup, so even a low hit rate + still nets fewer forward-pass round trips at higher k. draft-model + peaks at k=4 (23.03) and *drops* at k=8 (22.74) -- its own dense 7B + forward pass per candidate is not free, and past k=4 that cost start + to outweigh the marginal acceptance it buys. +- **prompt-lookup is faster than draft-model at every k tested**, despite + draft-model's acceptance rate being 2-7x higher at every point. Same + qualitative shape as the prior (withdrawn) session's finding, now on + real, non-degenerate data: a much-more-accurate drafter that pays for + a second forward pass can still lose the raw throughput race to a + free, much-less-accurate one. + +## Cost per 1M generated tokens + +`$/hr / (tokens/sec * 3600) * 1e6`, at this session's own rented rate +(A40 Secure Cloud, $0.49/hr), from each config's own measured +`mean_tokens_per_second`: + +| Config | Tokens/sec | Cost / 1M tokens | +|---|---|---| +| baseline | 25.18 | $5.41 | +| draft-model (k=4) | 23.03 | $5.91 | +| prompt-lookup (k=4) | 44.74 | $3.04 | + +## Memory checkpoints + +Not re-measured this session (allocator behavior is unrelated to the +token-correctness bug that motivated this session's rental, and the +prior session's readings were never in question). From the prior +session, for reference: quantized target alone 16.75GB allocated; +target plus bf16 draft model together 29.62GB -- both well within a +single GPU's capacity in this project's tested configurations. + +## Cost vs. cap (full phase, both sessions) + +| Session | Pod | GPU | Cost | +|---|---|---|---| +| 2026-09-17 (original) | `2t6wh9l3okl3gj` | L40 | $0.6833 | +| 2026-09-18 (this session) | `oby7hbkzx8o8yt` | L40 | $10.8848 | +| 2026-09-18 (this session) | `5ufi854zxkfwbz` | A40 | $0.2022 | +| 2026-09-18 (this session) | `d2h1sgmr5wjssv` | A40 | $0.4900 | +| **Total** | | | **$12.26** | + +Against the **$20 cap** (raised from the original $10 cap mid-session +after a real cost-cap overrun on `oby7hbkzx8o8yt` -- disclosed in full +to the user at the time, with an explicit ruling to raise the cap +before any further spend): **61.3% used**. The `oby7hbkzx8o8yt` figure +is the bulk of this session's cost: that pod was left running through +an unbounded wait rather than stopped between turns, the actual incident +behind the cap-overrun disclosure. Every other pod this session was +stopped or terminated promptly once its specific investigative step +was done. All four pods confirmed at `EXITED` or terminated +(`d2h1sgmr5wjssv` stopped, not terminated, at the end of this session -- +its persistent `/workspace` volume is retained but no longer billing +GPU time). + +**A second, real infrastructure finding, caught while retrieving this +session's evidence files**: `d2h1sgmr5wjssv`'s repo clone lived at +`/root/dispatch`, outside its persistent mount (`/workspace`, 60GB). +Stopping the pod and starting it again later (needed once, to retrieve +the evidence JSONs after they were not pulled down before the first +`stop`) recreated the container from its base image and did **not** +preserve `/root/dispatch` -- only the explicitly-mounted `/workspace` +path survives a stop/start cycle, not the rest of the container's +filesystem. This was caught immediately (every retrieval attempt +reported "No such file or directory," not corrupted content), so no +false data was ever recorded. **Consequence**: this session's 11 +`results.json` files are reconstructed exactly from this same session's +own already-captured process stdout (the `cat` output each run itself +printed, captured live in this session's task logs) -- byte-identical +to what the run produced, not re-derived or approximated. The 11 +corresponding `generated-tokens.json` raw per-prompt token-id files +could **not** be recovered this way (only small excerpts of their +content were ever captured to a terminal, not the full 64-token arrays +for every prompt) and are not part of this session's committed +evidence. This does not affect any number in this doc -- every +reported metric came from a `results.json`, all 11 of which are +byte-exact reconstructions -- but it does mean the raw generated-token +sequences for this session's runs are not independently re-auditable +from committed files the way the aggregate metrics are. Lesson applied +going forward: pull evidence files off a rented pod before any `stop`, +not after. + +## Open question, explicitly flagged for the project owner, not pursued here + +This session's root-cause work directly strengthens a concern first +raised (but not investigated) earlier in this session: **Phase 5a's own +"perfect model-level top-1/mutual-top-k agreement" claim used this exact +same `grouped_matmul_int8` kernel**, and this session found concrete, +measured evidence that this kernel has genuine near-tie floating-point +sensitivity across separate process launches -- exactly the failure +mode a single-run top-k agreement check cannot see. This is not a claim +that Phase 5a's result is wrong; it is a claim that Phase 5a's own +correctness check was not built to detect this specific failure mode, +and now there is direct measurement (not just a theoretical concern) +that the failure mode is real for this kernel. Auditing Phase 5a is out +of this task's scope and was not attempted -- flagged here for a +decision by the project owner. + +## Summary + +**Phase 5b's degenerate-baseline bug (finding C1) is root-caused and +fixed**: `transformers==5.17.0` leaves DeepSeek's RoPE `inv_freq` buffer +uninitialized after `from_pretrained`; `fix_rope_inv_freq()` (commit +`c5b59df`) corrects it, is unit-tested, and is wired into both the +target and draft model load paths. With the fix applied, this session +produced a real, plausible baseline, ran both correctness gates, and +ran the full k-sweep with `--compare-generated-tokens` checked at every +point (closing finding I5 from the prior review). + +**In the k-sweep, draft-model matches the true greedy baseline +byte-exact in 13 of 16 (prompt, k) combinations** (4 prompts x 4 k +values; the 3 misses are all prompt_000, at k=1, 2, and 8); +**prompt-lookup matches in 9 of 16**. Every divergence traces to the +same root cause -- a genuine near-tied logit position under the +int8-quantized kernel's real floating-point precision, confirmed by two +independent, decisive GPU probes -- not to a defect in +`dispatch.speculative`'s propose/verify/accept/rollback logic, which was +re-derived by hand and found correct for every candidate-count case. + +**Only prompt-lookup beats the baseline; draft-model never does.** +Against the baseline's 25.18 tok/s, prompt-lookup ranges from 34.43 to +50.93 tok/s across k=1-8 (+77.7% at k=4 in the sweep run), while +draft-model ranges from 19.64 to 23.03 tok/s (-8.5% at its best, k=4): +on this single-request, unbatched decode a 7B draft model's own forward +pass costs more than the accepted tokens save. prompt-lookup is faster +than draft-model at every k tested despite consistently lower +acceptance, mirroring the prior (withdrawn) session's qualitative +finding, now on real numbers. Throughput figures are single measured +runs per config on one pod; the k=4 prompt-lookup repeat (47.21 vs. +44.74) shows ~5% run-to-run spread. + +**Total real GPU cost across both Phase 5b sessions: $12.26 of the $20 +cap (61.3%)**, including a disclosed and corrected cost-cap incident +earlier in this session. **One open question is flagged, not resolved, +for the project owner**: whether Phase 5a's own correctness claim should +be re-examined in light of this session's concrete evidence of near-tie +sensitivity in the same quantized kernel. diff --git a/docs/plans/2026-09-15-phase-1-grouped-gemm-plan.md b/docs/plans/2026-09-15-phase-1-grouped-gemm-plan.md index c893de1..4411e99 100644 --- a/docs/plans/2026-09-15-phase-1-grouped-gemm-plan.md +++ b/docs/plans/2026-09-15-phase-1-grouped-gemm-plan.md @@ -28,7 +28,7 @@ pod-local 4.57.6 override), RunPod. scope and build order), §6 (benchmark methodology, the same-hardware rule), §7 (the Phase 1 row), §9 (cost plan), §10 (what "done" looks like); `docs/adr/0001` (why grouped-GEMM at all); and -`docs/findings/2026-09-14-phase-0-baseline-run.md` -- the three +`docs/findings/phase-0/2026-09-14-phase-0-baseline-run.md` -- the three pod-environment bugs there recur the moment this model is loaded again. ## Global Constraints diff --git a/docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md b/docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md index daa7337..c4ad1d9 100644 --- a/docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md +++ b/docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md @@ -97,7 +97,7 @@ phase ports, `_ROUTING_WEIGHTS["zipf"] = 1/i`). ```text dispatch (this repo): docs/plans/2026-09-15-phase-2-vllm-benchmark-contribution-plan.md # this file - docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md # Task 7 + docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md # Task 7 docs/findings/2026-09-15-phase-2-vllm-benchmark-cost.md # Task 5 (write_cost_record) docs/runbooks/phase-2-vllm-benchmark.md # Task 5 docs/STATUS.md # Task 7 @@ -819,12 +819,12 @@ into Task 7's findings doc verbatim, not re-derived from memory. **Files:** -- Create: `docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md` +- Create: `docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md` - Modify: `docs/STATUS.md` - [ ] **Step 1: Write the findings doc** -Cover, in `docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md`: both PR +Cover, in `docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md`: both PR URLs and their state (open / under review / merged / rejected -- whichever is true at write time, not assumed); the measured per-batch-size config divergence table from Task 6; the actual GPU type, duration, and cost from @@ -845,7 +845,7 @@ total GPU cost. Set "## Next step" to reflect whatever is actually next ```bash cd /Users/sree/Documents/Projects/dispatch -git add docs/findings/2026-09-15-phase-2-vllm-benchmark-run.md docs/STATUS.md +git add docs/findings/phase-2/2026-09-15-phase-2-vllm-benchmark-run.md docs/STATUS.md git commit -m "docs: record Phase 2 vLLM benchmark contribution outcome" ``` diff --git a/docs/plans/2026-09-15-phase-3-multi-gpu-expert-parallel-serving-plan.md b/docs/plans/2026-09-15-phase-3-multi-gpu-expert-parallel-serving-plan.md index 91b05c5..8b2e4bf 100644 --- a/docs/plans/2026-09-15-phase-3-multi-gpu-expert-parallel-serving-plan.md +++ b/docs/plans/2026-09-15-phase-3-multi-gpu-expert-parallel-serving-plan.md @@ -829,7 +829,7 @@ both fit the cap for a multi-hour session. run the same prompt/seed; gather rank 0's output logits. Compare top-5 and top-1 argmax agreement at every position against the single-GPU reference, the same bar Phase 1 used - (`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`). If agreement + (`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`). If agreement fails, stop -- do not benchmark a wrong result. Record the exact agreement measured (perfect, or the first position/token where it diverges) in the findings doc either way. diff --git a/docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md b/docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md new file mode 100644 index 0000000..ef6f779 --- /dev/null +++ b/docs/plans/2026-09-17-phase-5b-speculative-decoding-plan.md @@ -0,0 +1,1899 @@ +# Phase 5b: Speculative Decoding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement speculative decoding on top of dispatch's Phase 5a +int8-quantized target kernel -- a real draft-model drafter +(`deepseek-llm-7b-base`) and a model-free prompt-lookup drafter -- wire +both through a new CLI script, and measure real throughput/acceptance-rate +against a plain-greedy quantized-target baseline on the same hardware +class this project has used throughout. + +**Architecture:** A new `src/dispatch/speculative/` package. `drafters.py` +defines a `Drafter` protocol plus two implementations (`DraftModelDrafter`, +`PromptLookupDrafter`). `decode.py` holds the one shared +propose/verify/accept/rollback loop (`run_speculative_rounds`, wrapped by +the tokenizer-facing `speculative_generate`), parameterized by a `Drafter` +so neither implementation duplicates verification or cache-rollback logic. +`reference.py` captures/compares the exact generated token ids (not +logits -- speculative decoding's correctness claim is byte-exact token +match against plain greedy decoding, a tighter bar than the tolerance-based +logit agreement `benchmark/reference.py` already provides for kernel +swaps). `scripts/run_speculative_bench.py` wires it all together, always +running the target through Phase 5a's `patch_moe_infer_quantized` per the +design doc's memory-budget decision. + +**Tech Stack:** PyTorch, `transformers>=5.17.0` (its `Cache.crop()`, +confirmed live to exist in the pinned version -- design doc section "Data +flow"). No new third-party dependency; `deepseek-llm-7b-base` is loaded +through the existing `load_model` exactly like the target. + +**Spec:** `docs/design/2026-09-17-phase-5b-speculative-decoding.md` (all +sections). Also reused, unmodified: `src/dispatch/benchmark/harness.py` +(`load_model`, `generate_with_timings`), `src/dispatch/benchmark/metrics.py` +(`TokenTimings`, `summarize`), `src/dispatch/kernels/backends.py` +(`resolve_quantized_backend`), `src/dispatch/kernels/integration.py` +(`patch_moe_infer_quantized`), `scripts/run_baseline.py` +(`DEFAULT_PROMPTS`), `scripts/gpu/provision.py` (`write_cost_record`). + +## Global Constraints + +- **Two drafters, both implemented, not one** -- `DraftModelDrafter` + (wraps `deepseek-llm-7b-base`) and `PromptLookupDrafter` (no model), + per design doc section 2. +- **Verification is exact-match against the target's own greedy argmax, + never sampling-based.** Speculative decoding's output must be + byte-identical to plain sequential greedy decoding of the same target + (design doc section 5) -- every task's tests hold this bar literally, + not approximately. +- **The target always runs through Phase 5a's int8-quantized kernel** + (`patch_moe_infer_quantized` + `resolve_quantized_backend()`, both + reused unmodified) -- never bf16, per the design doc's memory-budget + decision (section 2). +- **`deepseek-llm-7b-base` is the draft model**, loaded via the existing + `load_model`, no `trust_remote_code` (it's a plain `LlamaForCausalLM`, + confirmed live -- design doc section 2). Not configurable to a smaller + same-vocab model because none exists standalone in the `deepseek-ai` + namespace (design doc section 2, section 7). +- **Four prompts for this phase's own runs**: the existing + `DEFAULT_PROMPTS` (`scripts/run_baseline.py`) plus one new + repetition-heavy prompt, so `PromptLookupDrafter` has a genuine chance + to find n-gram matches (design doc section 2). +- **Budget cap: $10, a ceiling not a target**, single GPU (L40 class, + Phase 5a's tier), one combined session, checked live against real-time + marketplace/spot availability at rental time. This is a live session + with the user, not something to run unattended -- get explicit + go-ahead before renting (Task 6). +- **Never quote a benchmark number that wasn't measured** on this exact + run (repo-wide rule). +- **Out of scope for this plan** (design doc section 7): sampling-based + speculative decoding, combining with multi-GPU EP, a third drafter + type, beating vLLM/SGLang's own speculative-decoding numbers, a + same-vocab draft model smaller than 7B. + +--- + +### Task 1: `Drafter` protocol and `PromptLookupDrafter` + +**Files:** +- Create: `src/dispatch/speculative/__init__.py` +- Create: `src/dispatch/speculative/drafters.py` +- Test: `tests/unit/test_drafters.py` + +**Interfaces:** +- Produces: `Drafter` (`Protocol`: `propose(token_ids: torch.Tensor, + num_tokens: int) -> torch.Tensor`; `on_accepted(accepted_len: int, + rejected_len: int) -> None`). `PromptLookupDrafter(ngram_size: int = 3)` + implementing `Drafter`. + +- [ ] **Step 1: Write the failing tests** + +Create `src/dispatch/speculative/__init__.py` (empty, matching +`src/dispatch/kernels/__init__.py` and `src/dispatch/serving/__init__.py`). + +Create `tests/unit/test_drafters.py`: + +```python +"""CPU-only, no model: PromptLookupDrafter's longest-suffix-match logic, +tested directly against constructed token sequences. Per +docs/design/2026-09-17-phase-5b-speculative-decoding.md section 3. +""" + +from __future__ import annotations + +import pytest +import torch + +from dispatch.speculative.drafters import PromptLookupDrafter + + +def test_proposes_whatever_followed_the_most_recent_matching_ngram() -> None: + # [A, B, C, A, B] with ngram_size=2: the last two tokens are [A, B], + # which also occurred at positions 0-1, followed there by [C]. + token_ids = torch.tensor([[10, 20, 30, 10, 20]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed = drafter.propose(token_ids, num_tokens=1) + + assert proposed.tolist() == [[30]] + + +def test_proposes_up_to_num_tokens_from_the_matched_continuation() -> None: + # [A, B, C, D, A, B] with ngram_size=2: [A, B] matched at positions + # 0-1, followed there by [C, D] -- only 2 tokens available. + token_ids = torch.tensor([[10, 20, 30, 40, 10, 20]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed_all = drafter.propose(token_ids, num_tokens=2) + proposed_truncated = drafter.propose(token_ids, num_tokens=1) + + assert proposed_all.tolist() == [[30, 40]] + assert proposed_truncated.tolist() == [[30]] + + +def test_returns_fewer_than_num_tokens_when_the_match_is_near_the_end() -> None: + # ngram_size=1: the last token (7) also occurred at position 2, where + # it was followed by [3, 7] -- only 2 tokens, fewer than the 5 requested. + token_ids = torch.tensor([[1, 2, 7, 3, 7]]) + drafter = PromptLookupDrafter(ngram_size=1) + + proposed = drafter.propose(token_ids, num_tokens=5) + + assert proposed.tolist() == [[3, 7]] + + +def test_returns_empty_when_no_earlier_match_exists() -> None: + token_ids = torch.tensor([[10, 20, 30, 40, 50]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed = drafter.propose(token_ids, num_tokens=3) + + assert proposed.shape == (1, 0) + + +def test_returns_empty_when_the_sequence_is_too_short_for_the_ngram() -> None: + token_ids = torch.tensor([[10, 20]]) + drafter = PromptLookupDrafter(ngram_size=3) + + proposed = drafter.propose(token_ids, num_tokens=3) + + assert proposed.shape == (1, 0) + + +def test_returns_empty_for_a_non_positive_num_tokens() -> None: + token_ids = torch.tensor([[10, 20, 30, 10, 20, 30]]) + drafter = PromptLookupDrafter(ngram_size=2) + + assert drafter.propose(token_ids, num_tokens=0).shape == (1, 0) + + +def test_rejects_an_ngram_size_below_one() -> None: + with pytest.raises(ValueError, match="ngram_size"): + PromptLookupDrafter(ngram_size=0) + + +def test_on_accepted_is_a_no_op() -> None: + PromptLookupDrafter().on_accepted(accepted_len=2, rejected_len=1) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_drafters.py -v` +Expected: FAIL (collection error) -- `dispatch.speculative.drafters` does +not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/dispatch/speculative/drafters.py`: + +```python +"""Pluggable draft-token proposers for speculative decoding +(docs/design/2026-09-17-phase-5b-speculative-decoding.md section 3): a +Drafter proposes candidate tokens; decode.py's run_speculative_rounds +verifies them against the target model's own greedy output and tells the +drafter how many were accepted so it can roll back any state it holds (a +draft model's own KV cache; a no-op for stateless drafters like this +module's PromptLookupDrafter). +""" + +from __future__ import annotations + +from typing import Protocol + +import torch + + +class Drafter(Protocol): + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + """token_ids: (1, seq_len), the full sequence so far. Returns up to + num_tokens proposed token ids, shape (1, <=num_tokens) -- a shorter + (including empty, shape (1, 0)) return is valid and the caller + must treat it as a smaller round, not an error.""" + ... + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + """Called once per verification round so a drafter holding its + own cache can roll it back. A no-op for stateless drafters.""" + ... + + +class PromptLookupDrafter: + """No model, no cache: proposes whatever tokens followed the most + recent earlier occurrence of the last `ngram_size` tokens, up to + num_tokens of them. Returns an empty proposal if no match exists, or + if fewer than num_tokens tokens followed the match (never padded) -- + both real, expected outcomes on non-repetitive prompts, not errors.""" + + def __init__(self, ngram_size: int = 3) -> None: + if ngram_size < 1: + raise ValueError(f"ngram_size must be >= 1, got {ngram_size}") + self.ngram_size = ngram_size + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + sequence = token_ids[0] + seq_len = int(sequence.shape[0]) + if num_tokens <= 0 or seq_len <= self.ngram_size: + return sequence.new_empty((1, 0)) + needle = sequence[-self.ngram_size :] + windows = sequence.unfold(0, self.ngram_size, 1)[:-1] # excludes needle's own window + matches = (windows == needle).all(dim=1).nonzero(as_tuple=True)[0] + if matches.numel() == 0: + return sequence.new_empty((1, 0)) + match_end = int(matches[-1]) + self.ngram_size # latest match wins + take = min(num_tokens, seq_len - match_end) + return sequence[match_end : match_end + take].clone().unsqueeze(0) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_drafters.py -v` +Expected: 8 passed. + +- [ ] **Step 5: Lint and typecheck** + +Run: `make lint && make typecheck` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatch/speculative/__init__.py src/dispatch/speculative/drafters.py \ + tests/unit/test_drafters.py +git commit -m "feat: add Drafter protocol and PromptLookupDrafter" +``` + +--- + +### Task 2: `DraftModelDrafter` + +**Files:** +- Modify: `src/dispatch/speculative/drafters.py` +- Modify: `tests/unit/test_drafters.py` + +**Interfaces:** +- Consumes: `transformers.PreTrainedModel`, `transformers.Cache`. +- Produces: `DraftModelDrafter(model: PreTrainedModel)` implementing + `Drafter`; `self.past_key_values: Cache | None` (exposed so + `decode.py`'s tests, Task 3, can assert cache bookkeeping directly if + needed). + +- [ ] **Step 1: Write the failing tests** + +At the top of `tests/unit/test_drafters.py`, change the import line to: + +```python +from dispatch.speculative.drafters import DraftModelDrafter, PromptLookupDrafter +``` + +Then append the following fake model and tests to the end of the file: + +```python +class _FakeCache: + """Tracks only a length -- the toy model below never reads cache + *contents*, only its length, so a cache-rollback bug shows up as a + wrong length, exactly the invariant this task's tests check.""" + + def __init__(self, length: int) -> None: + self.length = length + + def crop(self, tokens_to_remove: int) -> None: + self.length -= tokens_to_remove + + +class _FakeOutputs: + def __init__(self, logits: torch.Tensor, past_key_values: _FakeCache) -> None: + self.logits = logits + self.past_key_values = past_key_values + + +class _FakeIncrementModel: + """A toy causal LM: predicts (input_token + 1) % vocab_size at every + position, deliberately ignoring any actual cache contents -- only + call_count and cache length are used for assertions, not model + quality.""" + + def __init__(self, vocab_size: int = 16) -> None: + self.vocab_size = vocab_size + self.call_count = 0 + + def __call__( + self, *, input_ids: torch.Tensor, past_key_values: _FakeCache | None, use_cache: bool + ) -> _FakeOutputs: + self.call_count += 1 + next_ids = (input_ids + 1) % self.vocab_size + logits = torch.nn.functional.one_hot(next_ids, self.vocab_size).float() * 10.0 + prior_length = 0 if past_key_values is None else past_key_values.length + return _FakeOutputs(logits, _FakeCache(prior_length + input_ids.shape[1])) + + +def test_first_propose_call_feeds_the_whole_prompt() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + prompt = torch.tensor([[3, 4, 5]]) + + proposed = drafter.propose(prompt, num_tokens=2) + + assert proposed.tolist() == [[6, 7]] + assert model.call_count == 2 + assert drafter.past_key_values is not None + # Iteration 1 feeds the whole 3-token prompt (cache was empty); + # iteration 2 feeds only the 1 token iteration 1 just produced. + assert drafter.past_key_values.length == 3 + 1 + + +def test_later_propose_call_feeds_only_the_newest_token() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=2) # seeds the cache, length 3+1=4 + + proposed = drafter.propose(torch.tensor([[3, 4, 5, 6, 7]]), num_tokens=2) + + assert proposed.tolist() == [[8, 9]] + assert drafter.past_key_values is not None + # Cache already holds a real KV entry (length 4), so both iterations of + # this call feed exactly 1 new token each: 4 + 1 + 1 = 6. + assert drafter.past_key_values.length == 4 + 1 + 1 + + +def test_on_accepted_crops_the_cache_by_the_rejected_length() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + # Iteration 1 feeds the whole 3-token prompt; iterations 2-4 each feed + # 1 token: cache length = 3 + 1 + 1 + 1 = 6. + drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=4) + + drafter.on_accepted(accepted_len=1, rejected_len=3) + + assert drafter.past_key_values is not None + assert drafter.past_key_values.length == 6 - 3 + + +def test_on_accepted_before_any_propose_call_is_a_no_op() -> None: + DraftModelDrafter(_FakeIncrementModel()).on_accepted(accepted_len=0, rejected_len=0) # type: ignore[arg-type] + + +def test_propose_with_non_positive_num_tokens_returns_empty_without_calling_the_model() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + + proposed = drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=0) + + assert proposed.shape == (1, 0) + assert model.call_count == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_drafters.py -v` +Expected: FAIL -- `DraftModelDrafter` doesn't exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +At the top of `src/dispatch/speculative/drafters.py`, replace the +`from typing import Protocol` line with: + +```python +from typing import Protocol + +from transformers import Cache, PreTrainedModel +``` + +Then append the following below `PromptLookupDrafter`: + +```python +class DraftModelDrafter: + """Wraps a loaded causal LM and its own KV cache, greedily decoding up + to num_tokens candidates per round. Mirrors harness.py's plain decode + loop's own catch-up pattern (feed the whole sequence when the cache is + empty, otherwise just the newest token) applied across repeated + propose() calls: the drafter's cache always lags the true sequence by + exactly one token (the target's own most recent bonus/correction + token, which the drafter hasn't seen yet) -- the first forward call of + each round after the first both catches that token up and produces + this round's first candidate.""" + + def __init__(self, model: PreTrainedModel) -> None: + self.model = model + self.past_key_values: Cache | None = None + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + if num_tokens <= 0: + return token_ids.new_empty((1, 0)) + next_input = token_ids if self.past_key_values is None else token_ids[:, -1:] + proposed: list[torch.Tensor] = [] + with torch.no_grad(): + for _ in range(num_tokens): + outputs = self.model( + input_ids=next_input, past_key_values=self.past_key_values, use_cache=True + ) + self.past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + proposed.append(next_token) + next_input = next_token + return torch.cat(proposed, dim=1) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + if self.past_key_values is not None: + self.past_key_values.crop(rejected_len) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_drafters.py -v` +Expected: 13 passed. + +- [ ] **Step 5: Lint and typecheck** + +Run: `make lint && make typecheck` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatch/speculative/drafters.py tests/unit/test_drafters.py +git commit -m "feat: add DraftModelDrafter" +``` + +--- + +### Task 3: The shared speculative decode loop + +**Files:** +- Create: `src/dispatch/speculative/decode.py` +- Test: `tests/unit/test_decode.py` + +**Interfaces:** +- Consumes: `Drafter`, `PromptLookupDrafter` (Task 1). `TokenTimings` + (`benchmark/metrics.py`, unmodified). `transformers.PreTrainedModel`, + `transformers.PreTrainedTokenizerBase`. +- Produces: `SpeculativeRoundsResult` (frozen dataclass: `token_times: + tuple[float, ...]`, `accepted_lengths: tuple[int, ...]`, + `generated_token_ids: tuple[int, ...]`, `past_key_values: object`). + `run_speculative_rounds(model, drafter: Drafter | None, token_ids: + torch.Tensor, *, num_speculative_tokens: int, max_new_tokens: int, + eos_token_id: int | None, clock_fn=time.perf_counter) -> + SpeculativeRoundsResult`. `speculative_generate(model, tokenizer, + drafter: Drafter | None, prompt: str, *, num_speculative_tokens: int, + max_new_tokens: int = 32, device: str = "cpu", clock_fn=time.perf_counter) + -> tuple[TokenTimings, tuple[int, ...], tuple[int, ...]]`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/test_decode.py`: + +```python +"""CPU-only correctness of the shared speculative-decoding loop, against +a toy causal LM whose predictions are a pure, memoryless function of the +current input token -- deliberately so that a cache-rollback bug is +caught only by the explicit cache-length assertions below, never by a +lucky output-correctness coincidence. Per +docs/design/2026-09-17-phase-5b-speculative-decoding.md sections 3-5: +speculative decoding's output must be byte-identical to plain sequential +greedy decoding, regardless of whether the drafter is ever right. +""" + +from __future__ import annotations + +import pytest +import torch + +from dispatch.speculative.decode import run_speculative_rounds, speculative_generate +from dispatch.speculative.drafters import PromptLookupDrafter + +VOCAB_SIZE = 16 + + +class _FakeCache: + def __init__(self, length: int) -> None: + self.length = length + + def crop(self, tokens_to_remove: int) -> None: + self.length -= tokens_to_remove + + +class _FakeOutputs: + def __init__(self, logits: torch.Tensor, past_key_values: _FakeCache) -> None: + self.logits = logits + self.past_key_values = past_key_values + + +class _FakeIncrementModel: + """logits at every position spike at (that position's input token + 1) + % VOCAB_SIZE, regardless of past_key_values' contents -- so the + expected greedy continuation of any prompt is the closed-form + +1-mod-VOCAB_SIZE sequence computed by _expected_continuation below.""" + + def __call__( + self, *, input_ids: torch.Tensor, past_key_values: _FakeCache | None, use_cache: bool + ) -> _FakeOutputs: + next_ids = (input_ids + 1) % VOCAB_SIZE + logits = torch.nn.functional.one_hot(next_ids, VOCAB_SIZE).float() * 10.0 + prior_length = 0 if past_key_values is None else past_key_values.length + return _FakeOutputs(logits, _FakeCache(prior_length + input_ids.shape[1])) + + +class _AlwaysCorrectDrafter: + """Proposes exactly what _FakeIncrementModel will predict.""" + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + last = int(token_ids[0, -1]) + return torch.tensor([[(last + 1 + i) % VOCAB_SIZE for i in range(num_tokens)]]) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + +class _AlwaysWrongDrafter: + """Proposes a constant offset that can never match _FakeIncrementModel's + +1 rule (VOCAB_SIZE > 2, so +2 != +1 mod VOCAB_SIZE).""" + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + last = int(token_ids[0, -1]) + return torch.tensor([[(last + 2) % VOCAB_SIZE for _ in range(num_tokens)]]) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + +def _expected_continuation(prompt: list[int], num_tokens: int) -> list[int]: + sequence = list(prompt) + for _ in range(num_tokens): + sequence.append((sequence[-1] + 1) % VOCAB_SIZE) + return sequence[len(prompt) :] + + +def test_always_correct_drafter_matches_the_closed_form_continuation() -> None: + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysCorrectDrafter(), + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + # 2 rounds of k=4, each fully accepted (5 tokens emitted per round: 4 + # candidates + 1 bonus), summing to the 10 requested. + assert result.accepted_lengths == (4, 4) + + +def test_always_wrong_drafter_still_matches_the_closed_form_continuation() -> None: + """Correctness must hold even when the drafter is never right -- the + target's own bonus/correction token is what actually advances the + sequence every round.""" + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysWrongDrafter(), + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + assert all(length == 0 for length in result.accepted_lengths) + + +def test_none_drafter_matches_the_closed_form_continuation() -> None: + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + None, + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + # drafter=None means candidates are always empty, so every round emits + # exactly 1 (bonus) token -- 10 rounds for 10 requested tokens. + assert result.accepted_lengths == (0,) * 10 + + +def test_prompt_lookup_drafter_matches_the_continuation_and_accepts_something() -> None: + # [5, 6, 5] with ngram_size=1: the last token (5) also occurred at + # position 0, where it was followed by 6 -- exactly what + # _FakeIncrementModel's own +1 rule predicts after the current 5 too + # (its rule is a pure function of the token's value, memoryless), so + # the drafter's guess here is genuinely correct, not just harmless. + prompt = [5, 6, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + PromptLookupDrafter(ngram_size=1), + torch.tensor([prompt]), + num_speculative_tokens=1, + max_new_tokens=4, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 4) + assert sum(result.accepted_lengths) > 0 + + +def test_cache_length_invariant_holds_after_multiple_rejecting_rounds() -> None: + """The target's cache always lags the full sequence (prompt + + generated) by exactly one token -- the most recent bonus/correction + token is never fed back in until the *next* round. A cache-rollback + bug (e.g. forgetting to crop) breaks this even though + _FakeIncrementModel's predictions don't depend on cache contents, so + output correctness alone would not catch it.""" + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysWrongDrafter(), # every round rejects, exercising crop(rejected_len > 0) + torch.tensor([prompt]), + num_speculative_tokens=2, + max_new_tokens=6, + eos_token_id=None, + ) + + total_sequence_length = len(prompt) + len(result.generated_token_ids) + assert result.past_key_values.length == total_sequence_length - 1 # type: ignore[attr-defined] + + +def test_speculative_generate_matches_plain_decode_via_a_fake_tokenizer() -> None: + class _FakeBatchEncoding(dict): # type: ignore[type-arg] + def to(self, device: str) -> _FakeBatchEncoding: + return self + + class _FakeTokenizer: + eos_token_id = 999 + + def __call__(self, prompt: str, return_tensors: str) -> _FakeBatchEncoding: + return _FakeBatchEncoding(input_ids=torch.tensor([[3, 4, 5]])) + + timing, accepted_lengths, generated = speculative_generate( + _FakeIncrementModel(), # type: ignore[arg-type] + _FakeTokenizer(), # type: ignore[arg-type] + _AlwaysCorrectDrafter(), + "prompt", + num_speculative_tokens=2, + max_new_tokens=4, + ) + + assert list(generated) == _expected_continuation([3, 4, 5], 4) + assert timing.generated_token_count == 4 + assert timing.prompt_token_count == 3 + # Round 1 (k=2): both candidates accepted, +1 bonus = 3 tokens emitted. + # Round 2 (room=1, k=1): the 1 candidate accepted, no room left for a + # bonus = 1 token emitted. accepted_lengths = (2, 1), summing to 3. + assert sum(accepted_lengths) == 3 + + +@pytest.mark.slow +def test_speculative_generate_matches_plain_greedy_decode_on_a_real_tiny_model() -> None: + """The strongest correctness proof: a real model, real tokenizer, real + DynamicCache.crop() (transformers>=5.17.0) -- not the toy model above. + Uses a genuinely repetitive prompt so PromptLookupDrafter has real + matches to find. Plain greedy decoding is run manually, token-for-token + (generate_with_timings only returns timings, not the generated ids), + to get real ids to check token-for-token equality against.""" + from dispatch.benchmark.harness import load_model + + model, tokenizer = load_model("hf-internal-testing/tiny-random-gpt2") + prompt = "the cat sat on the mat the cat sat on the mat" + max_new_tokens = 8 + + input_ids = tokenizer(prompt, return_tensors="pt").input_ids + plain_ids: list[int] = [] + past_key_values = None + next_input = input_ids + with torch.no_grad(): + for _ in range(max_new_tokens): + outputs = model(input_ids=next_input, past_key_values=past_key_values, use_cache=True) + past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + plain_ids.append(int(next_token.item())) + next_input = next_token + + _, _, speculative_ids = speculative_generate( + model, + tokenizer, + PromptLookupDrafter(ngram_size=3), + prompt, + num_speculative_tokens=4, + max_new_tokens=max_new_tokens, + ) + + assert list(speculative_ids) == plain_ids +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_decode.py -v -m "not slow"` +Expected: FAIL (collection error) -- `dispatch.speculative.decode` does +not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/dispatch/speculative/decode.py`: + +```python +"""The shared propose/verify/accept/rollback speculative-decoding loop +(docs/design/2026-09-17-phase-5b-speculative-decoding.md sections 3-4), +parameterized by a Drafter so neither drafter implementation duplicates +verification or KV-cache-rollback logic. Because both the target and (for +DraftModelDrafter) the drafter decode strictly greedily, and a candidate +is accepted iff it equals the target's own greedy argmax, this loop's +output is a deterministic function of the target model alone -- it must +produce byte-identical tokens to plain sequential greedy decoding of the +same target (design doc section 5). +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass + +import torch +from transformers import PreTrainedModel, PreTrainedTokenizerBase + +from dispatch.benchmark.metrics import TokenTimings +from dispatch.speculative.drafters import Drafter + + +@dataclass(frozen=True) +class SpeculativeRoundsResult: + """past_key_values is exposed only so tests can assert the + cache-length invariant (design doc section 5) directly -- callers of + speculative_generate don't need it.""" + + token_times: tuple[float, ...] + accepted_lengths: tuple[int, ...] + generated_token_ids: tuple[int, ...] + past_key_values: object + + +def run_speculative_rounds( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model: PreTrainedModel, + drafter: Drafter | None, + token_ids: torch.Tensor, + *, + num_speculative_tokens: int, + max_new_tokens: int, + eos_token_id: int | None, + clock_fn: Callable[[], float] = time.perf_counter, +) -> SpeculativeRoundsResult: + token_times: list[float] = [] + accepted_lengths: list[int] = [] + generated: list[int] = [] + past_key_values = None + total_new = 0 + + with torch.no_grad(): + while total_new < max_new_tokens: + room = max_new_tokens - total_new + k = min(num_speculative_tokens, room) + candidates = ( + drafter.propose(token_ids, k) + if drafter is not None + else token_ids.new_empty((1, 0)) + ) + num_candidates = int(candidates.shape[-1]) + + step_input = token_ids if past_key_values is None else token_ids[:, -1:] + forward_input = torch.cat([step_input, candidates], dim=1) + outputs = model( + input_ids=forward_input, past_key_values=past_key_values, use_cache=True + ) + past_key_values = outputs.past_key_values + assert past_key_values is not None # use_cache=True guarantees this + + offset = step_input.shape[1] - 1 + target_predictions = outputs.logits[0, offset:, :].argmax(dim=-1) + + accepted_len = 0 + while accepted_len < num_candidates and int(target_predictions[accepted_len]) == int( + candidates[0, accepted_len] + ): + accepted_len += 1 + accepted_lengths.append(accepted_len) + + rejected_len = num_candidates - accepted_len + past_key_values.crop(rejected_len) + if drafter is not None: + drafter.on_accepted(accepted_len, rejected_len) + + emitted = candidates[:, :accepted_len] + if accepted_len < room: + bonus_token = target_predictions[accepted_len].view(1, 1) + emitted = torch.cat([emitted, bonus_token], dim=1) + + token_ids = torch.cat([token_ids, emitted], dim=1) + emitted_list = emitted[0].tolist() + for _ in emitted_list: + token_times.append(clock_fn()) + generated.extend(int(t) for t in emitted_list) + total_new += emitted.shape[1] + + if eos_token_id is not None and eos_token_id in emitted_list: + break + + return SpeculativeRoundsResult( + token_times=tuple(token_times), + accepted_lengths=tuple(accepted_lengths), + generated_token_ids=tuple(generated), + past_key_values=past_key_values, + ) + + +def speculative_generate( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model: PreTrainedModel, + tokenizer: PreTrainedTokenizerBase, + drafter: Drafter | None, + prompt: str, + *, + num_speculative_tokens: int, + max_new_tokens: int = 32, + device: str = "cpu", + clock_fn: Callable[[], float] = time.perf_counter, +) -> tuple[TokenTimings, tuple[int, ...], tuple[int, ...]]: + """Returns per-token timings (matching harness.generate_with_timings's + shape, so benchmark/metrics.py's summarize() works unchanged), each + round's accepted-candidate count (the raw data behind the + acceptance-rate metric), and the generated token ids.""" + inputs = tokenizer(prompt, return_tensors="pt").to(device) + token_ids = inputs["input_ids"] + prompt_token_count = int(token_ids.shape[-1]) + start_time = clock_fn() + + result = run_speculative_rounds( + model, + drafter, + token_ids, + num_speculative_tokens=num_speculative_tokens, + max_new_tokens=max_new_tokens, + eos_token_id=tokenizer.eos_token_id, + clock_fn=clock_fn, + ) + + timing = TokenTimings( + start_time=start_time, + token_times=result.token_times, + prompt_token_count=prompt_token_count, + ) + return timing, result.accepted_lengths, result.generated_token_ids +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_decode.py -v -m "not slow"` +Expected: 6 passed. + +Run (network access required, no GPU): `uv run pytest tests/unit/test_decode.py -v -m slow` +Expected: 1 passed. + +- [ ] **Step 5: Lint and typecheck** + +Run: `make lint && make typecheck` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatch/speculative/decode.py tests/unit/test_decode.py +git commit -m "feat: add the shared speculative decode loop" +``` + +--- + +### Task 4: Exact generated-token reference capture/compare + +**Files:** +- Create: `src/dispatch/speculative/reference.py` +- Test: `tests/unit/test_speculative_reference.py` + +**Interfaces:** +- Produces: `save_generated_tokens(tokens: dict[str, tuple[int, ...]], + path: Path) -> None`. `load_generated_tokens(path: Path) -> dict[str, + tuple[int, ...]]`. `compare_generated_tokens(actual: dict[str, + tuple[int, ...]], reference: dict[str, tuple[int, ...]]) -> dict[str, + bool]`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/test_speculative_reference.py`: + +```python +"""CPU-only: exact generated-token save/load/compare -- speculative +decoding's correctness claim (docs/design/2026-09-17-phase-5b-speculative-decoding.md +section 5) is byte-exact token match, not a logit tolerance, so this is a +separate, stricter mechanism than benchmark/reference.py's +compare_top_k_agreement. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dispatch.speculative.reference import ( + compare_generated_tokens, + load_generated_tokens, + save_generated_tokens, +) + + +def test_save_and_load_round_trip(tmp_path: Path) -> None: + tokens = {"prompt_000_tokens": (1, 2, 3), "prompt_001_tokens": (4, 5)} + path = tmp_path / "generated-tokens.json" + + save_generated_tokens(tokens, path) + loaded = load_generated_tokens(path) + + assert loaded == tokens + + +def test_compare_reports_true_when_identical() -> None: + tokens = {"prompt_000_tokens": (1, 2, 3)} + + assert compare_generated_tokens(tokens, tokens) == {"prompt_000_tokens": True} + + +def test_compare_reports_false_on_any_divergence() -> None: + actual = {"prompt_000_tokens": (1, 2, 9)} + reference = {"prompt_000_tokens": (1, 2, 3)} + + assert compare_generated_tokens(actual, reference) == {"prompt_000_tokens": False} + + +def test_compare_rejects_a_key_mismatch() -> None: + with pytest.raises(ValueError, match="key mismatch"): + compare_generated_tokens({"a": (1,)}, {"b": (1,)}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_speculative_reference.py -v` +Expected: FAIL (collection error) -- `dispatch.speculative.reference` +does not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Create `src/dispatch/speculative/reference.py`: + +```python +"""Exact generated-token reference capture/compare for speculative +decoding's correctness gate (docs/design/2026-09-17-phase-5b-speculative-decoding.md +section 5). Unlike benchmark/reference.py's compare_top_k_agreement (built +for kernel-swap comparisons that may legitimately diverge a little), +speculative decoding's claim is that its *generated token ids* are +byte-identical to plain greedy decoding of the same target -- so this +compares exact integer sequences, not logits within a tolerance. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def save_generated_tokens(tokens: dict[str, tuple[int, ...]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({key: list(value) for key, value in tokens.items()}, indent=2)) + + +def load_generated_tokens(path: Path) -> dict[str, tuple[int, ...]]: + raw: dict[str, list[int]] = json.loads(path.read_text()) + return {key: tuple(value) for key, value in raw.items()} + + +def compare_generated_tokens( + actual: dict[str, tuple[int, ...]], reference: dict[str, tuple[int, ...]] +) -> dict[str, bool]: + if actual.keys() != reference.keys(): + raise ValueError( + f"key mismatch: actual has {sorted(actual.keys())}, " + f"reference has {sorted(reference.keys())}" + ) + return {key: actual[key] == reference[key] for key in reference} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_speculative_reference.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Lint and typecheck** + +Run: `make lint && make typecheck` +Expected: both clean. + +- [ ] **Step 6: Commit** + +```bash +git add src/dispatch/speculative/reference.py tests/unit/test_speculative_reference.py +git commit -m "feat: add exact generated-token reference capture/compare" +``` + +--- + +### Task 5: CLI -- `scripts/run_speculative_bench.py` + +**Files:** +- Create: `scripts/run_speculative_bench.py` +- Test: `tests/unit/test_run_speculative_bench.py` + +**Interfaces:** +- Consumes: `load_model` (`benchmark/harness.py`). `summarize` + (`benchmark/metrics.py`). `resolve_quantized_backend` + (`kernels/backends.py`). `patch_moe_infer_quantized` + (`kernels/integration.py`). `speculative_generate` (Task 3). `Drafter`, + `DraftModelDrafter`, `PromptLookupDrafter` (Tasks 1-2). + `save_generated_tokens`, `load_generated_tokens`, + `compare_generated_tokens` (Task 4). `DEFAULT_PROMPTS` + (`scripts/run_baseline.py`). +- Produces: `SPECULATIVE_PROMPTS: list[str]`. `DRAFTERS: tuple[str, ...]`. + `build_drafter(drafter_name, *, draft_model_name, device, dtype, + prompt_lookup_ngram_size) -> Drafter | None`. + `run_speculative_bench(model_name, *, device, dtype, + trust_remote_code, prompts, repetitions, max_new_tokens, drafter_name, + draft_model_name, num_speculative_tokens, prompt_lookup_ngram_size) -> + tuple[list[tuple[TokenTimings, tuple[int, ...]]], dict[str, tuple[int, + ...]], int]`. `main(argv)`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/test_run_speculative_bench.py`: + +```python +"""main()'s plumbing (args -> files) is tested fast with +run_speculative_bench and summarize monkeypatched out, matching +test_run_baseline.py's pattern; the real end-to-end guard test is `slow`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import scripts.run_speculative_bench as run_speculative_bench_module +import torch +from scripts.run_speculative_bench import build_drafter, main + +from dispatch.benchmark.metrics import BenchmarkSummary +from dispatch.speculative.drafters import DraftModelDrafter, PromptLookupDrafter +from dispatch.speculative.reference import save_generated_tokens + +FAKE_SUMMARY = BenchmarkSummary( + run_count=1, + mean_ttft=0.1, + p50_ttft=0.1, + p99_ttft=0.1, + mean_inter_token_latency=0.05, + mean_tokens_per_second=20.0, +) +FAKE_TOKENS = {"prompt_000_tokens": (1, 2, 3)} + + +def _fake_run_speculative_bench( + tokens: dict[str, tuple[int, ...]], moe_layers_patched: int +) -> object: + def fake(model_name: str, **kwargs: object) -> tuple[list[object], dict, int]: # type: ignore[type-arg] + return [(object(), (2, 1))], tokens, moe_layers_patched + + return fake + + +def test_build_drafter_none_returns_none() -> None: + assert ( + build_drafter( + "none", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + is None + ) + + +def test_build_drafter_prompt_lookup_returns_a_prompt_lookup_drafter() -> None: + drafter = build_drafter( + "prompt-lookup", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=5, + ) + + assert isinstance(drafter, PromptLookupDrafter) + assert drafter.ngram_size == 5 + + +def test_build_drafter_draft_model_loads_and_wraps_it(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + run_speculative_bench_module, "load_model", lambda *a, **k: ("the-model", "the-tokenizer") + ) + + drafter = build_drafter( + "draft-model", + draft_model_name="deepseek-ai/deepseek-llm-7b-base", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + + assert isinstance(drafter, DraftModelDrafter) + assert drafter.model == "the-model" + + +def test_build_drafter_rejects_an_unknown_name() -> None: + with pytest.raises(ValueError, match="unknown drafter"): + build_drafter( + "bogus", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + + +def test_main_writes_results_and_generated_tokens( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(FAKE_TOKENS, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + main( + [ + "--model-name", + "tiny/test-model", + "--output-dir", + str(tmp_path), + "--run-label", + "test-run", + ] + ) + + results = json.loads((tmp_path / "test-run-results.json").read_text()) + assert results["model"] == "tiny/test-model" + assert results["mean_tokens_per_second"] == 20.0 + assert results["moe_layers_patched"] == 27 + assert results["acceptance_rate"] == pytest.approx( + 1.5 / 4 + ) # mean(2, 1) / num_speculative_tokens + assert results["token_match"] == {} + assert (tmp_path / "test-run-generated-tokens.json").exists() + + +def test_main_records_agreement_with_a_baseline( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + reference_path = tmp_path / "baseline-generated-tokens.json" + save_generated_tokens(FAKE_TOKENS, reference_path) + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(FAKE_TOKENS, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + main( + [ + "--drafter", + "prompt-lookup", + "--compare-generated-tokens", + str(reference_path), + "--output-dir", + str(tmp_path), + "--run-label", + "spec-run", + ] + ) + + results = json.loads((tmp_path / "spec-run-results.json").read_text()) + assert results["token_match"] == {"prompt_000_tokens": True} + + +def test_main_exits_nonzero_on_token_divergence_but_keeps_the_evidence( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + reference_path = tmp_path / "baseline-generated-tokens.json" + save_generated_tokens(FAKE_TOKENS, reference_path) + wrong_tokens = {"prompt_000_tokens": (1, 2, 9)} + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(wrong_tokens, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + with pytest.raises(SystemExit, match="disagree"): + main( + [ + "--drafter", + "draft-model", + "--compare-generated-tokens", + str(reference_path), + "--output-dir", + str(tmp_path), + "--run-label", + "spec-run", + ] + ) + + results = json.loads((tmp_path / "spec-run-results.json").read_text()) + assert results["token_match"] == {"prompt_000_tokens": False} + assert (tmp_path / "spec-run-generated-tokens.json").exists() + + +@pytest.mark.slow +def test_run_speculative_bench_refuses_a_target_that_patches_nothing() -> None: + import scripts.run_speculative_bench as module + + with pytest.raises(RuntimeError, match="patched no MoE layers"): + module.run_speculative_bench( + "hf-internal-testing/tiny-random-gpt2", + device="cpu", + dtype=torch.float32, + trust_remote_code=False, + prompts=["hello"], + repetitions=1, + max_new_tokens=3, + drafter_name="none", + draft_model_name="unused", + num_speculative_tokens=4, + prompt_lookup_ngram_size=3, + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/unit/test_run_speculative_bench.py -v -m "not slow"` +Expected: FAIL (collection error) -- `scripts.run_speculative_bench` does +not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Create `scripts/run_speculative_bench.py`: + +```python +"""CLI: speculative decoding on top of dispatch's own int8-quantized +target kernel (Phase 5a) -- either a real draft model +(deepseek-llm-7b-base) or model-free prompt-lookup decoding. Writes +latency/throughput and acceptance rate, and with +--compare-generated-tokens, checks the run's exact generated token ids +against a same-session baseline and exits non-zero on any divergence. +docs/design/2026-09-17-phase-5b-speculative-decoding.md. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import time +from dataclasses import asdict +from pathlib import Path + +import torch + +from dispatch.benchmark.harness import load_model +from dispatch.benchmark.metrics import TokenTimings, summarize +from dispatch.kernels.backends import resolve_quantized_backend +from dispatch.kernels.integration import patch_moe_infer_quantized +from dispatch.speculative.decode import speculative_generate +from dispatch.speculative.drafters import DraftModelDrafter, Drafter, PromptLookupDrafter +from dispatch.speculative.reference import ( + compare_generated_tokens, + load_generated_tokens, + save_generated_tokens, +) +from scripts.run_baseline import DEFAULT_PROMPTS + +REPETITION_PROMPT = ( + "Please restate the following sentence exactly twice, back to back: " + "'The system will now process each incoming request in the order it was received.'" +) +SPECULATIVE_PROMPTS = [*DEFAULT_PROMPTS, REPETITION_PROMPT] +DRAFTERS = ("none", "draft-model", "prompt-lookup") + + +def build_drafter( + drafter_name: str, + *, + draft_model_name: str, + device: str, + dtype: torch.dtype, + prompt_lookup_ngram_size: int, +) -> Drafter | None: + if drafter_name == "none": + return None + if drafter_name == "prompt-lookup": + return PromptLookupDrafter(ngram_size=prompt_lookup_ngram_size) + if drafter_name == "draft-model": + draft_model, _ = load_model(draft_model_name, device=device, dtype=dtype) + return DraftModelDrafter(draft_model) + raise ValueError(f"unknown drafter {drafter_name!r}; expected one of {DRAFTERS}") + + +def run_speculative_bench( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model_name: str, + *, + device: str, + dtype: torch.dtype, + trust_remote_code: bool, + prompts: list[str], + repetitions: int, + max_new_tokens: int, + drafter_name: str, + draft_model_name: str, + num_speculative_tokens: int, + prompt_lookup_ngram_size: int, +) -> tuple[list[tuple[TokenTimings, tuple[int, ...]]], dict[str, tuple[int, ...]], int]: + """Returns the timed runs paired with each run's per-round accepted + lengths, the first repetition's generated tokens per prompt (greedy + decoding is deterministic, so later repetitions would be identical), + and how many MoE layers were patched.""" + model, tokenizer = load_model( + model_name, device=device, dtype=dtype, trust_remote_code=trust_remote_code + ) + moe_layers_patched = patch_moe_infer_quantized(model, resolve_quantized_backend()) + if moe_layers_patched == 0: + raise RuntimeError( + f"quantized target patched no MoE layers: {model_name} has no moe_infer to " + "replace, so this run would time the stock model under the quantized kernel's name" + ) + + drafter = build_drafter( + drafter_name, + draft_model_name=draft_model_name, + device=device, + dtype=dtype, + prompt_lookup_ngram_size=prompt_lookup_ngram_size, + ) + + runs: list[tuple[TokenTimings, tuple[int, ...]]] = [] + generated_tokens: dict[str, tuple[int, ...]] = {} + for prompt_index, prompt in enumerate(prompts): + for repetition in range(repetitions): + timing, accepted_lengths, tokens = speculative_generate( + model, + tokenizer, + drafter, + prompt, + num_speculative_tokens=num_speculative_tokens, + max_new_tokens=max_new_tokens, + device=device, + ) + runs.append((timing, accepted_lengths)) + if repetition == 0: + generated_tokens[f"prompt_{prompt_index:03d}_tokens"] = tokens + + return runs, generated_tokens, moe_layers_patched + + +def _acceptance_rate( + runs: list[tuple[TokenTimings, tuple[int, ...]]], num_speculative_tokens: int +) -> float | None: + lengths = [length for _, accepted_lengths in runs for length in accepted_lengths] + if not lengths or num_speculative_tokens <= 0: + return None + return statistics.mean(lengths) / num_speculative_tokens + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Run dispatch's speculative-decoding benchmark (Phase 5b)" + ) + parser.add_argument("--model-name", default="deepseek-ai/deepseek-moe-16b-base") + parser.add_argument("--draft-model-name", default="deepseek-ai/deepseek-llm-7b-base") + parser.add_argument("--device", default="cuda") + parser.add_argument("--dtype", default="bfloat16", choices=["float32", "bfloat16", "float16"]) + parser.add_argument("--trust-remote-code", action="store_true") + parser.add_argument("--repetitions", type=int, default=5) + parser.add_argument("--max-new-tokens", type=int, default=64) + parser.add_argument("--output-dir", type=Path, default=Path("docs/findings")) + parser.add_argument("--run-label", default=time.strftime("%Y-%m-%d-speculative")) + parser.add_argument("--drafter", default="none", choices=DRAFTERS) + parser.add_argument("--num-speculative-tokens", type=int, default=4) + parser.add_argument("--prompt-lookup-ngram-size", type=int, default=3) + parser.add_argument( + "--compare-generated-tokens", + type=Path, + default=None, + help="generated-tokens JSON from a same-session baseline run of the same model and prompts", + ) + args = parser.parse_args(argv) + + dtype: torch.dtype = getattr(torch, args.dtype) + runs, generated_tokens, moe_layers_patched = run_speculative_bench( + args.model_name, + device=args.device, + dtype=dtype, + trust_remote_code=args.trust_remote_code, + prompts=SPECULATIVE_PROMPTS, + repetitions=args.repetitions, + max_new_tokens=args.max_new_tokens, + drafter_name=args.drafter, + draft_model_name=args.draft_model_name, + num_speculative_tokens=args.num_speculative_tokens, + prompt_lookup_ngram_size=args.prompt_lookup_ngram_size, + ) + timings = [timing for timing, _ in runs] + summary = summarize(timings) + acceptance_rate = _acceptance_rate(runs, args.num_speculative_tokens) + + args.output_dir.mkdir(parents=True, exist_ok=True) + tokens_path = args.output_dir / f"{args.run_label}-generated-tokens.json" + save_generated_tokens(generated_tokens, tokens_path) + print(f"wrote {tokens_path}") + + comparison = ( + compare_generated_tokens( + generated_tokens, load_generated_tokens(args.compare_generated_tokens) + ) + if args.compare_generated_tokens is not None + else {} + ) + + results_path = args.output_dir / f"{args.run_label}-results.json" + results_path.write_text( + json.dumps( + { + "model": args.model_name, + "draft_model": args.draft_model_name if args.drafter == "draft-model" else None, + "device": args.device, + "dtype": args.dtype, + "drafter": args.drafter, + "num_speculative_tokens": args.num_speculative_tokens, + "prompt_lookup_ngram_size": args.prompt_lookup_ngram_size, + "moe_layers_patched": moe_layers_patched, + "acceptance_rate": acceptance_rate, + **asdict(summary), + "token_match": comparison, + }, + indent=2, + ) + ) + print(f"wrote {results_path}") + + if comparison and not all(comparison.values()): + raise SystemExit( + f"{args.drafter} generated tokens disagree with " + f"{args.compare_generated_tokens} -- see {results_path}" + ) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/unit/test_run_speculative_bench.py -v -m "not slow"` +Expected: 8 passed. + +Run (network access required, no GPU): `uv run pytest tests/unit/test_run_speculative_bench.py -v -m slow` +Expected: 1 passed. + +- [ ] **Step 5: Run the full CPU-only suite** + +Run: `make test` +Expected: all pass, nothing else broken by this task's edits. + +- [ ] **Step 6: Lint and typecheck** + +Run: `make lint && make typecheck` +Expected: both clean. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/run_speculative_bench.py tests/unit/test_run_speculative_bench.py +git commit -m "feat: add scripts/run_speculative_bench.py" +``` + +--- + +### Task 6: GPU rental runbook -- correctness gate, measured run, k-sweep + +**Files:** +- Create: `docs/runbooks/phase-5b-speculative-decoding.md` +- Creates live, on the pod / written by the CLI (not pre-committed as + code, committed as evidence after the session): + `docs/findings/-phase-5b-baseline-generated-tokens.json`, + `docs/findings/-phase-5b-baseline-results.json`, + `docs/findings/-phase-5b-draft-model-results.json`, + `docs/findings/-phase-5b-prompt-lookup-results.json`, + `docs/findings/-phase-5b-k-sweep-*-results.json`, + `docs/findings/-phase-5b-memory-checkpoints.json` +- Create (via `write_cost_record`, reused unmodified): + `docs/findings/-phase-5b-speculative-decoding-cost.md` + +**Budget cap: $10, a ceiling not a target -- be surgical (Global +Constraints). This is a live session with the user, not something to run +unattended -- get explicit go-ahead and confirm the cap before renting.** + +- [ ] **Step 1: Write the runbook** + +Create `docs/runbooks/phase-5b-speculative-decoding.md`: + +````markdown +# Runbook: Phase 5b speculative decoding (rented GPU) + +One combined session -- correctness gate, then the measured run and a +targeted k-sweep -- per +docs/design/2026-09-17-phase-5b-speculative-decoding.md section 8. +Budget cap: $10, a ceiling not a target. + +1. Pick the cheapest L40-class card (Phase 5a's tier) with enough memory + for the quantized target plus the bf16 draft model resident together + (design doc section 2: quantizing the target frees ~13.9GB, per Phase + 5a's own measurement, specifically to make room for this), checked + live against real-time availability. +2. Create and wait, with a 60GB+ volume (Phase 0's real trap: the target + model alone is 32.8GB and this session also downloads a 7B draft + model): + + uv run python -m scripts.gpu.provision create --name dispatch-phase-5b \ + --gpu-type "" --image "" \ + --cloud SECURE --disk-gb 70 + uv run python -m scripts.gpu.provision wait --pod-id + +3. Confirm the card: + + nvidia-smi --query-gpu=name,compute_cap,memory.total --format=csv + +4. Transfer and set up, all of Phase 0/1/5a's known environment traps in + one place -- **confirm live whether DeepSeek's `modeling_deepseek.py` + still needs these** (check + https://huggingface.co/deepseek-ai/deepseek-moe-16b-base/commits/main + since Phase 5a's session) before assuming either is still required: + + git archive HEAD | ssh "mkdir -p dispatch && tar -x -C dispatch" + ssh + cd dispatch + export HF_HOME=/workspace/hf_cache # NOT the container disk + export HF_HUB_ENABLE_HF_TRANSFER=1 + command -v uv || pip install uv + uv sync --all-extras --dev + uv pip install hf_transfer # Phase 5a's session needed this explicitly + uv pip install transformers==4.57.6 # only if step 4's live check still shows the break + + If the `get_usable_length` break (Phase 0/1/5a) is still present, use + the same pod-local, never-committed wrapper prior sessions used: + + cat > _patch_and_run.py <<'EOF' + import sys + from transformers.cache_utils import DynamicCache + + + def _get_usable_length(self, new_seq_length=None, layer_idx=0): + return self.get_seq_length(layer_idx) + + + DynamicCache.get_usable_length = _get_usable_length + + from scripts.run_speculative_bench import main + + main(sys.argv[1:]) + EOF + + From here on invoke `.venv/bin/python` directly (`uv run` re-syncs to + `uv.lock` on every call and silently undoes the `transformers` + override). If the wrapper isn't needed, invoke + `scripts/run_speculative_bench.py` directly instead of + `_patch_and_run.py` in every command below. + +5. Full CPU-testable suite, one more time, on the pod itself: + + .venv/bin/python -m pytest -m "not gpu" -v + + Expected: all pass (confirms the pod's environment doesn't disagree + with what already passed locally, including the `slow` real-tiny-model + tests from Tasks 3 and 5). + +6. **Memory checkpoint after loading the target alone** (design doc + section 8's named risk: two real models resident on one GPU is new + territory for this project): + + DATE=$(date +%Y-%m-%d) + .venv/bin/python <<'EOF' + import json + import torch + + from dispatch.benchmark.harness import load_model + from dispatch.kernels.backends import resolve_quantized_backend + from dispatch.kernels.integration import patch_moe_infer_quantized + + model, _ = load_model( + "deepseek-ai/deepseek-moe-16b-base", + device="cuda", dtype=torch.bfloat16, trust_remote_code=True, + ) + patched = patch_moe_infer_quantized(model, resolve_quantized_backend()) + checkpoint = { + "after": "quantized target only", + "moe_layers_patched": patched, + "allocated_gb": round(torch.cuda.memory_allocated() / 2**30, 2), + "reserved_gb": round(torch.cuda.memory_reserved() / 2**30, 2), + } + print(json.dumps(checkpoint, indent=2)) + with open("target_checkpoint.json", "w") as f: + json.dump(checkpoint, f) + EOF + + If `allocated_gb` already leaves no plausible room for a 7B bf16 draft + (~14GB) under the card's total memory (from step 3), stop and report + this as a named finding -- do not proceed to load the draft model on a + card that clearly can't hold both; downsize the target's dtype further + or move to a card with more memory instead of discovering an OOM live. + +7. **Memory checkpoint after also loading the draft model:** + + .venv/bin/python <<'EOF' + import json + import torch + + from dispatch.benchmark.harness import load_model + + # Continues from step 6's session if run in the same process (a + # notebook/REPL); otherwise re-run step 6's loading first, then: + draft_model, _ = load_model( + "deepseek-ai/deepseek-llm-7b-base", device="cuda", dtype=torch.bfloat16, + ) + checkpoint = { + "after": "quantized target + bf16 draft model", + "allocated_gb": round(torch.cuda.memory_allocated() / 2**30, 2), + "reserved_gb": round(torch.cuda.memory_reserved() / 2**30, 2), + } + print(json.dumps(checkpoint, indent=2)) + with open("both_checkpoint.json", "w") as f: + json.dump(checkpoint, f) + EOF + + cat target_checkpoint.json both_checkpoint.json > \ + docs/findings/$DATE-phase-5b-memory-checkpoints.json + + Anything that OOMs here: stop, report exactly what was measured before + the failure (same discipline as Phase 5a's own mid-session OOM + finding), and decide live whether to move to a larger card within the + $10 cap before continuing. + +8. **The baseline run** (`--drafter none`), this session's own reference: + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter none \ + --run-label $DATE-phase-5b-baseline + + Must show `"moe_layers_patched": 27`. + +9. **Correctness gate -- draft-model drafter, exact match against the + baseline. Must pass before any timing is trusted.** + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter draft-model \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-draft-model + + Must show `"moe_layers_patched": 27` and every prompt's `token_match` + `true`. Any `false` is a real bug -- stop and debug before proceeding + to step 11's throughput numbers; the exact-match bar (design doc + section 5) is not a suggestion. + +10. **Correctness gate -- prompt-lookup drafter, same check:** + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter prompt-lookup \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-prompt-lookup + + Must show `"moe_layers_patched": 27` and every prompt's `token_match` + `true`. + +11. **Throughput and acceptance-rate comparison.** Read + `mean_tokens_per_second` and `acceptance_rate` out of the three + results JSONs from steps 8-10 -- no separate benchmark tool needed, + `run_speculative_bench`'s own harness already measured both + identically for all three configurations, at the default + `--num-speculative-tokens 4`. + +12. **Targeted k-sweep**, draft-model and prompt-lookup only, on the new + repetition-heavy prompt alone (the 4th of `SPECULATIVE_PROMPTS`) -- + smaller and more targeted than re-running the full 4-prompt suite at + every k, matching Phase 1's own token-count sweep's shape: + + for K in 1 2 4 8; do + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter draft-model --num-speculative-tokens $K \ + --run-label $DATE-phase-5b-k-sweep-draft-model-$K + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter prompt-lookup --num-speculative-tokens $K \ + --run-label $DATE-phase-5b-k-sweep-prompt-lookup-$K + done + + (This sweep intentionally omits `--compare-generated-tokens`: steps + 9-10 already proved exact-match correctness at k=4 for both drafters + across all 4 prompts; correctness does not depend on k, since the + verification rule is the same at every k, so the sweep only needs to + measure throughput/acceptance-rate, not re-verify correctness 8 more + times.) + +13. **Cost record and teardown:** + + .venv/bin/python -c " + from pathlib import Path + from scripts.gpu.provision import write_cost_record + write_cost_record( + Path('docs/findings'), + pod_id='', + gpu_type_id='', + cost_per_hour=, + duration_s=, + note='Phase 5b speculative decoding: correctness gates + measured run + k-sweep', + run_label='phase-5b-speculative-decoding', + ) + " + + Then tear the pod down immediately: + + uv run python -m scripts.gpu.provision delete --pod-id + uv run python -m scripts.gpu.provision get --pod-id # confirm TERMINATED +```` + +- [ ] **Step 2: Get explicit go-ahead and run the session** + +Confirm with the user: budget cap ($10), GPU class/price at real-time +availability, and that this is a live, watched session -- then execute +the runbook above. Copy back every result JSON, the memory-checkpoint +file, and the cost file into `docs/findings/` on the local machine before +tearing the pod down. + +- [ ] **Step 3: Commit the evidence** + +```bash +git add docs/runbooks/phase-5b-speculative-decoding.md docs/findings/*phase-5b* +git commit -m "docs: run Phase 5b GPU correctness gates, measured session, and k-sweep" +``` + +--- + +### Task 7: Findings doc and STATUS.md + +**Files:** +- Create: `docs/findings/-phase-5b-speculative-decoding-run.md` +- Modify: `docs/STATUS.md` + +- [ ] **Step 1: Write the findings doc** + +Cover, in `docs/findings/-phase-5b-speculative-decoding-run.md`: +whether both drafters' correctness gates passed (Task 6 steps 9-10), and +on which GPU; the baseline/draft-model/prompt-lookup throughput and +acceptance-rate numbers from the three results JSONs (Task 6 step 11), +each with its full config (model, draft model where applicable, dtype, +hardware, prompts, repetitions, k); the k-sweep results (Task 6 step 12), +reported as measured across {1, 2, 4, 8} for both drafters on the +repetition-heavy prompt; the memory-checkpoint numbers (Task 6 steps 6-7) +showing how much headroom the quantized target left for the draft model; +cost per 1M generated tokens for baseline/draft-model/prompt-lookup (same +`$/hr / (tokens/sec * 3600) * 1e6` computation every prior phase's +findings doc has used); the actual GPU type, duration, and cost from Task +6's cost record, **explicitly compared against the $10 cap**; and an +honest verdict on this phase's two named risks from the design doc +(whether the 7B draft's net speedup held up once its own cost is counted; +whether prompt-lookup found real matches on the repetition-heavy prompt +and how that showed up in its acceptance rate vs. the original 3 +prompts). If either correctness gate failed, or the budget ran out before +all three configurations and the sweep completed, say so plainly and +report exactly what was measured, matching this project's practice of +writing down a null or partial result rather than a flattering guess. + +- [ ] **Step 2: Update STATUS.md** + +Add a "## Phase 5b progress" section following the Phase 0-5a pattern: +design and plan links, task checklist, the correctness-gate results for +both drafters, the throughput/acceptance-rate numbers, the k-sweep +summary, and total GPU cost against the $10 cap. Update "## Next step" to +note Phase 5b is complete (or exactly how far it got) and that Phase 6 +(final benchmark vs. vLLM/SGLang) is next. + +- [ ] **Step 3: Commit** + +```bash +git add docs/findings/-phase-5b-speculative-decoding-run.md docs/STATUS.md +git commit -m "docs: record Phase 5b speculative decoding outcome" +``` + +Then open the PR for the whole `phase-5b-speculative-decoding` branch, +per this repo's one-branch-per-phase convention (README/CLAUDE.md refresh +first, per the standing instruction to update them proactively at natural +stopping points). + +--- + +## Self-Review Notes + +- **Spec coverage:** design doc section 2 (two drafters, quantized + target, exact-match verification, single GPU, $10 cap, 4 prompts) is a + Global Constraint and concretely implemented across Tasks 1-2 (both + drafters), Task 3 (exact-match loop), Task 5 (quantized target wiring, + the 4th prompt). Section 3 (architecture: `Drafter` protocol, + `DraftModelDrafter`, `PromptLookupDrafter`, `speculative_generate`, new + CLI script) maps to Tasks 1-3 and 5 one component per task, in + dependency order. Section 4 (data flow: propose, one forward pass, + accept longest prefix, crop, repeat) is Task 3's `run_speculative_rounds` + line for line. Section 5 (exact-match correctness, both CPU and GPU + tiers) is implemented in Task 3's tests (stub drafters, real + `PromptLookupDrafter`, the cache-length invariant, and the `slow` + real-tiny-model test) and Task 6 steps 9-10 (the GPU gate, against a + real target). Section 6's testing table maps one-to-one onto Tasks + 1-5 (CPU/`slow` rows) and Task 6 (the `gpu`/paid row, plus the k-sweep). + Section 7 (non-goals) is listed in Global Constraints so no task + reaches for sampling-based decoding, multi-GPU EP, or a third drafter. + Section 8 (risk/cost) is the $10 cap (Global Constraints, Task 6) and + the named memory/speedup/acceptance risks (Task 6 steps 6-7's explicit + checkpoint instruction and stop condition, Task 7's explicit + honest-verdict requirement -- not silently smoothed over). +- **Placeholder scan:** no task step describes an action without showing + the code; every test has real assertions; the GPU runbook's "confirm + live" instructions and stop conditions are concrete, tied to specific, + already-documented prior findings (Phase 0/1/5a's real bugs, Phase 5a's + own OOM), not vague "handle appropriately" language. +- **Type consistency:** `Drafter`'s two methods (`propose`, `on_accepted`) + are implemented identically by `PromptLookupDrafter` (Task 1) and + `DraftModelDrafter` (Task 2), and consumed identically by + `run_speculative_rounds` (Task 3) -- verified directly in Task 3's tests, + which pass a real `PromptLookupDrafter` and two stub doubles into the + exact same call sites `scripts/run_speculative_bench.py` (Task 5) later + uses for the real drafters. `SpeculativeRoundsResult`'s four fields + (Task 3) are consumed unchanged by `speculative_generate`'s return + tuple, which `run_speculative_bench` (Task 5) unpacks with the same + three-item shape its tests assert. `save_generated_tokens`/ + `load_generated_tokens`/`compare_generated_tokens` (Task 4) round-trip + the exact `dict[str, tuple[int, ...]]` shape `run_speculative_bench` + produces -- verified in Task 5's tests, which save real reference files + with Task 4's own `save_generated_tokens` and read them back through + `main()`'s `--compare-generated-tokens` path. +- **A decision the design doc left to this plan, resolved here:** the + design doc didn't specify how the "none" (plain baseline) path should + be implemented. This plan makes `run_speculative_rounds` accept + `drafter: Drafter | None` and treat `None` explicitly as "always + propose nothing," rather than requiring a trivial always-empty + `Drafter` implementation just to satisfy the type -- one fewer public + type, and the baseline path is provably identical to the speculative + path's own degenerate case (verified directly by + `test_none_drafter_matches_the_closed_form_continuation` in Task 3), + not a separately-maintained code path that could silently drift from + it. +- **A second decision resolved here:** the design doc's correctness bar + (section 5) is byte-exact *generated tokens*, which is a different + question from the tolerance-based *logit* agreement every prior phase's + `--compare-reference` checks. Task 4 adds a parallel, deliberately + separate mechanism (`reference.py`'s exact-token compare) rather than + overloading `benchmark/reference.py`'s existing logit-tolerance + machinery for a claim it wasn't built to make. diff --git a/docs/runbooks/phase-3-multi-gpu-ep.md b/docs/runbooks/phase-3-multi-gpu-ep.md index 972d171..428ff0c 100644 --- a/docs/runbooks/phase-3-multi-gpu-ep.md +++ b/docs/runbooks/phase-3-multi-gpu-ep.md @@ -25,7 +25,7 @@ of what was planned before real hardware corrected it, not as instructions to follow literally. Total cost: $13.03 (docs/findings/ 2026-09-16-phase-3-multi-gpu-ep-cost.md). Full account, including the correctness gate result and the measured answer to Phase 3's thesis: -docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md. +docs/findings/phase-3/2026-09-16-phase-3-multi-gpu-ep-run.md. 1. Create and wait for the 2-GPU pod: @@ -214,7 +214,7 @@ docs/findings/2026-09-16-phase-3-multi-gpu-ep-run.md. run the same prompt/seed; gather rank 0's output logits. Compare top-5 and top-1 argmax agreement at every position against the single-GPU reference, the same bar Phase 1 used - (`docs/findings/2026-09-15-phase-1-grouped-gemm-run.md`). If agreement + (`docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md`). If agreement fails, stop -- do not benchmark a wrong result. Record the exact agreement measured (perfect, or the first position/token where it diverges) in the findings doc either way. diff --git a/docs/runbooks/phase-4-disaggregated-prefill-decode.md b/docs/runbooks/phase-4-disaggregated-prefill-decode.md index a0d4cc1..32ae185 100644 --- a/docs/runbooks/phase-4-disaggregated-prefill-decode.md +++ b/docs/runbooks/phase-4-disaggregated-prefill-decode.md @@ -10,7 +10,7 @@ corrected the plan before any measurement happened. Total cost: **$10.94** of the $40 cap (docs/findings/ 2026-09-17-phase-4-disaggregated-prefill-decode-cost.md). Full account, including the correctness gate results and the measured contention -comparison: `docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. +comparison: `docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-run.md`. 1. Create and wait for the 4-GPU pod, reusing Phase 3's generic `--gpu-count` support: @@ -173,4 +173,4 @@ comparison: `docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-run.m Confirmed via a follow-up `list-pods` returning an empty list. 10. Recorded the measured cost via `write_cost_record`, reused - unmodified: `docs/findings/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md`. + unmodified: `docs/findings/phase-4/2026-09-17-phase-4-disaggregated-prefill-decode-cost.md`. diff --git a/docs/runbooks/phase-5a-quantization.md b/docs/runbooks/phase-5a-quantization.md index f4b5055..046e906 100644 --- a/docs/runbooks/phase-5a-quantization.md +++ b/docs/runbooks/phase-5a-quantization.md @@ -11,7 +11,7 @@ caller. Two Community Cloud pods hit a real host-level GPU bug before a Secure Cloud L40 worked. Total cost: **$1.9543** of the $5 cap. Full account, including the correctness gate, the three-way measured run, and a real bug found and fixed mid-session: -`docs/findings/2026-09-17-phase-5a-quantization-run.md`. +`docs/findings/phase-5a/2026-09-17-phase-5a-quantization-run.md`. 1. **Create the pod.** Quoted L40 on Community Cloud at $0.69/hr, confirmed with the user before creating anything. diff --git a/docs/runbooks/phase-5b-speculative-decoding.md b/docs/runbooks/phase-5b-speculative-decoding.md new file mode 100644 index 0000000..9fe807a --- /dev/null +++ b/docs/runbooks/phase-5b-speculative-decoding.md @@ -0,0 +1,483 @@ +# Runbook: Phase 5b speculative decoding (rented GPU) + +One combined session -- correctness gate, then the measured run and a +targeted k-sweep -- per +docs/design/2026-09-17-phase-5b-speculative-decoding.md section 8. +Budget cap: $10, a ceiling not a target. + +1. Pick the cheapest L40-class card (Phase 5a's tier) with enough memory + for the quantized target plus the bf16 draft model resident together + (design doc section 2: quantizing the target frees ~13.9GB, per Phase + 5a's own measurement, specifically to make room for this), checked + live against real-time availability. +2. Create and wait, with a 60GB+ volume (Phase 0's real trap: the target + model alone is 32.8GB and this session also downloads a 7B draft + model): + + uv run python -m scripts.gpu.provision create --name dispatch-phase-5b \ + --gpu-type "" --image "" \ + --cloud SECURE --disk-gb 70 + uv run python -m scripts.gpu.provision wait --pod-id + +3. Confirm the card: + + nvidia-smi --query-gpu=name,compute_cap,memory.total --format=csv + +4. Transfer and set up. **2026-09-17 session's actual finding: do NOT + downgrade `transformers` globally.** This project's own pinned + `transformers>=5.17.0` is load-bearing for `Cache.crop()`'s + negative-number "remove N tokens" semantics (Tasks 2-3's own fix + rounds depend on it); DeepSeek's remote code was written against a + much older `transformers` API surface, and downgrading to fix it (the + old `transformers==4.57.6` approach from Phase 0/1/5a) would silently + break this phase's own cache-rollback correctness instead. Keep the + pinned version and monkeypatch only the specific broken symbols, via + the pod-local, never-committed wrapper below. + + If the ssh proxy used to reach the pod requires a PTY for command + execution and rejects SFTP/`scp` (both true for RunPod's + `ssh.runpod.io` proxy this session), transfer the repo as base64 text + piped through an interactive `ssh -tt` session instead -- wrap the + base64 output at a short line length (e.g. 76 chars/line); an + unwrapped multi-megabyte single line risks silent truncation by the + PTY's line-discipline buffer (~4096 bytes/line on Linux): + + cd /local/repo && git archive HEAD -o repo.tar + base64 -b 76 -i repo.tar -o repo.tar.b64 # macOS; use -w 76 on GNU base64 + { echo "mkdir -p /root/dispatch"; echo "cat > /root/dispatch.tar.b64 <<'B64EOF'"; \ + cat repo.tar.b64; echo "B64EOF"; \ + echo "base64 -d < /root/dispatch.tar.b64 > /root/dispatch.tar"; \ + echo "tar -xf /root/dispatch.tar -C /root/dispatch"; echo "exit"; } \ + > payload.txt + ssh -tt @ssh.runpod.io < payload.txt + + Then set up the environment: + + ssh -tt @ssh.runpod.io + cd /root/dispatch + mkdir -p /workspace/hf_cache # confirm /workspace exists first -- + # it may just be a plain directory on + # the container's own disk, not a + # separate persistent mount, if the + # pod was created without one + export HF_HOME=/workspace/hf_cache + export HF_HUB_ENABLE_HF_TRANSFER=1 + command -v uv || pip install uv + uv sync --all-extras --dev + uv pip install hf_transfer # uv sync alone won't pull this in + + **Confirm live whether DeepSeek's `modeling_deepseek.py` still needs + patching** (check + https://huggingface.co/deepseek-ai/deepseek-moe-16b-base/commits/main + since this session) -- this session hit FIVE distinct breaks loading + `deepseek-ai/deepseek-moe-16b-base` under `transformers==5.17.0` + (`trust_remote_code=True`), all from the remote code targeting an + older `transformers` API surface than what's pinned: + + 1. `ImportError: cannot import name 'is_torch_fx_available'` -- + removed from `transformers.utils.import_utils`. Only guards an + optional `torch.fx.wrap` decoration on one helper (not used in + eager inference); safe to stub to `False`. + 2. `DynamicCache` has no `get_usable_length` (Phase 0/1/5a's own + already-known trap, still present). + 3. `DynamicCache` has no `from_legacy_cache` -- only ever called with + `None` in this project's own call pattern (guarded by + `not isinstance(past_key_values, Cache)` in the remote code), so a + minimal stub covers it. + 4. `DynamicCache` has no `to_legacy_cache` either -- the same + `use_legacy_cache` branch converts the OUTPUT cache back to a + legacy tuple, which would silently strip this project's own + required `.crop()` method from `outputs.past_key_values`. Stub as + an **identity function** (`lambda self: self`), not a real legacy + conversion -- this project always wants a real `Cache` object back. + 5. `KeyError: 'type'` in `_init_rope` at + `self.config.rope_scaling["type"]`, even though the model's own + `config.json` says `"rope_scaling": null` -- `transformers==5.17.0` + auto-populates `rope_scaling` into a dict without the old `"type"` + key by the time the remote code reads it. This one isn't a missing + *symbol* (nothing to monkeypatch on import), so it needs a direct, + pod-local edit to the cached remote-code file itself -- find the + real imported copy first (`transformers`' dynamic-module cache + copies the hub snapshot into a hyphen-escaped path under + `$HF_HOME/modules/transformers_modules/...`; a glob on the + original repo name like `*deepseek-moe-16b*` matches the WRONG + copy, the raw `hub/snapshots/...` one that isn't actually + imported): + + FILE=$(python3 -c " + import transformers.utils.import_utils as _iu + _iu.is_torch_fx_available = lambda: False + from transformers import AutoConfig + # trigger the dynamic module download/cache without instantiating a model + AutoConfig.from_pretrained('deepseek-ai/deepseek-moe-16b-base', trust_remote_code=True) + " 2>/dev/null; find "\$HF_HOME/modules/transformers_modules" -iname "modeling_deepseek.py") + sed -i 's/if self.config.rope_scaling is None:/if not self.config.rope_scaling or "type" not in self.config.rope_scaling:/' "\$FILE" + + Combine fixes 1-4 into one pod-local, never-committed wrapper (this + session's actual working version, reused unmodified across every + subsequent CLI invocation this session): + + cat > _patch_and_run.py <<'EOF' + import sys + + import transformers.utils.import_utils as _iu + + _iu.is_torch_fx_available = lambda: False + + from transformers.cache_utils import DynamicCache + + + def _get_usable_length(self, new_seq_length=None, layer_idx=0): + return self.get_seq_length(layer_idx) + + + DynamicCache.get_usable_length = _get_usable_length + + + @classmethod + def _from_legacy_cache(cls, past_key_values=None): + if past_key_values is None: + return cls() + if isinstance(past_key_values, cls): + return past_key_values + raise NotImplementedError( + "pod-local shim: only None/DynamicCache inputs are supported here" + ) + + + DynamicCache.from_legacy_cache = _from_legacy_cache + + # Identity, not a real legacy conversion -- see finding 4 above. + DynamicCache.to_legacy_cache = lambda self: self + + from scripts.run_speculative_bench import main + + main(sys.argv[1:]) + EOF + + From here on invoke `.venv/bin/python` directly (`uv run` re-syncs to + `uv.lock` on every call and silently undoes any environment override + -- there isn't one left after this session's fix, since `transformers` + stays pinned, but this still matters if a future session needs its own + temporary override). If none of the five breaks reproduce live, + invoke `scripts/run_speculative_bench.py` directly instead of + `_patch_and_run.py` in every command below. + +5. Full CPU-testable suite, one more time, on the pod itself: + + .venv/bin/python -m pytest -m "not gpu" -v + + Expected: all pass (confirms the pod's environment doesn't disagree + with what already passed locally, including the `slow` real-tiny-model + tests from Tasks 3 and 5). + +6. **Memory checkpoint after loading the target alone** (design doc + section 8's named risk: two real models resident on one GPU is new + territory for this project): + + DATE=$(date +%Y-%m-%d) + .venv/bin/python <<'EOF' + import json + import torch + + from dispatch.benchmark.harness import load_model + from dispatch.kernels.backends import resolve_quantized_backend + from dispatch.kernels.integration import patch_moe_infer_quantized + + model, _ = load_model( + "deepseek-ai/deepseek-moe-16b-base", + device="cuda", dtype=torch.bfloat16, trust_remote_code=True, + ) + patched = patch_moe_infer_quantized(model, resolve_quantized_backend()) + checkpoint = { + "after": "quantized target only", + "moe_layers_patched": patched, + "allocated_gb": round(torch.cuda.memory_allocated() / 2**30, 2), + "reserved_gb": round(torch.cuda.memory_reserved() / 2**30, 2), + } + print(json.dumps(checkpoint, indent=2)) + with open("target_checkpoint.json", "w") as f: + json.dump(checkpoint, f) + EOF + + If `allocated_gb` already leaves no plausible room for a 7B bf16 draft + (~14GB) under the card's total memory (from step 3), stop and report + this as a named finding -- do not proceed to load the draft model on a + card that clearly can't hold both; downsize the target's dtype further + or move to a card with more memory instead of discovering an OOM live. + +7. **Memory checkpoint after also loading the draft model:** + + .venv/bin/python <<'EOF' + import json + import torch + + from dispatch.benchmark.harness import load_model + + # Continues from step 6's session if run in the same process (a + # notebook/REPL); otherwise re-run step 6's loading first, then: + draft_model, _ = load_model( + "deepseek-ai/deepseek-llm-7b-base", device="cuda", dtype=torch.bfloat16, + ) + checkpoint = { + "after": "quantized target + bf16 draft model", + "allocated_gb": round(torch.cuda.memory_allocated() / 2**30, 2), + "reserved_gb": round(torch.cuda.memory_reserved() / 2**30, 2), + } + print(json.dumps(checkpoint, indent=2)) + with open("both_checkpoint.json", "w") as f: + json.dump(checkpoint, f) + EOF + + cat target_checkpoint.json both_checkpoint.json > \ + docs/findings/$DATE-phase-5b-memory-checkpoints.json + + Anything that OOMs here: stop, report exactly what was measured before + the failure (same discipline as Phase 5a's own mid-session OOM + finding), and decide live whether to move to a larger card within the + $10 cap before continuing. + +8. **Root-cause investigation (2026-09-17 session's own follow-up -- + run this BEFORE trusting anything below).** The first session on this + branch produced a baseline that degenerated into repeating a single + token id for all 4 prompts, and both "correctness gates" passed only + because they matched that same degenerate output -- a vacuous pass, not + a real one (caught by the final whole-branch review, not by the + session itself, because the oracle wasn't independent). That + independence gap is now fixed (`plain_greedy_generate` in + `src/dispatch/speculative/decode.py`), but the underlying question -- + *why* did the target model produce garbage -- was never answered, and + this session exists to answer it before spending any more money on + numbers that might again be meaningless. + + Two competing hypotheses, cheapest-first: + + **8a. Is it the quantized kernel, or the environment?** Run a short + (10-token, 1 prompt) plain greedy generation on the STOCK (bf16, + unpatched `moe_infer`) target, then the same on the QUANTIZED target, + both under this session's own monkeypatched environment (step 4). + Eyeball both outputs -- do they look like plausible English/code + continuations of the prompt, or degenerate/repetitive garbage? + + .venv/bin/python <<'EOF' + import transformers.utils.import_utils as _iu + _iu.is_torch_fx_available = lambda: False + from transformers.cache_utils import DynamicCache + + def _get_usable_length(self, new_seq_length=None, layer_idx=0): + return self.get_seq_length(layer_idx) + + DynamicCache.get_usable_length = _get_usable_length + + @classmethod + def _from_legacy_cache(cls, past_key_values=None): + return cls() if past_key_values is None else past_key_values + + DynamicCache.from_legacy_cache = _from_legacy_cache + DynamicCache.to_legacy_cache = lambda self: self + + import torch + from dispatch.benchmark.harness import load_model + from dispatch.speculative.decode import plain_greedy_generate + + prompt = "The quick brown fox jumps over the lazy dog." + + stock_model, tokenizer = load_model( + "deepseek-ai/deepseek-moe-16b-base", + device="cuda", dtype=torch.bfloat16, trust_remote_code=True, + ) + _, stock_ids = plain_greedy_generate(stock_model, tokenizer, prompt, max_new_tokens=10, device="cuda") + print("STOCK: ", stock_ids, "->", tokenizer.decode(stock_ids)) + + from dispatch.kernels.backends import resolve_quantized_backend + from dispatch.kernels.integration import patch_moe_infer_quantized + patched = patch_moe_infer_quantized(stock_model, resolve_quantized_backend()) + print("patched:", patched) + _, quant_ids = plain_greedy_generate(stock_model, tokenizer, prompt, max_new_tokens=10, device="cuda") + print("QUANTIZED:", quant_ids, "->", tokenizer.decode(quant_ids)) + EOF + + Interpretation: + - **Both degenerate (e.g. both repeat one token, or both produce + obvious non-language garbage):** points at the environment + (most likely the `rope_scaling` file patch, which changes actual + model behavior, not just papers over a missing symbol -- unlike the + other 4 patches). Next: try loading the STOCK model with the + `rope_scaling` patch NOT applied (fresh `AutoConfig`/`AutoModel` + call before patching that file) and see if it alone is sane; if the + un-patched load also crashes with the original `KeyError: 'type'`, + that confirms the patch is necessary but something about *how* it's + applied needs a different fix than "treat as no scaling" -- consider + instead reading whatever real scaling parameters `transformers` + auto-populated (`print(stock_model.config.rope_scaling)` before + patching the file) and mapping them properly instead of discarding + them. + - **Only quantized degenerates, stock is sane:** points at Phase 5a's + quantized kernel interacting badly with this specific environment + (different from Phase 5a's own correctness gate, which ran under + `transformers==4.57.6`, not 5.17.0). Next: compare stock vs. + quantized logits directly at a single position (not through a full + generate loop) to localize which expert layer's output diverges. + - **Neither degenerates at 10 tokens:** the failure may only emerge + over a longer generation (the original run used 64 tokens per + prompt). Re-run both at `max_new_tokens=64` before concluding + anything, and inspect whether degeneration appears partway through + rather than immediately. + + **8b. If 8a is inconclusive or both look sane at 10 tokens:** rerun + at the original `max_new_tokens=64` config, all 4 `SPECULATIVE_PROMPTS`, + stock only (cheapest single config that would have shown the original + symptom), before spending money on the quantized+drafter combinations. + + **This step has its own stop condition:** if 8a/8b can't be resolved + within a reasonable fraction of the budget (a rough guide: stop and + reassess if this step alone exceeds $2 of the $10 cap), stop, report + exactly what was measured, and treat it as a genuine open finding + rather than pushing further speculative debugging on the clock. + +9. **The baseline run** (`--drafter none`), now generated by the + independent `plain_greedy_generate` oracle (not by + `run_speculative_rounds` itself, per the final whole-branch review's + fix): + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter none \ + --run-label $DATE-phase-5b-baseline + + Must show `"moe_layers_patched": 27`. + + **Baseline plausibility check -- do this before trusting ANY + `token_match` result below.** The independent oracle only guards + against a bug in the shared speculative loop; it says nothing about + whether the target model itself is producing sane output. Read + `docs/findings/$DATE-phase-5b-baseline-generated-tokens.json`, + decode each prompt's token list with the tokenizer, and eyeball it: + + .venv/bin/python -c " + import json + from transformers import AutoTokenizer + tokenizer = AutoTokenizer.from_pretrained('deepseek-ai/deepseek-moe-16b-base', trust_remote_code=True) + tokens = json.load(open('docs/findings/$DATE-phase-5b-baseline-generated-tokens.json')) + for key, ids in tokens.items(): + unique = len(set(ids)) + print(key, 'unique_token_count=', unique, '/', len(ids)) + print(' decoded:', repr(tokenizer.decode(ids))) + " + + If any prompt's `unique_token_count` is 1 (or otherwise clearly + degenerate/repetitive garbage on inspection), **stop** -- do not + proceed to steps 10-11. The correctness gates cannot be trusted + against a broken baseline no matter what they report. Go back to + step 8's investigation instead. + +10. **Correctness gate -- draft-model drafter, exact match against the + baseline. Must pass before any timing is trusted.** + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter draft-model \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-draft-model + + Must show `"moe_layers_patched": 27` and every prompt's `token_match` + `true`. Any `false` is a real bug -- stop and debug before proceeding + to step 12's throughput numbers; the exact-match bar (design doc + section 5) is not a suggestion. + +11. **Correctness gate -- prompt-lookup drafter, same check:** + + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter prompt-lookup \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-prompt-lookup + + Must show `"moe_layers_patched": 27` and every prompt's `token_match` + `true`. + +12. **Throughput and acceptance-rate comparison.** Read + `mean_tokens_per_second` and `acceptance_rate` out of the three + results JSONs from steps 9-11 -- no separate benchmark tool needed, + `run_speculative_bench`'s own harness already measured both + identically for all three configurations, at the default + `--num-speculative-tokens 4`. Note: `acceptance_rate`'s denominator + is still the nominal `--num-speculative-tokens`, not the real count + of tokens actually proposed each round (a known, deferred precision + caveat -- see the ledger/final-review findings, item I1 -- direction + of the error is an undercount, so treat the reported acceptance rate + as a conservative lower bound, not exact). + +13. **k-sweep**, draft-model and prompt-lookup only, **now checking + exact-match correctness at every k, not just k=4** (the prior + session's own gap, finding I5 -- skipping this is what let a + degenerate baseline go uncaught in the first place). The original + intent was to target just the repetition-heavy prompt (the 4th of + `SPECULATIVE_PROMPTS`) to keep this cheap, matching Phase 1's own + token-count sweep's shape -- but `run_speculative_bench.py`'s actual + CLI (Task 5) has no per-prompt selection flag; `SPECULATIVE_PROMPTS` + is always the full fixed list, so this still runs the full 4-prompt + suite at each k. Still cheap in absolute terms ($0.82/hr-class card, + 8 extra runs): + + for K in 1 2 4 8; do + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter draft-model --num-speculative-tokens $K \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-k-sweep-draft-model-$K + .venv/bin/python -m scripts.run_speculative_bench --trust-remote-code \ + --drafter prompt-lookup --num-speculative-tokens $K \ + --compare-generated-tokens docs/findings/$DATE-phase-5b-baseline-generated-tokens.json \ + --run-label $DATE-phase-5b-k-sweep-prompt-lookup-$K + done + + Every one of these 8 runs must show `token_match: true` for all 4 + prompts before its throughput number is trusted. A `false` at some k + but not others is itself a real, reportable finding (e.g. the prior + session's own unexplained k=1/k=2 divergence) -- do not discard it or + treat only the k=4 result as authoritative. + +14. **Cost record and teardown:** + + .venv/bin/python -c " + from pathlib import Path + from scripts.gpu.provision import write_cost_record + write_cost_record( + Path('docs/findings'), + pod_id='', + gpu_type_id='', + cost_per_hour=, + duration_s=, + note='Phase 5b speculative decoding: correctness gates + measured run + k-sweep', + run_label='phase-5b-speculative-decoding', + ) + " + + Then tear the pod down immediately (`scripts/gpu/provision.py` exposes + `create`/`wait`/`terminate` only -- no `delete`/`get` subcommand, so + status confirmation goes through the RunPod API/dashboard directly): + + uv run python -m scripts.gpu.provision terminate --pod-id + # confirm terminated via the RunPod dashboard or API (get-pod ) + +## Session 2 result, 2026-09-18: root cause found, real run completed + +The root-cause investigation this runbook's prior addendum called for +is done. Root cause: `transformers==5.17.0`'s `from_pretrained` leaves +DeepSeek's remote code's RoPE `inv_freq` buffer (`persistent=False`) as +uninitialized memory instead of its computed value, poisoning every +attention layer with NaN on the first GPU forward pass -- present +before any GPU involvement, independent of attention backend, dtype, +and GPU architecture (confirmed across 4 pods, 2 architectures, 3 data +centers). Fixed permanently: `fix_rope_inv_freq()` in +`src/dispatch/kernels/integration.py` (commit `c5b59df`), called right +after `load_model()` for both the target and any draft model. + +With the fix applied: real baseline, both correctness gates, and the +full k-sweep (checked at every k, closing the "check every point" +step above) all ran successfully. One real, root-caused (not +bug-driven) divergence pattern was found and explained: a near-tied +logit position under the int8-quantized kernel's actual floating-point +precision, confirmed by a batch-width probe and a same-process +determinism probe. Full account, numbers, and the open Phase-5a-audit +question: `docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md`. + +Total cost across both sessions: $12.26 of the (raised, mid-session) +$20 cap. diff --git a/scripts/gpu/phase4_concurrency.py b/scripts/gpu/phase4_concurrency.py index 485cdca..47fcccb 100644 --- a/scripts/gpu/phase4_concurrency.py +++ b/scripts/gpu/phase4_concurrency.py @@ -56,7 +56,7 @@ ] MAX_NEW_TOKENS = 8 ARRIVAL_STAGGER_S = 0.05 -OUTPUT_DIR = Path("docs/findings") +OUTPUT_DIR = Path("docs/findings/phase-4") # See phase4_correctness_gate.py's own module docstring/comments for why diff --git a/scripts/gpu/phase4_correctness_gate.py b/scripts/gpu/phase4_correctness_gate.py index b1c44ef..465fc07 100644 --- a/scripts/gpu/phase4_correctness_gate.py +++ b/scripts/gpu/phase4_correctness_gate.py @@ -73,7 +73,7 @@ def _get_usable_length( "def fibonacci(n):", ] MAX_NEW_TOKENS = 16 -OUTPUT_DIR = Path("docs/findings") +OUTPUT_DIR = Path("docs/findings/phase-4") REFERENCE_PATH = OUTPUT_DIR / "2026-09-16-phase-4-single-gpu-reference.json" diff --git a/scripts/gpu/run_phase3_correctness_gate.py b/scripts/gpu/run_phase3_correctness_gate.py index 402f5c8..f9752af 100644 --- a/scripts/gpu/run_phase3_correctness_gate.py +++ b/scripts/gpu/run_phase3_correctness_gate.py @@ -1,7 +1,7 @@ """Phase 3 correctness gate: does DeepEP-backed 2-GPU expert-parallel inference on the real DeepSeekMoE-16B model agree with a single-GPU reference, at the same top-5/top-1 bar Phase 1 used -(docs/findings/2026-09-15-phase-1-grouped-gemm-run.md)? Must pass before +(docs/findings/phase-1/2026-09-15-phase-1-grouped-gemm-run.md)? Must pass before Task 4's benchmark sweep runs -- an EP run that disagrees with the single-GPU reference is not benchmarked, per this project's standing "correctness before speed" rule. @@ -43,7 +43,7 @@ "def fibonacci(n):", ] MODEL_NAME = "deepseek-ai/deepseek-moe-16b-base" -OUTPUT_DIR = Path("docs/findings") +OUTPUT_DIR = Path("docs/findings/phase-3") SINGLE_GPU_REFERENCE_PATH = OUTPUT_DIR / "2026-09-16-phase-3-single-gpu-reference.safetensors" diff --git a/scripts/run_baseline.py b/scripts/run_baseline.py index 9167df0..e841091 100644 --- a/scripts/run_baseline.py +++ b/scripts/run_baseline.py @@ -83,7 +83,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--trust-remote-code", action="store_true") parser.add_argument("--repetitions", type=int, default=5) parser.add_argument("--max-new-tokens", type=int, default=64) - parser.add_argument("--output-dir", type=Path, default=Path("docs/findings")) + parser.add_argument("--output-dir", type=Path, default=Path("docs/findings/phase-0")) parser.add_argument("--run-label", default=time.strftime("%Y-%m-%d-baseline")) parser.add_argument( "--moe-kernel", default="none", choices=["none", *BACKENDS, QUANTIZED_BACKEND] diff --git a/scripts/run_kernel_bench.py b/scripts/run_kernel_bench.py index c8cf637..002e693 100644 --- a/scripts/run_kernel_bench.py +++ b/scripts/run_kernel_bench.py @@ -1,6 +1,6 @@ """CLI: time the grouped-GEMM backends -- eager torch loop, naive Triton, persistent Triton -- on synthetic DeepSeekMoE-16B-shaped routed-expert work, -and write every summary with its full config to docs/findings/. A backend +and write every summary with its full config to docs/findings/phase-1/. A backend that disagrees with the eager torch backend on the benchmark's own input is refused, not timed. @@ -119,7 +119,7 @@ def main(argv: list[str] | None = None) -> None: parser.add_argument("--dtype", choices=["float16", "bfloat16"], default="bfloat16") parser.add_argument("--block-m", type=int, default=16) parser.add_argument("--seed", type=int, default=0) - parser.add_argument("--output-dir", type=Path, default=Path("docs/findings")) + parser.add_argument("--output-dir", type=Path, default=Path("docs/findings/phase-1")) parser.add_argument("--run-label", default=time.strftime("%Y-%m-%d-phase-1-kernel-bench")) args = parser.parse_args(argv) diff --git a/scripts/run_speculative_bench.py b/scripts/run_speculative_bench.py new file mode 100644 index 0000000..8d5ed0b --- /dev/null +++ b/scripts/run_speculative_bench.py @@ -0,0 +1,270 @@ +"""CLI: speculative decoding on top of dispatch's own int8-quantized +target kernel (Phase 5a) -- either a real draft model +(deepseek-llm-7b-base) or model-free prompt-lookup decoding. Writes +latency/throughput and acceptance rate, and with +--compare-generated-tokens, checks the run's exact generated token ids +against a same-session baseline and exits non-zero on any divergence. +docs/design/2026-09-17-phase-5b-speculative-decoding.md. +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import time +from dataclasses import asdict +from pathlib import Path + +import torch + +from dispatch.benchmark.harness import load_model +from dispatch.benchmark.metrics import TokenTimings, summarize +from dispatch.kernels.backends import resolve_quantized_backend +from dispatch.kernels.integration import fix_rope_inv_freq, patch_moe_infer_quantized +from dispatch.speculative.decode import plain_greedy_generate, speculative_generate +from dispatch.speculative.drafters import Drafter, DraftModelDrafter, PromptLookupDrafter +from dispatch.speculative.reference import ( + compare_generated_tokens, + load_generated_tokens, + save_generated_tokens, +) +from scripts.run_baseline import DEFAULT_PROMPTS + +REPETITION_PROMPT = ( + "Please restate the following sentence exactly twice, back to back: " + "'The system will now process each incoming request in the order it was received.'" +) +SPECULATIVE_PROMPTS = [*DEFAULT_PROMPTS, REPETITION_PROMPT] +DRAFTERS = ("none", "draft-model", "prompt-lookup") +# Single source of truth: used for both load_model() calls below AND +# recorded into the results JSON (final-review finding: a benchmark's +# machine-readable evidence must carry its full config, not just the +# prose findings doc -- CLAUDE.md's own testing-policy table). +ATTN_IMPLEMENTATION = "sdpa" + + +def build_drafter( + drafter_name: str, + *, + draft_model_name: str, + device: str, + dtype: torch.dtype, + prompt_lookup_ngram_size: int, +) -> Drafter | None: + if drafter_name == "none": + return None + if drafter_name == "prompt-lookup": + return PromptLookupDrafter(ngram_size=prompt_lookup_ngram_size) + if drafter_name == "draft-model": + # sdpa over this project's usual implicit default (eager): a + # reasonable, well-supported choice on its own merits. NOT a fix + # for anything specific -- an earlier round of this investigation + # attributed a real/fake distinction to eager-vs-sdpa that + # fix_rope_inv_freq (applied inside load_model itself, for every + # caller) has since superseded: the actual bug reproduced under + # every attn_implementation tested, sdpa included. + draft_model, _ = load_model( + draft_model_name, device=device, dtype=dtype, attn_implementation=ATTN_IMPLEMENTATION + ) + return DraftModelDrafter(draft_model) + raise ValueError(f"unknown drafter {drafter_name!r}; expected one of {DRAFTERS}") + + +def run_speculative_bench( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model_name: str, + *, + device: str, + dtype: torch.dtype, + trust_remote_code: bool, + prompts: list[str], + repetitions: int, + max_new_tokens: int, + drafter_name: str, + draft_model_name: str, + num_speculative_tokens: int, + prompt_lookup_ngram_size: int, +) -> tuple[list[tuple[TokenTimings, tuple[int, ...]]], dict[str, tuple[int, ...]], int, int]: + """Returns the timed runs paired with each run's per-round accepted + lengths, the first repetition's generated tokens per prompt (greedy + decoding is deterministic, so later repetitions would be identical), + how many MoE layers were patched, and how many RoPE buffers were + fixed.""" + # sdpa -- see build_drafter's comment above. + model, tokenizer = load_model( + model_name, + device=device, + dtype=dtype, + trust_remote_code=trust_remote_code, + attn_implementation=ATTN_IMPLEMENTATION, + ) + # load_model already applies this fix internally for every caller + # (the actual fix for this phase's degenerate-baseline finding, C1: + # transformers==5.17.0's model loading leaves DeepSeek's remote + # code's RoPE inv_freq buffer as uninitialized memory instead of its + # real computed value, poisoning every attention layer's output with + # NaN from the very first forward pass -- see fix_rope_inv_freq's own + # docstring for the full mechanism). This second, idempotent call + # exists only to capture the count for this script's own results + # JSON and to guard against silent regression: DeepSeekMoE always has + # a rotary embedding per attention layer, so 0 here means the + # duck-typed shape stopped matching (e.g. a future transformers + # release changing DeepSeek's remote code again) and this run would + # silently regress to the degenerate baseline this phase already + # spent a session root-causing. + rope_buffers_fixed = fix_rope_inv_freq(model) + moe_layers_patched = patch_moe_infer_quantized(model, resolve_quantized_backend()) + # Order matters for a model with neither shape (e.g. a plain GPT-2 + # used to test this guard in isolation): the MoE guard's existing + # contract ("a kernel run that patches no layers refuses to run," + # CLAUDE.md's testing policy) takes priority over the newer rope + # guard below, which exists for a real target that has MoE layers + # but somehow no rotary embeddings -- a structurally inconsistent + # state for any real decoder-only transformer, not a state this + # guard order can ever hide for DeepSeekMoE-16B itself. + if moe_layers_patched == 0: + raise RuntimeError( + f"quantized target patched no MoE layers: {model_name} has no moe_infer to " + "replace, so this run would time the stock model under the quantized kernel's name" + ) + if rope_buffers_fixed == 0: + raise RuntimeError( + f"fixed no RoPE buffers: {model_name} has no rotary embedding matching " + "fix_rope_inv_freq's duck-typed shape, so this run risks the degenerate-baseline " + "bug this phase root-caused (see fix_rope_inv_freq's docstring)" + ) + + drafter = build_drafter( + drafter_name, + draft_model_name=draft_model_name, + device=device, + dtype=dtype, + prompt_lookup_ngram_size=prompt_lookup_ngram_size, + ) + + runs: list[tuple[TokenTimings, tuple[int, ...]]] = [] + generated_tokens: dict[str, tuple[int, ...]] = {} + for prompt_index, prompt in enumerate(prompts): + for repetition in range(repetitions): + if drafter is None: + timing, tokens = plain_greedy_generate( + model, + tokenizer, + prompt, + max_new_tokens=max_new_tokens, + device=device, + ) + accepted_lengths: tuple[int, ...] = () + else: + timing, accepted_lengths, tokens = speculative_generate( + model, + tokenizer, + drafter, + prompt, + num_speculative_tokens=num_speculative_tokens, + max_new_tokens=max_new_tokens, + device=device, + ) + runs.append((timing, accepted_lengths)) + if repetition == 0: + generated_tokens[f"prompt_{prompt_index:03d}_tokens"] = tokens + + return runs, generated_tokens, moe_layers_patched, rope_buffers_fixed + + +def _acceptance_rate( + runs: list[tuple[TokenTimings, tuple[int, ...]]], num_speculative_tokens: int +) -> float | None: + lengths = [length for _, accepted_lengths in runs for length in accepted_lengths] + if not lengths or num_speculative_tokens <= 0: + return None + return statistics.mean(lengths) / num_speculative_tokens + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser( + description="Run dispatch's speculative-decoding benchmark (Phase 5b)" + ) + parser.add_argument("--model-name", default="deepseek-ai/deepseek-moe-16b-base") + parser.add_argument("--draft-model-name", default="deepseek-ai/deepseek-llm-7b-base") + parser.add_argument("--device", default="cuda") + parser.add_argument("--dtype", default="bfloat16", choices=["float32", "bfloat16", "float16"]) + parser.add_argument("--trust-remote-code", action="store_true") + parser.add_argument("--repetitions", type=int, default=5) + parser.add_argument("--max-new-tokens", type=int, default=64) + parser.add_argument("--output-dir", type=Path, default=Path("docs/findings/phase-5b")) + parser.add_argument("--run-label", default=time.strftime("%Y-%m-%d-speculative")) + parser.add_argument("--drafter", default="none", choices=DRAFTERS) + parser.add_argument("--num-speculative-tokens", type=int, default=4) + parser.add_argument("--prompt-lookup-ngram-size", type=int, default=3) + parser.add_argument( + "--compare-generated-tokens", + type=Path, + default=None, + help="generated-tokens JSON from a same-session baseline run of the same model and prompts", + ) + args = parser.parse_args(argv) + + dtype: torch.dtype = getattr(torch, args.dtype) + runs, generated_tokens, moe_layers_patched, rope_buffers_fixed = run_speculative_bench( + args.model_name, + device=args.device, + dtype=dtype, + trust_remote_code=args.trust_remote_code, + prompts=SPECULATIVE_PROMPTS, + repetitions=args.repetitions, + max_new_tokens=args.max_new_tokens, + drafter_name=args.drafter, + draft_model_name=args.draft_model_name, + num_speculative_tokens=args.num_speculative_tokens, + prompt_lookup_ngram_size=args.prompt_lookup_ngram_size, + ) + timings = [timing for timing, _ in runs] + summary = summarize(timings) + acceptance_rate = _acceptance_rate(runs, args.num_speculative_tokens) + + args.output_dir.mkdir(parents=True, exist_ok=True) + tokens_path = args.output_dir / f"{args.run_label}-generated-tokens.json" + save_generated_tokens(generated_tokens, tokens_path) + print(f"wrote {tokens_path}") + + comparison = ( + compare_generated_tokens( + generated_tokens, load_generated_tokens(args.compare_generated_tokens) + ) + if args.compare_generated_tokens is not None + else {} + ) + + results_path = args.output_dir / f"{args.run_label}-results.json" + results_path.write_text( + json.dumps( + { + "model": args.model_name, + "draft_model": args.draft_model_name if args.drafter == "draft-model" else None, + "device": args.device, + "dtype": args.dtype, + "drafter": args.drafter, + "num_speculative_tokens": args.num_speculative_tokens, + "prompt_lookup_ngram_size": args.prompt_lookup_ngram_size, + "moe_layers_patched": moe_layers_patched, + "rope_buffers_fixed": rope_buffers_fixed, + "attn_implementation": ATTN_IMPLEMENTATION, + "acceptance_rate": acceptance_rate, + **asdict(summary), + "token_match": comparison, + }, + indent=2, + ) + ) + print(f"wrote {results_path}") + + if comparison and not all(comparison.values()): + raise SystemExit( + f"{args.drafter} generated tokens disagree with " + f"{args.compare_generated_tokens} -- see {results_path}" + ) + + +if __name__ == "__main__": + main() diff --git a/src/dispatch/benchmark/harness.py b/src/dispatch/benchmark/harness.py index d2f9927..79cb907 100644 --- a/src/dispatch/benchmark/harness.py +++ b/src/dispatch/benchmark/harness.py @@ -18,6 +18,7 @@ ) from dispatch.benchmark.metrics import TokenTimings +from dispatch.kernels.integration import fix_rope_inv_freq def load_model( @@ -26,14 +27,26 @@ def load_model( device: str = "cpu", dtype: torch.dtype = torch.float32, trust_remote_code: bool = False, + attn_implementation: str | None = None, ) -> tuple[PreTrainedModel, PreTrainedTokenizerBase]: tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=trust_remote_code) + extra_kwargs = ( + {} if attn_implementation is None else {"attn_implementation": attn_implementation} + ) model = AutoModelForCausalLM.from_pretrained( - model_name, dtype=dtype, trust_remote_code=trust_remote_code + model_name, dtype=dtype, trust_remote_code=trust_remote_code, **extra_kwargs ) # torch's Module.to() overloads don't resolve a str device; Module.eval() is untyped. model.to(device) # type: ignore[arg-type] model.eval() # type: ignore[no-untyped-call] + # Applied unconditionally, for every caller of load_model (not just + # scripts/run_speculative_bench.py) -- transformers>=5.17.0 (this + # project's own pinned floor) leaves DeepSeek's remote code's RoPE + # inv_freq buffer uninitialized after from_pretrained (see + # fix_rope_inv_freq's own docstring). A model that doesn't match its + # duck-typed shape (anything but this bug's exact rotary-embedding + # class) is left untouched -- harmless and idempotent either way. + fix_rope_inv_freq(model) return model, tokenizer diff --git a/src/dispatch/kernels/integration.py b/src/dispatch/kernels/integration.py index e9c4dbd..6f24e2a 100644 --- a/src/dispatch/kernels/integration.py +++ b/src/dispatch/kernels/integration.py @@ -2,11 +2,18 @@ each MoE layer's `moe_infer` -- the inference-time routed-expert method of DeepSeek's remote-code DeepseekMoE (modeling_deepseek.py). The gate, attention, and shared experts stay DeepSeek's own code. + +Also carries `fix_rope_inv_freq`, an unrelated but similarly-shaped +after-load repair: transformers>=5.17.0's model-loading path leaves +DeepSeek's remote code's rotary-embedding `inv_freq` buffer as +uninitialized memory rather than the value its own __init__ computes (see +that function's docstring for the full mechanism and how it was found). """ from __future__ import annotations from collections.abc import Callable, Iterator +from typing import cast import torch @@ -83,6 +90,54 @@ def patch_moe_infer_quantized( return patched +def fix_rope_inv_freq(model: torch.nn.Module) -> int: + """Returns how many rotary-embedding buffers were fixed, for the + caller to check. + + transformers==5.17.0's `from_pretrained` leaves DeepSeek's remote + code's `inv_freq` buffer -- computed fresh in + `DeepseekRotaryEmbedding.__init__` from `1/base**(i/dim)`, and marked + `persistent=False` because it is never meant to be part of a + checkpoint's state_dict -- as uninitialized memory instead of that + computed value. Found live on a real GPU session (Phase 5b): the + corruption is already present immediately after `from_pretrained` + returns, before any `.to(device)` call, so it is not a GPU/CUDA + numerics issue despite only manifesting as NaN once a forward pass + actually runs on GPU (a CPU forward pass reads the same garbage + `inv_freq` and still applies rotary embeddings with it, but generates + real, non-NaN, wrong-content output instead -- something about the + GPU path additionally propagates it to NaN). Every attention layer's + rotary embedding gets its own `inv_freq`, so this must run once per + loaded model, before any forward pass -- `dispatch.benchmark.harness. + load_model` calls this unconditionally on every model it loads, so + callers going through `load_model` get it for free; call it again + directly only to capture its return count (as `scripts/ + run_speculative_bench.py` does, to guard against silent regression). + + Discovered and matched via duck typing (`inv_freq`/`dim`/`base` + attributes) rather than a hardcoded module path, matching this file's + `moe_infer` discovery -- the same fix applies to any transformers + remote-code model built on the same Llama-derived rotary embedding + class (DeepSeek's is a direct, `# Copied from` derivative).""" + fixed = 0 + for module in model.modules(): + if not (hasattr(module, "inv_freq") and hasattr(module, "dim") and hasattr(module, "base")): + continue + dim = cast(int, module.dim) + base = cast(int, module.base) + inv_freq = cast(torch.Tensor, module.inv_freq) + correct_inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim)) + module.inv_freq = correct_inv_freq.to(inv_freq.device) + if hasattr(module, "max_seq_len_cached"): + # Forces the next forward call to rebuild cos_cached/sin_cached + # from the now-correct inv_freq -- they were derived from the + # broken buffer and are just as wrong. nn.Module's __setattr__ + # stub types this plain int attribute as Tensor | Module. + module.max_seq_len_cached = None # type: ignore[assignment] + fixed += 1 + return fixed + + def _free_expert_weights(experts: torch.nn.ModuleList) -> None: """Releases each expert's original bf16 weight now that moe_infer's quantized closure never reads `experts` again. stack_expert_weights diff --git a/src/dispatch/speculative/__init__.py b/src/dispatch/speculative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dispatch/speculative/decode.py b/src/dispatch/speculative/decode.py new file mode 100644 index 0000000..dfdd324 --- /dev/null +++ b/src/dispatch/speculative/decode.py @@ -0,0 +1,233 @@ +"""The shared propose/verify/accept/rollback speculative-decoding loop +(docs/design/2026-09-17-phase-5b-speculative-decoding.md sections 3-4), +parameterized by a Drafter so neither drafter implementation duplicates +verification or KV-cache-rollback logic. Because both the target and (for +DraftModelDrafter) the drafter decode strictly greedily, and a candidate +is accepted iff it equals the target's own greedy argmax, this loop's +output is a deterministic function of the target model alone -- it must +produce byte-identical tokens to plain sequential greedy decoding of the +same target (design doc section 5). +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass + +import torch +from transformers import PreTrainedModel, PreTrainedTokenizerBase + +from dispatch.benchmark.metrics import TokenTimings +from dispatch.speculative.drafters import Drafter + + +def plain_greedy_generate( # noqa: PLR0913 -- device/clock_fn are what make this testable + model: PreTrainedModel, + tokenizer: PreTrainedTokenizerBase, + prompt: str, + *, + max_new_tokens: int = 32, + device: str = "cpu", + clock_fn: Callable[[], float] = time.perf_counter, +) -> tuple[TokenTimings, tuple[int, ...]]: + """Independent correctness oracle for speculative decoding's + --drafter none baseline: a plain, token-by-token greedy decode loop + that shares NO code with run_speculative_rounds, so it can actually + catch a bug in the shared propose/verify/accept/rollback loop rather + than silently agreeing with it (a real gap found by this phase's + final whole-branch review -- the baseline was previously generated + by run_speculative_rounds itself with drafter=None, making the + correctness gate unable in principle to catch a bug in that shared + code). Deliberately duplicates harness.generate_with_timings's own + loop shape (not reused directly, since that file is reused + unmodified elsewhere in this phase and doesn't return token ids) + plus token-id capture.""" + inputs = tokenizer(prompt, return_tensors="pt").to(device) + input_ids = inputs["input_ids"] + prompt_token_count = int(input_ids.shape[-1]) + eos_token_id = tokenizer.eos_token_id + + start_time = clock_fn() + token_times: list[float] = [] + generated: list[int] = [] + past_key_values = None + next_input = input_ids + + with torch.no_grad(): + for _ in range(max_new_tokens): + outputs = model(input_ids=next_input, past_key_values=past_key_values, use_cache=True) + token_times.append(clock_fn()) + past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + token_id = int(next_token.item()) + generated.append(token_id) + next_input = next_token + if eos_token_id is not None and token_id == eos_token_id: + break + + timing = TokenTimings( + start_time=start_time, + token_times=tuple(token_times), + prompt_token_count=prompt_token_count, + ) + return timing, tuple(generated) + + +@dataclass(frozen=True) +class SpeculativeRoundsResult: + """past_key_values is exposed only so tests can assert the + cache-length invariant (design doc section 5) directly -- callers of + speculative_generate don't need it.""" + + token_times: tuple[float, ...] + accepted_lengths: tuple[int, ...] + generated_token_ids: tuple[int, ...] + past_key_values: object + + +def run_speculative_rounds( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model: PreTrainedModel, + drafter: Drafter | None, + token_ids: torch.Tensor, + *, + num_speculative_tokens: int, + max_new_tokens: int, + eos_token_id: int | None, + clock_fn: Callable[[], float] = time.perf_counter, +) -> SpeculativeRoundsResult: + token_times: list[float] = [] + accepted_lengths: list[int] = [] + generated: list[int] = [] + past_key_values = None + total_new = 0 + + if drafter is not None: + # A caller building one drafter and reusing it across several + # independent generate() calls (e.g. one prompt/repetition loop + # benchmarking several configs) must not leak a prior call's KV + # cache into this one -- the target's own past_key_values is + # already a fresh local None above; the drafter needs the same. + drafter.reset() + + with torch.no_grad(): + while total_new < max_new_tokens: + room = max_new_tokens - total_new + k = min(num_speculative_tokens, room) + candidates = ( + drafter.propose(token_ids, k) + if drafter is not None + else token_ids.new_empty((1, 0)) + ) + num_candidates = int(candidates.shape[-1]) + + step_input = token_ids if past_key_values is None else token_ids[:, -1:] + forward_input = torch.cat([step_input, candidates], dim=1) + outputs = model( + input_ids=forward_input, past_key_values=past_key_values, use_cache=True + ) + past_key_values = outputs.past_key_values + assert past_key_values is not None # use_cache=True guarantees this + + if hasattr(past_key_values, "get_seq_length"): + # Immediately after this round's forward call, the cache must + # hold exactly the known-token history (token_ids, which + # still holds its pre-round value here) plus this round's + # num_candidates freshly-proposed-but-not-yet-verified + # tokens -- no more, no less. A future change to the + # catch-up/cache-update logic above that silently drifts + # from this would otherwise produce plausible-looking + # garbage instead of a crash (finding I3, final review). + cached_length = past_key_values.get_seq_length() + expected_length = token_ids.shape[1] + num_candidates + assert cached_length == expected_length, ( + f"cache-lag invariant violated: cache holds {cached_length} tokens, " + f"expected {expected_length} (this would silently corrupt position " + "encoding on the next round -- see the phase's final review, finding I3)" + ) + + offset = step_input.shape[1] - 1 + target_predictions = outputs.logits[0, offset:, :].argmax(dim=-1) + + accepted_len = 0 + while accepted_len < num_candidates and int(target_predictions[accepted_len]) == int( + candidates[0, accepted_len] + ): + accepted_len += 1 + accepted_lengths.append(accepted_len) + + rejected_len = num_candidates - accepted_len + past_key_values.crop(-rejected_len) + if drafter is not None: + drafter.on_accepted(accepted_len, rejected_len) + + emitted = candidates[:, :accepted_len] + if accepted_len < room: + bonus_token = target_predictions[accepted_len].view(1, 1) + emitted = torch.cat([emitted, bonus_token], dim=1) + + emitted_list = emitted[0].tolist() + if eos_token_id is not None and eos_token_id in emitted_list: + # Plain sequential greedy decoding stops the instant it + # emits eos_token_id, never emitting anything after it -- + # so truncate this round's emission there too, even if an + # earlier-accepted candidate (not the bonus token) is what + # actually matched, and even if room would have allowed + # more. + eos_position = emitted_list.index(eos_token_id) + emitted = emitted[:, : eos_position + 1] + emitted_list = emitted_list[: eos_position + 1] + + token_ids = torch.cat([token_ids, emitted], dim=1) + for _ in emitted_list: + token_times.append(clock_fn()) + generated.extend(int(t) for t in emitted_list) + total_new += emitted.shape[1] + + if eos_token_id is not None and eos_token_id in emitted_list: + break + + return SpeculativeRoundsResult( + token_times=tuple(token_times), + accepted_lengths=tuple(accepted_lengths), + generated_token_ids=tuple(generated), + past_key_values=past_key_values, + ) + + +def speculative_generate( # noqa: PLR0913 -- each of these is an independent, user-facing knob + model: PreTrainedModel, + tokenizer: PreTrainedTokenizerBase, + drafter: Drafter | None, + prompt: str, + *, + num_speculative_tokens: int, + max_new_tokens: int = 32, + device: str = "cpu", + clock_fn: Callable[[], float] = time.perf_counter, +) -> tuple[TokenTimings, tuple[int, ...], tuple[int, ...]]: + """Returns per-token timings (matching harness.generate_with_timings's + shape, so benchmark/metrics.py's summarize() works unchanged), each + round's accepted-candidate count (the raw data behind the + acceptance-rate metric), and the generated token ids.""" + inputs = tokenizer(prompt, return_tensors="pt").to(device) + token_ids = inputs["input_ids"] + prompt_token_count = int(token_ids.shape[-1]) + start_time = clock_fn() + + result = run_speculative_rounds( + model, + drafter, + token_ids, + num_speculative_tokens=num_speculative_tokens, + max_new_tokens=max_new_tokens, + eos_token_id=tokenizer.eos_token_id, + clock_fn=clock_fn, + ) + + timing = TokenTimings( + start_time=start_time, + token_times=result.token_times, + prompt_token_count=prompt_token_count, + ) + return timing, result.accepted_lengths, result.generated_token_ids diff --git a/src/dispatch/speculative/drafters.py b/src/dispatch/speculative/drafters.py new file mode 100644 index 0000000..5854d23 --- /dev/null +++ b/src/dispatch/speculative/drafters.py @@ -0,0 +1,142 @@ +"""Pluggable draft-token proposers for speculative decoding +(docs/design/2026-09-17-phase-5b-speculative-decoding.md section 3): a +Drafter proposes candidate tokens; decode.py's run_speculative_rounds +verifies them against the target model's own greedy output and tells the +drafter how many were accepted so it can roll back any state it holds (a +draft model's own KV cache; a no-op for stateless drafters like this +module's PromptLookupDrafter). +""" + +from __future__ import annotations + +from typing import Protocol + +import torch +from transformers import Cache, PreTrainedModel + + +class Drafter(Protocol): + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + """token_ids: (1, seq_len), the full sequence so far. Returns up to + num_tokens proposed token ids, shape (1, <=num_tokens) -- a shorter + (including empty, shape (1, 0)) return is valid and the caller + must treat it as a smaller round, not an error.""" + ... + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + """Called once per verification round so a drafter holding its + own cache can roll it back. A no-op for stateless drafters.""" + ... + + def reset(self) -> None: + """Called once at the start of each independent generate() call + so a drafter reused across multiple prompts (or repeated runs of + the same prompt) starts with no leftover cache from a prior, + unrelated sequence. A no-op for stateless drafters.""" + ... + + +class PromptLookupDrafter: + """No model, no cache: proposes whatever tokens followed the most + recent earlier occurrence of the last `ngram_size` tokens, up to + num_tokens of them. Returns an empty proposal if no match exists, or + if fewer than num_tokens tokens followed the match (never padded) -- + both real, expected outcomes on non-repetitive prompts, not errors.""" + + def __init__(self, ngram_size: int = 3) -> None: + if ngram_size < 1: + raise ValueError(f"ngram_size must be >= 1, got {ngram_size}") + self.ngram_size = ngram_size + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + sequence = token_ids[0] + seq_len = int(sequence.shape[0]) + if num_tokens <= 0 or seq_len <= self.ngram_size: + return sequence.new_empty((1, 0)) + needle = sequence[-self.ngram_size :] + windows = sequence.unfold(0, self.ngram_size, 1)[:-1] # excludes needle's own window + matches = (windows == needle).all(dim=1).nonzero(as_tuple=True)[0] + if matches.numel() == 0: + return sequence.new_empty((1, 0)) + match_end = int(matches[-1]) + self.ngram_size # latest match wins + take = min(num_tokens, seq_len - match_end) + return sequence[match_end : match_end + take].clone().unsqueeze(0) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + def reset(self) -> None: + pass + + +class DraftModelDrafter: + """Wraps a loaded causal LM and its own KV cache, greedily decoding up + to num_tokens candidates per round. Mirrors harness.py's plain decode + loop's own catch-up pattern (feed the whole sequence when the cache is + empty, otherwise just the newest token) applied across repeated + propose() calls: the drafter's cache always lags the true sequence by + exactly one token (the target's own most recent bonus/correction + token, which the drafter hasn't seen yet) -- the first forward call of + each round after the first both catches that token up and produces + this round's first candidate.""" + + def __init__(self, model: PreTrainedModel) -> None: + self.model = model + self.past_key_values: Cache | None = None + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + if num_tokens <= 0: + return token_ids.new_empty((1, 0)) + cache_was_empty = self.past_key_values is None + prior_length = ( + self.past_key_values.get_seq_length() + if self.past_key_values is not None and hasattr(self.past_key_values, "get_seq_length") + else 0 + ) + next_input = token_ids if self.past_key_values is None else token_ids[:, -1:] + proposed: list[torch.Tensor] = [] + with torch.no_grad(): + for _ in range(num_tokens): + outputs = self.model( + input_ids=next_input, past_key_values=self.past_key_values, use_cache=True + ) + self.past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + proposed.append(next_token) + next_input = next_token + # on_accepted's crop(-rejected_len) assumes every proposed + # candidate is cache-resident (matching the target's own + # crop formula in decode.py exactly) -- without this call the + # loop above would leave the very last candidate un-cached, + # silently evicting an accepted token on the next crop. + outputs = self.model( + input_ids=next_input, past_key_values=self.past_key_values, use_cache=True + ) + self.past_key_values = outputs.past_key_values + assert self.past_key_values is not None # use_cache=True guarantees this + if hasattr(self.past_key_values, "get_seq_length"): + # After this call, the cache must hold exactly the fed history + # plus every proposed candidate -- this class's own docstring + # and tests promise "cache ends up holding the prompt plus + # BOTH candidates", not just the last one. A silent drift here + # would desync on_accepted's crop(-rejected_len) on the next + # round without ever crashing (finding I3, final review). + expected_length = ( + token_ids.shape[1] + num_tokens + if cache_was_empty + else prior_length + num_tokens + 1 + ) + cached_length = self.past_key_values.get_seq_length() + assert cached_length == expected_length, ( + f"cache-lag invariant violated in DraftModelDrafter.propose: cache holds " + f"{cached_length} tokens, expected {expected_length} -- see the phase's " + "final review, finding I3" + ) + return torch.cat(proposed, dim=1) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + if self.past_key_values is not None: + self.past_key_values.crop(-rejected_len) + + def reset(self) -> None: + self.past_key_values = None diff --git a/src/dispatch/speculative/reference.py b/src/dispatch/speculative/reference.py new file mode 100644 index 0000000..959ced6 --- /dev/null +++ b/src/dispatch/speculative/reference.py @@ -0,0 +1,38 @@ +"""Exact generated-token reference capture/compare for speculative +decoding's correctness gate (docs/design/2026-09-17-phase-5b-speculative-decoding.md +section 5). Unlike benchmark/reference.py's compare_top_k_agreement (built +for kernel-swap comparisons that may legitimately diverge a little), +speculative decoding's claim is that its *generated token ids* are +byte-identical to plain greedy decoding of the same target -- so this +compares exact integer sequences, not logits within a tolerance. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from pathlib import Path + + +def save_generated_tokens(tokens: Mapping[str, Sequence[int]], path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({key: list(value) for key, value in tokens.items()}, indent=2)) + + +def load_generated_tokens(path: Path) -> dict[str, tuple[int, ...]]: + raw: dict[str, list[int]] = json.loads(path.read_text()) + return {key: tuple(value) for key, value in raw.items()} + + +def compare_generated_tokens( + actual: Mapping[str, Sequence[int]], reference: Mapping[str, Sequence[int]] +) -> dict[str, bool]: + if actual.keys() != reference.keys(): + raise ValueError( + f"key mismatch: actual has {sorted(actual.keys())}, " + f"reference has {sorted(reference.keys())}" + ) + # Normalize to tuple before comparing: Mapping/Sequence-typed inputs mean a + # caller could pass a list on one side and a tuple on the other, which + # Python's == would treat as unequal by container type alone, not content. + return {key: tuple(actual[key]) == tuple(reference[key]) for key in reference} diff --git a/tests/unit/test_decode.py b/tests/unit/test_decode.py new file mode 100644 index 0000000..1465766 --- /dev/null +++ b/tests/unit/test_decode.py @@ -0,0 +1,409 @@ +"""CPU-only correctness of the shared speculative-decoding loop, against +a toy causal LM whose predictions are a pure, memoryless function of the +current input token -- deliberately so that a cache-rollback bug is +caught only by the explicit cache-length assertions below, never by a +lucky output-correctness coincidence. Per +docs/design/2026-09-17-phase-5b-speculative-decoding.md sections 3-5: +speculative decoding's output must be byte-identical to plain sequential +greedy decoding, regardless of whether the drafter is ever right. +""" + +from __future__ import annotations + +import pytest +import torch +from transformers import PreTrainedModel, PreTrainedTokenizerBase + +from dispatch.benchmark.harness import load_model +from dispatch.speculative.decode import ( + plain_greedy_generate, + run_speculative_rounds, + speculative_generate, +) +from dispatch.speculative.drafters import DraftModelDrafter, PromptLookupDrafter + +VOCAB_SIZE = 16 + + +class _FakeBatchEncoding(dict): # type: ignore[type-arg] + def to(self, device: str) -> _FakeBatchEncoding: + return self + + +class _FakeCache: + def __init__(self, length: int) -> None: + self.length = length + + def crop(self, tokens_to_remove: int) -> None: + # transformers>=5.17.0 uses negative integers to remove tokens + # (e.g., crop(-2) removes the last 2 tokens) + self.length += tokens_to_remove + + def get_seq_length(self) -> int: + # Real transformers.Cache objects implement this; exercising it here + # (rather than relying on the hasattr guard to skip it) is what + # makes run_speculative_rounds's cache-lag assertion (finding I3, + # final review) actually run against these CPU-only tests. + return self.length + + +class _FakeOutputs: + def __init__(self, logits: torch.Tensor, past_key_values: _FakeCache) -> None: + self.logits = logits + self.past_key_values = past_key_values + + +class _FakeIncrementModel: + """logits at every position spike at (that position's input token + 1) + % VOCAB_SIZE, regardless of past_key_values' contents -- so the + expected greedy continuation of any prompt is the closed-form + +1-mod-VOCAB_SIZE sequence computed by _expected_continuation below.""" + + def __call__( + self, *, input_ids: torch.Tensor, past_key_values: _FakeCache | None, use_cache: bool + ) -> _FakeOutputs: + next_ids = (input_ids + 1) % VOCAB_SIZE + logits = torch.nn.functional.one_hot(next_ids, VOCAB_SIZE).float() * 10.0 + prior_length = 0 if past_key_values is None else past_key_values.length + return _FakeOutputs(logits, _FakeCache(prior_length + input_ids.shape[1])) + + +class _AlwaysCorrectDrafter: + """Proposes exactly what _FakeIncrementModel will predict.""" + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + last = int(token_ids[0, -1]) + return torch.tensor([[(last + 1 + i) % VOCAB_SIZE for i in range(num_tokens)]]) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + def reset(self) -> None: + pass + + +class _AlwaysWrongDrafter: + """Proposes a constant offset that can never match _FakeIncrementModel's + +1 rule (VOCAB_SIZE > 2, so +2 != +1 mod VOCAB_SIZE).""" + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + last = int(token_ids[0, -1]) + return torch.tensor([[(last + 2) % VOCAB_SIZE for _ in range(num_tokens)]]) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + def reset(self) -> None: + pass + + +class _ResetTrackingDrafter: + """Stateless otherwise, but counts reset() calls -- used to verify + run_speculative_rounds resets a reused drafter at the start of every + independent call, not just the first (the bug behind a stale, + ever-growing drafter cache when one drafter instance is reused across + several generate() calls, e.g. a benchmark's own prompt/repetition + loop).""" + + def __init__(self) -> None: + self.reset_count = 0 + + def propose(self, token_ids: torch.Tensor, num_tokens: int) -> torch.Tensor: + last = int(token_ids[0, -1]) + return torch.tensor([[(last + 1 + i) % VOCAB_SIZE for i in range(num_tokens)]]) + + def on_accepted(self, accepted_len: int, rejected_len: int) -> None: + pass + + def reset(self) -> None: + self.reset_count += 1 + + +def _expected_continuation(prompt: list[int], num_tokens: int) -> list[int]: + sequence = list(prompt) + for _ in range(num_tokens): + sequence.append((sequence[-1] + 1) % VOCAB_SIZE) + return sequence[len(prompt) :] + + +def test_always_correct_drafter_matches_the_closed_form_continuation() -> None: + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysCorrectDrafter(), + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + # 2 rounds of k=4, each fully accepted (5 tokens emitted per round: 4 + # candidates + 1 bonus), summing to the 10 requested. + assert result.accepted_lengths == (4, 4) + + +def test_reuses_the_same_drafter_across_two_independent_calls_resetting_each_time() -> None: + """A caller that builds one drafter and reuses it across several + independent generate() calls (real usage: run_speculative_bench.py + building one DraftModelDrafter outside its prompt/repetition loop) + must get a fresh drafter.reset() before every call, not just the + first -- otherwise a stateful drafter (DraftModelDrafter) would leak + a prior, unrelated sequence's cache into the next call.""" + drafter = _ResetTrackingDrafter() + + run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + drafter, + torch.tensor([[3, 4, 5]]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + assert drafter.reset_count == 1 + + run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + drafter, + torch.tensor([[9, 10, 11]]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + assert drafter.reset_count == 2 + + +def test_always_wrong_drafter_still_matches_the_closed_form_continuation() -> None: + """Correctness must hold even when the drafter is never right -- the + target's own bonus/correction token is what actually advances the + sequence every round.""" + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysWrongDrafter(), + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + assert all(length == 0 for length in result.accepted_lengths) + + +def test_none_drafter_matches_the_closed_form_continuation() -> None: + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + None, + torch.tensor([prompt]), + num_speculative_tokens=4, + max_new_tokens=10, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 10) + # drafter=None means candidates are always empty, so every round emits + # exactly 1 (bonus) token -- 10 rounds for 10 requested tokens. + assert result.accepted_lengths == (0,) * 10 + + +def test_prompt_lookup_drafter_matches_the_continuation_and_accepts_something() -> None: + # [5, 6, 5] with ngram_size=1: the last token (5) also occurred at + # position 0, where it was followed by 6 -- exactly what + # _FakeIncrementModel's own +1 rule predicts after the current 5 too + # (its rule is a pure function of the token's value, memoryless), so + # the drafter's guess here is genuinely correct, not just harmless. + prompt = [5, 6, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + PromptLookupDrafter(ngram_size=1), + torch.tensor([prompt]), + num_speculative_tokens=1, + max_new_tokens=4, + eos_token_id=None, + ) + + assert list(result.generated_token_ids) == _expected_continuation(prompt, 4) + assert sum(result.accepted_lengths) > 0 + + +def test_cache_length_invariant_holds_after_multiple_rejecting_rounds() -> None: + """The target's cache always lags the full sequence (prompt + + generated) by exactly one token -- the most recent bonus/correction + token is never fed back in until the *next* round. A cache-rollback + bug (e.g. forgetting to crop) breaks this even though + _FakeIncrementModel's predictions don't depend on cache contents, so + output correctness alone would not catch it.""" + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysWrongDrafter(), # every round rejects, exercising crop(rejected_len > 0) + torch.tensor([prompt]), + num_speculative_tokens=2, + max_new_tokens=6, + eos_token_id=None, + ) + + total_sequence_length = len(prompt) + len(result.generated_token_ids) + assert result.past_key_values.length == total_sequence_length - 1 # type: ignore[attr-defined] + + +def test_speculative_generate_matches_plain_decode_via_a_fake_tokenizer() -> None: + class _FakeTokenizer: + eos_token_id = 999 + + def __call__(self, prompt: str, return_tensors: str) -> _FakeBatchEncoding: + return _FakeBatchEncoding(input_ids=torch.tensor([[3, 4, 5]])) + + timing, accepted_lengths, generated = speculative_generate( + _FakeIncrementModel(), # type: ignore[arg-type] + _FakeTokenizer(), # type: ignore[arg-type] + _AlwaysCorrectDrafter(), + "prompt", + num_speculative_tokens=2, + max_new_tokens=4, + ) + + assert list(generated) == _expected_continuation([3, 4, 5], 4) + assert timing.generated_token_count == 4 + assert timing.prompt_token_count == 3 + # Round 1 (k=2): both candidates accepted, +1 bonus = 3 tokens emitted. + # Round 2 (room=1, k=1): the 1 candidate accepted, no room left for a + # bonus = 1 token emitted. accepted_lengths = (2, 1), summing to 3. + assert sum(accepted_lengths) == 3 + + +def test_plain_greedy_generate_matches_the_closed_form_continuation() -> None: + class _FakeTokenizer: + eos_token_id = 999 + + def __call__(self, prompt: str, return_tensors: str) -> _FakeBatchEncoding: + return _FakeBatchEncoding(input_ids=torch.tensor([[3, 4, 5]])) + + timing, generated = plain_greedy_generate( + _FakeIncrementModel(), # type: ignore[arg-type] + _FakeTokenizer(), # type: ignore[arg-type] + "prompt", + max_new_tokens=4, + ) + + assert list(generated) == _expected_continuation([3, 4, 5], 4) + assert timing.generated_token_count == 4 + assert timing.prompt_token_count == 3 + + +@pytest.mark.slow +def test_speculative_generate_matches_plain_greedy_decode_on_a_real_tiny_model() -> None: + """The strongest correctness proof: a real model, real tokenizer, real + DynamicCache.crop() (transformers>=5.17.0) -- not the toy model above. + Uses a genuinely repetitive prompt so PromptLookupDrafter has real + matches to find. Plain greedy decoding is run manually, token-for-token + (generate_with_timings only returns timings, not the generated ids), + to get real ids to check token-for-token equality against.""" + model, tokenizer = load_model("hf-internal-testing/tiny-random-gpt2") + prompt = "the cat sat on the mat the cat sat on the mat" + max_new_tokens = 8 + + input_ids = tokenizer(prompt, return_tensors="pt").input_ids + plain_ids: list[int] = [] + past_key_values = None + next_input = input_ids + with torch.no_grad(): + for _ in range(max_new_tokens): + outputs = model(input_ids=next_input, past_key_values=past_key_values, use_cache=True) + past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + plain_ids.append(int(next_token.item())) + next_input = next_token + + _, _, speculative_ids = speculative_generate( + model, + tokenizer, + PromptLookupDrafter(ngram_size=3), + prompt, + num_speculative_tokens=4, + max_new_tokens=max_new_tokens, + ) + + assert list(speculative_ids) == plain_ids + + +def _plain_greedy_reference( + model: PreTrainedModel, + tokenizer: PreTrainedTokenizerBase, + prompt: str, + max_new_tokens: int, +) -> list[int]: + """Factors out the same manual plain-greedy loop + test_speculative_generate_matches_plain_greedy_decode_on_a_real_tiny_model + above hand-rolls, so the new DraftModelDrafter end-to-end test below can + reuse it rather than duplicating the loop body a third time (the other + two being here and decode.py's own plain_greedy_generate -- deliberately + not reused directly, since that would make this test's reference no + longer independent of the code under test).""" + input_ids = tokenizer(prompt, return_tensors="pt").input_ids + plain_ids: list[int] = [] + past_key_values = None + next_input = input_ids + with torch.no_grad(): + for _ in range(max_new_tokens): + outputs = model(input_ids=next_input, past_key_values=past_key_values, use_cache=True) + past_key_values = outputs.past_key_values + next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) + plain_ids.append(int(next_token.item())) + next_input = next_token + return plain_ids + + +@pytest.mark.slow +def test_speculative_generate_with_draft_model_drafter_matches_plain_greedy_decode() -> None: + """DraftModelDrafter has never been tested end-to-end inside + run_speculative_rounds against a real model, real tokenizer, and a real + transformers.Cache -- only in isolation against fake caches + (test_drafters.py). Given this branch's own history (DraftModelDrafter + carried two real Critical bugs, both caught only by hand-tracing, not + by its own given tests), and given the GPU session's own correctness + gate -- the only thing that WAS exercising this exact combination for + real -- turned out to be structurally non-discriminating (finding C2, + final review: the gate compared against a baseline generated by this + same shared loop), this closes a real, now-urgent coverage gap. Uses a + second, independently-loaded copy of the same tiny model as the draft + model, satisfying the design doc's shared-vocab requirement trivially.""" + model, tokenizer = load_model("hf-internal-testing/tiny-random-gpt2") + draft_model, _ = load_model("hf-internal-testing/tiny-random-gpt2") + prompt = "the quick brown fox jumps over the lazy dog" + max_new_tokens = 8 + + plain_ids = _plain_greedy_reference(model, tokenizer, prompt, max_new_tokens) + + _, _, speculative_ids = speculative_generate( + model, + tokenizer, + DraftModelDrafter(draft_model), + prompt, + num_speculative_tokens=4, + max_new_tokens=max_new_tokens, + ) + + assert list(speculative_ids) == plain_ids + + +def test_stops_exactly_at_eos_even_when_a_full_round_of_room_remains() -> None: + """A plain greedy decode stops the instant it emits eos_token_id, + never emitting anything after it -- even a single-token round's own + bonus token. Without the fix, a round that accepts an eos-valued + candidate would still tack on its bonus token before the eos check + fires, overshooting what plain greedy decoding would have produced.""" + prompt = [3, 4, 5] + result = run_speculative_rounds( + _FakeIncrementModel(), # type: ignore[arg-type] + _AlwaysCorrectDrafter(), + torch.tensor([prompt]), + num_speculative_tokens=1, + max_new_tokens=5, + eos_token_id=6, + ) + + assert list(result.generated_token_ids) == [6] + assert result.accepted_lengths == (1,) diff --git a/tests/unit/test_drafters.py b/tests/unit/test_drafters.py new file mode 100644 index 0000000..a927928 --- /dev/null +++ b/tests/unit/test_drafters.py @@ -0,0 +1,284 @@ +"""CPU-only, no model: PromptLookupDrafter's longest-suffix-match logic, +tested directly against constructed token sequences. Per +docs/design/2026-09-17-phase-5b-speculative-decoding.md section 3. +""" + +from __future__ import annotations + +import pytest +import torch + +from dispatch.speculative.drafters import DraftModelDrafter, PromptLookupDrafter + + +def test_proposes_whatever_followed_the_most_recent_matching_ngram() -> None: + # [A, B, C, A, B] with ngram_size=2: the last two tokens are [A, B], + # which also occurred at positions 0-1, followed there by [C]. + token_ids = torch.tensor([[10, 20, 30, 10, 20]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed = drafter.propose(token_ids, num_tokens=1) + + assert proposed.tolist() == [[30]] + + +def test_proposes_up_to_num_tokens_from_the_matched_continuation() -> None: + # [A, B, C, D, A, B] with ngram_size=2: [A, B] matched at positions + # 0-1, followed there by [C, D] -- only 2 tokens available. + token_ids = torch.tensor([[10, 20, 30, 40, 10, 20]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed_all = drafter.propose(token_ids, num_tokens=2) + proposed_truncated = drafter.propose(token_ids, num_tokens=1) + + assert proposed_all.tolist() == [[30, 40]] + assert proposed_truncated.tolist() == [[30]] + + +def test_returns_fewer_than_num_tokens_when_the_match_is_near_the_end() -> None: + # ngram_size=1: the last token (7) also occurred at position 2, where + # it was followed by [3, 7] -- only 2 tokens, fewer than the 5 requested. + token_ids = torch.tensor([[1, 2, 7, 3, 7]]) + drafter = PromptLookupDrafter(ngram_size=1) + + proposed = drafter.propose(token_ids, num_tokens=5) + + assert proposed.tolist() == [[3, 7]] + + +def test_returns_empty_when_no_earlier_match_exists() -> None: + token_ids = torch.tensor([[10, 20, 30, 40, 50]]) + drafter = PromptLookupDrafter(ngram_size=2) + + proposed = drafter.propose(token_ids, num_tokens=3) + + assert proposed.shape == (1, 0) + + +def test_returns_empty_when_the_sequence_is_too_short_for_the_ngram() -> None: + token_ids = torch.tensor([[10, 20]]) + drafter = PromptLookupDrafter(ngram_size=3) + + proposed = drafter.propose(token_ids, num_tokens=3) + + assert proposed.shape == (1, 0) + + +def test_returns_empty_for_a_non_positive_num_tokens() -> None: + token_ids = torch.tensor([[10, 20, 30, 10, 20, 30]]) + drafter = PromptLookupDrafter(ngram_size=2) + + assert drafter.propose(token_ids, num_tokens=0).shape == (1, 0) + + +def test_rejects_an_ngram_size_below_one() -> None: + with pytest.raises(ValueError, match="ngram_size"): + PromptLookupDrafter(ngram_size=0) + + +def test_on_accepted_is_a_no_op() -> None: + PromptLookupDrafter().on_accepted(accepted_len=2, rejected_len=1) + + +def test_prompt_lookup_reset_is_a_no_op() -> None: + PromptLookupDrafter().reset() + + +class _FakeCache: + """Tracks only a length -- the toy model below never reads cache + *contents*, only its length, so a cache-rollback bug shows up as a + wrong length, exactly the invariant this task's tests check.""" + + def __init__(self, length: int) -> None: + self.length = length + + def crop(self, tokens_to_remove: int) -> None: + # transformers>=5.17.0 uses negative integers to remove tokens + # (e.g., crop(-2) removes the last 2 tokens). + self.length += tokens_to_remove + + def get_seq_length(self) -> int: + # Real transformers.Cache objects implement this; exercising it + # here (rather than relying on the hasattr guard to skip it) is + # what makes DraftModelDrafter.propose's cache-lag assertion + # (finding I3, final review) actually run against these CPU-only + # tests. + return self.length + + +class _FakeOutputs: + def __init__(self, logits: torch.Tensor, past_key_values: _FakeCache) -> None: + self.logits = logits + self.past_key_values = past_key_values + + +class _FakeIncrementModel: + """A toy causal LM: predicts (input_token + 1) % vocab_size at every + position, deliberately ignoring any actual cache contents -- only + call_count and cache length are used for assertions, not model + quality.""" + + def __init__(self, vocab_size: int = 16) -> None: + self.vocab_size = vocab_size + self.call_count = 0 + + def __call__( + self, *, input_ids: torch.Tensor, past_key_values: _FakeCache | None, use_cache: bool + ) -> _FakeOutputs: + self.call_count += 1 + next_ids = (input_ids + 1) % self.vocab_size + logits = torch.nn.functional.one_hot(next_ids, self.vocab_size).float() * 10.0 + prior_length = 0 if past_key_values is None else past_key_values.length + return _FakeOutputs(logits, _FakeCache(prior_length + input_ids.shape[1])) + + +def test_first_propose_call_feeds_the_whole_prompt() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + prompt = torch.tensor([[3, 4, 5]]) + + proposed = drafter.propose(prompt, num_tokens=2) + + assert proposed.tolist() == [[6, 7]] + assert model.call_count == 3 + assert drafter.past_key_values is not None + # Iteration 1 feeds the whole 3-token prompt (cache was empty) and + # produces candidate 6; iteration 2 feeds candidate 6 and produces + # candidate 7; a final extra call feeds candidate 7 too, so the + # cache ends up holding the prompt plus BOTH candidates -- not + # just the first one -- matching on_accepted's crop-by- + # rejected_len formula, which assumes every proposed candidate is + # cache-resident. + assert drafter.past_key_values.length == 3 + 2 # type: ignore[attr-defined] + + +def test_later_propose_call_feeds_only_the_newest_token() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=2) # seeds the cache, length 3+2=5 + + proposed = drafter.propose(torch.tensor([[3, 4, 5, 6, 7]]), num_tokens=2) + + assert proposed.tolist() == [[8, 9]] + assert drafter.past_key_values is not None + # This call feeds exactly 1 new (catch-up) token plus both of + # this round's 2 candidates: 5 + 1 + 2 = 8. + assert drafter.past_key_values.length == 5 + 1 + 2 # type: ignore[attr-defined] + + +def test_on_accepted_crops_the_cache_by_the_rejected_length() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + # Feeds the whole 3-token prompt plus all 4 proposed candidates: + # cache length = 3 + 4 = 7. + drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=4) + + drafter.on_accepted(accepted_len=1, rejected_len=3) + + assert drafter.past_key_values is not None + assert drafter.past_key_values.length == 7 - 3 # type: ignore[attr-defined] + + +def test_on_accepted_before_any_propose_call_is_a_no_op() -> None: + DraftModelDrafter(_FakeIncrementModel()).on_accepted(accepted_len=0, rejected_len=0) # type: ignore[arg-type] + + +def test_propose_with_non_positive_num_tokens_returns_empty_without_calling_the_model() -> None: + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + + proposed = drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=0) + + assert proposed.shape == (1, 0) + assert model.call_count == 0 + + +def test_reset_clears_the_cache_so_a_reused_drafter_starts_fresh() -> None: + """A caller that builds one DraftModelDrafter and reuses it across + several independent generate() calls (real usage: + run_speculative_bench.py building one drafter outside its + prompt/repetition loop) must not leak a prior, unrelated sequence's + KV cache into the next call -- the bug this reset() method fixes.""" + model = _FakeIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=2) # seeds the cache, length 5 + assert drafter.past_key_values is not None + + drafter.reset() + + # A brand new, unrelated 4-token sequence: without reset(), propose() + # would treat this as a continuation of the old cache (feeding only + # the last token) instead of a fresh sequence (feeding all 4). + proposed = drafter.propose(torch.tensor([[9, 10, 11, 12]]), num_tokens=1) + + assert proposed.tolist() == [[13]] + assert drafter.past_key_values is not None + assert drafter.past_key_values.length == 4 + 1 # type: ignore[attr-defined] + + +class _FakeTrackingCache: + """Like _FakeCache, but records the actual token values fed into it + across calls, so a test can assert which real tokens the cache holds + -- catching a bug where a crop removes the wrong tail even though the + resulting length happens to look right.""" + + def __init__(self, tokens: list[int]) -> None: + self.tokens = tokens + + @property + def length(self) -> int: + return len(self.tokens) + + def crop(self, tokens_to_remove: int) -> None: + # transformers>=5.17.0 uses negative integers to remove tokens. + if tokens_to_remove == 0: + return + del self.tokens[tokens_to_remove:] + + def get_seq_length(self) -> int: + return len(self.tokens) + + +class _FakeTrackingOutputs: + def __init__(self, logits: torch.Tensor, past_key_values: _FakeTrackingCache) -> None: + self.logits = logits + self.past_key_values = past_key_values + + +class _FakeTrackingIncrementModel: + """Same +1-mod-vocab-size rule as _FakeIncrementModel, but its cache + tracks real fed token values instead of just a length.""" + + def __init__(self, vocab_size: int = 16) -> None: + self.vocab_size = vocab_size + + def __call__( + self, + *, + input_ids: torch.Tensor, + past_key_values: _FakeTrackingCache | None, + use_cache: bool, + ) -> _FakeTrackingOutputs: + next_ids = (input_ids + 1) % self.vocab_size + logits = torch.nn.functional.one_hot(next_ids, self.vocab_size).float() * 10.0 + prior_tokens = [] if past_key_values is None else list(past_key_values.tokens) + new_tokens = prior_tokens + input_ids[0].tolist() + return _FakeTrackingOutputs(logits, _FakeTrackingCache(new_tokens)) + + +def test_propose_then_on_accepted_leaves_the_cache_holding_exactly_the_accepted_tokens() -> None: + """Chains propose() -> on_accepted() and checks the cache's actual + token content (not just its length): after a round, the drafter's + cache must hold exactly the prompt plus every token the target + actually accepted -- never a rejected candidate, and never short a + genuinely-accepted one.""" + model = _FakeTrackingIncrementModel() + drafter = DraftModelDrafter(model) # type: ignore[arg-type] + + proposed = drafter.propose(torch.tensor([[3, 4, 5]]), num_tokens=4) + assert proposed.tolist() == [[6, 7, 8, 9]] + + drafter.on_accepted(accepted_len=1, rejected_len=3) + + assert drafter.past_key_values is not None + assert drafter.past_key_values.tokens == [3, 4, 5, 6] # type: ignore[attr-defined] diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index af55f40..055a98e 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -5,14 +5,32 @@ from __future__ import annotations +from collections.abc import Iterator + import pytest import torch +from transformers import AutoModelForCausalLM, AutoTokenizer +import dispatch.benchmark.harness as harness_module from dispatch.benchmark.harness import generate_with_timings, load_model TINY_MODEL = "hf-internal-testing/tiny-random-gpt2" +class _FakeLoadedModel: + def to(self, device: str) -> _FakeLoadedModel: + return self + + def eval(self) -> None: # stub of nn.Module.eval() (train/eval mode), not the builtin + pass + + def modules(self) -> Iterator[torch.nn.Module]: + # A real nn.Module with no rotary-embedding submodules -- lets + # fix_rope_inv_freq's real implementation run against this fake + # (finding 0 matches) instead of needing its own mock. + return iter(()) + + class _FakeOutputs: def __init__(self, logits: torch.Tensor) -> None: self.logits = logits @@ -64,6 +82,56 @@ def test_generate_with_timings_runs_max_new_tokens_steps_without_eos() -> None: assert timing.token_times == (0.1, 0.2, 0.3) +def test_load_model_omits_attn_implementation_by_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_from_pretrained(model_name: str, **kwargs: object) -> _FakeLoadedModel: + captured_kwargs.update(kwargs) + return _FakeLoadedModel() + + monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", fake_from_pretrained) + monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *a, **k: _FakeTokenizer()) + + load_model("some-model") + + assert "attn_implementation" not in captured_kwargs + + +def test_load_model_forwards_an_explicit_attn_implementation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_from_pretrained(model_name: str, **kwargs: object) -> _FakeLoadedModel: + captured_kwargs.update(kwargs) + return _FakeLoadedModel() + + monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", fake_from_pretrained) + monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *a, **k: _FakeTokenizer()) + + load_model("some-model", attn_implementation="sdpa") + + assert captured_kwargs["attn_implementation"] == "sdpa" + + +def test_load_model_applies_the_rope_fix_to_every_caller(monkeypatch: pytest.MonkeyPatch) -> None: + """Regression test for the final-review finding that the rope fix was + only wired into scripts/run_speculative_bench.py, leaving every other + load_model caller (run_baseline.py included) still exposed to the + uninitialized-inv_freq bug. Spies on fix_rope_inv_freq rather than + re-testing its own logic (that's test_integration.py's job).""" + fixed_models: list[object] = [] + monkeypatch.setattr(AutoModelForCausalLM, "from_pretrained", lambda *a, **k: _FakeLoadedModel()) + monkeypatch.setattr(AutoTokenizer, "from_pretrained", lambda *a, **k: _FakeTokenizer()) + monkeypatch.setattr(harness_module, "fix_rope_inv_freq", fixed_models.append) + + model, _ = load_model("some-model") + + assert fixed_models == [model] + + @pytest.mark.slow def test_generate_with_timings_against_a_real_tiny_model() -> None: model, tokenizer = load_model(TINY_MODEL) diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py index 5b9f567..bdca4aa 100644 --- a/tests/unit/test_integration.py +++ b/tests/unit/test_integration.py @@ -14,7 +14,11 @@ import torch from dispatch.kernels import integration as integration_module -from dispatch.kernels.integration import patch_moe_infer, patch_moe_infer_quantized +from dispatch.kernels.integration import ( + fix_rope_inv_freq, + patch_moe_infer, + patch_moe_infer_quantized, +) from dispatch.kernels.moe_forward import stack_expert_weights, torch_grouped_matmul from dispatch.kernels.quantization import ( dequantize_int8, @@ -197,3 +201,142 @@ def moe_infer(self) -> None: with pytest.raises(TypeError, match="ModuleList"): patch_moe_infer_quantized(Odd(), torch_grouped_matmul_dequant) + + +class _FakeRotaryEmbedding(torch.nn.Module): + """Duck-types DeepseekRotaryEmbedding's shape (inv_freq/dim/base/ + max_seq_len_cached) AND its real forward()/_set_cos_sin_cache() + rebuild guard (`max_seq_len_cached is None or seq_len > ...`) -- so + a test can exercise the fix's second half (forcing forward() to + rebuild cos/sin from the *corrected* inv_freq) on CPU, not just + assert the precondition for it.""" + + def __init__(self, dim: int, base: int, corrupted_inv_freq: torch.Tensor) -> None: + super().__init__() + self.dim = dim + self.base = base + self.register_buffer("inv_freq", corrupted_inv_freq, persistent=False) + self.max_seq_len_cached = 11 # a real forward call would have set this + # Garbage caches, standing in for what a real model's stale + # cos_cached/sin_cached (derived from the corrupted inv_freq) + # would look like right after loading, before any fix runs. + self.register_buffer("cos_cached", torch.zeros(11, dim), persistent=False) + self.register_buffer("sin_cached", torch.zeros(11, dim), persistent=False) + + def _set_cos_sin_cache(self, seq_len: int) -> None: + self.max_seq_len_cached = seq_len + t = torch.arange(seq_len, dtype=torch.float32) + freqs = torch.einsum("i,j->ij", t, cast(torch.Tensor, self.inv_freq)) + emb = torch.cat((freqs, freqs), dim=-1) + self.cos_cached = emb.cos() + self.sin_cached = emb.sin() + + def forward(self, seq_len: int) -> tuple[torch.Tensor, torch.Tensor]: + if self.max_seq_len_cached is None or seq_len > self.max_seq_len_cached: + self._set_cos_sin_cache(seq_len) + return self.cos_cached[:seq_len], self.sin_cached[:seq_len] + + +class _FakeAttention(torch.nn.Module): + def __init__(self, rotary_emb: _FakeRotaryEmbedding) -> None: + super().__init__() + self.rotary_emb = rotary_emb + + +class _NativeLlamaStyleRotaryEmbedding(torch.nn.Module): + """Shaped like transformers' own (non-remote-code) 5.x + LlamaRotaryEmbedding: has inv_freq and max_seq_len_cached, but not + dim/base (it takes a config object in its __init__ instead) -- the + exact shape a native, non-DeepSeek draft model's rotary embedding + has today, per the final review. Must NOT match: a real Llama-native + model's inv_freq is not corrupted by this bug, so fixing it here + would rewrite a buffer this function has no evidence is wrong.""" + + def __init__(self) -> None: + super().__init__() + self.register_buffer("inv_freq", torch.tensor([1.0, 0.1, 0.01, 0.001]), persistent=False) + self.max_seq_len_cached = 2048 + + +def _expected_inv_freq(dim: int, base: int) -> torch.Tensor: + return 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim)) + + +def test_fix_rope_inv_freq_recomputes_a_corrupted_buffer() -> None: + garbage = torch.tensor([float("nan"), 0.0, 0.0, 0.0]) + rotary = _FakeRotaryEmbedding(dim=8, base=10000, corrupted_inv_freq=garbage) + model = _FakeAttention(rotary) + + fixed = fix_rope_inv_freq(model) + + assert fixed == 1 + # Literal expected values, not a second call to the same formula the + # implementation uses -- a shared misunderstanding of DeepSeek's own + # formula would otherwise pass silently (dim=8, base=10000: exponents + # 0/8, 2/8, 4/8, 6/8 -> 10000**0, 10000**0.25, 10000**0.5, 10000**0.75 + # -> 1, 10, 100, 1000, inverted). + assert torch.allclose( + cast(torch.Tensor, rotary.inv_freq), torch.tensor([1.0, 0.1, 0.01, 0.001]) + ) + assert rotary.max_seq_len_cached is None + + +def test_fix_rope_inv_freq_forces_forward_to_rebuild_from_the_corrected_buffer() -> None: + """The fix has two halves: recomputing inv_freq, and resetting + max_seq_len_cached so the next forward() call actually rebuilds + cos_cached/sin_cached from it (they were derived from the corrupted + buffer and are just as wrong). This test fails if either half is + missing: skip the inv_freq fix and the expected values below are + wrong; skip the max_seq_len_cached reset and forward() returns the + stale garbage cache untouched (seq_len=5 <= the fake's initial + max_seq_len_cached=11, so the guard's `seq_len > ...` branch alone + would never trigger a rebuild).""" + dim, base = 8, 10000 + garbage = torch.tensor([float("nan"), 0.0, 0.0, 0.0]) + rotary = _FakeRotaryEmbedding(dim, base, garbage) + model = _FakeAttention(rotary) + + fix_rope_inv_freq(model) + cos, sin = rotary(seq_len=5) + + t = torch.arange(5, dtype=torch.float32) + expected_freqs = torch.einsum("i,j->ij", t, _expected_inv_freq(dim, base)) + expected_emb = torch.cat((expected_freqs, expected_freqs), dim=-1) + assert torch.allclose(cos, expected_emb.cos()) + assert torch.allclose(sin, expected_emb.sin()) + + +def test_fix_rope_inv_freq_fixes_every_layer_found() -> None: + class ManyLayers(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.layers = torch.nn.ModuleList( + [_FakeAttention(_FakeRotaryEmbedding(8, 10000, torch.zeros(4))) for _ in range(3)] + ) + + model = ManyLayers() + + fixed = fix_rope_inv_freq(model) + + assert fixed == 3 + for module in model.layers: + layer = cast(_FakeAttention, module) + assert torch.allclose( + cast(torch.Tensor, layer.rotary_emb.inv_freq), _expected_inv_freq(8, 10000) + ) + + +def test_fix_rope_inv_freq_ignores_modules_without_the_rope_shape() -> None: + model = torch.nn.Sequential(torch.nn.Linear(4, 4), torch.nn.ReLU()) + + fixed = fix_rope_inv_freq(model) + + assert fixed == 0 + + +def test_fix_rope_inv_freq_ignores_a_native_llama_shaped_rotary_embedding() -> None: + model = _NativeLlamaStyleRotaryEmbedding() + + fixed = fix_rope_inv_freq(model) + + assert fixed == 0 diff --git a/tests/unit/test_run_speculative_bench.py b/tests/unit/test_run_speculative_bench.py new file mode 100644 index 0000000..c7c636c --- /dev/null +++ b/tests/unit/test_run_speculative_bench.py @@ -0,0 +1,250 @@ +"""main()'s plumbing (args -> files) is tested fast with +run_speculative_bench and summarize monkeypatched out, matching +test_run_baseline.py's pattern; the real end-to-end guard test is `slow`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import scripts.run_speculative_bench as run_speculative_bench_module +import torch +from scripts.run_speculative_bench import build_drafter, main + +from dispatch.benchmark.metrics import BenchmarkSummary +from dispatch.speculative.drafters import DraftModelDrafter, PromptLookupDrafter +from dispatch.speculative.reference import save_generated_tokens + +FAKE_SUMMARY = BenchmarkSummary( + run_count=1, + mean_ttft=0.1, + p50_ttft=0.1, + p99_ttft=0.1, + mean_inter_token_latency=0.05, + mean_tokens_per_second=20.0, +) +FAKE_TOKENS: dict[str, tuple[int, ...]] = {"prompt_000_tokens": (1, 2, 3)} + + +def _fake_run_speculative_bench( + tokens: dict[str, tuple[int, ...]], moe_layers_patched: int, rope_buffers_fixed: int = 27 +) -> object: + def fake(model_name: str, **kwargs: object) -> tuple[list[object], dict, int, int]: # type: ignore[type-arg] + return [(object(), (2, 1))], tokens, moe_layers_patched, rope_buffers_fixed + + return fake + + +def test_build_drafter_none_returns_none() -> None: + assert ( + build_drafter( + "none", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + is None + ) + + +def test_build_drafter_prompt_lookup_returns_a_prompt_lookup_drafter() -> None: + drafter = build_drafter( + "prompt-lookup", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=5, + ) + + assert isinstance(drafter, PromptLookupDrafter) + assert drafter.ngram_size == 5 + + +def test_build_drafter_draft_model_loads_and_wraps_it(monkeypatch: pytest.MonkeyPatch) -> None: + captured_kwargs: dict[str, object] = {} + + def fake_load_model(*args: object, **kwargs: object) -> tuple[str, str]: + captured_kwargs.update(kwargs) + return "the-model", "the-tokenizer" + + monkeypatch.setattr(run_speculative_bench_module, "load_model", fake_load_model) + + drafter = build_drafter( + "draft-model", + draft_model_name="deepseek-ai/deepseek-llm-7b-base", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + + assert isinstance(drafter, DraftModelDrafter) + model: object = drafter.model # load_model is monkeypatched to return a plain str here + assert model == "the-model" + assert captured_kwargs["attn_implementation"] == "sdpa" + # fix_rope_inv_freq itself now runs inside load_model, for every + # caller (see test_harness.py) -- not spied on here, since + # load_model is mocked out in this test and build_drafter no longer + # calls it a second time. + + +def test_build_drafter_rejects_an_unknown_name() -> None: + with pytest.raises(ValueError, match="unknown drafter"): + build_drafter( + "bogus", + draft_model_name="x", + device="cpu", + dtype=torch.float32, + prompt_lookup_ngram_size=3, + ) + + +def test_main_writes_results_and_generated_tokens( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(FAKE_TOKENS, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + main( + [ + "--model-name", + "tiny/test-model", + "--output-dir", + str(tmp_path), + "--run-label", + "test-run", + ] + ) + + results = json.loads((tmp_path / "test-run-results.json").read_text()) + assert results["model"] == "tiny/test-model" + assert results["mean_tokens_per_second"] == 20.0 + assert results["moe_layers_patched"] == 27 + assert results["rope_buffers_fixed"] == 27 + assert results["attn_implementation"] == "sdpa" + assert results["acceptance_rate"] == pytest.approx( + 1.5 / 4 + ) # mean(2, 1) / num_speculative_tokens + assert results["token_match"] == {} + assert (tmp_path / "test-run-generated-tokens.json").exists() + + +def test_main_records_agreement_with_a_baseline( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + reference_path = tmp_path / "baseline-generated-tokens.json" + save_generated_tokens(FAKE_TOKENS, reference_path) + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(FAKE_TOKENS, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + main( + [ + "--drafter", + "prompt-lookup", + "--compare-generated-tokens", + str(reference_path), + "--output-dir", + str(tmp_path), + "--run-label", + "spec-run", + ] + ) + + results = json.loads((tmp_path / "spec-run-results.json").read_text()) + assert results["token_match"] == {"prompt_000_tokens": True} + + +def test_main_exits_nonzero_on_token_divergence_but_keeps_the_evidence( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + reference_path = tmp_path / "baseline-generated-tokens.json" + save_generated_tokens(FAKE_TOKENS, reference_path) + wrong_tokens: dict[str, tuple[int, ...]] = {"prompt_000_tokens": (1, 2, 9)} + monkeypatch.setattr( + run_speculative_bench_module, + "run_speculative_bench", + _fake_run_speculative_bench(wrong_tokens, 27), + ) + monkeypatch.setattr(run_speculative_bench_module, "summarize", lambda runs: FAKE_SUMMARY) + + with pytest.raises(SystemExit, match="disagree"): + main( + [ + "--drafter", + "draft-model", + "--compare-generated-tokens", + str(reference_path), + "--output-dir", + str(tmp_path), + "--run-label", + "spec-run", + ] + ) + + results = json.loads((tmp_path / "spec-run-results.json").read_text()) + assert results["token_match"] == {"prompt_000_tokens": False} + assert (tmp_path / "spec-run-generated-tokens.json").exists() + + +def test_run_speculative_bench_refuses_a_target_with_no_rope_buffers_fixed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test for the scoped re-review finding that the new + rope_buffers_fixed==0 guard (added alongside the pre-existing + moe_layers_patched==0 one) had no test at any tier: a model that + patches MoE layers but matches no rotary-embedding shape must still + refuse to run, since that combination means fix_rope_inv_freq's + duck-typed shape stopped matching this target -- exactly the silent + regression the guard exists to catch.""" + monkeypatch.setattr( + run_speculative_bench_module, "load_model", lambda *a, **k: ("the-model", "the-tokenizer") + ) + monkeypatch.setattr(run_speculative_bench_module, "fix_rope_inv_freq", lambda model: 0) + monkeypatch.setattr( + run_speculative_bench_module, "patch_moe_infer_quantized", lambda model, backend: 27 + ) + monkeypatch.setattr(run_speculative_bench_module, "resolve_quantized_backend", object) + + with pytest.raises(RuntimeError, match="fixed no RoPE buffers"): + run_speculative_bench_module.run_speculative_bench( + "some-model", + device="cpu", + dtype=torch.float32, + trust_remote_code=False, + prompts=["hello"], + repetitions=1, + max_new_tokens=3, + drafter_name="none", + draft_model_name="unused", + num_speculative_tokens=4, + prompt_lookup_ngram_size=3, + ) + + +@pytest.mark.gpu +@pytest.mark.slow +def test_run_speculative_bench_refuses_a_target_that_patches_nothing() -> None: + with pytest.raises(RuntimeError, match="patched no MoE layers"): + run_speculative_bench_module.run_speculative_bench( + "hf-internal-testing/tiny-random-gpt2", + device="cpu", + dtype=torch.float32, + trust_remote_code=False, + prompts=["hello"], + repetitions=1, + max_new_tokens=3, + drafter_name="none", + draft_model_name="unused", + num_speculative_tokens=4, + prompt_lookup_ngram_size=3, + ) diff --git a/tests/unit/test_speculative_reference.py b/tests/unit/test_speculative_reference.py new file mode 100644 index 0000000..6feafb3 --- /dev/null +++ b/tests/unit/test_speculative_reference.py @@ -0,0 +1,46 @@ +"""CPU-only: exact generated-token save/load/compare -- speculative +decoding's correctness claim (docs/design/2026-09-17-phase-5b-speculative-decoding.md +section 5) is byte-exact token match, not a logit tolerance, so this is a +separate, stricter mechanism than benchmark/reference.py's +compare_top_k_agreement. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dispatch.speculative.reference import ( + compare_generated_tokens, + load_generated_tokens, + save_generated_tokens, +) + + +def test_save_and_load_round_trip(tmp_path: Path) -> None: + tokens = {"prompt_000_tokens": (1, 2, 3), "prompt_001_tokens": (4, 5)} + path = tmp_path / "generated-tokens.json" + + save_generated_tokens(tokens, path) + loaded = load_generated_tokens(path) + + assert loaded == tokens + + +def test_compare_reports_true_when_identical() -> None: + tokens = {"prompt_000_tokens": (1, 2, 3)} + + assert compare_generated_tokens(tokens, tokens) == {"prompt_000_tokens": True} + + +def test_compare_reports_false_on_any_divergence() -> None: + actual = {"prompt_000_tokens": (1, 2, 9)} + reference = {"prompt_000_tokens": (1, 2, 3)} + + assert compare_generated_tokens(actual, reference) == {"prompt_000_tokens": False} + + +def test_compare_rejects_a_key_mismatch() -> None: + with pytest.raises(ValueError, match="key mismatch"): + compare_generated_tokens({"a": (1,)}, {"b": (1,)})