MySQL 误删数据后除了跑路,还能怎么办?

级靥档籽NCCL路径计算

NCCL 已经建立了本节点上所有 PCIe 设备的连接图,但是还不知道该如何从某个 PCIe 设备到达另外一个 PCIe 设备。所以就需要计算 GPU, PCI, NVS, CPU, NIC, NET 它们之间的最优互通路径。例如需要计算 GPU 与 NIC 之间的最优路径,拓扑如果足够复杂,GPU 与 NIC 之间的互通路径可能有多条,NCCL 会从路径跳数,或者路径带宽去衡量,从而计算出一条最优路径。通过计算任意设备之间的最优互通路径,为后续 NCCL 进行多通道计算打基础。

NCCL 使用 ncclTopoComputePaths 计算多个 PCIe 设备间的最优可达路径。

ncclCommInitRank

ncclCommInitRankDev

ncclCommInitRankFunc

initTransportsRank

ncclTopoComputePaths(struct ncclTopoSystem* system, struct ncclComm* comm)

ncclTopoComputePaths 的前半部分实现如下:

从指定的 CPU 节点出发,计算其它任意 PCIe(GPU, PCI, NVS, NET)设备达到当前 CPU 的最优路径

从指定的 GPU 节点出发,计算其它任意 PCIe(CPU, PCI, NVS, NET)设备达到当前 GPU 的最优路径

从指定的 NET 节点出发,计算其它任意 PCIe(GPU, PCI, NVS, CPU)设备达到当前 NET 的最优路径

从指定的 NVS 节点出发,计算其它任意 PCIe(GPU, PCI, NET, CPU)设备达到当前 NVS 的最优路径

ncclResult_t ncclTopoComputePaths(struct ncclTopoSystem* system, struct ncclComm* comm) {

// Precompute paths between GPUs/NICs.

// Remove everything in case we're re-computing

ncclTopoRemovePaths(system);

// Set direct paths to CPUs. We need them in many cases.

for (int c=0; cnodes[CPU].count; c++) {

NCCLCHECK(ncclTopoSetPaths(system->nodes[CPU].nodes+c, system));

}

// Set direct paths to GPUs.

for (int g=0; gnodes[GPU].count; g++) {

NCCLCHECK(ncclTopoSetPaths(system->nodes[GPU].nodes+g, system));

}

// Set direct paths to NICs.

for (int n=0; nnodes[NET].count; n++) {

NCCLCHECK(ncclTopoSetPaths(system->nodes[NET].nodes+n, system));

}

// Set direct paths to NVSwitches.

for (int n=0; nnodes[NVS].count; n++) {

NCCLCHECK(ncclTopoSetPaths(system->nodes[NVS].nodes+n, system));

}

......

}

注意,是从指定设备出发,计算其它任意 PCIe 设备达到当前指定设备的最优路径。意味着在路径探索过程中,会在其它任意中间节点添加到达当前指定的,出发节点的路径信息。

比如有 PCIe 如下拓扑:

从 GPU0 出发时,在 PCI_SW1,PCI_SW2,GPU1 中都会添加自身到达 GPU0 的最优路径信息

从 GPU1 出发时,在 PCI_SW2,PCI_SW1,GPU0 中都会添加自身到达 GPU1 的最优路径信息

ncclTopoSetPaths 是实现路径计算的关键函数,通过 BFS 逐层搜索其它 PCIe 设备实现。

static ncclResult_t ncclTopoSetPaths(struct ncclTopoNode* baseNode, struct ncclTopoSystem* system) {

......

nodeList.count = 1;

nodeList.list[0] = baseNode;

// 将出发节点 baseNode 初始化

NCCLCHECK(getPath(system, baseNode, baseNode->type, baseNode->id, &basePath));

basePath->count = 0;

basePath->bw = LOC_BW;

basePath->type = PATH_LOC;

while (nodeList.count) {

nextNodeList.count = 0;

for (int n=0; n

struct ncclTopoNode* node = nodeList.list[n];

NCCLCHECK(getPath(system, node, baseNode->type, baseNode->id, &path));

for (int l=0; lnlinks; l++) {

struct ncclTopoLink* link = node->links+l;

struct ncclTopoNode* remNode = link->remNode;

......

NCCLCHECK(getPath(system, remNode, baseNode->type, baseNode->id, &remPath));

float bw = std::min(path->bw, link->bw);

......

// 如果remPath->bw == 0,直接进入

// 如果remPath->bw != 0,比较跳数。当前路径跳数:path->count,小于 remPath->count 时,再比较带宽

// 如果最后当前路径带宽高于之前计算的 remPath->bw,尝试更新

if ((remPath->bw == 0 || remPath->count > path->count) && remPath->bw < bw) {

// Find reverse link

for (int l=0; lnlinks; l++) {

if (remNode->links[l].remNode == node && remNode->links[l].type == link->type) {

// 将 remPath->list[0] 的第一个连接指向当前 node

remPath->list[0] = remNode->links+l;

break;

}

}

......

// 将剩余的,在前面已经得到的路径拷贝到 remPath->list

for (int i=0; icount; i++) remPath->list[i+1] = path->list[i];

// 赋值路径跳数和带宽

remPath->count = path->count + 1;

remPath->bw = bw;

......

// 赋值路径类型

if (link->type == LINK_PCI && (node->type == CPU || link->remNode->type == CPU)) type = PATH_PHB;

......

remPath->type = std::max(path->type, type);

// 寻找 nextNodeList 中是否已存在 remNode

// 如果存在,则不会将 remNode 添加至 nextNodeList

// 如果不存在,则将 remNode 添加至 nextNodeList

for (i=0; i

if (i == nextNodeList.count) nextNodeList.list[nextNodeList.count++] = remNode;

}

}

}

// 将 nextNodeList 拷贝至 nodeList ,使用 nodeList 重新遍历

memcpy(&nodeList, &nextNodeList, sizeof(nodeList));

}

......

}

按照如下拓扑,算法可通过拓扑 demo 简单推导为:

初始化basenode

basenode 赋值为 GPU0,因此有:

basePath->count = 0

basePath->bw = LOC_BW

basePath->type = PATH_LOC

节点 GPU0 到 GPU0 的路径为 LOCAL

节点 到 GPU0 的路径 路由跳数

GPU0 PATH_LOC 0

用于循环的 nodeList 只有 GPU0

nodeList = { GPU0 }

遍历 GPU0

GPU0 有两个连接,分别是:PCI_SW1 和 GPU1

GPU0 --(PCI)--> PCI_SW1

GPU0 --(NVL)--> GPU1

baseNode 初始化

用于循环的节点链表为: nodeList =

节点 到 GPU0 的路径 路径跳数

GPU0 PATH_LOC 0

GPU0 有两个连接,分别是:PCI_SW1 和 GPU1

PCI_SW1 的 remPath->list[0] 将被赋值为 GPU0

GPU1 的 remPath->list[0] 也将被赋值为 GPU0

最后用于下次循环的节点链表为: nodeList =

节点 到 GPU0 的路径 路径跳数

GPU0 PATH_LOC 0

PCI_SW1 PCI_SW1 -> GPU0 1

GPU1 GPU1 -> GPU0 1

开始下次大循环:while (nodeList = { PCI_SW1, GPU1})

先遍历节点 PCI_SW1,那么它的 remNode 为 PCI_SW2,因此 PCI_SW2 的 remPath->list[0] 将被赋值为 PCI_SW1, remPath->list[1] 将被赋值为 GPU0,此时 remPath->count = 2

节点 到 GPU0 的路径 路径跳数

GPU0 PATH_LOC 0

PCI_SW1 PCI_SW1 -> GPU0 1

GPU1 GPU1 -> GPU0 1

PCI_SW2 PCI_SW2 -> PCI_SW1 -> GPU0 2

再遍历节点 GPU1,此时 GPU1 的 remNode 同样为 PCI_SW2,但是:

remPath->bw != 0,因为遍历 PCI_SW1 时,PCI_SW2 已经被赋值

此时 remPath->count = 2,path->count = 1(GPU1 的 path->count 目前还是 1),所以 (remPath->bw == 0 || remPath->count > path->count) 最终为 true

因此再看 remPath->bw < bw 是否满足。当前 remPath->bw 也因为遍历 PCI_SW1 时被赋值,但是如果 PCI_SW2 -> PCI_SW1 -> GPU0 的路径带宽确实比 PCI_SW2 -> GPU1 -> GPU0 要小,那么,NCCL 会将 PCI_SW2 的路径信息更新为最优路径:PCI_SW2->GPU1->GPU0。因为 PCI_SW2 已经在遍历节点 PCI_SW1 时被缓存到 nodeList,所以遍历 PCI_SW2 时不再添加。因此,nodeList 只有 PCI_SW2

节点 到 GPU0 的路径 路径跳数

GPU0 PATH_LOC 0

PCI_SW1 PCI_SW1 -> GPU0 1

GPU1 GPU1 -> GPU0 1

PCI_SW2 PCI_SW2 -> GPU1 -> GPU0 2

开始下次大循环:while (nodeList = { PCI_SW2 }),

PCI_SW2 有两个连接,分别是:PCI_SW1 和 GPU1

遍历 PCI_SW1 和 GPU1 时,都因为 remPath->count(1) > path->count(2), 不满足(比如 PCI_SW1->count = 1,PCI_SW2->count = 2),导致 nodeList = { NULL }, 因此整个算法流程结束。

ncclTopoComputePaths 的前半部分用于 PCIe 设备之间的最优物理路径计算,获得的是:在硬件能力允许的条件下,怎么走最快的物理路径。但是物理路径可达不代表一些能力实际可用,比如物理 GPU 之间的 P2P PCIe Switch 不支持,或者未开启 PCIe P2P,或者 rank 运行在容器内,共享内存 SHM 方式可能是禁止的。因此,在 ncclTopoComputePaths 后半部分,将标记这些信息,为下一轮进行 PCIe 设备间路径重新计算做铺垫。具体实现可总结为:

检查任意 GPU 之间是否支持 P2P 和 SHM,如果都不支持,那么这两个 GPU 之间的路径类型标记为 PATH_NET

同一物理节点内部,支持通过 GPU 中继,允许比如 GPU0 通过 GPU1 访问 NIC0,但是只允许数据发送方向,即 GPU0 向外发送数据通过 GPU1 中继

如果 GPU 和 NIC 之间的 GDR(GPU Direct RDMA) 模式不可用,则在它们之间添加中继 CPU 节点,强制数据从 CPU 经过

提前计算与 NIC 处于同一 CPU 域的 GPU,并缓存

ncclResult_t ncclTopoComputePaths(struct ncclTopoSystem* system, struct ncclComm* comm) {

......

// Update path for GPUs when we don't want to / can't use GPU Direct P2P

for (int g=0; gnodes[GPU].count; g++) {

......

// Remove GPUs we can't (or don't want to) communicate with through P2P or SHM

struct ncclPeerInfo* dstInfo = comm->peerInfo+system->nodes[GPU].nodes[g].gpu.rank;

for (int p=0; pnodes[GPU].count; p++) {

......

int p2p;

NCCLCHECK(ncclTransports[TRANSPORT_P2P]->canConnect(&p2p, comm, NULL, srcInfo, dstInfo));

if (p2p == 0) {

NCCLCHECK(ncclTransports[TRANSPORT_SHM]->canConnect(&shm, comm, NULL, srcInfo, dstInfo));

if (shm == 0) {

// Mark this peer as inaccessible. We'll trim it later.

system->nodes[GPU].nodes[p].paths[GPU][g].type = PATH_NET;

}

}

}

}

......

// Update paths for NICs (no GPU Direct, PXN, ...)

for (int n=0; nnodes[NET].count; n++) {

struct ncclTopoNode* netNode = system->nodes[NET].nodes+n;

for (int g=0; gnodes[GPU].count; g++) {

// Check whether we can access the NIC through another NVLink-connected GPU (PXN)

struct ncclTopoNode* gpu = system->nodes[GPU].nodes+g;

if (ncclPxnDisable(comm) != 1) {

......

if (localGpuIndex != g && localGpuIndex != -1) {

// PXN = PCI + NVLink.

......

NCCLCHECK(addInterStep(system, GPU, localGpuIndex, GPU, g, NET, n));

}

}

if (gpu->paths[NET][n].type < PATH_PHB) {

// Update path when we dont want to / can't use GPU Direct RDMA.

......

if (gdr == 0) {

// We cannot use GPU Direct RDMA, divert all traffic through the CPU local to the GPU

int localCpu;

NCCLCHECK(ncclGetLocalCpu(system, g, &localCpu));

NCCLCHECK(addInterStep(system, CPU, localCpu, NET, n, GPU, g));

NCCLCHECK(addInterStep(system, CPU, localCpu, GPU, g, NET, n));

}

}

}

}

// Pre-compute NET local gpus to accelerate search

for (int n=0; nnodes[NET].count; n++) {

......

NCCLCHECK(ncclTopoGetLocalGpu(system, net->id, &net->net.localGpu));

}

......

}

NCCL 经过路径计算,如图中每个 PCIe 设备有如下路径信息:

PCIe设备裁剪和路径重新计算

ncclCommInitRank

ncclCommInitRankDev

ncclCommInitRankFunc

initTransportsRank

// 路径初始计算

ncclTopoComputePaths(struct ncclTopoSystem* system, struct ncclComm* comm)

// 设备裁剪

ncclTopoTrimSystem(struct ncclTopoSystem* system, struct ncclComm* comm)

// 路径再次计算

ncclTopoComputePaths(struct ncclTopoSystem* system, struct ncclComm* comm)

GPU 之间的裁剪逻辑可总结为:

判断 GPU 之间的路径类型是否小于类型 PATH_NET,如果小于则在同一个 domain,否则在不同 domain

通过 myDomain 记录本 rank 所在 domain

将与当前 rank 不在同一个 domain 的 GPU 节点都删掉

删除该节点的所有 paths

从其它节点的 links 中删除与该节点的连接 link

ncclResult_t ncclTopoTrimSystem(struct ncclTopoSystem* system, struct ncclComm* comm) {

int *domains;

int64_t *ids = NULL;

int myDomain = 0;

int ngpus = system->nodes[GPU].count;

......

for (int g=0; gnodes[GPU].count; g++) {

struct ncclTopoNode* gpu = system->nodes[GPU].nodes+g;

domains[g] = g;

ids[g] = gpu->id;

for (int p=0; p

if (gpu->paths[GPU][p].type < PATH_NET) {

domains[g] = std::min(domains[g], domains[p]);

}

}

if (gpu->gpu.rank == comm->rank) myDomain = domains[g];

}

for (int i=0; i

if (domains[i] == myDomain) continue;

......

for (g=0; gnodes[GPU].count /* This one varies over the loops */; g++) {

gpu = system->nodes[GPU].nodes+g;

if (gpu->id == ids[i]) break; else gpu=NULL;

}

......

NCCLCHECKGOTO(ncclTopoRemoveNode(system, GPU, g), ret, fail);

}

......

}

ncclResult_t ncclTopoRemoveNode(struct ncclTopoSystem* system, int type, int index) {

......

for (int t=0; t

free(delNode->paths[t]);

......

while (lnlinks && node->links[l].remNode == delNode) {

memmove(...);

node->nlinks--;

}

}

NCCL 基于裁剪后的拓扑,再次重新计算所有 PCIe 设备的可达路径。从而得到一个运行环境实际可用的拓扑路径,为后续通道计算做铺垫。以上逻辑对应日志如下:

设备列举

ncclTopoPrint:316 NCCL INFO === System : maxBw 6.2 totalBw 24.0 ===

ncclTopoPrintRec:289 NCCL INFO CPU/0-ffffffffffffff (1/1/4)

ncclTopoPrintRec:289 NCCL INFO + PCI[24.0] - GPU/0-80 (0)

ncclTopoPrintRec:289 NCCL INFO + PCI[24.0] - GPU/0-90 (1)

ncclTopoPrintRec:289 NCCL INFO + PCI[12.0] - NIC/0-0

ncclTopoPrintRec:308 NCCL INFO + NET[6.2] - NET/0-2 (0/7f7afb0003797334/1/6.250000)

路径展示

比如:从 GPU/0-80 经过 --PCI(24)->CPU/0-ffffffffffffff,可到达 PCI(24)->GPU/0-90

ncclTopoPrint:319 NCCL INFO ==========================================

printNodePaths:119 NCCL INFO Paths from GPU/0-80 :

printNodePaths:136 NCCL INFO (5000.000000)

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff--PCI(24)->GPU/0-90 (24.000000)

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff (24.000000)

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff--PCI(12)->NIC/0-0--NET(6.25)->NET/0-2 (6.250000)

printNodePaths:119 NCCL INFO Paths from GPU/0-90 :

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff--PCI(24)->GPU/0-80 (24.000000)

printNodePaths:136 NCCL INFO (5000.000000)

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff (24.000000)

printNodePaths:136 NCCL INFO --PCI(24)->CPU/0-ffffffffffffff--PCI(12)->NIC/0-0--NET(6.25)->NET/0-2 (6.250000)

printNodePaths:119 NCCL INFO Paths from NET/0-2 :

printNodePaths:136 NCCL INFO --NET(6.25)->NIC/0-0--PCI(12)->CPU/0-ffffffffffffff--PCI(24)->GPU/0-80 (6.250000)

printNodePaths:136 NCCL INFO --NET(6.25)->NIC/0-0--PCI(12)->CPU/0-ffffffffffffff--PCI(24)->GPU/0-90 (6.250000)

printNodePaths:136 NCCL INFO --NET(6.25)->NIC/0-0--PCI(12)->CPU/0-ffffffffffffff (6.250000)

printNodePaths:136 NCCL INFO (5000.000000)

NCCL通信算法

NCCL 使用 Ring,Tree 等算法在多 GPU 之间进行数据传递。不同的通信算法有对应的适用场景。

NCCL Ring拓扑计算

NCCL Ring 结构顾名思义是将多个 GPU 在逻辑上组织成一个环,从而利用环形拓扑进行数据通信,环形拓扑分为两种场景:

单物理节点上,只是将本节点内部的多个 GPU 组织为环,即形成拓扑结构:GPU a -> GPU b -> .. -> GPU x -> GPU a

多机通信场景,则需要在每个节点构造一个从 NIC 到 GPU-X0,再从 GPU-Xn 到 NIC 的拓扑结构,例如:NET n -> GPU a -> GPU b -> .. -> GPU x -> NET n (or m if crossNic)

NCCL 有 channel 的概念,这里的一个 channel 对应一个 ring 环。

NCCL 会尝试 ring 多通道计算,多 channel 的计算逻辑非常复杂。多 channel 的核心作用是可以使得 GPU 利用多 channel 进行数据的并行传输。好比将单车道拓宽为双车道,或者更多车道。但是通道也不是无限扩张,因为每个通道有基础带宽,在通道所经过的路径上,带宽资源也是有限的,NCCL 的目标就是通过多通道无限逼近硬件带宽资源,以求最大化利用。

通道计算由函数 ncclTopoSearchRec 实现

ncclCommInitRank

ncclCommInitRankDev

ncclCommInitRankFunc

initTransportsRank

ncclTopoCompute

ncclTopoSearchRec(struct ncclTopoSystem* system, struct ncclTopoGraph* graph, struct ncclTopoGraph* saveGraph, int* time)

如下归纳了 ncclTopoSearchRec 计算通道的算法逻辑(多机场景,只涉及算法主要思想):

(1) 给定一个通道基准带宽值(通道有初始基准带宽,基准带宽刚好小于任意 GPU 到任意 NIC 路径之间的最大带宽),基于基准带宽值去搜索通道。

(2) 按照 GPU 的顺序,找出所有 GPU 最合适的网卡,返回网卡列表

(3) 按照得到的网卡列表,遍历网卡,准备构造 ring 路径。每一个网卡都要参与路径计算

  1. 当前被选择的网卡 Xn,因为 graph->nChannels = 0,强制从 GPU0 开始计算,如果计算成功,得到一条 ring 路径,此时 graph->nChannels 被加 1

a. 如果当前所有通道的总带宽高于通道数 -1 的总带宽,则新增当前通道,保存到 saveGraph

b. 如果带宽相等情况下,但是当前所有通道的总跳数小于之前的通道总跳数,保存到 saveGraph

c. 如果带宽和跳数条件都不满足,则不会将当前 graph 保存到 saveGraph,但是 graph 内容不变

  1. 因为 graph->nChannels 被加 1,开始选择另外一块网卡Xn+1,计算公式:(graph->nChannels+i)%netCount,开始计算新通道(graph->nChannels 会影响网卡的选择,此时是递归计算,是网卡 Xn 计算成功后的递归调用,根据通道被加 1 选择了网卡 Xn+1,而不是大循环遍历到网卡 Xn+1,此时最外层大循环还是网卡 Xn),因为 graph->nChannels != 0,从与当前被选择网卡距离最近的 localGpu(与该 NIC 共享 PCIe root complex 或 NUMA node 的 GPU)开始计算,如果计算成功,得到一条 ring 路径,此时 graph->nChannels 再被加 1,保存策略参考 (3).1.abc

3.按照步骤 (3).2,继续迭代,直至无法再迭代,执行回溯。在递归计算的路径上,都会在通道途经的路径上依次扣减 PCIe 设备间的给定基准带宽值,表示已经占用了一部分带宽资源。当回溯时,再按回溯路径依次恢复 PCIe 设备间的带宽。最后回溯至最外层大循环,即第一次选择网卡 Xn 的位置时,带宽恢复完毕,graph->nChannels 也恢复至0

(3) 因为回溯至 Xn,接下来选择 localGpu 再次计算,重复过程 (3).1,只不过变为从 localGpu 开始计算,而不再是 GPU0

(4) localGpu 计算完毕后,继续尝试所有可达 GPU 路径计算,再次重复过程 (3).1,只不过变为从 GPU 列表循环开始计算,但是会除去 localGpu,因为上一步计算过了

(5) 回到网卡大循环,从下一块网卡,比如网卡 Xn+1 开始,再次重复上述整个过程,从步骤 (3) 开始,只是现在变为大循环网卡 Xn+1 出发,graph->nChannels = 0,之前所有 PCIe 设备间被扣减的带宽均已恢复

当 ncclTopoSearchRec 完成当前条件设置下的通道搜索,接下来进入如下逻辑:

当计算出的通道数 graph->nChannels 等于 NCCL 允许的最大通道数:graph->maxChannels 时,可以暂时结束搜索,跳转到指定标签 done,准备下一次搜索。暗示计算出来的所有通道在途径的链路上,带宽容量都是可以满足的

// Optimal solution, stop here

if (time == -1) goto done;

当计算出的通道数小于允许的最大通道数,但是所消耗的总带宽 >= 总带宽时(所有 GPU 中自身link链路的带宽和,取一个最大值),可以暂时结束搜索,跳转到指定标签 done,准备下一次搜索

// Optimal solution, stop here

if (graph->nChannels*graph->bwInter >= system->totalBw) goto done;

如果前面两个条件都未满足,进入通道搜索第一阶段:NCCL 尝试降低一些搜索条件去继续搜索 ring channel。

例如:在基准带宽基础上逐渐减小带宽值,不断尝试是否可以计算出更多的通道。通道带宽也不能无限小,有一个最小经验值去限制。通过减小通道带宽,并尝试增加通道数的方法,去无限逼近通道路径上的带宽上限。例如:假设一个 path 有 64 GB/s 的带宽,按照 18 GB/s 的粒度只能找到 3 个 Channel,总带宽是 54 GB/s,但是按照 16 GB/s 的粒度的话,能够找到 4 个 Channel,刚好利用路径通道的所有带宽

当降低搜索条件到极限:speedArray[speedIndex+1] / graph->bwInter > .49 不再成立,结束第一个阶段的通道搜索

// Decrease bw until we find a solution

if ((speedIndex < nspeeds-1) && (graph->nChannels == 0 || (speedArray[speedIndex+1]/graph->bwInter > .49))) {

tmpGraph.bwInter = tmpGraph.bwIntra = speedArray[++speedIndex];

goto search;

}

在以上逻辑完成后,可能会进入通道搜索第二阶段,比如其中有一个条件:(graph->bwIntra && graph->bwInter) >= 25.0,需要基准带宽足够大

将当前计算得到的通道数翻倍(需要小于允许的 maxChannels),通道基准带宽减半

ncclTopoDupChannels(graph, ccMin, ngpus);

在带宽减半的基础上,去逐渐反向增加带宽值,不断尝试是否可以计算出带宽更高的总带宽。前提是,在所有通道所经过的路径上,带宽容量都满足。

if (graph->pattern == NCCL_TOPO_PATTERN_RING) {

// increase bw for Ring

tmpGraph.bwIntra = tmpGraph.bwInter = speedArray[--speedIndex];

goto search;

}

NCCL 通过暴力搜索方法,不断尝试各种条件,计算出基础带宽值合适,通道数更多,总带宽更大的多通道,目标就是尽最大可能榨干硬件带宽资源。上述逻辑对应日志:

ncclTopoPrintGraph:1201 NCCL INFO Pattern 4, crossNic 0, nChannels 1, bw 6.000000/6.000000, type PHB/PHB, sameChannels 1

当前实验环境只计算出一条channel,从 NET/0-2 出发,经过 GPU/0-80,GPU/0-90,回到 NET/0-2

ncclTopoPrintGraph:1224 NCCL INFO 0 : NET/0-2 GPU/0-80 GPU/0-90 NET/0-2

有如下计算得到的 Ring 通道示意图:

NCCL Tree拓扑介绍

NCCL Tree 拓扑也使用 ncclTopoCompute 计算得到,但是 Tree 只能用于 All Reduce 操作。与 ring 一样,一颗 tree 将绑定一个 channel。这里只介绍 tree 拓扑的相关算法概念。NCCL Tree 拓扑也在不断演进,从最初的 Single Binary Tree 演变为 Double Binary Tree,再在 Double Binary Tree 的基础上演变出新的 Split Tree,Balanced Tree。

Single Binary Tree

有如下单二叉树结构:

Single Binary Tree 上执行 All Reduce 操作,规约阶段:

叶子节点 1, 3, 5, 7,......,31 分别向自己的父节点:2, 6, 10, 14, 18, 22, 26, 30 发送数据

非叶子节点 2, 6, 10, 14, 18, 22, 26, 30 把从子节点收到的数据与本地数据进行规约,再将规约后的数据分别发送给自己的父节点

依此类推,直到 root 节点 0 规约完成所有数据,时间开销 log_N

Broadcast 阶段:

root 节点将规约后的数据广播给自己的子节点 16

子节点将收到的数据继续广播给自己的子节点 8,24

依次类推,直到所有的子节点都收到 root 节点的规约数据。时间开销 log_N

缺点:

reduce 和 broadcast 操作在时间线上只能串行执行,需要 root 完成 reduce 后才能进行 broadcast。时间开销比较大

叶子节点只有一个 parent 节点,没有 child 节点;非叶子节点虽然有两个 child 节点,但是也只有一个 parent 节点。这种结构会导致带宽浪费

Double Binary Tree

有如下双二叉树结构:

Double Binary Tree 与 Single Binary Tree 的区别:

Double Binary Tree 基于 Single Binary Tree 的所有节点生成了第二棵单二叉树

相对于 Single Binary Tree,在第二棵二叉树中,节点的角色进行了互换,叶子节点变为非叶子节点,非叶子节点转变为叶子节点

如此,除了 root 点,任意节点都有了两个 parent 节点,两个 child 节点。root 节点也有了一个 child 和一个 parent

Double Binary Tree 带来的优势:

与 Single Binary Tree 形成互补 Tree,通信更加均衡。基本上所有节点都有两个 parent 连接,两个 child 连接

增加一个通道,数据可以并行发送和接收,通信时延进一步降低

比如执行 All Reduce 操作时,每个节点的数组分别有 2N 个元素,前 N 个元素被映射到 Double Binary Tree 1,剩下的 N 个元素被映射到 Double Binary Tree 2,数据 reduce 操作可以在两棵 Tree 上并行执行,broadcast 阶段也是如此。

Split Tree 和 Balanced Tree

Split Tree 和 Balanced Tree 本质上也都是 Double Binary Tree。但是都在 Double Binary Tree 基础上做了相应变化。

这里需要澄清一个概念,在多机通信时,每个物理节点会选取其中一些节点作为与外部通信的节点;在物理节点内部,GPU 节点之间退化为链路结构。比如现在有 4 个物理节点,每个节点都有 8 个 GPU:

node1 的 GPU 编号

7->6->5->4->3->2->1->0

node2 的 GPU 编号

15->14->13->12->11->10->9->8

node3 的 GPU 编号

23->22->21->20->19->18->17->16

node4 的 GPU 编号

31->30->29->28->27->26->25->24

那么对于普通 Double Binary Tree,有如下两棵树形结构:

Tree1 中 GPU-8 和 GPU-24 为叶子节点,GPU-16 和 GPU-0 为非叶子节点

{8, 24} -> 16 -> 0

Tree2 中 GPU-0 和 GPU-16 为叶子节点,GPU-8 和 GPU-24 为非叶子节点。相对于 Tree 1,节点角色已经互换

{0, 16} -> 8 -> 24

Double Binary Tree 1 如下图所示:

Double Binary Trees 2 如下图所示:

NCCL 对 Double Binary Tree 的定义:

#define NCCL_TOPO_PATTERN_TREE 3 // All NIC traffic going to/from the same GPU

对于 Split Tree,有如下两棵树形结构:

Tree1 中 GPU-8 和 GPU-24 为子节点,GPU-17 为 parent 节点, GPU-16 为 GPU-1 的子节点

{8, 24} -> {17, 16} -> 0

Tree2 中 GPU-0 和 GPU-16 为子节点,GPU-9 为 parent 节点, GPU-8 为 GPU-25 的子节点

{0, 16} -> {9, 8} -> 24

Split Tree 1 如下图所示:

Split Tree 2 如下图所示:

NCCL 对 Split Tree 的定义:

#define NCCL_TOPO_PATTERN_SPLIT_TREE 2 // Spread NIC traffic between two GPUs (Tree parent on first GPU, tree children on the second GPU)

带来的优势:

GPU 不再是单点热点。不再是每个节点的 first GPU 承担全部工作,其它 GPU 也会分担一些计算任务

例如 Tree1 中,GPU-1 承担了子节点的 reduce 计算,而不再是 GPU-0 既要承担全部 reduce,又要执行 broadcast

同理,Tree1 中 GPU-17,也为 GPU-16 分担了一些计算任务

不再是同一个 GPU 同时处理 RX/TX 流量。NIC 的 ingress 和 egress,落在了不同的 GPU 上,避免带宽下降(虽然是全双工,但实际上 RX/TX同时发生时,容易出现Copy Engine,arbitration争抢,AI说的)

例如 Tree1 中,reduce 操作时,GPU-17 只接收,GPU-16 只发送

对于 Balanced Tree,有如下两棵树形结构:

Tree1 中 GPU-8 和 GPU-24 为子节点,GPU-17 为 GPU-24 的 parent 节点, GPU-16 为 GPU-8 的 parent 节点

{8, 24} -> {17, 16} -> 0

Tree2 中 GPU-0 和 GPU-16 为子节点,GPU-9 为 GPU-16 的 parent 节点, GPU-8 为 GPU-0 的 parent 节点

{0, 16} -> {9, 8} -> 24

Balanced Tree 1 如下图所示:

Balanced Tree 2 如下图所示:

NCCL 对 Balanced Tree 的定义:

#define NCCL_TOPO_PATTERN_BALANCED_TREE 1 // Spread NIC traffic between two GPUs (Tree parent + one child on first GPU, second child on second GPU)

带来的优势:流量更加负载均衡,NIC 流量分摊到多个 GPU(流量送到多个 GPU,比流量送到同一个 GPU 要更容易吃满 NIC 带宽,AI说的);两个 parent 节点分别处理不同 child 节点的流量。

NCCL通信算法的选择

NCCL 会根据通信操作类型、数据大小、GPU/节点数量、底层网络拓扑等自动选择最优算法。如下为 Ring 和 Tree 算法的一些对比差异:

特性 Ring 算法 Tree 算法

通信步数 O(N) O(log N)

带宽效率 高(接近最优) 中等(根节点易成瓶颈)

延迟 较高 较低

适用操作 AllReduce(主流) Broadcast, Reduce, AllReduce

适用规模 小 / 中规模(节点少) 大规模(节点多)

在链接:https://developer.nvidia.com/blog/massively-scale-deep-learning-training-nccl-2-4/ 中,通过 All Reduce 操作,比较了 Ring 和 Tree 的时延和带宽差异,在大规模集群场景,Tree 的时延是显著低于 Ring 算法的。

NCCL 多节点之间的 Channel 连接

NCCL 在为每个 rank 计算完通道后,接下来就要将多机之间的 channel 进行连接,在多机之间形成逻辑上的通信路径。这里只涉及 Ring channel 的相关逻辑。

首先设置当前 rank 计算得到的通道相关信息,例如通道数,机间带宽等。

NCCLCHECKGOTO(ncclCalloc(&allGather3Data, nranks), ret, fail);

for (int a=0; a

allGather3Data[rank].graphInfo[a].pattern = graphs[a]->pattern;

allGather3Data[rank].graphInfo[a].nChannels = graphs[a]->nChannels;

allGather3Data[rank].graphInfo[a].sameChannels = graphs[a]->sameChannels;

allGather3Data[rank].graphInfo[a].bwIntra = graphs[a]->bwIntra;

allGather3Data[rank].graphInfo[a].bwInter = graphs[a]->bwInter;

allGather3Data[rank].graphInfo[a].typeIntra = graphs[a]->typeIntra;

allGather3Data[rank].graphInfo[a].typeInter = graphs[a]->typeInter;

allGather3Data[rank].graphInfo[a].crossNic = graphs[a]->crossNic;

}

设置当前 rank 的具体通道信息:

ringRecv[c] 表示:表示当前 ring channel c 的第一个接收数据的 rank id

ringSend[c] 表示:表示当前 ring channel c 的最后一个 rank id

ringPrev[c] 表示:当前 rank 的前驱 rank id

ringNext[c] 表示:当前 rank 的后继 rank id

ncclResult_t ncclTopoPreset(struct ncclComm* comm, struct ncclTopoGraph** graphs, struct ncclTopoRanks* topoRanks) {

......

for (int i=0; i

if (ringIntra[i] == rank) {

topoRanks->ringRecv[c] = ringIntra[0];

topoRanks->ringSend[c] = ringIntra[localRanks-1];

topoRanks->ringPrev[c] = (i == 0) ? -1 : ringIntra[i-1];

topoRanks->ringNext[c] = (i == localRanks-1) ? -1 : ringIntra[i+1];

}

......

}

接下来通过控制通道交换所有 rank 的通道信息,目的:

对齐通道数和通信带宽

统一多机通信算法等

bootstrapAllGather(comm->bootstrap, allGather3Data, sizeof(*allGather3Data))

for (int i=0; i

allTopoRanks[i] = &allGather3Data[i].topoRanks;

// Make sure we align all ranks so that the tuning is consistent across ranks

for (int a=0; a

graphs[a]->nChannels = std::min(allGather3Data[i].graphInfo[a].nChannels, graphs[a]->nChannels);

graphs[a]->sameChannels = std::min(allGather3Data[i].graphInfo[a].sameChannels, graphs[a]->sameChannels);

graphs[a]->bwIntra = std::min(allGather3Data[i].graphInfo[a].bwIntra, graphs[a]->bwIntra);

graphs[a]->bwInter = std::min(allGather3Data[i].graphInfo[a].bwInter, graphs[a]->bwInter);

graphs[a]->typeIntra = std::max(allGather3Data[i].graphInfo[a].typeIntra, graphs[a]->typeIntra);

graphs[a]->typeInter = std::max(allGather3Data[i].graphInfo[a].typeInter, graphs[a]->typeInter);

graphs[a]->crossNic = std::max(allGather3Data[i].graphInfo[a].crossNic, graphs[a]->crossNic);

}

comm->maxTreePattern = std::max(comm->maxTreePattern, allGather3Data[i].graphInfo[NCCL_ALGO_TREE].pattern);

}

信息交换并对齐后,接下来就开始跨节点合并通道,实现多机间的逻辑 ring 连接。

这里需要提到一个通信优化: 多机间 ring 通道通信,NCCL 将奇数 node 节点的奇数通道,偶数通道使用的网卡进行交换,避免跨轨通信。

当 NCCL_CROSS_NIC = 2(default),且节点有多块网卡,并计算出多 channel 时,NCCL 会使奇偶通道交替使用 NIC,实现流量负载均衡(NET0 和 NET1都实现收发),例如:

channel 0

NET 0 -> GPU a -> GPU b -> .. -> GPU x -> NET 1

channel 1

NET 1 -> GPU a -> GPU b -> .. -> GPU x -> NET 0

对于 channel 0,NCCL 会在所有节点上,都使用 NET 0 收,NET 1 发,那么流量到达 node-1 时,将会由 node-1 的 NET 0 收,造成了跨轨通信。NCCL 为了避免跨轨通信,将奇数 node 的奇偶通道互换,实现同轨通信。例如 node-0 使用 NET 1 发送时,node-1 实现通过 NET 1 接收数据。