Repository navigation
Description
Summary
internlm3 passes scale = 2.0 to mlxcel_core::fast_rope whenever rope_scaling is absent or its rope_type is "dynamic". The checkpoint the family is validated on (mlx-community/internlm3-8b-instruct-4bit) ships rope_scaling: {"factor": 6.0, "rope_type": "dynamic"}, so every query and key is rotated at twice its true position on every request. The correct schedule for dynamic NTK is: positions unscaled (scale = 1.0), and only the rotary base grows once the sequence passes max_position_embeddings. This change fixes the scale, moves the dynamic NTK base computation into a shared src/models/dynamic_ntk_rope.rs helper that internlm2 will reuse, and re-pins the InternLM3 greedy parity reference, which was captured against the same wrong schedule.
Current behavior
src/models/internlm3.rs:108-119:
pub fn rope_scale(&self) -> f32 {
self.rope_scaling
.as_ref()
.and_then(|s| {
if s.rope_type == "linear" {
Some(1.0 / s.factor)
} else {
None
}
})
.unwrap_or(2.0)
}rope_scaling == None and rope_type == "dynamic" both fall through to unwrap_or(2.0). The value is stored as Attention::rope_scale (src/models/internlm3.rs:297) and handed to both rotary calls at src/models/internlm3.rs:209-224:
let q = mlxcel_core::fast_rope(&q, self.head_dim, self.rope_traditional, rope_base, self.rope_scale, offset);
let k = mlxcel_core::fast_rope(&k, self.head_dim, self.rope_traditional, rope_base, self.rope_scale, offset);fast_rope multiplies every position by scale before computing the rotation angle, so with scale = 2.0 the token at absolute position p is rotated as if it sat at 2p. For rope_theta = 5e7, head_dim = 128, position 7, frequency pair j = 64: the correct angle is 7 * 5e7^(-64/128) = 9.899e-4; the runtime applies 1.980e-3. Relative angles between any two tokens are doubled as well, so attention patterns differ from what the weights were trained on at every context length, not only past 32768.
The base computation itself is right. compute_dynamic_ntk_base (src/models/internlm3.rs:140-154) returns rope_theta while seq_len <= max_position_embeddings and otherwise rescales it, which is equivalent to clamping seq_len to max(seq_len, max_position_embeddings) before applying the formula.
The private RopeScaling struct at src/models/internlm3.rs:68-72 requires a rope_type key; a config that spells the key type fails to deserialize. src/models/config.rs:309-324 already has a shared RopeScaling with #[serde(rename = "type", alias = "rope_type")] scaling_type: Option<String> and factor: Option<f32>, but no RoPE family uses it for this purpose.
tests/causal_prefill_greedy_parity.rs:104-107 pins INTERNLM3_REF_OUT for the 56-id prompt at INTERNLM3_INPUT_IDS. Those 24 ids were produced by a reference that applied the same scale = 2.0, so they pin the defect. docs/supported-models.md:38 repeats the claim that the family is token-exact against that reference.
Expected behavior
For a [B, n_heads, L, head_dim] query or key tensor at cache offset offset, with dims = head_dim, base = rope_theta, max_pos = max_position_embeddings:
rope_scalingabsent, orscaling_typein{"default", None}:fast_rope(x, dims, traditional, base, 1.0, offset).scaling_type == "linear"withfactor = f:fast_rope(x, dims, traditional, base, 1.0 / f, offset). Positionpis rotated asp / f.scaling_type == "dynamic"withfactor = f:seq_len = max(L + offset, max_pos)base_eff = base * ((f * seq_len / max_pos) - (f - 1)) ^ (dims / (dims - 2))fast_rope(x, dims, traditional, base_eff, 1.0, offset). Positions are never scaled.
- Any other
scaling_type(llama3,yarn, ...) is an error at load time for this family. A"linear"or"dynamic"entry withoutfactoris also an error.
Worked values for the validation checkpoint (base = 5e7, f = 6.0, dims = 128, max_pos = 32768):
L + offset |
seq_len |
base_eff |
|---|---|---|
| 100 | 32768 | 50 000 000 (unchanged) |
| 32768 | 32768 | 50 000 000 (unchanged) |
| 40000 | 40000 | 117 777 118.66 |
| 65536 | 65536 | 360 979 300.43 |
Angle for position p and frequency pair j (0 <= j < dims / 2): theta(p, j) = p * base_eff ^ (-2j / dims). At p = 7, j = 0: 7.0; at p = 7, j = 64: 9.899e-4.
Implementation plan
-
Add
src/models/dynamic_ntk_rope.rs(registerpub mod dynamic_ntk_rope;insrc/models/mod.rs, and attachdynamic_ntk_rope_tests.rswith the usual#[cfg(test)] #[path = "dynamic_ntk_rope_tests.rs"] mod tests;). Contents:/// Used by: InternLM3, InternLM2 #[derive(Debug, Clone, Copy, PartialEq)] pub enum DynamicNtkRopeMode { Default, Linear { factor: f32 }, Dynamic { factor: f32 }, } /// Used by: InternLM3, InternLM2 #[derive(Debug, Clone)] pub struct DynamicNtkRope { pub dims: i32, pub base: f32, pub traditional: bool, pub max_position_embeddings: usize, pub mode: DynamicNtkRopeMode, } impl DynamicNtkRope { /// Build from the shared `config::RopeScaling`. `None` and /// `scaling_type` in {None, "default"} give `Default`; "linear" and /// "dynamic" require `factor` (Err otherwise); anything else is Err. pub fn from_scaling(dims: i32, base: f32, traditional: bool, max_position_embeddings: usize, scaling: Option<&crate::models::config::RopeScaling>) -> Result<Self, String>; /// 1.0 for Default and Dynamic, 1.0 / factor for Linear. pub fn position_scale(&self) -> f32; /// `base` for Default and Linear; the NTK-rescaled base for Dynamic, /// computed from `seq_len = max(seq_len, max_position_embeddings)`. pub fn base_for(&self, seq_len: i32) -> f32; /// `mlxcel_core::fast_rope(x, dims, traditional, base_for(seq_len), position_scale(), offset)` pub fn apply(&self, x: &MlxArray, offset: i32, seq_len: i32) -> UniquePtr<MlxArray>; }
base_foris the existingcompute_dynamic_ntk_basebody with theif seq_len > max_posbranch replaced by themaxclamp (same result, fewer branches). Usef32throughout to matchfast_rope'sbase: f32parameter. -
In
src/models/internlm3.rs:- Delete the private
RopeScalingstruct (lines 68-72) and typerope_scalingasOption<crate::models::config::RopeScaling>. The shared struct'salias = "rope_type"keeps the existingrope_typekey working. - Delete
ModelArgs::rope_scale,ModelArgs::dynamic_ntk_factorandcompute_dynamic_ntk_base. - Replace the
rope_base,rope_scale,rope_traditional,max_position_embeddings,dynamic_ntk_factorfields ofAttentionwith onerope: DynamicNtkRope, built inAttention::from_weightswithDynamicNtkRope::from_scaling(head_dim, args.rope_theta, args.rope_traditional, args.max_position_embeddings, args.rope_scaling.as_ref())?(the constructor already returnsResult<Self, String>). - In
Attention::forward, replace the twofast_ropecalls withself.rope.apply(&q, offset, l + offset)andself.rope.apply(&k, offset, l + offset). - Update the module doc comment at the top of the file (it describes the dynamic base but not the position scale).
- Delete the private
-
Re-pin
INTERNLM3_REF_OUTintests/causal_prefill_greedy_parity.rsfrom an oracle that applies the corrected schedule (positions unscaled, base unchanged below 32768 tokens) to the same 4-bit checkpoint and the same 56 prompt ids, and update the comment above the constant to say which oracle produced it. LeaveINTERNLM3_INPUT_IDSas is. -
Update
docs/supported-models.md:38: drop the claim of token-exactness against the old reference, describe the corrected schedule in one sentence (positions unscaled; base rescaled only pastmax_position_embeddings), and name the oracle the re-pinned ids came from.
Validation
(a) Unit tests, src/models/dynamic_ntk_rope_tests.rs:
scale_is_one_when_rope_scaling_absent:from_scaling(128, 5e7, false, 32768, None)givesposition_scale() == 1.0andmode == Default.scale_is_one_for_dynamic:RopeScaling { scaling_type: Some("dynamic"), factor: Some(6.0), ..Default::default() }givesposition_scale() == 1.0andmode == Dynamic { factor: 6.0 }.scale_is_inverse_factor_for_linear:scaling_type: Some("linear"), factor: Some(4.0)givesposition_scale() == 0.25andbase_for(100_000) == base(linear never rescales the base).rope_type_alias_is_accepted: deserialize{"factor": 6.0, "rope_type": "dynamic"}and{"factor": 2.0, "type": "dynamic"}intoconfig::RopeScaling; both buildDynamic.dynamic_base_is_unchanged_at_or_below_max_position:base_for(100)andbase_for(32768)both equal5e7exactly for thefactor = 6.0, dims = 128configuration.dynamic_base_grows_past_max_position:base_for(40000)is within1e-3relative of117_777_118.66andbase_for(65536)within1e-3relative of360_979_300.43.unknown_and_incomplete_scaling_are_rejected:scaling_type: Some("yarn")isErr;scaling_type: Some("dynamic"), factor: NoneisErr.apply_matches_fast_rope_with_unit_scale: for a[1, 2, 8, 16]float32 tensor ofarangevalues andoffset = 3,apply(x, 3, 11)underDynamic { factor: 6.0 },max_pos = 32768isarray_equaltomlxcel_core::fast_rope(x, 16, false, base, 1.0, 3); and underLinear { factor: 2.0 }it isarray_equaltofast_rope(x, 16, false, base, 0.5, 3).
Also add src/models/internlm3_tests.rs (new, attached via #[path]) with internlm3_attention_uses_unit_scale_for_dynamic_config: build ModelArgs from the validation checkpoint's config JSON (rope_theta: 50000000, rope_scaling: {"factor": 6.0, "rope_type": "dynamic"}, max_position_embeddings: 32768) and assert the attention's rope.position_scale() == 1.0 and rope.base_for(56) == 50_000_000.0.
(b) Real checkpoint: mlx-community/internlm3-8b-instruct-4bit (local models/internlm3-8b-4bit).
./target/release/mlxcel generate -m models/internlm3-8b-4bit -p "The Industrial Revolution changed the way people lived and worked because" -n 64
cargo test --profile test-fast --features metal,accelerate --test causal_prefill_greedy_parity internlm3
Acceptance: the first 24 greedy tokens on the 56-id prompt are token-exact against the corrected oracle (re-pinned INTERNLM3_REF_OUT); internlm3_prefill_is_causal still passes at its existing tolerance; CLI output is finite and fluent. A second run with a prompt of at least 64 tokens must also be token-exact, since a 6-token prompt cannot distinguish the two schedules at small positions.
Acceptance criteria
-
internlm3passesscale = 1.0tofast_ropeforrope_scaling = Noneand forrope_type = "dynamic", and1 / factoronly for"linear" - Dynamic NTK base still equals
rope_thetaforL + offset <= max_position_embeddingsand matches the formula above past it (table values) -
src/models/dynamic_ntk_rope.rsexists with a// Used by:comment andinternlm3uses it; the privateRopeScalingininternlm3.rsis gone in favor ofconfig::RopeScaling -
INTERNLM3_REF_OUTre-pinned andtests/causal_prefill_greedy_parity.rspasses onmodels/internlm3-8b-4bit -
docs/supported-models.mdInternLM entry updated - cargo test --workspace --profile test-fast --features metal,accelerate passes
- cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass
Out of scope
internlm2(tracked separately; it will consume the helper introduced here).- YaRN or Llama-3 style scaling for the InternLM families; no public checkpoint uses them.
Implementation note (PR #1389). Two deviations from the plan above, both deliberate:
- The helper is built on
rope_utils::RopeScalingSpec(fix(llama3): apply llama3 rope_scaling frequencies on the shared Llama path (Llama 3.1/3.2 text and Llama 3.2 Vision) #1355), not oncrate::models::config::RopeScaling. That struct has no importers anywhere in the tree and uses the#[serde(rename = "type", alias = "rope_type")]construct that hard-errors withduplicate fieldon a config carrying both keys. It is left untouched; the privateinternlm3::RopeScalingis deleted as planned. internlm2is in scope after all. ItsModelArgsdid not declarerope_scalingat all, so its checkpoints' live{"type": "dynamic", "factor": 2.0}was dropped at deserialization; shipping a helper "shaped for" a family that still ignores its own block was not worth the deferral. Its position scale was already correct, so everything insidemax_position_embeddingsis bit-identical andmodels/internlm2-7b-4bitgenerates byte-identical output before and after.
The workspace gate lines are checked against the local equivalents that were actually run (--lib for the mlxcel package plus the parity integration test, and clippy --lib --tests); the full --workspace sweep is the orchestrator's.
Note also that stock mlx-lm 0.31.3 carries this same defect, so the acceptance criterion "token-exact against the mlx-lm greedy reference" would have been satisfied by the buggy code. The re-pin comes from an mlx-lm whose rope module was corrected first, never from mlxcel's own output.
Activity
fix(llama3): apply rope_scaling frequencies on the shared Llama path (#…
docs(internlm): commit the rope oracle and record the new shared-help…
fix(internlm): stop doubling RoPE positions under dynamic NTK scaling (…
fix(internlm2): add family rope_scaling tests and NTK rescale log
docs(internlm2): state what the long-context run does and does not show
fix(internlm2): add family rope_scaling tests and NTK rescale log (#1736
fix(llama3): apply rope_scaling frequencies on the shared Llama path (l…
fix(internlm): stop doubling RoPE positions under dynamic NTK scaling (…