Views
No views yet
t+2 (one step beyond the backbone's own t+1 prediction)t+3, conditioned on Head 0's own predictionMTPModule) is a small transformer block that fuses two signals:fused = concat(LayerNorm(prev_hidden), LayerNorm(shifted_embedding))
hidden = Linear(fused)
hidden = CausalTransformerEncoderLayer(hidden)
logits = LMHead(LayerNorm(hidden))lm_head weight is initialized as a copy of the backbone's own lm_head weight, giving
the heads a strong prior toward the backbone's output distribution before any training happens. Both
the hidden-state branch and the embedding branch are independently normalized (via separate
LayerNorm layers) before being concatenated and projected — normalizing both branches, rather than
just one, keeps their scales comparable going into the fusion projection.lm_head unfrozen; all earlier layers frozen). Each head was trained with a
standard next-token cross-entropy loss against the ground-truth token at its target offset
(teacher-forced embeddings), with the backbone's own next-token loss included in the same joint
objective so the unfrozen backbone layers keep adapting alongside the new heads. This stage
establishes the heads' basic capacity to predict multiple tokens ahead before any distillation signal
is introduced.1def distillation_kl_loss(student_logits, teacher_logits, temperature):
2 student_log_probs = F.log_softmax(student_logits.float() / temperature, dim=-1)
3 teacher_probs = F.softmax(teacher_logits.float().detach() / temperature, dim=-1)
4 kl = F.kl_div(student_log_probs, teacher_probs, reduction="batchmean", log_target=False)
5 return kl * (temperature ** 2)
6
7loss = (
8 main_loss
9 + kl_weight_h0 * kl_h0 + ce_weight_h0 * loss_h0_ce
10 + kl_weight_h1 * kl_h1 + ce_weight_h1 * loss_h1_ce
11)k (shift = k+1) at local position i predicts the token at
global position i+shift+1. Since main_logits[:, j, :] predicts token j+1, setting j+1 = i+shift+1
gives j = i+shift — so the teacher slice for head k is main_logits[:, shift:-1, :], length-aligned
to match that head's own logits.float16 (found empirically faster than bfloat16 on this hardware —
see note below) with KV-cached, single-parallel-verify-per-round self-speculative decoding.do_sample=False)| Metric | Value |
|---|---|
| Vanilla Qwen2.5-1.5B speed | 23.44 tok/s |
| MTP speculative speed | 51.49 tok/s |
| Speedup | 2.20x |
| Head-0 accepted (either alone or with Head-1) | 22.7% |
| Both heads accepted | 3.3% |
| Complete rejections (resampled from base) | 75.7% |
| Avg. tokens committed per round | 2.21 |
do_sample=True, temperature=0.5)| Metric | Value |
|---|---|
| Vanilla Qwen2.5-1.5B speed | 23.43 tok/s |
| MTP speculative speed | 51.56 tok/s |
| Speedup | 2.20x |
| Head-0 accepted (either alone or with Head-1) | 23.3% |
| Both heads accepted | 6.9% |
| Complete rejections (resampled from base) | 75.6% |
| Avg. tokens committed per round | 2.22 |
min(1, p_target(x)/p_draft(x)), and on rejection the next token is drawn from the
normalized residual distribution max(0, p_target - p_draft). This is provably distributionally
equivalent to sampling directly from the backbone at the same temperature, so sampling-mode speedup
comes "for free" without altering the output distribution.Hardware note:bfloat16, despite being architecturally supported, was measurably slower thanfloat16for this workload on the T4 GPU used for benchmarking (0.68x vs 2.15x+ speedup under otherwise identical settings) — a known T4-specific throughput characteristic, not a correctness issue. If deploying on newer datacenter GPUs (A100/H100), re-benchmark both dtypes before choosing.
1from transformers import AutoModelForCausalLM, AutoTokenizer
2import torch
3
4model = AutoModelForCausalLM.from_pretrained(
5 "Harish241412/Qwen-2.5-1.5b-mtp-speculative",
6 torch_dtype=torch.float16, # benchmark this vs bfloat16 on your own hardware
7 device_map="cuda:0",
8 trust_remote_code=True, # required: this repo ships a custom architecture
9)
10tokenizer = AutoTokenizer.from_pretrained("Harish241412/Qwen-2.5-1.5b-mtp-speculative")
11
12inputs = tokenizer("def calculate_factorial(n):", return_tensors="pt").input_ids.to("cuda:0")
13
14# Greedy self-speculative decoding
15outputs, metrics = model.generate_speculative(inputs, max_new_tokens=80, do_sample=False)
16
17# Sampling self-speculative decoding
18outputs, metrics = model.generate_speculative(
19 inputs, max_new_tokens=80, do_sample=True, temperature=0.5
20)
21
22print(tokenizer.decode(outputs[0], skip_special_tokens=True))
23print(metrics) # {'total_rounds': ..., 'both_heads_accepts': ..., ...}generate_speculative currently supports batch size 1 only — per-sequence KV cache management for
batched speculative decoding (as done via paged attention in serving stacks like vLLM/TGI) is not
implemented in this reference version.generate_speculative.