Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions megatron/core/distributed/finalize_model_grads.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ def reset_model_temporary_tensors(config: TransformerConfig, model: List[torch.n
or "global_aux_loss" in config.moe_router_load_balancing_type
) and hasattr(module, 'reset_global_aux_loss_tracker'):
module.reset_global_aux_loss_tracker()
if getattr(module, 'qb_beta_accum', None) is not None:
module.qb_beta_accum.zero_()
module.qb_beta_count.zero_()


def _update_router_expert_bias(
Expand Down Expand Up @@ -368,6 +371,48 @@ def _update_router_expert_bias(
expert_bias.copy_(updated_expert_bias)


def _update_router_qb_beta(
model: List[torch.nn.Module],
config: TransformerConfig,
dp_cp_group: Optional[torch.distributed.ProcessGroup] = None,
):
"""Update the quantile-balancing per-expert bias once per global batch.

Averages each router's accumulated quantile (qb_beta_accum/qb_beta_count) across
DP, EMA-blends it with the current qb_beta, re-centers, and writes it back.
"""
qb_beta_list = []
qb_beta_accum_list = []
qb_beta_count_list = []
for model_chunk in model:
for module in get_attr_wrapped_model(model_chunk, 'modules')():
if getattr(module, 'qb_beta_accum', None) is not None and module.training:
qb_beta_list.append(module.qb_beta)
qb_beta_accum_list.append(module.qb_beta_accum)
qb_beta_count_list.append(module.qb_beta_count)

if len(qb_beta_list) == 0:
return

stacked_beta = torch.stack(qb_beta_list, dim=0)
local_avg_list = [
accum / count.clamp(min=1).to(accum.dtype)
for accum, count in zip(qb_beta_accum_list, qb_beta_count_list)
]
stacked_local_avg = torch.stack(local_avg_list, dim=0)

torch.distributed.all_reduce(
stacked_local_avg, op=torch.distributed.ReduceOp.AVG, group=dp_cp_group
)

ema = config.moe_router_quantile_balancing_ema
stacked_new_beta = ema * stacked_beta + (1.0 - ema) * stacked_local_avg
stacked_new_beta = stacked_new_beta - stacked_new_beta.mean(dim=-1, keepdim=True)

for qb_beta, new_beta in zip(qb_beta_list, stacked_new_beta):
qb_beta.copy_(new_beta)


def _allreduce_non_tensor_model_parallel_grads(
model: List[torch.nn.Module],
config: TransformerConfig,
Expand Down Expand Up @@ -542,6 +587,9 @@ def finalize_model_grads(
)
_update_router_expert_bias(model, config, tp_dp_cp_group=tp_dp_cp_group)

if config.moe_router_load_balancing_type == "quantile_balancing":
_update_router_qb_beta(model, config, dp_cp_group=dp_cp_group)
Comment on lines +590 to +591

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.

[IMPORTANT Correctness] This dp_cp_group reduction is correct because quantile_balancing() already gathers logits across TP+CP (so all TP/CP ranks compute identical beta_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 different beta_local values but never synchronize them.

Consider adding a brief comment here noting the dependency:

if config.moe_router_load_balancing_type == "quantile_balancing":
    # TP ranks already agree (quantile_balancing gathers across TP+CP),
    # so dp_cp_group is sufficient — no TP reduction needed.
    _update_router_qb_beta(model, config, dp_cp_group=dp_cp_group)


reset_model_temporary_tensors(config, model)

# normalize gradients for per-token loss normalization.
Expand Down
63 changes: 60 additions & 3 deletions megatron/core/transformer/moe/moe_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,44 @@ def sinkhorn(cost: torch.Tensor, tol: float = 0.0001) -> torch.Tensor:
return d1 * cost * d0.unsqueeze(1)


def qb_dual_update(
scores: torch.Tensor, k: int, beta: torch.Tensor, update_beta: bool = True
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Dual coordinate-descent quantile-balancing routing assignment.

Picks the top-k experts per token from ``scores - beta``. When ``update_beta`` is
True, also returns the raw column quantile of ``scores`` that drives each expert
toward ``m * k / n`` tokens.

Args:
scores (torch.Tensor): Scores of shape ``[m, n]`` (tokens, experts).
k (int): Experts to select per token.
beta (torch.Tensor): Current per-expert bias of shape ``[n]``.
update_beta (bool): If False, return ``beta`` unchanged (eval/inference).

Returns:
Tuple[torch.Tensor, torch.Tensor]: indices of shape ``[m, k]`` and either
``beta`` (when ``update_beta`` is False) or the column quantile ``[n]``.
"""
num_tokens, num_experts = scores.shape

topk_result = (scores - beta).topk(k + 1, dim=1)
indices = topk_result.indices[:, :-1]

if not update_beta:
return indices, beta

assert (num_tokens * k) % num_experts == 0, (
"Quantile balancing requires the number of routed assignments "
f"({num_tokens} tokens * top-{k}) to be divisible by "
f"{num_experts} experts."
)
col_target = num_tokens * k // num_experts
alpha = topk_result.values[:, -1:]
beta_local = (scores - alpha).topk(col_target + 1, dim=0).values[-1].contiguous()
return indices, beta_local


def get_capacity(
num_tokens: int, num_experts: int, capacity_factor: float, min_capacity: Optional[int] = None
) -> int:
Expand Down Expand Up @@ -681,6 +719,7 @@ def topk_routing_with_score_function(
fused: bool = False,
router_replay: Optional['RouterReplay'] = None,
dense_output: bool = False,
precomputed_indices: Optional[torch.Tensor] = None,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Compute the routing probabilities and map for top-k selection with score function.

Expand All @@ -705,6 +744,10 @@ def topk_routing_with_score_function(
Defaults to None.
dense_output (bool, optional): If True, return dense tensors [num_tokens, topk] instead of
sparse tensors [num_tokens, num_experts]. Defaults to False.
precomputed_indices (torch.Tensor, optional): Top-k indices [num_tokens, topk]
selected by the caller. When given, the score function's
own top-k is bypassed and probs are computed at these
indices (e.g. for quantile balancing). Defaults to None.

Returns:
Tuple[torch.Tensor, torch.Tensor]:
Expand All @@ -723,6 +766,9 @@ def topk_routing_with_score_function(
"""
assert logits.dim() == 2, f"Expected 2D logits [num_tokens, num_experts], got {logits.dim()}."
num_tokens, num_experts = logits.shape
assert not (
fused and precomputed_indices is not None
), "precomputed_indices is not supported with the fused top-k score function."
if fused:
if not HAVE_TE or fused_topk_with_score_function is None:
raise ValueError(
Expand Down Expand Up @@ -793,16 +839,27 @@ def compute_topk(scores, topk, num_groups=None, group_topk=None):
if score_function == "softmax":
if use_pre_softmax:
scores = torch.softmax(logits, dim=-1, dtype=torch.float32)
probs, top_indices = compute_topk(scores, topk, num_groups, group_topk)
if precomputed_indices is not None:
top_indices = precomputed_indices
probs = torch.gather(scores, dim=1, index=top_indices)
else:
probs, top_indices = compute_topk(scores, topk, num_groups, group_topk)
else:
scores, top_indices = compute_topk(logits, topk, num_groups, group_topk)
if precomputed_indices is not None:
top_indices = precomputed_indices
scores = torch.gather(logits, dim=1, index=top_indices)
else:
scores, top_indices = compute_topk(logits, topk, num_groups, group_topk)
probs = torch.softmax(scores, dim=-1, dtype=torch.float32)
elif score_function in ("sigmoid", "sqrtsoftplus"):
if score_function == "sigmoid":
scores = torch.sigmoid(logits.float())
else:
scores = torch.nn.functional.softplus(logits.float()).sqrt()
if expert_bias is not None:
if precomputed_indices is not None:
top_indices = precomputed_indices
scores = torch.gather(scores, dim=1, index=top_indices)
elif expert_bias is not None:
scores_for_routing = scores + expert_bias.float()
_, top_indices = compute_topk(scores_for_routing, topk, num_groups, group_topk)
scores = torch.gather(scores, dim=1, index=top_indices)
Expand Down
131 changes: 131 additions & 0 deletions megatron/core/transformer/moe/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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

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.

[SUGGESTION Naming] The method name _maintain_float32_expert_bias no longer accurately describes what it does — it now also maintains QB biases in fp32. Consider renaming to _maintain_float32_buffers or _maintain_float32_biases to reflect the broader scope.


def sinkhorn_load_balancing(self, logits: torch.Tensor):
"""Apply sinkhorn routing to the logits tensor.
Expand Down Expand Up @@ -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

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.

[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 qb_dual_update is a cross-token statistic — padding tokens with non-trivial logits will skew the per-expert bias estimate.

For variable-length training with significant padding, this could systematically distort the balance target. Consider either:

  1. Masking out padding tokens from full_logits before calling qb_dual_update, or
  2. Documenting that QB assumes fixed-length sequences (no padding).

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

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.

[IMPORTANT Performance] A new full_logits tensor of shape [local_num_tokens * gather_size, num_experts] is allocated on every forward pass for each MoE layer. For large-scale training with many tokens and many MoE layers, this creates significant transient memory pressure and allocation overhead.

Consider pre-allocating full_logits as a non-persistent buffer (resized if needed) to avoid per-forward allocation:

# 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_buf

This 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

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.

[SUGGESTION Simplification] The fused=self.config.moe_router_fusion argument is always False here because the assert at line 323 enforces not self.config.moe_router_fusion. Passing fused=False directly is clearer and avoids the reader needing to trace back to the assertion:

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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -799,6 +922,7 @@ def _compiled_topk_routing(
fused,
router_replay,
dense_output,
precomputed_indices,
):
return topk_routing_with_score_function(
logits,
Expand All @@ -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,
Expand All @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions megatron/core/transformer/transformer_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,9 @@ class TransformerConfig(ModelParallelConfig):
for each individual sample.
- "global_aux_loss": Load balancing loss calculated at global batch level.
- "sinkhorn": Balancing algorithm used in S-BASE.
- "quantile_balancing": Dual coordinate-descent quantile balancing (QB). Load balance is
handled entirely by an internal per-expert bias update; auxiliary losses must be disabled
(`moe_aux_loss_coeff` = 0) when QB is selected.
- "none": No load balancing.
A list of strings can be provided to combine multiple aux-loss load balancing types.
The default is "aux_loss".
Expand Down Expand Up @@ -790,6 +793,12 @@ class TransformerConfig(ModelParallelConfig):
and decreased for the experts with more assigned tokens.
The default value 1e-3 is same as that used in DeepSeekV3."""

moe_router_quantile_balancing_ema: float = 0.0
"""EMA coefficient for the quantile-balancing per-expert bias (`qb_beta`), used only when
`moe_router_load_balancing_type` is "quantile_balancing". At each global batch the bias is
updated as `qb_beta = ema * qb_beta + (1 - ema) * local_quantile`. The default 0.0 means
no memory: the bias is replaced by the latest global-batch quantile estimate each step."""

moe_router_force_load_balancing: bool = False
"""[Experimental] Force load balancing with random logits for MoE router, supports naive topk
and group-limited topk. This is an experimental feature and only for benchmark."""
Expand Down
Loading
Loading