Skip to content

OLMoE3: log the OLMo-core hero's metrics, fix z-loss under GA, and add OLMo-core's perplexity evals - #5880

Open
AkshitaB wants to merge 6 commits into
AI-Hypercomputer:agagik-olmo35from
AkshitaB:akshitab/olmo35-hero-metrics
Open

AkshitaB wants to merge 6 commits into
AI-Hypercomputer:agagik-olmo35from
AkshitaB:akshitab/olmo35-hero-metrics

Conversation

@AkshitaB

Copy link
Copy Markdown

This PR logs what the OLMo-core hero run olmo35-small-hero-20260907-emo logs, so the MaxText replay (olmo35-tiny on 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-hero plus the three commits below, so until #5867 merges the diff shows its three commits too.

Changes

  • OLMo-core training metrics (be14764a6, behind olmoe3_router_metrics and peak_device_tflops):
    • Per layer and totalled: 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.
    • Local load imbalance is measured over groups of router_metrics_group_seqs sequences, to match OLMo-core's per-rank value. The hero used 32 sequences per GPU early on.
    • Also: 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.
  • z-loss under gradient accumulation (02e2120d9): learning/z_loss was the sum of the micro-batches' means, i.e. GA times too large. It's now the token-weighted mean over the step.
  • Perplexity evals (b05d15d32, behind olmo_ppl_eval_interval): new input_pipeline/olmo_ppl_eval.py reproduces OLMo-core's LMEvaluator on the v3-small ppl validation sets.
    • One EOS-delimited document per row, padded and never packed. The CE denominator is the sum of document lengths, as in OLMo-core.
    • Logs eval/lm/<set>-validation/{CE loss, PPL}.
    • The step runs under a new 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

  • Replay with every metric: 1000 steps on 1024 v4 chips.
    • The dropless replay's CE is within 0.22% of the hero (2.733 vs 2.739 at step 1000).
    • z-loss and LR match. Router metrics track the hero once the group size matches.
    • Grad norm is still 16–20% high and router z-loss 8% low late in the window.
  • Perplexity evals on the step-1000 weights: 10 of the 11 sets are within ±0.5% of the hero's eval/lm/*. dolma_stack is 3.6% high, still under investigation.

Tests

On CPU, olmo_ppl_eval_test.py, olmoe3_global_lb_test.py and gradient_accumulation_nnx_test.py give 19 passed. The new tests are:

  • test_router_metrics_match_whole_batch
  • test_z_loss_is_the_token_weighted_mean_over_microbatches
  • the five perplexity-eval tests

🤖 Generated with Claude Code

AkshitaB and others added 6 commits October 20, 2026 12:00
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>
@google-cla

google-cla Bot commented Oct 21, 2026

Copy link
Copy Markdown

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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]

Comment on lines +758 to +776
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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant