-
Notifications
You must be signed in to change notification settings - Fork 4.4k
Implement Quantile Balancing in MoE #5349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| apply_router_token_dropping, | ||
| compute_routing_scores_for_aux_loss, | ||
| get_tokens_per_expert_and_token_count, | ||
| qb_dual_update, | ||
| router_gating_linear, | ||
| sinkhorn, | ||
| switch_load_balancing_loss_func, | ||
|
|
@@ -216,6 +217,41 @@ def __init__( | |
| self.global_tokens_per_expert = None | ||
| self.ga_steps = None | ||
|
|
||
| # Quantile balancing replaces the aux loss with a per-expert bias `qb_beta`. | ||
| # `qb_beta_accum`/`qb_beta_count` collect the per-microbatch quantile, reduced | ||
| # and reset each global batch. | ||
| if self.routing_type == "quantile_balancing": | ||
| assert not self.is_aux_loss_enabled(), ( | ||
| "Quantile balancing handles load balance via the bias update; " | ||
| "aux losses must be disabled (set moe_aux_loss_coeff to 0)." | ||
| ) | ||
| self.register_buffer( | ||
| 'qb_beta', | ||
| torch.zeros( | ||
| self.config.num_moe_experts, | ||
| dtype=torch.float32, | ||
| device=torch.cuda.current_device(), | ||
| ), | ||
| ) | ||
| self.register_buffer( | ||
| 'qb_beta_accum', | ||
| torch.zeros( | ||
| self.config.num_moe_experts, | ||
| dtype=torch.float32, | ||
| device=torch.cuda.current_device(), | ||
| ), | ||
| persistent=False, | ||
| ) | ||
| self.register_buffer( | ||
| 'qb_beta_count', | ||
| torch.zeros((), dtype=torch.long, device=torch.cuda.current_device()), | ||
| persistent=False, | ||
| ) | ||
| else: | ||
| self.qb_beta = None | ||
| self.qb_beta_accum = None | ||
| self.qb_beta_count = None | ||
|
|
||
| self.router_replay = None | ||
| if self.config.moe_enable_routing_replay: | ||
| self.router_replay = RouterReplay() | ||
|
|
@@ -230,6 +266,13 @@ def _maintain_float32_expert_bias(self): | |
| if hasattr(self, 'expert_bias') and self.expert_bias is not None: | ||
| if self.expert_bias.dtype != torch.float32: | ||
| self.expert_bias.data = self.expert_bias.data.to(torch.float32) | ||
| # Keep the QB bias in fp32 for the same reason. | ||
| if hasattr(self, 'qb_beta') and self.qb_beta is not None: | ||
| if self.qb_beta.dtype != torch.float32: | ||
| self.qb_beta.data = self.qb_beta.data.to(torch.float32) | ||
| if hasattr(self, 'qb_beta_accum') and self.qb_beta_accum is not None: | ||
| if self.qb_beta_accum.dtype != torch.float32: | ||
| self.qb_beta_accum.data = self.qb_beta_accum.data.to(torch.float32) | ||
|
Comment on lines
+269
to
+275
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION Naming] The method name |
||
|
|
||
| def sinkhorn_load_balancing(self, logits: torch.Tensor): | ||
| """Apply sinkhorn routing to the logits tensor. | ||
|
|
@@ -264,6 +307,81 @@ def _sinkhorn_activation(logits): | |
| scores = logits * map | ||
| return scores, map | ||
|
|
||
| def quantile_balancing(self, logits: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: | ||
| """Apply quantile-balancing (QB) routing to the logits tensor. | ||
|
|
||
| Selects top-k experts per token using a dual coordinate-descent update on | ||
| a per-expert bias ``qb_beta``. Load balance is handled entirely by the bias | ||
| update; auxiliary losses must be disabled when QB is active. | ||
|
|
||
| Args: | ||
| logits (torch.Tensor): The logits tensor, shape ``[num_tokens, num_experts]``. | ||
|
|
||
| Returns: | ||
| Tuple[torch.Tensor, torch.Tensor]: Sparse routing probs and boolean | ||
| routing map, each shaped ``[num_tokens, num_experts]``. | ||
| """ | ||
| assert ( | ||
| not self.config.moe_router_fusion | ||
| ), "Quantile balancing routing does not support moe_router_fusion." | ||
| assert ( | ||
| self.config.moe_router_num_groups is None and self.config.moe_router_group_topk is None | ||
| ), "Quantile balancing routing does not support group-limited routing." | ||
|
|
||
| local_num_tokens = logits.shape[0] | ||
| # Gather logits across TP/CP so the quantile sees a whole sequence's tokens. | ||
| # The DP reduction and qb_beta update run at the global-batch boundary in | ||
| # finalize_model_grads._update_router_qb_beta. | ||
| gather_group = self.tp_cp_group | ||
| gather_size = gather_group.size() if gather_group is not None else 1 | ||
|
Comment on lines
+331
to
+336
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [IMPORTANT Correctness] The QB quantile computation runs on ALL gathered tokens including potential padding tokens. Unlike per-token top-k (which is independent per token), the column quantile in For variable-length training with significant padding, this could systematically distort the balance target. Consider either:
The existing routing functions don't have this issue because their top-k is per-token independent, but QB aggregates across tokens for the quantile. |
||
|
|
||
| should_update_beta = self.training and torch.is_grad_enabled() | ||
|
|
||
| with torch.no_grad(): | ||
| logits_fp32 = logits.detach().to(dtype=torch.float32) | ||
|
|
||
| if gather_size > 1: | ||
| full_logits = torch.empty( | ||
| (local_num_tokens * gather_size, self.config.num_moe_experts), | ||
| dtype=logits_fp32.dtype, | ||
| device=logits_fp32.device, | ||
| ) | ||
| torch.distributed.all_gather_into_tensor( | ||
| full_logits, logits_fp32.contiguous(), group=gather_group | ||
| ) | ||
|
Comment on lines
+342
to
+351
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [IMPORTANT Performance] A new Consider pre-allocating # In __init__:
self._qb_gather_buf = None
# In quantile_balancing:
buf_size = (local_num_tokens * gather_size, self.config.num_moe_experts)
if self._qb_gather_buf is None or self._qb_gather_buf.shape != buf_size:
self._qb_gather_buf = torch.empty(buf_size, dtype=torch.float32, device=logits.device)
full_logits = self._qb_gather_bufThis is a minor optimization but reduces GC pressure in the hot path. |
||
| gather_rank = torch.distributed.get_rank(group=gather_group) | ||
| else: | ||
| full_logits = logits_fp32 | ||
| gather_rank = 0 | ||
|
|
||
| # Route with the previous batch's qb_beta; in training, accumulate this | ||
| # microbatch's quantile for the next update. | ||
| full_indices, beta_local = qb_dual_update( | ||
| full_logits, self.topk, self.qb_beta, update_beta=should_update_beta | ||
| ) | ||
| if should_update_beta: | ||
| self.qb_beta_accum.add_(beta_local) | ||
| self.qb_beta_count.add_(1) | ||
|
|
||
| # Take this rank's rows (all_gather orders rows by rank). | ||
| if gather_size > 1: | ||
| indices = full_indices[ | ||
| gather_rank * local_num_tokens : (gather_rank + 1) * local_num_tokens | ||
| ].contiguous() | ||
| else: | ||
| indices = full_indices | ||
|
|
||
| # QB only picks the experts; reuse the shared score function for the probs. | ||
| return topk_routing_with_score_function( | ||
| logits, | ||
| self.topk, | ||
| use_pre_softmax=self.config.moe_router_pre_softmax, | ||
| scaling_factor=self.config.moe_router_topk_scaling_factor, | ||
| score_function=self.score_function, | ||
| fused=self.config.moe_router_fusion, | ||
| precomputed_indices=indices, | ||
| ) | ||
|
Comment on lines
+375
to
+383
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [SUGGESTION Simplification] The return topk_routing_with_score_function(
logits,
self.topk,
use_pre_softmax=self.config.moe_router_pre_softmax,
scaling_factor=self.config.moe_router_topk_scaling_factor,
score_function=self.score_function,
fused=False,
precomputed_indices=indices,
) |
||
|
|
||
| def get_aux_loss_coeff(self, aux_loss_type: str) -> float: | ||
| """Return the aux loss coeff for the given auxiliary loss type. | ||
| If the auxiliary loss type is not found, return 0.0. | ||
|
|
@@ -641,6 +759,11 @@ def routing(self, logits: torch.Tensor, padding_mask: Optional[torch.Tensor] = N | |
| # Calculate probs and routing_map for token dispatching | ||
| if self.routing_type == "sinkhorn": | ||
| probs, routing_map = self.sinkhorn_load_balancing(logits) | ||
| elif self.routing_type == "quantile_balancing": | ||
| assert ( | ||
| padding_mask is None | ||
| ), "Quantile balancing routing does not support padding masks yet." | ||
| probs, routing_map = self.quantile_balancing(logits) | ||
| else: | ||
| probs, routing_map = topk_routing_with_score_function( | ||
| logits, | ||
|
|
@@ -799,6 +922,7 @@ def _compiled_topk_routing( | |
| fused, | ||
| router_replay, | ||
| dense_output, | ||
| precomputed_indices, | ||
| ): | ||
| return topk_routing_with_score_function( | ||
| logits, | ||
|
|
@@ -812,11 +936,17 @@ def _compiled_topk_routing( | |
| fused=fused, | ||
| router_replay=router_replay, | ||
| dense_output=dense_output, | ||
| precomputed_indices=precomputed_indices, | ||
| ) | ||
|
|
||
| def _forward(self, input: torch.Tensor, padding_mask: Optional[torch.Tensor] = None): | ||
| logits = self.gating(input).squeeze(1) # [num_tokens, num_experts] | ||
|
|
||
| # QB selects on (logits - qb_beta); at inference qb_beta is fixed, so it's per-token. | ||
| precomputed_indices = None | ||
| if self.qb_beta is not None: | ||
| precomputed_indices = (logits - self.qb_beta).topk(self.topk, dim=1).indices | ||
|
|
||
| probs, top_indices = self._compiled_topk_routing( | ||
| logits, | ||
| self.topk, | ||
|
|
@@ -829,6 +959,7 @@ def _forward(self, input: torch.Tensor, padding_mask: Optional[torch.Tensor] = N | |
| fused=self.config.moe_router_fusion, | ||
| router_replay=self.router_replay, | ||
| dense_output=True, | ||
| precomputed_indices=precomputed_indices, | ||
| ) | ||
| return probs.squeeze(1), top_indices.squeeze(1) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[IMPORTANT Correctness] This
dp_cp_groupreduction is correct becausequantile_balancing()already gathers logits across TP+CP (so all TP/CP ranks compute identicalbeta_local). However, this subtle invariant — that TP ranks are excluded from the reduction because they already agree — is non-obvious and fragile.If anyone later changes
quantile_balancing()to skip the TP/CP gather (e.g., for performance), this reduction would silently produce incorrect results: TP ranks would have differentbeta_localvalues but never synchronize them.Consider adding a brief comment here noting the dependency: