Skip to content

Commit d1b7542

Browse files
orestis-zclaude
andauthored
feat(eagle3): add per-layer FC normalization (--fc-norm) (#670)
## Summary - Add `--fc-norm` flag for per-layer RMSNorm on each auxiliary hidden state before concatenation and FC projection: `concat(Norm(h_a), Norm(h_b), Norm(h_c))` - This matches the Eagle 3.1 paper specification and vLLM's `fc_norm` config field - Differs from `--norm-before-fc` which applies a single norm to the full concatenation: `Norm(concat(h_a, h_b, h_c))` - `norm_before_fc` and `fc_norm` are validated as mutually exclusive in the config (enabling both would cause double-norming) ## Test plan ```bash pytest tests/unit/test_config.py -k "fc_norm" -v pytest tests/integration/models/test_model_forward.py -k "fc_norm" -v ``` - `test_fc_norm` — Eagle3 with fc_norm + norm_output, verifies fc_norms module created and forward + backward pass - `test_peagle_fc_norm` — P-Eagle with fc_norm - `test_eagle3_config_fc_norm_roundtrip` — config serialization roundtrip - `test_eagle3_config_rejects_both_norm_before_fc_and_fc_norm` — mutual exclusivity validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Orestis Zambounis <orestis.zambounis@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent acbe81d commit d1b7542

9 files changed

Lines changed: 139 additions & 9 deletions

File tree

docs/cli/train.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ torchrun --standalone --nproc_per_node=4 scripts/train.py \
136136

137137
- **`--embed-requires-grad` / `--no-embed-requires-grad`** (flag, default: `False`) Whether to train embedding layer weights.
138138

139-
- **`--norm-before-fc` / `--no-norm-before-fc`** (flag, default: `True` for eagle3, `False` otherwise) Apply RMSNorm before the FC layer in the draft path.
139+
- **`--norm-before-fc` / `--no-norm-before-fc`** (flag, default: `True` for eagle3, `False` otherwise) Apply a single RMSNorm to the concatenated auxiliary hidden states before the FC projection (gpt-oss style). See `--fc-norm` for the per-layer alternative from the Eagle 3.1 paper.
140+
141+
- **`--fc-norm`** (flag, default: `False`) Apply per-layer RMSNorm to each auxiliary hidden state before concatenation and FC projection (Eagle 3.1 paper approach).
140142

141143
- **`--norm-output` / `--no-norm-output`** (flag, default: `True` for eagle3, `False` otherwise) Feed post-norm hidden states back across TTT steps to stabilize magnitude drift across speculation depths.
142144

scripts/train.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,10 +916,19 @@ def parse_args():
916916
"--norm-before-fc",
917917
action=argparse.BooleanOptionalAction,
918918
default=None,
919-
help="Apply RMSNorm before the FC layer in the draft path "
919+
help="Apply a single RMSNorm to the concatenated auxiliary hidden states "
920+
"before the FC projection (gpt-oss style). See --fc-norm for the "
921+
"per-layer alternative from the Eagle 3.1 paper. "
920922
"(default: True for eagle3, False otherwise). "
921923
"Disable with --no-norm-before-fc.",
922924
)
925+
parser.add_argument(
926+
"--fc-norm",
927+
action="store_true",
928+
default=False,
929+
help="Apply per-layer RMSNorm to each auxiliary hidden state before "
930+
"concatenation and FC projection (Eagle 3.1 paper approach).",
931+
)
923932
parser.add_argument(
924933
"--norm-output",
925934
action=argparse.BooleanOptionalAction,

src/speculators/convert/eagle/eagle3_converter.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def convert(
4040
validate: bool = True,
4141
norm_before_residual: bool = False,
4242
norm_before_fc: bool = False,
43+
fc_norm: bool = False,
4344
norm_output: bool = False,
4445
eagle_aux_hidden_state_layer_ids: list[int] | None = None,
4546
cache_dir: str | Path | None = None,
@@ -80,6 +81,7 @@ def convert(
8081
base_model,
8182
norm_before_residual,
8283
norm_before_fc,
84+
fc_norm,
8385
norm_output,
8486
eagle_aux_hidden_state_layer_ids,
8587
)
@@ -111,6 +113,7 @@ def _build_eagle3_speculator_config(
111113
base_model: str,
112114
norm_before_residual: bool = False,
113115
norm_before_fc: bool = False,
116+
fc_norm: bool = False,
114117
norm_output: bool = False,
115118
eagle_aux_hidden_state_layer_ids: list[int] | None = None,
116119
) -> Eagle3SpeculatorConfig:
@@ -137,6 +140,7 @@ def _build_eagle3_speculator_config(
137140
draft_vocab_size=eagle_config.get("draft_vocab_size", 32000),
138141
norm_before_residual=norm_before_residual,
139142
norm_before_fc=norm_before_fc or eagle_config.get("norm_before_fc", False),
143+
fc_norm=fc_norm or eagle_config.get("fc_norm", False),
140144
norm_output=norm_output or eagle_config.get("norm_output", False),
141145
target_hidden_size=eagle_config.get("target_hidden_size"),
142146
eagle_aux_hidden_state_layer_ids=eagle_aux_hidden_state_layer_ids,

src/speculators/models/eagle3/config.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Literal
22

3-
from pydantic import Field, field_serializer, field_validator
3+
from pydantic import Field, field_serializer, field_validator, model_validator
44
from transformers import AutoConfig, PretrainedConfig
55
from transformers.models.qwen3.configuration_qwen3 import Qwen3Config
66

@@ -58,8 +58,18 @@ class Eagle3SpeculatorConfig(SpeculatorModelConfig):
5858
norm_before_fc: bool = Field(
5959
default=False,
6060
description=(
61-
"Use RMSNorm before FC layer in draft path "
62-
"(e.g., for Eagle 3.1 / gpt-oss models)."
61+
"Apply a single RMSNorm to the concatenated auxiliary hidden states "
62+
"before the FC projection (gpt-oss style)."
63+
),
64+
)
65+
66+
fc_norm: bool = Field(
67+
default=False,
68+
description=(
69+
"Apply per-layer RMSNorm to each auxiliary hidden state before "
70+
"concatenation and FC projection — i.e. "
71+
"concat(Norm(h_a), Norm(h_b), Norm(h_c)) instead of the single "
72+
"Norm(concat(h_a, h_b, h_c)) used by norm_before_fc."
6373
),
6474
)
6575

@@ -76,6 +86,15 @@ class Eagle3SpeculatorConfig(SpeculatorModelConfig):
7686
description="Whether embedding layer weights require gradients during training",
7787
)
7888

89+
@model_validator(mode="after")
90+
def _check_norm_flags(self) -> "Eagle3SpeculatorConfig":
91+
if self.norm_before_fc and self.fc_norm:
92+
raise ValueError(
93+
"norm_before_fc and fc_norm are mutually exclusive — "
94+
"enable one or the other, not both."
95+
)
96+
return self
97+
7998
@field_serializer("transformer_layer_config")
8099
def serialize_transformer_config(self, value: PretrainedConfig) -> dict:
81100
"""Serialize transformer config to dict."""

src/speculators/models/eagle3/core.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,14 @@ def __init__(self, config: Eagle3SpeculatorConfig):
6161
self.embed_tokens.weight.requires_grad = self.config.embed_requires_grad
6262

6363
# FC LAYER
64-
self.fc = torch.nn.Linear(3 * self.hidden_size, self.hidden_size, bias=False)
64+
num_aux = (
65+
len(config.eagle_aux_hidden_state_layer_ids)
66+
if config.eagle_aux_hidden_state_layer_ids
67+
else 3
68+
)
69+
self.fc = torch.nn.Linear(
70+
num_aux * self.hidden_size, self.hidden_size, bias=False
71+
)
6572

6673
# DECODER LAYERS
6774
num_layers = tl_config.num_hidden_layers
@@ -95,12 +102,24 @@ def __init__(self, config: Eagle3SpeculatorConfig):
95102

96103
if config.norm_before_fc:
97104
self.input_norm = self._model_definitions.norm_class(
98-
3 * self.hidden_size,
105+
num_aux * self.hidden_size,
99106
eps=config.transformer_layer_config.rms_norm_eps,
100107
)
101108
else:
102109
self.input_norm = None
103110

111+
self.fc_norm: torch.nn.ModuleList | None = None
112+
if config.fc_norm:
113+
self.fc_norm = torch.nn.ModuleList(
114+
[
115+
self._model_definitions.norm_class(
116+
self.hidden_size,
117+
eps=config.transformer_layer_config.rms_norm_eps,
118+
)
119+
for _ in range(num_aux)
120+
]
121+
)
122+
104123
self.post_init()
105124

106125
@property
@@ -139,7 +158,7 @@ def load_verifier_weights(self):
139158
@conditional_torch_compile
140159
def forward( # noqa: C901
141160
self,
142-
hidden_states: torch.Tensor, # shape: [1, total_seq_len, 3 * hidden_size]
161+
hidden_states: torch.Tensor, # shape: [1, total_seq_len, num_aux * hidden_size]
143162
input_ids: torch.Tensor, # shape: [1, total_seq_len]
144163
document_ids: torch.Tensor, # shape: [1, total_seq_len]
145164
loss_mask: torch.Tensor | None = None, # shape: [1, total_seq_len]
@@ -178,6 +197,12 @@ def forward( # noqa: C901
178197

179198
if self.input_norm is not None:
180199
hidden_states = self.input_norm(hidden_states)
200+
if self.fc_norm is not None:
201+
chunks = hidden_states.chunk(len(self.fc_norm), dim=-1)
202+
hidden_states = torch.cat(
203+
[norm(chunk) for norm, chunk in zip(self.fc_norm, chunks, strict=True)],
204+
dim=-1,
205+
)
181206
hidden_states = self.fc(hidden_states)
182207
# shape: [1, total_seq_len, hidden_size]
183208

@@ -329,6 +354,7 @@ def from_training_args(
329354
draft_vocab_size=kwargs["draft_vocab_size"],
330355
norm_before_residual=kwargs["norm_before_residual"],
331356
norm_before_fc=kwargs.get("norm_before_fc", False),
357+
fc_norm=kwargs.get("fc_norm", False),
332358
norm_output=kwargs.get("norm_output", False),
333359
embed_requires_grad=kwargs.get("embed_requires_grad", False),
334360
eagle_aux_hidden_state_layer_ids=target_layer_ids,

src/speculators/models/peagle/core.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ def __init__(
4444
self.mask_token_id = config.mask_token_id
4545

4646
# Learnable mask_hidden parameter for padding unsampled positions
47-
self.mask_hidden = torch.nn.Parameter(torch.randn(1, 1, 3 * self.hidden_size))
47+
num_aux = (
48+
len(self.config.eagle_aux_hidden_state_layer_ids)
49+
if self.config.eagle_aux_hidden_state_layer_ids
50+
else 3
51+
)
52+
self.mask_hidden = torch.nn.Parameter(
53+
torch.randn(1, 1, num_aux * self.hidden_size)
54+
)
4855

4956
@conditional_torch_compile
5057
def forward(
@@ -117,6 +124,12 @@ def forward(
117124
# Project concatenated hidden states (3*hidden_size) -> hidden_size
118125
if self.input_norm is not None:
119126
sampled_hidden = self.input_norm(sampled_hidden)
127+
if self.fc_norm is not None:
128+
chunks = sampled_hidden.chunk(len(self.fc_norm), dim=-1)
129+
sampled_hidden = torch.cat(
130+
[norm(chunk) for norm, chunk in zip(self.fc_norm, chunks, strict=True)],
131+
dim=-1,
132+
)
120133
sampled_hidden = self.fc(sampled_hidden) # [1, total_sampled, hidden_size]
121134

122135
layer_input = torch.cat(
@@ -216,6 +229,7 @@ def from_training_args(
216229
draft_vocab_size=kwargs["draft_vocab_size"],
217230
norm_before_residual=kwargs.get("norm_before_residual", False),
218231
norm_before_fc=kwargs.get("norm_before_fc", False),
232+
fc_norm=kwargs.get("fc_norm", False),
219233
norm_output=kwargs.get("norm_output", False),
220234
eagle_aux_hidden_state_layer_ids=target_layer_ids,
221235
num_depths=kwargs.get("num_depths", 8),

tests/integration/conftest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def make_eagle3_model(
9898
draft_vocab_size: int = 64,
9999
norm_before_residual: bool = False,
100100
norm_before_fc: bool = False,
101+
fc_norm: bool = False,
101102
norm_output: bool = False,
102103
draft_attn_impl: str | None = None,
103104
device: str = "cuda:0",
@@ -111,6 +112,7 @@ def make_eagle3_model(
111112
draft_vocab_size=draft_vocab_size,
112113
norm_before_residual=norm_before_residual,
113114
norm_before_fc=norm_before_fc,
115+
fc_norm=fc_norm,
114116
norm_output=norm_output,
115117
embed_requires_grad=False,
116118
speculators_config=SpeculatorsConfig(
@@ -171,6 +173,7 @@ def make_peagle_model(
171173
num_depths: int = 4,
172174
down_sample_ratio: float = 0.7,
173175
norm_before_fc: bool = False,
176+
fc_norm: bool = False,
174177
norm_output: bool = False,
175178
draft_attn_impl: str | None = None,
176179
device: str = "cuda:0",
@@ -184,6 +187,7 @@ def make_peagle_model(
184187
draft_vocab_size=draft_vocab_size,
185188
norm_before_residual=False,
186189
norm_before_fc=norm_before_fc,
190+
fc_norm=fc_norm,
187191
norm_output=norm_output,
188192
embed_requires_grad=True,
189193
num_depths=num_depths,

tests/integration/models/test_model_forward.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,30 @@ def test_norm_output_without_norm_before_fc(self):
382382
assert loss.isfinite()
383383
loss.backward()
384384

385+
def test_fc_norm(self):
386+
model = make_eagle3_model(fc_norm=True, norm_output=True)
387+
assert model.fc_norm is not None
388+
assert len(model.fc_norm) == 3
389+
assert model.input_norm is None
390+
samples = _make_samples([128])
391+
batch = make_batch(max_len=MAX_LEN, samples=samples, hidden_size=HIDDEN_SIZE)
392+
draft_tokens, loss, _metrics = model(**batch, ttt_steps=3)
393+
394+
assert len(draft_tokens) == 3
395+
assert loss.isfinite()
396+
loss.backward()
397+
398+
def test_peagle_fc_norm(self):
399+
model = make_peagle_model(fc_norm=True)
400+
assert model.fc_norm is not None
401+
assert len(model.fc_norm) == 3
402+
samples = _make_samples([128])
403+
batch = make_batch(max_len=MAX_LEN, samples=samples, hidden_size=HIDDEN_SIZE)
404+
_draft_tokens, loss, _metrics = model(**batch)
405+
406+
assert loss.isfinite()
407+
loss.backward()
408+
385409
def test_peagle_norm_before_fc(self):
386410
model = make_peagle_model()
387411
assert model.input_norm is None

tests/unit/test_config.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,31 @@ def test_eagle3_config_norm_output_defaults():
538538
speculators_config=_make_eagle3_speculators_config(),
539539
)
540540
assert config.norm_output is False
541+
assert config.fc_norm is False
542+
543+
544+
@pytest.mark.sanity
545+
def test_eagle3_config_rejects_both_norm_before_fc_and_fc_norm():
546+
with pytest.raises(ValidationError, match="mutually exclusive"):
547+
Eagle3SpeculatorConfig(
548+
transformer_layer_config=copy.deepcopy(TINY_LLAMA_CONFIG),
549+
norm_before_fc=True,
550+
fc_norm=True,
551+
speculators_config=_make_eagle3_speculators_config(),
552+
)
553+
554+
555+
@pytest.mark.sanity
556+
def test_eagle3_config_fc_norm_roundtrip():
557+
original = Eagle3SpeculatorConfig(
558+
transformer_layer_config=copy.deepcopy(TINY_LLAMA_CONFIG),
559+
draft_vocab_size=32000,
560+
fc_norm=True,
561+
speculators_config=_make_eagle3_speculators_config(),
562+
)
563+
564+
config_dict = original.to_dict()
565+
assert config_dict["fc_norm"] is True
566+
567+
reloaded = SpeculatorModelConfig.from_dict(config_dict)
568+
assert reloaded.fc_norm is True

0 commit comments

Comments
 (0)