Skip to content

[feat] [speculative] implement pipelined self-speculative decoding for looped transformers - #121

Merged
bjf-frz merged 14 commits into
InferMatrixLab:mainfrom
cs-ai-li:feat/pipelined-speculative-decoding
Oct 27, 2026
Merged

bjf-frz merged 14 commits into
InferMatrixLab:mainfrom
cs-ai-li:feat/pipelined-speculative-decoding

Conversation

@cs-ai-li

@cs-ai-li cs-ai-li commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add --speculative-mode pipeline to the shared self-speculative runtime. Block mode serializes autoregressive drafting and multi-token verification. Pipeline mode batches tokens at different recurrent depths, advancing drafting and verification together; rejection discards dependent descendants and restarts from the corrected token. Block remains the default.

The current implementation separates synchronous execution in speculative.py from genuinely asynchronous execution in async_speculative.py. The asynchronous path advances fixed-capacity device state before CPU collection, supports two outstanding steps for the same request, and uses the existing copy stream and leased pinned buffers for compact D2H results. Result decoding is independent of whether statistics collection is enabled.

CUDA Graph replay supports active-row attention metadata and masked KV writes. Cancellation, request-ID reuse, failed submissions, and resident/CPU-offloaded preemption have explicit lifetime handling; snapshots require drained pending work. Native Linear/RMSNorm are retained. The experimental PRELUDE/CODA row selection has been reverted.

Pipeline mode requires greedy decoding, last_exited KV layout, and compatible draft/target loop counts. Mixed prefill/decode is not supported for this mode. Pipeline plus PD has not been validated end to end.

Token-level execution

The diagrams show PRELUDE, RECURRENT and CODA, token-level batches, and corresponding acceptance/rejection events. Device decisions become available before asynchronous host delivery.

Accepted pipeline

Rejected pipeline

See the self-speculation documentation.

Current validation

Results below are reviewer-run on NVIDIA B300. Performance and quality were measured on 2026-10-26; all 65 runtime Python files were SHA-256 checked against publication commit 483c10e. The final working tree was regression-tested again on 2026-10-27: 327 passed, 1 skipped in 168.74 s. The skip is the unsupported FA4/FP32 combination, not an unrun GPU suite.

CUDA_VISIBLE_DEVICES=0,3 python -m pytest \
  tests/test_ouro.py tests/test_normalization.py tests/test_auto_model.py \
  tests/test_async_speculative.py tests/test_async_speculative_graphs.py \
  tests/test_async_speculative_lifecycle.py tests/test_cuda_graph.py \
  tests/test_mixed_execution.py tests/test_speculative.py --run-gpu -q -ra

This covers the shared runtime and pipeline tests, including asynchronous ticket/state handling, cancellation and ID reuse, preemption/restore, and graph execution. The mixed-execution tests do not establish pipeline/mixed support.

Matched performance comparison

Ouro-1.4B, BF16, one B300 per experiment, FA4 4.0.0b30, PyTorch 2.13.0+cu130, Triton 3.7.1, Transformers 4.55.0. Both block and pipeline use asynchronous scheduling and CUDA Graphs. Target depth 4, draft depth 1 or 2, K=4; max sequences 32, token budget 256, prefill chunk 1024, graph row/entry limits 256, one CPU thread. Graph limits are benchmark settings, not changed defaults.

Fixed ShareGPT-derived token IDs, input lengths 128/4096, 128 generated tokens per request, greedy, ignore EOS, no chat template. Each case submits C requests together. Two engines share the same model in one process, warm up fully, then run three alternating A/B repetitions. 48 timed runs, 588 completed requests, 75,264 output tokens, no failed measured requests; zero timed graph captures or fallbacks.

Throughput is median output tokens / complete batch wall time, including prefill and final synchronization. E2E and TTFT run from local request arrival to host-visible outputs; HTTP/IPC is excluded. Latency percentiles pool requests across three repetitions. TPOT is (last output - first output) / 127, not individual inter-token latency. These are fixed-burst comparisons, not a saturation sweep; small-sample p95 values are descriptive.

All arrows below are block → pipeline. Relative changes use unrounded measurements; negative latency changes are improvements.

Input / concurrency Draft loops Throughput tok/s Change E2E p50 s Change TPOT p50 ms Change
128 / 8 1 747.56 → 878.38 +17.50% 1.174 → 0.893 -23.95% 8.101 → 6.397 -21.03%
128 / 8 2 550.66 → 664.80 +20.73% 1.695 → 1.473 -13.10% 11.805 → 11.040 -6.48%
4096 / 1 1 79.00 → 99.32 +25.72% 1.620 → 1.289 -20.46% 7.556 → 4.993 -33.92%
4096 / 1 2 68.20 → 71.30 +4.54% 1.877 → 1.795 -4.34% 9.858 → 9.208 -6.59%
4096 / 8 1 165.21 → 170.82 +3.40% 6.086 → 5.991 -1.57% 10.707 → 10.231 -4.45%
4096 / 8 2 146.71 → 155.16 +5.76% 6.826 → 6.572 -3.72% 14.033 → 13.877 -1.11%
4096 / 32 1 172.92 → 173.30 +0.22% 22.981 → 23.517 +2.34% 20.883 → 32.291 +54.62%
4096 / 32 2 169.36 → 170.71 +0.80% 23.398 → 23.839 +1.88% 24.709 → 35.610 +44.12%

Short prompts and low concurrency benefit most. At 4096/32, throughput changes of +0.22%/+0.80% do not establish a meaningful gain; E2E p50 worsens by 2.34%/1.88%. TPOT also rises substantially. Earlier first-token delivery and later completion both increase that interval, so it is not a direct measure of recurrent-kernel slowdown.

Pipelining combines shallow draft and deeper verification work into fewer submissions; it does not remove all corresponding token-loop computation. At high concurrency, block verification already has substantial batching, while pipeline state updates and more frequent output commits remain. Long-prompt prefill/admission time also dominates much of the end-to-end interval. These explain the limited opportunity, rather than proving a single isolated source for every timing difference.

Full throughput ranges and TTFT/E2E/TPOT p50/p95
Input / concurrency / draft loops Mode tok/s median [min, max] TTFT p50/p95 ms E2E p50/p95 s TPOT p50/p95 ms
128/8/1 block 747.56 [688.31, 751.06] 141.4/253.3 1.174/1.470 8.101/10.204
128/8/1 pipeline 878.38 [877.80, 883.58] 115.1/181.9 0.893/1.166 6.397/8.439
128/8/2 block 550.66 [550.34, 551.14] 162.1/288.1 1.695/1.859 11.805/13.688
128/8/2 pipeline 664.80 [664.53, 664.88] 100.6/163.4 1.473/1.540 11.040/11.386
4096/1/1 block 79.00 [78.89, 79.04] 660.2/662.6 1.620/1.622 7.556/7.558
4096/1/1 pipeline 99.32 [99.16, 99.75] 654.6/656.8 1.289/1.291 4.993/4.994
4096/1/2 block 68.20 [68.18, 68.32] 624.8/624.9 1.877/1.877 9.858/9.861
4096/1/2 pipeline 71.30 [71.28, 77.07] 625.5/626.2 1.795/1.796 9.208/9.211
4096/8/1 block 165.21 [164.82, 165.68] 4726.1/4986.2 6.086/6.211 10.707/11.381
4096/8/1 pipeline 170.82 [169.10, 170.84] 4699.9/4843.0 5.991/6.054 10.231/11.154
4096/8/2 block 146.71 [144.97, 148.42] 5045.4/5363.3 6.826/7.052 14.033/14.804
4096/8/2 pipeline 155.16 [154.79, 155.50] 4808.0/4966.4 6.572/6.613 13.877/14.776
4096/32/1 block 172.92 [172.54, 173.00] 20283.2/21953.0 22.981/23.678 20.883/22.345
4096/32/1 pipeline 173.30 [173.28, 173.47] 19439.1/20298.6 23.517/23.628 32.291/35.089
4096/32/2 block 169.36 [164.21, 169.85] 20247.3/22211.1 23.398/24.718 24.709/26.573
4096/32/2 pipeline 170.71 [169.83, 171.11] 19324.5/20244.9 23.839/24.077 35.610/38.547

Acceptance and rejection

Collected in one separate, untimed replay per case. Rates divide by actually tested candidates, excluding untested descendants discarded after rejection. They are not throughput proxies. Counts differ because block and pipeline commit tokens differently.

Input / concurrency / draft loops Block accepted / rejected (accept %) Pipeline accepted / rejected (accept %)
128/8/1 782 / 46 (94.444%) 958 / 58 (94.291%)
128/8/2 796 / 23 (97.192%) 992 / 24 (97.638%)
4096/1/1 101 / 0 (100.000%) 127 / 0 (100.000%)
4096/1/2 101 / 0 (100.000%) 127 / 0 (100.000%)
4096/8/1 808 / 0 (100.000%) 1016 / 0 (100.000%)
4096/8/2 808 / 0 (100.000%) 1016 / 0 (100.000%)
4096/32/1 3231 / 2 (99.938%) 4062 / 2 (99.951%)
4096/32/2 3232 / 0 (100.000%) 4064 / 0 (100.000%)

Output agreement and task accuracy

Timed repeats were deterministic within each configuration. Block/pipeline outputs matched exactly for 288/294 paired requests across repetitions. The six differences are the same short-prompt request at two draft depths across three repetitions. All long-prompt pairs matched. This is output agreement, not a task-accuracy metric.

Separately evaluated 256 fixed GSM8K test questions in each of five modes: existing raw 3-shot protocol and strict answer extraction, no chat template, greedy generation, natural EOS/protocol stop, 1024-token cap, concurrency 8, fixed target depth 4, BF16/FA4/async/graphs. The subset was selected before evaluating outputs by sorting SHA-256(pr121-native-quality-20261026:ID), taking 256 IDs, then restoring ID order. Protocol hash: f63b08813b0703f57ca3c7f740dcef46db18284f19078d03f3ca4a25ce685544.

Mode Correct / 256 Accuracy Difference vs native Unparseable answers Length-limited
Native, 4 loops 162/256 63.28% +0.00 pp 59 0
Block, draft 1 163/256 63.67% +0.39 pp 61 1
Block, draft 2 164/256 64.06% +0.78 pp 61 1
Pipeline, draft 1 163/256 63.67% +0.39 pp 57 0
Pipeline, draft 2 164/256 64.06% +0.78 pp 58 0

Pipeline and block have equal aggregate scores at each draft depth, but individual answers change: draft 1 has five correct→wrong and five wrong→correct; draft 2 has four each. Exact full-sequence matches are 192/256 and 189/256. This slice shows no aggregate quality loss; it does not prove improvement or sub-1-percentage-point non-inferiority. A separate 64-question pipeline draft-1 batch-sensitivity check scored 42/41/42 at concurrency 1/8/32, with no monotonic accuracy trend.

Numerical cause: an untimed same-input layer trace found the first divergence in model.layers.0.mlp.down_proj, with 8 versus 32 rows: three BF16 elements differed, maximum absolute difference 0.0001220703125. Later recurrent layers amplify small rounding differences. For one short request, output token 93 changed from 0 to :: the block logits tied at 21.25/21.25, whereas pipeline favored : at 21.375 versus 21.25. This directly demonstrates a native linear-algebra batch-shape effect; it should not be attributed solely to FA4 or called evidence of a state-machine bug.

Differences can grow with changing batch shapes, repeated recurrent computation, near-tied logits, and longer autoregressive continuations after the first changed token. They need not grow monotonically with concurrency or sequence length. Native Linear/RMSNorm remain in use; there is no custom batch-invariant math file in this version.

Evaluated and reverted: PRELUDE/CODA row selection

The experiment selected active newborn rows for PRELUDE and root/newborn endpoints for CODA on GPU, ran the existing native modules on compact buffers, then scattered results back. Static capacities preserved graph replay and avoided CPU-side device decisions.

Matched B300/FA4/async/graph comparisons, draft 1, three alternating repetitions:

Input / concurrency Baseline tok/s Selected rows tok/s Change E2E p50, baseline → selected
128 / 8 790.27 785.78 -0.57% 0.988 → 1.000 s
4096 / 32 172.22 168.10 -2.39% 23.680 → 24.263 s

A separate short-prompt ablation found PRELUDE-only -0.56%, CODA-only -0.55%, and both -0.46%. Absolute timing drifted between phases, so only within-phase comparisons are used. Timed outputs matched; all 512 quality comparisons matched exactly. Paired ops/full profiles over eight steps showed CODA projection rows falling from 32 to 16, but projection time only changed from 0.297 to 0.293 ms while ordinary launches rose from 4,552 to 4,672. Indexing, scatter and selection overhead outweighed the measured saving. The experiment is reverted; active/write_mask correctness handling remains.

Implementation details, ablation and profiling evidence are recorded in the existing self-speculation chapter. Raw local reports remain under .reviews/async-pipeline-native-perf, .reviews/async-pipeline-native-quality, .reviews/async-pipeline-selective-perf, and .reviews/async-pipeline-selective-quality; benchmark dumps are not committed.

Historical author-reported RTX 5090 results (previous implementation)

Preserved from the previous PR description for provenance. These results concern the earlier shared-runtime revision through df3c17e, not the current asynchronous implementation, and have not been rerun by us on RTX 5090. The author reported 31 GPU tests passing, six deselected, using the Triton backend. The synthetic inputs and older execution path differ from the current B300 measurements; do not compare the absolute numbers across these campaigns. The prior inference that FA4 was the primary source of disagreement is not established by these cross-device tests; the current same-input trace above demonstrates a Linear batch-shape contribution. The small GSM8K prompt runs below measure speed/output agreement, not GSM8K task accuracy.

2. Matched A/B Matrix vs. B300 Setup (Ouro-1.4B, BF16, triton, max_tokens=128, num_speculative_tokens=4)

Using the same prompt synthesis ((x + i*17) % 1000 + 100), max_tokens=128, async_scheduling=True, cuda_graphs=True, cuda_graph_max_batch_size=256, cuda_graph_max_graphs=256, 1 warmup + 2 timed iterations on 1× NVIDIA GeForce RTX 5090 (32 GB, triton backend) (4096 / 4 used instead of 4096 / 8 due to 32 GB VRAM capacity):

  • Block 1-loop vs Pipeline 1-loop (draft_loops=1, target_loops=4):
Input len / batch Block median tok/s Pipeline median tok/s Change Block range Pipeline range Matched requests
128 / 8 704.70 887.42 +25.9% 704.61-705.09 802.90-888.22 8/8
4096 / 1 39.43 35.62 -9.7% 38.05-39.56 35.55-35.63 0/1
4096 / 4 47.35 46.98 -0.8% 47.35-47.37 46.98-47.05 3/4
  • Block 2-loop vs Pipeline 2-loop (draft_loops=2, target_loops=4):
Input len / batch Block median tok/s Pipeline median tok/s Change Block range Pipeline range Matched requests
128 / 8 495.38 641.86 +29.6% 495.38-495.43 574.07-643.45 8/8
4096 / 1 33.50 36.33 +8.5% 33.50-33.50 36.29-36.38 0/1
4096 / 4 46.98 45.98 -2.1% 46.98-47.00 45.95-45.98 4/4

3. Real-Prompt GSM8K Benchmark (Ouro-1.4B, BF16 & FP32, triton, max_tokens=64, K=4)

Single-Request Latency & Throughput (Concurrency = 1, GSM8K prompt = 50 tokens, 3 reps after warmup)

Mode Draft Loops Compilation Throughput (tok/s) TPOT (ms/tok) E2E Latency (s) Speedup vs AR Graph Speedup vs Block Graph Exact Match vs AR
Standard AR — Eager 40.86 24.47 1.5663 0.59x 0.31x Baseline
Standard AR — CUDA Graph 68.90 14.51 0.9289 1.00x 0.53x 1/1 (100%)
Block Spec (K=4) 1 Eager 88.91 11.25 0.7198 1.29x 0.68x 1/1 (100%)
Block Spec (K=4) 1 CUDA Graph 129.84 7.71 0.4936 1.88x 1.00x 1/1 (100%)
Pipeline Spec (K=4) 1 Eager 114.57 8.73 0.5587 1.66x 0.88x 1/1 (100%)
Pipeline Spec (K=4) 1 CUDA Graph 156.56 6.40 0.4095 2.27x 1.21x (+20.6%) 1/1 (100%)
Pipeline Spec (K=4) 2 CUDA Graph 106.54 9.39 0.6007 1.55x 0.82x 1/1 (100%)

Multi-Request Continuous Batching (Concurrency = 8, 8 ragged GSM8K prompts [38..114] tokens, 512 output tokens)

Mode Draft Loops Compilation Async Sched Total Throughput (tok/s) Wall Time (s) Speedup vs AR Graph Speedup vs Block Graph Deterministic Equivalence
Standard AR — Eager Off 291.08 1.7605 0.74x 0.39x Baseline
Standard AR — CUDA Graph Off 393.27 1.3019 1.00x 0.53x 8/8 vs AR Eager
Block Spec (K=4) 1 CUDA Graph Off 738.55 0.6932 1.88x 1.00x 6/8 BF16 (8/8 FP32)
Pipeline Spec (K=4) 1 Eager Off 725.80 0.7054 1.85x 0.98x pipe_eager == pipe_graph (8/8)
Pipeline Spec (K=4) 1 CUDA Graph Off 954.02 0.5367 2.43x 1.29x (+29.2%) pipe_graph == pipe_eager (8/8)
Pipeline Spec (K=4) 1 CUDA Graph On 957.14 0.5349 2.43x 1.30x (+29.6%) pipe_async == pipe_graph (8/8)
Pipeline Spec (K=4) 2 CUDA Graph On 619.28 0.8268 1.57x 0.84x pipe_async == pipe_graph (8/8)

Numerical Precision Verification (BF16 vs FP32 on 8-Request Ragged GSM8K Batch)

BF16 8-req: pipe_eager == pipe_graph: True (8/8 exact match)
BF16 8-req: pipe_graph == pipe_async: True (8/8 exact match)
BF16 8-req: exact sequence match vs AR: 6/8
FP32 8-req: pipe_graph_async == ar_eager: True (8/8 exact match)

Required human contributor answers

  1. Why this PR? Batch shallow drafting and deeper verification together using the shared runtime, reducing serialized submissions while retaining correct rejection and recovery behavior.
  2. Linked discussion? The design and runtime integration were reviewed in this PR; this update addresses the subsequent synchronous/asynchronous separation, resource-lifetime, numerical-quality and performance discussions.
  3. How tested? Reviewer-run final B300 regression, matched performance, and GSM8K results are reported above with their scope. Earlier author-reported RTX 5090 results are retained separately and do not validate this latest version.

@cs-ai-li

cs-ai-li commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

If the review is approved, I will proceed with the implementation. Please also let me know if you have any suggestions, and I will implement them. @hsliuustc0106 @bjf-frz

@cs-ai-li

Copy link
Copy Markdown
Contributor Author

#43

@0z5a 0z5a left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: PR #121 — Pipelined Self-Speculative Decoding

Review status: Request changes (correctness)

Thanks for implementing the pipelined scheduler and CDB executor. The direction is interesting, and the initial single-request speedup is encouraging. My main concern, however, is branch/KV correctness in the actual execution path, not merely API contracts or the breadth of the benchmark matrix.

Running longer contexts or higher concurrency would not, by itself, prove that the underlying issue is fixed. We need a branch-aware cache invariant, an explanation of how the implementation enforces it, and deterministic tests that exercise the conflicting histories.

P0 — Speculative branches do not appear to have isolated KV histories

The scheduler tracks branch / parent_branch, but PipelinedSpeculativeRunner.forward() builds KV batches using the same request_id and a position derived only from lane.index and recurrent depth. The KV address preparation has no branch identity. Checking for duplicate write addresses within one batch does not protect a live branch's history from writes by another branch, either in that batch or in an earlier cycle.

A concrete path to examine with S=4, K=1, second_step=2:

  1. At position p, q1(A) proposes B for p+1; q1(B) later proposes D for p+2.
  2. q2(A) proposes a distinct candidate C != B, also for p+1.
  3. In the following cycle, the lanes for C (depth=0, position=p+1) and D (depth=0, position=p+2) may execute in the same CDB batch.
  4. The model writes all rows' KV before attending. Therefore, D can read C's newly written KV at p+1, whereas D belongs to the B ancestry and requires B's KV. The duplicate-write guard would not detect this because the rows write different positions.

This is a possible read-after-overwrite across branch histories, not simply an allocation or API-semantics issue. Even if the final decision selects B, a descendant may already have consumed C's KV, so pruning the losing branch afterward cannot repair its hidden state.

Could you either implement branch-aware/versioned speculative KV (with explicit commit/rollback semantics), or document and test an equivalent mechanism that prevents conflicting writes/reads and safely recomputes affected descendants? In particular, please state the invariant connecting a live lane's ancestry to every KV position and depth it may attend.

P0 — Pruning states is not sufficient to reconcile the KV cache

retain_branches() currently retains/recycles hidden-state slots only. truncate_suffix() is called at generation completion (and in the first-token stop path), not when a speculative branch is rejected. Thus the advertised rollback behavior is not yet evident in the implementation.

The key requirement is stronger than eventually truncating the suffix: after each acceptance/rejection, no surviving lane may read KV or written-position metadata left by an incompatible branch. Please show how this is enforced before the next attention operation, including when the selected branch changes after one or more speculative descendants have already run.

P0 — Add directed executor-level tests, not just longer random generations

The current end-to-end test uses only S=4, K=1, second_step=None, three short CPU/FP32 generations, and compares final token IDs. The logical mock tests exercise q2/rejection decisions but cannot expose shared physical KV corruption. The reported real-model example has 72 accepted / 0 rejected, so it does not validate rejection handling either.

I would consider the following the minimum correctness evidence before calling this lossless:

  • Force q1 != q2 and keep both branches live, including the B/C/D interleaving above; test q1 acceptance, q2 acceptance, and rejection of both proposals using the real CDB executor/cache, not only a mock scheduler.
  • Inject deterministic rejection patterns (all reject, alternating accept/reject, consecutive rollbacks), including a KV-block boundary and EOS/length termination with outstanding descendants.
  • Compare at the first affected cycle, not only at the end: branch ancestry, KV ownership/written metadata, per-depth KV and hidden states where comparable, and final logits against a teacher-forced serial reference on the same token prefix. In FP32, use this to diagnose structural errors; in BF16, report tolerances, top-1/top-2 margins, and the first divergent token.
  • Assert that pruning/slot reuse never leaves a surviving lane dependent on another branch's KV, and that a corrected branch can continue generating consistently for several more tokens.

A targeted small test that deterministically exercises the conflicting branch histories is more convincing here than a large benchmark that happens not to trigger them.

P1 — Separate correctness claims from scalability/performance evidence

Long-context and high-concurrency benchmarks are useful after the cache lineage/rollback problem is addressed, for capacity, latency, and performance regressions. They are not a substitute for the P0 invariant and adversarial tests; high concurrency is also outside the current single-request runner's serving integration scope.

Separately, DecisionReadback.read() synchronizes on the CUDA event immediately after staging in the present forward path. This can still be a compact asynchronous copy, but does not demonstrate useful host/device overlap. It would be helpful to profile the synchronization cost and distinguish copy asynchrony from actual pipeline overlap when reporting the speedup.

Suggested merge bar: first establish branch-aware KV correctness and deterministic q1/q2/rejection coverage; then use real-model BF16 validation to support the scope of the lossless claim. Broader context/concurrency sweeps are follow-up performance evidence, not proof of the fix.

If larger-scale GPU validation becomes useful later, I can help run verification on an H100. Feel free to ping me.

@0z5a 0z5a left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the PR. The single-proposal pipeline looks right, but tree branching via second_step is not lossless yet, so I'm requesting changes for that part. Below: a reproducer, then supplementary H100 measurements of the runner on Ouro-1.4B.

1. second_step branching breaks greedy equivalence

Everything here runs on the PR head (8961c6a), on CPU in FP32. It reuses the PR's own test helpers (tests/test_pipelined_speculative.py: make_cache, native_autoregressive_decode) with tiny_ouro_config(total_ut_steps=4).

schedule seeds tried result
PipelineSchedule(4, 1, None) 40 seeds, 40 tokens 40/40 token-identical to AR
PipelineSchedule(4, 1, 2) 42, 999, 71 (the seeds in the PR's test), 0–3; prompt [1, 2, 3], 12 tokens every run raises ValueError: a write batch cannot contain duplicate request/depth/position addresses
PipelineSchedule(4, 1, 3) 40 seeds, 40 tokens no error, but 2/40 silently differ from AR. Example: seed 9, prompt [3, 2, 3, 5], first divergence at output index 22

Minimal repro, run from the repository root on the PR branch:

import torch
from tests.helpers import tiny_ouro_config
from tests.test_pipelined_speculative import make_cache, native_autoregressive_decode
from vllm_rlt.models import OuroForCausalLM
from vllm_rlt.worker.pipelined_speculative import PipelinedSpeculativeRunner
from vllm_rlt.worker.speculative_pipeline import PipelineSchedule

for second, seed, prompt, n in ((2, 42, [1, 2, 3], 12), (3, 9, [3, 2, 3, 5], 40)):
    torch.manual_seed(seed)
    cfg = tiny_ouro_config(total_ut_steps=4)
    model = OuroForCausalLM(cfg)
    ref = native_autoregressive_decode(model, make_cache(cfg, blocks=256), prompt, n)
    runner = PipelinedSpeculativeRunner(model, make_cache(cfg, blocks=256), PipelineSchedule(4, 1, second))
    try:
        print(second, runner.generate("r", prompt, max_new_tokens=n) == ref)
    except ValueError as e:
        print(second, e)

Here is my reading of the cause; please correct me if the tree logic intends something else. The paged cache holds exactly one KV slot per (request, position, depth). The q1 and q2 branches both spawn a lane for the same next position, so they share those slots.

  • X=2: both lanes sit at stage 0 in the same cycle, so the batch writes the same address twice.
  • X=3: the q2 lane writes the depth-0 slot of position p+1 two cycles later. By then the q1 branch's descendants (p+2, p+3) still read that slot, and they read the alternative token's KV.

Branches would need their own KV storage (for example, copy-on-branch into separate pages), or second_step should be rejected until they have it. It would also help to add second_step cases and a longer random-model stress test to test_pipelined_speculative.py. The current lossless test only covers second_step=None with 5–8 tokens, and the 40-token stress above is what exposes the silent case.

A smaller point: retain_branches says it rolls back the uncommitted KV suffix, but it only recycles state slots. For second_step=None, correctness then depends on rejected positions being rewritten before any later row reads them. As far as I can tell that holds today, but it should be either documented or asserted.

2. H100 measurements (Ouro-1.4B, BF16, Triton, eager, C=1, greedy)

Setup:

  • 16 GSM8K 3-shot prompts of 294–354 tokens, 256 new tokens each, ignore_eos.
  • Prefill is untimed. Decode tokens/s is the median of 3 interleaved reps.
  • Every mode runs through the same KVCacheManager and model.recurrent_prepared API, so the AR baseline pays the same per-call overhead.
  • Token equality is checked against AR on the same prompts, in both FP32 and BF16.
mode GSM8K BF16 tok/s (×AR) MATH-500 BF16 tok/s (×AR) core calls / token FP32: token-identical to AR
AR, same cache API 22.3 (1.00) 22.4 (1.00) 4.00 —
PipelineSchedule(4, 1) 66.6 (2.99) 65.9 (2.94) 1.28 16/16
PipelineSchedule(4, 2) 41.5 (1.86) 42.0 (1.87) 2.10 16/16
PipelineSchedule(4, 1, 3) 69.3 (3.11) 70.4 (3.14) 1.23 8/16; first divergence as early as token 9
PipelineSchedule(4, 1, 2) — — — raises the duplicate-address ValueError on the real model too

The GSM8K rows use the 16 prompts above (FP32 runs use the same prompts). The MATH-500 rows use 8 Minerva 4-shot prompts of 721–899 tokens, also with 256 new tokens. Rep-to-rep spread is ≤3%, except for one MATH warm-up rep at −8.5%.

For context, the native engine on the same prompts gives:

native engine (same 16 GSM8K prompts × 256) tok/s ×eager AR greedy-identical to eager AR (BF16)
AR, eager 21.9 1.00 —
AR, CUDA graphs 45.5 2.07 16/16
sync self-spec d=1, K=4, eager 45.8 2.09 7/16
async self-spec d=1, K=4 46.0 2.10 7/16
sync self-spec d=1, K=4, CUDA graphs 92.9 4.23 7/16

Notes:

  • The FP32 column is the correctness signal. In BF16, even the exact modes (step_size=1/2 here and the native d1k4) match AR on only 5–7 of 16 prompts because of batch-shape numerics; the FP32 runs remove that noise. In FP32, step_size=1 and step_size=2 are exact, while second_step=3 diverges on half of the prompts. That matches the CPU reproducer above.
  • Throughput: the eager pipelined runner is about 3× eager AR, about 1.46× CUDA-graph AR, and about 1.45× the existing eager/async d1k4 self-speculation. However, the existing d1k4 with CUDA graphs is already at 92.9 tok/s on the same prompts, which is faster than the eager pipeline (66.6). The comparison that will decide the value of this PR is therefore Phase 3 (graphs) against graph-captured self-spec.
  • An independent prototype of the same scheme (one mixed-depth core call per step, draft from depth 1) reaches the same 1.28 core calls per committed token. In FP32 it is token-identical to AR on all 16 prompts. That independently supports the second_step=None design.

@hsliuustc0106 hsliuustc0106 left a comment

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.

Maintainer self-review of head 8961c6ae9084f42f4d4ff5f22cb69217b44586f5. Verdict: FAIL — blocking correctness defect, not ready to merge.

Blocker (reproduced): the documented two-question (second_step) path crashes on a real KV cache. Minimal reproduction: PipelineSchedule(stages=4, step_size=1, second_step=2) + tiny_ouro_config(total_ut_steps=4) + runner.generate("r", [1,2,3], 8) raises ValueError: duplicate KV write addresses for request/depth/position rows, stack vllm_rlt/worker/pipelined_speculative.py:128 → vllm_rlt/core/kv_cache_manager.py:587 → vllm_rlt/worker/metadata.py:153. The failing batch has depths [3,2,1,1,0,0,0], positions [3,4,4,5,5,5,6], duplicate triple (req,0,5): q1 and q2 create two sibling lanes that write KV at the same depth/position. The q2 path is only covered by a mock test (tests/test_speculative_pipeline.py:50-124, no KV); the only real-model test uses second_step=None (tests/test_pipelined_speculative.py:73). The schedule's own reproducer is the one path that fails. Fix q2 (single lane per position) and add a real-cache test, or remove q2 from the documented/claimed surface and make the unsupported configuration raise.

The change is not integrated with the engine. The PR adds six files and modifies none: vllm_rlt/worker/pipelined_speculative.py, speculative_pipeline.py, speculative_decisions.py plus three test files. Nothing in StageEngine, ModelRunner, scheduling, KV leases, prefix pinning, CUDA Graphs or PD imports or calls them, so the "natively integrates with the CDB engine" statement is not supported by this diff, and the 2.62× serving number cannot be produced by merged code.

Other correctness gaps found:

  • PipelineSchedule.stages is not validated against model.config.total_ut_steps (pipelined_speculative.py:39-58); stages=2 on a 4-loop model is accepted and returns wrong tokens (native [9,9,9,9,9] vs [20,41,37,37,41]).
  • The acceptance counter is inconsistent with the claim: stats.accepted_proposals can increment at most once per committed token (pipelined_speculative.py:169-173), so "72 accepted / 0 rejected" for 64 output tokens is impossible.
  • retain_branches docstring says it rolls back KV (:195-202); the code reclaims slots only (:229-231) and truncates KV at the end.
  • speculative_pipeline.py:60 claims Huginn/Raven support, but the runner calls prelude/prepared/coda (:84/:92/:95) while Huginn exposes only *_prepared (models/huginn.py:251/278/295).
  • Greedy-only, no RNG, no default EOS, no chunked prefill; single-request scheduling only, so "continuous batching" is not delivered either.

Tests run (CPU, torch 2.8.0, no CUDA): full pytest -q -m "not gpu" on the merged tree (main + this PR) → 793 passed, 35 skipped, 322 deselected; the three new test files → 13 passed, 1 skipped (CUDA readback). ruff check clean. Not run: any GPU path, the RTX 5090 benchmark (its script is not in the PR), real-model q2/step_size>1 correctness.

Path to merge: fix the q2 KV collision with a real-cache regression test, validate stages against the model, correct the counter/docstrings, and either wire the runner into the engine or retitle/descope the PR to an experimental standalone prototype with the claims removed.

@hsliuustc0106

Copy link
Copy Markdown
Collaborator

Follow-up: the maintainer's re-verification criteria for this PR. I ran four read-only probes against the unfixed head 8961c6a (a checkout of 21f59c9 + this PR's added files). All four are currently red, so these are the conditions that must turn green before a second review.

1. q2 real-KV correctness. Currently 18 failing cases: second_step=2 with 4-loop/step=1 and 8-loop/step=2, 3 seeds × 3 prompts, all raising the duplicate-KV error above. Passing means either (A) every case produces tokens identical to dense native decoding, committed_tokens == len(output), and the KV written prefix equals the committed frontier after truncate_suffix; or (B) q2 is rejected explicitly at construction, the capability claim is removed from the docs, and a rejection test is added. Also required: a real-model q2 regression test — the current real-model test only covers second_step=None (tests/test_pipelined_speculative.py:73).

2. stages vs model.config.total_ut_steps. Currently stages=2 on a 4-loop model returns [20,41,37,37,41] against native [9,9,9,9,9] with no error, and stages=8 fails only at execution time. Passing means a construction-time ValueError that names both fields (same pattern as target_loops in stage_engine.py), with valid schedules still lossless.

3. Acceptance counter and performance claims. Required invariants: committed_tokens == len(tokens), accepted + rejected == len(tokens) - 1, 0 <= accepted <= len(tokens) - 1, cycles >= 1. As reported, "72 accepted / 0 rejected for 64 new tokens" cannot come from this counter (pipelined_speculative.py:169-173 increments at most once per committed token). Provide the benchmark script and the full conditions (hardware, precision, attention backend, CUDA Graph on/off) as diagnostic measurements, or drop the number.

4. Huginn claim. Currently construction succeeds and generate raises AttributeError: 'HuginnForCausalLM' object has no attribute 'prelude', while speculative_pipeline.py:60 claims "32 for Huginn/Raven". Either support it with a native-comparison lossless test, or raise NotImplementedError/ValueError at construction and delete the claim. An accidental AttributeError or ValueError during generation is a FAIL.

5. Claims and limits to converge in the same PR. Without an engine/CLI integration path, do not claim "natively integrates with the CDB engine" or continuous batching; either wire one real path or label the code an experimental building block. Fix the retain_branches KV-rollback docstring (:195-202 vs implementation :229-231) and document the single-request, greedy-only, no-default-EOS limits. Each of items 1–4 needs at least one regression test that is red on 8961c6a and green after the fix.

CPU-side gates for the new head: full pytest -q -m "not gpu" (currently 793 passed on a merged tree), ruff check, ruff format --check; GPU paths must be reported as unverified unless actually run.

@hsliuustc0106 hsliuustc0106 left a comment

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.

Changes requested: the q2 pipeline branch is not correct against a real KV cache

The idea (overlap the next recurrent step with reading back the previous decision) is worth pursuing, but in its current form this PR cannot be merged: one of its documented code paths crashes on a real KV cache, and the module is not reachable from the engine. Details below, all reproduced on this branch.

Blocking: second_step (q2) produces duplicate KV writes

Minimal reproduction with the repository's tiny-model helpers:

schedule = PipelineSchedule(stages=4, step_size=1, second_step=2)
runner = <PipelineRunner built on tiny_ouro_config(total_ut_steps=4)>
runner.generate("r", [1, 2, 3], 8)
ValueError: duplicate KV write addresses for request/depth/position rows
  at vllm_rlt/worker/pipelined_speculative.py:128  (cache._prepare_batch)
  -> vllm_rlt/core/kv_cache_manager.py:587
  -> vllm_rlt/worker/metadata.py:153

Failing batch arguments: ids all "r", depths=[3,2,1,1,0,0,0], positions=[3,4,4,5,5,5,6]; the duplicate tuple is (request="r", depth=0, position=5). q2 spawns a sibling lane at the same index, and step 3 of the cycle calls cache._prepare_batch(ids, depths, positions) for all lanes at once, so both siblings write the same depth/position in the same step.

This is not a small patch: the current design writes KV for every live lane on every cycle, and q2 therefore needs either per-branch KV staging or a deferred write until the surviving branch is committed. The existing q2 coverage is mock-only (tests/test_speculative_pipeline.py:50-124 uses a MockExecutor that never touches KV), and the real-model integration test only runs second_step=None (tests/test_pipelined_speculative.py:73). Please add a real-cache test that crosses the q2 path before re-requesting review.

Blocking: the module is not integrated with the engine

git grep finds the three new modules referenced only from tests; the PR itself lists CLI/serving integration as future work. As a result the reported 2.62x serving gain cannot be produced by the code being merged, and the claim of native CDB-engine integration does not hold: the implementation bypasses StageEngine, ModelRunner, the scheduler, KV leases/pins, CUDA graphs and PD, and holds a single self.request_id (no continuous batching).

Also:

  • PipelinedSpeculativeRunner does not validate schedule.stages against model.config.total_ut_steps (pipelined_speculative.py:39-58); building it with stages=2 for a 4-loop model does not raise, and the output is wrong ([9,9,9,9,9] versus [20,41,37,37,41] from the engine path). StageEngine does validate this class of invariant, so the module should too.
  • The 2.62x measurement references a script that is not in the PR, and the reported "72 accepted / 0 rejected" is not reproducible from the counters in this diff (accepted_proposals can increase by at most one per committed token, pipelined_speculative.py:169-173; 64 committed tokens cannot reach 72).
  • retain_branches docstring says it rolls back the uncommitted KV suffix (:195-202), but the implementation only recycles the state slot; KV is truncated at the end of generate (:229-231).
  • speculative_pipeline.py:60 claims Huginn/Raven support, but the runner calls model.prelude/recurrent_prepared/coda (:84/:92/:95) while Huginn only exposes the *_prepared variants (models/huginn.py:251/278/295).

What I could verify

On a synthesized merge tree (main + the six new files), the CPU suite is green: 793 passed / 35 skipped (780 baseline + 13 new cases), and ruff check passes. The failure above is on the GPU-free path as well; there is nothing environment-specific about it.

Recommendation: keep this as a prototype branch. Re-request review after (a) the q2 KV design plus a real-cache test, (b) validation of stages against the model, and (c) either engine integration or a clearly reduced claim with a reproducible benchmark script in the PR.

@cs-ai-li cs-ai-li left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1. Commit Message

fix(speculative): reject unsupported second_step (q2) at construction with regression
test

PipelinedSpeculativeRunner currently performs synchronous physical KV writes
for all active lanes on every cycle. Multi-proposal schedules with second_step
(q2) spawn sibling lanes at the same token index, causing collision error:
'ValueError: duplicate KV write addresses for request/depth/position rows'.

To prevent unhandled runtime crashes:
- Explicitly raise NotImplementedError in PipelinedSpeculativeRunner.init when schedule.second_step is not None.
- Clarify in docstrings that second_step is currently logical-only and not supported with physical KV cache execution.
- Add regression test test_second_step_rejected_at_construction across second_step=(2, 3) ensuring clean rejection at construction time.
──────

2. PR Description

Summary
Fixes the blocking KV write collision crash reported on real models when using
second_step (q2).

Root Cause
PipelinedSpeculativeRunner.forward() writes KV cache for all live lanes across recurrent steps in a single cache._prepare_batch() call with for_write=True. When second_step (q2) spawns sibling lanes for the same token position, both lanes attempt to write to (request_id, depth=0, position) concurrently, raising ValueError: duplicate KV write addresses for request/depth/position rows.

Changes
1. Explicit Construction Guard: In PipelinedSpeculativeRunner.__init__, raise
NotImplementedError if schedule.second_step is not None, directing users to
second_step=None.
2. Docstring Clarification: Updated docstrings in pipelined_speculative.py and
speculative_pipeline.py to clarify that second_step is only supported in the pure-
logical scheduler, not the physical runner.
3. Regression Tests: Added test_second_step_rejected_at_construction in
tests/test_pipelined_speculative.py to verify construction-time rejection.

Validation
- pytest tests/test_pipelined_speculative.py: 5 passed in 0.32s
- Existing single-proposal (second_step=None) lossless decode tests remain 100% green.
- ruff check and ruff format clean.
──────

@cs-ai-li cs-ai-li left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1. Commit Message

fix(speculative): validate schedule.stages against model.config.total_ut_steps at
construction

Previously, passing an incompatible schedule.stages (e.g. stages=2 on a 4-loop model) was accepted without warning, producing silently wrong tokens ([20,41,37,37,41] vs native [9,9,9,9,9]), while stages=8 failed late during execution.

Following the validation pattern in StageEngine:
- Validate schedule.stages == model.config.total_ut_steps in PipelinedSpeculativeRunner.init.
- Raise ValueError naming both fields if a mismatch is detected.
- Add regression test test_stages_mismatch_rejected_at_construction covering
both under-staged (stages=2) and over-staged (stages=8) configurations.
──────

2. PR Description

Summary

Validates schedule.stages against model.config.total_ut_steps at construction time to prevent silent corruption of decoded tokens.

Problem
When PipelineSchedule.stages did not match the recurrent model's expected loop count:
- stages=2 on a 4-loop model ran to completion but silently output incorrect tokens.
- stages=8 failed during execution after wasting compute.

Changes
1. Construction Validation: Added strict validation inPipelinedSpeculativeRunner.__init__ verifying schedule.stages ==model.config.total_ut_steps. Raises ValueError:schedule.stages (X) must match model.config.total_ut_steps (Y).
2. Regression Tests: Added test_stages_mismatch_rejected_at_construction in
tests/test_pipelined_speculative.py testing stages=2 and stages=8 on a 4-loop Ouro
configuration.
Validation
- pytest tests/test_pipelined_speculative.py: 7 passed in 0.34s
- Valid schedules (stages=4) remain 100% lossless against native decoding.
- Code style: clean on ruff check and ruff format.

@cs-ai-li cs-ai-li left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Commit Message

fix(speculative): validate model interface at construction and remove Huginn claim

PipelinedSpeculativeRunner requires model methods 'prelude', 'recurrent_prepared' , and 'coda' (used by Ouro-class models). HuginnForCausalLM exposes only *_prepared variants, causing generate() to raise an unhandled AttributeError at runtime,while PipelineSchedule docstring claimed Huginn/Raven support.

To guard against runtime crashes and align documentation with implementation:
- Validate that model implements 'prelude', 'recurrent_prepared', and 'coda' in
PipelinedSpeculativeRunner.init, raising NotImplementedError at construction.
- Remove unsupported '32 for Huginn/Raven' claim from PipelineSchedule docstring.
- Add regression test test_huginn_rejected_at_construction verifying clean
construction-time rejection when a Huginn model is passed.
──────
2. PR Description
Summary
Guards against unsupported model architectures at construction time and removes misleading claims regarding Huginn/Raven support.

Problem
- speculative_pipeline.py claimed support for Huginn/Raven models ("32 for
Huginn/Raven").
- However, PipelinedSpeculativeRunner relies on model.prelude and model.coda,
which HuginnForCausalLM does not implement (it exposes prelude_prepared and
coda_prepared).
- Instantiating with Huginn succeeded, but calling generate() crashed with
AttributeError: 'HuginnForCausalLM' object has no attribute 'prelude'.

Changes
1. Model Interface Validation: Checked for required methods (prelude,
recurrent_prepared, coda) in PipelinedSpeculativeRunner.__init__, raising
NotImplementedError with actionable explanation at construction time.
2. Docstring Correction: Removed "32 for Huginn/Raven" from PipelineSchedule
docstring.
3. Regression Tests: Added test_huginn_rejected_at_construction in
tests/test_pipelined_speculative.py using a real HuginnForCausalLM instance.

Validation
- pytest tests/test_pipelined_speculative.py: 8 passed in 0.32s
- Full speculative test suite (19 tests) passed cleanly.
- Code style: clean on ruff check and ruff format.

@cs-ai-li cs-ai-li left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Commit Message

fix(speculative): document prototype limits, correct retain_branches docstring, and
verify counter invariants

To address maintainer review feedback on claims, docstrings, and counter invariants:
- Label PipelinedSpeculativeRunner as an experimental standalone prototype in module and class docstrings, documenting known limits (single-request, greedy-only, no default EOS, not yet integrated into StageEngine/serving stack).
- Correct retain_branches docstring: clarifies that it reclaims state slots only,while KV truncation is deferred to generate() via cache.truncate_suffix().
- Fix stats.committed_tokens accounting on early prefill stopping.
- Add test_speculative_stats_invariants to verify required counter invariants: committed_tokens == len(tokens), accepted + rejected == len(tokens) - 1, 0 <= accepted <= len(tokens) - 1, and cycles >= 1.

@cs-ai-li

Copy link
Copy Markdown
Contributor Author

Future Work & Follow-up Roadmap

To maintain clean responsibility boundaries and reviewable diffs, the remaining
capabilities are deferred to dedicated follow-up PRs:

  1. PR 1: Per-Branch KV Staging for second_step (q2)
    - Goal: Introduce branch-isolated staging or deferred commit in KVCacheManager so
    sibling proposal lanes can run concurrently without write collisions.
    - Deliverable: Unlocks second_step != None with 18 parameterized lossless real-
    cache regression tests.

  2. PR 2: StageEngine & ModelRunner Integration
    - Goal: Wire PipelinedSpeculativeRunner into StageEngine, ModelRunner, and KV
    leases/prefix-pinning.
    - Deliverable: Enables multi-request continuous batching and CLI serving.

  3. PR 3: Huginn / Raven Architecture Adaptation
    - Goal: Adapt prelude_prepared and coda_prepared interfaces into the
    speculative runner.
    - Deliverable: Lossless speculative decoding verified against native Huginn
    autoregressive output.

  4. PR 4: Sampling Policies & Reproducible Benchmarks
    - Goal: Add temperature / top-p / RNG to speculative_decisions.py and commit the
    standalone reproduction benchmark script under benchmarks/ with full hardware/precision
    configs.

@cs-ai-li

Copy link
Copy Markdown
Contributor Author

Thanks for the PR. The single-proposal pipeline looks right, but tree branching via second_step is not lossless yet, so I'm requesting changes for that part. Below: a reproducer, then supplementary H100 measurements of the runner on Ouro-1.4B.

1. second_step branching breaks greedy equivalence

Everything here runs on the PR head (8961c6a), on CPU in FP32. It reuses the PR's own test helpers (tests/test_pipelined_speculative.py: make_cache, native_autoregressive_decode) with tiny_ouro_config(total_ut_steps=4).

schedule seeds tried result
PipelineSchedule(4, 1, None) 40 seeds, 40 tokens 40/40 token-identical to AR
PipelineSchedule(4, 1, 2) 42, 999, 71 (the seeds in the PR's test), 0–3; prompt [1, 2, 3], 12 tokens every run raises ValueError: a write batch cannot contain duplicate request/depth/position addresses
PipelineSchedule(4, 1, 3) 40 seeds, 40 tokens no error, but 2/40 silently differ from AR. Example: seed 9, prompt [3, 2, 3, 5], first divergence at output index 22
Minimal repro, run from the repository root on the PR branch:

import torch
from tests.helpers import tiny_ouro_config
from tests.test_pipelined_speculative import make_cache, native_autoregressive_decode
from vllm_rlt.models import OuroForCausalLM
from vllm_rlt.worker.pipelined_speculative import PipelinedSpeculativeRunner
from vllm_rlt.worker.speculative_pipeline import PipelineSchedule

for second, seed, prompt, n in ((2, 42, [1, 2, 3], 12), (3, 9, [3, 2, 3, 5], 40)):
    torch.manual_seed(seed)
    cfg = tiny_ouro_config(total_ut_steps=4)
    model = OuroForCausalLM(cfg)
    ref = native_autoregressive_decode(model, make_cache(cfg, blocks=256), prompt, n)
    runner = PipelinedSpeculativeRunner(model, make_cache(cfg, blocks=256), PipelineSchedule(4, 1, second))
    try:
        print(second, runner.generate("r", prompt, max_new_tokens=n) == ref)
    except ValueError as e:
        print(second, e)

Here is my reading of the cause; please correct me if the tree logic intends something else. The paged cache holds exactly one KV slot per (request, position, depth). The q1 and q2 branches both spawn a lane for the same next position, so they share those slots.

  • X=2: both lanes sit at stage 0 in the same cycle, so the batch writes the same address twice.
  • X=3: the q2 lane writes the depth-0 slot of position p+1 two cycles later. By then the q1 branch's descendants (p+2, p+3) still read that slot, and they read the alternative token's KV.

Branches would need their own KV storage (for example, copy-on-branch into separate pages), or second_step should be rejected until they have it. It would also help to add second_step cases and a longer random-model stress test to test_pipelined_speculative.py. The current lossless test only covers second_step=None with 5–8 tokens, and the 40-token stress above is what exposes the silent case.

A smaller point: retain_branches says it rolls back the uncommitted KV suffix, but it only recycles state slots. For second_step=None, correctness then depends on rejected positions being rewritten before any later row reads them. As far as I can tell that holds today, but it should be either documented or asserted.

2. H100 measurements (Ouro-1.4B, BF16, Triton, eager, C=1, greedy)

Setup:

  • 16 GSM8K 3-shot prompts of 294–354 tokens, 256 new tokens each, ignore_eos.
  • Prefill is untimed. Decode tokens/s is the median of 3 interleaved reps.
  • Every mode runs through the same KVCacheManager and model.recurrent_prepared API, so the AR baseline pays the same per-call overhead.
  • Token equality is checked against AR on the same prompts, in both FP32 and BF16.

mode GSM8K BF16 tok/s (×AR) MATH-500 BF16 tok/s (×AR) core calls / token FP32: token-identical to AR
AR, same cache API 22.3 (1.00) 22.4 (1.00) 4.00 —
PipelineSchedule(4, 1) 66.6 (2.99) 65.9 (2.94) 1.28 16/16
PipelineSchedule(4, 2) 41.5 (1.86) 42.0 (1.87) 2.10 16/16
PipelineSchedule(4, 1, 3) 69.3 (3.11) 70.4 (3.14) 1.23 8/16; first divergence as early as token 9
PipelineSchedule(4, 1, 2) — — — raises the duplicate-address ValueError on the real model too
The GSM8K rows use the 16 prompts above (FP32 runs use the same prompts). The MATH-500 rows use 8 Minerva 4-shot prompts of 721–899 tokens, also with 256 new tokens. Rep-to-rep spread is ≤3%, except for one MATH warm-up rep at −8.5%.

For context, the native engine on the same prompts gives:

native engine (same 16 GSM8K prompts × 256) tok/s ×eager AR greedy-identical to eager AR (BF16)
AR, eager 21.9 1.00 —
AR, CUDA graphs 45.5 2.07 16/16
sync self-spec d=1, K=4, eager 45.8 2.09 7/16
async self-spec d=1, K=4 46.0 2.10 7/16
sync self-spec d=1, K=4, CUDA graphs 92.9 4.23 7/16
Notes:

  • The FP32 column is the correctness signal. In BF16, even the exact modes (step_size=1/2 here and the native d1k4) match AR on only 5–7 of 16 prompts because of batch-shape numerics; the FP32 runs remove that noise. In FP32, step_size=1 and step_size=2 are exact, while second_step=3 diverges on half of the prompts. That matches the CPU reproducer above.
  • Throughput: the eager pipelined runner is about 3× eager AR, about 1.46× CUDA-graph AR, and about 1.45× the existing eager/async d1k4 self-speculation. However, the existing d1k4 with CUDA graphs is already at 92.9 tok/s on the same prompts, which is faster than the eager pipeline (66.6). The comparison that will decide the value of this PR is therefore Phase 3 (graphs) against graph-captured self-spec.
  • An independent prototype of the same scheme (one mixed-depth core call per step, draft from depth 1) reaches the same 1.28 core calls per committed token. In FP32 it is token-identical to AR on all 16 prompts. That independently supports the second_step=None design.

Hi @0z5a ,

Thank you for the exceptionally thorough review, the concrete minimal reproducer, and
the H100 benchmark measurements on Ouro-1.4B!

Your analysis of the cross-branch KV history pollution is spot-on: because physical KV
slots are indexed solely by (request, position, depth) without branch identity,
descendant lanes (e.g. $D$ at $p+2$) inevitably read KV written by incompatible sibling
candidates (e.g. $C$ at $p+1$) in the same CDB batch before attention. This explains both
the write collision at $X=2$ and the silent divergence you caught at $X=3$.

At the same time, your H100 results provide invaluable independent validation for the
core single-proposal architecture (second_step=None): achieving 66.6 tok/s (2.99× over
eager AR)
with 1.28 core calls per committed token and 16/16 exact match in FP32
on GSM8K is extremely encouraging.

Actions Taken in Response to Your Feedback

Following your recommendation ("Branches would need their own KV storage... or
second_step should be rejected until they have it"
), we have scoped this PR to solidify
the validated single-proposal foundation:

  1. Explicit Construction Rejection for second_step (P0):
    - PipelinedSpeculativeRunner.__init__ now explicitly rejects any schedule with
    second_step is not None by raising NotImplementedError, preventing both the duplicate-
    address crash ($X=2$) and the silent divergence ($X=3$).
    - Added regression test test_second_step_rejected_at_construction covering
    second_step=(2, 3).
    - Updated PipelineSchedule docstrings to remove claims of multi-branch physical
    execution.

  2. KV Rollback & Invariants Clarification (P0):
    - For second_step=None, the speculative frontier is strictly linear. Any
    uncommitted KV positions written during speculative cycles are overwritten on subsequent
    cycles or safely truncated at the end of generate() via cache.truncate_suffix().
    - Corrected retain_branches docstring to clarify that it recycles hidden-state
    slots only, and that physical KV rollback is explicitly handled at generate() completion.
    - Added test_speculative_stats_invariants asserting:
    committed_tokens == len(tokens), accepted + rejected == len(tokens) - 1, and 0 <= accepted <= len(tokens) - 1.

  3. Stage & Interface Guards:
    - Added construction validation enforcing schedule.stages == model.config. total_ut_steps (raising ValueError naming both fields).
    - Added model method inspection (prelude, recurrent_prepared, coda), rejecting
    incompatible architectures (e.g. Huginn) at construction with NotImplementedError.

  4. Follow-up Roadmap (Tree Branching & CUDA Graphs):
    - As you noted, true tree branching requires dedicated branch-aware KV management
    (such as copy-on-branch / versioned paged staging). We have documented this as a dedicated
    follow-up PR.
    - We also note your observation regarding graph-captured baseline throughput (92.9
    tok/s). Wiring the pipelined CDB runner into CUDA Graph capture and StageEngine is
    tracked as Phase 3 follow-up work.

All 22 speculative unit & integration tests are passing cleanly on the updated head,
with valid single-proposal configurations remaining 100% lossless against native
autoregressive decoding.

We would appreciate your re-review of the updated PR. Thanks again for your insightful
evaluation!

@hsliuustc0106 hsliuustc0106 left a comment

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.

Re-review of head 155d878cc444130a72e0f5b7962aecd1eefa75c3 against base a32c89a757d42a5a1702440e27425d000186068b. Recommendation: request changes.

The new construction guards address the earlier unsupported-q2, model-depth mismatch, and Huginn-interface findings. The single-proposal design has a plausible correctness argument with LAST-EXITED KV and full-depth verification: predecessors populate the depth-specific history needed by later lanes, and rejection prunes descendants before corrected tokens advance. The three inline comments identify remaining cache-layout, CUDA synchronization, and test-selection defects.

The performance and integration claims also need to match the reduced scope:

  • This is a standalone single-request, greedy prototype; it is not reachable from the engine/serving path.
  • forward() immediately calls read() after queue(), and that read synchronizes the event. The current implementation therefore still waits for decisions each cycle; pinned nonblocking copies alone do not demonstrate computation/readback overlap.
  • The reported 2.62x eager-AR comparison does not establish an improvement over the existing graph-enabled block speculation. That is a relevant alternative baseline for qualification.
  • For one generation producing 64 tokens, including the prefill token, the counters can record only 63 final proposal decisions. The reported "72 accepted / 0 rejected" needs a reproducible script and an explanation of any warmup or cumulative-counter scope.

Validation: local Ruff lint, formatting, and diff checks passed. The recorded CI merge build 981f15d reported 821 passed / 357 skipped; this is existing CI evidence, not a local runtime rerun. Runtime failures in the inline comments were not reproduced locally because user namespaces are disabled and the isolated execution attempt could not start. No GPU execution, BF16 losslessness validation, or performance measurements were performed for this review.

)

self.model = model
self.cache = cache

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.

[P1] Reject SHARED KV at construction

This standalone runner accepts KVCacheManager(layout="shared"), but its execution requires depth-separated KV. SHARED maps every depth to plane 0 (KVCacheManager._plane), while prefill() advances the whole prompt one depth at a time and forward() advances multiple positions at different depths. For example, the second prompt token's first loop reads the first token's first-loop KV here; sequential SHARED execution requires the first token's final-loop KV. Overlapping decode lanes have the same history problem, so accepting this configuration can silently change greedy outputs. Please require cache.layout == "last_exited" in this constructor, matching the existing speculative guard in LLMEngine, and add a construction-rejection regression test.

@cs-ai-li cs-ai-li Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in the shared runtime integration (49829d4 / 1ca951e): the standalone PipelinedSpeculativeDecoder was replaced by SpeculativeRunner (vllm_rlt/worker/speculative.py), StageEngine.__init__ (vllm_rlt/engine/stage_engine.py) enforces cache_config.layout == "last_exited" at construction, and SpeculativeConfig.__post_init__ (vllm_rlt/config.py) enforces target_loops % draft_loops == 0 when mode == "pipeline".

target = self.host[: rows * columns].view(rows, columns)
if self.is_cuda:
target.copy_(decisions, non_blocking=True)
self.ready.record()

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.

[P1] Record the completion event on the decision tensor's device

When decisions is on cuda:1 but the process's current device remains cuda:0, the nonblocking D2H copy is submitted on GPU 1, whereas bare self.ready.record() uses the current stream of GPU 0. read() can therefore finish synchronizing an unrelated event and consume the pinned buffer before its copy completes, yielding stale or uninitialized token decisions. Please record on torch.cuda.current_stream(self.device) (or the actual copy stream), and cover a tensor device that differs from the current device. This is a source-level finding; no multi-GPU reproducer was run in this review. PyTorch documents the default stream selection here: https://docs.pytorch.org/docs/2.14/generated/torch.cuda.Event.html#torch.cuda.Event.record

@cs-ai-li cs-ai-li Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in the shared runtime integration (49829d4 / 1ca951e): SpeculativeRunner._submit_pipeline (vllm_rlt/worker/speculative.py) copies endpoint choices into ticket-owned pinned host memory (decisions) and records the completion event on torch.cuda.current_stream(self.device) within self.compute_stream.

Comment thread tests/test_speculative_decisions.py Outdated
assert recovered == [[101], [202], [303]]


@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")

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.

[P2] Mark the CUDA readback test as gpu

This test is gated only by CUDA availability and lacks @pytest.mark.gpu. Consequently, both plain pytest and pytest -m "not gpu" can execute its CUDA allocations on a GPU host without --run-gpu; the opt-in hook in tests/conftest.py only skips tests carrying the gpu marker. Please add that marker so the test follows the repository's reservation and execution gate.

@cs-ai-li cs-ai-li Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in the shared runtime integration (49829d4 / 1ca951e): the standalone prototype tests were replaced by the unified pipeline test suite in tests/test_speculative.py, with all CUDA tests marked @pytest.mark.gpu.

@bjf-frz bjf-frz changed the title feat(speculative): implement pipelined self-speculative decoding for looped transformers [feat] [speculative] implement pipelined self-speculative decoding for looped transformers Oct 26, 2026
@cs-ai-li

cs-ai-li commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Review Response & Real-Hardware Benchmark Evidence (df3c17e)

Thanks @0z5a, @hsliuustc0106, and @bjf-frz for the thorough reviews and runtime integration refactoring! Here is a summary of how all review comments have been addressed on feat/pipelined-speculative-decoding, along with supplementary real-hardware validation on NVIDIA GeForce RTX 5090 (32 GB, SM120, triton attention backend).


1. Resolution of Review Feedback

  • Runtime integration & multi-request batching (@0z5a):
    • Replaced the standalone PipelinedSpeculativeEngine prototype (1ca951e) with shared runtime integration inside vllm_rlt/worker/speculative.py (SpeculativeRunner._submit_pipeline, SpeculativeRunner._collect_pipeline, PipelineState, PipelineToken, PipelineStep), coordinated by StageEngine (vllm_rlt/engine/stage_engine.py), Scheduler (vllm_rlt/core/scheduler.py), and PreemptionManager (vllm_rlt/engine/preemption.py) via --speculative-mode pipeline.
    • Supports multi-request ragged continuous batching, kv_frontier-based KV suffix truncation on rejection, preemption with CPU offload (snapshot_pipeline / restore_pipeline), CUDA graph capture/replay (RecurrentGraphs / CodaGraphs), and async ticket collection.
  • KV layout & loop divisibility checks (@hsliuustc0106):
    • StageEngine.__init__ enforces cache_config.layout == "last_exited" for all speculative decoding modes, and SpeculativeConfig.__post_init__ (vllm_rlt/config.py) validates self.target_loops % self.draft_loops == 0 when self.mode == "pipeline".
  • Completion event device & pinned readback (@hsliuustc0106):
    • SpeculativeRunner._submit_pipeline (vllm_rlt/worker/speculative.py) copies endpoint argmax decisions into ticket-owned pinned host memory (decisions) and records event on torch.cuda.current_stream(self.device) within the runner's compute_stream context.
  • Mark CUDA tests with @pytest.mark.gpu (@hsliuustc0106):
    • CUDA pipeline tests in tests/test_speculative.py are marked with @pytest.mark.gpu.

2. GPU Unit & Integration Tests (RTX 5090, triton)

CUDA_VISIBLE_DEVICES=0 python -m pytest tests/test_speculative.py -k "pipeline and not flash_attn_4" --run-gpu -v
# 31 passed, 6 deselected in 5.61s

3. Matched A/B Matrix vs. B300 Setup (Ouro-1.4B, BF16, triton, max_tokens=128, num_speculative_tokens=4)

Using the same synthetic prompt setup ((x + i*17) % 1000 + 100), max_tokens=128, async_scheduling=True, cuda_graphs=True, cuda_graph_max_batch_size=256, cuda_graph_max_graphs=256, 1 warmup + 2 timed iterations on 1× NVIDIA GeForce RTX 5090 (32 GB, triton) (4096 / 4 used instead of 4096 / 8 due to 32 GB VRAM capacity):

  • Block 1-loop vs Pipeline 1-loop (draft_loops=1, target_loops=4):
Input len / batch Block median tok/s Pipeline median tok/s Change Block range Pipeline range Matched requests
128 / 8 704.70 887.42 +25.9% 704.61-705.09 802.90-888.22 8/8
4096 / 1 39.43 35.62 -9.7% 38.05-39.56 35.55-35.63 0/1
4096 / 4 47.35 46.98 -0.8% 47.35-47.37 46.98-47.05 3/4
  • Block 2-loop vs Pipeline 2-loop (draft_loops=2, target_loops=4):
Input len / batch Block median tok/s Pipeline median tok/s Change Block range Pipeline range Matched requests
128 / 8 495.38 641.86 +29.6% 495.38-495.43 574.07-643.45 8/8
4096 / 1 33.50 36.33 +8.5% 33.50-33.50 36.29-36.38 0/1
4096 / 4 46.98 45.98 -2.1% 46.98-47.00 45.95-45.98 4/4

4. Real-Checkpoint GSM8K Benchmarks (Ouro-1.4B, BF16 & FP32, triton, max_tokens=64, K=4)

Single-Request Latency & Throughput (Concurrency = 1, GSM8K prompt = 50 tokens, 3 reps after warmup)

Mode Draft Loops Compilation Throughput (tok/s) TPOT (ms/tok) E2E Latency (s) Speedup vs AR Graph Speedup vs Block Graph Exact Match vs AR
Standard AR — Eager 40.86 24.47 1.5663 0.59x 0.31x Baseline
Standard AR — CUDA Graph 68.90 14.51 0.9289 1.00x 0.53x 1/1 (100%)
Block Spec (K=4) 1 Eager 88.91 11.25 0.7198 1.29x 0.68x 1/1 (100%)
Block Spec (K=4) 1 CUDA Graph 129.84 7.71 0.4936 1.88x 1.00x 1/1 (100%)
Pipeline Spec (K=4) 1 Eager 114.57 8.73 0.5587 1.66x 0.88x 1/1 (100%)
Pipeline Spec (K=4) 1 CUDA Graph 156.56 6.40 0.4095 2.27x 1.21x (+20.6%) 1/1 (100%)
Pipeline Spec (K=4) 2 CUDA Graph 106.54 9.39 0.6007 1.55x 0.82x 1/1 (100%)

Multi-Request Continuous Batching (Concurrency = 8, 8 ragged GSM8K prompts [38..114] tokens, 512 output tokens)

Mode Draft Loops Compilation Async Sched Total Throughput (tok/s) Wall Time (s) Speedup vs AR Graph Speedup vs Block Graph Deterministic Equivalence
Standard AR — Eager Off 291.08 1.7605 0.74x 0.39x Baseline
Standard AR — CUDA Graph Off 393.27 1.3019 1.00x 0.53x 8/8 vs AR Eager
Block Spec (K=4) 1 CUDA Graph Off 738.55 0.6932 1.88x 1.00x 6/8 BF16 (8/8 FP32)
Pipeline Spec (K=4) 1 Eager Off 725.80 0.7054 1.85x 0.98x pipe_eager == pipe_graph (8/8)
Pipeline Spec (K=4) 1 CUDA Graph Off 954.02 0.5367 2.43x 1.29x (+29.2%) pipe_graph == pipe_eager (8/8)
Pipeline Spec (K=4) 1 CUDA Graph On 957.14 0.5349 2.43x 1.30x (+29.6%) pipe_async == pipe_graph (8/8)
Pipeline Spec (K=4) 2 CUDA Graph On 619.28 0.8268 1.57x 0.84x pipe_async == pipe_graph (8/8)

Numerical Precision Verification (BF16 vs FP32 on 8-Request Ragged Batch)

BF16 8-req: pipe_eager == pipe_graph: True (8/8 exact match)
BF16 8-req: pipe_graph == pipe_async: True (8/8 exact match)
BF16 8-req: exact sequence match vs AR: 6/8
FP32 8-req: pipe_graph_async == ar_eager: True (8/8 exact match)

Separate synchronous and asynchronous pipeline execution, preserve lifecycle and KV masking, and document the reverted boundary row-selection experiment.

bjf-frz commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Updated the implementation and PR description in 483c10e.

  • The current synchronous/asynchronous pipeline paths, CUDA Graph handling and lifecycle fixes are now pushed. The PRELUDE/CODA row-selection experiment remains reverted; its implementation, negative performance results and profiling evidence are recorded in the self-speculation documentation.
  • The description now includes the complete matched B300 performance matrix (absolute and relative throughput, TTFT/E2E/TPOT, acceptance/rejection), the five-mode GSM8K evaluation, and the numerical-divergence investigation. The runtime files match those benchmarked. Earlier author-reported RTX 5090 results are explicitly separated as historical evidence.
  • Final GPU regression: 327 passed, 1 skipped (FA4 does not support the tested FP32 combination). At 4096/32, throughput is essentially unchanged and latency regresses; the description retains those results rather than presenting a universal speedup.

@bjf-frz

bjf-frz commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

lgtm

@bjf-frz
bjf-frz merged commit 2d1eb06 into InferMatrixLab:main Oct 27, 2026
1 check passed
0z5a added a commit to 0z5a/vllm-rlt that referenced this pull request Oct 27, 2026
Resolve conflicts with pipelined speculative decoding (InferMatrixLab#121):
- SpeculativeConfig keeps main's mode and WISE's draft_attention; a WISE draft
  only exists in block rounds, so draft_attention='wise' rejects
  mode='pipeline' (new test).
- Recurrent CUDA graph entries carry both main's write_mask buffer and WISE's
  static inputs; the graph key separates WISE from dense decode and masked
  from unmasked writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: 0z5a <dezhen.lu@student.uni-tuebingen.de>
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.

4 participants