Shard the GatedDeltaNet sequence under ici_context_parallelism - #4968
Shard the GatedDeltaNet sequence under ici_context_parallelism#4968WandLZhang wants to merge 2 commits into
Conversation
Two pspecs pinned the GDN sequence axis to None and the inter-chunk recurrence was a sequential lax.scan, so ici_context_parallelism could never shard the GDN sequence. The recurrence is affine in the state, h' = A.h + B, so it evaluates as a two-pass associative scan: fold each device's chunks into one (A, B) pair, all-gather the pairs, take the exclusive prefix, replay locally from the correct incoming state. A and B are 128x128 at every published Qwen 3.5 size, so the collective does not grow with the model. qkvz_pspec also had the sequence axis hardcoded replicated, which gathered the full sequence onto every device. Measured at 1,048,576 tokens on 64 v5p chips: 513 tokens/s/chip, 27.6% MFU.
There was a problem hiding this comment.
Code Review
This pull request introduces context-parallel evaluation for the GatedDeltaNet (GDN) inter-chunk recurrence to optimize memory usage during long-sequence training. It adds a new module gdn_cp.py to handle the two-pass affine composition of local chunks and cross-device state communication, and updates qwen3.py to support sequence sharding over context-parallel axes. A critical runtime issue was identified in gdn_cp.py where cp_axis is passed as a tuple of strings, but lax.axis_index only accepts a single string. A detailed code suggestion is provided to generalize incoming_state to support both single strings and tuples of strings, correctly handling multi-dimensional device grids.
| def incoming_state(A_loc, B_loc, h_init, cp_axis): | ||
| """State entering this device, plus the final state after all devices. | ||
|
|
||
| Gathering D pairs of small matrices is the only cross-device traffic in the | ||
| scheme. Must be called inside a shard_map over `cp_axis`. | ||
| """ | ||
| A_all = lax.all_gather(A_loc, cp_axis, axis=0, tiled=False) | ||
| B_all = lax.all_gather(B_loc, cp_axis, axis=0, tiled=False) | ||
| A_cum, B_cum = lax.associative_scan(compose, (A_all, B_all), axis=0) | ||
|
|
||
| idx = lax.axis_index(cp_axis) | ||
| prev = jnp.maximum(idx - 1, 0) | ||
| carried = jnp.matmul(A_cum[prev], h_init, precision=_PREC) + B_cum[prev] | ||
| h_in = jnp.where(idx == 0, h_init, carried) | ||
| final_h = jnp.matmul(A_cum[-1], h_init, precision=_PREC) + B_cum[-1] | ||
| return h_in, final_h |
There was a problem hiding this comment.
In qwen3.py, cp_axis is passed as a tuple of strings (e.g., ("context",) or ("context", "context_usp_ulysses")). However, lax.axis_index only accepts a single string axis name, and calling it with a tuple will raise a TypeError at runtime. Additionally, if there are multiple context parallel axes, lax.all_gather and lax.associative_scan need to be handled sequentially and flattened to correctly compute the 1D associative scan across the multi-dimensional device grid.
This suggestion generalizes incoming_state to support both a single string and a tuple of strings for cp_axis, gathers sequentially, flattens the gathered dimensions in the correct major-to-minor sequence order, and computes the correct flat device index.
def incoming_state(A_loc, B_loc, h_init, cp_axis):
"""State entering this device, plus the final state after all devices.
Gathering D pairs of small matrices is the only cross-device traffic in the
scheme. Must be called inside a shard_map over `cp_axis`.
"""
if isinstance(cp_axis, str):
cp_axes = (cp_axis,)
else:
cp_axes = cp_axis
A_all = A_loc
B_all = B_loc
for axis in cp_axes:
A_all = lax.all_gather(A_all, axis, axis=0, tiled=False)
B_all = lax.all_gather(B_all, axis, axis=0, tiled=False)
n_axes = len(cp_axes)
if n_axes > 1:
# Reverse the gathered dimensions to match the major-to-minor order of cp_axes
perm = list(range(n_axes))[::-1] + list(range(n_axes, A_all.ndim))
A_all = jnp.transpose(A_all, perm)
B_all = jnp.transpose(B_all, perm)
# Flatten the gathered dimensions into a single axis at 0
orig_shape_A = A_all.shape
orig_shape_B = B_all.shape
num_devices = 1
for axis in cp_axes:
num_devices *= lax.psum(1, axis)
A_all = A_all.reshape((num_devices,) + orig_shape_A[n_axes:])
B_all = B_all.reshape((num_devices,) + orig_shape_B[n_axes:])
A_cum, B_cum = lax.associative_scan(compose, (A_all, B_all), axis=0)
idx = 0
stride = 1
for axis in reversed(cp_axes):
idx += lax.axis_index(axis) * stride
stride *= lax.psum(1, axis)
prev = jnp.maximum(idx - 1, 0)
carried = jnp.matmul(A_cum[prev], h_init, precision=_PREC) + B_cum[prev]
h_in = jnp.where(idx == 0, h_init, carried)
final_h = jnp.matmul(A_cum[-1], h_init, precision=_PREC) + B_cum[-1]
return h_in, final_h
Fixes #4932.
Two pspecs pinned the GDN sequence axis to
None, and the inter-chunk recurrence was a sequentiallax.scan. Together they meanici_context_parallelismcan't shard the GDN sequence at all, so neither 256K nor 1M is reachable at any chip count.The recurrence is affine in the state:
so it evaluates as a two-pass associative scan. Each device folds its local chunks into one
(A, B)pair with alax.scan— notassociative_scan, which would materialize the running composition per chunk, 17 GB per device at a million tokens — then an all-gather exchanges the pairs and an exclusive prefix gives each device its incoming state.AandBare 128x128 at every published Qwen 3.5 size, so the collective doesn't grow with the model.The second pspec fix matters as much as the scan.
qkvz_pspechad the sequence axis hardcoded replicated, which told XLA to gather the full sequence onto every device: atctx=2, seq 32,768, six livebf16[2,32768,16,512]buffers of 1.07 GB each. Found by diffing XLA buffer assignment betweenctx=1andctx=2.Measured at 1,048,576 tokens on 64 v5p chips: 513 tokens/s/chip, 27.6% MFU, loss 6.736 to 4.335.
Interaction with #4348
The two can't both be live — the fused kernel returns above the state composition, so
use_pallasis forced false whenever a context axis is set and sequence-sharded runs take the XLA scan. @bzantium verified that fallback is loss-preserving: 6e-6 relative on the output, 2e-5 on final state. Disjoint rather than stacked, so this goes againstmainand #4348 rebases on top.Size
models/gdn_cp.pyis new, 79 lines.models/qwen3.pyis +42/-4. Applies to a pristine checkout ofmainwith no dependency on #4348 or on my other open PRs.Tests
ici_context_parallelism=4at sequence 32,768 andici_context_parallelism=8at 262,144, both training with loss descending, on v5p and v6e. 1,048,576 tokens on 64 v5p chips at 513 tokens/s/chip. Memory is flat inctxat a fixed local shard: 30.5 GB atctx=2against 30.7 GB atctx=4on a 2,048-token shard, so the sizing extrapolates. pyink clean.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.