Jaccl refactor (#3412)

This commit is contained in:
Angelos Katharopoulos
2026-04-14 23:52:21 -07:00
committed by GitHub
parent 1fa764fbec
commit 4400504ad5
26 changed files with 2279 additions and 414 deletions
+9
View File
@@ -321,6 +321,15 @@ FetchContent_MakeAvailable(json)
target_include_directories(
mlx PRIVATE $<BUILD_INTERFACE:${json_SOURCE_DIR}/single_include/nlohmann>)
# Add standalone JACCL library (RDMA over Thunderbolt distributed backend)
if(MLX_BUILD_CPU
AND ${CMAKE_SYSTEM_NAME} MATCHES "Darwin"
AND DEFINED MACOS_SDK_VERSION
AND MACOS_SDK_VERSION GREATER_EQUAL 26.2)
add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/mlx/distributed/jaccl/lib
${CMAKE_BINARY_DIR}/jaccl)
endif()
add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/mlx)
target_include_directories(
+1
View File
@@ -0,0 +1 @@
!lib
+2 -6
View File
@@ -1,12 +1,8 @@
if(MLX_BUILD_CPU
AND ${CMAKE_SYSTEM_NAME} MATCHES "Darwin"
AND MACOS_SDK_VERSION GREATER_EQUAL 26.2)
target_sources(
mlx
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/jaccl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/utils.cpp
${CMAKE_CURRENT_SOURCE_DIR}/mesh.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ring.cpp)
target_sources(mlx PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/jaccl.cpp)
target_link_libraries(mlx PRIVATE jaccl)
else()
target_sources(mlx PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/no_jaccl.cpp)
endif()
+138 -143
View File
@@ -1,178 +1,173 @@
// Copyright © 2025 Apple Inc.
#include <fstream>
#include <sstream>
#include <json.hpp>
#include "mlx/distributed/jaccl/jaccl.h"
#include "mlx/backend/cpu/encoder.h"
#include "mlx/distributed/distributed_impl.h"
#include "mlx/distributed/jaccl/mesh.h"
#include "mlx/distributed/jaccl/ring.h"
#include "mlx/distributed/jaccl/utils.h"
#include "mlx/dtype_utils.h"
#include <jaccl/group.h>
#include <jaccl/jaccl.h>
using GroupImpl = mlx::core::distributed::detail::GroupImpl;
using json = nlohmann::json;
namespace mlx::core::distributed::jaccl {
namespace {
struct DeviceFile {
DeviceFile(const char* dev_file) {
std::ifstream f(dev_file);
json devices = json::parse(f);
if (!devices.is_array()) {
throw std::runtime_error(
"[jaccl] The device file should start with an array");
}
/**
* Map MLX Dtype to JACCL Dtype for dispatch.
*/
int dtype_to_jaccl_dtype(Dtype dt) {
switch (dt) {
case bool_:
return ::jaccl::Dtype::Bool;
case int8:
return ::jaccl::Dtype::Int8;
case int16:
return ::jaccl::Dtype::Int16;
case int32:
return ::jaccl::Dtype::Int32;
case int64:
return ::jaccl::Dtype::Int64;
case uint8:
return ::jaccl::Dtype::UInt8;
case uint16:
return ::jaccl::Dtype::UInt16;
case uint32:
return ::jaccl::Dtype::UInt32;
case uint64:
return ::jaccl::Dtype::UInt64;
case float16:
return ::jaccl::Dtype::Float16;
case bfloat16:
return ::jaccl::Dtype::BFloat16;
case float32:
return ::jaccl::Dtype::Float32;
case float64:
return ::jaccl::Dtype::Float64;
case complex64:
return ::jaccl::Dtype::Complex64;
default:
throw std::runtime_error("[jaccl] Unsupported dtype for JACCL operation");
}
}
devices_.resize(devices.size());
for (int rank = 0; rank < devices.size(); rank++) {
auto conn = devices[rank];
if (!conn.is_array()) {
throw std::runtime_error(
"[jaccl] The device file should have an array of arrays");
}
if (conn.size() != devices_.size()) {
std::ostringstream msg;
msg << "[jaccl] The device file should contain the connectivity of each rank to "
<< "all other ranks but rank " << rank << " contains only "
<< conn.size() << " entries.";
throw std::runtime_error(msg.str());
}
/**
* Adapter that wraps a standalone jaccl::Group to implement
* MLX's distributed::detail::GroupImpl interface.
*
* This bridges mlx::core::array to raw pointers for JACCL operations.
*/
class JACCLGroup : public GroupImpl {
public:
JACCLGroup(std::shared_ptr<::jaccl::Group> group)
: group_(std::move(group)) {}
devices_[rank].resize(conn.size());
for (int dst = 0; dst < conn.size(); dst++) {
auto names = conn[dst];
if (names.is_string()) {
devices_[rank][dst].push_back(names);
} else if (names.is_array()) {
for (auto name_it = names.begin(); name_it != names.end();
name_it++) {
devices_[rank][dst].push_back(*name_it);
}
} else if (!names.is_null()) {
throw std::runtime_error(
"[jaccl] Device names should be null, a string or array of strings.");
}
}
}
Stream communication_stream(StreamOrDevice s) override {
return to_stream(s, Device::cpu);
}
int size() {
return devices_.size();
int rank() override {
return group_->rank();
}
bool is_valid_mesh() {
for (int src = 0; src < size(); src++) {
for (int dst = 0; dst < size(); dst++) {
if (devices_[src][dst].size() != static_cast<size_t>(src != dst)) {
return false;
}
}
}
return true;
int size() override {
return group_->size();
}
bool is_valid_ring() {
int num_connections = devices_[0][1].size();
if (num_connections == 0) {
return false;
}
for (int src = 0; src < size(); src++) {
int left = (src + size() - 1) % size();
int right = (src + 1) % size();
for (int dst = 0; dst < size(); dst++) {
if (dst != left && dst != right) {
if (devices_[src][dst].size() != 0) {
return false;
}
} else {
if (devices_[src][dst].size() != num_connections) {
return false;
}
}
}
}
return true;
void all_sum(const array& input, array& output, Stream stream) override {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
size_t n_bytes = input.nbytes();
int dtype = dtype_to_jaccl_dtype(output.dtype());
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, dtype, this]() {
group_->all_sum(in_ptr, out_ptr, n_bytes, dtype);
});
}
std::vector<std::string> extract_mesh_connectivity(int rank) {
std::vector<std::string> devices(size());
for (int dst = 0; dst < size(); dst++) {
if (dst != rank) {
devices[dst] = devices_[rank][dst][0];
}
}
return devices;
void all_max(const array& input, array& output, Stream stream) override {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
size_t n_bytes = input.nbytes();
int dtype = dtype_to_jaccl_dtype(output.dtype());
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, dtype, this]() {
group_->all_max(in_ptr, out_ptr, n_bytes, dtype);
});
}
std::pair<std::vector<std::string>, std::vector<std::string>>
extract_ring_connectivity(int rank) {
int left = (rank + size() - 1) % size();
int right = (rank + 1) % size();
return std::make_pair(devices_[rank][left], devices_[rank][right]);
void all_min(const array& input, array& output, Stream stream) override {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
size_t n_bytes = input.nbytes();
int dtype = dtype_to_jaccl_dtype(output.dtype());
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, dtype, this]() {
group_->all_min(in_ptr, out_ptr, n_bytes, dtype);
});
}
std::vector<std::vector<std::vector<std::string>>> devices_;
void all_gather(const array& input, array& output, Stream stream) override {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
size_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, this]() {
group_->all_gather(in_ptr, out_ptr, n_bytes);
});
}
void send(const array& input, int dst, Stream stream) override {
auto data = input.data<char>();
size_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.dispatch(
[data, n_bytes, dst, this]() { group_->send(data, n_bytes, dst); });
}
void recv(array& out, int src, Stream stream) override {
auto data = out.data<char>();
size_t n_bytes = out.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_output_array(out);
encoder.dispatch(
[data, n_bytes, src, this]() { group_->recv(data, n_bytes, src); });
}
void sum_scatter(const array& input, array& output, Stream stream) override {
throw std::runtime_error("[jaccl] sum_scatter not supported.");
}
std::shared_ptr<GroupImpl> split(int color, int key = -1) override {
throw std::runtime_error("[jaccl] Group split not supported.");
}
private:
std::shared_ptr<::jaccl::Group> group_;
};
} // namespace
namespace mlx::core::distributed::jaccl {
bool is_available() {
return ibv().is_available();
return ::jaccl::is_available();
}
std::shared_ptr<GroupImpl> init(bool strict /* = false */) {
const char* dev_file = std::getenv("MLX_IBV_DEVICES");
const char* coordinator = std::getenv("MLX_JACCL_COORDINATOR");
const char* rank_str = std::getenv("MLX_RANK");
const char* ring = std::getenv("MLX_JACCL_RING");
if (!is_available() || !dev_file || !coordinator || !rank_str) {
if (strict) {
std::ostringstream msg;
msg << "[jaccl] You need to provide via environment variables a rank (MLX_RANK), "
<< "a device file (MLX_IBV_DEVICES) and a coordinator ip/port (MLX_JACCL_COORDINATOR) "
<< "but provided MLX_RANK=\"" << ((rank_str) ? rank_str : "")
<< "\", MLX_IBV_DEVICES=\"" << ((dev_file) ? dev_file : "")
<< "\" and MLX_JACCL_COORDINATOR=\""
<< ((coordinator) ? coordinator : "");
throw std::runtime_error(msg.str());
}
auto group = ::jaccl::init(strict);
if (group == nullptr) {
return nullptr;
}
auto rank = std::atoi(rank_str);
bool prefer_ring = ring != nullptr;
DeviceFile devices(dev_file);
if (rank >= devices.size() || rank < 0) {
std::ostringstream msg;
msg << "[jaccl] Invalid rank " << rank << ". It should be between 0 and "
<< devices.size();
throw std::runtime_error(msg.str());
}
if (prefer_ring && devices.is_valid_ring()) {
auto [left, right] = devices.extract_ring_connectivity(rank);
return std::make_shared<RingGroup>(
rank, devices.size(), left, right, coordinator);
} else if (devices.is_valid_mesh()) {
auto device_names = devices.extract_mesh_connectivity(rank);
return std::make_shared<MeshGroup>(rank, device_names, coordinator);
} else if (devices.is_valid_ring()) {
auto [left, right] = devices.extract_ring_connectivity(rank);
return std::make_shared<RingGroup>(
rank, devices.size(), left, right, coordinator);
} else {
throw std::runtime_error(
"[jaccl] The device file should define a valid mesh or a valid ring.");
}
return std::make_shared<JACCLGroup>(std::move(group));
}
} // namespace mlx::core::distributed::jaccl
+2
View File
@@ -1,5 +1,7 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include "mlx/distributed/distributed.h"
namespace mlx::core::distributed::jaccl {
+83
View File
@@ -0,0 +1,83 @@
cmake_minimum_required(VERSION 3.24)
project(jaccl LANGUAGES CXX)
# Default to Release when built standalone. Without this CMake uses an empty
# build type (-O0) which severely impacts the reduction / memcpy hot paths.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE
Release
CACHE STRING "Build type" FORCE)
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
# nlohmann/json for device file parsing
message(STATUS "Downloading json for JACCL")
FetchContent_Declare(
json
URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
FetchContent_MakeAvailable(json)
# Check platform and SDK version requirements
if(NOT ${CMAKE_SYSTEM_NAME} MATCHES "Darwin")
message(STATUS "JACCL requires macOS (Darwin). Skipping JACCL build.")
return()
endif()
# Try to determine MACOS_SDK_VERSION if not set
if(NOT DEFINED MACOS_SDK_VERSION)
execute_process(
COMMAND xcrun --sdk macosx --show-sdk-version
OUTPUT_VARIABLE MACOS_SDK_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE)
if(MACOS_SDK_VERSION)
message(STATUS "Detected macOS SDK version: ${MACOS_SDK_VERSION}")
endif()
endif()
if(DEFINED MACOS_SDK_VERSION AND MACOS_SDK_VERSION VERSION_LESS "26.2")
message(STATUS "JACCL requires macOS SDK >= 26.2. Skipping JACCL build.")
return()
endif()
add_library(
jaccl
${CMAKE_CURRENT_SOURCE_DIR}/jaccl/tcp.cpp
${CMAKE_CURRENT_SOURCE_DIR}/jaccl/rdma.cpp
${CMAKE_CURRENT_SOURCE_DIR}/jaccl/mesh.cpp
${CMAKE_CURRENT_SOURCE_DIR}/jaccl/ring.cpp
${CMAKE_CURRENT_SOURCE_DIR}/jaccl/jaccl.cpp)
target_include_directories(
jaccl PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
$<INSTALL_INTERFACE:include>)
target_include_directories(
jaccl PRIVATE $<BUILD_INTERFACE:${json_SOURCE_DIR}/single_include/nlohmann>)
target_compile_features(jaccl PUBLIC cxx_std_20)
# Install targets
install(
TARGETS jaccl
EXPORT jacclTargets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES
DESTINATION include)
install(
DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/jaccl/
DESTINATION include/jaccl
FILES_MATCHING
PATTERN "*.h")
install(
EXPORT jacclTargets
FILE jacclTargets.cmake
NAMESPACE jaccl::
DESTINATION lib/cmake/jaccl)
+328
View File
@@ -0,0 +1,328 @@
# JACCL
**JACCL** is a low-latency distributed communication library designed for macOS
systems with Thunderbolt 5 connectivity.
## Overview
JACCL leverages RDMA (Remote Direct Memory Access) over Thunderbolt to achieve
communication latency an order of magnitude lower than traditional TCP-based
approaches. This makes it ideal for:
- Tensor parallelism in large model inference
- High-performance distributed training
- Low-latency collective operations between Macs
JACCL was made possible by Apple's RDMA over Thunderbolt technology introduced
in macOS 26.2.
## Features
- **Mesh Topology**: Fully connected communication where each node can directly
communicate with any other node
- **Ring Topology**: High-bandwidth ring all-reduce for large messages
- **Collective Operations**:
- `all_sum`: Sum values across all nodes
- `all_max`: Element-wise maximum across all nodes
- `all_min`: Element-wise minimum across all nodes
- `all_gather`: Gather data from all nodes
- **Point-to-Point Operations**:
- `send`: Send data to a specific node
- `recv`: Receive data from a specific node
- **Type Support**: Bool, Int8-64, UInt8-64, Float16, BFloat16, Float32,
Float64, Complex64
## Requirements
- macOS SDK >= 26.2
- Thunderbolt 5 connectivity between nodes
- RDMA over Thunderbolt enabled (requires macOS recovery mode setup)
## Enabling RDMA over Thunderbolt
RDMA over Thunderbolt must be enabled in macOS recovery mode:
1. Start your Mac in [recovery mode](https://support.apple.com/en-us/102518)
2. Open Terminal from Utilities -> Terminal
3. Run: `rdma_ctl enable`
4. Reboot
To verify RDMA is enabled, run:
```bash
ibv_devices
```
You should see output like:
```
device node GUID
------ ----------------
rdma_en2 8096a9d9edbaac05
rdma_en3 8196a9d9edbaac05
rdma_en5 8396a9d9edbaac05
```
## Building
JACCL can be built as a standalone library:
```bash
cd mlx/distributed/jaccl/lib
mkdir build && cd build
cmake ..
make
```
You can also include it in your own project via CMake:
```
FetchContent_Declare(
jaccl
GIT_REPOSITORY https://github.com/ml-explore/mlx.git
GIT_TAG main
SOURCE_SUBDIR mlx/distributed/jaccl/lib
)
FetchContent_MakeAvailable(jaccl)
```
## Usage
### Environment Variables
The easiest way to intiialize JACCL is by using the following environment
variables:
- **JACCL_RANK** / **MLX_RANK**: The rank of this process (0-based integer)
- **JACCL_IBV_DEVICES** / **MLX_IBV_DEVICES**: Path to a JSON file describing
device connectivity
- **JACCL_COORDINATOR** / **MLX_JACCL_COORDINATOR**: IP:port of the coordinator
(rank 0 listener)
- **JACCL_RING** / **MLX_JACCL_RING**: (Optional) Prefer ring topology over
mesh
### Device File Format
The device file is a JSON array where each entry describes the RDMA devices
connecting that rank to all other ranks:
```json
[
[null, "rdma_en5", "rdma_en4", "rdma_en3"],
["rdma_en5", null, "rdma_en3", "rdma_en4"],
["rdma_en4", "rdma_en3", null, "rdma_en5"],
["rdma_en3", "rdma_en4", "rdma_en5", null]
]
```
For a valid mesh, `devices[i][j]` should contain the device name connecting
rank `i` to rank `j`, or `null` if `i == j`.
For a valid ring, only adjacent nodes should have device names (all others
should be null).
### Basic Example
```cpp
#include <iostream>
#include <jaccl/jaccl.h>
int main() {
// Initialize JACCL group
auto group = jaccl::init();
if (!group) {
std::cerr << "Failed to initialize JACCL" << std::endl;
return 1;
}
std::cout << "Rank " << group->rank() << " of " << group->size() << std::endl;
// Perform all-reduce sum
float input[10] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f};
float output[10];
group->all_sum(input, output, sizeof(input), jaccl::Float32);
std::cout << "Result: " << output[0] << std::endl;
return 0;
}
```
You can also manually define the configuration instead of reading it from
environment variables.
```cpp
#include <iostream>
#include <jaccl/jaccl.h>
int main() {
auto cfg = jaccl::Config()
.set_rank(0)
.set_coordinator("192.168.1.1:32132")
.set_devices({
{{}, {"rdma_en5"}, {"rdma_en4"}, {"rdma_en3"}},
{{"rdma_en5"}, {}, {"rdma_en3"}, {"rdma_en4"}},
{{"rdma_en4"}, {"rdma_en3"}, {}, {"rdma_en5"}},
{{"rdma_en3"}, {"rdma_en4"}, {"rdma_en5"}, {}}
});
auto group = jaccl::init(cfg);
if (!group) {
std::cerr << "Failed to initialize JACCL" << std::endl;
return 1;
}
std::cout << "Rank " << group->rank() << " of " << group->size() << std::endl;
// Perform all-reduce sum
float input[10] = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f};
float output[10];
group->all_sum(input, output, sizeof(input), jaccl::Float32);
std::cout << "Result: " << output[0] << std::endl;
return 0;
}
```
### Using with MLX
JACCL integrates seamlessly with MLX's distributed communication:
```python
import mlx.core as mx
# Initialize with JACCL backend
world = mx.distributed.init(backend="jaccl")
# Perform distributed operations
x = mx.ones((10,))
result = mx.distributed.all_sum(x, group=world)
```
Launch with `mlx.launch`:
```bash
mlx.launch --backend jaccl --hostfile hosts.json my_script.py
```
## Hostfile Example
For use with `mlx.launch`, create a hostfile JSON:
```json
{
"backend": "jaccl",
"hosts": [
{
"ssh": "m3-ultra-1",
"ips": ["192.168.1.1"],
"rdma": [null, "rdma_en5", "rdma_en4", "rdma_en3"]
},
{
"ssh": "m3-ultra-2",
"ips": [],
"rdma": ["rdma_en5", null, "rdma_en3", "rdma_en4"]
},
{
"ssh": "m3-ultra-3",
"ips": [],
"rdma": ["rdma_en4", "rdma_en3", null, "rdma_en5"]
},
{
"ssh": "m3-ultra-4",
"ips": [],
"rdma": ["rdma_en3", "rdma_en4", "rdma_en5", null]
}
]
}
```
## Automatic Configuration
MLX provides `mlx.distributed_config` to automatically discover and configure
Thunderbolt connectivity:
```bash
# Visualize connections
mlx.distributed_config --verbose \
--hosts m3-ultra-1,m3-ultra-2,m3-ultra-3,m3-ultra-4 \
--over thunderbolt --dot | dot -Tpng | open -f -a Preview
# Auto-configure and generate hostfile
mlx.distributed_config --verbose \
--hosts m3-ultra-1,m3-ultra-2,m3-ultra-3,m3-ultra-4 \
--over thunderbolt --backend jaccl \
--auto-setup --output m3-ultra-jaccl.json
```
## API
The main API of JACCL is the communication group. It provides efficient
high-level collectives.
**Note: JACCL does no memory allocation. All output pointers should point to a
location with sufficient memory allocated to hold the result.**
```cpp
class Group {
public:
virtual ~Group() {}
// Helper functions to know which process we are in the group
virtual int rank() = 0;
virtual int size() = 0;
// All reduce implementations. Input and output of the same size the
// reduction happens according to dtype and across the group.
virtual void all_sum(const void* input, void* output, size_t n_bytes, int dtype) = 0;
virtual void all_max(const void* input, void* output, size_t n_bytes, int dtype) = 0;
virtual void all_min(const void* input, void* output, size_t n_bytes, int dtype) = 0;
// All gather implementation. The output is group->size() * n_bytes.
virtual void all_gather(const void* input, void* output, size_t n_bytes) = 0;
// Simple send/recv primitives.
virtual void send(const void* input, size_t n_bytes, int dst) = 0;
virtual void recv(void* output, size_t n_bytes, int src) = 0;
};
```
All that is left to use JACCL (except the communication group) is
```cpp
std::shared_ptr<Group> init(bool strict = false);
std::shared_ptr<Group> init(const Config& cfg, bool strict = false);
```
that create the communication group from environment variables or from the
configuration object. The latter allows one to configure JACCL using means
other than environment variables.
```cpp
class Config {
public:
Config();
Config& set_rank(int rank);
Config& set_coordinator(std::string coordinator);
Config& set_devices(std::vector<std::vector<std::vector<std::string>>> devices);
Config& prefer_ring(bool prefer = true);
bool is_valid_mesh() const;
bool is_valid_ring() const;
}
```
## License
JACCL is part of MLX and is released under the same license.
## Acknowledgments
The name JACCL (pronounced Jackal) stands for Jack and Angelos Collective
Communication Library and it is an obvious pun to Nvidias NCCL but also
tribute to Jack Beasley who led the development of RDMA over Thunderbolt at
Apple.
@@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.24)
project(jaccl_examples LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
# cmake-format: off
#
# Treating the nearby directory like a fetched dependency to simulate what a
# user would do in their own C++ project. Ideally you would write something like
# the following:
#
# FetchContent_Declare(
# jaccl
# GIT_REPOSITORY https://github.com/ml-explore/mlx.git
# GIT_TAG main
# SOURCE_SUBDIR mlx/distributed/jaccl/lib
# )
#
# cmake-format: on
FetchContent_Declare(jaccl SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../)
FetchContent_MakeAvailable(jaccl)
# Helper function to build examples
function(build_example SRCFILE)
get_filename_component(example_name ${SRCFILE} NAME_WE)
set(target "jaccl_${example_name}")
add_executable(${target} ${SRCFILE})
target_link_libraries(${target} PRIVATE jaccl)
message(STATUS "Building JACCL example: ${target}")
endfunction()
# Examples
build_example(minimal_env.cpp)
build_example(minimal_cfg.cpp)
# Benchmarks
build_example(allreduce_bench.cpp)
@@ -0,0 +1,286 @@
// Copyright © 2025 Apple Inc.
//
// JACCL All-Reduce Benchmark
//
// Measures bandwidth and latency of all_sum across a sweep of message sizes,
// similar in spirit to the NCCL all-reduce benchmark (nccl-tests).
//
// Usage:
// Set the environment variables described in jaccl.h, then run:
//
// ./jaccl_allreduce_bench [-w <warmup_iters>] [-n <iters>]
// [-b <min_bytes>] [-e <max_bytes>]
// [-f <step_factor>] [-d <datatype>]
// [-c] [-h]
//
// Or use the MLX launcher:
//
// mlx.launch --hostfile hosts.json ./jaccl_allreduce_bench
//
// The arguments are:
//
// -w Warmup iterations per message size (default: 5)
// -n Timed iterations per message size (default: 20)
// -b Minimum message size in bytes (default: 1K)
// -e Maximum message size in bytes (default: 256M)
// -f Multiplicative step factor (default: 2)
// -d Datatype: float32, float16, bfloat16 (default: float32)
// -c Check correctness (default: off)
// -h Print this help message
#include <jaccl/jaccl.h>
#include <jaccl/types.h>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <memory>
#include <numeric>
#include <string>
#include <vector>
static void usage(const char* prog) {
std::cerr
<< "Usage: " << prog << " [options]\n"
<< " -w <warmup> Warmup iterations (default: 5)\n"
<< " -n <iters> Timed iterations (default: 20)\n"
<< " -b <min_bytes> Minimum message size (default: 1K)\n"
<< " -e <max_bytes> Maximum message size (default: 256M)\n"
<< " -f <factor> Multiplicative step factor (default: 2)\n"
<< " -d <dtype> float32|float16|bfloat16 (default: float32)\n"
<< " -c Check correctness\n"
<< " -h Show this help\n";
}
static size_t parse_size(const char* s) {
char* end = nullptr;
double val = std::strtod(s, &end);
if (end && (*end == 'K' || *end == 'k'))
val *= 1024;
else if (end && (*end == 'M' || *end == 'm'))
val *= 1024 * 1024;
else if (end && (*end == 'G' || *end == 'g'))
val *= 1024 * 1024 * 1024;
return static_cast<size_t>(val);
}
static std::string fmt_bytes(size_t bytes) {
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
int idx = 0;
double val = static_cast<double>(bytes);
while (val >= 1024.0 && idx < 4) {
val /= 1024.0;
idx++;
}
char buf[32];
if (val == static_cast<int>(val))
std::snprintf(buf, sizeof(buf), "%d %s", static_cast<int>(val), units[idx]);
else
std::snprintf(buf, sizeof(buf), "%.2f %s", val, units[idx]);
return buf;
}
// Conversion from algorithm bandwidth to bus bandwidth for a ring reduce.
static double bus_factor(int nranks) {
return 2.0 * (nranks - 1) / static_cast<double>(nranks);
}
int main(int argc, char** argv) {
int warmup_iters = 5;
int timed_iters = 20;
size_t min_bytes = 1024;
size_t max_bytes = 256 * 1024 * 1024;
int step_factor = 2;
std::string dtype_str = "float32";
bool check = false;
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
if (arg == "-h" || arg == "--help") {
usage(argv[0]);
return 0;
} else if (arg == "-w" && i + 1 < argc) {
warmup_iters = std::atoi(argv[++i]);
} else if (arg == "-n" && i + 1 < argc) {
timed_iters = std::atoi(argv[++i]);
} else if (arg == "-b" && i + 1 < argc) {
min_bytes = parse_size(argv[++i]);
} else if (arg == "-e" && i + 1 < argc) {
max_bytes = parse_size(argv[++i]);
} else if (arg == "-f" && i + 1 < argc) {
step_factor = std::atoi(argv[++i]);
} else if (arg == "-d" && i + 1 < argc) {
dtype_str = argv[++i];
} else if (arg == "-c") {
check = true;
} else {
std::cerr << "Unknown option: " << arg << "\n";
usage(argv[0]);
return 1;
}
}
jaccl::Dtype dtype;
size_t elem_size;
if (dtype_str == "float32") {
dtype = jaccl::Float32;
elem_size = 4;
} else if (dtype_str == "float16") {
dtype = jaccl::Float16;
elem_size = 2;
} else if (dtype_str == "bfloat16") {
dtype = jaccl::BFloat16;
elem_size = 2;
} else {
std::cerr << "Unsupported dtype: " << dtype_str << "\n";
return 1;
}
auto group = jaccl::init(/* strict= */ true);
int rank = group->rank();
int nranks = group->size();
if (rank == 0) {
std::cout << "# JACCL All-Reduce Benchmark\n"
<< "# Ranks: " << nranks << "\n"
<< "# Dtype: " << dtype_str << "\n"
<< "# Warmup: " << warmup_iters << " iters\n"
<< "# Timed: " << timed_iters << " iters\n"
<< "# Sizes: " << fmt_bytes(min_bytes) << " .. "
<< fmt_bytes(max_bytes) << " (x" << step_factor << ")\n"
<< "#\n";
// Table header (NCCL-style)
std::cout << std::left << std::setw(14) << "# size" << std::right
<< std::setw(12) << "count" << std::setw(12) << "type"
<< std::setw(14) << "time (us)" << std::setw(14) << "algo BW"
<< std::setw(14) << "bus BW";
if (check)
std::cout << std::setw(10) << "check";
std::cout << "\n";
std::cout << std::left << std::setw(14) << "# (bytes)" << std::right
<< std::setw(12) << "(elems)" << std::setw(12) << ""
<< std::setw(14) << "" << std::setw(14) << "(GB/s)"
<< std::setw(14) << "(GB/s)";
if (check)
std::cout << std::setw(10) << "";
std::cout << "\n";
}
size_t max_elems = max_bytes / elem_size;
max_bytes = max_elems * elem_size;
std::vector<char> sendbuf(max_bytes);
// Fill send buffer with a simple deterministic pattern (rank + 1) casted to
// the target type, so correctness checks are straightforward: after all_sum
// every element should equal sum_{r=0}^{nranks-1} (r + 1) =
// nranks*(nranks+1)/2.
auto fill_buffer = [&](char* buf, size_t n_bytes) {
float val = static_cast<float>(rank + 1);
if (dtype == jaccl::Float32) {
auto* p = reinterpret_cast<float*>(buf);
size_t n = n_bytes / sizeof(float);
for (size_t i = 0; i < n; i++) {
p[i] = val;
}
} else if (dtype == jaccl::Float16) {
// Write via the library's float16_t
auto* p = reinterpret_cast<jaccl::float16_t*>(buf);
size_t n = n_bytes / sizeof(jaccl::float16_t);
for (size_t i = 0; i < n; i++) {
p[i] = jaccl::float16_t(val);
}
} else if (dtype == jaccl::BFloat16) {
auto* p = reinterpret_cast<jaccl::bfloat16_t*>(buf);
size_t n = n_bytes / sizeof(jaccl::bfloat16_t);
for (size_t i = 0; i < n; i++) {
p[i] = jaccl::bfloat16_t(val);
}
}
};
auto check_buffer = [&](const char* buf, size_t n_bytes) -> bool {
float expected = static_cast<float>(nranks) * (nranks + 1) / 2.0f;
float tol = (dtype == jaccl::Float32) ? 1e-5f : 1e-1f;
size_t n = n_bytes / elem_size;
for (size_t i = 0; i < n; i++) {
float val;
if (dtype == jaccl::Float32) {
val = reinterpret_cast<const float*>(buf)[i];
} else if (dtype == jaccl::Float16) {
val = static_cast<float>(
reinterpret_cast<const jaccl::float16_t*>(buf)[i]);
} else {
val = static_cast<float>(
reinterpret_cast<const jaccl::bfloat16_t*>(buf)[i]);
}
if (std::abs(val - expected) > tol) {
return false;
}
}
return true;
};
double bf = bus_factor(nranks);
for (size_t nbytes = min_bytes; nbytes <= max_bytes;
nbytes *= static_cast<size_t>(step_factor)) {
// Round down to element boundary
size_t n = std::max((nbytes / elem_size) * elem_size, elem_size);
size_t count = n / elem_size;
fill_buffer(sendbuf.data(), n);
// Warmup
for (int i = 0; i < warmup_iters; i++) {
group->all_sum(sendbuf.data(), sendbuf.data(), n, dtype);
}
// Timed iterations
auto t0 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < timed_iters; i++) {
group->all_sum(sendbuf.data(), sendbuf.data(), n, dtype);
}
auto t1 = std::chrono::high_resolution_clock::now();
double elapsed_us =
std::chrono::duration<double, std::micro>(t1 - t0).count();
double avg_us = elapsed_us / timed_iters;
// Bandwidth in GB/s
double algo_bw = (static_cast<double>(n) / avg_us) / 1e3;
double bus_bw = algo_bw * bf;
// Correctness check
std::string check_result;
if (check) {
fill_buffer(sendbuf.data(), n);
group->all_sum(sendbuf.data(), sendbuf.data(), n, dtype);
check_result = check_buffer(sendbuf.data(), n) ? "OK" : "FAIL";
}
if (rank == 0) {
std::cout << std::left << std::setw(14) << n << std::right
<< std::setw(12) << count << std::setw(12) << dtype_str
<< std::setw(14) << std::fixed << std::setprecision(1) << avg_us
<< std::setw(14) << std::fixed << std::setprecision(2)
<< algo_bw << std::setw(14) << std::fixed
<< std::setprecision(2) << bus_bw;
if (check)
std::cout << std::setw(10) << check_result;
std::cout << "\n";
}
}
if (rank == 0) {
std::cout << "# Done.\n";
}
return 0;
}
@@ -0,0 +1,36 @@
#include <jaccl/jaccl.h>
#include <iostream>
int main() {
auto cfg =
jaccl::Config()
.set_rank(0) // should be different per node
.set_coordinator("192.168.1.1:32132") // rank 0 will listen here
.set_devices(
{{{}, {"rdma_en5"}, {"rdma_en4"}, {"rdma_en3"}},
{{"rdma_en5"}, {}, {"rdma_en3"}, {"rdma_en4"}},
{{"rdma_en4"}, {"rdma_en3"}, {}, {"rdma_en5"}},
{{"rdma_en3"}, {"rdma_en4"}, {"rdma_en5"}, {}}});
auto group = jaccl::init(cfg);
if (!group) {
std::cerr << "Failed to initialize JACCL" << std::endl;
return 1;
}
std::cout << "Rank " << group->rank() << " of " << group->size() << std::endl;
// Perform all-reduce sum
float input[10] = {
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f};
float output[10];
group->all_sum(input, output, sizeof(input), jaccl::Float32);
std::cout << "Result: ";
for (auto o : output) {
std::cout << o << " ";
}
std::cout << std::endl;
return 0;
}
@@ -0,0 +1,28 @@
#include <jaccl/jaccl.h>
#include <iostream>
int main() {
// Initialize JACCL group
auto group = jaccl::init();
if (!group) {
std::cerr << "Failed to initialize JACCL" << std::endl;
return 1;
}
std::cout << "Rank " << group->rank() << " of " << group->size() << std::endl;
// Perform all-reduce sum
float input[10] = {
1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f};
float output[10];
group->all_sum(input, output, sizeof(input), jaccl::Float32);
std::cout << "Result: ";
for (auto o : output) {
std::cout << o << " ";
}
std::cout << std::endl;
return 0;
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include <cstddef>
#include <memory>
namespace jaccl {
/**
* Abstract base class for a JACCL communication group.
*/
class Group {
public:
virtual ~Group() {}
virtual int rank() = 0;
virtual int size() = 0;
virtual void
all_sum(const void* input, void* output, size_t n_bytes, int dtype) = 0;
virtual void
all_max(const void* input, void* output, size_t n_bytes, int dtype) = 0;
virtual void
all_min(const void* input, void* output, size_t n_bytes, int dtype) = 0;
virtual void all_gather(const void* input, void* output, size_t n_bytes) = 0;
virtual void send(const void* input, size_t n_bytes, int dst) = 0;
virtual void recv(void* output, size_t n_bytes, int src) = 0;
};
/**
* Type IDs for dispatch in the standalone JACCL library.
*
* Users pass one of these to all_sum/all_max/all_min so JACCL knows how to
* interpret the data for typed reduction operations.
*/
enum Dtype {
Bool = 0,
Int8,
Int16,
Int32,
Int64,
UInt8,
UInt16,
UInt32,
UInt64,
Float16,
BFloat16,
Float32,
Float64,
Complex64,
};
} // namespace jaccl
+228
View File
@@ -0,0 +1,228 @@
// Copyright © 2025 Apple Inc.
#include <fstream>
#include <sstream>
#include <json.hpp>
#include "jaccl/jaccl.h"
#include "jaccl/mesh.h"
#include "jaccl/rdma.h"
#include "jaccl/ring.h"
using json = nlohmann::json;
namespace {
std::vector<std::vector<std::vector<std::string>>> parse_devices_json(
const char* dev_file) {
std::ifstream f(dev_file);
json devices = json::parse(f);
if (!devices.is_array()) {
throw std::runtime_error(
"[jaccl] The device file should start with an array");
}
std::vector<std::vector<std::vector<std::string>>> result(devices.size());
for (int rank = 0; rank < devices.size(); rank++) {
auto conn = devices[rank];
if (!conn.is_array()) {
throw std::runtime_error(
"[jaccl] The device file should have an array of arrays");
}
if (conn.size() != devices.size()) {
std::ostringstream msg;
msg << "[jaccl] The device file should contain the connectivity of each rank to "
<< "all other ranks but rank " << rank << " contains only "
<< conn.size() << " entries.";
throw std::runtime_error(msg.str());
}
result[rank].resize(conn.size());
for (int dst = 0; dst < conn.size(); dst++) {
auto names = conn[dst];
if (names.is_string()) {
result[rank][dst].push_back(names);
} else if (names.is_array()) {
for (auto name_it = names.begin(); name_it != names.end(); name_it++) {
result[rank][dst].push_back(*name_it);
}
} else if (!names.is_null()) {
throw std::runtime_error(
"[jaccl] Device names should be null, a string or array of strings.");
}
}
}
return result;
}
template <typename First, typename... Rest>
const char* getenv(First first, Rest... rest) {
const char* rs = std::getenv(first);
if (rs != nullptr) {
return rs;
}
if constexpr (sizeof...(rest) > 0) {
return getenv(rest...);
}
return rs;
}
} // namespace
namespace jaccl {
Config::Config() : rank_(0), size_(0) {}
Config& Config::set_rank(int rank) {
rank_ = rank;
return *this;
}
Config& Config::set_coordinator(std::string coordinator) {
coordinator_ = std::move(coordinator);
return *this;
}
Config& Config::set_devices(
std::vector<std::vector<std::vector<std::string>>> devices) {
devices_ = std::move(devices);
size_ = devices_.size();
for (int r = 0; r < size_; r++) {
if (size_ != devices_[r].size()) {
std::ostringstream msg;
msg << "[jaccl] The full connectivity matrix should be provided but we have "
<< size_ << " rows and row " << r << " has " << devices_[r].size()
<< " columns.";
throw std::invalid_argument(msg.str());
}
}
return *this;
}
Config& Config::prefer_ring(bool prefer /* = true */) {
prefer_ring_ = prefer;
return *this;
}
bool Config::is_valid_mesh() const {
if (size_ < 2) {
return false;
}
for (int src = 0; src < size_; src++) {
for (int dst = 0; dst < size_; dst++) {
if ((src == dst && devices_[src][dst].size() != 0) ||
(src != dst && devices_[src][dst].size() == 0)) {
return false;
}
}
}
return true;
}
bool Config::is_valid_ring() const {
if (size_ < 2) {
return false;
}
int num_connections = devices_[0][1].size();
for (int src = 0; src < size_; src++) {
int left = (src + size_ - 1) % size_;
int right = (src + 1) % size_;
for (int dst = 0; dst < size_; dst++) {
if (dst == left || dst == right) {
if (devices_[src][dst].size() != num_connections) {
return false;
}
}
}
}
return true;
}
std::vector<std::string> Config::get_mesh_connectivity() const {
if (!is_valid_mesh()) {
throw std::runtime_error("[jaccl] The devices do not form a valid mesh.");
}
std::vector<std::string> devices(size_);
for (int dst = 0; dst < size_; dst++) {
if (dst != rank_) {
devices[dst] = devices_[rank_][dst][0];
}
}
return devices;
}
std::pair<std::vector<std::string>, std::vector<std::string>>
Config::get_ring_connectivity() const {
if (!is_valid_ring()) {
throw std::runtime_error("[jaccl] The devices do not form a valid ring.");
}
int left = (rank_ + size_ - 1) % size_;
int right = (rank_ + 1) % size_;
return std::make_pair(devices_[rank_][left], devices_[rank_][right]);
}
std::optional<Config> Config::from_env() {
const char* dev_file = getenv("JACCL_IBV_DEVICES", "MLX_IBV_DEVICES");
const char* coordinator =
getenv("JACCL_COORDINATOR", "MLX_JACCL_COORDINATOR");
const char* rank_str = getenv("JACCL_RANK", "MLX_RANK");
const char* ring = getenv("JACCL_RING", "MLX_JACCL_RING");
if (!dev_file || !coordinator || !rank_str) {
return std::nullopt;
}
return Config()
.set_rank(std::atoi(rank_str))
.set_coordinator(coordinator)
.set_devices(parse_devices_json(dev_file))
.prefer_ring(ring != nullptr);
}
bool is_available() {
return ibv().is_available();
}
std::shared_ptr<Group> init(bool strict /* = false */) {
auto cfg = Config::from_env();
if (!cfg.has_value() && strict) {
std::ostringstream msg;
msg << "[jaccl] You need to provide via environment variables a rank "
<< "(JACCL_RANK/MLX_RANK), a device file (JACCL_IBV_DEVICES/"
<< "MLX_IBV_DEVICES) and a coordinator ip/port (JACCL_COORDINATOR/"
<< "MLX_JACCL_COORDINATOR).";
throw std::runtime_error(msg.str());
}
return init(*cfg, strict);
}
std::shared_ptr<Group> init(const Config& cfg, bool strict /* = false */) {
if (cfg.get_prefer_ring() && cfg.is_valid_ring()) {
auto [left, right] = cfg.get_ring_connectivity();
return std::make_shared<RingGroup>(
cfg.get_rank(), cfg.get_size(), left, right, cfg.get_coordinator());
} else if (cfg.is_valid_mesh()) {
auto mesh = cfg.get_mesh_connectivity();
return std::make_shared<MeshGroup>(
cfg.get_rank(), mesh, cfg.get_coordinator());
} else if (cfg.is_valid_ring()) {
auto [left, right] = cfg.get_ring_connectivity();
return std::make_shared<RingGroup>(
cfg.get_rank(), cfg.get_size(), left, right, cfg.get_coordinator());
} else {
if (!strict) {
return nullptr;
}
throw std::runtime_error(
"[jaccl] The configuration should define a valid mesh or a valid ring.");
}
}
} // namespace jaccl
+85
View File
@@ -0,0 +1,85 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include <memory>
#include <vector>
#include "jaccl/group.h"
namespace jaccl {
class Config {
public:
Config();
Config& set_rank(int rank);
Config& set_coordinator(std::string coordinator);
Config& set_devices(
std::vector<std::vector<std::vector<std::string>>> devices);
Config& prefer_ring(bool prefer = true);
bool is_valid_mesh() const;
bool is_valid_ring() const;
int get_rank() const {
return rank_;
}
int get_size() const {
return size_;
}
std::string get_coordinator() const {
return coordinator_;
}
bool get_prefer_ring() const {
return prefer_ring_;
}
static std::optional<Config> from_env();
friend std::shared_ptr<Group> init(const Config& cfg, bool strict);
private:
std::vector<std::string> get_mesh_connectivity() const;
std::pair<std::vector<std::string>, std::vector<std::string>>
get_ring_connectivity() const;
int rank_;
int size_;
std::string coordinator_;
std::vector<std::vector<std::vector<std::string>>> devices_;
bool prefer_ring_;
};
/**
* Check if JACCL (RDMA over Thunderbolt) is available on this system.
*/
bool is_available();
/**
* Initialize a JACCL communication group from environment variables.
*
* Reads configuration from environment variables:
* - JACCL_RANK / MLX_RANK: The rank of this process
* - JACCL_IBV_DEVICES / MLX_IBV_DEVICES: Path to the device connectivity
* JSON file
* - JACCL_COORDINATOR / MLX_JACCL_COORDINATOR: IP:port of the coordinator
* - JACCL_RING / MLX_JACCL_RING: If set, prefer ring topology
*
* Args:
* strict: If true, throw on failure. If false, return nullptr.
*
* Returns:
* A shared_ptr to the Group, or nullptr on failure.
*/
std::shared_ptr<Group> init(bool strict = false);
/**
* Initialize a JACCL communication group from an explicit Config object.
*/
std::shared_ptr<Group> init(const Config& cfg, bool strict = false);
} // namespace jaccl
@@ -1,19 +1,18 @@
// Copyright © 2026 Apple Inc.
#include "mlx/distributed/jaccl/mesh.h"
#include "mlx/backend/cpu/encoder.h"
#include "mlx/distributed/reduction_ops.h"
#include "mlx/dtype_utils.h"
#include "jaccl/mesh.h"
#include "jaccl/reduction_ops.h"
#include "jaccl/types.h"
namespace mlx::core::distributed::jaccl {
namespace jaccl {
MeshGroup::MeshGroup(
int rank,
const std::vector<std::string>& device_names,
const char* coordinator_addr)
const std::string& coordinator_addr)
: rank_(rank),
size_(device_names.size()),
side_channel_(rank_, size_, coordinator_addr),
side_channel_(rank_, size_, coordinator_addr.c_str()),
connections_(create_connections(device_names)) {
if (size_ > MESH_MAX_PEERS) {
std::ostringstream msg;
@@ -26,7 +25,7 @@ MeshGroup::MeshGroup(
initialize();
// Make sure every node has reached here before continuing
side_channel_.all_gather<int>(0);
side_channel_.barrier();
// Create the mesh implementation object
mesh_ = MeshImpl(rank_, size_, connections_, buffers_);
@@ -61,9 +60,9 @@ void MeshGroup::initialize() {
connections_[peer].queue_pair_init();
}
// Gather the information to be exchanged, this also serves as a barrier so
// that all peers have initialized their connections before attempting to
// transition to RTS.
// Gather the information to be exchanged, this also serves as a barrier
// so that all peers have initialized their connections before attempting
// to transition to RTS.
std::vector<Destination> info;
for (auto& conn : connections_) {
info.emplace_back(conn.info());
@@ -106,29 +105,27 @@ void MeshGroup::allocate_buffers() {
for (int i = 0; i < NUM_BUFFERS; i++) {
// Mesh buffers
for (int j = 0; j < size_; j++) {
// This is our send buffer so register it with all pds so we can send
// it to all connected devices.
if (j == rank_) {
// This is our send buffer so register it with all pds so we can
// send it to all connected devices.
for (auto& conn : connections_) {
if (conn.ctx != nullptr) {
buffers_[k * NUM_BUFFERS * size_ + i * size_ + j]
.register_to_protection_domain(conn.protection_domain);
}
}
}
// This is the recv buffer from rank j so register it to rank j's
// protection domain.
else {
} else {
// This is the recv buffer from rank j so register it to rank j's
// protection domain.
buffers_[k * NUM_BUFFERS * size_ + i * size_ + j]
.register_to_protection_domain(connections_[j].protection_domain);
}
}
// Ring buffers (see ring group for the logic below)
// We register send buffers to both the right and the left.
int left = (rank_ + size_ - 1) % size_;
int right = (rank_ + 1) % size_;
// We register send buffers to both the right and the left.
ring_send_buffers_[k * NUM_BUFFERS * 2 + i * 2 + 0]
.register_to_protection_domain(connections_[right].protection_domain);
ring_recv_buffers_[k * NUM_BUFFERS * 2 + i * 2 + 0]
@@ -141,78 +138,68 @@ void MeshGroup::allocate_buffers() {
}
}
void MeshGroup::all_sum(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::SumOp<T>{});
void MeshGroup::all_sum(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, SumOp<T>{});
});
}
void MeshGroup::all_max(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::MaxOp<T>{});
void MeshGroup::all_max(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, MaxOp<T>{});
});
}
void MeshGroup::all_min(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::MinOp<T>{});
void MeshGroup::all_min(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, MinOp<T>{});
});
}
void MeshGroup::all_gather(const array& input, array& output, Stream stream) {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
size_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, this]() {
mesh_.all_gather(in_ptr, out_ptr, n_bytes);
});
void MeshGroup::all_gather(const void* input, void* output, size_t n_bytes) {
mesh_.all_gather(
static_cast<const char*>(input), static_cast<char*>(output), n_bytes);
}
void MeshGroup::send(const array& input, int dst, Stream stream) {
auto data = input.data<char>();
int64_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.dispatch(
[data, n_bytes, dst, this]() { mesh_.send(data, n_bytes, dst); });
void MeshGroup::send(const void* input, size_t n_bytes, int dst) {
mesh_.send(static_cast<const char*>(input), n_bytes, dst);
}
void MeshGroup::recv(array& out, int src, Stream stream) {
auto data = out.data<char>();
int64_t n_bytes = out.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_output_array(out);
encoder.dispatch(
[data, n_bytes, src, this]() { mesh_.recv(data, n_bytes, src); });
void MeshGroup::recv(void* output, size_t n_bytes, int src) {
mesh_.recv(static_cast<char*>(output), n_bytes, src);
}
template <typename T, typename ReduceOp>
void MeshGroup::all_reduce(
const array& input,
array& output,
Stream stream,
const void* input,
void* output,
size_t n_bytes,
ReduceOp reduce_op) {
auto in_ptr = input.data<T>();
auto out_ptr = output.data<T>();
int64_t size = input.size();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, size, this, reduce_op]() {
if (size_ > 2 &&
((std::is_same_v<T, bfloat16_t> && size > 65536) ||
size >= 8 * 1024 * 1024 / sizeof(T))) {
ring_.all_reduce<2>(in_ptr, out_ptr, size, 1, reduce_op);
} else {
mesh_.all_reduce(in_ptr, out_ptr, size, reduce_op);
}
});
auto in_ptr = static_cast<const T*>(input);
auto out_ptr = static_cast<T*>(output);
int64_t count = n_bytes / sizeof(T);
if (size_ > 2 &&
((std::is_same_v<T, bfloat16_t> && count > 256 * 1024) ||
count >= 8 * 1024 * 1024 / static_cast<int64_t>(sizeof(T)))) {
ring_.all_reduce<2>(in_ptr, out_ptr, count, 1, reduce_op);
} else {
mesh_.all_reduce(in_ptr, out_ptr, count, reduce_op);
}
}
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -2,14 +2,12 @@
#pragma once
#include "mlx/distributed/distributed_impl.h"
#include "mlx/distributed/jaccl/mesh_impl.h"
#include "mlx/distributed/jaccl/ring_impl.h"
#include "mlx/distributed/jaccl/utils.h"
#include "jaccl/group.h"
#include "jaccl/mesh_impl.h"
#include "jaccl/rdma.h"
#include "jaccl/ring_impl.h"
using GroupImpl = mlx::core::distributed::detail::GroupImpl;
namespace mlx::core::distributed::jaccl {
namespace jaccl {
/**
* The JACCL communication group for a fully connected mesh. We expect one
@@ -20,16 +18,12 @@ namespace mlx::core::distributed::jaccl {
* information and then configure the connections to be ready for RDMA
* operations.
*/
class MeshGroup : public GroupImpl {
class MeshGroup : public Group {
public:
MeshGroup(
int rank,
const std::vector<std::string>& device_names,
const char* coordinator_addr);
Stream communication_stream(StreamOrDevice s) override {
return to_stream(s, Device::cpu);
}
const std::string& coordinator_addr);
int rank() override {
return rank_;
@@ -39,27 +33,26 @@ class MeshGroup : public GroupImpl {
return size_;
}
void all_sum(const array& input, array& output, Stream stream) override;
void all_max(const array& input, array& output, Stream stream) override;
void all_min(const array& input, array& output, Stream stream) override;
void all_gather(const array& input, array& output, Stream stream) override;
void send(const array& input, int dst, Stream stream) override;
void recv(array& out, int src, Stream stream) override;
void all_sum(const void* input, void* output, size_t n_bytes, int dtype)
override;
void sum_scatter(const array& input, array& output, Stream stream) override {
throw std::runtime_error("[jaccl] sum_scatter not supported.");
}
void all_max(const void* input, void* output, size_t n_bytes, int dtype)
override;
std::shared_ptr<GroupImpl> split(int color, int key = -1) override {
throw std::runtime_error("[jaccl] Group split not supported.");
}
void all_min(const void* input, void* output, size_t n_bytes, int dtype)
override;
void all_gather(const void* input, void* output, size_t n_bytes) override;
void send(const void* input, size_t n_bytes, int dst) override;
void recv(void* output, size_t n_bytes, int src) override;
private:
template <typename T, typename ReduceOp>
void all_reduce(
const array& input,
array& output,
Stream stream,
const void* input,
void* output,
size_t n_bytes,
ReduceOp reduce_op);
/**
@@ -86,4 +79,4 @@ class MeshGroup : public GroupImpl {
RingImpl ring_;
};
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -2,13 +2,16 @@
#pragma once
#include <memory>
#include <span>
#include "mlx/distributed/jaccl/utils.h"
#include "jaccl/rdma.h"
constexpr int MESH_MAX_PEERS = 8;
constexpr int MESH_PIPELINE = 2;
constexpr int64_t MAX_BUFFER_SIZE = FRAME_SIZE * (1 << (BUFFER_SIZES - 1));
namespace mlx::core::distributed::jaccl {
namespace jaccl {
class MeshImpl {
public:
@@ -17,20 +20,25 @@ class MeshImpl {
int size,
std::vector<Connection>& conns,
std::vector<SharedBuffer>& buffers)
: rank_(rank), size_(size), connections_(conns), buffers_(buffers) {}
: rank_(rank),
size_(size),
connections_(conns),
buffers_(buffers),
staging_mem_(
std::make_unique<char[]>(MESH_PIPELINE * MAX_BUFFER_SIZE)) {}
MeshImpl() : rank_(0), size_(1) {}
template <typename T, typename ReduceOp>
void
all_reduce(const T* in_ptr, T* out_ptr, int64_t size, ReduceOp reduce_op) {
// If not inplace all reduce then copy the input to the output first
if (in_ptr != out_ptr) {
std::memcpy(out_ptr, in_ptr, size * sizeof(T));
}
void all_reduce(const T* in, T* out, int64_t size, ReduceOp reduce_op) {
// Fully connected all reduce with deterministic reduction order.
//
// We copy rank 0's data to the output buffer and then we reduce every
// subsequent rank in-place in the output.
//
// Our own data is copied to a staging buffer to ensure we can reduce it in
// the output when needed.
// Fully connected all reduce
T* data = out_ptr;
auto [sz, buffer_size] = buffer_size_from_message(size * sizeof(T));
int64_t N = buffer_size / sizeof(T);
constexpr int PIPELINE = 2;
@@ -38,21 +46,36 @@ class MeshImpl {
int64_t total = static_cast<int64_t>(size);
int num_peers = size_ - 1;
// A helper for convenient access to the staging buffer.
auto local_staging = [&](int buff) -> T* {
return reinterpret_cast<T*>(staging_mem_.get() + buff * MAX_BUFFER_SIZE);
};
// Counters to maintain the state of transfers
int in_flight = 0;
int64_t read_offset = 0;
int completed_send_count[PIPELINE] = {0};
int completed_recv_begin[MESH_MAX_PEERS] = {0};
int completed_recv_end[MESH_MAX_PEERS] = {0};
int recv_end[MESH_MAX_PEERS] = {0};
int reduce_chunk = 0;
int reduce_rank = 0;
// Total number of chunks
int64_t total_chunks = (total + N - 1) / N;
// Prefill the pipeline
int buff = 0;
while (read_offset < total && buff < PIPELINE) {
post_recv_all(sz, buff);
// Copy the local data to send buffer and staging buffer
int64_t elems = std::min(N, total - read_offset);
std::copy(
data + read_offset,
data + std::min(read_offset + N, total),
in + read_offset, in + read_offset + elems, local_staging(buff));
std::copy(
in + read_offset,
in + read_offset + elems,
send_buffer(sz, buff).begin<T>());
recv_end[rank_]++;
post_send_all(sz, buff);
buff++;
@@ -61,14 +84,14 @@ class MeshImpl {
}
// Main loop
//
// Keep going until we have no longer data in flight.
while (in_flight > 0) {
while (reduce_chunk < total_chunks) {
// Poll the hardware for completions.
//
// If a send was completed mark how many completions we have received
// for that buffer. If we have sent the buffer to all peers we can
// reuse the buffer so copy the next chunk of data and send it to all.
// Also copy the next chunk into the staging area and advance our
// completed "receives".
//
// If a receive is completed then advance the pointer of completed
// receives.
@@ -84,10 +107,16 @@ class MeshImpl {
if (work_type == SEND_WR && read_offset < total) {
completed_send_count[buff]++;
if (completed_send_count[buff] == num_peers) {
int64_t elems = std::min(N, total - read_offset);
std::copy(
data + read_offset,
data + std::min(read_offset + N, total),
in + read_offset,
in + read_offset + elems,
local_staging(buff));
std::copy(
in + read_offset,
in + read_offset + elems,
send_buffer(sz, buff).begin<T>());
recv_end[rank_]++;
post_send_all(sz, buff);
completed_send_count[buff] = 0;
@@ -97,38 +126,70 @@ class MeshImpl {
}
else if (work_type == RECV_WR) {
completed_recv_end[rank]++;
recv_end[rank]++;
}
}
// Process the completed recv
// Process the received chunks in order.
//
// For each rank we have a range of completed recv defined by a begin
// and end inclusive and exlusive in standard C++ fashion.
//
// When there is an unprocessed receive we first check if we have
// finished sending the write location. If so then we reduce in-place
// and then check if there is more to be received and post a recv.
for (int r = 0; r < size_; r++) {
int s = completed_recv_begin[r];
int e = completed_recv_end[r];
int w = s * N;
while (w < read_offset && e - s > 0) {
int buff = s % PIPELINE;
reduce_op(
recv_buffer(sz, buff, r).begin<T>(),
data + w,
std::min(N, total - w));
w += N;
s++;
if (w + (PIPELINE - 1) * N < total) {
recv_from(sz, r, buff);
// Rank 0 is always copied as is. Our rank is always read from the
// staging area.
while (reduce_chunk < total_chunks) {
// w is our write location so break if it is ahead of the read location.
int64_t w = static_cast<int64_t>(reduce_chunk) * N;
if (w >= read_offset) {
break;
}
// We want to reduce the 'reduce_chunk' chunk but it hasn't arrived
// yet.
if (recv_end[reduce_rank] <= reduce_chunk) {
break;
}
int b = reduce_chunk % PIPELINE;
int64_t elems = std::min(N, total - w);
// Data is read from the staging area
if (reduce_rank == rank_) {
if (reduce_rank == 0) {
std::copy_n(local_staging(b), elems, out + w);
} else {
reduce_op(local_staging(b), out + w, elems);
}
}
// Data is read from the recv buffers
else {
if (reduce_rank == 0) {
std::copy_n(
recv_buffer(sz, b, reduce_rank).begin<T>(), elems, out + w);
} else {
reduce_op(
recv_buffer(sz, b, reduce_rank).begin<T>(), out + w, elems);
}
// Check if we need to post another receive
int64_t next_chunk = static_cast<int64_t>(reduce_chunk) + PIPELINE;
if (next_chunk < total_chunks) {
recv_from(sz, reduce_rank, b);
in_flight++;
}
}
completed_recv_begin[r] = s;
// Means we processed that chunk so move to the next one
reduce_rank++;
if (reduce_rank >= size_) {
reduce_rank = 0;
reduce_chunk++;
}
}
}
// Drain remaining in-flight completions (outstanding sends).
while (in_flight > 0) {
ibv_wc wc[WC_NUM];
int n = poll(connections_, WC_NUM, wc);
in_flight -= n;
}
}
void all_gather(const char* in_ptr, char* out_ptr, int64_t n_bytes) {
@@ -353,6 +414,7 @@ class MeshImpl {
int size_;
std::span<Connection> connections_;
std::span<SharedBuffer> buffers_;
std::unique_ptr<char[]> staging_mem_;
};
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -5,7 +5,7 @@
#include <iostream>
#include <sstream>
#include "mlx/distributed/jaccl/utils.h"
#include "jaccl/rdma.h"
#define LOAD_SYMBOL(symbol, variable) \
{ \
@@ -31,7 +31,7 @@ void* page_aligned_alloc(size_t num_bytes) {
} // namespace
namespace mlx::core::distributed::jaccl {
namespace jaccl {
IBVWrapper::IBVWrapper() {
librdma_handle_ = dlopen("librdma.dylib", RTLD_NOW | RTLD_GLOBAL);
@@ -182,11 +182,20 @@ const Destination& Connection::info() {
ibv_port_attr port_attr;
ibv().query_port(ctx, 1, &port_attr);
ibv_gid gid;
ibv().query_gid(ctx, 1, 1, &gid);
for (int i = 0; i < port_attr.gid_tbl_len; i++) {
ibv_gid tmp;
if (ibv().query_gid(ctx, 1, i, &tmp) == 0) {
if (*(uint64_t*)&tmp.raw[0] == 0 && *(uint16_t*)&tmp.raw[8] == 0 &&
*(uint16_t*)&tmp.raw[10] == 0xffff) {
gid = tmp;
break;
}
}
}
src.local_id = port_attr.lid;
src.queue_pair_number = queue_pair->qp_num;
src.packet_sequence_number = 7; // TODO: Change to sth random
src.packet_sequence_number = 7;
src.global_identifier = gid;
return src;
@@ -287,10 +296,10 @@ std::vector<Connection> create_connections(
SideChannel::SideChannel(int rank, int size, const char* addr)
: rank_(rank), size_(size) {
auto address = detail::parse_address(addr);
auto address = parse_address(addr);
if (rank_ == 0) {
detail::TCPSocket server(IBV_TAG);
TCPSocket server(IBV_TAG);
server.listen(IBV_TAG, address);
for (int i = 0; i < size - 1; i++) {
@@ -311,7 +320,7 @@ SideChannel::SideChannel(int rank, int size, const char* addr)
}
} else {
sockets_.push_back(
detail::TCPSocket::connect(
TCPSocket::connect(
IBV_TAG, address, 4, 1000, [](int attempt, int wait) {
std::cerr << IBV_TAG << " Connection attempt " << attempt
<< " waiting " << wait << " ms" << std::endl;
@@ -326,4 +335,4 @@ SideChannel::SideChannel(SideChannel&& sc)
sc.size_ = -1;
}
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -5,10 +5,11 @@
#include <infiniband/verbs.h>
#include <span>
#include <sstream>
#include <unordered_map>
#include <vector>
#include "mlx/distributed/utils.h"
#include "jaccl/tcp.h"
constexpr const char* IBV_TAG = "[jaccl]";
constexpr int SEND_WR = 1;
@@ -19,8 +20,6 @@ constexpr int BUFFER_SIZES = 8;
constexpr int NUM_BUFFERS = 2;
constexpr int FRAME_SIZE = 4096;
namespace detail = mlx::core::distributed::detail;
namespace {
template <typename T, typename = void>
@@ -45,7 +44,7 @@ inline std::pair<int, int64_t> buffer_size_from_message(int64_t msg) {
} // namespace
namespace mlx::core::distributed::jaccl {
namespace jaccl {
/**
* Wrapper for the ibverbs API.
@@ -334,10 +333,16 @@ class SideChannel {
return result;
}
void barrier() {
// Twice has proven to be more robust to initialization issues.
all_gather<int>(0);
all_gather<int>(0);
}
private:
int rank_;
int size_;
std::vector<detail::TCPSocket> sockets_;
std::vector<TCPSocket> sockets_;
};
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -0,0 +1,117 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include <cstddef>
#include "jaccl/types.h"
namespace jaccl {
template <typename T>
struct SumOp {
void operator()(const T* input, T* output, size_t N) const {
for (size_t i = 0; i < N; i++) {
output[i] = output[i] + input[i];
}
}
};
template <typename T>
struct MaxOp {
void operator()(const T* input, T* output, size_t N) const {
for (size_t i = 0; i < N; i++) {
output[i] = (output[i] > input[i]) ? output[i] : input[i];
}
}
};
template <typename T>
struct MinOp {
void operator()(const T* input, T* output, size_t N) const {
for (size_t i = 0; i < N; i++) {
output[i] = (output[i] < input[i]) ? output[i] : input[i];
}
}
};
//
// The last piece of the puzzle to use the native bf16 while compiling a single
// binary for all Macs is to compile these functions with
// target("arch=armv8.6-a").
//
// Now we can simply check in runtime and call them only when they are
// supported.
//
#if defined(__aarch64__)
__attribute__((target("arch=armv8.6-a"))) inline void
native_bf16_sum(const void* input, void* output, size_t N) {
auto in = reinterpret_cast<const __bf16*>(input);
auto out = reinterpret_cast<__bf16*>(output);
for (size_t i = 0; i < N; i++) {
out[i] = out[i] + in[i];
}
}
__attribute__((target("arch=armv8.6-a"))) inline void
native_bf16_max(const void* input, void* output, size_t N) {
auto in = reinterpret_cast<const __bf16*>(input);
auto out = reinterpret_cast<__bf16*>(output);
for (size_t i = 0; i < N; i++) {
out[i] = (out[i] > in[i]) ? out[i] : in[i];
}
}
__attribute__((target("arch=armv8.6-a"))) inline void
native_bf16_min(const void* input, void* output, size_t N) {
auto in = reinterpret_cast<const __bf16*>(input);
auto out = reinterpret_cast<__bf16*>(output);
for (size_t i = 0; i < N; i++) {
out[i] = (out[i] < in[i]) ? out[i] : in[i];
}
}
template <>
struct SumOp<bfloat16_t> {
void operator()(const bfloat16_t* input, bfloat16_t* output, size_t N) const {
if (has_native_bf16_support()) {
native_bf16_sum(input, output, N);
} else {
for (size_t i = 0; i < N; i++) {
output[i] = output[i] + input[i];
}
}
}
};
template <>
struct MaxOp<bfloat16_t> {
void operator()(const bfloat16_t* input, bfloat16_t* output, size_t N) const {
if (has_native_bf16_support()) {
native_bf16_max(input, output, N);
} else {
for (size_t i = 0; i < N; i++) {
output[i] = (output[i] > input[i]) ? output[i] : input[i];
}
}
}
};
template <>
struct MinOp<bfloat16_t> {
void operator()(const bfloat16_t* input, bfloat16_t* output, size_t N) const {
if (has_native_bf16_support()) {
native_bf16_min(input, output, N);
} else {
for (size_t i = 0; i < N; i++) {
output[i] = (output[i] < input[i]) ? output[i] : input[i];
}
}
}
};
#endif // defined(__aarch64__)
} // namespace jaccl
@@ -1,22 +1,21 @@
// Copyright © 2026 Apple Inc.
#include "mlx/distributed/jaccl/ring.h"
#include "mlx/backend/cpu/encoder.h"
#include "mlx/distributed/reduction_ops.h"
#include "mlx/dtype_utils.h"
#include "jaccl/ring.h"
#include "jaccl/reduction_ops.h"
#include "jaccl/types.h"
namespace mlx::core::distributed::jaccl {
namespace jaccl {
RingGroup::RingGroup(
int rank,
int size,
const std::vector<std::string>& left_devices,
const std::vector<std::string>& right_devices,
const char* coordinator_addr)
const std::string& coordinator_addr)
: rank_(rank),
size_(size),
n_conns_(left_devices.size()),
side_channel_(rank_, size_, coordinator_addr),
side_channel_(rank_, size_, coordinator_addr.c_str()),
left_(create_connections(left_devices)),
right_(create_connections(right_devices)) {
if (left_.size() > RING_MAX_CONNS || right_.size() > RING_MAX_CONNS) {
@@ -29,8 +28,8 @@ RingGroup::RingGroup(
// Initialize all the connections and allocate buffers
initialize();
// Make sure every node has reached here before continuing
side_channel_.all_gather<int>(0);
// Make sure every node has reached here before continuing.
side_channel_.barrier();
// Create the ring implementation object
ring_ = RingImpl(rank_, size_, left_, right_, send_buffers_, recv_buffers_);
@@ -60,9 +59,9 @@ void RingGroup::initialize() {
conn.queue_pair_init();
}
// Gather the information to be exchanged, this also serves as a barrier so
// that all peers have initialized their connections before attempting to
// transition to RTS.
// Gather the information to be exchanged, this also serves as a barrier
// so that all peers have initialized their connections before attempting
// to transition to RTS.
std::vector<Destination> left_info;
for (auto& conn : left_) {
left_info.emplace_back(conn.info());
@@ -126,40 +125,48 @@ void RingGroup::allocate_buffers() {
}
}
void RingGroup::all_sum(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::SumOp<T>{});
void RingGroup::all_sum(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, SumOp<T>{});
});
}
void RingGroup::all_max(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::MaxOp<T>{});
void RingGroup::all_max(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, MaxOp<T>{});
});
}
void RingGroup::all_min(const array& input, array& output, Stream stream) {
dispatch_all_types(output.dtype(), [&](auto type_tag) {
using T = MLX_GET_TYPE(type_tag);
all_reduce<T>(input, output, stream, detail::MinOp<T>{});
void RingGroup::all_min(
const void* input,
void* output,
size_t n_bytes,
int dtype) {
dispatch_all_types(dtype, [&](auto type_tag) {
using T = JACCL_GET_TYPE(type_tag);
all_reduce<T>(input, output, n_bytes, MinOp<T>{});
});
}
void RingGroup::all_gather(const array& input, array& output, Stream stream) {
auto in_ptr = input.data<char>();
auto out_ptr = output.data<char>();
int64_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, n_bytes, this]() {
ring_.all_gather(in_ptr, out_ptr, n_bytes, n_conns_);
});
void RingGroup::all_gather(const void* input, void* output, size_t n_bytes) {
ring_.all_gather(
static_cast<const char*>(input),
static_cast<char*>(output),
n_bytes,
n_conns_);
}
void RingGroup::send(const array& input, int dst, Stream stream) {
void RingGroup::send(const void* input, size_t n_bytes, int dst) {
int right = (rank_ + 1) % size_;
int left = (rank_ + size_ - 1) % size_;
if (dst != right && dst != left) {
@@ -168,16 +175,10 @@ void RingGroup::send(const array& input, int dst, Stream stream) {
<< "but tried to send to " << dst << " from " << rank_ << std::endl;
throw std::runtime_error(msg.str());
}
auto data = input.data<char>();
int64_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.dispatch([data, n_bytes, dst, this]() {
ring_.send(data, n_bytes, dst, n_conns_);
});
ring_.send(static_cast<const char*>(input), n_bytes, dst, n_conns_);
}
void RingGroup::recv(array& out, int src, Stream stream) {
void RingGroup::recv(void* output, size_t n_bytes, int src) {
int right = (rank_ + 1) % size_;
int left = (rank_ + size_ - 1) % size_;
if (src != right && src != left) {
@@ -186,42 +187,29 @@ void RingGroup::recv(array& out, int src, Stream stream) {
<< "but tried to recv from " << src << " to " << rank_ << std::endl;
throw std::runtime_error(msg.str());
}
auto data = out.data<char>();
int64_t n_bytes = out.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_output_array(out);
encoder.dispatch([data, n_bytes, src, this]() {
ring_.recv(data, n_bytes, src, n_conns_);
});
ring_.recv(static_cast<char*>(output), n_bytes, src, n_conns_);
}
template <typename T, typename ReduceOp>
void RingGroup::all_reduce(
const array& input,
array& output,
Stream stream,
const void* input,
void* output,
size_t n_bytes,
ReduceOp reduce_op) {
auto in_ptr = input.data<T>();
auto out_ptr = output.data<T>();
int64_t size = input.size();
int64_t n_bytes = input.nbytes();
auto& encoder = cpu::get_command_encoder(stream);
encoder.set_input_array(input);
encoder.set_output_array(output);
encoder.dispatch([in_ptr, out_ptr, size, n_bytes, this, reduce_op]() {
if (size < size_ * 2 * n_conns_) {
ring_.all_reduce<1, T, ReduceOp>(in_ptr, out_ptr, size, 1, reduce_op);
return;
}
auto in_ptr = static_cast<const T*>(input);
auto out_ptr = static_cast<T*>(output);
int64_t count = n_bytes / sizeof(T);
if (count < size_ * 2 * n_conns_) {
ring_.all_reduce<1, T, ReduceOp>(in_ptr, out_ptr, count, 1, reduce_op);
return;
}
if (n_bytes <= 65536) {
ring_.all_reduce<2, T, ReduceOp>(in_ptr, out_ptr, size, 1, reduce_op);
return;
}
if (n_bytes <= 65536) {
ring_.all_reduce<2, T, ReduceOp>(in_ptr, out_ptr, count, 1, reduce_op);
return;
}
ring_.all_reduce<2, T, ReduceOp>(
in_ptr, out_ptr, size, n_conns_, reduce_op);
});
ring_.all_reduce<2, T, ReduceOp>(in_ptr, out_ptr, count, n_conns_, reduce_op);
}
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -2,13 +2,11 @@
#pragma once
#include "mlx/distributed/distributed_impl.h"
#include "mlx/distributed/jaccl/ring_impl.h"
#include "mlx/distributed/jaccl/utils.h"
#include "jaccl/group.h"
#include "jaccl/rdma.h"
#include "jaccl/ring_impl.h"
using GroupImpl = mlx::core::distributed::detail::GroupImpl;
namespace mlx::core::distributed::jaccl {
namespace jaccl {
/**
* The JACCL communication group for a ring where each node is connected to its
@@ -19,18 +17,14 @@ namespace mlx::core::distributed::jaccl {
* information and then configure the connections to be ready for RDMA
* operations.
*/
class RingGroup : public GroupImpl {
class RingGroup : public Group {
public:
RingGroup(
int rank,
int size,
const std::vector<std::string>& left_devices,
const std::vector<std::string>& right_devices,
const char* coordinator_addr);
Stream communication_stream(StreamOrDevice s) override {
return to_stream(s, Device::cpu);
}
const std::string& coordinator_addr);
int rank() override {
return rank_;
@@ -40,27 +34,26 @@ class RingGroup : public GroupImpl {
return size_;
}
void all_sum(const array& input, array& output, Stream stream) override;
void all_max(const array& input, array& output, Stream stream) override;
void all_min(const array& input, array& output, Stream stream) override;
void all_gather(const array& input, array& output, Stream stream) override;
void send(const array& input, int dst, Stream stream) override;
void recv(array& out, int src, Stream stream) override;
void all_sum(const void* input, void* output, size_t n_bytes, int dtype)
override;
void sum_scatter(const array& input, array& output, Stream stream) override {
throw std::runtime_error("[jaccl] sum_scatter not supported.");
}
void all_max(const void* input, void* output, size_t n_bytes, int dtype)
override;
std::shared_ptr<GroupImpl> split(int color, int key = -1) override {
throw std::runtime_error("[jaccl] Group split not supported.");
}
void all_min(const void* input, void* output, size_t n_bytes, int dtype)
override;
void all_gather(const void* input, void* output, size_t n_bytes) override;
void send(const void* input, size_t n_bytes, int dst) override;
void recv(void* output, size_t n_bytes, int src) override;
private:
template <typename T, typename ReduceOp>
void all_reduce(
const array& input,
array& output,
Stream stream,
const void* input,
void* output,
size_t n_bytes,
ReduceOp reduce_op);
/**
@@ -86,4 +79,4 @@ class RingGroup : public GroupImpl {
RingImpl ring_;
};
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
@@ -4,11 +4,11 @@
#include <span>
#include "mlx/distributed/jaccl/utils.h"
#include "jaccl/rdma.h"
constexpr int RING_MAX_CONNS = 4;
namespace mlx::core::distributed::jaccl {
namespace jaccl {
class RingImpl {
public:
@@ -628,4 +628,4 @@ class RingImpl {
std::span<SharedBuffer> recv_buffers_;
};
} // namespace mlx::core::distributed::jaccl
} // namespace jaccl
+206
View File
@@ -0,0 +1,206 @@
// Copyright © 2025 Apple Inc.
#include <netdb.h>
#include <unistd.h>
#include <cstring>
#include <sstream>
#include <thread>
#include "jaccl/tcp.h"
namespace jaccl {
/**
* Parse a sockaddr from an ip and port provided as strings.
*/
address_t parse_address(const std::string& ip, const std::string& port) {
struct addrinfo hints, *res;
std::memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
int status = getaddrinfo(ip.c_str(), port.c_str(), &hints, &res);
if (status != 0) {
std::ostringstream msg;
msg << "Can't parse address " << ip << ":" << port;
throw std::runtime_error(msg.str());
}
address_t result;
memcpy(&result.addr, res->ai_addr, res->ai_addrlen);
result.len = res->ai_addrlen;
freeaddrinfo(res);
return result;
}
/**
* Parse a sockaddr provided as an <ip>:<port> string.
*/
address_t parse_address(const std::string& ip_port) {
auto colon = ip_port.find(":");
if (colon == std::string::npos) {
std::ostringstream msg;
msg << "Can't parse address " << ip_port;
throw std::runtime_error(msg.str());
}
std::string ip(ip_port.begin(), ip_port.begin() + colon);
std::string port(ip_port.begin() + colon + 1, ip_port.end());
return parse_address(ip, port);
}
TCPSocket::TCPSocket(const char* tag) {
sock_ = socket(AF_INET, SOCK_STREAM, 0);
if (sock_ < 0) {
std::ostringstream msg;
msg << tag << " Couldn't create socket (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
}
TCPSocket::TCPSocket(TCPSocket&& s) {
sock_ = s.sock_;
s.sock_ = -1;
}
TCPSocket& TCPSocket::operator=(TCPSocket&& s) {
if (this != &s) {
sock_ = s.sock_;
s.sock_ = -1;
}
return *this;
}
TCPSocket::TCPSocket(int s) : sock_(s) {}
TCPSocket::~TCPSocket() {
if (sock_ > 0) {
shutdown(sock_, 2);
close(sock_);
}
}
int TCPSocket::detach() {
int s = sock_;
sock_ = -1;
return s;
}
void TCPSocket::listen(const char* tag, const address_t& addr) {
int success;
// Make sure we can launch immediately after shutdown by setting the
// reuseaddr option so that we don't get address already in use errors
int enable = 1;
success = setsockopt(sock_, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int));
if (success < 0) {
std::ostringstream msg;
msg << tag << " Couldn't enable reuseaddr (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
success = setsockopt(sock_, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(int));
if (success < 0) {
std::ostringstream msg;
msg << tag << " Couldn't enable reuseport (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
// Bind the socket to the address and port
success = bind(sock_, addr.get(), addr.len);
if (success < 0) {
std::ostringstream msg;
msg << tag << " Couldn't bind socket (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
// Prepare waiting for connections
success = ::listen(sock_, 0);
if (success < 0) {
std::ostringstream msg;
msg << tag << " Couldn't listen (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
}
TCPSocket TCPSocket::accept(const char* tag) {
int peer = ::accept(sock_, nullptr, nullptr);
if (peer < 0) {
std::ostringstream msg;
msg << tag << " Accept failed (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
return TCPSocket(peer);
}
void TCPSocket::send(const char* tag, const void* data, size_t len) {
while (len > 0) {
auto n = ::send(sock_, data, len, 0);
if (n <= 0) {
std::ostringstream msg;
msg << tag << " Send failed with errno=" << errno;
throw std::runtime_error(msg.str());
}
len -= n;
data = static_cast<const char*>(data) + n;
}
}
void TCPSocket::recv(const char* tag, void* data, size_t len) {
while (len > 0) {
auto n = ::recv(sock_, data, len, 0);
if (n <= 0) {
std::ostringstream msg;
msg << tag << " Recv failed with errno=" << errno;
throw std::runtime_error(msg.str());
}
len -= n;
data = static_cast<char*>(data) + n;
}
}
TCPSocket TCPSocket::connect(
const char* tag,
const address_t& addr,
int num_retries,
int wait,
std::function<void(int, int)> cb) {
int sock, success;
// Attempt to connect `num_retries` times with exponential backoff.
for (int attempt = 0; attempt < num_retries; attempt++) {
// Create the socket
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
std::ostringstream msg;
msg << tag << " Couldn't create socket to connect (error: " << errno
<< ")";
throw std::runtime_error(msg.str());
}
success = ::connect(sock, addr.get(), addr.len);
if (success == 0) {
break;
}
if (cb != nullptr) {
cb(attempt, wait);
}
if (wait > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(wait));
}
wait <<= 1;
}
if (success < 0) {
std::ostringstream msg;
msg << tag << " Couldn't connect (error: " << errno << ")";
throw std::runtime_error(msg.str());
}
return TCPSocket(sock);
}
} // namespace jaccl
+67
View File
@@ -0,0 +1,67 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include <sys/socket.h>
#include <functional>
#include <string>
namespace jaccl {
struct address_t {
sockaddr_storage addr;
socklen_t len;
const sockaddr* get() const {
return (struct sockaddr*)&addr;
}
};
/**
* Parse a sockaddr from an ip and port provided as strings.
*/
address_t parse_address(const std::string& ip, const std::string& port);
/**
* Parse a sockaddr provided as an <ip>:<port> string.
*/
address_t parse_address(const std::string& ip_port);
/**
* Small wrapper over a TCP socket to simplify initiating connections.
*/
class TCPSocket {
public:
TCPSocket(const char* tag);
TCPSocket(const TCPSocket&) = delete;
TCPSocket& operator=(const TCPSocket&) = delete;
TCPSocket(TCPSocket&& s);
TCPSocket& operator=(TCPSocket&&);
~TCPSocket();
void listen(const char* tag, const address_t& addr);
TCPSocket accept(const char* tag);
void send(const char* tag, const void* data, size_t len);
void recv(const char* tag, void* data, size_t len);
int detach();
operator int() const {
return sock_;
}
static TCPSocket connect(
const char* tag,
const address_t& addr,
int num_retries = 1,
int wait = 0,
std::function<void(int, int)> cb = nullptr);
private:
TCPSocket(int sock);
int sock_;
};
} // namespace jaccl
+263
View File
@@ -0,0 +1,263 @@
// Copyright © 2025 Apple Inc.
#pragma once
#include <complex>
#include <cstdint>
#include <type_traits>
#if defined(__aarch64__) && defined(__APPLE__)
#include <sys/sysctl.h>
#endif
namespace jaccl {
namespace {
union float_bits {
float f;
uint32_t u;
};
} // namespace
#ifdef __ARM_FEATURE_FP16_SCALAR_ARITHMETIC
#include <arm_fp16.h>
#else
#define __JACCL_HALF_NAN__ 0x7D00
//
// The MLX float16 compatibility fallback. Redefined here to keep JACCL
// standalone. Also it doesn't really need all the ops MXL defines.
//
struct float16_t {
uint16_t bits_;
float16_t(const float& x) : bits_(0) {
float_bits in;
in.f = x;
// Extract sign
uint32_t x_sign_32 = in.u & uint32_t(0x80000000);
uint16_t x_sign_16 = (x_sign_32 >> 16);
if (std::isnan(x)) {
bits_ = x_sign_16 | uint16_t(__JACCL_HALF_NAN__);
} else {
// Union
float_bits inf_scale, zero_scale, magic_bits;
// Find exponent bits and take the max supported by half
uint32_t x_expo_32 = in.u & uint32_t(0x7f800000);
uint32_t max_expo_32 = uint32_t(0x38800000);
x_expo_32 = x_expo_32 < max_expo_32 ? max_expo_32 : x_expo_32;
x_expo_32 += uint32_t(15) << 23;
// Handle scaling to inf as needed
inf_scale.u = uint32_t(0x77800000);
zero_scale.u = uint32_t(0x08800000);
// Combine with magic and let addition do rounding
magic_bits.u = x_expo_32;
magic_bits.f += (std::abs(x) * inf_scale.f) * zero_scale.f;
// Take the lower 5 bits of the exponent
uint32_t x_expo_16 = ((magic_bits.u >> 13) & uint32_t(0x7c00));
// Collect the lower 12 bits which have the mantissa
uint32_t x_mant_16 = magic_bits.u & uint32_t(0x0fff);
// Combine sign, exp and mantissa
bits_ = (x_sign_16 | uint16_t(x_expo_16 + x_mant_16));
}
}
operator float() const {
float_bits out;
uint32_t x_sign_32 = (bits_ << 16) & uint32_t(0x80000000);
uint32_t base = (bits_ << 16);
uint32_t two_base = base + base;
uint32_t denorm_max = 1u << 27;
if (two_base < denorm_max) {
out.u = uint32_t(126) << 23; // magic mask
out.u |= (two_base >> 17); // Bits from fp16
out.f -= 0.5f; // magic bias
} else {
out.u = uint32_t(0xE0) << 23; // exponent offset
out.u += (two_base >> 4); // Bits from fp16
float out_unscaled = out.f; // Store value
out.u = uint32_t(0x7800000); // exponent scale
out.f *= out_unscaled;
}
// Add sign
out.u |= x_sign_32;
return out.f;
}
bool operator<(float16_t x) {
return static_cast<float>(*this) < static_cast<float>(x);
}
bool operator>(float16_t x) {
return static_cast<float>(*this) > static_cast<float>(x);
}
float16_t operator+(float16_t x) {
return static_cast<float>(*this) + static_cast<float>(x);
}
float16_t& operator+=(float16_t x) {
*this = *this + x;
return *this;
}
};
#endif
//
// Check at runtime if the CPU supports native bfloat16 (FEAT_BF16).
//
// This allows us to compile once for all Macs but still enable the feature if
// it is supported.
//
inline bool has_native_bf16_support() {
#if defined(__aarch64__) && defined(__APPLE__)
static bool has_support = []() {
int value = 0;
size_t value_size = sizeof(value);
int success = sysctlbyname(
"hw.optional.arm.FEAT_BF16", &value, &value_size, nullptr, 0);
return success == 0 & value != 0;
}();
return has_support;
#else
return false;
#endif
}
//
// The MLX bfloat16 compatibility fallback.
//
// Contrary to float16 we always define it and we 'll use
// has_native_bf16_support to decide if we are going to use __bf16 instead
// during runtime.
//
#define __JACCL_BFLOAT_NAN__ 0x7FC0
struct bfloat16_t {
uint16_t bits_;
bfloat16_t(const float& x) {
if (std::isnan(x)) {
bits_ = __JACCL_BFLOAT_NAN__;
} else {
float_bits in;
in.f = x;
in.u += (in.u >> 16 & 1) + uint32_t(0x7FFF);
bits_ = in.u >> 16;
}
}
operator float() const {
float_bits out;
out.u = ((uint32_t)bits_) << 16;
return out.f;
}
bool operator<(bfloat16_t x) {
return static_cast<float>(*this) < static_cast<float>(x);
}
bool operator>(bfloat16_t x) {
return static_cast<float>(*this) > static_cast<float>(x);
}
bfloat16_t operator+(bfloat16_t x) {
return static_cast<float>(*this) + static_cast<float>(x);
}
bfloat16_t& operator+=(bfloat16_t x) {
*this = *this + x;
return *this;
}
};
using complex64_t = std::complex<float>;
inline bool operator<(complex64_t lhs, complex64_t rhs) {
return lhs.real() < rhs.real() ||
(lhs.real() == rhs.real() && lhs.imag() < rhs.imag());
}
inline bool operator>(complex64_t lhs, complex64_t rhs) {
return lhs.real() > rhs.real() ||
(lhs.real() == rhs.real() && lhs.imag() > rhs.imag());
}
template <typename T>
struct type_identity {
using type = T;
};
#define JACCL_GET_TYPE(x) typename decltype(x)::type
/**
* Dispatch a function for all supported types based on a Dtype.
*/
template <typename F>
void dispatch_all_types(int dtype, F&& f) {
switch (dtype) {
case Dtype::Bool:
f(type_identity<bool>{});
break;
case Dtype::Int8:
f(type_identity<int8_t>{});
break;
case Dtype::Int16:
f(type_identity<int16_t>{});
break;
case Dtype::Int32:
f(type_identity<int32_t>{});
break;
case Dtype::Int64:
f(type_identity<int64_t>{});
break;
case Dtype::UInt8:
f(type_identity<uint8_t>{});
break;
case Dtype::UInt16:
f(type_identity<uint16_t>{});
break;
case Dtype::UInt32:
f(type_identity<uint32_t>{});
break;
case Dtype::UInt64:
f(type_identity<uint64_t>{});
break;
case Dtype::Float16:
f(type_identity<float16_t>{});
break;
case Dtype::BFloat16:
f(type_identity<bfloat16_t>{});
break;
case Dtype::Float32:
f(type_identity<float>{});
break;
case Dtype::Float64:
f(type_identity<double>{});
break;
case Dtype::Complex64:
f(type_identity<complex64_t>{});
break;
}
}
} // namespace jaccl