soobook
GPU

Collective Communication

all-reduce 계열 연산과 NCCL이 topology를 읽는 방식

Collective communication은 GPU 하나가 다른 하나에게 보내는 통신이 아니라, 여러 GPU가 같은 통신 연산에 함께 참여해 데이터를 교환하는 패턴이다. 분산 학습에서는 all-reduce 계열 연산이 매 step 반복되고, 이 시간이 GPU utilization을 좌우한다.

NVLink와 NVSwitch 글에서 한 서버 안의 GPU 경로를, NIC과 RDMA 글에서 서버 사이의 경로를 봤다.

하드웨어 경로는 준비됐다. 이번에는 그 경로 위로 실제로 무엇이, 어떤 패턴으로 오가는지를 보자.

키워드는 collective communication이다. NCCL 문서의 첫 문장은 스스로를 이렇게 소개한다.

“inter-GPU communication primitives that are topology-aware.”

Why Collective Communication

Data parallel 학습을 생각해 보자. GPU 8장이 같은 모델 복제본을 들고, 서로 다른 데이터 batch를 처리한다.

Forward와 backward는 각자 독립적으로 돌지만, 문제는 그 다음이다.

각 GPU가 계산한 gradient는 자기 batch만 반영한 값이라, weight를 업데이트하기 전에 8장의 gradient를 평균 내서 모두가 같은 값을 들고 있어야 한다.

이 “모두의 값을 모아 연산하고, 결과를 모두에게 나눠준다”가 all-reduce다.

그리고 이 연산은 학습이 끝날 때까지 매 step 반복된다. Step이 수십만 번이면 all-reduce도 수십만 번이다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "All-Reduce",
  "width": 960,
  "height": 620,
  "mobileWidth": 820,
  "regions": [
    {
      "id": "ranks",
      "label": "gradient 동기화",
      "x": 52,
      "y": 58,
      "width": 856,
      "height": 500
    }
  ],
  "nodes": [
    {
      "id": "before-0",
      "kind": "gpu",
      "label": "GPU 0",
      "caption": "g0",
      "x": 159,
      "y": 207,
      "width": 120,
      "height": 60
    },
    {
      "id": "before-1",
      "kind": "gpu",
      "label": "GPU 1",
      "caption": "g1",
      "x": 373,
      "y": 207,
      "width": 120,
      "height": 60
    },
    {
      "id": "before-2",
      "kind": "gpu",
      "label": "GPU 2",
      "caption": "g2",
      "x": 587,
      "y": 207,
      "width": 120,
      "height": 60
    },
    {
      "id": "before-3",
      "kind": "gpu",
      "label": "GPU 3",
      "caption": "g3",
      "x": 801,
      "y": 207,
      "width": 120,
      "height": 60
    },
    {
      "id": "allreduce-op",
      "kind": "block",
      "label": "AllReduce",
      "caption": "sum",
      "x": 480,
      "y": 330,
      "width": 700,
      "height": 56
    },
    {
      "id": "after-0",
      "kind": "gpu",
      "label": "GPU 0",
      "caption": "Σg",
      "x": 159,
      "y": 453,
      "width": 120,
      "height": 60
    },
    {
      "id": "after-1",
      "kind": "gpu",
      "label": "GPU 1",
      "caption": "Σg",
      "x": 373,
      "y": 453,
      "width": 120,
      "height": 60
    },
    {
      "id": "after-2",
      "kind": "gpu",
      "label": "GPU 2",
      "caption": "Σg",
      "x": 587,
      "y": 453,
      "width": 120,
      "height": 60
    },
    {
      "id": "after-3",
      "kind": "gpu",
      "label": "GPU 3",
      "caption": "Σg",
      "x": 801,
      "y": 453,
      "width": 120,
      "height": 60
    }
  ],
  "links": [
    {
      "id": "in-0",
      "points": [
        [
          159,
          237
        ],
        [
          159,
          302
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "soft"
      }
    },
    {
      "id": "in-1",
      "points": [
        [
          373,
          237
        ],
        [
          373,
          302
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "soft"
      }
    },
    {
      "id": "in-2",
      "points": [
        [
          587,
          237
        ],
        [
          587,
          302
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "soft"
      }
    },
    {
      "id": "in-3",
      "points": [
        [
          801,
          237
        ],
        [
          801,
          302
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "soft"
      }
    },
    {
      "id": "out-0",
      "points": [
        [
          159,
          358
        ],
        [
          159,
          423
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "out-1",
      "points": [
        [
          373,
          358
        ],
        [
          373,
          423
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "out-2",
      "points": [
        [
          587,
          358
        ],
        [
          587,
          423
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "out-3",
      "points": [
        [
          801,
          358
        ],
        [
          801,
          423
        ]
      ],
      "tone": "primary",
      "width": 2,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    }
  ]
}

그래서 collective communication은 부가 기능이 아니라 학습 루프의 일부다. 통신이 느리면 GPU는 다음 step을 시작하지 못하고 기다린다.

값비싼 GPU의 utilization이 떨어지는 지점이 compute가 아니라 통신인 경우가 많은 이유다.

Collective Operations

Point-to-point 통신은 보내는 쪽과 받는 쪽이 하나씩이다. sendrecv가 짝을 이룬다.

Collective operation은 통신에 참여하는 모든 rank가 같은 연산을 함께 호출한다. 여기서 rank란 통신 그룹 안에서 각 프로세스(보통 GPU 하나)에 붙는 번호를 말한다.

NCCL이 제공하는 collective는 다음과 같다.

NCCL(Nvidia Collective Communications Library)은 NVIDIA GPU 간의 고속 통신을 지원하는 라이브러리다.

연산동작
Broadcastroot rank의 버퍼를 모든 rank에 복사한다
Reduce모든 rank의 값을 연산(sum, max 등)해 root rank 하나에만 저장한다
AllReduceReduce와 같은 연산을 하되, 결과를 모든 rank가 받는다
AllGather각 rank의 N개 값을 모아 k×N 버퍼를 만들어 모든 rank에 나눠준다
ReduceScatterReduce 결과를 rank 수만큼 등분해, 각 rank가 자기 몫 하나씩 가져간다
AlltoAll각 rank가 버퍼를 k등분해 j번째 조각을 rank j에게 보낸다

연산 사이에는 조합 관계가 있다. NCCL 문서가 명시하는 두 가지가 특히 중요하다.

Reduce 뒤에 Broadcast를 하면 AllReduce와 같고, ReduceScatter 뒤에 AllGather를 해도 AllReduce와 같다.

두 번째 관계는 ring all-reduce 알고리즘의 뼈대이기도 하다.

어떤 학습 기법이 어떤 연산을 쓰는지 보면 감이 잡힌다.

PyTorch DDP는 backward 중에 gradient를 bucket 단위로 묶어 all-reduce를 비동기로 날린다. 통신을 backward 계산과 겹쳐서 숨기는 구조다.

FSDP처럼 파라미터를 shard하는 방식은 forward에서 all-gather로 파라미터를 모으고, backward에서 reduce-scatter로 gradient를 나눈다.

MoE 모델은 token을 expert가 있는 GPU로 라우팅하느라 all-to-all을 쓴다.

애플리케이션 코드가 이 연산을 직접 구현하는 경우는 드물다.

PyTorch의 torch.distributed가 API를 노출하고, GPU 학습에서는 그 아래 backend로 NCCL을 쓰는 것이 표준이다.

PyTorch 문서도 CUDA GPU 분산 학습에는 NCCL을 쓰라고 못박는다. InfiniBand와 GPUDirect를 지원하는 backend가 NCCL뿐이기 때문이다.

Ring All-Reduce

이제 all-reduce를 실제로 어떻게 수행하는지 보자. 가장 단순한 방법은 한 GPU가 코디네이터가 되는 것이다.

모두가 gradient를 한 곳으로 보내고, 거기서 합산해 다시 뿌린다. 이 방식의 통신량은 GPU 수에 비례해 늘어난다.

GPU가 10장이면 코디네이터는 9장 분량의 데이터를 받고 또 보내야 한다. GPU를 늘릴수록 통신이 느려지는 구조다.

Ring all-reduce는 이 문제를 우아하게 푼다. GPU들을 논리적인 링으로 배열하고, 각 GPU는 오른쪽 이웃에게만 보내고 왼쪽 이웃에게서만 받는다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "Ring All-Reduce",
  "width": 960,
  "height": 470,
  "mobileWidth": 820,
  "regions": [
    {
      "id": "ring",
      "label": "logical ring",
      "x": 52,
      "y": 58,
      "width": 856,
      "height": 350
    }
  ],
  "nodes": [
    {
      "id": "gpu-0",
      "kind": "gpu",
      "label": "GPU 0",
      "caption": "chunk a",
      "x": 300,
      "y": 165,
      "width": 130,
      "height": 64
    },
    {
      "id": "gpu-1",
      "kind": "gpu",
      "label": "GPU 1",
      "caption": "chunk b",
      "x": 660,
      "y": 165,
      "width": 130,
      "height": 64
    },
    {
      "id": "gpu-2",
      "kind": "gpu",
      "label": "GPU 2",
      "caption": "chunk c",
      "x": 660,
      "y": 345,
      "width": 130,
      "height": 64
    },
    {
      "id": "gpu-3",
      "kind": "gpu",
      "label": "GPU 3",
      "caption": "chunk d",
      "x": 300,
      "y": 345,
      "width": 130,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "ring-0-1",
      "points": [
        [
          365,
          165
        ],
        [
          595,
          165
        ]
      ],
      "tone": "primary",
      "width": 3,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "ring-1-2",
      "points": [
        [
          660,
          197
        ],
        [
          660,
          313
        ]
      ],
      "tone": "primary",
      "width": 3,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "ring-2-3",
      "points": [
        [
          595,
          345
        ],
        [
          365,
          345
        ]
      ],
      "tone": "primary",
      "width": 3,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    },
    {
      "id": "ring-3-0",
      "points": [
        [
          300,
          313
        ],
        [
          300,
          197
        ]
      ],
      "tone": "primary",
      "width": 3,
      "flow": {
        "speed": "normal",
        "count": 1,
        "emphasis": "strong"
      }
    }
  ],
  "callouts": [
    {
      "id": "chunk-note",
      "text": "전체가 아니라 1/N 조각만\\n한 방향으로 돈다",
      "x": 480,
      "y": 255,
      "width": 190,
      "tone": "primary"
    }
  ]
}

알고리즘은 두 단계다. 먼저 각 GPU가 자기 gradient 배열을 N등분한다(N은 링에 있는 GPU 수).

Scatter-reduce: N−1번 반복한다. 매 반복마다 각 GPU는 조각 하나를 오른쪽으로 보내고, 왼쪽에서 받은 조각을 자기 값에 누적한다.

끝나면 각 GPU는 “모든 GPU의 값이 합산된 조각”을 하나씩 나눠 갖게 된다. 이름 그대로 reduce 결과가 scatter된 상태, 즉 reduce-scatter다.

All-gather: 다시 N−1번 반복하되, 이번에는 누적하지 않고 받은 조각으로 덮어쓴다. 완성된 조각들이 링을 한 바퀴 돌면 모든 GPU가 전체 결과를 갖게 된다.

통신량을 세어 보면 이 알고리즘의 장점이 드러난다. 각 GPU는 조각(K/N 크기)을 2(N−1)번 보내므로, GPU당 전송량은 다음과 같다.

D=2(N1)KND = 2(N-1)\frac{K}{N}

K는 전체 gradient 크기다. N이 커지면 이 값은 2K에 수렴한다. 즉 GPU가 몇 장이든 GPU 하나가 보내는 데이터 양은 거의 일정하다.

코디네이터 방식처럼 N에 비례해 늘어나지 않는다.

대역폭 관점에서 ring all-reduce는 최적 알고리즘이라는 것이 증명되어 있고, 이것이 Baidu가 HPC의 ring all-reduce를 딥러닝에 가져온 이후 지금까지 표준으로 쓰이는 이유다.

단서가 하나 붙는다. “latency가 무시할 만하다면”이다.

Tree Algorithm

Ring의 약점은 latency다. 조각 하나가 결과에 반영되려면 링을 한 바퀴 돌아야 하므로, 통신 단계 수가 GPU 수에 비례해 늘어난다.

GPU가 수백, 수천 장이 되면 대역폭이 아니라 “몇 hop을 거치는가”가 병목이 된다. NVIDIA가 NCCL 2.4에서 double binary tree를 도입한 배경이다.

Tree 구조에서는 통신 단계가 GPU 수의 로그에 비례한다. 1,024장이어도 단계 수는 10 수준이다. 문제는 단순한 트리 하나로는 대역폭이 절반이 된다는 것이다. 트리의 leaf 노드는 받기만 하고 보내지 않아서, 절반의 GPU는 보내는 대역폭을 놀린다.

Double binary tree는 트리를 두 개 겹쳐서 이를 해결한다.

이진 트리에서는 rank의 절반 이상이 leaf라는 성질을 이용해, 첫 번째 트리의 leaf가 두 번째 트리에서는 내부 노드가 되도록 상보적인 트리를 만든다.

데이터를 반씩 나눠 두 트리로 동시에 흘리면, 모든 GPU가 보내기와 받기를 모두 수행해 ring과 같은 full bandwidth를 유지하면서 latency는 로그로 떨어진다.

그렇다고 tree가 ring을 대체한 것은 아니다. NCCL은 메시지 크기와 규모에 따라 두 알고리즘을 오간다.

큰 메시지에서 ring이 더 나은 대역폭을 내면 자동으로 ring으로 돌아간다.

NCCL_ALGO 환경변수로 강제할 수도 있지만, 기본값은 “unset”이고 문서의 표현대로 node topology와 아키텍처를 보고 NCCL이 알아서 고른다.

최근 버전에는 NVSwitch에 연산을 offload하는 NVLS 같은 알고리즘도 추가됐다.

Collectives over InfiniBand

Ring과 tree는 논리적인 연결이다. 링에서 이웃한 두 GPU가 같은 서버 안에 있으면 그 edge는 NVLink를 타지만, 서버 경계를 넘는 edge의 실체는 NIC과 그 너머의 InfiniBand fabric이다.

NCCL은 구간마다 transport를 다르게 고른다.

노드 안 이웃과는 P2P transport(NVLink, PCIe)로, 노드 밖 이웃과는 IB Verbs로 연결한다.

all-reduce 한 번이 NVLink와 InfiniBand를 동시에 타는 셈이다.

노드 경계를 넘는 edge에서 벌어지는 일을 풀어 보면 NIC과 RDMA 글의 내용이 그대로 등장한다.

TCP라면 GPU 메모리의 gradient 조각을 CPU 메모리로 복사하고, kernel network stack을 거쳐 NIC으로 보내야 한다.

RDMA는 이 경로를 건너뛴다. NIC이 원격 노드의 메모리에 CPU 개입 없이 직접 쓰고, GPUDirect RDMA까지 붙으면 NIC이 GPU HBM에서 직접 조각을 꺼내 fabric으로 내보낸다.

kernel도, CPU도, 중간 복사도 없는 GPU-to-GPU 경로가 서버 사이에 만들어진다.

이 경로가 collective에 결정적인 이유는 두 가지다.

첫째, all-reduce는 매 step 반복되는 동기화 지점이라서 경로의 latency가 step 시간에 그대로 더해진다.

kernel stack을 오가는 수십 마이크로초와 RDMA의 마이크로초 단위 지연의 차이가 step마다 누적된다.

둘째, ring의 속도는 가장 느린 링크가 정한다.

노드 안 NVLink가 수백 GB/s를 내도 노드 사이 IB NIC 하나는 400Gb/s, 즉 50GB/s급이다. 멀티노드 all-reduce의 상한은 사실상 InfiniBand 쪽에서 결정된다.

그래서 GPU 서버는 NIC을 GPU 수만큼 단다. 8 GPU 노드에 IB NIC 8개를 붙이고, NCCL은 GPU-NIC 쌍마다 채널을 만들어 노드 사이 구간을 NIC 8개로 병렬화한다.

링 배치도 이 구조에 맞춘다. 같은 노드의 GPU들이 링에서 연속한 이웃이 되도록 배열해, 노드 안에서는 NVLink로 조각을 옮기고 노드 경계는 꼭 필요한 만큼만 건넌다.

노드 안에서 reduce-scatter로 접고, 노드 사이에서만 all-reduce를 하고, 다시 노드 안에서 all-gather로 펼치는 hierarchical ring도 같은 사고방식의 연장이다.

Topology Awareness

Ring과 tree가 통신 패턴을 정하고 RDMA와 InfiniBand가 물리 경로를 제공한다면, 남은 문제는 NCCL이 그 경로를 어떻게 알아내느냐다.

같은 ring이라도 어떤 GPU를 이웃으로 삼는지에 따라 성능이 갈린다. Ring의 속도는 링에서 가장 느린 링크가 결정하기 때문이다.

NVLink로 연결된 GPU끼리 이웃이 되면 빠르고, 링 중간에 좁은 경로가 끼어들면 전체가 그 속도로 떨어진다.

그래서 NCCL은 초기화할 때 /sys 파일시스템을 읽어 GPU와 NIC의 PCIe topology를 파악하고, 그 위에서 ring과 tree를 설계한다.

이때 장치 사이의 거리를 등급으로 나눈다.

등급의미
NVLNVLink로 연결됨
PIX같은 PCIe switch 아래
PXBPCIe switch를 여러 개 거침
PHB같은 NUMA node, CPU(host bridge)를 경유
SYSNUMA node 사이 interconnect(UPI 등)를 건넘

nvidia-smi topo -m을 치면 나오는 그 등급이다. 이 거리는 단순한 참고 정보가 아니라 NCCL의 의사결정 입력이다.

GPU끼리 P2P 통신을 쓸지(NCCL_P2P_LEVEL), GPU 메모리에서 NIC으로 바로 쏘는 GPUDirect RDMA를 쓸지(NCCL_NET_GDR_LEVEL)가 모두 이 거리 기준으로 결정된다.

예를 들어 GPU와 NIC이 같은 PCIe switch 아래(PIX)에 있으면 GPUDirect RDMA로 CPU를 완전히 우회하지만, CPU를 경유해야 하는 거리(PHB 이상)면 성능 이득이 줄어든다.

멀티노드로 가면 GPU와 NIC의 짝짓기가 더 중요해진다. 8 GPU 서버는 보통 GPU마다 가까운 NIC을 하나씩 붙이고, 노드들의 같은 위치 NIC끼리 같은 스위치에 묶는 rail-optimized 구성을 쓴다. NCCL도 이 구성을 전제로, 노드 사이 통신에서 같은 rail의 NIC을 골라 트래픽 간섭을 피한다. 노드 안에서는 NVLink로 조각을 모으고, 노드 밖으로는 각 GPU가 자기 rail의 NIC으로 InfiniBand를 타는 계층 구조다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "GPU-NIC Distance",
  "width": 960,
  "height": 480,
  "mobileWidth": 820,
  "regions": [
    {
      "id": "pix-side",
      "label": "PIX — 같은 switch",
      "x": 52,
      "y": 58,
      "width": 408,
      "height": 360
    },
    {
      "id": "phb-side",
      "label": "PHB — CPU 경유",
      "x": 500,
      "y": 58,
      "width": 408,
      "height": 360
    }
  ],
  "nodes": [
    {
      "id": "pix-switch",
      "kind": "switch",
      "label": "PCIe switch",
      "x": 256,
      "y": 169,
      "width": 150,
      "height": 60
    },
    {
      "id": "pix-gpu",
      "kind": "gpu",
      "label": "GPU",
      "x": 150,
      "y": 349,
      "width": 120,
      "height": 64
    },
    {
      "id": "pix-nic",
      "kind": "nic",
      "label": "NIC",
      "x": 362,
      "y": 349,
      "width": 120,
      "height": 64
    },
    {
      "id": "phb-cpu",
      "kind": "host",
      "label": "CPU",
      "caption": "host bridge",
      "x": 704,
      "y": 169,
      "width": 150,
      "height": 60
    },
    {
      "id": "phb-gpu",
      "kind": "gpu",
      "label": "GPU",
      "x": 598,
      "y": 349,
      "width": 120,
      "height": 64
    },
    {
      "id": "phb-nic",
      "kind": "nic",
      "label": "NIC",
      "x": 810,
      "y": 349,
      "width": 120,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "pix-gpu-nic",
      "points": [
        [
          210,
          349
        ],
        [
          302,
          349
        ]
      ],
      "tone": "primary",
      "width": 3,
      "flow": {
        "speed": "fast",
        "count": 2,
        "emphasis": "strong"
      }
    },
    {
      "id": "pix-gpu-sw",
      "points": [
        [
          150,
          317
        ],
        [
          150,
          199
        ],
        [
          181,
          199
        ]
      ],
      "tone": "muted",
      "width": 2
    },
    {
      "id": "pix-nic-sw",
      "points": [
        [
          362,
          317
        ],
        [
          362,
          199
        ],
        [
          331,
          199
        ]
      ],
      "tone": "muted",
      "width": 2
    },
    {
      "id": "phb-gpu-cpu",
      "points": [
        [
          598,
          317
        ],
        [
          598,
          199
        ],
        [
          629,
          199
        ]
      ],
      "tone": "primary",
      "width": 3,
      "dashed": true,
      "flow": {
        "speed": "slow",
        "count": 1,
        "emphasis": "soft"
      }
    },
    {
      "id": "phb-cpu-nic",
      "points": [
        [
          779,
          199
        ],
        [
          810,
          199
        ],
        [
          810,
          317
        ]
      ],
      "tone": "primary",
      "width": 3,
      "dashed": true,
      "flow": {
        "speed": "slow",
        "count": 1,
        "emphasis": "soft"
      }
    }
  ],
  "callouts": [
    {
      "id": "pix-note",
      "text": "GPUDirect RDMA\\nCPU 우회 직행",
      "x": 256,
      "y": 265,
      "width": 170,
      "tone": "primary"
    },
    {
      "id": "phb-note",
      "text": "host bridge를 건너는\\n먼 경로",
      "x": 704,
      "y": 265,
      "width": 170,
      "tone": "muted"
    }
  ]
}

Topology in Virtualization

이 글을 여기까지 끌고 온 이유가 이 섹션이다. NCCL의 모든 판단은 자기가 보는 PCIe topology가 진짜라는 전제 위에 서 있다. 그런데 그 전제가 깨지는 환경이 있다. 가상화다.

VFIO 글에서 봤듯 GPU와 NIC은 VM에 passthrough로 넘길 수 있고, 장치 자체는 near-native로 동작한다.

문제는 topology다. 아무 설정 없이 장치만 넘기면 guest 입장에서는 모든 장치가 평평한 가상 버스에 매달린 것처럼 보인다.

Host에서는 GPU와 NIC이 같은 PCIe switch 아래(PIX)에 있었는데, guest의 nvidia-smi topo -m에는 그 관계가 지워진 채 전부 먼 거리로 찍힌다.

NCCL은 그 정보가 틀렸는지 검증할 방법이 없으니 보이는 대로 판단한다. GPU와 NIC의 짝을 못 찾고, GPUDirect RDMA를 보수적으로 끄거나 채널을 좁게 잡는다.

하드웨어 경로는 그대로인데 소프트웨어가 경로를 못 쓰는 상태가 된다.

NCCL 공식 troubleshooting 문서도 VM이나 컨테이너에서 /sys가 가상 PCI topology를 노출하면 성능이 저하될 수 있다고 말한다.

해법은 guest에게 진짜에 가까운 topology를 다시 보여주는 것이다. 방향은 두 가지다.

  • Hypervisor 쪽에서 가상 PCIe switch를 구성해, host에서 같은 switch 아래 있던 GPU-NIC 짝을 guest에서도 같은 switch 아래로 배치한다. QEMU의 q35 머신 타입이 PCIe topology를 표현할 수 있는 것이 전제 조건이다.
  • NCCL 쪽에서 NCCL_TOPO_FILE로 topology XML을 직접 주입해, /sys가 보여주지 못하는 실제 배치를 알려준다.

KubeVirt 같은 스택에서 VM 기반 GPU 클러스터를 만들 때 이 topology 재구성이 성능의 관건이 된다.

GPU passthrough까지는 장치를 넘기는 문제였다면, collective communication 성능은 topology 정보를 넘기는 문제인 셈이다.

Summary

Collective communication은 여러 GPU가 같은 연산에 참여해 데이터를 교환하는 패턴이다. 분산 학습에서는 all-reduce가 매 step 반복되므로, 이 연산의 속도가 GPU utilization을 결정한다.

Ring all-reduce는 GPU당 전송량을 GPU 수와 무관하게 만드는 대역폭 최적 알고리즘이고, double binary tree는 대규모에서 ring의 선형 latency를 로그로 줄인다. NCCL은 topology를 보고 두 알고리즘을 자동으로 오간다.

그 패턴이 노드 경계를 넘는 구간의 실체는 RDMA와 InfiniBand다. GPUDirect RDMA가 GPU HBM에서 NIC으로 직행하는 경로를 만들고, 노드 사이 링크가 전체 링의 속도를 정하므로 멀티노드 all-reduce의 상한은 InfiniBand fabric이 결정한다.

그 topology 인식은 /sys의 PCIe 정보 위에 서 있다. GPU와 NIC의 거리 등급(PIX~SYS)이 P2P와 GPUDirect RDMA 사용 여부를 결정하고, 가상화처럼 topology가 왜곡되는 환경에서는 하드웨어가 멀쩡해도 collective 성능이 무너진다. 통신 알고리즘의 문제처럼 보이는 것이 사실은 topology 가시성의 문제인 경우가 있다.

References