Skip to content

OLMoE3: match the OLMo-core hero recipe, fix KDA numerics, and fit GA on v4 8x8x16 - #5866

Closed
AkshitaB wants to merge 10 commits into
AI-Hypercomputer:agagik-olmoe3-v4from
AkshitaB:akshitab/olmoe3-v4
Closed

AkshitaB wants to merge 10 commits into
AI-Hypercomputer:agagik-olmoe3-v4from
AkshitaB:akshitab/olmoe3-v4

Conversation

@AkshitaB

Copy link
Copy Markdown

These are the changes that let MaxText replay the OLMo 3.5 hero run (OLMo-core olmo35-small-hero-20260907-emo, which is MaxText's olmo35-tiny) step for step on TPU v4. The replay starts from the hero's step-0 weights and trains on its batches in the same order, with its recipe. Every new behavior is behind a flag whose default keeps MaxText's current behavior.

Changes

Matching the OLMo-core recipe

  • moe_global_load_balancing: OLMo-core's global load balancing. Inside the routed MoE's shard_map, each device used to balance only its own tokens, and the logged moe_lb_loss was device 0's value. With the flag, expert counts are averaged across devices with no gradient through them, and so is the loss. A new test on 8 simulated devices checks that the value and gradient equal the whole-batch loss.
  • olmo_segment_ids_from_eos and olmoe3_mixers_ignore_segment_ids: EMO expert pools per EOS-delimited document, as in OLMo-core. Attention and KDA still see the whole instance, since the hero has no document masking.
  • moe_aux_loss_reduction=sum: OLMo-core sums the routers' auxiliary losses over layers; MaxText averages them. At the same weight that was 15× less balancing gradient for this model.
  • warmup_step_offset=1: warmup counts steps from one, as OLMo-core does.
  • olmo_global_indices_path (olmo_grain loader): reads OLMo-core's global-indices file, so global batch k is exactly OLMo-core's batch k.
  • Gradient accumulation reports z_loss and the MoE aux losses per micro-batch. They were logged gradient_accumulation_steps times too large.

KDA numerics

  • The chunk system is now inverted by forward substitution, replacing Newton-Schulz and the power series. On trained keys the old inverse overflowed, which caused the NaN at hero-replay steps 210–220. The q/k L2-norm epsilon now matches FLA.
  • Includes Fix sub-block KDA inverse miscompile on TPU v4 #5665, the sub-block inverse miscompile fix on TPU v4. That PR is still open against this base; this commit drops out of the diff if it merges first.

Memory with gradient accumulation (v4 8x8x16, EP 4 × FSDP 256)

  • vocab_tiling_seq_major: splits vocab tiles along the sequence instead of the flattened batch. That avoids a full all-gather of the micro-batch's hidden states. With GA=2, temporaries drop from 49.9 GB to 26.3 GB, so the run fits.
  • ga_params_loop_barrier (opt-in, off by default): stops XLA from hoisting loop-invariant work on the params out of the GA loop. On its own it takes temporaries from 49.9 GB to 44.3 GB.
  • The gradient-sharding constraint inside the GA scan didn't reduce memory on its own. It's kept because it's harmless and makes the intended layout explicit.

Results

The first 1000 steps on a v4 8x8x16 (1024 chips, GA 2, dropless) track the hero:

  • CE: within 0.4% in every window. The largest single-step gap is 0.062; at step 1000, 2.7425 vs 2.7389.
  • z-loss and LR: match.
  • Still open: the aux loss matches at step 0 but runs 11–72% above the hero after that, and the grad norm is 7–32% high. Under investigation.

Tests

On CPU:

  • olmoe3_test.py: 37 passed, 6 skipped (TPU-only).
  • olmo_grain loader tests: 33 passed.
  • olmoe3_global_lb_test.py: passes.
  • gradient_accumulation_nnx_test.py: 8 passed.
  • tiling_test.py CPU tests, including the new seq-major test: pass on 1 and 8 devices.
  • configs_value tests: 51 passed.

🤖 Generated with Claude Code

abhishekraok and others added 10 commits October 14, 2026 14:46
Index the lower-left block on m directly. Slicing a reshape of m drops the
column offset for blocks k >= 1 on TPU v4 (correct on CPU), which made the
off-diagonal blocks ~70% wrong and raised training CE by ~0.005.
olmo_global_indices_path points the training loader at OLMo-core's own
global indices file (a headerless uint32 permutation written by
NumpyFSLDataLoader). Global batch k is then exactly OLMo-core's batch k,
rows [k*B, (k+1)*B) of that permutation, split across hosts with a stride.
That allows step-for-step loss comparisons against an OLMo-core run on the
same mix. The seed-based shuffle is unchanged when the path is unset, and
eval keeps using it.

The Grain DataLoader shards the sampler's index space itself
(index * shard_count + shard_index), so this path gives it a single shard
and lets the sampler do the split. More than one Grain worker would batch
every worker_count-th record and mix steps, so that is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-norm epsilon

Port of 0162682 from CalebOuellette/maxtext to this branch's two chunked paths.

The pairwise chunked path solved (I + M) delta = beta (v - prediction) with a Newton-Schulz iteration,
and the sub-block path's _invert_unit_lower built each 16x16 diagonal block from the power series
(I - N)(I + N^2)(I + N^4)(I + N^8). Both are the Neumann partial sums of -M, which on keys as alike as
a trained model's, with beta up to 2, reach 1e8..1e20 in fp32 before cancelling. Newton's inverse was
off by 3e16 relative on such keys (the NaN loss at steps 210-220 of the OLMo 3.5 hero replay); the
power series, with its shorter blocks, was off by 2.4e-4 in the inverse and 2.9e-4 in the layer output.

_invert_unit_lower now runs row-by-row forward substitution inside the 16x16 diagonal blocks (at
HIGHEST precision), keeping its block doubling, with the lower-left indexing fix from PR AI-Hypercomputer#5665, and its
analytic VJP. The pairwise path calls it too instead of Newton.

The unfused path's q/k L2-norm also now uses rsqrt(sum + 1e-6), FLA's l2norm and what the fused kernel
does, instead of rsqrt(max(sum, 1e-12)). On real OLMo-core weights that epsilon alone was 8e-4 of logit
error against the PyTorch model; with FLA's it is 7e-5.

New tests run the inverse and both chunked rules on near-collinear keys; they fail on the previous code
as above and pass now. tests/unit/olmoe3_test.py: 36 passed, 6 skipped (TPU-only) on CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mulation

gradient_accumulation_loss_and_grad summed every aux value over the micro-batches. That is right for
xent_sum and total_weights, but loss_fn returns z_loss, moe_lb_loss, indexer_loss and mtp_loss as
per-micro-batch means, so learning/z_loss and learning/moe_lb_loss were logged
gradient_accumulation_steps times too large (16x on the hero replay), and has_moe_overflow became an
integer count. The loss itself already divided them correctly. Now z_loss is the per-token value over
the whole batch, the aux losses the mean over micro-batches, and the overflow flag an any().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124qNuoqVsfB6AqzrSh6XLB
MaxText's warmup trains step i at lr * i / warmup_steps, so step 0 trains at zero and every warmup step
lags a trainer that counts from one (OLMo-core: lr * s / warmup_steps at its step s = i + 1) by one
increment. Replaying the OLMo 3.5 hero's first 35 steps in MaxText, that lag was the size of the loss
gap (+0.03). warmup_step_offset=1 trains step i at lr * (i + 1) / warmup_steps and reaches the peak one
step earlier; the default 0 keeps MaxText's schedule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124qNuoqVsfB6AqzrSh6XLB
…over layers

MaxText adds load_balance_loss_weight times the MEAN of the per-layer MoE auxiliary losses to the training
loss. OLMo-core attaches every router's weighted loss to the graph on its own, which is the SUM: at the same
weight, a model with 15 MoE layers trains with 15x the balancing (and router z) gradient. The OLMo 3.5 hero
logs its load-balancing loss at 15.2 across its 15 MoE layers against MaxText's 1.07 mean, so at
load_balance_loss_weight 0.01 the two recipes were 15x apart. moe_aux_loss_reduction=sum matches
OLMo-core; the default 'mean' keeps MaxText's behavior.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124qNuoqVsfB6AqzrSh6XLB
… for OLMoE3

Two places where the OLMoE3 port differed from the OLMo-core hero run it replays.

Global load balancing (moe_global_load_balancing). On the sparse path the load-balancing 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 by the expert axis. Each device balanced its own tokens, and the
value leaving through out_specs=P() (check_vma off) was device 0's, which is what moe_lb_loss logged.
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 (checked on 8 CPU devices), so the final pmean fixes the value without rescaling it.

EMO documents (olmo_segment_ids_from_eos, olmoe3_mixers_ignore_segment_ids). OLMo-core's EMO router
builds its per-document expert pools from segment_ids_from_eos(input_ids, eos): an EOS token ends its
own document. The olmo_grain loader gave every instance one segment, so each 8192-token row was one
EMO document. olmo_segment_ids_from_eos makes inputs_segmentation number the documents that way (plus
one; 0 is padding). The hero trains without document masking (generate_doc_lengths=False), so
olmoe3_mixers_ignore_segment_ids passes (segment_ids != 0) to attention and KDA and leaves the document
ids to the router. The auxiliary losses already used the unmasked full softmax, as OLMo-core does.

Tests: 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, and d lb / d probs is the global counts. A loader
test checks the document ids against OLMo-core's formula, and a model test that with the mixer flag
three documents give the logits of one segment while without it they differ. On CPU:
olmoe3_test.py 37 passed, 6 skipped (TPU-only); olmo_grain loader tests 33 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cumulation scan

The per-micro-batch gradient and the running sum were only constrained after the scan. Inside the scan
body XLA is free to lay them out unsharded before the add. On OLMoE3 (olmo35-tiny, EP 4 x FSDP 256 on a
v4 8x8x16) the train step needed 49.8 GB of HBM temporaries with gradient_accumulation_steps=2 against
under 31.75 GB with 1; this pins both to grad_shardings in the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@google-cla

google-cla Bot commented Oct 20, 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 stability fixes to align MaxText with OLMo-core, including support for global load balancing, custom document segmentation from EOS tokens, sequence-major vocabulary tiling, and a more stable unit lower triangular matrix inversion using blocked forward substitution to prevent NaN losses. However, the review identified a critical shape mismatch bug in the ShiftToInputsTargets transform where inputs_seg is computed using the full tokens array (length L + 1) instead of inputs (length L), which will cause a runtime crash when segment_ids_from_eos is enabled. A corresponding assertion error in the unit tests was also flagged.

Comment on lines +537 to +541
if self.segment_ids_from_eos is None:
inputs_seg = np.ones(L, dtype=np.int32)
else:
eos = (tokens == self.segment_ids_from_eos).astype(np.int32)
inputs_seg = (np.cumsum(eos) - eos + 1).astype(np.int32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

There is a shape mismatch bug here. tokens has length L + 1 (since inputs = tokens[:-1] has length L). Therefore, inputs_seg computed as (np.cumsum(eos) - eos + 1) will have length L + 1. However, inputs_segmentation is expected to have length L to match the length of inputs, inputs_position, and targets_segmentation. This will cause a shape mismatch crash at runtime when segment_ids_from_eos is enabled. You should slice inputs_seg to length L (i.e., [:-1]) or compute it on inputs directly.

Suggested change
if self.segment_ids_from_eos is None:
inputs_seg = np.ones(L, dtype=np.int32)
else:
eos = (tokens == self.segment_ids_from_eos).astype(np.int32)
inputs_seg = (np.cumsum(eos) - eos + 1).astype(np.int32)
if self.segment_ids_from_eos is None:
inputs_seg = np.ones(L, dtype=np.int32)
else:
eos = (inputs == self.segment_ids_from_eos).astype(np.int32)
inputs_seg = (np.cumsum(eos) - eos + 1).astype(np.int32)

Comment on lines +298 to +311
def test_shift_to_inputs_targets_segment_ids_from_eos(self):
"""Documents numbered as OLMo-core's segment_ids_from_eos (EOS ends its own document), plus one."""
eos = 100257
arr = np.array([eos, 5, 6, eos, 7, eos, eos, 8, 9], dtype=np.uint32)
out = ShiftToInputsTargets(segment_ids_from_eos=eos).map(
{"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True}
)
# OLMo-core: eos.cumsum() - eos = [0, 1, 1, 1, 2, 2, 3, 4, 4]; MaxText reserves 0 for padding.
np.testing.assert_array_equal(out["inputs_segmentation"], np.array([1, 2, 2, 2, 3, 3, 4, 5, 5], dtype=np.int32))
# The loss mask is the default one.
np.testing.assert_array_equal(out["targets_segmentation"], np.array([1] * 8 + [0], dtype=np.int32))
default = ShiftToInputsTargets().map({"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True})
for key in ("inputs", "targets", "inputs_position", "targets_segmentation"):
np.testing.assert_array_equal(out[key], default[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.

high

The test assertions here incorrectly assume that inputs_segmentation and targets_segmentation have length 9 (the length of arr). However, since inputs is tokens[:-1], the length L is 8. Thus, inputs_segmentation and targets_segmentation must have length 8. Comparing them to arrays of length 9 will fail with a shape mismatch once the production bug in ShiftToInputsTargets is fixed.

Suggested change
def test_shift_to_inputs_targets_segment_ids_from_eos(self):
"""Documents numbered as OLMo-core's segment_ids_from_eos (EOS ends its own document), plus one."""
eos = 100257
arr = np.array([eos, 5, 6, eos, 7, eos, eos, 8, 9], dtype=np.uint32)
out = ShiftToInputsTargets(segment_ids_from_eos=eos).map(
{"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True}
)
# OLMo-core: eos.cumsum() - eos = [0, 1, 1, 1, 2, 2, 3, 4, 4]; MaxText reserves 0 for padding.
np.testing.assert_array_equal(out["inputs_segmentation"], np.array([1, 2, 2, 2, 3, 3, 4, 5, 5], dtype=np.int32))
# The loss mask is the default one.
np.testing.assert_array_equal(out["targets_segmentation"], np.array([1] * 8 + [0], dtype=np.int32))
default = ShiftToInputsTargets().map({"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True})
for key in ("inputs", "targets", "inputs_position", "targets_segmentation"):
np.testing.assert_array_equal(out[key], default[key])
def test_shift_to_inputs_targets_segment_ids_from_eos(self):
"""Documents numbered as OLMo-core's segment_ids_from_eos (EOS ends its own document), plus one."""
eos = 100257
arr = np.array([eos, 5, 6, eos, 7, eos, eos, 8, 9], dtype=np.uint32)
out = ShiftToInputsTargets(segment_ids_from_eos=eos).map(
{"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True}
)
# OLMo-core: eos.cumsum() - eos = [0, 1, 1, 1, 2, 2, 3, 4, 4]; MaxText reserves 0 for padding.
np.testing.assert_array_equal(out["inputs_segmentation"], np.array([1, 2, 2, 2, 3, 3, 4, 5], dtype=np.int32))
# The loss mask is the default one.
np.testing.assert_array_equal(out["targets_segmentation"], np.array([1] * 7 + [0], dtype=np.int32))
default = ShiftToInputsTargets().map({"tokens": arr, "instance_id": 0, "file_id": 0, "instance_mask": True})
for key in ("inputs", "targets", "inputs_position", "targets_segmentation"):
np.testing.assert_array_equal(out[key], default[key])

@AkshitaB

Copy link
Copy Markdown
Author

Superseded by #5867, which targets agagik-olmo35 and drops the commits that branch already has.

@AkshitaB AkshitaB closed this Oct 20, 2026
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.

3 participants