Skip to content

Commit d6fec64

Browse files
Copilotjustinchuby
andauthored
Make RoPE config nullable to fix silent NoPE-model corruption (#177)
`_extract_rope_config()` never returned `None`, so NoPE models (NemotronH, GraniteMoeHybrid, GPT-2/BERT/OPT families) silently received `rope_type='default'` and had spurious rotary ops injected into their ONNX graphs. The existing NemotronH fix (#110) is a model-class workaround; this PR fixes the root cause in the config system. ### Changes - **`_extract_rope_config()`** returns `None` unless the HF config has a real RoPE signal: `rope_parameters`, `rope_scaling`, or legacy `rotary_dim` / `rotary_pct` / `rotary_emb_base`. `rope_theta` alone is dead data (NemotronH carries it without using RoPE) and is deliberately not treated as a signal. - **`ArchitectureConfig.from_transformers()`** propagates `None` to both the `rope` sub-config and every flat RoPE field when the model is NoPE. `dataclasses.replace(rope_config, ...)` call sites now guard against `None`. - **`ArchitectureConfig.rope_type`** default is `None` (the structural NoPE signal). `rope_theta` / `partial_rotary_factor` keep inert numeric defaults so `ArchitectureConfig(rope_type="default", ...)` still works for direct construction in tests. - **`initialize_rope()`** returns `None` when `rope_type is None` and `mrope_section is None`. - **`TextModel.forward()`** guards `self.rotary_emb(...)` and passes `position_embeddings=None` down when RoPE is absent. - **`Attention.__init__`** tolerates `partial_rotary_factor=None` (treated as the inert 1.0) so NoPE-routed attention doesn't crash on `math.isclose(None, 1.0)`. ### Tests - New: `test_from_transformers_nope_model_has_none_rope`, `test_from_transformers_legacy_rotary_dim_enables_rope`, `test_rope_theta_alone_is_not_a_rope_signal`, `test_nope_returns_none` (initialize_rope). - Existing fakes in `_config_resolver_test.py` / `_configs_test.py` now include `rope_parameters={"rope_type": "default"}` to match how real HF `PretrainedConfig.__post_init__` populates the field. - `make_config()` test helper opts into `rope_type="default"` so component tests keep the RoPE code path. Direct `BambaConfig` / `JambaConfig` / `Gemma2Config` test constructors and `deepseek_ocr2.py`'s internal `ArchitectureConfig` opt in explicitly. ### Intentionally out of scope - Simplifying the NemotronH / GraniteMoeHybrid text-model workarounds — they use bespoke layer types (`NemotronHMambaLayer`, etc.) and can't drop straight onto `TextModel`. They remain correct and now coexist with a structural defense in the config layer. - Phase 2 (calling `validate()` in the build path, nullable MoE sub-config, deprecating flat RoPE fields) and Phase 3 (feature-group sub-configs, per-model config classes) from the issue. ### Example ```python # NemotronH config → no RoPE signal → structurally NoPE class FakeNemotronH: model_type = "nemotron_h" rope_theta = 10_000.0 # dead data, ignored # no rope_parameters, no rope_scaling, no rotary_* ... cfg = ArchitectureConfig.from_transformers(FakeNemotronH()) assert cfg.rope is None assert cfg.rope_type is None assert initialize_rope(cfg) is None # TextModel now skips RoPE automatically ``` --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: justinchuby <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <justinchuby@users.noreply.github.com>
1 parent 87c0524 commit d6fec64

11 files changed

Lines changed: 287 additions & 45 deletions

File tree

src/mobius/_config_resolver_test.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919

2020

2121
def _fake_hf_config(model_type: str, **overrides):
22-
"""Create a minimal HF-config-like object for testing."""
22+
"""Create a minimal HF-config-like object for testing.
23+
24+
``rope_parameters`` is present by default so the resolver treats the
25+
fake config as a RoPE-capable model (matching how real HuggingFace
26+
configs populate this field in ``PretrainedConfig.__post_init__``).
27+
Pass ``rope_parameters=None`` explicitly to exercise the NoPE path.
28+
"""
2329
defaults = {
2430
"model_type": model_type,
2531
"vocab_size": 100,
@@ -35,6 +41,7 @@ def _fake_hf_config(model_type: str, **overrides):
3541
"rms_norm_eps": 1e-6,
3642
"rope_theta": 10_000.0,
3743
"rope_scaling": None,
44+
"rope_parameters": {"rope_type": "default"},
3845
}
3946
defaults.update(overrides)
4047
return type("FakeHFConfig", (), defaults)()
@@ -141,6 +148,9 @@ def test_gemma3_nested_rope_scaling(self):
141148
hf = _fake_hf_config(
142149
"gemma3_text",
143150
rope_theta=None, # force fallback to nested lookup
151+
# Disable the default rope_parameters so the nested
152+
# rope_scaling entries are used for rope_type resolution.
153+
rope_parameters=None,
144154
rope_scaling={
145155
"full_attention": {
146156
"rope_type": "linear",
@@ -280,6 +290,10 @@ def _deepseek_config(self, **overrides):
280290
rms_norm_eps=1e-6,
281291
rope_theta=10000.0,
282292
rope_scaling=None,
293+
# Real HF DeepSeek configs populate rope_parameters in
294+
# __post_init__; include it here so _extract_rope_config
295+
# treats this as a RoPE-capable model (not NoPE).
296+
rope_parameters={"rope_type": "default"},
283297
# MLA-specific fields
284298
q_lora_rank=1536,
285299
kv_lora_rank=512,

src/mobius/_configs.py

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,42 @@ def _first_not_none(*values, default=None):
282282
return default
283283

284284

285-
def _extract_rope_config(config) -> RoPEConfig:
285+
def _extract_rope_config(config) -> RoPEConfig | None:
286286
"""Extract and normalize RoPE-related config fields.
287287
288288
Reads ``rope_scaling``, ``rope_parameters``, and related attributes
289289
from a HuggingFace config and returns a :class:`RoPEConfig`.
290+
291+
Returns ``None`` when the source config has no RoPE signal at all —
292+
i.e. it declares neither the modern ``rope_parameters``/``rope_scaling``
293+
fields nor the legacy ``rotary_dim``/``rotary_pct``/``rotary_emb_base``
294+
fields. This is the "NoPE" case (e.g. NemotronH, GraniteMoeHybrid,
295+
GPT-2 family, BERT family, OPT) — the model does not use rotary
296+
position embeddings at all, and callers should treat RoPE as absent
297+
rather than manufacturing defaults that would silently introduce RoPE
298+
operations into the ONNX graph.
299+
300+
Note: ``rope_theta`` alone is NOT a sufficient RoPE signal because
301+
models like NemotronH carry ``rope_theta`` in their config as dead
302+
data despite not using RoPE. HuggingFace's ``rope_parameters`` field
303+
(populated by ``PretrainedConfig.__post_init__``) is the authoritative
304+
modern signal; legacy GPT-J / GPT-NeoX / CodeGen models predate it
305+
and use the ``rotary_*`` fields instead.
290306
"""
291-
rope_scaling = getattr(config, "rope_scaling", None) or {}
292-
rope_parameters = getattr(config, "rope_parameters", None) or {}
307+
# Check for RoPE signals BEFORE the `or {}` fallback below —
308+
# `or {}` converts None to empty dict, destroying the absence signal.
309+
raw_rope_scaling = getattr(config, "rope_scaling", None)
310+
raw_rope_parameters = getattr(config, "rope_parameters", None)
311+
has_legacy_rope = (
312+
getattr(config, "rotary_dim", None) is not None
313+
or getattr(config, "rotary_pct", None) is not None
314+
or getattr(config, "rotary_emb_base", None) is not None
315+
)
316+
if raw_rope_scaling is None and raw_rope_parameters is None and not has_legacy_rope:
317+
return None
318+
319+
rope_scaling = raw_rope_scaling or {}
320+
rope_parameters = raw_rope_parameters or {}
293321

294322
return RoPEConfig(
295323
rope_type=_first_not_none(
@@ -734,11 +762,27 @@ class ArchitectureConfig(BaseModelConfig):
734762

735763
rms_norm_eps: float = 1e-6
736764

737-
# Rotary embedding config
738-
rope_type: str = "default"
739-
rope_theta: float = 10_000.0
765+
# Rotary embedding config.
766+
#
767+
# ``rope_type`` is the structural signal: ``None`` means "this model
768+
# does not use RoPE". ``from_transformers`` populates ``rope_type``
769+
# (and the other flat RoPE fields below) from the HuggingFace config
770+
# only when RoPE is actually declared — see :func:`_extract_rope_config`.
771+
# For NoPE models (NemotronH, GraniteMoeHybrid, GPT-2 family, BERT, OPT,
772+
# ...) ``from_transformers`` sets every flat RoPE field to ``None`` so
773+
# downstream code (``initialize_rope``, ``TextModel``, ``Attention``) can
774+
# structurally detect the absence of RoPE instead of spuriously applying
775+
# a "default" rotary encoding.
776+
#
777+
# The non-``rope_type`` fields keep inert numeric defaults at the
778+
# dataclass level so that code that constructs ``ArchitectureConfig``
779+
# directly with just ``rope_type="default"`` (e.g. tests, small reproducer
780+
# configs) works without having to spell out every RoPE parameter. These
781+
# defaults are only consumed when ``rope_type`` is non-``None``.
782+
rope_type: str | None = None
783+
rope_theta: float | None = 10_000.0
740784
rope_scaling: dict | None = None
741-
partial_rotary_factor: float = 1.0
785+
partial_rotary_factor: float | None = 1.0
742786
rope_local_base_freq: float | None = None
743787
original_max_position_embeddings: int | None = None
744788

@@ -883,14 +927,18 @@ def from_transformers(cls, config, parent_config=None) -> ArchitectureConfig:
883927
or 0
884928
)
885929

886-
# rope_interleave depends on model_type / qk_rope_head_dim
930+
# rope_interleave depends on model_type / qk_rope_head_dim.
931+
# Only compute it when RoPE is actually in use — for NoPE models
932+
# (rope_config is None) we leave the flat ``rope_interleave`` at
933+
# its inert ``False`` default.
887934
rope_interleave = getattr(
888935
config,
889936
"rope_interleave",
890937
(getattr(config, "qk_rope_head_dim", None) or 0) > 0
891938
or model_type in ("glm", "glm4", "glm4_moe", "chatglm"),
892939
)
893-
rope_config = dataclasses.replace(rope_config, rope_interleave=rope_interleave)
940+
if rope_config is not None:
941+
rope_config = dataclasses.replace(rope_config, rope_interleave=rope_interleave)
894942

895943
options = dict(
896944
head_dim=(
@@ -1052,14 +1100,26 @@ def from_transformers(cls, config, parent_config=None) -> ArchitectureConfig:
10521100
)
10531101
),
10541102
rope=rope_config,
1055-
# Set flat rope fields for direct access by components
1056-
rope_type=rope_config.rope_type,
1057-
rope_theta=rope_config.rope_theta,
1058-
rope_scaling=rope_config.rope_scaling,
1059-
partial_rotary_factor=rope_config.partial_rotary_factor,
1060-
rope_local_base_freq=rope_config.rope_local_base_freq,
1061-
original_max_position_embeddings=rope_config.original_max_position_embeddings,
1062-
rope_interleave=rope_config.rope_interleave,
1103+
# Flat rope field copies: ``None`` for NoPE models so that
1104+
# ``initialize_rope`` / ``TextModel`` / ``Attention`` can detect
1105+
# "this model has no RoPE" structurally.
1106+
rope_type=rope_config.rope_type if rope_config is not None else None,
1107+
rope_theta=rope_config.rope_theta if rope_config is not None else None,
1108+
rope_scaling=rope_config.rope_scaling if rope_config is not None else None,
1109+
partial_rotary_factor=(
1110+
rope_config.partial_rotary_factor if rope_config is not None else None
1111+
),
1112+
rope_local_base_freq=(
1113+
rope_config.rope_local_base_freq if rope_config is not None else None
1114+
),
1115+
original_max_position_embeddings=(
1116+
rope_config.original_max_position_embeddings
1117+
if rope_config is not None
1118+
else None
1119+
),
1120+
rope_interleave=(
1121+
rope_config.rope_interleave if rope_config is not None else False
1122+
),
10631123
**mrope_fields,
10641124
max_position_embeddings=getattr(config, "max_position_embeddings", 0),
10651125
tie_word_embeddings=(

src/mobius/_configs_test.py

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ def test_default_values(self):
3333
assert config.hidden_size == DEFAULT_INT
3434
assert config.num_hidden_layers == DEFAULT_INT
3535
assert config.rms_norm_eps == pytest.approx(1e-6)
36-
assert config.rope_type == "default"
36+
# rope_type defaults to None (NoPE) so that directly-constructed
37+
# ArchitectureConfig instances without an explicit rope_type are
38+
# treated as having no RoPE, matching the signal from_transformers
39+
# uses for NoPE models. The other RoPE fields keep inert numeric
40+
# defaults so that specifying only rope_type="default" is enough
41+
# for test / reproducer configs.
42+
assert config.rope_type is None
3743
assert config.rope_theta == pytest.approx(10_000.0)
3844
assert config.partial_rotary_factor == pytest.approx(1.0)
3945
assert config.attn_qkv_bias is False
@@ -125,6 +131,9 @@ class FakeLlamaConfig:
125131
rms_norm_eps = 1e-5
126132
rope_theta = 10000.0
127133
rope_scaling = None
134+
# Real HuggingFace LlamaConfig populates rope_parameters in
135+
# __post_init__ for any model that declares RoPE support.
136+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
128137

129138
config = ArchitectureConfig.from_transformers(FakeLlamaConfig())
130139
assert config.vocab_size == 32000
@@ -232,30 +241,128 @@ class FakeConfig:
232241
assert config.bos_token_id == 2
233242
assert config.eos_token_id == 1
234243

244+
def test_from_transformers_nope_model_has_none_rope(self):
245+
"""NoPE models (e.g. NemotronH) get ``rope=None`` and ``rope_type=None``.
246+
247+
This is the Phase 1 fix for the silent-RoPE-on-NoPE-models bug:
248+
when the HuggingFace config declares neither ``rope_parameters``
249+
nor ``rope_scaling`` nor the legacy ``rotary_dim`` / ``rotary_pct``
250+
/ ``rotary_emb_base`` fields, the resulting ``ArchitectureConfig``
251+
must express "no RoPE" structurally so that ``initialize_rope``
252+
returns ``None`` and ``TextModel`` skips rotary encoding entirely.
253+
"""
254+
255+
class FakeNemotronH:
256+
# Minimal NemotronH-like config: carries a stale ``rope_theta``
257+
# as dead data but declares NO ``rope_parameters`` / ``rope_scaling``
258+
# / ``rotary_*`` fields — so this is a NoPE model.
259+
model_type = "nemotron_h"
260+
num_attention_heads = 8
261+
num_key_value_heads = 2
262+
num_hidden_layers = 4
263+
vocab_size = 128
264+
hidden_size = 64
265+
intermediate_size = 128
266+
hidden_act = "relu2"
267+
max_position_embeddings = 128
268+
head_dim = 8
269+
pad_token_id = 0
270+
rms_norm_eps = 1e-6
271+
rope_theta = 10_000.0 # stale — ignored because no rope_parameters
272+
273+
config = ArchitectureConfig.from_transformers(FakeNemotronH())
274+
# Sub-config is None: no RoPE data exists at all.
275+
assert config.rope is None
276+
# Flat fields are all None: no spurious "default" values.
277+
assert config.rope_type is None
278+
assert config.rope_theta is None
279+
assert config.partial_rotary_factor is None
280+
assert config.rope_scaling is None
281+
assert config.rope_local_base_freq is None
282+
assert config.original_max_position_embeddings is None
283+
# rope_interleave stays at its inert False default.
284+
assert config.rope_interleave is False
285+
286+
def test_from_transformers_legacy_rotary_dim_enables_rope(self):
287+
"""GPT-J / CodeGen-style legacy configs use ``rotary_dim``."""
288+
289+
class FakeGPTJ:
290+
model_type = "gptj"
291+
num_attention_heads = 4
292+
num_key_value_heads = 4
293+
num_hidden_layers = 2
294+
vocab_size = 128
295+
hidden_size = 64
296+
intermediate_size = 128
297+
hidden_act = "gelu"
298+
max_position_embeddings = 128
299+
head_dim = 16
300+
pad_token_id = 0
301+
rms_norm_eps = 1e-6
302+
rotary_dim = 8 # legacy partial-RoPE signal
303+
304+
config = ArchitectureConfig.from_transformers(FakeGPTJ())
305+
# Legacy rotary_dim activates RoPE with partial_rotary_factor = 8/16.
306+
assert config.rope is not None
307+
assert config.rope_type == "default"
308+
assert config.partial_rotary_factor == pytest.approx(0.5)
309+
235310

236311
class TestExtractRopeConfig:
237312
"""Unit tests for _extract_rope_config helper."""
238313

239314
def test_defaults_when_no_rope_attrs(self):
240-
"""Bare config with no rope attrs yields sensible defaults."""
315+
"""Bare config with no RoPE signal yields ``None`` (NoPE model)."""
241316

242317
class Bare:
243318
pass
244319

245320
result = _extract_rope_config(Bare())
321+
assert result is None
322+
323+
def test_rope_theta_alone_is_not_a_rope_signal(self):
324+
"""rope_theta without rope_parameters/rope_scaling is not a RoPE signal.
325+
326+
For example NemotronH carries ``rope_theta`` as dead data while
327+
declaring no actual RoPE support — so the absence of
328+
``rope_parameters`` / ``rope_scaling`` / legacy rotary fields must
329+
produce ``None`` (NoPE), not a spurious ``RoPEConfig``.
330+
"""
331+
332+
class Cfg:
333+
# No rope_scaling, no rope_parameters — just a stale rope_theta.
334+
rope_theta = 10_000.0
335+
336+
assert _extract_rope_config(Cfg()) is None
337+
338+
def test_rope_parameters_activates_rope(self):
339+
"""``rope_parameters`` on the HF config is the modern RoPE signal."""
340+
341+
class Cfg:
342+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
343+
344+
result = _extract_rope_config(Cfg())
345+
assert result is not None
346+
assert result.rope_type == "default"
347+
348+
def test_legacy_rotary_dim_activates_rope(self):
349+
"""Legacy GPT-J / CodeGen configs use ``rotary_dim``."""
350+
351+
class Cfg:
352+
rotary_dim = 64
353+
354+
result = _extract_rope_config(Cfg())
355+
assert result is not None
246356
assert result.rope_type == "default"
247-
assert result.rope_theta == pytest.approx(10_000.0)
248-
assert result.rope_scaling is None
249-
assert result.partial_rotary_factor == pytest.approx(1.0)
250-
assert result.rope_local_base_freq is None
251-
assert result.original_max_position_embeddings is None
252357

253358
def test_rope_theta_from_config_attr(self):
254359
class Cfg:
255360
rope_theta = 500_000.0
256-
rope_scaling = None
361+
# rope_parameters triggers the RoPE path so rope_theta is read.
362+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
257363

258364
result = _extract_rope_config(Cfg())
365+
assert result is not None
259366
assert result.rope_theta == pytest.approx(500_000.0)
260367

261368
def test_rope_type_from_rope_scaling(self):
@@ -268,29 +375,32 @@ class Cfg:
268375
def test_partial_rotary_factor(self):
269376
class Cfg:
270377
partial_rotary_factor = 0.5
271-
rope_scaling = None
378+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
272379

273380
result = _extract_rope_config(Cfg())
381+
assert result is not None
274382
assert result.partial_rotary_factor == pytest.approx(0.5)
275383

276384
def test_partial_rotary_factor_zero_is_preserved(self):
277385
"""partial_rotary_factor=0.0 must NOT be replaced by default 1.0."""
278386

279387
class Cfg:
280388
partial_rotary_factor = 0.0
281-
rope_scaling = None
389+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
282390

283391
result = _extract_rope_config(Cfg())
392+
assert result is not None
284393
assert result.partial_rotary_factor == pytest.approx(0.0)
285394

286395
def test_rope_theta_zero_is_preserved(self):
287396
"""rope_theta=0.0 must NOT be replaced by default 10000.0."""
288397

289398
class Cfg:
290399
rope_theta = 0.0
291-
rope_scaling = None
400+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
292401

293402
result = _extract_rope_config(Cfg())
403+
assert result is not None
294404
assert result.rope_theta == pytest.approx(0.0)
295405

296406
def test_mrope_interleaved_from_rope_scaling(self):
@@ -319,9 +429,10 @@ class Cfg:
319429
def test_original_max_position_embeddings(self):
320430
class Cfg:
321431
original_max_position_embeddings = 8192
322-
rope_scaling = None
432+
rope_parameters: ClassVar[dict] = {"rope_type": "default"}
323433

324434
result = _extract_rope_config(Cfg())
435+
assert result is not None
325436
assert result.original_max_position_embeddings == 8192
326437

327438

0 commit comments

Comments
 (0)