feat: add MoE auxiliary loss metrics

- Propagates MoE load-balancing loss through model outputs
- Logs task, auxiliary, and weighted losses across strategies
- Computes only explicitly requested callback metrics
- Preserves tensor compute_loss API and adds regression tests
This commit is contained in:
2026-08-02 06:30:43 +08:00
parent 0fc1b1bd46
commit 1c7369f293
14 changed files with 370 additions and 68 deletions
+5 -1
View File
@@ -61,6 +61,7 @@ class TrainConfig(BaseConfig):
val_split (Optional[float]): Ratio to split from training dataset for validation, e.g. 0.05. Defaults to None. val_split (Optional[float]): Ratio to split from training dataset for validation, e.g. 0.05. Defaults to None.
val_step (int): Number of optimizer steps between validation runs. Defaults to 1000. val_step (int): Number of optimizer steps between validation runs. Defaults to 1000.
neftune_alpha (float): NEFTune noise alpha, 0=disabled, typical: 5.0. Defaults to 0.0. neftune_alpha (float): NEFTune noise alpha, 0=disabled, typical: 5.0. Defaults to 0.0.
moe_aux_loss_coef (float): Weight applied to the MoE load-balancing loss. Defaults to 0.01.
rollout_interval (int): Number of optimizer steps between online rollouts. Defaults to 512. rollout_interval (int): Number of optimizer steps between online rollouts. Defaults to 512.
rollout_temperature (float): Sampling temperature for online rollout. Defaults to 0.7. rollout_temperature (float): Sampling temperature for online rollout. Defaults to 0.7.
rollout_top_k (int): Top-k filtering for online rollout, 0=disable. Defaults to 0. rollout_top_k (int): Top-k filtering for online rollout, 0=disable. Defaults to 0.
@@ -112,6 +113,7 @@ class TrainConfig(BaseConfig):
val_split: Optional[float] = None val_split: Optional[float] = None
val_step: int = 1000 val_step: int = 1000
neftune_alpha: float = 0.0 neftune_alpha: float = 0.0
moe_aux_loss_coef: float = 0.01
rollout_interval: int = 512 rollout_interval: int = 512
rollout_temperature: float = 0.7 rollout_temperature: float = 0.7
@@ -187,7 +189,9 @@ class TrainConfig(BaseConfig):
raise ValueError(f"rollout_top_p must be in (0, 1], got {v}") raise ValueError(f"rollout_top_p must be in (0, 1], got {v}")
return v return v
@field_validator("rollout_top_k", "num_workers", "neftune_alpha") @field_validator(
"rollout_top_k", "num_workers", "neftune_alpha", "moe_aux_loss_coef"
)
def _validate_non_negative(cls, v): def _validate_non_negative(cls, v):
if v < 0: if v < 0:
raise ValueError(f"must be non-negative, got {v}") raise ValueError(f"must be non-negative, got {v}")
+11 -4
View File
@@ -1,5 +1,5 @@
from dataclasses import asdict from dataclasses import asdict
from typing import Optional from typing import Optional, TypedDict
import torch.nn as nn import torch.nn as nn
from torch import Tensor from torch import Tensor
@@ -10,6 +10,11 @@ from astrai.model.components.mlp import FFNFactory
from astrai.model.components.norm import RMSNorm from astrai.model.components.norm import RMSNorm
class DecoderOutput(TypedDict):
hidden_states: Tensor
aux_loss: Optional[Tensor]
class DecoderBlock(nn.Module): class DecoderBlock(nn.Module):
def __init__(self, config, layer_id: int): def __init__(self, config, layer_id: int):
super().__init__() super().__init__()
@@ -48,7 +53,7 @@ class DecoderBlock(nn.Module):
attention_mask: Optional[Tensor] = None, attention_mask: Optional[Tensor] = None,
kv_cache: Optional[KVCache] = None, kv_cache: Optional[KVCache] = None,
is_causal: bool = False, is_causal: bool = False,
) -> Tensor: ) -> DecoderOutput:
attn_output = self.attention( attn_output = self.attention(
self.input_norm(x), self.input_norm(x),
rotary_emb, rotary_emb,
@@ -57,6 +62,8 @@ class DecoderBlock(nn.Module):
is_causal, is_causal,
) )
x = attn_output + x x = attn_output + x
x = self.mlp(self.post_attention_norm(x)) + x normalized = self.post_attention_norm(x)
mlp_output = self.mlp(normalized)
x = mlp_output["hidden_states"] + x
return x return {"hidden_states": x, "aux_loss": mlp_output["aux_loss"]}
+36 -11
View File
@@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, TypedDict
import torch import torch
import torch.nn as nn import torch.nn as nn
@@ -13,6 +13,16 @@ class FFNFactory(BaseFactory[nn.Module]):
pass pass
class FFNOutput(TypedDict):
hidden_states: Tensor
aux_loss: Optional[Tensor]
class RoutedOutput(TypedDict):
hidden_states: Tensor
aux_loss: Optional[Tensor]
@FFNFactory.register("mlp") @FFNFactory.register("mlp")
class MLP(nn.Module): class MLP(nn.Module):
def __init__(self, dim: int, dim_ffn: int, down_init_std: float = 0.02): def __init__(self, dim: int, dim_ffn: int, down_init_std: float = 0.02):
@@ -21,10 +31,10 @@ class MLP(nn.Module):
self.gate = Linear(dim, dim_ffn) self.gate = Linear(dim, dim_ffn)
self.down = Linear(dim_ffn, dim, init_std=down_init_std) self.down = Linear(dim_ffn, dim, init_std=down_init_std)
def forward(self, x: Tensor) -> Tensor: def forward(self, x: Tensor) -> FFNOutput:
gated = self.up(x) * F.silu(self.gate(x)) gated = self.up(x) * F.silu(self.gate(x))
out = self.down(gated) out = self.down(gated)
return out return {"hidden_states": out, "aux_loss": None}
@FFNFactory.register("moe") @FFNFactory.register("moe")
@@ -76,22 +86,26 @@ class DeepSeekMoE(nn.Module):
] ]
) )
def forward(self, x: Tensor) -> Tensor: def forward(self, x: Tensor) -> FFNOutput:
include_aux_loss = self.training and torch.is_grad_enabled()
bsz, seq_len, dim = x.shape bsz, seq_len, dim = x.shape
x_flat = x.view(-1, dim) x_flat = x.view(-1, dim)
shared_out = self._shared_forward(x_flat) shared_out = self._shared_forward(x_flat)
routed_out = self._routed_forward(x_flat) routed_output = self._routed_forward(x_flat, include_aux_loss)
out = (shared_out + routed_out).view(bsz, seq_len, dim) out = (shared_out + routed_output["hidden_states"]).view(bsz, seq_len, dim)
return out return {"hidden_states": out, "aux_loss": routed_output["aux_loss"]}
def _shared_forward(self, x: Tensor) -> Tensor: def _shared_forward(self, x: Tensor) -> Tensor:
if self.n_shared_experts == 0: if self.n_shared_experts == 0:
return torch.zeros_like(x) return torch.zeros_like(x)
return sum(e(x) for e in self.shared_experts) / self.n_shared_experts return (
sum(e(x)["hidden_states"] for e in self.shared_experts)
/ self.n_shared_experts
)
def _routed_forward(self, x: Tensor) -> Tensor: def _routed_forward(self, x: Tensor, include_aux_loss: bool) -> RoutedOutput:
N, D = x.shape N, D = x.shape
K = self.n_activated_experts K = self.n_activated_experts
@@ -102,15 +116,26 @@ class DeepSeekMoE(nn.Module):
if self.norm_topk_prob: if self.norm_topk_prob:
topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True) topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
aux_loss = None
if include_aux_loss:
expert_load = F.one_hot(
topk_indices, num_classes=self.n_routed_experts
).float()
expert_load = expert_load.mean(dim=(0, 1))
router_prob = router_probs.float().mean(dim=0)
aux_loss = self.n_routed_experts * (expert_load * router_prob).sum()
output = torch.zeros(N, D, device=x.device, dtype=x.dtype) output = torch.zeros(N, D, device=x.device, dtype=x.dtype)
for expert_idx in range(self.n_routed_experts): for expert_idx in range(self.n_routed_experts):
expert_mask = topk_indices == expert_idx expert_mask = topk_indices == expert_idx
token_idx, k_idx = expert_mask.nonzero(as_tuple=True) token_idx, k_idx = expert_mask.nonzero(as_tuple=True)
if token_idx.numel() == 0: if token_idx.numel() == 0:
continue continue
expert = self.routed_experts[expert_idx]
expert_input = x[token_idx] expert_input = x[token_idx]
expert_output = self.routed_experts[expert_idx](expert_input) expert_output = expert(expert_input)["hidden_states"]
weights = topk_weights[token_idx, k_idx].unsqueeze(-1) weights = topk_weights[token_idx, k_idx].unsqueeze(-1)
output.index_add_(0, token_idx, expert_output * weights) output.index_add_(0, token_idx, expert_output * weights)
return output return {"hidden_states": output, "aux_loss": aux_loss}
+1 -1
View File
@@ -70,7 +70,7 @@ class EmbeddingEncoder(AutoModel):
attn_mask = process_attention_mask(input_mask) attn_mask = process_attention_mask(input_mask)
for layer in self.layers: for layer in self.layers:
x = layer(x, rotary_emb, attn_mask) x = layer(x, rotary_emb, attn_mask)["hidden_states"]
hidden_states = self.norm(x) hidden_states = self.norm(x)
+15 -2
View File
@@ -113,10 +113,23 @@ class AutoRegressiveLM(AutoModel):
attn_mask = process_attention_mask(input_mask) attn_mask = process_attention_mask(input_mask)
use_sdpa_causal_mask = attn_mask is None use_sdpa_causal_mask = attn_mask is None
aux_losses = []
for layer in self.layers: for layer in self.layers:
x = layer(x, rotary_emb, attn_mask, kv_cache, use_sdpa_causal_mask) layer_output = layer(
x,
rotary_emb,
attn_mask,
kv_cache,
use_sdpa_causal_mask,
)
x = layer_output["hidden_states"]
if layer_output["aux_loss"] is not None:
aux_losses.append(layer_output["aux_loss"])
hidden_states = self.norm(x) hidden_states = self.norm(x)
logits = self.lm_head(hidden_states) logits = self.lm_head(hidden_states)
return {"logits": logits, "hidden_states": hidden_states} output = {"logits": logits, "hidden_states": hidden_states}
if aux_losses:
output["aux_loss"] = torch.stack(aux_losses).mean()
return output
+95 -28
View File
@@ -1,7 +1,7 @@
"""Training strategy implementations with factory pattern.""" """Training strategy implementations with factory pattern."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Callable, Dict, Union from typing import Callable, Dict, Optional, TypedDict, Union
import torch import torch
import torch.nn as nn import torch.nn as nn
@@ -13,6 +13,16 @@ from astrai.parallel.executor import broadcast_state_dict
from astrai.trainer.rollout import RolloutResult from astrai.trainer.rollout import RolloutResult
class LossOutput(TypedDict):
loss: Tensor
metrics: Dict[str, Tensor]
class LogprobsOutput(TypedDict):
logprobs: Tensor
aux_loss: Optional[Tensor]
def move_to_device(batch: Dict[str, Tensor], device: str) -> Dict[str, Tensor]: def move_to_device(batch: Dict[str, Tensor], device: str) -> Dict[str, Tensor]:
"""Move batch tensors to specified device with non-blocking transfer.""" """Move batch tensors to specified device with non-blocking transfer."""
return {key: value.to(device, non_blocking=True) for key, value in batch.items()} return {key: value.to(device, non_blocking=True) for key, value in batch.items()}
@@ -24,7 +34,7 @@ def get_logprobs(
attn_mask: Tensor, attn_mask: Tensor,
loss_mask: Tensor, loss_mask: Tensor,
reduction: str, reduction: str,
) -> Tensor: ) -> LogprobsOutput:
"""Compute token-wise log probabilities from model outputs. """Compute token-wise log probabilities from model outputs.
Args: Args:
@@ -46,10 +56,11 @@ def get_logprobs(
shifted_input_ids = input_ids[:, 1:] shifted_input_ids = input_ids[:, 1:]
shifted_loss_mask = loss_mask[:, 1:] shifted_loss_mask = loss_mask[:, 1:]
logits = model( outputs = model(
input_ids[:, :-1], input_ids[:, :-1],
attn_mask[:, :, :-1, :-1] if attn_mask.dim() == 4 else attn_mask[:, :-1], attn_mask[:, :, :-1, :-1] if attn_mask.dim() == 4 else attn_mask[:, :-1],
)["logits"] )
logits = outputs["logits"]
log_probs = torch.log_softmax(logits.float(), dim=-1) log_probs = torch.log_softmax(logits.float(), dim=-1)
token_logprobs = torch.gather( token_logprobs = torch.gather(
@@ -57,13 +68,14 @@ def get_logprobs(
).squeeze(-1) ).squeeze(-1)
if reduction == "mean": if reduction == "mean":
return (token_logprobs * shifted_loss_mask).sum(dim=-1) / shifted_loss_mask.sum( logprobs = (token_logprobs * shifted_loss_mask).sum(
dim=-1 dim=-1
).clamp(min=1.0) ) / shifted_loss_mask.sum(dim=-1).clamp(min=1.0)
elif reduction == "sum": elif reduction == "sum":
return (token_logprobs * shifted_loss_mask).sum(dim=-1) logprobs = (token_logprobs * shifted_loss_mask).sum(dim=-1)
else: else:
return token_logprobs * shifted_loss_mask logprobs = token_logprobs * shifted_loss_mask
return {"logprobs": logprobs, "aux_loss": outputs.get("aux_loss")}
def make_doc_boundary_mask(position_ids: Tensor) -> Tensor: def make_doc_boundary_mask(position_ids: Tensor) -> Tensor:
@@ -102,6 +114,7 @@ class BaseStrategy(ABC):
self.model = model self.model = model
self.device = device self.device = device
self.executor = kwargs.pop("executor", None) self.executor = kwargs.pop("executor", None)
self.moe_aux_loss_coef = kwargs.pop("moe_aux_loss_coef", 0.01)
self.extra_kwargs = kwargs self.extra_kwargs = kwargs
self._rollout_runner = None self._rollout_runner = None
@@ -117,6 +130,33 @@ class BaseStrategy(ABC):
""" """
raise NotImplementedError raise NotImplementedError
def compute_loss_output(self, batch: Dict[str, Tensor]) -> LossOutput:
return self._normalize_output(self.compute_loss(batch))
def _loss_output(
self,
task_loss: Tensor,
metrics: Dict[str, Tensor],
aux_loss: Optional[Tensor] = None,
) -> LossOutput:
total_loss = task_loss
if aux_loss is not None:
weighted_aux_loss = self.moe_aux_loss_coef * aux_loss
total_loss = total_loss + weighted_aux_loss
metrics["moe_aux_loss"] = aux_loss
metrics["moe_aux_loss_weighted"] = weighted_aux_loss
metrics["loss"] = total_loss
return {
"loss": total_loss,
"metrics": {name: value.detach() for name, value in metrics.items()},
}
@staticmethod
def _normalize_output(output: Union[LossOutput, Tensor]) -> LossOutput:
if isinstance(output, dict):
return output
return {"loss": output, "metrics": {"loss": output.detach()}}
def supports_online(self) -> bool: def supports_online(self) -> bool:
"""Whether this strategy can operate with a rollout runner. """Whether this strategy can operate with a rollout runner.
@@ -153,17 +193,17 @@ class BaseStrategy(ABC):
if self._rollout_runner is not None: if self._rollout_runner is not None:
self._rollout_runner.step() self._rollout_runner.step()
def __call__(self, batch: Dict[str, Tensor]) -> Tensor: def __call__(self, batch: Dict[str, Tensor]) -> LossOutput:
"""Run offline or online forward depending on runner injection.""" """Run offline or online forward depending on runner injection."""
if self._rollout_runner is None: if self._rollout_runner is None:
return self.compute_loss(batch) return self.compute_loss_output(batch)
result, is_fresh = self._rollout_runner(batch) result, is_fresh = self._rollout_runner(batch)
if is_fresh: if is_fresh:
self._on_rollout_refresh() self._on_rollout_refresh()
train_batch = self.prepare_from_rollout(result) train_batch = self.prepare_from_rollout(result)
return self.compute_loss(train_batch) return self.compute_loss_output(train_batch)
class StrategyFactory(BaseFactory["BaseStrategy"]): class StrategyFactory(BaseFactory["BaseStrategy"]):
@@ -203,9 +243,13 @@ class SEQStrategy(BaseStrategy):
self.label_smoothing = label_smoothing self.label_smoothing = label_smoothing
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor: def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
return self.compute_loss_output(batch)["loss"]
def compute_loss_output(self, batch: Dict[str, Tensor]) -> LossOutput:
batch = move_to_device(batch, self.device) batch = move_to_device(batch, self.device)
input_ids, target_ids = batch["input_ids"], batch["target_ids"] input_ids, target_ids = batch["input_ids"], batch["target_ids"]
logits = self.model(input_ids=input_ids)["logits"] outputs = self.model(input_ids=input_ids)
logits = outputs["logits"]
loss = F.cross_entropy( loss = F.cross_entropy(
input=logits.flatten(0, 1).float(), input=logits.flatten(0, 1).float(),
@@ -213,7 +257,7 @@ class SEQStrategy(BaseStrategy):
label_smoothing=self.label_smoothing, label_smoothing=self.label_smoothing,
) )
return loss return self._loss_output(loss, {"task_loss": loss}, outputs.get("aux_loss"))
@StrategyFactory.register("sft") @StrategyFactory.register("sft")
@@ -234,6 +278,9 @@ class SFTStrategy(BaseStrategy):
self.label_smoothing = label_smoothing self.label_smoothing = label_smoothing
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor: def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
return self.compute_loss_output(batch)["loss"]
def compute_loss_output(self, batch: Dict[str, Tensor]) -> LossOutput:
batch = move_to_device(batch, self.device) batch = move_to_device(batch, self.device)
input_ids, target_ids, position_ids, loss_mask = ( input_ids, target_ids, position_ids, loss_mask = (
batch["input_ids"], batch["input_ids"],
@@ -245,9 +292,10 @@ class SFTStrategy(BaseStrategy):
ignore_index = -100 ignore_index = -100
input_mask = make_doc_boundary_mask(position_ids) input_mask = make_doc_boundary_mask(position_ids)
target_ids = target_ids.masked_fill(~loss_mask, ignore_index) target_ids = target_ids.masked_fill(~loss_mask, ignore_index)
logits = self.model( outputs = self.model(
input_ids=input_ids, position_ids=position_ids, input_mask=input_mask input_ids=input_ids, position_ids=position_ids, input_mask=input_mask
)["logits"] )
logits = outputs["logits"]
loss = F.cross_entropy( loss = F.cross_entropy(
input=logits.flatten(0, 1).float(), input=logits.flatten(0, 1).float(),
@@ -256,7 +304,7 @@ class SFTStrategy(BaseStrategy):
label_smoothing=self.label_smoothing, label_smoothing=self.label_smoothing,
) )
return loss return self._loss_output(loss, {"task_loss": loss}, outputs.get("aux_loss"))
@StrategyFactory.register("dpo") @StrategyFactory.register("dpo")
@@ -282,6 +330,9 @@ class DPOStrategy(BaseStrategy):
self.reduction = reduction self.reduction = reduction
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor: def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
return self.compute_loss_output(batch)["loss"]
def compute_loss_output(self, batch: Dict[str, Tensor]) -> LossOutput:
batch = move_to_device(batch, self.device) batch = move_to_device(batch, self.device)
chosen_ids, rejected_ids = batch["chosen"], batch["rejected"] chosen_ids, rejected_ids = batch["chosen"], batch["rejected"]
chosen_mask, rejected_mask = batch["chosen_mask"], batch["rejected_mask"] chosen_mask, rejected_mask = batch["chosen_mask"], batch["rejected_mask"]
@@ -297,22 +348,25 @@ class DPOStrategy(BaseStrategy):
)[None, None, :, :] # [1, 1, S, S] )[None, None, :, :] # [1, 1, S, S]
full_mask = key_pad & causal # [B*2, 1, S, S] — composed full_mask = key_pad & causal # [B*2, 1, S, S] — composed
log_pi = get_logprobs( policy_output = get_logprobs(
self.model, self.model,
concat_ids, concat_ids,
full_mask, full_mask,
concat_loss_mask, concat_loss_mask,
self.reduction, self.reduction,
) )
log_pi = policy_output["logprobs"]
aux_loss = policy_output["aux_loss"]
with torch.no_grad(): with torch.no_grad():
log_ref = get_logprobs( ref_output = get_logprobs(
self.ref_model, self.ref_model,
concat_ids, concat_ids,
full_mask, full_mask,
concat_loss_mask, concat_loss_mask,
self.reduction, self.reduction,
) )
log_ref = ref_output["logprobs"]
log_pi_chosen = log_pi[: chosen_ids.shape[0]] log_pi_chosen = log_pi[: chosen_ids.shape[0]]
log_pi_rejected = log_pi[chosen_ids.shape[0] :] log_pi_rejected = log_pi[chosen_ids.shape[0] :]
@@ -325,7 +379,7 @@ class DPOStrategy(BaseStrategy):
ratio_diff = pi_log_ratio - ref_log_ratio ratio_diff = pi_log_ratio - ref_log_ratio
dpo_loss = -F.logsigmoid(self.beta * ratio_diff).mean() dpo_loss = -F.logsigmoid(self.beta * ratio_diff).mean()
return dpo_loss return self._loss_output(dpo_loss, {"dpo_loss": dpo_loss}, aux_loss)
def supports_online(self) -> bool: def supports_online(self) -> bool:
return True return True
@@ -398,6 +452,9 @@ class GRPOStrategy(BaseStrategy):
self.old_model.load_state_dict(state_dict) self.old_model.load_state_dict(state_dict)
def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor: def compute_loss(self, batch: Dict[str, Tensor]) -> Tensor:
return self.compute_loss_output(batch)["loss"]
def compute_loss_output(self, batch: Dict[str, Tensor]) -> LossOutput:
batch = move_to_device(batch, self.device) batch = move_to_device(batch, self.device)
prompts = batch["prompts"] prompts = batch["prompts"]
responses = batch["responses"] responses = batch["responses"]
@@ -438,16 +495,23 @@ class GRPOStrategy(BaseStrategy):
# get_logprobs returns [B*G, S-1] (S = prompt_len + response_len). # get_logprobs returns [B*G, S-1] (S = prompt_len + response_len).
# Response token logprobs occupy the last ``response_len`` positions # Response token logprobs occupy the last ``response_len`` positions
# (the first response token is predicted from the last prompt token). # (the first response token is predicted from the last prompt token).
token_log_probs_policy = get_logprobs( policy_output = get_logprobs(
self.model, full_sequences, attn_mask, full_masks, "none" self.model, full_sequences, attn_mask, full_masks, "none"
)[:, prompt_len - 1 :] )
token_log_probs_policy = policy_output["logprobs"]
aux_loss = policy_output["aux_loss"]
token_log_probs_policy = token_log_probs_policy[:, prompt_len - 1 :]
with torch.no_grad(): with torch.no_grad():
token_log_probs_old = get_logprobs( old_output = get_logprobs(
self.old_model, full_sequences, attn_mask, full_masks, "none" self.old_model, full_sequences, attn_mask, full_masks, "none"
)[:, prompt_len - 1 :] )
token_log_probs_ref = get_logprobs( token_log_probs_old = old_output["logprobs"]
token_log_probs_old = token_log_probs_old[:, prompt_len - 1 :]
ref_output = get_logprobs(
self.ref_model, full_sequences, attn_mask, full_masks, "none" self.ref_model, full_sequences, attn_mask, full_masks, "none"
)[:, prompt_len - 1 :] )
token_log_probs_ref = ref_output["logprobs"]
token_log_probs_ref = token_log_probs_ref[:, prompt_len - 1 :]
# Reshape to [B, G, response_len] # Reshape to [B, G, response_len]
token_log_probs_policy = token_log_probs_policy.view(batch_size, group_size, -1) token_log_probs_policy = token_log_probs_policy.view(batch_size, group_size, -1)
@@ -480,9 +544,12 @@ class GRPOStrategy(BaseStrategy):
kl_per_token = r - torch.log(r + eps) - 1.0 kl_per_token = r - torch.log(r + eps) - 1.0
kl_penalty = self.kl_coef * (kl_per_token * token_masks).sum() / token_count kl_penalty = self.kl_coef * (kl_per_token * token_masks).sum() / token_count
total_loss = policy_loss + kl_penalty task_loss = policy_loss + kl_penalty
return self._loss_output(
return total_loss task_loss,
{"policy_loss": policy_loss, "kl_loss": kl_penalty},
aux_loss,
)
def supports_online(self) -> bool: def supports_online(self) -> bool:
return True return True
+24 -7
View File
@@ -260,11 +260,28 @@ class MetricCallback(TrainCallback):
} }
def _metrics(self, context: TrainContext, names): def _metrics(self, context: TrainContext, names):
return { metrics = dict(context.metrics)
m: self._metric_funcs[m](context) for name in names:
for m in names metric_fn = self._metric_funcs.get(name)
if self._metric_funcs[m](context) is not None if metric_fn is None:
} continue
value = metric_fn(context)
if value is not None:
metrics[name] = value
selected = set(context.metrics) | set(names)
selected.discard("*")
result = {name: metrics[name] for name in selected if name in metrics}
if context.world_size > 1 and dist.is_initialized() and result:
metric_names = sorted(result)
values = torch.tensor(
[result[name] for name in metric_names],
dtype=torch.float32,
device=get_current_device(),
)
dist.all_reduce(values, op=dist.ReduceOp.SUM)
values /= context.world_size
result.update(zip(metric_names, values.tolist()))
return result
@only_on_rank(0) @only_on_rank(0)
def _append(self, event_type: str, context: TrainContext, **extra): def _append(self, event_type: str, context: TrainContext, **extra):
@@ -286,8 +303,8 @@ class MetricCallback(TrainCallback):
with torch.no_grad(): with torch.no_grad():
for batch in context.val_dataloader: for batch in context.val_dataloader:
loss = context.strategy(batch) loss_output = context.strategy(batch)
total_loss += loss.item() total_loss += loss_output["loss"].item()
num_batches += 1 num_batches += 1
if context.world_size > 1 and dist.is_initialized(): if context.world_size > 1 and dist.is_initialized():
+2
View File
@@ -38,6 +38,7 @@ class TrainContext:
epoch: int = field(default=0) epoch: int = field(default=0)
consumed_samples: int = field(default=0) consumed_samples: int = field(default=0)
loss: float = field(default=0.0) loss: float = field(default=0.0)
metrics: Dict[str, float] = field(default_factory=dict)
grad_norm: Optional[float] = field(default=None) grad_norm: Optional[float] = field(default=None)
grad_snr_tracker: GradSNRTracker = field(default_factory=GradSNRTracker) grad_snr_tracker: GradSNRTracker = field(default_factory=GradSNRTracker)
val_dataloader: Optional[DataLoader] = field(default=None) val_dataloader: Optional[DataLoader] = field(default=None)
@@ -221,6 +222,7 @@ class TrainContextBuilder:
obj.load_state_dict(extra[name]) obj.load_state_dict(extra[name])
strategy_kwargs = dict(cfg.extra_kwargs) strategy_kwargs = dict(cfg.extra_kwargs)
strategy_kwargs.setdefault("moe_aux_loss_coef", cfg.moe_aux_loss_coef)
needs_ref = cfg.strategy in ( needs_ref = cfg.strategy in (
"dpo", "dpo",
+7 -3
View File
@@ -82,9 +82,13 @@ class Trainer:
break break
with executor.accumulate(context.model): with executor.accumulate(context.model):
self._call_callbacks("on_batch_begin", context) self._call_callbacks("on_batch_begin", context)
loss = context.strategy(batch) loss_output = context.strategy(batch)
context.loss = loss.item() context.loss = loss_output["loss"].item()
stand_loss = loss / executor.grad_accum_steps context.metrics = {
name: value.item()
for name, value in loss_output["metrics"].items()
}
stand_loss = loss_output["loss"] / executor.grad_accum_steps
executor.backward(stand_loss) executor.backward(stand_loss)
context.consumed_samples += ( context.consumed_samples += (
context.config.batch_per_device * context.world_size context.config.batch_per_device * context.world_size
+12 -3
View File
@@ -81,6 +81,14 @@ Where $\rho_t = \pi_\theta(a_t|s_t) / \pi_{\text{old}}(a_t|s_t)$ is the per-toke
Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`. Parameters: `group_size=4`, `clip_eps=0.2`, `kl_coef=0.01`.
### MoE Load Balancing
MoE layers add a differentiable load-balancing term based on mean router probabilities and top-k expert assignment frequency. The training objective is:
$$ L = L_{\text{task}} + \lambda_{\text{MoE}} L_{\text{aux}} $$
`TrainConfig.moe_aux_loss_coef` controls $\lambda_{\text{MoE}}$ (default `0.01`). The unweighted and weighted auxiliary losses are logged separately.
## Training Loop Internals ## Training Loop Internals
Two-level loop: **epoch****batch**. Optimizer step fires every `grad_accum_steps` batches. Two-level loop: **epoch****batch**. Optimizer step fires every `grad_accum_steps` batches.
@@ -92,9 +100,10 @@ on_train_begin
for batch in dataloader: for batch in dataloader:
on_batch_begin on_batch_begin
with executor.accumulate(model): with executor.accumulate(model):
loss = strategy.compute_loss(batch) loss_output = strategy(batch)
context.loss = loss.item() context.loss = loss_output["loss"].item()
stand_loss = loss / executor.grad_accum_steps context.metrics = loss_output["metrics"]
stand_loss = loss_output["loss"] / executor.grad_accum_steps
executor.backward(stand_loss) executor.backward(stand_loss)
context.consumed_samples += ( context.consumed_samples += (
context.config.batch_per_device * context.world_size context.config.batch_per_device * context.world_size
+6 -3
View File
@@ -54,9 +54,10 @@ on_train_begin
for batch in dataloader: for batch in dataloader:
on_batch_begin on_batch_begin
with executor.accumulate(model): with executor.accumulate(model):
loss = strategy.compute_loss(batch) loss_output = strategy(batch)
context.loss = loss.item() context.loss = loss_output["loss"].item()
stand_loss = loss / executor.grad_accum_steps context.metrics = loss_output["metrics"]
stand_loss = loss_output["loss"] / executor.grad_accum_steps
executor.backward(stand_loss) executor.backward(stand_loss)
context.consumed_samples += ( context.consumed_samples += (
context.config.batch_per_device * context.world_size context.config.batch_per_device * context.world_size
@@ -88,6 +89,8 @@ on_train_end
Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric` (JSONL + validation, rank-0), `progress_bar` (tqdm), `gradient_clipping` (always registered; computes grad norm, clips only when `max_grad_norm` is not `None`). Default callbacks (in order): `gradient_checkpointing` (activation checkpointing, optional), `checkpoint` (safetensors, rank-0), `metric` (JSONL + validation, rank-0), `progress_bar` (tqdm), `gradient_clipping` (always registered; computes grad norm, clips only when `max_grad_norm` is not `None`).
Strategies return `{"loss": Tensor, "metrics": Dict[str, Tensor]}` when called by the trainer. Built-in metrics include the task-specific loss and, for MoE models, `moe_aux_loss` plus `moe_aux_loss_weighted`. Direct `compute_loss(batch)` calls continue to return a single loss tensor.
## Strategies ## Strategies
### SEQ (Pre-training) ### SEQ (Pre-training)
+45
View File
@@ -265,6 +265,51 @@ def test_moe_defaults_preserve_normalized_routing():
assert model.layers[0].mlp.norm_topk_prob is True assert model.layers[0].mlp.norm_topk_prob is True
def test_moe_aux_loss_only_emitted_during_training():
from astrai.config.model_config import AutoRegressiveLMConfig
config = AutoRegressiveLMConfig(
**TINY_CONFIG,
ffn_type="moe",
n_routed_experts=4,
n_shared_experts=1,
n_activated_experts=2,
topk_method="greedy",
)
model = AutoRegressiveLM(config)
input_ids = torch.randint(0, config.vocab_size, (2, 8))
outputs = model(input_ids)
assert outputs["aux_loss"].ndim == 0
assert outputs["aux_loss"].requires_grad
assert torch.isfinite(outputs["aux_loss"])
with torch.no_grad():
outputs = model(input_ids)
assert "aux_loss" not in outputs
model.eval()
outputs = model(input_ids)
assert "aux_loss" not in outputs
def test_moe_component_forward_returns_ffn_output():
from astrai.model.components.mlp import DeepSeekMoE
moe = DeepSeekMoE(
dim=8,
dim_ffn=16,
n_routed_experts=4,
n_shared_experts=1,
n_activated_experts=2,
)
output = moe(torch.randn(2, 8, 8))
assert output["hidden_states"].shape == (2, 8, 8)
assert output["aux_loss"] is not None
@pytest.mark.parametrize("decoder_sparse_step", [0, -1]) @pytest.mark.parametrize("decoder_sparse_step", [0, -1])
def test_moe_rejects_invalid_decoder_sparse_step(decoder_sparse_step): def test_moe_rejects_invalid_decoder_sparse_step(decoder_sparse_step):
from pydantic import ValidationError from pydantic import ValidationError
+106
View File
@@ -0,0 +1,106 @@
from types import SimpleNamespace
import torch
from astrai.model.transformer import AutoRegressiveLM
from astrai.trainer.strategy import BaseStrategy, SEQStrategy
from astrai.trainer.train_callback import MetricCallback
from tests.helpers import make_tiny_config
def test_seq_strategy_combines_and_reports_moe_aux_loss(device):
config = make_tiny_config(
ffn_type="moe",
n_routed_experts=4,
n_shared_experts=1,
n_activated_experts=2,
topk_method="greedy",
)
model = AutoRegressiveLM(config).to(device=device)
strategy = SEQStrategy(model, device, moe_aux_loss_coef=0.25)
batch = {
"input_ids": torch.randint(0, config.vocab_size, (2, 8), device=device),
"target_ids": torch.randint(0, config.vocab_size, (2, 8), device=device),
}
output = strategy(batch)
legacy_loss = strategy.compute_loss(batch)
assert isinstance(legacy_loss, torch.Tensor)
assert set(output["metrics"]) == {
"loss",
"task_loss",
"moe_aux_loss",
"moe_aux_loss_weighted",
}
torch.testing.assert_close(
output["loss"],
output["metrics"]["task_loss"] + output["metrics"]["moe_aux_loss_weighted"],
)
torch.testing.assert_close(
output["metrics"]["moe_aux_loss_weighted"],
0.25 * output["metrics"]["moe_aux_loss"],
)
assert output["loss"].requires_grad
assert all(not metric.requires_grad for metric in output["metrics"].values())
def test_metric_callback_includes_dynamic_strategy_metrics(tmp_path):
callback = MetricCallback(
ckpt_dir=tmp_path,
save_interval=1,
metrics=["loss", "lr"],
)
context = SimpleNamespace(
metrics={"task_loss": 2.0, "moe_aux_loss": 1.0},
loss=2.01,
optimizer=SimpleNamespace(param_groups=[{"lr": 1e-3}]),
val_loss=None,
grad_norm=None,
grad_snr_tracker=None,
world_size=1,
)
metrics = callback._metrics(context, callback.metrics)
assert metrics == {
"loss": 2.01,
"lr": 1e-3,
"task_loss": 2.0,
"moe_aux_loss": 1.0,
}
def test_metric_callback_only_computes_requested_metrics(tmp_path):
def fail_metric(context):
_ = context
raise AssertionError("unrequested metric was computed")
callback = MetricCallback(
ckpt_dir=tmp_path,
save_interval=1,
metrics=["loss"],
)
callback._metric_funcs["grad_snr"] = fail_metric
context = SimpleNamespace(
metrics={},
loss=2.0,
world_size=1,
)
metrics = callback._metrics(context, callback.metrics)
assert metrics == {"loss": 2.0}
def test_legacy_strategy_tensor_loss_is_normalized():
class LegacyStrategy(BaseStrategy):
def compute_loss(self, batch):
return torch.tensor(2.0, requires_grad=True)
strategy = LegacyStrategy(torch.nn.Linear(1, 1), "cpu")
output = strategy({})
assert output["loss"].item() == 2.0
assert output["metrics"]["loss"].item() == 2.0
+5 -5
View File
@@ -159,21 +159,21 @@ def test_call_without_runner_falls_back_to_compute_loss_grpo(device):
"masks": torch.ones(2, 4, 6, device=device), "masks": torch.ones(2, 4, 6, device=device),
"rewards": torch.randn(2, 4, device=device), "rewards": torch.randn(2, 4, device=device),
} }
loss = strat(batch) loss = strat(batch)["loss"]
assert torch.isfinite(loss).item() assert torch.isfinite(loss).item()
def test_call_with_runner_returns_finite_loss_grpo(device): def test_call_with_runner_returns_finite_loss_grpo(device):
strat = _make_grpo(device) strat = _make_grpo(device)
strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device))) strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device)))
loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)}) loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)})["loss"]
assert torch.isfinite(loss).item() assert torch.isfinite(loss).item()
def test_call_with_runner_returns_finite_loss_dpo(device): def test_call_with_runner_returns_finite_loss_dpo(device):
strat = _make_dpo(device) strat = _make_dpo(device)
strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device))) strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device)))
loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)}) loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)})["loss"]
assert torch.isfinite(loss).item() assert torch.isfinite(loss).item()
@@ -268,7 +268,7 @@ def test_step_called_when_sync_gradients_true(device):
def test_loss_is_differentiable_dpo(device): def test_loss_is_differentiable_dpo(device):
strat = _make_dpo(device) strat = _make_dpo(device)
strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device))) strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device)))
loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)}) loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)})["loss"]
loss.backward() loss.backward()
has_grad = any( has_grad = any(
p.grad is not None and p.grad.abs().sum() > 0 for p in strat.model.parameters() p.grad is not None and p.grad.abs().sum() > 0 for p in strat.model.parameters()
@@ -279,7 +279,7 @@ def test_loss_is_differentiable_dpo(device):
def test_ref_model_not_updated_by_backward_dpo(device): def test_ref_model_not_updated_by_backward_dpo(device):
strat = _make_dpo(device) strat = _make_dpo(device)
strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device))) strat.set_rollout_runner(_RecordingRunner(_make_rollout_result(device=device)))
loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)}) loss = strat({"input_ids": torch.randint(3, 200, (2, 4), device=device)})["loss"]
loss.backward() loss.backward()
for p in strat.ref_model.parameters(): for p in strat.ref_model.parameters():
assert p.grad is None assert p.grad is None