Skip the expert-parallel sentinel masking when expert parallelism is off - #48201
Conversation
`grouped_mm_experts_forward` builds a sentinel mask and runs two `masked_fill_` on tensors the size of the expert activations, on every forward and again on the backward. Sentinel ids only ever come from `RouterParallel`, i.e. only under expert parallelism; without it the mask is all-False and the work is pure memory traffic. Default the flag off where the experts module is built and set it where `MoeExpertsParallel` installs its forward.
|
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. |
|
CI was right, this was broken. The gate is never True.
Repro, 2 GPUs, # torchrun --nproc_per_node=2 repro.py
import os
import tempfile
import torch
import torch.distributed as dist
from transformers import AutoModelForCausalLM, Qwen3MoeConfig
from transformers.distributed import DistributedConfig
dist.init_process_group(backend="nccl")
rank, world = dist.get_rank(), dist.get_world_size()
torch.cuda.set_device(rank)
path = os.path.join(tempfile.gettempdir(), "tiny_qwen3_moe")
if rank == 0:
config = Qwen3MoeConfig(
vocab_size=256, hidden_size=128, intermediate_size=256, moe_intermediate_size=64,
num_hidden_layers=2, num_attention_heads=4, num_key_value_heads=2,
num_experts=8, num_experts_per_tok=2, decoder_sparse_step=1, tie_word_embeddings=False,
)
torch.manual_seed(0)
AutoModelForCausalLM.from_config(config, dtype=torch.bfloat16).save_pretrained(
path, save_original_format=True
)
dist.barrier()
model_ep = AutoModelForCausalLM.from_pretrained(
path, dtype=torch.bfloat16,
distributed_config=DistributedConfig(tp_size=world, enable_expert_parallel=True),
)
dist.barrier()
model_ref = AutoModelForCausalLM.from_pretrained(path, dtype=torch.bfloat16).to(model_ep.device)
torch.manual_seed(0)
input_ids = torch.randint(0, 256, (2, 64), device=model_ep.device)
with torch.no_grad():
logits_ref = model_ref(input_ids).logits
logits_ep = model_ep(input_ids).logits
if rank == 0:
experts = model_ep.model.layers[0].mlp.experts
print(f"experts implementation: {model_ep.config._experts_implementation}")
print(f"is_expert_parallel: {getattr(experts, 'is_expert_parallel', '<absent>')}")
print(f"NaN in EP logits: {bool(torch.isnan(logits_ep).any())}")
print(f"max abs diff vs non-EP: {(logits_ref.float() - logits_ep.float()).abs().max().item()}")
dist.destroy_process_group()On this branch: With the flag actually plumbed through: On main (no gate, masking unconditional) the same script gives no NaN and 0.0. Fix is to pass the flag from Worth flagging separately: the EP mixin tests pass on CPU even without the fix, so the CPU suite doesn't cover this path. Only the GPU run above surfaces it. |
|
thanks for the optimization ! i remember measuring it for inference only and seeing only noise 🥲 |
|
not sure but i think we can probably know if EP is enabled by checking for DTensor + sharding dim (0) of gate_up/down proj |
|
btw there are other implementations that do the same, in finegrained-fp8 and deepgemm |
|
@IlyasMoutawwakil I tried the (to verify: an in-forward probe shows Kept the explicit attribute instead, applied your other two points: the batched_mm clamp is now gated too, and the same skip is applied in finegrained-fp8 and deepgemm. |
The finegrained-fp8 and deepgemm forwards now read `self.is_expert_parallel`, which real experts modules get from `use_experts_implementation.__init__`. The kernel tests build their stand-ins as a `SimpleNamespace` and bypass the decorator, so all nine of them raised `AttributeError`. `_build_experts` carries the flag like the decorator does, and the two tests that exercise sentinels set it to `True`, since sentinels only exist under expert parallelism.
|
CI was red on nine kernel tests, all the same cause: the tests build their experts stand-in as a |
VI-Arthur
left a comment
There was a problem hiding this comment.
LGTM, much needed indeed cc @IlyasMoutawwakil
…as is Wiring is_expert_parallel through the TP styles reached further than intended. It is read at tensor_parallel.py:601, where a true value skips the _AllReduceBackward on top_k_weights, so the router gradients would stop being summed across the group under expert parallelism. Nothing else sums them. That path now behaves exactly as on main. The flag also has to reach the experts module, and a module-level plan entry does not: llama4's expert-parallel plan has ep_router and grouped_gemm but no moe_tp_experts, so the experts would never have been marked. Set it in MoEParamShard.shard_param under shards_expert_dim, which is the grouped_gemm entry every expert-parallel plan carries, and which already rewrites the num_experts the sentinel mask compares against. The masking now tests the mask rather than the flag, so the two halves, forty lines apart in finegrained_fp8, cannot disagree.
Both call sites pass it.
|
@VI-Arthur needs another look, the version you approved had a bug. Passing the flag through transformers/src/transformers/distributed/tensor_parallel.py Lines 601 to 602 in 2ec1d59 where a true value skips the Flag is now set in Also dropped the parameter from the three overrides that ignored it, per your comment. |
…ls-v2 # Conflicts: # src/transformers/integrations/moe.py
IlyasMoutawwakil
left a comment
There was a problem hiding this comment.
lgtm ! just make sure the new flag will stay relevant in the a2a PR where there are no sentinels iiuc
The experts forwards read it at call time so it has to live on the module, but nothing outside them needs it, so it is `_is_expert_parallel` now.
CI recapDashboard: View test results in Grafana |
…off (huggingface#48201) * Skip the expert-parallel sentinel masking when expert parallelism is off `grouped_mm_experts_forward` builds a sentinel mask and runs two `masked_fill_` on tensors the size of the expert activations, on every forward and again on the backward. Sentinel ids only ever come from `RouterParallel`, i.e. only under expert parallelism; without it the mask is all-False and the work is pure memory traffic. Default the flag off where the experts module is built and set it where `MoeExpertsParallel` installs its forward. * Drop the explanatory comments; the reasoning is in the PR * Pass is_expert_parallel through to the experts TP style * Gate the batched_mm sentinel clamp and extend the EP-off skip to finegrained-fp8 and deepgemm * Apply ruff format to the deepgemm call sites * Carry is_expert_parallel on the test experts stand-ins The finegrained-fp8 and deepgemm forwards now read `self.is_expert_parallel`, which real experts modules get from `use_experts_implementation.__init__`. The kernel tests build their stand-ins as a `SimpleNamespace` and bypass the decorator, so all nine of them raised `AttributeError`. `_build_experts` carries the flag like the decorator does, and the two tests that exercise sentinels set it to `True`, since sentinels only exist under expert parallelism. * Set is_expert_parallel at the call site instead of threading a parameter * Set the flag where the experts are sharded, and keep the router path as is Wiring is_expert_parallel through the TP styles reached further than intended. It is read at tensor_parallel.py:601, where a true value skips the _AllReduceBackward on top_k_weights, so the router gradients would stop being summed across the group under expert parallelism. Nothing else sums them. That path now behaves exactly as on main. The flag also has to reach the experts module, and a module-level plan entry does not: llama4's expert-parallel plan has ep_router and grouped_gemm but no moe_tp_experts, so the experts would never have been marked. Set it in MoEParamShard.shard_param under shards_expert_dim, which is the grouped_gemm entry every expert-parallel plan carries, and which already rewrites the num_experts the sentinel mask compares against. The masking now tests the mask rather than the flag, so the two halves, forty lines apart in finegrained_fp8, cannot disagree. * Drop the dead default on _dispatch_routed_input Both call sites pass it. * Keep the expert-parallel flag off the public module surface The experts forwards read it at call time so it has to live on the module, but nothing outside them needs it, so it is `_is_expert_parallel` now.
grouped_mm_experts_forwardruns the expert-parallel sentinel dance unconditionally:transformers/src/transformers/integrations/moe.py
Lines 420 to 421 in d56c55b
transformers/src/transformers/integrations/moe.py
Line 437 in d56c55b
transformers/src/transformers/integrations/moe.py
Line 465 in d56c55b
Sentinel ids are produced only by
RouterParallel._prepare_output_fn, i.e. only under expert parallelism. Without it the router'stopkcannot return an index>= num_experts, sosentinel_maskis all-Falseand the twomasked_fill_are pure memory traffic, on tensors the size of the expert activations, twice in the forward and twice again in the backward.This skips the whole thing when expert parallelism is off. The flag defaults to
Falsewhere the experts module is built, and is set toTrueinMoEParamShard.shard_paramwhen it shards the expert dimension, i.e. on thegrouped_gemmplan entry that every expert-parallel plan carries. That is also wherenum_expertsis rewritten to the per-rank count the sentinel mask compares against, so the two cannot disagree. A module-level plan entry would not do: llama4's expert-parallel plan hasep_routerandgrouped_gemmbut nomoe_tp_experts. The expert-parallel path is unchanged.Numbers
One
Qwen3MoeSparseMoeBlockfromQwen/Qwen3-30B-A3B(128 experts, top-8,hidden=2048), seq 4096, bf16, fwd+bwd, single H100,grouped_mm:bench_sentinel.py — the script that produced the table
End to end on 2×H100 with FSDP2 (
Qwen/Qwen3-30B-A3B, seq 4096, per-device batch 2, LoRA r=16 on attention,chunked_nll):The same 1.07× was measured before #47579 refactored this code, on an 8×H100 run of the same model, and 1.05× on
Qwen3.6-35B-A3B.In an 8×H100 profile of that model,
aten::masked_fill_was 2.0% of all GPU kernel time at per-device batch 1 and 3.8% at batch 4, entirely from these two calls.