Repository navigation
Conversation
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>
|
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 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.
| 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) |
There was a problem hiding this comment.
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.
| 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) |
| 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]) |
There was a problem hiding this comment.
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.
| 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]) |
|
Superseded by #5867, which targets agagik-olmo35 and drops the commits that branch already has. |
These are the changes that let MaxText replay the OLMo 3.5 hero run (OLMo-core
olmo35-small-hero-20260907-emo, which is MaxText'solmo35-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'sshard_map, each device used to balance only its own tokens, and the loggedmoe_lb_losswas 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_eosandolmoe3_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.z_lossand the MoE aux losses per micro-batch. They were loggedgradient_accumulation_stepstimes too large.KDA numerics
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.Results
The first 1000 steps on a v4 8x8x16 (1024 chips, GA 2, dropless) track the hero:
Tests
On CPU:
olmoe3_test.py: 37 passed, 6 skipped (TPU-only).olmoe3_global_lb_test.py: passes.gradient_accumulation_nnx_test.py: 8 passed.tiling_test.pyCPU tests, including the new seq-major test: pass on 1 and 8 devices.configs_valuetests: 51 passed.🤖 Generated with Claude Code