Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
99d30c0
benchmark/spec_decode: MTP acceptance harness for Qwen3.5 without a s…
Oct 29, 2026
46b2563
test_spec_decode_verify: compare unreached draft positions NaN-aware
Oct 29, 2026
91f4e06
resources: speculative decoding registers, verify steps for KV, atten…
merceod Oct 29, 2026
874ebf8
qwen3_5: draft with the MTP head under num_draft_tokens
merceod Oct 29, 2026
3abd2c0
configs, catalog rows and a parity client for Qwen3.5 with MTP drafts
merceod Oct 29, 2026
4a12410
spec decode: capture rows carry no sampling config; accepted tokens a…
merceod Oct 29, 2026
820be59
test/rocm: GDN verify slots, no host sync in a verify step, captured …
merceod Oct 29, 2026
7bae2ff
spec decode: address every draft pass at once, one rotary table for a…
merceod Oct 29, 2026
02d8120
qwen3_5: non-speculative default on the class; docs for speculative d…
merceod Oct 29, 2026
4bc6aed
docs: speculative Qwen3.5 deployments
merceod Oct 29, 2026
d01147e
spec decode: sampled verification (top-k, top-p, temperature) as a se…
merceod Oct 29, 2026
2abd924
test/rocm: import KVPlanState from the manager
merceod Oct 29, 2026
d6b6884
spec decode: sample at temperature only; ROCm cannot capture the sort…
merceod Oct 29, 2026
048c42b
spec decode: sampled verification through the sampler's captured top-…
merceod Oct 29, 2026
bb47c82
spec decode: a draft pass runs its post-attention work and lm_head on…
merceod Oct 29, 2026
6657da7
benchmark/spec_decode: a served distribution test for sampled text
merceod Oct 29, 2026
bd88186
docs: speculative sampling takes top-k and top-p
merceod Oct 29, 2026
ac4cb5e
docs: draft passes read one slot a row
merceod Oct 29, 2026
fc10ccc
mstar cache: pack and unpack compile-cache bundles keyed by the stack
merceod Oct 29, 2026
30398b2
Capture configs: compile_dynamic compiles one graph for every bucket
merceod Oct 29, 2026
b858163
docs: compile-cache bundles
merceod Oct 29, 2026
d46e0af
mstar cache pack --kinds: leave a nested Triton dir out of Inductor's…
merceod Oct 29, 2026
769554b
tests: the platform-dispatch capture stub has compile_dynamic
merceod Oct 29, 2026
bea9c12
mstar cache pack: merge MIOpen's databases with the bundle's copy
merceod Oct 29, 2026
b573d92
docs: compile-cache bundles merge MIOpen's databases
merceod Oct 29, 2026
60e9ebe
gdn: a verify step runs FlyDSL's draft-chain kernel, nothing packed o…
merceod Oct 31, 2026
7dfed55
platforms: a bucket of a few tokens a row can compile as decode, by i…
merceod Oct 31, 2026
d972542
spec decode: one kernel per bookkeeping phase, draft passes one row a…
merceod Oct 31, 2026
5a92103
benchmark/spec_decode: what cheaper draft heads cost in acceptance
merceod Oct 31, 2026
7d50f4c
spec decode: tests and docs for one-row draft passes
merceod Oct 31, 2026
2718fed
test/rocm: one query a row against its slot of a verify row
merceod Oct 31, 2026
a940b42
benchmark/spec_decode: variants over a further prompt set
merceod Oct 31, 2026
4bb15a9
kernels: MI325X decode configs for 3, 6 and 12 rows and the MTP fc
merceod Oct 31, 2026
d13370a
spec decode: draft_head: fp8, an fp8 copy of the vocabulary head for …
merceod Oct 31, 2026
8dc2466
spec decode: the fp8 head quantises inside its op, not in a compiled …
merceod Oct 31, 2026
297acc7
spec decode: the fp8 head runs at any row count, hipBLASLt past two rows
merceod Oct 31, 2026
34cc57d
qwen3_5: name the head a draft pass reads
merceod Oct 31, 2026
5e29c0e
spec decode: the fp8 head's GEMM stays out of autocast
merceod Oct 31, 2026
8f0b6f2
docs: the draft head option
merceod Oct 31, 2026
64ec42b
spec decode: the fp8 head under the host-sync tripwire
merceod Oct 31, 2026
510a998
spec decode: the fp8 head keeps its dtype through the engine's move; …
merceod Oct 31, 2026
ec4c203
spec decode: drop the autocast guard, _scaled_mm has no autocast kernel
merceod Oct 31, 2026
e65494b
spec decode: the draft head's docstring states what was measured
merceod Oct 31, 2026
6e5cca0
spec decode: argmax buffers sized by the bucket, no gate on an ungate…
merceod Oct 31, 2026
ec19862
spec decode: the fp8 head's reference names the format; prologue: dro…
merceod Oct 31, 2026
c60cddf
qwen3_5: the MTP configs draft from the fp8 head
merceod Oct 31, 2026
a458c8a
gdn: a decode-only conv update kernel on ROCm, bit-equal to the gener…
merceod Nov 2, 2026
e52c2c0
attn: pin a captured decode step's AITER variant at 4096 keys, not th…
merceod Nov 2, 2026
262b087
test: stubs carry the pool's write slots; the conv route test no long…
merceod Nov 2, 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
2 changes: 2 additions & 0 deletions benchmark/amd/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ def _qwen3tts(name: str, size: str, runner_model: str, sglang_omni_config: str,
_qwen("4B"),
_qwen("9B"),
_qwen("9B", gpus=2, tp=2, name="qwen3_5_9b_tp2"),
# speculative decoding with the checkpoint's MTP head drafting k tokens (configs/qwen3_5_<size>_mtp<k>.yaml)
*(_qwen(size, name=f"qwen3_5_{size.lower()}_mtp{k}") for size in ("0.8B", "9B") for k in (2, 3)),
Row("kokoro", "tts", "hexgrad/Kokoro-82M", "kokoro"),
Row("orpheus", "tts", "canopylabs/orpheus-3b-0.1-ft", "orpheus", gpus=2),
Row("orpheus_colocated", "tts", "canopylabs/orpheus-3b-0.1-ft", "orpheus"),
Expand Down
2 changes: 2 additions & 0 deletions benchmark/spec_decode/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Speculative decoding measurements that run without a server: the acceptance rate of a draft model against its
target over the benchmark prompt sets, and the cost of a draft token."""
183 changes: 183 additions & 0 deletions benchmark/spec_decode/draft_cost.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
"""What one draft token costs on the GPU: the Qwen3.5 MTP head at batch 1 plus the shared lm_head.

python -m benchmark.spec_decode.draft_cost --model Qwen/Qwen3.5-9B --context 1024

Times, with CUDA events over repeated runs: the lm_head GEMM at 1..6 rows (a verify step's rows at concurrency 1),
each GEMM of the head at one row, and one complete draft step (fc, the full-attention decoder layer over a
fixed-length KV cache, final norm, lm_head, argmax) both eager and replayed from a HIP graph. The graph number is the
launch-free cost a captured engine step would pay; the eager number is what the acceptance harness pays.
"""
from __future__ import annotations

import argparse
import sys

import torch
import torch.nn.functional as F
from transformers.models.qwen3_5.modeling_qwen3_5 import apply_rotary_pos_emb, repeat_kv

from benchmark.spec_decode.qwen3_5_mtp import BaseModel, MtpHead, load_base


def time_fn(fn, iters: int, repeats: int = 3) -> float:
"""Mean milliseconds per call, best of `repeats` blocks of `iters` calls."""
for _ in range(3):
fn()
torch.cuda.synchronize()
best = float("inf")
for _ in range(repeats):
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(iters):
fn()
end.record()
torch.cuda.synchronize()
best = min(best, start.elapsed_time(end) / iters)
return best


def gemm_row(name: str, weight: torch.Tensor, rows: int, iters: int) -> dict:
x = torch.randn(rows, weight.shape[1], device=weight.device, dtype=weight.dtype)
ms = time_fn(lambda: F.linear(x, weight), iters)
byte_count = weight.numel() * weight.element_size()
return {"name": name, "rows": rows, "shape": list(weight.shape), "ms": ms, "mb": byte_count / 1e6,
"tb_s": byte_count / (ms * 1e-3) / 1e12}


class DraftStep:
"""One draft token through the head with static buffers, so it can be captured into a HIP graph."""

def __init__(self, head: MtpHead, base: BaseModel, context: int):
self.head, self.base = head, base
layer = head.layers[0]
attn = layer.self_attn
self.attn, self.layer = attn, layer
cfg = base.config
device, dtype = base.device, base.dtype
self.kv_heads = cfg.num_key_value_heads
self.groups = attn.num_key_value_groups
self.head_dim = attn.head_dim
hidden = cfg.hidden_size
self.h = torch.randn(1, hidden, device=device, dtype=dtype)
self.tok = torch.tensor([1], device=device)
# Keys and values of `context` earlier positions; the new token lands in the last slot.
self.K = torch.randn(1, self.kv_heads, context, self.head_dim, device=device, dtype=dtype)
self.V = torch.randn(1, self.kv_heads, context, self.head_dim, device=device, dtype=dtype)
pos = torch.tensor([[[context - 1]]], device=device).expand(3, 1, 1)
self.cos, self.sin = base.rotary(self.h[None], pos)
self.gqa = True
self.out = None

def attention(self, q, k, v):
if self.gqa:
try:
return F.scaled_dot_product_attention(q, k, v, scale=self.attn.scaling, enable_gqa=True)
except (RuntimeError, TypeError):
self.gqa = False
return F.scaled_dot_product_attention(
q, repeat_kv(k, self.groups), repeat_kv(v, self.groups), scale=self.attn.scaling
)

def layer_forward(self) -> torch.Tensor:
head, layer, attn = self.head, self.layer, self.attn
emb = head.pre_fc_norm_embedding(self.base.embed(self.tok))
x = head.fc(torch.cat((emb, head.pre_fc_norm_hidden(self.h)), dim=-1))[None] # (1, 1, hidden)
residual = x
y = layer.input_layernorm(x)
q, gate = torch.chunk(attn.q_proj(y).view(1, 1, -1, self.head_dim * 2), 2, dim=-1)
gate = gate.reshape(1, 1, -1)
q = attn.q_norm(q.reshape(1, 1, -1, self.head_dim)).transpose(1, 2)
k = attn.k_norm(attn.k_proj(y).view(1, 1, -1, self.head_dim)).transpose(1, 2)
v = attn.v_proj(y).view(1, 1, -1, self.head_dim).transpose(1, 2)
q, k = apply_rotary_pos_emb(q, k, self.cos, self.sin)
self.K[:, :, -1:] = k
self.V[:, :, -1:] = v
o = self.attention(q, self.K, self.V).transpose(1, 2).reshape(1, 1, -1)
x = residual + attn.o_proj(o * torch.sigmoid(gate))
residual = x
x = residual + layer.mlp(layer.post_attention_layernorm(x))
return head.norm(x)[0, -1]

def full(self) -> None:
self.out = self.base.lm_head(self.layer_forward()).argmax()

def layer_only(self) -> None:
self.out = self.layer_forward()


def capture(fn) -> torch.cuda.CUDAGraph:
stream = torch.cuda.Stream()
stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(stream):
for _ in range(3):
fn()
torch.cuda.current_stream().wait_stream(stream)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
fn()
return graph


def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model", default="Qwen/Qwen3.5-9B")
ap.add_argument("--device", default="cuda")
ap.add_argument("--context", type=int, nargs="+", default=[256, 1024])
ap.add_argument("--iters", type=int, default=50)
args = ap.parse_args(argv)

base, snapshot = load_base(args.model, args.device)
head = MtpHead.from_snapshot(snapshot, base.config, base.device, base.dtype)
print(f"{args.model} on {torch.cuda.get_device_name(0)}, torch {torch.__version__}, {base.dtype}")
lm_head = base.lm_head.weight
mb = lambda w: w.numel() * w.element_size() / 1e6 # noqa: E731
layer_bytes = sum(mb(p) for p in head.parameters())
print(f"mtp head weights {layer_bytes:.1f} MB, lm_head {mb(lm_head):.1f} MB")

with torch.inference_mode():
print("\nlm_head GEMM (bf16, torch F.linear):")
for rows in range(1, 7):
r = gemm_row("lm_head", lm_head, rows, args.iters)
print(f" rows={rows}: {r['ms']:.3f} ms {r['tb_s']:.2f} TB/s")

print("\nmtp head GEMMs at one row:")
layer = head.layers[0]
attn, mlp = layer.self_attn, layer.mlp
weights = [
("fc", head.fc.weight), ("q_proj", attn.q_proj.weight), ("k_proj", attn.k_proj.weight),
("v_proj", attn.v_proj.weight), ("o_proj", attn.o_proj.weight), ("gate_proj", mlp.gate_proj.weight),
("up_proj", mlp.up_proj.weight), ("down_proj", mlp.down_proj.weight),
]
total_ms = 0.0
for name, w in weights:
r = gemm_row(name, w, 1, args.iters)
total_ms += r["ms"]
print(f" {name:10s} {str(r['shape']):16s} {r['ms']:.3f} ms {r['mb']:7.1f} MB {r['tb_s']:.2f} TB/s")
lm = gemm_row("lm_head", lm_head, 1, args.iters)
print(f" GEMM sum: head {total_ms:.3f} ms + lm_head {lm['ms']:.3f} ms = {total_ms + lm['ms']:.3f} ms")

for context in args.context:
step = DraftStep(head, base, context)
eager_full = time_fn(step.full, args.iters)
eager_layer = time_fn(step.layer_only, args.iters)
print(
f"\ndraft step, context {context} (gqa sdpa={step.gqa}):\n"
f" eager: full {eager_full:.3f} ms, layer only {eager_layer:.3f} ms"
)
try:
g_full = capture(step.full)
graph_full = time_fn(g_full.replay, args.iters)
g_layer = capture(step.layer_only)
graph_layer = time_fn(g_layer.replay, args.iters)
except RuntimeError as exc:
print(f" graph capture failed: {exc!r}")
continue
print(
f" graph: full {graph_full:.3f} ms, layer only {graph_layer:.3f} ms, "
f"lm_head share {(graph_full - graph_layer) / graph_full * 100:.0f}%"
)
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading