speculative: window dspark drafter staging to its trained position range - #98
speculative: window dspark drafter staging to its trained position range#98thadreber-web wants to merge 2 commits into
Conversation
The drafter was staged at absolute target positions, so past its 4096 n_ctx_train its acceptance collapsed (59% -> 4.6% between short and 28k prompts on Ternary-Bonsai-27B). Stage rows at rebased positions inside window = min(drafter n_ctx_train, n_batch) and slide the window (wipe + restage last w_keep rows) when a block would cross it. Cap the draft context n_batch at the drafter's n_ctx_train instead of the target n_ctx, which forced a full-context micro-batch that stalled startup at large -c. Measured (target Q2_0, drafter Q4_1, ctx 131072): acceptance holds ~70-78% at short context (54-58 tok/s decode) and ~69% at 60k (32 tok/s); 1.36x over no-speculation at 24k depth vs 0.69x for the old staging. Assisted-by: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PhRPgjy3fSwQ2dhdxdWkzW
bri-prism
left a comment
There was a problem hiding this comment.
Thanks for this. The writeup and the measured before/after numbers made it easy to review. I traced the windowing invariants through common/speculative.cpp and built llama-common and llama-server from the PR head (build CI does not run on fork PRs here); both compile clean, and the core change looks correct.
What I verified
- Batch bounds hold on both paths: non-rebase rounds satisfy
n_stage + block <= window <= n_batchvia the rebase trigger condition, and rebase rounds satisfykeep + block <= w_keep + block <= windowby construction ofw_keep. The retained "structurally unreachable" guard is honest. - The
[n_retained | pending]buffer layout stays consistent withaccept()'s tail trim (it can only drop rows appended after the lastdraft()snapshot, never the retained head), and the contiguity asserts are correctly ordered after the recoverable mismatch check, so a post-reset state hits the soft skip path instead of aborting. - All drafter-cache position uses are rebase-aware: the tail crop at
start - base, and full-range removals inbegin()and both recovery paths. - The logits harvest is unaffected, since the bulk
llama_get_logits()buffer only contains the block rows. - Rebase cadence is sane: post-rebase headroom is
window/2 - blocktokens, so restaging amortizes to roughly one extra drafter row per generated token, consistent with the 60k-context numbers you measured. - On the server side, the
n_ubatch = n_batchraise in the dspark block confirms the compute-buffer mechanism behind #74, so cappingn_batchat the drafter'sn_ctx_trainis the right lever rather than a symptom patch.
Asks before merge
- Please run, and ideally extend,
tests/test-dspark-loop.cpp. Its header still documents "continuous absolute RoPE positions across rounds" as the loop contract, which this PR changes, and it has no coverage of window crossing, rebase restaging, or the failure resets. If the synthetic rounds stay inside the window it passes vacuously; if they cross it, it will diverge from the Python reference. Driving at least one rebase in the gate (with the matching change indspark_phase2_py_ref.py) would lock in the new semantics. GGML_ASSERT(window > 2 * block_size)aborts the whole process on a reachable configuration. The constructor already throwsstd::runtime_errorfor invalid pairings a few lines up; a small CLI-b(for example-b 32with block size 16 throughllama-speculative, which bypasses the server's n_batch raise) now kills the process at init where the old code degraded gracefully. A throw, or a clamp with a warning, would fit better. The bound also permits near-thrash configs wherewindowbarely exceeds2 * blockand a rebase fires every round or two; worth a word in the comment.
Non-blocking notes
- The window is derived from the GGUF
context_lengthmetadata, which makes that field silently load-bearing: a drafter GGUF that inherits the target's context length getswindow = n_batch, which disables the windowing and reintroduces the full-size compute buffer. Worth verifying published drafter GGUFs carry the trained range, and possibly adding an explicitdspark.*KV or a CLI override later so the trained range is stated rather than inferred. - This fixes the boot OOM in #74, but the draft context still inherits the target's
n_ctx(params_dft = params_base, never overridden), so the drafter KV cache is still allocated at full target context even though no position at or beyondwindowis addressable anymore. At-c 131072with-ngld 99that can still be significant VRAM. Fine as a follow-up issue so it is not lost when #74 closes. - The failed-rebase-decode recovery permanently disables drafting for the sequence: after the reset,
ctx_lencounts from zero while the buffer restarts at the current position, so every later round hits the mismatch branch and logs an error until the nextbegin(). That mirrors the pre-existing crop-failure semantics, so it is not a regression, but a "drafting disabled for this seq" flag with a single log line would be kinder than per-round error spam. - Nits: the lazy-trim threshold
512deserves a named constant; after the first long-prompt trim the feature buffer's capacity is never released (ashrink_to_fitafter a large cut would return real host memory for long prompts, though the old code behaved the same); and given the assert,w_keep = min(window/2, window - block)always resolves towindow/2, fine as a safety net but worth noting in the comment.
Nice work overall. The invariant comments that explain the why (including the measured 59% to 4.6% decay), the consistent state resets on both failure paths, and keeping the overflow guard instead of deleting it as unreachable all made this a pleasant one to review.
There was a problem hiding this comment.
🟡 Not ready to approve
Batch capping, failed-rebase recovery, metadata compatibility, and regression coverage need correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Windows dspark staging to the drafter’s trained context range, reducing oversized draft-context allocations.
Changes:
- Rebase retained staging rows within a bounded window.
- Size draft batches using trained context length.
- Document the new staging behavior and benchmarks.
File summaries
| File | Description |
|---|---|
common/speculative.cpp |
Implements windowed staging and recovery bookkeeping. |
tools/server/server-context.cpp |
Adjusts dspark draft batch sizing. |
docs/dspark-scope.md |
Documents windowing and performance results. |
Review details
Comments suppressed due to low confidence (1)
common/speculative.cpp:1264
- Clearing both the cache and all buffered rows makes recovery impossible after a failed rebase. On the next round
n_cacheis 0, soctx_lencovers the entire absolute history, butprocess()can only append rows decoded after this failure; consequentlyn_buf - n_ret != ctx_lenand every later draft is skipped. Preserve the retained window (and reset the physical cache again in case decode partially wrote it) so it can be restaged on the next round.
if (rebase) {
// the cache was already wiped for the rebase: bookkeeping must
// not pretend the pre-rebase rows are still resident.
n_cache[seq_id] = 0;
pos_base[seq_id] = 0;
n_retained[seq_id] = 0;
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| if (cparams.n_batch < n_batch_dspark) { | ||
| SRV_INF("draft-dspark: raising draft ctx n_batch %u -> %u (full-context staging + block)\n", | ||
| SRV_INF("draft-dspark: raising draft ctx n_batch %u -> %u (windowed staging + block)\n", | ||
| cparams.n_batch, n_batch_dspark); | ||
| cparams.n_batch = n_batch_dspark; | ||
| } |
| // windowed staging bounds (see the member comment on `window`): keep | ||
| // the drafter's positions inside its trained context regardless of the | ||
| // target's n_ctx. A drafter GGUF without context_length metadata | ||
| // falls back to the batch size (i.e. the old behavior's ceiling). | ||
| const int32_t n_ctx_train_dft = llama_model_n_ctx_train(model_dft); | ||
| window = std::min<int64_t>(n_ctx_train_dft > 0 ? n_ctx_train_dft : n_b, n_b); |
| int64_t base = pos_base[seq_id]; | ||
| int64_t stage0 = n_ret; | ||
| const bool rebase = start - base + (int64_t) block_size > window; | ||
| if (rebase) { |
| The server also caps the draft context `n_batch` at the drafter's | ||
| `n_ctx_train` instead of the full target `n_ctx`, which previously forced a | ||
| full-context micro-batch that stalled context creation at large `-c`. |
The draft context only ever raised n_batch toward the drafter's trained range, so an explicit -b above that range passed through untouched and kept reserving a compute batch it can never address. Set it in both directions and align n_ubatch. Measured it with a 4096-range drafter at -b 8192: draft ctx n_batch was 8192, now 4096. test-dspark-loop takes a tiny GGUF and a reference JSON on the command line, but nothing in the tree produced them: - scripts/dspark/build_tiny.py: deterministic tiny drafter export, converted to GGUF by conversion/dspark.py - scripts/dspark/phase2_py_ref.py: drives the same rounds through the upstream DeepSpec reference, rebasing positions the way common/speculative.cpp does, and writes ref.json The gate now counts rounds that cross the staging window and fails if none did. It passes 7/7 token-for-token with 3 rebases. Also correct the test header, which still documented the pre-windowing "continuous absolute RoPE positions" contract. speculative: throw instead of GGML_ASSERT when the staging window is too small for a draft block, which a small -b can reach. Drop the claim that a drafter without context_length metadata falls back to the batch size: context_length is a required GGUF key, so such a model never loads. I have read and agree with the contributing guidelines. AI usage disclosure: YES - analysis done by Claude Opus 5. I supervised and reviewed the implementation and bench marking.
Overview
draft-dspark stages every context row since the drafter's cache position plus a full draft block in one batch. To make that fit, the server raised the draft context's n_batch to the target n_ctx + block size. Causing two problems:
This PR windows the drafter's staging instead:
No new CLI flags, no API changes.
Measured (ctx 131072, DGX Spark GB10):
Additional information
Fixes #74. The failed-decode and failed-crop recovery paths reset the window bookkeeping (pos_base, n_retained) along with the existing cache reset, so a rebase that fails mid-round
cannot leave the drafter cache and the staging buffer out of sync. Buffer head trimming is done lazily in 512-row chunks to keep the erase off the per-round hot path.
Requirements