Skip to content

Commit ab79f56

Browse files
committed
feat: anchored Eagle3 training
Add training.eagle3_num_anchors. When set, the TTT unroll is evaluated at N sampled anchor positions per sample instead of at every supervised position, so each depth costs O(N) rather than O(sequence length). Off by default; the dense Eagle3 path is untouched. The draft is a single layer, so its depth-0 keys and values are a pointwise projection of the layer input and can be built for the whole sequence without attention. Only the queries are gathered. KV is laid out as [context | chain depth 1 | ...] with N slots per depth; an anchor sees the context up to and including its own position plus its own slot in each chain block, and anchors never see each other. test_context_rows_match_the_dense_ eagle3_mask asserts a row equals the dense Eagle3 row for that position. Masking is a materialised [B, 1, N, KV] boolean passed to scaled_dot_product_attention. Anchored attention is small enough that a BlockMask costs more to build than the density it removes: at S=4096 with 4 depths the build alone is 2.1ms against 1.2ms for the whole SDPA path. Anchor sampling and the per-depth mask use the target position mask when the draft vocabulary is pruned, matching the dense path -- those positions have no representable argmax, so training on them fits a renormalized target the draft cannot emit. Anchoring rejects drafts whose attention has no k_proj/v_proj (MLA projects through kv_a_proj_with_mqa/kv_b_proj and needs its own context-KV path). Anchors are sampled with a spacing cap. training.eagle3_anchor_max_gap is how many supervised positions may sit between neighbours, so 0 puts them side by side. It matters because anchor a supervises token a+d at depth d: a token is seen at depth d only if the anchor d positions behind it was also picked, so anchors g apart leave each token ttt_length/(g+1) of its depths, and once g reaches ttt_length every token is pinned to a single depth. None (the default) means block_size, which spreads anchors as evenly as the budget allows. Gaps count supervised positions rather than raw tokens, so no slot is spent on an unsupervised region and anchors stay token-adjacent inside a span. Qwen3-8B, 10k PerfectBlend, 5 epochs, N=512: max_gap=0 reaches the dense simulated acceptance length of 0.78 at 1.09x the samples/s, where sampling without a cap plateaus at 0.47 from a third of the way in. The gap is not data volume -- both see the same 512 slots and the same tokens -- it is that isolated anchors never show a token more than one depth. The anchor sampler moves out of models/dflash.py to models/ops/anchors.py so both paths share it. DFlash picks up the default cap, which replaces its uniformly random draw with an evenly strided one at the same density: same expected depth coverage, without the isolated anchors that random gaps produce. Attention cost against dense TTT (TTT=4, N=256, one GB300, ms): seq 4096 dense 1.78 anchored 1.12 seq 6144 dense 3.82 anchored 1.10 seq 32768 dense 75.95 anchored 4.24 seq 131072 dense 1134.5 anchored 15.89 test_dflash's test_accuracy_improves trained for 30 steps, which left the accuracy in the noise: it improved on only 9 of 12 seeds, so the assertion turned on which anchors happened to be drawn rather than on whether the model learned. Both the old and new samplers score 9/12 there, so this is not a regression, but changing the sampler shifts the RNG stream and moves which seeds land where. At 100 steps every seed improves by at least 0.18 under either sampler. Claude-Session: https://claude.ai/code/session_01CqPZAxkfxK7vTCbBQniBni
1 parent 5f217f9 commit ab79f56

9 files changed

Lines changed: 1016 additions & 65 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Anchored Eagle3 for Qwen3-8B: the TTT unroll is evaluated at N sampled anchor
2+
# positions per sample rather than at every supervised position.
3+
#
4+
# Pick N against your data. Anchoring only does work when a sample has more
5+
# supervised positions than N; on short conversational data a large N is a no-op.
6+
#
7+
# Usage:
8+
# python -m torchspec.train_entry --config configs/vllm_qwen3_8b_anchored.yaml
9+
10+
model:
11+
target_model_path: Qwen/Qwen3-8B
12+
trust_remote_code: true
13+
14+
dataset:
15+
train_data_path: ../examples/data/sample_conversations.jsonl
16+
eval_data_path: ../examples/data/eval_conversations.jsonl
17+
eval_interval: 250
18+
chat_template: qwen
19+
prompt_key: conversations
20+
21+
training:
22+
# The gathered-query mask has no sdpa path, so anchoring requires flex_attention.
23+
attention_backend: flex_attention
24+
eagle3_num_anchors: 512
25+
micro_batch_size: 1
26+
draft_accumulation_steps: 1
27+
learning_rate: 1e-4
28+
max_concurrent_batches: 1
29+
max_grad_norm: 0.5
30+
max_seq_length: 16384
31+
num_epochs: 1
32+
seed: 42
33+
training_num_gpus_per_node: 2
34+
training_num_nodes: 1
35+
ttt_length: 7
36+
save_per_epoch: true
37+
warmup_ratio: 0.015
38+
39+
inference:
40+
inference_engine_type: vllm
41+
inference_num_gpus: 2
42+
inference_num_gpus_per_engine: 2
43+
inference_num_gpus_per_node: 4
44+
max_sample_pool_size: 64
45+
inference_buffer_threshold: 32
46+
inference_batch_size: 4
47+
vllm:
48+
tp_size: 2
49+
mem_fraction_static: 0.7
50+
extra_args:
51+
max_num_batched_tokens: 8192
52+
53+
mooncake:
54+
master_server_address: null
55+
metadata_server: null
56+
protocol: tcp
57+
global_segment_size: 32GB
58+
local_buffer_size: 4GB
59+
60+
output_dir: ./outputs/qwen3-8b-anchored
61+
cache_dir: ./cache
62+
model_download_dir: null
63+
64+
logging:
65+
report_to: none

0 commit comments

Comments
 (0)