Skip to content

Commit 965b2a4

Browse files
K3 Stage 2: alignment-training path for the DFlash drafter
Treat the residual inference-fidelity gap as an f_θ-style alignment task (ADR 0008 §11): freeze the verifier + drafter backbone, train the projection fc + hidden_norm + norm so the drafter's mask-position logits predict the verifier's greedy next tokens. * drafter: add grad-enabled draft_logits (remove no_grad from the context-KV path); draft_block now wraps it under no_grad. * scripts/research/k3_dflash_alignment_train.py: builds verifier-greedy sequences, caches aux for every prefix via one causal forward, trains {fc,hidden_norm,norm} (or full) on CE vs verifier targets, saves the aligned drafter state. * eval harness: --drafter-state loads an aligned checkpoint for re-test. 20 drafter tests pass. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent c71f053 commit 965b2a4

3 files changed

Lines changed: 262 additions & 6 deletions

File tree

inference_engine/v04/dflash_drafter.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,6 @@ def __init__(self, cfg: DFlashConfig) -> None:
226226
self.k_norm = _RMSNorm(self.hd, cfg.rms_norm_eps)
227227
self.scale = self.hd ** -0.5
228228

229-
@torch.no_grad()
230229
def project_context_kv(
231230
self, ctx_normed: torch.Tensor, ctx_positions: torch.Tensor,
232231
):
@@ -355,7 +354,6 @@ def combine_aux(self, aux_hidden_states: Sequence[torch.Tensor]) -> torch.Tensor
355354
return self.fc(cat.to(self.fc.weight.dtype))
356355

357356
# -- context K/V precompute (from target hidden) -----------------------
358-
@torch.no_grad()
359357
def precompute_context_kv(
360358
self, context_states: torch.Tensor, ctx_positions: torch.Tensor,
361359
):
@@ -463,6 +461,30 @@ def draft_block(
463461
the sampled (drafted) tokens are the MASK positions
464462
(``query_off > 0``), and the bonus query sits at ``last_pos+1 == C``.
465463
"""
464+
logits = self.draft_logits(
465+
aux_hidden_context, bonus_token_id, embed_fn, lm_head_fn,
466+
block_size=block_size,
467+
).clone()
468+
logits[..., self.cfg.mask_token_id] = float("-inf") # never draft the sentinel
469+
return torch.argmax(logits[0], dim=-1).tolist()
470+
471+
def draft_logits(
472+
self,
473+
aux_hidden_context: Sequence[torch.Tensor],
474+
bonus_token_id: int,
475+
embed_fn: Callable[[torch.Tensor], torch.Tensor],
476+
lm_head_fn: Callable[[torch.Tensor], torch.Tensor],
477+
*,
478+
block_size: int,
479+
) -> torch.Tensor:
480+
"""Grad-enabled forward → mask-position logits ``[1, block_size, vocab]``.
481+
482+
Same single non-causal pass as :meth:`draft_block` but differentiable
483+
(no ``no_grad`` wrapper), so the projection / norms can be trained to
484+
align the drafter to a verifier (K3 ``f_θ`` alignment; see
485+
``docs/design/k3-f-theta-training-pipeline.md``). Gradients flow into
486+
whichever drafter params are left trainable.
487+
"""
466488
if block_size <= 0:
467489
raise ValueError("block_size must be positive")
468490
cfg = self.cfg
@@ -482,10 +504,8 @@ def draft_block(
482504
h = embed_fn(query_ids).to(self.fc.weight.dtype) # [1, 1+block_size, hidden]
483505
h = self._run_layers(h, query_positions, ctx_kv) # [1, 1+block_size, hidden]
484506
logits = lm_head_fn(h) # [1, 1+block_size, vocab]
485-
logits[..., cfg.mask_token_id] = float("-inf") # never draft the sentinel
486-
# Drafts are the MASK positions query_off=1..block_size (the bonus at
487-
# query_off=0 is the known token and is not sampled).
488-
return torch.argmax(logits[0, 1:1 + block_size], dim=-1).tolist()
507+
# Mask positions query_off=1..block_size are the drafts.
508+
return logits[:, 1:1 + block_size, :]
489509

490510

491511
# ===========================================================================
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""K3 alignment training — align the native DFlash drafter to the Gemma-4
2+
verifier (treats the residual inference-fidelity gap as an f_θ-style
3+
alignment task; ADR 0008 §11, docs/design/k3-f-theta-training-pipeline.md).
4+
5+
Rather than perfectly reconstructing vLLM's aux-hidden-tap semantics, we
6+
freeze the verifier (and, by default, the drafter's Qwen3 backbone) and
7+
train the projection ``fc`` + ``hidden_norm`` + ``norm`` so the drafter's
8+
mask-position logits predict the verifier's greedy next tokens. The trained
9+
drafter state is saved and re-evaluated by k3_dflash_specdecode_eval.py.
10+
11+
Efficiency: the verifier is causal, so ONE full-sequence forward yields the
12+
aux hidden for every prefix; training is then drafter-only per step.
13+
14+
Usage:
15+
HF_TOKEN=hf_xxx PYTHONPATH=.:sdks/python python scripts/research/k3_dflash_alignment_train.py \
16+
--steps 600 --lr 1e-4 --block-size 16 --n-prompts 8 --gen-len 192 \
17+
--train-scope fc_norms --save results/research/dflash_aligned_fcnorms.pt
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import json
24+
import math
25+
import random
26+
import sys
27+
import time
28+
from pathlib import Path
29+
from typing import List
30+
31+
import torch
32+
import torch.nn.functional as F
33+
34+
from inference_engine.v04.dflash_drafter import DFlashDrafter
35+
36+
37+
PROMPTS = [
38+
"Write a Python function that returns the n-th Fibonacci number.",
39+
"Explain in two sentences why the sky is blue.",
40+
"List three prime numbers greater than 100.",
41+
"Summarize the plot of Romeo and Juliet in one sentence.",
42+
"What is the capital of Australia, and why is it not Sydney?",
43+
"Write a haiku about speculative decoding.",
44+
"Describe how a hash map works in one paragraph.",
45+
"Give three tips for writing clear commit messages.",
46+
"What causes the seasons on Earth?",
47+
"Write a short limerick about a cat who loves GPUs.",
48+
]
49+
50+
51+
def _embed_lm_head(model, hidden_size, softcap):
52+
emb = model.get_input_embeddings()
53+
head = model.get_output_embeddings()
54+
scale = math.sqrt(hidden_size)
55+
56+
def embed_fn(ids):
57+
return emb(ids).float() * scale
58+
59+
def lm_head_fn(h):
60+
logits = head(h.to(head.weight.dtype)).float()
61+
if softcap is not None:
62+
logits = softcap * torch.tanh(logits / softcap)
63+
return logits
64+
65+
return embed_fn, lm_head_fn
66+
67+
68+
@torch.no_grad()
69+
def greedy_seq(model, prompt_ids, gen_len, device, eos_ids):
70+
cur = list(prompt_ids)
71+
for _ in range(gen_len):
72+
inp = torch.tensor([cur], dtype=torch.long, device=device)
73+
out = model(input_ids=inp, use_cache=False)
74+
nxt = int(torch.argmax(out.logits[0, -1]).item())
75+
cur.append(nxt)
76+
if nxt in eos_ids:
77+
break
78+
return cur
79+
80+
81+
@torch.no_grad()
82+
def cache_aux(model, ids, aux_layer_ids, device):
83+
inp = torch.tensor([ids], dtype=torch.long, device=device)
84+
out = model(input_ids=inp, use_cache=False, output_hidden_states=True)
85+
hs = out.hidden_states
86+
# [1, T, hidden] per aux layer, kept on GPU in fp16 to save memory.
87+
return [hs[a].half() for a in aux_layer_ids]
88+
89+
90+
def main() -> int:
91+
ap = argparse.ArgumentParser(description=__doc__)
92+
ap.add_argument("--verifier-id", default="google/gemma-4-26B-A4B-it")
93+
ap.add_argument("--drafter-id", default="z-lab/gemma-4-26B-A4B-it-DFlash")
94+
ap.add_argument("--steps", type=int, default=600)
95+
ap.add_argument("--lr", type=float, default=1e-4)
96+
ap.add_argument("--block-size", type=int, default=16)
97+
ap.add_argument("--n-prompts", type=int, default=8)
98+
ap.add_argument("--gen-len", type=int, default=192)
99+
ap.add_argument("--prompt-min-ctx", type=int, default=8)
100+
ap.add_argument("--train-scope", choices=["fc_norms", "full"], default="fc_norms")
101+
ap.add_argument("--seed", type=int, default=0)
102+
ap.add_argument("--save", default="results/research/dflash_aligned.pt")
103+
ap.add_argument("--log-every", type=int, default=25)
104+
args = ap.parse_args()
105+
106+
random.seed(args.seed)
107+
torch.manual_seed(args.seed)
108+
device = torch.device("cuda")
109+
dtype = torch.bfloat16
110+
from transformers import AutoModelForCausalLM, AutoTokenizer
111+
112+
print(f"[align] loading verifier {args.verifier_id}", file=sys.stderr, flush=True)
113+
tok = AutoTokenizer.from_pretrained(args.verifier_id)
114+
verifier = AutoModelForCausalLM.from_pretrained(
115+
args.verifier_id, dtype=dtype, attn_implementation="sdpa", device_map="auto",
116+
).eval()
117+
for p in verifier.parameters():
118+
p.requires_grad_(False)
119+
print(f"[align] loading drafter {args.drafter_id}", file=sys.stderr, flush=True)
120+
drafter = DFlashDrafter.from_pretrained(args.drafter_id, dtype=dtype).to(device)
121+
cfg = drafter.cfg
122+
embed_fn, lm_head_fn = _embed_lm_head(verifier, cfg.hidden_size, cfg.final_logit_softcapping)
123+
eos_ids = {x for x in [tok.eos_token_id] if x is not None}
124+
125+
# Trainable surface.
126+
for p in drafter.parameters():
127+
p.requires_grad_(False)
128+
if args.train_scope == "full":
129+
trainable = list(drafter.parameters())
130+
for p in trainable:
131+
p.requires_grad_(True)
132+
else: # fc + hidden_norm + norm (the EAGLE-3 projection/norms)
133+
trainable = (
134+
list(drafter.fc.parameters())
135+
+ list(drafter.hidden_norm.parameters())
136+
+ list(drafter.norm.parameters())
137+
)
138+
for p in trainable:
139+
p.requires_grad_(True)
140+
# Train the projection/norms in fp32 for stable optimisation.
141+
for p in trainable:
142+
p.data = p.data.float()
143+
n_train = sum(p.numel() for p in trainable)
144+
print(f"[align] trainable params ({args.train_scope}): {n_train:,}", file=sys.stderr)
145+
opt = torch.optim.AdamW(trainable, lr=args.lr)
146+
147+
# Build greedy sequences + cache aux for every prefix (causal => one forward).
148+
L = args.block_size
149+
seqs, aux_cache, prompt_lens = [], [], []
150+
for i in range(min(args.n_prompts, len(PROMPTS))):
151+
msgs = [{"role": "user", "content": PROMPTS[i]}]
152+
enc = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=True,
153+
return_tensors="pt")
154+
if hasattr(enc, "keys"):
155+
enc = enc["input_ids"]
156+
pids = enc[0].tolist()
157+
seq = greedy_seq(verifier, pids, args.gen_len, device, eos_ids)
158+
if len(seq) < len(pids) + L + 2:
159+
continue
160+
seqs.append(seq)
161+
aux_cache.append(cache_aux(verifier, seq, cfg.aux_layer_ids, device))
162+
prompt_lens.append(len(pids))
163+
print(f"[align] seq {i}: len={len(seq)} (prompt {len(pids)})", file=sys.stderr)
164+
165+
if not seqs:
166+
print("[align] no usable sequences", file=sys.stderr)
167+
return 1
168+
169+
# Sampleable (seq_idx, C) windows.
170+
windows = []
171+
for si, seq in enumerate(seqs):
172+
for C in range(max(prompt_lens[si], args.prompt_min_ctx), len(seq) - L - 1):
173+
windows.append((si, C))
174+
print(f"[align] {len(windows)} training windows", file=sys.stderr)
175+
176+
drafter.train()
177+
losses, matches = [], []
178+
t0 = time.perf_counter()
179+
for step in range(1, args.steps + 1):
180+
si, C = random.choice(windows)
181+
seq = seqs[si]
182+
aux = [a[:, :C, :].float() for a in aux_cache[si]] # [1, C, hidden]
183+
bonus = seq[C]
184+
targets = torch.tensor(seq[C + 1 : C + 1 + L], dtype=torch.long, device=device)
185+
logits = drafter.draft_logits(aux, bonus, embed_fn, lm_head_fn, block_size=L)
186+
loss = F.cross_entropy(logits[0].float(), targets)
187+
opt.zero_grad()
188+
loss.backward()
189+
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
190+
opt.step()
191+
losses.append(float(loss.item()))
192+
with torch.no_grad():
193+
pred = torch.argmax(logits[0], dim=-1)
194+
matches.append(float((pred == targets).float().mean().item()))
195+
if step % args.log_every == 0:
196+
print(
197+
f"[align] step={step} loss={sum(losses[-args.log_every:])/args.log_every:.4f} "
198+
f"match={sum(matches[-args.log_every:])/args.log_every:.3f}",
199+
file=sys.stderr, flush=True,
200+
)
201+
202+
elapsed = time.perf_counter() - t0
203+
# Save the (fp32-trained) params cast back to the model dtype.
204+
drafter.eval()
205+
state = {k: v.detach().to(dtype).cpu() for k, v in drafter.state_dict().items()}
206+
Path(args.save).parent.mkdir(parents=True, exist_ok=True)
207+
torch.save(state, args.save)
208+
report = {
209+
"kind": "k3_dflash_alignment_train",
210+
"config": vars(args),
211+
"trainable_params": n_train,
212+
"n_windows": len(windows),
213+
"final_loss": sum(losses[-args.log_every:]) / max(len(losses[-args.log_every:]), 1),
214+
"final_train_match": sum(matches[-args.log_every:]) / max(len(matches[-args.log_every:]), 1),
215+
"elapsed_s": elapsed,
216+
}
217+
Path(args.save).with_suffix(".json").write_text(json.dumps(report, indent=2))
218+
print(
219+
f"[align] DONE in {elapsed:.0f}s; final loss={report['final_loss']:.4f} "
220+
f"train_match={report['final_train_match']:.3f}; saved -> {args.save}",
221+
file=sys.stderr,
222+
)
223+
return 0
224+
225+
226+
if __name__ == "__main__":
227+
sys.exit(main())

scripts/research/k3_dflash_specdecode_eval.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ def main() -> int:
140140
ap.add_argument("--block-size", type=int, default=16)
141141
ap.add_argument("--num-steps", type=int, default=8)
142142
ap.add_argument("--n-prompts", type=int, default=4)
143+
ap.add_argument("--drafter-state", default=None,
144+
help="optional .pt state_dict to load over the drafter "
145+
"(e.g. an alignment-trained checkpoint).")
143146
ap.add_argument("--output", default=None)
144147
args = ap.parse_args()
145148

@@ -154,6 +157,12 @@ def main() -> int:
154157
).eval()
155158
print(f"[k3-sd] loading drafter {args.drafter_id}", file=sys.stderr, flush=True)
156159
drafter = DFlashDrafter.from_pretrained(args.drafter_id, dtype=dtype).to(device).eval()
160+
if args.drafter_state:
161+
sd = torch.load(args.drafter_state, map_location=device)
162+
drafter.load_state_dict({k: v.to(dtype) for k, v in sd.items()})
163+
drafter.eval()
164+
print(f"[k3-sd] loaded aligned drafter state from {args.drafter_state}",
165+
file=sys.stderr)
157166
cfg = drafter.cfg
158167
hidden = cfg.hidden_size
159168
softcap = cfg.final_logit_softcapping

0 commit comments

Comments
 (0)