Skip to content

Commit 6cbd32d

Browse files
K3 Stage 2: DFlash speculative-decoding acceptance harness (CUDA)
scripts/research/k3_dflash_specdecode_eval.py drives the native DFlashProposer against the real Gemma-4 26B-A4B verifier and measures acceptance length / acceptance rate (the metric behind DFlash speedup; ref ~7.7 / ~44%). Self-speculative loop: verifier forward -> aux hidden at last pos -> DFlash draft block -> verifier forward -> greedy accept; confirms lossless vs greedy AR. Requires transformers>=5 (gemma4) + HF_TOKEN. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 07f8193 commit 6cbd32d

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
"""K3 Stage 2 — native DFlash speculative-decoding acceptance eval (CUDA).
2+
3+
Drives the engine's native DFlash drafter (`inference_engine/v04/
4+
dflash_drafter.py`) against the real Gemma-4 26B-A4B verifier and measures
5+
the speculative-decoding **acceptance length / acceptance rate** — the
6+
metric that determines DFlash speedup (reference: ~7.7 length / ~44 % on
7+
HumanEval, vLLM PR #41703).
8+
9+
Self-speculative loop (no KV cache; measures acceptance correctness, not
10+
wall-clock):
11+
12+
1. verifier forward over `committed` → aux hidden at the last position
13+
(layers `aux_layer_ids` = target_layer_ids+1) + next-token logits.
14+
2. DFlashProposer.propose_block(committed, L, steps) → draft block.
15+
3. verifier forward over `committed + draft` → greedy-accept the longest
16+
prefix where the verifier's argmax matches the draft.
17+
4. commit accepted (+1 bonus/correction token), repeat.
18+
19+
Reports per-prompt and aggregate acceptance length/rate, and confirms the
20+
spec output equals greedy AR (lossless). Requires transformers >= 5
21+
(gemma4) and HF_TOKEN.
22+
23+
Usage:
24+
HF_TOKEN=hf_xxx PYTHONPATH=.:sdks/python python scripts/research/k3_dflash_specdecode_eval.py \
25+
--max-new-tokens 48 --block-size 16 --num-steps 8 --n-prompts 4 \
26+
--output results/research/k3_dflash_specdecode_<stamp>.json
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import argparse
32+
import json
33+
import math
34+
import sys
35+
import time
36+
from pathlib import Path
37+
from typing import List
38+
39+
import torch
40+
41+
from inference_engine.v04.dflash_drafter import (
42+
AuxHiddenProvider,
43+
DFlashDrafter,
44+
DFlashProposer,
45+
)
46+
47+
48+
PROMPTS = [
49+
"Write a Python function that returns the n-th Fibonacci number.",
50+
"Explain in two sentences why the sky is blue.",
51+
"List three prime numbers greater than 100.",
52+
"Summarize the plot of Romeo and Juliet in one sentence.",
53+
"What is the capital of Australia, and why is it not Sydney?",
54+
"Write a haiku about speculative decoding.",
55+
]
56+
57+
58+
class VerifierAuxProvider(AuxHiddenProvider):
59+
"""Wraps the Gemma-4 verifier: runs a forward over `committed`, caches
60+
the aux-layer hidden states + next-token logits, and serves the aux
61+
hidden at the last position to the drafter."""
62+
63+
def __init__(self, model, aux_layer_ids, device):
64+
self.model = model
65+
self.aux_layer_ids = aux_layer_ids
66+
self.device = device
67+
self._cache_key = None
68+
self._aux_last = None
69+
self.next_logits = None
70+
self.forward_calls = 0
71+
72+
@torch.no_grad()
73+
def _run(self, ids: List[int]):
74+
key = tuple(ids)
75+
if key == self._cache_key:
76+
return
77+
inp = torch.tensor([ids], dtype=torch.long, device=self.device)
78+
out = self.model(input_ids=inp, use_cache=False, output_hidden_states=True)
79+
self.forward_calls += 1
80+
hs = out.hidden_states # tuple len = num_layers+1 (0 = embeddings)
81+
self._aux_last = [hs[a][:, -1:, :].float() for a in self.aux_layer_ids]
82+
self.next_logits = out.logits[0, -1, :].float()
83+
self._cache_key = key
84+
85+
def aux_hidden_last(self, committed_token_ids: List[int]) -> List[torch.Tensor]:
86+
self._run(committed_token_ids)
87+
return self._aux_last
88+
89+
90+
def _build_embed_lm_head(model, hidden_size, softcap):
91+
emb = model.get_input_embeddings()
92+
head = model.get_output_embeddings()
93+
scale = math.sqrt(hidden_size)
94+
95+
def embed_fn(ids: torch.Tensor) -> torch.Tensor:
96+
# Gemma scales token embeddings by sqrt(hidden) (PR #41703: DFlash
97+
# draft path applies the target embedding normalization).
98+
return emb(ids).float() * scale
99+
100+
def lm_head_fn(h: torch.Tensor) -> torch.Tensor:
101+
logits = head(h.to(head.weight.dtype)).float()
102+
if softcap is not None:
103+
logits = softcap * torch.tanh(logits / softcap)
104+
return logits
105+
106+
return embed_fn, lm_head_fn
107+
108+
109+
@torch.no_grad()
110+
def verify_block(model, committed: List[int], draft: List[int], device):
111+
"""Return the list of greedily-accepted draft tokens + the verifier's
112+
correction/bonus token, via one forward over committed+draft."""
113+
seq = committed + draft
114+
inp = torch.tensor([seq], dtype=torch.long, device=device)
115+
out = model(input_ids=inp, use_cache=False)
116+
logits = out.logits[0].float() # [C+L, V]
117+
C = len(committed)
118+
accepted = 0
119+
for i in range(len(draft)):
120+
pred = int(torch.argmax(logits[C - 1 + i]).item())
121+
if pred == draft[i]:
122+
accepted += 1
123+
else:
124+
break
125+
correction = int(torch.argmax(logits[C - 1 + accepted]).item())
126+
return accepted, correction
127+
128+
129+
@torch.no_grad()
130+
def greedy_ar(model, prompt_ids: List[int], max_new_tokens: int, device, eos_ids):
131+
cur = list(prompt_ids)
132+
forwards = 0
133+
for _ in range(max_new_tokens):
134+
inp = torch.tensor([cur], dtype=torch.long, device=device)
135+
out = model(input_ids=inp, use_cache=False)
136+
forwards += 1
137+
nxt = int(torch.argmax(out.logits[0, -1]).item())
138+
cur.append(nxt)
139+
if nxt in eos_ids:
140+
break
141+
return cur[len(prompt_ids):], forwards
142+
143+
144+
def main() -> int:
145+
ap = argparse.ArgumentParser(description=__doc__)
146+
ap.add_argument("--verifier-id", default="google/gemma-4-26B-A4B-it")
147+
ap.add_argument("--drafter-id", default="z-lab/gemma-4-26B-A4B-it-DFlash")
148+
ap.add_argument("--max-new-tokens", type=int, default=48)
149+
ap.add_argument("--block-size", type=int, default=16)
150+
ap.add_argument("--num-steps", type=int, default=8)
151+
ap.add_argument("--n-prompts", type=int, default=4)
152+
ap.add_argument("--output", default=None)
153+
args = ap.parse_args()
154+
155+
device = torch.device("cuda")
156+
dtype = torch.bfloat16
157+
from transformers import AutoModelForCausalLM, AutoTokenizer
158+
159+
print(f"[k3-sd] loading verifier {args.verifier_id}", file=sys.stderr, flush=True)
160+
tok = AutoTokenizer.from_pretrained(args.verifier_id)
161+
verifier = AutoModelForCausalLM.from_pretrained(
162+
args.verifier_id, dtype=dtype, attn_implementation="sdpa", device_map="auto",
163+
).eval()
164+
print(f"[k3-sd] loading drafter {args.drafter_id}", file=sys.stderr, flush=True)
165+
drafter = DFlashDrafter.from_pretrained(args.drafter_id, dtype=dtype).to(device).eval()
166+
cfg = drafter.cfg
167+
hidden = cfg.hidden_size
168+
softcap = cfg.final_logit_softcapping
169+
embed_fn, lm_head_fn = _build_embed_lm_head(verifier, hidden, softcap)
170+
provider = VerifierAuxProvider(verifier, cfg.aux_layer_ids, device)
171+
proposer = DFlashProposer(drafter, provider, embed_fn, lm_head_fn)
172+
173+
eos_ids = set(
174+
x for x in [tok.eos_token_id, getattr(tok, "eot_token_id", None)] if x is not None
175+
)
176+
177+
per_prompt = []
178+
tot_accepted = tot_drafted = tot_blocks = 0
179+
tot_spec_forwards = tot_ar_forwards = 0
180+
lossless = True
181+
182+
for pi in range(min(args.n_prompts, len(PROMPTS))):
183+
prompt = PROMPTS[pi]
184+
msgs = [{"role": "user", "content": prompt}]
185+
ids = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=True)
186+
if isinstance(ids, torch.Tensor):
187+
ids = ids[0].tolist()
188+
committed = list(ids)
189+
generated: List[int] = []
190+
blk_accepts = []
191+
provider.forward_calls = 0
192+
spec_forwards_before = 0
193+
t0 = time.perf_counter()
194+
while len(generated) < args.max_new_tokens:
195+
L = min(args.block_size, args.max_new_tokens - len(generated))
196+
proposal = proposer.propose_block(committed, L, args.num_steps)
197+
d = proposal.tokens
198+
accepted, correction = verify_block(verifier, committed, d, device)
199+
tot_spec_forwards += 2 # 1 aux/prefill forward + 1 verify forward
200+
committed += d[:accepted]
201+
generated += d[:accepted]
202+
generated.append(correction)
203+
committed.append(correction)
204+
blk_accepts.append(accepted)
205+
tot_accepted += accepted
206+
tot_drafted += L
207+
tot_blocks += 1
208+
if correction in eos_ids:
209+
break
210+
spec_time = time.perf_counter() - t0
211+
spec_out = generated[: args.max_new_tokens]
212+
213+
# AR reference (lossless check + forward count)
214+
ar_out, ar_forwards = greedy_ar(
215+
verifier, ids, len(spec_out), device, eos_ids,
216+
)
217+
tot_ar_forwards += ar_forwards
218+
match = spec_out[: len(ar_out)] == ar_out[: len(spec_out)]
219+
lossless = lossless and match
220+
mean_acc = sum(blk_accepts) / max(len(blk_accepts), 1)
221+
per_prompt.append({
222+
"prompt": prompt,
223+
"blocks": len(blk_accepts),
224+
"block_accepts": blk_accepts,
225+
"mean_accepted_per_block": mean_acc,
226+
"tokens_generated": len(spec_out),
227+
"verifier_forwards_spec": provider.forward_calls,
228+
"lossless_vs_ar": match,
229+
"decoded": tok.decode(spec_out, skip_special_tokens=True)[:200],
230+
})
231+
print(
232+
f"[k3-sd] prompt {pi}: blocks={len(blk_accepts)} "
233+
f"mean_accept={mean_acc:.2f} accepts={blk_accepts} lossless={match}",
234+
file=sys.stderr,
235+
)
236+
237+
acc_rate = tot_accepted / max(tot_drafted, 1)
238+
# acceptance length = accepted + 1 bonus per block, the standard metric
239+
acc_length = (tot_accepted + tot_blocks) / max(tot_blocks, 1)
240+
report = {
241+
"schema_version": 1,
242+
"kind": "k3_dflash_specdecode_acceptance",
243+
"config": {
244+
"verifier_id": args.verifier_id,
245+
"drafter_id": args.drafter_id,
246+
"block_size": args.block_size,
247+
"num_steps": args.num_steps,
248+
"max_new_tokens": args.max_new_tokens,
249+
"n_prompts": min(args.n_prompts, len(PROMPTS)),
250+
"aux_layer_ids": list(cfg.aux_layer_ids),
251+
},
252+
"aggregate": {
253+
"acceptance_rate": acc_rate,
254+
"acceptance_length": acc_length,
255+
"total_accepted": tot_accepted,
256+
"total_drafted": tot_drafted,
257+
"total_blocks": tot_blocks,
258+
"lossless_vs_ar": lossless,
259+
"reference_humaneval": {"acceptance_length": 7.7, "acceptance_rate": 0.447},
260+
},
261+
"per_prompt": per_prompt,
262+
}
263+
out_path = Path(args.output) if args.output else Path(
264+
f"results/research/k3_dflash_specdecode_{int(time.time())}.json"
265+
)
266+
out_path.parent.mkdir(parents=True, exist_ok=True)
267+
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
268+
print(
269+
f"[k3-sd] AGGREGATE acceptance_rate={acc_rate:.3f} "
270+
f"acceptance_length={acc_length:.2f} lossless={lossless} "
271+
f"(ref ~0.447 / ~7.7) -> {out_path}",
272+
file=sys.stderr,
273+
)
274+
return 0
275+
276+
277+
if __name__ == "__main__":
278+
sys.exit(main())

0 commit comments

Comments
 (0)