Skip to content

Phase 5b: speculative decoding, RoPE inv_freq root-cause fix, per-phase findings folders - #6

Merged
bsreecharanreddy merged 27 commits into
mainfrom
phase-5b-speculative-decoding
Sep 18, 2026
Merged

bsreecharanreddy merged 27 commits into
mainfrom
phase-5b-speculative-decoding

Conversation

@bsreecharanreddy

@bsreecharanreddy bsreecharanreddy commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Speculative decoding on the int8-quantized MoE target: a shared propose/verify/accept/rollback loop (src/dispatch/speculative/) with two drafters (a real 7B draft model and model-free prompt-lookup), an independent plain-greedy oracle for the baseline, and scripts/run_speculative_bench.py.
  • Root-caused and fixed the degenerate baseline that had forced the earlier GPU numbers to be withdrawn: 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 (previously only one script would have been covered), with a regression guard and results-JSON fields (rope_buffers_fixed, attn_implementation).
  • Real re-measured run (A40, checked against the baseline at every k, closing finding I5), superseding the withdrawn numbers.
  • docs/findings/ split into phase-0/ ... phase-5b/, filenames unchanged; all cross-references and the six GPU scripts' default output dirs updated.

Results (A40, bf16, unbatched decode, 64 new tokens, 4 prompts x 5 reps)

Config Tokens/sec vs. baseline Acceptance token_match (4 prompts)
baseline 25.18 n/a n/a n/a
draft-model, k=4 (gate run) 23.03 -8.5% 74.5% 4/4
prompt-lookup, k=4 (gate run) 47.21 +87.5% 17.7% 2/4
prompt-lookup, k=4 (k-sweep re-run, same config) 44.74 +77.7% 14.9% 3/4

Full k-sweep (k=1,2,4,8, both drafters) in docs/findings/phase-5b/2026-09-18-phase-5b-speculative-decoding-run.md. 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 higher acceptance. Throughputs are single measured runs on one pod; the two k=4 prompt-lookup runs differ by ~5%.

Not every config matches the baseline byte-for-byte, and this is reported rather than hidden: In 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 diverges in 6 of 8 configs, including draft-model at k=1/2/8, and the same k=4 prompt-lookup config matched 2/4 prompts in one process and 3/4 in another. Root-caused live on the GPU to a genuine near-tied logit position (top-2 gap ~0.1-0.4 of ~20) under the int8 kernel's floating-point precision, confirmed by a batch-width probe and a 10-trial same-process determinism probe. The propose/verify/rollback logic was re-derived by hand and is correct; this is a numerical-precision limit, not a logic defect.

Things worth a reviewer's attention

  • Open question, not audited here: the same grouped_matmul_int8 kernel underlies Phase 5a's "perfect top-1/mutual-top-k agreement" claim, and a single-run top-k check would not detect this near-tie sensitivity. Flagged in the findings doc and STATUS.md for a follow-up decision.
  • Evidence gap: the 11 results.json files are exact reconstructions from the session's captured stdout; the 11 raw generated-tokens.json files for the real run were lost when the pod's repo clone did not survive a stop/start (it lived outside the persistent mount). Documented in the findings doc.
  • Cost: $12.26 of a $20 cap across both Phase 5b sessions, including a disclosed overrun of the original $10 cap on one pod left running through an unbounded wait.
  • Final whole-branch review and a scoped re-review both ran; all findings were fixed (the RoPE fix moved into load_model, guard ordering, test-oracle quality, guard coverage).

Test plan

  • make check equivalent locally: ruff format/lint clean, mypy --strict clean, 170 passed / 2 skipped / 2 deselected (pytest -m "not gpu")
  • Real GPU runs (baseline, both gates, 8-config k-sweep) recorded under docs/findings/phase-5b/
  • Reviewer: spot-check that moved findings links resolve (a few pre-existing stale filename predictions in old plan docs are intentionally left alone)

Real L40 session (RunPod Secure Cloud, US-KS-2, $0.6833 of $10 cap):
both drafters (draft-model, prompt-lookup) pass exact-match correctness
against a plain-greedy baseline on the real 16B target under the int8
quantized kernel. Draft-model: 91.9% acceptance, +21.8% throughput at
k=4. Prompt-lookup: 22.1% acceptance, +76.9% throughput at k=4 (lower
acceptance but near-zero overhead wins on raw speed). Memory
checkpoints confirm the design's budget rationale: quantized target
alone allocates 16.75GB, target+draft together 29.62GB, comfortably
under the ~44.4GB L40.

Five distinct DeepSeek remote-code breaks found and fixed live against
transformers==5.17.0 (is_torch_fx_available, get_usable_length,
from_legacy_cache, and to_legacy_cache all removed/changed; rope_scaling
auto-populated into a dict missing the old "type" key). Deliberately
did not downgrade transformers this time -- Phase 0/1/5a's own
established downgrade fix would have silently broken this phase's own
Cache.crop() negative-number semantics that Tasks 2-3 depend on -- so
kept the pinned version and monkeypatched the specific broken symbols
instead. All folded into the runbook for future phases.

The k-sweep ran the full 4-prompt suite at every k rather than just the
originally-planned repetition-heavy prompt alone, since the CLI has no
per-prompt selection flag and adding one mid-session wasn't worth the
risk for a pure cost optimization; noted in the runbook.
…o-end draft-model test; withdraw unverified GPU throughput numbers

Final whole-branch review found the baseline oracle (--drafter none)
was generated by the same shared verify/accept/reject loop the
correctness gates were meant to check, making a real degenerate GPU
session output (all 4 prompts collapsing to a single repeated token)
pass as a "correctness match" against itself. Adds a genuinely
independent plain-greedy oracle, runtime cache-lag assertions, and a
real end-to-end CPU test of DraftModelDrafter inside the loop -- none
of which existed before. Withdraws the GPU session's throughput and
correctness-gate claims from the findings doc and STATUS.md pending a
root-cause investigation (out of scope here, needs a new GPU session).
…o the runbook

Final whole-branch review found the prior GPU session's baseline
degenerated (repeating one token id across all 4 prompts) and both
correctness gates passed only because they matched that same broken
output -- a vacuous pass. Fixes two real gaps for the next session:
a stock-vs-quantized investigation step to localize the cause before
spending more money, and an explicit baseline-plausibility check
(decode and eyeball the tokens) before trusting any token_match
result. Also closes finding I5: the k-sweep now checks
--compare-generated-tokens at every k, not just k=4.
…5.17.0

Root-caused the C1 degenerate-baseline finding on a second GPU session:
DeepSeek's remote code collapses attention_mask to None for an unpadded
single-sequence input, then eager attention builds a causal 4D mask via
the deprecated transformers.modeling_attn_mask_utils path. That path
produces all-NaN logits under transformers==5.17.0 (the version pinned
for Cache.crop()'s negative-argument semantics), independent of dtype
(reproduced in both bf16 and fp16) and independent of quantization
(reproduced on the stock, unpatched model). torch.argmax on an all-NaN
row returns index 0 by first-occurrence tie-break, which is exactly the
degenerate all-token-0 output the final whole-branch review flagged.

Forcing attn_implementation="sdpa" on both the target and draft model
loads sidesteps the deprecated mask construction entirely (sdpa relies
on its own is_causal fast path when no mask is passed) and produces
real, varied, plausible logits with no NaN. Verified live against the
real 16B model on a rented L40: eager reproduces the NaN exactly, sdpa
does not.

load_model() gains an optional attn_implementation parameter, forwarded
to from_pretrained only when given, so every other call site's existing
behavior is unchanged.
…() call

Found live on the second GPU session, immediately after the sdpa fix
made real (non-degenerate) generation happen for the first time:
run_speculative_bench.py builds one DraftModelDrafter and reuses it
across 20 separate speculative_generate() calls (4 prompts x 5
repetitions), but DraftModelDrafter.propose() only feeds the whole
sequence when its own past_key_values is None -- true only for the very
first call ever made on that instance. Every later call, including ones
for a completely different prompt, was treated as a continuation of
whatever sequence came before it: the drafter's cache grew across all
20 runs instead of restarting per prompt, eventually exhausting the
GPU's memory (the run that hit this went silent and the SSH connection
dropped, consistent with a Linux OOM-kill).

Session 1 never surfaced this: every prompt's baseline was already
degenerate (all-token-0, the NaN bug fixed in the prior commit), so a
corrupted drafter cache made no visible difference to an already-broken
result.

Adds reset() to the Drafter protocol (a no-op for stateless
PromptLookupDrafter, clears past_key_values for DraftModelDrafter) and
calls it at the top of run_speculative_rounds, alongside the target's
own fresh past_key_values = None -- fixing this at the shared loop's
level so any future caller that reuses a drafter across calls is
correct by construction, not by caller discipline.
AutoModelForCausalLM/AutoTokenizer accessed via harness_module.X are an
implicit reexport, which --strict flags. Import them directly from
transformers instead -- same class objects at runtime, so monkeypatching
their from_pretrained still reaches harness.load_model's own calls.
…ot cause of the degenerate baseline)

Root-caused via layer-by-layer NaN isolation on a real GPU session:
NaN first appears in layer 0's self_attn, before the MoE FFN is ever
touched. Tracing deeper (per-submodule hooks on q_proj/k_proj/v_proj/
o_proj/rotary_emb) found it: DeepseekRotaryEmbedding's inv_freq buffer
--computed fresh in __init__ as 1/base**(i/dim), marked
persistent=False because it's not meant to be part of a checkpoint's
state_dict -- is left as uninitialized memory by transformers==5.17.0's
model-loading path instead of holding that computed value. Confirmed
the corruption is present immediately after AutoModelForCausalLM.
from_pretrained returns, before any .to(device) call -- not a GPU/CUDA
numerics issue despite only reliably producing NaN once a forward pass
runs on GPU (this explains why a CPU forward pass reading the same
garbage inv_freq produced real, non-NaN, wrong-content output instead,
and why the bug reproduced identically across two different GPU
architectures, hosts, and every attn_implementation tested including
plain "math" SDPA).

This supersedes this branch's earlier "force sdpa" fix. That change is
kept (sdpa is a reasonable choice on its own merits), but the comments
claiming it fixed the eager-masking path have been corrected: that
distinction doesn't hold up under this fully identified root cause --
the actual bug reproduced under every attn_implementation tested,
sdpa included.

fix_rope_inv_freq() (dispatch.kernels.integration, alongside the other
after-load DeepSeek remote-code repairs) discovers rotary-embedding
modules by duck typing (inv_freq/dim/base attributes, matching this
file's existing moe_infer discovery pattern) and recomputes each one's
inv_freq correctly, resetting its cached cos/sin so the next forward
call rebuilds them from the corrected values. Wired into
run_speculative_bench.py right after loading both the target and draft
models.

Verified live on a real GPU: after the fix, inv_freq matches the
expected closed-form sequence exactly, and greedy-decoding "The quick
brown fox jumps over the lazy dog." produces a real, coherent
continuation instead of 64 repetitions of token 0.
fix_rope_inv_freq (commit c5b59df) fixed the actual bug; this commit
records the real GPU re-run it made possible: a non-degenerate
baseline, both correctness gates, and the full k-sweep checked at
every point. Supersedes the WITHDRAWN framing in the prior findings
doc and STATUS.md.

The one real gate divergence (prompt-lookup) and the k-sweep's wider
pattern (prompt_000 diverging in most configs, including draft-model
at k=1/k=2/k=8) are root-caused to a genuine near-tied logit position
under the int8-quantized kernel's real floating-point precision,
confirmed by two independent GPU probes -- not a defect in
dispatch.speculative, which was re-derived by hand and found correct.

Also documents a second, unrelated infrastructure finding hit while
retrieving this session's evidence: the pod's repo clone lived outside
its persistent mount and did not survive a stop/start cycle. The 11
results.json files here are exact reconstructions from this session's
own already-captured process output; the corresponding raw
generated-tokens.json files could not be recovered this way and are
not included.

Total real GPU cost across both Phase 5b sessions: $12.26 of the $20
cap. Flags, but does not audit, whether Phase 5a's own correctness
claim should be re-examined given this session's concrete evidence of
near-tie sensitivity in the same quantized kernel.
Final-review finding: fix_rope_inv_freq was wired into exactly one of
several DeepSeek-loading entrypoints (scripts/run_speculative_bench.py),
while run_baseline.py -- which produces the reference logits other
correctness gates compare against -- and every scripts/gpu/*.py script
had no protection against the same bug, despite the pinned
transformers>=5.17.0 guaranteeing it.

Moves the fix into dispatch.benchmark.harness.load_model itself so
every caller gets it by construction; idempotent and harmless on a
model whose rotary embedding doesn't match the duck-typed shape.
run_speculative_bench.py keeps one explicit call on the target only, to
capture the count for its results JSON and guard against silent
regression (0 fixed now refuses the run, ordered after the existing
moe_layers_patched guard so a model with neither shape still reports
the pre-existing error). Also records attn_implementation in the
results JSON (previously only in the prose findings doc).

Test-quality fixes: the inv_freq oracle now asserts literal expected
values instead of a second call to the same formula the implementation
uses; a new test exercises the fix's other half (forward() actually
rebuilding cos/sin from the corrected buffer, not just resetting
max_seq_len_cached); a new negative test uses transformers' own native
5.x rotary-embedding shape (no dim/base) rather than an unrelated
Sequential(Linear, ReLU).

make check green throughout (169 passed, 2 skipped, 2 deselected; lint
and mypy --strict clean).
Scoped re-review of the prior fix round found the new guard itself
(added to catch a future silent regression of fix_rope_inv_freq's
duck-typed match) had no test at any tier -- exactly the kind of gap
the guard exists to prevent. Adds a fast CPU test that monkeypatches
load_model/fix_rope_inv_freq/patch_moe_infer_quantized to construct the
guard's actual trigger condition (MoE layers patched, no rope buffers
fixed) and asserts the RuntimeError fires.

make check green (170 passed, 2 skipped, 2 deselected; lint and mypy
--strict clean).
The flat directory had grown to 81 files (7 phases, 0 through 5b) with
no structure beyond a date prefix. Moves every file into
docs/findings/phase-N/, filenames unchanged, so every cross-reference
update is a mechanical directory-prefix rewrite rather than a rename:
CHANGELOG.md, CLAUDE.md, README.md, STATUS.md, every plan doc, every
runbook, and each findings doc's own cross-references to sibling
findings docs.

Also repoints the 6 GPU scripts that defaulted --output-dir (or a
module-level OUTPUT_DIR) to the bare docs/findings/ at their own
specific phase folder (run_baseline.py -> phase-0, run_kernel_bench.py
-> phase-1, run_phase3_correctness_gate.py -> phase-3,
phase4_concurrency.py/phase4_correctness_gate.py -> phase-4,
run_speculative_bench.py -> phase-5b) -- previously all six wrote into
one shared, undifferentiated folder.

Three gitignored *.safetensors reference-logit files (phase-0, phase-3)
moved on disk to match, outside git's tracking.

Left untouched: a handful of already-stale filename predictions in
planning docs (e.g. a cost-log path with a one-day-off date) that never
matched what was actually produced -- pre-existing, not introduced or
worsened by this move.

make check green throughout (170 passed, 2 skipped, 2 deselected; lint
and mypy --strict clean).
…gures

README and CLAUDE.md now reflect Phase 5a merged (PR #5), Phase 5b
complete (PR #6), the per-phase findings folders, 170 tests, and the
running GPU cost ($39.95 across nine sessions). CLAUDE.md's cost
discipline gains three rules that each came from a real Phase 5b
incident: never leave a pod running across an unbounded wait, pull
evidence off the pod before stop, and treat stop as possibly final.

Also corrects errors found while re-verifying the Phase 5b findings
against the committed results JSONs: the prompt-lookup gate row had
mixed in the k-sweep run's numbers (the gate run matched 2 of 4 prompts
at 17.7% acceptance and 47.21 tok/s, not 3 of 4 at 14.9% and 44.74);
match counts were stated out of 32 instead of 16 (draft-model 13/16,
prompt-lookup 9/16); prompt_000 diverges in 6 of 8 sweep configs, not
7; and the summary claimed both drafters beat baseline when draft-model
is below it at every k. Adds the direct evidence that the same k=4
prompt-lookup config matched 2/4 prompts in one process and 3/4 in
another.
@bsreecharanreddy
bsreecharanreddy merged commit fabf32c into main Sep 18, 2026
1 check passed
@bsreecharanreddy
bsreecharanreddy deleted the phase-5b-speculative-decoding branch September 18, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant