Skip to content

Commit 477a895

Browse files
committed
add dense_fsdp_use_two_stage_all_gather
1 parent 80646f7 commit 477a895

5 files changed

Lines changed: 197 additions & 43 deletions

File tree

src/maxtext/configs/base.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,9 @@ check_vma: False
656656
# Enable ZeRO-1 optimizer sharding over data axis
657657
shard_optimizer_over_data: false
658658

659+
# when dense MLP weight matrices are sharded on both fsdp and fsdp-transpose axes, use two separate all-gather calls
660+
dense_fsdp_use_two_stage_all_gather: false
661+
659662
# Unless explicitly specified, the number of TPU slices is automatically determined. It should only be set for
660663
# disaggregated reinforcement learning workloads using multiple slices. For ahead of time compilation,
661664
# you should set compile_toplogy_num_slices, which will in turn set this value. For non-TPU environments this is set to 1.

src/maxtext/configs/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,10 @@ class LayoutAndSharding(BaseModel):
11141114
"with auto sharding, megablox kernel, and EP / FSDP parallelisms.",
11151115
)
11161116
shard_optimizer_over_data: bool = Field(False, description="Enable ZeRO-1 optimizer sharding over the data axis.")
1117+
dense_fsdp_use_two_stage_all_gather: bool = Field(
1118+
False,
1119+
description="Use two separate All-Gather calls for dense MLP weights sharded on both FSDP and FSDP-transpose.",
1120+
)
11171121
internal_compile: bool = Field(
11181122
False,
11191123
description="Use internal_compile to bypass open-source topology mappings.",

src/maxtext/layers/linears.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import jax.numpy as jnp
2424

2525
from jax import lax
26-
from jax.sharding import NamedSharding, Mesh
26+
from jax.sharding import NamedSharding, Mesh, PartitionSpec
2727
from jax.ad_checkpoint import checkpoint_name
2828

2929
from flax import nnx
@@ -38,6 +38,9 @@
3838
from maxtext.utils import max_logging
3939
from maxtext.utils import max_utils
4040
from maxtext.utils.sharding import maybe_shard_with_logical
41+
from maxtext.utils.sharding import maybe_shard_with_name
42+
from maxtext.utils.sharding import get_physical_spec_without_axes
43+
from maxtext.utils.sharding import FSDP_MESH_AXES
4144

4245

4346
def _convert_to_activation_function(fn_or_string: str | Callable[..., Any]) -> Callable[..., Any]:
@@ -121,6 +124,9 @@ def __init__(
121124
shard_mode: ShardMode = ShardMode.AUTO,
122125
matmul_precision: str = "default",
123126
parameter_memory_host_offload: bool = False,
127+
mesh: Mesh | None = None,
128+
use_two_stage_all_gather: bool = False,
129+
debug_sharding: bool = False,
124130
*, # Following arguments are keyword-only
125131
rngs: nnx.Rngs = None,
126132
):
@@ -140,6 +146,13 @@ def __init__(
140146
shard_mode: auto or explicit shard mode.
141147
matmul_precision: Precision for matrix multiplication.
142148
parameter_memory_host_offload: Determines whether to offload params to host
149+
mesh: Mesh of devices and physical axes, needed for two-stage all-gather.
150+
use_two_stage_all_gather: when the kernel is sharded on both the fsdp and
151+
fsdp_transpose axes, gather the two axes with two separate all-gather
152+
calls (separated by an optimization barrier) to avoid the relayout
153+
transpose XLA emits for a single combined 2-axis all-gather.
154+
debug_sharding: when True, log the logical/physical sharding of the
155+
two-stage all-gather constraints to the sharding dump files.
143156
rngs: RNG state for initialization in nnx.
144157
"""
145158
self.in_features_shape = canonicalize_tuple(in_features_shape)
@@ -154,6 +167,9 @@ def __init__(
154167
self.shard_mode = shard_mode
155168
self.matmul_precision = matmul_precision
156169
self.parameter_memory_host_offload = parameter_memory_host_offload
170+
self.mesh = mesh
171+
self.use_two_stage_all_gather = use_two_stage_all_gather
172+
self.debug_sharding = debug_sharding
157173

158174
# Parameter initialization
159175
kernel_shape = self.in_features_shape + self.out_features_shape
@@ -200,6 +216,39 @@ def quant_dot_general(self) -> nnx_wrappers.ToNNX | None:
200216
return None
201217
return getattr(self, self._quant_dot_general_name)
202218

219+
def _maybe_two_stage_all_gather(self, kernel):
220+
"""Gather a 2D-FSDP-sharded MLP kernel with two single-axis all-gathers.
221+
222+
When the kernel is sharded on both the `fsdp` and `fsdp_transpose` mesh axes,
223+
a single combined 2-axis all-gather forces XLA to materialize an interleave
224+
transpose to fix the layout. Splitting into two single-axis gathers separated
225+
by an `optimization_barrier` makes each stage produce a contiguous layout, so
226+
no transpose is emitted. Mirrors `moe_fsdp_use_two_stage_all_gather`.
227+
"""
228+
if (
229+
not self.use_two_stage_all_gather
230+
or self.mesh is None
231+
or self.mesh.shape.get("fsdp", 1) <= 1
232+
or self.mesh.shape.get("fsdp_transpose", 1) <= 1
233+
):
234+
return kernel
235+
236+
# kernel_axes is a plain tuple of logical names; wrap it so the logical-to-physical
237+
# lookup treats it as a single spec rather than a pytree of strings.
238+
full_logical = PartitionSpec(*self.kernel_axes)
239+
# Stage 1 gathers fsdp_transpose, stage 2 gathers the remaining fsdp.
240+
stage1 = get_physical_spec_without_axes(full_logical, self.mesh, ("fsdp_transpose",))
241+
stage2 = get_physical_spec_without_axes(full_logical, self.mesh, FSDP_MESH_AXES)
242+
if stage1.spec == stage2.spec:
243+
# Not sharded on both FSDP axes, so a single all-gather is already optimal.
244+
return kernel
245+
246+
shard = functools.partial(maybe_shard_with_name, shard_mode=self.shard_mode, debug_sharding=self.debug_sharding)
247+
kernel = shard(kernel, stage1)
248+
kernel = jax.lax.optimization_barrier(kernel)
249+
kernel = shard(kernel, stage2)
250+
return kernel
251+
203252
def __call__(self, inputs: Array, _initializing: bool = False, out_sharding: NamedSharding | None = None) -> Array:
204253
"""Applies a linear transformation to the inputs along multiple dimensions.
205254
@@ -230,6 +279,8 @@ def __call__(self, inputs: Array, _initializing: bool = False, out_sharding: Nam
230279
kernel = jax.device_put(kernel, max_utils.device_space())
231280
kernel = jnp.asarray(kernel, self.dtype)
232281

282+
kernel = self._maybe_two_stage_all_gather(kernel)
283+
233284
# out_sharding should be None for auto mesh axis
234285
if self.shard_mode != ShardMode.EXPLICIT:
235286
out_sharding = None
@@ -422,6 +473,9 @@ def __init__(
422473
use_bias=self.use_bias,
423474
shard_mode=self.config.shard_mode,
424475
matmul_precision=self.config.matmul_precision,
476+
mesh=self.mesh,
477+
use_two_stage_all_gather=self.config.dense_fsdp_use_two_stage_all_gather,
478+
debug_sharding=self.config.debug_sharding,
425479
rngs=rngs,
426480
)
427481
else:
@@ -438,6 +492,9 @@ def __init__(
438492
use_bias=self.use_bias,
439493
shard_mode=self.config.shard_mode,
440494
matmul_precision=self.config.matmul_precision,
495+
mesh=self.mesh,
496+
use_two_stage_all_gather=self.config.dense_fsdp_use_two_stage_all_gather,
497+
debug_sharding=self.config.debug_sharding,
441498
rngs=rngs,
442499
)
443500
setattr(self, dense_name, module)
@@ -453,6 +510,9 @@ def __init__(
453510
use_bias=self.use_bias,
454511
shard_mode=self.config.shard_mode,
455512
matmul_precision=self.config.matmul_precision,
513+
mesh=self.mesh,
514+
use_two_stage_all_gather=self.config.dense_fsdp_use_two_stage_all_gather,
515+
debug_sharding=self.config.debug_sharding,
456516
rngs=rngs,
457517
)
458518

src/maxtext/utils/sharding.py

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -822,34 +822,50 @@ def get_formatted_sharding_annotations(params, mesh=None):
822822
return "\n".join(annotation_lines)
823823

824824

825-
def remove_fsdp_sharding(sharding_tree):
826-
"""Recursively traverses the sharding tree to remove fsdp axes."""
825+
FSDP_MESH_AXES = ("fsdp", "fsdp_transpose")
827826

828-
def _remove_fsdp_from_partition_spec(named_sharding):
829-
"""Removes 'fsdp' and 'fsdp_transpose' from a PartitionSpec."""
827+
828+
def remove_mesh_axes_from_partition_spec(pspec, axes_to_remove, dims=None):
829+
"""Return `pspec` with `axes_to_remove` stripped from the given dims.
830+
831+
Replacing a mesh axis with `None` in a PartitionSpec instructs JAX to replicate
832+
the array data along that physical mesh dimension, so peeling an axis here is
833+
what turns a sharding constraint into an all-gather.
834+
835+
Args:
836+
pspec: The PartitionSpec to strip axes from.
837+
axes_to_remove: Collection of physical mesh axis names to remove.
838+
dims: Dim indices to modify; `None` (the default) modifies every dim.
839+
840+
Returns:
841+
A new PartitionSpec with the requested axes replaced by `None`.
842+
"""
843+
axes_to_remove = set(axes_to_remove)
844+
new_spec = []
845+
for i, axis in enumerate(pspec):
846+
if axis is None or (dims is not None and i not in dims):
847+
new_spec.append(axis)
848+
elif isinstance(axis, str):
849+
new_spec.append(None if axis in axes_to_remove else axis)
850+
elif isinstance(axis, (list, tuple)):
851+
new_spec.append(tuple(a for a in axis if a not in axes_to_remove) or None)
852+
else:
853+
raise ValueError(f"Unsupported axis type: {type(axis)}")
854+
return jax.sharding.PartitionSpec(*new_spec)
855+
856+
857+
def remove_mesh_axes_from_sharding(sharding_tree, axes_to_remove):
858+
"""Recursively traverses a sharding tree removing `axes_to_remove` from each spec."""
859+
860+
def _peel(named_sharding):
830861
if isinstance(named_sharding, jax.sharding.NamedSharding):
831-
new_spec = []
832-
# Iterate through each axis in the original PartitionSpec.
833-
for axis in named_sharding.spec:
834-
if axis is None:
835-
new_spec.append(None)
836-
elif isinstance(axis, str):
837-
# If the axis is 'fsdp', replace it with None to signify replication.
838-
if axis not in ("fsdp", "fsdp_transpose"):
839-
new_spec.append(axis)
840-
else:
841-
new_spec.append(None)
842-
elif isinstance(axis, (list, tuple)):
843-
# If the axis is a collection, filter out 'fsdp'.
844-
new_axis = [a for a in axis if a not in ("fsdp", "fsdp_transpose")]
845-
new_spec.append(tuple(new_axis))
846-
else:
847-
raise ValueError(f"Unsupported_axis_type: {type(axis)}")
848-
# Return a new sharding object with the modified spec.
849-
return jax.sharding.NamedSharding(named_sharding.mesh, jax.sharding.PartitionSpec(*new_spec))
862+
return jax.sharding.NamedSharding(
863+
named_sharding.mesh,
864+
remove_mesh_axes_from_partition_spec(named_sharding.spec, axes_to_remove),
865+
)
850866
return named_sharding
851867

852-
return jax.tree.map(_remove_fsdp_from_partition_spec, sharding_tree)
868+
return jax.tree.map(_peel, sharding_tree)
853869

854870

855871
def remove_expert_from_partition_spec(pspec, dims_to_peel):
@@ -863,19 +879,31 @@ def remove_expert_from_partition_spec(pspec, dims_to_peel):
863879
untouched. Avoids needing a separate `activation_batch_no_exp` logical rule that every
864880
`custom_mesh_and_rule` set would have to redefine.
865881
"""
866-
new_spec = list(pspec)
867-
for i in dims_to_peel:
868-
axis = new_spec[i]
869-
if axis is None:
870-
continue
871-
if isinstance(axis, str):
872-
new_spec[i] = None if axis == "expert" else axis
873-
elif isinstance(axis, (list, tuple)):
874-
filtered = tuple(a for a in axis if a != "expert")
875-
new_spec[i] = filtered or None
876-
else:
877-
raise ValueError(f"Unsupported axis type: {type(axis)}")
878-
return jax.sharding.PartitionSpec(*new_spec)
882+
return remove_mesh_axes_from_partition_spec(pspec, ("expert",), dims=dims_to_peel)
883+
884+
885+
def get_physical_spec_without_axes(full_logical, mesh, axes_to_remove, logical_axis_rules=None):
886+
"""Resolve `full_logical` to a physical sharding with `axes_to_remove` peeled off.
887+
888+
Combines the logical-to-physical lookup with an axis peel, producing a target
889+
layout for an all-gather over exactly the named mesh axes. Peeling a subset of
890+
the FSDP axes (rather than all of them at once) is what lets a 2D-FSDP-sharded
891+
weight be gathered in two separate single-axis stages.
892+
893+
Args:
894+
full_logical: A PyTree of logical PartitionSpecs. Note that a bare tuple of
895+
logical names is a pytree of strings, not a leaf -- wrap it in a
896+
`PartitionSpec` before passing it in.
897+
mesh: The JAX device mesh.
898+
axes_to_remove: Collection of physical mesh axis names to peel.
899+
logical_axis_rules: Rules for converting logical axes to physical mesh axes.
900+
Defaults to the ambient rules context.
901+
902+
Returns:
903+
A PyTree of physical `jax.sharding.NamedSharding` objects.
904+
"""
905+
physical = logical_to_mesh_sharding(full_logical, mesh=mesh, rules=logical_axis_rules)
906+
return remove_mesh_axes_from_sharding(physical, axes_to_remove)
879907

880908

881909
def get_physical_spec_no_fsdp(full_logical, mesh, logical_axis_rules):
@@ -902,11 +930,7 @@ def get_physical_spec_no_fsdp(full_logical, mesh, logical_axis_rules):
902930
mesh axis.
903931
"""
904932

905-
# Convert the high-level logical spec to a physical one using default rules.
906-
physical = logical_to_mesh_sharding(full_logical, mesh=mesh, rules=logical_axis_rules)
907-
# Apply the function to remove the FSDP sharding, defining our target layout.
908-
physical_no_fsdp = remove_fsdp_sharding(physical)
909-
return physical_no_fsdp
933+
return get_physical_spec_without_axes(full_logical, mesh, FSDP_MESH_AXES, logical_axis_rules)
910934

911935

912936
def all_gather_over_fsdp(variables, sharding_info, mesh, logical_axis_rules, shard_mode):

tests/unit/linears_test.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
import sys
1818
import unittest
1919
from flax import nnx
20+
from flax.linen import partitioning as nn_partitioning
2021
import jax
2122
import jax.numpy as jnp
2223
import numpy as np
24+
import pytest
2325

2426
from maxtext.layers import linears
2527
from maxtext.configs import pyconfig
@@ -199,6 +201,67 @@ def test_fused_mlp(self):
199201
self.assertEqual(outputs.shape, (batch_size, seq_len, in_features))
200202
self.assertEqual(layer.wi.kernel[...].shape, (in_features, 1, intermediate_dim))
201203

204+
@pytest.mark.tpu_only
205+
def test_dense_fsdp_two_stage_all_gather_tpu_only(self):
206+
"""`dense_fsdp_use_two_stage_all_gather` is a pure resharding and must not change MLP numerics.
207+
208+
The flag replaces the single combined 2-axis (fsdp x fsdp_transpose) kernel all-gather with two
209+
single-axis gathers separated by an `optimization_barrier`. That changes only the weight's layout,
210+
not its values, so on a 2D-FSDP mesh the MLP output must match the default single-all-gather path.
211+
Covers both the non-fused (2D `wi`/`wo` kernels) and fused (3D `wi` kernel) MLP paths.
212+
"""
213+
# Imperative skip: calling jax.device_count() in a decorator would force JAX init during collection.
214+
if jax.device_count() != 4:
215+
self.skipTest("Dense FSDP two-stage all-gather test requires exactly 4 devices")
216+
217+
in_features = 128
218+
intermediate_dim = 256
219+
batch_size = 4
220+
seq_len = 8
221+
222+
def build_and_run(use_two_stage, fused_mlp):
223+
config_arguments = {
224+
"per_device_batch_size": 1.0,
225+
"run_name": "test",
226+
"enable_checkpointing": False,
227+
"max_target_length": seq_len,
228+
"dtype": "bfloat16",
229+
"fused_mlp": fused_mlp,
230+
"ici_fsdp_parallelism": 2,
231+
"ici_fsdp_transpose_parallelism": 2,
232+
"dense_fsdp_use_two_stage_all_gather": use_two_stage,
233+
}
234+
argv = [sys.argv[0], get_test_config_path()]
235+
cfg = pyconfig.initialize(argv, **config_arguments)
236+
237+
devices_array = maxtext_utils.create_device_mesh(cfg)
238+
mesh = jax.sharding.Mesh(devices_array, cfg.mesh_axes)
239+
# Fresh, fixed-seed Rngs yields deterministic, identical weights across both builds.
240+
with mesh, nn_partitioning.axis_rules(cfg.logical_axis_rules):
241+
layer = linears.MlpBlock(
242+
config=cfg,
243+
mesh=mesh,
244+
in_features=in_features,
245+
intermediate_dim=intermediate_dim,
246+
rngs=nnx.Rngs(params=0, dropout=1),
247+
)
248+
inputs = jax.random.uniform(jax.random.PRNGKey(1), (batch_size, seq_len, in_features), dtype=cfg.dtype)
249+
return nnx.jit(lambda m, x: m(x))(layer, inputs)
250+
251+
for fused_mlp in (False, True):
252+
with self.subTest(fused_mlp=fused_mlp):
253+
reference = build_and_run(use_two_stage=False, fused_mlp=fused_mlp)
254+
two_stage = build_and_run(use_two_stage=True, fused_mlp=fused_mlp)
255+
256+
self.assertEqual(two_stage.shape, (batch_size, seq_len, in_features))
257+
self.assertTrue(
258+
jnp.allclose(reference, two_stage, rtol=1e-2, atol=1e-2, equal_nan=False),
259+
msg=(
260+
f"two-stage all-gather changed MLP output (fused_mlp={fused_mlp}): max abs diff="
261+
f"{jnp.max(jnp.abs(reference.astype(jnp.float32) - two_stage.astype(jnp.float32)))}"
262+
),
263+
)
264+
202265

203266
if __name__ == "__main__":
204267
unittest.main()

0 commit comments

Comments
 (0)