Mac 集群通信提速一个数量级:MLX JACCL(Thunderbolt 5 RDMA)从零到上手全解
【免费下载链接】mlxMLX: An array framework for Apple silicon项目地址: https://gitcode.com/GitHub_Trending/ml/mlx
MLX 的分布式后端 JACCL 专为 Thunderbolt 5 RDMA 设计,让多台 Mac 之间的集合通信延迟比 TCP 方案低一个数量级。读完这篇手册,你能在 Mac 集群上完成 RDMA over Thunderbolt 的开启与验证、构建 JACCL 库、写出第一个 all_sum 示例、生成连接矩阵与 hostfile,并跑通mlx.launch --backend jaccl启动的分布式 Python 任务。
多台 Mac 组集群,为什么通信会被 TCP 拖慢
这一节回答"JACCL 解决什么问题":传统 TCP 方案的延迟开销来自哪里,以及 JACCL 凭什么能快。
想象你要在 4 台 Mac 上做张量并行推理:模型权重切到每台机器上,每算完一层,各设备就要做一次跨机同步。
同步这一步走传统 TCP 时,数据要反复穿越内核的 TCP/IP 协议栈,每一次拷贝和调度都是延迟。JACCL 的做法不同:它基于 verbs API(rdma.h 中封装的ibv_*系列函数)在 Thunderbolt 链路上直接操作 RDMA(远程直接内存访问,即一台机器直接读写另一台机器的内存),绕开协议栈的拷贝与调度开销,换来比 TCP 低一个数量级的端到端延迟。
这条能力由 Apple 在 macOS 26.2 中引入的 RDMA over Thunderbolt 技术提供。落到用途上,JACCL 正是为这类场景准备的:大模型推理的张量并行(每层之后用all_sum/all_reduce同步中间结果,低延迟直接决定推理吞吐)、分布式训练中的梯度同步,以及 Mac 之间的低延迟集合操作(all-sum、all-max、all-min、all-gather)。
底层还有一组工程细节值得知道:JACCL 提供 mesh(全连接,任意两节点直连)与 ring(环形,邻居相连)两种拓扑,前者适合小消息低延迟,后者的 reduce-scatter + all-gather 流水线化设计是大消息下带宽最高的方案;支持的数据类型覆盖 Bool、Int8-64、UInt8-64、Float16、BFloat16、Float32、Float64、Complex64。它甚至自带与 MLX 兼容的float16_t、bfloat16_t、complex64_t实现,保证可以脱离 MLX 单独编译,并在运行时通过has_native_bf16_support()检测 CPU 是否支持FEAT_BF16,做到一次编译、按机器能力启用原生 bf16。可以说,JACCL 就是 Mac 集群里的 NCCL。
从 0 到可构建:门槛检查、恢复模式开启 RDMA、验证设备、编译独立库
这一节按操作时序带你完成全部前置工作:确认硬件与系统版本、在恢复模式开启 RDMA over Thunderbolt、用ibv_devices验证、最后构建出可用的 JACCL 库。
先对一遍门槛,任何一条不满足都走不通:
- 硬件:节点之间用 Thunderbolt 5 互连;
- 系统:macOS SDK >= 26.2,且
CMAKE_OSX_DEPLOYMENT_TARGET同样 >= 26.2; - 构建工具:CMake >= 3.24、C++20(构建脚本设置了
CMAKE_CXX_STANDARD 20)。
RDMA over Thunderbolt 默认是关闭的,需要在 macOS 恢复模式里手动开启一次:
- 将 Mac 启动进入恢复模式;
- 在"实用工具 -> 终端"中打开终端;
- 执行
rdma_ctl enable; - 重启系统。
重启后在每台节点上验证 RDMA 设备是否可见:
ibv_devices正常输出形如:
device node GUID ------ ---------------- rdma_en2 8096a9d9edbaac05 rdma_en3 8196a9d9edbaac05 rdma_en5 8396a9d9edbaac05记下每台机器实际可见的设备名(如rdma_en5),它们稍后就是连接矩阵里的连接标识。从源码看,rdma.h 通过运行时动态加载 librdma 句柄判断 RDMA 是否可用(is_available()),这也是 MLX 侧jaccl::is_available()的底层依据。
验证通过就可以构建独立库了:
cd mlx/distributed/jaccl/lib mkdir build && cd build cmake .. make构建产物会安装到lib、include(头文件进include/jaccl),并导出jaccl::jaccl这个 CMake 目标。两个值得注意的构建细节:
- lib/CMakeLists.txt 默认使用 Release 编译类型——注释里专门解释:不设构建类型时 CMake 会用空类型即
-O0,会严重拖累归约和 memcpy 热点路径的性能; - 构建时通过 FetchContent 拉取 nlohmann/json(v3.11.3),用来解析设备配置文件——这就是设备文件格式必须是 JSON 的原因。
如果你不想本地构建,在自己的 CMake 工程里直接引入也行:把 lib 目录当作 FetchContent 依赖声明(SOURCE_SUBDIR mlx/distributed/jaccl/lib)后FetchContent_MakeAvailable,再链接jaccl即可。仓库里的 examples/CMakeLists.txt 正是这种用法,它用这个方式依赖../(lib 目录)构建出四个示例程序。
最小上手:环境变量 + 20 行代码跑通一次 all_sum
这一节给你一个能直接运行的最小示例:每个进程设置好环境变量,jaccl::init()无参调用,执行一次 all-reduce 求和。
JACCL 支持纯环境变量初始化(对应 jaccl.cpp 中的Config::from_env())。每个变量都提供JACCL_*与MLX_*两种写法,优先读取JACCL_*:
| 环境变量 | 别名 | 含义 |
|---|---|---|
JACCL_RANK | MLX_RANK | 本进程的 rank(从 0 开始的整数) |
JACCL_IBV_DEVICES | MLX_IBV_DEVICES | 描述设备连接关系的 JSON 文件路径 |
JACCL_COORDINATOR | MLX_JACCL_COORDINATOR | 协调者(rank 0 监听端)的 IP:port |
JACCL_RING | MLX_JACCL_RING | 可选;设置了即优先使用 ring 拓扑而非 mesh |
对应仓库 examples/minimal_env.cpp 的最小示例:
#include <iostream> #include <jaccl/jaccl.h> int main() { // 从环境变量构建配置并初始化通信组 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; // 一次 all-reduce 求和:10 个 float float input[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; float output[10]; group->all_sum(input, output, sizeof(input), jaccl::Float32); std::cout << "Result: " << output[0] << std::endl; return 0; }运行前,记得为每个进程设置好JACCL_RANK、JACCL_IBV_DEVICES、JACCL_COORDINATOR三个环境变量。如果你更习惯不依赖环境变量,用Config对象显式配置即可:jaccl::Config().set_rank(0).set_coordinator("192.168.1.1:32132").set_devices({...})链式调用后传给jaccl::init(cfg),完整示例见 examples/minimal_cfg.cpp,Config的全部可用方法在第 7 节展开。
用三种方式描述集群连接关系:设备文件 JSON、set_devices、hostfile 与自动探测
这一节讲清连接矩阵的三种等价写法(外加一个自动探测工具),你只需要挑一种维护即可。
设备文件、Config::set_devices、mlx.launch的 hostfile 三者描述的是同一张"谁用哪个 RDMA 设备连谁"的矩阵,语义完全等价。
设备文件是一个 JSON 数组,每个条目描述某个 rank 到其余所有 rank 所使用的 RDMA 设备名:
[ [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] ]填写规则取决于拓扑:
- mesh 拓扑:
devices[i][j]是连接 rank i 到 rank j 的设备名,i == j时填null; - ring 拓扑:只有相邻节点之间填设备名,其余位置一律
null。
解析逻辑在 jaccl.cpp 的parse_devices_json中:它要求顶层必须是数组,每个 rank 的连接条目数量必须等于节点总数,否则抛出包含具体 rank 与缺失数量的错误;每个元素可以是null、单个设备名字符串,或字符串数组(一条链路上允许多个设备)。校验方面,is_valid_mesh()要求每个非对角位置恰好有一个设备、对角位置为空;is_valid_ring()则要求每个节点到左右邻居的设备数量一致。
Config::set_devices用同样的矩阵结构(三层嵌套的字符串向量,空数组代替null),hostfile 则把矩阵拆成每节点一行,交给mlx.launch:
{ "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] } ] }每个 host 的rdma数组就是设备文件矩阵中的一行,null表示自己到自己的连接。
手写矩阵容易出错,好在 MLX 提供了mlx.distributed_config工具,自动探测各节点的 Thunderbolt 连接关系:
# 可视化连接拓扑:生成 DOT 图并用 Preview 打开 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 # 自动配置并生成 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第一条命令以 DOT 图形展示节点间的实际物理连接,方便你先确认接线没错;第二条在--auto-setup模式下自动完成 RDMA 相关配置,把探测到的连接矩阵写入--output指定的 hostfile,可以直接交给mlx.launch使用。
读懂 Group 接口契约:谁分配内存、strict 语义与拓扑选择逻辑
这一节把 Group 的能力与约定讲透:哪些操作可用、调用方必须守住的内存约定、init失败时发生什么,以及库内部如何在 mesh 与 ring 之间做选择。
Group 对外提供三类能力:
- 集合操作:
all_sum、all_max、all_min(输入输出大小相同,归约按 dtype 语义在组内进行),以及all_gather(输出大小为size() * n_bytes); - 点对点操作:
send、recv; - 同步原语:
barrier,阻塞直到组内每个 rank 都到达此点。
仓库当前实现里还额外提供了sum_scatter(reduce-scatter with sum):输入为size()个连续的n_bytes分块,归约后 rank r 的输出是各 rank 第 r 块之和。
比能力清单更重要的是两条硬约定:
其一,JACCL 自身不做任何内存分配。所有输出指针必须指向已分配、且足以容纳结果的内存区域——output缓冲区由你准备好再传进去,这是调用方必须守住的契约。所有操作都以原始字节数n_bytes计长度,归约类操作再用dtype参数指定按哪种类型语义归约。
其二,init的 strict 语义决定失败时的行为。组有两个创建入口:
std::shared_ptr<Group> init(bool strict = false); std::shared_ptr<Group> init(const Config& cfg, bool strict = false);无参版从环境变量构建配置并创建组;strict=true时初始化失败抛出带完整提示信息的运行时错误,否则返回nullptr。若 rank、设备文件或 coordinator 缺失,strict 模式下的报错信息会直接告诉你缺了哪个变量。另有init(bool strict, std::function<AllGatherFn(int, int)> factory)重载(见 jaccl.h),用自定义 all-gather 工厂替换默认的 TCP 侧信道,来交换 RDMA 连接元数据。
拓扑选择逻辑:若设置了prefer_ring且配置是合法 ring,则创建RingGroup;否则优先创建MeshGroup;两者都非法时按strict决定返回空指针或抛错。JACCL_RING环境变量就是走prefer_ring(true)这条路径的开关。
配置由Config类完成,核心方法链是set_rank/set_coordinator/set_devices/prefer_ring(bool prefer = true),另有is_valid_mesh()与is_valid_ring()两个校验器。头文件 jaccl.h 中还有一组便捷方法:set_rank(const char*)、set_coordinator(const char*)、set_devices_from_file(const char* dev_file)(直接读取 JSON 设备文件)、set_all_gather(...)/set_all_gather_factory(...)(定制侧信道),以及static Config from_env()和is_valid()。注意set_devices会推导组大小(size_ = devices_.size())并校验矩阵必须是方阵,否则抛出std::invalid_argument。
接入 MLX:三行 Python 代码与 mlx.launch 启动
这一节展示 JACCL 作为 MLX 分布式后端时的接入方式,以及集成层需要留意的限制。
JACCL 是 MLX 的分布式后端之一,Python 侧用起来非常简单:
import mlx.core as mx # 用 JACCL 后端初始化通信组 world = mx.distributed.init(backend="jaccl") # 执行分布式集合操作 x = mx.ones((10,)) result = mx.distributed.all_sum(x, group=world)多机任务用mlx.launch启动,hostfile 用上一节生成的那个即可:
mlx.launch --backend jaccl --hostfile hosts.json my_script.py集成层位于 mlx/distributed/jaccl/jaccl.cpp:其中的JACCLGroup适配器把独立库的jaccl::Group包装成 MLX 的GroupImpl,通过dtype_to_jaccl_dtype完成 MLX 数据类型到 JACCLDtype的映射,并把集合操作经 CPU command encoder 调度到 MLX 的流(stream)上执行。该文件实现了all_max、all_min、all_gather、sum_scatter、send/recv等接口。有一个限制需要留意:split(组内再分片)目前不支持,会抛出 "Group split not supported" 错误。
性能验证:allreduce_bench 用途与关键参数说明
这一节介绍仓库自带的 NCCL 风格 all-reduce 基准程序:它能扫出你集群的延迟与带宽曲线,帮你确认 RDMA 链路是否真的跑满了。
examples 目录提供四个可直接编译运行的参考程序:
- minimal_env.cpp:最小环境变量模式示例;
- minimal_cfg.cpp:最小手动配置示例;
- minimal_barrier.cpp:演示
barrier()的栅栏语义——各 rank 按100ms * rank错峰到达 barrier,退出后用一次all_sum(Int32)验证组仍然健康,并校验结果为size * (size + 1) / 2; - allreduce_bench.cpp:NCCL 风格的 all-reduce 基准,对 1K~256M 字节的消息扫描带宽与延迟。
allreduce_bench的关键参数(均可通过-h查看):
| 参数 | 含义 | 默认值 |
|---|---|---|
-w | 每个消息大小的预热次数 | 5 |
-n | 每个消息大小的计时迭代次数 | 20 |
-b | 最小消息字节数 | 1K |
-e | 最大消息字节数 | 256M |
-f | 倍增步长 | 2 |
-d | 数据类型:float32 / float16 / bfloat16 | float32 |
-c | 开启正确性检查 | 关 |
输出包含算法带宽与总线带宽(bus BW 按 ring 的2*(n-1)/n因子折算)。你也可以用mlx.launch --hostfile hosts.json ./jaccl_allreduce_bench在多机上直接启动它。
常见卡点排查:构建被跳过、ibv_devices 无输出、设备名对不上、ring 校验失败
这一节集中回答四个最可能卡住你的问题。
Q:构建时 CMake 为何直接跳过 JACCL?
两个原因,都看构建日志就能对上号。其一,独立库构建在 lib/CMakeLists.txt 中先用xcrun --sdk macosx --show-sdk-version探测 SDK 版本,低于 26.2 会打印 "JACCL requires macOS SDK >= 26.2. Skipping JACCL build." 并跳过;非 Darwin 平台同样直接跳过。其二,MLX 主库侧的 jaccl/CMakeLists.txt 要求MACOS_SDK_VERSION与CMAKE_OSX_DEPLOYMENT_TARGET均 >= 26.2 才编译 JACCL 后端,否则回退到no_jaccl.cpp占位实现——这是设计行为,不是错误。
Q:ibv_devices看不到设备怎么办?
说明 RDMA over Thunderbolt 还没启用(或恢复模式的配置没生效)。按第 2 节的步骤重来:进恢复模式,"实用工具 -> 终端"中执行rdma_ctl enable,然后重启。源码层面,rdma.h通过运行时动态加载 librdma 句柄判断可用性(is_available()),设备不可见时一切上层调用都不成立。
Q:设备文件里的设备名对不上怎么办?
连接矩阵里的每个设备名必须逐节点核对:不同 Mac 上同一根 Thunderbolt 线缆两端的设备名可能不同(例如一边是rdma_en5、另一边是rdma_en3)。建议先跑mlx.distributed_config --over thunderbolt --dot用图形确认物理连接,再用--auto-setup自动生成矩阵,避免手工抄错。
Q:选了 ring 拓扑却初始化失败?
is_valid_ring()的要求比 mesh 更严格:只有相邻节点之间可以有设备名,每个节点到左右邻居的设备数量必须一致,非相邻位置必须留空(null/空数组)。如果矩阵是 mesh 填法却设了JACCL_RING,prefer_ring路径会因配置不是合法 ring 而回退,strict模式下直接抛错。
另外提醒一个高频坑:strict=true初始化在 rank、设备文件或 coordinator 缺失时会抛出带完整提示信息的运行时错误,错误信息里会直接点名缺的是哪个变量——按提示补齐环境变量即可。
收尾:许可证与 JACCL 的命名由来
JACCL 是 MLX 的一部分,采用与 MLX 相同的许可证发布(见仓库根目录 LICENSE)。名字 JACCL(发音 Jackal)是 Jack and Angelos' Collective Communication Library 的缩写,既是对 NVIDIA NCCL 的戏仿式致敬,也纪念主导 Apple RDMA over Thunderbolt 技术开发的 Jack Beasley。
从rdma_ctl enable到mlx.launch --backend jaccl,整条链路如今都已就位:RDMA over Thunderbolt 把 Mac 集群的通信延迟压低了一个数量级,张量并行推理与分布式训练的通信热点路径第一次不再被 TCP 拖住。
【免费下载链接】mlxMLX: An array framework for Apple silicon项目地址: https://gitcode.com/GitHub_Trending/ml/mlx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考