Skip to content

DSpark: replay-free partial accepts + encoder-batched captures (+40% greedy, byte-lossless)#590

Open
gilbert-barajas wants to merge 2 commits into
antirez:mainfrom
gilbert-barajas:pr-dspark-A
Open

DSpark: replay-free partial accepts + encoder-batched captures (+40% greedy, byte-lossless)#590
gilbert-barajas wants to merge 2 commits into
antirez:mainfrom
gilbert-barajas:pr-dspark-A

Conversation

@gilbert-barajas

Copy link
Copy Markdown

Two coupled improvements to the DSpark speculative-decode path (fc9efd1), Metal, no backend regressions.

What

  1. Replay-free partial accepts. On a partial accept of k drafts, the accepted prefix's KV is adopted from the verify pass via per-depth frontier checkpoints, instead of rolling back and re-decoding each accepted token. Re-decode on a partial accept cost ~24 ms/token — more than a plain decode; it's now ~0 (replay=0.000).
  2. Encoder-batched captures. The per-depth checkpoint captures are encoded as compute-kernel copies on the already-open batch encoder (ds4_gpu_tensor_copy_f32_inline) rather than separate blit encoders, which were tearing down and rebuilding the encoder ~350–500× per verify. Bounds-checked blit fallback retained.

Gains

Metal, DeepSeek-V4-Flash Q2_K, full residency, server decode-phase, cooled:

  • Greedy single-stream: 40.2 → 56.4 t/s (+40%) on a high-acceptance prompt (≈51 on diverse prompts). replay=0.000, accept_rate 94.5% confirms partial accepts commit through the checkpoint path.

Testing (per CONTRIBUTING.md)

Machine: Apple M5 Max, 128 GB, Metal. make clean && make.

  • Build: warning-neutral — this branch emits the identical 27 warnings as the unmodified base (comm diff = 0 new); all are the macOS-27 didModifyRange: SDK deprecation, not from this change. make cpu builds clean.
  • Correctness:
    • make dspark-verify-depthworst_argmax_gap = 0.000 — every committed greedy token (partial accepts included) is the exact plain-decode argmax.
    • ./ds4_test --metal-kernels → OK; compressor/router exactness all max_ulp = 0/0/0.
  • No base-path regression (code-level): the diff touches only DSpark-gated / checkpoint / capture code (and comments) — the non-speculative decode path is unmodified, so --dspark-off decode is byte-for-byte the same code. Base decode measured unchanged (server baseline 40.18 t/s; ds4-bench base 485 prefill / 39 gen @ ctx 2048).
  • Greedy output is byte-identical to baseline where no near-tie occurs. The single Q2_K near-tie float flip is pre-existing — it fires on the unmodified base binary too (a batched-verify-vs-sequential-decode quantization tie-break), neither introduced nor fixed here.

--logprob-vectors was skipped — the official continuation .vec isn't in the repo; glad to run it against the official vectors. A clean ds4-bench before/after CSV is best captured on a thermally-settled box (this machine was saturated during testing).

Notes for the reviewer

  • net_saved in the stats line reads understated with the scheduler disabled: the replay-free path skips the re-decode whose timing fed saved_ms, so saved_ms no longer accrues in that mode and net_saved shows a misleading negative. Wall-clock throughput is the ground truth (+40%). Glad to credit saved_ms from the verify-pass timing in a follow-up.
  • No backend regression. Metal change is additive (_inline copy + fallback). ds4_cuda.cu gets a 9-line extern "C" alias to the existing device copy. CPU / GLM / distributed / tensor-parallel sessions fall back to a plain one-token decode. C99 throughout the core.

Diff: +363 / −10 across 4 files (ds4.c, ds4_metal.m, ds4_gpu.h, ds4_cuda.cu).


A follow-up PR adds opt-in DS4_DSPARK_SAMPLE=1 sampling-mode speculation at temperature > 0 via the rejection rule — distributionally lossless — and will be sent after this one merges.

The batched verifier steps the compressor token by token, so the mutable
frontier after every verify row is captured in-stream into per-depth slot
buffers (a few small blits per layer), generalizing the MTP prefix1
mechanism to whole blocks.  A partial accept of k drafts now commits by
copying slot k-1 back over the live frontiers and adopting the batch's
own logits row, instead of restoring the pre-block snapshot and
re-decoding each accepted token through the full target model (~24 ms per
accepted token on an M5 Max).  The restore+replay path survives as the
fallback and as the TP-lockstep route; DS4_DSPARK_NOREPLAY=0 pins the old
behavior.

The commit path also retargets the DSpark drafter capture at the
committed depth: batch row k of the verifier's hidden-state capture
becomes the new anchor hc and the capture window is trimmed to the
accepted prefix, so the proposer's initial-cache seeding stays healthy
after partial accepts (the old path left the batch capture stale, costing
the following cycle its draft).

The aligned-chunk bulk compressor path has no per-row boundary to capture
at, so the encoder takes the per-token path only while checkpoints are
requested; prefill and MTP are unaffected.  Measured overhead of the
in-stream captures is ~4 ms per verify against ~24 ms per replayed token.

Gates on an M5 Max (Q2_K DeepSeek-V4-Flash target, DSpark drafter,
greedy, 512-token code generation, scheduler off, medians of 3 cooled
runs):
- conf 0.5: 50.4 -> 52.8 t/s (replay 846 ms -> 0; checkpoint commits
  cost 5 ms per run)
- conf 0.3: 45.6 -> 53.4 t/s; replay-free makes lower confidence the new
  best operating point (avg_accept 4.11)
- dspark-verify-depth: worst_argmax_gap=0.000, including a conf-0.5 run
  with partial accepts flowing through the new path
- token-stream parity vs plain greedy on 6 prompts: the new path diverges
  on 2/6 at batch-verifier near-ties vs 3/6 for the old path (the
  acceptance fixture's redis case already fails byte-identity on this
  quant with the unmodified base -- a pre-existing near-tie class, not
  introduced here)
The per-depth frontier checkpoints ride the batched verify interleaved
with the per-row compressor dispatches, and each ds4_gpu_tensor_copy there
ended the cached batch compute encoder, opened a blit encoder for one
copy, and forced the next dispatch to reopen the compute encoder -- four
encoder transitions per captured tensor, several hundred per verify.

ds4_gpu_tensor_copy_f32_inline encodes the same copy as a cpy_f32_f32
compute dispatch instead, which stays inside the open batch encoder (the
buffers use default hazard tracking, the same ordering the row-to-row
compressor chain already depends on).  Metal-only in effect: CUDA/ROCm
alias it to the plain device copy, and it falls back to the blit path
outside a batch command buffer.  Snapshot and commit copies keep the blit
path -- they run in their own command buffers with no encoder churn.

The committed token streams are byte-identical to the blit encoding at
conf 0.3 and 0.5 (512-token generation) and across the 6-prompt parity
set; dspark-verify-depth still reports worst_argmax_gap=0.000, including
a conf-0.5 run with partial accepts through the checkpoint path.

Measured on an M5 Max (Q2_K DeepSeek-V4-Flash target, DSpark drafter,
greedy 512-token code generation, scheduler off, medians of 3 cooled
runs):
- conf 0.3: 53.4 -> 55.3 t/s
- conf 0.5: 52.8 -> 54.0 t/s
- verify phase at conf 0.5: 6048 -> 5880 ms per run against 5630 ms with
  captures disabled -- the checkpoint overhead drops from ~4 ms to ~2 ms
  per verify, now mostly the copies' own memory traffic.
@thomasjohnflaherty

Copy link
Copy Markdown

Independent datapoint from another M5 Max (128 GB, Metal, mixed Q2+Q4 ds4flash.gguf), on current main (efdadd4) — reproduces both behaviors this PR describes:

  • Partial-accept replay cost: best-case greedy code-gen A/B came out a net loss — 38.2 t/s with --dspark vs 40.0 baseline, despite an 85% accept rate on proposed drafts (net_saved=-149ms, 60 proposals/150 cycles). Consistent with the ~24 ms/token replay overhead described here.
  • Pre-existing greedy near-tie flip: make dspark-acceptance fails on the redis fixture with a greedy output divergence (baseline: "…known for its speed and support for various data types" / dspark: "…known for its speed and versatility in handling key-value") — matching the batched-verify-vs-sequential-decode tie-break you note as pre-existing.

Baseline sanity: base numbers here match yours (485 t/s prefill / ~39-40 t/s gen @2k — full sweep in #587). Happy to re-run the acceptance fixture and the A/B on this branch if useful.

@gilbert-barajas

Copy link
Copy Markdown
Author

Appreciate the independent M5 Max datapoint — that ~24 ms/token replay match is exactly the confirmation this needed. Same thing I hit before writing the fix: baseline DSpark net-negative despite a high accept rate.

And you've got the tie-break right — that greedy flip in dspark-acceptance is pre-existing (batched-verify vs sequential-decode), not introduced here. #590 just calls it out so it doesn't get pinned on the replay-free path.

Please do run the A/B on the branch — that's the number that matters. #590's whole job is killing that replay cost: partial accepts no longer replay, so your net_saved=-149ms should flip positive. On my M5 Max the same greedy code-gen A/B that lost on main comes out ~+40% on this branch, byte-lossless — output is identical to baseline, so you can diff the two runs and confirm only the speed changed.

If it's easy: make dspark-acceptance on the branch, then the greedy --dspark vs baseline A/B on the same prompt. Curious whether you land near my +40% or somewhere else — either way it's a great datapoint. Thanks for digging in.

@thomasjohnflaherty

Copy link
Copy Markdown

Ran the branch (567e798) on the same M5 Max / mixed Q2+Q4 setup. Results confirm the fix, with one tuning finding that may interest you:

  • Replay fix verified: replay=0.000 throughout, and the same code-gen A/B that lost on main (38.2 vs 40.0) now runs at parity on default settings — net_saved flipped from −149 ms to +9.6 ms.
  • Why parity and not +40% at defaults: on this quant the drafter's confidence rarely clears the default 0.9 gate — no_draft=120 of 147 cycles, so there was almost nothing to accelerate. Quant noise on the mixed Q2+Q4 seems to depress draft confidence relative to your Q2_K run.
  • --dspark-confidence 0.5 unlocks it: 50.69 t/s vs 39.86 baseline (+27%), byte-identical output. accept_rate=80.65%, avg_accept=2.885, net_saved=+1121ms, 40/52 cycles proposing. Without your replay fix this proposal volume would have been a heavy loss, so the win is directly attributable to this branch.
  • make dspark-acceptance: hello fixture penalty shrank −199 ms → −19 ms (32-token prompts still can't amortize); the redis near-tie flip persists exactly as you documented — pre-existing, not this PR.

Takeaway from this datapoint: on mixed quants the default confidence gate may leave most of the branch's benefit on the table — possibly worth a note in the DSpark README section, or a quant-aware default down the line.

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.

2 participants