Mac 集群通信提速一个数量级:MLX JACCL(Thunderbolt 5 RDMA)从零到上手全解
2026/9/13 3:21:19 网站建设 项目流程

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_tbfloat16_tcomplex64_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 恢复模式里手动开启一次:

  1. 将 Mac 启动进入恢复模式;
  2. 在"实用工具 -> 终端"中打开终端;
  3. 执行rdma_ctl enable
  4. 重启系统。

重启后在每台节点上验证 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

构建产物会安装到libinclude(头文件进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_RANKMLX_RANK本进程的 rank(从 0 开始的整数)
JACCL_IBV_DEVICESMLX_IBV_DEVICES描述设备连接关系的 JSON 文件路径
JACCL_COORDINATORMLX_JACCL_COORDINATOR协调者(rank 0 监听端)的 IP:port
JACCL_RINGMLX_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_RANKJACCL_IBV_DEVICESJACCL_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_devicesmlx.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_sumall_maxall_min(输入输出大小相同,归约按 dtype 语义在组内进行),以及all_gather(输出大小为size() * n_bytes);
  • 点对点操作sendrecv
  • 同步原语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_maxall_minall_gathersum_scattersend/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 / bfloat16float32
-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_VERSIONCMAKE_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_RINGprefer_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 enablemlx.launch --backend jaccl,整条链路如今都已就位:RDMA over Thunderbolt 把 Mac 集群的通信延迟压低了一个数量级,张量并行推理与分布式训练的通信热点路径第一次不再被 TCP 拖住。

【免费下载链接】mlxMLX: An array framework for Apple silicon项目地址: https://gitcode.com/GitHub_Trending/ml/mlx

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询