Skip to content

Commit be5d783

Browse files
faresoPrimeclaude
andcommitted
feat(trainer): MoE loss-free load balancing and block dropout
Add training-time MoE load balancing and per-block dropout to the custom model implementation. MoE load balancing via [model.moe_load_balance] (mode/coeff), off by default. mode='loss_free' applies the DeepSeek auxiliary-loss-free bias update (arXiv:2408.15664) once per optimizer step: expert_bias += coeff * sign(load error), from a per-step usage accumulator, with the delta recentered to zero mean (matches torchtitan) so the bias vector doesn't drift a global offset. Expert usage is aggregated across the dp_cp group so balancing targets the whole global batch. Default coeff 1e-3. Before this, prime-rl only allocated the expert_bias buffer (loaded from checkpoints and frozen) with no update. model.dropout applies dropout at each block's output before the residual add (attention + FFN/MoE), intended for SFT/self-distillation (e.g. 0.15); default 0.0 so RL is unaffected. SFT validation switches the model to eval mode when dropout is active so val/loss stays deterministic (skipped when dropout is off to avoid torch.compile recompilation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a977ec commit be5d783

8 files changed

Lines changed: 143 additions & 5 deletions

File tree

docs/advanced.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ This page covers the specialized features layered on top of the core training st
66

77
- [Custom Modeling](#custom-modeling)
88
- [Expert Parallelism Backends](#expert-parallelism-backends)
9+
- [MoE Load Balancing](#moe-load-balancing)
10+
- [Block Dropout](#block-dropout)
911
- [Multimodal Training](#multimodal-training)
1012
- [Supported Families](#supported-families)
1113
- [Enabling VLM Mode](#enabling-vlm-mode)
@@ -50,6 +52,22 @@ DeepEP requires some careful tuning to achieve optimal performance, tuning param
5052

5153
With DeepEP, gradient clipping is currently not supported. (`optim.max_norm` is set to `None` automatically.)
5254

55+
### MoE Load Balancing
56+
57+
Custom MoE models can rebalance expert usage during training via `[model.moe_load_balance]`. This is off by default (a pretrained router bias, if present, is still used for routing).
58+
59+
```toml
60+
[model.moe_load_balance]
61+
mode = "loss_free" # "off" (default) | "loss_free"
62+
coeff = 1e-3 # bias update step size
63+
```
64+
65+
**`loss_free`** applies the DeepSeek auxiliary-loss-free update ([arXiv:2408.15664](https://arxiv.org/abs/2408.15664)): each optimizer step it nudges the router `expert_bias` toward balanced usage by `coeff · sign(load_error)` (the delta is recentered to zero mean so the bias vector doesn't drift a global offset), with no extra loss term. Expert token counts are summed across the data-parallel (`dp_cp`) group so balancing targets the whole global batch rather than each rank's shard.
66+
67+
### Block Dropout
68+
69+
`model.dropout` (default `0.0`) applies dropout at each block's output before the residual add — attention block and FFN/MoE block. It is intended for SFT/self-distillation (e.g. `0.15`); leave it at `0.0` for RL. Requires the custom model implementation.
70+
5371
## Multimodal Training
5472

5573
### Supported Families

packages/prime-rl-configs/src/prime_rl/configs/trainer.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,31 @@ class DebugModelConfig(BaseConfig):
116116
"""Replace MoE token-choice routing with a round-robin assignment so every expert sees an equal share. Intended for fake-data smoke tests where untrained routing would otherwise OOM under severe imbalance. Gating scores are still gathered from the override indices so the forward pass stays consistent."""
117117

118118

119+
class MoELoadBalanceConfig(BaseConfig):
120+
"""Training-time MoE load balancing (only affects the custom MoE implementation).
121+
122+
Auxiliary-loss-free bias balancing (DeepSeek, https://arxiv.org/abs/2408.15664). Expert usage
123+
is aggregated across the data-parallel group so balancing targets the whole global batch rather
124+
than each rank's local shard.
125+
"""
126+
127+
mode: Literal["off", "loss_free"] = "off"
128+
"""``off`` disables training-time balancing (a pretrained ``expert_bias`` is still used for routing if present). ``loss_free`` updates the router ``expert_bias`` each optimizer step from expert usage."""
129+
130+
coeff: float = Field(1e-3, gt=0)
131+
"""Bias update step size for ``loss_free`` (``expert_bias += coeff * sign(load_error)``)."""
132+
133+
119134
class ModelConfig(BaseModelConfig):
120135
seq_len: int = 2048
121136
"""Sequence length the model is trained on."""
122137

138+
dropout: float = Field(0.0, ge=0, le=1)
139+
"""Dropout applied at each block's output before the residual add (attention block and FFN/MoE block). Intended for SFT/self-distillation (e.g. 0.15); leave at 0 for RL. Requires the custom model implementation."""
140+
141+
moe_load_balance: MoELoadBalanceConfig = MoELoadBalanceConfig()
142+
"""Training-time MoE load-balancing configuration."""
143+
123144
attn: AttnImplementation = "flash_attention_2"
124145
"""Attention implementation. With CP enabled, ring attention uses the matching kernel family (FA2/FA3/FA4)."""
125146

src/prime_rl/trainer/model.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import torch
1313
import torch._dynamo
14+
import torch.distributed as dist
1415
import torch.nn as nn
1516
from huggingface_hub import snapshot_download
1617
from jaxtyping import Int
@@ -446,6 +447,39 @@ def get_load_balance_stats(
446447
}
447448

448449

450+
def set_block_dropout(model: nn.Module, dropout_p: float) -> None:
451+
"""Set the pre-residual block-output dropout probability on every supported block (attention,
452+
dense MLP, MoE, LatentMoE). Dropout is applied to each block's output before the residual add.
453+
``dropout_p == 0.0`` is a no-op at runtime."""
454+
for module in model.modules():
455+
if hasattr(module, "dropout_p"):
456+
module.dropout_p = dropout_p
457+
458+
459+
@torch.no_grad()
460+
def update_expert_bias(model: nn.Module, update_rate: float, dp_group=None) -> None:
461+
"""Auxiliary-loss-free load balancing (DeepSeek, https://arxiv.org/abs/2408.15664).
462+
463+
Nudges each MoE layer's router ``expert_bias`` toward balanced usage by ``update_rate`` in the
464+
direction of the sign of the load error, using the usage accumulated since the last call
465+
(``expert_load_acc``) summed across ``dp_group`` for the global batch. Call once per optimizer
466+
step. The bias affects routing selection only, not gating.
467+
"""
468+
language_model = get_language_model(model)
469+
for transformer_block in language_model.layers:
470+
block_mlp = getattr(transformer_block, "mlp", None)
471+
if block_mlp is None or getattr(block_mlp, "expert_bias", None) is None:
472+
continue
473+
counts = block_mlp.expert_load_acc
474+
if dp_group is not None:
475+
counts = counts.clone()
476+
dist.all_reduce(counts, op=dist.ReduceOp.SUM, group=dp_group)
477+
delta = update_rate * torch.sign(counts.mean() - counts) # positive where under-loaded
478+
delta = delta - delta.mean() # zero-mean so the bias vector doesn't drift a global offset
479+
block_mlp.expert_bias.add_(delta)
480+
block_mlp.expert_load_acc.zero_()
481+
482+
449483
def get_model(
450484
config: ModelConfig, device: torch.device = torch.device("cpu"), dtype: torch.dtype = torch.bfloat16
451485
) -> nn.Module:

src/prime_rl/trainer/models/layers/attn.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ def __init__(self, config: AttentionConfig, flash_attn_version: int = 2):
7272
config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
7373
)
7474
self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.output_bias)
75+
# Pre-residual block-output dropout. Set via set_block_dropout(); 0.0 is a no-op.
76+
self.dropout_p = 0.0
7577
self.use_qk_norm = config.use_qk_norm
7678
self.qk_norm_type = config.qk_norm_type
7779
if self.use_qk_norm:
@@ -180,6 +182,7 @@ def forward(
180182
max_seqlen=max_seqlen,
181183
)
182184
attn_output = self.output_proj(attn_output)
185+
attn_output = F.dropout(attn_output, p=self.dropout_p, training=self.training)
183186
return attn_output, None
184187

185188

@@ -203,6 +206,8 @@ def __init__(self, config: AttentionConfig):
203206
config.hidden_size, config.num_key_value_heads * self.head_dim, bias=config.attention_bias
204207
)
205208
self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=config.output_bias)
209+
# Pre-residual block-output dropout. Set via set_block_dropout(); 0.0 is a no-op.
210+
self.dropout_p = 0.0
206211
self.use_qk_norm = config.use_qk_norm
207212
self.qk_norm_type = config.qk_norm_type
208213
if self.use_qk_norm:
@@ -277,6 +282,7 @@ def forward(
277282

278283
attn_output = self._attention_core(query_states, key_states, value_states)
279284
attn_output = self.output_proj(attn_output)
285+
attn_output = F.dropout(attn_output, p=self.dropout_p, training=self.training)
280286
return attn_output, None
281287

282288

src/prime_rl/trainer/models/layers/mlp.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
22

33
import torch
4+
import torch.nn.functional as F
45
from torch import nn
56
from transformers.activations import ACT2FN
67

@@ -23,7 +24,9 @@ def __init__(self, config: MLPConfig):
2324
self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
2425
self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False)
2526
self.gate_act_fn = ACT2FN[config.gate_act]
27+
# Pre-residual block-output dropout. Set via set_block_dropout(); 0.0 is a no-op.
28+
self.dropout_p = 0.0
2629

2730
def forward(self, x, routed_experts: torch.Tensor | None = None):
2831
down_proj = self.down_proj(self.gate_act_fn(self.gate_proj(x)) * self.up_proj(x))
29-
return down_proj
32+
return F.dropout(down_proj, p=self.dropout_p, training=self.training)

src/prime_rl/trainer/models/layers/moe.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,6 @@ def forward(
494494
min=0,
495495
max=self.num_experts,
496496
)
497-
498497
return top_scores, selected_experts_indices, num_tokens_per_expert, routing_confidence_sum
499498

500499
def init_weights(self, init_std: float):
@@ -592,6 +591,8 @@ def __init__(self, moe_args: MoEArgs, dim: int, hidden_dim: int):
592591
)
593592
self.score_before_experts = moe_args.score_before_experts
594593
self.deepep_token_chunk_size: int | None = None
594+
# Pre-residual block-output dropout. Set via set_block_dropout(); 0.0 is a no-op.
595+
self.dropout_p = 0.0
595596

596597
# define fields for auxiliary-loss-free load balancing (https://arxiv.org/abs/2408.15664)
597598
# NOTE: tokens_per_expert is accumulated in the model forward pass.
@@ -614,6 +615,13 @@ def __init__(self, moe_args: MoEArgs, dim: int, hidden_dim: int):
614615
persistent=False,
615616
)
616617
self.register_buffer("routing_confidence_sum", torch.tensor(0.0, dtype=torch.float32), persistent=False)
618+
# Separate usage accumulator for the loss-free bias update (consumed once per optimizer step by
619+
# update_expert_bias), kept distinct from tokens_per_expert which logging resets every micro-step.
620+
self.register_buffer(
621+
"expert_load_acc",
622+
torch.zeros(num_experts, dtype=torch.float32),
623+
persistent=False,
624+
)
617625

618626
def set_ep_comm_backend(self, backend: EPCommBackend) -> None:
619627
self.ep_comm_backend = backend
@@ -743,9 +751,15 @@ def forward(
743751
with torch.no_grad():
744752
self.tokens_per_expert.add_(num_tokens_per_expert)
745753
self.routing_confidence_sum.add_(routing_confidence_sum)
754+
# Loss-free bias-update usage: training forwards only, so validation traffic (which runs
755+
# before update_expert_bias in the SFT loop) is excluded. Full-block AC reruns this in
756+
# backward, doubling counts uniformly per layer, which the sign-based update is invariant to.
757+
if self.expert_bias is not None and self.training:
758+
self.expert_load_acc.add_(num_tokens_per_expert)
746759

747760
if self.ep_comm_backend == "deepep":
748761
routed_output = self._run_deepep_routed_experts(x, selected_experts_indices, top_scores)
762+
routed_output = F.dropout(routed_output, p=self.dropout_p, training=self.training)
749763
return routed_output.reshape(bs, slen, dim)
750764

751765
# top_scores and token_indices_experts_sorted shape (bs*slen*top_k,)
@@ -775,6 +789,7 @@ def forward(
775789

776790
routed_indices = token_indices_experts_sorted.reshape(-1, 1).expand(-1, dim)
777791
out = out.scatter_add(dim=0, index=routed_indices, src=routed_output)
792+
out = F.dropout(out, p=self.dropout_p, training=self.training)
778793
out = out.reshape(bs, slen, dim)
779794
return out
780795

@@ -791,6 +806,7 @@ def init_weights(
791806
with torch.device(buffer_device):
792807
self.tokens_per_expert = torch.zeros(self.experts.num_experts, dtype=torch.float32)
793808
self.routing_confidence_sum = torch.tensor(0.0, dtype=torch.float32)
809+
self.expert_load_acc = torch.zeros(self.experts.num_experts, dtype=torch.float32)
794810
if self.load_balance_coeff is not None:
795811
self.expert_bias = torch.zeros(self.experts.num_experts, dtype=torch.float32)
796812

@@ -1005,7 +1021,6 @@ def forward(
10051021
min=0,
10061022
max=self.num_experts,
10071023
)
1008-
10091024
return top_scores, selected_experts_indices, num_tokens_per_expert, routing_confidence_sum
10101025

10111026
def init_weights(self, init_std: float):
@@ -1073,6 +1088,8 @@ def __init__(
10731088
self.reorderer = TokenReorderer(num_experts=num_experts, top_k=top_k)
10741089
self.shared_expert = BCNonGatedFeedForward(dim=dim, hidden_dim=shared_expert_intermediate_size)
10751090
self.deepep_token_chunk_size: int | None = None
1091+
# Pre-residual block-output dropout. Set via set_block_dropout(); 0.0 is a no-op.
1092+
self.dropout_p = 0.0
10761093

10771094
if latent_dim is not None:
10781095
self.fc1_latent_proj = nn.Linear(dim, latent_dim, bias=False)
@@ -1098,6 +1115,13 @@ def __init__(
10981115
persistent=False,
10991116
)
11001117
self.register_buffer("routing_confidence_sum", torch.tensor(0.0, dtype=torch.float32), persistent=False)
1118+
# Separate usage accumulator for the loss-free bias update (consumed once per optimizer step by
1119+
# update_expert_bias), kept distinct from tokens_per_expert which logging resets every micro-step.
1120+
self.register_buffer(
1121+
"expert_load_acc",
1122+
torch.zeros(num_experts, dtype=torch.float32),
1123+
persistent=False,
1124+
)
11011125

11021126
def set_ep_comm_backend(self, backend: EPCommBackend) -> None:
11031127
self.ep_comm_backend = backend
@@ -1204,9 +1228,15 @@ def forward(self, x: torch.Tensor, routed_experts: torch.Tensor | None = None) -
12041228
with torch.no_grad():
12051229
self.tokens_per_expert.add_(num_tokens_per_expert)
12061230
self.routing_confidence_sum.add_(routing_confidence_sum)
1231+
# Loss-free bias-update usage: training forwards only, so validation traffic (which runs
1232+
# before update_expert_bias in the SFT loop) is excluded. Full-block AC reruns this in
1233+
# backward, doubling counts uniformly per layer, which the sign-based update is invariant to.
1234+
if self.expert_bias is not None and self.training:
1235+
self.expert_load_acc.add_(num_tokens_per_expert)
12071236

12081237
if self.ep_comm_backend == "deepep":
12091238
routed_output = self._run_deepep_routed_experts(x_flat, selected_experts_indices, top_scores)
1239+
routed_output = F.dropout(routed_output, p=self.dropout_p, training=self.training)
12101240
return routed_output.reshape(bs, slen, dim)
12111241

12121242
(
@@ -1226,6 +1256,7 @@ def forward(self, x: torch.Tensor, routed_experts: torch.Tensor | None = None) -
12261256

12271257
token_indices_full = token_indices_experts_sorted.reshape(-1, 1).expand(-1, dim)
12281258
out = out.scatter_add(dim=0, index=token_indices_full, src=routed_output)
1259+
out = F.dropout(out, p=self.dropout_p, training=self.training)
12291260
out = out.reshape(bs, slen, dim)
12301261
return out
12311262

@@ -1236,5 +1267,6 @@ def init_weights(self, init_std: float, buffer_device: torch.device):
12361267
with torch.device(buffer_device):
12371268
self.tokens_per_expert = torch.zeros(self.experts.num_experts, dtype=torch.float32)
12381269
self.routing_confidence_sum = torch.tensor(0.0, dtype=torch.float32)
1270+
self.expert_load_acc = torch.zeros(self.experts.num_experts, dtype=torch.float32)
12391271
if self.load_balance_coeff is not None:
12401272
self.expert_bias = torch.zeros(self.experts.num_experts, dtype=torch.float32)

src/prime_rl/trainer/rl/train.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
setup_model,
4343
is_tt_moe_model,
4444
get_load_balance_stats,
45+
update_expert_bias,
4546
)
4647
from prime_rl.trainer.parallel_dims import get_parallel_dims, resolve_ep
4748
from prime_rl.trainer.perf import get_perf_counter
@@ -543,6 +544,9 @@ def load_run_checkpoint(_optimizer, idx: int) -> None:
543544

544545
# Update the model parameters
545546
optimizer.step()
547+
if config.model.moe_load_balance.mode == "loss_free":
548+
lb = config.model.moe_load_balance
549+
update_expert_bias(model, lb.coeff, dp_cp_group)
546550
optimizer.zero_grad()
547551

548552
# Update learning rate scheduler

src/prime_rl/trainer/sft/train.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@
2929
forward,
3030
get_load_balance_stats,
3131
is_tt_moe_model,
32+
set_block_dropout,
3233
setup_tokenizer,
3334
setup_model,
35+
update_expert_bias,
3436
)
3537
from prime_rl.trainer.parallel_dims import get_parallel_dims, resolve_ep
3638
from prime_rl.trainer.perf import get_perf_counter
@@ -142,6 +144,10 @@ def train(config: SFTConfig):
142144
fused_cross_entropy: bool | str = {"liger_fused": "liger", "quack_fused": "quack"}.get(config.loss_impl, False)
143145
model = setup_model(config.model, parallel_dims, loading_from_ckpt_later, fused_cross_entropy=fused_cross_entropy)
144146

147+
if config.model.dropout > 0:
148+
logger.info(f"Enabling per-block pre-residual dropout {config.model.dropout}")
149+
set_block_dropout(model, config.model.dropout)
150+
145151
if parallel_dims.cp_enabled:
146152
from prime_rl.utils.cp import assert_cp_style_supports_model
147153

@@ -324,8 +330,19 @@ def run_validation(step: int) -> None:
324330
)
325331
val_dataloader = setup_dataloader(val_dataset, config.val.data)
326332

327-
# No train/eval switch: no dropout in these models, and toggling would trigger torch.compile recompilation
328-
mean_loss, nan_count = run_eval_loop(val_dataloader)
333+
# Toggling train/eval can trigger torch.compile recompilation, so only switch when it
334+
# matters: block dropout must be disabled for a deterministic val metric, and loss-free load
335+
# balancing accumulates expert usage in training mode only (so validation, which runs before
336+
# update_expert_bias, doesn't skew the balance). With both off (the default) there is nothing
337+
# to disable, so we keep the model in train mode.
338+
toggle_eval = config.model.dropout > 0 or config.model.moe_load_balance.mode != "off"
339+
if toggle_eval:
340+
model.eval()
341+
try:
342+
mean_loss, nan_count = run_eval_loop(val_dataloader)
343+
finally:
344+
if toggle_eval:
345+
model.train()
329346
if nan_count > 0:
330347
logger.warning(f"Validation at step {step}: {nan_count} batches had NaN loss")
331348
if mean_loss != mean_loss:
@@ -448,6 +465,9 @@ def run_validation(step: int) -> None:
448465

449466
logger.debug("Optimizer step")
450467
optimizer.step()
468+
if config.model.moe_load_balance.mode == "loss_free":
469+
lb = config.model.moe_load_balance
470+
update_expert_bias(model, lb.coeff, dp_cp_group)
451471
optimizer.zero_grad()
452472

453473
# Update learning rate scheduler

0 commit comments

Comments
 (0)