Offload MoE collectives and ragged-sort index math to the TPU SparseCore - #5140
Offload MoE collectives and ragged-sort index math to the TPU SparseCore#5140NuojCheng wants to merge 3 commits into
Conversation
Adds `moe_sparse_core_offload_targets`, a comma-separated opt-in list that
moves selected routed-MoE ops onto the TPU SparseCore via
`jax.experimental.compute_on`, freeing TensorCore cycles for the expert GEMMs.
Targets:
fsdp_all_gather - the MoE weight all-gather over fsdp / fsdp_transpose
ep_collectives - expert-parallel activation all-gathers and ragged
all-to-alls (including the ring-of-experts path)
ragged_sort - the routing index math in the ragged sort kernels
The default is empty, so nothing changes for existing configs.
`fsdp_all_gather` cannot annotate the gather GSPMD synthesizes at the
`sparse_matmul` shard_map boundary, because an implicit collective has no op to
tag. Instead the weights are handed to the shard_map still FSDP-sharded and the
same gather is performed inside the manual region. That also improves the
backward pass on its own: weight gradients come back as reduce-scatters rather
than all-reduces. The gather uses `all_gather`'s default `to="varying"`, whose
transpose is the `psum_scatter` that reduces each shard's partial gradient;
`to="invarying"` transposes to a bare slice and silently drops that reduction.
`ragged_sort` annotates only the index math -- argsorts, one-hot histograms,
group offsets, permutations of 1D index/weight vectors. The Pallas
gather/reduce kernels and everything touching the hidden dimension stay on the
TensorCore.
SparseCore presence is read from JAX's chip table rather than by
pattern-matching device strings, and is validated at config time against
`compile_topology` / `hardware`.
There was a problem hiding this comment.
Code Review
This pull request introduces support for offloading specific Mixture of Experts (MoE) operations—such as FSDP weight all-gathers, expert-parallel collectives, and ragged sort index math—to the TPU SparseCore instead of the TensorCore. This is controlled via a new configuration parameter moe_sparse_core_offload_targets and supported by a new sparsecore.py utility module. The review feedback suggests several robustness improvements: wrapping the compile-time topology check in a try-except block to handle potential JAX/Pallas version mismatches, checking for None values of pspec in _pspec_axes_per_dim to prevent a TypeError, and unpacking single-element tuples to plain strings in jax.lax.all_gather for broader compatibility.
| if compile_topology: | ||
| try: | ||
| spec = accelerator_to_spec_map.get_system_characteristics(compile_topology) | ||
| except ValueError: | ||
| return None | ||
| if spec.platform != "tpu": | ||
| return None | ||
| chip_version = _chip_version(compile_topology) | ||
| if chip_version is None: | ||
| return None | ||
| # SparseCore presence does not depend on the Megacore split, so 1 core per | ||
| # logical device is a valid probe for every chip version. | ||
| return pltpu.get_tpu_info_for_chip(chip_version, 1).sparse_core |
There was a problem hiding this comment.
The compile-time topology check relies on pltpu.get_tpu_info_for_chip which can raise ValueError or AttributeError depending on the JAX/Pallas version or environment. Wrapping the entire block in a try-except block catching these exceptions prevents unexpected compile-time crashes.
| if compile_topology: | |
| try: | |
| spec = accelerator_to_spec_map.get_system_characteristics(compile_topology) | |
| except ValueError: | |
| return None | |
| if spec.platform != "tpu": | |
| return None | |
| chip_version = _chip_version(compile_topology) | |
| if chip_version is None: | |
| return None | |
| # SparseCore presence does not depend on the Megacore split, so 1 core per | |
| # logical device is a valid probe for every chip version. | |
| return pltpu.get_tpu_info_for_chip(chip_version, 1).sparse_core | |
| if compile_topology: | |
| try: | |
| spec = accelerator_to_spec_map.get_system_characteristics(compile_topology) | |
| if spec.platform != 'tpu': | |
| return None | |
| chip_version = _chip_version(compile_topology) | |
| if chip_version is None: | |
| return None | |
| # SparseCore presence does not depend on the Megacore split, so 1 core per | |
| # logical device is a valid probe for every chip version. | |
| return pltpu.get_tpu_info_for_chip(chip_version, 1).sparse_core | |
| except (ValueError, AttributeError, RuntimeError): | |
| return None |
| def _pspec_axes_per_dim(pspec, ndim): | ||
| """Normalizes a PartitionSpec into one axis-name tuple per array dim.""" | ||
| per_dim = [] | ||
| for i in range(ndim): | ||
| axis = pspec[i] if i < len(pspec) else None | ||
| if axis is None: | ||
| per_dim.append(()) | ||
| elif isinstance(axis, str): | ||
| per_dim.append((axis,)) | ||
| else: | ||
| per_dim.append(tuple(axis)) | ||
| return per_dim |
There was a problem hiding this comment.
If pspec is None (representing fully replicated sharding in JAX), len(pspec) will raise a TypeError. Adding a check for pspec is not None ensures robust handling of replicated shardings.
| def _pspec_axes_per_dim(pspec, ndim): | |
| """Normalizes a PartitionSpec into one axis-name tuple per array dim.""" | |
| per_dim = [] | |
| for i in range(ndim): | |
| axis = pspec[i] if i < len(pspec) else None | |
| if axis is None: | |
| per_dim.append(()) | |
| elif isinstance(axis, str): | |
| per_dim.append((axis,)) | |
| else: | |
| per_dim.append(tuple(axis)) | |
| return per_dim | |
| def _pspec_axes_per_dim(pspec, ndim): | |
| '''Normalizes a PartitionSpec into one axis-name tuple per array dim.''' | |
| per_dim = [] | |
| for i in range(ndim): | |
| axis = pspec[i] if pspec is not None and i < len(pspec) else None | |
| if axis is None: | |
| per_dim.append(()) | |
| elif isinstance(axis, str): | |
| per_dim.append((axis,)) | |
| else: | |
| per_dim.append(tuple(axis)) | |
| return per_dim |
| for w, plan in zip((w0, w1, wo), weight_ag_plans): | ||
| for dim, axes in plan: | ||
| w = jax.lax.all_gather(w, axes, axis=dim, tiled=True) | ||
| gathered.append(w) |
There was a problem hiding this comment.
Passing a single-element tuple as axis_name to jax.lax.all_gather can sometimes cause issues or different code paths in older JAX versions. Unpacking single-element tuples to a plain string is safer and more compatible.
| for w, plan in zip((w0, w1, wo), weight_ag_plans): | |
| for dim, axes in plan: | |
| w = jax.lax.all_gather(w, axes, axis=dim, tiled=True) | |
| gathered.append(w) | |
| for w, plan in zip((w0, w1, wo), weight_ag_plans): | |
| for dim, axes in plan: | |
| axis_name = axes[0] if len(axes) == 1 else axes | |
| w = jax.lax.all_gather(w, axis_name, axis=dim, tiled=True) | |
| gathered.append(w) |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
XLA's SparseCore collective-offload pass does not treat the compute-type
annotation as a hint. When it selects an annotated collective the chip
cannot lower, sparse_core_collective_offload.cc:586 CHECK-fails and
aborts the compile:
Candidate rejected: instruction has compute type annotation
sparseoffload but the operation is currently not supported on SC.
%all-gather-start = ...
That pass runs under its default flags on v5p with libtpu 0.0.46, so the
fsdp_all_gather TPU test took the whole pytest process down. Probing one
collective per process on that toolchain: an annotated ragged all-to-all
and reduce-scatter are offloaded, an annotated all-reduce is silently
stripped, and an annotated all-gather aborts at every rank.
The annotation also survives AD. It is snapshotted onto the jaxpr
equation and carried onto the transposed one, so annotating a
reduce-scatter puts an annotated all-gather in the backward pass, which
is just as fatal. Both ends of a transposition pair therefore have to be
offloadable before either is annotated.
sparsecore now models this: supports_collective_offload() gates each
collective on the chip generation both it and its transpose need, and
supported_offload_targets() drops a target whose one collective the chip
cannot serve, with a warning rather than an error so the same config
still runs everywhere. On v5p/v6e that leaves ep_collectives with its
ragged all-to-alls and turns fsdp_all_gather off.
Also fixed, all found while verifying the above:
* offload() gated on is_tpu_runtime(), which reads *local* devices, so
an ahead-of-time compile on a TPU-less host silently produced HLO
with no annotations at all. The config validator already guarantees
the target chip has a SparseCore and the attribute is inert on other
backends, so the gate is gone.
* The manual FSDP weight gather makes the shard_map body vary over the
fsdp axis, but maybe_replicate_incompatible_batch strips that axis
off the output pspec when the batch is not divisible by the mesh,
and check_vma=True then rejects the region outright. The plan is now
only taken when every gathered axis still appears in the out specs.
* An empty all-gather plan (weight already in its target layout) read
as "not a pure all-gather" and dropped the whole triple.
* _pspec_axes_per_dim silently ignored a PartitionSpec naming more
dims than the array had, describing a sharding nobody asked for.
Tests: the compute-type API is the part of this feature most likely to
move under us, and every test that could catch it moving was TPU-gated,
so a JAX upgrade could break the annotation with CI green. There is now
a CPU test asserting _xla_compute_type = "sparseoffload" in the lowered
HLO, plus coverage of the capability gate.
`fsdp_all_gather` is dropped on a chip whose SparseCore cannot offload an all-gather, so on v5p/v6e the parameterized equivalence case had nothing to compare and skipped -- leaving the riskiest code in this PR, the explicit gather and its reduce-scatter transpose, untested on the only hardware available. The rewrite's numerics have nothing to do with the SparseCore, so the capability gate is patched out and the comparison runs anyway. The per-collective gate inside `sparsecore.offload` still suppresses the annotation, so XLA does not abort; the test asserts the annotation count matches what the chip supports, and that the baseline has no reduce-scatter while the rewritten graph does -- without which the whole comparison would be the baseline against itself. On v5p this moves the weight-gradient collectives from 5 all-reduces to 3 all-reduces plus 2 reduce-scatters, with bit-identical gradients.
Description
Adds
moe_sparse_core_offload_targets, an opt-in, comma-separated list of routed-MoE ops to run on the TPU SparseCore instead of the TensorCore, freeing TensorCore cycles for the expert GEMMs. Default is"", so nothing changes for existing configs.fsdp_all_gatherfsdp/fsdp_transposeaxesep_collectivesragged_sortallenables every target. Unknown targets are rejected at config time, as is any target on hardware without a SparseCore (checked againstcompile_topology/hardware, so an AOT compile is validated the same way a real run is).This is a redesign of #5125, which does not currently work:
jax.experimental.compute_on.compute_onis a context manager whose only parameter iscompute_type, socompute_on(fn, compute_type="tpu_sparsecore")raisesTypeError: compute_on() got multiple values for argument 'compute_type'the first time the feature is exercised. Itsis_non_gen7_tpugate is also inverted for this purpose in both directions — it disables the feature on v5p and v6e, which do have SparseCores, and fails open on GPU (a3) — and its_fsdp_all_gather_with_rsbackward is amaybe_shard_with_pspecresharding constraint rather than a reduce-scatter.Design notes
Collectives are not a hint
The single most important finding here, and the thing that shaped the whole design.
For ordinary compute the annotation is advisory: XLA offloads what it can lower and quietly leaves the rest on the TensorCore. For collectives it is not. XLA's SparseCore collective-offload pass reads
_xla_compute_type="sparseoffload"as force this one, and when it selects an annotated collective the chip cannot lower it CHECK-fails — aborting the compiler process, not falling back:Probed one collective per process on v5p with libtpu 0.0.46 (the toolchain CI uses), through a minimal
shard_map, over 1Dbf16[4096], 2Dbf16[32,1024]and 3Dbf16[8,1024,256]:all_gatherpsum(all-reduce)sparseoffload=0)psum_scatter(reduce-scatter)sparseoffload=3,async_execution_thread="sparsecore")ragged_all_to_allNote this happens under the default flags on v5p —
--xla_tpu_enable_sparse_core_collective_offload_all_gatheris already true there with libtpu 0.0.46, which contradicts the comments inbenchmarks/xla_flags_library.pysaying these default on only from Ironwood. An earlier draft of this PR assumed the pass was dormant on v5p; it is not, and thefsdp_all_gatherTPU test took the whole pytest process down with the trace above.The annotation survives AD, so transposition pairs move together
The compute type is snapshotted onto the jaxpr equation (
jax/_src/core.py,JaxprEqnContext), and AD carries it onto the transposed equation. Verified both directions:all_gatherproduces an annotated — and genuinely offloaded — backward reduce-scatter;psum_scatterproduces an annotated all-gather intranspose(jvp(...)), which is just as fatal as annotating one directly.So a collective may only be annotated when both ends of its transposition pair are offloadable.
utils/sparsecore.pyencodes this as_TRANSPOSE_OF, and on v5p it is why reduce-scatter is disabled despite the probe above showing it works standalone.Capability model
supports_collective_offload(collective, ...)gates each collective on the lowest chip generation both it and its transpose are known to work on (_MIN_GENERATION; all-gather is Ironwood-and-later perxla_flags_library.pyand the original SparseCore MoE work).supported_offload_targets(...)then drops any target whose one required collective the chip cannot serve — with a warning, not an error, so the same config file runs unchanged across chip generations.fsdp_all_gatherep_collectivesragged_sortChip detection reads JAX's own table (
pltpu.get_tpu_info_for_chip) for both SparseCore presence and generation, rather than pattern-matching device strings, so it stays correct as chips are added.Where the annotation goes
compute_ontags ops as they are traced, so there has to be an op to tag. The MoE weight all-gather had none: the weights entered thesparse_matmulshard_mapwith anin_specthat dropped the FSDP mesh axes and GSPMD synthesized the collective at that boundary. Whenfsdp_all_gatheris live the weights are instead handed to theshard_mapstill FSDP-sharded and the identical gather is performed inside the manual region, where it is a realjax.lax.all_gatherthat can carry a compute type._fsdp_weight_all_gather_planderives the(dim, axes)gathers from the source and targetPartitionSpecs and falls back to the previous implicit boundary gather whenever the transition is not a pure all-gather, the weights are quantized, orexplicitly_weight_ag()already hand-wrote its own gather.The restructuring is deliberately tied to the target being live, not merely requested: on v5p/v6e the target is dropped, so the graph is byte-identical to
moe_sparse_core_offload_targets: "". Making the gather explicit is arguably a win in its own right — with it the weight-gradient collectives go from 5 all-reduces to 3 all-reduces plus 2 reduce-scatters, gradients bit-identical — but that is an unrelated change and shouldn't ride in as a consolation prize under a flag named for offloading. Happy to be argued out of this and let the rewrite apply wherever the target is requested.all_gather's defaultto="varying"is load-bearing: it transposes to thepsum_scatterthat reduces each shard's partial weight gradient.to="invarying"transposes to a baredynamic_slice_in_dimwith nopsum, silently dropping the FSDP gradient reduction — the forward value stays correct, so this only shows up in a gradient comparison, which is what the equivalence tests below exist to catch.One more trap the plan has to dodge: the manual gather makes the
shard_mapbody vary over the FSDP axis, butmaybe_replicate_incompatible_batchstrips that axis off the output pspec when the batch is not divisible by the mesh, andcheck_vma=Truethen rejects the region outright. The plan is only taken when every gathered axis still appears in the out specs; otherwise it logs and leaves the gather to the partitioner.ragged_sortannotates index math onlyArgsorts, one-hot histograms, group sizes/offsets, and permutations of 1D index/weight vectors move; the Pallas
ragged_gather/ragged_gather_reducekernels and everything touching the hidden dimension stay on the TensorCore, since that is dense vector work the SparseCore is not the right place for.ragged_sortinvolves no collectives, so it is unaffected by everything above and runs on every SparseCore chip.AOT
offload()deliberately does not check local devices. An ahead-of-time compile for a SparseCore topology usually runs on a host with no TPU at all, and its HLO has to match what the real run compiles; the config validator has already established that the target chip has a SparseCore, and the frontend attribute is inert on backends that don't consume it.Tests
tests/unit/sparsecore_test.py(50 CPU tests): target-string parsing, chip-table detection for v5p/v6e/tpu7x/v4/v5e/GPU/unknown topologies, the per-collective capability gate including the transpose rule, warn-and-drop of unserviceable targets, thePartitionSpec→ all-gather-plan derivation including the cases that must decline (gaining an axis, dropping a major axis, swapped axes, a pspec naming more dims than the array has), and config validation.Two of those run the real lowering on CPU and assert
_xla_compute_type = "sparseoffload"is present when offloading and absent when not. That coverage exists because the compute-type API is the part of this feature most likely to move under us and every test that could catch it moving used to be TPU-gated — a JAX upgrade could have broken the annotation with CI green.SparseCoreOffloadTestintests/unit/moe_test.py(@pytest.mark.tpu_only, 6 cases): for each target, compares loss and every parameter gradient against the same config with offloading off, and asserts the baseline has zero annotations while the offloaded HLO has some. Gradients are constrained back to the parameter sharding so the weight-gradient collectives actually appear — this is the test that catches theto="invarying"trap. The parameterizedfsdp_all_gathercase skips where the chip cannot offload an all-gather, since the target is dropped there and there would be nothing to assert.That skip would have left the riskiest code here untested on the only hardware available, so
test_explicit_fsdp_weight_gather_matches_the_implicit_onepatches the capability gate out and runs the comparison anyway. The rewrite's numerics have nothing to do with the SparseCore; the per-collective gate insidesparsecore.offloadstill suppresses the annotation so XLA does not abort. It asserts the annotation count matches what the chip supports, and that the baseline has no reduce-scatter while the rewritten graph does — without which the whole comparison would be the baseline against itself.The 5 failures (
test_gmm_grad_equivalence_tokamax_v2_fp8_{dynamic_ep4,static_ep1,static_ep1_qag,static_ep4}andtest_shard_embed_moe_on_fsdp) reproduce identically at the merge base in a clean worktree with the same venv — pre-existing and unrelated.Gradient equivalence
Separate harness over 7 parallelism/feature combinations, mixtral-8x7b on 4x v5p, comparing loss and all 5 parameter gradients against offloading off. Run on both jax 0.11.1 and nightly. (The
fsdp4row is the no-op-on-v5p case; the rewrite itself is covered by the unit test above.)fsdp4+fsdp_all_gatherep4+ep_collectivesep4+ragged_sortep4+ ragged +allfsdp2 x ep2+allep4+ ring-of-experts + chunking +allep4+ ring-of-experts + ragged +allBenchmarks
mixtral-8x7b MoE layer, 4x v5p, bf16, megablox,
base_emb_dim=4096,base_mlp_dim=1024, per-device batch 4, seqlen 512; median of 30 iterations after warmup. HLO counts are AR = all-reduce, AG = all-gather, A2A = all-to-all, RS = reduce-scatter, SO =sparseoffloadannotations.Default XLA flags:
baseline_fsdp4fsdp4+fsdp_all_gatherbaseline_ep4ep4+ep_collectivesbaseline_ep4_raggedep4+ ragged +ragged_sortep4+ ragged +allbaseline_fsdp2_ep2fsdp2 x ep2+allThe
fsdp4row is the backward-compatibility datapoint, not a result: on v5p the target is dropped, so the HLO is identical to the baseline and the 0.05% is noise.With the SparseCore op-offload flags on (
--xla_tpu_enable_offloading_{sort,gather,reduce,scatter,reshape,copy}_to_sparsecore=true), which also speed up the baselines:baseline_ep4_raggedep4+ ragged +ragged_sortep4+ ragged +allbaseline_ep4ep4+ep_collectivesNumerics at benchmark scale: losses across all 19 runs take one of two
float64-printed values, 1.2e-10 apart in relative terms, and which one a run lands on does not track the offload setting — the same unmodified baseline gives one value under default flags and the other with the op-offload flags on. That is collective-decomposition reassociation in XLA, not the annotation.Backward compatibility: the default
moe_sparse_core_offload_targets: ""produces HLO with zerosparseoffloadannotations and takes the original implicit-boundary-gather path, asserted bytest_disabled_offload_leaves_the_hlo_untouched.Shortcomings
The only SparseCore hardware available for this work was v5p, where the SparseCore can take the ragged all-to-alls but not the all-gather. So
fsdp_all_gatheris exercised here only for correctness and for its no-op behaviour; its throughput case rests on Ironwood._MIN_GENERATIONis deliberately conservative and should be revisited on that hardware — in particular an all-reduce entry may want adding once there is a chip where offloading one does something, and reduce-scatter is currently disabled on v5p purely because its transpose is an all-gather, not because the reduce-scatter itself fails.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.