Skip to content

Commit 579f955

Browse files
committed
Trying wrap free ring schedule
1 parent d2486fa commit 579f955

3 files changed

Lines changed: 222 additions & 44 deletions

File tree

src/maxdiffusion/generate_wan.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,13 @@ def run(config, pipeline=None, filename_prefix="", commit_hash=None):
299299
max_logging.log(f"compile_time: {compile_time}")
300300
if writer and jax.process_index() == 0:
301301
writer.add_scalar("inference/compile_time", compile_time, global_step=0)
302+
# Optional numerical dump of the decoded output for correctness checks
303+
# (env WAN_DUMP_OUTPUT=/path/to.npy). Used to A/B attention variants.
304+
if os.environ.get("WAN_DUMP_OUTPUT"):
305+
import numpy as _np
306+
307+
_np.save(os.environ["WAN_DUMP_OUTPUT"], _np.asarray(videos[0], dtype=_np.float32))
308+
max_logging.log(f"WAN_DUMP_OUTPUT: wrote {os.environ['WAN_DUMP_OUTPUT']}")
302309
saved_video_path = []
303310
for i in range(len(videos)):
304311
video_path = f"{filename_prefix}wan_output_{config.seed}_{i}.mp4"

src/maxdiffusion/kernels/splash_attention/ring_attention_kernel.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,109 @@ def make_ring_attention(
726726
# ---------------------------------------------------------------------------
727727

728728

729+
def _custom_bidirectional_ring_forward(
730+
q: jax.Array,
731+
k: jax.Array,
732+
v: jax.Array,
733+
*,
734+
block_sizes: "custom_splash._BlockSizes",
735+
bkv_compute_in: int,
736+
orig_q_seq_len: int,
737+
orig_kv_seq_len: int,
738+
use_base2_exp: bool,
739+
use_experimental_scheduler: bool,
740+
vmem_limit_bytes: int | None,
741+
mask_value: float,
742+
ring_axis: str,
743+
) -> jax.Array:
744+
"""Wrap-free (bidirectional) ring attention for a NON-wrapping ring axis.
745+
746+
On a torus dimension the +1-mod-R ppermute is nearest-neighbor, but on a
747+
cut/non-wrapping axis (e.g. the size-4 z line of a v7x-16 slice) the wrap edge
748+
(R-1 -> 0) spans the whole line diameter. Instead of one rotating stream with
749+
that long, congested wrap, stream K/V BOTH directions one hop at a time:
750+
- rightward stream: device i holds KV_{i-t} after t hops,
751+
- leftward stream: device i holds KV_{i+t} after t hops,
752+
with out-of-range shards (line ends) masked out of the online softmax. Every
753+
step is a single hop and uses both link directions; no edge ever spans the
754+
diameter.
755+
756+
Trade-off: each device computes ~2x attention blocks (the line-end ones are
757+
masked), traded for the removed multi-hop wrap. Net win when the ring is
758+
comms-bound (the case on a non-wrapping axis). Operates on the FULL real ring
759+
axis (no sub-group perm).
760+
"""
761+
axis_size = lax.axis_size(ring_axis)
762+
idx = lax.axis_index(ring_axis)
763+
exp_fn = jnp.exp2 if use_base2_exp else jnp.exp
764+
765+
def _attn(kc, vc):
766+
o, m, l = custom_splash._splash_attention_forward_ring( # pylint: disable=protected-access
767+
q,
768+
kc,
769+
vc,
770+
block_sizes,
771+
bkv_compute_in,
772+
q_seq_len=orig_q_seq_len,
773+
kv_seq_len=orig_kv_seq_len,
774+
use_base2_exp=use_base2_exp,
775+
use_experimental_scheduler=use_experimental_scheduler,
776+
vmem_limit_bytes=vmem_limit_bytes,
777+
)
778+
return o.astype(jnp.float32), m.astype(jnp.float32), l.astype(jnp.float32)
779+
780+
def _merge(m, l, o, mc, lc, oc, valid):
781+
# Nullify invalid (line-end) contributions. Force mc to mask_value (beta -> 0)
782+
# AND zero lc/oc so a non-finite zero-buffer result can't leak via 0*inf=nan.
783+
mc = jnp.where(valid, mc, mask_value)
784+
lc = jnp.where(valid, lc, 0.0)
785+
oc = jnp.where(valid, oc, 0.0)
786+
m_next = jnp.maximum(m, mc)
787+
alpha = exp_fn(m - m_next)
788+
beta = exp_fn(mc - m_next)
789+
return m_next, alpha * l + beta * lc, alpha[..., None] * o + beta[..., None] * oc
790+
791+
# t=0: own shard (always valid). _attn returns (o, m, l).
792+
o, m, l = _attn(k, v)
793+
794+
# Non-wrapping one-hop shifts (line ends send/receive nothing).
795+
shift_r = partial(lax.ppermute, axis_name=ring_axis, perm=[(i, i + 1) for i in range(axis_size - 1)])
796+
shift_l = partial(lax.ppermute, axis_name=ring_axis, perm=[(i, i - 1) for i in range(1, axis_size)])
797+
798+
# Prime buffers for t=1 (one hop each direction): device i -> KV_{i-1}, KV_{i+1}.
799+
kr, vr = shift_r(k), shift_r(v)
800+
kl, vl = shift_l(k), shift_l(v)
801+
802+
def body(carry, t):
803+
m, l, o, kr, vr, kl, vl = carry
804+
valid_r = (idx - t) >= 0
805+
valid_l = (idx + t) <= (axis_size - 1)
806+
# Feed real (own) K/V on invalid steps so _attn never runs on a degenerate
807+
# zero buffer (line ends receive 0 from the partial ppermute); masked below.
808+
kr_s, vr_s = jnp.where(valid_r, kr, k), jnp.where(valid_r, vr, v)
809+
kl_s, vl_s = jnp.where(valid_l, kl, k), jnp.where(valid_l, vl, v)
810+
# Compute against the current shards (KV_{i-t}, KV_{i+t}) ...
811+
o_r, m_r, l_r = _attn(kr_s, vr_s)
812+
m, l, o = _merge(m, l, o, m_r, l_r, o_r, valid_r)
813+
o_l, m_l, l_l = _attn(kl_s, vl_s)
814+
m, l, o = _merge(m, l, o, m_l, l_l, o_l, valid_l)
815+
# ... and prefetch the next hop (independent of the matmuls above -> overlaps).
816+
kr_n, vr_n = shift_r(kr), shift_r(vr)
817+
kl_n, vl_n = shift_l(kl), shift_l(vl)
818+
return (m, l, o, kr_n, vr_n, kl_n, vl_n), None
819+
820+
(_, l_final, o_final, *_), _ = lax.scan(
821+
body,
822+
(m, l, o, kr, vr, kl, vl),
823+
xs=jnp.arange(1, axis_size),
824+
length=axis_size - 1,
825+
unroll=True,
826+
)
827+
828+
l_inv = jnp.where(l_final == 0.0, 0.0, 1.0 / l_final)
829+
return (o_final * l_inv[..., None]).astype(q.dtype)
830+
831+
729832
def _custom_ring_attention_forward(
730833
q: jax.Array,
731834
k: jax.Array,
@@ -742,6 +845,7 @@ def _custom_ring_attention_forward(
742845
ring_axis: str,
743846
ring_size: int | None = None,
744847
perm: list[tuple[int, int]] | None = None,
848+
bidirectional: bool = False,
745849
) -> jax.Array:
746850
"""Forward-only ring attention using the custom dense splash kernel.
747851
@@ -776,6 +880,26 @@ def _custom_ring_attention_forward(
776880
Normalized attention output, shape `(num_q_heads, q_seq_len, head_dim_v)`.
777881
"""
778882
axis_size = lax.axis_size(ring_axis)
883+
if bidirectional:
884+
if perm is not None or (ring_size is not None and ring_size != axis_size):
885+
raise ValueError(
886+
"bidirectional (wrap-free) ring requires perm=None and ring_size==axis_size "
887+
"(it operates on the full real ring axis)."
888+
)
889+
return _custom_bidirectional_ring_forward(
890+
q,
891+
k,
892+
v,
893+
block_sizes=block_sizes,
894+
bkv_compute_in=bkv_compute_in,
895+
orig_q_seq_len=orig_q_seq_len,
896+
orig_kv_seq_len=orig_kv_seq_len,
897+
use_base2_exp=use_base2_exp,
898+
use_experimental_scheduler=use_experimental_scheduler,
899+
vmem_limit_bytes=vmem_limit_bytes,
900+
mask_value=mask_value,
901+
ring_axis=ring_axis,
902+
)
779903
if ring_size is None:
780904
ring_size = axis_size
781905
if perm is None:
@@ -847,6 +971,7 @@ def make_custom_ring_attention(
847971
ring_axis: str = "context",
848972
ring_size: int | None = None,
849973
perm: list[tuple[int, int]] | None = None,
974+
bidirectional: bool = False,
850975
):
851976
"""Builds a forward-only ring-attention callable around the custom kernel.
852977
@@ -858,6 +983,10 @@ def make_custom_ring_attention(
858983
`ring_size` / `perm` let a caller restrict the rotation to a ring sub-group of
859984
the axis (for the hybrid Ulysses+Ring / USP split); when omitted the rotation
860985
covers the whole `ring_axis`.
986+
987+
`bidirectional=True` selects the wrap-free schedule (streams K/V both directions
988+
one hop at a time) for a NON-wrapping ring axis, avoiding the diameter-length
989+
wrap hop. Requires `perm=None` and the full real ring axis (no sub-group).
861990
"""
862991

863992
def _ring(q, k, v):
@@ -876,6 +1005,7 @@ def _ring(q, k, v):
8761005
ring_axis=ring_axis,
8771006
ring_size=ring_size,
8781007
perm=perm,
1008+
bidirectional=bidirectional,
8791009
)
8801010

8811011
return _ring

src/maxdiffusion/models/attention_flax.py

Lines changed: 85 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -766,24 +766,47 @@ def wrap_ulysses_attention(query, key, value):
766766
return x
767767

768768

769-
def _usp_groups(num_shards: int, ulysses_degree: int):
770-
"""Factorizes the `context` axis into a U x R (Ulysses x Ring) grid.
769+
INTERNAL_RING_AXIS = "ring"
770+
INTERNAL_ULYSSES_AXIS = "ulysses"
771+
772+
773+
def _replace_mesh_axis(axis_spec, old_axis, new_axes):
774+
"""Replace a mesh-axis name (or one nested inside a tuple) with new_axes."""
775+
if axis_spec == old_axis:
776+
return new_axes
777+
if isinstance(axis_spec, tuple):
778+
replacement = []
779+
for axis in axis_spec:
780+
if axis == old_axis:
781+
replacement.extend(new_axes)
782+
else:
783+
replacement.append(axis)
784+
return tuple(replacement)
785+
return axis_spec
786+
787+
788+
def _replace_mesh_axis_names(axis_names, old_axis, new_axes):
789+
return jax.sharding.PartitionSpec(*(_replace_mesh_axis(a, old_axis, new_axes) for a in axis_names))
771790

772-
Device index along the `context` axis is laid out u-major: `c = u*R + r`, so
773-
- the Ulysses all-to-all group (fixed r, varying u) is `[u*R + r for u]`, and
774-
- the Ring rotation group (fixed u, varying r) is `[u*R + r for r]`.
775791

776-
Returns `(U, R, ulysses_groups, ring_perm)` where `ulysses_groups` is the
777-
`axis_index_groups` for the all-to-all and `ring_perm` is a `ppermute` perm
778-
that rotates K/V by +1 *within each ring sub-group only*.
792+
def _create_internal_ulysses_ring_mesh(
793+
mesh, ring_shards, ulysses_shards, ring_axis=INTERNAL_RING_AXIS, ulysses_axis=INTERNAL_ULYSSES_AXIS
794+
):
795+
"""Split the public `context` mesh axis into private (ring, ulysses) axes.
796+
797+
Ported from origin/main's tested implementation (commit c104db51). The reshape
798+
is `(..., ring_shards, ulysses_shards, ...)` with the Ulysses axis INNERMOST, so
799+
for ulysses_shards==2 the Ulysses group is consecutive device ids (the two cores
800+
of one chip): the all-to-all stays intra-chip while the ring rotates across chips.
779801
"""
780-
U = ulysses_degree
781-
if num_shards % U != 0:
782-
raise ValueError(f"ulysses_degree={U} must divide context shard count {num_shards}.")
783-
R = num_shards // U
784-
ulysses_groups = [[u * R + r for u in range(U)] for r in range(R)]
785-
ring_perm = [(u * R + r, u * R + (r + 1) % R) for u in range(U) for r in range(R)]
786-
return U, R, ulysses_groups, ring_perm
802+
mesh_axis_names = tuple(mesh.axis_names)
803+
context_axis_index = mesh_axis_names.index("context")
804+
devices = mesh.devices
805+
new_shape = devices.shape[:context_axis_index] + (ring_shards, ulysses_shards) + devices.shape[context_axis_index + 1 :]
806+
new_axis_names = (
807+
mesh_axis_names[:context_axis_index] + (ring_axis, ulysses_axis) + mesh_axis_names[context_axis_index + 1 :]
808+
)
809+
return Mesh(devices.reshape(new_shape), new_axis_names)
787810

788811

789812
def _ulysses_ring_attention(
@@ -802,49 +825,66 @@ def _ulysses_ring_attention(
802825
use_base2_exp: bool = True,
803826
use_experimental_scheduler: bool = False,
804827
) -> jax.Array:
805-
"""Hybrid Ulysses + Ring (USP) sequence parallelism with the custom kernel.
806-
807-
Splits the single `context` mesh axis into a U x R grid using collective
808-
sub-groups (no extra mesh axes needed):
809-
1. all-to-all WITHIN each Ulysses group (size U): trade sequence for heads,
810-
so each device holds the full sequence of its ring-chunk but heads/U heads;
811-
2. ring (ppermute) WITHIN each Ring group (size R): rotate K/V R times and
812-
merge via online softmax, using the custom dense kernel per step;
828+
"""Hybrid Ulysses + Ring (USP) with the CUSTOM splash kernel on main's mesh.
829+
830+
Uses origin/main's explicit internal `(ring, ulysses)` mesh
831+
(`_create_internal_ulysses_ring_mesh`, commit c104db51) instead of single-axis
832+
collective sub-groups: the public `context` axis is reshaped with the Ulysses
833+
axis innermost, so the Ulysses all-to-all stays INTRA-chip and the ring rotates
834+
ACROSS chips. The per-shard attention is our custom splash kernel
835+
(`make_custom_ring_attention`), not the tokamax_ring kernel main uses.
836+
837+
1. all-to-all over the (intra-chip) Ulysses axis: trade sequence for heads;
838+
2. ring (full ppermute) over the (cross-chip) ring axis, online-softmax merge;
813839
3. all-to-all back to restore the sequence-sharded / full-heads layout.
814840
815-
U is read from the env var ULYSSES_RING_DEGREE (default 2); R = context // U.
816-
U=context reduces to pure Ulysses, U=1 to pure Ring.
841+
U = ULYSSES_RING_DEGREE (default 2); R = context // U. U=context -> pure
842+
Ulysses, U=1 -> pure Ring (all on the same custom kernel).
817843
"""
818844
axis_name = "context"
819-
num_shards = mesh.shape[axis_name]
820-
ulysses_degree = int(os.environ.get("ULYSSES_RING_DEGREE", "2"))
821-
U, R, ulysses_groups, ring_perm = _usp_groups(num_shards, ulysses_degree)
845+
num_context_shards = mesh.shape[axis_name]
846+
num_ulysses_shards = int(os.environ.get("ULYSSES_RING_DEGREE", "2"))
847+
if num_ulysses_shards <= 0 or num_context_shards % num_ulysses_shards != 0:
848+
raise ValueError(
849+
f"ULYSSES_RING_DEGREE={num_ulysses_shards} must be a positive divisor of "
850+
f"context shard count {num_context_shards}."
851+
)
852+
num_ring_shards = num_context_shards // num_ulysses_shards
853+
# Wrap-free (bidirectional) ring schedule for a non-wrapping ring axis (e.g. the
854+
# cut size-4 z line of a v7x-16 slice). Set USP_WRAP_FREE_RING=1 to A/B it.
855+
wrap_free_ring = os.environ.get("USP_WRAP_FREE_RING", "0") == "1"
822856

823-
query, orig_q_seq_len = _reshape_data_for_flash(query, heads, num_shards)
824-
key, _ = _reshape_data_for_flash(key, heads, num_shards)
825-
value, _ = _reshape_data_for_flash(value, heads, num_shards)
857+
query, orig_q_seq_len = _reshape_data_for_flash(query, heads, num_context_shards)
858+
key, _ = _reshape_data_for_flash(key, heads, num_context_shards)
859+
value, _ = _reshape_data_for_flash(value, heads, num_context_shards)
826860
num_heads = query.shape[1]
827-
if num_heads % U != 0:
828-
raise ValueError(f"Ulysses+Ring requires heads divisible by the Ulysses degree U={U}, got heads={num_heads}.")
861+
if num_heads % num_ulysses_shards != 0:
862+
raise ValueError(f"Ulysses+Ring requires heads divisible by U={num_ulysses_shards}, got heads={num_heads}.")
829863

830864
bq, bkv, bkv_compute, bkv_compute_in, heads_per_tile, vmem_limit_bytes = _extract_custom_block_sizes(flash_block_sizes)
831865
if heads_per_tile > 1:
832866
raise NotImplementedError("ulysses_ring_custom currently supports heads_per_tile == 1 only.")
833867

868+
internal_mesh = _create_internal_ulysses_ring_mesh(mesh, num_ring_shards, num_ulysses_shards)
869+
ring_axis = INTERNAL_RING_AXIS
870+
ulysses_axis = INTERNAL_ULYSSES_AXIS
871+
834872
q_axis_names = nn.logical_to_mesh_axes(axis_names_q)
835873
kv_axis_names = nn.logical_to_mesh_axes(axis_names_kv)
874+
internal_q_axis_names = _replace_mesh_axis_names(q_axis_names, axis_name, (ring_axis, ulysses_axis))
875+
internal_kv_axis_names = _replace_mesh_axis_names(kv_axis_names, axis_name, (ring_axis, ulysses_axis))
836876

837877
@functools.partial(
838878
jax.shard_map,
839-
mesh=mesh,
840-
in_specs=(q_axis_names, kv_axis_names, kv_axis_names),
841-
out_specs=q_axis_names,
879+
mesh=internal_mesh,
880+
in_specs=(internal_q_axis_names, internal_kv_axis_names, internal_kv_axis_names),
881+
out_specs=internal_q_axis_names,
842882
check_vma=False,
843883
)
844884
def wrap_ulysses_ring_attention(query, key, value):
845-
# (1) Ulysses all-to-all within each U-group: heads -> sequence swap, so each
846-
# device now holds the full ring-chunk sequence with heads/U heads.
847-
a2a = functools.partial(jax.lax.all_to_all, axis_name=axis_name, axis_index_groups=ulysses_groups, tiled=True)
885+
# (1) Ulysses all-to-all over the (intra-chip) ulysses axis: heads -> sequence,
886+
# so each device holds the full ring-chunk sequence with heads/U heads.
887+
a2a = functools.partial(jax.lax.all_to_all, axis_name=ulysses_axis, tiled=True)
848888
query = a2a(query, split_axis=1, concat_axis=2)
849889
key = a2a(key, split_axis=1, concat_axis=2)
850890
value = a2a(value, split_axis=1, concat_axis=2)
@@ -857,7 +897,7 @@ def wrap_ulysses_ring_attention(query, key, value):
857897
value, _, _ = _pad_data_for_flash(value, heads, bkv)
858898

859899
bsizes = custom_splash._BlockSizes(block_q=bq, block_kv=bkv, block_kv_compute=bkv_compute)
860-
# (2) Ring over R within each U-group (rotation restricted by ring_perm).
900+
# (2) Ring (full ppermute over the cross-chip ring axis) with the custom kernel.
861901
ring_kernel = tokamax_ring_attention_kernel.make_custom_ring_attention(
862902
block_sizes=bsizes,
863903
bkv_compute_in=bkv_compute_in,
@@ -866,19 +906,20 @@ def wrap_ulysses_ring_attention(query, key, value):
866906
use_base2_exp=use_base2_exp,
867907
use_experimental_scheduler=use_experimental_scheduler,
868908
vmem_limit_bytes=vmem_limit_bytes,
869-
ring_axis=axis_name,
870-
ring_size=R,
871-
perm=ring_perm,
909+
ring_axis=ring_axis,
910+
ring_size=num_ring_shards,
911+
bidirectional=wrap_free_ring,
872912
)
873913
vmapped_ring = jax.vmap(ring_kernel, in_axes=(0, 0, 0))
874914
attention_output = vmapped_ring(query, key, value)
875915
attention_output = attention_output[:, :, :query_seq_len, :kv_size].astype(query.dtype)
876916

877-
# (3) Ulysses all-to-all back: sequence -> heads swap, restoring the layout.
917+
# (3) Ulysses all-to-all back: sequence -> heads, restoring the layout.
878918
attention_output = a2a(attention_output, split_axis=2, concat_axis=1)
879919
return attention_output
880920

881921
x = wrap_ulysses_ring_attention(query, key, value)
922+
x = jax.lax.with_sharding_constraint(x, q_axis_names)
882923
x = x[:, :, :orig_q_seq_len, :]
883924
x = _reshape_heads_to_head_dim(x)
884925
return x

0 commit comments

Comments
 (0)