Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
aaa31de
feat: add opt-in loop-level speculative pipeline
Oct 9, 2026
9586b4a
feat: add greedy early rejection pruning
Oct 18, 2026
9c0dd53
docs: record rebased loop speculative benchmark
Oct 18, 2026
f3a6a1d
bench: expose CUDA graph batch limit
Oct 18, 2026
dbaea32
docs: use graph256 result for c128 benchmark
Oct 18, 2026
7200a75
docs: record graph256 sampling benchmark
Oct 18, 2026
2ce6d21
docs: clarify loop speculative usage and results
Oct 18, 2026
853b653
docs: explain issue scope and pruning limits
Oct 18, 2026
41bf7a6
docs: add loop pipeline schedule diagram
Oct 18, 2026
e9e5ef1
test: document issue 43 loop validation
Oct 18, 2026
ada1e4e
docs: clarify sampling results and throughput units
Oct 18, 2026
e27f9f5
docs: clarify aggregate decode throughput
Oct 18, 2026
3415844
test: record filtered sampling errors
Oct 18, 2026
68c724b
docs: correct memory units and summary
Oct 20, 2026
80f849d
Merge main into loop-level speculation
0z5a Oct 24, 2026
0388ea0
Run loop-level speculation as a planned submit/collect round
0z5a Oct 24, 2026
1d7c401
Pair benchmark trials and report interval estimates
0z5a Oct 24, 2026
c29bad4
Expose the CUDA graph cache limit in the benchmark
0z5a Oct 24, 2026
2a38a99
Restore the filtered top-p/top-k rejection-sampling test
0z5a Oct 24, 2026
80a737d
Document the loop round, its cost model and RTX 5090 measurements
0z5a Oct 24, 2026
3f10ece
docs: keep loop diagram local
Oct 25, 2026
83690cd
Merge upstream main and preserve loop-level speculative mode
Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions benchmarks/loop_speculative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
"""Paired block/loop-level speculative decode comparison on a local Ouro model.

Both engines share one resident model. Prefill is excluded, trial order alternates,
and every request has a fixed seed, so both arms see the same RNG draws: sampled
runs compare equal acceptance instead of two different random acceptance paths.
Raw timings, generated tokens and CUDA graph counters are retained. This is not
the full RFC #43 accuracy/performance acceptance matrix.
"""

import argparse
import hashlib
import json
import random
import statistics
import subprocess
import time
from dataclasses import asdict
from pathlib import Path

import torch

from vllm_rlt import (
CacheConfig,
ExecutionConfig,
SamplingParams,
SchedulerConfig,
SpeculativeConfig,
)
from vllm_rlt.engine.llm_engine import LLMEngine
from vllm_rlt.models import OuroForCausalLM, load_tokenizer
from vllm_rlt.worker.speculative import SpeculativeStats


def graph_counters(runner):
return {
name: None
if graphs is None
else dict(captures=graphs.captures, replays=graphs.replays, fallbacks=graphs.fallbacks)
for name, graphs in (("recurrent", runner.graphs), ("coda", runner.coda_graphs))
}


@torch.inference_mode()
def trial(engine, prompts, new_tokens, temperature, top_p, top_k, seed):
for i, prompt in enumerate(prompts):
params = SamplingParams(
max_tokens=new_tokens + 1,
ignore_eos=True,
temperature=temperature,
top_p=top_p,
top_k=top_k,
seed=seed + i,
)
engine.add_request(str(i), prompt, params)
# Hold decode until all requests have crossed the first-output barrier.
while any(not r.generated_token_ids for r in engine.scheduler.requests.values()):
engine.step()
engine.speculative_runner.stats = SpeculativeStats()
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats()
start = time.perf_counter()
outputs = {}
steps = 0
while engine.has_unfinished_requests():
steps += 1
for output in engine.step():
if output.finished:
outputs[output.request_id] = output.token_ids
torch.cuda.synchronize()
elapsed = time.perf_counter() - start
committed = len(prompts) * new_tokens
stats = asdict(engine.speculative_runner.stats)
if stats["committed_tokens"] != committed:
raise RuntimeError(
f"benchmark committed {stats['committed_tokens']} tokens, expected {committed}; "
f"output lengths={[len(outputs[str(i)]) for i in range(len(prompts))]}"
)
return dict(
seconds=elapsed,
steps=steps,
committed_tokens=committed,
tokens_per_second=committed / elapsed,
peak_allocated_bytes=torch.cuda.max_memory_allocated(),
stats=stats,
tokens=[outputs[str(i)] for i in range(len(prompts))],
)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=Path, required=True, help="Existing local checkpoint")
parser.add_argument("--prompts", type=Path, help="JSON list of prompt strings")
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--backend", choices=["triton", "flash_attn_4"], default="triton")
parser.add_argument("--cuda-graphs", action="store_true")
parser.add_argument("--k", type=int, default=4)
parser.add_argument("--draft-loops", type=int, default=2)
parser.add_argument("--concurrency", type=int, default=1)
parser.add_argument("--new-tokens", type=int, default=128)
parser.add_argument("--repeats", type=int, default=7)
parser.add_argument("--seed", type=int, default=0, help="Seed of request 0; request i adds i")
parser.add_argument("--temperature", type=float, default=0.0)
parser.add_argument("--top-p", type=float, default=1.0)
parser.add_argument("--top-k", type=int, default=-1)
parser.add_argument("--cuda-graph-max-batch-size", type=int, default=128)
parser.add_argument(
"--cuda-graph-max-graphs",
type=int,
default=16,
help="Raise it when ragged rounds exhaust the cache; check the reported fallbacks",
)
args = parser.parse_args()
if not args.model.is_dir():
parser.error("--model must be a complete local checkpoint directory")
if min(args.concurrency, args.new_tokens, args.repeats, args.k) < 1:
parser.error("concurrency, new-tokens, repeats and k must be positive")
if args.temperature < 0 or not 0 < args.top_p <= 1:
parser.error("temperature must be nonnegative and top-p must be in (0, 1]")
if args.top_k == 0 or args.top_k < -1:
parser.error("top-k must be -1 or positive")
if min(args.cuda_graph_max_batch_size, args.cuda_graph_max_graphs) < 1:
parser.error("cuda-graph-max-batch-size and cuda-graph-max-graphs must be positive")
torch.set_num_threads(1)
texts = (
json.loads(args.prompts.read_text())
if args.prompts
else [
"Explain why the sky appears blue during the day.",
"Write a Python function that returns the Fibonacci sequence.",
]
)
if not texts or not all(isinstance(t, str) and t for t in texts):
parser.error("prompts must be a nonempty JSON list of nonempty strings")
tokenizer = load_tokenizer(str(args.model))
prompts = [tokenizer.encode(texts[i % len(texts)]) for i in range(args.concurrency)]
model = OuroForCausalLM.from_pretrained(args.model, device="cuda", dtype=torch.bfloat16)
blocks = model.config.total_ut_steps * sum(
(len(p) + args.new_tokens + 15) // 16 for p in prompts
)
scheduler = SchedulerConfig(
max_num_seqs=args.concurrency,
max_num_batched_tokens=max(
sum(map(len, prompts)),
args.concurrency * max(map(len, prompts)),
args.concurrency * (args.k + 1),
),
prefill_chunk_size=max(map(len, prompts)),
max_prefill_batches_before_decode=1024,
)
execution = ExecutionConfig(
cuda_graphs=args.cuda_graphs,
cuda_graph_max_batch_size=args.cuda_graph_max_batch_size,
cuda_graph_max_graphs=args.cuda_graph_max_graphs,
)
engines = {
name: LLMEngine(
model,
cache_config=CacheConfig(num_blocks=blocks, block_size=16),
scheduler_config=scheduler,
execution_config=execution,
attention_backend=args.backend,
speculative_config=SpeculativeConfig(
num_speculative_tokens=args.k,
draft_loops=args.draft_loops,
target_loops=model.config.total_ut_steps,
loop_level_pipelining=enabled,
),
)
for name, enabled in [("baseline", False), ("pipeline", True)]
}
raw = {name: [] for name in engines}
try:
for engine in engines.values():
trial(
engine,
prompts,
args.new_tokens,
args.temperature,
args.top_p,
args.top_k,
args.seed,
)
for repeat in range(args.repeats):
order = list(engines) if repeat % 2 == 0 else list(reversed(engines))
for name in order:
result = trial(
engines[name],
prompts,
args.new_tokens,
args.temperature,
args.top_p,
args.top_k,
args.seed,
)
raw[name].append(result)
print(
f"{name} repeat={repeat}: {result['tokens_per_second']:.2f} tokens/s",
flush=True,
)
graphs = {name: graph_counters(e.speculative_runner) for name, e in engines.items()}
finally:
for engine in engines.values():
engine.close()

def acceptance(stats):
return stats["accepted_tokens"] / max(stats["drafted_tokens"], 1)

summary = {}
for name, trials in raw.items():
seconds = sorted(r["seconds"] for r in trials)
quartiles = statistics.quantiles(seconds, n=4) if len(seconds) > 1 else seconds * 3
summary[name] = dict(
median_seconds=statistics.median(seconds),
q1_seconds=quartiles[0],
q3_seconds=quartiles[2],
min_seconds=seconds[0],
max_seconds=seconds[-1],
median_tokens_per_second=statistics.median(r["tokens_per_second"] for r in trials),
median_step_ms=statistics.median(1e3 * r["seconds"] / r["steps"] for r in trials),
acceptance=[acceptance(r["stats"]) for r in trials],
)
# Repeats pair adjacent baseline/pipeline trials; report the paired ratio and a
# bootstrap interval of its median rather than a ratio of independent medians.
ratios = [b["seconds"] / p["seconds"] for b, p in zip(raw["baseline"], raw["pipeline"])]
rng = random.Random(0)
boot = sorted(statistics.median(rng.choices(ratios, k=len(ratios))) for _ in range(2000))
paired = dict(
ratios=ratios,
median=statistics.median(ratios),
ci95=[boot[49], boot[1949]],
)
agreement = []
for baseline, pipeline in zip(raw["baseline"], raw["pipeline"]):
agreement.append(
[
next((i for i, (a, b) in enumerate(zip(left, right)) if a != b), None)
for left, right in zip(baseline["tokens"], pipeline["tokens"])
]
)
report = dict(
arguments={k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
git_head=subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
tracked_diff_sha256=hashlib.sha256(
subprocess.check_output(["git", "diff", "HEAD"])
).hexdigest(),
torch=torch.__version__,
cuda=torch.version.cuda,
gpu=torch.cuda.get_device_name(),
dtype="bfloat16",
checkpoint_config_sha256=hashlib.sha256(
(args.model / "config.json").read_bytes()
).hexdigest(),
prompt_token_ids=prompts,
scheduler=asdict(scheduler),
execution=asdict(execution),
summary=summary,
speedup=paired["median"],
paired_speedup=paired,
cuda_graphs=graphs,
first_divergent_output_positions=agreement,
trials=raw,
limitations="Paired decode-throughput check; not BF16 acceptance validation. "
"Peak memory includes both resident engines. Checkpoint config hash does not hash weights.",
)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2) + "\n")
print(
json.dumps(
dict(summary=summary, paired_speedup=paired, graphs=graphs, divergence=agreement),
indent=2,
)
)


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ an environment to receiving the first generated response. The
| [Runtime execution and configuration](runtime.md) | Batch contracts, preparation, transfers, execution options and compatibility |
| [Split prefill and WCPB](split-prefill-wavefront.md) | Chunk/depth scheduling, hidden-state ring, KV dependencies and validation limits |
| [Self-speculation](async-speculative.md) | Cross-round asynchronous block execution, token pipelines, and acceptance/rejection timelines |
| [Loop-level speculation](loop_speculative.md) | Opt-in mixed-depth execution, KV dependencies, and feasibility measurements |
| [Asynchronous scheduling](https://github.com/InferMatrixLab/vllm-rlt/pull/30) | CPU/GPU pipelining and single-stream or multi-stream execution |
| [FlashAttention](https://github.com/InferMatrixLab/vllm-rlt/pull/30) | FA2/FA3/FA4 installation, hardware selection, and constraints |
| [Cache and scheduling features](https://github.com/InferMatrixLab/vllm-rlt/pull/31) | Prefix reuse, incremental KV, priorities, and preemption |
Expand Down
Loading