NVIDIA 集合通信库(NCCL)是一个实现了多 GPU collective communication 的集合通信库。NCCL 在设计上是以 GPU Driven 为核心的,而非传统的 CPU Driven 代码,以获取更小的传输延迟和更大的传输带宽。在源码中,NCCL 有许多实现上的细节,本文将分享 NCCL 源码中有关网络传输以及 GDR 部分的相关内容。
GPU Driven 模式 在讲解网络以及 RDMA 部分的实现之前,首先需要了解 GPU Driven 这一核心的思路。由于 CPU 与 GPU 之间存在着通信的延迟,如果使用 CPU 进行数据链路的控制,在数据包较小时,CPU - GPU 中间的通信延迟就会显得不可忽略。以一个经典的 CPU 模型来展示:
GPU Driven 就是绕过 CPU 线程的控制,直接使用 GPU 线程去做数据链路的控制。在具体实现上,CPU Driven 其实就是传统的事件\阻塞模型,当 GPU 数据准备完毕后,通知 CPU 去做一些控制工作;而 GPU Driven 则是通过轮询某个标识位,让控制工作去等待数据准备完毕。这样就能够避免线程调度所带来的不可避免的延迟。听起来,GPU Driven 确实延迟会很低,但是不可避免地其 CPU 占用率会较高。但是当数据的传输工作较为密集时,CPU 占用率的提升就显得可以接收,这正是 NCCL 的应用场景。
作为集合通信库,NCCL 完成每一个集合通信操作,例如 allreduce,都是以小块的形式去发送数据,但是当一个集合通信操作被下发到 NCCL 中时,未来所要完成的所有操作都是确定的了。因此,每一个节点所需要接收和发送的数据量都是已经确定好的,这样就能够实现控制事件提前下发并等待数据准备完毕。
网络通信 代理线程 nccl 中所有网络 IO 都是由代理线程完成的,代理线程会以轮询的模式处理已经下发的网络请求。代理线程接收控制消息的方式与接收下发网络请求的方式所有不同。控制消息是通过 socket 接收的,是一个典型的事件通知模式。
网络请求的下发则是通过队列的模式来完成接收的,proxy 会为每一个 rank 创建一个 op 池,网络请求会被下发到对应的 op pool 中。当有可以处理的网络请求时,代理线程将会优先处理这些请求。当已收割的网络请求处理完毕时,代理线程会尝试从 op pool 中收割全部下发的请求,如果 op pool 中也没有请求,则会进入阻塞状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void * ncclProxyProgress (void *proxyState_) { ... int lastIdle = 0 ; int proxyOpAppendCounter = 0 ; struct ncclProxyArgs profArgs; while ((state->stop == false || (state->stop == true && state->active)) && *proxyState->abortFlag == 0 ) { int idle = 1 ; ncclResult_t ret = progressOps (proxyState, state, state->active, &idle); if (idle || (++proxyOpAppendCounter == ncclParamProgressAppendOpFreq ())) { int added = 0 ; proxyOpAppendCounter = 0 ; if (state->stop == false ) ret = ncclProxyGetPostedOps (proxyState, &added); if (added == 0 ) { sched_yield (); } } lastIdle = idle; } return NULL ; }
网络收发中的代理线程在总体上是轮询 + 事件通知的模式。当没有下发的任务时,线程会进入阻塞。这里为了避免竞争,NCCL 也做出了优化。
网络事件的下发 NCCL 中的集合操作会经过一个很长的调用链去转换为网络收发操作,这里先忽略其中的转换。在使用以下参数运行all_reduce_perf
时,我们观察网络不同的事件下发:
1 ./all_reduce_perf -b 4M -e 4M -f 2 -g 1 -t 1 -n 20
这个 allreduce 操作中,每个节点所需要发送的数据量为 4M,数据发送的类型为 float32,因此一共需要发送 1M 个数据点。通过NCCL 中预先埋下的日志分析,我们可以看到,allreduce 最终被分解为两次网络发送和两次网络接收请求。其中,单次网络发送数据的数据量为 2M。allreduce 操作虽然需要经过很多次网络传输,但是 NCCL 只是将其分解为了 reduce-scatter 和 all-gather 这两个操作,并将这两个操作需要的总发送数据量下发到网络发送请求中。下发的网络事件不会立刻进行数据传输,而是由代理线程轮询并等待数据准备完毕。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static ncclResult_t sendProxyProgress (struct ncclProxyState* proxyState, struct ncclProxyArgs* args) { if (sub->posted < sub->nsteps && sub->posted < sub->done + maxDepth) { ... } if (sub->transmitted < sub->posted && sub->transmitted < sub->done + NCCL_STEPS) { if (sizesFifo[buffSlot] != -1 && ((*recvTail > (sub->base+sub->transmitted)) || p == NCCL_PROTO_LL)){ } } if (sub->done < sub->transmitted) { } }
这种网络事件的设计模式正是 NCCL 中 GPU Driven 的一个具体体现。当 GPU Kernel 函数完成数据的准备后,就可以通过修改队列尾的方式激活已经提前下发好的网络请求了。
RDMA 通信 与 rsocket 等通用的网络库不同,NCCL 中的 RDMA 通信也针对应用场景采用了不同的设计。使用 RDMA 通信,一个原则性要求是发送方在 post_send 之前,必须要确保接收方已经使用 post_receive_wr 下发了一个接收操作,这样才能够保证消息的正确传递。正常情况下,RDMA 网络库都会在初始化时下发一部分接收操作,并且采用计时的方式进行流量控制。而 NCCL 中则采取了不同的模式——接收方通知发送方进行发送,这是同样也是因为当一个操作被下发之前,所有的网络 IO 次数以及数量都是确定好的。
缓冲区的分配 控制缓冲区 控制缓冲区是用来调控收发请求的,一次操作对应一个槽。这部分是注册在 cudaMallocHost 中的锁页内存中,因为 GPU 和 CPU 都需要去访问这一块缓冲区。
收发缓冲区 这部分缓冲区会根据是否支持 gdr 选择分配在内存或者是显存上。如果分配到显存上面,会使用 gdr ,收发直接到显存上。gdr 优先使用 dmabuf 方式,否则使用 peermem 方式。
RDMA 通信优化
按组进行接收 comp channel 通知
inline 标识位减少延迟
减少接收 comp channel 通知次数