Skip to content

[FEAT] [PERF] Remove per-candidate and metadata host syncs from speculative decoding - #51

Merged
bjf-frz merged 4 commits into
ThinkFlowLab:mainfrom
IDEA-V:device-loop/batched-readback
Sep 30, 2026
Merged

bjf-frz merged 4 commits into
ThinkFlowLab:mainfrom
IDEA-V:device-loop/batched-readback

Conversation

@IDEA-V

@IDEA-V IDEA-V commented Sep 24, 2026 •

Copy link
Copy Markdown

Scope

First step of the Device-side execution loop item in #43. A speculative round now synchronizes with the host once, down from 73–110 times, and depth-independent KV metadata is prepared once per token batch instead of once per depth. The branch is rebased on main (which now includes #48). Following review, the benchmark harness is no longer part of this PR.

Behavior is unchanged: in every configuration measured, including sampling, outputs are token-identical to main.

Changes

  1. perf: batch greedy draft and verification readbacks (speculative.py): accepted greedy candidates equal their row's target argmax, so the committed tokens are the target-argmax prefix through the first mismatch. Verification reads all argmaxes with one argmax(-1).tolist() and compares on the host via the new greedy_accept().
  2. perf: stage prepared KV metadata in pinned memory (kv_cache_manager.py): _prepare_batch stages its metadata in two pinned host tensors and copies them with non_blocking=True, instead of 5–6 pageable torch.tensor(..., device=cuda) copies, each of which waited for all queued GPU work.
  3. perf: share depth-independent KV metadata across recurrence depths: _prepare_batches() validates the rows once and builds positions, write offsets, context lengths and packed-query boundaries once. It returns one batch per depth, where only block addresses and page tables are per depth. _prepare_batch() delegates to it. Ownership and written-prefix checks still run when each depth executes.
  4. perf: keep speculative draft IDs on the device: each draft offset's IDs feed the next prelude directly. The rows still drafting at one offset are exactly the next offset's active rows, in the same order, so ragged requests need no remapping. Sampled rows write their drawn token on the device, and per-request RNG consumption order is unchanged. All draft IDs and the greedy targets are read back in one transfer after verification.

Results

Ouro-1.4B BF16 @ 574fa66c, torch 2.14+cu130. Prompt 128 tokens, 64 output tokens, ignore_eos, D=4/d=2, K=4, decode-only after matched prefill. Two interleaved cycles, run order reversed in the second; each point is 5 repeats. tok/s is the median of cycles. The same configuration varied by <1% between cycles.

A100-SXM4-80GB (MIG 7g.80gb, 98 SMs), triton backend

Speculative decoding, main vs changes 1–2 vs this PR:

K=4 main + changes 1–2 this PR
B=1 graphs 46.6 47.5 (+2.0%) 48.2 (+3.5%)
B=8 graphs 241.3 250.3 (+3.7%) 254.1 (+5.3%)
B=16 graphs 541.4 577.8 (+6.7%) 586.7 (+8.4%)
B=32 graphs 766.9 901.4 (+17.5%) 936.7 (+22.1%)
B=64 graphs 1363.8 1697.4 (+24.5%) 1737.8 (+27.4%)
B=1 / 8 / 16 / 32 / 64 eager – – +0.5% / +2.0% / +2.2% / +4.4% / +6.7%
sampling T=0.8, B=1 / 8 graphs 59.0 / 255.4 – 60.9 (+3.2%) / 266.5 (+4.4%)

Syncs per round: exactly 1 at B=8 and B=64, counted with torch.cuda.set_sync_debug_mode.

Non-speculative path. _prepare_batch is shared with native decoding, so this covers small batches and long contexts. Prefill is chunked at 8,192 tokens.

native eager graphs
B=1, 128 +1.7% +0.6%
B=8, 128 −0.2% +0.8%
B=1, 2k +0.9% +0.4%
B=8, 2k +0.9% +0.4%
B=1, 8k +0.4% −0.2%
B=4, 8k +0.1% +0.2%

No regression; the largest negative change, −0.2%, is within run-to-run variation. Absolute values are in the table below.

Absolute native tok/s (main → this PR)
native eager graphs
B=1, 128 13.25 → 13.46 44.61 → 44.90
B=8, 128 100.26 → 100.05 306.41 → 308.75
B=1, 2k 13.32 → 13.44 28.47 → 28.58
B=8, 2k 99.09 → 99.94 193.00 → 193.75
B=1, 8k 11.87 → 11.93 13.26 → 13.23
B=4, 8k 44.36 → 44.41 49.70 → 49.81

B200 (MIG 4g.90gb, 72 SMs), FA4, previous round

These runs cover changes 1–2 only, on top of #48, which is identical to today's main for these files:

graphs, K=4 B=1 B=8
#48 → + changes 1–2 77.3 → 79.2 (+2.5%) 475.2 → 501.0 (+5.4%)

Changes 3–4 have not been measured on B200 yet; see "Not covered yet".

Profiling notes

  • Eager speculation is launch-bound. A 24-layer core pass launches ~1,400 kernels, so syncs matter little in eager mode. This is why the eager gains above are small.
  • Reusing staging buffers (review suggestion 4): host time inside _stage() is 0.66% of the decode window at B=8 and 0.99% at B=64, so it is not implemented. Since the host runs ahead of the GPU, not all of that time is on the critical path.
  • A100 note, independent of this PR: with triton, verification is not packed. On main, spec K=4 + graphs at B=8 (241 tok/s) is slower than native + graphs (306 tok/s).

Relation to other work

  • Metadata contracts (#46): _prepare_batch now delegates to _prepare_batches and returns views into shared staging tensors. The fields and semantics of _PreparedKVBatch are unchanged.

Correctness and tests

  • Outputs are token-identical to main in every configuration above, for greedy and sampling (T=0.8). The sampling match confirms the RNG consumption order is unchanged.
  • New tests:
    • greedy_accept: K=0/1/2/4/8, every rejection position.
    • _prepare_batches matches per-depth _prepare_batch for packed and unpacked batches, shares one copy of the depth-independent tensors, and rejects invalid depths.
    • A ragged greedy round synchronizes exactly once.
  • tests/test_prepared_kv.py now expects 2 metadata tensor constructions per core pass instead of 5.
  • pytest --run-gpu tests/ on A100: 405 passed. The 30 failures are the same set as on main (400 passed): FA4 not supported on SM80, FA2 requiring a block size that is a multiple of 256, and PD needing a second GPU.

Not covered yet

  • Changes 3–4 on the FA4 packed-verify path on a GPU. They are covered only by the CPU packed-metadata test. This needs a B200/H100 run.
  • Long-context speculation and natural-EOS workloads.

@bjf-frz bjf-frz changed the title Remove per-candidate and metadata host syncs from speculative decoding [FEAT] Remove per-candidate and metadata host syncs from speculative decoding Sep 28, 2026
@bjf-frz bjf-frz changed the title [FEAT] Remove per-candidate and metadata host syncs from speculative decoding [FEAT] [PERF] Remove per-candidate and metadata host syncs from speculative decoding Sep 28, 2026
Comment thread vllm_rlt/core/kv_cache_manager.py Outdated
# blocking: a pageable H2D copy would wait for all queued GPU work. The
# caching host allocator keeps each staging block alive until its copy ends.
n, t = len(rows), len(table_rows)
wide = self._stage(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since _prepare_batch() is also used by non-speculative inference, could you include a before/after performance comparison for that path as well? Please cover small batch sizes and long contexts to check whether the pinned-memory staging introduces any regression.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. See the "Non-speculative path" part of PR message.

@bjf-frz

bjf-frz commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Please remove benchmarks dir.

@bjf-frz

bjf-frz commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

A few additional optimization opportunities worth exploring:

  • Batch draft-token readbacks for sampling. The sampling path still calls .item() for each request. Collect the sampled token tensors and read them back once per draft step, preserving each request’s RNG consumption order.

  • Keep greedy draft tokens on the GPU. Currently, draft IDs are copied to the CPU and then uploaded for the next prelude. Feed the GPU IDs directly into the next step and defer candidate readback until verification completes, handling ragged request lengths carefully.

  • Reuse metadata across recurrence depths. Positions, write offsets, context lengths, and packed-query boundaries remain unchanged across depths for the same token batch. Reuse those fields while updating depth-dependent block addresses and preserving ownership/write-state checks.

  • Reuse staging buffers. _stage() constructs fresh host and device tensors on each call. Consider reusable pinned-host/device buffers if profiling shows meaningful overhead, with explicit lifetime management to prevent reuse before outstanding copies or consumers finish.

The current implementation still copies draft token IDs back to the CPU, although they could be consumed directly on the GPU without that transfer. This is the gap between the current implementation and the intended behavior. The other three optimizations (1, 3, and 4) are also worth implementing and exploring. We may see benefits under high-throughput, high-concurrency workloads.

Greedy speculation read every draft candidate and every verification
argmax with its own .item(), one host sync per request per draft offset
and one per verified candidate.

Accepted greedy candidates equal the target argmax of their row, so the
committed tokens are the target-argmax prefix through the first
mismatch (the correction, or the bonus when every candidate matches).
Read all verification argmaxes with one argmax(-1).tolist() and compare
on the host, and read each draft offset's greedy candidates in one
batch. Sampling keeps sequential rejection with an unchanged RNG
consumption order.
_prepare_batch built its five or six metadata tensors with
torch.tensor(list, device=cuda). Each pageable host-to-device copy
waits for all queued GPU work, so every core call serialized the host
behind the previous core. A speculative round makes 2K+4 core calls.

Stage the int64 address metadata and the int32 table/length metadata in
two pinned host tensors, copy them with non_blocking=True, and return
views of the device copies. The caching host allocator keeps each
staging block alive until its copy completes, and stream order places
the copies before the kernels that read them. The speculative draft
prelude input uses the same staging.
A speculative round prepares the same request/position rows once per
recurrence depth: twice for each draft offset and twice for
verification. Positions, write offsets, context lengths and packed-query
boundaries do not depend on depth; only block addresses and page tables
select a per-depth plane.

Add KVCacheManager._prepare_batches(), which validates the rows once,
builds the depth-independent fields once and returns one prepared batch
per depth assignment as views into two shared staging copies.
_prepare_batch() delegates to it with a single assignment. Preparation
still initializes no KV slot, so ownership and written-prefix checks run
when each depth executes. SpeculativeRunner prepares all depths of a
draft offset, and of verification, in one call.
Draft IDs were read back to the host after every draft offset and
uploaded again as the next prelude input, which put a host sync inside
the draft loop.

Keep each offset's IDs on the device and feed them straight into the
next offset's prelude. The rows still drafting at one offset are
exactly the active rows of the next, in the same order, so ragged
requests need no remapping. Greedy rows take the argmax; sampled rows
overwrite their entry with the drawn token on the device, with an
unchanged per-request RNG consumption order. All draft IDs and the
greedy verification targets are read back in one transfer after
verification, so a greedy round synchronizes with the host once.
@IDEA-V
IDEA-V force-pushed the device-loop/batched-readback branch from 7e54987 to e6e9fd0 Compare September 30, 2026 01:02
@IDEA-V
IDEA-V marked this pull request as ready for review September 30, 2026 01:23
@IDEA-V

IDEA-V commented Sep 30, 2026

Copy link
Copy Markdown
Author

A few additional optimization opportunities worth exploring:

* Batch draft-token readbacks for sampling. The sampling path still calls `.item()` for each request. Collect the sampled token tensors and read them back once per draft step, preserving each request’s RNG consumption order.

* Keep greedy draft tokens on the GPU. Currently, draft IDs are copied to the CPU and then uploaded for the next prelude. Feed the GPU IDs directly into the next step and defer candidate readback until verification completes, handling ragged request lengths carefully.

* Reuse metadata across recurrence depths. Positions, write offsets, context lengths, and packed-query boundaries remain unchanged across depths for the same token batch. Reuse those fields while updating depth-dependent block addresses and preserving ownership/write-state checks.

* Reuse staging `buffers. _stage()` constructs fresh host and device tensors on each call. Consider reusable pinned-host/device buffers if profiling shows meaningful overhead, with explicit lifetime management to prevent reuse before outstanding copies or consumers finish.

The current implementation still copies draft token IDs back to the CPU, although they could be consumed directly on the GPU without that transfer. This is the gap between the current implementation and the intended behavior. The other three optimizations (1, 3, and 4) are also worth implementing and exploring. We may see benefits under high-throughput, high-concurrency workloads.

Thanks, all four are addressed; details and data are in the updated description.

  • 1 + 2: draft IDs (greedy and sampled) now stay on the GPU and feed the next prelude directly, with one readback per round after verification. Sampled outputs are token-identical to main, so the RNG order is preserved.
  • 3: depth-independent metadata is built once per token batch via _prepare_batches(), and ownership/write-state checks still run per depth.
  • 4: measured before deciding. I wrapped _stage() with a host timer over a full decode window (A100, K=4, graphs). It runs ~13 times per round at 35 µs (B=8) and 77 µs (B=64) per call, which is 0.66% and 0.99% of the window. Since the host now runs ahead of the GPU, not all of that is on the critical path, so I left buffer reuse out for now.

As you expected, the gains grow with concurrency: on A100 with graphs, +5.3% at B=8 and +27.4% at B=64.

The PR is now ready for review.

@IDEA-V
IDEA-V requested a review from bjf-frz September 30, 2026 01:32
@bjf-frz

bjf-frz commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Independent performance validation using my rlt-perf-opt skill

This analysis was performed by Codex using my provided rlt-perf-opt skill as the evaluation procedure: source-level review, reproducible unprofiled A/B benchmarks, separate ops-only/full profiles, and correctness checks. These are independently measured results, not a restatement of the PR author's benchmarks.

Conclusion: this PR provides a measurable improvement to speculative decoding, particularly greedy decoding with CUDA Graphs at larger batch sizes. The tested native paths are approximately unchanged. The one-host-synchronization-per-round result applies to greedy decoding, not to the sampling path.

Tested revisions and environment

  • Base: 9e3d13d0074a63ae9f2a46287544e11c7de22b22 (already includes [FEAT] Capture fixed-depth speculative decode with CUDA Graphs #48).
  • Candidate: e6e9fd061662440887e5460010f44ca142c2406f.
  • Both source worktrees were clean; no implementation changes or profiler patches were applied.
  • One NVIDIA B300 SXM6 AC, SM103, 148 SMs; no MIG. Driver 610.43.02.
  • Ouro-1.4B BF16, checkpoint revision 574fa66cb8bf5abdc979642d01cf2b79b16bfab1.
  • Performance runs: PyTorch 2.13.0+cu130, Triton 3.7.1, Transformers 5.14.1; FA4 4.0.0b30, CUTLASS DSL 4.6.2 where applicable.
  • Fixed target depth D=4, draft depth d=2, K=4. Greedy unless noted; sampling uses T=0.8, top_p=1, top_k disabled, seed=42.
  • Default workload: 128 input tokens and 64 output tokens, ignore_eos=True, simultaneous arrivals. Fixed-length inputs repeat and truncate eight English prompts; they are not a representative acceptance-rate dataset.
  • Same configuration for both revisions: last_exited KV, block size 16, prefix caching off, async scheduling off, FCFS/refill, prefill chunk 128, token budget 128×B, max sequences B. KV block count = 4 * sum(ceil((input_length + 64 + 4) / 16)) + 16.
  • Graph row limit 512 and graph entry limit 16, except the explicitly labeled default-128-row experiment. The row limit is not the request count: B=32/K=4 verification can require 160 rows.
  • CPU affinity 32–39, one PyTorch CPU thread.

Unprofiled results

Each configuration/revision was warmed up twice, then measured three times per cycle across two cycles, reversing A/B order in the second cycle: six measured runs per revision/configuration, 180 runs across 15 configurations. Reported throughput is the median of the two per-cycle medians. All performance figures below were measured with the profiler disabled.

Configuration Base decode tok/s PR decode tok/s Decode change E2E output-throughput change
Triton, greedy graphs, B=1 111.4 114.4 +2.67% +2.62%
Triton, greedy graphs, B=8 741.8 793.5 +6.96% +8.21%
Triton, greedy graphs, B=32 2337.4 2723.1 +16.50% +14.11%
Triton, greedy graphs, B=64 3728.0 4759.7 +27.67% +32.33%
Triton, greedy eager, B=8 425.6 440.2 +3.44% +3.48%
Triton, sampling T=0.8, graphs, B=8 703.7 724.3 +2.92% +3.06%
Triton, 2048-token input, graphs, B=8 362.2 375.0 +3.53% +3.09%
Triton, natural inputs + EOS, graphs, B=16 787.8 849.8 +7.87% +7.30%
FA4, greedy graphs, B=8 800.3 854.1 +6.73% +6.39%
FA4, sampling T=0.8, graphs, B=8 750.0 774.6 +3.27% +2.91%
FA4, natural inputs + EOS, graphs, B=8 619.0 654.6 +5.76% +5.00%
Triton, native eager, B=8 258.8 257.5 -0.54% -0.39%
Triton, native graphs, B=1 67.5 67.7 +0.42% -0.11%
Triton, native graphs, B=8 467.1 470.6 +0.76% +0.94%
Triton, greedy graphs, B=32, default 128-row limit 2202.4 2660.1 +20.78% +17.61%

Timing definitions and interpretation:

  • Decode starts after every request has produced its first token; fixed-length cases count B×63 remaining tokens. For natural-length inputs, some shorter requests can already have advanced, so the numerator is the actual remaining token count; prioritize E2E for those cases.
  • E2E covers in-process engine arrival through completion, including prefill, but excludes HTTP/network and tokenization. These are short-burst measurements, not steady-state service throughput or saturation capacity.
  • Across the six decode measurements per variant/configuration, the largest max/min−1 spread was 1.60%. Native eager's −0.54% change is within observed variation; the earlier apparent larger regression did not reproduce in the controlled environment.
  • Natural-input tests use the first qualifying ShareGPT human prompts in file order, with 16–1024 input tokens, no chat template, and normal EOS handling. Triton B=16 acceptance was 721/935 = 77.1%; outputs included EOS-shortened requests. This is an output-equivalence check on the tested slice, not a task-quality evaluation.
  • The default-128-row B=32 case incurs 24 core and 12 coda fallbacks per measured request batch on both revisions. Natural Triton B=16 also has graph-entry-limit fallbacks. The measured gains do not imply complete graph coverage.

Profiling evidence and source attribution

For the same four complete B=8 greedy rounds, the trace counts were:

Event Base PR
cudaStreamSynchronize 548 4
H2D copies 260 52
D2H copies 288 4
D2D copies 328 328
CUDA Graph launches 68 68
GPU kernels 66,780 66,516

This is 137 → 1 synchronizations per complete greedy round. Independent sync-debug runs confirm it. With T=0.8, complete rounds still perform 169 → 73 synchronizations; the sampling path is not down to one.

The mechanism matches the implementation:

  • SpeculativeRunner.execute keeps draft IDs on-device and reads draft/greedy verification IDs back together after verification.
  • _cores and _prepare_batches share depth-independent metadata. _stage uses pinned memory and nonblocking H2D copies.
  • rejection_sample retains sequential tensor-to-host decisions, explaining the remaining sampling synchronizations.

The graph launch count and model loop depths are unchanged; kernel count only drops about 0.4%. The evidence supports reduced host synchronization, readbacks, and metadata work rather than less model computation or shorter outputs. No per-commit ablation was performed, so the total gain cannot be quantitatively assigned to individual commits. Host synchronization wait time includes preceding GPU work and must not be treated as pure removable copy time.

Profiling caveat: ops-only captures succeeded under the performance environment's PyTorch 2.13. Some full captures failed during export with UnicodeDecodeError. Paired ops-only/full captures were therefore repeated under the already-installed PyTorch 2.14.0+cu130, using the same clean revisions, GPU, model, workload and Triton backend. Both full exports succeeded with readable shapes, memory and stacks; the event counts above agree between 2.13 ops-only and both 2.14 modes. No profiled timings or 2.14 timings are mixed into the performance table. The original full traces were roughly 150–282 MiB; the skill and local harness have since been updated to start with one complete representative round and reduce oversized windows before retrying.

Correctness and experimental controls

  • All 180 controlled performance runs had matching A/B token IDs and exit depths, including T=0.8 sampling, natural EOS, and the FA4 packed-verification path. No failed requests were silently excluded.
  • Targeted tests: pytest --run-gpu tests/test_speculative.py tests/test_prepared_kv.py tests/test_cuda_graph.py -q.
    • Base: 103 passed, 1 skipped.
    • PR: 111 passed, 1 skipped.
  • Initial GPU 0 runs experienced resource contention, including unrelated allocations exceeding 100 GiB. Those results were retained but excluded from the conclusions by rerunning the entire main matrix on GPU 2. During the controlled runs, sampled compute-process telemetry found no competing process on the tested GPU. Sampling cannot rule out every momentary disturbance.
  • The local experiment bundle retains commands, exact inputs/token IDs, configurations, per-run outputs, telemetry, traces, and summaries. These artifacts are not attached to this comment; the comment contains the measured results and methodology rather than links to inaccessible local paths.

Assessment: the measured performance benefit is real, especially for larger greedy graph batches. Keep claims scoped to the tested workload and configuration: the ~28% result is not a universal speedup, and “one synchronization per round” should explicitly say greedy. PD, async speculation, adaptive exit, sustained service load, and other GPU/software stacks were not validated in this review.

@bjf-frz

bjf-frz commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

lgtm

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.

3 participants