MoE fp8 backward: in-kernel drhs operand quantization + configurable gradient dtype - #4971
Conversation
…rd + x_sorted remat save Two default-off flags, for performance improvement on expert-parallel MoE: - moe_ring_cotangent_ag: the backward cotangent all-gather of the combine reduce-scatter runs on a TensorCore Pallas bidirectional ring kernel instead of the XLA collective, so it cannot serialize on the SparseCore collective-offload queue. Forward unchanged; backward numerically equal to lax.all_gather. - moe_x_sorted=device: remat-save the routed (expert-sorted) MoE input and its routing metadata so the backward loads them instead of re-running the dispatch all-gather and sort. Intended for small per-device batch; at larger batch the save exceeds HBM (compile-checked). New file src/maxtext/kernels/ring_ag.py: bidirectional store-and-forward ring all-gather (TC Pallas, ICI DMA) with caller-supplied collective_id and an explicit CostEstimate so the latency-hiding scheduler accounts for the DMA cost.
There was a problem hiding this comment.
Code Review
This pull request introduces several Mixture of Experts (MoE) performance optimizations, including in-kernel backward quantization for ragged kernels, a configurable backward quantization dtype, a rematerialization policy (moe_x_sorted) to save routed expert inputs, and a Pallas-based bidirectional ring all-gather kernel for backward cotangent reduce-scatters. The code review feedback highlights a critical synchronization bug in the ring all-gather kernel where a semaphore wait is needed to prevent forwarding stale data, a performance optimization to avoid redundant dense quantization of drhs_dout when in-kernel quantization is active, and a configuration improvement to use a Literal type for the backward quantization dtype.
| for i in range(s - 1): | ||
| for li, (si, nb, sign, ss, sl) in enumerate(lanes): | ||
| start = block_start(my - i if sign < 0 else my + i) | ||
| if prev[li] is not None: | ||
| prev[li].wait() | ||
| prev[li] = pltpu.async_remote_copy( | ||
| o_ref.at[idx(start, ss, sl)], o_ref.at[idx(start, ss, sl)], | ||
| send_sem.at[si], recv_sem.at[si], device_id=nb, device_id_type=MESH) |
There was a problem hiding this comment.
In a store-and-forward ring collective, each device forwards the block it received in the previous step. At step i (for i > 0), the block to be sent was written to the local o_ref by the neighbor in step i - 1. Without waiting for the local recv_sem to be signaled, a device can read and forward uninitialized or stale data, leading to silent data corruption. Adding a semaphore wait ensures correctness.
| for i in range(s - 1): | |
| for li, (si, nb, sign, ss, sl) in enumerate(lanes): | |
| start = block_start(my - i if sign < 0 else my + i) | |
| if prev[li] is not None: | |
| prev[li].wait() | |
| prev[li] = pltpu.async_remote_copy( | |
| o_ref.at[idx(start, ss, sl)], o_ref.at[idx(start, ss, sl)], | |
| send_sem.at[si], recv_sem.at[si], device_id=nb, device_id_type=MESH) | |
| for i in range(s - 1): | |
| for li, (si, nb, sign, ss, sl) in enumerate(lanes): | |
| start = block_start(my - i if sign < 0 else my + i) | |
| if i > 0: | |
| pl.semaphore_wait(recv_sem.at[si], i) | |
| if prev[li] is not None: | |
| prev[li].wait() | |
| prev[li] = pltpu.async_remote_copy( | |
| o_ref.at[idx(start, ss, sl)], o_ref.at[idx(start, ss, sl)], | |
| send_sem.at[si], recv_sem.at[si], device_id=nb, device_id_type=MESH) |
There was a problem hiding this comment.
No race: pltpu.make_async_remote_copy(...).wait() waits on both semaphores — the send semaphore and the local recv_sem (the split wait_send() / wait_recv() forms exist precisely because plain wait() covers both). So at step i, prev[li].wait() blocks on recv_sem.at[si], which is signaled exactly by the step i-1 incoming block from the ring neighbor — the block step i forwards. The signal/wait accounting balances: one arrival per step per lane, one recv-wait consumed per step, and the trailing for d in prev: d.wait() consumes the final arrival. Adding an extra pl.semaphore_wait(recv_sem.at[si], i) would double-count the arrivals and deadlock. The kernel is validated bit-exact against jax.lax.all_gather(..., tiled=True) on v7x hardware from 8 to 512 chips.
| if inkernel_drhs: | ||
| # the in-kernel tgmm quantizes drhs_dout ITSELF; keep the raw cotangent for the drhs side. | ||
| dlhs_dout, _ = _bwd_quantize_gradient(dlhs_dout, drhs_dout, quantization_rule) |
There was a problem hiding this comment.
Calling _bwd_quantize_gradient unconditionally quantizes both dlhs_dout and drhs_dout. Since the in-kernel path is designed to avoid dense XLA-level quantization of drhs_dout, calling this helper and discarding the second return value still executes the expensive dense quantization of drhs_dout. Directly quantizing dlhs_dout avoids this redundant computation.
| if inkernel_drhs: | |
| # the in-kernel tgmm quantizes drhs_dout ITSELF; keep the raw cotangent for the drhs side. | |
| dlhs_dout, _ = _bwd_quantize_gradient(dlhs_dout, drhs_dout, quantization_rule) | |
| if inkernel_drhs: | |
| # the in-kernel tgmm quantizes drhs_dout ITSELF; keep the raw cotangent for the drhs side. | |
| dlhs_dout = qpl.quantize( | |
| dlhs_dout, | |
| quantization_rule.bwd_qtype, | |
| channelwise_axes=[] if quantization_rule.disable_channelwise_axes else [0], | |
| calibration_method=quantization_rule.bwd_calibration_method, | |
| ) |
There was a problem hiding this comment.
Good catch — the discarded second output still traced the dense drhs quantize (XLA DCE removes it, but relying on DCE for the exact op this flag eliminates is fragile). Applied: the in-kernel branch now quantizes only the dlhs cotangent inline.
| bwd_quantization_dtype: str = Field( | ||
| "e5m2", | ||
| description=( | ||
| "fp8 dtype for the BACKWARD (gradient) quantization in the fp8_full qwix recipe: 'e5m2' " | ||
| "(default) or 'e4m3'." | ||
| ), | ||
| ) |
There was a problem hiding this comment.
Using a strict Literal type instead of a generic str ensures that invalid quantization data types are caught early during configuration validation.
| bwd_quantization_dtype: str = Field( | |
| "e5m2", | |
| description=( | |
| "fp8 dtype for the BACKWARD (gradient) quantization in the fp8_full qwix recipe: 'e5m2' " | |
| "(default) or 'e4m3'." | |
| ), | |
| ) | |
| bwd_quantization_dtype: Literal["e5m2", "e4m3"] = Field( | |
| "e5m2", | |
| description=( | |
| "fp8 dtype for the BACKWARD (gradient) quantization in the fp8_full qwix recipe: 'e5m2' " | |
| "(default) or 'e4m3'." | |
| ), | |
| ) |
There was a problem hiding this comment.
Applied — Literal["e5m2", "e4m3"].
…gradient dtype Two default-off knobs for performance improvement of fp8 MoE training at dropless (worst-case) ragged buffer sizes: - moe_bwd_inkernel_quant: quantize the drhs tgmm operands INSIDE the kernel (per-gm-tile-per-channel e4m3) instead of with dense XLA-level quantize ops. The dense quantize/amax ops process every row of the ragged buffer at its STATIC size; the in-kernel path touches only valid group_sizes tiles. This subsumes the per-row lhs re-quantize, the drhs_dout *= lhs.scale multiply, and the per-N cotangent quantize -- and because no reduction ever reads past the valid rows, amax can never ingest uninitialized buffer tail rows. - bwd_quantization_dtype: gradient fp8 dtype for the fp8_full qwix recipe, e5m2 (default, unchanged) or e4m3. Flags off reproduce the existing behavior.
…al type for bwd_quantization_dtype
c5d4f0a to
a7bf842
Compare
Stacked on #4895 (token quantized all-gather) and #4969 (ring collectives + x_sorted save); the base branch is #4895's head, so the diff shows this change plus #4969's commit — review the top commit.
What
Two default-off knobs for performance improvement of fp8 MoE training at dropless (worst-case) ragged buffer sizes:
moe_bwd_inkernel_quant— quantize the drhs tgmm's operands inside the kernel (per-gm-tile-per-channel e4m3, implemented intgmm_v2'squantize_operandspath) instead of with dense XLA-level quantize ops. The dense quantize/amax ops process every row of the ragged buffer at its static size — atragged_buffer_factor<=0(dropless worst case) that istokens * top_krows regardless of how many are valid — while the in-kernel path touches only the tiles covered bygroup_sizes. It subsumes three buffer-sized ops: the per-row lhs re-quantize, thedrhs_dout *= lhs.scalemultiply, and the per-N cotangent quantize. A second effect: because no reduction ever reads past the valid rows, the amax can never ingest uninitialized buffer tail rows (with the dense path, a stale-rowamax=InfpropagatesInf*0=NaNinto the weight gradient; we root-caused a training NaN to exactly this).bwd_quantization_dtype— the gradient fp8 dtype for thefp8_fullqwix recipe:e5m2(default, existing behavior) ore4m3.Requires
use_tokamax_gmm=true use_gmm_v2=trueand an fp8 qwixbwd_qtype; falls back to the XLA-level quantize otherwise. Flags off reproduce the existing behavior.Repro
DeepSeek-V3 671B on v7x, the #4895 recipe at the dropless configuration this lever targets:
ragged_buffer_factor(e.g. 2.0) the dense ops are cheap and the flag is roughly neutral. Use it for dropless (ragged_buffer_factor=-1) configurations.Validation
tpu7xtopology) green: flag-on at the droplessper_device_batch_size=1.0 / ragged_buffer_factor=-1config above, and flag-off on the unmodified Add token quantized all gather flow #4895 recipe.ragged_buffer_factor=-1with no buffer sanitization and no non-finite losses; a 500-step real-data curve on this backward matches the dense-quantize reference at every eval point.