Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6efad5b
fix(test): repair legacy partial-rotary spec in vanilla-HF parity ref…
yuhezhang-ai Aug 23, 2026
fa7900e
test(ci): use the shared relaxed resume envelope for MiniMax M2.7 LoRA
yuhezhang-ai Aug 23, 2026
f084608
fix(moe): score sigmoid routing in fp32 by default
yuhezhang-ai Aug 23, 2026
b5e4162
fix(test): pin fp32 reference tensors across vanilla-HF layout differ…
yuhezhang-ai Aug 23, 2026
f60a246
fix(minimax): restore the checkpoint's fp32 router end to end
yuhezhang-ai Aug 25, 2026
e894ea4
fix(moe): allocate fp32-contract router gates in fp32 at construction
yuhezhang-ai Aug 24, 2026
cd2c0e1
test(ci): gate MiniMax M2.7 cross-framework parity at the measured en…
yuhezhang-ai Aug 25, 2026
a83a083
fix(minimax): extend the fp32 router contract and robustness gates to…
yuhezhang-ai Aug 26, 2026
f1e2bed
fix(test): resolve in-tree reference configs past AutoModel registrat…
yuhezhang-ai Aug 26, 2026
5cdfa10
test(ci): fit the 427B MiniMax-M3 vanilla reference on rank 0's node
yuhezhang-ai Aug 26, 2026
5f2ec3c
fix(test): apply device-map memory caps to the source-load reference
yuhezhang-ai Aug 26, 2026
ac5194f
test(ci): skip the intractable 427B M3-VL vanilla-HF phases
yuhezhang-ai Aug 26, 2026
6fb4b3d
fix(test): restrict fp32 leaf aliases to distinctive names
yuhezhang-ai Aug 26, 2026
0728023
test(ci): right-size the M3-VL robustness job time to the measured ru…
yuhezhang-ai Aug 26, 2026
1376177
refactor(moe): resolve the gate score dtype once at construction
yuhezhang-ai Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,33 @@ ci:
# Use Transformers' built-in MiniMax M2 implementation for the HF+PEFT
# reload; the checkpoint's remote code targets an older Transformers API.
trust_remote_code: false
# Keep the cross-framework gates blocking: the current built-in-HF mismatch
# must be fixed or explicitly proven to be an invalid reference before it is skipped.
tokenizer_name: MiniMaxAI/MiniMax-M2.7
hf_device_map_auto: true
# Measured envelope for the two cross-framework gates (AMINT-286). The
# residual AM-vs-HF divergence is knife-edge routing amplifying bf16
# kernel noise, not an implementation defect: the checkpoint's fp32
# e_score_correction_bias lattices put top-8 selection inside the
# kernel-noise band (68% of tokens flip an expert within five layers,
# pipeline 64182587), and the HF reference against itself (eager vs sdpa
# attention, 62-layer sweep on real weights) diverges at the same order
# (mean KL 0.237 vs AutoModel's 0.320). Gate the full 2048-token document
# at the measured envelope (three scoped runs: mean KL 0.086-0.094,
# p95 0.346-0.375, cos 0.964-0.965); real conversion or model-math bugs
# stay loud (the repaired rope-reference bug measured mean KL 13.1).
parity_threshold_overrides:
source_load:
mean_kl: 0.15
p95_kl: 0.5
cosine_similarity: 0.95
hf_reload:
mean_kl: 0.15
p95_kl: 0.5
cosine_similarity: 0.95
# Restored state and the first resumed forward are exact; three scoped-CI
# runs measured 0.012-0.020 routed-MoE loss drift at continuation steps 6-7
# versus the standard 0.0117 allowance (pipelines 63311388, 63340676,
# 64093591). Match the routed hybrid-MoE precedent and use the shared
# relaxed resume envelope; every logit gate stays standard.
resume_tolerance_profile: relaxed
dataset.num_samples_limit: 500
validation_dataset.num_samples_limit: 500
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,25 @@ wandb:
ci:
recipe_owner: athitten
nodes: 8
time: "00:20:00"
# The robustness matrix (train/save, reload, resume) measured 1028s of
# phase time on the first scoped run (job 412098422, ~32 min including
# CI-side setup outside the Slurm window); 45 min covers it with headroom.
time: "00:45:00"
checkpoint_robustness:
# PP=4 with pp_microbatch_size=1 needs at least four pipeline microbatches;
# dp = 64/4 = 16, so global = 4 * 16 with one grad-accum step.
step_scheduler.local_batch_size: 4
step_scheduler.global_batch_size: 64
tokenizer_name: MiniMaxAI/MiniMax-M3
# The 427B (854 GiB bf16) vanilla reference does not fit rank 0's eight
# GPUs (uncapped device_map OOMs on the fused-expert concat; capped, it
# CPU-offloads ~400 GiB and idles all 64 GPUs for hours per run). Skip
# both vanilla-HF phases entirely: MiniMax-M2.7 carries the family's
# blocking cross-framework parity gates with the same measured knife-edge
# router lattice (AMINT-286), while this recipe keeps the AutoModel-side
# gates blocking (train/save, bitwise reload, resume) for the VL wrapper
# and mixed dense/sparse decoder.
skip_source_load_parity: true
skip_hf_reload: true
# Routed hybrid-MoE resume precedent (MiniMax M2.7, Nemotron chat).
resume_tolerance_profile: relaxed
20 changes: 18 additions & 2 deletions nemo_automodel/components/models/minimax_m2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Any, Union

import torch
Expand Down Expand Up @@ -98,6 +98,14 @@ def __init__(
moe_overrides: dict | None = None,
):
super().__init__()
# Released MiniMax-M2 checkpoints store the router gate weight in fp32,
# and the HF reference projects with hidden_states.to(weight.dtype), so
# the checkpoint-faithful router runs an fp32 projection, fp32 scoring,
# and fp32 selected weights. Keep that default while preserving an
# explicit backend override (see AMINT-286; ERNIE follows the same
# pattern for its fp32 router).
if backend.gate_precision is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 would it make sense to have this if -> set default in the __post_init__ of BackendConfig? I'm wondering if that would break anything, otherwise looks like it would us some headache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think None needs to remain the BackendConfig default. It means the caller did not specify a gate-compute dtype, allowing each model to install its reference-compatible policy while preserving an explicit override.

Making FP32 the global default in BackendConfig.__post_init__ would change projection arithmetic for router families whose references intentionally project in BF16, including Qwen3 MoE, DeepSeek V4, GPT-OSS, Mistral 4, and Laguna. #3650 specifically found that a global FP32 router policy would be incorrect.

For MiniMax, the F32 checkpoint gate and FP32 routing chain are model-owned, so I think this conditional belongs here. We could consider a follow-up helper to reduce the repeated immutable replace(...) pattern, but it should still receive the model-specific default explicitly and preserve None globally.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fair point @yuhezhang-ai , perhaps i should provide more context, what i wanted to ensure is that later the code accesses gate_precision without assuming there's a case its be None, right now i see something like

            scores = torch.sigmoid(scores.to(dtype=self.gate_precision or torch.float32))

If possible, i feel it would be easier to maintain if it used scores.to(dtype=self.gate_precision).

backend = replace(backend, gate_precision=torch.float32)
self.backend = backend
self.config = config
if moe_config is not None and moe_overrides is not None:
Expand Down Expand Up @@ -133,6 +141,14 @@ def __init__(
expert_activation="swiglu",
softmax_before_topk=(score_func == "softmax"),
force_e_score_correction_bias=True,
# The HF reference returns selected weights in the fp32 router
# logits dtype; keep them fp32 through the expert combine.
router_weights_fp32=True,
# The checkpoint stores the gate weight in fp32; allocate it fp32 so
# every construction path (including meta-device init before FSDP
# sharding) keeps the gate's dtype group uniform with its fp32
# correction-bias buffer.
gate_dtype=torch.float32,
dtype=model_dtype,
)
if moe_overrides:
Expand Down Expand Up @@ -229,7 +245,7 @@ def init_weights(self, buffer_device: torch.device | None = None) -> None:

class MiniMaxM2ForCausalLM(HFCheckpointingMixin, nn.Module, MoEFSDPSyncMixin):
tie_word_embeddings_support: TieSupport = TieSupport.UNTIED_ONLY
_keep_in_fp32_modules_strict = ["mlp.gate.e_score_correction_bias"]
_keep_in_fp32_modules_strict = ["mlp.gate.weight", "mlp.gate.e_score_correction_bias"]

@dataclass(frozen=True)
class ModelCapabilities:
Expand Down
9 changes: 7 additions & 2 deletions nemo_automodel/components/models/minimax_m3_vl/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ def build_moe_config(config: Any, dtype: torch.dtype) -> MoEConfig:
activation_limit=float(getattr(config, "swiglu_limit", 7.0)),
softmax_before_topk=False,
force_e_score_correction_bias=bool(getattr(config, "use_routing_bias", True)),
# Released MiniMax-M3 checkpoints store the router gate weight in fp32
# (same 1e-3-quantized correction-bias lattice as MiniMax-M2.7); allocate
# it fp32 so every construction path keeps the gate's FSDP dtype group
# uniform with its fp32 bias buffer (AMINT-286 pattern).
gate_dtype=torch.float32,
dtype=dtype,
)

Expand Down Expand Up @@ -264,7 +269,7 @@ class MiniMaxM3SparseForCausalLM(HFCheckpointingMixin, nn.Module, MoEFSDPSyncMix

tie_word_embeddings_support: TieSupport = TieSupport.UNTIED_ONLY

_keep_in_fp32_modules_strict = ["mlp.gate.e_score_correction_bias"]
_keep_in_fp32_modules_strict = ["mlp.gate.weight", "mlp.gate.e_score_correction_bias"]

# The state-dict adapter loads every tensor from the checkpoint, so skip HF
# random init on load (also avoids DTensor-collective hangs under sharding/PP).
Expand Down Expand Up @@ -389,7 +394,7 @@ class MiniMaxM3SparseForConditionalGeneration(HFCheckpointingMixin, nn.Module, M
# (vision_encoder.py) fp32 — the bf16 cast would otherwise round it and degrade
# vision RoPE (see llama/rope_utils.py).
_keep_in_fp32_modules = ["rotary_emb", "inv_freq"]
_keep_in_fp32_modules_strict = ["mlp.gate.e_score_correction_bias"]
_keep_in_fp32_modules_strict = ["mlp.gate.weight", "mlp.gate.e_score_correction_bias"]
_pp_keep_self_forward: bool = True
mtp_outputs_are_logits = True
# Opt into context parallelism on the SDPA attention backend (M3's block-sparse DSA
Expand Down
7 changes: 7 additions & 0 deletions nemo_automodel/components/moe/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ class MoEConfig:
router_weights_fp32: bool = False
router_weight_uses_score_correction_bias: bool = False
dtype: str | torch.dtype = torch.bfloat16
# Storage dtype for the router gate parameters. None inherits ``dtype``.
# Models whose checkpoints store the gate in fp32 (e.g. MiniMax-M2) set
# this so the gate is fp32 from allocation on every construction path,
# keeping FSDP dtype groups uniform with the fp32 correction-bias buffer.
gate_dtype: str | torch.dtype | None = None
shared_expert_gate: bool = False
shared_expert_inter_dim: int | None = None
shared_expert_activation: str = "swiglu" # Activation for shared experts ("swiglu" or "relu2")
Expand All @@ -74,6 +79,8 @@ def expert_dim(self) -> int:
def __post_init__(self):
if isinstance(self.dtype, str):
self.dtype = dtype_from_str(self.dtype, default=torch.bfloat16)
if isinstance(self.gate_dtype, str):
self.gate_dtype = dtype_from_str(self.gate_dtype, default=torch.bfloat16)


@dataclass
Expand Down
14 changes: 10 additions & 4 deletions nemo_automodel/components/moe/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,13 @@ def __init__(
if self.bias_update_factor > 0:
assert self.train_gate, "Require train_gate to be set to True to apply the bias update"

gate_dtype = config.gate_dtype or config.dtype
self.weight = nn.Parameter(
torch.empty(config.n_routed_experts, config.dim, dtype=config.dtype), requires_grad=self.train_gate
torch.empty(config.n_routed_experts, config.dim, dtype=gate_dtype), requires_grad=self.train_gate
)
if config.router_bias:
self.bias = nn.Parameter(
torch.empty(config.n_routed_experts, dtype=config.dtype), requires_grad=self.train_gate
torch.empty(config.n_routed_experts, dtype=gate_dtype), requires_grad=self.train_gate
)
else:
self.bias = None
Expand Down Expand Up @@ -458,7 +459,11 @@ def _route_scores(self, scores: torch.Tensor) -> tuple[torch.Tensor, torch.Tenso
indices = replay_selection(self.router_replay, indices)
weights = original_scores.gather(1, indices)
elif self.score_func == "sigmoid_with_bias":
scores = scores.sigmoid()
# Score in fp32 like the softmax path: HF sigmoid-router references
# compute sigmoid(logits.float()), and bf16 sigmoid quantizes scores
# at ~2e-3 — enough to flip knife-edge e_score_correction_bias
# selections (AMINT-286).
scores = torch.sigmoid(scores.to(dtype=self.gate_precision or torch.float32))
original_scores = scores
scores_for_choice = scores

Expand All @@ -479,7 +484,8 @@ def _route_scores(self, scores: torch.Tensor) -> tuple[torch.Tensor, torch.Tenso
indices = replay_selection(self.router_replay, indices)
weights = original_scores.gather(1, indices)
else:
scores = scores.sigmoid()
# Score in fp32 like the softmax path (see sigmoid_with_bias above).
scores = torch.sigmoid(scores.to(dtype=self.gate_precision or torch.float32))
original_scores = scores

# Add correction bias to balance tokens across gates.
Expand Down
Loading
Loading