Skip to content

Distributed support - #482

Open
ronaldmannak wants to merge 9 commits into
ml-explore:mainfrom
PicoMLX:distributed-minimal
Open

ronaldmannak wants to merge 9 commits into
ml-explore:mainfrom
PicoMLX:distributed-minimal

Conversation

@ronaldmannak

@ronaldmannak ronaldmannak commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Proposed changes

Distributed support for MLX Swift

Ports mlx.core.distributed and the core of mlx.nn.layers.distributed, and
enables the ring backend. Supersedes #371, which was written against mlx 0.31.1.

Not included, deliberately: the quantized sharded layers, fully_shard, and the
JACCL backend, which will be added in a separate PR.

mlx-c gaps

Three things in mlx::core::distributed aren't reachable through mlx-c. None
block this PR, but (1) and (2) would need to land before a follow-up.

1. No way to pass an unspecified stream. mlx_stream_get_() rejects a null
handle, so Group::communication_stream never applies and every caller must
hardcode a device, CPU here, which is wrong the moment NCCL is enabled, since
NCCL is the one backend that wants the GPU.

The distributed entry points already take const mlx_distributed_group group /* may be null */
and read null as "the global group". Letting mlx_stream follow the same
convention would resolve it without an API change:

int mlx_distributed_all_sum(mlx_array* res, const mlx_array x,
                            const mlx_distributed_group group /* may be null */,
                            const mlx_stream s /* may be null */);

Blocks: correct NCCL support, and full parity with Python's stream=None.

2. The JACCL side-channel init overload isn't exposed.

MLX_API Group init(bool strict, const std::string& bk, AllGatherFactory factory);

Python offers this as all_gather_factory. There's no C equivalent, so Swift
can't supply a custom rendezvous.

3. clear_backends() isn't exposed. Groups are cached per backend for the
process lifetime with no way to release them. Python registers it with atexit.

Blocks: nothing outright, but it makes test isolation awkward, a process that
has formed a real group can't go back to a singleton one.

Tests

DistributedRingTests runs two real ranks on loopback, including the sharded
layers. Set MLX_TEST_DISTRIBUTED=1 to run it:

MLX_TEST_DISTRIBUTED=1 xcrun xctest -XCTest DistributedRingTests .../MLXTests.xctest

Checklist

  • I have read the CONTRIBUTING document
  • I have run pre-commit run --all-files to format my code / installed pre-commit prior to committing changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the necessary documentation (if needed)

Compile mlx-c/mlx/c/distributed.cpp and distributed_group.cpp into the
Cmlx target.  No backend is enabled yet, so every backend reports
unavailable and distributed::init() returns an empty group of size 1.

Adds MLXDistributed.isAvailable() and a test, which together verify the
wrappers both compile and link.
Build mlx/distributed/ring/ring.cpp instead of the no_ring.cpp stub.
Ring needs only TCP sockets, so it builds on every platform without
additional include paths or SDK requirements.

Without MLX_HOSTFILE and MLX_RANK, ring::init() returns null for a
non-strict init, so the default group remains an empty group of size 1
and single-process tests are unaffected.
Port mlx.core.distributed to Swift: MLXDistributed.Group with rank, size
and split, plus the eight collectives and point to point operations
(allSum, allMax, allMin, allGather, sumScatter, send, recv, recvLike).

Following the rest of the Swift API none of these throw -- errors raised
by MLX are reported through mlx_set_error_handler and surface via
withError()/checkedEval().

Tests run single process: at size one MLX short circuits the collectives
to the identity and rejects the point to point operations, which covers
the whole API surface without a launcher.
Port the core of mlx.nn.layers.distributed: AllToShardedLinear and
ShardedToAllLinear, the shardLinear/shardInPlace helpers, sumGradients
and averageGradients.

Python's segments argument accepts an int or a list of either indices or
fractions; that becomes the Segments enum here.  The from_linear class
methods become convenience initializers taking a Linear, matching how
QuantizedLinear converts.

The quantized layers and fully_shard are not included.
The distributed primitives have no GPU implementation on Metal --
AllReduce::eval_gpu and friends in backend/metal/distributed.cpp throw
unconditionally -- so a collective submitted to the default GPU stream
always fails.

MLX handles this by letting each backend choose its device when no
stream is given: Group::communication_stream returns to_stream(s,
Device::cpu) for ring, mpi and jaccl, and Device::gpu for nccl.  That
path is unreachable through mlx-c, whose mlx_stream_get_() rejects a
null handle, so Swift has to pass a concrete stream.

Default to the CPU stream, which is correct for every backend that can
be built on Apple platforms.  This will need revisiting if nccl is ever
enabled; the real fix is for mlx-c to accept an unspecified stream.
The test process is itself a rank: with MLX_TEST_DISTRIBUTED=1 the first
process reserves two loopback ports, writes a temporary hostfile,
re-executes the test bundle as rank 1 and runs as rank 0.  Both ranks
execute the same body, so no separate worker executable is needed --
this mirrors how the Python distributed tests are run under mlx.launch.

Skipped unless MLX_TEST_DISTRIBUTED=1, matching the Python tests, which
are likewise not part of CI.  It can be enabled by adding a second
xctest invocation with that variable set.

There is a single test method because MLX caches the group per process,
and because every rank must issue the same collectives in the same
order.  The single process tests skip during a multi process run, since
their assertions describe a group of size one.

Run with:
  xcodebuild build-for-testing -scheme mlx-swift-Package -destination 'platform=macOS'
  MLX_TEST_DISTRIBUTED=1 xcrun xctest -XCTest DistributedRingTests \
    ~/Library/Developer/Xcode/DerivedData/mlx-swift-*/Build/Products/Debug/MLXTests.xctest
The single process tests only exercise the sharded layers in a group of
size one, where sharding is the identity and the interesting paths --
splitting the weight, selecting this rank's slice, reducing inside the
layer -- never run.

Every rank seeds identically and builds the same Linear, then:

  AllToShardedLinear holds half the output dimensions; gathering the
  transposed partial results reassembles the columns in rank order and
  must equal the unsharded result.

  ShardedToAllLinear holds half the input dimensions and is fed the
  matching slice; its internal allSum must reproduce the unsharded
  result on both ranks.
mlx_distributed_init reports failure through the error handler and
leaves the group handle null.  Wrapping that in a Group produced an
object whose rank and size silently read as zero, so a caller that did
not wrap the call tightly in withError got a plausible looking group
that was quietly broken.

Make both initialize and split failable, and add globalGroup for the
common case where a non-strict init cannot meaningfully fail.

NOT BUILT.
@ronaldmannak

Copy link
Copy Markdown
Contributor Author

Pushed one fix found while enabling JACCL: initialize and split now return
Group?. mlx_distributed_init reports failure through the error handler and
leaves the handle null, so the previous version returned a group whose rank
and size silently read as 0. Added globalGroup for the common case where a
non-strict init can't meaningfully fail.

Comment on lines +141 to +147
public func shardLinear(
_ layer: Linear, sharding: ShardingType, segments: Segments = .count(1),
group: MLXDistributed.Group? = nil
) -> Module {
switch sharding {
case .allToSharded:
AllToShardedLinear(layer, segments: segments, group: group)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If passed a QuantizedLinear this will (I think) not do anything good. It looks like the python side has QuantizedAllToShardedLinear etc.

@davidkoski

Copy link
Copy Markdown
Member

Doesn't have to be fixed right away, but the new test files have to be added in the xcode/MLX.xcodeproj. I think just opening that file in Xcode should populate it.

/// the result is sharded across the group.
///
/// The gradients are automatically aggregated from each member of the group.
open class AllToShardedLinear: Module, UnaryLayer {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something that would be substituted inside the model, like quantization does today? If so, I wonder:

  • do we need marker protocols like Quantizable/Quantized?
  • should this be a subtype of Linear?

Still working on understanding how you use this in practice so these questions might be silly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the python code it isn't generic like quantization. There is an optional method at the LanguageModel layer:

    def shard(self, group: Optional[mx.distributed.Group] = None):
        group = group or mx.distributed.init()
        N = group.size()
        for layer in self.model.layers:
            # Shard the self attention
            layer.self_attn.q_proj = shard_linear(
                layer.self_attn.q_proj, "all-to-sharded", group=group
            )
            layer.self_attn.k_proj = shard_linear(
                layer.self_attn.k_proj, "all-to-sharded", group=group
            )

So I think Quantizable/Quantized isn't strictly required. We could add it, but there is no clear use for it.

Making this a subtype might be useful. Otherwise we would have to change the swift models from declaring modules as Linear -> modules as UnaryLayer. I think it loses some of the documentation of the actual types but since authors have to write the shard method by hand anyway, it could be ok.

So combining the two ideas, perhaps a protocol like ShardableLinear would be useful -- both Linear and AllToShardedLinear could conform. This keeps the documentation of the type and prevents accidental sharding of the wrong layers.

Anyway, something to think of -- this PR isn't adding the adoption just yet, only the underpinnings.

Comment on lines +87 to +90
private func shard(
_ parameters: ModuleParameters, group: MLXDistributed.Group,
_ sharding: ShardingPredicate
) -> ModuleParameters {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at how this works on the python side:

# mlx-lm def sharded_load
    model, _ = load_model(
        model_path, lazy=True, strict=False, trust_remote_code=trust_remote_code
    )
    # dkoski -- still lazy at this point
    if tensor_group is not None:
        model.shard(tensor_group)
    if pipeline_group is not None:
        model.model.pipeline(pipeline_group)
    mx.eval(model.parameters())

We have recently made some changes that make loading faster but non-lazy -- that may defeat some of this behavior (meaning, I think, very large models will require full memory to load at peak).

Well, this is special loading code for distributed in python, so I guess we need something similar in swift. Anyway, just highlighting the point that swift is maybe diverging slightly from the python core in loading.

@aleroot FYI

///
/// - Parameters:
/// - backend: the backend to use, defaulting to ``Backend/any``
/// - strict: if `true` report an error when no backend can be initialized

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is the error reported?

I wonder if this would be easier if we had two calls: one that throws and one that does not?

public func shardLinear(
_ layer: Linear, sharding: ShardingType, segments: Segments = .count(1),
group: MLXDistributed.Group? = nil
) -> Module {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the question below on AllToShardedLinear -- this probably needs to return something that can be called. A plain Module type doesn't have callAsFunction()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants