Skip to content

Honor config.output_router_logits in the MoE VLM wrappers - #48885

Merged
qgallouedec merged 8 commits into
mainfrom
moe-vlm-output-router-logits-config
Sep 23, 2026
Merged

qgallouedec merged 8 commits into
mainfrom
moe-vlm-output-router-logits-config

Conversation

@qgallouedec

@qgallouedec qgallouedec commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

CPU CI GPU run-slow

Four ForConditionalGeneration wrappers read the flag straight off kwargs instead of resolving it against the config, so config.output_router_logits = True does nothing and only a forward argument turns the auxiliary loss on. Every text-only MoE honors both.

Qwen3MoeForCausalLM is the reference:

output_router_logits = (
output_router_logits if output_router_logits is not None else self.config.output_router_logits
)

if kwargs.get("output_router_logits", False):

if kwargs.get("output_router_logits", False):

if kwargs.get("output_router_logits", False):

if kwargs.get("output_router_logits", False):

Qwen3.5-MoE and Qwen4-Exp carry both patterns in the same file, the text model resolving against the config and the wrapper ignoring it:

output_router_logits = (
output_router_logits if output_router_logits is not None else self.config.output_router_logits
)

vs
if kwargs.get("output_router_logits", False):

After, all three agree with the reference, and an explicit output_router_logits=False still wins over a config True.

Changes

Resolve the argument against the text config before the backbone call, the way Qwen3MoeForCausalLM.forward does, and pass it down explicitly. Two modular files cover all four, since Qwen3_5MoeForConditionalGeneration(Qwen3VLMoeForConditionalGeneration) and Qwen4ExpForConditionalGeneration(Qwen3_5MoeForConditionalGeneration).

The two configs also need the field:

One test per modular source, asserting the config default is honored and that the explicit argument wins over it.

Why it matters

Downstream code that decides "can this model produce the auxiliary loss?" from the config gets the wrong answer in both directions. This is what TRL's router_aux_loss_coef was keyed on (huggingface/trl#7222): the coefficient was silently ignored on Qwen3-VL-MoE and GLM4V-MoE.

Not covered here

This aligns how the flag is read, not what every MoE does with it. Four models still expose aux_loss in their output type and never populate it, so outputs.aux_loss is None even with the flag on:

output_router_logits argument returns router_logits computes aux_loss
AFMoE yes yes no
Zaya yes yes no
Cohere2-MoE no yes no
HY-V3 no yes no

HYV3ForCausalLM is explicit about it: aux_loss=None, # Not used in this model. Either the loss should be computed from the router logits these models already return, or the field should be dropped from their output types. That is a per-architecture question, so it belongs in its own PR.

Four `ForConditionalGeneration` wrappers read the flag straight off `kwargs` instead of resolving it
against the config, so setting `config.output_router_logits = True` did nothing and only a forward
argument turned the auxiliary loss on: Qwen3-VL-MoE, GLM4V-MoE, Qwen3.5-MoE and Qwen4-Exp. Every
text-only MoE honors both, Qwen3-MoE being the reference.

Resolve the argument against the text config before the backbone call, as `Qwen3MoeForCausalLM` does,
and pass it down explicitly. Two modular files cover all four, since Qwen3.5-MoE and Qwen4-Exp inherit
the wrapper from Qwen3-VL-MoE.

`Qwen3VLMoeTextConfig` had `output_router_logits` deleted in #41250 because nothing read it, and
`Glm4vMoeTextConfig` never had it, inheriting from the auxiliary-loss-free `Glm4MoeConfig`. Both need
it now, so restore one and add the other.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Same gap as the other four, one step further along. `MiniMaxM3VLTextForCausalLM` implements the
auxiliary loss in full, while `MiniMaxM3SparseForConditionalGeneration` has no `output_router_logits`
argument and no auxiliary-loss block, and neither of its output types carries `aux_loss`.

Add the argument and resolve it against the text config like the others, carry `router_logits` through
the backbone output instead of dropping it when the output is rebuilt, and add `aux_loss` and
`router_logits` to the output types.
Comment on lines +208 to +220
def test_output_router_logits_from_config(self):
"""`config.output_router_logits` turns the auxiliary loss on, and an explicit argument wins over it."""
config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
model = Glm4vMoeForConditionalGeneration(config).to(torch_device).eval()
input_ids = inputs_dict["input_ids"]

assert model(input_ids).aux_loss is None
assert model(input_ids, output_router_logits=True).aux_loss is not None

model.config.get_text_config().output_router_logits = True
assert model(input_ids).aux_loss is not None
assert model(input_ids, output_router_logits=False).aux_loss is None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i thought we had a test in common mixin, but seeing that the PR never got merged due to merge-queue checkd (#46642)

Might be worth to move into mixin which enforces for all models

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ah also the linked PR added aux-loss in gemma4 family, i suppose it is still missing so let's add (if it is still needed)

qgallouedec added a commit that referenced this pull request Sep 18, 2026
The modular graph carried the recorder and the config field into models this branch never named:
DeepSeek-OCR2 and GLM5-Next through DeepseekV3/Glm4Moe, GLM4V-MoE through Glm4MoeConfig, Youtu through
DeepseekV3Config. CI's modular check regenerates everything, so those files have to match.

Youtu is dense and DeepSeek-OCR2's wrapper does not carry router logits, so both delete the inherited
`output_router_logits` alongside the other MoE fields they already drop. GLM4V-MoE reads the flag as a
forward kwarg and #48885 wires it explicitly, so it is allow-listed here until then. Cohere2-MoE's head
now takes and resolves the flag like the others, which is also what makes the checker see it.

@Cyrilvallez Cyrilvallez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, let's just make the test general instead of model specific.
We can either merge both PR (the one linked by @zucchini-nlp) or merge separately, no strong opinion.
One important thing to consider is that the general test from the linked PR relies on the config attr to be present to trigger the test, which is exactly what both PR fix -> so would not detect faulty configs. Maybe we should find a better way to detect MoEs to have a more robust test, maybe if "router_logits"is in _can_record_outputs is a more robust way

Comment on lines +208 to +215
def test_output_router_logits_from_config(self):
"""`config.output_router_logits` turns the auxiliary loss on, and an explicit argument wins over it."""
config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common()
model = Glm4vMoeForConditionalGeneration(config).to(torch_device).eval()
input_ids = inputs_dict["input_ids"]

assert model(input_ids).aux_loss is None
assert model(input_ids, output_router_logits=True).aux_loss is not None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As @zucchini-nlp said, would be better as a general test instead!

@qgallouedec

Copy link
Copy Markdown
Member Author

Moved to ModelTesterMixin as test_output_router_logits_from_config.

It runs on every model whose config has output_router_logits and whose head declares router_logits in its output type, checks that the config flag turns the logits on (and the aux loss where the model computes one) and that an explicit argument wins over it.

Gemma4: it has no output_router_logits at all, so there is nothing for this PR to honor. Adding the aux loss there is a modeling change, separate PR.

@zucchini-nlp zucchini-nlp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few tests failing, can merge after fixing them

*(getattr(t, "__dataclass_fields__", {}).keys() for t in (get_args(return_type) or (return_type,)))
)
if "router_logits" not in output_fields:
self.skipTest(f"{model_class.__name__} does not declare router_logits in its output type.")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

unrelated to this pr - if config has output_router_logits, it should prob have the field in outputs of BaseModels

Or if this is to guard a base model vs generative model, then we can filter all_model_names by get_values(MODEL_MAPPING_NAMES)

@github-actions

Copy link
Copy Markdown
Contributor

[For maintainers] Suggested jobs to run (before merge)

run-slow: glm4v_moe, jetmoe, minimax_m3_vl, qwen3_5_moe, qwen3_vl_moe, qwen4_exp

@qgallouedec

Copy link
Copy Markdown
Member Author

Fixed the 4 failures in 5afac8b: JetMoe ignored config.output_router_logits (same bug as the VLMs, now resolved against the config), and musicgen's composite config needs get_text_config(decoder=True) in the common test. The Qwen4-Exp TP failure is a crash in ple_embedding on the text-only model, which this PR doesn't touch.

run-slow: glm4v_moe, jetmoe, minimax_m3_vl, qwen3_5_moe, qwen3_vl_moe, qwen4_exp

@qgallouedec
qgallouedec added this pull request to the merge queue Sep 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 35919856028:1
Result: success | Jobs: 16 | Tests: 195,238 | Failures: 4 | Duration: 16h 50m

Merged via the queue into main with commit f324707 Sep 23, 2026
114 checks passed
@qgallouedec
qgallouedec deleted the moe-vlm-output-router-logits-config branch September 23, 2026 22:42
Stanley00 pushed a commit to stanley-fork/hf-transformers that referenced this pull request Oct 5, 2026
…ngface#48920)

* Return router logits from the MoE models that balance with a router bias

17 MoE models compute router logits in their gate and drop them: the MoE block unpacks the gate's
tuple past them, nothing records them, and the LM head returns a plain `CausalLMOutputWithPast`. So
`output_router_logits=True` is silently ignored on DeepSeek-V3/V3.2, GLM4-MoE and Lite, GLM-MoE-DSA,
HY-V4, Solar-Open, MiMo-V2-Flash, AXK1/2, LongCat-Flash, Kimi-Linear, dots1, EXAONE-MoE, LFM2-MoE and
Nemotron-H. Two of them, EXAONE-MoE and Kimi-Linear, already declared the recorder; the head dropped
the value when rebuilding its output field by field.

These models balance their experts with a per-expert bias rather than an auxiliary loss, so not
computing a loss is right. The logits are still what you need to inspect expert load, and what a
trainer needs to compute its own balancing term.

Same shape as Qwen3-MoE and AFMoE: a `router_logits` recorder on the pretrained base, the backbone
returns `MoeModelOutputWithPast`, the LM head takes `output_router_logits`, resolves it against the
config, passes it down and returns `MoeCausalLMOutputWithPast`, and the config gains the field. One
change in `deepseek_v3` propagates through the modular graph to eleven of them; `dots1`, `exaone_moe`,
`lfm2_moe` and `nemotron_h` get it by hand. `LongcatFlashTopkRouter` now returns its logits first,
like the DeepSeek-V3 router it derives from. `aux_loss` stays `None` on all of them, and the docstring
on `MoeCausalLMOutputWithPast` now says so instead of tying it to `labels`.

A new common test, `test_router_logits_are_returned`, holds every model that records router logits
to actually returning them from heads whose output type carries the field. It caught Doge resolving
`output_router_logits` and never passing it to its backbone, fixed here too.

* Also return router logits from DeepSeek-V2, HunYuan-V1-MoE and Mistral4

huggingface#48911 listed the bias-balanced models. These three have neither a bias nor a loss in HF and were
hiding their logits the same way: thin modulars over Llama with a router that already returns
`router_logits` first, and heads that return `CausalLMOutputWithPast`. Same recipe as the others.

* Return Llama 4's router logits and drop the coefficient nobody chose

`Llama4TextConfig` declared `output_router_logits` and `router_aux_loss_coef` and the modeling read
neither. The recorder existed but pointed at `Llama4TextMoe`, which returns hidden states; the router
is what returns `(router_scores, router_logits)`. Both heads then rebuilt their outputs without the
field.

Point the recorder at `Llama4Router`, index 1, return `MoeModelOutputWithPast` from the text model and
`MoeCausalLMOutputWithPast` from the causal head, and carry `router_logits` through
`Llama4CausalLMOutputWithPast`. `output_router_logits=True` now returns one tensor per MoE layer of
shape `(batch * seq, num_local_experts)`.

`router_aux_loss_coef` goes. Meta published no training recipe and their reference implementation
has no balancing loss, so the 0.001 was the porting template's default, not a choice. Downstream code
that reads the coefficient as "this model trains with the loss" was crashing on Llama 4 because of it.

* Give Cohere2-MoE the output_router_logits config key it was already honouring

Its backbone is decorated with merge_with_config_defaults, so the kwarg already worked; only the
config default was missing, which made the key an unreliable marker for the one model that returned
router logits without declaring it.

* Regenerate the downstream models and keep check_config_attributes honest

The modular graph carried the recorder and the config field into models this branch never named:
DeepSeek-OCR2 and GLM5-Next through DeepseekV3/Glm4Moe, GLM4V-MoE through Glm4MoeConfig, Youtu through
DeepseekV3Config. CI's modular check regenerates everything, so those files have to match.

Youtu is dense and DeepSeek-OCR2's wrapper does not carry router logits, so both delete the inherited
`output_router_logits` alongside the other MoE fields they already drop. GLM4V-MoE reads the flag as a
forward kwarg and huggingface#48885 wires it explicitly, so it is allow-listed here until then. Cohere2-MoE's head
now takes and resolves the flag like the others, which is also what makes the checker see it.

* Return router logits from the Qwen3-Omni-MoE thinker

* Inherit the MoE backbones and heads through modular instead of redefining them

DeepseekV2Model inherits OlmoeModel, DeepseekV2ForCausalLM is the one
router-logits head without a balancing loss, and DeepSeek-V3, HunYuan-V1-MoE,
Mistral4, Dots1, Exaone-MoE and LFM2-MoE inherit from those.

* Drop duplicate output_router_logits field in Glm4vMoeTextConfig after merge

* Address review: rely on the output-capturing decorators, inherit where possible, fix Step3p7 and DeepSeek-OCR2, rework the test

* Return router logits from Aria, Inkling and Gemma4

* Align the aux_loss docstring with router_logits

* Drop the subTest and the per-model output_router_logits allowances

* Address review: keep step3p7 on Gemma3TextModel, no router logits for gemma4_unified, Aria inherits DeepseekV2ForCausalLM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants