Repository navigation
OLMoE3: log the OLMo-core hero's metrics, fix z-loss under GA, and add OLMo-core's perplexity evals - #5880
OLMoE3: log the OLMo-core hero's metrics, fix z-loss under GA, and add OLMo-core's perplexity evals#5880AkshitaB wants to merge 6 commits into
Conversation
vocab_tiling_seq_major: the tiled loss reshapes (batch, seq, emb) to (tiles, batch * seq / tiles, emb), which cuts the flattened batch, so a whole sequence lands in one tile. At one sequence per device, XLA reshards the hidden states through a full all-gather of the micro-batch (2 x 16 GiB on olmo35-tiny at v4-2048). Cutting along the sequence keeps each device's tokens local; the loss is the same token sum in another order. GA=2 temporaries: 49.9 -> 26.3 GB. ga_params_loop_barrier: ties the params to each micro-batch inside the GA scan so XLA can't hoist loop-invariant work on them out of the loop. Off by default; 49.9 -> 44.3 GB on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…A unchanged Configs built without the field (the GA unit tests' stub) crashed on the plain attribute read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he whole batch On the sparse path OLMoE3LatentRoutedMoE.load_balance_loss runs inside the routed MoE's shard_map, where top_k_indices and the router probabilities are one device's tokens whenever the batch is sharded. Each device balanced its own tokens, and the value leaving through out_specs=P() (check_vma off) was device 0's. OLMo-core's global_load_balancing multiplies local probabilities by expert counts all-reduced over the batch, with no gradient through the counts. With the flag, the counts are pmean'd over every mesh axis under stop_gradient and the loss is pmean'd as well. The loss is linear in the probabilities, so this is exactly the global-batch loss; the gradient through P() is already the mean of the per-device gradients, so the final pmean fixes the value without rescaling it. Off by default. Ported from AkshitaB/maxtext 4fe6dff; that commit's EOS-delimited EMO segments are already here as olmo_segment_eos_token_id and olmoe3_segments_for_emo_only. olmoe3_global_lb_test.py (own process, 8 simulated devices) runs load_balance_loss in the same shard_map as the routed path, batch split over a data and an expert axis: value and gradient equal the whole-batch loss, the per-device version does not. 3 passed on CPU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…CE, masked fraction, throughput For a metric-for-metric comparison with the OLMo-core hero run, which logs these and MaxText did not: - olmoe3_router_metrics: per MoE layer (learning/block_NN/...) and in total (learning/...), load_balancing_loss and router_z_loss, scaled and unscaled, load_imbalance and global_load_imbalance. load_balance_loss returns [lb, load imbalance, global load imbalance] when the flag is on (the decoder layer splits it; the loss is unchanged). The counts behind the ratios are per sequence, summed over any sequence-sharding axes and gathered over the batch axes inside the routed MoE's shard_map, so the local ratio is over consecutive groups of router_metrics_group_seqs sequences (16 = the hero's per-GPU batch) whatever the TPU mesh. Totals follow OLMo-core: max over layers for load_imbalance, sums otherwise (including its global_load_imbalance total). Per-layer values are reordered by layer index (pytree key order puts layers_10 before layers_2). - learning/ce_loss (lm_loss includes the z-loss), learning/ce_perplexity, learning/z_loss_unscaled. - learning/masked_instance_fraction: rows the repetition filter masked out entirely (OLMo-core's "masked instances (%)", which is a fraction). - perf/mfu (peak_device_tflops, or inferred from the device kind), perf/steps_per_sec, perf/total_tokens, perf/total_petaflops, perf/data_loading_seconds and _percent, checkpoint/save_duration_s. Gradient accumulation averages the router metrics and masked fraction over micro-batches (OLMo-core sums the counts first, so its imbalance under GA is from the step's pooled counts). Tests: olmoe3_global_lb_test (8 simulated devices) checks the metrics vector from inside the shard_map against whole-batch counts; a 4-step CPU training run of olmoe3-30m (FSDP 8, sparse path) logs every metric, with the per-layer losses summing to the totals and to moe_lb_loss, and ce_loss + z_loss = lm_loss. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not the sum over micro-batches loss_fn returns each micro-batch's per-token z-loss; the accumulation summed it with the other aux values but, unlike moe_lb_loss and the rest, never normalized it, so learning/z_loss (and z_loss_unscaled) came out gradient_accumulation_steps times too large and learning/ce_loss = lm_loss - z_loss too small. The step's value is now the micro-batch means weighted by their token counts, which is what GA=1 logs. The loss and gradients were never affected (the z-loss inside xent_sum is accumulated with it). Test: with per-token z-losses 1.0 and 5.0 on micro-batches of 3 and 1 tokens, the logged z_loss is 2.0 (it was 6.0). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…PL) in the training loop OLMo-core's LMEvaluator on the v3-small-ppl-validation mix: every EOS-delimited document (EOS included) is one row, truncated to the sequence length and padded, never packed. Its per-set CE averages the per-token losses over label_mask, which includes each document's last token at zero loss, so it is sum of next-token CE over tokens 1..L-1 / sum of L and PPL = exp(CE). With olmo_ppl_eval_interval > 0, the loop builds those rows once (olmo_ppl_eval_dir/<set>/val/ part-0-00000.npy, one set per batch, the last batch padded with weightless rows) and, after every step that completes a multiple of the interval (OLMo-core evaluates after step N with N % interval == 0), runs them through its own jitted eval step. A batch's token-loss sum minus its z-loss is the set's numerator; the denominator is counted on the host. Metrics go out with that step's under OLMo-core's names, eval/lm/<set>-validation/CE loss and /PPL, plus eval/lm/time_seconds. The router uses the EMO eval pool there, as OLMo-core does (eval_batch puts the model in eval mode and EmoRouterV2._pool_sizes returns eval_document_expert_pool when not training). MaxText calls the model the same way in train and eval steps (and enable_dropout is off in training too), so _emo_mask_logits sampled pool sizes in eval. emo_eval_pools(), set while the eval program is traced, makes it use emo_eval_document_expert_pool. Tests (CPU): document splitting and truncation, row targets and masks, one set per batch with weightless fill rows and the sum-of-L denominator, the eval schedule, and that the EMO router samples pools in training but keeps the eval pool under emo_eval_pools(). A 4-step training run of olmoe3-30m with the eval every 2 steps on the real WikiText-103 validation file logs eval/lm/wikitext_103-validation/CE loss = 11.45 (untrained, about ln vocab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
There was a problem hiding this comment.
Code Review
This pull request introduces several features and metrics to align MaxText with OLMo-core, including in-loop perplexity evaluations, detailed OLMoE3 router metrics, global load balancing, sequence-major vocabulary tiling, and a gradient accumulation loop barrier. The review feedback highlights a few important improvements: robustly extracting router metrics by checking both 'key' and 'name' attributes in PyTree paths, avoiding a potential division-by-zero error when the load-balancing loss weight is zero, and raising an explicit error instead of silently falling back to batch-major tiling when sequence length is not divisible by the number of vocabulary tiles.
| """ | ||
| found = [] | ||
| for path, val in jax.tree_util.tree_leaves_with_path(intermediate_outputs): | ||
| keys = [str(k.key) for k in path if hasattr(k, "key")] |
There was a problem hiding this comment.
The current implementation of _collect_olmoe3_router_metrics only extracts keys from path if they have a key attribute (which is typical for DictKey). However, in JAX/Flax/NNX PyTree paths, some path elements can be GetAttrKey (which has a name attribute instead of key). If GetAttrKey is present, hasattr(k, "key") will be False, and the layer index won't be extracted correctly, leading to incorrect sorting of router metrics. Checking both key and name attributes ensures robust extraction across different PyTree path element types.
| keys = [str(k.key) for k in path if hasattr(k, "key")] | |
| keys = [str(k.key) if hasattr(k, "key") else str(k.name) if hasattr(k, "name") else "" for k in path] |
| if router_metrics is not None: | ||
| lb_weight = config.load_balance_loss_weight | ||
| lb, router_z, imbalance, global_imbalance = (router_metrics[:, i] for i in range(4)) | ||
| # Totals as OLMo-core forms them from the per-block values: max over blocks for load imbalance, sum for the | ||
| # rest (its "global load imbalance" total is the sum of the per-block ratios). | ||
| extra_metrics["learning/load_balancing_loss"] = jnp.sum(lb) | ||
| extra_metrics["learning/load_balancing_loss_unscaled"] = jnp.sum(lb) / lb_weight | ||
| extra_metrics["learning/router_z_loss"] = jnp.sum(router_z) | ||
| extra_metrics["learning/router_z_loss_unscaled"] = jnp.sum(router_z) / _OLMOE3_ROUTER_Z_LOSS_WEIGHT | ||
| extra_metrics["learning/load_imbalance"] = jnp.max(imbalance) | ||
| extra_metrics["learning/global_load_imbalance"] = jnp.sum(global_imbalance) | ||
| for i in range(router_metrics.shape[0]): | ||
| block = f"learning/block_{i + 1:02d}" | ||
| extra_metrics[f"{block}/load_balancing_loss"] = lb[i] | ||
| extra_metrics[f"{block}/load_balancing_loss_unscaled"] = lb[i] / lb_weight | ||
| extra_metrics[f"{block}/router_z_loss"] = router_z[i] | ||
| extra_metrics[f"{block}/router_z_loss_unscaled"] = router_z[i] / _OLMOE3_ROUTER_Z_LOSS_WEIGHT | ||
| extra_metrics[f"{block}/load_imbalance"] = imbalance[i] | ||
| extra_metrics[f"{block}/global_load_imbalance"] = global_imbalance[i] |
There was a problem hiding this comment.
If config.load_balance_loss_weight is set to 0 (which is a valid configuration to disable load balancing loss), dividing by lb_weight will result in a division by zero (NaN or Inf) in the unscaled load balancing loss metrics. Wrapping the unscaled load balancing loss calculation in a check if lb_weight > 0: prevents this division by zero.
if router_metrics is not None:
lb_weight = config.load_balance_loss_weight
lb, router_z, imbalance, global_imbalance = (router_metrics[:, i] for i in range(4))
# Totals as OLMo-core forms them from the per-block values: max over blocks for load imbalance, sum for the
# rest (its "global load imbalance" total is the sum of the per-block ratios).
extra_metrics["learning/load_balancing_loss"] = jnp.sum(lb)
if lb_weight > 0:
extra_metrics["learning/load_balancing_loss_unscaled"] = jnp.sum(lb) / lb_weight
extra_metrics["learning/router_z_loss"] = jnp.sum(router_z)
extra_metrics["learning/router_z_loss_unscaled"] = jnp.sum(router_z) / _OLMOE3_ROUTER_Z_LOSS_WEIGHT
extra_metrics["learning/load_imbalance"] = jnp.max(imbalance)
extra_metrics["learning/global_load_imbalance"] = jnp.sum(global_imbalance)
for i in range(router_metrics.shape[0]):
block = f"learning/block_{i + 1:02d}"
extra_metrics[f"{block}/load_balancing_loss"] = lb[i]
if lb_weight > 0:
extra_metrics[f"{block}/load_balancing_loss_unscaled"] = lb[i] / lb_weight
extra_metrics[f"{block}/router_z_loss"] = router_z[i]
extra_metrics[f"{block}/router_z_loss_unscaled"] = router_z[i] / _OLMOE3_ROUTER_Z_LOSS_WEIGHT
extra_metrics[f"{block}/load_imbalance"] = imbalance[i]
extra_metrics[f"{block}/global_load_imbalance"] = global_imbalance[i]| inputs = jax.lax.reshape(inputs, out_shape, out_sharding=reshape_out_sharding) | ||
| return _maybe_shard_with_name(inputs, out_sharding) | ||
|
|
||
| seq_major = config.vocab_tiling_seq_major and hidden_states.shape[1] % config.num_vocab_tiling == 0 |
There was a problem hiding this comment.
If vocab_tiling_seq_major is set to True but the sequence length (hidden_states.shape[1]) is not divisible by num_vocab_tiling, the code silently falls back to batch-major tiling. This can lead to unexpected performance degradation due to all-gather resharding without any warning to the user. Raising a ValueError when this fallback occurs prevents silent performance traps.
if config.vocab_tiling_seq_major and hidden_states.shape[1] % config.num_vocab_tiling != 0:
raise ValueError(
f"vocab_tiling_seq_major is True, but sequence length {hidden_states.shape[1]} "
f"is not divisible by num_vocab_tiling {config.num_vocab_tiling}."
)
seq_major = config.vocab_tiling_seq_major
This PR logs what the OLMo-core hero run
olmo35-small-hero-20260907-emologs, so the MaxText replay (olmo35-tinyon TPU v4 8x8x16) can be compared with it metric by metric. It also adds OLMo-core's in-loop perplexity evals. Each change is behind a flag whose default keeps current behavior.It stacks on #5867: the branch is
akshitab/olmo35-heroplus the three commits below, so until #5867 merges the diff shows its three commits too.Changes
be14764a6, behindolmoe3_router_metricsandpeak_device_tflops):learning/block_NN/{load_balancing_loss[_unscaled], router_z_loss[_unscaled], load_imbalance, global_load_imbalance}. Totals follow OLMo-core: max over blocks for load imbalance, sum for everything else.router_metrics_group_seqssequences, to match OLMo-core's per-rank value. The hero used 32 sequences per GPU early on.learning/ce_loss(lm loss minus z-loss),ce_perplexity,z_loss_unscaled,masked_instance_fraction,perf/mfu,steps_per_sec,total_tokens,total_petaflops, data-loading time and checkpoint save duration.02e2120d9):learning/z_losswas the sum of the micro-batches' means, i.e. GA times too large. It's now the token-weighted mean over the step.b05d15d32, behindolmo_ppl_eval_interval): newinput_pipeline/olmo_ppl_eval.pyreproduces OLMo-core'sLMEvaluatoron the v3-small ppl validation sets.eval/lm/<set>-validation/{CE loss, PPL}.olmoe3.emo_eval_pools()context, so the EMO router uses its fixed eval pool as OLMo-core does in eval mode, instead of sampling pool sizes.Results
eval/lm/*.dolma_stackis 3.6% high, still under investigation.Tests
On CPU,
olmo_ppl_eval_test.py,olmoe3_global_lb_test.pyandgradient_accumulation_nnx_test.pygive 19 passed. The new tests are:test_router_metrics_match_whole_batchtest_z_loss_is_the_token_weighted_mean_over_microbatches🤖 Generated with Claude Code