NCCL 从建链到通信
以当前 NCCL master 分支源码为例,版本标识来自 makefiles/version.mk,当前为 NCCL 2.30.7。本文梳理 communicator 初始化时如何从拓扑搜索结果走到 transport 连接建立,以及建链完成后一次通信如何复用这些连接。
这里说的“建链”可以拆成两层:
- 图层:决定每个 channel 上,本 rank 与哪个 rank 通信。
- transport 层:决定这条边具体用 P2P、SHM 还是 NET 连接,并把 device kernel 需要的
ncclConnInfo填好。
建链完成后,通信阶段主要复用 ncclConnInfo,由 host 生成 work,由 device kernel 和必要的 proxy 线程推进数据传输。
相关阅读:reading nccl 主要记录 NCCL 2.14.3 中 net_ib、RDMA、QP、MR、FIFO 等网络传输细节。本文侧重从 communicator 建链到一次通信执行的主线,两篇可以结合阅读。
关于业务侧如何组织 communicator、rank / GPU placement 如何影响 NCCL clique、MNNVL clique 和通信顺序,可以参考:业务侧配置与 NCCL clique。
总览
阶段主线
主线可以压缩为:
1 | topology / graph |
下图按阶段和模块边界展示建链过程:
主要源码入口
对应的主要文件:
src/init.ccinitTransportsRank是 communicator 建链主线。ncclTopoCompute搜索 ring/tree/collnet/nvls graph。ncclTopoPostset后,comm->channels[c].ring/tree已经知道逻辑邻居。- 非 runtime connect 时,在初始化阶段直接调用
ncclTransportRingConnect和ncclTransportTreeConnect。
src/graph/connect.ccncclTopoPreset:根据本节点 intra 排列先生成本 rank 的topoRanks。ncclTopoPostset:聚合所有 rank 的topoRanks,生成全局 ring/tree 关系。
src/transport/generic.ccncclTransportRingConnectncclTransportTreeConnectncclTransportPatConnect
src/transport.ccncclTransportP2pConnect:标记要连谁。ncclTransportP2pSetup:执行 setup、交换 handle、connect。selectTransport:按 transport 优先级选择连接方式。
src/transport/p2p.ccsrc/transport/shm.ccsrc/transport/net.cc
与 reading nccl 的内容对照
reading nccl 可以看作本文中 NET/IB 传输部分的展开。本文描述 NCCL 的通用抽象和调用主线,reading nccl 更接近 ncclNetIb 的实现细节。
| 本文中的位置 | reading nccl 中的对应内容 | 对应关系 |
|---|---|---|
transport/net.cc 和 NET transport |
nccl net、nccl net ib |
本文说明 NCCL 如何选择 NET transport;旧文说明 NET 后端如何落到 socket 或 IB。 |
transport.setup / transport.connect |
ib connect / ib accept / send check / recv check |
本文把它抽象为 setup、handle 交换、connect;旧文展开为 QP、CQ、MR、FIFO、QP 状态转换。 |
ncclConnect |
qpInfo、fifo addr、rkey、qp 信息 |
本文中的 ncclConnect 是 transport 交换的抽象 handle;IB 实现中交换的是对端建立 RDMA 连接所需的信息。 |
ncclConnInfo |
MR、buffer、fifo、rkey 等可传输资源 | 本文关注 device/proxy 后续如何使用连接信息;旧文关注这些资源在 IB verbs 中如何注册和暴露。 |
ncclProxyStart / ncclProxyProgress |
nccl recv 流程说明、nccl 流程说明 |
本文说明 proxy 何时被投递和推进;旧文说明 proxy 最终调用 ncclNetIrecv / ncclIbIrecv 等接口。 |
sendProxyProgress / recvProxyProgress |
ncclIbIsend / ncclIbIrecv / ncclIbTest |
本文描述 NET proxy 的发送、接收、轮询边界;旧文展开 IB 后端如何 post_send、post_recv、poll_cq。 |
| step / FIFO 通信协议 | ncclIbSendFifo、ncclIbPostFifo |
本文描述 NCCL 通用的 step/FIFO 协议;旧文展示 IB 后端如何通过 FIFO 传递接收侧地址和 rkey。 |
阅读时可以先用本文建立两条主线:
1 | 建链:topology -> transport -> ncclConnInfo |
再回到 reading nccl 看 IB verbs 细节:
1 | ncclNet -> ncclNetIb |
阅读路径
如果直接从 initTransportsRank 沿调用链展开,会同时涉及 topology、bootstrap、proxy、CUDA IPC、SHM、NET、GDR、PXN 等多个子系统。
阅读时可以先按问题边界分层,再进入完整调用链。
第一层:先看谁连谁
这一层只看 graph 层,不关心 P2P、SHM、NET。
要回答的问题是:
1 | 每个 channel 上,本 rank 的上游和下游分别是谁? |
关注函数:
1 | src/init.cc |
这一层的结论:
1 | topo / graph 阶段只是在填逻辑邻居: |
也就是说,这一层还没有真的建立连接,只是把通信图画出来。
第二层:再看逻辑邻居如何变成待连接任务
这一层看 generic.cc 和 transport.cc 的第一段。
要回答的问题是:
1 | ring.prev / ring.next 这些逻辑邻居,怎么告诉 transport 层去连接? |
关注函数:
1 | src/transport/generic.cc |
这一层的阶段边界:
1 | ncclTransportP2pConnect 不建链。 |
它只是把待连接的 channel 写进 bitmask:
1 | comm->connectRecv[peer] |
因此它可以视为待连接记录的生成步骤。
第三层:看连接建立函数
这一层进入连接建立逻辑。
要回答的问题是:
1 | 待连接 bitmask 如何变成 device kernel 能用的 ncclConnInfo? |
关注函数:
1 | src/transport.cc |
读 ncclTransportP2pSetup 时,可以先将分析范围限定为:
1 | rank A 和 rank B |
然后按这个顺序看:
1 | 读 connectSend / connectRecv bitmask |
这里最重要的是区分两个结构:
1 | ncclConnect: |
这两个结构对应不同阶段:ncclConnect 属于 bootstrap 交换阶段,ncclConnInfo 属于 device 侧执行阶段。
第四层:最后再分 transport 看细节
transport 细节可以作为最后一层阅读,第一阶段只关注 setup 和 connect。
阅读顺序:
1 | src/transport/p2p.cc |
可以先读 P2P,再读 SHM 和 NET:
1 | P2P: |
proxyProgress 主要用于运行时数据传输的进度推进,可以不纳入初始化建链的第一阶段阅读。
最小闭环
最小阅读闭环可以只覆盖 ring:
1 | connectRings |
在这个闭环中暂不展开 tree、CollNet、NVLS、runtime connect。ring 路径建立后,其它路径主要差异为:
1 | 邻居集合不同 |
对应关系如下:
1 | graph 层:生成 channel 的逻辑通信关系 |
建链流程
建链流程可以按阶段阅读:先生成 channel 的逻辑邻居,再把逻辑邻居登记成待连接任务,随后选择 transport、交换 handle、填充 ncclConnInfo。
生成逻辑邻居
初始化时,NCCL 先探测机器拓扑,计算 GPU、NIC、CPU 之间路径,然后搜索不同 collective algorithm 对应的 graph。
典型流程在 initTransportsRank 中:
1 | ncclTopoGetSystem |
此时 graph 中包含拓扑搜索结果。之后进入:
1 | ncclTopoPreset |
ncclTopoPreset 先基于本节点 intra 排列生成本 rank 视角下的信息,例如:
topoRanks->ringRecv[c]topoRanks->ringSend[c]topoRanks->ringPrev[c]topoRanks->ringNext[c]topoRanks->treeToParent[c]topoRanks->treeToChild0[c]topoRanks->treeToChild1[c]
然后所有 rank 做一次 bootstrapAllGather,每个 rank 都拿到全局的 topoRanks。
ncclTopoPostset 会调用:
1 | connectRings |
这里会把 channel 中的逻辑邻居写好:
1 | channel->ring.prev |
注意,这一步只回答“谁和谁连”,还没有真的建立 CUDA IPC、SHM 或 NET 连接。
记录待连接 bitmask
以 ring 为例:
1 | ncclTransportP2pConnect(comm, c, 1, &channel->ring.prev, 1, &channel->ring.next, 0) |
tree 类似,只是 recv/send peer 来自 tree.down[] 和 tree.up。
ncclTransportP2pConnect 不创建 transport 资源,也不交换 handle。它根据 channel id 在两个 bitmask 上记录待连接关系:
1 | comm->connectRecv[peer] |= 1ULL << channelId |
因此,ncclTransportP2pConnect 的输出是待连接 bitmask,而不是已建立的连接。
连接建立发生在随后调用的:
1 | ncclTransportP2pSetup(comm, graph, connIndex) |
建立连接并写入 ncclConnInfo
ncclTransportP2pSetup 可以拆成一个循环:
更具体一点:
- 按 rank 距离枚举 peer。
- 读取
connectRecv[recvPeer]和connectSend[sendPeer],知道哪些 channel 需要连接。 - 对每条待连接边调用
selectTransport。 selectTransport按顺序尝试P2P、SHM、NET、COLLNET。- 命中某个 transport 后调用它的
setup。 setup创建本地资源,并把远端需要的信息写到ncclConnect。- 通过 bootstrap 把
ncclConnect交换给对端。 - 调用对应 transport 的
connect,用对端 handle 去 import/map 资源。 - 填好
connector->conn,也就是ncclConnInfo。 - 把
ncclConnInfo拷到 device side 的devPeers。 - 最后做一次 bootstrap 同步,清理
connectSend/connectRecvbitmask。
ncclConnInfo 是 device kernel 使用的信息,核心字段包括:
buffs[]headtailconnFifostepSizeflags- 部分 NET/GDR 场景下的 net device handle 和 memory handle
选择 transport
transport 抽象在 src/include/transport.h:
1 | struct ncclTransportComm { |
全局 transport 顺序在 src/transport.cc:
1 | struct ncclTransport* ncclTransports[NTRANSPORTS + 1] = { |
selectTransport 会按这个顺序调用 canConnect,第一个返回可用的 transport 就会被选中。
因此普通点对点边的选择顺序为:
1 | 优先 P2P |
P2P transport
src/transport/p2p.cc 处理 GPU 之间可以直接或间接访问的场景。
setup 阶段:
- 判断是否可以 CUDA P2P。
- 判断使用 read 还是 write。
- 判断是否需要 intermediate rank。
- 同进程可能用 direct pointer。
- 跨进程可能用 CUDA IPC 或 cuMem handle。
- 通过本地 proxy 分配或导出可共享 buffer。
- 把
p2pConnectInfo写入ncclConnect。
connect 阶段:
- import/map 对端 buffer。
- 设置本端
conn.buffs[]。 - 设置
head/tail。 - 设置
ptrExchange/redOpArgExchange等辅助字段。 - 设置
proxyProgress。
P2P 连接不一定是 A 与 B 直接连接。p2pGetInfo 可能选择 intermediate rank,形成 indirect P2P path。
SHM transport
src/transport/shm.cc 处理同 host 且共享 /dev/shm 的场景。
shmCanConnect 主要检查:
NCCL_SHM_DISABLE是否关闭 SHM。- topo 是否要求走 NET。
- 两个 rank 是否在同 host。
- 两个 rank 是否有共同的 shm device。
setup 阶段通过 SHM proxy 创建共享内存,把描述符写入 shmConnectInfo。
connect 阶段 import 对端共享内存,然后设置:
1 | conn.buffs[] |
NET transport
src/transport/net.cc 是跨节点通信的主要 transport,也可能在同节点但拓扑要求走 NET 时使用。
setup 阶段会:
- 通过
ncclTopoGetNetDev选择 NIC。 - 判断是否启用 GDR。
- 判断是否需要 PXN proxy。
- 通过
ncclProxyConnect连到对应 proxy rank。 - 对 proxy 发送
ncclProxyMsgSetup。 - 把 proxy rank 和 GDR 信息放进
ncclConnect。
NET connect 阶段可能异步进行:
1 | ncclProxyCallAsync(ncclProxyMsgConnect) |
如果还没完成,会返回 ncclInProgress。外层 ncclTransportP2pSetup 因此会循环 polling,直到所有 channel 都完成连接。
NET connect 完成后,会 map host/device/shared memory,并设置:
1 | conn.head |
runtime connect
另一个分支是 runtime connect:
1 | comm->runtimeConn = comm->cuMemSupport && ncclParamRuntimeConnect(); |
如果 runtime connect 开启,初始化阶段不会把 ring/tree 都预连接好,而是在 group/enqueue 阶段按需连接。
相关入口:
ncclP2PPreconnectFuncncclCollPreconnect
这也是为什么有时候读 initTransportsRank 会发现某些连接没有在 init 阶段直接发生。
通信流程
建链完成后,NCCL 不会在每次通信时重新选择 transport。建链阶段已经把每个 channel、peer、connIndex 对应的连接信息写入 connector->conn,并同步到 device side 的 devPeers。通信阶段复用这些连接信息,主要完成三件事:
1 | host 侧生成本次通信的 work |
下图展示一次通信对建链产物的使用方式:
Host 侧生成 work
一次 collective 或 P2P API 调用先被放入 communicator 的 planner。group launch 阶段会调用:
1 | ncclLaunchPrepare |
ncclLaunchPrepare 的输出是 ncclKernelPlan。plan 中包含:
- 本次通信使用哪些 channel。
- 每个 channel 上的 device work。
- kernel 参数和 work buffer。
- 需要 proxy 推进时的 proxy op。
随后 launch 阶段会执行:
1 | uploadWork |
在这个阶段,host 侧主要负责“把本次通信描述清楚”。数据搬运发生在 device kernel 和 proxy progress 中。
Device kernel 消费连接信息
device kernel 拿到 work 后,会根据 collective 类型、algorithm、protocol 进入对应实现。
以 ring allreduce 为例,核心路径在 src/device/all_reduce.h 的 runRing。它使用建链阶段写好的:
1 | ncclShmem.channel.ring.prev |
并构造 Primitives。ring allreduce 的执行可以分成两个阶段:
1 | reduce-scatter: |
这些 primitive 最终会进入 src/device/prims_simple.h 中的 genericOp。genericOp 的基本循环是:
1 | waitPeer |
对于用户显式的 ncclSend / ncclRecv,device 侧路径在 src/device/sendrecv.h。send 侧循环调用 directSend,recv 侧循环调用 directRecv,同样通过 Primitives 使用建链阶段准备好的连接。
step 和 FIFO
ncclConnInfo 中的 buffs、head、tail、connFifo、stepSize 是通信阶段的关键状态。
可以把它理解为一个带 credit 的环形 FIFO:
1 | NCCL_STEPS 个 slot 循环使用 |
因此,通信阶段不是简单地调用一次 memcpy。它是按照 slice/chunk 粒度,在 channel 上反复执行:
1 | 等待可用 step |
Proxy 推进 NET 等传输
如果当前连接是 GPU 可以直接访问的 P2P 路径,device primitive 可以直接读写对端可见的 buffer。
如果当前连接需要 CPU proxy,例如 NET transport,device kernel 和 proxy thread 会共同推进传输:
1 | device kernel: |
对应代码路径:
1 | src/proxy.cc |
sendProxyProgress 主要做三件事:
1 | 给 GPU 投递可用 buffer |
recvProxyProgress 主要做三件事:
1 | 把接收 buffer 投递给 ncclNet->irecv |
建链和通信的边界
二者的边界可以概括为:
1 | 建链阶段: |
小结
NCCL 的建链和通信可以按两个阶段理解。
建链阶段可以概括为:
1 | topology 决定 channel 的逻辑邻居, |
通信阶段可以概括为:
1 | host 把 API 调用切成 ncclKernelPlan, |
源码结构对应关系:
graph/connect.cc负责生成 channel 的逻辑通信关系。transport.cc回答“这些边什么时候建、怎么调度建”。transport/p2p.cc、transport/shm.cc、transport/net.cc回答“具体 transport 如何创建资源、交换 handle、填 device 可用的连接信息”。enqueue.cc和group.cc负责把一次 API 调用变成 kernel plan。device/all_reduce.h、device/sendrecv.h和device/prims_simple.h负责 device 侧 copy、reduce 和 step/FIFO 协议。proxy.cc和transport/net.cc负责需要 proxy 参与的运行时传输。
按上述边界划分后,NCCL 可以拆成两条相互衔接的主线:建链阶段准备连接,通信阶段复用连接。