CHAPTER 01

제1장: 제1장: 실행과 현상: 하나의 AllReduce부터 외부 동작 살펴보기

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 1 / 25 장

제1장: 실행과 현상: 하나의 AllReduce부터 외부 동작 살펴보기

어떤 커널 코드를 깊이 파고들기 전에, 먼저 NCCL을 실행해 보고 그것이 외부에 드러내는 동작을 관찰하자. 이 장에서는 커널을 읽지 않고 단 한 가지 일만 한다. 바로 검증 가능한 참조 체계를 세우는 것이다. 이후의 모든 내부 메커니즘 분석은 결국 여기서 보이는 외부 동작을 설명할 수 있어야 한다.

1.1 빌드 진입점에서 본 NCCL의 엔지니어링 구조

직관적 모델

빌드 시스템은 건물의 시공 도면과 같다. 누가 그 건물에 사는지는 결정하지 않지만, 어떤 방이 있고 문이 어느 쪽으로 나는지는 결정한다. 빌드 진입점이 혼란스러우면 "실행하기"라는 첫걸음조차 떼지 못한다. NCCL은 Makefile과 CMake 두 가지 빌드 진입점을 동시에 제공하는데, 그 차이를 이해하는 것이 이 프로젝트의 엔지니어링 조직을 이해하는 첫걸음이다.

두 빌드 진입점의 구조

최상위Makefile은 매우 얇은 디스패치 계층으로, 그 자체로는 어떤 소스 파일도 컴파일하지 않고 작업을 각 하위 디렉터리의 Makefile로 전달한다.

📎 Makefile:44-45은src.%패턴 규칙을 정의하여src.build、src.install등의 타깃을src/Makefile:

code
src.%:
	${MAKE} -C src $* BUILDDIR=${ABSBUILDDIR}

📎 Makefile:47-48복사examples은src.build타깃을 정의하는데, 이는docs/examples에 의존한 다음

code
examples: src.build
	${MAKE} -C docs/examples NCCL_HOME=${ABSBUILDDIR}

복사src.build여기서 의존 관계에 주목하자. 예제 빌드는NCCL_HOME이 먼저 완료되는 것에 의존한다. 예제가 NCCL 라이브러리를 링크해야 하고,

📎 Makefile:29정리 가능한 모든 대상 집합을 나열합니다:

code
TARGETS := src pkg nccl4py ir

📎 Makefile:30GNU Make의 치환 참조 문법을 사용하여${TARGETS:%=%.clean}를src pkg nccl4py ir로 확장해src.clean pkg.clean nccl4py.clean ir.clean한 번에 모든 정리 대상을 정의합니다. 이는 Makefile에서 흔히 쓰이는 "데이터 기반 규칙" 기법입니다 — 새 모듈을 추가하려면TARGETS에 단어 하나만 더하면 됩니다.

CMake 진입점: 버전 번호는 어디서 오는가

CMake 진입점은 Makefile보다 훨씬 복잡합니다. 크로스 플랫폼, CUDA 버전 탐지, 아키텍처 선택 등을 처리해야 하기 때문입니다. 우리는 "실행"과 직접 관련된 부분에만 집중합니다.

📎 CMakeLists.txt:5-11버전 번호의 출처를 보여줍니다 — CMakeLists.txt에 하드코딩된 것이 아니라makefiles/version.mk에서 읽어 정규식으로 추출합니다:

cmake
file(READ ${CMAKE_SOURCE_DIR}/makefiles/version.mk VERSION_CONTENT)
string(REGEX REPLACE ".*NCCL_MAJOR[ ]*:=[ ]*([0-9]+).*" "\\1" NCCL_MAJOR "${VERSION_CONTENT}")
...
math(EXPR NCCL_VERSION_CODE "(${NCCL_MAJOR} * 10000) + (${NCCL_MINOR} * 100) + ${NCCL_PATCH}")
〔설계 추론 및 아키텍처 트레이드오프〕

버전 번호를version.mk에 집중시켜 Makefile과 CMake 두 빌드 시스템이 동일한 버전 소스를 공유하게 함으로써 "두 빌드 시스템의 버전 번호 불일치"라는 고전적인 엔지니어링 함정을 피합니다.NCCL_VERSION_CODE의 계산 공식MAJOR*10000 + MINOR*100 + PATCH은 헤더 파일의NCCL_VERSION매크로와 일치합니다.

📎 CMakeLists.txt:14-20이 버전 번호들을add_compile_definitions를 통해 모든 C++ 소스 파일에 주입합니다:

cmake
add_compile_definitions(
    NCCL_USE_CMAKE
    NCCL_MAJOR=${NCCL_MAJOR}
    NCCL_MINOR=${NCCL_MINOR}
    NCCL_PATCH=${NCCL_PATCH}
    NCCL_VERSION_CODE=${NCCL_VERSION_CODE}
)

📎 CMakeLists.txt:24-25는 프로젝트 언어를 CUDA, CXX, C로 선언합니다:

cmake
project(NCCL VERSION ${NCCL_MAJOR}.${NCCL_MINOR}.${NCCL_PATCH}
        LANGUAGES CUDA CXX C)

CUDA 아키텍처 선택: 기본값이 왜 이렇게 복잡한가

📎 CMakeLists.txt:140-171는 CUDA 버전에 따라CMAKE_CUDA_ARCHITECTURES를 결정하는 긴 로직입니다. CUDA 12.8 이상을 예로 들면:

cmake
elseif(${CUDA_MAJOR} EQUAL 12)
    if(${CUDA_MINOR} LESS 8)
        set(CMAKE_CUDA_ARCHITECTURES "50;60;61;70;80;90")
    else()
        set(CMAKE_CUDA_ARCHITECTURES "50;60;61;70;80;90;100;120")
    endif()
〔설계 추론 및 아키텍처 트레이드오프〕

이 로직의 설계 동기는: 새 아키텍처(예: 100, 120)의 PTX는 최신 CUDA 툴체인만 인식하므로, 구버전 CUDA에 새 아키텍처를 강제로 지정하면 컴파일이 바로 실패합니다. 따라서 기본 아키텍처 목록은 CUDA 버전에 따라 동적으로 조정되어야 합니다. 독자에게 이는 다음을 의미합니다:를 명시적으로 설정하지 않으면CMAKE_CUDA_ARCHITECTURES컴파일 산출물에 긴 아키텍처 목록의 fatbin이 포함되어 컴파일 시간이 현저히 길어집니다. 프로덕션 환경에서는 보통 대상 아키텍처를 명시적으로 지정해 빌드를 가속합니다.

빌드 흐름 결정 다이어그램

아래 그림은make실행부터 실행 가능한 예제 산출까지의 전체 결정 경로를 보여줍니다:

mermaid
flowchart TD
    start["执行 make 或 make examples"] --> check_ir{"EMIT_LLVM_IR 或<br/>NCCL_EMIT_LTO_IR 非 0?"}
    check_ir -->|是| add_ir["IR_GOALS 加入 llvm_ir/ltoir<br/>default 依赖 ir-emit"]
    check_ir -->|否| only_src["default 仅依赖 src.build"]
    add_ir --> src_build["make -C src build<br/>BUILDDIR=build"]
    only_src --> src_build
    src_build --> build_ok{"src.build 成功?"}
    build_ok -->|否| fail["构建失败,终止"]
    build_ok -->|是| is_examples{"目标是 examples?"}
    is_examples -->|是| ex_build["make -C docs/examples<br/>NCCL_HOME=build"]
    is_examples -->|否| done["产出 libnccl.so"]
    ex_build --> ex_ok{"示例链接成功?"}
    ex_ok -->|否| fail
    ex_ok -->|是| runnable["产出可执行示例"]

이 그림의 핵심 분기는IR_GOALS가 비어 있지 않은지 여부입니다 — 이는 기본 빌드가 LLVM IR 생성을 추가로 트리거할지 결정합니다. 단지 "실행"만 원하는 독자라면EMIT_LLVM_IR=0를 유지하여 최단 경로를 따르면 됩니다.

1.2 최소 실행 가능 프로그램의 전제 조건

직관적 모델

NCCL 프로그램을 작성하는 것은 다자간 전화 회의를 조직하는 것과 같습니다. 먼저 확인해야 할 것: 몇 명이 참여하는지(장치 수), 각자가 누구인지(rank), 어떤 회선으로 통화하는지(stream). 이 중 하나라도 빠지면 회의는 열릴 수 없습니다. 이 절에서는01_communicators예제를 통해 이 세 가지 전제 조건이 코드에서 어떻게 생겼는지 살펴봅니다.

데이터 구조: 세 개의 배열이 모든 상태를 담당

📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:88-92는 예제의 핵심 변수를 정의합니다:

c
int num_gpus;                 // Number of available CUDA devices
ncclComm_t *comms = NULL;     // Array of NCCL communicators (one per GPU)
cudaStream_t *streams = NULL; // Array of CUDA streams (one per GPU)
int *devices = NULL;          // Array of device IDs to use

여기서 NCCL 단일 프로세스 다중 GPU 프로그래밍 모델의 핵심이 드러납니다:각 GPU에 하나의 통신 도메인, 하나의 stream, 하나의 장치 번호. 세 배열의 길이는 모두num_gpus이며, 인덱스i는i번째 GPU에 대응합니다.

ncclComm_t는 헤더 파일에서 불투명 포인터로 정의됩니다.📎 src/nccl.h.in:36는 실제 타입을 보여줍니다:

c
typedef struct ncclComm* ncclComm_t;
〔설계 추론 및 아키텍처 트레이드오프〕

"불투명 포인터"(opaque pointer)는 C 언어에서 정보 은닉을 구현하는 고전적 기법입니다: 헤더 파일은struct ncclComm*라는 포인터 타입만 노출하고, 사용자 코드는 구조체 내부 필드에 접근할 수 없으며 모든 작업은 API 함수를 통해야 합니다. 이렇게 하면 NCCL은 ABI를 깨뜨리지 않고ncclComm의 내부 레이아웃을 자유롭게 수정할 수 있습니다. 초보 독자에게는 "블랙박스 핸들을 받았고 공식 인터페이스로만 조작할 수 있다"고 이해하면 됩니다.

단계별: 장치 탐지부터 통신 도메인 생성까지

첫 번째 단계: 장치 수 탐지. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:96-104는cudaGetDeviceCount를 호출하고 0인지 확인합니다:

c
CUDACHECK(cudaGetDeviceCount(&num_gpus));

if (num_gpus == 0) {
    fprintf(stderr, "ERROR: No CUDA devices found on this system\n");
    ...
    return 1;
}

이 단계에서 하는 일: CUDA 런타임에 "이 머신에 GPU가 몇 장 있는가"를 묻습니다. 0이 반환되면 사용 가능한 장치가 없다는 뜻이므로 프로그램이 바로 종료됩니다 — 가장 앞선 가드 조건입니다.

두 번째 단계: 호스트 메모리 할당 및 장치 목록 채우기. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:114-121는 세 배열을 할당하고 할당 성공 여부를 확인합니다:

c
devices = (int *)malloc(num_gpus * sizeof(int));
comms = (ncclComm_t *)malloc(num_gpus * sizeof(ncclComm_t));
streams = (cudaStream_t *)malloc(num_gpus * sizeof(cudaStream_t));

if (!devices || !comms || !streams) {
    fprintf(stderr, "ERROR: Failed to allocate memory for device arrays\n");
    return 1;
}

📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:126-136는 루프로devices[i] = i를 채우고 각 장치의 속성을 출력합니다:

c
for (int i = 0; i < num_gpus; i++) {
    devices[i] = i; // Use device i for communicator i
    cudaDeviceProp prop;
    CUDACHECK(cudaGetDeviceProperties(&prop, devices[i]));
    printf("  GPU %d: %s (CUDA Device %d)\n", i, prop.name, devices[i]);
    ...
}

세 번째 단계: 각 GPU에 stream 생성. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:140-145가 핵심입니다:

c
for (int i = 0; i < num_gpus; i++) {
    CUDACHECK(cudaSetDevice(devices[i]));
    CUDACHECK(cudaStreamCreate(&streams[i]));
}

주의:cudaSetDevice는 반드시cudaStreamCreate전에 호출해야 합니다. 이는 CUDA 프로그래밍의 기본 규칙입니다:stream은 현재 활성 장치에 속합니다, 장치를 먼저 전환하지 않으면 stream이 잘못된 GPU에 생성됩니다. 초보자가 가장 쉽게 빠지는 함정 중 하나입니다.

네 번째 단계: 통신 도메인 생성. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:169는 전체 예제의 핵심 호출입니다:

c
NCCLCHECK(ncclCommInitAll(comms, num_gpus, devices));

ncclCommInitAll는 단일 프로세스 다중 GPU 시나리오의 편의 진입점입니다. 헤더 파일📎 src/nccl.h.in:301-301는 그 계약을 제시합니다:

c
/* Creates a clique of communicators (single process version).
 * This is a convenience function to create a single-process communicator clique.
 * Returns an array of ndev newly initialized communicators in comm.
 * comm should be pre-allocated with size at least ndev*sizeof(ncclComm_t).
 * If devlist is NULL, the first ndev CUDA devices are used.
 * Order of devlist defines user-order of processors within the communicator. */
ncclResult_t  ncclCommInitAll(ncclComm_t* comm, int ndev, const int* devlist);

세 매개변수의 의미:comm는 미리 할당된 통신 도메인 배열,ndev는 장치 수,devlist는 장치 번호 목록입니다(NULL 전달 시 앞ndev개 장치 사용). 호출이 반환되면comms[i]는i번째 장치의 통신 도메인이며, 그 rank는i。

다섯 번째 단계: 통신 도메인 속성 검증. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:185-189는 세 개의 조회 API로 검증합니다:

c
NCCLCHECK(ncclCommUserRank(comms[i], &rank));
NCCLCHECK(ncclCommCount(comms[i], &size));
NCCLCHECK(ncclCommCuDevice(comms[i], &device));

이 세 API의 헤더 파일 정의는 각각📎 src/nccl.h.in:396、📎 src/nccl.h.in:400、📎 src/nccl.h.in:404입니다. 이들은 세 가지 질문에 답합니다: 나는 누구인가(rank), 총 몇 명인가(size), 나는 어느 카드에 있는가(device).

통신 도메인 생성 흐름 시퀀스 다이어그램

mermaid
sequenceDiagram
    participant App as 应用主线程
    participant CUDA as CUDA Runtime
    participant NCCL as NCCL 库
    App->>CUDA: cudaGetDeviceCount(&num_gpus)
    CUDA-->>App: num_gpus = N
    loop i in 0..N-1
        App->>CUDA: cudaSetDevice(devices[i])
        App->>CUDA: cudaStreamCreate(&streams[i])
        CUDA-->>App: streams[i]
    end
    App->>NCCL: ncclCommInitAll(comms, N, devices)
    Note over NCCL: 内部为每个设备建立通信域<br/>分配 rank 0..N-1
    NCCL-->>App: comms[0..N-1]
    loop i in 0..N-1
        App->>NCCL: ncclCommUserRank(comms[i], &rank)
        NCCL-->>App: rank = i
        App->>NCCL: ncclCommCount(comms[i], &size)
        NCCL-->>App: size = N
    end

이 시퀀스 다이어그램이 드러내는 핵심:ncclCommInitAll는동기 블로킹 호출이며, 내부에서 모든 장치 간 조정을 완료하고 반환 시 모든 통신 도메인이 준비됩니다.

설계 고찰: 왜 ncclCommInitAll이 필요한가

〔설계 추론 및 아키텍처 트레이드오프〕

다중 프로세스 시나리오에서는 각 프로세스가 GPU 하나만 관리하므로ncclCommInitRank을 각자 초기화하면 된다. 하지만 단일 프로세스 다중 GPU 시나리오에서 사용자가 각 GPU마다 수동으로ncclCommInitRank을 호출하게 하면 "여러 rank 간의 동기화"를 처리해야 하는데, 단일 프로세스에는 스레드가 하나뿐이라 여러 rank의 초기화를 동시에 진행할 수 없어 교착 상태에 빠진다.ncclCommInitAll은 이러한 조정을 라이브러리 내부에 캡슐화하여, 내부 메커니즘(보통 멀티스레드나 상태 머신)으로 모든 rank의 동기화 초기화를 완료하고 사용자에게는 단순한 동기 호출로 노출한다. 이것이 "편의 함수"가 존재하는 근본적인 이유다.

1.3 한 번의 AllReduce의 완전한 외부 동작

직관적 모델

AllReduce는 집합 통신에서 가장 많이 쓰이는 연산이다: 각 참여자가 데이터를 하나씩 기여하고, 모든 사람이 모든 데이터의 합계를 받는다. 마치 조별 과제 총점 계산처럼——각자 자기 점수를 보고하면, 마지막에 모든 사람이 전체 총점을 손에 쥔다. 이 절에서는03_collectives/01_allreduce예제를 추적하며 AllReduce가 호출부터 결과 검증까지의 완전한 외부 동작을 살펴본다.

데이터 구조: 데이터 버퍼와 초기화

📎 docs/examples/03_collectives/01_allreduce/c/main.cc:59-63이 핵심 변수를 정의한다:

c
int num_gpus = 0;
ncclComm_t *comms;
cudaStream_t *streams;
float **sendbuff;
float **recvbuff;

주의:sendbuff과recvbuff은float**——포인터 배열을 가리키는 포인터다. 각sendbuff[i]은i번째 GPU의 디바이스 메모리 주소다.

📎 docs/examples/03_collectives/01_allreduce/c/main.cc:99이 데이터 규모를 정의한다:

c
const size_t size = 32 * 1024 * 1024; // 32M floats for demonstration

32M개의 float, 각 4바이트, 즉 128 MB의 송신 버퍼와 128 MB의 수신 버퍼, 각 GPU마다 하나씩.

📎 docs/examples/03_collectives/01_allreduce/c/main.cc:101-120은 각 디바이스의 초기화 루프다:

c
for (int i = 0; i < num_gpus; i++) {
    CUDACHECK(cudaSetDevice(i));
    CUDACHECK(cudaStreamCreate(&streams[i]));
    CUDACHECK(cudaMalloc((void **)&sendbuff[i], size * sizeof(float)));
    CUDACHECK(cudaMalloc((void **)&recvbuff[i], size * sizeof(float)));
    CUDACHECK(cudaMemset(sendbuff[i], 0, size * sizeof(float)));
    float rank_value = (float)i;
    CUDACHECK(cudaMemcpy(sendbuff[i], &rank_value, sizeof(float),
                         cudaMemcpyHostToDevice));
    printf("  Device %d initialized with data value %d\n", i, i);
}

이 코드의 교묘한 점: 먼저 전체 송신 버퍼를 0으로 초기화한 다음,첫 번째 요소만을i(해당 디바이스의 rank 값)으로 설정한다. 이렇게 하면 AllReduce 합산 후 첫 번째 요소의 결과가0 + 1 + 2 + ... + (num_gpus-1)가 되고 나머지 요소는 모두 0이 된다. 검증 시 첫 번째 요소만 확인하면 AllReduce가 올바른지 확인할 수 있다.

단계별: AllReduce 호출과 검증

첫 번째 단계: Group으로 감싸기. 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136이 핵심 호출이다:

c
NCCLCHECK(ncclGroupStart());
for (int i = 0; i < num_gpus; i++) {
    NCCLCHECK(ncclAllReduce(sendbuff[i], recvbuff[i], size, ncclFloat, ncclSum,
                            comms[i], streams[i]));
}
NCCLCHECK(ncclGroupEnd());

여기에는극히 중요한 세부 사항이 있다: 주석📎 docs/examples/03_collectives/01_allreduce/c/main.cc:128-129이 명확히 설명한다:

c
// NOTE: ncclGroupStart and ncclGroupEnd are essential to avoid
// deadlock when using ncclCommInitAll and multiple communication calls.

왜 반드시 Group을 써야 하는가? 헤더 파일📎 src/nccl.h.in:844-864이 설명을 제공한다:

c
/* Group semantics
 *
 * When managing multiple GPUs from a single thread, and since NCCL collective
 * calls may perform inter-CPU synchronization, we need to "group" calls for
 * different ranks/devices into a single call.
 * ...
 * Both collective communication and ncclCommInitRank can be used in conjunction
 * of ncclGroupStart/ncclGroupEnd, but not together.
 */
〔설계 추론 및 아키텍처 트레이드오프〕

핵심 모순은: 집합 통신은 모든 rank가 동시에 참여해야 하지만, 단일 스레드에서는 하나씩ncclAllReduce을 호출할 수밖에 없다. 만약 첫 번째ncclAllReduce호출이 다른 rank를 기다리며 블로킹되는데 다른 rank의 호출이 아직 발행되지 않았다면 교착 상태에 빠진다. Group 메커니즘의 역할은:ncclGroupStart이후의 모든 호출은 "등록"만 하고 실제로 시작하지 않으며,ncclGroupEnd시에 등록된 모든 연산을 함께 제출하여 동시에 진행될 수 있게 한다. 이는 마치 배달 주문 시 모든 요리를 먼저 장바구니에 담고 마지막에 함께 결제하는 것과 같다. 한 요리씩 주문하는 것이 아니라.

두 번째 단계: stream 동기화. 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142:

c
for (int i = 0; i < num_gpus; i++) {
    CUDACHECK(cudaSetDevice(i));
    CUDACHECK(cudaStreamSynchronize(streams[i]));
}

헤더 파일📎 src/nccl.h.in:854-856이 강조한다:ncclGroupEnd은 연산이stream에 큐잉됨만 보장하고, 연산이완료됨은 보장하지 않는다. 따라서 결과를 안전하게 읽으려면 반드시 stream을 명시적으로 동기화해야 한다.

세 번째 단계: 결과 검증. 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:152-169:

c
float expected = (float)(num_gpus * (num_gpus - 1) / 2);
...
for (int i = 0; i < num_gpus; i++) {
    float result;
    CUDACHECK(cudaSetDevice(i));
    CUDACHECK(cudaMemcpy(&result, recvbuff[i], sizeof(float),
                         cudaMemcpyDeviceToHost));
    if (result != expected) {
        printf("  Device %d received incorrect result: %.0f (expected %.0f)\n", i,
               result, expected);
        success = false;
    } else {
        printf("  Device %d correctly received sum: %.0f\n", i, result);
    }
}

기대값은 등차수열 합0 + 1 + ... + (N-1) = N*(N-1)/2이다. 모든 GPU가 동일한 값을 받아야 한다——이것이 바로 AllReduce의 정의다.

AllReduce 데이터 흐름도

mermaid
flowchart LR
    subgraph dev0["GPU 0 (rank 0)"]
        s0["sendbuff[0]<br/>首元素=0"]
        r0["recvbuff[0]"]
    end
    subgraph dev1["GPU 1 (rank 1)"]
        s1["sendbuff[1]<br/>首元素=1"]
        r1["recvbuff[1]"]
    end
    subgraph dev2["GPU 2 (rank 2)"]
        s2["sendbuff[2]<br/>首元素=2"]
        r2["recvbuff[2]"]
    end
    s0 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce["归约求和<br/>0+1+2=3"]
    s1 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce
    s2 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce
    reduce -->|广播结果| r0
    reduce -->|广播结果| r1
    reduce -->|广播结果| r2

이 그림은 AllReduce의 두 단계를 보여준다: 먼저 리듀스(reduce), 그다음 브로드캐스트(broadcast). 각 rank의recvbuff은 최종적으로 모두 동일한 결과를 얻는다.

설계 고찰: 왜 하나씩 호출하지 않고 Group을 쓰는가

〔설계 추론 및 아키텍처 트레이드오프〕

만약ncclGroupStart/ncclGroupEnd을 제거하면 코드는 이렇게 된다:

c
for (int i = 0; i < num_gpus; i++) {
    ncclAllReduce(sendbuff[i], recvbuff[i], size, ncclFloat, ncclSum,
                  comms[i], streams[i]);
}

단일 스레드에서 첫 번째 반복이ncclAllReduce을 호출할 때, NCCL은 모든 rank가 AllReduce를 시작할 때까지 기다려야 진행할 수 있다. 하지만 다른 rank의 호출은 아직 루프에서 실행되지 않았으므로, 첫 번째 호출은 영원히 다른 rank를 기다릴 수 없어 교착 상태에 빠진다. Group 메커니즘은 "시작"과 "실행"을 분리하여 모든 rank의 호출을 먼저 전부 등록하고 함께 실행하게 함으로써 단일 스레드 교착 상태를 근본적으로 방지한다.

1.4 통신 도메인의 수명 주기와 리소스 정리

직관적 모델

통신 도메인은 한 번의 회의와 같다. 회의 전에는签到(초기화)하고, 회의가 끝나면 해산(소멸)한다. 만약 해산 순서가 잘못되면——예를 들어 사람들이 아직 나가지 않았는데 회의실을 잠그면——문제가 생긴다. 이 절에서는 NCCL 통신 도메인의 소멸 순서와 왜 이 순서를 뒤집을 수 없는지 살펴본다.

소멸의 두 단계: Finalize와 Destroy

📎 docs/examples/03_collectives/01_allreduce/c/main.cc:176-183이 표준 소멸 흐름을 보여준다:

c
NCCLCHECK(ncclGroupStart());
for (int i = 0; i < num_gpus; i++) {
    NCCLCHECK(ncclCommFinalize(comms[i]));
}
NCCLCHECK(ncclGroupEnd());
for (int i = 0; i < num_gpus; i++) {
    NCCLCHECK(ncclCommDestroy(comms[i]));
}

헤더 파일📎 src/nccl.h.in:309-309이ncclCommFinalize의 의미를 설명한다:

c
/* Finalize a communicator. ncclCommFinalize flushes all issued communications,
 * and marks communicator state as ncclInProgress. The state will change to ncclSuccess
 * when the communicator is globally quiescent and related resources are freed; then,
 * calling ncclCommDestroy can locally free the rest of the resources (e.g. communicator
 * itself) without blocking. */
ncclResult_t  ncclCommFinalize(ncclComm_t comm);

📎 src/nccl.h.in:313-313이ncclCommDestroy:

c
/* Frees local resources associated with communicator object. */
ncclResult_t  ncclCommDestroy(ncclComm_t comm);
복사

〔설계 추론 및 아키텍처 트레이드오프〕ncclCommFinalize왜 소멸이 두 단계로 나뉘는가?은전역 연산ncclCommDestroy——모든 rank가 참여해야 하며, 진행 중인 통신이 없음을 보장한다.은로컬 연산ncclCommDestroy——본 프로세스의 리소스만 해제하고 블로킹하지 않는다. 이 설계는 "모든 rank의 정적 대기"와 "로컬 리소스 해제"를 분리한다: 전자는 시간이 오래 걸릴 수 있고(네트워크 상대방을 기다려야 함), 후자는 순수 로컬 연산이다. 만약

하나만 있다면 두 가지 책임을 동시에 져야 하므로, 너무 오래 블로킹되거나 전역 정적을 보장할 수 없게 된다.

📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:221-249소멸 순서의 완전한 사슬📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:218-219이 완전한 정리 순서를 보여주며, 주석

c
// IMPORTANT: Proper cleanup is critical for NCCL applications
// Resources must be cleaned up in the correct order to avoid issues

복사

순서는:📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:224-227)

2. Finalize + Destroy 통신 도메인(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240)

3. CUDA stream 파괴(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:246-249)

4. 호스트 메모리 해제(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:253-255)

통신 도메인 상태 머신

ncclCommFinalize의 문서에서 상태 전환을 명확히 언급하고 있으며, 이는 상태 머신의 진입 조건에 부합합니다:

mermaid
stateDiagram-v2
    [*] --> Active : ncclCommInitAll() 成功
    Active --> InProgress : ncclCommFinalize()<br/>刷新在途通信
    InProgress --> Quiescent : 全局静默<br/>相关资源释放
    Quiescent --> Destroyed : ncclCommDestroy()<br/>释放本地资源
    Destroyed --> [*]
    Active --> Aborted : ncclCommAbort()<br/>中止在途操作
    Aborted --> [*]

이 상태 머신의 핵심 전환은InProgress -> Quiescent: 이는 "전역 침묵"이라는 이벤트에 의해 트리거되며, 특정 함수 호출에 의해 직접 트리거되지 않습니다. 이는ncclCommFinalize가 반환된 후, 통신 도메인이 여전히InProgress상태에 있을 수 있으며,ncclCommGetAsyncError를 폴링해야 언제Quiescent。

에 진입하는지 알 수 있음을 의미합니다

설계 사고: 왜 파괴 순서를 뒤집을 수 없는가

〔설계 추론 및 아키텍처 트레이드오프〕comms만약 CUDA stream을 먼저 파괴하고 통신 도메인을 나중에 파괴하면 어떤 문제가 발생할까? 통신 도메인 내부에 stream에 대한 참조(예: 비동기 작업의 완료 알림용)를 보유하고 있을 수 있다. stream이 먼저 파괴되면, 통신 도메인이 Finalize 시 이미 파괴된 stream에 접근하여 정의되지 않은 동작을 초래한다. 마찬가지로, 호스트 메모리(ncclCommDestroy배열)를 먼저 해제하고 통신 도메인을 나중에 파괴하면,는 댕글링 포인터를 얻게 된다. 이것이 순서가 반드시 "먼저 동기화, 그다음 통신 도메인 파괴, 그다음 stream 파괴, 마지막으로 호스트 메모리 해제"여야 하는 이유이다——。

의존 관계가 파괴 순서가 생성 순서의 역순이어야 함을 결정한다

1.5 프로덕션 함정 회피 가이드

함정 1: Group을 잊어 교착 상태 발생ncclAllReduce이는 초보자가 가장 자주 빠지는 함정이다. 단일 프로세스 다중 GPU 시나리오에서 Group 없이

를 직접 루프 호출하면, 프로그램이 첫 번째 호출에서 교착 상태에 빠진다. 증상은: 프로그램이 멈춰 움직이지 않고, CPU 사용률이 거의 0이며, 아무 출력도 없다.gdb진단 방법:ncclGroupStart/ncclGroupEnd。

를 사용하여 프로세스에 attach하고, 스택이 NCCL 내부 대기 로직에 멈춰 있는지 확인한다. 그렇다면

📎 src/nccl.h.in:854-856를 누락했는지 점검한다ncclGroupEnd함정 2: stream 동기화를 잊고 결과를 읽음📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142는recvbuff가 큐에 넣기만 보장하고 완료는 보장하지 않음을 명확히 설명한다. 만약

의 stream 동기화를 생략하고 직접cudaMemcpy를 읽으면, 완료되지 않은 데이터를 읽게 된다.증상은: 결과가 맞았다 틀렸다 하거나, 전부 0을 읽는다. 이는가 기본적으로 동기화이지만, 동기화하는 대상이cudaStreamSynchronize현재 stream

이며, AllReduce는 다른 stream에서 실행될 수 있기 때문이다. 진단 방법: 결과를 읽기 전에

를 추가하고, 문제가 사라지면 이 함정이다.ncclCommDestroy함정 3: 파괴 순서 오류로 세그멘테이션 폴트 발생cudaFree만약sendbuff/recvbuff이전에

를

했다면, 통신 도메인이 Finalize 시 여전히 이 버퍼들에 접근하여 세그멘테이션 폴트나 데이터 손상을 초래할 수 있다.

📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:198-200증상은: 프로그램이 종료 단계에서 크래시하거나, 간헐적으로 쓰레기 데이터를 읽는다. 진단 방법: 정리 코드의 순서를 점검하여 통신 도메인 파괴가 모든 CUDA 리소스 해제보다 앞서도록 보장한다.

c
if (device != devices[i]) {
    printf(" [WARNING: Expected device %d]", devices[i]);
}
에는 검증이 있다:

복사ncclCommInitAll〔설계 추론 및 아키텍처 트레이드오프〕devices[i] = irank와 device는 두 가지 다른 개념이다. rank는 통신 도메인 내의 논리 번호(0부터 nRanks-1)이고, device는 물리적 GPU 번호이다.devlist의 기본 사용법에서는{2, 0, 1}이므로 rank와 device가 정확히 일치한다. 하지만 사용자 정의

(예:

)를 전달하면, rank 0이 device 2에 대응된다. 이 두 개념을 혼동하면 데이터가 잘못된 GPU로 전송된다.

1. 이 장 요약이 장에서 우리는 세 가지를 완료했다:make examples빌드 진입점NCCL_HOME: Makefile의 전달 메커니즘과 CMake의 버전 번호 출처, CUDA 아키텍처 선택 로직을 이해했다. 핵심 결론은

2. 가 먼저 라이브러리를 빌드하고 그다음 예제를 빌드하며,가 빌드 산출물 디렉터리를 예제에 전달한다는 것이다.cudaGetDeviceCount최소 실행 가능 프로그램의 세 가지 요소ncclCommInitAll: 디바이스 수(ncclCommInitAll), rank(

3. 가 자동 할당), stream(각 GPU당 하나).는 단일 프로세스 다중 GPU의 편리한 진입점이며, 다중 rank 동기 초기화를 라이브러리 내부에 캡슐화한다.ncclGroupStart한 번의 AllReduce의 완전한 외부 동작ncclAllReduce:ncclGroupEnd가 여러cudaStreamSynchronize호출을 감싸고,

4. 가 제출하며,:ncclCommFinalize가 완료를 대기하고, 마지막으로 결과를 검증한다. Group 메커니즘은 단일 스레드 다중 GPU 시나리오에서 교착 상태를 방지하는 핵심이다.ncclCommDestroy통신 도메인 생명주기

(전역 침묵) +

(로컬 해제)의 2단계 파괴, 그리고 "먼저 동기화, 그다음 통신 도메인 파괴, 그다음 stream 파괴, 마지막으로 호스트 메모리 해제"라는 순서 제약.📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136이 장 사고와 자가 점검

Q1: 만약의 ncclGroupStart/ncclGroupEnd를 제거하고, 직접 루프로 ncclAllReduce를 호출하도록 변경하면, 단일 프로세스 다중 GPU 시나리오에서 무슨 일이 발생하는가? 왜인가?📎 src/nccl.h.in:844-864참고 해석ncclAllReduce(comms[0], ...): 교착 상태가 발생한다. 헤더 파일

이 원인을 설명한다: 집합 통신 호출은 inter-CPU 동기화를 실행할 수 있으며, 모든 rank가 동시에 참여해야 한다. 단일 스레드에서 첫 번째 루프 반복이ncclGroupStart를 호출할 때, NCCL은 다른 rank도 AllReduce를 시작할 때까지 기다려야 진행할 수 있다. 하지만 다른 rank의 호출은 아직 루프에서 실행되지 않았으므로(현재 스레드가 첫 번째 호출에 블록되어 있기 때문에), 첫 번째 호출은 영원히 다른 rank를 기다릴 수 없어 교착 상태가 된다.ncclGroupEndGroup 메커니즘의 역할은 "시작"과 "실행"을 분리하는 것이다:

이후의 모든 호출은 등록만 하고,gdbattach로 스택을 보면 NCCL 내부의 대기 로직에서 멈추고, CPU 사용률은 거의 0에 가깝다.

Q2: 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142의 cudaStreamSynchronize를 cudaDeviceSynchronize로 대체할 수 있는가? 둘은 의미상 어떤 차이가 있는가? 어떤 시나리오에서 이 대체가 문제를 일으키는가?

참고 해석: 사용할 수 있다cudaDeviceSynchronize로 대체할 수 있지만, 의미가 다르다.cudaStreamSynchronize(streams[i])는 지정된 stream上的 작업 완료만 기다린다;cudaDeviceSynchronize는 현재 디바이스上的모든stream의 작업 완료를 기다린다.

단일 프로세스 다중 GPU 시나리오에서,cudaDeviceSynchronize는 현재 디바이스만 동기화한다(cudaSetDevice에 의해 결정됨), 따라서cudaSetDevice(i)루프와 함께 사용해야 한다.cudaSetDevice,cudaDeviceSynchronize를 생략하면 기본 디바이스(보통 device 0)만 동기화되어, 다른 디바이스의 AllReduce가 아직 완료되지 않았을 수 있다.

헤더 파일📎 src/nccl.h.in:854-856는ncclGroupEnd가 큐에 넣기만 보장하고 완료는 보장하지 않음을 강조하므로, 동기화는 필수적이다.cudaStreamSynchronize를 사용하는 것이 더 정밀한데, 관련 stream만 기다리고 무관한 작업을 잘못 기다리지 않기 때문이다.cudaDeviceSynchronize를 사용할 때의 문제는: 디바이스에 다른 무관한 장시간 실행 kernel이 있으면 잘못 기다리게 되어 성능이 저하된다.

Q3: 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240의 소멸 순서는 "먼저 모든 통신 도메인을 Finalize한 후, 모든 통신 도메인을 Destroy"이다. 만약 "각 통신 도메인에 대해 먼저 Finalize한 후 Destroy"(즉, 하나의 루프에서 두 작업을 완료)로 변경하면 어떤 문제가 발생하는가?

참고 해석: Group 의미가 깨진다. 현재 작성 방식은:

c
ncclGroupStart();
for (i) ncclCommFinalize(comms[i]);
ncclGroupEnd();
for (i) ncclCommDestroy(comms[i]);

ncclCommFinalize가 Group으로 감싸져 있어, 모든 통신 도메인의 Finalize가 함께 제출되어 동시에 진행될 수 있다. 만약 다음과 같이 변경하면:

c
for (i) {
    ncclCommFinalize(comms[i]);
    ncclCommDestroy(comms[i]);
}

첫 번째 반복의ncclCommFinalize(comms[0])는 모든 rank가 조용해질 때까지 블로킹 대기하지만, 다른 통신 도메인의 Finalize는 아직 시작되지 않아 교착 상태가 발생한다——이는 Q1의 교착 상태와 같은 종류의 문제이다.

또한, 헤더 파일📎 src/nccl.h.in:309-309는ncclCommFinalize가 반환될 때 통신 도메인이 아직ncclInProgress상태일 수 있음을 설명하며, 전역적으로 조용해질 때까지 기다려야ncclSuccess로 진입할 수 있다. 만약 바로 이어서ncclCommDestroy를 하면, 통신 도메인이 완전히 조용해지기 전에 로컬 리소스를 해제하여 정의되지 않은 동작을 초래할 수 있다. 올바른 방법은 Finalize 후ncclCommGetAsyncError를 폴링하여 상태를 확인한 후 Destroy하는 것이다.

이러한 외부 동작들은 이후 모든 소스 코드 분석의 참조 체계를 구성한다. 제2장에서는 핵심 멘탈 모델을 구축할 것이다: 통신 도메인, 채널, 알고리즘, 프로토콜, 전송 계층이라는 다섯 가지 세트를 통해 NCCL 내부에서 이러한 개념들이 어떻게 조직되는지 살펴본다.

CHAPTER 02

제2장: 제2장: 핵심 추상 모델: 통신 연산자, 토폴로지, 알고리즘, 프로토콜 및 전송 계층

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 2 / 25 장

제2장: 핵심 추상 모델: 통신 연산자, 토폴로지, 알고리즘, 프로토콜 및 전송 계층

이전 장에서 우리는 NCCL을 실행시키고 ncclCommInitRank, ncclAllReduce, ncclCommDestroy 세 API의 외부 동작을 관찰했다. 하지만 외부 동작은 빙산의 일각에 불과하다——ncclAllReduce가 반환될 때 GPU上에서 도대체 무슨 일이 일어나는가? 데이터는 어느 경로로 가는가? 왜 동일한 AllReduce가 다른 머신에서 성능 차이가 큰가? 이러한 질문에 답하려면 먼저 NCCL의 공통 용어집을 구축해야 한다. 이 장에서는 다섯 가지 핵심 추상 개념을 하나씩 분해한다: 통신 도메인(ncclComm), 채널(channel), 알고리즘(algorithm), 프로토콜(protocol), 전송 계층(transport). 이 다섯 가지 개념은 전권을 관통하며, 이후 각 장의 분석에서 모두 사용된다. 이들 간의 관계를 이해하면 NCCL의 골격을 이해한 것이다.

2.1 통신 도메인 ncclComm: 한 프로세스의 통신 컨텍스트

직관적 모델

를ncclComm「그룹 채팅」이라고 상상하자: 각 프로세스가 그룹 채팅에 참여한 후 그룹 ID를 받고, 이후 모든 메시지가 이 그룹에서 발송된다. 그룹에 몇 명이 있는지(nRanks), 내가 누구인지(rank), 어떤 회선을 타는지(channels), 어떤 규칙을 사용하는지(config)가 모두 이 그룹 채팅 객체에 기록된다.

만약ncclComm가 없다면, NCCL은 「누가 누구와 통신하는지」「데이터가 어디로 가는지」를 알 수 없다——매번 API를 호출할 때마다 rank 목록을 재협상하고 연결을 재구축해야 하므로, 오버헤드를 감당할 수 없다.

데이터 구조와 메모리 레이아웃

ncclComm는 전체 NCCL에서 가장 핵심적인 구조체로,src/include/comm.h에 정의되어 있다. 그것은 극도로 방대하며(거의 300줄), 우리는 기능별로 그룹화하여 핵심 필드를 살펴본다.

신원 식별과 수명 주기 센티넬

📎 src/include/comm.h:576-580는startMagic,📎 src/include/comm.h:879-881를 정의하고endMagic를 정의한다. 이 두 필드는 보안 키가 아니라 메모리 경계 초과 감지 센티넬이다.📎 src/include/comm.h:883-885에 두 개의static_assert:

c
static_assert(offsetof(struct ncclComm, startMagic) == 0, "startMagic must be the first field of ncclComm");
static_assert(offsetof(struct ncclComm, endMagic) == sizeof(struct ncclComm) - sizeof(uint64_t),
              "endMagic must be the last field of ncclComm");
〔설계 추론과 아키텍처 트레이드오프〕

이 두 단언은 컴파일 시startMagic가 구조체 첫 주소에,endMagic가 끝에 위치하도록 강제한다. 런타임에 이 두 매직 넘버가 변조되었는지 확인하여ncclComm포인터가 유효한지 빠르게 판단할 수 있다——이는 다중 스레드 환경에서 「와일드 포인터가 소멸된 통신 도메인에 접근」하는 종류의 버그를排查할 때 매우 유용하다.

Rank와 토폴로지 정보

📎 src/include/comm.h:628-629는rank와nRanks를 정의한다——통신 도메인에서의 내 번호와 총 참여자 수.📎 src/include/comm.h:644-652는 노드 관련 필드를 정의한다:node(내가 있는 노드 번호),nNodes(총 노드 수),localRank(노드 내 번호),localRanks(노드 내 GPU 수), 그리고 세 개의 매핑 테이블rankToNode、rankToLocalRank、localRankToRank。

〔설계 추론과 아키텍처 트레이드오프〕

이 세 개의 매핑 테이블은 토폴로지 인식 알고리즘의 기초입니다. 예를 들어 Ring 알고리즘은 '내 다음 rank가 같은 노드 내에 있는지'를 알아야 NVLink를 탈지 네트워크를 탈지 결정할 수 있습니다. 이러한 매핑 테이블이 없으면 알고리즘 선택 시마다 토폴로지 그래프를 다시 쿼리해야 하므로 오버헤드가 막대합니다.

채널과 버퍼

📎 src/include/comm.h:593-593는 다음을 정의합니다channels[MAXCHANNELS]——이것은 통신 도메인 내 모든 채널의 배열입니다.📎 src/include/comm.h:674-676는 채널 수를 정의합니다:nChannels(연결 채널 수),collChannels(집합 통신 인큐 채널 수),nvlsChannels(NVLS 채널 수).

📎 src/include/comm.h:691-693는 버퍼 크기를 정의합니다:buffSizes[NCCL_NUM_PROTOCOLS](각 프로토콜의 버퍼 크기),p2pChunkSize(P2P 블록 크기),nvlsChunkSize(NVLS 블록 크기).

〔설계 추론 및 아키텍처 트레이드오프〕

buffSizes배열의 인덱스는 프로토콜 열거형 값(LL/LL128/Simple)입니다. 이는 각 프로토콜이 독립적인 버퍼 크기 구성을 가진다는 것을 의미합니다. LL 프로토콜은 지연 시간을 줄이기 위해 작은 버퍼가 필요하고, Simple 프로토콜은 대역폭을 높이기 위해 큰 버퍼가 필요합니다——이 배열은 두 요구 사항이 공존할 수 있게 합니다.

작업 큐와 FIFO

📎 src/include/comm.h:719-728는 작업 FIFO 관련 필드를 정의합니다:workFifoBytes(FIFO 크기, 2의 거듭제곱),workFifoBuf(호스트 측 FIFO 버퍼),workFifoBufDev(디바이스 측 FIFO 버퍼),workFifoProduced(생산된 바이트 수),workFifoConsumed(소비된 바이트 수).

〔설계 추론 및 아키텍처 트레이드오프〕

이것은 전형적인 생산자-소비자 링 버퍼입니다. 호스트 측(생산자)이 작업 설명을 FIFO에 쓰고, GPU kernel(소비자)이 읽어서 실행합니다.workFifoBytes는 반드시 2의 거듭제곱이어야 합니다. 이렇게 하면 모듈로 연산 대신 비트 마스크를 사용할 수 있어 인덱스 계산이 빨라집니다.

프로세스 내 동기화 배리어

📎 src/include/comm.h:731-731는 프로세스 내 다중 통신 도메인 동기화 메커니즘을 정의합니다:

c
struct ncclComm* intraComm0; // leader of intra-process comms (self possible)
struct ncclComm* intraNext; // next of intra-process comms, intraComm0 is head
int intraRank;
int intraRanks;
uint32_t intraBarrierPhase;
char intraPad1[64 - sizeof(uint64_t)];
uint64_t intraBarrierCounter; // only used if this is intraComm0
char intraPad2[64 - sizeof(uint64_t)];
uint64_t intraBarrierGate; // only used if this is intraComm0

주의intraPad1와intraPad2의 크기는64 - sizeof(uint64_t), 즉 56바이트입니다. 앞의uint64_t필드를 더하면 각 필드 그룹은 정확히 64바이트——즉 하나의 캐시 라인(Cache Line)을 차지합니다.

〔설계 추론 및 아키텍처 트레이드오프〕

이것은 전형적인캐시 라인 패딩(Cache Line Padding)기법입니다.intraBarrierCounter와intraBarrierGate는 여러 스레드에 의해 빈번히 읽히고 쓰입니다. 만약 이들이 같은 캐시 라인을 공유하면거짓 공유(False Sharing)가 발생합니다: 한 스레드가intraBarrierCounter를 수정하면 다른 스레드의intraBarrierGate캐시가 무효화되어 성능이 급격히 저하됩니다. 56바이트 패딩으로 이들을 서로 다른 캐시 라인으로 분리하는 것은 고성능 동시성 프로그래밍의 표준 기법입니다.

비동기 오류 상태

📎 src/include/comm.h:705-705는asyncResult를 정의합니다——이 필드는 통신 도메인의 비동기 작업 상태를 기록합니다. 이전 장에서ncclCommFinalize가 반환될 때 통신 도메인이 아직ncclInProgress상태일 수 있다고 언급했는데, 바로 이 필드를 통해 추적됩니다.

시나리오 기반 Walkthrough: ncclCommInitRank에서 구조체 채우기까지

사용자가ncclCommInitRank(&comm, nranks, commId, rank)를 호출하면 NCCL 내부에서ncclComm구조체를 할당하고 필드별로 채웁니다. 이 흐름을 따라가며 주요 필드가 어떻게 설정되는지 살펴보겠습니다:

1단계: 할당 및 초기화

NCCL은ncclCalloc를 사용하여ncclComm를 할당하고 모든 필드가 0으로 초기화되도록 합니다. 이때startMagic와endMagic는NCCL_MAGIC(📎 src/include/comm.h:563-569로 설정됩니다(0x0280028002800280는

로 정의되며, 주석에는 "Nickel atomic number is 28"이라고 되어 있습니다).

rank、nRanks、cudaDev2단계: 신원 정보 채우기commHash는 매개변수와 CUDA API에서 가져옵니다.ncclCommId는

를 해시하여 얻으며, 이후 네트워크 통신에서 일관성 검증에 사용됩니다.

3단계: 토폴로지 그래프 구축topoNCCL은 토폴로지 탐지 모듈을 호출하여 모든 GPU, NIC, PCI 스위치를 열거하고📎 src/include/comm.h:595-595필드(

)를 구축합니다. 이 토폴로지 그래프가 이후 알고리즘 선택과 경로 계획을 결정합니다.

channels[MAXCHANNELS]4단계: 채널 초기화id배열이 하나씩 초기화됩니다. 각 채널의peers는 배열 인덱스로 설정되고,devPeers와

포인터가 할당됩니다.

5단계: 전송 연결 설정setup토폴로지 그래프에 따라 NCCL은 각 rank 쌍에 대해 전송 계층(P2P/SHM/NET)을 선택하고 해당connect와channels[i].peers[j]콜백을 호출합니다. 연결 정보는

에 저장됩니다.

6단계: 매직 넘버 설정endMagic마지막으로,NCCL_MAGIC가

로 설정되어 구조체 초기화가 완료되었음을 표시합니다.

설계 고찰과 실무 함정ncclComm왜

가 이렇게 큰가?

ncclComm〔설계 추론 및 아키텍처 트레이드오프〕

는 거의 300개의 필드를 포함하는데, 이는 하나의 통신 도메인의 전체 상태를 담고 있기 때문입니다. NCCL의 설계 철학은 '한 번 초기화, 여러 번 재사용'입니다——초기화 시 사용 가능한 모든 정보를 미리 계산하여 저장하고, 런타임에는 테이블을 직접 조회하여 반복 계산을 피합니다. 대가는 메모리 사용량이 다소 크다는 것(통신 도메인당 약 몇 KB)이지만, GPU 메모리와 네트워크 대역폭에 비하면 이 정도 메모리는 미미합니다.

함정 시나리오 1: 다중 스레드가 통신 도메인 공유

ncclComm〔설계 추론 및 아키텍처 트레이드오프〕ncclComm는 스레드 안전하지 않습니다. 두 스레드가 동시에 같은ncclAllReduce,workFifoProduced에 대해

등을 호출하면 필드 경쟁이 발생하여 데이터가 손상됩니다. 올바른 방법은 각 스레드가 독립적인 통신 도메인을 사용하거나 외부 잠금으로 호출을 직렬화하는 것입니다.

ncclCommDestroy함정 시나리오 2: 파괴 후 접근startMagic가 구조체 메모리를 해제한 후에도 스레드가 포인터를 보유하고 접근하면 해제된 메모리를 읽게 됩니다.endMagic와

는 이러한 상황을 감지하는 데 도움이 됩니다——매직 넘버가 일치하지 않으면 포인터가 무효화되었음을 의미합니다.

함정 시나리오 3: 캐시 라인 거짓 공유intraBarrierCounter다중 프로세스 시나리오(프로세스당 하나의 rank)에서intraBarrierGate와

의 패딩은 특히 중요합니다. 패딩을 생략하면 여러 프로세스의 배리어 작업이 서로 간섭하여 동기화 지연이 나노초 수준에서 마이크로초 수준으로 증가합니다.

2.2 채널 channel: 하나의 통신을 여러 파이프라인으로 분할

직관적 모델channelNCCL의 「컨베이어 벨트」——집합 통신 한 번의 데이터를 여러 조각으로 나누고, 각 채널이 독립적으로 한 조각을 운반하며 병렬로 진행하여 대역폭 활용률을 높인다.

채널이 없으면 모든 데이터가 단 하나의 경로로만 흐를 수 있어, GPU 간의 여러 물리 링크(여러 NIC, 여러 NVLink 그룹)를 동시에 활용할 수 없고 대역폭 활용률이 크게 떨어진다.

데이터 구조와 메모리 레이아웃

ncclChannel정의 위치📎 src/include/comm.h:169-191:

c
struct ncclChannel {
  struct ncclChannelPeer** peers;
  struct ncclDevChannelPeer** devPeers;
  /* devPeer pointer array used for host side access */
  struct ncclDevChannelPeer** devPeersHostPtr;
  struct ncclRing ring;
  int* devRingUserRanks;
  struct ncclTree tree;

  struct ncclTree collnetChain;
  struct ncclDirect collnetDirect;

  struct ncclNvls nvls;

  int id; // index of this channel
  uint32_t workFifoProduced; // +1 successor of last used work fifo byte

  /* comm split sharable resources */
  struct ncclChannelPeer* collnetPeers;
  struct ncclDevChannelPeer* collnetDevPeers;
  struct ncclChannelPeer* nvlsPeers;
  struct ncclDevChannelPeer* nvlsDevPeers;
};

주요 필드 분석

  • peers / devPeers: 해당 채널 내 모든 rank의 연결 정보를 가리킨다.peers는 호스트 측 뷰이고,devPeers는 디바이스 측 뷰이다(GPU kernel이 직접 접근).
  • ring: Ring 알고리즘의 토폴로지 설명——각 rank의 전임자와 후임자.
  • tree: Tree 알고리즘의 토폴로지 설명——부모 노드와 자식 노드 리스트.
  • collnetChain / collnetDirect: CollNet 알고리즘의 두 가지 변형 토폴로지.
  • nvls: NVLink SHARP의 토폴로지 설명.
  • id: 채널 인덱스, 0부터nChannels-1。
  • workFifoProduced: 해당 채널의 작업 FIFO 생산 포인터.
〔설계 추론 및 아키텍처 트레이드오프〕

주의ring、tree、collnetChain、collnetDirect、nvls이 다섯 필드는병렬이다——동일한 채널이 동시에 여러 알고리즘의 토폴로지 설명을 보유할 수 있다. 런타임에 알고리즘 선택에 따라 어느 필드를 사용할지 결정한다. 이 설계 덕분에 알고리즘 전환 시 채널을 재구축할 필요 없이 읽는 필드만 바꾸면 된다.

채널 수 계산

채널 수는ncclComm에 정의된다(📎 src/include/comm.h:674-676):

c
int nChannels; // connection nChannels
int collChannels; // enqueue nChannels
int nvlsChannels; // enqueue nChannels
〔설계 추론 및 아키텍처 트레이드오프〕

nChannels는 실제로 설정된 연결 수이고,collChannels는 집합 통신 인큐 시 사용되는 채널 수이며,nvlsChannels는 NVLS 전용 채널 수이다. 세 가지가 다를 수 있다——예를 들어 일부 채널은 P2P에만 사용되고 집합 통신에는 사용되지 않는다.

P2P 채널 스케줄링

📎 src/include/channel.h:21-33가ncclP2pChannelBaseForRound함수를 정의하며, P2P 통신에서 각 round에 사용되는 채널 기반 주소를 계산한다:

c
inline uint8_t ncclP2pChannelBaseForRound(struct ncclComm* comm, int p2pRound) {
  int base;
  if (comm->nNodes > 1) {
    int localSize = comm->p2pSchedGroupSize;
    int groupDelta = p2pRound / localSize;
    int localDelta = p2pRound % localSize;
    base = groupDelta * divUp(localSize, NCCL_MAX_DEV_WORK_P2P_PER_BATCH);
    base += localDelta / NCCL_MAX_DEV_WORK_P2P_PER_BATCH;
  } else {
    base = p2pRound;
  }
  return reverseBits(base, log2Up(comm->p2pnChannels));
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 함수의 로직은: 다중 노드 시나리오에서는 P2P 통신이 「그룹」 단위로 스케줄링되고, 각 그룹 내 rank는 인접 채널을 사용한다; 단일 노드 시나리오에서는 각 round가 직접 하나의 채널에 매핑된다.reverseBits는 비트 반전 연산으로, 채널 할당을 분산시켜 핫스팟 집중을 방지한다.

시나리오 기반 Walkthrough: AllReduce 한 번이 채널을 어떻게 할당하는가

8개 rank, 4개 채널이라고 가정하고 AllReduce를 한 번 실행한다. 데이터는 4조각으로 나뉘고, 각 조각을 하나의 채널이 담당한다.

1단계: 알고리즘 선택

NCCL의 tuning 모듈이 메시지 크기와 토폴로지에 따라 알고리즘(예: Ring)과 프로토콜(예: Simple)을 선택한다.

2단계: 채널 할당

ncclTaskColl구조체(📎 src/include/comm.h:212-273)가 생성되고, 그중nChannels필드가 4로 설정된다(📎 src/include/comm.h:254-254)。channelLo와channelHi필드(📎 src/include/comm.h:256-257)는 해당 작업이 사용하는 채널 범위를 표시한다.

3단계: 데이터 분할

각 채널이count / nChannels개 요소를 담당한다. 채널 0은 0번째부터 count/4-1번째 요소를 처리하고, 채널 1은 count/4번째부터 count/2-1번째 요소를 처리하는 식이다.

4단계: 병렬 실행

4개 채널의 GPU kernel이 동시에 시작되고, 각자 자신의 데이터 슬라이스에서 Ring AllReduce를 수행한다. 채널 간에 데이터 의존성이 없으므로 완전히 병렬로 실행할 수 있다.

5단계: 결과 병합

모든 채널이 완료되면 각 rank의 recv buffer에는 완전한 AllReduce 결과가 담긴다.

동시성 제어와 하드웨어 상호작용

채널과 GPU 리소스의 매핑

〔설계 추론 및 아키텍처 트레이드오프〕

각 채널은 일반적으로 독립적인 CUDA stream 또는 GPU 하드웨어 큐에 바인딩된다. 이렇게 하면 서로 다른 채널의 kernel이 GPU에서 동시에 실행되어 SM(스트리밍 멀티프로세서) 리소스를 충분히 활용할 수 있다.

채널과 네트워크 디바이스의 매핑

다중 NIC 시나리오에서는 서로 다른 채널을 서로 다른 NIC에 바인딩할 수 있다. 예를 들어 4개 채널, 2개 NIC라면 채널 0과 1은 NIC A를, 채널 2와 3은 NIC B를 사용한다. 이렇게 하면 두 NIC의 대역폭을 모두 활용할 수 있다.

채널 수 선택

〔설계 추론 및 아키텍처 트레이드오프〕

채널 수가 많을수록 좋은 것은 아니다. 채널 수 증가는 다음을 초래한다:

  • 더 많은 kernel 시작 오버헤드
  • 더 많은 연결 설정 오버헤드
  • 더 복잡한 동기화

NCCL의 tuning 모듈은 메시지 크기에 따라 최적의 채널 수를 자동으로 선택한다. 작은 메시지는 적은 채널(오버헤드 감소), 큰 메시지는 많은 채널(대역폭 향상)을 사용한다.

프로덕션 함정 회피 가이드

함정 시나리오 1: 채널 수 설정 부적절

〔설계 추론 및 아키텍처 트레이드오프〕

만약 수동으로NCCL_NCHANNELS를 너무 크게 설정하면, 작은 메시지 시나리오에서 kernel 시작 오버헤드가 이득을 초과하여 성능이 오히려 떨어진다. 명확한 튜닝 요구가 없다면 NCCL이 자동 선택하도록 하는 것을 권장한다.

함정 시나리오 2: 채널과 토폴로지 불일치

〔설계 추론 및 아키텍처 트레이드오프〕

채널 수가 물리 링크 수를 초과하면 일부 채널이 링크를 공유하게 되어 진정한 병렬을 달성할 수 없다. 예를 들어 2개 NIC에 8개 채널을 설정하면 실제로는 2개 채널만 동시 전송이 가능하고 나머지 6개는 대기한다.

함정 시나리오 3: P2P 채널 충돌

ncclP2pChannelBaseForRound의reverseBits연산이 잘못 구현되면 여러 round가 동일 채널에 매핑되어 직렬화가 발생한다.📎 src/include/channel.h:32-32의reverseBits(base, log2Up(comm->p2pnChannels))는 채널 할당을 균등하게 보장한다.

2.3 알고리즘 algorithm: Tree/Ring/CollNet/NVLS/PAT의 토폴로지 구성

직관적 모델

베이징에서 상하이까지 고속철도, 비행기 또는 자가용으로 갈 수 있으며, 각 방식은 서로 다른 거리와 인원에 적합합니다. NCCL의 알고리즘은 바로 이러한 '이동 방식'입니다 — Ring은 대용량 메시지의 안정적인 대역폭에 적합하고, Tree는 소용량 메시지의 저지연에 적합하며, CollNet은 네트워크 카드 오프로드를 활용하고, NVLS는 NVLink SHARP 하드웨어 가속을 활용하며, PAT는 NVLS의 병렬화 변형입니다.

알고리즘 선택이 없다면 NCCL은 하나의 고정된 모드로만 통신할 수 있어 다양한 메시지 크기와 토폴로지 구조에 적응할 수 없고, 성능이 크게 저하됩니다.

데이터 구조와 메모리 레이아웃

Ring 알고리즘

Ring 알고리즘의 핵심은ncclRing구조체(src/include/comm.h에서channels[i].ring를 통해 참조)입니다.📎 src/include/collectives.h:81-116는RingAlgorithm기반 클래스를 정의합니다:

c
class RingAlgorithm {
protected:
  int refCount;
  int nRanks;
  int nStepsPerLoop;
  int chunkSteps;
  int sliceSteps;
  ssize_t sliceSize;
  ssize_t loopSize;
  ssize_t channelSize;
  uint8_t* sendbuff;
  uint8_t* recvbuff;
  void* sendMhandle;
  void* recvMhandle;
  void* srecvMhandle;

public:
  virtual void getNextSendAddr(int curStep, uint8_t** sendbuffOut, size_t* sizeOut, void** mhandleOut) = 0;
  virtual void getNextRecvAddr(int curStep, uint8_t** recvbuffOut, size_t* sizeOut, void** mhandleOut) = 0;
  int incRefCount() {
    return (int)COMPILER_ATOMIC_ADD_FETCH(&refCount, 1, std::memory_order_relaxed);
  }
  int decRefCount() {
    return (int)COMPILER_ATOMIC_SUB_FETCH(&refCount, 1, std::memory_order_release);
  }
  RingAlgorithm() {
    refCount = 0;
  }
  virtual ~RingAlgorithm() {};
};

주요 필드 분석

  • refCount: 참조 카운트로, proxy 스레드와 GPU kernel이 알고리즘 객체를 공유하는 데 사용됩니다.
  • nRanks: 링 위의 노드 수.
  • nStepsPerLoop: 매 루프의 스텝 수. AllReduce는2*(nRanks-1)*chunkSteps(📎 src/include/collectives.h:218-218)。
  • chunkSteps / sliceSteps: 블록 스텝 수와 슬라이스 스텝 수로, 파이프라인 세분성을 제어합니다.
  • sliceSize / loopSize / channelSize: 슬라이스 크기, 루프 크기, 채널 크기.
  • sendbuff / recvbuff: 송신 및 수신 버퍼 포인터.
  • sendMhandle / recvMhandle / srecvMhandle: 메모리 핸들로, 네트워크 등록에 사용됩니다.

참조 카운트의 원자적 연산

📎 src/include/collectives.h:106-108은incRefCount과decRefCount:

c
int incRefCount() {
  return (int)COMPILER_ATOMIC_ADD_FETCH(&refCount, 1, std::memory_order_relaxed);
}
int decRefCount() {
  return (int)COMPILER_ATOMIC_SUB_FETCH(&refCount, 1, std::memory_order_release);
}
〔설계 추론 및 아키텍처 트레이드오프〕

incRefCount는memory_order_relaxed을 사용합니다 — 참조 카운트를 증가시킬 때는 동기화가 필요 없고, 원자성만 보장하면 됩니다.decRefCount는memory_order_release을 사용합니다 — 참조 카운트를 감소시킬 때는 이전 쓰기 작업이 다른 스레드에 보이는지 확인해야 합니다(객체 소멸을 트리거할 수 있기 때문).

RingARAlgorithm: AllReduce의 Ring 구현

📎 src/include/collectives.h:118-234은RingARAlgorithm를 정의하며,RingAlgorithm를 상속받습니다. 핵심 메서드는getNextSendAddr과getNextRecvAddr。

📎 src/include/collectives.h:126-167의getNextSendAddr로직:

c
void getNextSendAddr(int curStep, uint8_t** sendbuffOut, size_t* sizeOut, void** mhandleOut) {
  int curLoop = curStep / nStepsPerLoop;
  int curLoopStage = (curStep % nStepsPerLoop) / chunkSteps;
  int chunkStage = curLoopStage % nRanks;
  int sliceStage = (curStep % chunkSteps) / sliceSteps;
  ssize_t elemOffset = curLoop * loopSize;
  ssize_t remSize = channelSize - elemOffset;
  // ... 计算 chunkOffset, sliceOffset, curSliceSize ...
  if (remSize < loopSize) {
    curChunkSize = alignUp(divUp(remSize / elemSize, nRanks), 16 / elemSize) * elemSize;
  } else {
    curChunkSize = chunkSize;
  }
  chunkId = (ringIndex + nRanks - 1 - chunkStage) % nRanks;
  chunkOffset = chunkId * curChunkSize;
  nelem = std::min(remSize - chunkOffset, curChunkSize);
  curSliceSize = std::max(divUp(nelem / elemSize, 16 * slicePerChunk) * 16, sliceSize / elemSize / 32) * elemSize;
  sliceOffset = sliceStage * curSliceSize;
  // ... 设置 sendbuffOut, sizeOut, mhandleOut ...
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 코드의 핵심은주소 계산입니다: 현재 스텝 수curStep가 주어지면, 어느 데이터 블록의 어느 슬라이스를 전송해야 하는지 계산합니다.chunkId의 계산(ringIndex + nRanks - 1 - chunkStage) % nRanks은 링 위의 역방향 전파를 구현합니다 — 각 rank는 전임자로부터 데이터를 수신하고, 처리 후 후임자에게 전송합니다.

PAT 알고리즘

PAT(Parallel Aggregated Tree)는 NVLS의 병렬화 변형입니다.📎 src/include/collectives.h:416-423은ncclPatStep:

c
struct ncclPatStep {
  int recvDim, sendDim, recvOffset, sendOffset, stepOffset, postRecv, postSend, nelem, last, flags;
  // PAT algo computation thread step number; -1 while the slot is free.
  int step;
  // This PAT group's offset within the shared NVLS slot.
  int nvlsOffset;
  size_t inpIx, outIx;
};

📎 src/include/collectives.h:425-435은ncclPatPeer:

c
struct ncclPatPeer {
  uint64_t step;
  struct ncclConnInfo* conn;
  struct ncclConnFifo* connFifo;
  void* buff;
  uint64_t* headPtr;
  uint64_t* tailPtr;
  uint64_t stepCache;
  long long int accSize;
  int connStepSize;
};
〔설계 추론 및 아키텍처 트레이드오프〕

PAT 알고리즘의 핵심 사상은여러 작은 단계를 하나의 큰 단계로 집계하여 동기화 오버헤드를 줄이는 것입니다.ncclPatStep은 집계 단계의 송수신 차원, 오프셋, 요소 수 등의 정보를 설명합니다.ncclPatPeer은 피어 노드의 연결 상태와 버퍼 포인터를 설명합니다.

시나리오 기반 Walkthrough: Ring AllReduce의 단계 진화

4개의 rank(0, 1, 2, 3)가 있고, 각 rank에 4개의 요소가 있다고 가정하고 Ring AllReduce를 실행합니다.

Reduce-Scatter 단계

  • 스텝 0: rank 0이 요소 0을 rank 1에 전송하고, rank 1이 요소 1을 rank 2에 전송하며, rank 2가 요소 2를 rank 3에 전송하고, rank 3이 요소 3을 rank 0에 전송합니다.
  • 스텝 1: 각 rank는 수신한 요소를 로컬의 대응 요소와 더한 후, 다음 rank에 전송합니다.
  • 스텝 2: 계속 누적하고 전달합니다.
  • 스텝 3: 이 시점에서 각 rank는 하나의 완전한 리덕션 결과를 가집니다(rank 0은 요소 3의 결과, rank 1은 요소 0의 결과 등).

AllGather 단계

  • 스텝 4-6: 각 rank는 자신이 가진 리덕션 결과를 링을 따라 전파하고, 최종적으로 모든 rank가 완전한 결과를 가집니다.

📎 src/include/collectives.h:218-218의nStepsPerLoop = 2 * (nRanks - 1) * chunkSteps는 이 흐름에 정확히 대응합니다: Reduce-Scatter는(nRanks-1)*chunkSteps스텝이 필요하고, AllGather도(nRanks-1)*chunkSteps스텝이 필요하며, 총2*(nRanks-1)*chunkSteps스텝입니다.

설계 사고와 프로덕션 함정

왜 Ring과 Tree가 공존하는가?

〔설계 추론 및 아키텍처 트레이드오프〕

Ring 알고리즘은 대역폭 활용률이 높지만(모든 링크가 전송 중), 지연이 rank 수에 따라 선형으로 증가합니다. Tree 알고리즘의 지연은 로그 스케일이지만 대역폭 활용률이 낮습니다(일부 링크만 작동). NCCL은 메시지 크기에 따라 자동 선택합니다: 소용량 메시지는 Tree(지연 민감), 대용량 메시지는 Ring(대역폭 민감).

함정 시나리오 1: 알고리즘 선택 오류

〔설계 추론 및 아키텍처 트레이드오프〕

소용량 메시지에 Ring을 수동으로 강제 사용하면 지연이 현저히 증가합니다. 명확한 성능 분석 데이터가 수동 개입을 뒷받침하지 않는 한, tuning 모듈이 자동 선택하도록 하는 것이 권장됩니다.

함정 시나리오 2: NVLS 하드웨어 미지원

NVLS는 특정 하드웨어 지원(NVLink SHARP)이 필요합니다. 하드웨어가 지원하지 않는데 코드가 NVLS를 강제 사용하면 Ring 또는 Tree로 폴백되지만, 성능 변동이 동반될 수 있습니다.📎 src/include/comm.h:755-755의nvlsSupport필드는 하드웨어가 NVLS를 지원하는지 표시합니다.

함정 시나리오 3: PAT 알고리즘의 집계 인자 설정

PAT 알고리즘의aggFactor은 몇 개의 단계를 집계할지 결정합니다.📎 src/include/collectives.h:537-560은aggFactor의 계산 로직을 보여줍니다:

c
aggFactor = 1;
size_t channelSize = end - offset;
while (stepSize / (channelSize * sizeof(T) * aggFactor) >= 2 && aggFactor < nranks / 2) {
  aggFactor *= 2;
  aggDelta /= 2;
}
postFreq = aggFactor;
if (postFreq < parallelFactor) parallelFactor = postFreq;
int d = stepDepth;
while (d > 1 && aggFactor < nranks / 2) {
  d /= 2;
  aggFactor *= 2;
  aggDelta /= 2;
}
〔설계 추론 및 아키텍처 트레이드오프〕

aggFactor가 너무 작으면 동기화 오버헤드가 커지고, 너무 크면 파이프라인 버블이 발생합니다. NCCL은stepSize、channelSize、nranks에 따라 최적값을 자동 계산합니다.

2.4 프로토콜 protocol: LL/LL128/Simple 세 가지 데이터 전송 전략

직관적 모델

택배를 보낼 때 「동일 도시 당일 배송」「익일 배송」 또는 「일반 택배」를 선택할 수 있으며, 속도와 비용이 다릅니다. NCCL의 프로토콜이 바로 이러한 「발송 방식」입니다 — LL(Low Latency)은 작은 메시지의 저지연 전송에 적합하고, LL128은 중간 크기 메시지의 128바이트 정렬 전송에 적합하며, Simple은 큰 메시지의 고대역폭 전송에 적합합니다.

프로토콜 선택이 없다면, NCCL은 하나의 고정된 전략으로만 데이터를 운반할 수 있어 지연과 대역폭 사이의 균형을 맞출 수 없습니다.

데이터 구조와 메모리 레이아웃

프로토콜 열거형

📎 src/include/comm.h:55-57프로토콜 관련 스레드 임계값을 정의합니다:

c
#define NCCL_LL_THREAD_THRESHOLD 8
#define NCCL_LL128_THREAD_THRESHOLD 8
#define NCCL_SIMPLE_THREAD_THRESHOLD 64
〔설계 추론 및 아키텍처 트레이드오프〕

이 임계값들은 각 프로토콜이 몇 개의 스레드를 사용하는지 결정합니다. LL과 LL128은 8개의 스레드를 사용하고(저지연, 적은 스레드로 충분), Simple은 64개의 스레드를 사용합니다(고대역폭, 더 많은 스레드의 병렬 운반 필요).

프로토콜 버퍼

📎 src/include/comm.h:691-691다음을 정의합니다buffSizes[NCCL_NUM_PROTOCOLS]——각 프로토콜은 독립적인 버퍼 크기를 가집니다.

프로토콜 관련 FIFO 구조

📎 src/include/comm.h:59-83다음을 정의합니다ncclSendMem그리고ncclRecvMem:

c
struct ncclSendMem {
  union {
    struct {
      uint64_t head;
      char pad1[CACHE_LINE_SIZE - sizeof(uint64_t)];
      void* ptrExchange;
      uint64_t redOpArgExchange[2];
      char pad2[CACHE_LINE_SIZE - sizeof(void*) - 2 * sizeof(uint64_t)];
      int offsFifo[NCCL_STEPS];
    };
    char pad3[MEM_ALIGN];
  };
};

struct ncclRecvMem {
  union {
    struct {
      uint64_t tail;
      char pad1[CACHE_LINE_SIZE - sizeof(uint64_t)];
      struct ncclConnFifo connFifo[NCCL_STEPS];
      int flush; // For GDRCopy-based flush
    };
    char pad4[MEM_ALIGN];
  };
};
〔설계 추론 및 아키텍처 트레이드오프〕

ncclSendMem그리고ncclRecvMem은 송수신의 공유 메모리 구조입니다.head그리고tail은 링 버퍼의 읽기/쓰기 포인터이며,pad1이들이 서로 다른 캐시 라인에 있도록 보장합니다.connFifo배열은 각 단계의 연결 정보(모드, 오프셋, 크기, 포인터)를 저장하며, 다음에 정의됩니다📎 src/include/collectives.h:72-77:

c
struct ncclConnFifo {
  int mode;
  ssize_t offset;
  ssize_t size;
  void* ptr;
};

프로토콜 선택 로직

〔설계 추론 및 아키텍처 트레이드오프〕

프로토콜 선택은 tuning 모듈에 의해 수행되며, 고려 요소는 다음과 같습니다:

  • 메시지 크기: 작은 메시지는 LL, 중간은 LL128, 큰 메시지는 Simple.
  • 토폴로지 구조: NVLink 연결은 LL128에 적합하고, 네트워크 연결은 Simple에 적합합니다.
  • 하드웨어 능력: 일부 GPU 아키텍처는 특정 프로토콜에 최적화되어 있습니다.

시나리오 기반 Walkthrough: LL 프로토콜의 데이터 운반

LL 프로토콜을 사용하여 1KB 데이터를 전송한다고 가정합니다.

첫 번째 단계: 데이터를 송신 버퍼에 쓰기

호스트 측에서 데이터를 다음에 씁니다sendbuff, 그런 다음ncclSendMem.head포인터를 갱신하여 GPU kernel에 새 데이터가 있음을 알립니다.

두 번째 단계: GPU kernel이 데이터 읽기

GPU kernel이head포인터를 폴링하여 새 데이터를 발견하면,sendbuff에서 데이터를 읽습니다.

세 번째 단계: 데이터 전송

GPU kernel이 NVLink 또는 네트워크를 통해 데이터를 대상 rank로 전송합니다.

네 번째 단계: 대상 rank가 데이터 수신

대상 rank의 GPU kernel이 데이터를 다음에 씁니다recvbuff, 그런 다음ncclRecvMem.tail포인터를 갱신합니다.

다섯 번째 단계: 호스트 측에서 데이터 읽기

호스트 측에서tail포인터를 폴링하여 새 데이터를 발견하면,recvbuff에서 데이터를 읽습니다.

동시성 제어와 하드웨어 상호작용

LL 프로토콜의 저지연 메커니즘

〔설계 추론 및 아키텍처 트레이드오프〕

LL 프로토콜은폴링(Polling)을 사용하여 인터럽트 대신 데이터 도착을 감지합니다. GPU kernel이 지속적으로head포인터를 읽으며, 변화가 발견되면 즉시 처리합니다. 이는 인터럽트 방식보다 지연이 낮지만 GPU 연산 자원을 점유합니다.

LL128 프로토콜의 128바이트 정렬

〔설계 추론 및 아키텍처 트레이드오프〕

LL128 프로토콜은 데이터가 128바이트로 정렬될 것을 요구하며, 이렇게 하면 매 전송이 정확히 하나의 캐시 라인을 채웁니다. 정렬의 이점은:

  • 부분 캐시 라인 쓰기(Partial Cache Line Write) 감소
  • 메모리 대역폭 활용률 향상
  • 하드웨어 처리 로직 단순화

Simple 프로토콜의 배치 전송

〔설계 추론 및 아키텍처 트레이드오프〕

Simple 프로토콜은배치 전송모드를 사용합니다: 일정량의 데이터를 축적한 후 한 번에 전송하여 동기화 횟수를 줄입니다. 이는 동기화 오버헤드가 대량의 데이터에 분산되므로 큰 메시지 시나리오에 적합합니다.

프로덕션 함정 회피 가이드

함정 시나리오 1: 프로토콜과 메시지 크기 불일치

〔설계 추론 및 아키텍처 트레이드오프〕

LL 프로토콜을 강제로 사용하여 큰 메시지를 전송하면 성능이 급격히 저하됩니다. LL 프로토콜의 설계 목표는 저지연이지 고대역폭이 아니기 때문입니다. 큰 메시지는 Simple 프로토콜을 사용해야 합니다.

함정 시나리오 2: LL128 정렬 문제

〔설계 추론 및 아키텍처 트레이드오프〕

데이터가 128바이트로 정렬되지 않으면 LL128 프로토콜은 LL 또는 Simple로 폴백하여 성능이 불안정해집니다. 송신 버퍼와 수신 버퍼 모두 128바이트로 정렬되도록 보장할 것을 권장합니다.

함정 시나리오 3: 프로토콜 전환 오버헤드

〔설계 추론 및 아키텍처 트레이드오프〕

런타임에 동적으로 프로토콜을 전환하면 추가 오버헤드가 발생합니다. NCCL은 초기화 시 프로토콜을 결정하고 런타임에는 전환하지 않습니다. 전환이 필요하면 통신 도메인을 재초기화해야 합니다.

2.5 전송 계층 transport: P2P/SHM/NET/CollNet 하위 운반 채널

직관적 모델

A 지점에서 B 지점까지 걸어가거나, 자전거를 타거나, 지하철을 타거나, 택시를 탈 수 있습니다. NCCL의 전송 계층이 바로 이러한 다양한 「이동 방식」입니다. 상위 계층은 구체적으로 어떻게 가는지에 관심이 없고, 전달할 수 있는지만 관심이 있습니다. P2P는 「걷기」(동일 머신 GPU 직결), SHM은 「자전거 타기」(공유 메모리), NET은 「지하철 타기」(네트워크), CollNet은 「택시 타기」(네트워크 카드 오프로드)입니다.

전송 계층 추상화가 없다면, 상위 알고리즘은 각 물리 링크마다 다른 코드를 작성해야 하며 재사용할 수 없습니다.

데이터 구조와 메모리 레이아웃

전송 계층 열거형

📎 src/include/transport.h:18-23전송 계층 유형을 정의합니다:

c
#define NTRANSPORTS 4
#define TRANSPORT_UNDEFINED -1
#define TRANSPORT_P2P 0
#define TRANSPORT_SHM 1
#define TRANSPORT_NET 2
#define TRANSPORT_COLLNET 3

전송 계층 인터페이스

📎 src/include/transport.h:129-146다음을 정의합니다ncclTransportComm——전송 계층의 통신 인터페이스:

c
struct ncclTransportComm {
  ncclResult_t (*setup)(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo*, struct ncclPeerInfo*,
                        struct ncclConnect*, struct ncclConnector*, int channelId, int connIndex);
  ncclResult_t (*connect)(struct ncclComm* comm, struct ncclConnect*, int nranks, int rank, struct ncclConnector*);
  ncclResult_t (*free)(struct ncclComm* comm, struct ncclConnector*);
  ncclResult_t (*proxySharedInit)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
                                  int nChannels);
  ncclResult_t (*proxySetup)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState, void* reqBuff,
                             int reqSize, void* respBuff, int respSize, int* done);
  ncclResult_t (*proxyConnect)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState, void* reqBuff,
                               int reqSize, void* respBuff, int respSize, int* done);
  ncclResult_t (*proxyFree)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState);
  ncclResult_t (*proxyProgress)(struct ncclProxyState* proxyState, struct ncclProxyArgs*);
  ncclResult_t (*proxyRegister)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
                                void* reqBuff, int reqSize, void* respBuff, int respSize, int* done);
  ncclResult_t (*proxyDeregister)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
                                  void* reqBuff, int reqSize, int* done);
};

주요 콜백 분석

  • setup: 연결 수립 전 준비 작업으로, 연결 파라미터를 교환합니다.
  • connect: 실제로 연결을 수립합니다.
  • free: 연결 자원을 해제합니다.
  • proxySharedInit: proxy 스레드의 공유 리소스를 초기화합니다.
  • proxySetup / proxyConnect: proxy 스레드 측의 연결 설정.
  • proxyProgress: proxy 스레드가 데이터 전송을 진행합니다.
  • proxyRegister / proxyDeregister: 메모리 등록 및 해제.

전송 계층 구조체

📎 src/include/transport.h:148-154정의함ncclTransport:

c
struct ncclTransport {
  const char name[8];
  ncclResult_t (*canConnect)(int*, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo*,
                             struct ncclPeerInfo*);
  struct ncclTransportComm send;
  struct ncclTransportComm recv;
};
〔설계 추론 및 아키텍처 트레이드오프〕

name은 전송 계층 이름(예: "P2P", "SHM", "NET")이며,canConnect두 rank 사이에 해당 전송 계층을 사용할 수 있는지 판단합니다,send과recv은 각각 송신 및 수신 방향의 통신 인터페이스입니다.

전송 계층 인스턴스

📎 src/include/transport.h:36-36네 개의 전송 계층 인스턴스를 선언합니다:

c
extern struct ncclTransport p2pTransport;
extern struct ncclTransport shmTransport;
extern struct ncclTransport netTransport;
extern struct ncclTransport collNetTransport;

📎 src/include/transport.h:36-36전송 계층 배열을 정의합니다:

c
extern struct ncclTransport* ncclTransports[];

피어 노드 정보

📎 src/include/transport.h:46-74정의함ncclPeerInfo——rank 간에 교환되는 메타데이터:

c
struct ncclPeerInfo {
  int rank;
  int cudaDev;
  int nvmlDev;
  int gdrSupport;
  uint64_t hostHash;
  uint64_t pidHash;
  dev_t shmDev;
  int64_t busId;
  cudaUUID_t gpuUuid;
  struct ncclComm* comm;
  int cudaCompCap;
  int gpuCftSupport;
  size_t totalGlobalMem;
  // MNNVL support
  nvmlGpuFabricInfoV_t fabricInfo;
  int fabricHandleSupport;
  int cuMemSupport;
  int version;
  uint64_t supportedGinTypeBitMask;
  bool crossNicSupport;
  bool rmaPluginAvailable;
  bool cuMemGdrSupport;
  int mloPart; // MLOPart partition index, or -1 if not an MLOPart GPU
  int cudaDriverVersion;
  bool gpuCftMulticastSupport;
  bool gpuCftCountedSupport;
  uint32_t gitVersionHash;
};
〔설계 추론 및 아키텍처 트레이드오프〕

이 필드들은 두 rank 사이에 어떤 전송 계층을 사용할 수 있는지 판단하는 데 사용됩니다:

  • hostHash동일 → 같은 호스트 → P2P 또는 SHM 사용 가능
  • hostHash다름 → 다른 호스트 → 반드시 NET 사용
  • gdrSupport→ GPUDirect RDMA 지원 여부
  • cudaCompCap→ GPU 컴퓨팅 능력, 프로토콜 선택에 영향

시나리오 기반 Walkthrough: P2P 연결 설정

두 rank가 같은 호스트 내에 있다고 가정하면, NCCL은 P2P 전송 계층을 선택합니다.

첫 번째 단계: PeerInfo 교환

두 rank가 bootstrap 채널을 통해ncclPeerInfo을 교환하고, 서로 같은 호스트에 있으며 GPU가 P2P를 지원하는지 확인합니다.

두 번째 단계: canConnect 호출

📎 src/include/transport.h:148-154의canConnect콜백이 호출되어, 토폴로지 그래프를 확인하여 두 GPU 사이에 NVLink 또는 PCIe 연결이 있는지 검사합니다.

세 번째 단계: setup 호출

p2pTransport.send.setup과p2pTransport.recv.setup이 호출되어, 연결 파라미터(예: IPC 핸들)를 준비합니다.

네 번째 단계: connect 호출

p2pTransport.send.connect과p2pTransport.recv.connect이 호출되어, 실제로 연결을 설정합니다.

다섯 번째 단계: 메모리 등록

RDMA가 필요하면,proxyRegister을 호출하여 송신 및 수신 버퍼를 등록합니다.

동시성 제어 및 하드웨어 상호작용

P2P 전송 계층

〔설계 추론 및 아키텍처 트레이드오프〕

P2P는 CUDA IPC(Inter-Process Communication) 메커니즘을 사용하여, 한 GPU가 다른 GPU의 VRAM에 직접 접근할 수 있게 합니다. 이를 위해서는:

  • 두 GPU가 같은 PCIe 도메인 또는 NVLink 도메인에 있어야 함
  • 운영체제가 CUDA IPC를 지원해야 함
  • 충분한 권한

SHM 전송 계층

〔설계 추론 및 아키텍처 트레이드오프〕

SHM은 호스트 공유 메모리를 중계로 사용합니다. 두 GPU 사이에 직접 연결이 없을 때, 데이터는 먼저 호스트 메모리로 복사된 후 대상 GPU로 복사됩니다. 이는 P2P보다 느리지만 호환성이 더 좋습니다.

NET 전송 계층

〔설계 추론 및 아키텍처 트레이드오프〕

NET은 네트워크 장치(InfiniBand 또는 RoCE)를 사용하여 데이터를 전송합니다. 이를 위해서는:

  • 네트워크 장치가 GPUDirect RDMA를 지원해야 함(선택 사항이지만 권장됨)
  • 올바른 네트워크 구성(IP 주소, 서브넷 마스크 등)
  • 충분한 네트워크 대역폭

CollNet 전송 계층

〔설계 추론 및 아키텍처 트레이드오프〕

CollNet은 NIC의 집합 통신 오프로드 기능(예: NVIDIA SHARP)을 활용합니다. NIC가 직접 리덕션 연산을 수행하여 GPU의 계산 부담을 줄입니다. 이를 위해서는:

  • SHARP를 지원하는 NIC
  • 올바른 SHARP 구성

프로덕션 함정 회피 가이드

함정 시나리오 1: P2P 사용 불가

〔설계 추론 및 아키텍처 트레이드오프〕

두 GPU 사이에 NVLink가 없고 PCIe 토폴로지가 P2P를 지원하지 않으면, NCCL은 SHM으로 폴백합니다. 이로 인해 성능이 저하됩니다.NCCL_P2P_DISABLE=1을 통해 P2P를 강제로 비활성화하고 성능 변화를 관찰할 수 있습니다.

함정 시나리오 2: 네트워크 구성 오류

〔설계 추론 및 아키텍처 트레이드오프〕

네트워크 장치의 IP 주소가 잘못 구성되면 NET 전송 계층이 연결을 설정할 수 없습니다. 흔한 오류로는 서브넷 마스크 오류, 라우팅 테이블 누락, 방화벽 차단이 있습니다.ibstat과ibping을 사용하여 InfiniBand 연결을 확인하는 것을 권장합니다.

함정 시나리오 3: GPUDirect RDMA 미활성화

〔설계 추론 및 아키텍처 트레이드오프〕

만약gdrSupport이 0이면, NET 전송 계층은 "먼저 호스트 메모리로 복사한 후 전송" 모드로 폴백하여 지연이 현저히 증가합니다.nvidia-peermem모듈이 로드되었는지, 그리고 NIC 드라이버가 GPUDirect를 지원하는지 확인하십시오.

2.6 다섯 가지 요소의 조합: 한 번의 통신 전체 라이프사이클

조합 관계도

mermaid
flowchart TD
    api["ncclAllReduce(sendbuff, recvbuff, count, ...)"] --> comm_lookup["查找 ncclComm"]
    comm_lookup --> task_create["创建 ncclTaskColl"]
    task_create --> tuning{"tuning 模块选择算法和协议"}
    tuning -->|"小消息"| tree_ll["Tree + LL"]
    tuning -->|"中等消息"| ring_ll128["Ring + LL128"]
    tuning -->|"大消息"| ring_simple["Ring + Simple"]
    tuning -->|"NVLS 可用"| nvls["NVLS + Simple"]
    tree_ll --> channel_assign["分配通道"]
    ring_ll128 --> channel_assign
    ring_simple --> channel_assign
    nvls --> channel_assign
    channel_assign --> transport_select{"选择传输层"}
    transport_select -->|"同机 GPU 直连"| p2p["P2P"]
    transport_select -->|"同机无直连"| shm["SHM"]
    transport_select -->|"跨机"| net["NET"]
    transport_select -->|"CollNet 可用"| collnet["CollNet"]
    p2p --> kernel_launch["启动 GPU kernel"]
    shm --> kernel_launch
    net --> kernel_launch
    collnet --> kernel_launch
    kernel_launch --> execute["执行通信"]
    execute --> complete["完成,更新 asyncResult"]

전체 라이프사이클

단계 1: API 호출

사용자가ncclAllReduce을 호출하여, 송신 버퍼, 수신 버퍼, 요소 수, 데이터 타입, 리덕션 연산, 통신 도메인, CUDA stream을 전달합니다.

단계 2: 작업 생성

NCCL이ncclTaskColl구조체를 생성하고(📎 src/include/comm.h:212-273),func(AllReduce)、sendbuff、recvbuff、count、datatype、opHost등의 필드를 채웁니다.

단계 3: 알고리즘 및 프로토콜 선택

Tuning 모듈이 메시지 크기, 토폴로지 구조, 하드웨어 능력에 따라 알고리즘(Ring/Tree/NVLS)과 프로토콜(LL/LL128/Simple)을 선택합니다. 선택 결과는ncclTaskColl의algorithm과protocol필드에 기록됩니다(📎 src/include/comm.h:227-227)。

단계 4: 채널 할당

알고리즘과 프로토콜에 따라 사용할 채널 수와 채널 범위를 결정합니다.nChannels、channelLo、channelHi필드가 설정됩니다(📎 src/include/comm.h:254-257)。

단계 5: 전송 계층 선택

토폴로지 그래프에 따라 각 rank 쌍에 대해 전송 계층(P2P/SHM/NET/CollNet)을 선택합니다. 연결 정보는channels[i].peers[j]에 저장됩니다.

단계 6: Kernel 시작

NCCL이ncclKernelPlan(📎 src/include/comm.h:357-410)을 구축합니다. 여기에는 작업 큐, 정리 큐, 태스크 큐 등이 포함됩니다. 그런 다음 GPU kernel을 시작합니다.

7단계: 통신 실행

GPU kernel이 작업 FIFO를 읽고 데이터 전송 및 리덕션 연산을 수행한다. Proxy 스레드가 비동기적으로 네트워크 I/O를 진행한다.

8단계: 완료

모든 채널이 완료되면,asyncResult이(가) 로 설정된다ncclSuccess. 사용자는ncclCommGetAsyncError을 통해 상태를 조회할 수 있다.

설계 고찰

왜 다섯 가지 세트가 필요한가?

〔설계 추론 및 아키텍처 트레이드오프〕

이 다섯 가지 추상화는 각각 서로 다른 차원의 문제를 해결한다:

  • ncclComm: 「누가 누구와 통신하는가」 문제를 해결한다.
  • channel: 「어떻게 병렬화할 것인가」 문제를 해결한다.
  • algorithm: 「어떤 토폴로지를 사용할 것인가」 문제를 해결한다.
  • protocol: 「어떤 전략을 사용할 것인가」 문제를 해결한다.
  • transport: 「어떤 물리적 링크를 사용할 것인가」 문제를 해결한다.

이들은 직교적으로 조합되어, NCCL이 각 조합마다 전용 코드를 작성할 필요 없이 다양한 하드웨어 구성과 메시지 크기에 적응할 수 있게 한다.

조합의 유연성

〔설계 추론 및 아키텍처 트레이드오프〕

다섯 가지 세트의 조합 수는:

  • 알고리즘: 5가지 (Tree/Ring/CollNet/NVLS/PAT)
  • 프로토콜: 3가지 (LL/LL128/Simple)
  • 전송 계층: 4가지 (P2P/SHM/NET/CollNet)

이 장의 고찰과 자가 점검

Q1: 만약📎 src/include/comm.h:731-731의intraPad1[64 - sizeof(uint64_t)]을intraPad1[0]로 변경하면 (즉, 캐시 라인 패딩을 제거하면), 다중 프로세스 시나리오에서 어떤 성능 문제가 발생하는가? 왜인가?

참고 해석:

패딩을 제거하면,intraBarrierPhase、intraBarrierCounter、intraBarrierGate세 필드가 메모리에 밀집 배열되어 동일한 캐시 라인(보통 64바이트)을 공유할 가능성이 높다.

다중 프로세스 시나리오에서 각 프로세스는 자신만의ncclComm복사본을 가지지만,intraComm0이 가리키는 leader 통신 도메인의intraBarrierCounter과intraBarrierGate은 모든 프로세스가 읽고 쓴다. 프로세스 A가ncclCommIntraBarrierIn을 호출하여intraBarrierCounter(📎 src/include/comm.h:943-959을 업데이트할 때, 프로세스 B의intraBarrierGate캐시 라인이 무효화된다. 프로세스 B가ncclCommIntraBarrierOut에서intraBarrierGate(📎 src/include/comm.h:962-977을 폴링할 때, 매번 캐시 무효화가 발생할 때마다 메모리에서 다시 로드해야 하므로 지연이 나노초 수준에서 마이크로초 수준으로 상승한다.

이것이 바로거짓 공유(False Sharing)문제이다. 56바이트를 패딩하여 각 필드가 독점적으로 하나의 캐시 라인을 차지하도록 보장하여 거짓 공유를 제거한다.

Q2: 만약📎 src/include/collectives.h:106-108의incRefCount을memory_order_relaxed에서memory_order_seq_cst로 변경하면 어떤 영향이 있는가? 왜 저자는relaxed?

참고 해석:

memory_order_seq_cst은 전역 순차 일관성을 강제하여, 참조 카운트를 증가시킬 때마다 메모리 배리어를 삽입해야 하므로 성능이 저하된다.

incRefCount은 원자성만 보장하면 되고 다른 메모리 연산을 동기화할 필요가 없다. 참조 카운트 증가는 객체 소멸을 트리거하지 않으며 다른 스레드의 쓰기 연산에 의존하지도 않기 때문이다.memory_order_relaxed이 바로 이 요구사항을 충족한다 — 원자성만 보장하고 배리어를 삽입하지 않는다.

이와 대조적으로,decRefCount(📎 src/include/collectives.h:109-111)는memory_order_release을 사용하는데, 참조 카운트 감소가 객체 소멸을 트리거할 수 있어 이전 쓰기 연산이 다른 스레드에 가시적임을 보장해야 하기 때문이다.

이것은 C++ 메모리 모델의 전형적인 응용이다: 연산 의미론에 따라 가장 약한 메모리 순서를 선택하여 정확성을 보장하면서 성능을 극대화한다.

Q3: 만약📎 src/include/channel.h:32-32의reverseBits(base, log2Up(comm->p2pnChannels))을 직접base % comm->p2pnChannels을 반환하도록 변경하면, 어떤 시나리오에서 성능이 저하되는가? 왜인가?

참고 해석:

reverseBits은 비트 반전 연산으로, 채널 할당을 분산시키는 데 사용된다. 직접 모듈로 연산을 하면 채널 할당이 규칙성을 띠게 된다: round 0은 채널 0, round 1은 채널 1, ..., round N은 채널 N%p2pnChannels을 사용한다.

다중 노드 시나리오에서 여러 rank의 P2P 통신이 동시에 진행되면, 규칙적인 채널 할당은 핫스팟 집중을 초래한다 — 특정 채널이 여러 rank에 의해 동시에 사용되고 다른 채널은 유휴 상태가 된다. 이는 링크 혼잡을 야기하고 전체 대역폭 활용률을 저하시킨다.

reverseBits은 채널 할당을 분산시켜 서로 다른 round가 겉보기에 무작위적인 채널을 사용하게 하여 부하를 균등하게 분배한다. 이것은부하 균형의 전형적인 기법이다.

또한,reverseBits은 순수 비트 연산으로 모듈로 연산보다 빠르다 (모듈로는 나눗셈 명령이 필요하지만 비트 연산은 몇 개의 명령만 필요하다).

---

다음 장에서는ncclCommInitRank의 내부 구현을 깊이 파고들어, NCCL이 빈ncclComm구조체에서 시작하여 점진적으로 토폴로지 그래프를 구축하고, 채널을 초기화하고, 전송 연결을 설정하여 최종적으로 사용 가능한 통신 도메인을 구축하는 과정을 살펴본다. 이 장에서 확립한 다섯 가지 세트의 멘탈 모델은 다음 장에서 하나씩 구체화될 것이다.

이 다섯 가지 추상화는 고립되어 존재하지 않는다: 통신 도메인은 컨테이너이고, 채널은 병렬 실행의 단위이며, 알고리즘은 데이터가 어떻게 리덕션될지 결정하고, 프로토콜은 데이터가 어떻게 인코딩될지 규정하며, 전송 계층은 데이터가 어떻게 이동할지 담당한다. 이들의 조합 — 5개 차원, 각 차원당 3~4가지 선택 — 은 NCCL 성능 튜닝의 탐색 공간을 구성한다. 그렇다면 이 통신 도메인 객체는 도대체 어떻게 처음부터 구축되는가? 다음 장에서는 ncclCommInitRank의 호출 체인을 깊이 파고들어, NCCL이 초기화 단계에서 장치 탐지, 토폴로지 발견, 채널 할당을 어떻게 완료하는지 살펴보고, comm->rank, comm->nRanks, comm->channels 등 핵심 필드의 할당 시점을 밝힌다.

CHAPTER 03

제 3 장: 제 3 장: 초기화 진입: ncclCommInitRank가 고립된 프로세스 무리를 통신 도메인으로 구축하는 방법

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 3 / 25 장

제 3 장: 초기화 진입: ncclCommInitRank가 고립된 프로세스 무리를 통신 도메인으로 구축하는 방법

이전 장에서 우리는 책 전체를 관통하는 다섯 가지 핵심 추상화인 ncclComm, channel, algorithm, protocol, transport를 확립했으며, 이들은 함께 「하나의 통신 = 여러 channel × 하나의 algorithm × 하나의 protocol × 여러 transport」라는 공통 어휘집을 구성한다. 이제 우리는 더 근본적인 질문에 답하려 한다: 이 ncclComm 객체는 도대체 어떻게 무에서 유로 구축되는가? ncclCommInitRank를 호출할 때, NCCL은 수백 밀리초 내에 일련의 복잡한 작업을 완료해야 한다: 모든 rank가 도착했는지 확인하고, 디바이스 정보를 교환하고, 머신 토폴로지를 탐지하고, 데이터 경로를 계산하고, GPU 메모리와 호스트 메모리를 할당하고, 최종적으로 이 모든 것을 하나의 ncclComm 객체로 패키징한다. 이 장에서는 이 호출 체인을 따라 API 진입점에서 initTransportsRank의 마지막 모세혈관까지 내려가 볼 것이다.

3.1 API 진입점: ncclCommInitRank의 동기外壳와 비동기内核

직관적 모델

ncclCommInitRank표면적으로는 "통신 도메인을 하나 만드는 것"이지만, 실제로는 "백그라운드 작업을 시작하고, (기본적으로) 그것이 완료되기를 기다리는 것"이다. 이것은 식당에서 주문하는 것과 같다: 주문하는 행위(API 호출)는 즉시 반환되지만, 주방에서 요리하는 것(실제 초기화)은 백그라운드에서 진행된다. 기본 "블로킹 모드"는 카운터 앞에서 요리가 완성될 때까지 기다리게 하는 것에 불과하고, "논블로킹 모드"는 픽업 번호를 주어 다른 일을 먼저 할 수 있게 한다.

만약 이러한 비동기 설계가 없다면, NCCL은 초기화 중에 CUDA Graph 캡처, 다중 통신 도메인 병렬 초기화 등의 시나리오와 협력할 수 없을 것이다——모든 초기화가 직렬화되고 사용자 코드와 겹칠 수 없는 블로킹 작업이 될 것이다.

데이터 구조와 메모리 레이아웃

먼저 API 진입점 자체를 살펴보자.ncclCommInitRank은 매우 얇은 동기外壳이다:

📎 src/init.cc:2946-2970

그것은 네 가지 일을 한다: 호출ncclInitEnv()환경 변수 플러그인 로드, NVTX 성능 마커 열기, 현재 CUDA 디바이스 번호 읽기, 그런 다음 호출ncclGroupStartInternal()group 시맨틱에 진입하고, 마지막으로 실제 작업을 위임한다ncclCommInitRankDev。

주의ncclGroupStartInternal() / ncclGroupEndInternal()이 한 쌍의 호출——비록 통신 도메인을 하나만 초기화하더라도, NCCL은 그것을 group 시맨틱으로 감싼다. 이는 "사용자가 하나의 group에서 여러 통신 도메인을 초기화하는" 시나리오를 통일적으로 처리하여, 단일 통신 도메인과 다중 통신 도메인을 위해 두 세트의 코드 경로를 작성하는 것을 피하기 위함이다.

실제 매개변수 검증과 객체 할당은ncclCommInitRankDev에서 이루어진다:

📎 src/init.cc:2851-2943

이 함수는 전체 링크의 "총调度台"이다. 먼저 매개변수 검증(nId범위,nranks/myrank합법성)을 수행한 다음,ncclComm구조체 자체와 중단 메커니즘과 관련된 세 가지 필드를 할당한다:abortFlag(호스트 측 원자 플래그),abortFlagDev(디바이스 측에서 볼 수 있는 고정 메모리 복사본),abortFlagRefCount(참조 카운트, split으로 생성된 하위 통신 도메인이 부모 통신 도메인의 abortFlag를 공유할 수 있기 때문).

여기 주목할 만한 세부 사항이 있다——comm->startMagic = comm->endMagic = NCCL_MAGIC:

📎 src/init.cc:2886-2886

이 한 쌍의 magic 값은 "봉인"처럼ncclComm구조체의 처음과 끝을 감싼다. 어떤 범위를 벗어난 쓰기나 구조체 손상도 이 한 쌍의 magic을 파괴하며, 후속 작업은 이들을 검증하여 메모리 침범을 감지할 수 있다. 이것은 저렴하지만 효과적인 메모리 무결성 보호이다.

Step-by-Step Walkthrough

ncclCommInitRankDev이 마지막에 도달하면,ncclCommInitRankAsyncJob을 구성하고 비동기 작업을 시작한다:

📎 src/init.cc:2896-2929

job구조체는 초기화에 필요한 모든 매개변수를 담고 있다. 주의job->commId은복사된 것이지, 사용자가 전달한commId:

📎 src/init.cc:2903-2910

을 직접 참조하는 것이 아니다. 왜 복사하는가? 소스 주석이 답을 준다:ncclUniqueId와ncclBootstrapHandle의 정렬 요구 사항이 다르기 때문에, 사용자가 전달한 배열이ncclBootstrapHandle에 필요한 경계에 올바르게 정렬되지 않았을 수 있다. 새로 할당된 메모리로 복사하면 정렬을 보장할 수 있다. 이것은 전형적인 "ABI 호환성 함정"이다——사용자가 보는 것은ncclUniqueId이지만, 내부적으로는ncclBootstrapHandle으로 사용해야 하며, 둘은 크기는 같지만 정렬이 다르다.

마지막으로,ncclParamEnqueueRearchEnable()의 값에 따라 작업은 관리 큐에 들어가거나ncclAsyncLaunch을 통해 직접 시작된다:

📎 src/init.cc:2922-2929

ncclAsyncLaunch은 새 스레드를 생성하여ncclCommInitRankFunc을 실행한다. 블로킹 모드(기본값)라면, 호출자는ncclGroupEndInternal()에서 이 스레드가 완료되기를 기다린다; 논블로킹 모드라면, 호출자는 즉시 반환하고, 사용자는 나중에ncclCommGetAsyncError로 상태를 폴링한다.

설계 사고

여기서 설계 핵심은 "동기 API + 비동기 구현"이다. 왜ncclCommInitRank이 모든 초기화를 직접 동기적으로 실행하게 하지 않는가? NCCL이ncclCommInitRankConfig의 논블로킹 모드를 지원해야 하고, 논블로킹 모드는 초기화가 백그라운드 스레드에서 실행될 것을 요구하기 때문이다. 만약 동기 경로와 비동기 경로가 두 세트의 코드라면, 유지보수 비용이 두 배가 될 것이다. 통일적으로 비동기로 가고, 동기 경로는 단지 "시작 후 즉시 대기"일 뿐이며, 코드는 하나만 존재한다.

mermaid
flowchart TD
    api["ncclCommInitRank(newcomm, nranks, commId, myrank)"]
    env["ncclInitEnv() 加载环境变量插件"]
    group["ncclGroupStartInternal()"]
    dev["ncclCommInitRankDev(...)"]
    check{"nId/nranks/myrank 合法?"}
    alloc["ncclCalloc 分配 comm + abortFlag"]
    parse["parseCommConfig() 解析配置"]
    job["构造 ncclCommInitRankAsyncJob"]
    copyid["拷贝 commId 保证对齐"]
    enq{"ncclParamEnqueueRearchEnable()?"}
    mgmt["ncclMgmtTaskEnqueue()"]
    async["ncclAsyncLaunch() 启动后台线程"]
    func["ncclCommInitRankFunc() 执行初始化"]
    fail["返回 ncclInvalidArgument"]

    api --> env --> group --> dev --> check
    check -->|否| fail
    check -->|是| alloc --> parse --> job --> copyid --> enq
    enq -->|是| mgmt --> func
    enq -->|否| async --> func

3.2 Bootstrap: rank 간의 첫 번째 제어 채널

직관적 모델

Bootstrap은 NCCL의 "회의 전 위챗 그룹"이다. 공식 통신이 시작되기 전에, 모든 rank는 먼저 제어 채널을建立해야 하며, 이를 통해 "나는 누구인가, 나는 어느 머신에 있는가, 내 GPU는 어떤 모델인가, 내 네트워크 카드 주소는 무엇인가"라는 메타데이터를 교환한다. bootstrap이 없으면, rank 간에는 서로를 모르는 낯선 사람들의 무리에 불과하여 어떤 통신도 조정할 수 없다.

만약 bootstrap이 실패하거나 시간 초과되면, 전체 통신 도메인 초기화가 교착 상태에 빠질 것이다——이것은 프로덕션 환경에서 가장 흔한 NCCL 행 원인 중 하나이다.

데이터 구조와 메모리 레이아웃

Bootstrap의 핵심 상태는bootstrapState구조체에 저장된다:

📎 src/bootstrap.cc:527-546

이 구조체에서 주목할 만한 몇 가지 핵심 필드를 살펴보겠습니다:

  • ring: 네트워크 디바이스 핸들이거나(net.sendComm/net.recvComm), 한 쌍의 소켓(socket.send/socket.recv)인 유니온입니다. 이는 두 가지 bootstrap 모드에 대응합니다: 소켓 기반 기본 모드와 네트워크 디바이스 기반NCCL_OOB_NET_ENABLE모드입니다.
  • listen: 리스닝 엔드포인트 정보로, 마찬가지로 네트워크와 소켓 두 가지 형태가 있습니다.
  • peerP2pAddresses / peerProxyAddresses: 모든 rank의 P2P 주소와 proxy 주소 배열로, ring allgather를 통해 채워집니다.
  • unexpectedConnections: "수신했지만 아직 매칭되지 않은" 연결을 캐시하는 연결 리스트입니다. 이는 bootstrap 프로토콜의 핵심 설계입니다——수신 측은 누가 먼저 연결해 올지 예측할 수 없기 때문에, 매칭되지 않은 연결을 먼저 저장해 두어야 합니다.
  • asyncSendQueue + asyncSendLock + asyncSendCond: 비동기 전송 큐와 그 동기화 프리미티브로, TLS 암호화 모드에서의 동시 전송에 사용됩니다.

bootstrapState의 할당은bootstrapInit시작 부분에서 발생합니다:

📎 src/bootstrap.cc:769-776

다음을 주목하세요:comm->bootstrap = state이 줄——bootstrap 상태가 통신 도메인에 연결되며, 이후 모든 bootstrap 작업은comm->bootstrap를 통해 접근합니다.

Step-by-Step Walkthrough

bootstrapInit는 bootstrap의 주 간선 함수입니다. 실행 순서대로 분해해 보겠습니다:

첫 번째 단계: magic 값 결정.magic은 bootstrap 통신의 "암호"로, 동일한 magic을 가진 rank만 서로 연결할 수 있습니다.

📎 src/bootstrap.cc:778-788

정상 초기화(handles != NULL)인 경우, magic은 첫 번째 handle에서 옵니다; split/grow(parent != NULL)인 경우, magic은hashCombine(parent->magic, parent->childCount)를 통해 파생됩니다. 이는 각 하위 통신 도메인이 고유한 magic을 갖도록 보장합니다.

두 번째 단계: 리스닝 소켓 생성.각 rank는 두 개의 리스닝 엔드포인트가 필요합니다: 하나는 ring 이웃 연결용(STATE_LISTEN(state, socket)), 하나는 root 연결용(listenSockRoot):

📎 src/bootstrap.cc:797-831

여기에 핵심적인 역할 분담이 있습니다: ring 리스닝 소켓은comm->magic를 사용하고, root 리스닝 소켓은BOOTSTRAP_HANDLE(handles, curr_root)->magic를 사용합니다. 왜일까요? root는 전역 조정자로 모든 rank가 연결해야 하므로 통일된 magic을 사용하고, ring 이웃은 점대점이므로 통신 도메인 자체의 magic이면 충분하기 때문입니다.

세 번째 단계: 시차 연결.rank 수가 많을 때 모든 rank가 동시에 root에 연결하면 연결 폭풍이 발생합니다. NCCL은NCCL_UID_STAGGER_RATE과NCCL_UID_STAGGER_THRESHOLD를 사용하여 시차를 제어합니다:

📎 src/bootstrap.cc:833-843

특정 root가 담당하는 rank 수가 임계값(기본 256)을 초과하면, 각 rank는 root 아래에서의 자신의 로컬 ID를 기반으로 지연 마이크로초를 계산한 후 sleep합니다. 이는 간단하지만 효과적인 "토큰 버킷" 방식의 속도 제한입니다.

네 번째 단계: root에 자신의 연결 정보 전송.각 rank는 자신의 리스닝 주소를 root에 보냅니다:

📎 src/bootstrap.cc:845-867

root는 모든 rank의 정보를 받은 후 "링 페어링"을 수행합니다——rank i의 주소를 rank i-1에 보내고, rank i+1의 주소를 rank i에 보냅니다. 이렇게 하면 각 rank가 자신의 ring 상 앞뒤 이웃을 알게 됩니다.

다섯 번째 단계: ring 연결 수립.각 rank는 자신의 "다음" 이웃에 연결하고, 동시에 "이전" 이웃의 연결을 수락합니다:

📎 src/bootstrap.cc:885-894

여기서socketRingConnect내부적으로bootstrapConcurrent를 사용합니다——TLS 암호화 모드에서는 connect와 accept가 반드시 동시에 실행되어야 합니다. 그렇지 않으면 교착 상태가 발생합니다(TLS 핸드셰이크는 양측이 동시에 참여해야 하기 때문). 비암호화 모드에서는 connect를 먼저 직렬로 실행한 후 accept합니다.

여섯 번째 단계: 모든 주소 AllGather.ring이 수립된 후,ringAllInfo를 통해 모든 rank의 P2P 주소, proxy 주소, UDS 주소를 한 번 allgather합니다:

📎 src/bootstrap.cc:934-938

ringAllInfo내부적으로bootstrapAllGather를 호출하며, 후자는 소켓 모드에서socketRingAllGather를 사용합니다——양방향 ring allgather 알고리즘으로, N개의 rank에 N/2 단계만 필요합니다:

📎 src/bootstrap.cc:1363-1412

이 양방향 알고리즘은 bootstrap 성능의 핵심 최적화입니다. 전통적인 단방향 ring allgather는 N-1 단계가 필요하지만, 양방향 버전은 단계 수를 절반으로 줄입니다. 각 단계에서 동시에 양방향으로 데이터를 보내고 받으며,socketDoubleSendRecv를 사용하여 4개의 작업(2회 송신, 2회 수신)을 하나의 시스템 호출로 묶습니다.

동시성 제어와 저수준 상호작용

Bootstrap의 동시성 제어에는 여러 계층이 있습니다:

첫 번째 계층: abort 검사.모든 블로킹 루프는 주기적으로 abortFlag를 검사합니다:

📎 src/bootstrap.cc:150-159

BOOTSTRAP_N_CHECK_ABORT를 10000으로 설정한다는 것은 매 10000회 루프마다 abort 플래그를 한 번 검사한다는 의미입니다. 이 숫자는 성능과 응답성의 절충입니다——너무 자주 검사하면 성능에 영향을 미치고, 너무 적게 검사하면 abort 응답이 지연됩니다.

두 번째 계층: 비동기 전송 큐.TLS 암호화 모드에서bootstrapSend는 동기적으로 실행될 수 없습니다(TLS 핸드셰이크는 수신 측도 참여해야 하기 때문). 따라서 NCCL은 전송 작업을 별도 스레드에 배치합니다:

📎 src/bootstrap.cc:1161-1217

여기에는 정교한 순서 보장 메커니즘이 있습니다.bootstrapAsyncSendMain는 전송 전에 큐에 "더 이른, 동일한 (peer, tag)로의 전송"이 있는지 검사합니다:

📎 src/bootstrap.cc:1124-1152

왜 동일한 (peer, tag)의 전송 순서를 보장해야 하는가? 소스 코드 주석에 명확히 설명되어 있다: 수신 측은 (peer, tag)로 연결을 매칭하는데, 만약 동일한 (peer, tag)로 전송된 두 메시지의 도착 순서가 뒤바뀌면 수신 측이 잘못 매칭하게 된다. NVLS 초기화 중에는 동일한 peer에게 동일한 tag로 여러 번 브로드캐스트하므로, 이 순서 보장은 필수적이다.

세 번째 계층: 예상치 못한 연결 큐.수신 측은 누가 먼저 연결해 올지 예측할 수 없으므로socketAccept매칭되지 않는 연결을unexpectedConnections연결 리스트에 저장한다:

📎 src/bootstrap.cc:1276-1300

이 설계는 고전적인 분산 문제를 해결한다: 여러 rank가 동시에 당신에게 연결을 시도할 수 있지만, 당신의bootstrapRecv호출 순서는 고정되어 있다. 매칭되지 않는 연결을 그냥 버리면 송신 측이 타임아웃되고, 블로킹 대기하면 데드락이 발생할 수 있다. 큐에 저장하는 것이 가장 안전한 방법이다.

프로덕션 함정 회피 가이드

함정 1: bootstrap 타임아웃으로 인한 초기화 중단.특정 rank가 네트워크 문제로 root에 연결할 수 없으면, 다른 모든 rank가ncclSocketAccept또는ncclSocketRecv에서 무한 대기하게 된다. NCCL에는 내장된 bootstrap 타임아웃 메커니즘이 없으며, 유일한 탈출 경로는 abortFlag이다. 프로덕션 환경에서는 대규모 클러스터의 연결 폭풍을 완화하기 위해NCCL_UID_STAGGER_RATE를 설정하는 것이 권장된다.

함정 2:NCCL_COMM_ID과 다중 handle 충돌.사용자가NCCL_COMM_ID환경 변수를 설정하면, NCCL은 강제로nId를 1로 낮춘다:

📎 src/init.cc:2912-2921

이는ncclCommInitRankScalable의 다중 handle 기능이 조용히 비활성화됨을 의미한다. scalable 초기화를 사용하면서NCCL_COMM_ID도 설정했다면, 동작이 예상과 다를 것이다.

함정 3: TLS 모드에서의 데드락.TLS 암호화 모드에서 connect와 accept가 동시에 실행되지 않으면, 양쪽 모두 TLS 핸드셰이크에서 멈추게 된다.bootstrapConcurrent가 바로 이 문제를 해결하기 위한 것이다:

📎 src/bootstrap.cc:648-669

비암호화 모드에서는 직렬 실행(send 후 recv), 암호화 모드에서는 스레드를 하나 시작해 send를 처리하고 메인 스레드가 recv를 처리한다.

mermaid
sequenceDiagram
    participant R0 as Rank 0
    participant Root as Bootstrap Root
    participant R1 as Rank 1
    participant R2 as Rank 2

    R0->>Root: sendToRoot(extInfo{rank=0, listenAddr})
    R1->>Root: sendToRoot(extInfo{rank=1, listenAddr})
    R2->>Root: sendToRoot(extInfo{rank=2, listenAddr})
    Note over Root: 收集所有 rank 的监听地址
    Root-->>R0: rootSend(rank2.addr) 下一个邻居
    Root-->>R1: rootSend(rank0.addr) 下一个邻居
    Root-->>R2: rootSend(rank1.addr) 下一个邻居
    R0->>R1: socketRingConnect(connect to next)
    R1->>R2: socketRingConnect(connect to next)
    R2->>R0: socketRingConnect(connect to next)
    Note over R0,R2: Ring 建立完成
    R0->>R1: socketRingAllGather 双向交换
    R1->>R2: socketRingAllGather 双向交换
    R2->>R0: socketRingAllGather 双向交换
    Note over R0,R2: 所有地址交换完成

3.3 commAlloc: 통신 도메인 객체의 메모리 골격

직관적 모델

commAlloc는 통신 도메인의 "골조 인도"이다 — 구조체 메모리를 할당하고, 모든 필드를 안전한 기본값으로 초기화하며, 필요한 CUDA 객체와 동기화 프리미티브를 생성하지만, 토폴로지 정보, 채널 구성, 전송 연결 같은 "내부 인테리어"는 아직 채우지 않는다. 만약ncclComm를 건물에 비유하면,commAlloc은 기초 공사와 골조 타설이고,initTransportsRank가 내부 인테리어이다.

만약commAlloc의 초기화가 없으면, 이후 코드가 초기화되지 않은 필드에 접근하여 예측 불가능한 동작을 초래할 수 있다 — 예를 들어comm->channels[c].id가 임의의 값이면, 채널 초기화 로직이 채널 상태를 잘못 판단하게 된다.

데이터 구조와 메모리 레이아웃

commAlloc의 시그니처와 시작 부분 검증:

📎 src/init.cc:512-526

먼저ndev와rank의 유효성을 검증한 후, 두 개의 메모리 스택(memPermanent과memScoped)을 구성하고,rank과nRanks를 설정한다. 이 두 메모리 스택은 NCCL의 메모리 관리 인프라이다 —memPermanent는 수명 주기가 통신 도메인과 동일한 할당에 사용되고,memScoped는 임시 할당에 사용된다.

다음은 CUDA 디바이스 탐지:

📎 src/init.cc:528-531

cudaGetDevice는 현재 디바이스 번호를 가져오고,ncclCudaCompCap는 컴퓨팅 능력을 가져온다. 소스 코드 주석에 아주 직설적으로 쓰여 있다: "Try to create a CUDA object right away. If there is something wrong with the device we're on, better know it early." — 디바이스 문제를 조기에 노출시켜 초기화 후반에야 발견하는 것을 피한다.

그 다음은 공유 리소스의 할당 또는 상속:

📎 src/init.cc:533-555

여기에 중요한 분기가 있다: 만약parent == NULL || !parent->shareResources이면 새로운ncclSharedResources을 생성하고, 그렇지 않으면 부모 통신 도메인의 공유 리소스를 상속하고 참조 카운트를 증가시킨다.ncclSharedResources는 디바이스 스트림, 호스트 스트림, 시작 이벤트, scratch 이벤트 등을 포함한다 — 이러한 리소스는 split 시나리오에서 자식 통신 도메인이 재사용할 수 있어 중복 생성을 피한다.

주목할 점은sharedRes->refCount = 1이 줄이다 — 초기 참조 카운트가 1이고, split 공유 시마다 증가하며, 마지막 참조가 해제될 때 비로소 실제로 파괴된다.

다음은 네트워크, RMA, GIN의 초기화:

📎 src/init.cc:547-549

이 세 하위 시스템은 각각 네트워크 전송, 원격 메모리 접근, GPU 발起的 네트워크 통신을 담당한다. 초기화 순서에는 이유가 있다 —ncclNetInit는 반드시ncclRmaInit보다 먼저여야 하는데, RMA가 네트워크 플러그인에 의존하기 때문이다.

메모리 관리자의 초기화:

📎 src/init.cc:567-576

마찬가지로 공유/신규 두 가지 경로가 있다.ncclMemManager는 CUDA 메모리 풀과 등록 캐시를 관리한다.

채널 초기화 마커:

📎 src/init.cc:607-608

이 줄은 모든 채널의id를 -1로 설정하여 "미초기화"를 나타낸다. 이후setupChannel가 이 값을 확인하여 초기화 필요 여부를 결정한다.

인터럽트 큐의 구성:

📎 src/init.cc:619-632

NCCL은 침입형 큐(intrusive queue)를 사용하여 다양한 작업을 관리한다. 이 큐들은commAlloc단계에서 모두 빈 상태로 구성되며, 이후 작업이 큐에 들어갈 때 바로 사용된다.

CUDA 메모리 풀 생성:

📎 src/init.cc:636-652

디바이스가 메모리 풀(cudaDevAttrMemoryPoolsSupported)을 지원하면, pinned 타입의 메모리 풀을 생성하고 해제 임계값을 최대값(~uint64_t(0))으로 설정한다. 이는 "절대 자동 해제하지 않음"을 의미한다. CUDA 런타임이 NCCL이 모르는 사이에 메모리를 회수하는 것을 방지하기 위해서이다.

Step-by-Step Walkthrough

구체적인 초기화 시나리오를 추적해 보자: 단일 머신 8 GPU, 프로세스당 하나의 rank, 정상 초기화.

1. commAlloc(comm, NULL, 8, rank)가 호출되고,parent == NULL。

2. 검증 통과,comm->rank = rank,comm->nRanks = 8。

3. cudaGetDevice가 현재 디바이스 번호를 반환하고,comm->compCap가 설정된다.

4. 새로운ncclSharedResources를 생성하고, 참조 카운트는 1이다.

5. ncclNetInit네트워크 플러그인 초기화 (Socket 또는 IB일 수 있음).

6. ncclMemManagerInit메모리 관리자 생성.

7. getBusIdPCI 버스 ID 가져오기,ncclNvmlDeviceGetHandleByPciBusIdNVML 핸들 가져오기.

8. dmaBufSupportedDMA-BUF 지원 감지.

9. 할당connectSend / connectRecv비트맵 배열.

10. 모든 채널id을 -1로 설정.

11. 모든 인터럽트 큐 구성.

12. CUDA 메모리 풀 생성.

설계 고찰

commAlloc에서 가장 흥미로운 설계는 "조기 실패" 원칙이다. 이는 함수 시작 부분에서cudaGetDevice를 호출하며, 나중에 장치 정보가 필요할 때까지 기다리지 않는다. 이렇게 하면 장치에 문제가 있을 경우(예: 다른 프로세스가 독점 중인 경우) 대량의 메모리를 할당한 후가 아니라 초기화 초기에 오류가 드러난다는 장점이 있다.

또 다른 설계는preconnectNext의 초기화이다:

📎 src/init.cc:598-598

reinterpret_cast<struct ncclComm*>(0x1)는 "다음 사전 연결"의 상태를 표시하는 센티넬 값이다. 이렇게 잘못된 포인터 값을 상태 표시로 사용하는 기법은 시스템 프로그래밍에서 흔히 볼 수 있다. 추가 불리언 필드보다 메모리를 절약하지만 역참조하지 않도록 주의해야 한다.

3.4 initTransportsRank: 토폴로지 발견 및 채널 할당

직관적 모델

initTransportsRank는 초기화의 "심장"이다. 이는 세 가지 큰 일을 한다: 두 번의 AllGather를 통해 모든 rank의 장치 정보와 토폴로지 정보를 교환하고, 이 정보를 기반으로 ring/tree/collnet/nvls 등의 알고리즘 그래프 구조를 계산하며, 마지막으로 모든 전송 연결을 설정한다. 통신 도메인을 도시의 교통 시스템에 비유하면,initTransportsRank는 모든 도로, 입체 교차로, 버스 노선을 계획하는 과정이다.

이 단계가 없으면 NCCL은 데이터가 어느 경로로 가야 할지 알 수 없다. 데이터가 우회하거나 도달 가능한 경로를 전혀 찾지 못할 수 있다.

데이터 구조와 메모리 레이아웃

initTransportsRank의 지역 변수는 매우 많으므로 핵심적인 것만 살펴보자:

📎 src/init.cc:1163-1179

여기서comm->graphs배열의 각 그래프 구조를 가져와 별칭을 만든다.graphs배열은 알고리즘별로 인덱싱되며,nvlsGraph가 두 번 사용된 것에 주의하라 (NVLS와 NVLSTree가 동일한 그래프 구조를 공유).

두 가지 핵심 임시 구조체:

📎 src/init.cc:1181-1206

graphInfo는 단일 rank의 특정 알고리즘에 대한 그래프 정보(채널 수, 대역폭, 유형 등)를 저장하고,allGatherInfo는 AllGather의 데이터 단위로, 모든 알고리즘의 그래프 정보와 토폴로지 rank 정보를 포함한다.

Step-by-Step Walkthrough

1단계: AllGather1 — 장치 정보 교환.

📎 src/init.cc:1234-1239

각 rank는fillInfo를 호출하여 자신의ncclPeerInfo를 채우고,bootstrapAllGather를 통해 교환한다.fillInfo에 채워지는 정보에는 rank 번호, CUDA 장치 번호, NVML 장치 번호, NCCL 버전, git hash, 호스트 hash, 프로세스 hash, GPU UUID, 버스 ID, VRAM 크기, 드라이버 버전 등이 포함된다.

📎 src/init.cc:888-982

참고:info->hostHash = getHostHash() + commHash와info->pidHash = getPidHash() + commHash— host hash와 pid hash 모두 commHash가 추가된다. 이는 동일한 머신의 서로 다른 통신 도메인을 구분하기 위함이다.

AllGather 완료 후, 각 rank는 모든 peer의 정보를 순회하며 전역 속성을 계산한다:

📎 src/init.cc:1250-1303

이 루프는 많은 일을 한다: 버전 불일치 감지, 노드 수 집계,cuMemSupport의 교집합 계산, 여러 rank가 동일한 GPU를 사용하는지 감지, GIN 유형 마스크의 교집합 계산 등.nNodes의 집계 방식에 주의하라 — 서로 다른 hostHash를 만날 때마다 증가시키는데, 이는 rank가 노드별로 연속 배치되어 있다고 가정한다.

2단계: 토폴로지 발견.

📎 src/init.cc:1390-1403

이 여섯 단계는 토폴로지 발견의 핵심 흐름이다:ncclTopoGetSystem는 시스템 장치를 열거하여 토폴로지 그래프를 구축하고,ncclTopoComputePaths는 GPU에서 NIC까지의 경로를 계산하며,ncclTopoTrimSystem는 도달 불가능한 장치를 제거하고 경로를 다시 계산하며,ncclTopoSearchInit는 검색 상태를 초기화하고 마지막으로 토폴로지를 출력한다.

3단계: 그래프 계산.

📎 src/init.cc:1421-1468

ring, tree, collnet chain, collnet direct, nvls 다섯 가지 그래프를 순차적으로 계산한다. 각 그래프는 서로 다른 pattern과 채널 수 제약을 가진다. 참고:treeGraph->minChannels = ringGraph->nChannels— tree의 채널 수는 ring과 동일하게 제한되는데, 이는 서로 다른 알고리즘 간의 채널 정렬을 보장하기 위함이다.

4단계: AllGather3 — 그래프 정보 교환.

📎 src/init.cc:1490-1533

각 rank는 자신의 그래프 정보를allGather3Data[rank]에 채운 후 다시bootstrapAllGather를 수행한다. 이번에 교환되는 정보에는 각 알고리즘의 pattern/nChannels/bwIntra/bwInter/typeIntra/typeInter/crossNic, CPU 아키텍처, P2P 채널 수, 네트워크 장치 수, CollNet 장치 수 등이 포함된다.

AllGather3 완료 후, 각 rank는 모든 peer의 그래프 정보를 순회하며 최솟값/최댓값을 취해 정렬한다:

📎 src/init.cc:1687-1703

여기서 정렬 전략에 주의하라:nChannels、sameChannels、bwIntra、bwInter는 최솟값을 취하고,typeIntra、typeInter、crossNic는 최댓값을 취한다. 왜인가? 채널 수와 대역폭은 가장 약한 링크에 의해 제한되며, 유형과 crossNic은 호환성을 보장하기 위해 합집합을 취해야 하기 때문이다.

5단계: 전송 연결 설정.

📎 src/init.cc:1811-1892

여기에는 두 가지 분기가 있다:runtimeConn가 참이면 채널 setup만 하고 연결은 하지 않으며(런타임까지 지연), 그렇지 않으면 모든 연결을 즉시 설정한다. 연결 순서는 ring → tree → NVLS → PAT → NVLS tree → CollNet이다.

동시성 제어와 하드웨어 상호작용

initTransportsRank에는 주목할 만한 동시성/하드웨어 상호작용 지점이 몇 가지 있다:

CPU 친화성 설정:

📎 src/init.cc:1406-1412

NCCL은 현재 스레드를 GPU 근처의 CPU 코어에 바인딩하여 호스트 메모리 할당이 로컬 NUMA 노드에서 이루어지도록 보장합니다. 이는 NUMA 간 접근 지연을 줄입니다.

NVLS 초기화:

📎 src/init.cc:1419-1419

ncclNvlsInitNVLink SHARP 지원을 감지합니다. NVLS는 스위치가 직접 reduce 작업을 수행할 수 있게 하여 AllReduce 지연을 크게 줄입니다.

Proxy 스레드 생성:

📎 src/init.cc:1780-1786

Proxy 스레드는 네트워크 I/O를 비동기적으로 진행하는 역할을 합니다. 이는initTransportsRank에서 생성되며, 이후 모든 네트워크 작업은 proxy를 통해 이루어집니다.

프로덕션 함정 회피 가이드

함정 1: 네트워크 장치 수 불일치.서로 다른 rank의 로컬 NIC 수가 다르면 NCCL이 오류를 발생시킵니다:

📎 src/init.cc:1576-1596

다음을 설정하지 않는 한NCCL_IGNORE_NET_MISMATCH=1. 이는 이기종 클러스터에서 흔합니다—어떤 노드는 NIC가 8개, 어떤 노드는 4개뿐입니다. 불일치를 무시하면 채널 수가 가장 약한 노드에 의해 제한되므로 성능 저하가 발생할 수 있습니다.

함정 2: 여러 rank가 동일한 GPU를 공유.두 rank의 GPU UUID가 같으면 NCCL이 초기화를 거부합니다:

📎 src/init.cc:1291-1296

다음을 설정하지 않는 한NCCL_MULTI_RANK_GPU_ENABLE=1. 이 검사는 사용자의 잘못된 구성으로 인한 성능 문제를 방지합니다.

함정 3: CollNet 노드 수 부족.CollNet은 최소NCCL_COLLNET_NODE_THRESHOLD개의 노드가 있어야 활성화됩니다:

📎 src/init.cc:1720-1728

기본 임계값은 2입니다. 단일 노드 환경에서는 CollNet이 자동으로 비활성화됩니다.

mermaid
flowchart TD
    start["initTransportsRank(comm, parent, timers)"]
    ag1["AllGather1: fillInfo + bootstrapAllGather"]
    check_ver{"版本匹配?"}
    fail_ver["返回 ncclInvalidUsage"]
    topo["ncclTopoGetSystem + ComputePaths + TrimSystem"]
    graphs["计算 ring/tree/collnet/nvls 图"]
    ag3["AllGather3: 交换图信息"]
    align["对齐 nChannels/bwIntra/bwInter"]
    setup["setupChannel 初始化所有通道"]
    conn_ring["ncclTransportRingConnect"]
    conn_tree["ncclTransportTreeConnect"]
    conn_nvls["ncclNvlsSetup + ncclNvlsBufferSetup"]
    conn_collnet{"collnetEnable?"}
    conn_collnet_yes["ncclCollNetSetup + BufferSetup"]
    devcomm["devCommSetup 映射到设备"]
    barrier["bootstrapIntraNodeBarrier"]
    done["初始化完成"]

    start --> ag1 --> check_ver
    check_ver -->|否| fail_ver
    check_ver -->|是| topo --> graphs --> ag3 --> align --> setup
    setup --> conn_ring --> conn_tree --> conn_nvls --> conn_collnet
    conn_collnet -->|是| conn_collnet_yes --> devcomm
    conn_collnet -->|否| devcomm
    devcomm --> barrier --> done

3.5 NCCL_PARAM: 환경 변수 체계의 컴파일 타임 마법

직관적 모델

NCCL_PARAM은 NCCL의 "구성 스위치 공장"입니다. 매크로를 사용하여 컴파일 타임에 함수를 생성하고, 런타임에 처음 호출될 때 환경 변수를 읽고 결과를 캐시합니다. 이는 집의 전등 스위치와 같습니다—스위치를 누르면(함수 호출) 불이 켜지고(구성 값 반환), 이후 스위치 상태가 기억되어 매번 다시 누를 필요가 없습니다.

이 메커니즘이 없다면 NCCL은 구성을 사용하는 모든 곳에서 수동으로getenv을 호출하고 문자열을 파싱해야 하므로 코드가 극도로 장황해지고 오류가 발생하기 쉬워집니다.

데이터 구조와 메모리 레이아웃

NCCL_PARAM매크로의 정의:

📎 src/include/param.h:22-31

이 매크로는 확장되어 함수ncclParam##name()를 생성하며, 내부에 세 개의 정적 변수가 있습니다:

  • uninitialized = INT64_MIN: 센티넬 값으로 "아직 초기화되지 않음"을 나타냅니다.
  • noCache: 삼상 플래그로, -1은 미초기화, 0은 캐시, 1은 캐시하지 않음을 나타냅니다.
  • cache: 캐시된 값으로, 초기값은uninitialized。

함수 로직은: 만약cache이 여전히uninitialized이면ncclLoadParam을 호출하여 로드하고; 그렇지 않으면 직접cache。COMPILER_EXPECT(..., false)을 반환합니다.

ncclLoadParam은 컴파일러에게 이 분기가 거의 실행되지 않음을 알려 핫 패스를 최적화합니다.

📎 src/misc/param.cc:78-108

의 구현:noCache전체 로딩 과정을 뮤텍스로 보호하며, 먼저

Step-by-Step Walkthrough

정책을 확인하고, 캐시가 유효한지 확인한 다음, 환경 변수를 읽고 파싱합니다. 파싱 실패 시 기본값을 사용하고 경고를 출력합니다.NCCL_PARAM(BuffSize, "BUFFSIZE", -2)을 예로 들면:

📎 src/init.cc:1007-1007

매크로 확장 후 생성:

cpp
int64_t ncclParamBuffSize() {
  constexpr int64_t uninitialized = INT64_MIN;
  static int8_t noCache = -1;
  static_assert(-2 != uninitialized, "...");
  static int64_t cache = uninitialized;
  if (COMPILER_EXPECT(COMPILER_ATOMIC_LOAD(&cache, std::memory_order_relaxed) == uninitialized, false)) {
    return ncclLoadParam("NCCL_BUFFSIZE", -2, uninitialized, &cache, &noCache);
  }
  return cache;
}

첫 호출 시,cache == uninitialized,ncclLoadParam에 진입합니다.NCCL_BUFFSIZE환경 변수를 읽고, 설정되지 않았으면 기본값 -2를 반환합니다. 그런 다음noCache정책에 따라 캐시 여부를 결정합니다.

noCache정책은ncclParamIsCacheDisabled에 의해 결정됩니다:

📎 src/misc/param.cc:74-76

환경 변수 이름이 특정 패턴과 일치하면(예:_로 끝나는 경우), 캐시하지 않고 매번 다시 읽습니다. 이를 통해 사용자는 런타임에 특정 구성을 동적으로 수정할 수 있습니다.

설계 고찰

이 설계의 정교함은 "제로 비용 추상화"에 있습니다: 핫 패스에서는 원자적 로드와 비교만 있고, 락도 문자열 파싱도 없습니다. 콜드 패스(최초 로드)에서만 전체 비용을 지불합니다.COMPILER_EXPECT은 컴파일러에게 핫 패스를 명령어 캐시 앞쪽에 배치하도록 힌트하여 성능을 더욱 향상시킵니다.

또 다른 설계는noCache의 삼상 설계입니다. -1은 "아직 결정되지 않음", 0은 "캐시", 1은 "캐시하지 않음"을 나타냅니다. 이 결정은 최초 로드 시 한 번만 이루어지고 이후 변경되지 않습니다.

프로덕션 함정 회피 가이드

함정 1: 환경 변수 철자 오류.사용자가NCCL_BUFSIZE대신NCCL_BUFFSIZE을 쓰면 NCCL은 오류를 내지 않고 기본값을 사용합니다.NCCL_DEBUG=ENV을 사용하여 인식된 모든 환경 변수를 확인하는 것을 권장합니다.

함정 2:NCCL_CONF_FILE의 로딩 순서.NCCL은$NCCL_CONF_FILE(또는~/.nccl.conf)과/etc/nccl.conf:

📎 src/misc/param.cc:52-67

을 순차적으로 로드합니다. 나중에 로드된 파일이 먼저 로드된 것을 덮어씁니다. 두 파일 모두 같은 변수를 설정하면/etc/nccl.conf의 값이 적용됩니다.

함정 3:noCache변수의 스레드 안전성.소스 주석에 "noCache is only load/stored within the mutex, no need for atomic"이라고 되어 있습니다:

📎 src/misc/param.cc:74-76

이는noCache의 읽기/쓰기가 모두 뮤텍스 보호 하에 있어 원자적 연산이 필요 없음을 의미합니다. 그러나cache의 읽기는 락이 없으므로(핫 패스) 원자적 로드를 사용합니다.

3.6 devCommSetup: 통신 도메인을 디바이스에 매핑

직관적 모델

devCommSetup은 통신 도메인의 "디바이스 측 프로젝션"입니다. GPU kernel은 디바이스에서 실행되며 호스트 메모리의ncclComm구조체에 직접 접근할 수 없습니다. 따라서 NCCL은 통신 도메인의 핵심 필드를 디바이스 접근 가능 메모리에 복사하여ncclDevComm을 형성합니다. 이는 회사의 연락처를 복사하여 각 직원의 책상에 두는 것과 같습니다—직원이 매번 프런트에 가서 동료 전화번호를 물을 필요가 없습니다.

만약devCommSetup이 없다면 GPU kernel은 자신의 rank, 채널 구성, 버퍼 크기 등의 정보를 알 수 없어 집합 통신 kernel이 시작될 수 없습니다.

데이터 구조와 메모리 레이아웃

devCommSetup은 임시 구조체ncclKernelCommAndChannels를 사용하여 디바이스에 복사할 데이터를 패킹합니다:

📎 src/init.cc:712-746

이 구조체는ncclDevComm(디바이스 측 통신 도메인)와 채널 배열을 포함합니다. 함수는 먼저 호스트 측 데이터를 임시 구조체에 채운 다음, 한 번에cudaMemcpyAsync을 디바이스로 수행합니다.

핵심 필드 채우기:

📎 src/init.cc:734-746

주목할 점은comm->devComm = &devCommAndChans->comm—호스트 측의comm->devComm은 디바이스 메모리의ncclDevComm을 가리킵니다. 이후 kernel 시작 시comm->devComm이 매개변수로 전달됩니다.

채널 정보 채우기:

📎 src/init.cc:829-843

각 채널의 peers, ring, tree, collnetChain, collnetDirect, nvls 포인터가 모두 디바이스 측으로 복사됩니다. 주의ring.userRanks추가로 한 번의cudaMemcpyAsync이 필요합니다. 왜냐하면 이것은 배열이기 때문입니다.

Step-by-Step Walkthrough

1. 디바이스 스트림 가져오기:ncclStrongStreamAcquire강한 스트림(strong stream)을 가져와 이후의 비동기 복사가 순서대로 실행되도록 보장합니다.

2. 디바이스 메모리 할당:ncclCudaCallocAsync할당devCommAndChans。

3. 호스트 측 임시 구조체 채우기: rank, nRanks, node, nNodes, abortFlag, buffSizes 등을 설정합니다.

4. 할당 및 복사rankToLocalRank배열.

5. 계산workFifoBytes: CC(Confidential Computing) 상태에 따라 결정됩니다.

6. workFifo 버퍼 할당: GDR 모드에서는ncclGdrCudaCalloc를 사용하고, 그렇지 않으면ncclCudaHostCalloc。

7. profiler 카운터 할당.

8. 진행 카운터 할당(활성화된 경우).

9. 채널 정보 채우기.

10. 디바이스로 한 번에 복사:ncclCudaMemcpyAsync(devCommAndChans, &tmpCommAndChans, 1, deviceStream)。

11. 강한 스트림 해제 및 동기화.

설계 고찰

devCommSetup에서 가장 주목할 만한 설계는 "일괄 복사"입니다. NCCL은 각 필드마다 개별적으로cudaMemcpy를 호출하지 않고, 모든 필드를 하나의 임시 구조체로 패킹하여 한 번의cudaMemcpyAsync로 완료합니다. 이는 CUDA API 호출 횟수와 동기화 오버헤드를 크게 줄입니다.

또 다른 설계는workFifoBytes의 CC 처리입니다:

📎 src/init.cc:750-763

CC(Confidential Computing) 모드에서,workFifoBytes는 0으로 설정됩니다. 왜냐하면 GDR 복사는 CC 모드에서 사용할 수 없기 때문입니다. 이는 하드웨어 제약에 대한 우아한 성능 저하입니다.

프로덕션 함정 회피 가이드

함정 1:devCommSetup은 반드시 barrier 이전에 호출해야 합니다.소스 코드 주석이 그 이유를 설명합니다:

📎 src/init.cc:1950-1952

barrier 이후에 호출하면, 일부 스레드가 이미 NCCL kernel을 시작했을 수 있고, 이때 디바이스 메모리가 아직 할당되지 않아 데드락이 발생할 수 있습니다.

함정 2:workFifoBytes은 반드시 2의 거듭제곱이어야 합니다.그렇지 않으면 NCCL이 경고를 표시하고 기본값을 사용합니다:

📎 src/init.cc:757-762

이 장의 고찰과 자가 테스트

Q1: 만약📎 src/init.cc:1291-1296에서 "여러 rank가 동일 GPU를 사용"하는지 감지하는 로직을 제거하면, 어떤 시나리오에서 문제가 발생할까요? 왜 NCCL은 기본적으로 이러한 구성을 거부할까요?

참고 해석:

이 코드는 동일 호스트에서 두 rank의 GPU UUID가 동일한지 감지합니다. 만약 동일하고NCCL_MULTI_RANK_GPU_ENABLE=0(기본값)이면,ncclInvalidUsage。

을 반환합니다. 이 검사를 제거하면, 여러 rank가 동일한 GPU를 공유하게 됩니다. 이는 다음을 초래합니다:

1. P2P 전송 충돌: NCCL의 P2P 전송은 각 rank가 하나의 GPU를 독점한다고 가정합니다. 두 rank가 GPU를 공유하면, 동시에 동일한 GPU의 동일한 버퍼에 데이터를 쓰게 되어 데이터 경쟁과 결과 오류가 발생합니다.

2. 채널 할당 충돌:comm->channels에서 채널 리소스(버퍼, FIFO)는 rank별로 할당됩니다. GPU를 공유하는 rank들은 동일한 리소스를 두고 경쟁하게 됩니다.

3. 성능 재앙: 정확성 문제가 없더라도, 두 rank가 하나의 GPU의 연산 능력과 메모리 대역폭을 공유하면 성능이 급격히 저하됩니다.

NCCL이 기본적으로 이러한 구성을 거부하는 것은 "빠른 실패"를 위한 것입니다 — 사용자가 잘못된 구성에서 몇 시간을 디버깅하며 낭비하게 하는 것보다, 초기화 시점에 명확히 오류를 보고하는 것이 낫습니다.NCCL_MULTI_RANK_GPU_ENABLE=1은 자신이 무엇을 하는지 명확히 아는 사용자(예: MPS 시나리오)를 위한 탈출구입니다.

Q2: 만약📎 src/bootstrap.cc:1129-1134에서 "동일한 (peer, tag)의 더 이른 전송"을 기다리는 로직을 제거하면, 어떤 시나리오에서 수신 측 매칭 오류가 발생할까요?

참고 해석:

이 코드는 비동기 전송 스레드에서 큐에 동일한 (peer, tag)로 향하는 더 이른 전송이 없을 때까지 대기합니다.

이 대기를 제거하면, 동일한 (peer, tag)로 향하는 두 전송이 동시에 실행될 수 있고, 수신 측에 도달하는 순서가 불확정해집니다. 수신 측의socketAccept은 (peer, tag)로 연결을 매칭합니다:

📎 src/bootstrap.cc:1291-1292

만약 송신자 A가 먼저bootstrapSend를 호출했지만 나중에 도착하고, 송신자 B가 나중에 호출했지만 먼저 도착하면, 수신 측은 B의 메시지를 A의 응답으로 간주합니다. 이는 데이터 불일치를 초래합니다 — 수신 측은 첫 번째 요청의 응답을 받았다고 생각하지만, 실제로는 두 번째 요청의 응답입니다.

소스 코드 주석이 이 시나리오를 명확히 지적합니다: "NVLS setup broadcasts to the same peers with the same tag several times during init". NVLS 초기화 중에 동일한 peer에게 동일한 tag로 여러 번 브로드캐스트하는데, 순서가 뒤바뀌면 NVLS 구성이 완전히 엉망이 됩니다.

이 순서 보장의 대가는: 동일한 (peer, tag)의 전송이 직렬화됩니다. 그러나 다른 (peer, tag)의 전송은 여전히 동시에 실행되므로, 전체 처리량은 영향을 받지 않습니다.

Q3: 만약📎 src/init.cc:1691-1697에서 정렬 전략을 "nChannels는 min, typeIntra는 max"에서 "전부 min" 또는 "전부 max"로 변경하면, 각각 어떤 문제가 발생할까요?

참고 해석:

현재 전략은:nChannels、sameChannels、bwIntra、bwInter은 min,typeIntra、typeInter、crossNic은 max를 취합니다.

만약 전부 min을 취하면:typeIntra과typeIntermin을 취하면 일부 rank의 전송 타입이 강등될 수 있다. 예를 들어 rank A는 P2P(typeIntra=P2P)를 지원하고, rank B는 SHM(typeIntra=SHM)만 지원한다면, min을 취한 후 모든 rank가 SHM을 사용하게 된다. 하지만 SHM의 열거값이 P2P보다 작을 수 있어, min을 취하면 잘못된 타입이 선택된다. 실제로typeIntra는 비트마스크 또는 열거형이며, max를 취하는 것은 "가장 강한 능력"의 타입을 선택하기 위함이다.

만약 전부 max를 취한다면:nChannelsmax를 취하면 일부 rank에 그 능력을 초과하는 채널 수가 할당될 수 있다. 예를 들어 rank A는 4개의 채널만 지원하고, rank B는 8개를 지원한다면, max를 취한 후 모든 rank가 8개의 채널을 사용하려 시도하여 rank A는 실패하거나 성능이 저하된다.bwIntramax를 취하면 대역폭 추정이 지나치게 낙관적으로 되어, tuning 모듈이 부적합한 알고리즘을 선택할 수 있다.

이 정렬 전략의 본질은:자원 제약은 교집합(min), 능력 열거는 합집합(max)이다. 채널 수와 대역폭은 "상한" 제약이므로 반드시 가장 보수적인 값을 취해야 하고, 전송 타입은 "능력" 열거이므로 최댓값을 취하여 모든 rank가 호환되는 전송 방식을 찾을 수 있도록 보장한다.

다음 장에서는 토폴로지 발견과 그래프 탐색을 깊이 다루며, NCCL이 머신 내의 GPU, 네트워크 카드, PCI 스위치를 어떻게 열거하여 완전한 토폴로지 그래프를 구축하고, 이 그래프에서 최적의 ring과 tree 구조를 탐색하는지 살펴본다. 이 장에서 확립한 bootstrap 통신, commAlloc 메모리 골격, initTransportsRank 주 간선 흐름은 다음 장에서 그 토폴로지 세부 사항을 하나씩 펼쳐낼 것이다.

지금까지 우리는 ncclCommInitRank의 호출 체인을 완전히 걸어가며 ncclComm 객체가 처음부터 구축되는 전 과정을 확인했다. 그러나 초기화 과정에서 한 가지 핵심 단계를 그냥 지나쳤다: NCCL은 머신 내부의 GPU와 네트워크 카드를 어떻게 탐지하고, 이를 기반으로 데이터가 어느 경로로 가야 할지 결정하는가? 이것이 바로 다음 장에서 깊이 다룰 주제——토폴로지 발견과 그래프 탐색이다. 우리는 src/graph/topo.cc가 PCI/NVLink/네트워크 카드 장치를 어떻게 열거하고 토폴로지 그래프를 구축하는지, src/graph/search.cc가 이 그래프에서 최적 경로를 어떻게 탐색하는지, 그리고 src/graph/rings.cc와 trees.cc가 탐색 결과를 Ring과 Tree 알고리즘 토폴로지로 어떻게 구체화하는지 분석할 것이다. 이 메커니즘을 이해하면 NCCL이 왜 다양한 머신에서 자동으로 적합한 알고리즘을 선택할 수 있는지 알게 될 것이다.

CHAPTER 04

제4장: 제4장: 토폴로지 발견과 그래프 탐색: NCCL이 다중 GPU 시스템의 물리적 상호 연결을 어떻게 "꿰뚫어 보는가"

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제4 / 25장

제4장: 토폴로지 발견과 그래프 탐색: NCCL이 다중 GPU 시스템의 물리적 상호 연결을 어떻게 "꿰뚫어 보는가"

이전 장에서 우리는 ncclCommInitRank의 호출 체인을 따라 층층이 파고들며 comm->topo 필드가 채워지는 시점을 보았지만, 그 내부 구조는 펼치지 않았다. 그렇다면 NCCL은 도대체 어떻게 머신 내의 GPU와 네트워크 카드를 "보고", 이를 사용 가능한 토폴로지 정보로 조직하는가? 이 장에서는 이 과정의 세 가지 핵심 단계를 분석한다: topo.cc는 물리적 장치를 그래프로 열거하는 역할을, search.cc는 이 그래프에서 최적 경로를 탐색하는 역할을, rings.cc와 trees.cc는 탐색 결과를 Ring과 Tree 두 가지 알고리즘 토폴로지로 구체화하는 역할을 한다. 이 세 가지의 협력을 이해해야 NCCL이 왜 다양한 머신에서 자동으로 적합한 알고리즘을 선택할 수 있는지 알 수 있다.

토폴로지 그래프: 머신을 "지하철 노선도"로 그리기

직관적 모델

당신이 낯선 도시에 막 도착한 택배 기사라고 상상해 보자. A 지점에서 B 지점으로 소포를 배송해야 하는데, 어느 길이 가장 빠른지 모른다. 당신에게는 지도가 필요하다——모든 역(GPU, 네트워크 카드, CPU, PCI 스위치)과 역 사이의 연결(NVLink, PCIe, 네트워크)이 표시된 지도 말이다. NCCL의 토폴로지 그래프가 바로 이 지도다.

이 지도가 없다면 NCCL은 "모든 GPU 간 대역폭이 동일하다"고 맹목적으로 가정할 수밖에 없으며, 8카드 NVLink 전연결 머신에서는 그럭저럭 버틸 수 있겠지만, NUMA를 넘나들거나 PCI 스위치를 넘나들거나 NVLink + PCIe가 혼합된 복잡한 토폴로지를 만나면 잘못된 경로를 선택하여 본래 NVLink로 가야 할 데이터를 느린 PCIe에 밀어넣어 성능이 곧바로 반토막 난다.

데이터 구조와 메모리 레이아웃

토폴로지 그래프의 핵심은ncclTopoSystem이며, 이는 노드 타입별로 모든 장치를 그룹화하여 저장한다. 노드 타입은topoNodeTypeStr배열에 정의되어 있다:

📎 src/graph/topo.cc:33-35

c
const char* topoNodeTypeStr[] = {"GPU", "PCI", "NVS", "CPU", "NIC", "NET", "GIN", "RMA", "DEV", "CXB"};
const char* topoLinkTypeStr[] = {"LOC", "NVL", "", "C2C", "PCI", "", "", "", "", "SYS", "NET"};
const char* topoPathTypeStr[] = {"LOC", "NVL", "NVB", "C2C", "PIX", "PXB", "P2C", "PXN", "PHB", "SYS", "NET", "DIS"};

이 세 배열은 각각 노드 타입, 링크 타입, 경로 타입의 문자열 표현을 정의한다. 주목할 점은topoPathTypeStr의 순서——이는 동시에 경로 품질의 정렬 기준으로 작용한다: 인덱스가 작을수록 경로가 더 빠르다.LOC(로컬)이 가장 빠르고,DIS(끊김)이 가장 느리다. 이 순서는 이후 탐색에서 경로 우열을 비교하는 데 반복적으로 사용된다.

각 노드는ncclTopoNode로 표현되며, 생성 시 타입에 따라 서로 다른 필드를 초기화한다. GPU 노드를 예로 들면:

📎 src/graph/topo.cc:105-141

c
ncclResult_t ncclTopoCreateNode(struct ncclTopoSystem* system, struct ncclTopoNode** node, int type, uint64_t id) {
  if (system->nodes[type].count == NCCL_TOPO_MAX_NODES) {
    WARN("Error : tried to create too many nodes of type %d", type);
    return ncclInternalError;
  }
  struct ncclTopoNode* n = system->nodes[type].nodes + system->nodes[type].count;
  system->nodes[type].count++;
  n->type = type;
  n->id = id;
  if (type == GPU) {
    n->gpu.dev = NCCL_TOPO_UNDEF;
    n->gpu.rank = NCCL_TOPO_UNDEF;
    n->gpu.cudaCompCap = NCCL_TOPO_UNDEF;
    n->gpu.mloPart = NCCL_TOPO_UNDEF;
  } else if (type == CPU) {
    ...

여기에는 몇 가지 핵심 설계 포인트가 있다. 첫째, 노드는 미리 할당된 배열(system->nodes[type].nodes)에 저장되지, 연결 리스트가 아니다. 이는 노드가 메모리에서 연속적으로 배치되어 순회 시 캐시 친화적이라는 것을 의미한다. 둘째,NCCL_TOPO_MAX_NODES는 하드 상한으로, 초과하면 오류를 발생시킨다——이는 토폴로지 이상 시 무한 증가를 방지하기 위함이다. 셋째, 각 노드에는id필드가 있으며, 이는 64비트 정수로 상위 32비트는 systemId(어느 호스트인지 식별), 하위 32비트는 localId(호스트 내 장치 번호)이다.

노드 간의 연결은ncclTopoLink로 표현된다.ncclTopoConnectNodes는 양방향 연결을 설정하는 역할을 한다:

📎 src/graph/topo.cc:179-204

c
ncclResult_t ncclTopoConnectNodes(struct ncclTopoNode* node, struct ncclTopoNode* remNode, int type, float bw) {
  // Aggregate links into higher bw for NVLink
  struct ncclTopoLink* link;
  for (link = node->links; link - node->links != NCCL_TOPO_MAX_LINKS && link->remNode; link++) {
    if (link->remNode == remNode && link->type == type) break;
  }
  if (link - node->links == NCCL_TOPO_MAX_LINKS) {
    WARN("Error : too many Topo links (max %d)", NCCL_TOPO_MAX_LINKS);
    return ncclInternalError;
  }
  if (link->remNode == NULL) node->nlinks++;
  link->type = type;
  link->remNode = remNode;
  link->bw += bw;

  // Sort links in BW descending order
  struct ncclTopoLink linkSave;
  memcpy(&linkSave, link, sizeof(struct ncclTopoLink));
  while (link != node->links) {
    if ((link - 1)->bw >= linkSave.bw) break;
    memcpy(link, link - 1, sizeof(struct ncclTopoLink));
    link--;
  }
  memcpy(link, &linkSave, sizeof(struct ncclTopoLink));
  return ncclSuccess;
}

이 함수는 세 가지 일을 한다. 첫째, 동일한 대상, 동일한 타입의 링크가 이미 존재하는지 찾는다——존재하면 대역폭을 누적한다(link->bw += bw). 이는 여러 NVLink가 동일한 GPU에 연결된 경우를 처리한다: 4개의 NVLink가 각각 25 GB/s라면 집계 후 100 GB/s가 된다. 둘째, 찾지 못하면 새 링크를 추가한다. 셋째, 삽입 후 대역폭 내림차순으로 정렬하여 이후 순회 시 고대역폭 링크를 우선적으로 보게 한다.

〔설계 추론 및 아키텍처 트레이드오프〕

대역폭 내림차순 정렬의 설계 동기는 검색 알고리즘이 고대역폭 경로를 조기에 발견하여 더 빠르게 우수한 해로 수렴하도록 하기 위함이다. 검색에는 타임아웃 제한이 있으며(나중에 보게 될NCCL_SEARCH_TIMEOUT), 정렬은 한정된 시간 예산을 더 유망한 경로에 사용할 수 있게 한다.

시나리오 기반 단계별 워크스루

이제 구체적인 시나리오를 대입해 보자: 8장의 A100 서버 한 대, 각 카드는 NVLink로 완전 연결되어 있고, 추가로 4장의 Mellanox ConnectX-6 네트워크 카드가 PCIe 슬롯에 꽂혀 있다. NCCL 초기화 시,ncclTopoGetSystem가 호출되며, 이는 XML 파일(nvidia-topologyd또는 NCCL 자체에서 생성)에서 장치 정보를 읽은 후 토폴로지 그래프를 구축한다.

첫 번째 단계, CPU 노드를 파싱한다.ncclTopoAddCpu는 XML에서 CPU의 아키텍처, 제조사, 모델을 읽고 CPU 노드를 생성한다:

📎 src/graph/topo.cc:806-875

c
ncclResult_t ncclTopoAddCpu(struct ncclXmlNode* xmlCpu, struct ncclTopoSystem* system) {
  int numaId;
  NCCLCHECK(xmlGetAttrInt(xmlCpu, "numaid", &numaId));
  int systemId;
  NCCLCHECK(ncclGetSystemId(system, xmlCpu, &systemId));
  struct ncclTopoNode* cpu;
  NCCLCHECK(ncclTopoCreateNode(system, &cpu, CPU, NCCL_TOPO_ID(systemId, numaId)));
  ...
  for (int s = 0; s < xmlCpu->nSubs; s++) {
    struct ncclXmlNode* node = xmlCpu->subs[s];
    if (strcmp(node->name, "pci") == 0) NCCLCHECK(ncclTopoAddPci(node, system, cpu, systemId, numaId));
    if (strcmp(node->name, "nic") == 0) {
      ...
    }
  }
  return ncclSuccess;
}

CPU 노드는 토폴로지 트리의 루트이다. 각 CPU 아래에는 PCI 서브트리와 NIC 노드가 매달려 있다.ncclTopoAddPci는 PCI 트리를 재귀적으로 처리하며, GPU를 만나면 GPU 노드를, NIC를 만나면 NIC 노드를 생성한다.

두 번째 단계, NVLink 연결을 추가한다. 주목할 점은ncclTopoAddGpu가 GPU의 기본 속성만 읽으며, 주석에 명확히 "Do not go any further, nvlinks will be added in a second pass"라고 되어 있다:

📎 src/graph/topo.cc:590-598

c
ncclResult_t ncclTopoAddGpu(struct ncclXmlNode* xmlGpu, struct ncclTopoSystem* system, struct ncclTopoNode* gpu) {
  NCCLCHECK(xmlGetAttrInt(xmlGpu, "rank", &gpu->gpu.rank));
  NCCLCHECK(xmlGetAttrInt(xmlGpu, "sm", &gpu->gpu.cudaCompCap));
  NCCLCHECK(xmlGetAttrInt(xmlGpu, "dev", &gpu->gpu.dev));
  NCCLCHECK(xmlGetAttrInt(xmlGpu, "gdr", &gpu->gpu.gdrSupport));
  NCCLCHECK(xmlGetAttrIntDefault(xmlGpu, "mlopart", &gpu->gpu.mlopart, NCCL_TOPO_UNDEF));
  // Do not go any further, nvlinks will be added in a second pass
  return ncclSuccess;
}

왜 두 번에 나누어 하는가? NVLink는 GPU 간의 연결이므로 링크를 설정하려면 양쪽 GPU 노드가 모두 존재해야 하기 때문이다. 첫 번째 패스에서 모든 노드를 생성하고, 두 번째 패스에서ncclTopoAddNvLinks가 이들을 연결한다.

세 번째 단계, 네트워크 장치를 처리한다.ncclTopoAddNic는 NIC 아래의 net/gin/rma 자식 노드를 순회하며 각각 해당 추가 함수를 호출한다.ncclTopoAddNet를 예로 들면:

📎 src/graph/topo.cc:461-503

c
static ncclResult_t ncclTopoAddNet(struct ncclXmlNode* xmlNet, struct ncclXmlNode* parent,
                                   struct ncclTopoSystem* system, struct ncclTopoNode* nic, int systemId) {
  int dev;
  NCCLCHECK(xmlGetAttrInt(xmlNet, "dev", &dev));
  int64_t netId = NCCL_TOPO_ID(systemId, dev);
  struct ncclTopoNode* net;
  NCCLCHECK(ncclTopoCreateNode(system, &net, NET, netId));
  net->net.dev = dev;
  int mbps;
  NCCLCHECKNOWARN(xmlGetAttrIntDefault(xmlNet, "speed", &mbps, 0), NCCL_GRAPH);
  if (mbps <= 0) mbps = 10000; // Some NICs define speed = -1
  net->net.bw = mbps / 8000.0;
  ...
  NCCLCHECK(ncclTopoConnectNodes(nic, net, LINK_NET, net->net.bw));
  NCCLCHECK(ncclTopoConnectNodes(net, nic, LINK_NET, net->net.bw));
  return ncclSuccess;
}

주목할 점은mbps / 8000.0이 변환이다: mbps는 메가비트 per 초이며, 8000으로 나누면 GB/s가 된다(1 GB/s = 8000 Mbps이므로). 네트워크 카드가 speed = -1을 보고하면(일부 가상 네트워크 카드가 그렇다), 기본값으로 10000 Mbps = 1.25 GB/s를 사용한다.

네 번째 단계, 마무리 처리.ncclTopoGetSystemFromXml는 모든 노드와 링크 추가를 완료한 후 몇 가지 정리 작업을 수행한다:

📎 src/graph/topo.cc:1080-1088

c
  NCCLCHECK(ncclTopoAddNvLinks(topNode, *topoSystem, NULL, 0));
  NCCLCHECK(ncclTopoAddC2c(topNode, *topoSystem, NULL, 0));
  NCCLCHECK(ncclTopoAddPciLinks(topNode, *topoSystem, NULL, 0));

  NCCLCHECK(ncclTopoFlattenBcmSwitches(*topoSystem));
  NCCLCHECK(ncclTopoConnectCpus(*topoSystem));
  NCCLCHECK(ncclTopoSortSystem(*topoSystem));

ncclTopoFlattenBcmSwitches는 Broadcom Gen4 PCIe 스위치의 특수한 경우를 처리한다——이들은 자신을 2계층 스위치로 표현하지만 실제로는 전대역폭이므로, 검색 알고리즘이 오도되지 않도록 "평탄화"해야 한다.ncclTopoConnectCpus는 모든 CPU 노드를 상호 연결한다(크로스 NUMA 접근은 SYS 링크를 통함).ncclTopoSortSystem는 링크를 정렬하여 PCI 다운스트림 링크가 앞에 오도록 하여 순회를 용이하게 한다.

설계 고찰 및 프로덕션 함정

〔설계 추론 및 아키텍처 트레이드오프〕

왜 XML을 중간 형식으로 사용하는가?토폴로지 발견은 프로세스 간 공유가 필요하기 때문이다——각 rank는 자신이 관리하는 GPU만 탐지한 후 bootstrap을 통해 XML을 교환하고, 최종적으로 완전한 토폴로지로 융합한다. XML은 자기 서술적 텍스트 형식으로 디버깅(덤프해서 볼 수 있음)과 버전 호환에 용이하다.

함정 1:ncclTopoGetNode는 노드를 찾지 못해도 오류를 보고하지 않는다.이 함수를 보자:

📎 src/graph/topo.cc:95-103

c
ncclResult_t ncclTopoGetNode(struct ncclTopoSystem* system, struct ncclTopoNode** node, int type, uint64_t id) {
  for (int i = 0; i < system->nodes[type].count; i++) {
    if (system->nodes[type].nodes[i].id == id) {
      *node = system->nodes[type].nodes + i;
      return ncclSuccess;
    }
  }
  return ncclSuccess;
}

찾지 못하면ncclSuccess를 반환하지만*node는 변경되지 않는다(호출자는 보통 NULL로 초기화). 호출자는 반드시 스스로*node == NULL를 확인해야 한다. 이런 설계는 놓치기 쉽다——호출자가 확인을 잊으면 이후 역참조에서 크래시가 발생한다.

함정 2:ncclTopoConnectNodes의 대역폭 누적이 오버플로를 일으킬 수 있다.동일한 노드 쌍 사이에 많은 링크가 있으면(예: NVSwitch 시나리오),link->bw += bw가 매우 크게 누적될 수 있다. float의 정밀도는 충분하지만, 링크 수가 비정상적으로 많으면 정렬 로직에 문제가 생길 수 있다.

함정 3:ncclTopoRemoveNode의 포인터 수정.노드를 삭제할 때, 삭제된 노드를 가리키는 모든 링크를 제거해야 하며, 삭제된 노드 이후 노드를 가리키는 포인터는 앞으로 이동해야 한다:

📎 src/graph/topo.cc:143-177

c
ncclResult_t ncclTopoRemoveNode(struct ncclTopoSystem* system, int type, int index) {
  struct ncclTopoNode* delNode = system->nodes[type].nodes + index;
  for (int t = 0; t < NCCL_TOPO_NODE_TYPES; t++) {
    if (delNode->paths[t] != nullptr) {
      WARN("Cannot remove topology node %d/%lx while paths are computed", type, delNode->id);
      return ncclInternalError;
    }
    for (int n = 0; n < system->nodes[t].count; n++) {
      struct ncclTopoNode* node = system->nodes[t].nodes + n;
      if (node == delNode) continue;
      for (int l = 0; l < node->nlinks; l++) {
        while (l < node->nlinks && node->links[l].remNode == delNode) {
          memmove(node->links + l, node->links + l + 1, (node->nlinks - l - 1) * sizeof(struct ncclTopoLink));
          node->nlinks--;
        }
        if (l < node->nlinks && node->links[l].remNode->type == type && node->links[l].remNode >= delNode) {
          node->links[l].remNode--;
        }
      }
    }
  }
  ...

여기에는 미묘한 점이 있다:node->links[l].remNode--는 포인터를 수정하는 중이다. 노드가 연속 배열에 저장되므로, 노드 하나를 삭제하면 이후 노드의 주소가 모두sizeof(struct ncclTopoNode)만큼 앞으로 이동한다. 따라서 삭제된 노드 이후 노드를 가리키는 모든 포인터는 1씩 감소해야 한다. 이 작업은memmove이전에 실행되며, 순서가 매우 중요합니다.

경로 탐색: 그래프에서 "최적 경로" 찾기

직관적 모델

지도만으로는 부족합니다. 내비게이션 알고리즘도 필요합니다. NCCL의 경로 탐색은 두 계층으로 나뉩니다: 첫 번째 계층은 전처리로, 모든 노드 쌍 사이의 최단 경로를 계산합니다(BFS). 두 번째 계층은 그래프 탐색으로, 전처리 결과 위에서 다양한 Ring/Tree 구조를 시도하여 대역폭이 가장 높은 것을 찾습니다.

경로 탐색이 없다면, NCCL은 "GPU 0 연결 GPU 1 연결 GPU 2..."와 같은 고정 순서를 하드코딩할 수밖에 없으며, 비균일 토폴로지에서 느린 경로를 선택하게 됩니다.

데이터 구조와 메모리 레이아웃

경로 탐색의 핵심 데이터 구조는ncclTopoLinkList이며, 특정 소스 노드에서 특정 대상 노드까지의 전체 경로를 저장합니다:

c
struct ncclTopoLinkList {
  struct ncclTopoLink* list[NCCL_TOPO_MAX_HOPS];  // 路径上的链路
  int count;      // 跳数
  float bw;       // 瓶颈带宽
  int type;       // 路径类型(PATH_LOC, PATH_NVL, ...)
  int capacity;   // list 数组的容量
};

각 노드에는paths[type]배열이 있어 해당 유형의 모든 노드로 가는 경로를 저장합니다. 예를 들어 GPU 노드의paths[NET]은 모든 네트워크 카드로 가는 경로를 저장합니다.

경로 계산은ncclTopoSetPaths에 의해 수행되며, 이는 BFS입니다:

📎 src/graph/paths.cc:52-147

c
static ncclResult_t ncclTopoSetPaths(struct ncclTopoNode* baseNode, struct ncclTopoSystem* system) {
  if (baseNode->paths[baseNode->type] == NULL) {
    NCCLCHECK(ncclCalloc(baseNode->paths + baseNode->type, system->nodes[baseNode->type].count));
    for (int i = 0; i < system->nodes[baseNode->type].count; i++) baseNode->paths[baseNode->type][i].type = PATH_DIS;
  }

  // breadth-first search to set all paths to that node in the system
  struct ncclTopoNodeList nodeList;
  struct ncclTopoNodeList nextNodeList = {{0}, 0};
  nodeList.count = 1;
  nodeList.list[0] = baseNode;
  ...
  while (nodeList.count) {
    nextNodeList.count = 0;
    for (int n = 0; n < nodeList.count; n++) {
      struct ncclTopoNode* node = nodeList.list[n];
      struct ncclTopoLinkList* path;
      NCCLCHECK(getPath(system, node, baseNode->type, baseNode->id, &path));
      for (int l = 0; l < node->nlinks; l++) {
        struct ncclTopoLink* link = node->links + l;
        struct ncclTopoNode* remNode = link->remNode;
        ...
        float bw = std::min(path->bw, link->bw);
        ...
        // Update if better path type, OR same type with higher bw, OR same type/bw with strickly fewer hops.
        if (newType < remPath->type || (newType == remPath->type && remPath->bw < bw) ||
            (newType == remPath->type && remPath->bw == bw && remPath->count > (path->count + 1))) {
          ...
          remPath->bw = bw;
          remPath->type = newType;
          ...
        }
      }
    }
    memcpy(&nodeList, &nextNodeList, sizeof(nodeList));
  }
  return ncclSuccess;
}

BFS는baseNode에서 출발하여 계층별로 확장합니다. 새로운 노드에 도달할 때마다 경로의 병목 대역폭(std::min(path->bw, link->bw))과 경로 유형을 계산합니다. 경로 유형 계산에는 몇 가지 특별한 규칙이 있습니다:

  • 두 개의 PCI 스위치를 거치면 유형이PATH_PXB
  • 으로 승격됩니다.PATH_PHB
  • CPU를 거치면 유형이PATH_NVB

으로 승격됩니다. DEV 노드를 거치고 NVLink이면 유형이

으로 승격됩니다. 업데이트 조건은 "더 나은 경로"입니다: 유형이 더 좋거나, 유형이 같지만 대역폭이 더 높거나, 유형과 대역폭이 같지만 홉 수가 더 적은 경우입니다.

시나리오 기반 단계별 워크스루ncclTopoCompute이제 두 번째 계층 탐색을 살펴봅니다.ncclTopoSearchRec이 진입점이며, 다양한 매개변수 조합을 시도하고

을 호출하여 탐색을 수행합니다.ncclTopoSearchRecGpu탐색의 핵심은 재귀 함수

📎 src/graph/search.cc:639-756

c
ncclResult_t ncclTopoSearchRecGpu(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
                                  struct ncclTopoGraph* saveGraph, struct ncclTopoNode* gpu, int step, int backToNet,
                                  int backToFirstRank, int forcedOrder, int* time) {
  if ((*time) <= 0) return ncclSuccess;
  (*time)--;
  ...
  if (step == ngpus) {
    // Determine whether we found a better solution or not
    int copy = 0;
    graph->nChannels++;
    NCCLCHECKGOTO(ncclTopoCompareGraphs(system, graph, saveGraph, &copy), ret, exit);
    if (copy) {
      memcpy(saveGraph, graph, sizeof(struct ncclTopoGraph));
      if (graph->nChannels == graph->maxChannels) *time = -1;
    }
    if (graph->nChannels < graph->maxChannels) {
      NCCLCHECKGOTO(ncclTopoSearchRec(system, graph, saveGraph, time), ret, exit);
    }
    graph->nChannels--;
    ret = ncclSuccess;
    goto exit;
  }
  graph->intra[graph->nChannels * ngpus + step] = gpu->gpu.rank;
  g = gpu - system->nodes[GPU].nodes;
  if (step == backToNet) {
    // first get back to NIC
    ...
  } else if (graph->pattern == NCCL_TOPO_PATTERN_NVLS) {
    ...
  } else if (step < system->nodes[GPU].count - 1) {
    // Go to next GPU
    ...
  } else if (step == backToFirstRank) {
    // Find first GPU and loop back to it
    ...
  } else {
    // Next path
    NCCLCHECKGOTO(ncclTopoSearchRecGpu(system, graph, saveGraph, gpu, ngpus, -1, -1, forcedOrder, time), ret, exit);
  }
  ...
}

복사

1. step == ngpus이 함수에는 몇 가지 핵심 분기가 있습니다:nChannels: 모든 GPU를 방문하여 완전한 경로가 형성된 경우입니다. 이때ncclTopoSearchRec을 증가시키고, 현재 그래프와 저장된 최적 그래프를 비교하여 더 좋으면 저장합니다. 그런 다음 재귀적으로

2. step == backToNet을 호출하여 다음 channel 탐색을 시도합니다.

3. step < ngpus - 1: 네트워크 카드로 돌아가야 하는 경우입니다. 이는 Ring 모드(마지막 GPU가 시작 네트워크 카드로 연결) 또는 Tree 모드(첫 번째 GPU가 네트워크 카드로 연결)에서 발생합니다.ncclTopoSearchNextGpuSort: 다음 GPU로 계속 진행합니다. 여기서

4. step == backToFirstRank을 호출하여 후보 GPU를 정렬합니다.

5. else: Ring 모드에서 마지막 GPU가 첫 번째 GPU로 연결됩니다.

ncclTopoSearchNextGpuSort: 경로가 끝나고 다음 라운드로 진입합니다.

📎 src/graph/search.cc:254-327

c
ncclResult_t ncclTopoSearchNextGpuSort(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
                                       struct ncclTopoNode* gpu, int* next, int* countPtr, int sortNet) {
  const uint64_t flag = 1ULL << (graph->nChannels);
  int ngpus = system->nodes[GPU].count;
  struct ncclTopoLinkList* paths = gpu->paths[GPU];
  ...
  for (int i = 1; i < ngpus; i++) {
    int g = (start + i) % ngpus;
    if (paths[g].count == 0) continue; // There is no path to that GPU
    if (system->nodes[GPU].nodes[g].used & flag) continue;
    scores[count].g = g;
    scores[count].startIndex = i;
    scores[count].intraNhops = paths[g].count;
    scores[count].intraBw = paths[g].bw;
    if (netPaths) {
      scores[count].interNhops = netPaths[g].count;
      scores[count].interPciBw = gpuPciBw(system->nodes[GPU].nodes + g);
      scores[count].interBw = netPaths[g].bw;
    }
    count++;
  }

  // Sort GPUs
  qsort(scores, count, sizeof(struct ncclGpuScore), cmpScore);
  ...
}

복사

각 후보 GPU에 점수를 매기고, 정렬 규칙은: 먼저 interBw(네트워크 카드까지의 대역폭), 그다음 interPciBw, 그다음 interNhops, 그다음 intraBw, 마지막으로 intraNhops를 비교합니다. 이 우선순위는 NCCL의 최적화 목표를 반영합니다: 크로스 머신 통신이 병목이므로, 네트워크 카드 대역폭이 높은 GPU를 우선 선택합니다.

설계 고찰과 프로덕션 함정탐색에 왜 타임아웃이 있는가?

📎 src/graph/search.cc:329-330

c
#define NCCL_SEARCH_GLOBAL_TIMEOUT (1ULL << 19)
#define NCCL_SEARCH_TIMEOUT (1 << 14)
#define NCCL_SEARCH_TIMEOUT_TREE (1 << 14)
#define NCCL_SEARCH_TIMEOUT_SAMECHANNELS (1 << 8)

복사NCCL_SEARCH_TIMEOUT탐색 공간은 지수적입니다—각 channel은 O(ngpus!) 가지 배열을 가집니다. 8카드 머신은 40320가지, 16카드는 2조 가지입니다. 탐색 시간을 제한해야 합니다.NCCL_SEARCH_GLOBAL_TIMEOUT은 16384회 반복,

은 524288회입니다. 타임아웃 후 현재 최적 해를 반환합니다.ncclTopoFollowPath함정 1:의 대역폭 차감은 전역 부작용입니다.

📎 src/graph/search.cc:127-173

c
static ncclResult_t ncclTopoFollowPath(struct ncclTopoSystem* system, struct ncclTopoGraph* graph, int type1,
                                       int index1, int type2, int index2, float mult, struct ncclTopoNode** node) {
  ...
  bw *= mult;
  // Check there is enough bandwidth on paths.
  int step = 0;
  NCCLCHECK(followPath(path, node1, path->count, bw, &step));
  if (step < path->count) goto rewind;
  // Enough bandwidth : return destination node.
  graph->nHops += mult * path->count;
  *node = system->nodes[type2].nodes + index2;
  return ncclSuccess;
rewind:
  // Not enough bandwidth : rewind and exit.
  NCCLCHECK(followPath(path, node1, step, -bw, &step));
  return ncclSuccess;
}

followPath복사bw은 경로상의 각 링크의followPath을 수정합니다(사용된 대역폭 차감). 탐색이 실패하면 반드시-bw을 호출하여

로 복원해야 합니다. 이 "차감-복원" 패턴은 재귀 탐색에서 오류가 나기 쉽습니다—어떤 분기가 복원을 잊으면, 이후 탐색에서 잘못된 대역폭을 보게 됩니다.ncclTopoCompareGraphs함정 2:의 비교 로직은 매우 미묘합니다.nChannels * bwIntra은

📎 src/graph/search.cc:446-477

c
ncclResult_t ncclTopoCompareGraphs(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
                                   struct ncclTopoGraph* refGraph, int* copy) {
  // 1. Try to get the same nChannels between Rings and Trees
  if (graph->nChannels < graph->minChannels) return ncclSuccess;
  const bool evenReference = refGraph->nChannels > 0 && !(refGraph->nChannels & 1);
  const bool evenReferenceIsBetter = refGraph->nChannels * refGraph->bwIntra >= graph->nChannels * graph->bwIntra;
  // Favor an even number of channels when aggregate bandwidth is equal or better.
  if (graph->pattern != NCCL_TOPO_PATTERN_NVLS && evenReference && (graph->nChannels & 1) &&
      graph->nChannels < system->nodes[NET].count && evenReferenceIsBetter)
    return ncclSuccess;
  ...
복사

〔설계 추론과 아키텍처 트레이드오프〕

왜 짝수 channel을 선호하는가? Ring 알고리즘이 짝수 channel에서 더 잘 페어링될 수 있기 때문입니다—각 channel을 두 halves로 나누어, 절반은 시계 방향, 절반은 반시계 방향으로 하여 네트워크 혼잡을 줄입니다.

Ring과 Tree: 탐색 결과를 알고리즘 토폴로지로 변환

직관적 모델

탐색 알고리즘이 찾는 것은 경로 집합이지만, 알고리즘이 필요로 하는 것은 명확한 "누가 누구에게 보내는가"의 순서입니다. Ring은 모든 rank를 하나의 고리로 연결하여, 각 rank가 이전에서 받고 다음으로 보냅니다. Tree는 트리로, 데이터가 루트에서 아래로 흐르거나 리프에서 위로 모입니다.

이 두 모듈이 없다면, 탐색 알고리즘은 단지 여러 경로를 찾았을 뿐, GPU kernel에게 구체적으로 데이터를 어떻게 보낼지 알려줄 수 없습니다.

데이터 구조와 메모리 레이아웃ncclBuildRingsRing의 구축은

📎 src/graph/rings.cc:29-74

c
ncclResult_t ncclBuildRings(int nrings, int* rings, int rank, int nranks, int* prev, int* next) {
  ncclResult_t ret = ncclSuccess;
  uint64_t* rankFound;
  int rankFoundSize = DIVUP(nranks, 64);
  NCCLCHECK(ncclCalloc(&rankFound, rankFoundSize));

  for (int r = 0; r < nrings; r++) {
    int current = rank;
    for (int i = 0; i < nranks; i++) {
      rankFound[current / 64] |= (1ULL << (current % 64));
      rings[r * nranks + i] = current;
      current = next[r * nranks + current];
    }
    ...
    if (current != rank) {
      WARN("Error : ring %d does not loop back to start (%d != %d)", r, current, rank);
      ret = ncclInternalError;
      goto end;
    }
    // Check that all ranks are there
    for (int i = 0; i < nranks; i++) {
      uint64_t bits = rankFound[i / 64], mask = 1ULL << (i % 64);
      // Fast check 64 ranks at a time
      if (mask == 1 && bits == 0xffffffffffffffff) {
        i += 63;
        continue;
      }
      if ((bits & mask) == 0) {
        WARN("Error : ring %d does not contain rank %d", r, i);
        ret = ncclInternalError;
        goto end;
      }
    }
    memset(rankFound, 0, rankFoundSize * sizeof(uint64_t));
  }
end:
  free(rankFound);
  return ret;
}

복사prev입력은next과rings배열(각 rank의 전임자와 후임자)이며, 출력은next배열(각 channel의 전체 rank 순서)입니다. 현재 rank에서 출발하여

포인터를 따라 한 바퀴 돌며, 시작점으로 돌아오는지 검증하고 모든 rank가 방문되었는지 확인합니다.ncclGetBtreeTree의 구축은

📎 src/graph/trees.cc:32-67

c
ncclResult_t ncclGetBtree(int nranks, int rank, int* u, int* d0, int* d1, int* parentChildType) {
  int up, down0, down1;
  int bit;
  for (bit = 1; bit < nranks; bit <<= 1) {
    if (bit & rank) break;
  }

  if (rank == 0) {
    *u = -1;
    *d0 = -1;
    // Child rank is > 0 so it has to be our child 1, not 0.
    *d1 = nranks > 1 ? bit >> 1 : -1;
    return ncclSuccess;
  }

  up = (rank ^ bit) | (bit << 1);
  // if smaller than the parent, we are his first child, otherwise we're his second
  if (up >= nranks) up = (rank ^ bit);
  *parentChildType = (rank < up) ? 0 : 1;
  *u = up;

  int lowbit = bit >> 1;
  // down0 is always within bounds
  down0 = lowbit == 0 ? -1 : rank - lowbit;

  down1 = lowbit == 0 ? -1 : rank + lowbit;
  // Make sure down1 is within bounds
  while (down1 >= nranks) {
    down1 = lowbit == 0 ? -1 : rank + lowbit;
    lowbit >>= 1;
  }
  *d0 = down0;
  *d1 = down1;

  return ncclSuccess;
}

복사bit이 함수는 비트 연산으로 이진 트리를 구축합니다. 핵심 아이디어는: rank의 최하위 비트(rank ^ bit) | (bit << 1)를 찾아, 부모 노드는rank - (bit >> 1), 왼쪽 자식은rank + (bit >> 1), 오른쪽 자식은

입니다. 주석의 ASCII 그림이 이 구조를 명확히 보여줍니다.

8개 카드 Ring을 예로 들어보자. 검색 결과가 각 rank의next포인터:

code
rank 0 -> rank 1
rank 1 -> rank 2
...
rank 7 -> rank 0

ncclBuildRingsrank 0에서 출발하여 1, 2, ..., 7을 순서대로 방문하고 마지막에 0으로 돌아온다. 생성된rings[0..7] = {0, 1, 2, 3, 4, 5, 6, 7}。

Tree의 경우,ncclGetBtree각 rank에 대해 부모 노드와 자식 노드를 계산한다. rank 1을 예로 들면:

  • bit= 1 (최하위 비트가 0번째 비트)
  • up = (1 ^ 1) | (1 << 1) = 0 | 2 = 2
  • up >= nranks? 2 < 8, 따라서up = 2
  • parentChildType = (1 < 2) ? 0 : 1 = 0(부모 노드의 첫 번째 자식)
  • lowbit = 0, 따라서down0 = -1
  • down1 = -1

따라서 rank 1의 부모 노드는 rank 2이고 자식 노드는 없다. 이는 주석의 트리 구조와 일치한다: rank 1은 리프이다.

설계 고찰과 프로덕션 함정

〔설계 추론과 아키텍처 트레이드오프〕

Tree는 왜 명시적 트리 구축 대신 비트 연산을 사용하는가?각 rank는 자신의 부모 노드와 자식 노드만 알면 되고 전역 트리 구조는 필요하지 않기 때문이다. 비트 연산은 O(1) 시간에 이 정보를 계산할 수 있어 전체 트리를 저장하고 동기화하는 오버헤드를 피할 수 있다.

함정 1:ncclBuildRings의 검증이 건너뛰어질 수 있다.만약next배열에 사이클이 있으면 (예: rank 0 -> rank 1 -> rank 0), 루프는nranks번 반복 후 종료되지만,current != rank검사가 이 문제를 잡아낸다. 하지만 사이클의 길이가 정확히nranks의 약수이고 모든 rank를 포함하지 않으면,rankFound검사가 잡아낸다.

함정 2:ncclGetDtree의 홀수 rank 처리.홀수 개의 rank에 대해 두 번째 트리는 "미러"가 아니라 "시프트"이다:

📎 src/graph/trees.cc:90-112

c
ncclResult_t ncclGetDtree(int nranks, int rank, int* s0, int* d0_0, int* d0_1, int* parentChildType0, int* s1,
                          int* d1_0, int* d1_1, int* parentChildType1) {
  // First tree ... use a btree
  ncclGetBtree(nranks, rank, s0, d0_0, d0_1, parentChildType0);
  // Second tree ... mirror or shift
  if (nranks % 2 == 1) {
    // shift
    int shiftrank = (rank - 1 + nranks) % nranks;
    ...
  } else {
    // mirror
    int u, d0, d1;
    ncclGetBtree(nranks, nranks - 1 - rank, &u, &d0, &d1, parentChildType1);
    *s1 = u == -1 ? -1 : nranks - 1 - u;
    ...
  }
  return ncclSuccess;
}

이중 이진 트리(Double Tree)는 NCCL의 Tree 알고리즘 구현이다——두 트리가 동시에 작동하며, 하나는 전반부 데이터를, 다른 하나는 후반부 데이터를 담당하여 대역폭 활용률을 높인다. 홀수 rank일 때 미러를 사용하면 rank 매핑이 불완전해지므로 시프트로 변경한다.

세 가지의 협력: 토폴로지에서 알고리즘까지

이제 세 모듈을 연결해보자. 전체 흐름은 하나의 그림으로 표현할 수 있다:

mermaid
flowchart TD
    A["ncclTopoGetSystem()"] --> B["解析 XML,创建节点"]
    B --> C["ncclTopoConnectNodes() 建立链路"]
    C --> D["ncclTopoComputePaths() 计算所有路径"]
    D --> E{"ncclTopoCompute() 搜索"}
    E -->|"Ring 模式"| F["ncclTopoSearchRecNet()"]
    E -->|"Tree 模式"| G["ncclTopoSearchRecNet()"]
    F --> H["ncclTopoSearchRecGpu() 递归搜索"]
    G --> H
    H --> I{"找到更优解?"}
    I -->|"是"| J["memcpy 保存到 saveGraph"]
    I -->|"否"| K["继续尝试其他路径"]
    J --> L["ncclBuildRings() 或 ncclGetDtree()"]
    K --> H
    L --> M["生成最终算法拓扑"]

이 그림은 토폴로지 발견부터 알고리즘 생성까지의 전체 흐름을 보여준다. 주목할 점은ncclTopoSearchRecGpu이 재귀 함수로서 타임아웃되거나 최적해를 찾을 때까지 계속해서 다른 GPU 순서를 시도한다는 것이다.

더 세밀한 타임시퀀스 다이어그램을 보면 검색 과정에서 각 모듈의 상호작용을 볼 수 있다:

mermaid
sequenceDiagram
    participant Init as ncclTopoCompute
    participant Search as ncclTopoSearchRec
    participant Net as ncclTopoSearchRecNet
    participant Gpu as ncclTopoSearchRecGpu
    participant Follow as ncclTopoFollowPath
    participant Compare as ncclTopoCompareGraphs

    Init->>Search: ncclTopoSearchRec(system, tmpGraph, graph, &time)
    Search->>Net: ncclTopoSearchRecNet(system, graph, saveGraph, backToNet, backToFirstRank, time)
    Net->>Net: ncclTopoSelectNets() 选择候选网卡
    Net->>Gpu: ncclTopoSearchTryGpu(..., NET, n, gpu)
    Gpu->>Follow: ncclTopoFollowPath(system, graph, NET, n, GPU, g, 1, &gpu)
    Follow-->>Gpu: 返回目标 GPU 节点
    Gpu->>Gpu: 递归 ncclTopoSearchRecGpu(step+1)
    Gpu->>Compare: ncclTopoCompareGraphs(system, graph, saveGraph, &copy)
    Compare-->>Gpu: copy=1 表示更优
    Gpu->>Gpu: memcpy(saveGraph, graph)
    Gpu->>Follow: ncclTopoFollowPath(..., -1, &gpu) 恢复带宽

이 타임시퀀스 다이어그램은 검색의 핵심 루프를 보여준다: NIC 선택 -> GPU 시도 -> 재귀 검색 -> 결과 비교 -> 대역폭 복원.

이 장 요약

이 장에서는 NCCL 토폴로지 인식의 세 가지 단계를 분석했다:

1. 토폴로지 발견(topo.cc): XML에서 장치 정보를 읽고, GPU/CPU/PCI/NIC 노드를 생성하고, NVLink/PCIe/네트워크 링크를 구축하여 완전한 토폴로지 그래프를 형성한다.

2. 경로 검색(search.cc + paths.cc): 먼저 BFS로 모든 노드 쌍 간의 최단 경로를 미리 계산한 다음, 재귀 검색으로 다양한 Ring/Tree 구조를 시도하여 대역폭이 가장 높은 방식을 찾는다.

3. 알고리즘 토폴로지 생성(rings.cc + trees.cc): 검색 결과를 구체적인 rank 순서로 변환한다. Ring은ncclBuildRings으로 링을 생성하고, Tree는ncclGetBtree으로 이진 트리를 생성한다.

이 장 사고와 자가 점검

Q1: 만약ncclTopoConnectNodes에서 대역폭 누적link->bw += bw을link->bw = std::max(link->bw, bw)로 변경하면, 어떤 시나리오에서 성능 저하가 발생하는가? 왜인가?

참고 해석: 대역폭 누적은 여러 병렬 링크의 경우를 처리한다. 4개의 NVLink가 각각 25 GB/s인 경우를 예로 들면, 누적 후 100 GB/s이지만 max를 취하면 25 GB/s에 불과하다.ncclTopoSetPaths에서 경로 대역폭은std::min(path->bw, link->bw)이며, 링크 대역폭이 과소평가되면 전체 경로의 대역폭이 과소평가된다. 이로 인해ncclTopoCompareGraphs이 잘못된 그래프를 선택하게 된다——채널 수는 더 많지만 각 채널의 대역폭이 더 낮은 방식을 선택하여 실제 성능이 오히려 더 나빠질 수 있다. 구체적 시나리오: 8카드 A100 전 NVLink 상호 연결, 각 GPU 쌍 사이에 4개의 NVLink. 누적하면 100 GB/s, max를 취하면 25 GB/s. 검색 알고리즘은 NVLink와 PCIe Gen4 x16(약 25 GB/s)의 대역폭이 같다고 판단하여 PCIe 경로를 선택할 수 있다.

Q2: ncclTopoSearchRecGpu에서(*time)--이 함수 진입점에서 실행된다. 검색이 타임아웃되면(*time <= 0), 함수가 바로 반환된다. 이 설계는 어떤 상황에서 검색이 무한 루프에 빠지게 하는가? 어떻게 수정하는가?

참고 해석:(*time)--이 진입점에서 감소하며, 만약*time의 초기값이 0이거나 음수이면 함수가 바로 반환되어 감소하지 않는다. 하지만*time이 매우 큰 양수이면 매 재귀마다 감소하여 결국 0에 도달한다. 문제는: 어떤 분기의 재귀 깊이가 매우 크지만 매번 감소 후*time이 여전히 0보다 크면 검색이 계속된다. 진짜 위험은ncclTopoSearchRec의goto search루프이다——만약time이 루프에서 올바르게 리셋되지 않으면 무한 루프가 발생할 수 있다.ncclTopoCompute의globalTimeout로직을 보자:globalTimeout -= time이 매번search레이블에서 실행되며, 만약globalTimeout이 음수가 되면goto done을 실행한다. 하지만 만약time이NCCL_SEARCH_TIMEOUT,globalTimeout로 리셋되면 절대 음수가 되지 않을 수 있다. 수정 방법은globalTimeout이 매 검색 후 감소하고 하드 상한이 있도록 보장하는 것이다.

Q3: ncclTopoFollowPath검색 실패 시followPath(path, node1, step, -bw, &step)을 호출하여 대역폭을 복원한다. 만약 어떤 재귀 분기가 복원 전에 반환되면(예:NCCLCHECKGOTO이exit으로 점프), 무슨 일이 발생하는가? 이런 문제를 어떻게 감지하는가?

참고 해석: 만약 복원이 건너뛰어지면 경로상의 링크 대역폭이 차감된 상태로 유지된다. 후속 검색은 잘못된 대역폭을 보게 되어 최적해를 놓칠 수 있다. 감지 방법:ncclTopoCompute종료 후, 모든 링크를 순회하며 대역폭이 초기값과 일치하는지 확인한다. 불일치가 발견되면 복구 누락이 있다는 뜻이다. 수정 방법: RAII 스타일의 가드 객체를 사용하여 소멸 시 대역폭을 자동으로 복구한다. 또는 매 검색 전에 모든 링크의 대역폭 스냅샷을 저장하고, 검색 후 복구한다. NCCL의 현재 방식은 각ncclTopoFollowPath호출 지점에서 정방향과 역방향 호출을 수동으로 짝지어 주는 것이며, 이는 오류가 발생하기 쉽다. 더 견고한 설계는 대역폭 차감과 복구를 하나의 함수로 캡슐화하여 쌍으로 나타나도록 보장하는 것이다.

다음 장에서는 tuning 모듈을 깊이 살펴보며, NCCL이 토폴로지 검색 결과와 메시지 크기에 따라 Ring, Tree, CollNet 등의 알고리즘 중에서 최종 선택을 어떻게 내리는지 알아본다. 이 장에서 구축한 토폴로지 그래프, 경로 검색 결과, 알고리즘 템플릿은 tuning 모듈의 입력이 된다.

topo.cc의 그래프 구축, search.cc의 경로 검색, 그리고 rings.cc와 trees.cc의 토폴로지 생성을 통해, NCCL은 범용 그래프 구조로 임의의 토폴로지를 기술하고, 구성 가능한 검색 알고리즘으로 최적해를 찾으며, 간단한 템플릿으로 최종 알고리즘을 생성하는 설계 철학을 구현했다. 이 메커니즘 덕분에 NCCL은 2장 워크스테이션부터 10000장 클러스터까지 다양한 머신에서 적합한 알고리즘을 자동으로 선택할 수 있다. 그러나 토폴로지 그래프는 알고리즘의 후보 경로만 제공할 뿐, 특정 통신에서 어느 경로를 택하고 어떤 프로토콜을 사용할지는 더 정교한 결정이 필요하다. 다음 장에서는 src/tuning 디렉터리에 초점을 맞추어, tuning 모듈이 비용 모델과 알고리즘 추정을 결합하여 Ring/Tree/NVLS/PAT 및 LL/LL128/Simple 사이에서 최종 선택을 어떻게 내리는지 살펴본다.

CHAPTER 05

제5장: 제5장: 알고리즘과 프로토콜 선택: tuning 모듈이 통신 경로를 결정하는 방법

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제5 / 25장

제5장: 알고리즘과 프로토콜 선택: tuning 모듈이 통신 경로를 결정하는 방법

지난 장에서 우리는 NCCL의 토폴로지 인식 능력을 분석했다: src/graph/topo.cc에서 디바이스를 열거하여 토폴로지 그래프를 구축하고, src/graph/search.cc에서 최적 경로를 검색하며, rings.cc와 trees.cc에서 검색 결과를 Ring과 Tree 알고리즘 토폴로지로 구체화한다. 그러나 토폴로지 그래프는 "데이터가 어느 길로 갈 수 있는가"만 답할 뿐, "이번 통신이 어느 길로 가야 하는가"는 답하지 않는다. 같은 머신에서 4KB AllReduce와 400MB AllReduce의 최적해는 완전히 다를 수 있다: 전자는 지연 시간을 겨루고, 후자는 대역폭을 겨룬다. 전자는 Tree/LL을 선택할 수 있고, 후자는 Ring/Simple 또는 NVLS를 선택할 수 있다. tuning 모듈이 바로 그 "결정권자"다. 그 입력은 메시지 크기, rank 수, 토폴로지 그래프(지난 장의 산물), 사용자 환경 변수이며, 출력은 ncclTuningResult_t로, 여기에 어떤 알고리즘(algo)을, 어떤 프로토콜(proto)을, 채널을 몇 개 열고, warp를 몇 개 사용할지가 담겨 있다. 이 장에서는 "총调度 → 비용 모델 → 각 알고리즘 추정 → 마무리 결정"의 순서로 src/tuning 디렉터리를 해부한다. 핵심 질문은 단 하나다: NCCL은 어떻게 수십 가지 (알고리즘, 프로토콜) 조합 중에서 순수 CPU 수학 모델로 마이크로초 단위 시간 안에 가장 빠른 하나를 선택하는가?

一、tuning.cc: 총调度와 결정의 주 간선

직관적 모델

tuning 모듈을 한이사 회사라고 상상해 보자. 고객(한 번의 집합 통신)이 와서 "100MB의 화물을 8개 창고에서 8개 창고로 옮기고 싶다"고 말한다. 배차 담당자(ncclTuningCompute)는 실제로 한 번 옮겨보지 않고,가격표(비용 모델)를 꺼내서 각 방안(Ring/LL, Tree/Simple, NVLS/Simple……)에 대해 "예상 소요 시간"을 추정한 뒤, 가장 짧은 견적을 골라 고객에게 제시한다.

이 배차 담당자가 없다면, NCCL은 "AllReduce는 항상 Ring을 쓴다"고 하드코딩할 수밖에 없고, 그러면 작은 메시지 시나리오에서는 Tree에게, 대규모 NVLink 시나리오에서는 NVLS에게 완패할 것이다.그 대가는 특정 시나리오에서 성능이 반토막 나거나 더 나빠지는 것이다.

데이터 구조와 메모리 레이아웃

결정의 매개체는ncclTuningResult_t이고, 후보 집합은ncclTuningResultList_t(단일 연결 리스트)이다. 연결 리스트 노드는tuning_int.h에 정의되어 있지만, push 로직은tuning.cc에 있다:

📎 src/tuning/tuning.cc:32-39

c
ncclResult_t ncclTuningResultListPushFront(struct ncclTuningResultList_t* list, struct ncclTuningResult_t result) {
  struct ncclTuningResultListNode* node = nullptr;
  NCCLCHECK(ncclCalloc(&node, 1));
  node->result = result;
  node->next = list->head;
  list->head = node;
  return ncclSuccess;
}
〔설계 추론과 아키텍처 트레이드오프〕

여기서 주목할 점은헤드 삽입법이라는 것이다: 유효한 후보가 하나 계산될 때마다 연결 리스트의 머리에 삽입한다. 이는 연결 리스트 순서와 id 순서가반대라는 뜻이다. 왜 배열이 아니라 연결 리스트를 쓰는가? 후보 수는 컴파일 시점에NCCL_TUNING_COUNT에 의해 결정되지만, 실제 유효한 후보는 동적이며(tuningMask, 플랫폼 능력, 사용자 환경 변수의 영향을 받음), 연결 리스트는 "유효한 것만 매달아 두기"를 허용하여 순회 시 반복적으로valid를 판단하는 것을 피할 수 있기 때문이다. 그 대가는 매 결정마다ncclCalloc한 번이지만, 튜닝이 인큐 경로에서 발생하고 빈도가 높지 않으므로 이 정도 할당 오버헤드는 허용 가능하다.

ncclTuningResult_t에서 가장 핵심적인 두 필드는timeUs(예상 소요 시간, 마이크로초)와selectionTimeUs(선택에 사용되는 소요 시간, tuner 플러그인에 의해 덮어쓰여질 수 있음)이다. 선택 로직은 후자만 본다:

📎 src/tuning/tuning.cc:155-173

c
static ncclResult_t ncclTuningSelectBestTuning(struct ncclTuningResultList_t* tunings,
                                               struct ncclTuningResult_t* const bestTuning) {
  bestTuning->timeUs = FLT_MAX;
  float bestSelectionTimeUs = FLT_MAX;
  struct ncclTuningResultListNode* node = tunings->head;
  while (node != nullptr) {
    const struct ncclTuningResult_t& tuning = node->result;
    float selectionTimeUs = tuning.selectionTimeUs > 0.0f ? tuning.selectionTimeUs : tuning.timeUs;
    ...
    if (selectionTimeUs < bestSelectionTimeUs) {
      *bestTuning = tuning;
      bestSelectionTimeUs = selectionTimeUs;
    }
    node = node->next;
  }
  return ncclSuccess;
}

여기에는 디테일이 하나 있다:bestTuning->timeUs이 먼저FLT_MAX로 설정된 후, 순회한다. 만약 연결 리스트가 비어 있다면 (모든 후보가 무효),bestTuning은NCCL_TUNING_RESULT_INIT의 초기값을 유지하며, algo/proto 모두UNDEF이다. 이 "빈 결과"는 호출 측에서 특별 처리된다 — 뒤의 에러 분기를 참조.

Step-by-Step Walkthrough: 한 번의 AllReduce 의사결정 흐름

애플리케이션이ncclAllReduce를 호출한다고 가정하자. 메시지 1MB, 8개 rank 단일 머신 NVLink. 우리는ncclTuningCompute을 따라가 본다.

0단계: 단일 rank 단락.만약nRanks <= 1이면, 통신이 전혀 필요 없으므로 바로 Ring/Simple을 반환하고, channel 수를 0으로 설정한다:

📎 src/tuning/tuning.cc:191-200

c
  // Set tuning to Ring/Simple for single rank case
  if (input->comm->nRanks <= 1) {
    bestTuning.algo = NCCL_ALGO_RING;
    bestTuning.proto = NCCL_PROTO_SIMPLE;
    bestTuning.symKernelId = ncclSymkKernelId_Count;
    bestTuning.ceMethodId = ncclCeMethodId_Count;
    bestTuning.nChannels = 0;
    bestTuning.maxChannels = 0;
    bestTuning.nWarps = 0;
    bestTuning.forced = 0;
  } else {

이 단락은 매우 중요하다: 단일 rank일 때 어떤 알고리즘 추정이든nRanks-1같은 양으로 나누게 되어 NaN이나 0으로 나누기가 발생하기 쉽다.먼저 방어하고, 그 다음 계산한다는 방어적 프로그래밍의 전형이다.

1단계: 모든 후보 열거.이ncclTuningComputeAllTunings에 진입하면,NCCL_TUNING_COUNT개의 id를 순회한다:

📎 src/tuning/tuning.cc:128-149

c
ncclResult_t ncclTuningComputeAllTunings(struct ncclTuningInput_t* const input,
                                         struct ncclTuningResultList_t* const tunings) {
  ncclResult_t ret = ncclSuccess;

  for (int i = 0; i < NCCL_TUNING_COUNT; i++) {
    struct ncclTuningResult_t tuning = NCCL_TUNING_RESULT_INIT;
    tuning.id = i;
    tuning.valid = 1;

    if (!(input->tuningMask & (1ULL << i))) {
      tuning.valid = 0;
      continue;
    }
    NCCLCHECK(ncclTuningExpandId(i, &tuning.algo, &tuning.proto, &tuning.symKernelId, &tuning.ceMethodId));
    NCCLCHECKGOTO(ncclTuningComputeTuning(i, input, &tuning), ret, fail);
    if (tuning.valid) NCCLCHECKGOTO(ncclTuningResultListPushFront(tunings, tuning), ret, fail);
  }
...
}

주목할 점은tuningMask이 64비트 마스크라는 것이다. i번째 비트는 "i번째 (algo, proto) 조합이 허용되는지"를 나타낸다. 이 마스크는 상위 계층에서 플랫폼 능력, 사용자 환경 변수, 함수 타입에 따라 계산된다.마스크는 "거친 필터", 비용 모델은 "정밀 계산"— 먼저 근본적으로 불가능한 것(예: PCI 머신에서 NVLS는 불가능)을 제외하고, 나머지에 대해 시간을 계산한다.

ncclTuningExpandId은 1차원 id를 (algo, proto, symKernelId, ceMethodId)로 전개한다. 이 매핑 관계는cost_model.cc의modelMap배열과 엄격히 일치해야 하며, 그렇지 않으면 모델이 잘못 계산된다.

2단계: 개별 비용 계산. ncclTuningComputeTuning은 한 줄뿐이며, 비용 모델로 전달한다:

📎 src/tuning/tuning.cc:339-343

c
ncclResult_t ncclTuningComputeTuning(int id, struct ncclTuningInput_t* const input,
                                     struct ncclTuningResult_t* const result) {
  NCCLCHECK(ncclTuningCostModelSimModel(id, input, result));
  return ncclSuccess;
}

3단계: tuner 플러그인 개입 (선택적).만약 사용자가 tuner 플러그인(예: 일부 클라우드 업체의 자체 튜닝기)을 설치했다면, NCCL은 모든 후보의timeUs을 2차원 테이블generalTable[algo][proto]로 묶어 플러그인에 전달하고, 플러그인이 덮어쓰도록 한다:

📎 src/tuning/tuning.cc:203-230

c
    if (input->comm->tuner != NULL) {
      float generalTable[NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];
      for (int i = 0; i < NCCL_NUM_ALGORITHMS; i++) {
        for (int j = 0; j < NCCL_NUM_PROTOCOLS; j++) {
          generalTable[i][j] = NCCL_TUNING_IGNORE;
        }
      }
      struct ncclTuningResultListNode* node = tunings.head;
      while (node != nullptr) {
        const struct ncclTuningResult_t& tuning = node->result;
        node = node->next;
        if (tuning.algo == NCCL_ALGO_UNDEF || tuning.proto == NCCL_PROTO_UNDEF) continue;
        generalTable[tuning.algo][tuning.proto] = tuning.timeUs;
      }
      node = tunings.head;
      int nMaxChannels = 0;
      NCCLCHECKGOTO(input->comm->tuner->getCollInfo(input->comm->tunerContext, input->func, input->nBytes,
                                                    input->numPipeOps, (float**)generalTable, NCCL_NUM_ALGORITHMS,
                                                    NCCL_NUM_PROTOCOLS, input->regBuff, &nMaxChannels),
                    ret, exit);
      while (node != nullptr) {
        struct ncclTuningResult_t& tuning = node->result;
        node = node->next;
        if (tuning.algo == NCCL_ALGO_UNDEF || tuning.proto == NCCL_PROTO_UNDEF) continue;
        tuning.maxChannels = nMaxChannels;
        tuning.timeUs = generalTable[tuning.algo][tuning.proto];
      }
    }

여기서NCCL_TUNING_IGNORE은 센티넬 값으로, "이 조합은 계산되지 않았음/적용 불가"를 나타낸다. 플러그인은 관심 있는 셀만 수정할 수 있고, 다른 셀은 IGNORE로 유지하면 NCCL이 건너뛴다.

4단계: 최적 선택.이ncclTuningSelectBestTuning을 호출하여, 연결 리스트를 순회하며selectionTimeUs이 가장 작은 것을 선택한다.

5단계: channel 수 계산.알고리즘을 선택한 후, channel을 몇 개 열지 결정해야 한다:

📎 src/tuning/tuning.cc:233-235

c
  if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
    NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
  }

ncclTuningGetChannels은tuning_int.h에서, 메시지 크기와 알고리즘 타입에 따라minChannels와maxChannels사이를 보간한다. channel 수는 대역폭에 직접 영향을 미친다: channel이 많을수록 병렬성이 높아지지만, 각 channel의 시작 오버헤드도 커진다.

6단계: CTA Policy 편향 (NVLS 우선).만약 사용자가NCCL_CTA_POLICY_EFFICIENCY을 설정했고, 현재 AllGather/ReduceScatter이며 buffer가 등록되어 있다면, NCCL은 결과를 NVLS로 변경하려고 시도한다:

📎 src/tuning/tuning.cc:240-257

c
  if (input->comm->tuner == NULL && (input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY) &&
      ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL && !input->comm->MNNVL &&
      (input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)))) {
    if (input->regBuff && (input->func == ncclFuncAllGather || input->func == ncclFuncReduceScatter)) {
      if ((input->comm->nNodes > 1 && input->collNetSupport && input->nvlsSupport) ||
          (input->comm->nNodes == 1 && input->nvlsSupport)) {
        int recChannels;
        NCCLCHECKGOTO(ncclNvlsRegResourcesQuery(input->comm, input->func, &recChannels), ret, exit);
        if (recChannels <= bestTuning.nChannels) {
          bestTuning.algo = NCCL_ALGO_NVLS;
          ...

이 코드의 주석이 매우 중요하다:EFFICIENCY 편향은 반드시GetChannels이후에 실행되어야 한다. 왜냐하면bestTuning.nChannels을 사용해야 하기 때문이다. 또한tuningMask에서 NVLS 비트가 허용되는지 반드시 확인해야 한다. 그렇지 않으면 상위 계층에서 제외된 알고리즘을 "부활"시키게 된다. 이것은 전형적인상태 의존 순서 함정。

7단계: 대칭 kernel 폴백.만약 선택된 것이 대칭 kernel(symKernelId)이지만 buffer가 등록되지 않았거나 플랫폼이 지원하지 않는다면, 일반 kernel로 폴백해야 한다. 이 로직은tuning.cc:258-298에 있으며, 전체 장에서 가장 복잡한 부분이므로 5절에서专门 다룬다.

8단계: 해결 불가 오류.만약 모든 후보가 무효라면, algo/proto 모두 UNDEF이며, NCCL은 WARN을 출력하고 사용자가 환경 변수를 설정했는지에 따라 다른 오류 코드를 반환한다:

📎 src/tuning/tuning.cc:308-329

c
  if ((bestTuning.algo == NCCL_ALGO_UNDEF || bestTuning.proto == NCCL_PROTO_UNDEF) &&
      bestTuning.symKernelId == ncclSymkKernelId_Count && bestTuning.ceMethodId == ncclCeMethodId_Count) {
    ...
    WARN("No algorithm/protocol nor symKernelId available for function %s with datatype %s.%s%s%s",
         ncclFuncToString(input->func), ncclDatatypeToString(input->datatype), ncclAlgoEnvStr, ncclProtoEnvStr,
         ncclSymKernelIdEnvStr);
    ret = (algoEnv || protoEnv || symKernelIdEnv) ? ncclInvalidUsage : ncclInternalError;
  }

왜 오류 코드를 구분하는가?만약 사용자가NCCL_ALGO=ring을 설정했지만 현재 플랫폼이 ring을 지원하지 않는다면(예: 일부 특수 토폴로지), 그것은사용자 구성 오류(ncclInvalidUsage)이다. 만약 사용자가 어떤 환경 변수도 설정하지 않았는데 알고리즘을 선택할 수 없다면, 그것은NCCL 내부 버그(ncclInternalError)이다. 이 구분은 트러블슈팅에 매우 중요하다.

의사결정 주간 흐름도

mermaid
flowchart TD
    start["ncclTuningCompute(input)"] --> check_rank{"comm->nRanks <= 1?"}
    check_rank -->|是| single["bestTuning = Ring/Simple<br/>nChannels = 0"]
    check_rank -->|否| enum["ncclTuningComputeAllTunings<br/>遍历 NCCL_TUNING_COUNT"]
    enum --> mask{"tuningMask & (1<<i)?"}
    mask -->|否| skip["tuning.valid = 0<br/>continue"]
    mask -->|是| expand["ncclTuningExpandId(i)"]
    expand --> sim["ncclTuningComputeTuning<br/>-> ncclTuningCostModelSimModel"]
    sim --> valid{"result.valid?"}
    valid -->|是| push["ncclTuningResultListPushFront"]
    valid -->|否| skip
    push --> tuner{"comm->tuner != NULL?"}
    tuner -->|是| plugin["tuner->getCollInfo<br/>覆盖 generalTable"]
    tuner -->|否| select
    plugin --> select["ncclTuningSelectBestTuning<br/>取 selectionTimeUs 最小"]
    select --> getch["ncclTuningGetChannels"]
    getch --> cta{"CTA_POLICY_EFFICIENCY<br/>且 NVLS 在 mask 内?"}
    cta -->|是| nvls["ncclNvlsRegResourcesQuery<br/>可能改写为 NVLS"]
    cta -->|否| symk
    nvls --> symk{"symKernelId 需要回退?"}
    symk -->|是| fallback["ncclTuningCompute(generalInput)<br/>回退普通 kernel"]
    symk -->|否| done
    fallback --> done["*result = bestTuning"]
    single --> done
    done --> undef{"algo/proto 仍 UNDEF?"}
    undef -->|是| warn["WARN + 返回<br/>InvalidUsage 或 InternalError"]
    undef -->|否| ret_ok["返回 ncclSuccess"]

---

2. cost_model.cc: 모델 레지스트리와 스위치 매트릭스

직관적 모델

cost_model.cc은 tuning의총 장부이다. 그것은modelMap테이블을 유지하며, 각 행은 (algo, proto) 조합에 대응하고 "이 조합의 초기화 함수는 누구인지, 시뮬레이션 함수는 누구인지, 어떤 함수에 대해 활성화되는지"를 기록한다. 동시에 사용자 환경 변수NCCL_ALGO/NCCL_PROTO/NCCL_SYM_KERNEL를 파싱하여, 사용자의 의도를enabled[i][f]스위치 매트릭스로 변환한다.

만약 이 테이블이 없다면, 새로운 알고리즘을 추가할 때마다 tuning 메인 흐름을 수정해야 하며, 코드는 엉망이 될 것이다.테이블 기반은 "알고리즘 추가"를 "한 줄 추가"로 만든다.

데이터 구조: modelMap과 스위치 매트릭스

modelMap은 정적 배열이며, 각 요소는ncclTuningModelEntry_t:

📎 src/tuning/cost_model.cc:230-277

c
static struct ncclTuningModelEntry_t modelMap[] = {
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/LL
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/LL128
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/Simple
  {ncclTuningRingModelInit, ncclTuningRingModelSim, nullptr, {1, 1, 1, 1, 1}},       // Ring/LL
  ...
  {nullptr, nullptr, nullptr, {0}}, // CollNetDirect/LL, disabled as there is no implementation
  ...
};

각 entry는 네 개의 필드를 가진다:init(초기화, latency/bandwidth를 계산하여 comm에 저장),model(시뮬레이션, 메시지 크기에 따라 최종 timeUs 계산),finalize(정리),enabled[5](Broadcast/Reduce/AllGather/ReduceScatter/AllReduce 다섯 함수의 활성화 여부에 대해).

주의enabled배열의 순서 주석은 L234에 있음:Enable order: Broadcast, Reduce, AllGather, ReduceScatter, AllReduce. 이 순서는 반드시ncclFunc_t열거형과 일치해야 하며, 그렇지 않으면 잘못 매칭됨.

〔설계 추론 및 아키텍처 트레이드오프〕

왜 init과 sim을 분리해야 하는가?init에서 계산하는 것들(latency, bandwidth)은comm의 정적 속성에만 의존하기 때문(토폴로지, rank 수, compCap), 구체적인 메시지 크기와는 무관. 한 번의 통신에서 연속으로 여러 번 tuning을 호출할 수 있음(예: group에 여러 op가 있을 때), init은 한 번만 실행되고 sim은 매번 실행됨. 이는 전형적인 「사전 계산 + 빠른 조회」 최적화.

Step-by-Step: 환경 변수 파싱과 스위치 매트릭스 구축

1단계: 기본값은 전부 활성화, LL128은 특별. ncclTuningCostModelInit처음에 모든 proto를 1(활성화)로 설정하지만, LL128은 2로 설정:

📎 src/tuning/cost_model.cc:313-323

c
  for (int f = 0; f < NCCL_NUM_FUNCTIONS; f++) {
    for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
      protoEnable[f * NCCL_NUM_PROTOCOLS + p] = p == NCCL_PROTO_LL128 ? 2 : 1;
    }
    for (int a = 0; a < NCCL_NUM_ALGORITHMS; a++) {
      algoEnable[f * NCCL_NUM_ALGORITHMS + a] = 1;
    }
    for (int k = 0; k < ncclSymkKernelId_Count; k++) {
      symKernelIdEnable[f * ncclSymkKernelId_Count + k] = 1;
    }
  }

왜 LL128은 1이 아니라 2인가?LL128은 「기본 활성화」가 아니라 「조건부 활성화」이기 때문. 2는 특수 마커로, 「사용자가 명시적으로 요구하지 않았고, 나중에isLL128Enabled플랫폼 능력에 따라 결정됨」을 나타냄. 1은 「무조건 활성화」, 0은 「비활성화」. 이 삼태 설계는 L366의 판단에 반영됨:

📎 src/tuning/cost_model.cc:364-370

c
      // Disable LL128 when 1) it is not supported on the platform, and 2) user did not explicitly request it.
      // protoEnable[..] == 2 indicates that user did not set NCCL_PROTO=LL128 explicitly.
      if (proto == NCCL_PROTO_LL128 && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] == 2 &&
          !isLL128Enabled(comm->minCompCap, comm->maxCompCap, comm->graphs[algo].typeInter,
                          comm->graphs[algo].typeIntra, comm->nRanks, f, algo, comm->minDriverVersion)) {
        comm->tuningContext.enabled[i][f] = 0;
      }

2단계: 사용자 환경 변수 파싱.사용자가NCCL_ALGO또는NCCL_SYM_KERNEL를 설정했다면, 먼저 algo와 symKernel을 전부 0으로 초기화(사용자가 화이트리스트를 지정했기 때문):

📎 src/tuning/cost_model.cc:327-345

c
  if ((algoStr && strlen(algoStr) > 0) || (symKernelIdStr && strlen(symKernelIdStr) > 0)) {
    std::fill_n(algoEnable, NCCL_NUM_FUNCTIONS * NCCL_NUM_ALGORITHMS, 0);
    std::fill_n(symKernelIdEnable, NCCL_NUM_FUNCTIONS * ncclSymkKernelId_Count, 0);
  }
  if (protoStr) {
    INFO(NCCL_ENV, "NCCL_PROTO set by environment to %s", protoStr);
    NCCLCHECK(parseList(protoStr, ncclFuncStr, NCCL_NUM_FUNCTIONS, ncclProtoStr, NCCL_NUM_PROTOCOLS, protoEnable,
                        comm->tuningContext.forced));
  }

proto는 초기화하지 않음에 주의 — proto의 기본값은 1/2이고, 사용자가NCCL_PROTO=LL를 설정하면,parseListLL을 1로, 나머지를 0으로 설정하기 때문(unset로직). 이 비대칭은 의도적: algo는 기본적으로 전부 활성화지만 사용자가 지정하면 좁혀야 하고, proto의 축소는parseList내부에서 처리.

3단계: parseList의 문법.이 함수는 상당히 복잡한 문법을 지원하며, 주석에 예시가 있음:

📎 src/tuning/cost_model.cc:14-32

c
// Parse a map of prefixes to a list of elements. The first prefix is
// optional and, if not present, the list of elements will be applied
// to all prefixes. Only the first list of elements can lack a
// prefix. Prefixes (if present) are followed by a colon. Lists of
// elements are comma delimited. Mappings of prefix to the lists of
// elements are semi-colon delimited.
//
// For example:
//
//     NCCL_ALGO="ring,collnetdirect;allreduce:tree,collnetdirect;broadcast:ring"
// Enable ring and collnetdirect for all functions, then select tree
// and collnetdirect for allreduce and ring for broadcast.

^접두사는 「부정」을 나타냄:

📎 src/tuning/cost_model.cc:59-67

c
    int unset, set;
    if (elemList[0] == '^') {
      unset = 1;
      set = 0;
      elemList++;
    } else {
      unset = 0;
      set = 1;
    }

따라서NCCL_PROTO="^LL128;allreduce:LL128"의 의미는: 전역적으로 LL128을 비활성화하지만, AllReduce는 예외적으로 LL128을 활성화.

4단계: enabled 매트릭스 병합.마지막으로 모든 model을 순회하며,model->enabled[f]과 사용자 스위치를 AND 연산:

📎 src/tuning/cost_model.cc:371-383

c
      //  Check the user env vars only for functions that have a forced configuration and not already disabled.
      if (comm->tuningContext.forced[f] == 0 || comm->tuningContext.enabled[i][f] == 0) continue;
      comm->tuningContext.enabled[i][f] = 0;
      ...
      if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
           (proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
          (symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
        comm->tuningContext.enabled[i][f] = 1;
      }

로직은:사용자가 특정 함수에 forced 설정을 했을 때만, 사용자 설정으로 모델 기본값을 덮어씀. 사용자가 설정하지 않았다면,forced[f] == 0, 바로continue, 모델 자체의enabled을 유지. 이는 「사용자 명시 지정 > 모델 기본값」의 우선순위.

모델 시뮬레이션의 통합 진입점

모든 모델은 최종적으로ncclTuningCostModelSimModel을 통해 호출됨:

📎 src/tuning/cost_model.cc:470-497

c
ncclResult_t ncclTuningCostModelSimModel(int id, struct ncclTuningInput_t* const input,
                                         struct ncclTuningResult_t* const result) {
  struct ncclTuningModelEntry_t* model = nullptr;
  ncclResult_t ret = ncclSuccess;
  result->forced = input->comm->tuningContext.forced[input->func];
  NCCLCHECKGOTO(getModelEntry(id, &model), ret, not_valid);
  if (model == nullptr) {
    ret = ncclInternalError;
    goto not_valid;
  }
  if (input->comm->tuningContext.enabled[id][input->func] == 0) {
    goto not_valid;
  }
  if (model->model != nullptr) {
    NCCLCHECKGOTO(model->model(input, result), ret, not_valid);
    if (result->timeUs <= 0.0) {
      goto not_valid;
    }
  } else {
    goto not_valid;
  }
exit:
  return ret;
not_valid:
  result->timeUs = NCCL_TUNING_IGNORE;
  result->valid = 0;
  goto exit;
}

삼중 필터:id 범위 초과 → 모델 비활성화 → 모델이 비양수 시간 반환, 어느 한 계층이라도 통과하지 못하면not_valid로 가서,timeUs을NCCL_TUNING_IGNORE(음수 센티넬)로 설정,valid = 0. 호출자가valid == 0을 보면 후보 연결 리스트에 넣지 않음.

설계 사고

modelMap의 주석에 핵심 경고가 한 줄 있음:

📎 src/tuning/cost_model.cc:229

c
// IMPORTANT: this table need must be consistent with the algRegistry in src/config/algorithm_registry.cc
〔설계 추론 및 아키텍처 트레이드오프〕

이는modelMap의인덱스 순서가algorithm_registry.cc의 알고리즘 등록 순서와 엄격히 일치해야 함을 의미. 만약 누군가 registry에 새 알고리즘을 삽입하고modelMap를 수정하는 것을 잊으면, 모든 id가 어긋나서 tuning이 완전히 잘못된 알고리즘을 선택하게 됨.이는 테이블 기반 설계의 전형적인 함정: 암묵적 계약.더 견고한 방법은 인덱스 대신 열거형 이름을 key로 사용하는 것이지만, 그렇게 하면 컴파일 타임 최적화를 약간 희생함.

---

3. ring.cc: Ring 알고리즘의 비용 추정

직관적 모델

Ring 알고리즘은 N개의 rank를 원형으로 배열하고, 데이터가 원을 따라 한 바퀴씩 전달됨. 그 비용 모델은 두 가지 질문에 답해야 함:각 단계에서 얼마나 많은 데이터를 전송하는가 (대역폭)、총 몇 단계가 필요한가 (지연)。

Ring의 직관은 「파이프라인」: N명이 원형으로 서서 물통을 전달하고, 각자 통을 받으면 물을 조금 붓고 다음 사람에게 전달한다고 상상. 통이 한 바퀴 돌면 모든 사람의 물이 섞임. 통이 빨리 돌수록(대역폭 높음), 원이 작을수록(단계 수 적음), 전체가 빨라짐.

데이터 구조: latency/bandwidth 테이블

Ring 모델은 새로운 구조를 도입하지 않고, 추정 결과를comm->tuningContext.generalLatencies[c][algo][proto]과generalBandwidths[c][algo][proto]에 기록. 이 둘은 3차원 배열: 함수 × 알고리즘 × 프로토콜.

초기화 시 먼저 전부 -1.0으로 설정(센티넬, 「계산 안 됨」을 나타냄):

📎 src/tuning/ring.cc:31-33

c
  for (int c = 0; c < NCCL_NUM_FUNCTIONS; c++) {
    comm->tuningContext.generalLatencies[c][algo][proto] = -1.0;
    comm->tuningContext.generalBandwidths[c][algo][proto] = -1.0;

-1.0 센티넬은 sim 단계에서 검사됨:

📎 src/tuning/ring.cc:94-97

c
  if (inputs->comm->tuningContext.generalBandwidths[inputs->func][tuning->algo][tuning->proto] == -1.0f) {
    tuning->valid = 0;
    return ncclSuccess;
  }

왜 0이 아니라 -1.0을 사용하는가?0은 합법적인 대역폭 값(물리적으로 불가능하지만)이고, -1.0은 「미초기화」를 명확히 나타내기 때문. 부동소수점 비교에==을 사용하는 것은 여기서 안전한데, -1.0은 정확히 표현 가능하기 때문.

Step-by-Step: Ring 대역폭 추정

1단계: intra를 사용할지 inter를 사용할지 결정.단일 노드(nNodes==1)는 intra, 다중 노드는 inter:

📎 src/tuning/ring.cc:34-37

c
    int nSteps = ncclTuningGetNsteps(c, comm->nRanks);
    float bw = (comm->nNodes == 1 || (comm->nNodes <= 2 && comm->minCompCap < 100)) ? comm->graphs[algo].bwIntra :
                                                                                      comm->graphs[algo].bwInter;
    float busBw = bw * comm->graphs[algo].nChannels;

nSteps은 알고리즘에 필요한 단계 수이며, Ring의 경우 AllReduce는2*(nRanks-1), 나머지는nRanks-1。busBw은 「버스 대역폭」 = 단일 링크 대역폭 × channel 수.

2단계: 프로토콜별 할인.LL 프로토콜은 대역폭의 절반만 사용한다 (LL의 flag 오버헤드 때문). LL128은 92%(120/128)를 사용한다:

📎 src/tuning/ring.cc:38-42

c
    if (proto == NCCL_PROTO_LL) {
      busBw = std::min(llMaxBw, busBw * .5);
    }
    if (proto == NCCL_PROTO_LL128)
      busBw = std::min(busBw * (0.92 /*120.0/128.0*/), comm->graphs[algo].nChannels * perChMaxRingLL128Bw);

0.92 = 120/128LL128은 128바이트마다 8바이트가 flag이고 유효 페이로드는 120바이트이기 때문이다. 이 숫자는 프로토콜 설계에서 직접 나온 것이다.

3단계: 유효 대역폭 계산.여기서 곱한 것에 주의nRanks / nSteps:

📎 src/tuning/ring.cc:44-46

c
    comm->tuningContext.generalLatencies[c][algo][proto] =
      comm->tuningContext.tuningConstants.baseLatencies[algo][proto];
    comm->tuningContext.generalBandwidths[c][algo][proto] = busBw * comm->nRanks / nSteps;

왜 곱하는가nRanks / nSteps?이것은 Ring 알고리즘의 핵심 특성이다: 각 rank가 실제로 운반하는 데이터량은nBytes * nSteps / nRanks(데이터가 링을 여러 바퀴 돌기 때문). 따라서 '유효 대역폭' = 버스 대역폭 × nRanks / nSteps. AllReduce의 경우 nSteps = 2(nRanks-1)이므로 유효 대역폭 ≈ busBw/2.

4단계: 지연 계산.지연은 intra와 inter 두 부분으로 나뉜다:

📎 src/tuning/ring.cc:48-63

c
    int intraHw, interHw;
    ncclTuningGetHwIndexes(comm, algo, &intraHw, &interHw);
    int hwLevel = comm->nNodes == 1 ? intraHw : interHw;

    float intraLat = comm->tuningContext.tuningConstants.hwLatencies[intraHw][algo][proto];
    // Preserve the pre-refactor model: with one rank per node, Ring inter-node steps use the exposed Tree NET latency.
    float interLat;
    if (comm->nNodes == 1) {
      interLat = intraLat;
    } else if (comm->maxLocalRanks == 1) {
      interLat = comm->tuningContext.tuningConstants.hwLatencies[NCCL_HW_NET][NCCL_ALGO_TREE][proto];
    } else {
      interLat = comm->tuningContext.tuningConstants.hwLatencies[interHw][algo][proto];
    }
    interLat += comm->graphs[algo].latencyInter;
    if (proto == NCCL_PROTO_SIMPLE) interLat += comm->graphs[algo].latencyInter;

L57-58의 특수 처리에 주의:maxLocalRanks == 1(각 노드에 rank가 1개만 있을 때) Ring의 inter-node 지연은Tree의 NET 지연을 사용한다. 주석에 따르면 이것은 'preserve the pre-refactor model' — 즉 리팩터링 전 동작과 일치시키기 위해 의도적으로 남겨둔 '괴이한 특성'이다.이런 역사적 부채는 성숙한 시스템에서 흔하다. 소스 코드를 읽을 때 'preserve'라는 단어를 보면 각별히 주의해야 한다. 그것은 종종 여기에 건드릴 수 없는 호환성 제약이 있음을 의미한다.

5단계: 함수 유형별로 누적.Reduce/Broadcast와 AllReduce/AllGather/ReduceScatter의 지연 모델은 다르다:

📎 src/tuning/ring.cc:65-87

c
    if ((c == ncclFuncReduce || c == ncclFuncBroadcast)) {
      float lat = comm->tuningContext.tuningConstants.hwLatencies[hwLevel][algo][proto];
      if (comm->graphs[algo].sameChannels) {
        comm->tuningContext.generalLatencies[c][algo][proto] += lat;
      } else {
        if (proto == NCCL_PROTO_SIMPLE)
          lat =
            comm->tuningContext.tuningConstants
              .hwLatencies[hwLevel][NCCL_ALGO_TREE][proto]; // Add some chunk latency, waiting for proper chunk modeling
        comm->tuningContext.generalLatencies[c][algo][proto] += nSteps * lat;
      }
    } else {
      // Inter-node rings still have to launch nsteps * net overhead.
      float netOverhead = 0.0;
      if (comm->nNodes > 1) {
        netOverhead = getNetOverhead(comm);
        if (proto == NCCL_PROTO_SIMPLE) netOverhead *= 3;
      }
      intraLat = std::max(intraLat, netOverhead);
      int nInterSteps = comm->nNodes == 1 ? 0 : c == ncclFuncAllReduce ? 2 * (comm->nNodes - 1) : comm->nNodes - 1;
      comm->tuningContext.generalLatencies[c][algo][proto] +=
        (nSteps - nInterSteps) * intraLat + nInterSteps * interLat;
    }

sameChannels은 토폴로지 속성으로, '링의 intra와 inter 단계가 같은 채널 그룹을 사용하는지'를 나타낸다. 다르면 지연에nSteps을 곱해야 한다 (매 단계마다 대기해야 함).netOverhead은 네트워크 post 오버헤드이며, Simple 프로토콜은 3을 곱한다 (Simple은 send, recv, ack 세 번의 네트워크 왕복이 있기 때문).

프로덕션 함정 회피: Ring/Simple의 plateau 효과

ncclTuningRingModelSim에 'plateau'를专门 처리하는 코드가 있다:

📎 src/tuning/ring.cc:105-137

c
  // Update Ring/Simple latency for multi-node AllReduce and
  // single NVL Domain AllReduce/AllGather/ReduceScatter for Blackwell
  bool isBlackwellNvLink =
    inputs->comm->minCompCap >= 100 && inputs->comm->graphs[NCCL_ALGO_RING].typeIntra == PATH_NVL;
  bool ringSimplePlateau =
    (inputs->comm->nNodes > 1 && inputs->func == ncclFuncAllReduce) ||
    (inputs->comm->nNodes == 1 && isBlackwellNvLink &&
     (inputs->func == ncclFuncAllReduce || inputs->func == ncclFuncAllGather || inputs->func == ncclFuncReduceScatter));
  size_t bytesPerRankPerChannel = inputs->nBytes / (inputs->comm->nChannels * inputs->comm->nRanks);

  if (tuning->algo == NCCL_ALGO_RING && tuning->proto == NCCL_PROTO_SIMPLE && ringSimplePlateau &&
      bytesPerRankPerChannel >= 64) {
    float plateauFactor = inputs->comm->minCompCap < 80 ? 1.9 : 1.4;
    ...
    lat *= plateauFactor; // Plateau effect of ring
  }
〔설계 추론 및 아키텍처 트레이드오프〕

plateau란 무엇인가?Ring/Simple에서 메시지가 어느 정도 커지면 지연이 더 이상 메시지에 따라 선형으로 증가하지 않고 '정체'되어 플랫폼에 머문다 — 이때 병목이 '시작 오버헤드'에서 '대역폭'으로 바뀌었고 대역폭은 이미 포화 상태이기 때문이다. 이 현상은 Blackwell NVLink에서 특히 두드러진다 (NVLink 대역폭이 너무 높아 지연 비중이 더 크기 때문). 코드는plateauFactor(1.4 또는 1.9)을 지연에 곱하여 이 '지연이 증폭되는' 효과를 시뮬레이션한다.

bytesPerRankPerChannel >= 64은 트리거 조건이다: 각 rank의 각 채널이 최소 64바이트를 전송해야 하며, 그렇지 않으면 plateau가 성립하지 않는다. 이 64바이트는 LL 프로토콜의 flag 크기에서 온다.

함정 시나리오: Blackwell에서 1MB AllReduce를 실행했는데 실제 지연이 모델 예측보다 40% 높다면 버그라고 생각하지 마라 — 이것은 plateau 효과이며 모델이 이미それを 계산에 넣은 것이다. 만약 수동으로plateauFactor을 줄이면 모델이 지연을 과소평가하여 잘못된 알고리즘을 선택하게 된다.

---

4. tree.cc와 nvls.cc: Tree와 NVLS의 비용 추정

직관적 모델

Tree 알고리즘은 '트리 브로드캐스트'이다: 루트 노드가 데이터를 자식 노드에 나누고, 자식 노드가 다시 손자 노드에 나눈다. 장점은단계 수가 적다(N이 아닌 log N)는 것으로, 작은 메시지에 적합하다; 단점은대역폭 활용률이 낮다(각 비단말 노드가 포워딩해야 하므로 실제 유효 대역폭은 절반에 불과하다).

NVLS(NVLink SHARP)는 '하드웨어 멀티캐스트'이다: 스위치가 데이터를 여러 GPU에 직접 복사하며 소프트웨어 포워딩이 필요 없다. 장점은대역폭이 높고 지연이 낮다는 것이지만, 특정 하드웨어(Hopper 이상)와 특정 구성이 필요하다.

Tree 모델: AllReduce만 서비스

Tree 모델에는硬性 제한이 있다 —AllReduce에만 활성화됨:

📎 src/tuning/tree.cc:21-27

c
  for (int c = 0; c < NCCL_NUM_FUNCTIONS; c++) {
    if (c != ncclFuncAllReduce) {
      comm->tuningContext.generalLatencies[c][algo][proto] = -1.0;
      comm->tuningContext.generalBandwidths[c][algo][proto] = -1.0;
      enabled[c] = 0; // Hard disable
      continue;
    }
〔설계 추론 및 아키텍처 트레이드오프〕

왜인가?NCCL의 Tree 구현은 AllReduce만 지원하기 때문이다 (다른 집합 연산에는 Tree 버전이 없다). 이것은 구현 제약이지 이론적 한계가 아니다.enabled[c] = 0은 '하드 비활성화'로,generalBandwidths = -1보다 더 철저하다 — 전자는ncclTuningCostModelSimModel이 L480에서 바로not_valid을 반환하게 하고, 후자는 sim 함수에 이르러서야 검사한다.

Tree 대역폭 추정:

📎 src/tuning/tree.cc:28-43

c
    float bw = (comm->minCompCap < 100) ?
                 ((comm->nNodes <= 2) ? comm->graphs[algo].bwIntra : comm->graphs[algo].bwInter) :
                 std::min(comm->graphs[algo].bwInter, comm->graphs[algo].bwIntra);
    float busBw = bw * comm->graphs[algo].nChannels;
    if (c == ncclFuncAllReduce) busBw = std::min(busBw * .92, comm->graphs[algo].nChannels * perChMaxTreeBw);
    if (proto == NCCL_PROTO_LL) {
      busBw = std::min(busBw * 1.0 / 3.8, llMaxBw);
    }
    if (proto == NCCL_PROTO_LL128)
      busBw = std::min(busBw * (comm->nNodes == 1 ? 7.0 / 9.0 : 120.0 / 128.0),
                       comm->graphs[algo].nChannels * perChMaxTreeLL128Bw);
    if (comm->maxTreePattern == NCCL_TOPO_PATTERN_TREE) busBw *= .85;
〔설계 추론 및 아키텍처 트레이드오프〕

LL 프로토콜의 할인 계수는1/3.8로, Ring의0.5보다 더 강하다.왜 Tree의 LL 효율이 더 낮은가?Tree의 각 중간 노드가 받기도 하고 보내기도 해야 하므로 LL의 flag 오버헤드가 양방향 트래픽에서 증폭되기 때문이다.1/3.8이 숫자는 실측에서 온 것이다.

Tree 지연 추정:

📎 src/tuning/tree.cc:55-58

c
    if (c == ncclFuncAllReduce) {
      comm->tuningContext.generalLatencies[c][algo][proto] +=
        2 * ((comm->nRanks / comm->nNodes - 1) * intraLat + log2i(comm->nNodes) * interLat);
    }

2 *은 AllReduce = ReduceScatter + AllGather, 두 번의 패스이기 때문이다.(nRanks/nNodes - 1)은 노드 내 단계 수(각 노드 내 rank 수 빼기 1)이고,log2i(nNodes)은 노드 간 단계 수(트리의 높이)이다.

Tree의 수정 계수:Tree 모델은 sim 단계에서treeCorrectionFactor:

📎 src/tuning/tree.cc:75-79

c
  int logSize = log2i(inputs->nBytes >> 6);
  float bw = inputs->comm->tuningContext.generalBandwidths[inputs->func][tuning->algo][tuning->proto];
  float lat = inputs->comm->tuningContext.generalLatencies[inputs->func][tuning->algo][tuning->proto];
  if (inputs->func == ncclFuncAllReduce && logSize >= 0 && logSize < 23)
    bw *= treeCorrectionFactor[tuning->proto][logSize];

treeCorrectionFactor을 곱하는데, 이는 3×24 테이블입니다:

📎 src/tuning/cost_model.cc:223-227

c
float treeCorrectionFactor[NCCL_NUM_PROTOCOLS][24] = {
  {1.0, 1.0, 1.0, 1.0, .9, .8, .7, .7, .7, .7, .6, .5, .4, .4, .5, .6, .7, .8, .9, 1.0, 1.0, 1.0, 1.0, 1.0},
  {1.0, 1.0, 1.0, 1.0, 1.0, .9, .8, .8, .8, .7, .6, .6, .6, .6, .6, .6, .8, .9, .9, .9, .9, 1.0, 1.0, 1.0},
  {.9, .9, .9, .9, .9, .9, .9, .8, .7, .6, .6, .5, .5, .5, .5, .6, .7, .8, .7, .7, .8, .9, .9, .9}
};

logSize = log2(nBytes >> 6), 즉 메시지 크기를 64바이트 단위로 log2 취한 값입니다. 테이블의 인덱스 0-23은 64B부터 64B×2^23 ≈ 512MB에 대응합니다.이 테이블은 실측으로 얻은 「Tree 효율 곡선」입니다:작은 메시지에서는 효율 1.0(지연 지배), 중간 메시지에서는 효율이 0.4-0.5로 떨어지고(대역폭 미포화), 큰 메시지에서는 다시 1.0으로 돌아옵니다(대역폭 포화). 이 「중간 함몰」은 Tree 알고리즘의 고유 특성입니다.

NVLS 모델: 하드웨어 멀티캐스트의 대가

NVLS 모델은 먼저 하드웨어가 지원하는지 확인합니다:

📎 src/tuning/nvls.cc:19-24

c
ncclResult_t ncclTuningNvlsModelInit(struct ncclComm* comm, int id, int enabled[NCCL_NUM_FUNCTIONS]) {
  ncclResult_t ret = ncclSuccess;
  if (!ncclNvlsTransportEnabled(comm)) {
    memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
    return ncclSuccess;
  }

그다음 일련의 강제 제약이 있습니다: Simple 프로토콜만 지원, 단일 노드에서는 NVLSTree 미지원, 다중 노드 NVLS는 CollNet 필요:

📎 src/tuning/nvls.cc:28-41

c
  if ((algo == NCCL_ALGO_NVLS || algo == NCCL_ALGO_NVLS_TREE) && (proto != NCCL_PROTO_SIMPLE)) {
    memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
    return ncclSuccess;
  }

  if (comm->nNodes == 1 && algo == NCCL_ALGO_NVLS_TREE) {
    memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
    return ncclSuccess;
  }

  if (comm->config.collnetEnable == 0 && algo == NCCL_ALGO_NVLS && comm->nNodes > 1) {
    memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
    return ncclSuccess;
  }

NVLS 대역폭 추정에는 효율 계수를 사용합니다:

📎 src/tuning/nvls.cc:12-17

c
static const float nvlsEfficiency[NCCL_NUM_COMPCAPS] = {
  0.0f, // Volta
  0.0f, // Ampere
  0.85f, // Hopper
  0.74f, // Blackwell
};
〔설계 추론과 아키텍처 트레이드오프〕

Hopper는 0.85, Blackwell은 오히려 0.74로 떨어집니다.왜 신세대 하드웨어의 효율이 더 낮을까요?Blackwell의 NVLink 대역폭은 더 높지만, NVLS의 스위치 처리 능력은 그에 비례해 향상되지 않아 상대 효율이 떨어지기 때문입니다. 이 숫자는 이론값이 아니라 실측값입니다.

대역폭 계산에는(nChannels - 1) / nChannels계수가 있습니다:

📎 src/tuning/nvls.cc:62-74

c
    int nSteps = ncclTuningGetNsteps(c, comm->nRanks);
    float intraBw = comm->graphs[algo].bwIntra * nvlsEfficiency[compCapIndex] * (comm->graphs[algo].nChannels - 1) /
                    comm->graphs[algo].nChannels;
    if (c == ncclFuncAllReduce) {
      intraBw *= 2.0f;
    } else {
      float ppn = comm->minLocalRanks;
      intraBw *= (ppn - 1) / ppn;
    }
    float interBw = comm->graphs[algo].bwInter * ((comm->nNodes <= 2 && algo == NCCL_ALGO_NVLS_TREE) ? 2 : 1);
    bw = std::min({intraBw, interBw,
                   algo == NCCL_ALGO_NVLS_TREE ? (float)perChMaxNVLSTreeBw : std::numeric_limits<float>::max()});
    bw = bw * comm->graphs[algo].nChannels;

(nChannels - 1) / nChannels은 NVLS가 동기화를 위해 채널 하나를 남겨둬야 하기 때문입니다.(ppn - 1) / ppn은 AllGather/ReduceScatter의 추가 오버헤드입니다(각 rank가 이전 rank의 데이터를 기다려야 함).

프로덕션 함정 회피: NVLS의 강제 제약

NVLS 모델은 sim 단계에서 런타임 검사를 한 겹 더 거칩니다:

📎 src/tuning/nvls.cc:136-156

c
  int nvlsSupport = inputs->nvlsSupport;
  if (!nvlsSupport) {
    tuning->valid = 0;
    tuning->timeUs = -1.0;
    return ret;
  }
  if (inputs->func != ncclFuncAllReduce && inputs->comm->graphs[tuning->algo].nChannels > NCCL_MAX_NVLS_ARITY) {
    tuning->valid = 0;
    tuning->timeUs = -1.0;
    return ret;
  }
  if (inputs->func != ncclFuncAllReduce && inputs->comm->localRanks > NCCL_MAX_NVLS_ARITY) {
    tuning->valid = 0;
    tuning->timeUs = -1.0;
    return ret;
  }

NCCL_MAX_NVLS_ARITY은 NVLS 멀티캐스트 그룹이 수용할 수 있는 최대 GPU 수입니다. 이 수를 초과하면 NVLS를 사용할 수 없습니다.함정 시나리오:16카드 NVLink 도메인에서 AllGather를 실행할 때, 만약NCCL_MAX_NVLS_ARITY이 8이라면 NVLS가 비활성화되고 tuning은 Ring으로 폴백합니다. 이 제한을 모르면 「NVLS가 분명히 하드웨어 지원이 되는데 왜 안 쓰지」라고 생각하게 됩니다.

---

5. 대칭 kernel 폴백과 오류 복구 체인

직관적 모델

대칭 kernel(symmetric kernel)은 NCCL의 새로운 기능입니다: 모든 rank의 buffer가 대칭 메모리에 등록되면, kernel이 더 효율적인 명령어로 상대방 메모리에 접근할 수 있습니다. 하지만buffer가 등록되지 않았거나 플랫폼이 지원하지 않으면 반드시 일반 kernel로 폴백해야 합니다. 이 폴백 로직은 tuning에서 가장 복잡한 부분입니다.

Step-by-Step: 폴백 결정

폴백 로직은tuning.cc:258-298에 있습니다. 하나씩 뜯어봅시다.

1단계: 폴백이 필요한지 판단합니다.진입 조건:

📎 src/tuning/tuning.cc:258-263

여기까지, tuning 모듈의 의사결정 체인은 명확해졌습니다: 토폴로지 그래프와 통신 파라미터를 받아, 비용 모델과 알고리즘 추정을 통해 마이크로초 단위로 최적의 (알고리즘, 프로토콜, channel, warp) 조합을 출력합니다. 하지만 선택은 시작일 뿐입니다——이 결정 결과는 하류에서 어떻게 사용될까요? 다음 장에서는 src/enqueue/enqueue.cc의 본줄기로 들어가, 한 번의 ncclAllReduce 호출이 파라미터 검증, 알고리즘/프로토콜 확정, channel 분할을 거쳐 최종적으로 ncclInfo와 ncclTaskColl 구조를 생성하는 과정을 살펴봅니다. 이는 전서에서 「사용자 관점」에서 「엔진 관점」으로 전환하는 핵심 장이며, 한 번의 집합 통신 호출이 host 측에서 무엇으로 번역되는지, 그리고 그것이 이후 kernel 시작과의 경계가 어디인지 밝혀낼 것입니다.

CHAPTER 06

제 6 장: 제 6 장: 연산자 하달 전경: ncclAllReduce가 어떻게 실행 가능한 kernel 작업이 되는가

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전서 진행도: 제 6 / 25 장

제 6 장: 연산자 하달 전경: ncclAllReduce가 어떻게 실행 가능한 kernel 작업이 되는가

지난 장에서 우리는 tuning 모듈을 마치며, NCCL이 마이크로초 단위로 한 번의 집합 통신을 위해 (알고리즘, 프로토콜, channel, warp) 조합을 선택한다는 것을 알았다. 하지만 선택 결과 자체는 단지 숫자 더미일 뿐이다. 이것이 실제로 실행되려면 GPU kernel이 읽을 수 있는 작업 설명 객체로 "번역"되어야 한다. 이번 장에서는 src/enqueue/enqueue.cc의 본체로 들어가, 핵심 질문 하나에 답한다. 사용자가 ncclAllReduce를 호출할 때 host 측에서는 대체 무슨 일이 일어나는가? ncclAllReduce에서 ncclEnqueueCheck까지, 파라미터 검증, 알고리즘/프로토콜 결정, channel 분할을 거쳐 최종적으로 ncclInfo와 ncclTaskColl 구조체가 생성된다. 이는 이 책 전체에서 "사용자 관점"에서 "엔진 관점"으로 전환되는 핵심 장이다. NCCL을 식당에 비유하면, enqueue 모듈은 "프런트 주문 시스템"이다. 사용자(애플리케이션 계층)가 "AllReduce 하나 주세요"라고 말하면, 프런트는 이를 주방(GPU kernel)이 실행할 수 있는 작업 지시서로 번역한다. 몇 번 화구, 어떤 팬을 쓸지, 몇 배치로 나눌지까지. 이 번역 계층이 없으면 주방은 무슨 요리를 해야 할지 전혀 알 수 없다.

一、入口:ncclAllReduce 如何构造 ncclInfo

직관적 모델

ncclAllReduce는 사용자가 직접 호출하는 API 함수이다. 그 역할은 극도로 단일하다:사용자가 전달한 날것의 파라미터를 하나의ncclInfo구조체로 패키징한 뒤ncclEnqueueCheck에 넘긴다. 이는 마치 은행 창구에서 업무를 보는 것과 같다. 창구 직원이 먼저 당신의 요구를 표준 양식에 기입한 뒤 백엔드 시스템으로 전달한다.

이 계층이 없다면, 모든 집합 통신 API가 각자 파라미터 검증, group 시맨틱, profiler 계측을 처리해야 한다. 코드는 유지보수 불가능할 정도로 중복될 것이다.

데이터 구조: ncclInfo의 메모리 레이아웃

ncclInfo는 enqueue 전체 흐름을 관통하는 핵심 매개체이다. 그 정의는src/include/info.h:

📎 src/include/info.h:17-44

이 구조체에는 20개 이상의 필드가 있으며, 기능별로 네 그룹으로 나눌 수 있다:

필드 그룹필드역할
집합 통신 파라미터coll, sendbuff, recvbuff, count, datatype, op, root"무엇을 하는지" 설명
통신 도메인과 스트림comm, stream"어디서 하는지" 설명
알고리즘 세부사항chunkSteps, sliceSteps"어떻게 분할하는지" 설명
단방향 연산peerWinOffset, peerWin, sigIdx, ctx, flags, nDesc, signalDescsRMA 전용
사용자 구성collConfig사용자 config에서 복사한 사설 복사본

주의collConfig의 주석:"A config copied from config passed by user so older user config can be safely accessed during synchronous host scheduling (never at launch/replay)" 📎 src/include/info.h:41-43. 이것은 핵심 설계이다. 사용자가 전달한 config 포인터는ncclGroupEnd이전에 파괴될 수 있으므로, NCCL은ncclInfo에서 복사본을 만들어 둔다.

Step-by-Step: ncclAllReduce의 호출 체인

우리는ncclAllReduce을 예로 들어, 사용자 호출부터ncclInfo생성까지의 전체 경로를 추적한다.

1단계: 사용자가 ncclAllReduce를 호출한다.진입점은src/collectives.cc:

📎 src/collectives.cc:206-211

여기서 세 가지 일을 한다:

1. NVTX3_FUNC_WITH_PARAMSNVTX 마커를 찍는다(Nsight 등 도구 시각화용)

2.ncclAllReduceConfigImpl을 호출하며,config = nullptr

을 전달한다

3. 결과를 반환한다2단계: ncclAllReduceConfigImpl이 ncclInfo를 생성한다.

📎 src/collectives.cc:192-202

이것이 핵심 단계이다:

c
struct ncclInfo info = {ncclFuncAllReduce, "AllReduce",
                        sendbuff, recvbuff, count, datatype, op, 0, comm, stream,
                        ALLREDUCE_CHUNKSTEPS, ALLREDUCE_SLICESTEPS};

복사ncclInfo필드는ALLREDUCE_CHUNKSTEPS의 선언 순서와 일대일로 대응한다.ALLREDUCE_SLICESTEPS과src/include/collectives.h:

📎 src/include/collectives.h:19-20

NCCL_STEPS는NCCL_STEPS/2에 정의되어 있다.NCCL_STEPS/4는 링 버퍼의 스텝 수(보통 8 또는 16)이므로, AllReduce의 chunkSteps는

, sliceSteps는 ncclParseCollConfig이다. 이는 하나의 chunk가 2개의 slice를 포함한다는 뜻이다.ncclCollConfig_t*3단계: 사용자 config를 파싱한다.info.collConfig사용자가 전달한config == nullptr을

로 파싱한다. 만약이면, 이 필드는 0으로 초기화된 상태를 유지한다.

4단계: ncclEnqueueCheck에 넘긴다.

이것이 enqueue 모듈의 진정한 진입점이다.

설계 고찰: 왜 필드별 할당 대신 집합 초기화를 사용하는가?〔설계 추론과 아키텍처 트레이드오프〕집합 초기화에는 두 가지 장점이 있다. 첫째, 컴파일러가 필드 수가 맞는지 검사한다(필드가 하나 부족하면 경고). 둘째, 코드가 더 간결하다. 하지만 단점은ncclInfo필드 순서가 구조체 선언과 엄격히 일치해야 한다

는 점이다. 만약 누군가

중간에 필드를 삽입하면, 모든 집합 초기화 지점이 조용히 어긋난다. 이는 NCCL 코드에 내재된 유지보수 위험이다.

c
ncclCollConfig_t config = {...};
ncclAllReduceConfig(..., &config);
// config 在这里被销毁(比如是栈变量,函数返回了)

실제 함정 시나리오: 사용자가 이렇게 코드를 작성한다:ncclInfo복사ncclGroupEnd만약 NCCL이info.collConfig에서 config를 복사하지 않았다면,src/include/info.h:41-43시에 접근할 때 이미 해제된 메모리를 읽게 된다.。

---

의 주석은 바로 이 설계를 설명하기 위한 것이다.

config는 task append 단계에서 파싱되고 복사되며, 이후에는 사용자 포인터에 의존하지 않는다

ncclEnqueueCheck二、ncclEnqueueCheck:参数校验与 group 语义직관적 모델는 enqueue 모듈의 "메인 게이트"이다. 모든 집합 통신 API가 최종적으로 여기로 모인다. 그 역할은:ncclEnqueueCheck。

파라미터 유효성 검증, group 시맨틱 처리, taskAppend 호출로 작업 생성

Step-by-Step: ncclEnqueueCheck의 실행 흐름

📎 src/enqueue/enqueue.cc:3478-3527

단계별로 분석해 보겠습니다:

1단계: CommCheck로 통신 도메인 검증. CommCheck(info->comm, info->opName, "comm")comm 포인터가 null이 아닌지, 초기화되었는지 확인합니다. 만약 comm이 revoke되었다면(예: 특정 rank 오류), 바로 오류를 반환합니다:

📎 src/enqueue/enqueue.cc:3480-3485

2단계: profiler 깊이 처리.이미 group 내부에 있다면(profilerGroupDepth > 0), 깊이 카운트를 증가시킵니다. 이는 암시적ncclGroupStartInternal/ncclGroupEndInternal호출을 올바르게 처리하기 위함입니다.

3단계: 내부 group 진입. ncclGroupStartInternal()는 NCCL 내부의 group 메커니즘입니다.핵심 포인트: 사용자가 명시적으로ncclGroupStart를 호출하지 않아도, NCCL은 각 API 호출마다 암시적 group을 생성합니다. 이는 단일 호출의 원자성을 보장합니다.

4단계: comm 준비 확인. ncclCommEnsureReady(info->comm)통신 도메인 초기화 완료(예: bootstrap 완료, 연결 설정)를 기다립니다.

5단계: ArgsCheck 매개변수 검증.이것이 가장 복잡한 검증 단계입니다:

📎 src/enqueue/enqueue.cc:3497-3503

주의checkMode처리: 만약ncclCheckModeDebugGlobal,ArgsCheck이면 info를 큐에 넣고,ncclGroupEnd시점에 전역 검증(예: 모든 rank의 count 일치 여부 확인)을 수행합니다.

6단계: taskAppend 호출.이것이 핵심 변환 단계입니다:

📎 src/enqueue/enqueue.cc:3513

7단계: opCount 증가.매번 성공적으로 큐에 넣은 후,comm->opCount++. 이 카운터는 send/recv 작업 매칭에 사용되며, profiler 타임라인의 근거이기도 합니다.

8단계: group 종료. ncclGroupEndInternal()depth가 0으로 떨어지면, 실제 group 작업(스케줄링, kernel 시작)이 트리거됩니다.

동시성 제어: group 의미론과 스레드 안전성

〔설계 추론 및 아키텍처 트레이드오프〕

ncclGroupStartInternal/ncclGroupEndInternal스레드 로컬 저장소(TLS)를 사용하여 group 상태를 유지합니다. 이는동일 스레드 내의 여러 API 호출이 하나의 group으로 병합됨을 의미하지만, 다른 스레드의 호출은 독립적입니다. 이것이 NCCL이 멀티스레드 호출을 지원하는 기반입니다.

쉽게 빠질 수 있는 함정: 사용자가ncclGroupStart와ncclGroupEnd사이에 비 NCCL CUDA API(예:cudaMemcpy)를 호출하면, stream 순서 문제가 발생할 수 있습니다. NCCL의 group 메커니즘은 group 내 작업이 모두 동일한 stream 그룹에 있다고 가정합니다.

오류 복구 체인

ncclEnqueueCheck의 오류 처리에는 정교한 설계가 있습니다:

📎 src/enqueue/enqueue.cc:3524-3526

만약taskAppend이 실패하고 comm이 비차단 모드이면,ncclCommSetAsyncError을 호출하여 오류를 기록합니다. 이렇게 하면 이후 API 호출이 계속 시도하는 대신 즉시 오류를 반환합니다. 이것이 비동기 오류 전파 메커니즘입니다.

---

3. taskAppend: 작업 분배의 교차로

직관적 모델

taskAppend는 enqueue 모듈의 "교통 허브"입니다.info->coll값에 따라 작업을 P2P, RMA, CE, 또는 일반 집합 통신 등 다양한 처리 경로로 분배합니다. 이는 마치 우체국 분류 센터처럼——봉투에 적힌 주소에 따라 편지를 다른 우체통에 넣는 것과 같습니다.

만약 이 분배 계층이 없다면, 모든 유형의 작업이 하나의 거대한 if-else에 몰려 코드 유지보수가 어려워질 것입니다.

Step-by-Step: taskAppend의 분배 로직

📎 src/enqueue/enqueue.cc:3337-3476

1단계: 새 아키텍처 활성화 여부 판단. ncclParamEnqueueRearchEnable()는 환경 변수 스위치(기본값 0)입니다. 활성화되면rawTaskAppend경로로 진행합니다——이는 NCCL이 개발 중인 새 작업 모델입니다.

2단계: P2P 분배.Send/Recv인 경우,p2pTaskAppend:

📎 src/enqueue/enqueue.cc:3343-3345

호출3단계: RMA 분배.rmaTaskAppend:

📎 src/enqueue/enqueue.cc:3346-3347

PutSignal/Signal/WaitSignal인 경우, if (info->count == 0) return ncclSuccess;호출

4단계: 빈 집합 통신 조기 반환. ncclCollConfigGetAlgMask——count가 0인 집합 통신은 바로 폐기합니다.

📎 src/enqueue/enqueue.cc:3357-3358

5단계: 알고리즘 선택 검증.사용자가 전달한 알고리즘 선택이 유효한지 검증합니다:

📎 src/enqueue/enqueue.cc:3360-3366

6단계: FP8 타입 검사. hostToDevRedOpFP8 리덕션은 sm90+가 필요합니다:ncclRedOp_t7단계: 리덕션 작업 변환.ncclDevRedOpFull:

📎 src/enqueue/enqueue.cc:3370-3371

host 측을 디바이스 측comm->nRanks == 1로 변환ncclLaunchOneRank8단계: 단일 rank 조기 반환.

📎 src/enqueue/enqueue.cc:3373-3377

만약이면,

📎 src/enqueue/enqueue.cc:3378-3470

을 직접 호출하여 로컬 리덕션을 수행하고, 작업을 생성할 필요가 없습니다:

collTaskAppend9단계: 다중 rank 경로.ncclTaskColl이것이 가장 복잡한 분기로, CE 라우팅, AllToAll/Gather/Scatter 강등, 그리고 일반 집합 통신을 포함합니다:

📎 src/enqueue/enqueue.cc:2757-2851

데이터 구조: ncclTaskColl의 필드

는을 생성하는 곳입니다. 핵심 로직을 살펴보겠습니다:주요 필드 할당:
funcinfo->coll필드
sendbuff/recvbuffinfo->sendbuff/recvbuff출처
countinfo->count의미
datatypeinfo->datatype집합 통신 유형
trafficBytescount * elementSize * ncclFuncTrafficPerByte버퍼 포인터
opHost/opDevinfo->op/opDev요소 수
chunkSteps/sliceStepsinfo->chunkSteps/sliceSteps데이터 타입
minCTAs/maxCTAs/nvlsCTAs트래픽 추정리덕션 작업
algMaskncclCollConfigGetAlgMask분할 단계 수

설정 파싱trafficBytes리소스 상한

📎 src/enqueue/enqueue.cc:2813

ncclFuncTrafficPerByte알고리즘 선택 마스크

📎 src/enqueue/enqueue.cc:123-134

주의

계산:

📎 src/enqueue/enqueue.cc:2808-2812

은 각 집합 통신의 트래픽 배수를 반환합니다:ncclInt8. 이것은 최적화입니다:이 두 작업은 리덕션을 포함하지 않으므로 데이터 타입을 신경 쓸 필요가 없고, 통일적으로 바이트 단위로 처리하면 kernel 로직을 단순화할 수 있습니다。

프로덕션 함정: CTAPolicy의 파싱 순서

📎 src/enqueue/enqueue.cc:3390-3397

CTAPolicy의 파싱에는 미묘한 우선순위가 있습니다:env > per-call > comm. 그리고NCCL_CTA_POLICY_ZERO이(가)NCCL_CTA_POLICY_EFFICIENCY보다 우선합니다. 사용자가 이 두 플래그를 동시에 설정하면 ZERO가 적용됩니다.

실제 함정 시나리오: 사용자가NCCL_CTA_POLICY=EFFICIENCY를 설정했지만 CE 경로가 사용되지 않는 것을 발견했습니다. 원인은 CE 라우팅이CTAPolicy & NCCL_CTA_POLICY_ZERO가 참이어야 하는데 EFFICIENCY는 이 조건을 충족하지 않기 때문입니다.

---

4. ncclPrepareTasks: 작업 목록에서 스케줄링 큐까지

직관적 모델

ncclPrepareTasks은 enqueue 모듈의 "전처리기"입니다. 흩어진 작업 목록을 (func, op, datatype)별로 버킷팅한 다음, 각 버킷에 대해 알고리즘과 프로토콜을 계산합니다. 이는 마치 도서관 사서와 같습니다 — 반납된 책을 먼저 분류별로 정리한 다음, 각 분류의 책을 어느 서가에 놓을지 결정합니다.

이 단계가 없으면 이후의scheduleCollTasksToPlan이 각 작업에 대해 개별적으로 알고리즘을 계산해야 하므로 효율이 매우 낮습니다.

단계별: ncclPrepareTasks의 버킷팅 로직

📎 src/enqueue/enqueue.cc:423-642

1단계: Broadcast 작업 변환.broadcast peer가 하나뿐이면 broadcast 작업을 coll 작업으로 변환합니다:

📎 src/enqueue/enqueue.cc:430-461

여기서bcastTask의 필드를 새로운ncclTaskColl에 복사하고trafficBytes을 계산합니다. 그런 다음memPool_ncclTaskBcast에서 원래 작업을 해제합니다.

2단계: (func, op, datatype)별 버킷팅.작업은 sorter에서 size 내림차순으로 나온 다음tasksByFnOpTy배열에 할당됩니다:

📎 src/enqueue/enqueue.cc:464-487

인덱스 계산:((int)task->func * ncclNumDevRedOps + (int)task->opDev.op) * ncclNumTypes + (int)task->datatype. 이것은 3차원 배열의 선형화입니다.

3단계: 집계 및 알고리즘 선택.각 버킷에 대해 크기가 비슷한 작업(4배 이내)을 집계한 다음ncclGetAlgoInfo:

📎 src/enqueue/enqueue.cc:503-547

을 호출합니다4단계: (collnet, nvls)별 버킷팅.collBins[2][2]:

📎 src/enqueue/enqueue.cc:517-544

알고리즘 유형에 따라 작업을에 할당합니다planner->collTaskQueue:

📎 src/enqueue/enqueue.cc:553-557

5단계: 최종 큐 연결.

ncclTaskCollSorter네 개의 버킷을trafficBytes로 연결합니다ncclTaskCollSorterInsert데이터 구조: ncclTaskCollSorterncclTaskCollSorterDequeueAll은

을 기준으로 정렬하는 삽입식 정렬기입니다.

작업을 올바른 위치에 삽입하고,순서대로 모든 작업을 꺼냅니다.〔설계 추론 및 아키텍처 트레이드오프〕

이 정렬기의 설계 동기는:

📎 src/enqueue/enqueue.cc:572-583

대형 작업 우선 스케줄링comm->runtimeConn. 대형 작업은 전송 시간이 길기 때문에 먼저 시작하면 계산과 통신을 더 잘 중첩할 수 있습니다.algoNeedConnect동시성 제어: runtimeConn과 연결 설정

만약

📎 src/enqueue/enqueue.cc:507-508

이 참이면(런타임 연결 모드), 그리고 특정 알고리즘의 channel이 아직 초기화되지 않았다면aggEnd->trafficBytes < 4 * aggBeg->trafficBytes을 표시합니다. 이는 이후에 연결 설정을 트리거합니다.aggIsolate프로덕션 함정: 집계의 경계 조건maxCTAs),aggIsolate집계 조건은

이고, 두 작업 모두maxCTAs=4을 설정하지 않아야 합니다. 사용자가 per-call config를 설정하면(예:aggIsolate가 true로 설정됨), 이 작업은 집계되지 않습니다.collTaskAppend실제 함정 시나리오: 사용자가 특정 AllReduce에

📎 src/enqueue/enqueue.cc:2821-2822

---

를 설정하여 4개의 CTA만 사용하기를 기대했습니다. 그러나 집계 로직으로 인해 이 작업이 인접 작업과 병합되어 실제 사용되는 CTA 수가 예상과 다를 수 있습니다. 해결책은

을 설정하는 것입니다 — NCCL은

scheduleCollTasksToPlan에서 이미 이를 처리했습니다:

5. scheduleCollTasksToPlan: channel 분할과 예산 제어

직관적 모델

📎 src/enqueue/enqueue.cc:644-947

은 enqueue 모듈의 "스케줄러"입니다. 작업을 구체적인 channel에 할당하고 각 channel의 데이터 분할을 계산합니다. 이는 공장의 생산 계획 시스템과 같습니다 — 각 생산 라인이 무엇을, 얼마나 만들지 결정합니다.이 단계가 없으면 GPU kernel은 자신이 어느 부분의 데이터를 처리해야 하는지 알 수 없습니다.

📎 src/enqueue/enqueue.cc:648-689

ncclTestBudget단계별: channel 분할 알고리즘

📎 src/enqueue/enqueue.cc:343-349

1단계: 예산 추정.먼저 이 plan에 넣을 수 있는 작업 수를 추정합니다:trafficPerChannel:

📎 src/enqueue/enqueue.cc:701-707

작업 바이트 수가 예산을 초과하는지 확인합니다:2단계: 각 channel의 트래픽 계산.

📎 src/enqueue/enqueue.cc:709-739

kind(collnet/nvls)에 따라을 계산합니다

📎 src/enqueue/enqueue.cc:740-845

3단계: Collnet 경로.

  • cellSizecollnet 알고리즘이면 channel 할당이 비교적 간단합니다:MinTrafficPerChannel(32KB)
  • cells4단계: 일반 경로의 cell 분할.
  • cellsPerChannel이것이 가장 복잡한 부분입니다. NCCL은 데이터를 "cell"로 나누며, 각 cell은 최소 전송 단위입니다:
  • cellsLo/cellsHi핵심 변수:

: 각 cell의 바이트 수, 최소: 총 cell 수calcCollChunking:

📎 src/enqueue/enqueue.cc:811-825

: 각 channel이 처리하는 cell 수: 처음과 끝 channel의 cell 수(불완전할 수 있음)

📎 src/enqueue/enqueue.cc:844-894

5단계: chunkGrains 계산.

ncclDevWorkColl각 channel 세그먼트에 대해

을 호출합니다6단계: proxyOp 생성.
sendbuff/recvbuff각 channel에 대해 proxy 작업을 생성합니다:
channelLo/channelHi데이터 구조: ncclDevWorkColl
cbd.countLo/countMid/countHi은 디바이스 측 작업 설명자입니다. 핵심 필드:
cbd.chunkGrainsLo/Mid/Hi필드
direct의미

버퍼 포인터

📎 src/enqueue/enqueue.cc:897

channel 범위(2ull << channelHi) - (1ull << channelLo)。예를 들어 channelLo=2, channelHi=5이면 결과는(2<<5) - (1<<2) = 64 - 4 = 60 = 0b111100, 즉 bit 2-5가 설정됩니다.

프로덕션 함정: 예산 오버플로

📎 src/enqueue/enqueue.cc:792-794

예산이 부족하면 바로 반환하여ncclSuccess, 외부 루프가 새로운 plan을 생성하도록 합니다. 이것은 우아한 성능 저하 전략입니다——오류를 내지 않고, 단지 배치로 처리할 뿐입니다。

실제 함정 시나리오: 만약NCCL_WORK_FIFO_BYTES이 너무 작게 설정되면, 각 plan이 매우 적은 작업만 수용할 수 있어 kernel 시작 횟수가 증가하고 성능이 저하됩니다.

---

6. finishPlan: 작업에서 kernel 파라미터로

직관적 모델

finishPlan은 enqueue 모듈의 "패커"입니다. 작업, batch, proxyOp를 kernel이 직접 읽을 수 있는 파라미터 구조로 패킹합니다. 이는 택배 포장과 같습니다——낱개 물품을 상자에 넣고, 운송장을 붙여, 발송을 기다립니다.

단계별: finishPlan의 패킹 로직

📎 src/enqueue/enqueue.cc:236-330

1단계: 저장 유형 결정.모든 작업이 kernel args에 들어갈 수 있으면,ncclDevWorkStorageTypeArgs:

📎 src/enqueue/enqueue.cc:244-250

2단계: kernelArgs 할당.메모리 스택에서 할당:

📎 src/enqueue/enqueue.cc:251-255

3단계: Round-robin으로 batch 배치.각 channel의 첫 번째 batch는 반드시batchZero[blockIdx.x]:

📎 src/enqueue/enqueue.cc:257-280

4단계: proxyOp 큐 병합.opCount 기준 병합 정렬:

📎 src/enqueue/enqueue.cc:282-329

데이터 구조: ncclDevKernelArgs

ncclDevKernelArgs은 kernel에 전달되는 파라미터 구조입니다. 다음을 포함합니다:

  • comm: 디바이스 측 communicator
  • channelMask: channel 비트 마스크
  • workStorageType: 작업 저장 유형
  • workBuf: 작업 버퍼 포인터
  • workMask: 작업 버퍼 마스크

프로덕션 함정: batch 순서

📎 src/enqueue/enqueue.cc:257-259

주석에 명확히 나와 있습니다: "The first batch for each channel must be located at batchZero[blockIdx.x]". 이 순서가 틀리면 kernel이 잘못된 batch를 읽어 데이터 손상이 발생합니다.

---

이 장 요약

이 장에서 우리는ncclAllReduce에서ncclTaskColl까지의 전체 경로를 추적했습니다:

1. ncclAllReduce이ncclInfo을 구성하고, 사용자 파라미터를 패킹

2. ncclEnqueueCheck파라미터 검증, group 시맨틱 처리

3. taskAppend작업 유형에 따라 다른 경로로 분배

4. collTaskAppend을 생성하고, 설정 파싱ncclTaskColl(func, op, datatype) 기준 버킷팅, 알고리즘 계산

5. ncclPrepareTaskschannel 분할,

6. scheduleCollTasksToPlan을 생성하여 kernel 파라미터로 패킹ncclDevWorkColl

7. finishPlan핵심 설계 사상:

계층적 디커플링

  • : 각 함수는 한 가지 일만 하며,과ncclInfo을 통해 상태 전달ncclTaskColl예산 제어
  • :을 통해 각 plan의 크기 제어ncclTestBudget집계 최적화
  • : 크기가 비슷한 작업이 집계되어 kernel 시작 횟수 감소설정 우선순위
  • 다음 장에서는:env > per-call > comm

으로 들어가, NCCL이 다중 channel 다중 kernel의 실행 순서를 어떻게 편성하는지 살펴봅니다.task_sched이 장 생각해보기와 자가 점검

Q1: 만약

에서collTaskAppend판단을 제거하면(즉,aggIsolate이 항상 false를 반환하면), 어떤 시나리오에서 사용자가 설정한src/enqueue/enqueue.cc:2821-2822이 무효화됩니까? 왜 그럴까요?maxCTAs참고 해석

의 역할은 "이 작업은 집계될 수 없다"를 표시하는 것입니다. 이 판단을 제거하면, per-call config를 설정한 작업이 인접 작업과 병합됩니다.:aggIsolate의 집계 루프에서(ncclPrepareTasks), 집계 조건은src/enqueue/enqueue.cc:507-508입니다. 만약aggEnd->trafficBytes < 4 * aggBeg->trafficBytes && !aggBeg->aggIsolate && !aggEnd->aggIsolate이 항상 false이면, 작업이aggIsolate을 설정했더라도maxCTAs=4인 작업과 병합될 수 있습니다. 병합된maxCTAs=32은两者的某种组合을 취하게 되어(구체적으로는agg의 구현에 따라 다름), 실제 사용되는 CTA 수가 사용자 기대와 맞지 않게 됩니다.ncclGetAlgoInfo더 심각한 것은,

에서(scheduleCollTasksToPlan는 per-call 리소스가 설정된 작업이 단독으로 하나의 plan을 차지하도록 보장하는 데 사용됨) 이 판단이 무효화되면, 여러 작업이 plan의 channel 예산을 공유하게 되어 리소스 할당이 기대와 맞지 않게 됩니다.src/enqueue/enqueue.cc:665-666),taskAggIsolateQ2:

에서 만약ncclEnqueueCheck이 오류를 반환하면(예: 특정 rank의 ArgsCheck 실패), 하지만ncclGroupEndInternal()이 이미 성공적으로 실행되었다면, 무슨 일이 발생합니까? NCCL은 어떻게 상태 일관성을 보장합니까?taskAppend참고 해석

:의 제어 흐름을 보면:src/enqueue/enqueue.cc:3513-3519복사

c
NCCLCHECKGOTO(taskAppend(info->comm, info), ret, fail);
info->comm->opCount++;
exit:
  if (devOld != -1) CUDACHECK(cudaSetDevice(devOld));
  ncclGroupErrCheck(ret);
  NCCLCHECK(ncclGroupEndInternal());

이 성공했지만taskAppend이 실패하면,ncclGroupEndInternal이 이미 증가했습니다. 이로 인해 후속 작업의 opCount가 상대방과 맞지 않아 hang이 발생할 수 있습니다.opCountNCCL의 처리 방식은:

이 오류가 있는지 확인하고, 있으면 comm의 오류 상태를 설정합니다. 후속 API 호출은ncclGroupErrCheck(ret)을 통해 이 오류를 감지하고 즉시 반환합니다. 이것은 "빠른 실패" 전략입니다——일단 오류가 나면 전체 comm이 오류 상태로 들어가고, 더 이상 복구를 시도하지 않습니다.ncclCommGetAsyncError프로덕션 환경에서 이는 group 오류가 발생하면 사용자가 communicator를 파괴하고 재생성해야 함을 의미합니다.

의 cell 분할 알고리즘(

Q3: scheduleCollTasksToPlan)에는 경계 조건이 있습니다: 만약src/enqueue/enqueue.cc:740-845이면, 최소 channel을 건너뜁니다. 만약 이 건너뛰기 로직에 버그가 있으면(예:cellsLo == 0이 올바르게 증가하지 않으면), 어떤 결과가 발생합니까?channelId참고 해석

:복사src/enqueue/enqueue.cc:770-780:

c
if (cellsLo == 0) {
  // Least channel skipped. Make the next channel the new least.
  channelId += 1;
  if (nMidChannels == 0) {
    cellsLo = cellsHi;
    cellsHi = 0;
  } else {
    cellsLo = cellsPerChannel;
    nMidChannels -= 1;
  }
}

이 올바르게 증가하지 않으면, 다음 작업이 잘못된 channel에서 할당을 시작합니다. 이로 인해:channelIdchannel 중복

1. : 두 작업이 같은 channel의 같은 데이터 구간에 할당될 수 있음데이터 손상

2. : kernel이 데이터를 중복 처리하거나 누락함성능 저하

3. 性能下降: channel 부하 불균형

더 은밀한 것은, 이런 버그가 특정 메시지 크기에서만 발생할 수 있다는 점이다(cellsLo == 0일 때), 재현하기 어렵다. NCCL은plan->channelMask |= (2ull << devWork->channelHi) - (1ull << devWork->channelLo)를 통해 사용된 channel을 추적하지만, 이는 단지 기록일 뿐 중복을 방지하지는 못한다.

여기까지 우리는 ncclAllReduce가 사용자 호출에서 어떻게 실행 가능한 kernel 작업들의 연속으로 변하는지 살펴보았다: 파라미터 검증, 알고리즘/프로토콜 결정, channel 분할, 최종적으로 ncclInfo와 ncclTaskColl 생성. 하지만 작업이 생성되는 것은 첫 단계일 뿐이다——이들은 여러 channel에 스케줄링되고, kernel 시작 파라미터를 생성하며, group 시맨틱 하에서 배치 제출과 의존성 정렬을 처리해야 한다. 다음 장에서는 src/enqueue/task_sched와 src/enqueue/task_prep를 깊이 파고들어 "왜 한 번의 AllReduce가 여러 kernel을 시작하는가, 이들 간의 순서와 의존성은 어떻게 보장되는가"를 답하고, 동시에 src/group.cc에서 ncclGroupStart/ncclGroupEnd가 어떻게 여러 API 호출을 하나의 제출로 병합하는지 밝힌다.

CHAPTER 07

제 7 장: 제 7 장: 작업 스케줄러: task_sched가 다중 channel과 kernel의 실행 순서를 어떻게 편성하는가

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 7 / 25 장

제 7 장: 작업 스케줄러: task_sched가 다중 channel과 kernel의 실행 순서를 어떻게 편성하는가

이전 장에서 우리는 ncclAllReduce를 ncclTaskColl까지 추적했다——작업 설명 객체가 이미 comm->planner에 놓여 있다. 하지만 작업 설명은 단지 '작업 지시서'일 뿐, 아직 GPU에서 실제로 실행되는 kernel이 되지 못했다. 이 장에서는 세 가지 질문에 답한다: 여러 API 호출은 어떻게 모아서 함께 제출되는가? 모인 작업들은 어떻게 여러 channel로 분할되는가? 여러 kernel 간의 순서와 의존성은 무엇으로 보장되는가? 먼저 전체적인 멘탈 모델을 제시한다. NCCL을 식당이라고 상상해 보자: ncclGroupStart/ncclGroupEnd는 '장바구니'로, 사용자가 여러 요리(여러 집합 통신 호출)를 장바구니에 담는다; ncclGroupEnd는 '주문'으로, 주방이 그제서야 주문에 따라 요리를 시작한다. 그리고 doLaunches는 '서빙调度员'으로, 어떤 요리를 먼저 내고 어떤 요리를 병렬로 만들 수 있는지 결정한다. group 시맨틱이 없으면 각 요리를 개별 주문하고, 주방은 요리 하나 만들 때마다 불을 다시 피워야(kernel 시작) 하므로 오버헤드가 막대하다; doLaunches의 라운드 스케줄링이 없으면 다중 channel의 kernel이 순서 없이 시작되어 데이터 의존성이 파괴된다.

一、Group 시맨틱의 전역 상태: thread_local 변수와 '장바구니' 모델

직관적 모델

ncclGroupStart과ncclGroupEnd사이의 모든 통신 호출은 즉시 kernel을 시작하지 않고 '모아'진다. 어디에 모이는가? 바로스레드 로컬(thread_local)전역 변수에 모인다. 왜 thread_local인가? NCCL은 같은 스레드 내의 group 호출이 직렬이라고 가정하고, 다른 스레드는 각자 독립적인 장바구니를 가져 서로 간섭하지 않기 때문이다. 만약 이 상태들이 전역 변수이고 thread_local이 아니라면, 두 스레드가 동시에ncclGroupStart를 호출할 때 서로 충돌하여 한 스레드의 작업이 다른 스레드의ncclGroupEnd로 제출되는——치명적인 상황이 발생한다.

데이터 구조와 메모리 레이아웃

먼저 group의 전역 상태 정의를 보자.

📎 src/group.cc:34-34

cpp
thread_local int ncclGroupDepth = 0; // depth of ncclGroupStart nesting
thread_local ncclResult_t ncclGroupError = ncclSuccess;
thread_local struct ncclComm* ncclGroupCommHead[ncclGroupTaskTypeNum] = {nullptr};
thread_local struct ncclComm* ncclGroupCommPreconnectHead = nullptr;
thread_local struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next> ncclAsyncJobs;
thread_local int ncclGroupBlocking = -1; /* default mode */

필드별로 분석:

  • ncclGroupDepth: 중첩 깊이.ncclGroupStart는 중첩 호출이 가능하며(흔하지는 않지만), 매번ncclGroupStart는 1 증가,ncclGroupEnd는 1 감소한다. 0으로 줄어들 때만 실제로 제출된다. 이는 장바구니가 중첩될 수 있는 것과 같다——장바구니 안에 또 하위 장바구니를 열고, 가장 바깥쪽에서 결제할 때만 실제로 주문된다.
  • ncclGroupError: group 내 임의의 호출에서 오류가 발생하면 오류가 여기에 기록되고,ncclGroupEnd시 통합 처리된다. 이는 '한 번 호출 실패 후 이후 호출이 계속 장바구니에 물건을 담는' 불일치 상태를 방지한다.
  • ncclGroupCommHead[ncclGroupTaskTypeNum]: 작업 유형별로 그룹화된 통신 도메인 연결 리스트 헤드.ncclGroupTaskTypeNum는 작업 유형 수(집합 통신, 원시 작업, 관리 작업, 대칭 등록 등)이다. 각 유형마다 하나의 연결 리스트가 있고, 연결 리스트 노드는ncclComm이며,comm->groupNext[type]를 통해 연결된다. 왜 유형별로 나누는가? 유형마다 작업 제출 시점과 의존 관계가 다르기 때문이다——집합 통신 작업은 먼저 preconnect해야 하고, 관리 작업(예: destroy)은 마지막에 실행해야 한다.
  • ncclGroupCommPreconnectHead: 사전 연결이 필요한 통신 도메인 연결 리스트. 사전 연결은 '미리 네트워크 연결을 설정'하여 kernel 시작 시점에 연결을 설정함으로써 발생하는 지연을 방지한다.
  • ncclAsyncJobs: 비동기 작업 큐. 일부 작업(예:ncclCommInitRank)은 비동기이며, 이 큐에 들어가ncclGroupEnd시 통합 시작된다.
  • ncclGroupBlocking: 블로킹 모드 플래그.-1는 아직 결정되지 않음을,0는 비블로킹을 나타내며,1는 블로킹을 나타낸다. 동일한 group 내에서 블로킹과 논블로킹 통신 도메인을 혼용하는 것은 허용되지 않으며, 그렇지 않으면 오류가 발생한다.

여기에 핵심 설계가 있다:ncclGroupCommHead는배열이며, 각 요소는 하나의 연결 리스트이다. 연결 리스트 노드는comm->groupNext[type]를 통해 연결되며, 독립적인 연결 리스트 노드 구조체를 사용하지 않는다. 이는ncclComm구조체 내에groupNext배열 필드를 반드시预留해야 함을 의미한다. 이러한 「침투형 연결 리스트」 설계는 추가적인 메모리 할당을 방지하지만, 그 대가로ncclComm구조체가 커진다.

시나리오 기반 Step-by-Step Walkthrough

시나리오: 사용자가ncclGroupStart()를 호출한 후, 연속으로 두 번ncclAllReduce를 호출하고 (각각 서로 다른 두 통신 도메인 commA와 commB에 대해), 마지막으로ncclGroupEnd()。

를 호출한다.ncclGroupStart첫 번째 단계:

📎 src/include/group.h:63-66

cpp
inline ncclResult_t ncclGroupStartInternal() {
  ncclGroupDepth++;
  return ncclSuccess;
}

복사ncclGroupStart극히 단순하다: 깊이를 1 증가시킨다. 메모리 할당도, 락도, 시스템 콜도 없다. 이것이

가 거의 제로 오버헤드인 이유이다.ncclAllReduce두 번째 단계:

ncclAllReduce가 group 내에서 호출되면 무슨 일이 발생하는가?ncclGroupCommJoin(comm, ncclGroupTaskTypeCollective)내부에서

📎 src/include/group.h:80-116

cpp
inline void ncclGroupCommJoin(struct ncclComm* comm, int type) {
  if (comm->groupNext[type] == reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID)) {
    // Insert comm into ncclGroupCommHead adjacent to sibling comms. This preserves
    // the users program order yet insures siblings occur consecutively. This
    // is required by doLaunches() in "group.cc".
    struct ncclComm** pp = &ncclGroupCommHead[type];
    while (*pp != nullptr && comm->intraComm0 != (*pp)->intraComm0) pp = &(*pp)->groupNext[type];

    // didn't find its clique, we need to insert it with ascending order based on commHash
    if (*pp == nullptr) {
      pp = &ncclGroupCommHead[type];
      while (*pp != nullptr && (*pp)->commHash < comm->commHash) pp = &(*pp)->groupNext[type];
    }
    comm->groupNext[type] = *pp;
    *pp = comm;
    // Comms gets a new memory stack scope upon joining. Each task batched for
    // this comm is allocated there.
    if (type == ncclGroupTaskTypeCollective || type == ncclGroupTaskTypeRawTask) {
      // Initialize planner
      ncclMemoryStackPush(&comm->memScoped);
      ncclKernelPlanner::Peer* tmp = comm->planner.peers;
      ncclIntruQueue<ncclTaskRma, &ncclTaskRma::next>* tmpRmaQueues = comm->planner.rmaTaskQueues;
      int numRmaCtx = comm->config.numRmaCtx;
      memset(&comm->planner, 0, sizeof(comm->planner));
      comm->planner.peers = tmp;
      comm->planner.bcast_info.minBcastPeer = INT_MAX;
      comm->planner.bcast_info.maxBcastPeer = INT_MIN;
      comm->planner.rmaTaskQueues = tmpRmaQueues;
      if (comm->planner.rmaTaskQueues != NULL) {
        for (int i = 0; i < numRmaCtx; i++) {
          ncclIntruQueueConstruct(&comm->planner.rmaTaskQueues[i]);
        }
      }
    }
  }
  ncclGroupBlocking = comm->config.blocking;
}

복사

1. 이 코드에는 몇 가지 정교한 점이 있다::if (comm->groupNext[type] == NCCL_COMM_GROUP_INVALID)멱등성 검사ncclAllReduce는 동일한 통신 도메인이 동일한 group 내에서 한 번만 추가되도록 보장한다. 만약 사용자가 동일한 comm에 대해comm->planner를 두 번 호출하면, 두 번째는 연결 리스트에 중복 추가되지 않지만, 태스크는

2. 에追加된다.:intraComm0clique 정렬ncclCommSplit는 「전역 엔티티」의 식별자이다. 여러 통신 도메인이 동일한 전역 엔티티에 속하면 (예를 들어intraComm0를 통해 분할된 경우), 그들의intraComm0가 동일하며, 이를 하나의 clique라고 한다. 코드는 먼저commHash로 clique를 찾아 comm을 동일 clique의 형제 노드 옆에 삽입한다. clique를 찾지 못하면doLaunches오름차순으로 삽입한다. 이 정렬은

3. 가 clique 내의 barrier 동기화를 올바르게 처리할 수 있도록 하기 위한 것이다.:ncclMemoryStackPush(&comm->memScoped)메모리 스택 스코프ncclTaskColl는 이 comm을 위해 group 내에 새로운 메모리 스택 스코프를 할당한다. 이 comm을 위해 할당된 모든 태스크(ncclGroupCommLeave등)는 이 스택에서 할당된다.ncclMemoryStackPop시malloc/free가 모든 태스크 메모리를 한 번에 해제한다——이는 「일괄 할당, 일괄 해제」의 고전적인 최적화로, 각 태스크마다 개별적으로

4. 하는 오버헤드를 방지한다.:memset(&comm->planner, 0, sizeof(comm->planner))planner 리셋peers는 planner를 비우지만,rmaTaskQueues와bcast_info포인터는 유지한다 (먼저 임시 변수에 저장하고, memset 후 복원). 왜 유지하는가? 이 둘은 사전 할당된 배열이므로 매번 재할당할 필요가 없기 때문이다.INT_MAX/INT_MIN의 min/max는

로 리셋되어 이후 broadcast 태스크의 병합 최적화에 사용된다.ncclGroupEnd세 번째 단계:

📎 src/group.cc:1039-1164

ncclGroupEndInternal는 무엇을 하는가?

📎 src/group.cc:1048-1061

cpp
if (ncclGroupDepth == 0) {
  WARN("ncclGroupEnd: not in a group call.");
  ret = ncclInvalidUsage;
  goto exit;
}
// ...
if ((--ncclGroupDepth) > 0) goto exit;

복사

📎 src/group.cc:1063

cpp
if ((ret = ncclGroupError) != ncclSuccess) goto fail;

복사

📎 src/group.cc:1084-1093

cpp
NEW_NOTHROW_GOTO(groupJob, ncclGroupJob, ret, fail);
ncclIntruQueueConstruct(&groupJob->asyncJobs);
groupJob->groupRefCount = 0;
groupJob->nonBlockingInit = false;
memcpy(groupJob->groupCommHead, ncclGroupCommHead, sizeof(ncclGroupCommHead));
groupJob->groupCommPreconnectHead = ncclGroupCommPreconnectHead;
groupJob->groupError = ncclSuccess;
groupJob->abortFlag = false;
groupJob->joined = false;
ncclIntruQueueTransfer(&groupJob->asyncJobs, &ncclAsyncJobs);

복사ncclGroupJob를 생성하여 thread_local의 group 상태를 job 객체로 「이전」한다.ncclIntruQueueTransfer는ncclAsyncJobs큐 전체를groupJob->asyncJobs로 이전한다. 이 단계가 핵심이다: thread_local 상태는 「임시」이고, job 객체는 「영속적」이므로 비동기 스레드가 보유할 수 있다.

📎 src/group.cc:1095-1147

cpp
if (hasCommHead || !ncclIntruQueueEmpty(&groupJob->asyncJobs) || ncclGroupCommPreconnectHead != nullptr) {
  /* make sure ncclGroupBlocking has been set. */
  if (ncclGroupBlocking != 0 && ncclGroupBlocking != 1) {
    WARN("Invalid group blocking state %d", ncclGroupBlocking);
    ret = ncclInternalError;
    goto fail;
  }
  if (ncclGroupBlocking == 0) {
    /* nonblocking group */
    // ... 设置 async error 为 ncclInProgress,创建线程执行 groupLaunchNonBlocking
    groupJob->base.func = groupLaunchNonBlocking;
    STDTHREADCREATE_GOTO(groupJob->base.thread, ncclAsyncJobMain, ret, fail, &groupJob->base);
    groupJob->nonBlockingInit = true;
    ret = ncclInProgress;
  } else {
    /* blocking group */
    int savedDev;
    CUDACHECKGOTO(cudaGetDevice(&savedDev), ret, fail);
    NCCLCHECKGOTO(groupLaunch(&groupJob->base, internalSimInfoPtr), ret, fail);
    CUDACHECKGOTO(cudaSetDevice(savedDev), ret, fail);
    if (simInfo) memcpy((void*)simInfo, (void*)internalSimInfoPtr, realSize);
    delete groupJob;
  }
} else {
  // Free when not needed (single rank case)
  delete groupJob;
}

블로킹 모드: 현재 스레드에서 직접groupLaunch를 호출하여 동기적으로 완료한다. 논블로킹 모드: 스레드를 생성하여groupLaunchNonBlocking를 실행하고 즉시ncclInProgress를 반환한다. 사용자는 이후ncclCommGetAsyncError를 통해 진행 상황을 조회한다.

주의:cudaGetDevice/cudaSetDevice의 저장과 복원:groupLaunch내부에서 CUDA 디바이스를 전환한다 (서로 다른 comm이 서로 다른 GPU에 있을 수 있기 때문). 실행 완료 후 사용자의 원래 디바이스를 복원한다. 이는 「NCCL 내부에서 디바이스를 전환한 후 되돌리지 않아」 사용자의 이후 CUDA 호출이 잘못된 디바이스에서 실행되는 것을 방지하기 위한 것이다.

설계 사고와 프로덕션 함정

함정 1: 블로킹과 논블로킹 통신 도메인 혼용。ncclAsyncLaunch에 검사가 있다:

📎 src/group.cc:55-64

cpp
/* check if there are blocking and nonblocking comms at the same time in group. */
if (comm->destroyFlag) {
  ncclGroupBlocking = 1;
} else if (ncclGroupBlocking == -1) {
  /* first met communicator */
  ncclGroupBlocking = comm->config.blocking;
} else if (ncclGroupBlocking != comm->config.blocking) {
  WARN("Blocking and nonblocking communicators are not allowed in the same group.");
  ret = ncclInvalidArgument;
}

왜 혼용이 허용되지 않는가? 블로킹 group은 현재 스레드에서 동기적으로 실행되고, 논블로킹 group은 독립 스레드에서 비동기적으로 실행되기 때문이다. 만약 혼용하면ncclGroupEnd가 동기적으로 반환해야 하는지ncclInProgress를 반환해야 하는지 결정할 수 없다. 프로덕션 환경에서 사용자가 실수로 블로킹과 논블로킹 comm을 동일한 group에 넣으면ncclInvalidArgument를 받게 되지만, 이 시점에서 group 상태는 이미 오염되었으므로 반드시ncclGroupStart。

를 다시 해야 한다.ncclGroupError함정 2:의 전파ncclGroupError. 만약 group 내에서 어느 한 번의 호출이 실패하면ncclGroupEnd가 설정되고,groupCleanup。groupCleanup는 fail 분기로 점프하여ncclGroupStart를 실행한다.

📎 src/group.cc:514-607

cpp
static void groupCleanup(struct ncclComm** groupCommHeadPtr,
                         struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncJobsPtr,
                         ncclResult_t error) {
  struct ncclComm* comm;
  for (int type = 0; type < ncclGroupTaskTypeNum; ++type) {
    comm = groupCommHeadPtr[type];
    groupCommHeadPtr[type] = nullptr;
    while (comm != nullptr) {
      struct ncclComm* next = comm->groupNext[type];
      (void)ncclGroupCommLeave(comm, type);
      // We don't know if preconnect succeeded or happened at all, so clear
      // the flags that let `taskAppend()` skip over checking if preconnect
      // is needed.
      if (type == ncclGroupTaskTypeCollective || type == ncclGroupTaskTypeRawTask) {
        comm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1);
        for (int i = 0; i < comm->nRanks; i++) {
          comm->connectSend[i] = 0UL;
          comm->connectRecv[i] = 0UL;
        }
        // Reclaim abandoned kernel plan memory.
        while (!ncclIntruQueueEmpty(&comm->planner.planQueue)) {
          struct ncclKernelPlan* plan = ncclIntruQueueDequeue(&comm->planner.planQueue);
          if (!plan->persistent) {
            while (!ncclIntruQueueEmpty(&plan->proxyOpQueue)) {
              struct ncclProxyOp* pxop = ncclIntruQueueDequeue(&plan->proxyOpQueue);
              ncclMemoryPoolFree(&comm->memPool_ncclProxyOp, pxop);
            }
            ncclMemoryPoolFree(&comm->memPool_ncclKernelPlan, plan);
          }
        }
        // Reset comm->planner to empty.
        // ...
      }
      // ...
    }
  }
  // ...
}

시 planner에 이전 데이터가 남아 있어 태스크 중복 제출이나 메모리 누수가 발생한다.comm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1)복사0x1주의:ncclGroupCommPreconnect이 줄. 이것은 「센티넬 값」으로, 「이 comm은 preconnect를 다시 해야 한다」를 나타낸다. 왜인가? cleanup 시 preconnect가 성공했는지 알 수 없으므로, 다음 번에 강제로 다시 검사하도록 하기 때문이다.if (comm->preconnectNext == reinterpret_cast<struct ncclComm*>(0x1))이 값은 매우 정교하다——합법적인 포인터는 아니지만, 「미초기화」 마커로 사용할 수 있다.

---

에서ncclPrepareTasks를 검사하여 preconnect 연결 리스트에 추가해야 하는지 판단한다.

二、태스크 준비:

ncclPrepareTasks이것은 「재료 준비」 단계입니다. 장바구니에 있는 재료(작업 설명)는 아직 생것이므로, 먼저 씻고 썰고 배합해야(알고리즘, 프로토콜, channel 분할 결정) 냄비에 넣을(kernel 시작) 수 있습니다. 이 단계를 건너뛰고 바로 kernel을 시작하면, kernel은 데이터를 어떻게 분할하고 어느 경로로 갈지 모르기 때문에 바로 크래시합니다.

시나리오 기반 Step-by-Step Walkthrough

ncclPrepareTasks여기서groupLaunchLegacy에서 호출됩니다:

📎 src/group.cc:705-746

cpp
static ncclResult_t ncclPrepareTasksAndCollPreconnect(
  struct ncclComm* comm, ncclSimInfo_t* simInfo,
  struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncCollJobs) {
  if (ncclParamSingleProcMemRegEnable()) {
    // 单进程内存注册模式:把 prepare 和 preconnect 合并成一个异步 job
    struct ncclPrepareTasksAndCollPreconnectJob* job;
    NEW_NOTHROW(job, ncclPrepareTasksAndCollPreconnectJob);
    job->base.func = ncclPrepareTasksAndCollPreconnectFunc;
    // ...
    ncclIntruQueueEnqueue(asyncCollJobs, &job->base);
  } else {
    bool needConnect = false;
    bool algoNeedConnect[NCCL_NUM_ALGORITHMS];
    memset(algoNeedConnect, 0, sizeof(bool) * NCCL_NUM_ALGORITHMS);

    CUDACHECK(cudaSetDevice(comm->cudaDev));
    NCCLCHECK(ncclPrepareTasks(comm, algoNeedConnect, &needConnect, simInfo));

    if (comm->cuMemSupport && needConnect) {
      // 创建 preconnect job
      struct ncclPreconnectJob* job;
      NEW_NOTHROW(job, ncclPreconnectJob);
      job->base.func = ncclCollPreconnectFunc;
      // ...
      ncclIntruQueueEnqueue(asyncCollJobs, &job->base);
    }
  }
  return ncclSuccess;
}

ncclPrepareTasks의 출력은 두 가지입니다:algoNeedConnect배열(어떤 알고리즘이 연결을 설정해야 하는지)과needConnect플래그(연결이 필요한지 여부)입니다. 만약needConnect가 참이고 cuMem을 지원하면, preconnect job을 생성하여 비동기로 실행합니다.

ncclPrepareTasks내부에서 무엇을 할까요? 그것은comm->planner안의 작업들을 순회하며, 각 작업에 대해 알고리즘과 프로토콜을 결정한 다음,taskAppend를 호출하여 작업을 planner의 plan에 추가합니다. 이 부분 로직은 이전 장에서 이미 전개했으므로 여기서는 반복하지 않습니다.

핵심 포인트:ncclPrepareTasks는comm별로 하나씩 호출되지만, preconnect는clique별로 배치 실행됩니다. 왜일까요?groupLaunchLegacy안의 주석을 보세요:

📎 src/group.cc:818-834

cpp
do {
  // We need to preconnect connections for collectives clique by clique to avoid
  // race condition for split shared comms which can connect the same connections
  // at the same time.
  comm = cliqueHead;
  do {
    NCCLCHECKGOTO(ncclPrepareTasksAndCollPreconnect(comm, simInfo, &asyncCollJobs), ret, fail);
    comm = comm->groupNext[ncclGroupTaskTypeCollective];
  } while (comm != nullptr && comm->intraComm0 == cliqueHead->intraComm0);
  // connect
  NCCLCHECKGOTO(asyncJobLaunch(&asyncCollJobs, groupAbortFlag), ret, fail);
  // ...
  cliqueHead = comm;
} while (cliqueHead != nullptr);

주석에 명확히 나와 있습니다:clique별로 하나씩 preconnect하여, split shared comms가 동시에 같은 연결 그룹에 연결해 경쟁 상태가 발생하는 것을 방지. 만약 두 comm이 같은 부모 comm에서 split된 것이라면,它们은 일부 연결을 공유할 수 있습니다. 만약 병렬로 preconnect하면, 두 스레드가 동시에 같은 연결을 설정하려고 시도하여 중복 연결이나 연결 상태 불일치가 발생할 수 있습니다. clique별로 직렬 실행하여, 같은 시점에 하나의 clique만 연결을 설정하도록 보장합니다.

동시성 제어와 저수준 상호작용

asyncJobLaunch는 비동기 작업 시작의 핵심입니다:

📎 src/group.cc:609-678

cpp
static ncclResult_t asyncJobLaunch(struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncJobsMain,
                                   volatile bool* groupAbortFlag) {
  ncclResult_t ret = ncclSuccess;
  bool jobsDone = false;
  bool errorJobAbortFlag = false;

  if (!ncclIntruQueueEmpty(asyncJobsMain)) {
    struct ncclAsyncJob* job = ncclIntruQueueHead(asyncJobsMain);
    if (job->next == nullptr) {
      // 只有一个 job,直接在当前线程执行,避免线程创建开销
      job->isThreadMain = true;
      ncclAsyncJobMain(job);
      job->state = ncclGroupJobJoined;
      return job->result;
    }
    // 多个 job,每个创建一个线程
    do {
      STDTHREADCREATE(job->thread, ncclAsyncJobMain, job);
      job = job->next;
    } while (job != nullptr);

    do {
      jobsDone = true;
      job = ncclIntruQueueHead(asyncJobsMain);
      do {
        ncclGroupJobState_t state = COMPILER_ATOMIC_LOAD(&job->state, std::memory_order_acquire);
        if (state == ncclGroupJobRunning) {
          jobsDone = false;
        } else if (state == ncclGroupJobDone) {
          int err;
          if ((err = ncclThreadJoin(job->thread)) != ncclSuccess) {
            WARN("asyncJobLaunch: failed to join thread for job");
            ret = ncclSystemError;
          }
          job->state = ncclGroupJobJoined;
          if (job->result != ncclSuccess && ret == ncclSuccess) {
            ret = job->result;
            errorJobAbortFlag = true;
          }
        } else {
          // safety check
          if (state != ncclGroupJobJoined) {
            WARN("Async job state is %d, expected %d", state, ncclGroupJobJoined);
            if (ret == ncclSuccess) ret = ncclInternalError;
            errorJobAbortFlag = true;
          }
        }

        if (!job->destroyFlag &&
            (COMPILER_ATOMIC_LOAD(groupAbortFlag, std::memory_order_acquire) || errorJobAbortFlag == true)) {
          COMPILER_ATOMIC_STORE(job->abortFlag, uint32_t(1), std::memory_order_release);
          COMPILER_ATOMIC_STORE(job->abortFlagDev, uint32_t(1), std::memory_order_release);
          if (job->childAbortFlag) {
            COMPILER_ATOMIC_STORE(job->childAbortFlag, uint32_t(1), std::memory_order_release);
            COMPILER_ATOMIC_STORE(job->childAbortFlagDev, uint32_t(1), std::memory_order_release);
          }
        }

        job = job->next;
      } while (job != nullptr);
      // Let preconnect threads progress.
      if (jobsDone == false) std::this_thread::sleep_for(std::chrono::microseconds(1));
    } while (jobsDone == false);

    if (ret != ncclSuccess) goto fail;
  }

exit:
  return ret;
fail:
  goto exit;
}

이 코드에는 몇 가지 핵심 설계가 있습니다:

1. 단일 job 최적화: 만약 큐에 job이 하나만 있으면, 스레드를 생성하지 않고 현재 스레드에서 직접 실행합니다. 이는 스레드 생성과 join 오버헤드를 피합니다. 단일 comm 그룹의 경우, 이것이 일반적인 상황입니다.

2. 원자적 상태 머신:job->state는 원자 변수이며, 세 가지 상태가 있습니다:ncclGroupJobRunning、ncclGroupJobDone、ncclGroupJobJoined. 작업 스레드가 실행을 마친 후COMPILER_ATOMIC_STORE(..., std::memory_order_release)를 사용하여Done로 설정합니다; 메인 스레드는COMPILER_ATOMIC_LOAD(..., std::memory_order_acquire)를 사용하여 읽습니다. release/acquire 페어링은 작업 스레드의 모든 메모리 쓰기가 메인 스레드에 보이도록 보장합니다.

3. 바쁜 대기 + 마이크로 슬립: 메인 스레드는 모든 job의 상태를 폴링하며, 아직 실행 중인 job이 있으면sleep_for(1us)후 계속 폴링합니다. 왜 1마이크로초를 사용하고 조건 변수를 사용하지 않을까요? preconnect는 짧은 작업(보통 수십 마이크로초에서 수 밀리초)이기 때문에, 조건 변수의 깨우기 오버헤드가 바쁜 대기보다 더 클 수 있습니다. 1마이크로초의 슬립은 순수 스핀으로 인한 CPU 낭비를 방지합니다.

4. 오류 전파와 abort: 만약 어떤 job이든 실패하면,errorJobAbortFlag가 설정되고, 이후 모든 job의abortFlag가 원자적으로 1로 설정됩니다. 작업 스레드는 실행 중에abortFlag를 확인하고, abort된 것을 발견하면 조기에 종료합니다. 이것은 「빠른 실패」 메커니즘으로, 하나의 job이 실패한 후 다른 job들이 계속 헛돌지 않도록 방지합니다.

Mermaid 다이어그램: group 제출의 제어 흐름

mermaid
flowchart TD
    gs["ncclGroupStart()"] --> depth_inc["ncclGroupDepth++"]
    depth_inc --> api_calls["用户调用 ncclAllReduce 等"]
    api_calls --> join["ncclGroupCommJoin(comm, type)"]
    join --> check_dup{"comm->groupNext[type]<br/>== NCCL_COMM_GROUP_INVALID?"}
    check_dup -->|是| insert["插入 clique 链表<br/>ncclMemoryStackPush"]
    check_dup -->|否| skip["跳过(已加入)"]
    insert --> ge["ncclGroupEnd()"]
    skip --> ge
    ge --> depth_dec["--ncclGroupDepth"]
    depth_dec --> depth_zero{"depth == 0?"}
    depth_zero -->|否| ret_early["返回(嵌套内层)"]
    depth_zero -->|是| check_err{"ncclGroupError<br/>== ncclSuccess?"}
    check_err -->|否| fail_cleanup["groupCleanup()"]
    check_err -->|是| create_job["创建 ncclGroupJob<br/>转移 thread_local 状态"]
    create_job --> blocking{"ncclGroupBlocking?"}
    blocking -->|0 非阻塞| spawn_thread["STDTHREADCREATE<br/>groupLaunchNonBlocking"]
    blocking -->|1 阻塞| sync_launch["groupLaunch() 同步执行"]
    spawn_thread --> ret_progress["返回 ncclInProgress"]
    sync_launch --> ret_ok["返回 ncclSuccess"]
    fail_cleanup --> reset["groupLocalResetJobState()"]
    ret_progress --> reset
    ret_ok --> reset

---

3.doLaunches: 다중 channel 다중 kernel의 라운드 스케줄링

직관적 모델

doLaunches는 「음식 전달 스케줄러」입니다. 주방(GPU)에는 여러 화구(channel)가 있고, 각 요리(kernel plan)는 순서대로 올라가야 합니다. 하지만 다른 comm의 요리는 병렬로 올라갈 수 있고, 같은 comm의 요리는 반드시 순서대로 올라가야 합니다. 스케줄러는 다음을 보장해야 합니다: 같은 clique 내의 comm은 동기적으로 진행(barrier 사용)하고, 다른 clique 간에는 독립적으로 진행할 수 있습니다.

데이터 구조와 메모리 레이아웃

doLaunches의 핵심 데이터 구조는ncclKernelPlan와comm->planner.unlaunchedPlansHead。

📎 src/group.cc:427-503

cpp
ncclResult_t doLaunches(struct ncclComm* head, int taskType) {
  ncclResult_t result = ncclSuccess;
  struct ncclComm* cliqueHead = head;
  struct ncclComm* cliqueNextHead;
  bool useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup;
  // This outer loop iterates over cliques of comms which are siblings of the
  // same global entity. We calculate a clique as all comms which have the same
  // `intraComm0` value.
  do {
    struct ncclComm* comm = cliqueHead;
    bool capturingYes = false, capturingNo = false;
    do {
      (ncclCudaGraphValid(comm->planner.capturingGraph) ? capturingYes : capturingNo) = true;
      CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
      NCCLCHECKGOTO(ncclLaunchPrepare(comm), result, failure);
      if (useBarrier) ncclCommIntraBarrierIn(comm, 1);
      comm = comm->groupNext[taskType];
    } while (comm != nullptr && comm != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID) &&
             comm->intraComm0 == cliqueHead->intraComm0);
    cliqueNextHead = comm;

    if (capturingYes && capturingNo) {
      // We have entered barriers but are aborting without leaving them. Thus
      // these comms are permanently trashed. We need a good mechanism for
      // tracking and reporting that.
      WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
      result = ncclInvalidUsage;
      goto failure;
    }

    while (true) {
      // Iterate rounds of launches for clique.
      bool moreRounds = false;
      comm = cliqueHead;
      do {
        // Iterate clique members.
        struct ncclComm* next = comm->groupNext[taskType];
        if (useBarrier) {
          // Barrier reduction result tells us if this was the final round.
          moreRounds = 0 != ncclCommIntraBarrierOut(comm);
        } else {
          moreRounds |= comm->planner.unlaunchedPlansHead != nullptr;
        }
        if (moreRounds) {
          // Pop next unlaunched kernel
          struct ncclKernelPlan* plan = comm->planner.unlaunchedPlansHead;
          if (plan != nullptr) {
            comm->planner.unlaunchedPlansHead = plan->next;
            CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
            NCCLCHECKGOTO(ncclLaunchKernelBefore_NoUncapturedCuda(comm, plan), result, failure);
            if (plan->isCeColl) {
              NCCLCHECKGOTO(ncclLaunchCeColl(comm, plan), result, failure);
            } else if (plan->isRma) {
              NCCLCHECKGOTO(ncclLaunchRma(comm, plan), result, failure);
            } else {
              NCCLCHECKGOTO(ncclLaunchKernel(comm, plan), result, failure);
            }
          }
          // Barrier reduction input indicates if we require further rounds.
          if (useBarrier) ncclCommIntraBarrierIn(comm, comm->planner.unlaunchedPlansHead != nullptr ? 1 : 0);
          if (plan != nullptr) {
            NCCLCHECKGOTO(ncclLaunchKernelAfter_NoCuda(comm, plan), result, failure);
          }
        } else {
          // Final round.
          CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
          NCCLCHECKGOTO(ncclLaunchFinish(comm), result, failure);
        }
        comm = next;
      } while (comm != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID) && comm != cliqueNextHead);
      if (!moreRounds) break;
    }
    cliqueHead = cliqueNextHead;
  } while (cliqueHead != nullptr && cliqueHead != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID));
failure:
  return result;
}

시나리오 기반 Step-by-Step Walkthrough

시나리오: 두 comm(commA와 commB)이 같은 clique에 속하고(intraComm0동일), 각 comm에는 시작할 3개의 kernel plan이 있습니다.

첫 번째 계층 루프: clique 순회

외부do-while는 모든 clique를 순회합니다.cliqueHead는 현재 clique의 첫 번째 comm입니다. 내부do-while는 clique 내의 모든 comm을 순회합니다(comm->intraComm0 == cliqueHead->intraComm0)。

각 comm에 대해:

  • cudaSetDevice(comm->cudaDev): 해당 comm에 대응하는 GPU로 전환합니다.
  • ncclLaunchPrepare(comm): 시작 준비, CUDA 스트림 설정, 리소스 확인 등을 포함합니다.
  • ncclCommIntraBarrierIn(comm, 1): barrier에 진입, 초기값은 1입니다.

두 번째 계층 루프: 라운드 스케줄링

while (true)루프는 「라운드」를 실행합니다. 각 라운드에서 clique 내의 각 comm은 하나의 kernel plan을 시작합니다.

핵심은moreRounds의 계산입니다:

  • barrier 모드 있음(useBarrier == true):moreRounds = 0 != ncclCommIntraBarrierOut(comm)。ncclCommIntraBarrierOut는comm 간 barrier 리덕션 연산입니다. clique 내의 모든 comm이ncclCommIntraBarrierIn를 호출할 때까지 기다린 후, 모든 입력값의 리덕션 결과(여기서는 논리 OR)를 반환합니다. 만약 어떤 comm이든 아직 시작하지 않은 plan이 있으면, 리덕션 결과는 1이고,moreRounds는 true이며, 다음 라운드를 계속합니다. 만약 모든 comm에 시작하지 않은 plan이 없으면, 리덕션 결과는 0이고,moreRounds는 false이며, final round에 진입합니다.
  • barrier 모드 없음:moreRounds |= comm->planner.unlaunchedPlansHead != nullptr. 각 comm에 아직 시작되지 않은 plan이 있는지 직접 확인한다. 여기서 사용하는 것은|=, 하나의 comm이라도 plan이 남아 있으면moreRounds은 true가 된다.

왜 barrier가 필요한가? clique 내의 comm은 "형제"로서 GPU 리소스나 네트워크 연결을 공유할 수 있기 때문이다. 한 comm이 3개의 kernel을 시작하고 다른 하나는 1개만 시작했다면, 먼저 시작을 마친 comm은ncclLaunchFinish에 진입하여 리소스를 해제하는데, 다른 comm은 아직 이 리소스를 사용 중이므로 use-after-free가 발생한다. barrier는 clique 내 모든 comm이 동기적으로 진행하도록 보장한다: 모두 N번째 라운드를 시작하거나, 모두 final round에 진입한다.

kernel 시작 분기

📎 src/group.cc:477-483

cpp
if (plan->isCeColl) {
  NCCLCHECKGOTO(ncclLaunchCeColl(comm, plan), result, failure);
} else if (plan->isRma) {
  NCCLCHECKGOTO(ncclLaunchRma(comm, plan), result, failure);
} else {
  NCCLCHECKGOTO(ncclLaunchKernel(comm, plan), result, failure);
}

세 가지 plan 유형:

  • isCeColl: CollNet 집합 통신 (NIC offload로 집합 통신 수행).
  • isRma: RMA (Remote Memory Access) 작업.
  • 기본: 일반 GPU kernel.

각 유형의 시작 함수는 다르지만 모두 "Before -> Launch -> After" 패턴을 따른다:

  • ncclLaunchKernelBefore_NoUncapturedCuda: 시작 전 준비 (kernel 파라미터 설정, 디바이스에 업로드 등).
  • ncclLaunchKernel: 실제 kernel 시작 (cudaLaunchKernel)。
  • ncclLaunchKernelAfter_NoCuda: 시작 후 정리 (상태 업데이트, 임시 리소스 해제).

Final round

moreRounds이 false일 때,ncclLaunchFinish(comm)을 실행한다. 이 단계는 최종 정리를 수행한다: plan 메모리 해제, comm 상태 업데이트, proxy 스레드 통지 등.

동시성 제어와 하드웨어 상호작용

ncclCommIntraBarrierIn/Out은 clique 내 comm의 동기화 프리미티브이다. 그 구현은 원자적 연산과 스핀 대기를 포함한다.In은 값을 공유 메모리에 쓰고,Out은 모든 comm이 쓴 후 리덕션 결과를 읽을 때까지 기다린다. 이 barrier는프로세스 간이며 (comm이 서로 다른 프로세스에 있는 경우), 하위 레벨에서 공유 메모리나 네트워크를 사용할 수 있다.

왜 단순한 "모든 comm에 plan이 남아 있는지 확인" 대신 barrier를 사용하는가? "확인"은 비원자적이기 때문이다: commA가 확인할 때 commB에 plan이 남아 있으면 commA는 계속하기로 결정한다. 그러나 commB는 commA의 확인 직후 마지막 plan을 시작 완료하고 final round에 진입한다. commA는 아직 kernel을 시작 중인데 commB는 이미 공유 리소스를 해제했다. barrier는 "확인"과 "결정"을 하나의 원자적 연산으로 만들어 이 경쟁 조건을 제거한다.

프로덕션 함정 회피 가이드

함정 1: CUDA graph capture 혼용。

📎 src/group.cc:448-455

cpp
if (capturingYes && capturingNo) {
  // We have entered barriers but are aborting without leaving them. Thus
  // these comms are permanently trashed. We need a good mechanism for
  // tracking and reporting that.
  WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
  result = ncclInvalidUsage;
  goto failure;
}

clique 내 일부 comm이 CUDA graph capture 모드에 있고 다른 일부는 그렇지 않으면 즉시 오류가 발생한다. 주석에는 "these comms are permanently trashed"라고 되어 있다 — barrier에 진입했지만 빠져나오지 못했기 때문에 이 comm들의 barrier 상태는 영원히 일관되지 않으며 이후 사용할 수 없다. 이는복구 불가능한 오류이며, 사용자는 통신 도메인을 재생성해야 한다. 프로덕션 환경에서 사용자가 graph capture와 비-capture comm을 혼용하면ncclInvalidUsage을 받지만, 더 심각한 것은 comm이 이미 손상되었다는 점이다.

함정 2:useBarrier의 구성 의존성。useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup. 사용자가NCCL_LAUNCH_MODE=GROUP을 설정하면 barrier 경로를 타고, 그렇지 않으면 비-barrier 경로를 탄다. 비-barrier 경로에서moreRounds은|=으로 누적하지만, 각 comm이 독립적으로 판단한다. commA에는 plan이 남아 있고 commB에는 없다면, commB는 final round에 진입하여ncclLaunchFinish을 실행하는데 commA는 아직 kernel을 시작 중이다. 이는 일부 시나리오에서는 안전하지만 (comm 간에 공유 리소스가 없는 경우), proxy 스레드나 네트워크 연결을 공유하면 문제가 발생할 수 있다. 따라서 기본적으로 barrier 모드를 권장한다.

---

4.groupLaunchLegacy의 전체 실행 체인

시나리오 기반 Step-by-Step Walkthrough

groupLaunchLegacy은 블로킹 모드에서의 전체 제출 흐름이다. 순서대로 실행한다:

단계 1: P2P preconnect

📎 src/group.cc:756-774

cpp
if (!simInfo && groupCommPreconnectHeadMain != nullptr) {
  struct ncclComm* comm = groupCommPreconnectHeadMain;
  do {
    struct ncclPreconnectJob* job;
    NEW_NOTHROW_GOTO(job, ncclPreconnectJob, ret, fail);
    job->base.func = ncclP2PPreconnectFunc;
    // ...
    ncclIntruQueueEnqueue(asyncJobsMain, (struct ncclAsyncJob*)job);
    struct ncclComm* next = comm->preconnectNext;
    comm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1);
    comm = next;
  } while (comm != nullptr);
}
NCCLCHECKGOTO(asyncJobLaunch(asyncJobsMain, groupAbortFlag), ret, fail);

preconnect가 필요한 각 comm에 대해ncclP2PPreconnectFuncjob을 생성한 후 일괄 시작한다.ncclP2PPreconnectFunc내부에서ncclTransportP2pSetup을 호출하여 P2P 연결을 설정한다.

단계 2: 대칭 메모리 등록

📎 src/group.cc:778-808

cpp
// only loop through sym alloc and register tasks
for (int type = ncclGroupTaskTypeSymRegister; type <= ncclGroupTaskTypeSymRegister; ++type) {
  if (groupCommHeadMain[type]) {
    // 按 clique 批量执行 ncclCommGroupRegisterSymmetric
  }
}

대칭 메모리 등록 (ncclCommWindowRegister등)을 clique별로 일괄 실행한다.

단계 3: 집합 통신 preconnect

📎 src/group.cc:810-870

cpp
if (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr) {
  // 按 clique 逐个 prepare + preconnect
  // 然后 ncclTasksRegAndEnqueue
  // 然后 debug check
}

이것이 핵심 단계이다. clique별로ncclPrepareTasksAndCollPreconnect을 호출한 후asyncJobLaunch이 preconnect를 실행한다. preconnect 완료 후ncclTasksRegAndEnqueue을 호출하여 작업을 plan에 등록하고 kernel 시작 파라미터를 생성한다.

단계 4:doLaunches

📎 src/group.cc:872-874

cpp
if ((!simInfo) && (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr)) {
  NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeCollective], ncclGroupTaskTypeCollective), ret, fail);
}

모든 kernel plan을 시작한다.

단계 5: 정리

📎 src/group.cc:876-903

cpp
while (!ncclIntruQueueEmpty(asyncJobsMain)) {
  struct ncclAsyncJob* job = ncclIntruQueueDequeue(asyncJobsMain);
  if (!job->destroyFlag && job->comm && !job->comm->config.blocking &&
      groupCommHeadMain[ncclGroupTaskTypeCollective] == nullptr) {
    (void)ncclCommSetAsyncError(job->comm, ret);
  }
  if (job->destructor) job->destructor((void*)job);
}

for (int type = 0; type < ncclGroupTaskTypeNum; ++type) {
  while (groupCommHeadMain[type] != nullptr) {
    struct ncclComm* comm = groupCommHeadMain[type];
    struct ncclComm* next = comm->groupNext[type];
    // Poll for callbacks sent to us from other threads.
    if (comm->reclaimSteps == GROUP_MAX_RECLAIM_STEPS) {
      NCCLCHECKGOTO(ncclCommPollCallbacks(comm, /*waitSome=*/false), ret, fail);
      comm->reclaimSteps = 0;
    } else {
      comm->reclaimSteps++;
    }
    (void)ncclGroupCommLeave(comm, type);
    if (!comm->config.blocking) {
      (void)ncclCommSetAsyncError(comm, ret);
    }
    groupCommHeadMain[type] = next;
  }
}

비동기 job을 정리한 후 모든 comm을 순회하며ncclGroupCommLeave을 호출한다.reclaimSteps의 카운트에 주의: 매GROUP_MAX_RECLAIM_STEPS(10)번 group 호출마다 callbacks를 한 번 폴링합니다. 이는 매 group마다 callbacks를 폴링하는 오버헤드를 피하면서 callbacks가 무한정 쌓이지 않도록 보장하기 위함입니다.

Mermaid 다이어그램:groupLaunchLegacy의 데이터 흐름

mermaid
flowchart LR
    subgraph input["输入"]
        preconnect["ncclGroupCommPreconnectHead"]
        coll["ncclGroupCommHead[Collective]"]
        sym["ncclGroupCommHead[SymRegister]"]
    end

    subgraph phase1["阶段1: P2P preconnect"]
        p2p_job["ncclPreconnectJob<br/>func=ncclP2PPreconnectFunc"]
        p2p_launch["asyncJobLaunch"]
    end

    subgraph phase2["阶段2: 对称内存注册"]
        sym_job["ncclGroupSymmetricJob<br/>func=ncclCommGroupRegisterSymmetric"]
    end

    subgraph phase3["阶段3: 集合通信 prepare+preconnect"]
        prep["ncclPrepareTasksAndCollPreconnect"]
        coll_job["ncclPreconnectJob<br/>func=ncclCollPreconnectFunc"]
        reg_enq["ncclTasksRegAndEnqueue"]
    end

    subgraph phase4["阶段4: kernel 启动"]
        do_launch["doLaunches<br/>轮次调度"]
        plan["ncclKernelPlan"]
        kernel["ncclLaunchKernel"]
    end

    preconnect --> p2p_job --> p2p_launch
    sym --> sym_job
    coll --> prep --> coll_job --> reg_enq
    reg_enq --> plan --> do_launch --> kernel

---

5.groupLaunchEnqueueRearch: 새로운 아키텍처의 스케줄러

직관적 모델

groupLaunchEnqueueRearch은 NCCL이 개발 중인 새로운 스케줄링 아키텍처입니다. 작업 준비, 스케줄링, 시작을 더 세분화된 단계로 나누고 비동기 job 큐로 관리합니다. 현재 스케줄러와 런처 모듈은 "아직 구현되지 않았으며", legacy의doLaunches。

📎 src/group.cc:991-996

cpp
// Schedule and launch tasks. Scheduler and launcher module of the enqueue framework
// is not yet implemented and falls back to the legacy launcher: a single phased
// doLaunches over the clique, run here on the user's thread.
if (!simInfo && groupCommHeadMain[ncclGroupTaskTypeRawTask] != nullptr) {
  NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeRawTask], ncclGroupTaskTypeRawTask), ret, fail);
}

새로운 아키텍처의 실행 흐름:

1. 작업 관리:ncclMgmtTaskJobFunc처리mgmtTaskQueue안의 작업(예: destroy)을 처리합니다.

2. 작업 준비:ncclTaskPrepareJobFunc호출ncclTaskPrepare。

3. 스케줄링 및 시작: 로 폴백doLaunches。

새로운 아키텍처는ncclGroupJobLaunch를 대체하여asyncJobLaunch를 사용하며, 더 엄격한 상태 검사를 추가했습니다:

📎 src/group.cc:113-116

cpp
} else {
  /* safety check */
  assert(state == ncclGroupJobJoined);
}

legacy 버전은WARN대신assert를 사용하고, 새로운 아키텍처는assert를 사용합니다. 이는 새로운 아키텍처가 상태 머신의 정확성에 더 높은 요구를 한다는 것을 보여줍니다.

설계 고찰

새로운 아키텍처의 동기는디커플링: legacy의groupLaunchLegacy는 모든 단계를 하나의 함수에 뒤섞어 유지보수와 확장이 어렵습니다. 새로운 아키텍처는 각 단계를 독립적인 job 타입으로 분리하고 큐로 연결합니다. 하지만 현재 스케줄러와 런처가 아직 구현되지 않았으므로 "프레임워크 선행"일 뿐입니다.

ncclParamEnqueueRearchEnable()이 새로운 아키텍처로 갈지 legacy로 갈지 제어합니다:

📎 src/group.cc:1031-1033

cpp
static ncclResult_t groupLaunch(struct ncclAsyncJob* job_, ncclSimInfo_t* simInfo = NULL) {
  return ncclParamEnqueueRearchEnable() ? groupLaunchEnqueueRearch(job_, simInfo) : groupLaunchLegacy(job_, simInfo);
}

사용자는 환경 변수NCCL_ENQUEUE_REARCH_ENABLE로 전환할 수 있습니다. 프로덕션 환경에서는 새로운 아키텍처가 아직 개발 중이므로 기본값(legacy)을 유지하는 것을 권장합니다.

---

6. 논블로킹 group과 비동기 오류 처리

시나리오 기반 단계별 워크스루

논블로킹 group의 핵심은ncclGroupJobComplete과ncclGroupJobAbort:

📎 src/group.cc:1166-1190

cpp
ncclResult_t ncclGroupJobComplete(struct ncclGroupJob* groupJob) {
  ncclResult_t ret = ncclSuccess;
  if (groupJob && groupJob->nonBlockingInit) {
    if (!COMPILER_ATOMIC_EXCHANGE(&groupJob->joined, true, std::memory_order_acq_rel)) {
      ret = ncclAsyncJobComplete(&groupJob->base);
    }
    if (ncclAtomicRefCountDecrement(&groupJob->groupRefCount) == 0) {
      delete groupJob;
    }
  }
  return ret;
}

ncclResult_t ncclGroupJobAbort(struct ncclGroupJob* groupJob) {
  if (groupJob && groupJob->nonBlockingInit) {
    if (!COMPILER_ATOMIC_EXCHANGE(&groupJob->joined, true, std::memory_order_acq_rel)) {
      COMPILER_ATOMIC_STORE(&groupJob->abortFlag, true, std::memory_order_relaxed);
      ncclAsyncJobComplete(&groupJob->base);
    }
    if (ncclAtomicRefCountDecrement(&groupJob->groupRefCount) == 0) {
      delete groupJob;
    }
  }
  return ncclSuccess;
}

핵심 설계:

1. joined원자적 플래그:COMPILER_ATOMIC_EXCHANGE를 사용하여 하나의 스레드만 join 로직을 실행할 수 있도록 보장합니다. 두 스레드가 동시에ncclGroupJobComplete를 호출하면 하나만 실제로 join하고 다른 하나는 바로 건너뜁니다. 이는 double-join을 방지합니다.

2. 참조 카운트:groupRefCount는 이 group job에 몇 개의 comm이 연관되어 있는지 기록합니다. 각 comm은ncclGroupEndInternal에서 참조 카운트를 증가시킵니다:

📎 src/group.cc:1108-1111

cpp
if (job->comm->groupJob == NULL) {
  job->comm->groupJob = groupJob;
  groupJob->groupRefCount++;
}

모든 comm이ncclGroupJobComplete또는ncclGroupJobAbort를 호출하여 참조 카운트가 0으로 줄어들 때만 group job을 삭제합니다. 이는 group job의 수명 주기가 연관된 모든 comm을 포괄하도록 보장합니다.

3. abort 시맨틱:ncclGroupJobAbort은 먼저abortFlag를 설정한 후 join합니다. 워커 스레드는 실행 중에abortFlag를 확인하고 abort된 경우 조기 종료합니다. 이는 "협력적 취소"입니다 — 스레드를 강제로 죽이는 것이 아니라 스레드 스스로 플래그를 확인한 후 종료하게 합니다.

프로덕션 함정 회피 가이드

함정 3: 논블로킹 group의 오류 조회. 논블로킹 group은ncclInProgress를 반환하며, 사용자는ncclCommGetAsyncError를 통해 진행 상황을 조회해야 합니다. 사용자가 조회를 잊고 바로 다음 통신을 호출하면ncclInProgress오류가 발생할 수 있습니다. 더 심각한 것은 group job이 아직 실행 중인데 사용자가ncclCommDestroy를 호출하면 use-after-free가 발생합니다. NCCL은comm->groupJob포인터와 참조 카운트로 이를 방지합니다:ncclCommDestroy는 먼저comm->groupJob를 확인하고, 미완료 group job이 있으면 대기하거나 오류를 보고합니다.

함정 4:ncclGroupJobComplete의 반환값. group job 실행이 실패하면ncclAsyncJobComplete는 오류 코드를 반환합니다. 하지만ncclGroupJobComplete는 첫 번째 호출에서만 이 오류 코드를 반환하고, 이후 호출은ncclSuccess를 반환합니다(joined가 이미 true이기 때문). 사용자는 반드시 첫 번째 호출에서 반환값을 확인해야 하며, 그렇지 않으면 오류 정보를 잃게 됩니다.

---

이 장 요약

이 장에서는 NCCL의 "작업 설명"에서 "kernel 시작"까지의 완전한 스케줄링 체인을 분석했습니다:

1. Group 시맨틱:ncclGroupStart/ncclGroupEnd은 thread_local 변수로 작업을 모으고,ncclGroupEnd시 일괄 제출합니다. 블로킹 모드는 동기 실행, 논블로킹 모드는 스레드를 생성하여 비동기 실행합니다.

2. 작업 준비:ncclPrepareTasks는 알고리즘/프로토콜을 결정하고,ncclPrepareTasksAndCollPreconnect는 clique별로 하나씩 preconnect하여 split comms의 경쟁 조건을 피합니다.

3. 라운드 스케줄링:doLaunches은 clique별로 그룹화하고, barrier로 clique 내 comm을 동기화하며, 매 라운드마다 하나의 kernel plan을 시작하여 모든 plan이 시작될 때까지 반복합니다.

4. 비동기 작업:asyncJobLaunch은 원자적 상태 머신과 바쁜 대기로 비동기 job을 관리하며, 빠른 실패와 abort를 지원합니다.

5. 새로운 아키텍처:groupLaunchEnqueueRearch는 개발 중인 새로운 스케줄링 프레임워크이며, 현재 legacy의doLaunches。

로 폴백합니다.ncclLaunchKernel다음 장에서는 kernel 시작의 마지막 단계로 들어갑니다:ncclKernelPlan가 어떻게 GPU에서 실제로 실행되는 kernel이 되는지, 그리고 디바이스 측에서 어떻게DevComm메타데이터를 읽는지입니다.

이 장 생각해보기와 자가 점검

Q1: 만약ncclGroupCommJoin에서ncclMemoryStackPush(&comm->memScoped)를 제거하면 무슨 일이 발생할까요? 어떤 시나리오에서 메모리 누수나 데이터 손상이 발생할까요?

참고 해석:ncclMemoryStackPush은 comm을 위해 group에서

여기까지, 작업 설명은 이미 실행 가능한 시작 계획으로 변모했다: group 시맨틱은 여러 번의 API 호출을 하나의 제출로 병합하고, channel 분할은 작업을 여러 실행 스트림에 할당하며, doLaunches의 라운드 스케줄링은 커널 간의 순서와 의존성을 보장한다. 그러나 계획은 결국 계획일 뿐, host 측의 작업 설명이 어떻게 GPU 상의 하나의 grid로 변하는가? 다음 장에서는 ncclLaunchKernel을 깊이 파고들어 파라미터 준비, kernel 변형 선택 및 cudaLaunchKernel 호출을 살펴보며 host에서 device로의 마지막 도약을 완성할 것이다.

CHAPTER 08

제8장: 제8장: Kernel 시작 및 디바이스 측 실행: host 측 호출에서 GPU 스레드 블록 출발까지

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제8 / 25장

제8장: Kernel 시작 및 디바이스 측 실행: host 측 호출에서 GPU 스레드 블록 출발까지

이전 장에서 우리는 작업이 어떻게 여러 channel로 분할되고, kernel 시작 파라미터가 어떻게 생성되며, group 시맨틱 하에서 일괄 제출과 의존성 정렬 메커니즘이 어떻게 작동하는지 분석했다. 이제 시작 계획은 준비되었지만, 아직 host 측의 데이터 구조일 뿐이다. 이 장에서 답하고자 하는 핵심 질문은:ncclKernelPlan어떻게 GPU 상에서 실제로 실행되는 grid로 변하는가? 우리는ncclLaunchKernel의 호출 체인을 따라가며 파라미터가 어떻게 kernel args에 삽입되고, kernel 변형이 어떻게 선택되며,cuLaunchKernelEx가 어떻게 호출되고, 디바이스 측ncclKernelMain가 어떻게 공유 메모리에서 작업 설명을 읽어 구체적인 구현으로 분배하는지 살펴볼 것이다.

Plan에서 Grid로: 시작 경로의 전경

세부 사항을 파고들기 전에, 먼저 전체적인 멘탈 모델을 세워보자.ncclKernelPlan를 "시공 도면"이라고 상상해보자: 이번에 몇 개의 channel(몇 개의 block)을 시작할지, 각 block에 몇 개의 스레드가 있는지, 어떤 work를 실행할지, 어떤 kernel 함수를 사용할지를 기록한다. 그리고ncclLaunchKernel는 "시공팀이 현장에 들어가는" 동작이다—도면의 정보를 CUDA 드라이버가 이해할 수 있는CUlaunchConfig로 번역한 다음,cuLaunchKernelEx를 호출하여 grid를 실제로 GPU에 발사한다.

이 계층이 없다면, host 측의 모든 스케줄링(이전 장의 channel 분할, batch 구성, proxy op 정렬)은 종이 위의 계획에 불과하며, GPU 상에서 어떤 kernel도 실행되지 않고 통신은 결코 일어나지 않을 것이다. 이것은 엔드투엔드 메인라인의 마지막 고리이자, host와 device의 경계선이다.

전체 시작 경로는 세 단계로 요약할 수 있다:

1. 파라미터 준비(finishPlan + uploadWork): work 구조체, batch 디스크립터, kernel args를 하나의 연속 메모리에 구성하고, kernel 파라미터에 넣을지, FIFO에 넣을지, 아니면 영구 버퍼에 넣을지를 결정한다.

2. kernel 발사(ncclLaunchKernel): grid/block 차원을 계산하고, launch attributes(CGA cluster, mem sync domain, launch completion event)를 조립한 후,cuLaunchKernelEx。

3. 디바이스 측 진입점(ncclKernelMain): 각 block은blockIdx.x에 따라 자신의 channelId를 결정하고, args 또는 FIFO에서 work batch를 공유 메모리에 로드한 다음,ncclDevFuncTable를 통해 구체적인 알고리즘/프로토콜 구현으로 분배한다.

아래 그림은 plan에서 grid까지의 완전한 제어 흐름을 보여주며, 핵심 분기 판단을 포함한다:

mermaid
flowchart TD
    plan["ncclKernelPlan<br/>channelMask / workBytes / kernelFn"]
    finish["finishPlan()<br/>决定 workStorageType"]
    check_budget{"sizeof(args)+batchBytes<br/>+workBytes <= workArgsBytes?"}
    args_type["workStorageType = Args<br/>work 直接放 kernel 参数"]
    fifo_type["workStorageType = Fifo/Persistent<br/>work 放外部缓冲区"]
    upload["uploadWork()<br/>拷贝 work 到目标缓冲区"]
    launch["ncclLaunchKernel()<br/>组装 CUlaunchConfig"]
    check_cluster{"compCap >= 90<br/>且 clusterSize > 0?"}
    add_cluster["添加 CLUSTER_DIMENSION<br/>+ SPREAD 调度策略"]
    no_cluster["不添加 cluster 属性"]
    check_event{"userKernelEvent<br/>且 driver >= 12030?"}
    add_event["添加 LAUNCH_COMPLETION_EVENT"]
    no_event["无 completion event"]
    cu_launch["cuLaunchKernelEx()<br/>发射 grid 到 GPU"]

    plan --> finish --> check_budget
    check_budget -->|是| args_type
    check_budget -->|否| fifo_type
    args_type --> upload
    fifo_type --> upload
    upload --> launch --> check_cluster
    check_cluster -->|是| add_cluster
    check_cluster -->|否| no_cluster
    add_cluster --> check_event
    no_cluster --> check_event
    check_event -->|是| add_event
    check_event -->|否| no_event
    add_event --> cu_launch
    no_event --> cu_launch

이 그림은 이 장의 세 가지 핵심 함수를 고정한다:finishPlan、uploadWork、ncclLaunchKernel. 다음으로 하나씩 분석해보자.

파라미터 준비: work 구조체가 자신의 위치를 찾는 방법

직관적 모델

finishPlan의 역할은 택배 분류 센터의 "포장 담당자"와 유사하다. 여러 개의 흩어진 work 구조체(각 collective 또는 p2p 작업에 하나씩 대응)를 마주하고, 다음과 같이 결정해야 한다: 이 work들을 kernel 파라미터라는 "휴대용 배낭"에 넣을지, FIFO라는 "컨베이어 벨트"에 넣을지, 아니면 영구 버퍼라는 "창고"에 넣을지?

만약 이 결정이 잘못되면—예를 들어 work가 너무 커서 kernel 파라미터에 들어가지 않는데 억지로 넣으면—kernel 시작이 바로 실패한다. work를 잘못된 위치에 넣으면, 디바이스 측에서 읽는 것이 쓰레기 데이터가 되어 통신 결과가 완전히 잘못된다.

데이터 구조와 메모리 레이아웃

먼저ncclDevKernelArgs의 구조를 보자. 이것은 host와 device 사이의 "봉투"이다:

📎 src/include/device.h:514-522

c
struct alignas(16) ncclDevKernelArgs {
  struct ncclKernelComm* comm;      // 指向设备侧通信器元数据
  uint64_t channelMask;             // 哪些 channel 有工作
  enum ncclDevWorkStorageType workStorageType;  // work 存在哪里
  uint32_t workMask;                // FIFO 环形缓冲区的掩码
  void* workBuf;                    // work 缓冲区指针
  // struct ncclDevWorkBatch batches[];  // 紧随其后的是 batch 数组
};

이 구조체는 단 5개의 필드만 있지만, 각 필드는 핵심 정보를 담고 있다.channelMask는 64비트 마스크로, 각 비트가 하나의 channel에 대응하며, 디바이스 측은__popcll를 통해blockIdx.x에 대응하는 channelId를 계산한다.workStorageType는 디바이스 측이 어디서 work를 읽을지 결정한다:Args는 work가 kernel 파라미터에 있음을 의미하고,Fifo는 링 버퍼에 있음을 의미하며,Persistent는 영구 버퍼에 있음을 의미한다.

ncclDevWorkBatchbatch 디스크립터로, 디바이스 측에 "이 channel의 work가 어디에 있고 몇 개인지"를 알려줍니다:

📎 src/include/device.h:400-421

c
struct alignas(16) ncclDevWorkBatch {
  union {
    struct {
      uint32_t nextJump:14, nextExtends:1;
      uint32_t workType:2, funcId : NCCL_DEV_WORK_BATCH_FUNC_ID_BITS, func : NCCL_DEV_WORK_BATCH_FUNC_BITS;
    };
    uint32_t flags;
  };
  uint32_t offsetBase;    // work 在 FIFO 中的起始偏移
  uint64_t offsetBitset;  // 哪些 work 属于这个 channel
};

offsetBitset64비트 마스크로, 각 비트가 하나의 work 구조체에 대응합니다. 디바이스 측은__popc과fns(find n-th set) 명령어로 각 work의 오프셋을 찾습니다.nextJump과nextExtends은 여러 batch를 연결하는 데 사용됩니다——work가 너무 많아 하나의 batch에 담을 수 없을 때 "확장 batch"를 생성합니다.

Step-by-Step Walkthrough

이제 구체적인 시나리오를 대입해 봅시다: 하나의 AllReduce가 4개의 channel로 분할되고, 각 channel에 2개의 work 구조체가 있어 총 8개의 work가 있습니다.

첫 번째 단계:finishPlan이 저장 유형을 결정합니다.

📎 src/enqueue/enqueue.cc:245-255

c
if (sizeof(ncclDevKernelArgs) + batchBytes + workBytes <= comm->workArgsBytes) {
  plan->workStorageType = ncclDevWorkStorageTypeArgs;
}
plan->kernelArgsSize = sizeof(struct ncclDevKernelArgs) + batchBytes;
plan->kernelArgsSize += (plan->workStorageType == ncclDevWorkStorageTypeArgs) ? workBytes : 0;
plan->kernelArgsSize = alignUp(plan->kernelArgsSize, 16);
plan->kernelArgs =
  (struct ncclDevKernelArgs*)ncclMemoryStackAlloc(&comm->memScoped, plan->kernelArgsSize, /*align=*/16);
plan->kernelArgs->comm = comm->devComm;
plan->kernelArgs->channelMask = plan->channelMask;
plan->kernelArgs->workStorageType = plan->workStorageType;

여기서 핵심 판단은: 만약sizeof(ncclDevKernelArgs) + batchBytes + workBytes이comm->workArgsBytes(보통 4KB)에 들어갈 수 있으면 work를 커널 파라미터에 직접 넣습니다. 그렇지 않으면 work는 FIFO 또는 영구 버퍼에 배치되고, 커널 파라미터에는 batch 디스크립터만 넣습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

왜 커널 파라미터에 우선 배치하는가? 커널 파라미터는 CUDA 드라이버에서 상수 메모리(constant memory)를 통해 전달되며, 디바이스 측에서 읽을 때ld.param명령어를 사용하므로 전역 메모리에서 FIFO를 읽는 것보다 훨씬 빠릅니다. 작은 메시지(work 총량이 적음)의 경우 이는 지연을 크게 줄일 수 있습니다.

두 번째 단계: batch를 channel별로 번갈아 가며 kernel args에 넣습니다.

📎 src/enqueue/enqueue.cc:257-280

c
uint64_t hasBatchMask = plan->channelMask;
struct ncclDevWorkBatch* batchPrev[MAXCHANNELS] = {};
struct ncclDevWorkBatch* batchZero = (struct ncclDevWorkBatch*)(plan->kernelArgs + 1);
int batchIx = 0;
while (hasBatchMask != 0) {
  uint64_t tmpMask = hasBatchMask;
  do {
    int c = popFirstOneBit(&tmpMask);
    if (!ncclIntruQueueEmpty(&wipChannels[c].workBatchQueue)) {
      struct ncclWorkBatchList* batchNode = ncclIntruQueueDequeue(&wipChannels[c].workBatchQueue);
      if (batchPrev[c] != nullptr) {
        batchPrev[c]->nextJump = int(&batchZero[batchIx] - batchPrev[c]);
      }
      batchPrev[c] = &batchZero[batchIx];
      batchZero[batchIx++] = batchNode->batch;
    }
    if (ncclIntruQueueEmpty(&wipChannels[c].workBatchQueue)) {
      hasBatchMask ^= 1ull << c;
    }
  } while (tmpMask != 0);
}

이 코드의 로직은 "폴링"입니다: 매 라운드마다 아직 batch가 남아 있는 각 channel에서 batch를 하나씩 가져와 channel 번호 오름차순으로batchZero배열에 넣습니다. 이렇게 하는 목적은 "각 channel의 첫 번째 batch가batchZero[blockIdx.x]에 위치"하도록 보장하는 것입니다——디바이스 측의 각 block은blockIdx.x을 통해 자신의 첫 번째 batch를 직접 인덱싱하므로 검색이 필요 없습니다.

nextJump필드는 같은 channel의 다음 batch가 현재 batch에 대해 가지는 오프셋을 기록합니다. 디바이스 측은batchIx += batch.nextJump을 통해 다음 batch로 점프할 수 있어 연결 리스트를 형성합니다.

세 번째 단계:uploadWork이 work를 대상 버퍼에 복사합니다.

📎 src/enqueue/enqueue.cc:1365-1430

c
static ncclResult_t uploadWork(struct ncclComm* comm, struct ncclKernelPlan* plan) {
  if (plan->isSymColl || plan->isCeColl || plan->isRma) return ncclSuccess;
  size_t workBytes = plan->workBytes;
  size_t batchBytes = plan->nWorkBatches * sizeof(struct ncclDevWorkBatch);
  void* fifoBufHost;
  uint32_t fifoCursor, fifoMask;
  switch (plan->workStorageType) {
  case ncclDevWorkStorageTypeArgs:
    plan->kernelArgs->workBuf = nullptr;
    fifoBufHost = (void*)plan->kernelArgs;
    fifoCursor = sizeof(ncclDevKernelArgs) + batchBytes;
    fifoMask = ~0u;
    break;
  case ncclDevWorkStorageTypeFifo:
    fifoBufHost = comm->workFifoBuf;
    fifoCursor = comm->workFifoProduced;
    fifoMask = comm->workFifoBytes - 1;
    NCCLCHECK(waitWorkFifoAvailable(comm, fifoCursor + workBytes));
    plan->kernelArgs->workBuf = comm->workFifoBufDev;
    break;
  // ...
  }
  plan->kernelArgs->workMask = fifoMask;
  // 修正 batch 的 offsetBase
  struct ncclDevWorkBatch* batchZero = (struct ncclDevWorkBatch*)(plan->kernelArgs + 1);
  for (int b = 0; b < plan->nWorkBatches; b++) {
    batchZero[b].offsetBase += fifoCursor;
  }
  // 拷贝 work 结构体
  struct ncclWorkList* workNode = ncclIntruQueueHead(&plan->workQueue);
  while (workNode != nullptr) {
    char* dst = (char*)fifoBufHost;
    char* src = (char*)(workNode + 1);
    for (int n = workNode->size; n != 0; n -= 16) {
      memcpy(COMPILER_ASSUME_ALIGNED(dst + (fifoCursor & fifoMask), 16), COMPILER_ASSUME_ALIGNED(src, 16), 16);
      fifoCursor += 16;
      src += 16;
    }
    workNode = workNode->next;
  }
  // ...
}

여기에는 몇 가지 핵심 사항이 있습니다:

1. fifoCursor의 의미:Args유형의 경우kernelArgs시작 주소에 대한 오프셋이고,Fifo유형의 경우 FIFO 기본 주소에 대한 오프셋이며,Persistent유형의 경우 0부터 시작합니다.

2. offsetBase의 보정:finishPlan에서 batch의offsetBase은 plan의 work 시작 위치에 대한 것입니다(0부터 시작).uploadWork이를 실제 저장 위치에 대한 오프셋으로 변환해야 합니다.Args유형의 경우sizeof(ncclDevKernelArgs) + batchBytes을 더하고,Fifo유형의 경우comm->workFifoProduced。

3. 16바이트 정렬 복사: work 구조체는 모두 16바이트 정렬(alignas(16))이므로 복사할 때 16바이트 단위로 합니다.COMPILER_ASSUME_ALIGNED은 컴파일러에게 이 주소가 16바이트 정렬임을 알려주어 컴파일러가 더 효율적인 벡터화 명령어를 생성하도록 합니다.

4. FIFO 대기:Fifo유형의 경우,waitWorkFifoAvailable은 FIFO에 충분한 공간이 생길 때까지 스핀 대기합니다. 이 대기는comm->abortFlag을 확인하여 abort 시 데드락을 방지합니다.

설계 고찰 및 프로덕션 함정

〔설계 추론 및 아키텍처 트레이드오프〕

왜 세 가지 저장 유형이 있어야 하는가?이는 공간과 지연의 트레이드오프입니다:

  • Args: 가장 빠르지만(상수 메모리) 용량이 제한적(4KB)입니다. 작은 메시지, 적은 work에 적합합니다.
  • Fifo: 용량이 크지만(링 버퍼) 디바이스 측 읽기가 전역 메모리를 거쳐야 합니다. 중간 크기 메시지에 적합합니다.
  • Persistent: CUDA Graph 캡처 시나리오에 사용됩니다. graph 캡처 시cudaMemcpy을 할 수 없으므로 영구 버퍼를 미리 할당하고 work를 복사한 다음 커널이 거기서 읽도록 해야 합니다.

함정 1: FIFO 오버플로로 인한 데드락.만약waitWorkFifoAvailable이abortFlag을 확인하지 않으면, FIFO가 가득 차고 소비자(GPU kernel)가 어떤 이유로 소비를 중단할 때 host가 영원히 스핀합니다. 소스 코드에서📎 src/enqueue/enqueue.cc:1333-1349은 abort flag를 명확히 확인합니다:

c
if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
  return ncclInternalError;
}

함정 2:offsetBitset오버플로. offsetBitset은 64비트로, 하나의 batch에 최대 64개의 work를 지원합니다. 64개를 초과하면1ull << (offset / workSize)이 오버플로합니다. 소스 코드에서NCCL_MAX_DEV_WORK_BATCH_BYTES을 통해 batch 크기를 제한하며(1024바이트), 가장 작은 work 구조체가ncclDevWorkColl(약 80바이트)이므로 최대 12개의 work로 오버플로하지 않습니다.

함정 3: Persistent 모드에서의 메모리 누수.uploadWork의Persistent분기에서,fifoBufHost은ncclOsAlignedAlloc을 통해 할당되며uploadWork_cleanup_fn에서 해제해야 합니다. 만약cudaMemcpyAsync이 실패하면,fail레이블이cleanup이 null인지 확인하고, null이면fifoBufHost을 직접 해제합니다. 이 오류 복구 체인은📎 src/enqueue/enqueue.cc:1483-1485에서 볼 수 있습니다.

Kernel 발사: CUlaunchConfig에서 cuLaunchKernelEx까지

직관적 모델

ncclLaunchKernel의 역할은 "로켓 발사 제어 콘솔"과 유사합니다. 이미 연료(work 데이터)가 장전된 plan을 받아 로켓의 비행 파라미터(grid/block 차원)를 계산하고, 다양한 발사 옵션(cluster, mem sync domain, completion event)을 설정한 뒤 발사 버튼을 누릅니다(cuLaunchKernelEx)。

이 단계에서 오류가 발생하면——예를 들어 grid 차원을 잘못 계산하면——GPU에서 잘못된 수의 block이 시작되어 일부 channel의 작업이 영원히 실행되지 않고 통신이 중단됩니다.

데이터 구조와 메모리 레이아웃

CUlaunchConfig는 CUDA 드라이버 API의 런치 구성 구조체이며, NCCL은 스택에 이를 생성합니다:

📎 src/enqueue/enqueue.cc:1916-1917

c
CUlaunchConfig launchConfig = {0};
CUlaunchAttribute launchAttrs[6] = {};
int attrs = 0;

launchAttrs는 최대 6개 요소의 배열이며, 각 요소는CUlaunchAttribute입니다. NCCL은 하드웨어 능력과 드라이버 버전에 따라 조건부로 다양한 속성을 추가합니다:

  • CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION: CGA cluster 차원(sm90+)
  • CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE: cluster 스케줄링 정책
  • CU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAIN: 메모리 동기화 도메인(CUDA 12.0+)
  • CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT: 런치 완료 이벤트(CUDA 12.3+)
  • CU_LAUNCH_ATTRIBUTE_PROGRAMMATIC_STREAM_SERIALIZATION: 프로그램적 스트림 직렬화(sym kernel)
  • CU_LAUNCH_ATTRIBUTE_NVLINK_UTIL_CENTRIC_SCHEDULING: NVLink 활용도 중심 스케줄링(CUDA 13.0+)

Step-by-Step Walkthrough

첫 번째 단계: grid와 block 차원 계산.

📎 src/enqueue/enqueue.cc:1889-1893

c
int nChannels = countOneBits(plan->channelMask);
void* sym = plan->kernelFn;
dim3 grid = {(unsigned)nChannels, 1, 1};
dim3 block = {(unsigned)plan->threadPerBlock, 1, 1};
int smem = plan->isSymColl ? plan->kernelDynSmem : ncclShmemDynamicSize(comm->cudaArch);

nChannels는channelMask에서 설정된 비트의 개수, 즉 이 plan이 시작할 block의 수입니다. 각 block은 하나의 channel을 담당합니다.threadPerBlock는scheduleCollTasksToPlan에서plan->threadPerBlock = std::max(plan->threadPerBlock, task->nWarps * WARP_SIZE)를 통해 계산되며, 모든 task 중 가장 큰nWarps * 32。

smem는 동적 공유 메모리 크기입니다. 일반 kernel의 경우ncclShmemDynamicSize(comm->cudaArch)이며, 이는 아키텍처에 따라 달라지는 컴파일 타임 상수입니다(sm70+는ncclShmemScratchWarpSize * (NCCL_MAX_NTHREADS / WARP_SIZE)). sym kernel의 경우plan->kernelDynSmem인데, sym kernel의 공유 메모리 요구 사항이 다를 수 있기 때문입니다.

두 번째 단계: kernel 파라미터 조립.

📎 src/enqueue/enqueue.cc:1902-1903

c
void* extra[] = {CU_LAUNCH_PARAM_BUFFER_POINTER, plan->kernelArgs, CU_LAUNCH_PARAM_BUFFER_SIZE, &plan->kernelArgsSize,
                 CU_LAUNCH_PARAM_END};

이는 CUDA 드라이버 API의 파라미터 전달 방식 중 하나입니다:CU_LAUNCH_PARAM_BUFFER_POINTER는 드라이버에게 "파라미터가 하나씩 전달되는 것이 아니라 연속된 메모리 블록이다"라고 알려주고,CU_LAUNCH_PARAM_BUFFER_SIZE는 드라이버에게 이 블록의 크기를 알려줍니다. 이 방식의 장점은 NCCL이ncclDevKernelArgs와 뒤따르는 batch 배열을 한 번에 전달할 수 있어 파라미터를 하나씩 패킹할 필요가 없다는 것입니다.

세 번째 단계: launch attributes 추가.

📎 src/enqueue/enqueue.cc:1929-1936

c
if (clusterSize) {
  if (grid.x % clusterSize) clusterSize = 1;
  launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
  launchAttrs[attrs++].value.clusterDim = {clusterSize, 1, 1};
  launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE;
  launchAttrs[attrs++].value.clusterSchedulingPolicyPreference = CU_CLUSTER_SCHEDULING_POLICY_SPREAD;
}

CGA(Cooperative Group Array)는 sm90에서 도입된 하드웨어 기능으로, 여러 block을 하나의 cluster로 구성할 수 있게 해줍니다. cluster 내의 block은 동시에 한 그룹의 SM에 스케줄링되는 것이 보장되며, 서로의 공유 메모리에 접근할 수 있습니다. NCCL은 이 기능을 사용하여 NVLS 등 block 간 동기화가 필요한 알고리즘을 구현합니다.

주의:if (grid.x % clusterSize) clusterSize = 1;이 보호 조건이 있습니다: cluster 차원은 grid 차원을 나눌 수 있어야 하며, 그렇지 않으면 드라이버가 오류를 반환합니다. 만약grid.x이clusterSize로 나누어지지 않으면 cluster를 사용하지 않는 것으로 퇴화합니다.

네 번째 단계: launch completion event 추가.

📎 src/enqueue/enqueue.cc:1944-1964

c
#if CUDART_VERSION >= 12030
enum ncclImplicitOrder implicitOrder;
NCCLCHECKGOTO(getImplicitOrder(&implicitOrder, comm, plan->persistent, driverVersion), ret, do_return);
if (implicitOrder == ncclImplicitOrderLaunch) {
  launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT;
  launchAttrs[attrs].value.launchCompletionEvent.event = comm->sharedRes->launchEvent;
  launchAttrs[attrs].value.launchCompletionEvent.flags = 0;
  attrs++;
  if (userKernelEvent) {
    NCCLCHECKGOTO(ncclUncapturedStreamPoolAcquire(&comm->sharedRes->uncapturedStreamPool, &relayStream), ret, do_return);
    relayUserLaunchCompletionEvent = true;
    userKernelEventArmed = true;
  }
} else if (userKernelEvent && driverVersion >= 12030) {
  launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT;
  launchAttrs[attrs].value.launchCompletionEvent.event = plan->launchCompletionEvent;
  launchAttrs[attrs].value.launchCompletionEvent.flags = 0;
  attrs++;
  userKernelEventArmed = true;
}
#endif

CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT는 CUDA 12.3에서 도입된 기능입니다: 드라이버가 kernel이 실제로 실행을 시작할 때(host 측 호출이 반환될 때가 아니라) 이벤트를 기록합니다. 이는 "암시적 순서"(implicit order)를 구현하는 데 매우 중요합니다——NCCL은 여러 kernel이 순서대로 실행되는 것을 보장해야 하지만, host 측이 블로킹 대기하는 것은 원하지 않습니다.

getImplicitOrder의 로직은 다음과 같습니다: 사용자가launchOrderImplicit를 설정했고 드라이버 버전이 충분히 새로우면ncclImplicitOrderLaunch를 사용하고(launch event로 정렬), 그렇지 않으면ncclImplicitOrderSerial를 사용합니다(completion event로 정렬, 즉 직렬 실행).

다섯 번째 단계:cuLaunchKernelEx。

📎 src/enqueue/enqueue.cc:1978-1996

c
launchConfig.gridDimX = grid.x;
launchConfig.gridDimY = grid.y;
launchConfig.gridDimZ = grid.z;
launchConfig.blockDimX = block.x;
launchConfig.blockDimY = block.y;
launchConfig.blockDimZ = block.z;
launchConfig.sharedMemBytes = smem;
launchConfig.attrs = launchAttrs;
launchConfig.numAttrs = attrs;
launchConfig.hStream = launchStream;
if (userKernelEvent && !userKernelEventArmed) {
  WARN("CUDA launch-completion events require CUDA 12.3 or newer; recording the user event before launch");
  CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, launchStream), ret, do_return);
}
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);
if (relayUserLaunchCompletionEvent) {
  CUDACHECKGOTO(cudaStreamWaitEvent(relayStream, comm->sharedRes->launchEvent, 0), ret, do_return);
  CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, relayStream), ret, do_return);
}

cuLaunchKernelEx호출.cuLaunchKernel:

📎 src/enqueue/enqueue.cc:1998-2007

c
} else {
  // Standard kernel launch
  if (userKernelEvent) {
    WARN("CUDA launch-completion events require CUDA 12.3 or newer; recording the user event before launch");
    CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, launchStream), ret, do_return);
  }
  CUCHECKGOTO(cuLaunchKernel(fn, grid.x, grid.y, grid.z, block.x, block.y, block.z, smem, launchStream, nullptr,
                             extra),
              ret, do_return);
}

복사

동시성 제어와 하드웨어 상호작용Launch completion event의 relay 메커니즘.ncclImplicitOrderLaunchlaunchCompletionEvent를 사용하고 사용자가

를 제공한 경우, NCCL은 사용자의 event를 드라이버에 직접 전달할 수 없습니다. 드라이버는 하나의 launch completion event만 지원하기 때문입니다. NCCL의 방식은:comm->sharedRes->launchEvent1.

를 드라이버에 전달합니다.relayStream2.launchEvent。

에서relayStream를 기다립니다.

3.

Mem Sync Domain。 📎 src/enqueue/enqueue.cc:1938-1942에 사용자의 event를 기록합니다.CU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAIN이렇게 하면 사용자의 event는 host 측 호출이 반환될 때가 아니라 kernel이 실제로 실행을 시작한 후에 트리거됩니다.cudaLaunchMemSyncDomainRemote

sm90+에서 NCCL은

를로 설정합니다. 이는 Hopper 아키텍처에서 도입된 메모리 동기화 도메인 메커니즘으로, 서로 다른 kernel의 메모리 배리어를 격리하여 불필요한 동기화 오버헤드를 줄입니다.grid.x프로덕션 함정 가이드clusterSize함정 1: cluster 차원이 나누어지지 않아 런치 실패.CUDA_ERROR_INVALID_VALUE만약if (grid.x % clusterSize) clusterSize = 1;이cgaClusterSize로 나누어지지 않으면 드라이버는nChannels를 반환합니다. 소스 코드에서는

로 보호하고 있지만, 이는 cluster 기능이 조용히 비활성화되었음을 의미합니다. 사용자가 cluster로 인한 성능 향상을 기대한다면 ncclInitKernelsForDevice초기화 시 각 kernel의 드라이버 요구 사항을 확인합니다:

📎 src/enqueue/enqueue.cc:71-76

c
for (int k = 0; k < kcount; k++) {
  if (kptrs[k] != nullptr && driverVersion < krequires[k]) {
    INFO(NCCL_INIT, "Skipping %skernel %d which requires driver %d", sym ? "symmetric " : "", k, krequires[k]);
    kptrs[k] = nullptr;
    if (kptrsProfile != nullptr) kptrsProfile[k] = nullptr;
  }

드라이버 버전이 충분하지 않으면 kernel 포인터가 null로 설정됩니다. 이후 스케줄러가 이 kernel을 선택하면,cuLaunchKernelEx실패합니다. NCCL의 tuner는 사용 불가능한 kernel을 선택하지 않도록 해야 하지만, 사용자가 알고리즘을 강제로 지정한 경우(NCCL_ALGO), 이 문제가 발생할 수 있습니다.

함정 포인트 3:launchCompletionEvent구형 드라이버에서의 동작.드라이버 버전이 < 12.3이면, NCCL은 kernel 시작 전에 event를 기록합니다. 이는 event가 kernel이 실제로 실행을 시작할 때가 아니라 kernel 실행 전에 트리거된다는 것을 의미합니다. 이로 인해 사용자 코드의 타이밍 가정이 무효화될 수 있습니다.

디바이스 측 진입점: blockIdx에서 구체적 구현까지

직관적 모델

ncclKernelMain은 GPU에서 각 block의 "입구 홀"입니다. block이 SM에 스케줄되어 실행을 시작하면, 먼저 이 홀에 들어가 세 가지를 완료합니다: 자신의 정체성 확인(나는 어느 channel인가), 자신의 작업 수령(work batch 로드), 그리고 해당 창구로 이동(구체적 알고리즘 구현 호출).

이 진입점이 없으면, 각 kernel 변형이 "나는 누구인가, 무엇을 해야 하는가" 문제를 자체적으로 처리해야 하므로 코드가 대량으로 중복됩니다.ncclKernelMain은 템플릿 매개변수SpecializedFnId와SpecializedRunWorkBatch를 통해 "범용 진입점 + 특수화 실행" 패턴을 구현합니다.

데이터 구조와 메모리 레이아웃

디바이스 측 공유 메모리 레이아웃은ncclKernelMain을 이해하는 핵심입니다.ncclShmemData은 모든 block이 공유하는 "작업대"입니다:

📎 src/device/common.h:48-72

c
struct ncclShmemData {
  struct ncclDevKernelArgs args;
  int channelId;
  int aborted;
  alignas(16) struct ncclKernelComm comm;
  alignas(16) struct ncclDevChannel channel;

  int batchIx, nextBatchIx;
  enum ncclDevWorkType workType;
  uint8_t directMode;
  uint16_t funcId;
  int nWorks;
  int workSize;
  uint64_t workCounter;
  bool profilerEnabled;
  uint8_t func;
  struct ncclShmemGroup groups[NCCL_MAX_GROUPS];

  alignas(16) char workStorage[ncclMaxDevWorkBatchBytes()];

  alignas(16) union {
    unpackShmem unpack;
  } devicePlugin;
};

이 구조체의 레이아웃은 정교하게 설계되었습니다:

  • args은 맨 앞에 위치하는데, kernel 매개변수에서 복사되므로 16바이트 정렬이 필요하기 때문입니다.
  • comm과channel도 16바이트 정렬인데,copyToShmem16를 통해 벡터화 명령어로 복사되기 때문입니다.
  • workStorage은 work 구조체의 임시 저장 영역이며, 크기는ncclMaxDevWorkBatchBytes()입니다(sm90+는 16KB).
  • groups배열은 각 group의 연결 정보를 저장하는 데 사용되며,NCCL_MAX_GROUPS은 16입니다.

Step-by-Step Walkthrough

첫 번째 단계: kernel args를 공유 메모리에 복사.

📎 src/device/common.h:426-428

c
if (tid < sizeof(ncclDevKernelArgs) / sizeof(uint32_t)) {
  ((uint32_t*)&ncclShmem.args)[tid] = ((uint32_t*)args)[tid];
}

여기서는 앞sizeof(ncclDevKernelArgs) / 4개의 스레드를 사용하여 각 스레드가 32비트 워드를 하나씩 복사합니다. 왜 공유 메모리에 복사할까요? kernel 매개변수는 상수 메모리에 있어 접근 속도는 빠르지만, 모든 스레드가 접근할 때 브로드캐스트 오버헤드가 발생하기 때문입니다. 공유 메모리에 복사한 후에는 모든 스레드가 동일한 공유 메모리에 접근하므로 효율이 더 높습니다.

두 번째 단계: channelId 결정.

📎 src/device/common.h:430-437

c
if (tid < MAXCHANNELS && (args->channelMask & (1ull << tid))) {
  int n = __popcll(args->channelMask & ((1ull << tid) - 1));
  if (blockIdx.x == n) ncclShmem.channelId = tid;
}
__syncthreads();

이 코드의 로직은: 설정된 각 channel(args->channelMask & (1ull << tid))에 대해, 그 앞에 몇 개의 설정된 channel(__popcll)이 있는지 계산하고, 이 수가blockIdx.x와 같으면 현재 block이 해당 channel을 담당합니다.

예를 들어:channelMask = 0b1011(channel 0, 1, 3에 작업이 있음).blockIdx.x = 0의 block은 channel 0을 담당하고(앞에 0개의 설정),blockIdx.x = 1의 block은 channel 1을 담당하며(앞에 1개의 설정),blockIdx.x = 2의 block은 channel 3을 담당합니다(앞에 2개의 설정).

세 번째 단계: comm과 channel을 공유 메모리에 로드.

📎 src/device/common.h:446-478

c
switch (tid / WARP_SIZE) {
case 0:
  {
    void* dst = &ncclShmem.comm;
    void* src = ncclShmem.args.comm;
    int bytes = sizeof(ncclKernelComm);
    static_assert(sizeof(ncclKernelComm) <= 16 * WARP_SIZE,
                  "ncclKernelComm cannot be loaded by a single warp in one insn.");
    copyToShmem16(tid, dst, src, bytes);
  }
  break;
case 1:
  {
    void* dst = &ncclShmem.channel;
    void* src = &((ncclKernelCommAndChannels*)ncclShmem.args.comm)->channels[ncclShmem.channelId];
    int bytes = sizeof(ncclDevChannel);
    static_assert(sizeof(ncclDevChannel) <= 16 * WARP_SIZE,
                  "ncclDevChannel cannot be loaded by a single warp in one insn.");
    copyToShmem16(tid - WARP_SIZE, dst, src, bytes);
  }
  break;
default:
  {
    int subtid = tid - 2 * WARP_SIZE;
    int subtn = tn - 2 * WARP_SIZE;
    loadWorkBatchToShmem(subtid, subtn, args, /*batchIx=*/blockIdx.x);
  }
  break;
}
__syncthreads();

여기서 스레드를 세 그룹으로 나눕니다:

  • 0번째 warp:ncclKernelComm(통신자 메타데이터)를 공유 메모리에 로드.
  • 1번째 warp: 현재 channel의ncclDevChannel(channel 메타데이터)를 공유 메모리에 로드.
  • 나머지 warp: work batch를 공유 메모리에 로드.

copyToShmem16은 인라인 PTX로 구현된 16바이트 복사 함수입니다:

📎 src/device/common.h:131-139

c
inline __device__ void copyToShmem16(int tid, void* dst, void const* src, int bytes) {
  int offset = 16 * tid;
  if (offset < bytes) {
    uint64_t a = 0, b = 0;
    asm volatile("ld.v2.u64 {%0,%1},[%2];" : "=l"(a), "=l"(b) : "l"((char const*)src + offset) : "memory");
    uint32_t udst = (uint32_t)__cvta_generic_to_shared(dst);
    asm volatile("st.shared.v2.u64 [%0],{%1,%2};" ::"r"(udst + offset), "l"(a), "l"(b) : "memory");
  }
}

이것은ld.v2.u64를 사용하여 전역 메모리에서 16바이트를 로드하고,st.shared.v2.u64를 사용하여 공유 메모리에 저장합니다.__cvta_generic_to_shared은 범용 주소를 공유 메모리 주소로 변환합니다(공유 메모리 주소 공간은 32비트).

네 번째 단계: work batch 로드.

loadWorkBatchToShmem이 가장 복잡한 부분입니다. 그 임무는 batch 디스크립터가 가리키는 work 구조체를 전역 메모리(또는 kernel 매개변수)에서 공유 메모리의workStorage에 복사하는 것입니다.

📎 src/device/common.h:142-260

c
__device__ __forceinline__ void loadWorkBatchToShmem(int tid, int tn, struct ncclDevKernelArgs const* args,
                                                     int batchIx) {
  int lane = tid % WARP_SIZE;
  int workCursor = 0;
  while (true) {
    struct ncclDevWorkBatch batch = ((struct ncclDevWorkBatch*)(args + 1))[batchIx];

    uint8_t* fnsOfBitset = (uint8_t*)ncclScratchForWarp(threadIdx.x / WARP_SIZE);
    __syncwarp();
    if (uint32_t(batch.offsetBitset) & (1u << lane)) {
      int nWorksBelow = __popc(uint32_t(batch.offsetBitset) & ((1u << lane) - 1));
      fnsOfBitset[nWorksBelow] = lane;
    }
    int nWorksLow32 = __popc(uint32_t(batch.offsetBitset));
    if (uint32_t(batch.offsetBitset >> 32) & (1u << lane)) {
      int nWorksBelow = nWorksLow32;
      nWorksBelow += __popc(uint32_t(batch.offsetBitset >> 32) & ((1u << lane) - 1));
      fnsOfBitset[nWorksBelow] = 32 + lane;
    }
    int nWorks = nWorksLow32 + __popc(uint32_t(batch.offsetBitset >> 32));
    __syncwarp();
    // ...
  }
}

이 코드의 핵심은fnsOfBitset을 계산하는 것입니다:offsetBitset에서 n번째 설정된 비트의 비트 인덱스가 무엇인지. PTX에는fns명령어가 있어 이를 수행할 수 있지만, 많은 SASS 명령어로 전개됩니다. NCCL의 방식은 공유 메모리를 사용하는 것입니다: 각 lane이 자신의 비트가 설정되었는지 확인하고, 설정되었다면 앞에 몇 개의 설정이 있는지 계산한 후 자신의 lane 번호를fnsOfBitset[nWorksBelow]。

에 기록합니다. 다음은 실제 복사입니다:

📎 src/device/common.h:209-241

c
if (tid < nPacks) {
  int srcWork = fnsOfBitset[dstWork];
  ulonglong2 tmp;
  if (ncclShmem.args.workStorageType == ncclDevWorkStorageTypeArgs) {
    char* src = (char*)args + (batch.offsetBase + srcWork * workSize + packInWork * 16);
    tmp = *(ulonglong2*)src; // becomes ld.param.v2.u64
  } else {
    char* src = (char*)ncclShmem.args.workBuf +
                ((batch.offsetBase + srcWork * workSize + packInWork * 16) & ncclShmem.args.workMask);
    tmp = *(ulonglong2*)src; // becomes ld.v2.u64
  }
  char* dst = ncclShmem.workStorage;
  dst += (workCursor + dstWork) * workSize + packInWork * 16;
  *(ulonglong2*)dst = tmp;
}

여기에는 중요한 최적화가 있습니다:Args타입의 경우, 소스 코드에 직접(char*)args + offset를 작성하면 컴파일러가 이것이 kernel 매개변수에서 읽는 것임을 인식하고ld.param.v2.u64명령어를 생성합니다.Fifo타입의 경우, 소스 코드에(char*)ncclShmem.args.workBuf + (offset & workMask)를 작성하면 컴파일러가ld.v2.u64명령어를 생성합니다.

주석에서 이 두 경우를 병합할 수 없다고 특별히 강조합니다:

📎 src/device/common.h:212-229

c
// The loads done in these two cases must be kept separate since we are
// relying on the compiler to use "ld.param" in the first one. The parameter
// space is not generically addressable, so any attempt to load through
// a pointer that *might* be parameter space backed will cause the
// compiler to spill the parameter struct (4K!) to each thread's local space
// before creating a pointer (to the spill) and decimate perf.

컴파일러가 포인터가 매개변수 공간을 가리키는지 전역 공간을 가리키는지 확정할 수 없으면, 전체 매개변수 구조체(4KB)를 각 스레드의 로컬 메모리로 스필하여 성능이 급격히 저하됩니다.

다섯 번째 단계: work 실행.

📎 src/device/common.h:481-497

c
while (ncclShmem.aborted == 0) {
  profiler(START);
  if (0 <= SpecializedFnId && ncclShmem.funcId == (unsigned)SpecializedFnId) {
    SpecializedRunWorkBatch().run();
  } else {
    ncclDevFuncTable[ncclShmem.funcId]();
  }

  if (ncclShmem.nextBatchIx == -1) break;
  int batchIx = ncclShmem.nextBatchIx;
  __syncthreads();
  profiler(STOP);
  if (ncclShmem.comm.progressCounters != nullptr) __syncthreads();
  loadWorkBatchToShmem(tid, tn, args, batchIx);
  __syncthreads();
}

여기에는 중요한 최적화가 있습니다: 만약SpecializedFnId이 현재 batch의funcId와 일치하면, 직접SpecializedRunWorkBatch().run()을 호출합니다. 이것은 컴파일 시점에 특수화된 함수로, 함수 포인터 호출 오버헤드가 없습니다. 그렇지 않으면ncclDevFuncTable[ncclShmem.funcId]()를 통해 간접 호출합니다.

ncclDevFuncTable은 디바이스 측 함수 포인터 배열로,generate.py생성:

📎 src/device/generate.py:261-270

python
out("__device__ ncclDevFuncPtr_t const ncclDevFuncTable[] = {\n")
index = 0
for fn in primary_funcs:
  sym = paste("_", "ncclDevFunc", *fn)
  cudart, arch = required_cuda(*fn)
  if (cudart, arch) != (0, 0):
    out("#if CUDART_VERSION >= %d && __CUDA_ARCH__ >= %d\n" % (cudart ,arch))
  out("/*%4d*/ %s,\n" % (index, sym))
  if (cudart, arch) != (0, 0):
    out("#else\n" "/*%4d*/ nullptr,\n" "#endif\n" % index)
  index += 1
out("nullptr};\n")

설계 사고와 프로덕션 함정

왜 사용하는가__grid_constant__? 📎 src/device/common.h:19-24

c
#if __CUDA_ARCH__ >= 700
// __grid_constant__ appears to break cuda-gdb
#define NCCL_GRID_CONSTANT __grid_constant__
#else
#define NCCL_GRID_CONSTANT
#endif

__grid_constant__컴파일러에게 이 파라미터가 읽기 전용이며 상수 메모리에 배치될 수 있음을 알려줍니다. 이렇게 하면 디바이스 측에서 읽을 때ld.param명령어를 사용하여 전역 메모리에서 읽는 것보다 빠릅니다. 주석에 cuda-gdb를 손상시킨다고 언급되어 있어 sm70+에서만 활성화됩니다.

함정 포인트 1:workStorage오버플로. workStorage의 크기는ncclMaxDevWorkBatchBytes(), sm90+는 16KB입니다. 만약nWorks * workSize이 값을 초과하면 범위를 벗어나 쓰게 됩니다. 소스 코드에서는NCCL_MAX_DEV_WORK_BATCH_BYTES을 통해 host 측에서 batch 크기를 제한하지만, 디바이스 측에는 추가 검사가 없습니다. 만약 host 측 제약이 우회되면(예: 환경 변수 수정을 통해) 공유 메모리 범위 초과가 발생합니다.

함정 포인트 2:__syncthreads()의 부재로 인한 데이터 경쟁.이loadWorkBatchToShmem이후에, 모든 스레드가 완전한__syncthreads()을 볼 수 있도록 반드시workStorage이 있어야 합니다. 소스 코드에서📎 src/device/common.h:479에__syncthreads(); // publish ncclShmem이 있습니다. 만약 이 동기화가 제거되면, 일부 스레드가workStorage이 아직 쓰여지기 전에 읽기를 시작하여 쓰레기 데이터를 읽을 수 있습니다.

함정 포인트 3: abort 검사의 타이밍. while (ncclShmem.aborted == 0)은 각 batch 시작 시에만 abort를 검사합니다. 만약 특정 batch의 실행 시간이 매우 길면, abort 신호가 적용되기까지 오래 기다려야 할 수 있습니다. 이는 설계상의 트레이드오프입니다: 더 빈번한 검사는 오버헤드를 증가시키지만 응답이 더 빠릅니다.

Kernel 변형 선택: generate.py가 kernel 목록을 생성하는 방법

직관적 모델

generate.py의 역할은 "자동차 공장의 생산 라인 설계자"와 유사합니다. 그것은 거대한 조합 공간(7가지 집합 연산 × 5가지 리덕션 연산 × 12가지 데이터 타입 × 7가지 알고리즘 × 3가지 프로토콜)에 직면하여 결정해야 합니다: 어떤 조합에 전용 kernel을 생성해야 하는가? 어떤 것이 범용 kernel을 공유할 수 있는가?

만약 모든 조합에 대해 kernel을 생성하면, 컴파일 시간과 바이너리 크기가 폭발합니다. 만약 하나의 범용 kernel만 생성하면, 런타임에 함수 포인터 호출과 분기 판단으로 인해 느려집니다.generate.py의 해결책은 "대표적 kernel"입니다: 각 동등 클래스에 대해 하나의 kernel을 생성하고, 런타임에 함수 포인터 테이블을 통해 디스패치합니다.

데이터 구조와 메모리 레이아웃

generate.py은 세 가지 핵심 파일을 생성합니다:

1. device_table.cu: 디바이스 측의ncclDevFuncTable, funcId를 구체적인 디바이스 함수에 매핑합니다.

2. host_table.cc: host 측의ncclDevKernelList、ncclDevKernelForFunc、ncclDevFuncRowToId등의 테이블.

3. 각<coll>_<op>_<ty>.cu: 구체적인 kernel 구현.

Step-by-Step Walkthrough

첫 번째 단계: 모든 함수 행을 열거합니다.

📎 src/device/generate.py:186-199

python
def enumerate_func_rows():
  yield ("SendRecv", None, None, None, None)
  for coll in ("AllGather", "Broadcast", "AllGatherV"):
    algos = algos_of_coll[coll]
    for algo in algos:
      for proto in all_protos:
        yield (coll, None, None, algo, proto)
  for coll in ("AllReduce", "Reduce", "ReduceScatter"):
    algos = algos_of_coll[coll]
    for redop in all_redops:
      for ty in all_tys:
        for algo in algos:
          for proto in all_protos:
            yield (coll, redop, ty, algo, proto)

이 열거 순서는ncclDevFuncId()의 계산 공식과 일치해야 합니다:

📎 src/include/device.h:646-706

c
inline int ncclDevFuncId(int coll, int devRedOp, int type, int algo, int proto) {
  constexpr int NumTypes = ncclNumTypes;
  int row;
  do {
    row = 0; // ncclDevFuncIndex_P2p
    if (coll == ncclFuncSendRecv) break;
    row += 1;
    // ...
  } while (false);
  return ncclDevFuncRowToId[row];
}

ncclDevFuncId이 계산하는 것은 "행 번호"이며, 그런 다음ncclDevFuncRowToId을 통해 "주 함수 ID"로 매핑됩니다. 이 매핑의 이유는: 많은 행이 동일한 주 함수에 매핑될 수 있기 때문입니다(예: 모든AllReduce Sum i32의 행이AllReduce Sum u32의 주 함수에 매핑됨).

두 번째 단계: 주 함수와 kernel 함수를 계산합니다.

📎 src/device/generate.py:211-225

python
func_rows = [validate(*fn) for fn in enumerate_func_rows()]
primary_funcs = sorted(set(equivalent_primary(*fn) for fn in func_rows if fn is not None))
primary_to_index = {fn: i for (i,fn) in zip(range(len(primary_funcs)), primary_funcs)}
kernel_funcs = sorted(set(best_kernel(*fn) for fn in primary_funcs))

equivalent_primary은 부호 있는 정수를 부호 없는 정수로 매핑합니다(덧셈/곱셈이 둘 다에 대해 동일하기 때문):

📎 src/device/generate.py:158-166

python
def equivalent_primary(coll, redop, ty, algo, proto):
  if coll in ("AllReduce", "Reduce", "ReduceScatter"):
    if redop in ("Sum","Prod","PreMulSum","SumPostDiv") and ty[0]=="i":
      return (coll, redop, "u"+ty[1:], algo, proto)
    if redop=="MinMax" and ty[0]=="i" and ("NVLS" not in algo):
      return (coll, redop, "u"+ty[1:], algo, proto)
  return (coll, redop, ty, algo, proto)

best_kernel은 여러 주 함수를 동일한 kernel에 매핑합니다(예: 모든AllGather의 알고리즘이AllGather RING LL):

📎 src/device/generate.py:171-183

python
def best_kernel(coll, redop, ty, algo, proto):
  def best(coll, redop, ty, algo, proto):
    if coll=="Nop": return ("Generic", None, None, None, None)
    if coll=="SendRecv": return ("SendRecv", None, None, None, None)
    if exact_kernel_names: return (coll, redop, ty, algo, proto)
    if coll in ("AllGather","Broadcast","AllGatherV"): return (coll, None, None, "RING", "LL")
    return (coll, "Sum", ty, ("TREE" if algo=="TREE" else "RING"), "LL")
  kfn = equivalent_primary(*best(coll, redop, ty, algo, proto))
  if not func_filter(*kfn): return ("Generic", None, None, None, None)
  return kfn

세 번째 단계: kernel 정의를 생성합니다.

📎 src/device/generate.py:458-480

python
(_, kfns) = name_to_kernels.get(name) or (None, [])
for kfn in kfns:
  (coll, redop, ty, algo, proto) = kfn
  sym = kernel_suffix(kfn)
  fn_id = primary_to_index[kfn]
  cudart, arch = required_cuda(*kfn)
  s = "DEFINE_ncclDevKernel({sym}, ncclFunc{coll}, {redop_cxx}, {ty_cxx}, NCCL_ALGO_{algo}, NCCL_PROTO_{proto}, {fn_id})\n"
  # ...
  out(s.format(...))

DEFINE_ncclDevKernel매크로 확장 후:

📎 src/device/common.h:507-509

c
#define DEFINE_ncclDevKernel(suffix, coll, redop, ty, algo, proto, specializedFnId) \
  __global__ void ncclDevKernel_##suffix(ncclDevKernelArgs4K NCCL_GRID_CONSTANT const args4K) { \
    ncclKernelMain<specializedFnId, RunWorkBatch<coll, ty, redop<ty>, algo, proto>>(&args4K.args); \
  }

따라서 각 kernel은__global__함수이며,ncclKernelMain을 호출하고, 템플릿 파라미터는specializedFnId과RunWorkBatch<coll, ty, redop<ty>, algo, proto>。

설계 사고와 프로덕션 함정

〔설계 추론과 아키텍처 트레이드오프〕

왜 "대표적 kernel"을 사용하고 각 조합마다 하나의 kernel을 사용하지 않는가?컴파일 시간과 바이너리 크기의 트레이드오프. 완전한 조합 공간은 7 × 5 × 12 × 7 × 3 ≈ 8820개의 kernel이며, 각 kernel 컴파일에는 몇 초가 걸려 총 몇 시간이 필요합니다. 또한 바이너리 크기가 수백 MB에 달합니다. 대표적 kernel로 매핑함으로써 실제 생성되는 kernel 수가 수십 개로 줄어듭니다.

함정 포인트 1:NCCL_EXACT_KERNEL_NAMES로 인한 컴파일 폭발.만약 이 환경 변수가 설정되면,best_kernel은 원시 함수를 반환하여 모든 조합에 대해 kernel이 생성됩니다. 이는 개발 시 유용하지만(어떤 kernel이 컴파일되는지 정확히 제어 가능), 프로덕션 환경에서는 컴파일 시간이 너무 길어집니다.

함정 포인트 2:required_cuda의 버전 검사.일부 kernel은 특정 CUDA 버전이나 아키텍처가 필요합니다:

📎 src/device/generate.py:130-154

여기까지, kernel이 GPU에서 시작되었고 디바이스 측도 작업 설명을 받았습니다. 하지만 성능을 진정으로 결정하는 것은 디바이스 내부에서 데이터를 어떻게 운반하는가입니다. 다음 장에서는 src/device 아래의 세 가지 프로토콜 원시 요소인 LL, LL128, Simple을 깊이 살펴보며, 동일한 AllReduce 로직에 왜 세 가지 운반 원시 요소가 필요한지, 그리고 동기화 방식, 버퍼 레이아웃, flag 의미에서의 차이점을 알아봅니다.

CHAPTER 09

제 9 장: 제 9 장: 디바이스 측 통신 원시 요소: LL, LL128, Simple 세 가지 프로토콜의 데이터 운반 구현

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 9 / 25 장

제 9 장: 디바이스 측 통신 원시 요소: LL, LL128, Simple 세 가지 프로토콜의 데이터 운반 구현

지난 장에서는 host 측이 하나의 AllReduce를 어떻게 __global__ kernel로 변환하는지 추적했고, device 측 진입점인 ncclKernelMain이 알고리즘과 프로토콜에 따라 분배를 수행하는 것을 확인했다. 그러나 분배는 도구를 선택했을 뿐이고, 실제 성능을 결정하는 것은 이 도구들이 데이터 이동을 어떻게 실행하는가이다. 이번 장에서는 src/device 아래의 세 가지 이동 프리미티브인 LL, LL128, Simple을 깊이 파고들어, 각각의 데이터 이동 구현을 하나씩 분석하고 서로 다른 프로토콜이 지연과 대역폭 사이에서 어떻게 절충하는지 이해한다.

왜 동일한 AllReduce에 세 가지 이동 프리미티브가 필요한가

먼저 직관적인 모델을 세워보자. 하나의 컨베이어 벨트 공장을 상상해보자. 원료(사용자 데이터)는 한쪽 끝에서 들어오고, 완제품은 다른 쪽 끝에서 나가며, 중간에 여러 공정(rank)이 서로 반제품을 교환해야 한다. 반제품을 운반하는 방식에는 세 가지가 있다:

  • LL(Low Latency): 두 사람이 마주 보고 쪽지를 건네는 것처럼, 건네는 동시에 상대방은 "이건 너에게 주는 것"이라는 것을 안다. 핸드셰이크 오버헤드가 거의 없다. 하지만 쪽지가 매우 작아서 한 번에 8바이트의 유효 데이터만 전달할 수 있다. 작은 메시지에 적합하다.
  • LL128: 쪽지를 128바이트 메모지로 바꾸고, 한 번에 120바이트의 유효 데이터를 전달하지만, 메모지가 반드시 16바이트 정렬로 배치되어야 하며, 그렇지 않으면 먼저 공유 메모리에서 "재배치"해야 한다. 중간 크기 메시지에 적합하다.
  • Simple: 택배함처럼, 먼저 소포를 함(FIFO 버퍼)에 넣고, 그다음 "N번 함에 물건이 있다"는 알림을 보낸다. 핸드셰이크 오버헤드가 크지만, 한 번에 많이 옮길 수 있다. 큰 메시지에 적합하다.
〔설계 추론과 아키텍처 절충〕

만약 프리미티브가 하나만 있다면 어떻게 될까? LL만 사용하면, 큰 메시지는 "모든 메시지마다 상대방의 flag 확인을 기다려야" 하므로 대역폭이 죽는다. Simple만 사용하면, 작은 메시지는 "FIFO 쓰기 + 알림 보내기 + 알림 기다리기"의 고정 오버헤드 때문에 지연이 폭발한다. NCCL의 성능 곡선이 8KB, 128KB 근처에서 뚜렷한 변곡점을 보이는 근본 원인이 바로 여기에 있다.

세 가지 프리미티브는 동일한 템플릿 골격을 공유한다Primitives<T, RedOp, Fan, Direct, Proto, P2p, isNetOffload>,Proto이 템플릿 파라미터를 통해 세 가지 버전으로 특화된다📎 src/device/primitives.h:117-117。ProtoLL、ProtoLL128、ProtoSimple세 구조체는 각각 프로토콜 관련 상수와 계산 메서드를 가진다📎 src/device/primitives.h:25-75, 알고리즘 코드는prims.send()、prims.recvReduceSend()같은 통합 인터페이스만 호출하고, 저수준이 어떤 프로토콜인지 신경 쓰지 않는다.

mermaid
flowchart TD
    algo["算法层 all_reduce.h<br/>调用 prims.recvReduceSend()"] --> dispatch{"Proto 模板参数?"}
    dispatch -->|ProtoLL| ll["Primitives&lt;..., ProtoLL, ...&gt;<br/>prims_ll.h"]
    dispatch -->|ProtoLL128| ll128["Primitives&lt;..., ProtoLL128, ...&gt;<br/>prims_ll128.h"]
    dispatch -->|ProtoSimple| simple["Primitives&lt;..., ProtoSimple&lt;...&gt;, ...&gt;<br/>prims_simple.h"]
    ll --> llop["LLGenericOp&lt;RECV,SEND,SrcBuf,DstBuf&gt;"]
    ll128 --> ll128op["GenericOp -&gt; recvReduceSendCopy"]
    simple --> simpleop["genericOp -&gt; waitPeer / reduceCopy / postPeer"]

이 그림은 "동일한 AllReduce 로직에 왜 세 가지 이동 프리미티브가 필요한가"를 설명한다: 알고리즘 계층은 프로토콜 독립적이고, 프로토콜 차이는Primitives의 세 가지 특화에 캡슐화된다.

LL: flag를 데이터 행에 내장한 제로 핸드셰이크 이동

직관적 모델

LL의 핵심 사상은:"데이터"와 "데이터가 준비되었는지"에 대한 표시를 동일한 16바이트 읽기/쓰기 단위에 집어넣는 것이다. 수신 측은 별도의 "알림 메시지"가 필요 없고, 데이터 행의 flag 필드를 폴링하기만 하면 되며, flag가 일치하면 데이터가 도착한 것이다. 이는 편지를 보낼 때 "수신자 서명"을 봉투에 직접 인쇄하는 것과 같다. 우체부는 서명만 보면 배달해야 할지 알 수 있고, 별도의 수령증을 보낼 필요가 없다.

만약 이 설계가 없다면, 수신 측은 먼저 "데이터가 기록됨" 알림을 기다린 후 다시 데이터를 읽어야 하므로, 메모리 왕복이 두 번 발생하여 지연이 두 배가 된다.

데이터 구조와 메모리 레이아웃

LL의 이동 단위는union ncclLLFifoLine이며,storeLL의 어셈블리에서 그 레이아웃을 볼 수 있다📎 src/device/prims_ll.h:154-158:

code
st.volatile.global.v4.u32 [%0], {%1,%2,%3,%4};
// 写入 4 个 u32:data1, flag, data2, flag

하나의ncclLLFifoLine은 16바이트이고, 배치는[data1(4B) | flag(4B) | data2(4B) | flag(4B)]이다. 유효 데이터는 8바이트(data1 + data2)뿐이고, 나머지 8바이트는 전부 flag이다. 이것이ProtoLL::calcBytePerGrain()가sizeof(uint64_t)을 반환하는 이유이다 — "One 16-byte line has 8-bytes of data"📎 src/device/primitives.h:55-57。

핵심 필드(Primitives의 LL 특화)📎 src/device/prims_ll.h:20-42:

필드타입역할
recvStep[i] / sendStep[i]uint64_t[MaxRecv/MaxSend]각 peer의 스텝 카운트로, 버퍼 오프셋과 flag 값을 결정한다
recvBuff[i] / sendBuff[i]ncclLLFifoLine*각 peer의 FIFO 버퍼 기저 주소를 가리킨다
recvConnHeadPtrvolatile uint64_t*수신 측 "몇 번째 스텝까지 소비했는지"의 전역 포인터
sendConnHeadPtrvolatile uint64_t*송신 측 "상대방이 몇 번째 스텝까지 소비했는지"의 전역 포인터
sendConnHeadCacheuint64_t마지막으로 읽은 head 값을 캐시하여 매번 전역 메모리를 읽지 않도록 한다

버퍼 오프셋은recvOffset(i) = (recvStep[i] % NCCL_STEPS) * stepLines으로 계산한다📎 src/device/prims_ll.h:44-46,NCCL_STEPS은 링 버퍼의 슬롯 수이고,stepLines은 슬롯당 행 수이다. flag 값은recvFlag(i) = NCCL_LL_FLAG(recvStep[i] + 1)으로 계산한다📎 src/device/prims_ll.h:56-58,+1에 주의하라 — flag 초깃값이 0이므로, 첫 번째 스텝의 flag는 반드시 1이어야 "기록되지 않음"과 구분할 수 있다.

시나리오 기반 Walkthrough: 한 번의 recvReduceSend

rank 0이 Ring AllReduce에서recvReduceSend을 실행한다고 가정하자: 이전 rank로부터 데이터를 받고, 로컬 데이터와 reduce한 후, 다음 rank로 보낸다. 호출 체인은recvReduceSend(inpIx, eltN) → LLGenericOp<1, 1, Input, -1>(inpIx, -1, eltN, false) 📎 src/device/prims_ll.h:403-405。

첫 번째 단계: 송신 버퍼가 사용 가능할 때까지 기다린다. waitSend은sendConnHeadCache + NCCL_STEPS < sendConnHead + 1 📎 src/device/prims_ll.h:73-89을 검사한다. 의미는: 상대방의 소비 진행(head)이 나보다 너무 뒤처져 있으면 링 버퍼가 거의 가득 찼다는 뜻이므로 반드시 기다려야 한다.NCCL_STEPS은 버퍼의 총 슬롯 수이고,sendConnHead + 1은 내가 곧 차지할 슬롯이다. 기다리는 동안*sendConnHeadPtr을 폴링하여checkAbort캐시를 갱신하고, 주기적으로📎 src/device/prims_ll.h:73-89。

을 호출하여 abort되었는지 확인한다 DataLoader::loadBegin두 번째 단계: 로컬 데이터를 로드한다.📎 src/device/prims_ll.h:200-216은 정렬 문제를 처리한다sizeof(T) <= 2.u4[0..2]일 때(예: half 또는 int8), 소스 주소가 4바이트 정렬이 아닐 수 있으므로, 먼저 4바이트 정렬로misalign에 읽어들이고,loadFinish을 기록한 후,__funnelshift_r에서📎 src/device/prims_ll.h:218-225을 사용하여 바이트 수준 시프트로 올바른 64비트 값을 조립한다

. 이것은 전형적인 "정렬 읽기 + 시프트 재조립" 기법으로, 비정렬 접근의 성능 페널티를 피한다. readLL세 번째 단계: 상대방 데이터를 읽고 flag를 기다린다.📎 src/device/prims_ll.h:108-122:

cpp
do {
  asm volatile("ld.volatile.global.v4.u32 {%0,%1,%2,%3}, [%4];" ...);
  if (checkAbort(abort, 1, spins)) break;
} while ((flag1 != flag) || (flag2 != flag));

복사ld.volatile.global.v4.u32한 번에 16바이트(4개의 u32)를 읽고, 두 flag 필드가 모두 기대값과 일치하는지 확인한다.volatile키워드는 컴파일러가 이 읽기를 최적화로 제거하거나 레지스터에 캐시하지 않도록 보장한다——상대방이 언제든 새 데이터를 쓸 수 있기 때문이다. 두 flag가 모두 일치해야 하는 이유는, 쓰기 측이storeLL한 번에 4개의 u32를 쓰지만 이론적으로 두 번의 8바이트 쓰기로 쪼개질 수 있기 때문에, 두 flag가 모두 일치해야 16바이트가 완전하다는 것이 보장된다.

네 번째 단계: reduce 후 전송.peerData를 수신한 후,applyReduce(redOp, peerData, data)리듀스를 수행한다📎 src/device/prims_ll.h:279. 그런 다음storeLL(sendPtr(i) + offset, data, sendFlag(i))결과를 송신 버퍼에 쓴다📎 src/device/prims_ll.h:295-296. 전송 순서에 주의: 먼저i=1..MaxSend(보통 네트워크 peer)를 보내고, 마지막에i=0(보통 로컬 peer)를 보낸다📎 src/device/prims_ll.h:291-297. 주석에 명확히 쓰여 있다: 「Send : inter-node, then intra-node, then local」——느린 것(네트워크)을 먼저 보내 백그라운드에서 날아가게 하고, 그다음 빠른 것(로컬)을 보내면 로컬 peer가 네트워크를 기다리지 않는다.

다섯 번째 단계: step을 진행하고 post. incRecv(i)수신 스텝을 증가시키고📎 src/device/prims_ll.h:91-93,postRecv()recvConnHead를 전역 포인터에 다시 쓴다📎 src/device/prims_ll.h:94-97, 상대방에게 「나는 이 단계를 소비했다」고 알린다. 송신 측incSend에는 특수 로직이 있다📎 src/device/prims_ll.h:99-106:

cpp
if ((sendStep[i] & NCCL_LL_CLEAN_MASK) == NCCL_LL_CLEAN_MASK) {
  for (int o = offset; o < stepLines; o += nthreads) storeLL(sendPtr(i) + o, 0, sendFlag(i));
}

step이NCCL_LL_CLEAN_MASK경계에 도달하면, 전체 slice의 모든 행을 현재 flag로 한 번씩 써야 한다(데이터는 0으로 채움). 왜인가? flag는 순환 재사용되기 때문에, 어떤 행의 이전 flag가 이번 기대값과 우연히 같으면 수신 측이 데이터가 준비된 것으로 오판한다. 이 「cleanup」 작업은 모든 행의 flag를 새 값으로 통일해 모호성을 제거한다.

동시성 제어와 하드웨어 상호작용

LL의 동기화는 전적으로volatile읽기/쓰기 + flag 폴링에 의존하며, 락이 없다.barrier()__syncwarp()(단일 warp일 때) 또는barrier_sync(15 - group, nthreads)(다중 warp일 때)를 사용한다📎 src/device/prims_ll.h:63-69。15 - group

checkAbort는 barrier 번호로, NCCL은 서로 다른 barrier 번호로 서로 다른 group을 격리해 상호 간섭을 피한다.📎 src/device/primitives.h:154-164NCCL_SPINS_BEFORE_CHECK_ABORT는 무한 루프 방지의 핵심이다abortFlag: 매ncclShmem.aborted(10000)번 스핀마다 한 번씩

를 읽어, 잦은 전역 메모리 읽기로 핫 패스가 느려지는 것을 피한다. abort가 발견되면

를 설정하고 캐시하여, 이후 모든 대기 루프가 빠르게 빠져나간다.프로덕션 함정NCCL_LL_CLEAN_MASK함정 1: flag 랩어라운드로 인한 가짜 준비 상태.

만약MaxRecv == 0의 cleanup 로직이 제거되면, 장시간 실행(step이 mask 주기를 초과) 후 수신 측이 이전 라운드의 잔류 flag를 읽어 데이터가 준비된 것으로 오판하고 더티 데이터를 읽을 수 있다. 이런 버그는 step이 특정 값으로 정확히 랩어라운드되는 것에 의존하기 때문에 재현이 극히 어렵다.함정 2:MaxRecv = Fan::MaxRecv > 1 ? Fan::MaxRecv : 1 📎 src/device/prims_ll.h:13의 컴파일 함정.MaxSend코드에서📎 src/device/prims_ll.h:14-19。

, 왜냐하면 송신만 하고 수신하지 않더라도 길이가 MaxRecv인 수신 버퍼를 할당하기 때문에, MaxRecv가 0이면 길이 0 배열 컴파일 실패가 발생한다. Windows에서

도 동일하게 처리한다

LL128: 128바이트 정렬로 더 높은 페이로드를 얻는다직관적 모델LL의痛点은 페이로드가 50%에 불과하다는 것(16바이트 중 8바이트가 flag)이다. LL128의 아이디어는:

flag를 매 128바이트의 마지막 8바이트에 집중시키고, 앞의 120바이트는 전부 데이터로 채우는 것이다

. 이렇게 하면 페이로드가 50%에서 93.75%로 향상된다. 대가는 128바이트 정렬을 반드시 보장해야 하며, 그렇지 않으면 「공유 메모리 재배치」를 해야 한다.uint64_t데이터 구조와 메모리 레이아웃NCCL_LL128_LINEELEMSLL128의 전송 단위는NCCL_LL128_DATAELEMS(8바이트)이지만, 128바이트 「line」으로 구성된다.

📎 src/device/prims_ll128.h:292-294:

cpp
static constexpr int WireWordPerSlice = WARP_SIZE * NCCL_LL128_SHMEM_ELEMS_PER_THREAD;
static constexpr int DataEltPerSlice =
  (WireWordPerSlice - WireWordPerSlice / NCCL_LL128_LINEELEMS) * (sizeof(uint64_t) / sizeof(T));

WireWordPerSliceDataEltPerSlice는 그중 데이터 요소 수(15개)이며, 마지막 요소에 flag를 넣는다.

핵심 상수복사flagThread 📎 src/device/prims_ll128.h:373。flagThread = ((tid % 8) == 7)는 하나의 warp가 한 번에 전송하는 64비트 워드 수이고,

는 그중 유효 데이터 요소 수(각 line마다 flag 요소 하나를 뺀 값)이다.recvReduceSend(inpIx, eltN) → GenericOp<1, 1, Input, -1> → recvReduceSendCopy<NCCL_LL128_SHMEM_ELEMS_PER_THREAD, RECV, SEND, SrcBuf, DstBuf> 📎 src/device/prims_ll128.h:422-423, 296-333。

LL128의 flag 메커니즘은 LL과 다르다: loadRegsBegin매 8개 스레드 중 7번째(📎 src/device/prims_ll128.h:99-142:

  • )만 flag를 검사한다. 왜인가? flag는 매 128바이트마다 하나이고, 하나의 warp에는 32개 스레드가 있으며, 매 8개 스레드가 128바이트를 처리하므로(8스레드 × 16바이트 = 128바이트), 매 8개 스레드 중 1개만 flag를 읽으면 된다.load128시나리오 기반 Walkthrough: 한 번의 recvReduceSendCopyflagThread호출 체인:g % 2 == 0첫 번째 단계: 로컬 데이터를 레지스터에 로드.📎 src/device/prims_ll128.h:109-114。
  • 두 가지 경우로 나뉜다16바이트 정렬ncclScratchForWarp(warpInBlock),__syncwarp(): 직접📎 src/device/prims_ll128.h:115-141。

를 레지스터로, 공유 메모리 중계 없음. 주의 recvReduceSendCopy는 데이터의 절반만 로드한다(📎 src/device/prims_ll128.h:190-207:

cpp
do {
  needReload = false;
  for (int u = 0; u < ELEMS_PER_THREAD; u += 2) {
    load128(ptr + u * WARP_SIZE, vr[u], vr[u + 1]);
    needReload |= flagThread && (vr[u + 1] != flag);
  }
  needReload &= (0 == checkAbort(abort, 1, spins));
} while (__any_sync(WARP_MASK, needReload));

비정렬flagThread: 먼저 정렬된 영역을 공유 메모리__any_sync에 로드한 후 공유 메모리에서 올바른 오프셋으로 레지스터에 다시 읽는다

두 번째 단계: 상대방 데이터를 기다리고 읽기. loadRegsFinish📎 src/device/prims_ll128.h:145-151。주석은 이 설계를 설명한다: 「By deferring register shuffle here we've overlapped spinning on first peer's data with memory loads of src data」— 레지스터 재배치를 대기 이후로 미룸으로써 대기 시간과 로컬 데이터 로딩을 겹치게 한다.

네 번째 단계: reduce 후 전송.데이터를 수신한 후applyReduce 📎 src/device/prims_ll128.h:227-230를 수행하고, 그런 다음store128전송 버퍼에📎 src/device/prims_ll128.h:274-287를 기록한다. 전송 시flagThread ? flag : v[u+1]에 주의 — flagThread는 flag를 쓰고, 다른 스레드들은 데이터를 쓴다.

다섯 번째 단계: step 전진.LL과 달리, LL128의 step 전진은GenericOp끝에서 일괄적으로📎 src/device/prims_ll128.h:324-332를 수행하며,recvReduceSendCopy안에서 하지 않는다. 또한postSend는__threadfence_system()(SM90+) 또는__threadfence() 📎 src/device/prims_ll128.h:87-96를 사용하여 데이터가 다른 GPU/네트워크 카드에 가시화된 후에야 tail 포인터를 업데이트한다.

동시성 제어와 하드웨어 상호작용

LL128의barrier()은 항상barrier_sync(15 - group, nthreads) 📎 src/device/prims_ll128.h:64-66를 사용하며, LL처럼 단일 warp 최적화가 없다. LL128의 데이터 전송은 warp 수준이므로 warp 간 동기화가 필요하기 때문이다.

loadRegsBegin의 공유 메모리 재배치는__syncwarp()로📎 src/device/prims_ll128.h:129를 동기화하여, 모든 스레드가 공유 메모리에 쓴 후에 읽도록 보장한다.

프로덕션 함정

함정 1: 비정렬 접근의 성능 절벽.사용자 버퍼가 16바이트 정렬이 아니면 매번 전송 시 공유 메모리를 경유해야 하며, 성능이 30% 이상 저하될 수 있다. 프로덕션 환경에서는 입출력 버퍼를 16바이트 정렬로 할당해야 한다.

함정 2:flagThread의 레지스터 압박.flagThread는 데이터의 절반만 로드하므로, 레지스터 활용률이 다른 스레드와 다르다. 컴파일러가 레지스터를 올바르게 할당하지 않으면 레지스터가 로컬 메모리로 스필되어 성능이 급락할 수 있다.

Simple: FIFO + 알림으로 대용량 메시지 고처리량 구현

직관적 모델

Simple 프로토콜은 택배함과 같다: 송신측이 FIFO 버퍼(함)에 데이터를 넣고, 「N번 함에 넣었다」는 step 포인터를 업데이트한다(알림 발송). 수신측은 step 포인터를 폴링하다가 새 값을 보면 해당 함에서 물건을 꺼낸다. 핸드셰이크 오버헤드가 크지만(포인터 쓰기 + 포인터 읽기), 한 번에 많은 데이터를 전송할 수 있어 대용량 메시지에 적합하다.

데이터 구조와 메모리 레이아웃

Simple의 필드는 LL/LL128보다 훨씬 복잡하다📎 src/device/prims_simple.h:28-46:

필드타입역할
flagsint비트 플래그, 역할(WaitRecv/WaitSend/PostRecv/PostSend), Direct 모드, NetReg 등을 인코딩
stepuint64_t현재 스텝
connStepPtruint64_t*연결의 상대방 step 포인터를 가리킴
connStepCacheuint64_t마지막으로 읽은 step 값을 캐시
connEltsFifoT*FIFO 버퍼 베이스 주소
connStepSizeint스텝당 바이트 수
directBuffT*Direct 모드에서의 직접 버퍼 포인터

flags의 비트 정의📎 src/device/prims_simple.h:23-27:

cpp
RoleInput = 0x01, RoleOutput = 0x02, RoleWaitRecv = 0x04, RoleWaitSend = 0x08,
RolePostSend = 0x10, RolePostRecv = 0x20, Aborted = 0x40, NetRegMode = 0x80,
ConnFifoEnabled = 0x100, DirectWrite = 0x200, DirectRead = 0x400, PatMode = 0x800,
NvlsMinPolling = 0x1000, NetDeviceUnpack = 0x2000, AnyNetDeviceUnpack = 0x4000,
RoleWaitPatNvls = 0x8000, RolePostPatNvls = 0x10000;

이는 전형적인 「비트 연산으로 여러 bool 필드를 대체」하는 설계로, 레지스터를 절약한다. 각 스레드는 자신의tid에 따라 역할📎 src/device/prims_simple.h:651-666을 부여받는다: 처음nrecv개 스레드는 WaitRecv, 다음nsend개는 WaitSend, 마지막nrecv개는 PostRecv, 끝에서nsend개는 PostSend.

시나리오 기반 Walkthrough: recvReduceSend 한 번

호출 체인:recvReduceSend(inpIx, eltN) → genericOp<0, 0, 1, 1, Input, -1> 📎 src/device/prims_simple.h:994-996。

첫 번째 단계: slice 크기 계산. sliceSize = max(divUp(nelem, 16 * SlicePerChunk) * 16, sliceSize / 32) 📎 src/device/prims_simple.h:185-186. 이 공식은 slice가 최소 16바이트 정렬을 보장하며, 너무 작지 않도록 한다.

두 번째 단계: worker 루프.tid < nworkers인 스레드만 메인 루프에 진입한다📎 src/device/prims_simple.h:190。nworkers = nthreads - (MaxSend > 0 && nthreads >= NCCL_SIMPLE_EXTRA_GROUP_IF_NTHREADS_GE ? WARP_SIZE : 0) 📎 src/device/prims_simple.h:626— 하나의 warp를 threadfence와 copy의 오버랩을 위해 예약한다.

세 번째 단계: 상대방 대기. waitPeer는 핵심📎 src/device/prims_simple.h:103-164:

cpp
while (connStepCache + (isSendNotRecv ? NCCL_STEPS : 0) < step + StepPerSlice) {
  connStepCache = loadStepValue(connStepPtr);
  if (checkAbort(flags, Aborted, spins)) break;
}

isSendNotRecv는 송신과 수신을 구분한다: 송신 시에는 「상대방이 소비 완료」(head)를 기다리고, 수신 시에는 「상대방이 생산 완료」(tail)를 기다린다.NCCL_STEPS는 버퍼 슬롯 수,StepPerSlice는 slice당 스텝 수.

대기 완료 후, Direct 모드에 따라ptrs[index] 📎 src/device/prims_simple.h:123-158를 설정한다. Direct 모드는 상대방 버퍼를 직접 읽고 쓸 수 있어 FIFO를 우회하고 복사 한 번을 줄인다.

네 번째 단계: reduceCopy.Direct 조합에 따라 다른reduceCopy를 호출하여📎 src/device/prims_simple.h:241-277를 호출한다. 가장 복잡한 분기는srcs[0] && dsts[0]가 모두 존재할 때📎 src/device/prims_simple.h:258-271로,reduceCopy<Unroll, RedOp, T, MultimemSrcs, Recv+Src, Recv*MaxRecv+Src, MultimemDsts, Send+Dst, Send*MaxSend+Dst, PreOpSrcs>를 호출하며, 매개변수 의미는:Recv*MaxRecv+Src개 소스에서 읽어, 리듀스 후Send*MaxSend+Dst개 목적지에 쓴다.

다섯 번째 단계: postPeer. postPeerstep 포인터 업데이트📎 src/device/prims_simple.h:167-175:

cpp
if (Send && (flags & RolePostSend) && (dataStored || (flags & ConnFifoEnabled))) {
  fence_acq_rel_sys();
}
st_relaxed_sys_global(connStepPtr, step);

송신측은 step 업데이트 전에fence_acq_rel_sys()를 해야 하며, 데이터 쓰기가 다른 GPU/네트워크 카드에 가시화되도록 보장한다. 수신측은 fence가 필요 없는데, 수신측은 「소비 완료」만 알릴 뿐 데이터 가시성과는 무관하기 때문이다.

동시성 제어와 하드웨어 상호작용

Simple의 동기화는st_relaxed_sys_global로 step 포인터를 쓰고,📎 src/device/prims_simple.h:167-175로 읽으며,loadStepValue는 SM90+에서📎 src/device/prims_simple.h:86-100。loadStepValue가 활성화된 경우NvlsMinPolling명령을 사용한다multimem.ld_reduce.acquire.sys.global.min.u64, 이는 NVLink SHARP의 하드웨어 가속 폴링이다.📎 src/device/prims_simple.h:86-100와

barrier()의 차이subBarrier()는 모든📎 src/device/prims_simple.h:49-55:barrier()개 스레드를 동기화하고,nthreads는subBarrier()개 worker 스레드만 동기화한다.nworkers의 barrier 번호는subBarrier이며, worker 수가 전체 스레드 수와 다를 때 다른 barrier를 사용하여15 - group - (nworkers != nthreads ? 1 : 0)와의 충돌을 피한다.barrier()프로덕션 함정

함정 1: NetRegMode에서의 소멸 대기.

소멸자에 특별한 로직이 있다복사📎 src/device/prims_simple.h:794-804:

cpp
if ((flags & NetRegMode) && (flags & RoleWaitSend)) {
  uint64_t prevStep = step - StepPerSlice;
  volatile ssize_t* ptr = &(connFifo[prevStep % NCCL_STEPS].size);
  while (*ptr != -1) { ... }
}

NetRegMode에서는 송신 버퍼가 NIC에 의해 직접 접근되므로, proxy 스레드가 전송 완료(size가 -1로 설정됨)를 확인할 때까지 반환해서는 안 됩니다. 그렇지 않으면 다음 kernel이 NIC가 읽고 있는 데이터를 덮어쓸 수 있습니다.

함정 2: DirectRead의 sendrecv 데드락.소멸자에 또 한 부분이 있습니다📎 src/device/prims_simple.h:814-824:

cpp
if ((flags & DirectRead) && (flags & RoleWaitSend) && P2p) {
  while (*tail > *head) { ... }
}

sendrecv의 DirectRead 모드에서 송신자는 수신자가 데이터를 다 읽을 때까지 기다려야 반환할 수 있습니다. 수신자가 어떤 이유로 tail을 진행시키지 않으면 송신자는 데드락에 빠집니다. 이 대기는barrier()이후에 수행해야 합니다. 그렇지 않으면 post 스레드와 경쟁할 수 있습니다.

함정 3:roundUp으로 인한 step 점프. loadRecvConn과loadSendConn에 모두step = roundUp(step, SlicePerChunk * StepPerSlice) 📎 src/device/prims_simple.h:486, 533이 있습니다. 이는 step을 slice 경계에 정렬하지만, 이전 단계의 step이 정렬되지 않은 경우 건너뛴 슬롯이 올바르게 초기화되지 않을 수 있습니다. 코드는loadRecvConn에*connStepPtr = step를 추가하여 credit을 반환합니다📎 src/device/prims_simple.h:489。

세 가지 프리미티브의 비교와 선택

mermaid
flowchart LR
    subgraph LL["LL 协议"]
        ll_data["ncclLLFifoLine 16B<br/>data1(4B)+flag(4B)+data2(4B)+flag(4B)"]
        ll_sync["flag 内嵌数据行<br/>轮询 flag 匹配"]
    end
    subgraph LL128["LL128 协议"]
        ll128_data["128B line<br/>15×8B data + 1×8B flag"]
        ll128_sync["flagThread 每8线程1个<br/>__any_sync 投票"]
    end
    subgraph Simple["Simple 协议"]
        simple_data["FIFO 缓冲区<br/>connEltsFifo + step*connStepSize"]
        simple_sync["step 指针 + fence<br/>loadStepValue 轮询"]
    end
    ll_data --> ll_sync
    ll128_data --> ll128_sync
    simple_data --> simple_sync
차원LLLL128Simple
유효 페이로드 비율50%93.75%~100%
동기화 방식flag 내장, 폴링flagThread + warp 투표step 포인터 + fence
정렬 요구사항없음(시프트 재조립 있음)16 바이트없음
적용 가능한 메시지 크기작음(< 8KB)중간(8KB ~ 128KB)큼(> 128KB)
버퍼 레이아웃ncclLLFifoLine[]uint64_t[]128B line 기준T[] FIFO
Direct 지원없음(PrimitivesWithoutDirect로 강등)없음(동일)완전 지원

LL과 LL128은 모두PrimitivesWithoutDirect 📎 src/device/prims_ll.h:9-10, src/device/prims_ll128.h:13-14를 상속받습니다. 이들의 버퍼 레이아웃이 상대방 메모리를 직접 읽고 쓰는 것을 지원하지 않기 때문입니다. Simple은 Direct 모드를 완전히 구현하여 P2P 직접 연결과 NVLS를 지원합니다.

설계 고찰

〔설계 추론과 아키텍처 트레이드오프〕

LL의 flag를 왜 두 번 반복할까?GPU의 전역 메모리 쓰기는 원자성을 보장하지 않기 때문입니다.storeLL16 바이트를 쓸 때 하드웨어가 두 번의 8 바이트 쓰기로 나눌 수 있습니다. flag를 하나만 두면 수신자가 데이터가 절반만 쓰였는데도 준비되었다고 판단할 수 있습니다. 두 flag는 각각 16 바이트의 전반부와 후반부에 위치하여, 두 번의 쓰기가 모두 완료되어야 두 flag가 모두 일치합니다.

Simple은 왜 warp 하나를 예약할까? 📎 src/device/prims_simple.h:625-626주석에 「For send operations, we need an extra warp to overlap the threadfence and the copy」라고 되어 있습니다.fence_acq_rel_sys()은 비용이 큰 연산입니다. 모든 스레드가 fence 완료를 기다리면 많은 시간이 낭비됩니다. warp 하나를 fence 전용으로 예약하면 다른 warp는 다음 데이터 배치를 계속 옮길 수 있습니다.

〔설계 추론과 아키텍처 트레이드오프〕

LL128의 step 진행이 왜 recvReduceSendCopy가 아니라 GenericOp 끝에서 이루어질까?LL128의 전송은 warp 단위이므로 여러 warp가 서로 다른 slice를 병렬로 처리할 수 있습니다.recvReduceSendCopy에서 step을 진행하면 각 warp가 한 번씩 진행시켜 step이 여러 번 진행됩니다.GenericOp끝에서 통합 진행하면 각 slice가 한 번만 진행됩니다.

이 장의 요약

이 장에서는 세 가지 전송 프리미티브의 구현을 깊이 살펴보았습니다:

1. LL: 16 바이트ncclLLFifoLine로 flag를 데이터 행에 내장하여, 수신자가 flag 일치를 폴링하면 데이터 준비를 확인할 수 있습니다. 유효 페이로드 50%, 작은 메시지에 적합합니다. 핵심은readLL의ld.volatile.global.v4.u32과storeLL의st.volatile.global.v4.u32。

2. LL128: flag를 128 바이트마다 마지막 8 바이트에 집중시켜 유효 페이로드를 93.75%로 높였습니다.flagThread(8 스레드당 1개)로 flag를 검사하고,__any_sync로 warp 투표를 합니다. 비정렬 시 공유 메모리 재배치를 거칩니다.

3. Simple: FIFO 버퍼 + step 포인터 알림으로 큰 메시지의 높은 처리량을 구현합니다.flags비트 플래그로 역할을 인코딩하고,waitPeer로 step을 폴링하며,postPeer로 step을 갱신하고 fence합니다. Direct 모드를 완전히 지원합니다.

세 프리미티브는 동일한 템플릿 골격을 공유하며,Proto템플릿 파라미터로 특수화됩니다. 알고리즘 계층은 통합 인터페이스만 호출하고 하위 프로토콜은 신경 쓰지 않습니다. 이것이 「동일한 AllReduce 로직에 왜 세 가지 전송 프리미티브가 필요한가」에 대한 답입니다: 메시지 크기마다 다른 동기화 전략과 버퍼 레이아웃이 필요하며, 세 프리미티브는 각각 작은, 중간, 큰 메시지에 최적화되어 있습니다.

이 장의 생각과 자가 점검

Q1: 만약incSend의 cleanup 로직(📎 src/device/prims_ll.h:99-106)을 제거하면 어떤 시나리오에서 데이터 손상이 발생할까? 왜일까?

참고 해설: cleanup 로직은sendStep[i] & NCCL_LL_CLEAN_MASK == NCCL_LL_CLEAN_MASK시 slice의 모든 행을 현재 flag로 한 번씩 씁니다(데이터는 0으로 채움). 제거하면 step이NCCL_LL_CLEAN_MASK경계로 되돌아갈 때 일부 행의 flag가 이전 라운드 값일 수 있습니다. 이전 라운드의 flag가 이번 라운드 수신자가 기대하는 flag와 일치하면, 수신자는 데이터가 준비된 것으로 오인하여 이전 라운드의 잔여 데이터를 읽습니다. 이는 전형적인 ABA 문제입니다. 발생 조건은 장시간 실행(step이NCCL_LL_CLEAN_MASK주기를 초과) 중 flag가 정확히 같은 값으로 되돌아갈 때입니다. 이런 버그는 정확한 step 정렬이 필요하기 때문에 재현이 극히 어렵습니다.

Q2: Simple 프로토콜의 소멸자에서 NetRegMode下的 대기(📎 src/device/prims_simple.h:794-804)와 DirectRead下的 대기(📎 src/device/prims_simple.h:814-824)는 각각 무엇을 방지하는가? 둘 중 하나를 제거하면 고동시성 시나리오에서 무슨 일이 발생하는가?

참고 해석: NetRegMode는 proxy 스레드가connFifo[prevStep].size를 -1로 설정하는 것을 기다리며, 이는 NIC가 전송을 완료했음을 나타낸다. 이를 제거하면 다음 kernel이 NIC DMA가 읽고 있는 전송 버퍼를 덮어써서 NIC가 오염된 데이터를 읽을 수 있다. DirectRead는 수신자가 tail(*tail > *head)을 전진시키는 것을 기다리며, 이는 수신자가 직접 버퍼 읽기를 완료했음을 나타낸다. 이를 제거하면 송신자가 수신자가 아직 읽지 않은 상태에서 버퍼를 덮어써서 수신자가 이전 데이터 대신 새 데이터를 읽을 수 있다. 고동시성 시나리오에서 이 두 대기는 모두 필수적이며, 어느 하나라도 제거하면 데이터 경쟁이 발생한다. 차이점은 NetRegMode는 'NIC 읽기'를 방지하고, DirectRead는 '상대방 GPU 읽기'를 방지한다는 것이다.

Q3: LL128의loadRegsBegin가 비정렬 시 공유 메모리 재배열(📎 src/device/prims_ll128.h:115-141)을 거치는데, 이 경로는 정렬 경로보다 얼마나 느린가? 왜 NCCL은 사용자 버퍼가 반드시 16바이트 정렬이어야 한다고 직접 요구하지 않는가?

참고 해석: 비정렬 경로는 세 단계가 추가된다: 공유 메모리 쓰기,__syncwarp(), 공유 메모리 읽기. 공유 메모리 대역폭은 높지만__syncwarp()는 동기화 지점으로, 모든 스레드가 쓰기를 완료할 때까지 warp를 차단한다. 대략적으로 비정렬 경로는 정렬 경로보다 20-40% 느리며, 구체적으로는 공유 메모리 bank 충돌 상황에 따라 다르다. NCCL이 정렬을 강제하지 않는 이유는 사용자가 임의 오프셋의 버퍼(예: tensor 슬라이스)를 전달할 수 있기 때문이며, 정렬을 강제하면 API의 유연성이 제한된다. NCCL의 전략은 '정렬 시 빠른 경로, 비정렬 시 느린 경로지만 정확성 보장'이다. 프로덕션 환경에서는 사용자가 가능한 한 16바이트 정렬로 버퍼를 할당하여 빠른 경로를 타도록 권장한다.

여기까지 우리는 LL, LL128, Simple 세 가지 원시의 데이터 전송 메커니즘을 파악했으며, 이들은 상위 알고리즘에 유연한 성능 조절 수단을 제공한다. 다음 장에서는 집합 통신 알고리즘 커널을 깊이 살펴보며, AllReduce, AllGather, ReduceScatter 등이 이러한 원시를 어떻게 호출하는지, 그리고 Ring, Tree, CollNet 등의 알고리즘이 데이터 흐름을 어떻게 조직하여 최종적으로 종단 간 집합 통신을 완성하는지 알아본다.

CHAPTER 10

제 10 장: 제 10 장: 집합 통신 알고리즘 커널: AllReduce, AllGather, ReduceScatter의 디바이스 측 구현

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 10 / 25 장

제 10 장: 집합 통신 알고리즘 커널: AllReduce, AllGather, ReduceScatter의 디바이스 측 구현

이전 장에서는 LL, LL128, Simple 세 가지 프로토콜 원시를 분석했는데, 이들은 데이터 전송의 '엔진'이지만 엔진 자체는 무엇을, 어디로, 어떤 순서로 옮길지 모른다. 이번 장에서 살펴볼 src/device 아래의 알고리즘 커널 파일들은 '변속기'로, AllReduce, AllGather, ReduceScatter 같은 집합 통신 의미를 prims.directSend, prims.directRecvReduceDirectSend 같은 원시 호출의 연속으로 번역한다. 한 문장으로 이번 장의 핵심 모순을 요약하면: 동일한 AllReduce에 왜 Ring, Tree, CollNet, NVLS 네 가지 완전히 다른 디바이스 측 구현이 필요한가? 답은 '데이터 흐름 토폴로지'와 '하드웨어 능력'의 매칭에 숨어 있다. Ring은 최소한의 네트워크 대역폭으로 2단계 파이프라인을 수행하고, Tree는 트리형 리덕션으로 지연을 log(n)까지 압축하며, CollNet/NVLS는 리덕션을 NIC나 NVLink 스위치에 오프로드한다. 이번 장에서 하나씩 풀어본다.

10.1 Ring AllReduce: 2단계 파이프라인이 kernel 내에서 어떻게 구현되는가

직관적 모델: 링 컨베이어 벨트 위의 '릴레이 경주'

n명의 작업자가 원형으로 서 있고, 각자 원료 상자를 하나씩 들고 있다고 상상하자. AllReduce의 목표는 모든 사람이 최종적으로 '모든 원료가 혼합된 완성품'을 받는 것이다. Ring 알고리즘의 방식은 두 단계로 나뉜다: 첫 번째 단계(reduce-scatter)에서는 각자가 상자를 링을 따라 전달하고, 한 역을 지날 때마다 자신의 원료를 혼합하여 n-1 역을 돈 후 각자 손에 정확히 '완전 혼합'된 완성품 한 부를 갖게 되지만, 1/n의 몫만 가진다; 두 번째 단계(all-gather)에서는 이 완성품 몫들이 다시 링을 따라 한 바퀴 돌아 각자가 모든 몫을 채운다.

Ring이 없다면 가장 순진한 방법은 각 rank가 데이터를 root에 보내고, root가 리덕션 후 브로드캐스트하는 것이다——root의 네트워크 대역폭이 병목이 되어 n이 클수록 느려진다. Ring의 정묘함은:각 rank의 송신량과 수신량은 모두 2(n-1)/n배의 데이터량이며, n과 무관하게 모든 링크에 균등하게 분산된다。

데이터 구조와 메모리 레이아웃

Ring 알고리즘의 핵심 상태는ncclRing구조에 있으며(device.h에 정의됨, 이 장에서는 다루지 않음),runRing그중 두 개의 필드만 사용한다:

  • ring->index: 링에서 본 rank의 논리적 위치로, "j번째 단계에서 어떤 chunk를 처리해야 하는가"를 계산하는 데 사용된다.
  • ring->prev / ring->next: 전임 및 후임 rank 번호로,Primitives생성자의 recv/send peer 파라미터로 사용된다.

핵심적인 분할 파라미터는ncclCollCbdPart에 의해 계산된다(📎 src/device/all_reduce.h:21-22):

code
ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);

이 함수는 전체 통신 도메인의 데이터를 channel별로 분할하고 세 가지 값을 출력한다:gridOffset(본 channel이 담당하는 데이터의 전체 buffer 내 시작 오프셋),channelCount(본 channel이 담당하는 총 요소 수),chunkCount(각 rank에 할당되는 chunk 요소 수).chunkCount은 Ring 알고리즘의 입도(granularity)이다 — 매 단계마다 하나의 chunk를 전송한다.

loopCount = nranks * chunkCount(📎 src/device/all_reduce.h:23)는 "한 바퀴 전체를 도는" 처리 데이터량을 나타낸다. 외부 루프for (elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount)(📎 src/device/all_reduce.h:34)는 다음을 의미한다: channel 데이터량이 한 바퀴로 처리할 수 있는 양을 초과하면 여러 바퀴로 나누어 실행한다.

단계별 분석: Ring AllReduce의 전체 호출 흐름

시나리오 대입: 4개의 rank(nranks=4), 본 rank의ringIx=0,chunkCount=100,channelCount=400(정확히 한 바퀴).

0단계: "자신의 chunk"를 다음 GPU로 전송(📎 src/device/all_reduce.h:42-47)

code
chunk = modRanks(ringIx + nranks - 1);   // = 3
chunkOffset = chunk * chunkCount;         // = 300
offset = gridOffset + elemOffset + chunkOffset;
nelem = min(chunkCount, remCount - chunkOffset);
prims.directSend(offset, offset, nelem);

modRanks은 lambda로, nranks에 대한 모듈로 뺄셈을 수행한다(📎 src/device/all_reduce.h:40)。ringIx + nranks - 1은 "본 rank의 이전 chunk 번호"를 나타낸다. 왜 0단계에서 chunk 3을 보내는가? Ring의 reduce-scatter 단계에서 각 rank는 먼저 자신이 "보유해서는 안 되는" 데이터(즉 전임 rank의 chunk)를 전송하기 때문이다.directSend은 송신만 하고 수신하지 않는다. 이 시점에는 아직 어떤 데이터도 받지 못했기 때문이다.

1단계부터 nranks-2단계까지: 수신하면서 리듀스하고 전달(📎 src/device/all_reduce.h:50-56)

code
for (int j = 2; j < nranks; ++j) {
  chunk = modRanks(ringIx + nranks - j);
  ...
  prims.directRecvReduceDirectSend(offset, offset, nelem);
}

directRecvReduceDirectSend은 Ring의 핵심 프리미티브이다:prev에서 chunk를 하나 수신하여 로컬 데이터와 리듀스(예: 덧셈)한 후, 결과를next에게 전송한다. 주의할 점은offset과nelem이 매 반복마다 재계산된다는 것이다 — 매 단계마다 처리하는 chunk가 다르기 때문이다. j는 2부터 nranks-1까지, 총 nranks-2단계이다.

nranks-1단계: 마지막 chunk를 수신하고 리듀스하여 최종 결과 생성(📎 src/device/all_reduce.h:58-64)

code
chunk = ringIx + 0;
...
prims.directRecvReduceCopyDirectSend(offset, offset, nelem, /*postOp=*/true);

이 단계의postOp=true이 핵심이다: 리듀스 완료 후 후처리 연산(예: 평균 계산 시 나눗셈)을 수행해야 한다.directRecvReduceCopyDirectSend은 이전 단계보다Copy이 하나 더 있다 — 리듀스 결과를 로컬 recvbuff와 next로 전송할 데이터에 동시에 기록한다. 이로써 reduce-scatter 단계가 끝나고, 각 rank는 "완전히 리듀스된" chunk 하나를 보유하게 된다.

all-gather 단계: nranks-2단계의 순수 전달(📎 src/device/all_reduce.h:66-73)

code
for (int j = 1; j < nranks - 1; ++j) {
  chunk = modRanks(ringIx + nranks - j);
  ...
  prims.directRecvCopyDirectSend(offset, offset, nelem);
}

여기서 사용되는 것은directRecvCopyDirectSend이며,Reduce이 없다 — 데이터가 이미 리듀스되었으므로 복사 전달만 하면 되기 때문이다.

마지막 단계: 마지막 chunk 수신(📎 src/device/all_reduce.h:75-81)

code
chunk = modRanks(ringIx + 1);
...
prims.directRecv(offset, nelem);

수신만 하고 송신하지 않으며, 마지막 조각을 채운다.

전체 흐름은 아래 제어 흐름도로 요약할 수 있다:

mermaid
flowchart TD
    start["runRing 入口<br/>计算 chunkCount/loopCount"] --> loop{"elemOffset < channelCount?"}
    loop -->|否| done["返回"]
    loop -->|是| s0["step 0: directSend<br/>chunk = ringIx-1"]
    s0 --> mid{"j 从 2 到 nranks-1?"}
    mid -->|是| s1["directRecvReduceDirectSend<br/>chunk = ringIx-j"]
    s1 --> mid
    mid -->|否| s2["step nranks-1<br/>directRecvReduceCopyDirectSend<br/>postOp=true"]
    s2 --> ag{"j 从 1 到 nranks-2?"}
    ag -->|是| s3["directRecvCopyDirectSend<br/>纯转发"]
    s3 --> ag
    ag -->|否| s4["directRecv<br/>收最后一块"]
    s4 --> loop

설계 고찰: 왜 Ring의 chunk 순서는 "거꾸로 가는"가

chunk 번호의 규칙에 주목하라: 0단계에서ringIx-1을 보내고, j단계에서ringIx-j을 처리하며, 마지막 단계에서ringIx+0을 처리한다. 이것은반시계 방향진행이다. 왜인가? Ring의 각 rank는 "자신이 리듀스를 담당하는 chunk"(즉ringIx+0)만 보유하고, 나머지 chunk는 모두 지나가기 때문이다. 반시계 방향 진행은 다음을 보장한다: 어떤 chunk가 한 바퀴를 돌아 시작점으로 돌아왔을 때, 정확히 nranks번의 리듀스가 완료되어 최종 결과가 생성된다. 만약 시계 방향으로 진행하면, chunk는 잘못된 rank에서 리듀스가 완료된다.

프로덕션 함정:remCount < loopCount일 때의 정렬 트랩

📎 src/device/all_reduce.h:38에는 간과하기 쉬운 코드 한 줄이 있다:

code
if (remCount < loopCount) chunkCount = alignUp(divUp(remCount, nranks), 16 / sizeof(T));

남은 데이터가 한 바퀴에 미치지 못할 때, chunkCount를 재계산하고alignUp(..., 16/sizeof(T))이 16바이트 정렬을 강제한다. 왜인가? LL128 프로토콜은 128바이트 정렬을 요구하고, Simple 프로토콜도 벡터화된 접근을 위한 정렬 요구사항이 있기 때문이다. 이 정렬을 제거하면 비정렬 chunk가 느린 경로를 타게 되어 성능이 20-40% 저하된다. 프로덕션 환경에서 Ring AllReduce의 소형 메시지 꼬리 부분에서 성능 변동이 발견되면, 대개 이 정렬이 적용되지 않은 것이다 —channelCount이nranks * 16/sizeof(T)의 정수배인지 확인하라.

10.2 Tree AllReduce: 트리형 리듀스로 지연을 log(n)까지 압축

직관적 모델: 회사의 "단계별 보고"

Ring의 지연은 O(n)이다 — 데이터가 한 바퀴를 돌아야 한다. n이 매우 클 때(예: 1024개 GPU), 대역폭이 균등하게 분산되더라도 지연을 감당할 수 없다. Tree 알고리즘은 다른 접근을 취한다: 회사 조직도처럼 각 rank가 "부모 노드"와 "자식 노드"하고만 통신한다. 리듀스 단계에서는 리프 노드가 데이터를 위로 보고하고, 부모 노드가 자식 노드의 데이터를 병합한다; 브로드캐스트 단계에서는 반대로 루트 노드가 결과를 아래로 전송한다. 지연이 O(n)에서 O(log n)으로 감소한다.

Tree가 없으면 대규모 클러스터의 AllReduce 지연이 rank 수에 따라 선형으로 증가하고, 학습 반복 시간이 통신에 의해 발목 잡힌다.

데이터 구조와 메모리 레이아웃

Tree의 상태는ncclTree에 있다:

  • tree->up: 부모 노드 rank (-1은 이 rank가 루트임을 나타냄).
  • tree->down[]: 자식 노드 배열, 최대NCCL_MAX_TREE_ARITY개 (일반적으로 3, 즉 이진+로컬).

runTreeUpDown과runTreeSplit은 두 가지 변형이다. 전자는 「먼저 전부 reduce한 후 전부 broadcast」하는 2단계 모드를 사용하고, 후자는 스레드를 반으로 나누어 절반은 reduce, 절반은 broadcast를 수행하여 파이프라인 오버랩을 구현한다.

Step-by-Step Walkthrough: runTreeUpDown의 세 가지 분기

runTreeUpDown의 첫 번째 코드 블록은 reduce 단계(📎 src/device/all_reduce.h:96-118)로, 이 rank의 트리 내 위치에 따라 세 가지 경우로 나뉜다:

경우 A: 이 rank가 루트인 경우(tree->up == -1)(📎 src/device/all_reduce.h:99-104)

code
prims.directRecvReduceCopy(offset, offset, nelem, /*postOp=*/true);

루트 노드는 받기만 하고 보내지 않으며, 모든 자식 노드로부터 데이터를 받아 reduce하고 recvbuff에 쓴다.postOp=true이 후처리 작업을 실행한다.

경우 B: 이 rank가 리프인 경우(tree->down[0] == -1)(📎 src/device/all_reduce.h:105-110)

code
prims.directSend(offset, offset, nelem);

리프 노드는 보내기만 하고 받지 않으며, 자신의 데이터를 부모 노드로 보낸다.

경우 C: 중간 노드(📎 src/device/all_reduce.h:111-117)

code
prims.directRecvReduceDirectSend(offset, offset, nelem);

자식으로부터 받아 reduce하고 부모로 보낸다.

broadcast 단계(📎 src/device/all_reduce.h:120-142)는 논리가 대칭적이다: 루트 노드directSendFromOutput(recvbuff에서 보냄), 리프 노드directRecv, 중간 노드directRecvCopyDirectSend。

runTreeSplit: 스레드 분할로 reduce-broadcast 파이프라인 구현

runTreeUpDown의 문제는 reduce 단계와 broadcast 단계가 직렬로 진행되며 중간에 전역 동기화 지점이 있다는 것이다.runTreeSplit은 스레드를 두 그룹으로 나눈다(📎 src/device/all_reduce.h:155-164):

code
if (Proto::Id == NCCL_PROTO_SIMPLE) {
  nthreadsSplit = nthreads / 2;
  if (nthreadsSplit >= 256) nthreadsSplit += 64;
} else {
  nthreadsSplit = (nthreads * 7 / (10 * WARP_SIZE)) * WARP_SIZE;
}

Simple 프로토콜은 반반으로 나누고, LL/LL128 프로토콜은 7:3으로 나누는데, 이는 「3개의 소스에서 데이터를 받아 reduce하는 것」이 「3개의 대상에게 보내는 것」보다 계산 집약적이기 때문에 reduce 그룹에 더 많은 스레드를 할당한다.

그런 다음tid < nthreadsSplit의 스레드는 reduce 상향 전파(📎 src/device/all_reduce.h:175-202)를, 나머지 스레드는 broadcast 하향 전파(📎 src/device/all_reduce.h:203-224)를 수행한다. 두 그룹은Proto::MaxGroupWidth오프셋으로 각자의 통신 그룹(📎 src/device/all_reduce.h:189의0 * Proto::MaxGroupWidth과📎 src/device/all_reduce.h:210의1 * Proto::MaxGroupWidth)。

설계 사고: Tree의 루트 노드를 왜 특별 처리해야 하는가

트리 reduce의 루트 노드는 「집결점」으로, 수신량이 자식 노드 수의 배수이고 송신량은 0이다(reduce 단계). 만약 루트 노드도 일반적인directRecvReduceDirectSend를 따르면tree->up(-1)로 보내려고 시도하여 범위를 벗어난다. 따라서 반드시if (tree->up == -1)분기로 별도 처리해야 한다. 마찬가지로 리프 노드의tree->down[0] == -1판단도 마찬가지다.

프로덕션 함정: Tree 알고리즘의 「핫스팟 루트」 문제

Tree의 루트 노드는 모든 reduce 트래픽을 담당하는데, 만약 루트 노드가 위치한 GPU가 마침 느린 노드라면(예: PCIe 대역폭 제한), 전체 AllReduce가 느려진다. NCCL의 대응은:각 channel이 서로 다른 루트를 선택하여 루트 노드의 부하를 여러 rank에 분산시키는 것이다. 이것이runTreeSplit에서 루트 노드 분기가FanSymmetric<NCCL_MAX_TREE_ARITY_TOP>(📎 src/device/all_reduce.h:168)를 사용하는 이유다 — 동시에 여러 자식 노드의 reduce를 처리해야 하기 때문이다. 프로덕션 환경에서 Tree AllReduce 성능이 불균일하다면 channel의 루트 노드 분포가 균일한지 확인하라.

10.3 AllGather와 ReduceScatter: Ring의 「절반」 변형

직관적 모델: AllReduce를 둘로 쪼개기

AllGather와 ReduceScatter는 본질적으로 AllReduce의 두 단계를 각각 독립 API로 만든 것이다. AllGather는 「수집」만 수행한다 — 각 rank가 데이터를 하나씩 기여하고, 최종적으로 모든 사람이 전체 데이터를 받는다. ReduceScatter는 「reduce+분산」만 수행한다 — 모든 사람이 데이터를 기여하고, reduce 후 각자 한 조각을 받는다.

이 두 독립 API가 없다면, 사용자가 「먼저 reduce 후 수집」 또는 「먼저 수집 후 reduce」를 할 때 AllReduce를 호출한 뒤 수동으로 슬라이스해야 하므로 대역폭의 절반을 낭비한다.

AllGather의 Ring 구현

all_gather.h의runRing(📎 src/device/all_gather.h:14-88)는 AllReduce보다 간단하다: reduce 없이 복사-전달만 한다.

0단계: 자신의 데이터를 다음 GPU로 푸시(📎 src/device/all_gather.h:51-60)

code
rankDest = ringRanks[0];
offset = dataOffset + rankDest * count;
if ((inputBuf + dataOffset == outputBuf + offset) || isNetOffload) {
  prims.directSend(dataOffset, offset, nelem);
} else {
  prims.directCopySend(dataOffset, offset, nelem);
}

여기에 in-place 판단이 있다: 만약inputBuf + dataOffset == outputBuf + offset이면 입력과 출력이 같은 메모리(in-place AllGather)이므로 직접directSend하고, 그렇지 않으면directCopySend해야 한다(먼저 출력으로 복사한 후 전송).

중간 nranks-2 단계: 순수 전달(📎 src/device/all_gather.h:62-67)

code
prims.directRecvCopyDirectSend(offset, offset, nelem);

마지막 단계: 마지막 조각 받기(📎 src/device/all_gather.h:69-74)

code
prims.directRecv(offset, nelem);

isNetOffload: 단일 warp로 네트워크 구동 + 다중 warp 병렬 복사

📎 src/device/all_gather.h:28-36에 특수 분기가 있다:

code
if (isNetOffload) {
  workNthreads = WARP_SIZE;
  chunkCount = NCCL_MAX_NET_SIZE;
} else {
  workNthreads = nthreads;
}

만약isNetOffload=true(단일 RPN + 네트워크 등록 모드)이면, 1개의 warp만 Ring 통신을 구동하고 나머지 warp는 병렬로 「소스 데이터를 대상 buffer로 복사」(📎 src/device/all_gather.h:76-82)한다. 이는 비 in-place AllGather 시 복사 오버헤드와 통신 오버헤드를 오버랩하기 위함이다.

마지막에barrier_sync(14, nthreads)(📎 src/device/all_gather.h:87)가 있는데, 주석에 명확히 설명되어 있다: 모든 warp가 완료될 때까지 기다려야 하며, 그렇지 않으면 다음 work가 outputBuf를 재사용하여 경쟁이 발생할 수 있다. barrier 14를 사용하는 것은 prims 자체의 barrier와__syncthreads()。

ReduceScatter의 Ring 구현

reduce_scatter.h의runRing(📎 src/device/reduce_scatter.h:14-56)는 AllReduce의 reduce-scatter 단계를 별도로 추출한 것이다:

0단계: 자신의 데이터를 다음 GPU로 푸시(📎 src/device/reduce_scatter.h:39-42)

code
rankDest = ringRanks[nranks - 1];
offset = dataOffset + rankDest * count;
prims.send(offset, nelem);

중간 nranks-2 단계: 받으면서 reduce하고 전달(📎 src/device/reduce_scatter.h:44-49)

code
prims.recvReduceSend(offset, nelem);

마지막 단계: 받아서 reduce하여 최종 결과 생성(📎 src/device/reduce_scatter.h:61-64)

code
prims.recvReduceCopy(offset, dataOffset, nelem, /*postOp=*/true);

마지막 단계의recvReduceCopy두 개의 offset이 있습니다:offset(수신 소스)와dataOffset(로컬 입력), 리덕션 결과는dataOffset。

데이터 흐름 비교 다이어그램

mermaid
flowchart LR
    subgraph AllReduce["AllReduce (两阶段)"]
        A1["reduce-scatter<br/>n-1 步"] --> A2["all-gather<br/>n-1 步"]
    end
    subgraph AG["AllGather (单阶段)"]
        B1["directSend<br/>step 0"] --> B2["directRecvCopyDirectSend<br/>n-2 步"] --> B3["directRecv<br/>step n-1"]
    end
    subgraph RS["ReduceScatter (单阶段)"]
        C1["send<br/>step 0"] --> C2["recvReduceSend<br/>n-2 步"] --> C3["recvReduceCopy<br/>step n-1"]
    end
    AllReduce -.->|"拆解"| AG
    AllReduce -.->|"拆解"| RS

프로덕션 함정: in-place 판단의 경계

📎 src/device/all_gather.h:55의 in-place 판단inputBuf + dataOffset == outputBuf + offset은 포인터가 정확히 일치하는지에 의존합니다. 사용자가 전달한 sendbuff와 recvbuff가 오프셋이 있지만 논리적으로 같은 메모리 블록인 경우, 이 판단이 무효화되어directCopySend경로를 타게 됩니다 — 올바르지만 복사가 한 번 더 발생합니다. 프로덕션 환경에서는 in-place AllGather 시 sendbuff와 recvbuff가 완전히 일치하는지 확인하는 것이 좋습니다.

10.4 CollNet과 NVLS: 리덕션을 하드웨어로 오프로드

직관적 모델: '스위치'가 계산을 돕게 하기

Ring과 Tree는 모두 'GPU가 직접 리덕션을 계산'합니다. CollNet과 NVLS는 다른 접근을 취합니다: 리덕션 연산을 NIC(CollNet) 또는 NVLink 스위치(NVLS)로 오프로드합니다. GPU는 데이터를 보내기만 하고, 하드웨어가 리덕션을 완료한 후 다시 브로드캐스트합니다. 이는 '각 작업자가 직접 원료를 혼합'하는 것에서 '원료를 중앙 믹서로 보내고, 믹서가 혼합한 후 분배'하는 것으로 바뀌는 것과 같습니다.

하드웨어 오프로드가 없으면 리덕션 연산이 GPU의 SM 리소스를 차지하고, 리덕션 지연을 숨길 수 없습니다.

CollNet Direct의 스레드 분담

RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_COLLNET_DIRECT, ...>의run(📎 src/device/all_reduce.h:249-386)는 스레드를 네 그룹으로 나눕니다:

code
const int nThreadsScatter = WARP_SIZE + ((hasUp && hasDn) ? COLLNET_COPY_THREADS : ...);
const int nThreadsGather = ((hasUp && hasDn) ? COLLNET_COPY_THREADS : ...);
const int nThreadsBcast = WARP_SIZE + ((hasUp && hasDn) ? COLLNET_COPY_THREADS : ...);
const int nThreadsReduce = work->nWarps * WARP_SIZE - nThreadsScatter - nThreadsGather - nThreadsBcast;

네 그룹의 스레드는 각각 Scatter(데이터를 각 rail로 분산), Reduce(리덕션 후 네트워크로 전송), Gather(각 rail에서 수집), Bcast(네트워크에서 수신 후 브로드캐스트)를 담당합니다.COLLNET_COPY_THREADS = 96(📎 src/device/all_reduce.h:250)는 고정된 복사 스레드 수입니다.

netRegUsed: 네트워크 등록 모드의 버퍼 레이아웃

📎 src/device/all_reduce.h:280-288에는 중요한 분기가 있습니다:

code
if (work->netRegUsed) {
  offsetBase = bid * chunkSize;
  maxNelems = size;
  peerOffset = nChannels * chunkSize;
} else {
  offsetBase = bid * direct->nHeads * chunkSize;
  maxNelems = direct->nHeads * chunkSize;
  peerOffset = chunkSize;
}

netRegUsed모드에서는 버퍼가 channel별로 연속 배치되며(bid * chunkSize), peer 오프셋은nChannels * chunkSize; 비등록 모드에서는 head별로 배치되며(bid * nHeads * chunkSize), peer 오프셋은chunkSize. 이 차이는 네트워크 등록 모드가 NIC DMA를 위해 버퍼가 연속적일 것을 요구하기 때문입니다.

NVLS의 warp 할당

RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_NVLS, ...>의run(📎 src/device/all_reduce.h:391-523)는 더 세밀한 warp 할당을 사용합니다:

code
const int bcastWarps = hasOut ? (work->regUsed ? ((totalWarps - 2) >> 1) - 1 : 2) : 0;
const int reduceWarps = work->regUsed ? (totalWarps - bcastWarps - 2) : (hasOut ? 3 : nranks <= 6 ? 7 : 5);
const int scatterWarps = work->regUsed ? 1 : (totalWarps - reduceWarps - bcastWarps + 1) >> 1;
const int gatherWarps = work->regUsed ? 1 : (totalWarps - reduceWarps - bcastWarps) >> 1;

regUsed모드에서는 scatter/gather가 각각 1 warp만 차지하고(NVLS 하드웨어가 등록 메모리를 직접 조작하므로), reduce가 대부분을 차지합니다; 비등록 모드에서는 scatter/gather가 각각 약 절반을 차지하고, reduce는 rank 수에 따라 조정됩니다(≤6이면 7 warp, 그렇지 않으면 5 warp).

타이밍 상호작용 다이어그램

mermaid
sequenceDiagram
    participant App as 应用层
    participant Scatter as Scatter Warps
    participant NVLS as NVLS 硬件
    participant Reduce as Reduce Warps
    participant Bcast as Bcast Warps

    App->>Scatter: prims.scatter(offset, nelem, chunkSize)
    Scatter->>NVLS: 写入 NVLink SHARP 缓冲区
    NVLS->>NVLS: 硬件归约 (multimem)
    NVLS->>Reduce: prims.directRecvDirectSend(offset, nelem)
    Reduce->>NVLS: 归约结果写回
    NVLS->>Bcast: prims.directRecvDirectSend(offset, nelem)
    Bcast->>App: 广播到所有 rank

프로덕션 함정: CollNet의direct->out == -1함정

📎 src/device/reduce_scatter.h:521에는 한 줄이 있습니다:

code
if (direct->out == -1) __trap();

CollNet의 out 연결이 설정되지 않은 경우(-1), 바로__trap()하면 kernel이 크래시됩니다. 이는 방어적 프로그래밍입니다 — CollNet은 NIC에 의존하므로, NIC 초기화가 실패하면 out이 -1이 되고, 이때 계속 실행하면 정의되지 않은 동작이 발생합니다. 프로덕션 환경에서 kernel trap이 발생하면 CollNet NIC가 정상적으로 초기화되었는지 확인하세요.

10.5 Broadcast와 Reduce: 가장 단순한 두 집합 연산

Broadcast: root에서 팬아웃

broadcast.h의runRing(📎 src/device/broadcast.h:14-64) 로직은 매우 직접적입니다: root 노드가 데이터를 보내고, 다른 노드가 전달하며, 마지막 노드는 받기만 합니다.

code
if (rank == root) {
  if (inputBuf == outputBuf || isNetOffload) {
    prims.directSend(offset, offset, nelem);
  } else {
    prims.directCopySend(offset, offset, nelem);
  }
} else if (nextRank == root) {
  prims.directRecv(offset, nelem);
} else {
  prims.directRecvCopyDirectSend(offset, offset, nelem);
}

세 가지 분기: root 전송, root의 전임자 수신, 중간 노드 전달. 주목할 점은nextRank == root이 '이 노드의 다음이 root'인지 판단한다는 것입니다. 즉, 이 노드가 링의 마지막이며 — 받기만 하고 보내지 않습니다.

Reduce: root로 수렴

reduce.h의runRing(📎 src/device/reduce.h:14-53)는 Broadcast의 역연산입니다:

code
if (prevRank == root) {
  prims.send(offset, nelem);
} else if (rank == root) {
  prims.recvReduceCopy(offset, offset, nelem, /*postOp=*/true);
} else {
  prims.recvReduceSend(offset, nelem);
}

prevRank == root의 노드는 보내기만 하고(root의 전임자), root는 받아서 리덕션만 하며, 중간 노드는 받으면서 리덕션하고 전달합니다.

설계 고찰: 왜 Broadcast/Reduce도 Ring을 사용하는가

Broadcast와 Reduce는 이론적으로 Tree로 더 낮은 지연을 구현할 수 있지만, NCCL이 Ring을 선택한 이유는:이 두 연산의 데이터량이 일반적으로 적고, Ring 구현이 더 단순하며, AllReduce의 Ring 코드 경로를 재사용할 수 있기 때문입니다. Tree의 복잡성(루트 노드 선택, 스레드 분할)은 소규모 메시지 시나리오에서 이점이 뚜렷하지 않습니다.

프로덕션 함정: Broadcast의 root 노드 대역폭 병목

Broadcast의 root 노드는 모든 데이터를 전송해야 하므로, root가 느린 노드라면 전체 Broadcast가 지연됩니다. NCCL의 대응은:Broadcast도 다중 channel을 지원하며, 각 channel의 root가 다를 수 있습니다. 하지만 주의할 점은work->root이 전역적이어서 모든 channel이 동일한 root를 공유한다는 것입니다 — 이는 Broadcast의 의미론(소스가 하나뿐)에 의해 결정됩니다. 프로덕션 환경에서 Broadcast가 느리면 root 노드의 네트워크 대역폭을 확인하세요.

10.6 알고리즘 선택 매트릭스: RunWorkColl 템플릿 특수화

모든 알고리즘 커널은RunWorkColl템플릿 특수화로 등록됩니다(📎 src/device/all_reduce.h:228-788). 각 특수화는 '함수 × 알고리즘 × 프로토콜'의 조합에 대응합니다:

함수알고리즘프로토콜특수화 위치
AllReduceRINGSIMPLE📎 src/device/all_reduce.h:230-233
AllReduceTREESIMPLE📎 src/device/all_reduce.h:238-244
AllReduceCOLLNET_DIRECTSIMPLE📎 src/device/all_reduce.h:249-386
AllReduceNVLSSIMPLE📎 src/device/all_reduce.h:391-523
AllReduceNVLS_TREESIMPLE📎 src/device/all_reduce.h:528-634
AllReduceCOLLNET_CHAINSIMPLE📎 src/device/all_reduce.h:639-759
AllReduceRINGLL📎 src/device/all_reduce.h:764-766
AllReduceTREELL📎 src/device/all_reduce.h:771-773
AllReduceRINGLL128📎 src/device/all_reduce.h:778-780
AllReduceTREELL128📎 src/device/all_reduce.h:785-787

주의:CollNet과 NVLS는 SIMPLE 프로토콜만 지원한다. 이 두 알고리즘은 하드웨어 오프로드에 의존하는데, LL/LL128의 저지연 동기화 메커니즘은 하드웨어 오프로드와 호환되지 않기 때문이다——하드웨어 리덕션의 지연은 LL의 flag 폴링보다 훨씬 크므로, LL을 쓰면 오히려 오버헤드가 증가한다.

프로토콜 선택의 내재적 논리

  • LL: 작은 메시지(< 8KB), 저지연 우선. Ring과 Tree 모두 지원.
  • LL128: 중간 메시지(8KB - 1MB), 128바이트 정렬. Ring과 Tree 모두 지원.
  • SIMPLE: 큰 메시지(> 1MB), 대역폭 우선. 모든 알고리즘이 지원.

프로덕션 함정: 프로토콜과 알고리즘의 조합 제한

만약 사용자가 강제로NCCL_PROTO=LL를 지정했지만 알고리즘이 CollNet이라면, NCCL은 tuning 단계에서 SIMPLE로 폴백한다. 프로덕션 환경에서 프로토콜 설정이 적용되지 않는다면, 알고리즘이 해당 프로토콜을 지원하는지 확인하라.

설계 사고: 왜 동일한 AllReduce 로직에 이렇게 많은 구현이 필요한가

이 장을 돌아보면, AllReduce에는 Ring, Tree, CollNet Direct, CollNet Chain, NVLS, NVLS Tree 여섯 가지 알고리즘 구현이 있다. 이는 중복이 아니라서로 다른 하드웨어 토폴로지와 메시지 크기에 대한 최적해:

  • Ring: 범용, 큰 메시지에 적합, 대역폭 활용률 최고.
  • Tree: 대규모 클러스터에 적합, 지연 O(log n).
  • CollNet: 리덕션을 지원하는 NIC가 있는 클러스터에 적합, GPU 계산을 오프로드.
  • NVLS: 단일 노드 NVLink 전연결에 적합, 하드웨어 멀티캐스트 리덕션.

NCCL의 tuning 모듈(제5장)은 메시지 크기, rank 수, 토폴로지에 따라 자동으로 선택한다. 디바이스 측 구현은 「각 조합이 모두 정확함」만 보장하면 되고, 선택 로직은 host 측에 있다.

이 장 요약

이 장에서는src/device아래의 여섯 가지 알고리즘 커널 파일을 분석했다:

1. Ring AllReduce(📎 src/device/all_reduce.h:14-83): 2단계 파이프라인, reduce-scatter + all-gather, 각 단계당 n-1 스텝.

2. Tree AllReduce(📎 src/device/all_reduce.h:86-225): 트리형 리덕션, 지연 O(log n),runTreeSplit스레드 분할로 리덕션-브로드캐스트 파이프라인을 구현.

3. AllGather(📎 src/device/all_gather.h:14-88): Ring 단일 단계, in-place와 netOffload 지원.

4. ReduceScatter(📎 src/device/reduce_scatter.h:14-56): Ring 단일 단계, AllReduce의 reduce-scatter 단계.

5. Broadcast/Reduce(📎 src/device/broadcast.h:14-64、📎 src/device/reduce.h:14-53): 가장 단순한 Ring 변형.

6. CollNet/NVLS(📎 src/device/all_reduce.h:247-635): 하드웨어 오프로드, SIMPLE 프로토콜만 지원.

이 장 사고와 자가 점검

Q1: Ring AllReduce의 reduce-scatter 단계에서, 0번째 스텝은directSend를 사용하고, 중간 스텝은directRecvReduceDirectSend를 사용하며, 마지막 스텝은directRecvReduceCopyDirectSend를 사용한다. 만약 마지막 스텝의postOp=true를 제거하면, 어떤 시나리오에서 잘못된 결과가 발생하는가?

참고 해석:postOp=true가 트리거되어 후처리 연산(예: 평균 계산 시 나눗셈)을 수행한다.ncclAvg를 예로 들면, 리덕션은 합산이고 postOp는 nranks로 나누는 것이다. 만약postOp를 제거하면, 마지막 스텝은 리덕션만 하고 나눗셈을 하지 않으므로 recvbuff에는 「합」이 아닌 「평균」이 저장된다. reduce-scatter 단계에서 각 rank는 하나의 chunk에 대한 최종 결과만 보유하는데, 이 chunk는 정확히ringIx+0(📎 src/device/all_reduce.h:60)이다. 만약 postOp가 누락되면, 이 chunk의 합은 nranks로 나뉘지 않고, 이후 all-gather 단계에서 이 잘못된 「합」이 모든 rank에 전파된다. 주의: 마지막 스텝만 postOp가 필요한데, 이 스텝만 「완전한 리덕션」 결과를 생성하기 때문이다; 중간 스텝의 리덕션은 부분합이므로 postOp가 필요 없다. 프로덕션 환경에서 AllReduce 결과가 nranks배만큼 크게 나온다면, postOp가 올바르게 전달되었는지 확인하라.

Q2: runTreeSplitLL/LL128 프로토콜에서 스레드를 7:3으로 분할하고(📎 src/device/all_reduce.h:163), Simple 프로토콜에서는 1:1로 분할한다(📎 src/device/all_reduce.h:157). 만약 LL 프로토콜도 강제로 1:1로 바꾸면, 무슨 일이 발생하는가?

참고 해석: LL/LL128의 리덕션 그룹은 최대 3개의 자식 노드로부터 데이터를 받아 리덕션해야 하며(📎 src/device/all_reduce.h:187의FanAsymmetric<NCCL_MAX_TREE_ARITY, 1>), 계산 집약적이다; 브로드캐스트 그룹은 복사-전달만 하며(📎 src/device/all_reduce.h:208의FanAsymmetric<1, NCCL_MAX_TREE_ARITY>), 계산이 가볍다. 7:3 분할은 리덕션 그룹이 3-way 리덕션을 처리할 충분한 스레드를 갖게 하고, 브로드캐스트 그룹은 스레드가 적지만 충분하다. 만약 1:1로 바꾸면, 리덕션 그룹의 스레드가 부족해져 리덕션이 병목이 된다; 브로드캐스트 그룹은 스레드가 과잉되어 낭비된다. 더 심각한 것은, LL 프로토콜의 flag 폴링이 바쁜 대기(busy-wait)이므로, 스레드가 많아지면 flag 경쟁이 증가한다. 프로덕션 환경에서 Tree AllReduce가 LL 프로토콜에서 성능 이상을 보인다면,nthreadsSplit의 계산이 수정되었는지 확인하라.

Q3: AllGather의isNetOffload모드에서, 1개의 warp만 Ring 통신을 구동하고(📎 src/device/all_gather.h:32), 나머지 warp는 병렬로 복사한다(📎 src/device/all_gather.h:76-82). 만약 마지막barrier_sync(14, nthreads)(📎 src/device/all_gather.h:87)을 제거하면, 어떤 시나리오에서 데이터 경쟁이 발생하는가?

참고 해석:barrier_sync모든 warp(통신 warp와 복사 warp 포함)가 본 work를 완료한 후에만 다음 work로 진입하도록 보장한다. 이를 제거하면 통신 warp가 복사 warp가 아직 outputBuf를 다 쓰지 못한 상태에서 다음 work의 통신을 시작할 수 있고, 다음 work가 동일한 outputBuf를 재사용할 수 있다. 구체적 시나리오: 연속 두 번의 AllGather에서 첫 번째 복사 warp가 아직 outputBuf의 꼬리 부분을 쓰고 있는데, 두 번째 통신 warp가 이미 outputBuf에 새 데이터를 쓰기 시작하여 첫 번째 데이터가 덮어써진다. 주석에 명확히 나와 있다: 「otherwise, we can have contention if next work will use the outputBuf in this work」. 기본 barrier가 아닌 barrier 14를 사용하는 것은 prims 내부의 barrier와__syncthreads()를 피하여 데드락을 방지하기 위함이다. 프로덕션 환경에서 AllGather 결과가 간헐적으로 오류가 발생하면isNetOffload경로의 barrier가 최적화로 제거되었는지 확인하라.

여기까지 우리는 디바이스 측 알고리즘 커널이 어떻게 데이터 흐름을 조직하는지 살펴보았다. 각 알고리즘은Primitives를 통해 이전 장의 원시 연산을 호출하며, 알고리즘 계층은 「누가 누구에게, 어느 chunk를 보내는지, reduce인지 copy인지」만 신경 쓴다. 다음 장에서는 전송 계층 추상화를 깊이 파고들어 P2P, SHM, NET, NVLS가 어떻게 하나의 인터페이스로 통합되는지, 그리고 host 측 proxy 스레드가 디바이스 측 kernel과 어떻게 협력하여 크로스 머신 통신을 완료하는지 살펴본다.

핵심 규칙: 모든 알고리즘은 Primitives 템플릿 클래스를 통해 원시 연산을 호출하며, 알고리즘은 「데이터 흐름 토폴로지」만 담당하고 원시 연산은 「데이터 이동」을 담당한다. 이러한 계층화 덕분에 새로운 알고리즘을 추가할 때 토폴로지 로직만 구현하면 되고 저수준 동기화는 신경 쓸 필요가 없다. 그러나 토폴로지가 어떻게 변하든 데이터는 결국 물리적 링크를 통해 전송되어야 한다. 다음 장에서는 src/transport 디렉터리를 깊이 파고들어 NCCL이 어떻게 통일된 transport 인터페이스로 P2P, SHM, NET, NVLS의 차이를 가리는지, 그리고 각 transport의 setup/connect/send/recv 시맨틱을 살펴본다. 이것이 크로스 머신 통신을 이해하는 기초다.

CHAPTER 11

제 11 장: 제 11 장: 전송 계층 추상화: P2P, SHM, NET, NVLS가 어떻게 동일한 인터페이스 아래 통합되는가

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 11 / 25 장

제 11 장: 전송 계층 추상화: P2P, SHM, NET, NVLS가 어떻게 동일한 인터페이스 아래 통합되는가

이전 장에서 우리는 알고리즘 커널을 깊이 파고들어 Ring AllReduce가 어떻게 데이터를 분할한 후 두 단계로 reduce하는지, Tree AllReduce가 어떻게 트리 구조를 통해 지연을 낮추는지 살펴보았다. 그러나 이러한 알고리즘은 「누가 누구에게, 어느 chunk를 보내는지」의 논리적 뷰만 정의한다. 데이터는 결국 실제 물리적 링크, 즉 NVLink, PCIe, 공유 메모리 또는 네트워크 카드를 통과해야 한다. 이 장에서는 src/transport 디렉터리를 해부하여 NCCL이 어떻게 통일된 ncclTransport 인터페이스로 P2P, SHM, NET, NVLS 네 가지 물리적 채널을 동일한 얼굴로 가리는지, 알고리즘 토폴로지에서 물리적 전송까지의 마지막 1마일을 완성하는지 살펴본다.

1. 통일 인터페이스: ncclTransport가 어떻게 네 가지 물리적 채널을 가리는가

직관적 모델

물류 회사를 상상해 보자: 고객이 시내 택배(P2P), 건물 내 전달(SHM), 성 간 운송(NET), 전용 직통(NVLS) 중 무엇을 보내든 프런트에서는 「운송장」 한 장만 작성한다. 이 운송장이 바로ncclTransport구조체다. 이는 각 운송 방식이 반드시 제공해야 하는canConnect、setup、connect、free등의 고정 동작을 규정한다. 이러한 추상화 계층이 없다면 상위 알고리즘은 네 가지if-else를 작성하여 어느 링크로 갈지 판단해야 하고, 새로운 하드웨어를 추가할 때마다 모든 알고리즘을 수정해야 한다.

데이터 구조와 메모리 레이아웃

NCCL은 전역 배열 하나로 모든 transport를 등록하며, 순서가 곧 우선순위다:

📎 src/transport.cc:15-20

c
struct ncclTransport* ncclTransports[NTRANSPORTS] = {
  &p2pTransport,
  &shmTransport,
  &netTransport,
  &collNetTransport,
};

배열 순서가 선택 순서를 결정한다: P2P 우선, 다음 SHM, 다음 NET, 마지막 CollNet. 각 transport는ncclTransport구조체로 설명되며, 이는canConnect함수 포인터 하나와 두 개의ncclTransportComm(send/recv 각각 하나)를 포함한다. P2P를 예로 들면:

📎 src/transport/p2p.cc:1493-1498

c
struct ncclTransport p2pTransport = {"P2P",
                                     p2pCanConnect,
                                     {p2pSendSetup, p2pSendConnect, p2pSendFree, NULL, p2pSendProxySetup, NULL,
                                      p2pSendProxyFree, NULL, p2pProxyRegister, p2pProxyDeregister},
                                     {p2pRecvSetup, p2pRecvConnect, p2pRecvFree, NULL, p2pRecvProxySetup, NULL,
                                      p2pRecvProxyFree, NULL, p2pProxyRegister, p2pProxyDeregister}};

ncclTransportComm의 필드 순서는 고정된 「생명주기 슬롯」이다:setup(리소스 준비),connect(연결 정보 교환),free(해제),proxySharedInit(프록시 공유 초기화),proxySetup、proxyConnect、proxyFree、proxyProgress、proxyRegister、proxyDeregister. P2P의proxyProgress슬롯은NULL이다. P2P는 GPU가 상대방의 메모리를 직접 읽고 쓰는 방식이므로 host 프록시 스레드가 데이터를 옮길 필요가 없기 때문이다. 반면 NET의proxyProgress는sendProxyProgress/recvProxyProgress이다. 네트워크 카드 I/O는 반드시 host 스레드가 구동해야 하기 때문이다.

시나리오 기반 Walkthrough: 하나의 연결이 어떻게 transport를 선택하는가

NCCL이 특정 channel의 특정 peer에 대한 연결을 설정해야 할 때,selectTransport:

📎 src/transport.cc:23-44

c
template <int type>
static ncclResult_t selectTransport(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclConnect* connect,
                                    int channelId, int peer, int connIndex, int* transportType) {
  struct ncclPeerInfo* myInfo = comm->peerInfo + comm->rank;
  struct ncclPeerInfo* peerInfo = comm->peerInfo + peer;
  struct ncclConnector* connector = (type == 1) ? comm->channels[channelId].peers[peer]->send + connIndex :
                                                  comm->channels[channelId].peers[peer]->recv + connIndex;
  for (int t = 0; t < NTRANSPORTS; t++) {
    struct ncclTransport* transport = ncclTransports[t];
    struct ncclTransportComm* transportComm = type == 1 ? &transport->send : &transport->recv;
    int ret = 0;
    NCCLCHECK(transport->canConnect(&ret, comm, graph, myInfo, peerInfo));
    if (ret) {
      connector->transportComm = transportComm;
      NCCLCHECK(transportComm->setup(comm, graph, myInfo, peerInfo, connect, connector, channelId, connIndex));
      if (transportType) *transportType = t;
      return ncclSuccess;
    }
  }
  WARN("No transport found for rank %d[%lx] -> rank %d[%lx]", myInfo->rank, myInfo->busId, peerInfo->rank,
       peerInfo->busId);
  return ncclSystemError;
}

type==1send 방향을 나타내고,type==0recv 방향을 나타냅니다. 루프는 각 transport의canConnect를 순차적으로 질의합니다:ret=1를 반환하면 「내가 이 작업을 할 수 있다」는 뜻이며, 즉시connector->transportComm를 해당 transport의 대응 방향으로 지정하고 그setup를 호출합니다. 모든 transport가 0을 반환하면 경고를 출력하고ncclSystemError。

canConnect의 판정 로직은 각 transport의 「영역 경계」를 반영합니다. P2P를 예로 들면:

📎 src/transport/p2p.cc:129-157

c
ncclResult_t p2pCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
                           struct ncclPeerInfo* info2) {
  initCeOperation();
  int intermediateRank;
  int isCrossClique;
  NCCLCHECK(ncclTopoCheckP2p(comm, comm->topo, info1->rank, info2->rank, ret, NULL, &intermediateRank, NULL,
                             &isCrossClique));
  if (*ret == 0) return ncclSuccess;
  if (intermediateRank != -1) {
    if (useMemcpy) *ret = 0;
    return ncclSuccess;
  }
  if (!isCrossClique) {
    int useNet = 0;
    NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, &useNet));
    if (useNet) {
      *ret = 0;
      return ncclSuccess;
    }
  }
  if (info1->hostHash != comm->peerInfo[comm->rank].hostHash || info1->hostHash != info2->hostHash) {
    return ncclSuccess;
  }
  ...

P2P의 판정 체인: 먼저 토폴로지에 「두 rank 사이에 P2P 경로가 있는가」를 질의하고; 중간 홉(intermediateRank != -1)이 있고 CE memcpy가 활성화되어 있으면 P2P를 포기하고 SHM/NET에 양보하며; 토폴로지가 네트워크 경로(useNet)를 권장해도 포기하고; 마지막으로 동일 호스트인지 확인합니다. SHM의 판정은 더 간단합니다:

📎 src/transport/shm.cc:61-83

c
static ncclResult_t shmCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph,
                                  struct ncclPeerInfo* info1, struct ncclPeerInfo* info2) {
  *ret = 0;
  initShmLocality();
  if (ncclParamShmDisable() == 1) return ncclSuccess;
  int useNet = 0;
  NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, &useNet));
  if (useNet) return ncclSuccess;
  if (info1->hostHash != info2->hostHash) return ncclSuccess;
  if (info1->shmDev != info2->shmDev) return ncclSuccess;
  *ret = 1;
  return ncclSuccess;
}

SHM은 동일 호스트(hostHash동일)이면서 동일한/dev/shm(shmDev를 공유해야 합니다(동일, 컨테이너 간 통신에 사용). NET은 거의 항상 1을 반환하며, 동일 호스트일 때만 intra-node net이 비활성화되었는지 확인합니다:

📎 src/transport/net.cc:160-168

c
static ncclResult_t canConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
                               struct ncclPeerInfo* info2) {
  *ret = 1;
  if (info1->hostHash == info2->hostHash) {
    NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, ret));
  }
  return ncclSuccess;
}

NET은 「최후의 보루」입니다 — 앞에서 아무도 받지 않으면 그것이 받습니다. NVLS의canConnect는 직접 0을 반환합니다:

📎 src/transport/nvls.cc:21-26

c
ncclResult_t nvlsCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
                            struct ncclPeerInfo* info2) {
  // This transport cannot be used for p2p
  *ret = 0;
  return ncclSuccess;
}

NVLS는 일반적인 peer-to-peer 연결 경로를 사용하지 않고,ncclNvlsSetup를 통해 별도로 멀티캐스트 그룹을 설정하므로canConnect는 항상 0을 반환합니다.

mermaid
flowchart TD
    start["selectTransport(comm, peer, connIndex)"] --> loop{"遍历 ncclTransports[t]"}
    loop -->|t=0| p2p["p2pCanConnect()"]
    p2p --> p2p_chk{"拓扑有P2P路径<br/>且非中间跳<br/>且同主机?"}
    p2p_chk -->|是| use_p2p["connector->transportComm = p2pTransport<br/>调用 p2pSendSetup/p2pRecvSetup"]
    p2p_chk -->|否| shm["shmCanConnect()"]
    shm --> shm_chk{"同hostHash<br/>且同shmDev?"}
    shm_chk -->|是| use_shm["connector->transportComm = shmTransport<br/>调用 shmSendSetup/shmRecvSetup"]
    shm_chk -->|否| net["canConnect() (NET)"]
    net --> net_chk{"同主机时<br/>intra-node net 启用?"}
    net_chk -->|是/跨机| use_net["connector->transportComm = netTransport<br/>调用 sendSetup/recvSetup"]
    net_chk -->|否| collnet["collNetTransport"]
    collnet --> fail["WARN: No transport found<br/>return ncclSystemError"]
    use_p2p --> done["return ncclSuccess"]
    use_shm --> done
    use_net --> done

설계 고찰

〔설계 추론 및 아키텍처 트레이드오프〕

왜 「배열 순서 + canConnect 투표」를 사용하고 명시적 라우팅 테이블을 사용하지 않는가? 토폴로지가 동적이기 때문입니다: 동일한 머신이라도NCCL_P2P_DISABLE, 컨테이너 격리, CUDA IPC 가용성 등의 요인으로 P2P가 불가능해질 수 있으며, 이때 자동으로 SHM 또는 NET으로 강등됩니다. 투표 메커니즘은 각 transport가 스스로 「내가 할 수 있는가」를 판단하게 하며, 새로운 transport를 추가할 때 배열에 항목 하나만 추가하면 되고 선택 로직을 변경할 필요가 없습니다. 이것이 바로 개방-폐쇄 원칙이 시스템 프로그래밍에서 구현된 모습입니다.

2. P2P: 동일 머신 GPU 직결의 네 가지 형태

직관적 모델

P2P는 「이웃 간에 직접 물건을 전달」하는 것입니다 — GPU 0이 CPU나 네트워크 카드를 거치지 않고 GPU 1의 메모리를 직접 읽고 씁니다. P2P가 없으면 동일 머신 다중 GPU 통신은 host 메모리로 우회해야 하므로 지연이 두 배, 대역폭이 절반으로 줄어듭니다.

데이터 구조와 메모리 레이아웃

P2P 내부에는 네 가지 형태가 있으며,enum p2pType로 구분됩니다:

📎 src/transport/p2p.cc:19-24

c
enum p2pType {
  P2P_DIRECT,
  P2P_INTERMEDIATE,
  P2P_IPC,
  P2P_CUMEM
};
  • P2P_DIRECT: 동일 프로세스 내 서로 다른 GPU, 포인터로 직접 접근(가장 빠름).
  • P2P_INTERMEDIATE: 두 GPU 사이에 직결이 없어 중간 GPU를 경유해야 함.
  • P2P_IPC: 프로세스 간, 전통적인cudaIpcOpenMemHandle로 상대방 메모리를 임포트.
  • P2P_CUMEM: 프로세스 간, cuMem API(cuMemExportToShareableHandle)로 임포트하며 더 세밀한 메모리 관리를 지원.

핵심 리소스 구조체:

📎 src/transport/p2p.cc:79-94

c
struct p2pResources {
  enum p2pType type;
  union {
    struct ncclSendMem* sendDevMem;
    struct ncclRecvMem* recvDevMem;
  };
  void* sendMemIpc;
  int sendMemSameProc;
  void* recvMemIpc;
  int recvMemSameProc;
  // CE memcpy support
  struct p2pShmProxyInfo proxyInfo;
  struct p2pShm* shm;
  struct p2pShm* devShm;
  ncclShmIpcDesc_t desc;
};

sendDevMem/recvDevMem는 union입니다 — 송신 측은sendDevMem만 신경 쓰고, 수신 측은recvDevMem만 신경 쓰며, 하나의 메모리를 공유합니다.sendMemIpc/recvMemIpc는 임포트한 상대방 메모리 핸들을 저장하고,sendMemSameProc/recvMemSameProc는 동일 프로세스 여부를 표시합니다(해제 시ncclCuMemFreeAddr를 쓸지cudaIpcCloseMemHandle)。

를 쓸지 결정).p2pConnectInfo연결 정보 구조체는 bootstrap을 통해 교환됩니다:

📎 src/transport/p2p.cc:38-44

c
struct p2pConnectInfo {
  int rank;
  int read;
  struct ncclP2pBuff p2pBuff;
  // Used by CE memcpy
  ncclShmIpcDesc_t desc;
};
static_assert(sizeof(struct p2pConnectInfo) <= CONNECT_SIZE, "p2pConnectInfo is too large");

static_assert는 연결 정보가CONNECT_SIZE(bootstrap 단일 교환의 고정 버퍼 크기)를 초과하지 않도록 보장합니다.read필드는 데이터 흐름 방향을 결정합니다:read=1는 수신 측이 송신 측 메모리를 능동적으로 읽음(P2P Read),read=0는 송신 측이 수신 측 메모리에 능동적으로 씀(P2P Write).

시나리오 기반 Walkthrough: P2P Send의 설정

selectTransport가 P2P를 선택하면p2pSendSetup:

📎 src/transport/p2p.cc:393-471

c
ncclResult_t p2pSendSetup(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* myInfo,
                          struct ncclPeerInfo* peerInfo, struct ncclConnect* connectInfo, struct ncclConnector* send,
                          int channelId, int connIndex) {
  struct p2pResources* resources;
  struct ncclP2pRequest req;
  NCCLCHECK(ncclCalloc(&resources, 1));
  send->transportResources = resources;
  int useRead, intermediateRank;
  NCCLCHECK(p2pGetInfo(comm, myInfo, peerInfo, &useRead, &intermediateRank));
  if (useMemcpy) useRead = 0;
  ...
  int sendSize = sizeof(struct ncclSendMem);
  if (info->read) sendSize += comm->buffSizes[NCCL_PROTO_SIMPLE];
  ALIGN_SIZE(sendSize, CUDA_IPC_MIN);
  ...

복사sendSize핵심 포인트:ncclSendMem는 P2P Read 모드에서 SIMPLE 프로토콜 버퍼 크기를 추가로 더해야 합니다 — 읽기 모드에서는 송신 측의 SIMPLE buffer를 수신 측이 직접 읽으므로 반드시ALIGN_SIZE(sendSize, CUDA_IPC_MIN)와 함께 동일한 공유 가능 메모리에 할당해야 합니다.

는 크기를 CUDA IPC 최소 입자에 맞춰 정렬합니다.intermediateRank이어서

📎 src/transport/p2p.cc:416-437

c
  if (intermediateRank == -1) {
    info->rank = myInfo->rank;
    if (P2P_SAME_PID(myInfo, peerInfo) && ncclParamP2pDirectDisable() == 0 && useMemcpy == 0) {
      resources->type = P2P_DIRECT;
      ...
    } else {
      if (ncclCuMemEnable()) {
        resources->type = P2P_CUMEM;
        ...
      } else {
        resources->type = P2P_IPC;
        ...
      }
    }
    send->conn.flags |= info->read ? NCCL_P2P_READ : NCCL_P2P_WRITE;
  } else {
    resources->type = P2P_INTERMEDIATE;
    info->rank = intermediateRank;
    ...
  }

P2P_SAME_PID복사

📎 src/transport/p2p.cc:334-335

c
#define P2P_SAME_PID(MYINFO, PEERINFO) \
  ((MYINFO->hostHash == PEERINFO->hostHash) && (MYINFO->pidHash == PEERINFO->pidHash))

복사P2P_DIRECT동일 프로세스이고 direct가 비활성화되지 않았으며 memcpy가 활성화되지 않았다면 가장 빠른

입니다 — 상대방 포인터를 직접 가져옵니다. 그렇지 않으면 IPC/CUMEM을 사용합니다.

📎 src/transport/p2p.cc:457-468

c
  NCCLCHECK(ncclProxyConnect(comm, TRANSPORT_P2P, 1, info->rank, &send->proxyConn));
  if (useMemcpy) {
    NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, NULL, 0, &resources->proxyInfo,
                                    sizeof(struct p2pShmProxyInfo)));
    memcpy(&info->desc, &resources->proxyInfo.desc, sizeof(ncclShmIpcDesc_t));
  } else {
    NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, &req, sizeof(struct ncclP2pRequest),
                                    &info->p2pBuff, sizeof(struct ncclP2pBuff)));
    NCCLCHECK(p2pMap(comm, &send->proxyConn, myInfo, comm->peerInfo + info->rank, &info->p2pBuff,
                     (void**)&resources->sendDevMem, &resources->sendMemIpc));
    resources->sendMemSameProc = P2P_SAME_PID(myInfo, (comm->peerInfo + info->rank));
  }

ncclProxyCallBlocking복사p2pSendProxySetup는 동기 RPC입니다: host 스레드가 프록시 스레드에 메시지를 보내고, 프록시 스레드가ncclP2pBuff를 호출하여 공유 가능 버퍼를 할당한 뒤p2pMap(IPC 핸들 포함)를 반환합니다. 그런 다음

p2pMap가 상대방 버퍼를 로컬 주소 공간에 매핑합니다.

📎 src/transport/p2p.cc:349-390

c
static ncclResult_t p2pMap(struct ncclComm* comm, struct ncclProxyConnector* proxyConn, struct ncclPeerInfo* myInfo,
                           struct ncclPeerInfo* peerInfo, struct ncclP2pBuff* p2pBuff, void** devMem, void** ipcPtr) {
  if (P2P_SAME_PID(myInfo, peerInfo)) {
    if (peerInfo->cudaDev != myInfo->cudaDev) {
      cudaError_t err = cudaDeviceEnablePeerAccess(peerInfo->cudaDev, 0);
      ...
      if (ncclCuMemEnable()) {
        NCCLCHECK(ncclCuMemAllocAddr(devMem, &p2pBuff->ipcDesc.memHandle, p2pBuff->size));
        CUCHECK(cuMemRelease(p2pBuff->ipcDesc.memHandle));
        *ipcPtr = *devMem;
        ...
      } else {
        *devMem = p2pBuff->directPtr;
        *ipcPtr = NULL;
      }
    } else {
      *devMem = p2pBuff->directPtr;
      *ipcPtr = NULL;
    }
  } else {
    NCCLCHECK(ncclP2pImportShareableBuffer(comm, peerInfo->rank, p2pBuff->size, &p2pBuff->ipcDesc, devMem,
                                           p2pBuff->directPtr, ncclMemOffload));
    *ipcPtr = *devMem;
  }
  return ncclSuccess;
}

복사cudaDeviceEnablePeerAccess동일 프로세스 다른 GPU: 먼저directPtr로 P2P 채널을 열고, 그런 다음ncclP2pImportShareableBuffer를 직접 사용합니다(동일 프로세스 주소 공간 공유이므로). 프로세스 간:

를 호출하여 상대방 메모리 핸들을 임포트합니다.

동시성 제어와 하드웨어 상호작용ncclSendMem/ncclRecvMemP2P의 동기화는head/tail내의head포인터에 의존합니다. 송신 측은tail를 써서 수신 측에 「내가 어디까지 썼는지」를 알리고, 수신 측은

📎 src/transport/p2p.cc:571-576

c
  } else {
    send->conn.tail = &remDevMem->tail;
    send->conn.head = &resources->sendDevMem->head;
    send->conn.ptrExchange = &resources->sendDevMem->ptrExchange;
    send->conn.redOpArgExchange = resources->sendDevMem->redOpArgExchange;
  }

head복사sendDevMem,tail는 로컬remDevMem를 가리키고

는 상대방

를 가리킵니다. GPU kernel은 이 두 포인터를 읽고 써서 CPU 개입 없이 GPU 간 동기화를 구현합니다.프로덕션 함정 회피 가이드p2pSendConnect:

📎 src/transport/p2p.cc:551-559

c
  for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
    if (info->read && p == NCCL_PROTO_SIMPLE) {
      /* For P2P Read the SIMPLE buffer is local (ncclSendMem) */
      if (resources->sendDevMem == NULL) return ncclInternalError; // We should not use read + memcpy
      send->conn.buffs[p] = (char*)(resources->sendDevMem + 1);
    } else {
      send->conn.buffs[p] = buff;
      buff += comm->buffSizes[p];
    }
  }

read=1를 봅니다sendDevMem==NULL복사ncclInternalError만약NCCL_P2P_READ_ENABLE=1인데NCCL_P2P_USE_CUDA_MEMCPY=1이면, 직접

를 반환합니다. 프로덕션 환경에서 이 오류가 보이면 p2pSendFree와sendMemSameProc를 동시에 설정했는지 확인하세요 — 이 둘은 의미가 충돌합니다.

📎 src/transport/p2p.cc:624-651

c
ncclResult_t p2pSendFree(struct ncclComm* comm, struct ncclConnector* send) {
  struct p2pResources* resources = (struct p2pResources*)send->transportResources;
  if (resources) {
    if (ncclCuMemEnable()) {
      if (resources->sendMemIpc) {
        if (resources->sendMemSameProc) {
          NCCLCHECK(ncclCuMemFreeAddr(resources->sendMemIpc, comm->memManager));
        } else {
          NCCLCHECK(ncclCudaFree(resources->sendMemIpc, comm->memManager));
        }
      }
      ...

ncclCuMemFreeAddr에 따라 해제 방식을 결정합니다:ncclCudaFree(물리 메모리 해제). 반대로 하면 메모리 누수나 use-after-free가 발생한다.

3. SHM: 공유 메모리의 「누가 메모리를 내는가」 논쟁

직관적 모델

SHM은 「두 프로세스가 하나의 화이트보드를 공유하는 것」이다 — 송신자가 쓰고, 수신자가 읽는다. 하지만 화이트보드를 누구 집에 둘 것인가? 송신자 집(sender-side)에 두고 수신자가 달려와 읽을 것인가, 아니면 수신자 집(receiver-side)에 두고 송신자가 달려가 쓸 것인가? 이것이 바로NCCL_SHM_LOCALITY파라미터가 해결하려는 문제다.

데이터 구조와 메모리 레이아웃

📎 src/transport/shm.cc:28-34

c
struct shmSendResources {
  struct ncclRecvMem* remHostMem;
  struct ncclRecvMem* devRemHostMem;
  ncclShmIpcDesc_t remDesc;
  struct ncclSendMem* hostMem;
  struct ncclSendMem* devHostMem;
};

struct shmRecvResources {
  struct ncclSendMem* remHostMem;
  struct ncclSendMem* devRemHostMem;
  ncclShmIpcDesc_t remDesc;
  struct ncclRecvMem* hostMem;
  struct ncclRecvMem* devHostMem;
};

주의hostMem와devHostMem는 쌍으로 나타난다:hostMem는 host 측 포인터,devHostMem는 디바이스 측 포인터(UVA 또는 cuMem 매핑을 통해).remHostMem/devRemHostMem는 상대방 공유 메모리의 로컬 매핑이다.

시나리오 기반 Walkthrough: SHM의 locality 선택

shmSendSetuplocality에 따라 얼마나 큰 메모리를 할당할지 결정한다:

📎 src/transport/shm.cc:88-119

c
static ncclResult_t shmSendSetup(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* myInfo,
                                 struct ncclPeerInfo* peerInfo, struct ncclConnect* connectInfo,
                                 struct ncclConnector* send, int channelId, int connIndex) {
  struct shmSendResources* resources;
  struct shmConnectInfo* info = (struct shmConnectInfo*)connectInfo;
  size_t shmSize = sizeof(struct ncclSendMem);
  struct shmRequest req;

  NCCLCHECK(ncclCalloc(&resources, 1));
  send->transportResources = resources;

  if (shmLocality == SHM_SEND_SIDE) {
    for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) shmSize += comm->buffSizes[p];
  }
  req.size = shmSize;
  if (myInfo->hostHash == peerInfo->hostHash && myInfo->pidHash == peerInfo->pidHash) req.legacy = true;
  else req.legacy = false;

  NCCLCHECK(ncclProxyConnect(comm, TRANSPORT_SHM, 1, myInfo->rank, &send->proxyConn));
  NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, (void*)&req, sizeof(struct shmRequest),
                                  (void*)info, sizeof(struct shmConnectInfo)));

  info->rank = comm->rank;
  resources->hostMem = (struct ncclSendMem*)info->buf.hptr;
  resources->devHostMem = (struct ncclSendMem*)info->buf.dptr;
  ...

shmLocality == SHM_SEND_SIDE시, 송신자는 데이터 버퍼(shmSize에 모든 프로토콜 버퍼를 더한 값)를 할당하고, 그렇지 않으면ncclSendMem제어 구조만 할당한다.req.legacy동일 프로세스인지 표시한다 — 동일 프로세스는 전통적인mmap를 사용할 수 있고, 프로세스 간에는 cuMem 또는/dev/shm파일이 필요하다.

shmSendConnect에서 locality에 따라buffs가 로컬을 가리킬지 상대방을 가리킬지 결정한다:

📎 src/transport/shm.cc:153-176

c
static ncclResult_t shmSendConnect(struct ncclComm* comm, struct ncclConnect* connectInfo, int nranks, int rank,
                                   struct ncclConnector* send) {
  struct shmConnectInfo* info = (struct shmConnectInfo*)connectInfo;
  struct shmSendResources* resources = (struct shmSendResources*)send->transportResources;
  char* buff;

  NCCLCHECK(ncclShmImportShareableBuffer(comm, info->rank, &info->desc, (void**)&resources->remHostMem,
                                         (void**)&resources->devRemHostMem, &resources->remDesc));

  buff = shmLocality == SHM_SEND_SIDE ? (char*)(resources->devHostMem + 1) : (char*)(resources->devRemHostMem + 1);
  for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
    send->conn.buffs[p] = buff;
    buff += comm->buffSizes[p];
  }
  send->conn.tail = &resources->devRemHostMem->tail;
  send->conn.head = &resources->devHostMem->head;
  send->conn.stepSize = comm->buffSizes[NCCL_PROTO_SIMPLE] / NCCL_STEPS;
  ...

SHM_SEND_SIDE:buffs는 로컬devHostMem을 가리킨다(송신자가 자신의 메모리에 쓴다);SHM_RECV_SIDE:buffs는 상대방devRemHostMem을 가리킨다(송신자가 수신자의 메모리에 쓴다).head는 항상 로컬을 가리키고,tail는 항상 상대방을 가리킨다 — 왜냐하면 송신자는head를 갱신하고, 수신자는tail。

설계 사고

〔설계 추론과 아키텍처 트레이드오프〕

왜 기본값이SHM_RECV_SIDE인가? 수신자는 보통 공유 메모리에서 자신의 GPU 메모리로 데이터를 복사해야 하는데, 공유 메모리가 수신자 로컬에 있으면 복사 경로가 더 짧아진다(로컬 메모리 → 로컬 GPU). NUMA 간 접근을 피할 수 있다. 송신자가 원격 메모리에 쓰는 것은 노드 간 쓰기가 한 번 더 발생하지만, 송신자는 보통 계산 집약적인 GPU이므로 쓰기 작업을 비동기로 진행할 수 있다.

프로덕션 함정 회피 가이드

함정: 컨테이너 간/dev/shm가 공유되지 않는다. shmCanConnect확인info1->shmDev != info2->shmDev:

📎 src/transport/shm.cc:76-78

c
  TRACE(NCCL_INIT | NCCL_SHM, "peer1 shmDev %lx peer2 shmDev %lx", info1->shmDev, info2->shmDev);
  if (info1->shmDev != info2->shmDev) return ncclSuccess;

두 컨테이너가 서로 다른/dev/shm,shmDev를 마운트하면 다르고, SHM은 자동으로 NET으로 강등된다. 프로덕션 환경에서 같은 호스트 통신인데 네트워크를 탄다면, 컨테이너의/dev/shm마운트가 일치하는지 확인하라.

4. NET: 네트워크 전송의 매핑 테이블과 프록시 진행

직관적 모델

NET은 「도시 간 택배」다 — 데이터를 패키징해서 NIC에 넘기면, NIC가 광섬유를 통해 상대방에게 보낸다. 하지만 NIC는 GPU 메모리 주소를 인식하지 못하므로, GPU 가상 주소를 NIC가 이해할 수 있는 물리 주소로 변환하는 「주소 매핑 테이블」이 필요하다. 이 테이블이 바로connectMap。

데이터 구조와 메모리 레이아웃

📎 src/transport/net.cc:73-86

c
struct connectMapMem {
  char* gpuPtr;
  char* cpuPtr;
  ssize_t size;
  ncclIpcDesc ipcDesc;
  ncclShmIpcDesc_t attachDesc;
  ncclShmIpcDesc_t createDesc;
};

struct connectMap {
  int sameProcess;
  int shared;
  int cudaDev;
  // First 3 bits of offsets determine the mem bank. 001 is host mem, 011 is dev mem, 101 is shared host mem and 111
  // is shared dev mem.
  struct connectMapMem mems[NCCL_NET_MAP_MEMS];
  // Offsets. 3 MSBs indicate mem bank, 111 indicates NULL.
  struct {
    uint32_t sendMem;
    uint32_t recvMem;
    uint32_t buffs[NCCL_NUM_PROTOCOLS];
  } offsets;
};

connectMap는 「메모리 은행」 시스템이다:mems배열에는 5개의 슬롯(NCCL_NET_MAP_MEMS=5)이 있으며, 각각 host mem, dev mem, shared host mem, shared dev mem, GDC mem에 대응한다.offsets의 각 필드는 32비트 정수로, 상위 3비트는 「어느 은행인지」를 인코딩하고 하위 29비트는 「은행 내 오프셋」을 인코딩한다.

디코딩 매크로:

📎 src/transport/net.cc:36-46

c
#define NCCL_NET_MAP_OFFSET_BANK(mapStruct, offsetName) ((mapStruct)->offsets.offsetName >> 30)

#define NCCL_NET_MAP_OFFSET_NULL(mapStruct, offsetName) (((mapStruct)->offsets.offsetName >> 29) == 0)

#define NCCL_NET_MAP_GET_POINTER(mapStruct, cpuOrGpu, offsetName) \
  (NCCL_NET_MAP_OFFSET_NULL(mapStruct, offsetName) ? \
     NULL : \
     (mapStruct)->mems[NCCL_NET_MAP_OFFSET_BANK(mapStruct, offsetName)].cpuOrGpu##Ptr + \
       ((mapStruct)->offsets.offsetName & NCCL_NET_MAP_MASK_OFFSET))

#define NCCL_NET_MAP_DEV_MEM(mapStruct, offsetName) (((mapStruct)->offsets.offsetName & NCCL_NET_MAP_MASK_DEVMEM) != 0)

NCCL_NET_MAP_GET_POINTER(map, gpu, sendMem)전개 후:offsets.sendMem의 상위 2비트를 bank 인덱스로 취하고,mems[bank].gpuPtr에 하위 29비트 오프셋을 더해 실제 포인터를 얻는다. 이 인코딩은 「어느 메모리 영역 + 영역 내 오프셋」을 하나의 32비트 정수에 압축하여connectMap의 전송 크기를 절약한다.

시나리오 기반 Walkthrough: sendProxyConnect의 매핑 설정

sendProxyConnect는 NET에서 가장 복잡한 함수로, NIC 연결 설정, 버퍼 할당, 메모리 등록을 담당한다:

📎 src/transport/net.cc:858-1041

c
static ncclResult_t sendProxyConnect(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
                                     void* reqBuff, int reqSize, void* respBuff, int respSize, int* done) {
  struct sendNetResources* resources = (struct sendNetResources*)(connection->transportResources);
  ...
  if (resources->shared) {
    // Shared buffers
    ...
    if (resources->maxRecvs > 1 && ncclParamNetSharedComms()) {
      // Connect or reuse connection for a netdev/remote rank.
      ...
      if (comms->sendComm[resources->channelId] == NULL &&
          comms->activeConnect[resources->channelId] == (resources->tpLocalRank + 1)) {
        ret = proxyState->ncclNet->connect(proxyState->netContext, resources->netDev, req->handle,
                                           comms->sendComm + resources->channelId, &resources->netDeviceHandle);
      }
      ...

maxRecvs > 1시 「공유 연결」을 활성화한다: 여러 channel이 동일한 NIC 연결을 재사용하여 연결 수를 줄인다.activeConnect배열은 하나의 local rank만 연결을 시작하도록 보장하여 중복을 피한다.

이어서 버퍼를 할당하고 등록한다:

📎 src/transport/net.cc:933-956

c
  if (resources->shared == 0) {
    // Only allocate dedicated buffers for ring/tree, not for p2p
    for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
      NCCL_NET_MAP_ADD_POINTER(map, 0, p != NCCL_PROTO_LL && resources->useGdr ? 1 : 0, proxyState->buffSizes[p],
                               buffs[p]);
      resources->buffSizes[p] = proxyState->buffSizes[p];
    }
  } else {
    // Get shared buffers
    int bank = resources->useGdr ? NCCL_NET_MAP_SHARED_DEVMEM : NCCL_NET_MAP_SHARED_HOSTMEM;
    struct connectMapMem* mapMem = map->mems + bank;
    NCCLCHECK(sharedNetBuffersInit(proxyState, resources->useGdr, resources->tpLocalRank, 0, map->sameProcess,
                                   proxyState->p2pnChannels, &mapMem->gpuPtr, &mapMem->cpuPtr, &mapMem->size,
                                   &mapMem->ipcDesc));
    resources->buffSizes[NCCL_PROTO_SIMPLE] = mapMem->size;
    ...

NCCL_NET_MAP_ADD_POINTER매크로가 버퍼를connectMap:

📎 src/transport/net.cc:48-62

c
#define NCCL_NET_MAP_ADD_POINTER(mapStruct, shared, dev, memSize, offsetName) \
  do { \
    int bank = NCCL_NET_MAP_MASK_USED + (dev) * NCCL_NET_MAP_MASK_DEVMEM + (shared) * NCCL_NET_MAP_MASK_SHARED; \
    if ((shared) == 0) { \
      if (dev) { \
        (mapStruct)->offsets.offsetName = bank + (mapStruct)->mems[NCCL_NET_MAP_DEVMEM].size; \
        (mapStruct)->mems[NCCL_NET_MAP_DEVMEM].size += memSize; \
      } else { \
        (mapStruct)->offsets.offsetName = bank + (mapStruct)->mems[NCCL_NET_MAP_HOSTMEM].size; \
        (mapStruct)->mems[NCCL_NET_MAP_HOSTMEM].size += memSize; \
      } \
    } else { \
      (mapStruct)->offsets.offsetName = bank; \
    } \
  } while (0);

복사size비공유 버퍼: 현재 bank의offsets를 오프셋으로size += memSize에 기록한 후

— 이것은 bump allocator다. 공유 버퍼: bank 번호를 직접 쓰고 오프셋은 0이다(공유 버퍼 전체가 하나의 bank이기 때문).

📎 src/transport/net.cc:1004-1035

c
  for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
    resources->buffers[p] = NCCL_NET_MAP_GET_POINTER(map, cpu, buffs[p]);
    if (resources->buffers[p]) {
#if CUDA_VERSION >= 11070
      int type = NCCL_NET_MAP_DEV_MEM(map, buffs[p]) ? NCCL_PTR_CUDA : NCCL_PTR_HOST;
      if (type == NCCL_PTR_CUDA && resources->useDmaBuf) {
        int dmabuf_fd;
        size_t dmaBufSize = resources->buffSizes[p];
        ALIGN_SIZE(dmaBufSize, ncclOsGetPageSize());
        CUCHECK(cuMemGetHandleForAddressRange((void*)&dmabuf_fd, (CUdeviceptr)resources->buffers[p], dmaBufSize,
                                              CU_MEM_RANGE_HANDLE_TYPE_DMA_BUF_FD,
                                              getHandleForAddressRangeFlags(resources->useGdr)));
        NCCLCHECK(proxyState->ncclNet->regMrDmaBuf(resources->netSendComm, resources->buffers[p],
                                                   resources->buffSizes[p], type, 0ULL, dmabuf_fd,
                                                   &resources->mhandles[p]));
        (void)close(dmabuf_fd);
      } else
#endif
      {
        NCCLCHECK(proxyState->ncclNet->regMr(resources->netSendComm, resources->buffers[p], resources->buffSizes[p],
                                             NCCL_NET_MAP_DEV_MEM(map, buffs[p]) ? NCCL_PTR_CUDA : NCCL_PTR_HOST,
                                             &resources->mhandles[p]));
      }
      ...

복사cuMemGetHandleForAddressRangeDMA-BUF 경로를 우선한다(regMr로 fd를 얻어 NIC 플러그인에 전달). 실패하면

로 폴백한다(전통적인 nv_peermem GDR).

sendProxyProgress동시성 제어와 하드웨어 상호작용: sendProxyProgress의 3단계 파이프라인

📎 src/transport/net.cc:1324-1491

c
static ncclResult_t sendProxyProgress(struct ncclProxyState* proxyState, struct ncclProxyArgs* args) {
  ...
  if (args->state == ncclProxyOpProgress) {
    int p = args->protocol;
    int maxDepth = std::min(NCCL_STEPS, NCCL_SHARED_STEPS / args->nsubs);
    for (int s = 0; s < args->nsubs; s++) {
      struct ncclProxySubArgs* sub = args->subs + s;
      ...
      // Post buffers to the GPU
      if (sub->posted < sub->nsteps && sub->posted < sub->done + maxDepth) {
        ...
        if (resources->shared) {
          ...
          volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
          sub->posted += args->sliceSteps;
          *sendHead = sub->base + sub->posted - NCCL_STEPS;
          if (resources->gdcSync) wc_store_fence(); // Flush out WC write
        } else {
          sub->posted += args->sliceSteps;
        }
        ...
        continue;
      }
      // Check whether we received data from the GPU and send it to the network
      if (sub->transmitted < sub->posted && sub->transmitted < sub->done + NCCL_STEPS) {
        ...
        if (connFifo[buffSlot].size != -1 && (*recvTail > tail || p == NCCL_PROTO_LL)) {
          ...
          if (ready) {
            ...
            NCCLCHECK(proxyState->ncclNet->isend(resources->netSendComm, buff, size, resources->tpRank,
                                                 sub->sendMhandle, phandle, sub->requests + buffSlot));
            ...
  • post복사sendMem->head: 프록시 스레드가
  • transmit를 갱신하여 GPU에 「버퍼가 준비되었으니 데이터를 써도 된다」고 알린다.recvMem->tail:connFifo[buffSlot].size != -1가 진행되었는지 확인하고(GPU가 쓰기를 완료함),ncclNet->isend를 확인한 후(데이터 크기가 채워짐),
  • done를 호출하여 비동기 전송을 시작한다.ncclNet->test:sendMem->head를 호출하여 전송 완료를 확인하고,

wc_store_fence()를 갱신하여 버퍼를 반환한다.gdcSync는 쓰기 결합 배리어다 — GDRCopy 시나리오에서 CPU가

를 쓴 후 반드시 쓰기 결합 버퍼를 플러시해야 하며, 그렇지 않으면 GPU가 갱신을 볼 수 없다.

프로덕션 함정 회피 가이드함정 1: LL128 프로토콜의 flag 검증.

📎 src/transport/net.cc:1388-1403

c
          if (p == NCCL_PROTO_LL128) {
            ready = resources->useGdr;
            if (!ready) {
              uint64_t flag = sub->base + sub->transmitted + 1;
              int nFifoLines = DIVUP(connFifo[buffSlot].size, sizeof(uint64_t) * NCCL_LL128_LINEELEMS);
              volatile uint64_t* lines = (volatile uint64_t*)buff;
              ready = 1;
              for (int i = 0; i < nFifoLines; i++) {
                if (lines[i * NCCL_LL128_LINEELEMS + NCCL_LL128_DATAELEMS] != flag) {
                  ready = 0;
                  break;
                }
              }
            }
          }

복사threadfence()GPU가useGdr올바른가——GDR 경로에서는 데이터가 직접 VRAM에 저장되므로 행별 검증이 필요 없다.

함정 2: GDRCopy flush의 메모리 순서.수신 측은recvProxyProgress안에 정교한 인라인 어셈블리가 있다:

📎 src/transport/net.cc:1664-1682

c
          if (totalSize > 0 && p == NCCL_PROTO_SIMPLE && needFlush) {
            struct recvNetResources* resources = (struct recvNetResources*)(subGroup->connection->transportResources);
            if (resources->gdcFlush) {
#if defined(__x86_64__)
              asm volatile("mfence" ::: "memory");
              asm volatile("mov (%0), %%eax" ::"l"(resources->gdcFlush) : "%eax", "memory");
#else
              std::atomic_thread_fence(std::memory_order_seq_cst);
              uint64_t dummy;
              NCCLCHECK(ncclGdrCudaRead(resources->gdrDesc, &dummy, resources->gdcFlush, sizeof(dummy)));
#endif
            }

mfenceCQE poll의 읽기가 flush 읽기 이전으로 재배열되지 않도록 보장한다;mov (%0), %%eaxPCIe 읽기를 강제로 한 번发起하여, CPU가 모든 이전 PCIe posted write(网卡 DMA 포함)가 제출될 때까지 정지하게 한다. 이는 GDRCopy 시나리오에서 「网卡가 쓰기를 완료했다고 하지만 데이터는 아직 PCIe 버퍼에 있는」 상황을 방지하는 핵심이다. 이 부분을 제거하면 수신 측이 오래된 데이터를 읽을 수 있다.

mermaid
sequenceDiagram
    participant GPU as GPU Kernel
    participant SM as ncclSendMem
    participant Proxy as sendProxyProgress
    participant NIC as ncclNet->isend
    participant Peer as 对端网卡

    GPU->>SM: 写数据到 buffs[p]
    GPU->>SM: 更新 recvMem->tail
    Proxy->>SM: 读 recvTail, connFifo[buffSlot].size
    Proxy->>Proxy: 检查 ready (LL128 flag / GDR)
    Proxy->>NIC: isend(comm, buff, size, mhandle)
    NIC->>Peer: DMA 发送
    Proxy->>NIC: test(request, &done)
    NIC-->>Proxy: done=1
    Proxy->>SM: 更新 sendMem->head (归还缓冲区)
    Proxy->>GPU: 下一轮 post

5. NVLS: 멀티캐스트 그룹과 UC/MC 메모리 바인딩

직관적 모델

NVLS는 「방송국」이다——하나의 rank가 데이터를 멀티캐스트 그룹에 쓰면, 하드웨어가 자동으로 모든 구독자에게 복사한다. 전통적인 AllReduce는 N-1번의 점대점 전송이 필요하지만, NVLS는 단 1번의 멀티캐스트 쓰기 + 1번의 멀티캐스트 읽기만 필요하다. NVLS가 없으면 대규모 AllReduce의 지연은 rank 수에 따라 선형으로 증가한다.

데이터 구조와 메모리 레이아웃

NVLS의 핵심은 「UC(유니캐스트) 메모리」와 「MC(멀티캐스트) 메모리」의 바인딩이다.nvlsAllocBindUcUC 메모리를 할당하고 MC 그룹에 바인딩:

📎 src/transport/nvls.cc:225-277

c
static ncclResult_t nvlsAllocBindUc(struct ncclComm* comm, const struct ncclMcPartition* partition, size_t size,
                                    struct ncclNvlsUcSegment* outUc) {
  CUmemAllocationProp ucprop;
  ...
  ucprop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
  ucprop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
  ucprop.location.id = comm->cudaDev;
  ucprop.requestedHandleTypes = ncclCuMemHandleType;
  CUCHECKGOTO(cuMemGetAllocationGranularity(&ucgran, &ucprop, CU_MEM_ALLOC_GRANULARITY_RECOMMENDED), ret, fail);
  ALIGN_SIZE(ucsize, ucgran);
  CUCHECKGOTO(cuMemAddressReserve((CUdeviceptr*)&ucptr, ucsize, ucgran, 0U, 0), ret, fail);
  CUCHECKGOTO(cuMemCreate(&ucHandle, ucsize, &ucprop, 0), ret, fail1);
  CUCHECKGOTO(cuMemMap((CUdeviceptr)ucptr, ucsize, 0, ucHandle, 0), ret, fail2);
  CUCHECKGOTO(cuMemSetAccess((CUdeviceptr)ucptr, ucsize, &comm->nvlsResources->accessDesc, 1), ret, fail3);
  CUDACHECKGOTO(cudaMemset(ucptr, 0, ucsize), ret, fail3);
  NCCLCHECKGOTO(ncclMemTrack(comm->memManager, ucptr, ucsize, ucHandle, ncclCuMemHandleType, ncclMemPersist), ret,
                fail3);
  NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
                                          comm->localRankToRank[0]),
                ret, fail3);
  NCCLCHECKGOTO(ncclMcPartitionBindMem(partition, 0 /*offsetInPartition*/, ucHandle, 0 /*memOffset*/, ucsize), ret,
                fail3);
  ...

흐름:cuMemCreate물리 메모리 할당 →cuMemMap가상 주소에 매핑 →cuMemSetAccessGPU 접근 권한 설정 →ncclMcPartitionBindMemUC 물리 메모리를 MC 그룹의 지정된 오프셋에 바인딩. 바인딩 후, 어떤 rank든 MC 주소에 쓰면 하드웨어가 데이터를 모든 바인딩된 UC 메모리에 복사한다.

주의bootstrapIntraNodeBarrier가cuMulticastBindMem이전에——주석에 따르면 이는 「mitigate the possible hang in cuMulticastBindMem during abort」를 위한 것이다. 이는 하드웨어 수준의 방어다: 만약 어떤 rank가 바인딩 과정에서 abort하면, 다른 rank들이cuMulticastBindMem에서 멈출 수 있다.

시나리오 기반 Walkthrough: ncclNvlsBufferSetup의 버퍼 레이아웃

📎 src/transport/nvls.cc:279-368

c
ncclResult_t ncclNvlsBufferSetup(struct ncclComm* comm) {
  ...
  nvlsStepSize = comm->nvlsChunkSize;
  buffSize = nvlsStepSize * NCCL_STEPS;
  nvlsPerRankSize = nChannels * 2 * buffSize;
  nvlsTotalSize = nvlsPerRankSize * nHeads;
  ...
  if (resources->dataUc.ptr == NULL) {
    NCCLCHECKGOTO(nvlsAllocBindUc(comm, &resources->dataPartition, nvlsTotalSize, &resources->dataUc), res, fail);
  }
  ...
  for (int h = 0; h < nHeads; h++) {
    int nvlsPeer = comm->nRanks + 1 + h;
    for (int c = 0; c < nChannels; c++) {
      struct ncclChannel* channel = comm->channels + c;
      struct ncclChannelPeer* peer = channel->peers[nvlsPeer];

      // Reduce UC -> MC
      peer->send[1].conn.buffs[NCCL_PROTO_SIMPLE] = (char*)resources->dataUc.ptr + (h * 2 * nChannels + c) * buffSize;
      peer->recv[0].conn.buffs[NCCL_PROTO_SIMPLE] =
        (char*)resources->dataPartition.ptr + (h * 2 * nChannels + c) * buffSize;

      // Broadcast MC -> UC
      peer->recv[1].conn.buffs[NCCL_PROTO_SIMPLE] =
        (char*)resources->dataUc.ptr + ((h * 2 + 1) * nChannels + c) * buffSize;
      peer->send[0].conn.buffs[NCCL_PROTO_SIMPLE] =
        (char*)resources->dataPartition.ptr + ((h * 2 + 1) * nChannels + c) * buffSize;
      ...

버퍼 레이아웃: 각 head는2 * nChannels개의 buffer를 가진다(절반은 reduce용, 절반은 broadcast용).send[1]와recv[0]는 reduce 방향(UC → MC),recv[1]와send[0]는 broadcast 방향(MC → UC)이다.dataUc.ptr는 로컬 UC 메모리,dataPartition.ptr는 MC 그룹 매핑 주소이다.

설계 사고

〔설계 추론과 아키텍처 트레이드오프〕

왜 NVLS의canConnect는 0을 반환하는가? NVLS는 점대점 전송이 아니라——「일대다」 멀티캐스트 모델이기 때문이다.selectTransport의 루프는 점대점 연결을 위해 설계되었고, NVLS의 연결 설정은ncclNvlsSetup독립 경로를 따른다. NVLS를ncclTransports배열에 넣는 것은 단지free인터페이스(nvlsSendFree/nvlsRecvFree)를 통일하기 위한 것이며, 실제 연결 로직은 완전히 독립적이다.

프로덕션 함정 회피 가이드

함정: MNNVL은 NVLS buffer 등록을 지원하지 않는다.보기ncclNvlsSetup:

여기까지, NCCL은 ncclTransport 추상화 계층을 통해 P2P, SHM, NET, NVLS 네 가지 이기종 채널을 일관된 인터페이스로 성공적으로 통일했으며, 알고리즘 커널은 하위가 NVLink인지 网卡인지 신경 쓸 필요가 없다. 그러나 전송 계층은 「채널을 어떻게 추상화할 것인가」만 해결했을 뿐, 「데이터가 어떻게 비동기적으로 구동되는가」는 아직 답하지 않았다. 다음 장에서는 src/proxy.cc와 src/include/proxy.h에 초점을 맞춰, proxy 스레드가 host 측에서 네트워크 송수신을 어떻게 비동기적으로 진행하며 GPU kernel과 생산자-소비자 관계를 형성하는지 살펴보고, NCCL 비동기성의 핵심 메커니즘을 밝힌다.

CHAPTER 12

제 12 장: 제 12 장: 프록시 스레드 비동기 스케줄링: proxy.cc가 I/O와 kernel 실행을 어떻게 분리하는가

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 12 / 25 장

제 12 장: 프록시 스레드 비동기 스케줄링: proxy.cc가 I/O와 kernel 실행을 어떻게 분리하는가

이전 장에서는 transport 추상화 계층을 분석하며 NCCL이 통일된 인터페이스로 P2P/SHM/NET/NVLS의 차이를 어떻게 가리는지 살펴보았다. 그러나 전송 계층은 「데이터가 어느 채널로 가는가」만 답했을 뿐, 「데이터가 어떻게 비동기적으로 구동되는가」는 아직 답하지 않았다. GPU kernel이 네트워크 대기에서 직접 블로킹되면, 연산 유닛이 I/O에 의해 질식할 것이다. 이 장에서는src/proxy.cc과src/include/proxy.h에 초점을 맞춰, NCCL이 독립적인 host 스레드로 네트워크 I/O를 kernel 실행 경로에서 어떻게 분리하여 GPU와 생산자-소비자 관계를 형성하는지 살펴본다.

12.1 왜 프록시 스레드가 필요한가: 「누가 네트워크를 기다리는가」부터

직관적 모델

식당을 상상해 보자: 주방(GPU kernel)은 요리만 담당하고, 서빙 직원(proxy 스레드)은 요리를 손님(네트워크 상대방)에게 가져다주는 역할을 한다. 만약 요리사가 직접 서빙을 하러 다닌다면, 서빙할 때마다 요리를 멈춰야 하므로 음식 제공 속도가 급락한다. NCCL의 proxy가 바로 그 전담 서빙 직원이다—kernel은 공유 버퍼에 데이터를 쓰고 버퍼에서 데이터를 읽기만 하며, 네트워크 송수신의 번거로운 작업은 모두 host 측 proxy 스레드에 맡긴다.

〔설계 추론 및 아키텍처 트레이드오프〕

만약 proxy가 없다면 시스템은 어떤 재앙에 직면할까? GPU kernel은 SIMT 대규모 병렬 방식이므로, 하나의 warp가 네트워크 폴링에 블로킹되면 전체 SM의 연산 능력이 낭비된다. 더 치명적인 것은 네트워크 송수신이 socket 시스템 호출, verbs 폴링, DMA 디스크립터 제출을 포함하는데, 이러한 작업은 device 코드에서 실행할 수 없다는 점이다. 따라서 NCCL은 네트워크 I/O를 host로 옮기고, kernel과 proxy가 공유 메모리의 FIFO를 통해 「데이터 준비 완료」 신호를 교환하도록 해야 한다.

두 종류 스레드의 역할 분담

NCCL은 host 측에서 두 종류의 proxy 스레드를 시작하며, 그 역할은 완전히 다르다:

  • Service 스레드(ncclProxyService): 제어 평면 요청을 처리한다—연결 설정, 메모리 등록, FD 조회. 하나의 socket을 수신 대기하며, 로컬 rank로부터의 RPC 요청을 받아 setup/connect 등의 작업을 비동기적으로 진행한다.
  • Progress 스레드(ncclProxyProgress): 데이터 평면을 처리한다—실제로 네트워크 송수신을 구동한다. 공유 메모리 풀에서 proxy op를 가져와 transport의proxyProgress콜백을 호출하여 데이터 이동을 진행한다.

📎 src/include/proxy.h:343-345표시ncclProxyState동시에 보유thread(Service)와threadUDS(UDS 서비스)를 가지며, Progress 스레드의 핸들은progressState.thread안에 숨겨져 있다📎 src/include/proxy.h:261-261。

생산자-소비자 관계의 구축

📎 src/proxy.cc:2130-2166의ncclProxyCreate은 스레드가 탄생하는 곳이다:当refCount == 1(첫 번째 comm 생성) 시, comm의 핵심 필드를proxyState에 복사한 후 Service 스레드와 UDS 스레드를 시작한다. Progress 스레드는 여기서 시작되지 않는다는 점에 주목하라—그것은proxyProgressInit이 처음으로 proxy progress가 필요한 연결 설정 시에 지연 시작된다📎 src/proxy.cc:1523-1524。

mermaid
flowchart TD
    create["ncclProxyCreate(comm)"] --> check_ref{"proxyState->refCount == 1?"}
    check_ref -->|否| skip["复用已有线程,直接返回"]
    check_ref -->|是| copy["拷贝 comm 字段到 proxyState"]
    copy --> start_svc["std::thread(ncclProxyService)"]
    start_svc --> start_uds["std::thread(ncclProxyServiceUDS)"]
    start_uds --> wait["等待连接建立请求"]
    wait --> conn_init{"proxyConnInit 发现<br/>tcomm->proxyProgress != NULL?"}
    conn_init -->|是| prog_init["proxyProgressInit()"]
    conn_init -->|否| no_prog["不启动 Progress 线程"]
    prog_init --> shm["ncclShmOpen 创建 opsPool 共享内存"]
    shm --> start_prog["std::thread(ncclProxyProgress)"]

이 그림은 스레드 시작의 실제 분기를 고정한다: 오직tcomm->proxyProgress이 비어 있지 않을 때(즉 해당 transport가 데이터 평면 진행을 필요로 할 때)만 Progress 스레드가 생성된다.

12.2 데이터 구조와 메모리 레이아웃: 공유 메모리 풀과 op 풀

핵심 구조체 전경

proxy의 동시성 모델은 두 개의 공유 메모리 위에 구축되며, 그 메모리 레이아웃을 이해하는 것이 전체 메커니즘을 이해하는 전제이다.

첫 번째 블록:ncclProxyOpsPool(📎 src/include/proxy.h:218-226). 이것은 메인 스레드와 Progress 스레드 사이의 「작업 투입함」이며,/dev/shm을 통해 프로세스 간 공유된다.

필드타입역할
ops[]ncclProxyOp[]사전 할당된 op 배열, 크기MAX_OPS_PER_PEER * NCCL_MAX_LOCAL_RANKS
nextOpsvolatile int처리 대기 op 연결 리스트 헤드 인덱스, -1은 비어 있음을 의미
nextOpsEndvolatile int처리 대기 op 연결 리스트 테일 인덱스
freeOps[]volatile int[]각 local rank의 유휴 op 연결 리스트 헤드
syncObjectsInitializedintmutex/cond가 초기화되었는지 표시
mutex / condstd::mutex / std::condition_variable프로세스 간 동기화 원시 요소

MAX_OPS_PER_PEER의 정의📎 src/include/proxy.h:218-226은2 * MAXCHANNELS * 2 * NCCL_MAX_DEV_WORK_P2P_PER_BATCH이다. 주석은 왜 2배인지 설명한다: 각 p2p work는 하나의 send와 하나의 recv proxy op를 포함하므로 2를 곱해야 하고, 다시 2를 곱하는 것은 두 라운드의 완전한 작업을 저장할 수 있기 위해서이며, 그렇지 않으면 「절반 투입, 절반 해제」가 불가능하다.

두 번째 블록:ncclProxyArgs(📎 src/include/proxy.h:174-209). 이것은 Progress 스레드 내부에서 사용되는 「런타임 op 설명」이며,ncclProxyPool에서 할당되고 프로세스 간 공유되지 않는다.

핵심 필드:

  • subs[NCCL_PROXY_MAX_SUBS]: 하위 작업 배열,NCCL_PROXY_MAX_SUBS = MAXCHANNELS 📎 src/include/proxy.h:55-55. 여러 channel의 동일 유형 작업이 하나의 args의 여러 sub로 집계된다.
  • progress: 함수 포인터, transport의proxyProgress콜백을 가리킴📎 src/include/proxy.h:176-176。
  • next / nextPeer / proxyAppendPtr: 세 개의 연결 리스트 포인터로, 복잡한 op 조직 관계를 구성한다.
  • state:ncclProxyOpNone / ncclProxyOpReady / ncclProxyOpProgress삼태📎 src/include/proxy.h:48-52。

메모리 풀의 계층적 설계

ncclProxyPool 📎 src/proxy.cc:50-53은 일괄 할당 단위이며, 각 pool은PROXYARGS_ALLOCATE_SIZE(즉NCCL_MAX_OPS)개의ncclProxyArgs。allocateArgs 📎 src/proxy.cc:207-231을 포함한다. 의 할당 로직은 자세히 볼 가치가 있다:

c
if (state->pool == NULL) {
    struct ncclProxyPool* newPool;
    NCCLCHECK(ncclCalloc(&newPool, 1));
    struct ncclProxyArgs* newElems = newPool->elems;
    for (int i = 0; i < PROXYARGS_ALLOCATE_SIZE; i++) {
      if (i + 1 < PROXYARGS_ALLOCATE_SIZE) newElems[i].next = newElems + i + 1;
    }
    state->pool = newElems;
    newPool->next = state->pools;
    state->pools = newPool;
}
elem = state->pool;
state->pool = state->pool->next;

📎 src/proxy.cc:207-231

〔설계 추론 및 아키텍처 트레이드오프〕

여기서 설계 동기는 다음과 같다:ncclProxyArgs구조체가 매우 크며(subs[MAXCHANNELS]배열을 포함하고, 각 sub는 다시requests[NCCL_STEPS]를 가짐), 만약 각 op를 개별적으로 malloc하면 심각한 메모리 단편화와 할당 오버헤드가 발생한다. 일괄 할당 + 유휴 연결 리스트 재사용은 할당 비용을 거의 제로에 가깝게 분산시킨다. 주석 「Make sure we allocate the memory close to the network thread」는 이것이 NUMA 친화성을 위한 것임을 암시한다—pool은 Progress 스레드가 처음 할당할 때 생성되어 자연스럽게 해당 스레드가 실행되는 CPU에 가까워진다.

거짓 공유와 원자 변수

ncclProxyOpsPool안의nextOps、nextOpsEnd、freeOps[]은 모두volatile int이다. 이것들은 메인 스레드와 Progress 스레드가 동시에 읽고 쓰지만, NCCL은 모든 접근을 잠금으로 보호하지 않는다—대신 원자 연산 + 메모리 순서로 정확성을 보장한다.

보기ncclLocalOpAppend에서 freeOps로부터 유휴 op를 가져오는 로직📎 src/proxy.cc:503-513:

c
int freeOp = -1;
while (freeOp == -1) {
  freeOp = COMPILER_ATOMIC_EXCHANGE(&pool->freeOps[tpLocalRank], -1, std::memory_order_acquire);
  if (freeOp == -1) std::this_thread::yield();
}

메인 스레드는atomic_exchange을 사용하여freeOps[tpLocalRank]-1로 설정하고 이전 값을 가져옵니다——이것은 「선점적 획득」입니다: 먼저 exchange에 성공한 쪽이 전체 유휴 연결 리스트를 가져갑니다. Progress 스레드가 op를 반환할 때 CAS 루프를 사용합니다📎 src/proxy.cc:898-907:

c
oldFree = COMPILER_ATOMIC_LOAD(&pool->freeOps[i], std::memory_order_acquire);
do {
  pool->ops[freeOpEnd[i]].next = oldFree;
} while (!COMPILER_ATOMIC_COMPARE_EXCHANGE(&pool->freeOps[i], &oldFree, newFree,
                                           std::memory_order_release,
                                           std::memory_order_acquire));
〔설계 추론 및 아키텍처 트레이드오프〕

여기서 seq_cst 대신 acquire/release를 사용하는 이유는 「연결 리스트 노드의 next 포인터 쓰기」가 획득자에게 보이는 것만 보장하면 되고, 전역 순서는 필요하지 않기 때문입니다.freeOps[]배열의 각 요소는 하나의 local rank에 대응하며, 자연스럽게 서로 다른 캐시 라인 근처에 분산되어 있어 거짓 공유를 줄입니다.

12.3 제어 평면: 연결 설정과 RPC 메커니즘

직관적 모델

〔설계 추론 및 아키텍처 트레이드오프〕

Service 스레드는 「프런트 데스크 접수원」과 같습니다: 로컬 rank가 네트워크 연결을 설정할 때 직접 연결하는 것이 아니라, Service 스레드에 RPC 요청을 보내고 그것이 대신 setup/connect를 실행합니다. 왜 이렇게 할까요? 네트워크 연결 설정(특히 verbs의 QP 생성, 메모리 등록)이 블로킹될 수 있고, 일부 리소스(예: listen socket)는 반드시 단일 스레드가 보유해야 하기 때문입니다. 제어 평면을 Service 스레드에 집중시키면 메인 스레드는 논블로킹으로 다른 일을 계속할 수 있습니다.

RPC 요청의 인코딩

ncclProxyCallAsync 📎 src/proxy.cc:1369-1394은 RPC의 송신端입니다. socket을 통해 순서대로 전송합니다: type, connection 포인터, reqSize, respSize, reqBuff, opId.

c
NCCLCHECKGOTO(ncclSocketSend(sock, &type, sizeof(int)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &proxyConn->connection, sizeof(void*)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &reqSize, sizeof(int)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &respSize, sizeof(int)), ret, error);
if (reqSize) NCCLCHECKGOTO(ncclSocketSend(sock, reqBuff, reqSize), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &opId, sizeof(opId)), ret, error);
NCCLCHECK(expectedProxyResponseEnqueue(sharedProxyState, opId, respSize));

📎 src/proxy.cc:1369-1394

마지막 단계에 주목하세요: 요청을 보낸 후 즉시 opId를expectedResponses큐에 등록합니다. 이것이 비동기 RPC의 핵심입니다——호출자는 응답을 기다리지 않고, 먼저 「나는 이 opId의 응답을 기대한다」고 등록한 후, 나중에ncclPollProxyResponse로 폴링합니다.

응답 큐의 연결 리스트 구현

expectedProxyResponseEnqueue 📎 src/proxy.cc:97-117은 단방향 연결 리스트로 대기 중인 op를 저장합니다.expectedProxyResponseStore 📎 src/proxy.cc:67-95은 응답 수신 시 opId로 매칭하여 응답 데이터를 미리 할당된respBuff에 memcpy하고,done = true。expectedProxyResponseDequeue 📎 src/proxy.cc:119-141을 표시합니다. 폴링 시 완료된 응답을 찾아 제거합니다.

여기에는 디테일이 있습니다:expectedProxyResponseStore은respSize이📎 src/proxy.cc:72-75과 일치하는지 확인하고, 일치하지 않으면ncclInternalError을 보고합니다. 이것은 방어적 프로그래밍입니다——요청자와 응답자가 응답 크기에 대한 이해가 다르다면 프로토콜이 어긋난 것이므로, 조용히 계속하지 말고 즉시 실패해야 합니다.

Service 스레드의 메인 루프

ncclProxyService 📎 src/proxy.cc:1789-2016의 핵심은 poll 루프입니다.pollfds배열로 모든 연결을 관리하며, listen socket과 각 peer의 socket을 포함합니다.

c
while (stop == PROXY_RUNNING || npeers > 0) {
    if (COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) != 0) stop = PROXY_ABORT;
    int ret = 0;
    const int timeout = asyncOpCount ? 0 : 500;
    ...
    ret = poll(activePollfds, nfds_to_poll, timeout);

📎 src/proxy.cc:1842-1863

timeout의 선택은 매우 신중합니다: 진행 중인 비동기 op가 있으면(asyncOpCount > 0), timeout을 0(논블로킹 폴링)으로 설정합니다. 왜냐하면proxyProgressAsync을 자주 호출하여 그것들을 진행시켜야 하기 때문입니다. 그렇지 않으면 500ms로 설정하여 공회전으로 CPU를 태우는 것을 방지합니다. 주석 「never let proxy service thread blocks in poll, or it cannot receive abortFlag」📎 src/proxy.cc:1847-1847는 왜 무한 블로킹할 수 없는지 명확히 합니다——주기적으로 깨어나 abortFlag를 확인해야 합니다.

비동기 op의 진행

proxyProgressAsync 📎 src/proxy.cc:1626-1700은 Service 스레드가 비동기 작업을 진행하는 핵심입니다. op 유형에 따라 서로 다른 transport 콜백으로 분배합니다:

c
if (op->type == ncclProxyMsgSetup) {
    res = op->connection->tcomm->proxySetup(op->connection, proxyState, op->reqBuff, op->reqSize, op->respBuff,
                                            op->respSize, &done);
} else if (op->type == ncclProxyMsgConnect) {
    res = op->connection->tcomm->proxyConnect(...);
} else if (op->type == ncclProxyMsgInit) {
    res = proxyConnInit(peer, connectionPool, proxyState, ...);
}

📎 src/proxy.cc:1631-1664

각 콜백은done출력 파라미터를 가집니다. 만약done == 0이면 작업이 아직 완료되지 않았음을 의미하며(예: 네트워크 연결이 아직 3-way handshake 중),ncclInProgress을 반환하고 다음 루프에서 계속 진행합니다. 만약done == 1이면 요청자에게 응답 헤더 + 응답 본문을 전송합니다📎 src/proxy.cc:1681-1689。

mermaid
sequenceDiagram
    participant Main as 主线程 (ncclSend)
    participant Svc as Service 线程
    participant Net as 网络插件 (ncclNet)
    Main->>Svc: ncclProxyCallAsync(ncclProxyMsgConnect)
    Note over Main: expectedProxyResponseEnqueue(opId)
    Svc->>Svc: proxyServiceInitOp 读取请求
    Svc->>Net: proxyConnect() 调用 ncclNet->connect
    alt connect 未完成
        Net-->>Svc: netSendComm == NULL, done=0
        Svc->>Svc: 返回 ncclInProgress,下次 poll 重试
    else connect 完成
        Net-->>Svc: netSendComm != NULL, done=1
        Svc->>Main: ncclSocketSend(resp header + connectMap)
    end
    Main->>Main: ncclPollProxyResponse 轮询
    Main->>Main: expectedProxyResponseDequeue 取回结果

이 시퀀스 다이어그램은sendProxyConnect에 있는*done = 0; return ncclInProgress의 실제 분기를 고정합니다📎 src/transport/net.cc:913-916。

12.4 데이터 평면: Progress 스레드가 네트워크 송수신을 구동하는 방법

직관적 모델

Progress 스레드는 「컨베이어 벨트 운영자」입니다: 공유 버퍼의 FIFO를 주시하다가 GPU가 데이터를 다 쓰면(FIFO에서 size != -1), 즉시isend을 호출하여 데이터를 전송합니다. 네트워크가 데이터 수신을 완료하면 recvTail을 업데이트하여 GPU에 읽어도 된다고 알립니다. 전체 과정에서 GPU와 proxy는 FIFO의 head/tail 포인터로 동기화하며, 어떤 락도 필요하지 않습니다.

op의 전달: 메인 스레드에서 Progress 스레드로

메인 스레드는ncclProxySaveOp 📎 src/proxy.cc:591-761에서 pattern에 따라 필요한 proxy op를 결정한 후,SaveProxy → ncclLocalOpAppend을 통해 op를 공유 메모리 풀에 씁니다.

ncclLocalOpAppend 📎 src/proxy.cc:488-554의 흐름:

1.proxyOps->freeOp또는pool->freeOps[tpLocalRank]에서 유휴 op 슬롯을 가져옵니다.

2. memcpy(op, proxyOp, sizeof(struct ncclProxyOp))op 내용을 공유 메모리📎 src/proxy.cc:515-515。

에 복사합니다proxyOps->nextOps3. op를

연결 리스트 끝에 붙입니다.MAX_OPS_PER_PEER4. 누적된 op 수가📎 src/proxy.cc:525-551。

에 도달하면 일괄 전달📎 src/proxy.cc:529-548。

을 트리거합니다. 일괄 전달의 논리는 매우 미묘합니다: 모든 op를 단순히 전부 보낼 수는 없습니다. 왜냐하면 「같은 opCount의 여러 op는 반드시 함께 전달되어야 하며, 그렇지 않으면 proxyArgs의 sub 집계가 깨지기」 때문입니다. 그래서 마지막 opCount 변화 경계를 찾아 거기까지만 전달합니다ncclProxyPost 📎 src/proxy.cc:476-486전달은pool->nextOps、notify_one을 통해 완료되며, 이것은 락을 걸고

을 업데이트하여 Progress 스레드를 깨웁니다.

ncclProxyProgress 📎 src/proxy.cc:951-1011Progress 스레드의 메인 루프

c
do {
    int idle = 1;
    ncclResult_t ret = progressOps(proxyState, state, state->active, &idle);
    ...
    if (idle || !state->active || (++proxyOpAppendCounter == ncclParamProgressAppendOpFreq())) {
      int added = 0;
      proxyOpAppendCounter = 0;
      ret = ncclProxyGetPostedOps(proxyState, &added);
      ...
    }
    lastIdle = idle;
    stopv = state->stop.load(std::memory_order_acquire);
} while ((stopv == 0 || (stopv == 1 && state->active)) &&
         COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) == 0);

📎 src/proxy.cc:976-1009

복사proxyOpAppendCounter여기서 주목할 만한 성능 최적화가 있습니다:📎 src/proxy.cc:974-974카운터📎 src/proxy.cc:969-973. 주석은ncclProxyGetPostedOps을 설명합니다:ProgressAppendOpFreq(기본 8)번마다 새 op를 한 번 가져옵니다.

op의 집계: ProxyAppend

ProxyAppend 📎 src/proxy.cc:437-474하나의 op가 「기존 args의 sub에 추가」인지 「새 args 생성」인지 결정합니다. 판단 기준은connection->shared && args->opCount == op->opCount 📎 src/proxy.cc:443-443——동일 연결, 동일 opCount의 여러 channel 작업이 집계됩니다.

〔설계 추론 및 아키텍처 트레이드오프〕

집계의 가치: 여러 channel의 동종 작업을 하나의 args로 병합하면 Progress 스레드가 한 번의 루프로 모든 channel을 진행할 수 있어 함수 호출 오버헤드와 캐시 무효화가 줄어듭니다.ncclProxyOpToArgs 📎 src/proxy.cc:368-435sub를 추가할 때 검증합니다sliceSteps、chunkSteps、protocol、dtype、redOp、coll일치 여부📎 src/proxy.cc:401-406불일치하면 오류를 보고합니다——이는 잘못된 집계를 방지하는 방어선입니다.

sendProxyProgress: 송신 측의 4단계 상태 머신

sendProxyProgress 📎 src/transport/net.cc:1324-1491송신 측의 핵심입니다. sub별로 하나씩 진행하며, 각 sub에는 네 개의 카운터가 있습니다:posted、transmitted、done。

1단계: Ready 초기화 📎 src/transport/net.cc:1326-1339

c
sub->base = ROUNDUP(resources->step, args->chunkSteps);
resources->step = sub->base + sub->nsteps;
sub->posted = sub->transmitted = sub->done = 0;

basestep의 시작 번호이며,ROUNDUP에 정렬되도록 보장합니다chunkSteps。resources->step누적하여 다음 op를 위한 공간을 확보합니다.

2단계: Post 버퍼를 GPU에 전달 📎 src/transport/net.cc:1355-1376

c
if (sub->posted < sub->nsteps && sub->posted < sub->done + maxDepth) {
    int buffSlot = (sub->base + sub->posted) % NCCL_STEPS;
    if (resources->shared) {
        ...
        *sendHead = sub->base + sub->posted - NCCL_STEPS;
    } else {
        sub->posted += args->sliceSteps;
    }
}

maxDepth파이프라인 깊이📎 src/transport/net.cc:1343-1343동시에 in-flight 상태인 step 수를 제한합니다. shared 모드에서 proxy는sendHead를 업데이트하여 GPU에 「이 slot에 쓸 수 있다」고 알립니다.

3단계: GPU가 다 썼는지 확인하고 isend 시작 📎 src/transport/net.cc:1378-1452

c
if (sub->transmitted < sub->posted && sub->transmitted < sub->done + NCCL_STEPS) {
    int buffSlot = (sub->base + sub->transmitted) % NCCL_STEPS;
    volatile uint64_t* recvTail = &resources->recvMem->tail;
    uint64_t tail = sub->base + sub->transmitted;
    if (connFifo[buffSlot].size != -1 && (*recvTail > tail || p == NCCL_PROTO_LL)) {
        int size = connFifo[buffSlot].size;
        ...
        NCCLCHECK(proxyState->ncclNet->isend(resources->netSendComm, buff, size, resources->tpRank,
                                             sub->sendMhandle, phandle, sub->requests + buffSlot));
        if (sub->requests[buffSlot] != NULL) {
            sub->transmitted += args->sliceSteps;
        }
    }
}

여기서 핵심 판단은connFifo[buffSlot].size != -1 && *recvTail > tail——GPU가 데이터를 다 쓰면 FIFO의 size와 recvTail을 업데이트하고, proxy는 이 두 조건이 충족된 것을 보고 나서야 isend를 시작합니다. LL 프로토콜의 경우 「제로 카피」 시맨틱이므로 recvTail을 기다릴 필요가 없습니다.

4단계: 전송 완료 확인, sendHead 업데이트 📎 src/transport/net.cc:1455-1481

c
if (sub->done < sub->transmitted) {
    int buffSlot = (sub->base + sub->done) % NCCL_STEPS;
    NCCLCHECK(proxyState->ncclNet->test(sub->requests[buffSlot], &done, &size));
    if (done) {
        connFifo[buffSlot].size = -1;
        std::atomic_thread_fence(std::memory_order_seq_cst);
        sub->done += args->sliceSteps;
        if (resources->shared == 0) {
            volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
            *sendHead = sub->base + sub->done;
        }
    }
}

test가 done을 반환하면 먼저 FIFO size를 -1로 재설정하고, seq_cst fence를 삽입한 뒤, sendHead를 업데이트하여 GPU에 「이 slot을 재사용할 수 있다」고 알립니다. fence의 역할은 size 재설정과 head 업데이트의 재정렬을 방지하는 것입니다——만약 head가 먼저 업데이트되면 GPU가 size가 아직 이전 값일 때 쓰기를 시작할 수 있습니다.

recvProxyProgress: 수신 측의 4단계

recvProxyProgress 📎 src/transport/net.cc:1493-1788더 복잡한데, sub 그룹화(여러 sub가 동일한 recvComm을 공유할 때 multirecv 사용)를 포함하기 때문입니다.

1단계: Ready 시 recvComm별로 그룹화 📎 src/transport/net.cc:1495-1538

c
for (int s = 0; s < args->nsubs; s++) {
    ...
    if (groupSize == maxRecvs) {
        groupSize = 0;
    } else if (s > 0) {
        int next;
        for (next = s; next < args->nsubs; next++) {
            struct recvNetResources* nextRes = ...;
            if (nextRes->netRecvComm == recvComm) break;
        }
        if (next == args->nsubs) {
            groupSize = 0;
        } else if (s != next) {
            // swap subs
        }
    }
    groupSize++;
    ...
    for (int i = 0; i < groupSize; i++) sub[-i].groupSize = groupSize;
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 코드는 동일한recvComm를 사용하는 sub를 함께 배치하고groupSize를 기록합니다. 왜 그룹화할까요? 왜냐하면irecv가 한 번에 여러 buffer를 수신(multirecv)할 수 있으므로, 같은 comm의 요청을 하나의 호출로 병합하면 플러그인 오버헤드를 크게 줄일 수 있기 때문입니다.

2단계: irecv 시작 📎 src/transport/net.cc:1543-1631

c
if (subCount) {
    uint64_t step = subGroup->posted;
    void** requestPtr = subGroup->requests + (step % NCCL_STEPS);
    bool ignoreCompletion = ncclParamNetOptionalRecvCompletion() &&
                            ((args->protocol == NCCL_PROTO_LL128) || (args->protocol == NCCL_PROTO_LL)) &&
                            (subCount == 1);
    if (ignoreCompletion) *requestPtr = (void*)NCCL_NET_OPTIONAL_RECV_COMPLETION;
    NCCLCHECK(proxyState->ncclNet->irecv(resources->netRecvComm, subCount, ptrs, sizes, tags, mhandles, phandles,
                                         requestPtr));
    if (*requestPtr) {
        subGroup->recvRequestsCache[step % NCCL_STEPS] = *requestPtr;
        subGroup->recvRequestsSubCount = subCount;
        for (int i = 0; i < subGroup->groupSize; i++) {
            sub->posted += args->sliceSteps;
        }
    }
}

ignoreCompletion최적화📎 src/transport/net.cc:1608-1610: LL/LL128 프로토콜의 단일 buffer 수신의 경우 완료 통지는 선택 사항이므로(데이터 자체에 flag가 있기 때문) completion 검사를 건너뛸 수 있습니다.

3단계: 수신 완료 확인, recvTail 업데이트 📎 src/transport/net.cc:1634-1743

c
NCCLCHECK(proxyState->ncclNet->test(subGroup->requests[step % NCCL_STEPS], &done, sizes));
if (done) {
    for (int i = 0; i < subGroup->groupSize; i++) {
        struct ncclProxySubArgs* sub = subGroup + i;
        int buffSlot = (sub->base + sub->received) % NCCL_STEPS;
        connFifo[buffSlot].size = -1;
        sub->received += args->sliceSteps;
    }
    ...
}

수신 완료 후 FIFO size를 재설정하고, flush 단계로 진입합니다(GDRDMA 시나리오에서는 데이터 가시성을 보장하기 위해 flush가 필요합니다).

4단계: GPU 소비 대기, done 업데이트 📎 src/transport/net.cc:1745-1779

c
if (sub->transmitted > sub->done) {
    volatile uint64_t* sendHead = &resources->sendMem->head;
    uint64_t done = *sendHead;
    while (done > sub->base + sub->done && sub->transmitted > sub->done) {
        if (subGroup->recvRequestsCache[sub->done % NCCL_STEPS]) {
            if (proxyState->ncclNet->irecvConsumed) {
                NCCLCHECK(proxyState->ncclNet->irecvConsumed(resources->netRecvComm, subGroup->recvRequestsSubCount,
                                                             subGroup->recvRequestsCache[sub->done % NCCL_STEPS]));
            }
            subGroup->recvRequestsCache[sub->done % NCCL_STEPS] = NULL;
        }
        sub->done += args->sliceSteps;
    }
}

여기서sendHead를 읽어 GPU가 데이터를 소비했는지 판단합니다.irecvConsumed는 플러그인에 대한 콜백으로, 「이 수신 요청의 buffer가 소비되었으니 재사용할 수 있다」고 알립니다.

데이터 흐름 전경

mermaid
flowchart LR
    subgraph GPU["GPU Kernel"]
        gpu_write["写入数据到 buff"]
        gpu_fifo["更新 connFifo.size<br/>和 recvTail"]
    end
    subgraph SHM["共享内存 FIFO"]
        fifo["ncclConnFifo<br/>size / offset"]
        head["sendMem->head"]
        tail["recvMem->tail"]
    end
    subgraph PROXY["Progress 线程"]
        check["检查 size != -1<br/>且 recvTail > tail"]
        isend["ncclNet->isend()"]
        test["ncclNet->test()"]
        update["更新 sendHead"]
    end
    gpu_write --> gpu_fifo
    gpu_fifo --> fifo
    gpu_fifo --> tail
    fifo --> check
    tail --> check
    check -->|数据就绪| isend
    isend --> test
    test -->|发送完成| update
    update --> head
    head -->|GPU 可复用 slot| gpu_write

이 데이터 흐름 다이어그램은 GPU와 proxy가 FIFO 및 head/tail 포인터를 통해 형성하는 폐루프를 보여줍니다: GPU가 데이터 쓰기 → tail 업데이트 → proxy가 감지하고 isend 시작 → test로 완료 확인 → head 업데이트 → GPU가 slot 재사용.

12.5 동시성 제어, 메모리 배리어 및 하드웨어 상호작용

락 프리 FIFO의 메모리 순서

proxy와 GPU 간의 동기화는 전적으로ncclConnFifo와 head/tail 포인터에 의존하며, 어떤 락도 없습니다. 이는 극히 신중한 메모리 순서 제어를 요구합니다.

송신 측에서 proxy는test가 done을 반환한 후📎 src/transport/net.cc:1460-1473:

c
connFifo[buffSlot].size = -1;
std::atomic_thread_fence(std::memory_order_seq_cst);
...
*sendHead = sub->base + sub->done;

seq_cst fence는 size 재설정이 GPU에 가시화된 후에 head 업데이트가 가시화되도록 보장합니다. 만약 순서가 뒤바뀌면 GPU가 새 head를 보지만 이전 size를 보게 되어 slot에 데이터가 있다고 오인할 수 있습니다.

수신 측에서 proxy는 recvTail을 업데이트하기 전에📎 src/transport/net.cc:1731-1736:

c
if (step < sub->nsteps) {
    std::atomic_thread_fence(std::memory_order_seq_cst);
    volatile uint64_t* recvTail = resources->gdcSync ? resources->gdcSync : &resources->recvMem->tail;
    *recvTail = sub->base + sub->transmitted;
}

같은 원리입니다: 먼저 fence로 데이터 쓰기 가시성을 보장하고, 그다음 tail을 업데이트하여 GPU에 읽을 수 있다고 알립니다.

GDRCOPY의 flush 메커니즘

GDRDMA를 사용할 때 NIC는 GPU 메모리에 직접 쓰지만, 쓰기 작업이 아직 PCIe 버스에서 커밋되지 않았을 수 있습니다. proxy가 능동적으로 flush해야 데이터 가시성이 보장됩니다.recvProxyProgress의 flush 로직📎 src/transport/net.cc:1664-1709:

c
if (totalSize > 0 && p == NCCL_PROTO_SIMPLE && needFlush) {
    if (resources->gdcFlush) {
#if defined(__x86_64__)
        asm volatile("mfence" ::: "memory");
        asm volatile("mov (%0), %%eax" ::"l"(resources->gdcFlush) : "%eax", "memory");
#else
        std::atomic_thread_fence(std::memory_order_seq_cst);
        uint64_t dummy;
        NCCLCHECK(ncclGdrCudaRead(resources->gdrDesc, &dummy, resources->gdcFlush, sizeof(dummy)));
#endif
    } else {
        // iflush 路径
        NCCLCHECK(proxyState->ncclNet->iflush(resources->netRecvComm, subCount, ptrs, sizes, mhandles,
                                              subGroup->requests + (step % NCCL_STEPS)));
    }
}

x86 경로의 주석이 매우 훌륭합니다📎 src/transport/net.cc:1668-1674:mfenceCQE-poll의 load가 flush load 이전으로 재배치되는 것을 방지한다;mov (%0), %%eaxPCIe 읽기를 강제하여 CPU가 모든 이전 PCIe posted write(NIC DMA 포함)가 엔드포인트에 커밋될 때까지 정지하게 한다. 이는 하드웨어 수준의 메모리 순서 제어로, 어떤 소프트웨어 fence보다도 강력하다.

원자 변수와 stop/abort의 협력

Progress 스레드의 종료 조건📎 src/proxy.cc:1007-1009:

c
stopv = state->stop.load(std::memory_order_acquire);
} while ((stopv == 0 || (stopv == 1 && state->active)) &&
         COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) == 0);

stop == 1그러나state->active != NULL시 계속 실행——이는 '우아한 중지'를 위한 것이다: 이미 투입된 op는 반드시 완료까지 진행되어야 하며, 그렇지 않으면 GPU가 영원히 데이터를 기다리게 된다. 오직stop == 2(abort) 또는abortFlag != 0만이 강제 종료한다.

ncclProxyProgressDestroy 📎 src/proxy.cc:1039-1065의 중지 절차:

c
std::lock_guard<std::mutex> lock(state->opsPool->mutex);
state->stop.store(1, std::memory_order_release);
state->opsPool->cond.notify_one();
state->thread.join();

먼저 잠금을 획득한 후 stop을 store하고, 그 다음 notify——이는 lost wakeup을 방지하는 표준 패턴이다. Progress 스레드는pool->cond.wait시 잠금을 보유하고 술어📎 src/proxy.cc:850-851를 검사하여 깨우침을 놓치지 않도록 보장한다.

12.6 프로덕션 함정 회피 가이드와 장애 복구 체인

함정 1: 연결 누수로 인한 Service 스레드 종료 불가

ncclProxyService의 메인 루프 조건은stop == PROXY_RUNNING || npeers > 0 📎 src/proxy.cc:1842-1842이다. 주석은📎 src/proxy.cc:1843-1845를 설명한다: 로컬 comm이 abort되더라도 peer 연결이 남아 있는 한 proxy 스레드는 종료할 수 없으며, 그렇지 않으면 세그멘테이션 폴트가 발생할 수 있다.

진단 시나리오: 특정 rank가 크래시했지만 상대방에게 통지하지 않은 경우, 상대방의 Service 스레드는npeers > 0의 루프에 계속 갇히게 된다. 이때는abortFlag또는 타임아웃 메커니즘에 의존해야 한다. 프로덕션 환경에서 프로세스가ncclProxyService에서 hang된 것을 발견하면, 먼저 상대 rank의 비정상 종료 여부를 확인하라.

함정 2: 응답 큐 불일치로 인한 메모리 누수

expectedProxyResponseStore는 opId가 불일치할 때ncclInternalError 📎 src/proxy.cc:93-94를 반환한다. 그러나 응답 도착 시 요청자가 이미 포기한 경우(예: 타임아웃), 이 응답은 영원히 큐에 남아respBuff누수가 발생한다.

방어 조치:expectedProxyResponseFree 📎 src/proxy.cc:55-65는ncclProxyDestroy시 전체 큐📎 src/proxy.cc:2226-2226를 정리한다. 그러나 이는 최후의 안전장치이며, 정상 운영 중에는 잔여물이 있어서는 안 된다.

함정 3: shared 모드에서 head가 음수로 초기화됨

sendProxyConnect에서📎 src/transport/net.cc:999-1000:

c
// Don't give credits yet in shared mode.
(resources->gdcSync ? *resources->gdcSync : resources->sendMem->head) = (map->shared ? -NCCL_STEPS : 0);

shared 모드에서 head는-NCCL_STEPS로 초기화되며, 이는 GPU가 처음에 쓸 수 있는 credit이 없음을 의미한다. proxy는 post 단계에서 점진적으로 head를 증가시켜 'credit을 발급'해야 한다. 이 초기화를 잊으면 GPU는 credit이 있다고 착각하여 준비되지 않은 slot에 쓰게 되어 데이터가 손상된다.

함정 4: LL128 프로토콜의 flag 검증

sendProxyProgress에서 LL128의 ready 판단📎 src/transport/net.cc:1388-1403:

c
if (p == NCCL_PROTO_LL128) {
    ready = resources->useGdr;
    if (!ready) {
        uint64_t flag = sub->base + sub->transmitted + 1;
        int nFifoLines = DIVUP(connFifo[buffSlot].size, sizeof(uint64_t) * NCCL_LL128_LINEELEMS);
        volatile uint64_t* lines = (volatile uint64_t*)buff;
        ready = 1;
        for (int i = 0; i < nFifoLines; i++) {
            if (lines[i * NCCL_LL128_LINEELEMS + NCCL_LL128_DATAELEMS] != flag) {
                ready = 0;
                break;
            }
        }
    }
}

데이터가 sysmem(비 GDR)에 있을 때, GPU는threadfence()만 호출하므로 proxy는 flag를 행별로 검사해야 데이터 완전성을 확인할 수 있다. 이 검사를 건너뛰고 바로 isend하면 잘린 데이터를 보낼 수 있다. 이는 LL128 특유의 함정이다.

장애 복구 체인

가proxyProgressAsync가 아닌 값을 반환할 때ncclSuccess/ncclInProgress,📎 src/proxy.cc:1929-1937Service 스레드는 연결을 닫고 해당 peer의 모든 async op📎 src/proxy.cc:1984-1995를 정리한다. 이 정리는 '전량 drain'——실패한 op만 정리하는 것이 아니라 전체 peer의 asyncOps 큐를 비워, 잔여 op가 해제된 연결을 참조하는 것을 방지한다.

Progress 스레드가 오류를 만나면📎 src/proxy.cc:979-983, 오류 코드를proxyState->asyncResult에 기록하고 루프를 종료한다. 메인 스레드는 이후 이 필드를 검사하여 오류를 감지할 수 있다.

이 장 요약

이 장에서 우리는 NCCL 프록시 스레드의 전체 메커니즘을 분석했다:

1. 두 종류 스레드의 분업: Service 스레드는 제어 평면 RPC(연결 설정, 메모리 등록)를 처리하고, Progress 스레드는 데이터 평면(네트워크 송수신 진행)을 처리한다.

2. 공유 메모리 풀:ncclProxyOpsPool이 프로세스 간에 op를 전달하고,ncclProxyArgs이 Progress 스레드 내에서 여러 channel의 작업을 집계한다.

3. 락프리 FIFO 동기화: GPU와 proxy는connFifo와 head/tail 포인터를 통해 데이터 준비 신호를 교환하며, seq_cst fence로 메모리 순서를 보장한다.

4. 4단계 상태 머신: send/recv 각각의 posted → transmitted → received → done 카운터가 파이프라인을 구동한다.

5. 하드웨어 수준 flush: GDRDMA 시나리오에서mfence+ PCIe 읽기로 posted write를 강제 커밋한다.

이 장 생각해보기와 자가 점검

Q1: 만약sendProxyProgress에서sub->done == sub->nsteps시sendHead를 갱신하는 로직을 제거하면(GPU slot 해제를 통지하지 않음), 어떤 시나리오에서 교착 상태가 발생하는가? 왜인가?

참고 해석:sendHead는 GPU가 '어떤 slot을 재사용할 수 있는지' 판단하는 유일한 근거이다. 보기📎 src/transport/net.cc:1469-1473:

c
if (resources->shared == 0) {
    volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
    *sendHead = sub->base + sub->done;
}

이 부분을 제거하면 GPU의 head는 영원히 초기값(shared 모드에서는-NCCL_STEPS, 비 shared에서는 0)에 머문다. GPU kernel은waitSend시head + NCCL_STEPS > step를 검사해야 credit이 있다고 판단한다. head가 전진하지 않으면 GPU는NCCL_STEPS개 slot을 모두 채운 후 credit을 기다리며 영원히 블록되고, proxy는 GPU가 새 데이터를 쓰기를 기다려야 isend할 수 있다——전형적인 생산자-소비자 교착 상태이다. shared 모드에서는 초기 head가 음수이므로 GPU가 처음부터 credit이 없어 더 심각하다.

Q2: ncclLocalOpAppend누적 op가 도달하면MAX_OPS_PER_PEER일괄 전송이 트리거되지만, 코드는 의도적으로 "마지막 opCount의 모든 op를 전송하지 않는다". 만약 단순히 모든 op를 전송하도록 변경하면 어떤 메커니즘이 깨지는가?

참고 해석: 보기📎 src/proxy.cc:525-548의 주석과 로직:

c
// Do not post last operations as we could have more coming with the same opCount, and posting
// them in different batches would break proxyArgs aggregation with subs.
uint64_t lastOpCount = pool->ops[proxyOps->nextOpsEnd].opCount;
int lastOp = -1;
...
for (int op = proxyOps->nextOps; op != proxyOps->nextOpsEnd; op = pool->ops[op].next) {
    ops++;
    if (pool->ops[op].opCount != lastOpCount) {
        lastOp = op;
        toSend = ops;
    }
}

ProxyAppend의 집계 로직📎 src/proxy.cc:443-443은args->opCount == op->opCount에 의존하여 sub를 추가할지 판단한다. 만약 동일한 opCount의 여러 channel op가 두 배치로 나뉘어 전송되면, 첫 번째 배치가 args를 생성하고, 두 번째 배치가 도착할 때args->opCount은 이미 새 op의 opCount와 같지 않게 되어( args가 이미 전진했을 수 있으므로), 본래 집계되어야 할 sub가 독립적인 args로 분리된다. 이는 성능을 저하시킬 뿐만 아니라,ncclProxyOpToArgs안의nChannels/nPeersmin을 취하는 로직📎 src/proxy.cc:399-400을 깨뜨려 잘못된 채널 수 계산을 초래할 수 있다.

Q3: recvProxyProgress의 Ready 단계는recvComm에 따라 sub를 재정렬하고 그룹화한다. 만약 이 그룹화 로직을 제거하고 각 sub가 독립적으로irecv을 호출하게 하면,maxRecvs > 1의 네트워크 카드에서 어떤 결과가 발생하는가?

참고 해석: 보기📎 src/transport/net.cc:1495-1538의 그룹화 로직과📎 src/transport/net.cc:1613-1614의 multirecv 호출:

c
NCCLCHECK(proxyState->ncclNet->irecv(resources->netRecvComm, subCount, ptrs, sizes, tags, mhandles, phandles,
                                     requestPtr));

maxRecvs은 네트워크 카드 플러그인이 선언한 "단일 irecv가 수신할 수 있는 최대 buffer 수"📎 src/transport/net.cc:1525-1525이다.当maxRecvs > 1일 때, 플러그인(예: IB)은 하나의 WQE로 여러 buffer를 수신하는 것을 지원하여 doorbell 오버헤드와 CQE 처리 비용을 현저히 줄일 수 있다. 만약 그룹화를 제거하고 각 sub를 개별적으로 irecv하면,subCount은 항상 1이 되고, 플러그인은 단일 buffer 모드로 퇴화하여 처리량이 감소한다. 더 중요한 것은,recvRequestsCache과irecvConsumed메커니즘📎 src/transport/net.cc:1616-1617이 multirecv를 위해 설계되었다는 점이다——단일 buffer 모드에서는 이러한 캐시 로직이 무효화되어 요청 누수가 발생할 수 있다.

여기까지 우리는 proxy 스레드가 어떻게 네트워크 I/O를 kernel 실행과 분리하여 GPU 계산과 통신을 진정으로 병렬화하는지 이해했다. 그러나 proxy는 단지 구동자일 뿐, 하위 네트워크 전송의 구체적인 구현은 아직 밝혀지지 않았다. 다음 장에서는net_ib을 깊이 파고들어, NCCL이 verbs API를 어떻게 캡슐화하여 InfiniBand 전송을 구현하는지, 그리고 GPUDirect RDMA가 어떻게 네트워크 카드가 GPU 메모리를 직접 읽고 쓸 수 있게 하는지 살펴본다.

CHAPTER 13

제 13 장: 제 13 장: InfiniBand 네트워크 전송: net_ib가 verbs와 GPUDirect RDMA를 캡슐화하는 방법

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 13 / 25 장

제 13 장: InfiniBand 네트워크 전송: net_ib가 verbs와 GPUDirect RDMA를 캡슐화하는 방법

이전 장에서 우리는 proxy 스레드가 어떻게 네트워크 I/O를 GPU kernel에서 분리하여 계산과 통신을 진정으로 병렬화하는지 보았다. 그러나 proxy는 단지 "구동자"일 뿐이다——그것은 ncclNet->isend/irecv라는 추상 인터페이스를 호출하지만, 그 아래가 TCP인지, InfiniBand인지, 아니면 다른 무엇인지 알지 못한다. 이번 장에서는 이 추상화의 층을 걷어내고 src/transport/net_ib와 src/misc/ibvwrap.cc로 들어가, NCCL이 libibverbs라는 C 라이브러리를 어떻게 플러그 가능한 심볼 테이블로 캡슐화하는지, Queue Pair(QP)를 어떻게 설정하는지, 그리고 GPUDirect RDMA가 어떻게 네트워크 카드가 host 메모리를 우회하여 GPU 메모리를 직접 읽고 쓸 수 있게 하는지 살펴본다.

13.1 NCCL이 libibverbs를 직접 호출하지 않는 이유

직관적 모델: 심볼 테이블은 "플러그 가능한 전원 콘센트"

수입 전기 제품을 샀는데 플러그 모양이 집의 콘센트와 맞지 않는다고 상상해 보자. 두 가지 선택이 있다: 전기 제품을 분해해서 배선을 바꾸거나(직접#include <infiniband/verbs.h>하고-libverbs을 링크), 아니면 만능 변환 플러그를 사는 것(런타임에 심볼을 동적으로 로드)이다. NCCL은 후자를 선택했다.

〔설계 추론과 아키텍처 트레이드오프〕

이 선택의 핵심 동기는배포 유연성이다: NCCL은 PyTorch, TensorFlow 등 상위 프레임워크에 의해 라이브러리로 로드되며, 실행 환경에 반드시libibverbs.so이 설치되어 있다고 가정할 수 없다. 만약 컴파일 시점에 하드 링크하면, InfiniBand 드라이버가 없는 머신에서는 전체 NCCL 라이브러리가 로드될 수 없다——단지 NVLink로 단일 머신 통신만 하고 싶어도 마찬가지다. 런타임dlopen+ 심볼 해석을 통해 NCCL은 IB가 없는 머신에서 우아하게 성능을 저하시킬 수 있다.

만약 이 캡슐화 계층이 없다면, 시스템이 직면할 재앙은:순수 NVLink 단일 머신 훈련 작업이 머신에 IB 드라이버가 설치되지 않아 직접 충돌하는 것이다. 이는 클라우드 환경, 개발 머신에서 극히 흔하다.

데이터 구조와 메모리 레이아웃: 심볼 테이블 컨테이너

핵심 데이터 구조는ncclIbvSymbols이며,ibvsymbols.h에 정의되어 있다(이번 장 자료에는 해당 파일이 포함되지 않았지만, 사용 방식으로부터 그 구조를 추론할 수 있다). 이것은 순수 함수 포인터 컨테이너로, 각 필드가 하나의 libibverbs 함수에 대응한다:

c
struct ncclIbvSymbols {
  int (*ibv_internal_fork_init)(void);
  struct ibv_device** (*ibv_internal_get_device_list)(int* num_devices);
  int (*ibv_internal_modify_qp)(struct ibv_qp*, struct ibv_qp_attr*, int);
  // ... 数十个函数指针
};

전역에 인스턴스가 하나만 있으며,std::once_flag과 함께 스레드 안전 초기화를 보장한다:

📎 src/misc/ibvwrap.cc:26-29

c
static std::once_flag initOnceFlag;
static ncclResult_t initResult;
struct ncclIbvSymbols ibvSymbols;

여기서 설계는 매우 절제되어 있다:initOnceFlag은std::once_flag,initResult캐시 초기화 결과,ibvSymbols전역 심볼 테이블이다. 세 가지 모두 정적 저장 기간을 가지며, 생명주기가 전체 프로세스에 걸쳐 있다.

〔설계 추론 및 아키텍처 트레이드오프〕

왜std::once_flag를 사용하고pthread_once를 사용하지 않는가? NCCL의 C++ 코드가 이미<mutex>와<thread>에 의존하고 있기 때문에, 표준 라이브러리를 사용하는 것이 더 일관적이다.call_once의 의미는: 아무리 많은 스레드가 동시에wrap_ibv_symbols()를 호출하더라도, lambda는 한 번만 실행되고 나머지 스레드는 차단 대기한 후 모두 동일한initResult을 얻는다. 이는 수동으로 이중 검사 잠금(DCLP)을 작성하는 것보다 훨씬 안전하다——DCLP는 C++ 메모리 모델에서 유명한 재정렬 함정이 있다.

Step-by-Step: 심볼 해석의 전체 흐름

NCCL이 처음으로 IB 전송이 필요할 때,wrap_ibv_symbols():

📎 src/misc/ibvwrap.cc:26-29

c
ncclResult_t wrap_ibv_symbols(void) {
  std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
  return initResult;
}

buildIbvSymbols를 호출한다.ibvsymbols.cc에 정의되어 있으며(이 장에서는 포함되지 않음), 그 작업은dlopen("libibverbs.so")로 라이브러리를 열고, 각 함수 이름에 대해dlsym를 호출하여 포인터를 채우는 것이다. 특정 심볼을 찾을 수 없으면 해당 필드는 NULL로 유지된다.

이 "NULL 허용" 설계는 전체 래핑 계층에 걸쳐 있다.CHECK_NOT_NULL매크로를 보자:

📎 src/misc/ibvwrap.cc:26-29

c
#define CHECK_NOT_NULL(container, internal_name) \
  if (container.internal_name == NULL) { \
    WARN("lib wrapper not initialized."); \
    return ncclInternalError; \
  }

각 래핑 함수는 호출 전에 해당 심볼이 비어 있지 않은지 확인한다. 이는 다음을 의미한다:구버전 libibverbs에 새로운 함수가 없는 경우, NCCL은 로드 시 충돌하지 않고 실제로 해당 함수를 사용할 때 오류를 보고한다. 이것이 점진적 성능 저하의 핵심이다.

설계 사고: 매크로 래핑의 삼중 책임

ibvwrap.cc에는 7개의 매크로가 정의되어 있으며, 이들은 단순한 문법 설탕이 아니라 삼중 책임을承担한다:

1. 널 포인터 방어:CHECK_NOT_NULL초기화되지 않음을 차단

2. 오류 코드 정규화: libibverbs의 다양한 오류 규약(-1 반환, errno 반환, NULL 포인터 반환)을 통일적으로ncclResult_t

3. 로 변환로그 삽입WARN: 실패 시

가 함수 이름과 errno를 출력IBV_PTR_CHECK_ERRNO가장 복잡한 매크로인

📎 src/misc/ibvwrap.cc:38-45

c
#define IBV_PTR_CHECK_ERRNO(container, internal_name, call, retval, error_retval, name) \
  CHECK_NOT_NULL(container, internal_name); \
  retval = container.call; \
  if (retval == error_retval) { \
    WARN("Call to " name " failed with error %s", strerror(errno)); \
    return ncclSystemError; \
  } \
  return ncclSuccess;

복사retval확장 후 네 가지 작업을 수행한다: 심볼이 비어 있지 않은지 확인, 호출 실행, 반환값을ibv_pd*에 쓰기(일반적으로 포인터 매개변수를 통해 반환되는strerror(errno)등), 오류 값과 같은지 판단. 주목할 점은ibv_alloc_pd——libibverbs의 포인터 반환형 함수(예:errno)는 실패 시 NULL을 반환하고errno를 설정하므로, 여기서

를 읽는 것이 맞다.IBV_INT_CHECK그리고

📎 src/misc/ibvwrap.cc:84-91

c
#define IBV_INT_CHECK(container, internal_name, call, error_retval, name) \
  CHECK_NOT_NULL(container, internal_name); \
  int ret = container.call; \
  if (ret == error_retval) { \
    WARN("Call to " name " failed"); \
    return ncclSystemError; \
  } \
  return ncclSuccess;

복사errno여기서는ibv_fork_init를 읽지 않는다. 왜냐하면 이러한 함수(예:

)는 직접 -1을 반환하여 실패를 나타내며, 오류 정보가 이미 손실되었기 때문이다.

〔설계 추론 및 아키텍처 트레이드오프〕net_ib.cc이러한 "함수마다 다른 매크로 사용" 방식은 번거로워 보이지만 필요하다: libibverbs의 API 오류 규약이 극도로 불통일하여, 어떤 것은 0/-1을 반환하고, 어떤 것은 errno 값을 반환하며, 어떤 것은 포인터를 반환한다. 억지로 통일하면 오히려 오류 정보가 손실된다. NCCL은 "있는 그대로 번역"을 선택하여 복잡성을 래핑 계층에 남기고, 상위ncclSuccess。

는

만 판단하면 된다.

ibvcore.h13.2 ibvcore.h: 헤더 파일에 의존하지 않는 ABI 계약직관적 모델: 자체 사전을 가진 번역가는 특이한 파일이다——libibverbs의 핵심 구조체, 열거형, 상수를#include <infiniband/verbs.h>재정의했다

. 왜? NCCL이

없이 이러한 타입을 사용해야 하기 때문이다.infiniband/verbs.h〔설계 추론 및 아키텍처 트레이드오프〕dlopen이는 실제 엔지니어링 문제를 해결한다:

는 다른 배포판, 다른 드라이버 버전에서 내용이 다르다. NCCL이 이를 직접 포함하면 컴파일 시 특정 버전에 바인딩된다. "최소 필요 부분집합"을 자체 정의함으로써 NCCL은 컴파일 시 IB 헤더 파일이 필요 없고, 런타임에를 통해 모든 버전의 라이브러리를 로드할 수 있다.libibverbs-dev이 계층이 없으면 재앙이다:가 설치되지 않은 머신에서 NCCL을 컴파일할 수 없다rdma-core. 실제로 런타임에는

를 통해 라이브러리 파일을 제공할 수 있다.

핵심 구조체의 메모리 레이아웃

ibv_gidRDMA 이해에 가장 중요한 몇 가지 구조체를 분석해 보자.

📎 src/include/ibvcore.h:58-64

c
union ibv_gid {
	uint8_t			raw[16];
	struct {
		uint64_t	subnet_prefix;
		uint64_t	interface_id;
	} global;
};

복사ibvGetGidStrGID는 InfiniBand의 "IP 주소"로, 16바이트이다. 16바이트 배열로도 접근할 수 있고, 두 개의 64비트 정수로도 접근할 수 있다. RoCE(RDMA over Converged Ethernet) 시나리오에서 GID는 실제로 IPv6 주소이다——이것이inet_ntop(AF_INET6, ...)가

📎 src/include/ibvwrap.h:102-108

c
static inline const char* ibvGetGidStr(union ibv_gid* gid, char* gidStr, size_t strLen) {
  static_assert(sizeof(union ibv_gid) == sizeof(struct in6_addr),
                "the sizeof struct ibv_gid must be the size of struct in6_addr");
  return inet_ntop(AF_INET6, gid->raw, gidStr, strLen);
}

static_assert복사ibv_gid는 컴파일 시in6_addr와inet_ntop의 크기가 일치함을 보장하여,

ibv_mr가 이 16바이트를 올바르게 해석할 수 있도록 한다.

📎 src/include/ibvcore.h:402-410

c
struct ibv_mr {
	struct ibv_context     *context;
	struct ibv_pd	       *pd;
	void		       *addr;
	size_t			length;
	uint32_t		handle;
	uint32_t		lkey;
	uint32_t		rkey;
};

복사addr이것은 GPUDirect RDMA의 핵심이다.length는 등록된 메모리 시작 주소(호스트 메모리일 수도 있고, GPU 메모리가 호스트에 매핑된 주소일 수도 있음)이고,lkey는 길이이다.rkey(local key)와lkey(remote key)는 네트워크 카드가 접근 권한을 검증하는 데 사용하는 "열쇠"이다——송신 측은 WQE에rkey를 포함하고, 수신 측은

로 검증한다.

〔설계 추론 및 아키텍처 트레이드오프〕addr왜 등록이 필요한가? 네트워크 카드가 DMA를 수행할 때 물리 주소를 사용하는데,lkey/rkey는 가상 주소이기 때문이다. 등록 과정에서 드라이버가 이 가상 주소의 페이지 테이블을 "고정"(pin)하고, IOMMU 매핑을 설정하며, 이후 참조를 위한 핸들로

ibv_send_wr를 반환한다. 등록은 비용이 많이 든다(페이지 테이블 순회와 IOMMU 프로그래밍 포함). 따라서 NCCL은 MR을 캐시하여 매 전송마다 등록하는 것을 피한다.

📎 src/include/ibvcore.h:704-738

c
struct ibv_send_wr {
	uint64_t		wr_id;
	struct ibv_send_wr     *next;
	struct ibv_sge	       *sg_list;
	int			num_sge;
	enum ibv_wr_opcode	opcode;
	int			send_flags;
	uint32_t		imm_data;
	union {
		struct {
			uint64_t	remote_addr;
			uint32_t	rkey;
		} rdma;
		// ...
	} wr;
};

복사wr_id이것은 "네트워크 카드가 무엇을 해야 하는가"에 대한 설명이다.sg_list는 사용자 정의 태그(완료 시 그대로 반환됨),opcode는 산란-수집 목록(scatter-gather list),wr.rdma.remote_addr및wr.rdma.rkey상대방의 대상 주소와 접근 키를 지정합니다.

ibv_sge로컬 메모리 일부를 설명합니다:

📎 src/include/ibvcore.h:698-702

c
struct ibv_sge {
	uint64_t		addr;
	uint32_t		length;
	uint32_t		lkey;
};

주의addr는uint64_t이며 포인터가 아닙니다 — WQE는 NIC 하드웨어에 의해 읽히므로 반드시 고정된 64비트 형식이어야 합니다.

인라인 함수: 심볼 테이블을 우회하는 빠른 경로

일부 함수는 NCCL이 심볼 테이블을 거치지 않고 인라인 구현을 선택합니다. 예를 들어ibv_post_send:

📎 src/include/ibvcore.h:1099-1101

c
static inline int ibv_post_send(struct ibv_qp *qp, struct ibv_send_wr *wr, struct ibv_send_wr **bad_wr) {
  return qp->context->ops.post_send(qp, wr, bad_wr);
}

그것은 직접qp->context->ops.post_send함수 포인터를 통해 호출됩니다. 이것은 libibverbs의 전형적인 설계입니다:ibv_context안에 하나의ops구조체가 있으며, 모든 연산 함수 포인터를 포함하고 구체적인 드라이버에 의해 채워집니다.

〔설계 추론 및 아키텍처 트레이드오프〕

왜post_send는ops를 거치고 심볼 테이블을 거치지 않습니까? 왜냐하면post_send는데이터 경로상의 핫 함수이며, 매번 전송할 때마다 호출됩니다. 만약dlsym로 해석되는 전역 심볼 테이블을 거치면 간접 주소 지정이 한 번 더 발생합니다. 반면qp->context->ops를 통하면 컴파일러가 더 나은 최적화를 할 수 있고, 이 포인터는 QP 생성 시에 고정됩니다. 이와 대조적으로,ibv_modify_qp는 제어 경로 함수이며 호출 빈도가 낮아 심볼 테이블을 거쳐도 상관없습니다.

NCCL의 래핑wrap_ibv_post_send도 인라인입니다:

📎 src/include/ibvwrap.h:77-85

c
static inline ncclResult_t wrap_ibv_post_send(struct ibv_qp* qp, struct ibv_send_wr* wr, struct ibv_send_wr** bad_wr) {
  int ret = qp->context->ops.post_send(
    qp, wr, bad_wr);
  if (ret != IBV_SUCCESS) {
    WARN("ibv_post_send() failed with error %s, Bad WR %p, First WR %p", strerror(ret), wr, *bad_wr);
    return ncclSystemError;
  }
  return ncclSuccess;
}

주의IBV_SUCCESS는 0으로 정의됩니다:

📎 src/include/ibvwrap.h:23-25

c
typedef enum ibv_return_enum {
  IBV_SUCCESS = 0,
} ibv_return_t;

설계 사고: ABI 호환성의 "버전 탐지"

ibvcore.h안에 정교한 ABI 버전 탐지 코드가 있습니다:

📎 src/include/ibvcore.h:81

c
static void *__VERBS_ABI_IS_EXTENDED = ((uint8_t *)NULL) - 1;

이것은 "매직 포인터"입니다 — 값은(uint8_t*)0 - 1, 즉0xFFFFFFFFFFFFFFFF입니다. 이것은ibv_context.abi_compat필드의 마커 값으로 사용됩니다:

📎 src/include/ibvcore.h:1072-1081

c
static inline struct verbs_context *verbs_get_ctx(struct ibv_context *ctx)
{
	if (ctx->abi_compat != __VERBS_ABI_IS_EXTENDED)
		return NULL;
	return (struct verbs_context *)(((uintptr_t)ctx) -
					offsetof(struct verbs_context,
						 context));
}

만약abi_compat가 이 매직 값과 같다면, 하위 라이브러리가 확장 ABI를 지원한다는 의미이며, 이때container_of기법을 통해ibv_context에서 역으로 외부의verbs_context。verbs_context의 마지막 필드가ibv_context:

📎 src/include/ibvcore.h:1068-1069

c
	size_t   sz;			/* Must be immediately before struct ibv_context */
	struct ibv_context context;	/* Must be last field in the struct */
〔설계 추론 및 아키텍처 트레이드오프〕

이것은 C 언어로 "상속"을 구현하는 전형적인 기법입니다:verbs_context는ibv_context를 "상속"하며, 기반 클래스를 끝에 배치함으로써container_of를 사용해 기반 클래스 포인터에서 파생 클래스 포인터를 역추적할 수 있습니다.sz필드는 구조체 크기를 기록하며 버전 호환성에 사용됩니다 — 새 버전 라이브러리는 구조체를 확장할 수 있고, 구버전 코드는sz를 검사하여 특정 필드가 존재하는지 판단합니다.

verbs_get_ctx_op매크로는 이 검사를 더욱 캡슐화합니다:

📎 src/include/ibvcore.h:1083-1086

c
#define verbs_get_ctx_op(ctx, op) ({ \
	struct verbs_context *__vctx = verbs_get_ctx(ctx); \
	(!__vctx || (__vctx->sz < sizeof(*__vctx) - offsetof(struct verbs_context, op)) || \
	 !__vctx->op) ? NULL : __vctx; })

그것은 세 가지를 검사합니다: 확장 ABI인지, 구조체가 해당 필드를 포함할 만큼 충분히 큰지, 해당 필드가 비어 있지 않은지. 모두 만족해야만 유효한 포인터를 반환합니다. 이것이ibv_query_port_ex가 안전하게 호출될 수 있는 기초입니다:

📎 src/include/ibvcore.h:1121-1132

c
static inline int ibv_query_port_ex(struct ibv_context *context,
				    uint8_t port_num,
				    struct ibv_port_attr *port_attr)
{
	struct verbs_context *vctx = verbs_get_ctx_op(context, query_port);
        if (vctx) {
          return vctx->query_port(context, port_num, port_attr, sizeof(*port_attr));
        }
        return -1;
}

만약 하위 라이브러리가 확장query_port를 지원하지 않으면 -1을 반환하고, 호출자wrap_ibv_query_port는 구 API로 폴백합니다:

📎 src/misc/ibvwrap.cc:156-171

c
ncclResult_t wrap_ibv_query_port(struct ibv_context* context, uint8_t port_num, struct ibv_port_attr* port_attr) {
#ifndef NCCL_BUILD_RDMA_CORE
  // First try and query the extended port attributes (e.g. active_speed_ex)
  if (ibv_query_port_ex(context, port_num, port_attr) != 0) {
    // Fall back to the original attribute API call, but zero all members first
    memset(port_attr, 0, sizeof(*port_attr));
    IBV_INT_CHECK_RET_ERRNO(ibvSymbols, ibv_internal_query_port, ibv_internal_query_port(context, port_num, port_attr),
                            0, "ibv_query_port");
  }
#else
  IBV_INT_CHECK_RET_ERRNO(ibvSymbols, ibv_internal_query_port, ibv_internal_query_port(context, port_num, port_attr), 0,
                          "ibv_query_port");
#endif
  return ncclSuccess;
}

주의memset(port_attr, 0, sizeof(*port_attr))— 폴백 전에 먼저 0으로 초기화해야 합니다. 구 API는active_speed_ex등의 새 필드를 채우지 않으므로, 0으로 초기화하지 않으면 스택의 쓰레기 값을 읽게 됩니다.

13.3 QP 상태 머신과 modify_qp의 재시도 예술

직관적 모델: QP는 "전화 걸기"의 전체 과정

Queue Pair(QP)는 RDMA 통신의 기본 단위이며, 송신 큐(SQ)와 수신 큐(RQ)를 포함합니다. QP를 설정하는 것은 전화를 거는 것과 같습니다: 먼저 다이얼을 돌리고(RESET→INIT), 상대방이 받기를 기다리고(INIT→RTR), 양쪽이 들을 수 있는지 확인한 후(RTR→RTS), 그제서야 통화할 수 있습니다.

만약 QP 상태 머신에 오류가 발생하면 재앙은:NIC가 연결을 설정할 수 없고, 모든 크로스 머신 통신이 실패하며, 훈련 작업이 멈추거나 충돌합니다. 그리고 QP 상태 전환은恰恰 가장 문제가 발생하기 쉬운 곳입니다 — 네트워크 지터, GID 변경, 크로스 rail 연결 오류 모두ibv_modify_qp실패를 초래합니다.

상태 열거와 전환

📎 src/include/ibvcore.h:636-645

c
enum ibv_qp_state {
	IBV_QPS_RESET,
	IBV_QPS_INIT,
	IBV_QPS_RTR,
	IBV_QPS_RTS,
	IBV_QPS_SQD,
	IBV_QPS_SQE,
	IBV_QPS_ERR,
	IBV_QPS_UNKNOWN
};

이것은 표준 RDMA QP 상태 머신입니다. NCCL의ibvQpStateName는 열거를 읽을 수 있는 문자열로 변환하여 로그에 사용합니다:

📎 src/misc/ibvwrap.cc:263-293

c
static void ibvQpStateName(enum ibv_qp_state state, char* msg, const size_t len) {
  switch (state) {
  case (IBV_QPS_RESET):
    snprintf(msg, len, "RESET");
    break;
  case (IBV_QPS_INIT):
    snprintf(msg, len, "INIT");
    break;
  // ...
  }
}

아래 상태 다이어그램은 소스 코드의 열거와 전환 의미에 정확히 대응합니다:

mermaid
stateDiagram-v2
    [*] --> RESET : ibv_create_qp()
    RESET --> INIT : modify_qp(IBV_QPS_INIT) [设置 pkey_index, port]
    INIT --> RTR : modify_qp(IBV_QPS_RTR) [设置 ah_attr, dest_qp_num, rq_psn]
    RTR --> RTS : modify_qp(IBV_QPS_RTS) [设置 sq_psn, timeout, retry_cnt]
    RTS --> SQD : modify_qp(IBV_QPS_SQD) [SQ Drain]
    SQD --> RTS : modify_qp(IBV_QPS_RTS)
    RTS --> ERR : 硬件错误 / WC 错误
    RTR --> ERR : 硬件错误
    ERR --> RESET : modify_qp(IBV_QPS_RESET) [错误恢复]
〔설계 추론 및 아키텍처 트레이드오프〕

주의IBV_QPS_SQD(SQ Drained)와IBV_QPS_SQE(SQ Error) 두 상태에 주목하십시오. SQD는 우아한 종료에 사용됩니다 — 송신 큐를 비운 후 전환합니다. SQE는 송신 큐 오류를 나타냅니다. NCCL은 정상 경로에서 이 두 상태로 능동적으로 진입하지 않지만, 오류 처리 시 이들을 식별해야 합니다.

단계별: modify_qp의 재시도 로직

wrap_ibv_modify_qp는 이 장에서 가장 복잡한 함수이며, 완전한 재시도 메커니즘을 구현합니다:

📎 src/misc/ibvwrap.cc:360-385

c
ncclResult_t wrap_ibv_modify_qp(struct ibv_qp* qp, struct ibv_qp_attr* attr, int attr_mask) {
  char qpMsg[1024];
  int ret = 0, attempts = 0;
  int maxCnt = (int)ncclParamIbMQpRetryCnt() + 1; // number of attempts = number of retry + 1
  int timeOut = (int)ncclParamIbMQpRetryTimeout();
  CHECK_NOT_NULL(ibvSymbols, ibv_internal_modify_qp);
  do {
    if (attempts > 0) {
      unsigned int sleepTime = timeOut * attempts;
      ibvModifyQpLog(qp, attr->qp_state, attr, attr_mask, qpMsg, sizeof(qpMsg));
      INFO(NCCL_NET, "Call to ibv_modify_qp failed with %d %s, %s, retrying %d/%d after %u msec of sleep", ret,
           strerror(ret), qpMsg, attempts, maxCnt, sleepTime);
      // sleep before retrying
      std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
    }
    ret = ibvSymbols.ibv_internal_modify_qp(qp, attr, attr_mask);
    attempts++;
  } while (IBV_MQP_RETRY_ERRNO_ALL(ret) && attempts < maxCnt);
  if (ret != 0) {
    ibvModifyQpLog(qp, attr->qp_state, attr, attr_mask, qpMsg, sizeof(qpMsg));
    WARN("Call to ibv_modify_qp failed with %d %s, %s", ret, strerror(ret), qpMsg);
    printIbModifyQpHint(ret);
    return ncclSystemError;
  }
  return ncclSuccess;
}

단계별 분해:

첫 번째 단계: 매개변수 읽기。maxCnt = IbMQpRetryCnt() + 1, 기본 재시도 34회이므로 최대 35회 시도합니다.timeOut기본 100밀리초.

두 번째 단계: 재시도 루프 진입. 첫 번째attempts == 0는 sleep하지 않고 직접 호출합니다. 이후 매번 실패할 때마다,sleepTime = timeOut * attempts— 이것은선형 백오프이며, 1번째 재시도는 100ms 대기, 2번째는 200ms 대기, 34번째는 3400ms 대기합니다.

세 번째 단계: 재시도 여부 판단。IBV_MQP_RETRY_ERRNO_ALL(ret)가 계속할지 결정합니다:

📎 src/misc/ibvwrap.cc:107-109

c
#define IBV_ERR_EQ(e, code) (e == code || e == (-code))
#define IBV_MQP_RETRY_ERRNO(e) (IBV_ERR_EQ(e, ETIMEDOUT))
#define IBV_MQP_RETRY_ERRNO_ALL(e) (ncclParamIbMQpRetryAll() ? (e != 0) : IBV_MQP_RETRY_ERRNO(e))

기본적으로ETIMEDOUT에만 재시도합니다.IBV_ERR_EQ는 양수와 음수 값을 동시에 매칭하는데, 드라이버마다ETIMEDOUT또는-ETIMEDOUT를 반환할 수 있기 때문입니다. 만약NCCL_IB_MQP_RETRY_ALL=1가 설정되면, 모든 0이 아닌 오류에 대해 재시도합니다.

네 번째 단계: 실패 시 진단 정보 출력。ibvModifyQpLog는 장치 이름, 포트 번호, 현재 상태, 대상 상태, 로컬/원격 GID를 수집합니다:

📎 src/misc/ibvwrap.cc:297-339

c
static void ibvModifyQpLog(struct ibv_qp* qp, enum ibv_qp_state qpState, struct ibv_qp_attr* userAttr, int userFlag,
                           char* msg, size_t msgLen) {
  // ...
  char nextState[32], currState[32];
  ibvQpStateName(qp->state, currState, sizeof(currState));
  ibvQpStateName(qpState, nextState, sizeof(nextState));
  char devName[IBV_SYSFS_NAME_MAX] = "";
  snprintf(devName, sizeof(devName), "%s",
           (qp->pd->context) ? wrap_ibv_get_device_name(qp->pd->context->device) : "N/A");
  // ...
}

주의QP_ATTR매크로의 교묘한 설계:

📎 src/misc/ibvwrap.cc:295

c
#define QP_ATTR(attr, userAttr, userFlag, mask) ((userFlag & mask) ? (userAttr) : (attr))

그것은 사용자가 전달한 속성을 우선 사용하고(만약attr_mask에 해당 비트가 설정되어 있으면), 그렇지 않으면query_qp에서 조회한 현재 속성으로 폴백합니다. 이렇게 하면query_qp가 실패하더라도 사용자 매개변수에서 일부 정보를 얻을 수 있습니다.

다섯 번째 단계: 실패 시 힌트 제공。printIbModifyQpHint는 일반적인 오류 코드에 대해 문제 해결 제안을 제공합니다:

📎 src/misc/ibvwrap.cc:341-358

c
static void printIbModifyQpHint(int status) {
  switch (status) {
  case ETIMEDOUT:
    INFO(NCCL_NET, "HINT: In many cases this error indicates that the NICs are not cross-rail connected.");
    INFO(NCCL_NET, "HINT: To confirm, set NCCL_CROSS_NIC=0 to disable cross-rail communication ...");
    return;
  case EINVAL:
    INFO(NCCL_NET, "HINT: In many cases this error indicates that an incorrect GID index is forced by "
                   "NCCL_IB_GID_INDEX, or that a NIC's GID changed mid-run.");
    // ...
  }
}
〔설계 추론 및 아키텍처 트레이드오프〕

이 힌트는 생산 경험의 결정체입니다.ETIMEDOUT의 가장 흔한 원인은 크로스 rail 연결 문제입니다 — 멀티 rail 네트워크에서 rank A의 NIC 0이 rank B의 NIC 1에 연결하려고 하는데, 같은 rail에 있지 않으면 타임아웃됩니다.EINVAL는 일반적으로 GID 인덱스 구성 오류, 또는 실행 중 GID 변경(예: NIC 재설정)입니다.

동시성 제어와 하드웨어 상호작용

wrap_ibv_modify_qp자체적으로 잠금을 걸지 않는다——호출자가 동일한 QP가 멀티스레드에 의해 동시에 수정되지 않도록 보장한다고 가정한다. 이는 NCCL에서 성립한다: QP 설정은 초기화 단계에서 단일 스레드에 의해 수행된다.

〔설계 추론 및 아키텍처 트레이드오프〕

그러나 재시도 루프 내의std::this_thread::sleep_for은 주목할 만하다. CPU를 양보하지만 어떤 잠금도 해제하지 않는다(애초에 잠금을 보유하지 않았으므로). proxy 스레드에서 이 함수를 호출할 때, sleep은 proxy의 진행을 차단한다——QP 설정이 막히면 전체 통신이 정체된다. 이것이 기본 재시도 횟수가 34회, 총 시간 약 60초인 이유다——짧은 네트워크 지터를 충분히 커버하지만 무한정 대기하지는 않는다.

13.4 메모리 등록: GPUDirect RDMA의 진입점

직관적 모델: NIC에 "출입 카드"를 발급하기

NIC가 메모리를 직접 읽고 쓰려면 먼저 이 메모리를 "인식"해야 한다. 메모리 등록(ibv_reg_mr)은 NIC에 출입 카드를 발급하는 것이다——이 메모리의 물리 주소 범위를 알려주고,lkey(로컬 키)와rkey(원격 키)를 반환한다. 이후 NIC가 DMA를 수행할 때 이 키로 접근한다.

메모리 등록이 없으면 재앙은:NIC가 어떤 메모리에도 접근할 수 없어 RDMA가 전혀 작동하지 않는다. 더 은밀한 문제는: host 메모리를 등록했지만 GPU VRAM에 접근하려 하면, NIC가 잘못된 데이터를 읽거나 보호 오류를 트리거한다.

세 가지 등록 경로

NCCL은 서로 다른 사용 시나리오에 대응하는 세 가지 메모리 등록 함수를 래핑한다:

경로 1: 일반 등록

📎 src/misc/ibvwrap.cc:198-201

c
ncclResult_t wrap_ibv_reg_mr(struct ibv_mr** ret, struct ibv_pd* pd, void* addr, size_t length, int access) {
  IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_mr, ibv_internal_reg_mr(pd, addr, length, access), *ret, NULL,
                      "ibv_reg_mr");
}

이것은 표준 경로이며,addr은 가상 주소,access은 접근 권한 플래그(IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE등)이다.

경로 2: IOVA 지정 등록

📎 src/misc/ibvwrap.cc:211-219

c
ncclResult_t wrap_ibv_reg_mr_iova2(struct ibv_mr** ret, struct ibv_pd* pd, void* addr, size_t length, uint64_t iova,
                                   int access) {
  if (ibvSymbols.ibv_internal_reg_mr_iova2 == NULL) {
    return ncclInternalError;
  }
  if (ret == NULL) return ncclSuccess; // Assume dummy call
  IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_mr_iova2, ibv_internal_reg_mr_iova2(pd, addr, length, iova, access),
                      *ret, NULL, "ibv_reg_mr_iova2");
}

iova(I/O Virtual Address)는 NIC가 보는 주소를 지정할 수 있게 한다. 고정 주소 매핑이 필요한 시나리오에서 유용하다. 주의:ret == NULL시 바로 성공을 반환한다——이것은 "탐지 호출"로, 함수 존재 여부만 확인하고 실제로 등록하지 않는다.

경로 3: DMA-BUF 등록 (GPUDirect RDMA의 핵심)

📎 src/misc/ibvwrap.cc:222-227

c
ncclResult_t wrap_ibv_reg_dmabuf_mr(struct ibv_mr** ret, struct ibv_pd* pd, uint64_t offset, size_t length,
                                    uint64_t iova, int fd, int access) {
  IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_dmabuf_mr,
                      ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access), *ret, NULL,
                      "ibv_reg_dmabuf_mr");
}

이것이 GPUDirect RDMA의 핵심이다.fd은 DMA-BUF 파일 디스크립터로——GPU VRAM 블록을 나타낸다. NCCL은cuMemGetHandleForAddressRange같은 CUDA API를 통해 이 fd를 얻은 후ibv_reg_dmabuf_mr에 전달한다. NIC 드라이버는 DMA-BUF 메커니즘을 통해 GPU VRAM을 직접 매핑하며, host 메모리 복사를 거치지 않는다.

〔설계 추론 및 아키텍처 트레이드오프〕

DMA-BUF는 Linux 커널의 버퍼 공유 프레임워크다. GPU 드라이버(예: NVIDIA의 nvidia.ko)가 VRAM을 DMA-BUF로 내보내고, NIC 드라이버(예: mlx5)가 이를 가져와 IOMMU 매핑을 설정한다. 전체 과정이 커널에서 완료되며, 사용자 공간은 fd 하나만 전달한다. 이것이 "NIC가 GPU VRAM을 직접 읽고 쓰는" 저수준 메커니즘이다.

직접 등록 vs 래핑 등록

두 개의 "direct" 버전이 있다는 점에 주목:

📎 src/misc/ibvwrap.cc:203-209

c
struct ibv_mr* wrap_direct_ibv_reg_mr(struct ibv_pd* pd, void* addr, size_t length, int access) {
  if (ibvSymbols.ibv_internal_reg_mr == NULL) {
    WARN("lib wrapper not initialized.");
    return NULL;
  }
  return ibvSymbols.ibv_internal_reg_mr(pd, addr, length, access);
}

📎 src/misc/ibvwrap.cc:229-236

c
struct ibv_mr* wrap_direct_ibv_reg_dmabuf_mr(struct ibv_pd* pd, uint64_t offset, size_t length, uint64_t iova, int fd,
                                             int access) {
  if (ibvSymbols.ibv_internal_reg_dmabuf_mr == NULL) {
    errno = EOPNOTSUPP; // ncclIbDmaBufSupport() requires this errno being set
    return NULL;
  }
  return ibvSymbols.ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access);
}

이들은ibv_mr*대신ncclResult_t을 직접 반환하며, WARN 로그를 출력하지 않는다. 왜인가?

〔설계 추론 및 아키텍처 트레이드오프〕

이 두 함수는능력 탐지。ncclIbDmaBufSupport()에 사용되기 때문이다.wrap_direct_ibv_reg_dmabuf_mr을 호출하여 NIC가 DMA-BUF를 지원하는지 탐색한다. 실패하면 "오류"가 아닌 "미지원"을 판단하기 위해errno == EOPNOTSUPP을 얻기를 기대한다. 여기서 WARN을 출력하면 DMA-BUF를 지원하지 않는 머신에서 로그가 도배된다. 따라서 direct 버전은 오류 처리 책임을 호출자에게 넘긴다.

접근 권한 플래그

📎 src/include/ibvcore.h:365-372

c
enum ibv_access_flags {
	IBV_ACCESS_LOCAL_WRITE		= 1,
	IBV_ACCESS_REMOTE_WRITE		= (1<<1),
	IBV_ACCESS_REMOTE_READ		= (1<<2),
	IBV_ACCESS_REMOTE_ATOMIC	= (1<<3),
	IBV_ACCESS_MW_BIND		= (1<<4),
	IBV_ACCESS_RELAXED_ORDERING     = (1<<20),
};

이 플래그들은 비트 마스크로, 조합할 수 있다.LOCAL_WRITE은 로컬 쓰기 허용(데이터 수신 시 필요),REMOTE_WRITE은 원격 쓰기 허용(RDMA WRITE 대상에 필요),REMOTE_READ은 원격 읽기 허용(RDMA READ 대상에 필요).

IBV_ACCESS_RELAXED_ORDERING은 성능 최적화 플래그로——NIC가 더 완화된 메모리 순서로 접근할 수 있게 하여 처리량을 높일 수 있지만, 애플리케이션 계층에서 정확성을 보장해야 한다.

데이터 흐름: GPU VRAM에서 NIC까지의 전체 경로

아래 그림은 크로스 머신 RDMA 쓰기의 데이터 흐름을 보여주며, 이 장에서 다루는 구조체를 기준으로 한다:

mermaid
flowchart LR
    subgraph GPU["GPU 显存"]
        buf["ncclSendBuff<br/>(device ptr)"]
    end
    subgraph Host["Host 进程"]
        dmabuf["DMA-BUF fd<br/>(cuMemGetHandleForAddressRange)"]
        mr["ibv_mr<br/>{addr, lkey, rkey}"]
        wr["ibv_send_wr<br/>{opcode=RDMA_WRITE,<br/>sg_list, wr.rdma.remote_addr, rkey}"]
    end
    subgraph NIC["网卡 mlx5"]
        qp["ibv_qp<br/>(SQ + RQ)"]
        wqe["WQE<br/>(硬件工作队列元素)"]
    end
    buf -->|导出| dmabuf
    dmabuf -->|ibv_reg_dmabuf_mr| mr
    mr -->|填充 sge.lkey| wr
    wr -->|ibv_post_send| qp
    qp -->|DMA 读取| wqe
    wqe -->|PCIe P2P| buf
    wqe -->|网络| remote["对端 GPU 显存<br/>(remote_addr + rkey)"]

그림의 각 노드는 소스 코드의 실제 타입에 대응한다:ibv_mr은📎 src/include/ibvcore.h:402-410,ibv_send_wr에서,📎 src/include/ibvcore.h:704-738,ibv_qp은📎 src/include/ibvcore.h:787-802。

에서 유래

13.5 작업 완료와 오류 진단

직관적 모델: 택배 수령 확인서post_sendRDMA는 비동기적이다——poll_cq이후에도 결과를 즉시 알 수 없다. NIC가 작업을 완료하면 Completion Queue(CQ)에 Work Completion(WC)을 넣는다. 마치 택배 기사가 수령 확인서를 우편함에 넣는 것과 같다. 능동적으로

를 가져와야 한다.WC 진단이 없으면 재앙은:통신 실패 시 "실패했다"만 알 뿐 "왜 실패했는지"를 모른다

. RDMA 오류 코드는 20가지 이상이며, 각각 다른 근본 원인에 대응한다.

📎 src/include/ibvcore.h:349-363

c
struct ibv_wc {
	uint64_t		wr_id;
	enum ibv_wc_status	status;
	enum ibv_wc_opcode	opcode;
	uint32_t		vendor_err;
	uint32_t		byte_len;
	uint32_t		imm_data;	/* in network byte order */
	uint32_t		qp_num;
	uint32_t		src_qp;
	int			wc_flags;
	uint16_t		pkey_index;
	uint16_t		slid;
	uint8_t			sl;
	uint8_t			dlid_path_bits;
};

wr_id복사status은 post 시 채운 태그,opcode는 완료 상태,byte_len는 작업 유형,qp_num는 실제 전송 바이트 수.src_qp와

는 다중 QP 시나리오에서 어느 QP가 완료했는지 식별하는 데 사용된다.

ibvWcStatusStr상태 코드 변환

📎 src/misc/ibvwrap.cc:415-464

c
const char* ibvWcStatusStr(enum ibv_wc_status status) {
  switch (status) {
  case IBV_WC_SUCCESS:
    return "IBV_WC_SUCCESS";
  case IBV_WC_LOC_LEN_ERR:
    return "IBV_WC_LOC_LEN_ERR";
  // ... 20 多个 case
  default:
    return "UNKNOWN_STATUS";
  }
}

복사

이 상태 코드들의 의미:상태 코드의미
IBV_WC_SUCCESS일반적 근본 원인—
IBV_WC_LOC_LEN_ERR성공로컬 길이 오류
IBV_WC_LOC_ACCESS_ERRSGE 길이가 MR 범위 초과로컬 접근 오류
IBV_WC_REM_ACCESS_ERRlkey 무효 또는 권한 부족원격 접근 오류
IBV_WC_RETRY_EXC_ERRrkey 무효 또는 상대방 MR이 해제됨재시도 소진
IBV_WC_RNR_RETRY_EXC_ERR네트워크 불통 또는 상대방 QP 미준비상대방이 recv를 post하지 않음
IBV_WC_RESP_TIMEOUT_ERR응답 시간 초과상대방 무응답
〔설계 추론 및 아키텍처 트레이드오프〕

IBV_WC_RNR_RETRY_EXC_ERR(Receiver Not Ready)는 프로덕션 환경에서 가장 흔한 문제 중 하나입니다. 이는 송신 측이 데이터를 보냈지만 수신 측이 충분한 recv buffer를 미리 post하지 않았음을 의미합니다. NCCL에서는 일반적으로 연결 설정 단계에서 발생합니다——양측 QP 상태가 동기화되지 않아 한쪽은 이미 전송을 시작했지만 다른 쪽은 아직 수신 준비가 되지 않은 경우입니다.

opcode 변환

ibvWcOpcodeStr과ibvWrOpcodeStr은 각각 완료 opcode와 요청 opcode로 변환됩니다:

📎 src/misc/ibvwrap.cc:467-488

c
const char* ibvWcOpcodeStr(enum ibv_wc_opcode opcode) {
  switch (opcode) {
  case IBV_WC_SEND:
    return "IBV_WC_SEND";
  case IBV_WC_RDMA_WRITE:
    return "IBV_WC_RDMA_WRITE";
  case IBV_WC_RDMA_READ:
    return "IBV_WC_RDMA_READ";
  // ...
  }
}

주의IBV_WC_RECV의 값은1 << 7:

📎 src/include/ibvcore.h:329-342

c
enum ibv_wc_opcode {
	IBV_WC_SEND,
	IBV_WC_RDMA_WRITE,
	IBV_WC_RDMA_READ,
	IBV_WC_COMP_SWAP,
	IBV_WC_FETCH_ADD,
	IBV_WC_BIND_MW,
	IBV_WC_RECV			= 1 << 7,
	IBV_WC_RECV_RDMA_WITH_IMM
};
〔설계 추론 및 아키텍처 트레이드오프〕

왜IBV_WC_RECV이1 << 7이고 순차 값이 아닌가? 수신 완료와 송신 완료는 서로 다른 두 가지 유형의 작업이므로 상위 비트로 구분하면 코드가opcode & IBV_WC_RECV을 통해 "이것이 수신 완료인지"를 빠르게 판단할 수 있습니다. 이는 libibverbs의 API 설계 규약입니다.

CQ 폴링

wrap_ibv_poll_cq은 인라인됩니다:

📎 src/include/ibvwrap.h:60-69

c
static inline ncclResult_t wrap_ibv_poll_cq(struct ibv_cq* cq, int num_entries, struct ibv_wc* wc, int* num_done) {
  int done = cq->context->ops.poll_cq(cq, num_entries,
                                      wc);
  if (done < 0) {
    WARN("Call to ibv_poll_cq() returned %d", done);
    return ncclSystemError;
  }
  *num_done = done;
  return ncclSuccess;
}

그것은cq->context->ops.poll_cq호출을 통해post_send과 마찬가지로ops빠른 경로를 탑니다. 반환값done은 이번 폴링에서 얻은 WC 수이며, 0은 새로운 완료가 없음을, 음수는 오류를 나타냅니다.

〔설계 추론 및 아키텍처 트레이드오프〕

poll_cq은바쁜 폴링——블로킹하지 않고 즉시 반환합니다. NCCL의 proxy 스레드는 완료 이벤트를 얻을 때까지 루프에서 반복적으로 호출합니다. 이것이 저지연의 핵심입니다: 인터럽트 기반과 비교하여 바쁜 폴링은 인터럽트 컨텍스트 전환 오버헤드를 피합니다. 대가는 높은 CPU 점유율이지만 고성능 컴퓨팅 시나리오에서는 허용 가능합니다.

13.6 프로덕션 함정 회피 가이드

함정 1: 크로스 rail 연결 시간 초과

현상:ibv_modify_qp이ETIMEDOUT을 반환하고, 34회 재시도 후 실패합니다.

근본 원인: 멀티 rail 네트워크에서 각 GPU는 일반적으로 특정 NIC에 바인딩됩니다. 만약 rank A의 GPU 0이 NIC 0에 바인딩되고, rank B의 GPU 0이 NIC 1에 바인딩되었는데 NIC 0과 NIC 1이 같은 rail에 있지 않다면(즉, 서로 다른 스위치에 연결되어 있다면), QP 설정이 시간 초과됩니다.

진단: 소스 코드에 이미 힌트가 있습니다:

📎 src/misc/ibvwrap.cc:343-347

c
  case ETIMEDOUT:
    INFO(NCCL_NET, "HINT: In many cases this error indicates that the NICs are not cross-rail connected.");
    INFO(NCCL_NET, "HINT: To confirm, set NCCL_CROSS_NIC=0 to disable cross-rail communication ...");
    return;

설정NCCL_CROSS_NIC=0은 동일 rail 통신을 강제할 수 있습니다. 이렇게 해서 해결된다면 실제로 크로스 rail 문제입니다.

복구 체인: NCCL의 재시도 메커니즘(34회, 선형 백오프)은 네트워크에 충분한 복구 시간을 줍니다. 하지만 근본 원인이 토폴로지 구성 오류라면 재시도는 무용하며, 반드시NCCL_IB_HCA또는NCCL_CROSS_NIC구성을 수정해야 합니다.

함정 2: GID 인덱스 오류

현상:ibv_modify_qp이EINVAL。

을 반환:NCCL_IB_GID_INDEX근본 원인

이 존재하지 않는 GID 인덱스를 강제 지정했거나, 실행 중 NIC의 GID가 변경된 경우(예: RoCE NIC가 IP를 다시 획득)입니다.:

📎 src/misc/ibvwrap.cc:341-358

c
  case EINVAL:
    INFO(NCCL_NET, "HINT: In many cases this error indicates an incorrect GID index is forced by "
                   "NCCL_IB_GID_INDEX, or that a NIC's GID changed mid-run.");
    INFO(NCCL_NET, "HINT: To confirm, set NCCL_IB_GID_INDEX=-1 to enable automatic detection and check "
                   "'dmesg | grep -i gid' for GID changes ...");
    return;

복사NCCL_IB_GID_INDEX=-1설정dmesg은 자동 감지를 활성화합니다. 동시에

에 GID 변경 이벤트가 있는지 확인하십시오.

함정 3: DMA-BUF 미지원으로 인한 host 복사로의 폴백현상

: GPUDirect RDMA가 작동하지 않아 성능이 예상보다 낮습니다.근본 원인wrap_direct_ibv_reg_dmabuf_mr: NIC 드라이버 또는 커널이 DMA-BUF를 지원하지 않아errno = EOPNOTSUPP:

📎 src/misc/ibvwrap.cc:229-236

c
struct ibv_mr* wrap_direct_ibv_reg_dmabuf_mr(struct ibv_pd* pd, uint64_t offset, size_t length, uint64_t iova, int fd,
                                             int access) {
  if (ibvSymbols.ibv_internal_reg_dmabuf_mr == NULL) {
    errno = EOPNOTSUPP; // ncclIbDmaBufSupport() requires this errno being set
    return NULL;
  }
  return ibvSymbols.ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access);
}

복사ncclIbDmaBufSupport()주석에 주의:errno이EOPNOTSUPP에 의존하여 지원 여부를 판단합니다. 여기서

을 설정하지 않으면 상위 계층이 "오류"로 오판하고 "미지원"으로 판단하지 않습니다.진단nvidia-peermem: 커널 버전(5.12+ 필요), NIC 드라이버 버전, 그리고

모듈이 로드되었는지 확인하십시오. 실제로 지원되지 않으면 NCCL은 host 메모리 중계로 폴백하여 성능은 저하되지만 기능은 정상입니다.

함정 4: MR 캐시와 메모리 누수

〔설계 추론 및 아키텍처 트레이드오프〕ibv_mr메모리 등록은 비용이 큰 작업(IOMMU 프로그래밍 포함)이므로 NCCL은

wrap_ibv_dereg_mr을 캐시합니다. 하지만 캐시 전략이 부적절하면 두 가지 문제가 발생합니다: 첫째, 메모리 누수(MR이 계속 해제되지 않음), 둘째, 캐시 무효화(메모리는 해제되었지만 MR이 여전히 이전 주소를 가리킴).

📎 src/misc/ibvwrap.cc:238-241

c
ncclResult_t wrap_ibv_dereg_mr(
  struct ibv_mr* mr) {
  IBV_INT_CHECK_RET_ERRNO(ibvSymbols, ibv_internal_dereg_mr, ibv_internal_dereg_mr(mr), 0, "ibv_dereg_mr");
}
복사

〔설계 추론 및 아키텍처 트레이드오프〕ibv_reg_mr프로덕션 환경에서 훈련 작업이 통신 도메인을 빈번히 생성/파괴하는데 MR이 제대로 해제되지 않으면 IOMMU 매핑 테이블이 팽창하여 결국ENOMEM실패(/sys/kernel/debug/iommu반환)를 유발합니다. 진단 방법은

아래의 매핑 수를 모니터링하는 것입니다.

설계 사고: 왜 래핑 계층이 이렇게 "두꺼운가"ibvwrap.cc이 장을 돌아보면,ibvcore.h은 509줄,

은 1134줄입니다. "단지 libibverbs를 호출"하는 래핑 계층치고는 상당한 규모입니다. 왜일까요?

〔설계 추론 및 아키텍처 트레이드오프〕

세 가지 이유:첫째, 오류 처리의 복잡성

. libibverbs의 API 오류 규약은 극도로 불통일적이어서 NCCL은 각 규약마다 매크로를 작성하고 모든 함수에서 올바르게 사용해야 합니다. 이는 과잉 설계가 아니라 "있는 그대로 번역"하기 위한 필수 비용입니다.。ibvcore.h둘째, ABI 호환성의 부담verbs_context은 모든 구조체를 재정의하고

의 버전 탐지도 처리해야 합니다. 이는 컴파일 시 IB 헤더 파일에 의존하지 않고 런타임에 임의 버전과 호환되기 위함입니다.。ibvModifyQpLog、printIbModifyQpHint、ibvWcStatusStr셋째, 진단 정보의 가치

이러한 함수들은 정상 경로에서는 호출되지 않지만 장애 진단 시 가치가 큽니다. NCCL은 진단 정보를 오류 발생 시 임시로 수집하는 대신 래핑 계층에 "미리 심어두기"로 선택했습니다.net_ib.cc이러한 "두꺼운 래핑"의 대가는 코드량이 많고 유지보수 비용이 높다는 것입니다. 하지만 이점은: 상위ncclResult_t이 통일된

인터페이스로 작성할 수 있어 libibverbs의 각종 특이점을 신경 쓸 필요가 없다는 것입니다. 이는 전형적인 "복잡성 격리" 설계입니다.

이번 장에서는 NCCL의 InfiniBand 전송 래핑 계층을 깊이 살펴보았으며, 핵심 요점은 다음과 같습니다:

1. 심볼 테이블 래핑:ncclIbvSymbols다음을 통해dlopen + dlsym런타임에 libibverbs를 로드하고, 함께std::once_flag스레드 안전 초기화를 보장합니다. 이를 통해 NCCL은 IB 드라이버가 없는 머신에서도 로드할 수 있습니다.

2. ABI 계약:ibvcore.hlibibverbs의 핵심 타입을 재정의하고, 다음을 통해__VERBS_ABI_IS_EXTENDED매직 포인터와verbs_context의container_of기법으로 버전 탐지를 구현합니다.

3. QP 상태 머신:wrap_ibv_modify_qp34회 선형 백오프 재시도를 구현했으며,ETIMEDOUT과EINVAL에 대한 진단 힌트를 제공합니다.

4. GPUDirect RDMA:wrap_ibv_reg_dmabuf_mrDMA-BUF 메커니즘을 통해 NIC가 GPU 메모리를 직접 매핑할 수 있게 하며,wrap_direct_ibv_reg_dmabuf_mr능력 탐지에 사용됩니다.

5. 오류 진단:ibvWcStatusStr、ibvWcOpcodeStr、ibvWrOpcodeStr하드웨어 오류 코드를 읽을 수 있는 문자열로 변환하는 것은 프로덕션 문제 해결의 핵심 도구입니다.

이번 장 생각해보기와 자기 점검

Q1: 만약wrap_ibv_symbols의std::call_once를 일반적인if (initResult == ncclSuccess) return initResult;이중 검사 잠금으로 바꾸면, 어떤 동시성 시나리오에서 문제가 발생할까?

참고 해석: 보기📎 src/misc/ibvwrap.cc:26-29:

c
ncclResult_t wrap_ibv_symbols(void) {
  std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
  return initResult;
}

만약 순수한 이중 검사 잠금으로 바꾸면, 문제는메모리 재정렬。buildIbvSymbols이ibvSymbols의 각 필드를 채운 후initResult에 씁니다. 메모리 배리어가 없으면 CPU나 컴파일러가initResult = ncclSuccess를 `

여기까지 우리는 NCCL이 net_ib를 통해 libibverbs를 플러그 가능한 전송 계층으로 래핑하고, GPUDirect RDMA를 활용해 NIC가 GPU 메모리에 직접 접근하는 방법을 살펴보았습니다. 이 메커니즘은 머신 간 통신의 지연과 대역폭 병목을 해결합니다. 하지만 머신 내 통신도 마찬가지로 중요합니다——다음 장에서는 대칭 메모리와 NVLS로 들어가, NCCL이 NVLink 멀티캐스트를 활용해 하드웨어 가속 집합 통신을 구현하는 방법을 살펴보겠습니다. 그때 여러분은 이번 장의 RDMA 메커니즘과 NVLS가 상호 보완적이라는 것을 알게 될 것입니다: 전자는 머신 간을, 후자는 머신 내를 담당합니다.

CHAPTER 14

제 14 장: 제 14 장: 대칭 메모리와 NVLS: 멀티캐스트 가속과 LSA 디바이스 측 직접 주소 지정

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 14 / 25 장

제 14 장: 대칭 메모리와 NVLS: 멀티캐스트 가속과 LSA 디바이스 측 직접 주소 지정

지난 장에서 우리는 머신 간 AllReduce를 따라가며 데이터가 GPU 메모리에서 NIC를 거쳐 상대방 GPU에 도달하는 과정을 보았고, 그 경로는 머신 간 통신을 해결합니다. 하지만 현대 AI 클러스터에서는 같은 머신, 심지어 같은 NVLink 도메인 내부의 GPU 간 통신량도 마찬가지로 막대합니다——데이터 병렬 훈련의 그래디언트 동기화, 텐서 병렬의 활성값 교환은 대부분 머신 내에서 발생합니다. 만약 머신 내 통신이 여전히 GPU→메모리→NIC→상대 NIC→메모리→GPU라는 머신 간 흐름을 따른다면, 같은 도시 내 택배를 굳이 항공편으로 보내는 것과 같아 지연이 낭비됩니다. 이번 장에서 분석할 것은 바로 NCCL이 머신 내 통신을 위해 준비한 두 가지 무기입니다: 대칭 메모리와 NVLS. 전자는 각 rank가 동일한 가상 주소로 모든 rank의 버퍼에 접근하게 하고, 후자는 NVSwitch 하드웨어의 멀티캐스트 능력을 활용해 리덕션을 수행합니다. 둘을 결합하면 작은 메시지 집합 통신의 지연을 하드웨어 한계에 가깝게 줄일 수 있습니다.

14.1 대칭 메모리: "3열 5번 좌석"이 모든 사람의 집에서 같은 위치를 가리키게 하기

직관적 모델

한 학급이 숙제장을 교환한다고 상상해 보세요. 전통적인 방식은: 각자 자신의 공책에 번호를 매기고 "장삼, 내 5번째 공책을 너에게; 이사, 내 8번째 공책을 너에게"라고 외치는 것입니다——각자 "누구의 공책이 어디에, 몇 번째인지"를 기억해야 합니다. 이것이 일반 통신입니다: 주소는상대적이고 사적인것이며, 상대방 데이터에 접근하려면 먼저 상대방의 주소 매핑을 알아야 합니다.

대칭 메모리는 다른 접근을 합니다: 반 전체가 "3열 5번 좌석"이라는 좌표를 약속하면, 모든 사람의 집에서 같은 물리적 위치를 가리킵니다. 그래서 장삼이 이사의 5번째 공책을 가지려면 "이사 집 3열 5번 좌석"이라고 바로 말하면 되고, 어떤 주소 변환도 필요 없습니다. 이것이 대칭 메모리의 핵심입니다:각 rank의 버퍼가 모든 rank의 주소 공간에서 동일한 가상 주소로 매핑됩니다。

〔설계 추론과 아키텍처 트레이드오프〕

대칭 메모리가 없다면 머신 내 집합 통신은 어떤 재앙을 맞이할까? 각 rank가 상대방 버퍼에 접근할 때마다 "주소 변환"을 거쳐야 합니다——테이블 조회, 오프셋 계산, 어쩌면 프로세스 간 통신으로 매핑 관계를 확인해야 할 수도 있습니다. 작은 메시지(몇 KB)의 경우 이 변환 오버헤드가 데이터 자체 전송보다 클 수 있습니다. 대칭 메모리는 이 오버헤드를 완전히 제거하며, 이것이 바로 "작은 메시지 지연을 현저히 줄이는" 근본 원인입니다.

데이터 구조와 메모리 레이아웃

대칭 메모리의 등록 타입은ncclSymRegType_t으로 설명되며,ncclGetSymRegType은 send/recv 윈도우에NCCL_WIN_COLL_SYMMETRIC플래그가 있는지에 따라 등록 상태를 네 가지로 나눕니다.

📎 src/sym_kernels.cc:395-412

c
ncclResult_t ncclGetSymRegType(struct ncclDevrWindow* sendWin, struct ncclDevrWindow* recvWin,
                               ncclSymRegType_t* winRegType) {
  bool isSendSymmReg = false;
  bool isRecvSymmReg = false;
  if (sendWin && (sendWin->winFlags & NCCL_WIN_COLL_SYMMETRIC)) isSendSymmReg = true;
  if (recvWin && (recvWin->winFlags & NCCL_WIN_COLL_SYMMETRIC)) isRecvSymmReg = true;
  // determine the registration type
  if (!isSendSymmReg && !isRecvSymmReg) {
    *winRegType = ncclSymSendNonregRecvNonreg;
  } else if (isSendSymmReg && !isRecvSymmReg) {
    *winRegType = ncclSymSendRegRecvNonreg;
  } else if (!isSendSymmReg && isRecvSymmReg) {
    *winRegType = ncclSymSendNonregRecvReg;
  } else if (isSendSymmReg && is isRecvSymmReg) {
    *winRegType = ncclSymSendRegRecvReg;
  }
  return ncclSuccess;
}

이 네 가지 상태는 이후 kernel이 어느 경로를 탈지 결정합니다: 완전 대칭 등록(SendRegRecvReg)은 가장 빠른 LSA 경로를, 완전 비등록(SendNonregRecvNonreg)은 일반 경로를, 혼합 상태는 특별 처리가 필요합니다.winFlags의NCCL_WIN_COLL_SYMMETRIC비트가 바로 "이 윈도우가 대칭 등록되었는지"의 표시입니다.

대칭 메모리의 초기화 진입점은ncclSymkInitOnce이며, 이것은 핵심적인 일을 합니다: 현재 통신 도메인이 LSA 멀티캐스트를 지원하는지 판단합니다(hasLsaMultimem)。

📎 src/sym_kernels.cc:185-196

c
ncclResult_t ncclSymkInitOnce(struct ncclComm* comm) {
  // ncclTeamLsa() below calls this internally but drops the error code so we do it here.
  NCCLCHECK(ncclDevrInitOnce(comm));

  struct ncclSymkState* symk = &comm->symkState;
  if (!symk->initialized) {
    symk->initialized = true;
    struct ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
    // Disable LSA multicast for cross-clique since NVLS isn't available across cliques
    symk->hasLsaMultimem =
      ncclNvlsSymmetricMultimemEnabled(comm) && ncclTeamLsa(comm).nRanks > 2 && !comm->p2pCrossClique;
    reqs.lsaMultimem = symk->hasLsaMultimem;

hasLsaMultimem세 가지 조건이 모두 충족되어야 합니다: NVLS 대칭 멀티캐스트가 활성화되어 있고, LSA 팀의 rank 수가 2보다 크며(두 rank는 직접 점대점 통신이 더 빠르므로 멀티캐스트가 필요 없음), clique를 넘지 않아야 합니다(clique를 넘으면 NVSwitch 멀티캐스트를 사용할 수 없음). 이 판단은reqs.lsaMultimem의 설정 여부를 직접 결정하며, 나아가 디바이스 측 통신자의 리소스 할당에 영향을 미칩니다.

시나리오 기반 단계별 워크스루

AllReduce를 한 번 실행한다고 가정해 봅시다. 메시지 크기는 4KB이고, 8개의 rank가 동일한 NVLink 도메인 내에 있습니다.ncclSymkMask은 어떤 kernel을 사용할 수 있는지 결정합니다.

📎 src/sym_kernels.cc:304-352

c
uint32_t ncclSymkMask(struct ncclComm* comm, ncclFunc_t coll, int /*ncclDevRedOp_t*/ red, ncclDataType_t ty,
                      size_t nElts, bool symAligned16B) {
  uint32_t kmask = kernelMask_coll(coll);

  bool hasSTMC = comm->symkState.hasLsaMultimem;
  bool hasLDMC = false;
  if (comm->symkState.hasLsaMultimem) {
    switch (ty) {
    case ncclInt32:
    ...
      hasLDMC = red == ncclDevSum || red == ncclDevMinMax || red == ncclDevSumPostDiv;
      break;
    ...
    }
  }
  if (!hasSTMC) kmask &= ~kernelMask_STMC;
  if (!hasLDMC) kmask &= ~kernelMask_LDMC;

첫 번째 단계:kernelMask_coll은 집합 유형(AllReduce)에 따라 후보 kernel 집합kernelMask_AR을 가져옵니다. 두 번째 단계:hasLsaMultimem을 확인하고, 멀티캐스트가 지원되면 데이터 타입과 리덕션 연산이 LDMC(Load-Multicast)를 지원하는지 추가로 판단합니다. 세 번째 단계: 비트마스크로 지원되지 않는 기능을 제거합니다——kmask &= ~kernelMask_STMC은 STMC를 지원하지 않는 kernel을 모두 제거합니다.

다음은 크기 제한입니다:

📎 src/sym_kernels.cc:336-342

c
  size_t nBytes = alignUp(nElts * ncclTypeSize(ty), NCCL_SYM_KERNEL_CELL_SIZE);
  size_t nBusBytes = (coll == ncclFuncAllReduce ? 1 : comm->nRanks) * nBytes;
  // LL kernels use 32-bit ints to track element counts and indices.
  if (nBusBytes >= (size_t(2) << 30)) kmask &= ~kernelMask_LL;
  // Any kernel might use 32-bit int to track unrolled loop chunks (which are going
  // to be at least 32 bytes per chunk)
  if (nBusBytes >= 32 * (size_t(2) << 30)) kmask = 0;

여기에는 두 가지 하드 경계가 있습니다: LL 계열 kernel은 32비트 정수로 요소 개수를 추적하므로, 버스 바이트 수가 2GB를 초과하면 LL kernel이 제거되고, 64GB를 초과하면 모든 kernel이 제거됩니다(kmask = 0). 이는 전형적인 "비트 폭으로 성능을 얻는" 방식입니다——32비트 인덱스는 64비트보다 레지스터와 명령어를 절약하지만, 그 대가로 메시지 크기 상한이 생깁니다.

마지막은 TMA와 GIN의 가용성 검사입니다:

📎 src/sym_kernels.cc:344-350

c
  if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
  if (!symAligned16B) kmask &= ~kernelMask_Tma;

  bool hasGin = ncclParamSymGinKernelsEnable() != 0;
  if (!hasGin) kmask &= ~kernelMask_Gin;
  bool needGin = ncclTeamLsa(comm).nRanks < comm->nRanks;
  kmask &= needGin ? kernelMask_Gin : ~kernelMask_Gin;
  return kmask;

TMA는 SMEM 용량이 기준을 충족해야 하고(ncclSymkTmaAvailable확인maxSharedMemOptin) 16바이트 정렬이어야 합니다. GIN은 "LSA 팀의 rank 수가 전체 rank 수보다 작을 때"만 필요합니다——즉, 통신 도메인이 LSA 경계를 넘어설 때(네트워크를 거쳐야 할 때)만 GIN이 의미가 있습니다. 전체 통신 도메인이 LSA 내에 있으면 GIN kernel은 제거됩니다.

동시성 제어와 하드웨어 상호작용

대칭 메모리의 주소 해석은 최종적으로 디바이스 측에서 이루어집니다.ncclSymkMakeDevWork은 호스트 측 작업 설명을 디바이스 측에서 읽을 수 있는 작업 항목으로 변환합니다.

📎 src/sym_kernels.cc:380-393

c
ncclResult_t ncclSymkMakeDevWork(struct ncclComm* comm, struct ncclTaskColl* task, struct ncclSymkDevWork* outDevWork) {
  outDevWork->rootRank = task->root;
  outDevWork->redOpArg = task->opDev.scalarArg;
  outDevWork->nElts = task->count;
  outDevWork->inputWin = task->sendWin ? task->sendWin->vidmem : nullptr;
  outDevWork->inputOff =
    task->sendWin ? (uint8_t*)task->sendbuff - (uint8_t*)task->sendWin->userPtr : (size_t)task->sendbuff;
  outDevWork->outputWin = task->recvWin ? task->recvWin->vidmem : nullptr;
  outDevWork->outputOff =
    task->recvWin ? (uint8_t*)task->recvbuff - (uint8_t*)task->recvWin->userPtr : (size_t)task->recvbuff;
  outDevWork->sChannelId = 0xffff;
  outDevWork->nChannels = 0;
  return ncclSuccess;
}

주의inputOff의 계산: sendWin이 존재하면(대칭 등록 윈도우), 오프셋은sendbuff - sendWin->userPtr입니다——이것은윈도우 내 오프셋이며, 디바이스 측에서inputWin(윈도우 베이스 주소)에inputOff을 더하면 실제 주소를 계산할 수 있습니다. sendWin이 존재하지 않으면 오프셋은 바로sendbuff의 절대 주소입니다. 이 설계 덕분에 디바이스 측 kernel은 동일한 로직으로 등록 및 비등록 버퍼를 처리할 수 있습니다.

ncclSymkInitOnce에서는 GIN 관련 리소스 요구사항도 초기화하는데, 여기에는 inbox, outbox, accumulation buffer, rail signal이 포함됩니다.

📎 src/sym_kernels.cc:208-251

c
    struct ncclDevResourceRequirements ginInboxRailReq = {};
    struct ncclDevResourceRequirements ginOutboxReq = {};
    struct ncclDevResourceRequirements rsGinAccumReq = {};
    struct ncclDevResourceRequirements railSignalReq = {};
    if (ncclParamSymGinKernelsEnable() && ncclTeamLsa(comm).nRanks < comm->nRanks) {
      int maxBlocks;
      size_t bufSize;
      getRequirements_gin(comm, &maxBlocks, &bufSize);

      maxBlocks = std::max(maxBlocks, comm->config.minCTAs);
      maxBlocks = std::min(maxBlocks, comm->config.maxCTAs);
      if (ncclParamSymCTAs() >= 1) maxBlocks = ncclParamSymCTAs();
      maxBlocks = std::min(maxBlocks, ncclSymkMaxBlocks);
      symk->maxGinInboxBlocks = maxBlocks;
      symk->kcomm.rsGinAccumBytesPerBlock = ncclSymkRsGinAccumBytesPerBlock();

      rsGinAccumReq.bufferSize = (size_t)maxBlocks * symk->kcomm.rsGinAccumBytesPerBlock;
      rsGinAccumReq.bufferAlign = 128;
      rsGinAccumReq.outBufferHandle = &symk->kcomm.rsGinAccumBuf;
      ...
      uint32_t railSignalCount = ncclTeamRail(comm).nRanks * ncclSymkMaxBlocks;
      ...
      reqs.barrierCount = ncclSymkMaxBlocks;
      reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAIL;
      reqs.ginStrongSignalsRequired = true;
      reqs.ginVaSignalsRequired = true;
    }

getRequirements_gin은 튜닝 모델로 필요한 block 수와 버퍼 크기를 계산한 다음[minCTAs, maxCTAs]구간으로 clamp됩니다.rsGinAccumBytesPerBlock은 각 block의 누적 버퍼 크기이며, 128바이트로 정렬됩니다——이는 캐시 라인 크기로, 거짓 공유를 방지합니다.

mermaid
flowchart TD
    start["ncclSymkMask(comm, coll, red, ty, nElts)"] --> coll{"集合类型?"}
    coll -->|AllGather| mask_ag["kmask = kernelMask_AG"]
    coll -->|AllReduce| mask_ar["kmask = kernelMask_AR"]
    coll -->|ReduceScatter| mask_rs["kmask = kernelMask_RS"]
    mask_ag --> check_stmc{"hasLsaMultimem?"}
    mask_ar --> check_stmc
    mask_rs --> check_stmc
    check_stmc -->|否| clear_stmc["kmask &= ~kernelMask_STMC"]
    check_stmc -->|是| check_ldmc{"数据类型+归约支持LDMC?"}
    clear_stmc --> size_check
    check_ldmc -->|否| clear_ldmc["kmask &= ~kernelMask_LDMC"]
    check_ldmc -->|是| size_check
    clear_ldmc --> size_check
    size_check{"nBusBytes >= 2GB?"} -->|是| clear_ll["kmask &= ~kernelMask_LL"]
    size_check -->|否| tma_check
    clear_ll --> tma_check{"TMA可用且16B对齐?"}
    tma_check -->|否| clear_tma["kmask &= ~kernelMask_Tma"]
    tma_check -->|是| gin_check
    clear_tma --> gin_check{"需要GIN? LSA rank < 总rank"}
    gin_check -->|否| clear_gin["kmask &= ~kernelMask_Gin"]
    gin_check -->|是| done
    clear_gin --> done["返回 kmask"]

이 그림은ncclSymkMask의 의사결정 체인을 완전히 묘사합니다: 집합 유형에서 출발하여 멀티캐스트 지원, 데이터 타입, 크기 경계, TMA 가용성, GIN 필요성의 다섯 가지 필터를 차례로 거쳐 최종적으로 비트마스크를 반환합니다. 각 필터는 한 무리의 kernel을 제거할 수 있으며, 이는 NCCL이 "시나리오에 따라 최적의 kernel을 선택"하는 모습을 보여줍니다.

프로덕션 함정 회피 가이드

함정 1: clique를 넘을 때 멀티캐스트가 조용히 무효화됩니다. hasLsaMultimem의 세 번째 조건은!comm->p2pCrossClique입니다. 클러스터에 MNNVL(Multi-Node NVLink)이 구성되어 있지만 일부 rank가 clique를 넘으면 멀티캐스트가 비활성화되고 성능이 조용히 일반 경로로 퇴화합니다. 문제를 진단할 때는ncclNvlsSymmetricMultimemEnabled의 로그 출력을 확인하세요.

함정 2: 16바이트 정렬의 숨은 요구사항. ncclSymkMask에서if (!symAligned16B) kmask &= ~kernelMask_Tma;——사용자 버퍼가 16바이트 정렬이 아니면 TMA kernel이 제거됩니다. TMA는 Hopper/Blackwell에서 가장 빠른 복사 엔진이며, 이를 잃는다는 것은 성능 저하를 의미합니다. 프로덕션 환경에서 사용자가 전달하는 buffer는 흔히cudaMalloc에서 오므로 자연스럽게 정렬되지만, 커스텀 allocator나 슬라이스에서 오면 함정에 빠질 수 있습니다.

함정 3: 2GB 경계.LL kernel은 32비트 인덱스를 사용하므로 버스 바이트 수가 2GB를 초과하면 제거됩니다. 대규모 모델 학습에서는 단일 AllReduce의 그래디언트가 이 값을 초과할 수 있으며, 이때 NCCL은 자동으로 STMC 또는 Simple 프로토콜로 전환합니다. 이는 버그가 아니지만, LL 프로토콜을 수동으로 지정하면ncclInvalidArgument。

---

14.2 NVLS: NVSwitch 하드웨어가 대신 리덕션을 수행하게 하기

직관적 모델

전통적인 AllReduce는 "소프트웨어 리덕션"입니다: 각 GPU가 데이터를 이웃에게 보내고, 이웃이 덧셈을 수행한 뒤 다시 전달합니다——데이터가 GPU 사이를 오가며, 덧셈은 SM에서 실행됩니다. 이는 마치 8명이 쪽지를 돌려가며 합계를 계산하는 것과 같아서, 각자가 한 번 읽고, 한 번 더하고, 다시 전달해야 합니다.

NVLS는 다른 접근을 취합니다: NVSwitch 칩에 내장된멀티캐스트(multicast)와 리덕션(reduction) 기능데이터를 멀티캐스트 주소에 쓰면 NVSwitch가 자동으로 모든 멤버에게 브로드캐스트하고 하드웨어에서 덧셈을 완료한다. 이는 마치 8명이 같은 화이트보드에 숫자를 쓰면 화이트보드가 자동으로 합계를 표시하는 것과 같다—GPU는 한 번 쓰고 한 번 읽으며, 중간의 이동과 덧셈은 모두 스위치 하드웨어가 수행한다.

NVLS가 없으면 노드 내 AllReduce의 대역폭이 GPU 간 점대점 링크에 의해 제한되고, SM이 덧셈에 많은 사이클을 소모해야 한다. NVLS는 이 두 가지를 모두 하드웨어로 오프로드하여 SM이 다른 연산을 할 수 있게 한다.

데이터 구조와 메모리 레이아웃

NVLS의 핵심은멀티캐스트 그룹(MC group)。ncclMcGroup구조체는 멀티캐스트 그룹의 전체 상태를 설명한다.

📎 src/transport/multicast.cc:72-77

c
struct ncclMcGroup {
  CUmemGenericAllocationHandle handle;  // the MC object
  char* base;                          // mapped MC VA base
  size_t capacity;                      // total mapped VA size
  int dev;                           // local device, for unbind
};

네 개의 필드:handle는 CUDA 멀티캐스트 객체의 핸들,base는 멀티캐스트 가상 주소의 베이스,capacity는 총 매핑 크기,dev는 로컬 디바이스 번호(언바인딩용)이다. 여기에는 잠금이 없다—멀티캐스트 그룹의 생성과 소멸은 초기화/소멸 단계에서 이루어지며 핫 패스에 있지 않다.

멀티캐스트 그룹은 여러 개로 분할된다파티션(partition), 각 파티션은 불변 슬라이스이다.ncclMcPartition는 파티션을 설명한다.

📎 src/transport/multicast.cc:162-170

c
  // A partition is self-sufficient for binds: it carries the group's handle, device and
  // bind granularity alongside its own extent.
  for (int i = 0; i < nRequests; i++) {
    if (outPartitions[i].size == 0) continue;
    outPartitions[i].ptr = group->base + outPartitions[i].offset;
    outPartitions[i].mcHandle = mcHandle;
    outPartitions[i].minGranularity = minGran;
    outPartitions[i].dev = comm->cudaDev;
  }

각 파티션은 자체offset、size、ptr, 그리고 소속 그룹의mcHandle、minGranularity、dev을 가진다. 이러한 "자급자족" 설계 덕분에 파티션은 그룹 정보를 다시 조회할 필요 없이 독립적으로 바인딩 함수에 전달될 수 있다.

시나리오 기반 단계별 워크스루

8개의 rank가 NVLS 도메인을 구축한다고 가정하자.ncclMcGroupBuildPartitions은 멀티캐스트 그룹을 생성하고 파티션을 분할하는 역할을 한다.

📎 src/transport/multicast.cc:79-121

c
ncclResult_t ncclMcGroupBuildPartitions(struct ncclComm* comm, const struct ncclMcRequest* requests, int nRequests,
                                        struct ncclMcGroup** outGroup, struct ncclMcPartition* outPartitions) {
  ...
  mcprop.numDevices = comm->localRanks;
  mcprop.handleTypes = ncclCuMemHandleType;
  mcprop.flags = 0;
  mcprop.size = 0;
  for (int i = 0; i < nRequests; i++) mcprop.size += requests[i].size;
  CUCHECKGOTO(cuMulticastGetGranularity(&recGran, &mcprop, CU_MULTICAST_GRANULARITY_RECOMMENDED), ret, fail);
  CUCHECKGOTO(cuMulticastGetGranularity(&minGran, &mcprop, CU_MULTICAST_GRANULARITY_MINIMUM), ret, fail);

  // Bump-allocate an immutable slice per request. Offsets and sizes are rounded
  // to the recommended granularity (a multiple of the MC minimum) so every slice
  // boundary is a valid bind offset.
  for (int i = 0; i < nRequests; i++) {
    outPartitions[i] = {};
    if (requests[i].size == 0) continue;
    size_t align = requests[i].alignment > recGran ? requests[i].alignment : recGran;
    ALIGN_SIZE(capacity, align);
    size_t slice = requests[i].size;
    ALIGN_SIZE(slice, recGran);
    outPartitions[i].offset = capacity;
    outPartitions[i].size = slice;
    capacity += slice;
  }

1단계: 모든 요청의 크기를 누적하여 멀티캐스트 그룹 총 크기를 구한다. 2단계: CUDA의 권장 입자 크기와 최소 입자 크기를 조회한다—이는 하드웨어 제약으로, 멀티캐스트 객체의 주소와 크기는 입자 크기의 정수 배여야 한다. 3단계: bump 할당—각 요청마다 한 조각을 잘라내고, 오프셋과 크기를 권장 입자 크기에 맞춘다.ALIGN_SIZE(capacity, align)은 각 슬라이스의 시작 오프셋이 유효한 바인딩 오프셋임을 보장한다.

다음은 rank 간 생성과 임포트이다:

📎 src/transport/multicast.cc:125-146

c
  if (comm->localRank == 0) {
    NCCLCHECKGOTO(ncclMcCreate(comm, &mcprop, comm->localRank, comm->localRanks, &mcHandle, shareableHandle), ret,
                  fail);
    mcCreated = 1;
    NCCLCHECKGOTO(bootstrapIntraNodeBroadcast(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
                                              0, shareableHandle, NVLS_HANDLE_SIZE),
                  ret, fail);
  } else {
    NCCLCHECKGOTO(bootstrapIntraNodeBroadcast(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
                                              0, shareableHandle, NVLS_HANDLE_SIZE),
                  ret, fail);
    NCCLCHECKGOTO(ncclMcImport(comm, shareableHandle, comm->localRankToRank[0], &mcHandle), ret, fail);
    mcCreated = 1;
  }
  CUCHECKGOTO(cuMulticastAddDevice(mcHandle, comm->cudaDev), ret, fail);

  // cuMemMap of an MC object blocks until every device has been added. This
  // abort-aware barrier makes a peer failing before cuMulticastAddDevice trip the
  // abort flag here instead of stranding survivors in the blocking cuMemMap.
  NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
                                          comm->localRankToRank[0]),
                ret, fail);

localRank 0이 멀티캐스트 객체를 생성한 후 bootstrap을 통해 shareable handle을 브로드캐스트하고, 다른 rank는 handle을 받아 임포트한다.cuMulticastAddDevice은 로컬 디바이스를 멀티캐스트 그룹에 추가한다. 저 barrier에 주목하라—주석이 명확히 설명한다:cuMemMap은 모든 디바이스가 참여할 때까지 블록되며, 만약 어떤 peer가cuMulticastAddDevice이전에 실패하면 생존자는cuMemMap에서 교착된다. 이 barrier는 실패가 블록되기 전에 abort 플래그로 포착되게 한다.

마지막은 매핑과 접근 권한 설정이다:

📎 src/transport/multicast.cc:148-155

c
  // Reserve and map the whole MC VA once; each consumer slice is a view into it.
  CUCHECKGOTO(cuMemAddressReserve(&base, capacity, recGran, 0U, 0), ret, fail);
  CUCHECKGOTO(cuMemMap(base, capacity, 0, mcHandle, 0), ret, fail);
  mapped = 1;
  desc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
  desc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
  desc.location.id = comm->cudaDev;
  CUCHECKGOTO(cuMemSetAccess(base, capacity, &desc, 1), ret, fail);

전체 멀티캐스트 VA는 한 번만 예약되고 매핑되며, 각 소비자 슬라이스는 이 VA의 뷰이다. 이는 "한 번 매핑, 여러 번 슬라이스" 설계로—각 소비자가 개별적으로 멀티캐스트 객체를 생성하는 것보다 자원을 절약한다.

동시성 제어와 하드웨어 상호작용

바인딩은 NVLS의 가장 핵심적인 작업이다.ncclMcPartitionBindMem은 UC(유니캐스트) 메모리 핸들을 멀티캐스트 그룹의 특정 오프셋에 바인딩한다.

📎 src/transport/multicast.cc:200-225

c
ncclResult_t ncclMcPartitionBindMem(const struct ncclMcPartition* partition, size_t offsetInPartition,
                                    CUmemGenericAllocationHandle mem, size_t memOffset, size_t bindSize) {
  // A bind overrunning its partition would corrupt the next consumer's partition; fail
  // cleanly instead (possible when UC rounding exceeds the MC-rounded partition).
  if (offsetInPartition + bindSize > partition->size) {
    WARN("NVLS MC bind of size %zu at slice offset %zu exceeds slice size %zu (UC/MC granularity mismatch)", bindSize,
         offsetInPartition, partition->size);
    return ncclInternalError;
  }
  size_t mcOffset = partition->offset + offsetInPartition;
  ...
  CUresult err = CUPFN(cuMulticastBindMem(partition->mcHandle, mcOffset, mem, memOffset, bindSize, 0 /*flags*/));
  if (err != CUDA_SUCCESS) {
    ...
    WARN("Failed to bind NVLink SHARP (NVLS) Multicast memory of size %zu at MC group %llx offset %zu : CUDA error %d "
         "'%s'.\nThis is usually caused by a system or configuration error in the Fabric Manager or NVSwitches.\n"
         "Disable NVLS (NCCL_NVLS_ENABLE=0) if you wish to avoid this error in the future.",
         bindSize, partition->mcHandle, mcOffset, err, errStr);
    return ncclUnhandledCudaError;
  }
  return ncclSuccess;
}

첫 번째 방어선은 경계 검사이다:offsetInPartition + bindSize > partition->size이면 오류를 보고한다. 주석이 그 이유를 설명한다—UC 메모리의 입자 크기가 MC 파티션보다 클 수 있으며, UC 정렬 후 MC 파티션 경계를 초과하면 다음 소비자의 파티션을 침범하게 된다. 이는 전형적인 "두 입자 크기 불일치" 함정이다.

cuMulticastBindMem은 하드웨어 호출로, 주석에 따르면 "blocks until all ranks have been added to the group"이다—이것이 NVLS에서 가장 문제가 발생하기 쉬운 부분이다. Fabric Manager 설정 오류나 NVSwitch 펌웨어 문제가 있으면 여기서 멈추거나 오류를 반환한다. 오류 메시지에서 사용자에게 직접NCCL_NVLS_ENABLE=0을 권장하는데, 이는 프로덕션 환경의 표준 탈출구이다.

사용자 버퍼 등록을 위한 "바인딩 시도" 변형도 있다:

📎 src/transport/multicast.cc:237-268

c
ncclResult_t ncclMcPartitionTryBindAddr(const struct ncclMcPartition* partition, size_t offsetInPartition,
                                        CUdeviceptr address, size_t bindSize, enum ncclMcBindStatus* outStatus) {
  const char* errStr = NULL;

  *outStatus = ncclMcBindStatusTransient;
  if (offsetInPartition + bindSize > partition->size) {
    ...
    return ncclInternalError;
  }
  size_t mcOffset = partition->offset + offsetInPartition;
  CUresult err = CUPFN(cuMulticastBindAddr(partition->mcHandle, mcOffset, address, bindSize, 0 /*flags*/));
  if (err == CUDA_SUCCESS) {
    *outStatus = ncclMcBindStatusOk;
    return ncclSuccess;
  }

  (void)pfn_cuGetErrorString(err, &errStr);
  // Only an outright rejection of the input is a property of the buffer. Anything else,
  // notably OUT_OF_MEMORY, may succeed later, so it must not be reported as permanent.
  if (err == CUDA_ERROR_INVALID_VALUE || err == CUDA_ERROR_NOT_SUPPORTED || err == CUDA_ERROR_NOT_PERMITTED) {
    *outStatus = ncclMcBindStatusNoSupport;
    ...
  } else {
    WARN("NVLS Multicast bind of size %zu at MC group %llx offset %zu dev %d failed transiently: CUDA error %d '%s'.\n"
         "The buffer is left unregistered for this operation and will be retried; repeated occurrences indicate "
         "sustained resource pressure.",
         bindSize, partition->mcHandle, mcOffset, partition->dev, err, errStr);
  }
  return ncclSuccess;
}

여기에는 정교한 오류 분류가 있다:CUDA_ERROR_INVALID_VALUE、NOT_SUPPORTED、NOT_PERMITTED은ncclMcBindStatusNoSupport로 분류된다—이는영구적 실패로, 해당 버퍼 자체가 멀티캐스트 바인딩을 지원하지 않음을 의미한다. 반면 다른 오류(특히OUT_OF_MEMORY)는ncclMcBindStatusTransient로 분류된다—이는일시적 실패로, 재시도할 수 있다. 이 구분은 매우 중요하다: OOM을 영구적 실패로 취급하면 성공할 수 있었던 등록을 잘못 포기하게 되고, 매개변수 오류를 일시적 실패로 취급하면 무한 재시도하게 된다.

프로덕션 함정 회피 가이드

함정 1: Fabric Manager 설정 오류로 인한cuMulticastBindMem멈춤.이는 NVLS의 가장 전형적인 프로덕션 장애이다. 오류 메시지가 Fabric Manager 또는 NVSwitch를 명확히 지목한다. 진단 단계: 먼저NCCL_NVLS_ENABLE=0으로 문제가 사라지는지 확인한 후, Fabric Manager 로그와 NVSwitch 펌웨어 버전을 점검한다.

함정 2: UC/MC 입자 크기 불일치. ncclMcPartitionBindMem의 경계 검사가 이 문제를 포착하지만, "UC/MC granularity mismatch" 경고가 보이면 특정 요청의 UC 크기가 정렬 후 MC 파티션을 초과했음을 의미한다. 이는 보통 요청 크기가 입자 크기 경계에 근접할 때 발생한다.

함정 3: 멀티캐스트 그룹 생성 실패 후 자원 누수. ncclMcGroupBuildPartitions의 fail 경로는CUCALL(best-effort)를 사용하며CUCHECK:

📎 src/transport/multicast.cc:179-184

c
fail:
  // Best-effort (CUCALL) so a failing cleanup op cannot skip releasing the MC handle.
  if (mapped) CUCALL(cuMemUnmap(base, capacity));
  if (base) CUCALL(cuMemAddressFree(base, capacity));
  if (mcCreated) CUCALL(cuMemRelease(mcHandle));
  return ret;

주석은 그 이유를 설명한다: cleanup 작업 자체가 실패하더라도 그 때문에 MC handle 해제를 건너뛸 수는 없다—MC slot은 희소 자원이며, 누수가 발생하면 이후 생성이 실패할 수 있다. 이는 "정리 경로는 반드시 최선을 다해야 한다"는 전형적인 설계이다.

mermaid
sequenceDiagram
    participant R0 as "Rank 0 (localRank=0)"
    participant R1 as "Rank 1..N-1"
    participant BS as "bootstrapIntraNode"
    participant CU as "CUDA Driver"

    R0->>CU: "cuMulticastCreate(mcHandle, prop)"
    CU-->>R0: "mcHandle"
    R0->>BS: "bootstrapIntraNodeBroadcast(shareableHandle)"
    BS-->>R1: "shareableHandle"
    R1->>CU: "cuMemImportFromShareableHandle(mcHandle)"
    CU-->>R1: "mcHandle"
    R0->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
    R1->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
    R0->>BS: "bootstrapIntraNodeBarrier()"
    R1->>BS: "bootstrapIntraNodeBarrier()"
    Note over R0,R1: "barrier 防止 cuMemMap 阻塞时 peer 失败"
    R0->>CU: "cuMemAddressReserve(base, capacity)"
    R0->>CU: "cuMemMap(base, capacity, mcHandle)"
    R0->>CU: "cuMemSetAccess(base, capacity, desc)"
    R0->>CU: "cuMulticastBindMem(mcHandle, mcOffset, ucHandle)"
    CU-->>R0: "绑定完成,硬件多播就绪"

이 시퀀스 다이어그램은 멀티캐스트 그룹이 생성에서 바인딩까지 이르는 전체 흐름을 묘사한다. 핵심은 그 barrier이다—이는 "peer 실패"와 "cuMemMap 블로킹"을 분리하여 생존자가 교착 상태에 빠지는 것을 방지한다.

---

14.3 대칭 메모리와 NVLS의 결합: LSA 포인터가 디바이스 측에서 해석되는 방법

직관적 모델

대칭 메모리는 "주소 일관성" 문제를 해결하고, NVLS는 "하드웨어 리덕션" 문제를 해결한다. 하지만 둘이 실제로 협력하려면 핵심 메커니즘이 하나 더 필요하다:디바이스 측은 어떻게 어떤 주소가 대칭이며 멀티캐스트 경로를 탈 수 있는지 아는가?

답은 LSA(Load-Store Accessible) 포인터에 있다. LSA는 "로드-스토어 접근 가능"의 약자로, 이 포인터가 가리키는 메모리를 GPU가 일반 load/store 명령어로 직접 접근할 수 있다는 뜻이다—물리적으로 로컬에 있든 원격에 있든 상관없이. 주소가 멀티캐스트 그룹 내에 있으면 load/store는 NVSwitch 하드웨어에 의해 가로채져 브로드캐스트된다.

데이터 구조와 메모리 레이아웃

ncclSymkDevWork은 디바이스 측 작업 디스크립터로, 대칭 메모리의 핵심 정보를 담고 있다.

📎 src/sym_kernels.cc:380-393

c
ncclResult_t ncclSymkMakeDevWork(struct ncclComm* comm, struct ncclTaskColl* task, struct ncclSymkDevWork* outDevWork) {
  outDevWork->rootRank = task->root;
  outDevWork->redOpArg = task->opDev.scalarArg;
  outDevWork->nElts = task->count;
  outDevWork->inputWin = task->sendWin ? task->sendWin->vidmem : nullptr;
  outDevWork->inputOff =
    task->sendWin ? (uint8_t*)task->sendbuff - (uint8_t*)task->sendWin->userPtr : (size_t)task->sendbuff;
  outDevWork->outputWin = task->recvWin ? task->recvWin->vidmem : nullptr;
  outDevWork->outputOff =
    task->recvWin ? (uint8_t*)task->recvbuff - (uint8_t*)task->recvWin->userPtr : (size_t)task->recvbuff;
  outDevWork->sChannelId = 0xffff;
  outDevWork->nChannels = 0;
  return ncclSuccess;
}

inputWin은 윈도우의 디바이스 측 가상 주소이고(vidmem),inputOff은 윈도우 내 버퍼의 오프셋이다. 디바이스 측 kernel이 이 두 값을 받으면inputWin + inputOff를 계산하여 실제 주소를 얻는다. 이 주소가 멀티캐스트 그룹 내에 있으면 하드웨어가 자동으로 브로드캐스트를 처리한다.

ncclSymkInitOnce에는 LSA barrier와 LLA2A(Low-Latency All-to-All) 리소스도 설정된다.

📎 src/sym_kernels.cc:197-206

c
    reqs.lsaBarrierCount = ncclSymkMaxBlocks;
    reqs.ginStrongSignalsRequired = false;
    reqs.ginVaSignalsRequired = false;

    struct ncclDevResourceRequirements lla2aReq;
    ncclLLA2ACreateRequirement(ncclSymkMaxBlocks,
                               ncclLLA2ACalcSlots(ncclTeamLsa(comm).nRanks * ncclSymkMaxThreads, ncclSymkLLMaxEltSize),
                               &symk->kcomm.lsaLLA2A, &lla2aReq);
    lla2aReq.next = reqs.resourceRequirementsList;
    reqs.resourceRequirementsList = &lla2aReq;

lsaBarrierCount을ncclSymkMaxBlocks로 설정—각 block마다 barrier 슬롯 하나. LLA2A는 저지연 all-to-all의 약자로, LSA 도메인 내에서 빠른 데이터 교환에 사용된다.ncclLLA2ACalcSlots은 rank 수, 스레드 수, 최대 요소 크기에 따라 필요한 슬롯 수를 계산한다.

시나리오 기반 단계별 워크스루

한 번의 AllReduce가AllReduce_AGxLLMC_Rkernel(AllGather + LL + MC + Reduce)을 사용한다고 가정하자. 이 kernel의 워크플로는:

1. AllGather 단계: 각 rank가 자신의 데이터를 멀티캐스트 그룹에 쓰면, NVSwitch 하드웨어가 모든 rank에 브로드캐스트한다.

2. Reduce 단계: 각 rank가 멀티캐스트 그룹에서 모든 rank의 데이터를 읽고 로컬에서 리덕션을 수행한다.

ncclSymkMask은 이 kernel이 사용 가능한지 확인한다.kernelMask_LL은AllReduce_AGxLLMC_R을 포함하지만, 전제는hasLsaMultimem이 참이어야 한다(그렇지 않으면kernelMask_STMC이 제거되고,AllReduce_AGxLLMC_R은 STMC 집합에 속한다).

잠깐, 여기 세부 사항이 있다:kernelMask_STMC이AllReduce_AGxLLMC_R을 포함하는가? 소스를 보자:

📎 src/sym_kernels.cc:17-21

c
constexpr uint32_t kernelMask_STMC =
  1 << ncclSymkKernelId_AllGather_LLMC | 1 << ncclSymkKernelId_AllGather_STMC |
  1 << ncclSymkKernelId_AllGather_TmaSTMC | 1 << ncclSymkKernelId_AllReduce_AGxLLMC_R |
  1 << ncclSymkKernelId_AllReduce_RSxLDMC_AGxSTMC | 1 << ncclSymkKernelId_ReduceScatter_LDMC |
  1 << ncclSymkKernelId_AllGather_RailRing_LsaSTMC;

그렇다,AllReduce_AGxLLMC_R은kernelMask_STMC안에 있다. 따라서hasLsaMultimem이 거짓이면 이 kernel은 제외된다. 이는 대칭 메모리와 NVLS가 반드시 협력해야 하는 이유를 설명한다—멀티캐스트가 없으면 MC 계열 kernel은 전부 사용할 수 없다.

디바이스 측이ncclSymkDevWork을 받은 후,inputWin과inputOff에 따라 주소를 계산한다. 주소가 멀티캐스트 그룹 내에 있으면 load/store 명령어가 NVSwitch에 의해 가로채진다. 이것이 LSA 포인터의 해석 과정이다:소프트웨어 변환이 필요 없고, 하드웨어가 주소 범위에 따라 자동으로 판단한다。

동시성 제어와 하드웨어 상호작용

NVLS의 동기화 메커니즘은credit(크레딧)。ncclNvlsSetup에 credit 파티션이 초기화된다.

📎 src/transport/nvls.cc:407-447

c
    int nChannels = comm->nvlsChannels;
    size_t creditSize = nChannels * 2 * memSize * nHeads;
    int nvlsStepSize = comm->nvlsChunkSize;

    NCCLCHECKGOTO(ncclCalloc(&comm->nvlsResources, 1), res, fail);
    comm->nvlsResources->inited = false;
    comm->nvlsResources->refCount = 1;
    comm->nvlsResources->nChannels = nChannels;
    comm->nvlsResources->nHeads = nHeads;
    comm->nvlsResources->chunkSize = comm->nvlsChunkSize;
    comm->nvlsResources->treeMaxChunkSize = comm->nvlsTreeMaxChunkSize;
    resources = comm->nvlsResources;

    for (int c = 0; c < nChannels; c++) {
      NCCLCHECKGOTO(initNvlsChannel(comm, c, NULL, false), res, fail);
    }

    memset(&resources->accessDesc, 0, sizeof(resources->accessDesc));
    resources->accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
    resources->accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
    resources->accessDesc.location.id = comm->cudaDev;
    resources->dev = comm->cudaDev;

    // Build the single shared MC group for this NVLS domain. The data slice is
    // reserved here but bound later by ncclNvlsBufferSetup.
    {
      size_t buffSize = nvlsStepSize * NCCL_STEPS;
      size_t dataSize = nChannels * 2 * buffSize * nHeads;
      size_t ubSize = ncclNvlsUbSize(comm);
      struct ncclMcRequest requests[3] = {{creditSize, 0}, {dataSize, 0}, {ubSize, 0}};
      struct ncclMcPartition partitions[3];
      NCCLCHECKGOTO(ncclMcGroupBuildPartitions(comm, requests, 3, &resources->mcGroup, partitions), res, fail);
      resources->creditPartition = partitions[0];
      resources->dataPartition = partitions[1];
      if (ubSize) {
        resources->ubPartition = partitions[2];
        NCCLCHECKGOTO(ncclMcArenaInit(comm, &resources->ubArena, &resources->ubPartition), res, fail);
        resources->ubEnabled = true;
      }
      NCCLCHECKGOTO(nvlsAllocBindUc(comm, &resources->creditPartition, creditSize, &resources->creditUc), res, fail);
    }

멀티캐스트 그룹이 세 개의 파티션으로 나뉜다:creditPartition(크레딧),dataPartition(데이터),ubPartition(사용자 버퍼). credit 파티션은 동기화에 사용된다—각 channel은 독립적인 head/tail 포인터를 가지며, 멀티캐스트 그룹을 통해 공유된다.

credit 초기화는 뒤의 루프에서 이루어진다:

📎 src/transport/nvls.cc:456-491

c
    for (int h = 0; h < nHeads; h++) {
      int nvlsPeer = comm->nRanks + 1 + h;
      for (int c = 0; c < nChannels; c++) {
        struct ncclChannel* channel = comm->channels + c;
        char* mem = NULL;
        struct ncclChannelPeer* peer = channel->peers[nvlsPeer];

        // Reduce UC -> MC
        mem = (char*)resources->creditUc.ptr + (h * 2 * nChannels + c) * memSize;
        peer->send[1].transportComm = &nvlsTransport.send;
        peer->send[1].conn.buffs[NCCL_PROTO_SIMPLE] = NULL;
        peer->send[1].conn.head = (uint64_t*)mem;
        peer->send[1].conn.tail = (uint64_t*)(mem + memSize / 2);
        peer->send[1].conn.stepSize = nvlsStepSize;
        mem = (char*)resources->creditPartition.ptr + (h * 2 * nChannels + c) * memSize;
        peer->recv[0].transportComm = &nvlsTransport.recv;
        peer->recv[0].conn.buffs[NCCL_PROTO_SIMPLE] = NULL;
        peer->recv[0].conn.head = (uint64_t*)mem;
        peer->recv[0].conn.tail = (uint64_t*)(mem + memSize / 2);
        peer->recv[0].conn.stepSize = nvlsStepSize;
        peer->recv[0].conn.flags |= NCCL_NVLS_MIN_POLL;

각 head와 channel 조합마다 독립적인 credit 영역이 있다.head과tail은 64비트 포인터이고,memSize은 64바이트(size_t memSize = 64;)이므로 head와 tail이 각각 32바이트를 차지한다—정확히 캐시 라인 절반이다.NCCL_NVLS_MIN_POLL플래그는 수신 측이 최소 폴링 모드를 사용하도록 하여 CPU 오버헤드를 줄인다.

프로덕션 함정 회피 가이드

함정 1: credit 파티션의 head/tail 경쟁.여러 channel이 같은 멀티캐스트 그룹을 공유하지만, 각 channel은 독립적인 credit 영역을 가진다. channel 수가 부적절하게 설정되면(예:nvlsCTAs를 너무 크게 설정), credit 영역이 팽창하여 소중한 멀티캐스트 주소 공간을 차지한다.ncclNvlsChannels은 GPU 아키텍처와 노드 수에 따라 channel 수를 자동으로 조정한다:

📎 src/transport/nvls.cc:100-133

c
  if (comm->config.nvlsCTAs != NCCL_CONFIG_UNDEF_INT) {
    channels = comm->config.nvlsCTAs;
  } else if (channels == 0 && comm->compCap >= 100) {
    // Use a reduced number of channels for single node/MNNVL domain on Blackwell and above.
    // comm->nNodes is not yet initialized at this point so we need to use local information.
    bool multiNode = false;
    if (comm->MNNVL) {
      multiNode = (comm->clique.size < comm->nRanks);
    } else {
      int i;
      for (i = 1; i < comm->nRanks; i++) {
        if (comm->peerInfo[i].hostHash != comm->peerInfo[0].hostHash) break;
      }
      multiNode = (i < comm->nRanks);
    }
    if (multiNode) {
      channels = RUBIN_AND_LATER(comm->compCap) ? /*RUBIN=*/64 : /*SM100=*/32;
    } else {
      channels = RUBIN_AND_LATER(comm->compCap) ? /*RUBIN=*/48 : /*SM100=*/24;
    }
  } else if (channels == 0) {
    channels = /*SM90=*/16;
  }

주의:comm->nNodes은 이 단계에서 아직 초기화되지 않았으므로, 코드는peerInfo[i].hostHash로 수동으로 다중 노드 여부를 판단한다. 이는 초기화 순서의 전형적인 함정이다—아직 계산되지 않은 필드에 의존할 수 없다.

함정 2: MNNVL은 NVLS buffer 등록을 지원하지 않는다. 📎 src/transport/nvls.cc:516-517

c
  // MNNVL does not support NVLS buffer registration
  if (!comm->MNNVL && comm->nvlsResources->nvlsShmemHandle == NULL) {

MNNVL(Multi-Node NVLink) 환경에서는 사용자 버퍼 등록이 건너뛰어진다. 클러스터가 MNNVL이고 UB 등록으로 성능 향상을 기대한다면, 등록이 적용되지 않음을 발견하게 된다. 이는 하드웨어 제한이지 버그가 아니다.

함정 3: 공유 자원의 참조 카운트. ncclNvlsSetup부모-자식 통신 도메인 간 NVLS 자원 공유 지원:

📎 src/transport/nvls.cc:380-392

c
  if (nvlsShare) {
    /* reuse NVLS resources */
    comm->nvlsChannels = std::min(comm->nvlsChannels, parent->nvlsResources->nChannels);
    /* Inherit chunk sizes from the shared resource since we're reusing the parent's
     * NVLS buffers, which were allocated and laid out based on these values. */
    comm->nvlsChunkSize = parent->nvlsResources->chunkSize;
    comm->nvlsTreeMaxChunkSize = parent->nvlsResources->treeMaxChunkSize;
    for (int c = 0; c < comm->nvlsChannels; c++) {
      NCCLCHECKGOTO(initNvlsChannel(comm, c, parent, true), res, fail);
    }

    comm->nvlsResources = parent->nvlsResources;
    ncclAtomicRefCountIncrement(&parent->nvlsResources->refCount);
  }

자식 통신 도메인이 부모 통신 도메인의 자원을 재사용하면 참조 카운트가 1 증가한다.ncclNvlsFree에서 참조 카운트가 0으로 줄어야 실제로 해제된다. 참조 카운트 관리에 오류가 생기면 자원이 조기 해제되거나 누수된다. 주의nvlsChunkSize과nvlsTreeMaxChunkSize은 반드시 부모 통신 도메인의 값을 상속해야 한다——버퍼가 이 값들에 따라 배치되므로, 변경하면 주소 계산 오류가 발생한다.

mermaid
flowchart LR
    subgraph host["Host 侧"]
        task["ncclTaskColl<br/>sendbuff/recvbuff"]
        devwork["ncclSymkDevWork<br/>inputWin + inputOff"]
        task -->|"ncclSymkMakeDevWork"| devwork
    end
    subgraph device["Device 侧"]
        kernel["SymKernel<br/>load/store"]
        lsa{"地址在多播组内?"}
        devwork --> kernel
        kernel --> lsa
    end
    subgraph hw["NVSwitch 硬件"]
        mc["多播组<br/>MC group"]
        reduce["硬件归约<br/>Reduction"]
        lsa -->|"是"| mc
        lsa -->|"否"| local["本地显存<br/>UC memory"]
        mc --> reduce
        reduce -->|"广播结果"| kernel
    end

이 데이터 흐름 다이어그램은 host 측 작업에서 디바이스 측 실행까지의 전체 경로를 보여준다. 핵심 분기는lsa{"地址在多播组内?"}——만약 그렇다면 NVSwitch 하드웨어 멀티캐스트와 리덕션을 타고, 아니라면 로컬 VRAM을 탄다. 이 판단은 하드웨어가 주소 범위에 따라 자동으로 수행하며 소프트웨어 개입이 필요 없다.

---

14.4 설계 사고: 왜 대칭 메모리가 소형 메시지 지연을 줄일 수 있는가

이 장 서두의 핵심 질문으로 돌아가자: 왜 대칭 메모리가 소형 메시지 지연을 현저히 줄일 수 있는가?

첫째, 주소 변환 오버헤드를 제거한다.전통적 통신에서는 각 rank가 상대방 버퍼에 접근할 때마다 테이블 조회와 오프셋 계산이 필요하다. 대칭 메모리는 모든 rank가 동일한 주소 집합을 사용하게 하여, 디바이스 측 kernel이 직접base + offset을 계산하면 된다. 소형 메시지의 경우 이 변환 오버헤드의 비중이 매우 높다.

둘째, 제어 메시지 왕복을 제거한다.전통적 통신은 "내가 너의 어느 버퍼에 쓸 것인가"와 같은 제어 정보를 교환해야 한다. 대칭 메모리에서는 주소가 사전에 약정되어 있어 런타임 협상이 필요 없다.

셋째, 하드웨어 멀티캐스트를 가능하게 한다.주소가 대칭일 때만 NVSwitch가 동일한 주소 집합으로 멀티캐스트를 수행할 수 있다. 각 rank의 주소가 다르면 하드웨어는 어디로 브로드캐스트해야 하는지 알 수 없다.

넷째, SM의 리덕션 부담을 줄인다.NVLS는 덧셈을 NVSwitch에 오프로드하여, SM은 한 번의 쓰기와 한 번의 읽기만 발행하면 된다. 소형 메시지의 경우 SM의 명령 오버헤드가 지연의 주요 원인이다.

이 네 가지 요소가 겹쳐져 소형 메시지 지연을 "마이크로초급"에서 "서브마이크로초급"으로 낮춘다.

〔설계 추론과 아키텍처 트레이드오프〕

엔지니어링 관점에서 대칭 메모리 설계는 NCCL의 핵심 철학을 보여준다:복잡성을 초기화 단계로 밀어넣고, 핫 패스를 가능한 한 단순하게 유지한다. 주소 협상, 멀티캐스트 그룹 생성, credit 할당은 모두 초기화 시 완료되며, 런타임 kernel은 가장 단순한 주소 계산과 load/store만 수행하면 된다. 이러한 "초기화는 무겁게, 런타임은 가볍게" 설계는 고성능 통신 라이브러리의 보편적 패턴이다.

---

이 장 요약

이 장은 NCCL 노드 내 통신의 두 기둥을 분해했다:

1. 대칭 메모리:ncclSymkInitOnce과ncclSymkMask을 통해 주소가 일치하는 버퍼를 구축하여, 각 rank가 동일한 주소 집합으로 모든 rank의 데이터에 접근하게 한다.ncclSymkMakeDevWork은 host 측 작업을 디바이스 측 작업 항목으로 변환하며,inputWin + inputOff은 주소 해석의 핵심 공식이다.

2. NVLS 멀티캐스트:ncclMcGroupBuildPartitions을 통해 멀티캐스트 그룹을 생성하고,ncclMcPartitionBindMem은 UC 메모리를 멀티캐스트 그룹에 바인딩하며,cuMulticastBindMem은 하드웨어 호출이다. 멀티캐스트 그룹은 credit, data, ub 세 파티션으로 나뉘어 각각 동기화, 데이터 전송, 사용자 버퍼 등록에 사용된다.

3. LSA 포인터 해석: 디바이스 측이 주소 범위에 따라 멀티캐스트 경로를 탈지 자동으로 판단하며, 소프트웨어 변환이 필요 없다.NCCL_NVLS_MIN_POLL플래그는 폴링 오버헤드를 최적화한다.

4. 오류 처리:ncclMcPartitionTryBindAddr은 영구적 실패와 일시적 실패를 구분하며,ncclMcGroupBuildPartitions의 fail 경로는CUCALL을 사용하여 자원 해제를 보장한다.

이 장 사고와 자가 점검

Q1: 만약ncclMcPartitionBindMem의 경계 검사if (offsetInPartition + bindSize > partition->size)을 제거하면, 어떤 시나리오에서 메모리 범위 초과가 발생하는가? 왜 이 검사를 "UC와 MC의 입도가 동일하다"로 대체할 수 없는가?

참고 해석:📎 src/transport/multicast.cc:200-208:

CHAPTER 15

제15장: 제15장: RMA와 GIN: 원격 메모리 접근과 GPU 직결 통신의 진화

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제15 / 25장

제15장: RMA와 GIN: 원격 메모리 접근과 GPU 직결 통신의 진화

이전 장에서 우리는 대칭 메모리가 각 rank로 하여금 동일한 주소 집합으로 모든 rank의 버퍼에 접근하게 하고, NVLS가 NVSwitch의 멀티캐스트 능력을 빌려 하드웨어 가속 리덕션을 극한으로 밀어붙이는 것을 보았다. 그러나 집합 통신이 전부는 아니다——애플리케이션이 점대점 원격 메모리 연산을 필요로 하거나, GPU kernel이 직접 네트워크 요청을 발행하기를 원할 때 RMA와 GIN이 등장한다. RMA는 put/get 시맨틱의 원격 메모리 접근을 제공하고, GIN은 GPU가 host proxy 스레드를 우회하여 네트워크와 직접 상호작용하게 한다. 이 장은 "먼저 RMA, 나중에 GIN" 순서로 이 두 메커니즘의 데이터 구조, 스케줄링 로직, 동시성 제어, 프로덕션 함정을 층층이 분해한다.

RMA의 이중 채널 모델: CE와 Proxy의 분업

직관적 모델

국가 간 택배 시스템을 상상해 보자: 같은 도시 내 택배(LSA 도달 가능한 rank)는 로컬 배송 차량으로 직접 배달할 수 있지만, 도시 간 택배(LSA 도달 불가능한 rank)는 반드시 항공 화물 대리점에 맡겨야 한다. NCCL의 RMA가 바로 이 모델이다——동일한 put 연산이 대상 rank가 LSA(Load-Store Accessible) 팀 내에 있는지에 따라 완전히 다른 두 실행 경로, 즉 CE(Copy Engine, 복사 엔진) 경로와 Proxy(프록시 스레드) 경로로 라우팅된다.

만약 이 분기 메커니즘이 없다면 모든 RMA 연산이 proxy 스레드를 거치게 되어, 같은 머신 내의 put도 host 스레드를 중계해야 하므로 불필요하게 host-device 왕복 지연이 한 번 추가된다. 반대로 모든 연산이 CE를 거친다면 머신 간 연산은 네트워크 플러그인의 비동기 능력을 활용할 수 없다.

데이터 구조와 메모리 레이아웃

RMA의 핵심 스케줄링 구조는ncclRmaArgs이며, 이는 하나의 plan에서 RMA 작업의 분기 결과를 기록한다. 주요 필드는 다음과 같다:

필드의미
func연산 유형(PutSignal / Signal / WaitSignal)
nRmaTasks총 작업 수
nRmaTasksProxyproxy 경로를 사용하는 작업 수
nRmaTasksCeCE 경로를 사용하는 작업 수

각 plan 내부에는 두 개의 침투적 큐가 유지된다:rmaTaskQueueCe와rmaTaskQueueProxy이며, 각각 두 경로의 작업을 저장한다.📎 src/rma/rma.cc:166-171

rank가 LSA 도달 가능한지 판단하는 로직은 매우 직접적이다——배열을 순회하며lsaRankList선형 검색을 수행한다.📎 src/rma/rma.cc:34-41이 검색은 작업 스케줄링 시 각 peer에 대해 한 번 실행되며, 복잡도는 O(lsaSize)이고, 일반적인 소규모 LSA 팀(보통 2-8개 rank)에서는 오버헤드가 무시할 수 있는 수준이다.

단계별 스케줄링 흐름

애플리케이션이 RMA put 연산을 한 번 호출하면, 작업이planner->rmaTaskQueues[ctx]。scheduleRmaTasksToPlan에 들어가며, 이는 큐의 작업을 plan에 할당하는 역할을 한다.📎 src/rma/rma.cc:141-296

첫 번째 단계: 첫 번째 비어 있지 않은 context 큐를 찾는다. NCCL은 여러 RMA context를 지원하며(numRmaCtx로 구성), 각 context는 독립적인 큐를 가진다.📎 src/rma/rma.cc:148-155

두 번째 단계: 첫 번째 작업을 꺼내 연산 유형을 판단한다. WaitSignal이면 특수한 분할 로직을, Put/Signal이면 일괄 병합 로직을 따른다.📎 src/rma/rma.cc:163-168

WaitSignal 작업의 경우, 스케줄러는 peers 목록을 LSA 도달 가능성에 따라 CE 그룹과 Proxy 그룹 두 그룹으로 분할해야 한다.📎 src/rma/rma.cc:187-204분할 후 각각 두 개의 새로운ncclTaskRma구조를 생성하며, 각각 해당 그룹의 peers 배열을 보유한다.📎 src/rma/rma.cc:207-246원본 작업은 해제된다.📎 src/rma/rma.cc:251

Put/Signal 작업의 경우 로직이 더 복잡하다——스케줄러는 모든 context의 큐를 순회하며 연속된 put/signal 작업을 모두 동일한 plan으로 끌어들이고, WaitSignal을 만나면 중단한다.📎 src/rma/rma.cc:279-295이 설계의 목적은 주석에 명확히 적혀 있다: 한 번의 kernel launch로 모든 context의 put/signal을 커버하고, proxy는 어떤 블로킹 연산 전에도 모든 비동기 요청을 한꺼번에 발행할 수 있으며, CE 경로는 모든 context의 복사와 신호를 일괄 제출한다.📎 src/rma/rma.cc:270-278

mermaid
flowchart TD
    start["scheduleRmaTasksToPlan(comm, plan)"]
    find_ctx{"找到非空 ctx 队列?"}
    no_task["返回 ncclSuccess"]
    dequeue["取出 firstTask"]
    check_func{"firstTask->func == WaitSignal?"}
    ws_split["按 isLsaAccessible 拆分 peers"]
    ws_ce{"npeersCe > 0?"}
    ws_proxy{"npeersProxy > 0?"}
    ws_ce_task["创建 CE WaitSignal 任务"]
    ws_proxy_task["创建 Proxy WaitSignal 任务"]
    ws_free["释放原始 firstTask"]
    put_check{"firstTask 的 peer LSA 可达?"}
    put_ce["入队 rmaTaskQueueCe"]
    put_proxy["入队 rmaTaskQueueProxy"]
    batch_loop["遍历所有 ctx 队列, 拉取连续 put/signal"]
    batch_check{"isRmaPutOrSignal(task->func)?"}
    batch_route{"isLsaAccessible(comm, task->peer)?"}
    batch_ce["入队 CE, nRmaTasksCe++"]
    batch_proxy["入队 Proxy, nRmaTasksProxy++"]
    done["记录 INFO 日志, 返回"]

    start --> find_ctx
    find_ctx -->|否| no_task
    find_ctx -->|是| dequeue
    dequeue --> check_func
    check_func -->|是| ws_split
    ws_split --> ws_ce
    ws_ce -->|是| ws_ce_task
    ws_ce -->|否| ws_proxy
    ws_ce_task --> ws_proxy
    ws_proxy -->|是| ws_proxy_task
    ws_proxy -->|否| ws_free
    ws_proxy_task --> ws_free
    ws_free --> done
    check_func -->|否| put_check
    put_check -->|是| put_ce
    put_check -->|否| put_proxy
    put_ce --> batch_loop
    put_proxy --> batch_loop
    batch_loop --> batch_check
    batch_check -->|否, 遇到 WaitSignal| done
    batch_check -->|是| batch_route
    batch_route -->|是| batch_ce
    batch_route -->|否| batch_proxy
    batch_ce --> batch_loop
    batch_proxy --> batch_loop

병렬 실행과 스트림 동기화

스케줄링이 완료되면,ncclLaunchRma은func필드에 따라ncclRmaPut또는ncclRmaWaitSignal。📎 src/rma/rma.cc:109-131

로 분배된다.ncclRmaPut을 예로 들면, plan에 proxy와 CE 작업이 동시에 존재할 때 두 경로는 병렬로 실행되어야 한다. NCCL의 방식은: 입력 스트림에 event를 기록하고, CE 스트림이 이 event를 기다리게 한 다음, 동시에 두 스트림에서 연산을 시작하고, 마지막으로 CE 스트림에 다시 event를 기록하여 입력 스트림이 이를 기다리게 하는 것이다.📎 src/rma/rma.cc:80-96이 event 체인은 다음을 보장한다: CE 연산은 입력 스트림의 의존성이 준비되기 전에 시작되지 않고, 입력 스트림의 후속 연산도 CE가 완료되기 전에 시작되지 않는다.

proxy 작업만 있거나 CE 작업만 있으면, 입력 스트림에서 해당 연산을 직접 시작하며 추가적인 스트림 동기화가 필요 없다.📎 src/rma/rma.cc:97-101

설계 고찰과 프로덕션 함정

함정 1: LSA 도달 가능성 판단의 정적성. isLsaAccessible스케줄링 시comm->devrState.lsaRankList을 조회하는데, 이 목록은 통신 도메인 초기화 후에는 변하지 않는다. 만약 실행 중 토폴로지가 변경되면(예: NVLink 장애로 인한 성능 저하), LSA 목록이 자동으로 갱신되지 않아, 원래 proxy를 사용해야 할 연산이 여전히 CE 경로를 사용하여 복구 불가능한 오류를 유발할 수 있다.

함정 2: 일괄 병합의 FIFO 보장.일괄 병합 로직은 연속된 put/signal 작업만 가져오고, WaitSignal을 만나면 중단한다.📎 src/rma/rma.cc:283이는 각 context 내의 FIFO 순서를 보장하지만, context 간 작업은 동일한 plan으로 병합될 수 있다. 애플리케이션이 context 간 연산 순서에 의존한다면, 명시적으로 WaitSignal을 사용하여 배리어를 설정해야 한다.

함정 3: 메모리 누수 경로.WaitSignal 분기에서 만약npeersProxy == 0이면, 코드는peersProxy、nsignalsProxy、signalIdxsProxy세 배열을 해제한다.📎 src/rma/rma.cc:239-244그러나 만약npeersCe == 0이고npeersProxy > 0,peersCe등의 배열이ncclMemoryStackAlloc로 할당되었다면, 수동으로 해제할 필요가 없다(스택 할당자가 일괄 회수).📎 src/rma/rma.cc:176-178이 비대칭성은 독자가 혼동하기 쉽지만 실제로는 올바르다—스택에 할당된 메모리는comm->memScoped에 의해 통합 관리된다.

RMA Proxy 컨텍스트: 시그널, 큐 및 락프리 링 버퍼

직관적 모델

Proxy 컨텍스트는 "우체국 분류 센터"와 같다: GPU는 보낼 소포(put 요청)를 수신함(링 버퍼)에 넣고, proxy 스레드는 수신함에서 소포를 꺼내 택배 회사(네트워크 플러그인)에 전달하며, 택배 회사는 배송 후 영수증(시그널)에 도장을 찍는다. 전체 과정에서 GPU와 proxy 스레드는 락프리 데이터 구조를 통해 통신하여 값비싼 락 경합을 피한다.

데이터 구조와 메모리 레이아웃

ncclRmaProxyCtx은 proxy 컨텍스트의 호스트 구조이며, 핵심 필드는 다음과 같다:

시그널 영역(signalsDev): GPU에 할당된 메모리 블록으로, 크기는nRanks * numRmaSig * sizeof(uint64_t)。📎 src/rma/rma_proxy.cc:120-123각 rank는numRmaSig개의 시그널 슬롯을 가지며, 해당 rank로부터의 시그널을 수신하는 데 사용된다. 이 메모리는 네트워크 플러그인에 등록될 때NCCL_NET_MR_FLAG_FORCE_SO(강제 강순서) 및NCCL_NET_MR_FLAG_SIGNAL_NEVER_RESET(시그널 절대 리셋 안 함) 플래그를 가진다.📎 src/rma/rma_proxy.cc:125-127강순서 플래그는 put과 signal 사이의 순서 관계를 보장한다—put이 signal보다 먼저 발행되면, 네트워크는 signal이 put 데이터 도착 후에만 기록되도록 보장해야 한다.

시퀀스 번호 영역(opSeqs/readySeqs/doneSeqs): 각 rank당 한 세트로,allocMemCPUAccessible을 통해 할당되며, GDR(GPU Direct RDMA) 메모리이거나 일반 host 메모리일 수 있다.📎 src/rma/rma_proxy.cc:132-137이 세 가지 시퀀스 번호는 각각 추적한다: 제출된 작업 번호, 준비된 작업 번호, 완료된 작업 번호.

락프리 링 버퍼(circularBuffers): 크기가nRanks * queueSize인 포인터 배열로, 각 rank마다 독립적인 링 큐를 가진다.📎 src/rma/rma_proxy.cc:163-164함께 제공되는pis(Producer Index)와cis(Consumer Index) 배열은 각각nRanks개의 요소를 가진다.📎 src/rma/rma_proxy.cc:165-166큐 크기는 2의 거듭제곱이어야 하며, 이렇게 하면 인덱스 랩어라운드가 모듈로 연산 대신 비트 AND 연산& (queueSize - 1)으로 처리될 수 있다.📎 src/rma/rma_proxy.cc:156-160

InProgress 큐: 각 peer당 하나의 침습적 연결 리스트로, 네트워크 플러그인에 제출되었지만 아직 완료되지 않은 디스크립터를 저장한다.📎 src/rma/rma_proxy.cc:170-175이는 단일 소비자 큐로, proxy 스레드만 접근하므로 원자적 연산이 필요 없다.

Step-by-Step: 컨텍스트 생성부터 진행推进까지

컨텍스트 생성:ncclRmaProxyCreateContext먼저 RMA 플러그인을 통해 네트워크 컨텍스트를 생성한다.📎 src/rma/rma_proxy.cc:229그런 다음ncclRmaProxyCtxAlloc을 호출하여 시그널, 시퀀스 번호, 링 버퍼 등의 리소스를 할당한다.📎 src/rma/rma_proxy.cc:231이어서ncclRmaProxyCtxAllocGraph을 호출하여 그래프 캡처 모드에 필요한 리소스—CPU 접근 가능 시그널, flush 버퍼, 영속 큐—를 할당한다.📎 src/rma/rma_proxy.cc:232

그래프 캡처 모드가 존재하는 이유는 CUDA Graph가 모든 작업의 재생을 요구하기 때문이다. 일반 모드에서는 시그널이 GPU 메모리에 있고 proxy가 GDR을 통해 읽는다; 그래프 캡처 모드에서는 시그널이 CPU 접근 가능 메모리에 있어 proxy가 직접 읽고 쓸 수 있으므로 GDR의 불확실성을 피한다.📎 src/rma/rma_proxy.cc:184-190

진행 스레드:ncclRmaProxyProgressThread은 proxy의 메인 루프이다.📎 src/rma/rma_proxy.cc:354-389이는rmaProgress상태 워드에 따라 동작을 결정한다:

  • rmaProgress == 1: 정상 진행 모드로, 모든 proxy 컨텍스트를 순회하며ncclRmaProxyProgress。📎 src/rma/rma_proxy.cc:361-372
  • rmaProgress == 2을 호출한다: 일시정지 모드로, 리소스 회수에 사용된다. 스레드는 일시정지를 확인한 후 조건 변수를 기다린다.📎 src/rma/rma_proxy.cc:373-378
  • rmaProgress == -1: 종료 시그널로, 스레드가 반환된다.📎 src/rma/rma_proxy.cc:379-380
  • rmaProgress == 0: 유휴 대기.📎 src/rma/rma_proxy.cc:381-382

만약ncclRmaProxyProgress이 오류를 반환하면, 스레드는 오류 코드를asyncResult에 기록하고,rmaProgress = -2을 설정한 후 종료한다.📎 src/rma/rma_proxy.cc:365-369이 오류 코드는 메인 스레드가 이후ncclCommGetAsyncError호출에서 읽게 된다.

동시성 제어와 메모리 순서

RMA proxy의 동시성 모델은 "단일 생산자-단일 소비자"이다: GPU kernel이 생산자이고, proxy 스레드가 소비자이다. 링 버퍼의 PI는 GPU가 업데이트하고, CI는 proxy가 업데이트한다. 단일 생산자 단일 소비자이므로 CAS 연산이 필요 없고, 올바른 메모리 순서만 필요하다.

시그널 영역의 강순서 플래그NCCL_NET_MR_FLAG_FORCE_SO이 핵심이다.📎 src/rma/rma_proxy.cc:127이 플래그가 없으면 네트워크 플러그인이 put과 signal의 순서를 재배열할 수 있어, 수신자가 데이터 도착 전에 시그널을 보고 더티 데이터를 읽을 수 있다.

NCCL_NET_MR_FLAG_SIGNAL_NEVER_RESET플래그는 네트워크 플러그인에게 알린다: 시그널은 한 번 기록되면 리셋되지 않는다.📎 src/rma/rma_proxy.cc:127이는 플러그인이 시그널 쓰기 경로를 최적화할 수 있게 한다—매번 쓰기 전에 제로화할 필요가 없다.

생산 함정

함정 1: 큐 크기가 2의 거듭제곱이 아님.만약 사용자가NCCL_RMA_PROXY_QUEUE_SIZE을 통해 2의 거듭제곱이 아닌 값을 설정하면, 코드는 기본값으로 폴백하고 INFO 로그를 출력한다.📎 src/rma/rma_proxy.cc:156-159이 폴백은 조용하다(INFO 레벨만). 프로덕션 환경에서 무시되기 쉽다. 사용자가 버스트 트래픽을 흡수하기 위해 더 큰 큐를 기대했지만 실제로는 기본값이 사용되면 백프레셔가 발생할 수 있다.

함정 2: DMA-BUF 등록 실패 시 폴백 체인. ncclRmaProxyRegMrSymCUDA 메모리 등록에는 세 단계 폴백이 있다: 먼저 DataDirect 모드의 DMA-BUF를 시도하고, 실패하면 비 DataDirect DMA-BUF를 시도하고, 다시 실패하면 일반regMrSym。📎 src/rma/rma_proxy.cc:76-108으로 폴백한다. 주석에서 특별히 경고한다: 하나의 MR이 비 DataDirect 경로로 들어가면, 다른 모든 MR도 그렇게 해야 하며, 혼합 사용은 GIN의 순서 보장을 깨뜨린다.📎 src/gin/gin_host_proxy.cc:429-430이 제약은 RMA 경로에서 명시적으로 검사되지 않으며, 잠재적 위험이다.

함정 3: 진행 스레드의 오류 전파 지연.当ncclRmaProxyProgress이 오류를 반환하면, 스레드는asyncResult을 설정하고 종료한다.📎 src/rma/rma_proxy.cc:366-369하지만 메인 스레드는 장시간 실행되는 kernel을 실행 중일 수 있어 즉시 확인하지 않습니다asyncResult. 이 기간 동안 후속 RMA 작업은 계속 큐에 들어가지만 메인 스레드가 오류를 발견할 때까지 처리되지 않습니다. 이는 비동기 오류 전파의 고유한 지연이며, 애플리케이션은 주기적으로ncclCommGetAsyncError를 호출하여 이 윈도우를 줄여야 합니다.

GIN 아키텍처: GPU가 직접 네트워크 요청을 시작

직관적 모델

전통적인 모드에서 GPU가 네트워크 데이터를 전송하려면 반드시 "GPU → host 메모리 → proxy 스레드 → 네트워크 카드" 경로를 거쳐야 합니다. GIN(GPU-Initiated Networking)의 목표는 GPU가 네트워크 카드의 전송 큐에 직접 쓰는 것으로, 마치 CPU가 네트워크 카드의 MMIO 레지스터에 직접 쓰는 것과 같습니다. 이를 위해서는 네트워크 카드가 GPU가 시작한 doorbell 쓰기를 지원해야 하며, GPU와 proxy 스레드 간의 통신 프로토콜이 필요합니다.

데이터 구조와 메모리 레이아웃

GIN의 핵심 데이터 구조는ginProxyHostGpuCtx이며, 이는 GPU-host 통신 컨텍스트를 나타냅니다:

필드타입의미
queuesncclGinProxyGfd_t*GFD 큐, 크기nRanks * queueSize
pisuint32_t*생산자 인덱스(GPU 쓰기)
cisuint32_t*소비자 인덱스(proxy 쓰기)
cisShadowuint32_t*CI의 섀도 복사본(proxy 로컬)
sisuint32_t*확인된 인덱스(proxy 로컬)
statesginProxyGfdState*각 GFD 슬롯의 상태
inlinesuint64_t*인라인 데이터 버퍼

GFD(GIN Forwarding Descriptor)는 GPU가 proxy에 쓰는 요청 설명자입니다. 각 GFD는 여러 qword로 구성되며, 작업 유형, 소스 주소, 대상 주소, 크기, 신호 정보 등을 포함합니다.📎 src/gin/gin_host_proxy.cc:158-163

queues배열의 메모리 할당에는 중요한 세부 사항이 있습니다:allocMemCPUAccessible를 통해 할당되지만,forceHost=true매개변수가 전달됩니다.📎 src/gin/gin_host_proxy.cc:564이는 큐 자체가 host 메모리에 있고 GPU가 PCIe를 통해 쓰는 것을 의미합니다. 반면cis배열은 GPU 접근 가능 메모리(GDR일 수 있음)에 할당됩니다. proxy가 이를 자주 업데이트해야 하기 때문입니다.📎 src/gin/gin_host_proxy.cc:565-566

cisShadow과sis는 proxy 스레드의 로컬 복사본으로, 매번 GPU 메모리에 있을 수 있는cis。📎 src/gin/gin_host_proxy.cc:44-47를 읽는 것을 피합니다.cisShadow가 전진할 때만cis。

Step-by-Step: GFD의 폴링과 처리

ncclGinProxyProgress는 GIN proxy의 메인 루프입니다.📎 src/gin/gin_host_proxy.cc:648-669

첫 번째 단계: 각 context에 대해 먼저proxyGinPollCompletions를 호출하여 제출된 요청의 완료 상태를 확인합니다.📎 src/gin/gin_host_proxy.cc:653

두 번째 단계: 각 target rank에 대해 GFD를 배치 폴링합니다.pollBatch는 매번 최대 몇 개의 GFD를 처리할지 제어합니다.📎 src/gin/gin_host_proxy.cc:654-655

세 번째 단계:proxyGinPollGfd는 큐 헤드에 새로운 GFD가 있는지 확인합니다. 판단 기준은 GFD 헤드의 flag 비트가 0이 아닌지 여부입니다.📎 src/gin/gin_host_proxy.cc:176-182있다면, 먼저 첫 번째 qword(헤드)를 복사한 후 나머지 qword가 준비될 때까지 기다립니다.📎 src/gin/gin_host_proxy.cc:194-202복사가 완료되면 큐의 GFD를 0으로 초기화하여 중복 처리를 방지합니다.📎 src/gin/gin_host_proxy.cc:206-208

네 번째 단계:proxyGinProcessGfd는 작업 유형에 따라 다른 처리 경로로 분배합니다.📎 src/gin/gin_host_proxy.cc:246-340

mermaid
flowchart TD
    poll_start["proxyGinPollGfd(ctx, hostGpuCtx, targetRank)"]
    check_avail{"isGfdAvailable?"}
    no_gfd["返回 0, 跳出批量循环"]
    copy_header["拷贝 GFD header qword"]
    copy_rest["循环等待并拷贝其余 qword"]
    reset_gfd["清零队列中的 GFD"]
    set_state["设置 state->op, counterId, done=0"]
    inc_sis["sis[targetRank]++"]
    process["proxyGinProcessGfd(ctx, hostGpuCtx, targetRank, gfd, state, isLastInBatch)"]
    check_va{"op & ncclGinProxyOpVASignal?"}
    check_get{"op & ncclGinProxyOpGet?"}
    check_flush{"op & ncclGinProxyOpFlush?"}
    check_inline{"op & ncclGinProxyOpWithInline?"}
    va_signal["rmaBackend->iputSignal(...)"]
    get_op["rmaBackend->iget(...)"]
    flush_op["rmaBackend->iflush(...)"]
    inline_src["从 inlines 缓冲区取源地址"]
    normal_src["从 GFD 取源地址"]
    put_signal["rmaBackend->iputSignal(...)"]
    put_only["rmaBackend->iput(...)"]

    poll_start --> check_avail
    check_avail -->|否| no_gfd
    check_avail -->|是| copy_header
    copy_header --> copy_rest
    copy_rest --> reset_gfd
    reset_gfd --> set_state
    set_state --> inc_sis
    inc_sis --> process
    process --> check_va
    check_va -->|是| va_signal
    check_va -->|否| check_get
    check_get -->|是| get_op
    check_get -->|否| check_flush
    check_flush -->|是| flush_op
    check_flush -->|否| check_inline
    check_inline -->|是| inline_src
    check_inline -->|否| normal_src
    inline_src --> put_signal
    normal_src --> put_signal
    put_signal --> put_only

폴링과 카운터 업데이트 완료

proxyGinPollCompletions는 제출된 요청의 완료 상태를 확인합니다.📎 src/gin/gin_host_proxy.cc:113-156

각 target rank에 대해cisShadow에서sis까지 확인되었지만 소비되지 않은 모든 GFD 상태를 순회합니다.📎 src/gin/gin_host_proxy.cc:117상태가 완료되지 않았다면rmaBackend->test를 호출하여 확인합니다.📎 src/gin/gin_host_proxy.cc:122완료되었고 작업에 카운터 플래그가 있다면 카운터 값을 업데이트합니다.📎 src/gin/gin_host_proxy.cc:132-141

카운터 업데이트는 원자적 로드와 원자적 저장을 사용하지만, 주석에서 원자적 덧셈이 필요 없는 이유를 설명합니다: GPU kernel은 미완료 작업이 있을 때 카운터를 재설정할 수 없으므로 경쟁이 존재하지 않습니다.📎 src/gin/gin_host_proxy.cc:133-135

CI 업데이트에는 "구멍 허용" 메커니즘이 있습니다:state->done && i == cisShadow[targetRank]일 때만 CI를 전진시킵니다.📎 src/gin/gin_host_proxy.cc:145-151이는 CI가 단조 증가하도록 보장하며, 일부 GFD가 먼저 완료되더라도 미완료 GFD를 건너뛰지 않습니다.

동시성 제어와 메모리 배리어

GIN proxy의 동시성 모델은 RMA proxy보다 더 복잡합니다. 여러 proxy 스레드가 존재하기 때문입니다(GIN_PROXY_NTHREADS에 의해 제어됨).📎 src/gin/gin_host.cc:90

ncclGinProgress에서 각 스레드는 연결 그룹을 담당합니다: 스레드 t는 연결 t, t+proxyNthreads, t+2*proxyNthreads, ...를 처리합니다.📎 src/gin/gin_host.cc:72이 할당 방식은 각 연결이 하나의 스레드에 의해서만 처리되도록 보장하여 연결 수준의 경쟁을 방지합니다.

devComms 연결 리스트의 수정은 쓰기 잠금으로 보호해야 합니다.ginProgressWriteLock먼저writePending플래그를 설정한 후 쓰기 잠금을 획득합니다.📎 src/gin/gin_host.cc:43-47진행 스레드는 각 루프 시작 시writePending를 확인하고, 참이면 CPU를 양보합니다.📎 src/gin/gin_host.cc:63-66이 설계는 진행 스레드가 읽기 잠금을 보유한 상태에서 쓰기 잠금에 의해 차단되는 것을 방지합니다.

writePending를 사용하지만, 주석에서 이 로직이 단일 작성자를 가정한다고 지적합니다.std::atomic<bool>NCCL 사용 시나리오에서는 메인 스레드만 devComms 연결 리스트를 수정하므로 이 가정이 성립합니다.📎 src/gin/gin_host.cc:43-47프로덕션 함정

함정 1: GFD 큐의 메모리 위치.

는 host 메모리에 강제 할당됩니다( queues이는 GPU가 GFD를 쓰려면 PCIe 버스를 거쳐야 함을 의미합니다. GFD 쓰기 빈도가 높으면(소형 메시지 시나리오) PCIe 대역폭이 병목이 될 수 있습니다. 반면,forceHost=true),📎 src/gin/gin_host_proxy.cc:564는 GPU 접근 가능 메모리에 할당됩니다. proxy가 이를 자주 업데이트해야 하기 때문입니다.cis함정 2: 인라인 데이터의 재구성.📎 src/gin/gin_host_proxy.cc:565-566

GFD에 인라인 데이터가 있을 때 proxy는 여러 qword에서 인라인 값을 재구성해야 합니다. 当 GFD 带有内联数据时,proxy 需要从多个 qword 中重建内联值。📎 src/gin/gin_host_proxy.cc:298-305재구성 로직은 size에 따라 어떤 qword를 읽을지 결정합니다: size ≤ 4이면 하위 32비트만 읽고, size > 4이면 하위 64비트를 읽고, size > 6이면 상위 16비트를 추가로 읽습니다. 이 분할 로직은 GPU 측 쓰기 로직과 반드시 엄격하게 대응되어야 하며, 어떤 불일치라도 데이터 손상을 초래합니다.

함정 3: 멀티스레드 진행 상황과 연결 할당.서로 다른 rank가 서로 다른GIN_PROXY_NTHREADS를 설정한 경우, AllGather로 최솟값을 취한 후 일부 스레드가 어떤 연결도 할당받지 못할 수 있습니다.📎 src/gin/gin_host.cc:181-183주석에 따르면 이러한 스레드는 stride 루프에서 공회전하며 정확성 문제는 일으키지 않지만 CPU 자원을 낭비합니다.

GIN 백엔드 선택과 버전 호환성

직관적 모델

GIN은 여러 백엔드를 지원합니다: Proxy(RMA 플러그인 기반 소프트웨어 시뮬레이션), GDAKI(GPU Direct Async Kernel Initiated), GPI(GPU-Initiated), EFA GDA(AWS EFA의 GPU Direct Async). 이는 동일한 API에 여러 구현이 있을 수 있는 것과 같습니다 — 소프트웨어 시뮬레이션 버전은 호환성이 가장 좋지만 성능은 보통이고, 하드웨어 오프로드 버전은 성능이 가장 좋지만 특정 NIC 지원이 필요합니다.

백엔드 버전 매트릭스

각 백엔드에는 버전 호환 배열이 있으며, 인덱스는 백엔드 버전 번호이고 값은 해당 버전이 요구하는 최소 NCCL 버전입니다.📎 src/gin/gin_host.cc:27-33

백엔드버전 0버전 1버전 2버전 3
Proxy02.30.32.30.52.32.0
GDAKI02.30.32.30.5-
GPI02.30.5--
EFA GDA02.31.02.32.0-

버전 선택 로직: 버전 배열을 순회하며 요구 버전이 현재 디바이스 코드 버전보다 높은 첫 번째 항목을 찾고, 그 이전 버전이 사용 가능한 버전입니다.📎 src/gin/gin_host.cc:300-304

백엔드 선택 흐름

ncclGinDevCommSetup모든 활성 백엔드를 순회하며 각 백엔드로 DevComm 생성을 시도합니다.📎 src/gin/gin_host.cc:427-442선택 조건에는 요청된 GIN 유형 일치(또는 미지정), 시그널 능력 요구사항 충족이 포함됩니다.📎 src/gin/gin_host.cc:430-435

ncclGinValidateSignalRequest두 가지 능력을 확인합니다: 강한 시그널(supportsStrongSignals)과 VA 시그널(supportsVASignals)。📎 src/gin/gin_host.cc:230-243요청이 강한 시그널을 요구하지만 백엔드가 지원하지 않으면 해당 백엔드를 건너뜁니다.

연결 설정과 stride 계산

ncclGinConnectOnceGIN 연결을 설정합니다.📎 src/gin/gin_host.cc:92-228

연결 유형이 stride를 결정합니다: FULL 모드에서 stride는 1(모든 rank에 연결), RAIL 모드에서 stride는contiguousRanksPerHost(동일 rail의 rank에만 연결)입니다.📎 src/gin/gin_host.cc:139-145

ginDevCommSetupWithBackend에서 stride 검증 로직은 매우 엄격합니다:

  • 요청된 stride는 0이 될 수 없습니다.📎 src/gin/gin_host.cc:318-323
  • 요청된 stride는 rail team의 stride보다 클 수 없습니다.📎 src/gin/gin_host.cc:324-330
  • 요청된 stride는 이미 연결된 stride의 배수여야 합니다.📎 src/gin/gin_host.cc:331-337

이러한 제약의 동기는 계층적 배리어가 GIN이 최소한 RAIL 연결이라고 가정하기 때문입니다.📎 src/gin/gin_host.cc:325stride가 이러한 조건을 충족하지 않으면 일부 rank 간의 통신 경로가 존재하지 않을 수 있습니다.

프로덕션 함정

함정 1: 백엔드 버전 불일치.디바이스 코드 버전이 백엔드가 요구하는 최소 버전보다 낮으면,backendVersion은 더 낮은 값에 머무릅니다.📎 src/gin/gin_host.cc:301-303이로 인해 일부 새로운 기능을 사용할 수 없게 될 수 있지만(예: 시그널이 절대 리셋되지 않음) 오류는 발생하지 않습니다. 그러나 디바이스 코드 버전이 알려진 모든 버전보다 높으면,backendVersion은 최댓값을 취하여 정의되지 않은 동작을 유발할 수 있습니다.

함정 2: stride 검증의 경계.만약requestedStride % connectedStride != 0이면 생성이 실패합니다.📎 src/gin/gin_host.cc:331-337이 검사는 connectedStride가 2의 거듭제곱(FULL 모드에서 1, RAIL 모드에서contiguousRanksPerHost)이라고 가정합니다. 만약contiguousRanksPerHost이 2의 거듭제곱이 아니면(예: 3), 배수 검사가 합법적인 stride를 거부할 수 있습니다.

이 장의 생각과 자가 점검

Q1:scheduleRmaTasksToPlan의 WaitSignal 분기에서plan->rmaArgs->nRmaTasks = (npeersCe > 0 ? 1 : 0) + (npeersProxy > 0 ? 1 : 0)이 줄을 제거하고 직접 1로 설정하면 어떤 시나리오에서 문제가 발생하는가?

참고 해석:📎 src/rma/rma.cc:248。nRmaTasks이 기록하는 것은 실제로 인큐된 작업 수입니다. 모든 peer가 LSA 도달 가능하면(npeersProxy == 0), 실제로는 1개의 CE 작업만 인큐되므로nRmaTasks은 1이어야 합니다. 모든 peer가 도달 불가능하면(npeersCe == 0), 실제로는 1개의 Proxy 작업만 인큐되므로nRmaTasks도 1이어야 합니다. 그러나 peer가 혼합 분포이면 두 작업 모두 인큐되므로nRmaTasks은 2여야 합니다.

이 줄을plan->rmaArgs->nRmaTasks = 1으로 바꾸면 혼합 분포 시나리오에서nRmaTasks이 실제 작업 수를 과소평가합니다. 이후ncclRmaWaitSignal의 판단plan->rmaArgs->nRmaTasksProxy > 0 && plan->rmaArgs->nRmaTasksCe > 0은 여전히 올바르게 작동하지만(nRmaTasksProxy과nRmaTasksCe),📎 src/rma/rma.cc:47를 사용하므로),nRmaTasks에 의존하여 자원 추정이나 로그 통계를 수행하는 코드는 잘못된 결과를 얻게 됩니다. 더 심각한 것은 이후 코드가nRmaTasks을 사용하여 배열을 할당하거나 루프 횟수를 계산하면 버퍼 오버플로나 작업 누락이 발생할 수 있습니다.

Q2:proxyGinPollGfd에서hostGpuCtx->sis[targetRank]++을proxyGinProcessGfd호출 이후로 옮기면 어떤 동시성 시나리오에서 GFD가 중복 처리되는가?

참고 해석:📎 src/gin/gin_host_proxy.cc:228。sis은 "이미 본 인덱스"로, proxy가 이미 보고 처리를 시작한 GFD 수를 나타냅니다.proxyGinPollGfd은 GFD 복사를 완료한 직후 증가되고,sis그런 다음 1을 반환하여 성공을 나타냅니다. 호출자ncclGinProxyProgress은 루프에서proxyGinPollGfd을 호출하고, 1을 반환하면 다음 GFD 처리를 계속합니다.📎 src/gin/gin_host_proxy.cc:648-669

만약sis++을proxyGinProcessGfd이후로 옮기면,proxyGinProcessGfd실행 중(네트워크 플러그인의 비동기 호출을 포함할 수 있음)에sis이 여전히 현재 GFD를 가리킵니다. 이때 GPU가 동일한 슬롯에 새로운 GFD를 기록하면(큐가 환형이므로pis이 이미 랩어라운드했을 수 있음),proxyGinPollGfd이 이 슬롯을 다시 보게 되지만sis이 전진하지 않아 동일한 슬롯을 중복 처리하게 됩니다.

더 위험한 것은,proxyGinPollGfdGFD를 복사한 후 큐에 있는 GFD를 0으로 초기화한다.📎 src/gin/gin_host_proxy.cc:206-208만약sis전진하지 않으면, 다음 폴링에서 0으로 초기화된 GFD(flag가 0)를 보게 되고,isGfdAvailablefalse를 반환하여 GFD가 유실된다. 이로 인해 GPU 측은 영원히 처리되지 않을 요청을 기다리게 되어 최종적으로 교착 상태에 빠진다.

Q3:ncclRmaProxyProgressThread에서 만약rmaProgress == 2분기에서rmaProxyState->cond.notify_one()호출을 잊어버리면, 어떤 시나리오에서 메인 스레드가 영구적으로 블로킹되는가?

참고 해석: 보면📎 src/rma/rma_proxy.cc:373-378。rmaProgress == 2은 "일시정지 요청" 상태로, 리소스 회수를 위해 사용된다. 메인 스레드가rmaProgress = 2를 설정한 후, 진행 스레드가 일시정지를 확인할 때까지 기다린다. 진행 스레드는cond.wait(lock)에서 대기하며, 메인 스레드는cond.notify_one()를 호출하여 깨워야 한다.📎 src/rma/rma_proxy.cc:377

만약 진행 스레드가rmaProgress = 0를 설정한 후notify_one()를 잊어버리면, 메인 스레드는 계속 조건 변수를 기다리게 된다. 하지만 더 중요한 것은, 진행 스레드가cond.wait(lock)에서 대기할 때 메인 스레드가rmaProgress = 2를 설정하려면 먼저 락을 획득해야 한다. 만약 진행 스레드가wait이전에 락을 해제하지 않으면, 메인 스레드가 락을 획득할 수 없어 교착 상태가 발생한다.

올바른 순서는: 진행 스레드가rmaProgress = 0를 설정하고,notify_one()를 호출하여 메인 스레드를 깨운 다음,cond.wait(lock)를 호출하여 락을 해제하고 대기한다. 메인 스레드가 깨어난 후 락을 획득하고,rmaProgress = 2를 설정하며,notify_one()를 호출하여 진행 스레드를 깨운 다음, 진행 스레드가 확인할 때까지 기다린다. 진행 스레드가 깨어난 후rmaProgress = 0를 설정하고, 다시notify_one()를 호출한 다음,wait를 호출한다. 이 핸드셰이크 프로토콜에서 어느 한 단계라도notify_one()가 누락되면 영구적 블로킹이 발생한다.

RMA의 put/get 시맨틱에서 GIN의 GPU 발기 네트워크 통신까지, 우리는 NCCL이 범용 원격 메모리 접근 엔진으로 진화하는 핵심 단계를 완료했다. 그러나 메커니즘이 아무리 정교하더라도, 최종적으로는 플러그인 체계를 통해 외부 네트워크 백엔드, 튜닝 전략, 성능 수집기와 연결되어야 한다. 다음 장에서는 플러그인 세계로 들어가, NCCL이 핵심 코드를 수정하지 않고도 net, tuner, profiler, env 등의 확장을 동적으로 로드하는 방법을 살펴보고, google-fastsocket과 google-CoMMA를 예로 들어 생태계 확장성 구현의 핵심을 밝힌다.

CHAPTER 16

제 16 장: 제 16 장: 플러그인 생태계와 환경 변수: net, tuner, profiler, env가 NCCL 동작을 확장하는 방법

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 16 / 25 장

제 16 장: 플러그인 생태계와 환경 변수: net, tuner, profiler, env가 NCCL 동작을 확장하는 방법

이전 장에서 우리는 NCCL이 RMA와 GIN을 통해 통신 능력을 집합 연산에서 점대점 원격 접근으로 확장하고, 심지어 GPU가 직접 네트워크 요청을 발기하도록 하는 방법을 보았다. 새로운 하드웨어와 저지연 시나리오로의 이러한 진화는 통신 엔진의 유연성에 더 높은 요구를 제기한다: 만약 새로운 네트워크, 새로운 튜닝 전략, 또는 새로운 수집 도구를 적응시킬 때마다 핵심 코드를 다시 컴파일해야 한다면, NCCL은 생태계 변화를 따라가기 어려울 것이다. 이 장에서는 src/plugin과 plugins 디렉터리를 분석하여, 핵심 질문 하나에 답한다: NCCL이 핵심 코드를 재컴파일하지 않고도 네트워크 백엔드, 튜닝 전략, 성능 수집기, 설정 소스를 교체하는 방법은 무엇인가.

16.1 플러그인 로더: plugin_open.cc가 .so를 사용 가능한 백엔드로 만드는 방법

직관적 모델

plugin_open.cc를 NCCL의 "채용 중개소"라고 상상해보자: 그곳에는 직무 목록(NET, GIN, RMA, TUNER, PROFILER, ENV)이 있고, 각 직무는 후보 라이브러리 이름에 대응한다. NCCL이 특정 직무의 인력이 필요할 때, 중개소는 정해진 순서대로 인재 시장(동적 링커)에서 사람을 찾고, 찾으면 계약을 맺고(dlopen), 찾지 못하면 "이 사람은 존재하지 않는다"고 기록한 후, 최종적으로 핸들을 반환한다. 만약 이 중개 계층이 없다면, NCCL은 네트워크 백엔드를 바이너리에 하드코딩할 수밖에 없고, 어떤 NIC 제조사든 접속하려면 NCCL 소스 코드를 수정해야 한다——이것이 바로 플러그인 체계가 없애고자 하는 재앙이다.

데이터 구조와 메모리 레이아웃

로더의 전체 상태는 여섯 개의 병렬 배열이며, 인덱스가 곧 플러그인 타입 열거형이다:

code
static char* libNames[NUM_LIBS];              // 已加载库的名字
char* ncclPluginLibPaths[NUM_LIBS];           // 库的绝对路径
static void* libHandles[NUM_LIBS];            // dlopen 返回的句柄
static const char* pluginNames[NUM_LIBS];     // 日志用的人类可读名
static const char* pluginPrefix[NUM_LIBS];    // 库名前缀
static const char* pluginFallback[NUM_LIBS];  // 找不到时的提示
static unsigned long subsys[NUM_LIBS];        // 日志子系统位掩码

이 일곱 개 배열의 인덱스는 반드시 엄격히 정렬되어야 하며,pluginNames[type]、pluginPrefix[type]、subsys[type]는 동일한 플러그인 타입을 설명한다.📎 src/plugin/plugin_open.cc:18-29는NUM_LIBS = 6를 정의하며, 타입 순서는{"NET", "GIN", "RMA", "TUNER", "PROFILER", "ENV"}이고, 접두사는{"libnccl-net", "libnccl-gin", "libnccl-rma", "libnccl-tuner", "libnccl-profiler", "libnccl-env"}。

〔설계 추론과 아키텍처 트레이드오프〕

여기서 구조체 배열 대신 병렬 배열을 사용하는 이유는openPluginLib라는 단일 함수가 여섯 가지 플러그인을 동시에 서비스할 수 있도록 하기 위해서다——타입은 인덱스로만 사용되고 로직은 완전히 재사용된다. 대가는 새로운 플러그인 타입을 추가할 때 여섯 개 배열을 동시에 수정해야 하며, 컴파일러가 누락을 검사해줄 수 없다는 점이다.

subsys배열은 로그 귀속을 결정한다: NET/GIN/RMA는 모두NCCL_INIT | NCCL_NET에 연결되고, TUNER는NCCL_INIT | NCCL_TUNING에 연결되며, PROFILER는NCCL_INIT에만 연결되고, ENV는NCCL_INIT | NCCL_ENV。📎 src/plugin/plugin_open.cc:26-29에 연결된다. 이렇게 하면NCCL_DEBUG_SUBSYS=NET시 네트워크 플러그인의 로그만 보이고, 튜닝 로그에 묻히지 않는다.

단계별 워크스루: 한 번의ncclOpenNetPluginLib("mlx5")전체 여정

사용자가NCCL_NET_PLUGIN=mlx5를 설정했다고 가정하면, NCCL 초기화 시ncclOpenNetPluginLib("mlx5")를 호출하고, 이는 직접openPluginLib(ncclPluginTypeNet, "mlx5")。📎 src/plugin/plugin_open.cc:132-134

로 전달된다.첫 번째 단계: 후보 라이브러리 이름 구성.libName비어 있지 않은snprintf(libName_, MAX_STR_LEN, "%s", libName)가 전달되었으므로libName_분기를 타고,"mlx5"。📎 src/plugin/plugin_open.cc:85-89는.so가 된다. 이때 아직 합법적인 라이브러리 파일 이름이 아니다——접두사도 없고

접미사도 없다. tryOpenLib("mlx5", ...)두 번째 단계: 첫 번째 열기 시도.📎 src/plugin/plugin_open.cc:91가 호출된다.tryOpenLib에 진입한 후, 먼저name가 비어 있거나 길이가 0인지 확인하고, 그 다음 특수 분기가 있다: 만약 이름이STATIC_PLUGIN로 시작하면,name를 로 설정한다.nullptr。📎 src/plugin/plugin_open.cc:37-39이것은 NCCL에 정적 링크된 플러그인을 위한 센티넬이다—dlopen(nullptr)Linux에서 메인 프로그램 핸들을 반환하여dlsym메인 프로그램 심볼 테이블에서 플러그인 심볼을 찾을 수 있게 한다.

이어서ncclOsDlopen(name)。📎 src/plugin/plugin_open.cc:41를 호출한다."mlx5"는 경로도 아니고 유효한 라이브러리 이름도 아니기 때문에dlopen는 실패한다. 실패 후 코드는ncclOsDlerror()의 오류 문자열을 가져와 정밀하게 판단한다: 오류 문자열에name와"No such file or directory"가 동시에 포함되어 있으면*err를ENOENT。📎 src/plugin/plugin_open.cc:42-55로 설정한다. 이 판단의 의미는 "파일이 아예 존재하지 않음"과 "파일은 존재하지만 로드 실패"를 구분하는 것이다—전자는 단지 후보 이름이 잘못된 것이므로 조용히 다음 후보 이름을 시도해야 하고, 후자는 실제 오류이므로 로그를 남겨야 한다.

세 번째 단계: 첫 번째 실패 후 처리.로 돌아가면openPluginLib,libHandles[type]가 비어 있고openErr == ENOENT이므로"mlx5"를eNoEntNameList。📎 src/plugin/plugin_open.cc:97-101에 추가한다. 이 리스트는 최종적으로 "Could not find: mlx5 libnccl-net-mlx5.so"라는 로그로 조합된다.

네 번째 단계: 두 번째 시도—접두사 추가.코드는libName가 경로가 아니고(/미포함) 라이브러리 이름도 아닌(lib로 시작하지 않고.so로 끝나지 않음)지 확인한다.📎 src/plugin/plugin_open.cc:105-107 "mlx5"조건을 만족하므로"libnccl-net-mlx5.so"를 조합하여 다시 시도한다.📎 src/plugin/plugin_open.cc:108이번에는dlopen가 성공하고libHandles[type]가 할당되며libNames[type]라이브러리 이름을 기록하고ncclPluginLibPaths[type]를 통해getLibPath절대 경로를 얻어 함수가 핸들을 반환한다.📎 src/plugin/plugin_open.cc:110-115

다섯 번째 단계: 절대 경로 얻기. getLibPathLinux에서dlinfo(handle, RTLD_DI_LINKMAP, &lm)로link_map를 꺼내고 다시strdup(lm->l_name)。📎 src/plugin/plugin_open.cc:65-69한다. 이 경로는 이후 모든 로그에 나타나 사용자가 어떤 파일이 로드되었는지 한눈에 볼 수 있게 한다—프로덕션 환경에서 "왜 잘못된 플러그인이 로드되었는가"를 조사할 때 이 로그 줄이 첫 번째 현장이다.

전체 의사결정 흐름은 다음과 같다:

mermaid
flowchart TD
    start["openPluginLib(type, libName)"] --> build{"libName 非空?"}
    build -->|是| use_name["libName_ = libName"]
    build -->|否| use_prefix["libName_ = pluginPrefix[type] + .so"]
    use_name --> try1["tryOpenLib(libName_)"]
    use_prefix --> try1
    try1 --> ok1{"handle 非空?"}
    ok1 -->|是| success["记录 libNames/libPaths, 返回 handle"]
    ok1 -->|否| enoent{"openErr == ENOENT?"}
    enoent -->|是| append1["appendNameToList(eNoEntNameList)"]
    enoent -->|否| log1["INFO 打印 dlopen 错误"]
    append1 --> shape{"非路径且非库名?"}
    log1 --> shape
    shape -->|是| try2["tryOpenLib(prefix-libName.so)"]
    shape -->|否| report["打印 Could not find 列表"]
    try2 --> ok2{"handle 非空?"}
    ok2 -->|是| success
    ok2 -->|否| report
    report --> retnull["返回 nullptr"]

설계 사고와 프로덕션 함정

〔설계 추론과 아키텍처 트레이드오프〕

후보 이름 순서가 곧 우선순위다.먼저 사용자가 준 원시 이름을 시도하고, 그다음 접두사가 붙은 이름을 시도한다. 이는 현재 디렉터리에mlx5라는 파일이 있으면 우선 로드된다는 의미다—이것은 잠재적 보안 표면이며, 프로덕션 환경에서는LD_LIBRARY_PATH에 플러그인과 같은 이름의 실행 파일을 넣지 말아야 한다.

STATIC_PLUGIN의 의미.일 때NCCL_NET_PLUGIN=STATIC_PLUGINtryOpenLib는 이름을 비우고dlopen(nullptr)는 메인 프로그램을 열고dlsym는 메인 프로그램 심볼 테이블에서ncclNet_v12등의 심볼을 찾는다.📎 src/plugin/plugin_open.cc:37-39이는 플러그인을 NCCL 바이너리에 정적 링크하여.so배포의 번거로움을 없앨 수 있게 한다. 대가는 런타임 교체 능력을 잃는 것이다.

참조 카운트와 언로드. ncclClosePluginLib는libHandles[type] == handle일 때만 실제로dlclose를 하고 경로와 이름을 비운다.📎 src/plugin/plugin_open.cc:176-186이 동등성 판단은 이미 교체된 핸들을 잘못 닫는 것을 방지한다. GIN과 RMA 플러그인은ncclGetGinPluginLib/ncclGetNetPluginLib를 통해 NET 라이브러리의 핸들을 재사용하며, 구현 방식은 같은 라이브러리 이름을 다시dlopen하여 참조 카운트를 증가시키는 것이다.📎 src/plugin/plugin_open.cc:156-164이것은dlopen의 참조 카운트 의미론이다—같은 라이브러리가 두 번 열리면dlclose를 두 번 해야 실제로 언로드된다.

16.2 net.cc: 네트워크 플러그인의 상태 머신과 생명주기

직관적 모델

net.cc는 네트워크 플러그인의 "스케줄링 센터"다. 플러그인 라이브러리 배열을 유지하며, 각 라이브러리는 자체 상태(미로드, 로드 실패, 로드 대기, 초기화 대기, 활성화됨)를 가진다. 새로운 통신 도메인(communicator)이 탄생하면 스케줄링 센터는 모든 후보 플러그인을 순회하며 하나씩 초기화를 시도하고, 첫 번째로 성공한 것이 이 통신 도메인에 "할당"되며 나머지 외부 플러그인은 모두 비활성화된다. 이 상태 머신 계층이 없으면 NCCL은 "플러그인은 로드되었지만 장치를 사용할 수 없음", "여러 플러그인이 공존할 때 어느 것을 선택할 것인가", "통신 도메인 파괴 시 안전하게 언로드하는 방법" 같은 현실 문제를 처리할 수 없다.

데이터 구조와 메모리 레이아웃

핵심 구조는netPluginLib_t:

필드타입의미
namechar[255]플러그인 라이브러리 이름
dlHandlevoid*dlopen 핸들
ncclNetncclNet_t*네트워크 함수 테이블
ncclNetVerint네트워크 API 버전 번호
ncclCollNetncclCollNet_t*집합 통신 오프로드 함수 테이블
ncclNetPluginState열거형네트워크 플러그인 상태
ncclCollNetPluginState열거형CollNet 플러그인 상태
ncclNetPluginRefCountint참조 카운트
netPhysDevs/netVirtDevsint물리/가상 장치 수
collNetPhysDevs/collNetVirtDevsintCollNet 장치 수

📎 src/plugin/net.cc:63-76가 이 필드들을 정의한다. 주의할 점은ncclNet와ncclCollNet가 분리된 두 함수 테이블이고 상태도 분리된 두 열거형이라는 것이다—하나의 플러그인이 네트워크 기능을 제공하지만 CollNet 오프로드는 제공하지 않을 수 있다.

상태 열거형에는 다섯 값이 있다:Disabled = -2(초기화 실패),LoadFailed = -1(로드 실패),LoadReady = 0(로드 대기),InitReady = 1(로드됨, 초기화 대기),Enabled = 2(활성화됨).📎 src/plugin/net.cc:54-60는 음수로 실패 상태를 표현하여 "상태 >= InitReady" 같은 비교가 자연스럽게 "최소한 로드됨"을 표현할 수 있게 한다.

전역 상태는 세 변수다:pluginCount는 플러그인 총수를 기록하고netPluginLibs[NCCL_NET_MAX_PLUGINS]는 플러그인 배열이며netPluginMutex는 동시 접근을 보호하고initPluginLibsOnceFlag는 초기화가 한 번만 수행되도록 보장한다.📎 src/plugin/net.cc:78-81

단계별 워크스루: 한 번의ncclNetInit(comm)의 완전한 여정

첫 번째 단계: 일회성 초기화. std::call_once(initPluginLibsOnceFlag, initPluginLibsOnceFunc)는 플러그인 리스트가 한 번만 구축되도록 보장한다.📎 src/plugin/net.cc:360 initPluginLibsOnceFunc는NCCL_NET_PLUGIN환경 변수를 읽고, 설정되지 않았으면 기본적으로"libnccl-net.so"를 추가한 다음 두 내장 플러그인ncclNetIb과ncclNetSocket。📎 src/plugin/net.cc:288-340

을 등록한다. 환경 변수 파싱은strtok_r로 쉼표로 분할하여 여러 플러그인 이름을 지원한다.📎 src/plugin/net.cc:303-324에는 용량 검사가 있다: 외부 플러그인 수가NCCL_NET_MAX_PLUGINS - NCCL_NET_NUM_INTERNAL_PLUGINS를 초과할 수 없으며, 초과분은 무시되고 로그가 남는다.📎 src/plugin/net.cc:307-311내장 플러그인은 2개(IB와 Socket)로 고정되어 있으므로 외부 플러그인은 최대NCCL_NET_MAX_PLUGINS - 2개다.

두 번째 단계: 잠금 순회. std::lock_guard<std::mutex> lock(netPluginMutex)는 전체 순회 과정을 보호한다.📎 src/plugin/net.cc:361각 플러그인 인덱스에 대해 먼저 외부 플러그인이면서LoadReady상태인지 판단하고, 그렇다면ncclNetPluginLoad。📎 src/plugin/net.cc:364-367

을 호출한다. 세 번째 단계: 플러그인 로드. ncclNetPluginLoad는ncclOpenNetPluginLib를 호출하여 핸들을 얻은 다음, 높은 버전에서 낮은 버전으로getNcclNet_v12부터getNcclNet_v6까지 차례로 시도하며, 첫 번째로 비어 있지 않은 것을 반환하는 버전이 채택된다.📎 src/plugin/net.cc:103-112버전 배열ncclNetVersion과 함수 포인터 배열getNcclNet은 내림차순으로 정렬되어 최신 API를 우선 사용하도록 보장한다.📎 src/plugin/net.cc:41-43

모든 버전에서ncclNet를 얻지 못하면 이 라이브러리는 합법적인 네트워크 플러그인이 아니다. 이때NCCL_NET_PLUGIN가 명시적으로 설정되었는지 확인한다: 설정되었다면ATTN레벨로 경고하고(사용자가 명확히 요구했지만 실패), 설정되지 않았다면INFO레벨(단지 기본 시도 실패).📎 src/plugin/net.cc:115-125이 구분은 중요하다 — 사용자가 명시적으로 설정한 실패는 반드시 보여줘야 한다.

네 번째 단계: 플러그인 초기화.다시ncclNetInit로 돌아가서, 상태가>= InitReady이고 이름이comm->config.netName와 일치하는 플러그인에 대해ncclNetPluginInit。📎 src/plugin/net.cc:369-372 ncclNetPluginInit을 호출하여 두 가지를 수행한다: 플러그인의init함수를 호출하여 통신 도메인 컨텍스트를 설정하고, 최초 초기화 시devices을 호출하여 장치 수를 탐지한다.📎 src/plugin/net.cc:186-236

주의:init의 호출 조건:pluginLib->ncclNetPluginState >= ncclNetPluginStateInitReady。📎 src/plugin/net.cc:190주석에서 명확히 설명하기를 "모든 새로운 통신 도메인은 올바른 컨텍스트 설정을 위해 반드시 init을 호출해야 한다".📎 src/plugin/net.cc:189그러나 장치 탐지는== InitReady일 때만 한 번 수행한다.📎 src/plugin/net.cc:201이 "init은 매번 호출, devices는 한 번만 호출" 구분은 성능 최적화이다 — 장치 탐지는 매우 느릴 수 있지만 컨텍스트는 각 통신 도메인마다 독립적이어야 한다.

다섯 번째 단계: 할당과 비활성화.초기화 성공 후ncclNetPluginAssignToComm을 호출하면, 플러그인의ncclNet을comm->ncclNet에 할당하고, 참조 카운트를 증가시키며,comm->netPluginIndex。📎 src/plugin/net.cc:238-255을 설정한다. 할당 성공 후 즉시ncclNetPluginDisableOtherExternal을 호출하여 다른 모든 외부 플러그인을 비활성화한다.📎 src/plugin/net.cc:377-380

〔설계 추론 및 아키텍처 트레이드오프〕

비활성화 로직에는 핵심 판단이 있다: 할당된 플러그인이 외부 플러그인(pluginIndex >= pluginCount - NCCL_NET_NUM_INTERNAL_PLUGINS)일 때만 다른 외부 플러그인을 비활성화한다.📎 src/plugin/net.cc:257-259할당된 것이 내장 IB 플러그인이라면 외부 플러그인은 그대로 유지된다 — 이는 후속 통신 도메인을 위해 선택의 여지를 남긴다.

mermaid
flowchart TD
    init["ncclNetInit(comm)"] --> once["call_once(initPluginLibsOnceFunc)"]
    once --> lock["lock(netPluginMutex)"]
    lock --> loop{"遍历 pluginIndex"}
    loop -->|外部且 LoadReady| load["ncclNetPluginLoad()"]
    loop -->|状态 >= InitReady| namechk{"netName 匹配?"}
    load --> namechk
    namechk -->|否| loop
    namechk -->|是| plugininit["ncclNetPluginInit()"]
    plugininit --> enabled{"状态 == Enabled?"}
    enabled -->|否| loop
    enabled -->|是| assign["ncclNetPluginAssignToComm()"]
    assign --> assigned{"isAssigned?"}
    assigned -->|否| finalize["ncclNetPluginFinalize()"]
    finalize --> loop
    assigned -->|是| disable["ncclNetPluginDisableOtherExternal()"]
    disable --> ok["返回 ncclSuccess"]
    loop -->|遍历结束| fail["WARN 无可用插件, 返回 ncclInvalidUsage"]

동시성 제어와 하드웨어 상호작용

netPluginMutex이netPluginLibs에 대한 모든 읽기/쓰기를 보호한다.ncclNetInit、ncclNetFinalize모두 잠금을 건다.📎 src/plugin/net.cc:361📎 src/plugin/net.cc:411-416그러나ncclNetGetDevCount등의 함수 주석에서는 "잠금이 필요 없다, 호출자가 이미ncclTopoGetSystem의 잠금 내에 있기 때문"이라고 설명한다.📎 src/plugin/net.cc:418-429이는 "잠금을 상위 계층이 보유한다"는 일종의 규약으로, 중첩 잠금 오버헤드를 줄이지만 대가로 호출자가 규약을 준수해야 한다.

ncclGpuGdrSupport플러그인과 하드웨어의 직접 상호작용을 보여준다: 2MB GPU 버퍼를 할당하고, 플러그인의listen/connect/accept을 통해 루프백 연결을 설정한 후,regMr을 시도하여 GPU 메모리를 등록한다.📎 src/plugin/net.cc:464-535등록이 성공하면 NIC가 GPUDirect RDMA를 지원한다는 의미이다. 이 탐지 결과는gdrSupportMatrix[32]에 캐시되며, CUDA 장치 번호로 인덱싱된다.📎 src/plugin/net.cc:478-480

〔설계 추론 및 아키텍처 트레이드오프〕

주의:gdrSupportMatrix은static의 것이며, 통신 도메인 간에 공유된다.📎 src/plugin/net.cc:478이는 동일 프로세스 내 여러 통신 도메인이 탐지 결과를 재사용하여 반복적인 고비용 탐지를 피한다는 의미이다. 그러나 배열 크기가 32로 하드코딩되어 있어, 32개 이상의 GPU를 가진 머신에서는 범위를 벗어난다 — 이는 암묵적인 상한 가정이다.

프로덕션 함정 회피 가이드

함정 1: 플러그인 로드는 성공했지만 장치 수가 0인 경우. ncclNetPluginInitdevices(&ndev) != ncclSuccess || ndev <= 0을 확인하면 실패 분기로 점프한다.📎 src/plugin/net.cc:202실패 후finalize을 호출하여 이미 설정된 컨텍스트를 정리하고, 장치 수를NCCL_UNDEF_DEV_COUNT으로 재설정하며, 상태를Disabled。📎 src/plugin/net.cc:229-234로 설정한다. 이 정리를 하지 않으면 후속 통신 도메인이 "초기화되었지만 장치가 없는" 플러그인을 보게 되어 진단하기 어려운 오류가 발생한다.

〔설계 추론 및 아키텍처 트레이드오프〕

함정 2:init은 성공했지만devices이 실패한 경우.코드는initCompleted플래그로init의 성공 여부를 추적한다.📎 src/plugin/net.cc:178-184📎 src/plugin/net.cc:198실패 분기에서는initCompleted이 참일 때만finalize。📎 src/plugin/net.cc:230을 호출한다. 이는 초기화되지 않은 컨텍스트에 대해finalize을 호출하는 것을 방지한다 — 많은 플러그인의finalize은 널 포인터를 검사하지 않으므로 잘못 호출하면 크래시가 발생한다.

함정 3: 통신 도메인 파괴 시의 참조 카운트. ncclNetPluginFinalize먼저 플러그인의finalize을 호출하고, 그 다음 참조 카운트를 감소시키며, 마지막으로 참조 카운트가 0이 되고 외부 플러그인일 때 라이브러리를 언로드한다.📎 src/plugin/net.cc:342-355 ncclNetPluginUnloaddlHandle이 널이 아니고 참조 카운트가 0일 때만 실제로dlclose。📎 src/plugin/net.cc:84-101을 수행한다. 언로드 후 필드를 재설정하지만name은 유지하여 재로드 시 재사용할 수 있게 한다.📎 src/plugin/net.cc:84-101

16.3 tuner.cc와 profiler.cc: 전략 플러그인과 관측 플러그인의 서로 다른 계약

직관적 모델

Tuner 플러그인은 "내비게이션 소프트웨어의 경로 선호 설정"과 같다 — 차가 어떻게 가는지는 바꾸지 않고, 어느 길을 선택할지만 바꾼다. Profiler 플러그인은 "블랙박스"와 같다 — 운전에 개입하지 않고 무슨 일이 있었는지만 기록한다. 둘의 공통점은 모두 함수 테이블을 통해 접속한다는 것이고, 차이점은 Tuner는 "통신 도메인마다 하나의 인스턴스"인 경량 전략 객체인 반면, Profiler는 GPU가 생성하는 이벤트를 비동기로 소비하기 위해 독립 스레드가 필요하다는 것이다.

tuner.cc: 극도로 단순한 전역 싱글턴

Tuner의 상태는 극히 단순하다: 뮤텍스 하나, 참조 카운트 하나, 라이브러리 핸들 하나, 심볼 포인터 하나, 상태 변수 하나.📎 src/plugin/tuner.cc:24-37플러그인 배열도 없고, 다중 플러그인 공존도 없다 — 전역에 tuner가 하나뿐이다.

ncclTunerPluginLoad의 로직은 "최초 로드, 이후 재사용"이다: 상태가LoadSuccess이면, 심볼을 직접comm->tuner에 할당하고 참조 카운트를 증가시킨다.📎 src/plugin/tuner.cc:53-57그렇지 않으면NCCL_TUNER_PLUGIN환경 변수를 읽고,"none"이면 직접 실패한다.📎 src/plugin/tuner.cc:59-63

〔설계 추론 및 아키텍처 트레이드오프〕

버전 협상은 v6에서 v2로 내려가며 하나씩 시도한다.📎 src/plugin/tuner.cc:75-87여기에는 v1이 없다 — tuner API는 v2부터 안정적인 함수 테이블 구조를 가진다.

〔설계 추론 및 아키텍처 트레이드오프〕

흥미로운 세부 사항: 만약ncclOpenTunerPluginLib이 비어 있으면, 코드는ncclGetNetPluginLib(ncclPluginTypeTuner)。📎 src/plugin/tuner.cc:65-70을 시도한다. 이는 tuner가 net 플러그인 라이브러리에 패키징될 수 있다는 의미이다 — 이는 배포 복잡도를 낮춰, 하나의.so이 네트워크와 튜닝 기능을 동시에 제공한다.

profiler.cc: 비동기 이벤트 소비 스레드

Profiler는 이 장에서 가장 복잡한 플러그인이다, GPU가 비동기로 생성하는 이벤트를 처리해야 하기 때문이다. 핵심 구조는ncclProfilerThread:

필드타입역할
threadstd::thread소비 스레드
mutexstd::mutex큐 보호
condcondition_variable새 작업이 있을 때 깨움
condIterationInactivecondition_variable반복 종료 대기
stopint중지 플래그
refCountint통신 도메인 참조 카운트
cudaDevint바인딩된 CUDA 장치
abortFlagvolatile uint32_t*중단 플래그
iterationActivebool반복 중인지 여부
pending/pendingTail연결 리스트대기 중 작업
active/activeTail연결 리스트처리 중 작업
opStack/opPool메모리 풀작업 객체 할당
inflight/maxInflightSeen/maxInflightsize_t백프레셔 관측
droppedOpsuint64_t할당 실패 카운트

📎 src/plugin/profiler.cc:38-69이 구조를 정의한다. 주의:pending과active은 두 개의 독립적인 연결 리스트이다: 생산자는pending에 추가하고, 소비 스레드는 잠금 내에서pending을active에 이어 붙인 후, 잠금 밖에서active。📎 src/plugin/profiler.cc:56-59

iterationActive을 순회한다.true플래그는 동시성 정확성의 핵심이다: 소비 스레드는 잠금 내에서false통신 도메인 상태를 해제할 수 있다.📎 src/plugin/profiler.cc:52-55

Step-by-Step Walkthrough: 하나의 KernelCh 이벤트 생성과 소비

첫 번째 단계: 호스트 측 인큐.커널 계획(kernel plan)이 제출될 때,ncclProfilerPostPlanWork계획 내의 집합 작업을 순회하며, 각각에 대해 활성화된ncclProfileKernelCh작업에 대해 채널 범위에 따라 호출한다profilerPostWorkInternal。📎 src/plugin/profiler.cc:1315-1331

profilerPostWorkInternal먼저 증가시키고comm->profiler.workCounter[channelId]그런 다음 호출한다profilerEnqueueOp。📎 src/plugin/profiler.cc:1259-1266주석은 이 증가가 "할당 실패 시에도 매 호출마다 정확히 한 번" 이루어져야 한다고 강조하며, 이는 디바이스 커널과의 동기화를 유지하기 위함이다.📎 src/plugin/profiler.cc:1259-1266

두 번째 단계: 작업 객체 할당. profilerEnqueueOp락 내에서 메모리 풀에서 할당한다ncclProfilerWorkOp채널 번호, 작업 카운터, 활성화 마스크, 작업 이벤트 핸들, 통신 도메인 컨텍스트 등의 필드를 채운다.📎 src/plugin/profiler.cc:1199-1223할당 실패 시 증가시키고droppedOps로그를 기록하지만,하지 않는다롤백을workCounter——이것이 디바이스와의 동기화를 유지하는 핵심이다.📎 src/plugin/profiler.cc:1202-1207

할당 성공 후 객체를 추가한다pending연결 리스트 끝에, 증가시키고inflight, 업데이트하고maxInflightSeen, 소비 스레드를 깨운다.📎 src/plugin/profiler.cc:1225-1239

세 번째 단계: 소비 스레드 대기. ncclProfilerThreadFunc반복 호출한다waitForAction。📎 src/plugin/profiler.cc:1074-1077 waitForAction락 내에서 조건 변수를 대기하며,pending또는active가 비어 있지 않거나 중지/중단 신호를 받을 때까지.📎 src/plugin/profiler.cc:1017-1031

깨어난 후, 호출한다appendWorkToActiveQueue를pending에 연결하고active끝에, 설정하고iterationActive = true, 반환한다NCCL_PROFILER_THREAD_PROGRESS。📎 src/plugin/profiler.cc:1017-1031

네 번째 단계: 작업 처리. profilerProgressOps에서락 외부순회한다active연결 리스트를.📎 src/plugin/profiler.cc:958-999각 작업 객체에 대해, 디바이스가 시작 타임스탬프를 기록했는지 확인한다:wc <= op->workStarted[ch].data[slot].counter。📎 src/plugin/profiler.cc:972사용된 것은<=가 아니라==이며, 디바이스가MAX_PROFILER_EVENTS_PER_CHANNEL개의 슬롯을 순환하므로 호스트가 뒤처지면 디바이스가 이미 해당 슬롯을 덮어썼을 수 있기 때문이다.📎 src/plugin/profiler.cc:969-971

시작 조건이 충족되면, 호출한다ncclProfilerStartKernelChEvent플러그인에 알린다.📎 src/plugin/profiler.cc:973그런 다음 완료 조건을 확인하고, 충족되면 먼저 단계 이벤트를 트리거한 후 호출한다ncclProfilerStopKernelChEvent。📎 src/plugin/profiler.cc:978-985

완료된 작업 객체는 연결 리스트에서 제거되어recycled리스트에 수집된다.📎 src/plugin/profiler.cc:987-991

다섯 번째 단계: 회수 및 게시. cleanupAndStop락 내에서 회수한다recycled리스트를, 새로운activeTail를 게시하고, 지우고iterationActive대기자에게 알린다.📎 src/plugin/profiler.cc:1036-1050

mermaid
sequenceDiagram
    participant Host as 主机线程
    participant PT as Profiler 线程
    participant Plugin as Profiler 插件
    participant Dev as GPU 内核

    Host->>Host: profilerPostWorkInternal() 递增 workCounter
    Host->>PT: profilerEnqueueOp() 追加到 pending
    Host->>PT: cond.notify_one()
    PT->>PT: waitForAction() 返回 PROGRESS
    PT->>PT: appendWorkToActiveQueue() 拼接 pending 到 active
    Dev->>Dev: 内核写入 workStarted/workCompleted 时间戳
    PT->>PT: profilerProgressOps() 检查 wc <= counter
    PT->>Plugin: startEvent(ncclProfileKernelCh)
    PT->>Plugin: recordEventState(ncclProfilerKernelChStop)
    PT->>Plugin: stopEvent()
    PT->>PT: cleanupAndStop() 回收对象, 清除 iterationActive

동시성 제어와 백프레셔

NCCL_PROFILER_DEFAULT_MAX_INFLIGHT로 정의된다MAXCHANNELS * MAX_PROFILER_EVENTS_PER_CHANNEL * 4。📎 src/plugin/profiler.cc:32-32이것은 "소프트 상한"이다——초과해도 인큐를 막지 않고 로그만 기록한다.📎 src/plugin/profiler.cc:1233-1238주석은 인큐를 유지하는 이유가 KernelCh 이벤트를 부모 작업 이벤트와 페어링하기 위함이라고 설명한다.📎 src/plugin/profiler.cc:32-32

로그는 2의 거듭제곱으로 트리거된다:(pt->inflight & (pt->inflight - 1)) == 0。📎 src/plugin/profiler.cc:1233이는 inflight가 1, 2, 4, 8...일 때만 로그를 기록하여 화면 도배를 방지한다.

소비 스레드의 백오프 전략은updateProgressInterval에 있다: 진행이 있으면 즉시 재시도하고, 진행이 없으면 1마이크로초부터 두 배로 늘려 최대 10마이크로초까지.📎 src/plugin/profiler.cc:1054-1057이 설계는 지연과 CPU 점유를 균형 있게 조정한다.

프로덕션 함정 회피 가이드

함정 1: 소멸 시 작업 누수. ncclProfilerThreadDestroy먼저 대기한다iterationActive가 거짓이 되면, 호출한다profilerPurgeByContext해당 통신 도메인 컨텍스트를 참조하는 모든 대기 중 작업을 제거한다.📎 src/plugin/profiler.cc:1162-1169이 제거를 하지 않으면 플러그인 콜백이 이미 소멸된 컨텍스트 포인터를 받아 use-after-free가 발생한다.

함정 2: 중지 시 배출.중지 신호를 받았지만active가 비어 있지 않을 때, 반환한다NCCL_PROFILER_THREAD_CLEANUP_AND_STOP,cleanupAndStop의drainStuck매개변수가 참이면, 모든 남은 작업을 직접 회수한다.📎 src/plugin/profiler.cc:1029📎 src/plugin/profiler.cc:1036-1050주석은 이 작업들의 커널이 절대 실행되지 않으므로 직접 버린다고 말한다.📎 src/plugin/profiler.cc:1034-1035

함정 3: CUDA 디바이스 바인딩.소비 스레드 시작 시 호출한다cudaSetDevice(pt->cudaDev)。📎 src/plugin/profiler.cc:1054-1057주석 설명: 스레드 자체는 호스트 고정 메모리만 읽지만, 플러그인이 컨텍스트 의존적 드라이버 호출을 할 수 있으므로 방어적으로 바인딩한다.📎 src/plugin/profiler.cc:1054-1057바인딩 실패 시 로그만 기록하고 중단하지 않는다, 스레드 자체가 CUDA에 의존하지 않기 때문이다.📎 src/plugin/profiler.cc:1065-1070

16.4 공식 예제: google-fastsocket과 google-CoMMA의 구현 요점

직관적 모델

공식 예제는 플러그인 API의 "참조 구현"이다.google-fastsocket사용자 공간 네트워크 스택으로 커널 TCP를 대체하는 방법을 보여준다;google-CoMMA통신 성능을 수집하는 profiler 플러그인을 구현하는 방법을 보여준다. 이들의 존재는 플러그인 API가 실제 요구를 표현하기에 충분함을 증명한다.

google-fastsocket: 네트워크 백엔드 대체

〔설계 추론과 아키텍처 트레이드오프〕

FastSocket은 Google이 오픈소스로 공개한 사용자 공간 네트워크 스택으로,AF_FABRIC주소 패밀리를 통해 커널 TCP/IP 스택을 우회한다. NCCL net 플러그인으로서ncclNet_t의 모든 함수를 구현해야 한다:init、devices、getProperties、listen、connect、accept、regMr、isend、irecv、test、closeSend등.

핵심 구현 포인트는getProperties가 반환하는ptrSupport이다: FastSocket이 GPUDirect RDMA를 지원하면NCCL_PTR_HOST|NCCL_PTR_CUDA로 설정해야 하고; 그렇지 않으면NCCL_PTR_HOST로만 설정할 수 있으며, NCCL은 전송 전에 GPU 데이터를 호스트 메모리로 복사한다.📎 plugins/net/README.md:245-245

connect와accept의 "비차단" 계약은 플러그인 구현의 핵심 난제이다: 즉시 반환해야 하며,sendComm/recvComm를NULL로 설정하여 NCCL이 성공할 때까지 반복 호출하게 한다.📎 plugins/net/README.md:299-311이는 플러그인 내부에 연결 상태 머신을 유지하고 시간이 많이 걸리는 핸드셰이크를 백그라운드에 두어야 함을 요구한다.

google-CoMMA: profiler 플러그인 구현

〔설계 추론과 아키텍처 트레이드오프〕

CoMMA(Collective Memory Monitoring Agent)는 Google의 통신 성능 수집기이다. profiler 플러그인으로서ncclProfiler_t함수 테이블을 구현한다:init、finalize、startEvent、stopEvent、recordEventState。

init를 받는다ncclProfilerEventMask포인터를, 플러그인은 이 마스크에 기록하여 어떤 이벤트를 구독할지 선택한다.📎 src/plugin/profiler.cc:341NCCL이 지원하는 이벤트 유형에는 Group, Coll, P2p, ProxyOp, ProxyStep, ProxyCtrl, KernelCh, KernelPhase, NetPlugin 등이 있다.📎 src/plugin/profiler.cc:285-307

startEvent이벤트 핸들을 반환하고, 이후stopEvent와recordEventState가 이 핸들로 이벤트를 연관시킨다.📎 src/plugin/profiler.cc:392📎 src/plugin/profiler.cc:400-407플러그인은 핸들로 자체 상태를 저장하여 이벤트 페어링과 소요 시간 통계를 구현할 수 있다.

설계 사고

왜 net 플러그인에는 버전 협상이 있고 tuner/profiler에는 없는가?net API는 디바이스 측 코드(ncclNetDeviceHandle)를 포함하기 때문에 버전 불일치 시 커널 크래시가 발생한다. 반면 tuner/profiler는 순수 호스트 측이므로 버전 불일치가 발생해도 최대 기능 누락에 그친다.📎 src/plugin/net.cc:153-176는ncclNetCheckDeviceVersion디바이스 타입과 버전을 확인하는 방법을 보여주며, 불일치 시ncclInternalError。

profiler에 왜 독립 스레드가 필요한가?profiler 콜백이 블로킹될 수 있기 때문이다(예: 파일 쓰기, 네트워크 요청). 호스트 스레드에서 호출하면 통신이 느려진다.📎 src/plugin/profiler.cc:950-952주석에 "플러그인 콜백이 블로킹될 수 있으므로 잠금을 보유한 상태에서 호출해서는 안 된다"고 명시되어 있다.

16.5 프로덕션 함정 회피 가이드와 장애 복구 체인

함정 1: 플러그인 버전 불일치로 인한 커널 크래시

ncclNetCheckDeviceVersion를 확인한다.props.netDeviceType및props.netDeviceVersion。📎 src/plugin/net.cc:153-176플러그인이 보고한NCCL_NET_DEVICE_UNPACK버전이 NCCL 컴파일 시의NCCL_NET_DEVICE_UNPACK_VERSION와 일치하지 않으면ncclInternalError를 반환하고 경고한다.📎 src/plugin/net.cc:153-176이 검사는ncclNetPluginAssignToComm에서 호출되며, 실패 시 플러그인은 통신 도메인에 할당되지 않는다.📎 src/plugin/net.cc:241

복구 체인: 버전 불일치 →ncclNetCheckDeviceVersion오류 반환 →ncclNetPluginAssignToComm가isAssigned = false → ncclNetInit를 반환하고 다음 플러그인 시도 → 최종적으로 내장 Socket 플러그인으로 폴백될 수 있다.

함정 2: profiler 스레드가 종료되지 않음

profiler 플러그인이stopEvent에서 블로킹되면 소비 스레드가profilerProgressOps에서 멈추고,iterationActive이 항상 참이 되어,ncclProfilerThreadDestroy이 영원히 대기한다.📎 src/plugin/profiler.cc:1166이는 실제 교착 상태 위험이다.

〔설계 추론과 아키텍처 트레이드오프〕

복구 체인:comm->abortFlag이 설정됨 →waitForAction이 중단을 감지 →CLEANUP_AND_STOP → cleanupAndStop를 반환하고 큐를 비운다.📎 src/plugin/profiler.cc:1017-1031그러나 스레드가 이미 플러그인 콜백에 갇혀 있다면 중단 플래그가 이를 중단시킬 수 없다 — 이는 플러그인 구현자의 책임이며, 콜백에는 반드시 타임아웃이 있어야 한다.

함정 3: tuner 플러그인의 참조 카운트 누수

ncclTunerPluginLoad성공 시tunerPluginRefCount。📎 src/plugin/tuner.cc:98 ncclTunerPluginUnload를 증가시키고,comm->tunerPluginLoaded이 참일 때 감소시킨다.📎 src/plugin/tuner.cc:111-123특정 통신 도메인이 tuner를 로드했지만 소멸 시tunerPluginLoaded이 예기치 않게 0으로 초기화되면 참조 카운트가 영원히 0이 되지 않아 플러그인 라이브러리가 영원히 언로드되지 않는다.

이 장의 생각과 자가 점검

Q1: 만약ncclNetPluginLoad에서 "높은 버전에서 낮은 버전으로 시도"하는 루프를 "최고 버전만 시도"로 변경하면, 어떤 시나리오에서 원래 사용 가능했던 플러그인이 로드되지 않게 되는가?

참고 해설:📎 src/plugin/net.cc:108-112을 본다. 루프는NCCL_NET_VERSION_COUNT개 버전을 순회하며 v12에서 v6까지 내려가고, 첫 번째로 비어 있지 않은 것을 반환한 것이 채택된다. v12만 시도하면 v11만 구현한 구버전 플러그인은 로드에 실패한다.

〔설계 추론과 아키텍처 트레이드오프〕

이 설계는 하위 호환성을 위한 것이다: NCCL 코어가 v12 지원으로 업그레이드된 후에도 v11만 제공하는 플러그인을 여전히 로드할 수 있다. 플러그인 작성자는 여러 버전의 심볼을 제공하도록 권장된다(📎 plugins/net/README.md:35-37참조). 이렇게 하면 동일한.so이 여러 NCCL 버전을 서비스할 수 있다.

다운그레이드 시도를 제거하면 사용자가 NCCL을 업그레이드한 후 구버전 플러그인이 갑자기 사용 불가능해져 내장 Socket 플러그인으로만 폴백해야 하며 성능이 크게 저하된다. 이것이 바로 버전 협상이 존재하는 이유이다.

Q2:profilerProgressOps에서 만약wc <= op->workStarted[ch].data[slot].counter을wc == op->workStarted[ch].data[slot].counter로 변경하면, 어떤 고동시성 시나리오에서 이벤트가 영원히 트리거되지 않는가?

참고 해설:📎 src/plugin/profiler.cc:969-972을 본다. 주석에 디바이스가MAX_PROFILER_EVENTS_PER_CHANNEL개 슬롯을 순환한다고 명시되어 있다. 호스트 소비 속도가 디바이스 생산 속도보다 뒤처지면, 디바이스가 이미 카운터wc + N로 슬롯wc % MAX_PROFILER_EVENTS_PER_CHANNEL。

을 덮어썼을 수 있다. 이때op->workStarted[ch].data[slot].counter의 값은wc + N이고op->workCounter은wc이다.==로 판단하면 실패하여 이벤트가 영원히 트리거되지 않고, 작업 객체가 영원히active연결 리스트에 남아inflight이 증가만 하고 감소하지 않아 결국 메모리 풀이 고갈된다.

<=를 사용하면 이러한 상황을 올바르게 처리할 수 있다: 디바이스가 기록한 카운터가 기대값 이상이면 이벤트가 준비된 것으로 간주한다. 이는 전형적인 "생산자-소비자 순환 버퍼"의 정확성 조건이다.

Q3: 만약ncclProfilerThreadDestroy에서iterationActive이 거짓이 되기를 기다리는 루프를 제거하면, 어떤 타이밍에서 profiler 플러그인이 이미 해제된 통신 도메인 컨텍스트에 접근하게 되는가?

참고 해설:📎 src/plugin/profiler.cc:1162-1166을 본다. 주석에ncclProfilerPluginFinalize이ncclProfilerThreadDestroy반환 후 즉시 통신 도메인의profilerContext。

을 파괴한다고 설명되어 있다. 소비 스레드가profilerProgressOps에서 플러그인 콜백을 호출할 때 전달되는 것은op->profilerContext。📎 src/plugin/profiler.cc:938이다. 소멸 스레드가iterationActive이 거짓이 되기를 기다리지 않고 반환하면,ncclProfilerPluginFinalize이 컨텍스트를 해제하는데 소비 스레드가 이 컨텍스트로 플러그인을 호출하고 있을 수 있다 — use-after-free.

iterationActive의 핸드셰이크 프로토콜은: 소비 스레드가 잠금 내에서true로 설정한 후 잠금을 해제하고 플러그인을 호출하며, 소멸 스레드는 잠금 내에서 그것이false。📎 src/plugin/profiler.cc:1028📎 src/plugin/profiler.cc:1054-1057로 돌아오기를 기다린다. 이 프로토콜은 플러그인 콜백 동안 컨텍스트가 항상 유효함을 보장한다.

대기를 제거하면 소멸 스레드가 소비 스레드가 막 플러그인 콜백에 진입한 시점에 반환하여 플러그인이 댕글링 포인터를 받을 수 있다. 이는 전형적인 "생명주기와 동시 접근" 경쟁이다.

플러그인 체계는 NCCL을 폐쇄에서 개방으로 전환시켰다: 네트워크 백엔드, 튜닝 전략, 성능 수집기, 설정 소스 모두 코어 코드를 수정하지 않고 교체할 수 있다. 그러나 플러그인은 새로운 장애 영역도 도입했다 — 버전 불일치, 생명주기 경쟁, 참조 카운트 누수. 다음 장에서는 RAS와 진단 서브시스템으로 들어가 NCCL이 장애를 감지하고, 진행 상황을 모니터링하며, 장시간 훈련 작업에서 자가 치유를 구현하는 방법을 살펴본다.

플러그인 체계는 NCCL의 핵심 통신 경로와 교체 가능한 컴포넌트 사이에 명확한 경계를 그었으며, net, tuner, profiler, env 네 가지 플러그인이 각각 등록과 참조 카운트 메커니즘을 통해 런타임 동작에 안전하게 개입한다. 그러나 확장 가능한 통신 엔진은 컴포넌트를 유연하게 교체할 수 있을 뿐만 아니라 장시간 훈련에서 안정적으로 실행되어야 한다 — 네트워크 카드나 GPU에 장애가 발생하면 NCCL은 어떻게 감지하고, 모니터링하며, 복구를 트리거하는가? 다음 장에서는 RAS와 진단 메커니즘으로 들어가 프로덕션 환경에서의 신뢰성이 어떻게 체계적으로 보장되는지 살펴본다.

CHAPTER 17

제17장: 제17장: RAS 메커니즘과 내결함성: 링크 장애 감지, 하트비트 및 우아한 성능 저하

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제17 / 25장

제17장: RAS 메커니즘과 내결함성: 링크 장애 감지, 하트비트 및 우아한 성능 저하

이전 장에서 우리는 플러그인 시스템이 어떻게 핵심 통신 경로와 교체 가능한 컴포넌트 사이의 경계를 명확히 하여, 핵심 코드를 수정하지 않고도 네트워크 백엔드, 튜닝 전략, 성능 수집기를 교체할 수 있는지 살펴보았다. 그러나 확장성은 프로덕션 사용 가능성의 한 차원일 뿐이며, 또 다른 equally hardcore한 문제는: AllReduce가 이미 72시간 동안 실행되었을 때, 특정 머신의 네트워크 카드가 조용히 고장 났다면, NCCL이 무엇을 근거로 발견하고, 격리하고, 계속할 수 있는가? RAS 서브시스템은 바로 NCCL이 "실행 가능"에서 "프로덕션 사용 가능"으로 나아가는 분수령이며, 이 장에서는 장애 감지, 진행 모니터링 및 자가 치유 메커니즘 뒤의 설계를 분석한다.

17.1 RAS 총괄: 프로세스당 하나의 RAS 스레드인 전역 조정자

직관적 모델

RAS를 전체 작업의 "당직실"로 상상해 보자. 각 NCCL 프로세스(각 rank)는 초기화 시 하나의 당직실을 열고, 그 안에 전담 스레드가 앉아 있다. 모든 통신 도메인(communicator)의 생성, 소멸, 진단 요청은 먼저 당직실에 등록해야 하며, 당직실 간에는 독립적인 RAS 네트워크를 통해 "누가 아직 살아 있고, 누가 이미 죽었는지"를 서로 통보한다.

만약 이 당직실이 없다면, NCCL은 통신 경로 자체의 타임아웃에만 의존하여 장애를 감지할 수밖에 없다 — 그리고 통신 경로상의 타임아웃은 느릴 뿐만 아니라 오판하기 쉽다(한 번의 네트워크 지터가 노드 사망으로 간주될 수 있다). RAS는 "장애 감지"를 데이터 플레인에서 제어 플레인으로 분리하여, 독립적인 경량 하트비트와 진단 채널로 건강 상태를 판정한다.

데이터 구조와 메모리 레이아웃

RAS의 핵심 상태는ras.cc의 전역 변수에 흩어져 있으며, 하나씩 분석해 보자:

변수타입역할
rasInitMutexstd::mutexRAS 싱글톤 초기화 보호
rasInitializedbool초기화 여부
rasInitRefCountint참조 카운트, 활성 comm 수와 동일
rasNetListeningSocketstruct ncclSocketRAS 네트워크 리스닝 소켓
rasNotificationPipe[2]ncclSocketPairDescriptor로컬 스레드 → RAS 스레드의 알림 파이프
rasPfdsstruct pollfd*메인 이벤트 루프의 poll 배열
ncclCommsstruct ncclComm**모든 통신 도메인 포인터 배열

📎 src/ras/ras.cc:49-61이 전역 상태들을 정의한다. 주목할 점은rasInitRefCount이ncclAtomicRefCountIncrement을 증감하는 데 사용되고,📎 src/ras/ras.cc:129은 일반 bool과 이중 검사 잠금으로rasInitialized을 보호한다는 것이다📎 src/ras/ras.cc:103-105— 이것은 전형적인 "한 번 초기화, 이후 읽기 전용" 패턴이다.

ncclComms배열의 할당 전략은 주목할 만하다: 필요에 따라 증가하는 것이 아니라, 확장할 때마다RAS_INCREMENT * 8(즉 32개 슬롯)📎 src/ras/ras.cc:139-140이다. 배열에는nullptr공백(comm 소멸 시 비워짐)이 허용되며, 새 comm은 첫 번째 공백을 재사용한다📎 src/ras/ras.cc:135-137。

시나리오 기반 Walkthrough: comm 초기화부터 RAS 스레드 시작까지

첫 번째 단계:ncclRasCommInit이 호출된다.이것은 각 comm 초기화 시 첫 번째로 호출되는 RAS 함수이다📎 src/ras/ras.cc:101. 먼저rasInitialized을 확인하고, 초기화되지 않았다면 임계 영역에 진입한다:

1. bootstrap 네트워크 인터페이스 주소로rasNetListeningSocket을 초기화하고, 포트를 0으로 설정하여 커널이 무작위로 할당하게 한다📎 src/ras/ras.cc:108-109

2. 해당 소켓을 리스닝한다📎 src/ras/ras.cc:113

3. 로컬 알림 파이프를 생성한다📎 src/ras/ras.cc:118

4. 진단 서브시스템을 초기화한다📎 src/ras/ras.cc:120

5.rasThreadMain스레드를 시작한다📎 src/ras/ras.cc:121

6.atexit(rasTerminate)을 등록하여 프로세스 종료 시 정리를 보장한다📎 src/ras/ras.cc:126

두 번째 단계: comm 등록.최초 초기화 여부와 관계없이comm포인터를ncclComms배열에 기록하고,📎 src/ras/ras.cc:142을 false로 설정한다ncclCommsSorted— 배열 순서가 변경되었으므로 이전 정렬이 무효화되기 때문이다.📎 src/ras/ras.cc:143세 번째 단계: 포트 역채움.

함수 마지막에(커널이 할당한 포트 포함)을rasNetListeningSocket.addr에 복사하여, 호출자가 RAS 네트워크가 어느 포트에서 리스닝하는지 알 수 있게 한다.myRank->addr 📎 src/ras/ras.cc:146메인 이벤트 루프: poll 기반 멀티플렉싱

은 RAS 스레드의 심장이다

rasThreadMain. 먼저 세 개의 고정 fd를 등록한다: 알림 파이프, RAS 네트워크 리스닝 소켓, 클라이언트 리스닝 소켓📎 src/ras/ras.cc:633. 그런 다음 무한 루프에 진입한다:📎 src/ras/ras.cc:641-652복사

code
for (int64_t nextWakeup = 0;;) {
  // 计算超时
  timeoutMs = min(..., 1000);
  nEvents = poll(rasPfds, nRasPfds, timeoutMs);
  // 处理事件
  for (pollIdx...) { ... }
  // 处理各类超时
  rasSocksHandleTimeouts(now, &nextWakeup);
  rasConnsHandleTimeouts(now, &nextWakeup);
  rasNetHandleTimeouts(now, &nextWakeup);
  rasCollsHandleTimeouts(now, &nextWakeup);
}

📎 src/ras/ras.cc:655-728이 1000ms 이내로 강제 제한된다는 것이다timeoutMs—📎 src/ras/ras.cc:664이 아무리 멀어도 매초 깨어나 타임아웃 검사의 적시성을 보장한다.nextWakeup이벤트 디스패치 로직은 fd 값을 라우팅에 사용한다

: 알림 파이프라면📎 src/ras/ras.cc:684-715을 호출하고; 리스닝 소켓이면 accept; 그렇지 않으면rasLocalHandle과rasSocketsHead연결 리스트를 순회하여 해당 socket을 찾아 처리한다.rasClientsHead로컬 알림 메커니즘: 파이프 + 고정 길이 구조

로컬 NCCL 스레드와 RAS 스레드는 socketpair를 통해 통신한다. 알림 구조

는 고정 길이rasNotification이며,📎 src/ras/ras.cc:35-46을 사용하여static_assert을 초과하지 않도록 보장한다PIPE_BUF 📎 src/ras/ras.cc:47— 이는 쓰기의 원자성을 보장하기 위함이다(POSIX는 PIPE_BUF보다 작은 쓰기가 원자적임을 보장한다).

송신 측rasLocalNotify은rasNotificationMutex을 사용하여 여러 사용자 스레드의 쓰기를 직렬화하고📎 src/ras/ras.cc:224-237, 전부 쓸 때까지 루프로 쓴다📎 src/ras/ras.cc:224-237. 수신 측rasLocalHandle도 마찬가지로 전체 구조를 다 읽을 때까지 루프로 읽고📎 src/ras/ras.cc:247-256, EOF를 읽으면ncclSystemError 📎 src/ras/ras.cc:251-253。

을 반환한다RAS_ADD_RANKS세 가지 알림 유형:RAS_RUN_DIAG(새 rank 참여),RAS_TERMINATE(진단 실행),📎 src/ras/ras.cc:28-32。

(종료)

메시지 송수신: 길이 접두사 + 증분 진행📎 src/ras/ras_internal.h:110-117RAS 메시지의 와이어 포맷은 "4바이트 길이 + 메시지 본문"이다rasConnSendMsg. 송신 시📎 src/ras/ras.cc:362-390은 먼저 길이를 보내고 그다음 메시지 본문을 보내며meta->offset,rasMsgRecv로 진행 상황을 기록하여 부분 전송 후 다음에 계속할 수 있다. 수신 시📎 src/ras/ras.cc:393-412。

은 먼저 길이를 받고, 길이에 따라 버퍼를 할당한 후, 메시지 본문을 받는다rasMsgAlloc여기에는 세부 사항이 있다:rasMsgMeta이 할당하는 것은msg구조이며,offsetof필드는 구조 끝에 있고,📎 src/ras/ras.cc:313-319으로 오프셋을 계산한다📎 src/ras/ras.cc:323-328. 이러한 "메타데이터 전치" 레이아웃은 메시지가 전송 진행률, 큐잉 시간 등 로컬 정보를 운반할 수 있게 하면서도 와이어 포맷을 차지하지 않습니다.

설계 고찰

〔설계 추론 및 아키텍처 트레이드오프〕

왜 epoll 대신 poll을 사용하는가?poll의 O(n) 복잡도는 RAS 시나리오에서 허용 가능합니다——RAS 연결 수는 데이터 플레인 연결 수보다 훨씬 적고, RAS 스레드 자체가 성능 임계 경로가 아니기 때문입니다. poll의 크로스 플랫폼성도 더 우수합니다(Windows 호환).

〔설계 추론 및 아키텍처 트레이드오프〕

왜 알림에 조건 변수 대신 파이프를 사용하는가?파이프는 poll 루프에 원활하게 통합되어 RAS 스레드가 통일된poll모든 이벤트 소스를 대기할 수 있습니다. 조건 변수를 사용하면 poll을 깨우기 위한 추가 메커니즘이 필요합니다.

mermaid
flowchart TD
    start["rasThreadMain 启动"] --> reg_pipe["注册通知管道 fd"]
    reg_pipe --> reg_net["注册 RAS 网络监听 fd"]
    reg_net --> reg_client["注册客户端监听 fd"]
    reg_client --> poll["poll(rasPfds, timeout<=1000ms)"]
    poll --> check{"nEvents == -1?"}
    check -->|"是且非 EINTR"| log_err["记录 poll 错误并继续"]
    check -->|"否"| dispatch["遍历 revents 分发事件"]
    log_err --> dispatch
    dispatch --> is_pipe{"fd == 通知管道?"}
    is_pipe -->|"是"| local_handle["rasLocalHandle()"]
    is_pipe -->|"否"| is_net{"fd == RAS 监听?"}
    is_net -->|"是"| accept_net["rasNetAcceptNewSocket()"]
    is_net -->|"否"| is_client{"fd == 客户端监听?"}
    is_client -->|"是"| accept_client["rasClientAcceptNewSocket()"]
    is_client -->|"否"| find_sock["遍历 rasSocketsHead 找匹配 socket"]
    find_sock --> sock_loop["rasSockEventLoop(sock, pollIdx)"]
    local_handle --> terminate{"terminate?"}
    terminate -->|"是"| cleanup["rasThreadCleanup() 并退出"]
    terminate -->|"否"| timeouts
    sock_loop --> timeouts["rasSocksHandleTimeouts / rasConnsHandleTimeouts / rasNetHandleTimeouts / rasCollsHandleTimeouts"]
    accept_net --> timeouts
    accept_client --> timeouts
    timeouts --> poll

17.2 진행 모니터링: DMA로 GPU 카운터를 호스트로 옮기기

직관적 모델

진행 모니터링은 자동차 대시보드의 "엔진 회전계"와 같습니다. 운전(통신)에 참여하지는 않지만, GPU 내부의 진행 카운터를 지속적으로 호스트 메모리에 복사하여 호스트가 "이 통신 도메인이 멈춘 건 아닌지" 판단할 수 있게 합니다. 이것이 없으면 AllReduce가 멈췄을 때 "프로그램이 반환되지 않는다"는 것만 보일 뿐, GPU가 연산 중인지, 네트워크를 기다리는지, 완전히 교착 상태인지 알 수 없습니다.

데이터 구조와 메모리 레이아웃

각 CUDA 디바이스는 하나의ncclGpuProgressCounterMonitor워커 스레드에 대응됩니다📎 src/ras/progress_monitor.cc:35-52:

필드타입역할
cudaDevint바인딩된 CUDA 디바이스 번호
threadstd::thread워커 스레드
mutex / cvstd::mutex / condition_variable가변 상태 보호 및 깨우기
running / shouldStopbool스레드 수명 주기 플래그
copyInFlightboolDMA 복사가 진행 중인지 여부
copyStallWarnedbool이번 정체에 대해 이미 경고했는지 여부
copyStartNsuint64_t이번 복사 시작 시간
sideStreamcudaStream_t전용 비블로킹 스트림
copyDonecudaEvent_t복사 완료 이벤트
warningMutexstd::mutex경고 타임스탬프 보호
lastStaleWarnNs / lastErrorWarnNsuint64_t스로틀링 타임스탬프
destroyRefsint파괴 참조 카운트
registrations침투적 큐이 디바이스에 등록된 comm 목록

📎 src/ras/progress_monitor.cc:59-62잠금 순서를 명확히 했습니다:gpuProgressCounterMonitorsMu이전에ncclGpuProgressCounterMonitor::mutex. 이것이 교착 상태를 피하기 위한 핵심 규약입니다.

전역 배열gpuProgressCounterMonitors[kRasMaxCudaDevices]디바이스 번호로 인덱싱📎 src/ras/progress_monitor.cc:59-62。

시나리오 기반 워크스루: 한 번의 카운터 복사

첫 번째 단계: 등록. ncclProgressCounterMonitorInit이 호출됩니다📎 src/ras/progress_monitor.cc:319. 만약deviceCountersBlock이 비어 있으면 바로 반환합니다(해당 comm은 모니터링에 참여하지 않음)📎 src/ras/progress_monitor.cc:323. 그렇지 않으면 전역 잠금 내에서 해당 디바이스의 worker를 찾거나 생성하고📎 src/ras/progress_monitor.cc:328-335, 그런 다음 comm을 큐에 넣습니다registrations 📎 src/ras/progress_monitor.cc:339。

두 번째 단계: 워커 스레드 시작. createGpuProgressCounterMonitorworker를 생성하고,cudaSetDevice을 설정하며,sideStream(cudaStreamNonBlocking)와copyDone이벤트를 생성하고📎 src/ras/progress_monitor.cc:280-282, 스레드 시작 후 최대 2000ms 동안running이 true가 되기를 기다립니다📎 src/ras/progress_monitor.cc:287-303。

세 번째 단계: 루프 복사. progressCounterMonitorLoop먼저 디바이스를 바인딩하고 relaxed 스트림 캡처 모드를 설정한 후(애플리케이션의 graph capture를 방해하지 않도록)📎 src/ras/progress_monitor.cc:97-121, 메인 루프에 진입합니다:

1.pollIntervalMs을 기다립니다(기본 1000ms)📎 src/ras/progress_monitor.cc:132-136

2. 이전 복사가 아직 진행 중이면cudaEventQuery으로📎 src/ras/progress_monitor.cc:140을 확인합니다. 만약cudaErrorNotReady이고 stale 임계값(기본 5000ms)을 초과하면 스로틀링 경고를 발생시킵니다📎 src/ras/progress_monitor.cc:141-154

3. 등록된 모든 comm을 순회하며, 각각에 대해cudaMemcpyAsync을 호출하여deviceCountersBlock을hostCountersBlock 📎 src/ras/progress_monitor.cc:170-185

로 복사합니다copyDone4. 복사가 하나라도 성공하면copyInFlight 📎 src/ras/progress_monitor.cc:194-202

이벤트를 기록하고

을 설정합니다progressCounterMonitorShouldWarn동시성 제어와 스로틀링📎 src/ras/progress_monitor.cc:78-87경고 스로틀링은warningMutex로 구현됩니다warnIntervalNs:staleWarnSec보호 아래에서 마지막 경고로부터📎 src/ras/progress_monitor.cc:27를 초과했는지 확인하고, 초과한 경우에만 업데이트하고 true를 반환합니다. 기본

은 600초📎 src/ras/progress_monitor.cc:29, 즉 동일한 유형의 경고는 최대 10분에 한 번입니다.📎 src/ras/progress_monitor.cc:30파라미터에는 하한 클램핑이 있습니다: poll 간격 최소 50ms

, stale 임계값 최소 1000ms

ncclProgressCounterMonitorDestroy. 이는 사용자가 과도하게 설정하여 CPU가 헛도는 것을 방지합니다.📎 src/ras/progress_monitor.cc:352-354:

파괴: 참조 카운트 + 스트림 동기화registrations의 파괴 로직은 이 장에서 가장 정교한 동시성 설계 중 하나입니다📎 src/ras/progress_monitor.cc:368

1. 전역 잠금 + worker 잠금 내에서destroyRefs++에서 comm을 제거합니다haveDestroyRef 📎 src/ras/progress_monitor.cc:371-372

2. 제거에 성공하면,shouldStop 📎 src/ras/progress_monitor.cc:373-376

하고cudaStreamSynchronize(g->sideStream)을 설정합니다📎 src/ras/progress_monitor.cc:393

3. 등록 목록이 비면 전역 배열에서 제거하고releaseGpuProgressCounterMonitorDestroyRef을 설정합니다📎 src/ras/progress_monitor.cc:219-246

4. 잠금 해제 후,

은 여전히 해당 comm 버퍼를 참조할 수 있는 복사를 배출합니다destroyRefs?5. 마지막으로cudaStreamSynchronize은 참조 카운트를 감소시키고, 0이 되고 큐가 비면 스레드를 join하고 삭제합니다

mermaid
sequenceDiagram
    participant App as 应用线程
    participant Mon as 监控线程
    participant GPU as CUDA 设备
    App->>Mon: ncclProgressCounterMonitorInit(comm)
    Mon->>Mon: 查找/创建 worker
    Mon->>Mon: registrations 入队 comm
    loop 每 pollIntervalMs
        Mon->>GPU: cudaEventQuery(copyDone)
        GPU-->>Mon: cudaErrorNotReady / cudaSuccess
        Mon->>GPU: cudaMemcpyAsync(hostCounters, deviceCounters, D2H, sideStream)
        Mon->>GPU: cudaEventRecord(copyDone, sideStream)
    end
    App->>Mon: ncclProgressCounterMonitorDestroy(comm)
    Mon->>Mon: registrations 删除 comm, destroyRefs++
    Mon->>GPU: cudaStreamSynchronize(sideStream)
    GPU-->>Mon: 拷贝排空完成
    Mon->>Mon: releaseGpuProgressCounterMonitorDestroyRef
    Mon->>Mon: join 线程, delete worker

왜

이 필요한가cudaSetDevice왜냐하면이 잠금 외부에서 실행되며, 그 동안 다른 스레드도 같은 worker를 파괴할 수 있기 때문입니다. 참조 카운트는 마지막 파괴자만이 실제로 join하고 delete하도록 보장합니다.cudaSetDevice복사shouldStop프로덕션 함정 회피📎 src/ras/progress_monitor.cc:97-107함정 1:NCCL_RAS실패로 인한 모니터링 무음 실패.

스레드 시작 시이 실패하면 worker는cudaThreadExchangeStreamCaptureMode(cudaStreamCaptureModeRelaxed)을 설정하고 종료합니다📎 src/ras/progress_monitor.cc:110-111, 하지만 이를 등록한 comm은 여전히 모니터링이 실행 중이라고 생각합니다. 이때 카운터 미러는 Init 단계에서 실패가 드러날 때까지 계속 오래된 상태로 남습니다. 문제를排查할 때는

로그에 "progress-counter mirrors will remain stale"이 있는지 확인해야 합니다.

함정 2: graph capture 충돌.

모니터링 스레드가 CUDA API를 호출할 때 애플리케이션이 stream capture를 수행 중이면 캡처 그래프를 오염시킵니다. 코드는nvidia-smi으로 회피합니다

, 이는 필수적인 보호입니다.

17.3 진단 프레임워크: 테이블 기반 검사 디스패치rasDiagnosticsChecks 📎 src/ras/diagnostics.cc:63-77직관적 모델collectLocal(로컬 수집) 및summarize(집계). 총 11개 검사 항목: GPU 모델, CUDA 드라이버 버전, ECC, NVLink, NCCL 환경, RDMA 토폴로지, IOMMU 모드, ATS, XID/SXID, NVIDIA 드라이버 버전, 경로.

rasDiagnosticsGetCheck삼중 검증을 수행한다: ID 범위, 테이블 항목 ID 일치, 콜백 비어있지 않음📎 src/ras/diagnostics.cc:104-128. 이는 방어적 프로그래밍이다 — 테이블 항목이 잘못 수정되어 널 포인터를 호출하는 것을 방지한다.

시나리오 기반 Walkthrough: 한 번의 진단 전체 생명주기

1단계: 로컬 payload 구성. rasDiagnosticsCollectLocalPeerPayload먼저 peer 헤더를 기록하고📎 src/ras/diagnostics.cc:226-227, 그다음 분배 테이블을 순회하며 각 항목에 대해rasDiagnosticsAppendCheckPayload 📎 src/ras/diagnostics.cc:229-231。

rasDiagnosticsAppendCheckPayload를 호출한다collectLocal를 호출하여rasDiagnosticsLocalData를 얻고,ncclUniquePtr로 records 소유권을 인수하며📎 src/ras/diagnostics.cc:191-192, 메타데이터를 검증하고📎 src/ras/diagnostics.cc:193, 레코드 수가 0이면 건너뛰고📎 src/ras/diagnostics.cc:194, 그렇지 않으면 검사 헤더 + 레코드 데이터를 기록한다📎 src/ras/diagnostics.cc:196-201。

2단계: 집합 통신 시작. rasDiagnosticsStart를 구성하여RAS_COLL_DIAG요청하고📎 src/ras/diagnostics.cc:532-537, 를 통해rasNetSendCollReq를 발송하며📎 src/ras/diagnostics.cc:539, 클라이언트 상태를 로 설정한다RAS_CLIENT_DIAG_FINI 📎 src/ras/diagnostics.cc:541。

3단계: 응답 병합. rasCollDiagMerge각 peer의 payload를 집합 버퍼에 추가한다📎 src/ras/diagnostics.cc:310-337. 여기서 대량의 오버플로 검사를 수행한다: peer 수 상한📎 src/ras/diagnostics.cc:320-324, 총 크기 상한📎 src/ras/diagnostics.cc:325-328。

4단계: 집계. rasDiagnosticsSummarizePeerPayloads은 두 번의 스캔이다📎 src/ras/diagnostics.cc:399:

  • 첫 번째: 각 peer 헤더와 검사 헤더를 검증하고, 각 검사 유형별 레코드 수와 바이트 수를 누적한다📎 src/ras/diagnostics.cc:418-470
  • 각 검사 유형별 병합 버퍼를 할당한다📎 src/ras/diagnostics.cc:472-476
  • 두 번째: 각 peer의 레코드를 해당 버퍼에 복사한다📎 src/ras/diagnostics.cc:479-497
  • 마지막으로 각 검사 유형에 대해 를 호출한다summarize 📎 src/ras/diagnostics.cc:499-506

클라이언트 상태와 취소

진단 상태는rasDiagnosticsClientState에 존재하며📎 src/ras/diagnostics.cc:242-245, 에 매달려 있다.rasClient->diagnostics클라이언트 소켓이 닫힐 때 reporter를 noop으로 교체하여rasDiagnosticsCancelTarget, 비동기 진단 완료 후 이미 닫힌 소켓에 쓰는 것을 방지한다📎 src/ras/diagnostics.cc:286-293설계 고찰📎 src/ras/diagnostics.cc:48-52。

〔설계 추론과 아키텍처 트레이드오프〕

왜 두 번 스캔하는가?

payload가 가변이기 때문에, 첫 번째 스캔에서만 각 검사 유형에 필요한 버퍼 크기를 계산할 수 있다. 한 번 스캔은 동적 증가(다중 realloc)이거나 과도한 사전 할당이 필요하다. 두 번 스캔은 한 번의 정확한 할당으로 결정성을 얻는다.왜 검사 헤더에 를 포함하는가?

검사마다 레코드 구조 크기가 다르기 때문에, 집계 시 스트라이드를 알아야 올바르게 복사하고 검증할 수 있다.recordStride? 📎 src/ras/diagnostics.cc:197동일 검사의 stride 일관성을 강제한다rasDiagnosticsAccountCheckRecords복사📎 src/ras/diagnostics.cc:381-385。

mermaid
flowchart TD
    start["rasDiagnosticsStart"] --> build_req["构造 RAS_COLL_DIAG 请求"]
    build_req --> send["rasNetSendCollReq"]
    send --> all_done{"allDone?"}
    all_done -->|"是"| fini["client->status = DIAG_FINI"]
    all_done -->|"否"| in_progress["返回 ncclInProgress"]
    fini --> resume["rasDiagnosticsResume"]
    in_progress --> resume
    resume --> summarize["rasDiagnosticsSummarizePeerPayloads"]
    summarize --> pass1["第一遍: 校验头 + 累计每类记录数"]
    pass1 --> valid{"payload 合法?"}
    valid -->|"否"| err["返回 ncclInternalError"]
    valid -->|"是"| alloc["为每类检查分配合并缓冲区"]
    alloc --> pass2["第二遍: 拷贝各 peer 记录"]
    pass2 --> emit["对每类检查调用 summarize"]
    emit --> finish["reporter.finish + rasCollFree"]

직관적 모델

이 유지하는 것은 "반 전체 명단"이다. 각 RAS 스레드는 완전히 동일한 명단을 보관하며, 각 NCCL 프로세스의 주소, PID, 관리 GPU를 기록한다. 새 학생이 합류하거나 누군가 "실종"되면 RAS 네트워크를 통해 변경을 브로드캐스트한다. 명단은 해시값을 버전 번호로 사용하여 매번 전체 동기화를 피한다.

peers.cc데이터 구조와 메모리 레이아웃

두 개의 핵심 배열:

: 모든 알려진 peer, 주소로 정렬

  • rasPeers. 죽은 peer 포함.📎 src/ras/peers.cc:18-19: 죽은 peer 주소, 별도 저장
  • rasDeadPeers왜 죽은 peer를 별도 저장하는가?📎 src/ras/peers.cc:37-38。

의 주석이 명확히 설명한다: 📎 src/ras/peers.cc:25-28은 대규모에서 기본적으로 정적이고 매우 크며,rasPeers은 동적이고 훨씬 작다. 분리 저장으로 매번 동기화 시 거대한rasDeadPeers배열을 전송하는 것을 피한다.rasPeers구조

rasPeerInfo필드📎 src/ras/ras_internal.h:110-117:

타입설명네트워크 주소 (정렬 키)
addrncclSocketAddress프로세스 ID
pidncclPid_tCUDA 디바이스 비트마스크 (CUDA_VISIBLE_DEVICES 영향 받음)
cudaDevsuint64_tNVML 디바이스 비트마스크 (영향 받지 않음)
nvmlDevsuint64_tcomm에서 추출, commHash를 빼서 통신 도메인과 무관하게 만듦
hostHash / pidHashuint64_t두 해시

와rasPeersHash는 동기화의 핵심이다rasDeadPeersHash시나리오 기반 Walkthrough: 새 rank 합류📎 src/ras/peers.cc:21📎 src/ras/peers.cc:37-38。

1단계: 변환.

이 rasRanksConvertToPeers배열을rasRankInit로 변환한다. 먼저 주소 + cudaDev로 정렬하고rasPeerInfo 📎 src/ras/peers.cc:104, 빈 주소를 건너뛰며📎 src/ras/peers.cc:114, 같은 주소의 다중 GPU 프로세스를 병합한다 (비트마스크 OR)📎 src/ras/peers.cc:127-1302단계: 로컬 배열 갱신.📎 src/ras/peers.cc:134-139。

은 이 장에서 가장 복잡한 병합 알고리즘이다 rasPeersUpdate. 먼저 새 배열 크기를 계산하고📎 src/ras/peers.cc:197, 그다음 두 정렬 배열을 병합한다📎 src/ras/peers.cc:202-229. 핵심: 병합 과정에서📎 src/ras/peers.cc:244-361을 "차이"로 변환 — 실제로 새로 추가된 GPU 비트만 유지하고rankPeers, 마지막으로 기여 없는 항목을 제거한다📎 src/ras/peers.cc:301-308. 이렇게 하면 브로드캐스트 데이터량이 최소화된다.📎 src/ras/peers.cc:393-4023단계: 전파.

을 따라 rasNetUpdatePeers과rasNextLink두 방향으로 전파하고rasPrevLink, 그다음 연결을 재구성한다📎 src/ras/peers.cc:430-4504단계: 업데이트 전송.📎 src/ras/peers.cc:443-444。

먼저 해시를 검사한다 rasConnSendPeersUpdate: 상대방이 현재 해시를 알고 있으면 건너뛴다. 메시지에📎 src/ras/peers.cc:500-508와peersHash를 포함하고deadPeersHash 📎 src/ras/peers.cc:521-524, 수신자가 병합 후에도 해시가 일치하지 않으면 를 회신한다📎 src/ras/peers.cc:608-653。

죽은 peer의 선언과 전파

rasPeerDeclareDead이 주소를 에 추가하고rasDeadPeers, 정렬 후 해시를 재계산한다📎 src/ras/peers.cc:793-812。rasMsgHandleBCDeadPeer이 브로드캐스트된 죽은 peer 메시지를 처리한다📎 src/ras/ras.cc:578-591: 로컬에 없으면 연결을 끊고 사망 선언하며, 그렇지 않으면 표시한다*pDone = true재브로드캐스트를 중지한다.

rasDeadPeersUpdate이 병합 정렬로 신구 죽은 peer 목록을 병합한다📎 src/ras/peers.cc:838-893. 여기서memmove가 아닌memcpy 📎 src/ras/peers.cc:855를 사용하는데, 소스와 대상이 겹칠 수 있기 때문이다.

연결 재구성: 중복 연결 경쟁 방지

rasLinkReinitConns이 peer 업데이트 후 링크 연결을 재구성한다📎 src/ras/peers.cc:680. 핵심 전략: 주소가 작은 쪽에서 연결을 시작하여📎 src/ras/peers.cc:706-711, 양쪽이 동시에 시작해 중복되는 것을 방지한다.

rasLinkCalculatePeer이 다음 peer 인덱스를 계산하며 죽은 peer를 건너뛴다📎 src/ras/peers.cc:743-785. fallback에는 추가 최적화가 있다: 이전 fallback과 같은 노드의 peer를 건너뛰어📎 src/ras/peers.cc:743-785, 전체 노드 다운 시 하나씩 대기하는 것을 피한다.

프로덕션 함정 회피

함정 1: 주소 비교의 바이트 순서 함정. ncclSocketsCompare은 주소 패밀리 → 주소 → 포트 순으로 정렬한다📎 src/ras/peers.cc:960-990. 주석은 단순히memcmp전체 구조를 비교할 수 없다고 지적하는데, 메모리 레이아웃 순서가 기대 정렬 순서와 다르기 때문이다📎 src/ras/peers.cc:957-959. IPv4 주소와 포트는 네트워크 바이트 순서에서 바이트별 비교가 가능하지만, 주소 패밀리 필드는 그렇지 않다.

함정 2:myPeerIdx무효화됩니다.배열이 증가할 때myPeerIdx가 변경됩니다📎 src/ras/peers.cc:22-23。rasPeersUpdate병합 과정에서 이를 동기적으로 업데이트하며📎 src/ras/peers.cc:312📎 src/ras/peers.cc:358, 업데이트 실패 시 이진 탐색으로 폴백합니다📎 src/ras/peers.cc:374-388。

〔설계 추론 및 아키텍처 트레이드오프〕

함정 3: 해시 충돌로 인한 동기화 누락.해시는 "동기화가 필요한지" 판단에만 사용되며, 정확성에는 사용되지 않습니다 . 해시 충돌로 동기화를 건너뛰더라도, 이후 keep-alive 교환 시 여전히 해시가 포함되어 최종적으로 수렴합니다.

mermaid
flowchart LR
    subgraph 输入
        ranks["rasRankInit[]"]
    end
    subgraph 转换
        convert["rasRanksConvertToPeers: 排序+合并同地址"]
        rankPeers["rasPeerInfo[] (rankPeers)"]
    end
    subgraph 合并
        update["rasPeersUpdate: 归并到 rasPeers"]
        diff["rankPeers 改造为差异"]
        hash["重算 rasPeersHash"]
    end
    subgraph 传播
        send["rasConnSendPeersUpdate: 带哈希"]
        recv["rasMsgHandlePeersUpdate: 合并+回发"]
        reinit["rasLinkReinitConns: 重建连接"]
    end
    ranks --> convert --> rankPeers --> update
    update --> diff --> hash
    hash --> send --> recv --> reinit

17.5 설계 사고: RAS와 주 통신 경로의 경계

RAS 서브시스템의 가장 핵심적인 설계 결정은데이터 플레인과 완전히 분리하는 것입니다 . RAS 스레드는 어떤 집합 통신의 데이터 전송에도 참여하지 않으며, 오직 세 가지 일만 합니다: peer 명단 유지, 연결 상태 감지, 진단 실행. 이러한 분리는 몇 가지 이점을 가져옵니다:

1. 장애 격리: RAS 스레드가 크래시해도 통신 실패로 직접 이어지지 않습니다 (비록 장애 감지 능력은 상실되지만)

2. 성능 무손실: RAS의 하트비트와 동기화 트래픽은 독립 네트워크를 사용하여 데이터 플레인 대역폭을 점유하지 않습니다

3. 관측 가능성: 진단과 모니터링이 통신 진행 중에 병렬로 실행될 수 있습니다

대가는상태 일관성의 도전입니다: RAS가 보는 comm 상태는 데이터 플레인보다 지연될 수 있습니다.ncclRasCommInit과ncclRasCommFini는ncclCommsMutex를 통해📎 src/ras/ras.cc:77-77를 보호하지만, RAS 스레드가 읽을 때는 스냅샷만 수행하고 강한 일관성 보장은 하지 않습니다.

또 다른 핵심 설계는타임아웃 계층화。ras_internal.h입니다. 일련의 타임아웃 상수를 정의합니다📎 src/ras/ras_internal.h:214-249: keep-alive 간격 1초, 경고 임계값 5초, 오류 임계값 20초, peer 사망 임계값 60초. 이러한 계층화를 통해 시스템은 심각도에 따라 다른 조치를 취할 수 있습니다——먼저 경고하고, 그다음 대체 연결을 시도하며, 마지막에야 사망을 선언합니다.

17.6 이 장의 요약

이 장에서는 NCCL RAS 서브시스템의 네 가지 핵심 모듈을 분석했습니다:

  • ras.cc: 싱글턴 RAS 스레드 + poll 이벤트 루프로, 파이프를 통해 로컬 알림을 수신하고 독립 네트워크를 통해 다른 rank와 메시지를 교환합니다
  • progress_monitor.cc: 디바이스당 하나의 워커 스레드로, DMA를 사용하여 GPU 진행 카운터를 호스트로 가져오며, 스로틀 경고와 참조 카운트 기반 파괴를 지원합니다
  • diagnostics.cc: 테이블 기반 검사 디스패치 프레임워크로, 두 번의 스캔으로 각 rank의 진단 payload를 집계합니다
  • peers.cc: 정렬된 배열 + 해시 동기화 기반의 peer 명단 관리로, 죽은 peer를 별도로 저장하여 대역폭을 절약합니다

이 장의 생각과 자가 점검

Q1:rasLocalNotify는rasNotificationMutex로 직렬화하여 쓰지만,rasLocalHandle는 읽을 때 대응하는 잠금이 없습니다. 왜 이것이 안전한가요? 만약static_assert(sizeof(struct rasNotification) <= PIPE_BUF)를 제거하면 어떤 시나리오에서 문제가 발생하나요?

참고 해석: 안전성은 파이프 쓰기의 원자성을 보장하는 POSIX에서 비롯됩니다——보다 작은PIPE_BUF쓰기는 원자적입니다📎 src/ras/ras.cc:47。rasLocalNotify의 루프 쓰기는📎 src/ras/ras.cc:224-237단일 쓰기로 완료될 때 다른 쓰기와 인터리빙되지 않습니다.rasLocalHandle의 루프 읽기는📎 src/ras/ras.cc:247-256부분 데이터를 읽을 수 있지만, 쓰기가 원자적이므로 읽은 것은 반드시 완전한 메시지의 접두사이며, 다음 읽기에서 보충하면 됩니다.

를 제거하면, 만약static_assert가rasNotification를 초과하면PIPE_BUF, 쓰기가 여러 번의 비원자적 쓰기로 분할될 수 있습니다. 두 스레드가 동시에 쓸 때 그들의 바이트가 인터리빙되어 RAS 스레드가 두 번의 알림이 이어붙은 기형 데이터를 읽을 수 있습니다.msg.type는 스레드 A에서 오고msg.addRanks.ranks는 스레드 B에서 올 수 있어,rasLocalHandle의 알 수 없는 타입 분기를 트리거하거나📎 src/ras/ras.cc:267-269더 나쁘게는 와일드 포인터 역참조를 발생시킵니다.

Q2:ncclProgressCounterMonitorDestroy는 잠금 해제 후에cudaStreamSynchronize 📎 src/ras/progress_monitor.cc:381-400를 실행합니다. 만약 동기화 중에 다른 스레드도 Destroy를 호출하여 같은 comm을 파괴하면 어떻게 되나요?destroyRefs는 어떻게 문제를 방지하나요?

참고 해석:destroyRefs는 워커가 너무 일찍 삭제되는 것을 방지하는 참조 카운트입니다. 첫 번째 스레드가 comm을 삭제한 후destroyRefs++ 📎 src/ras/progress_monitor.cc:371, 이때haveDestroyRef = true. 두 번째 스레드가 같은 comm을 삭제하려 할 때,ncclIntruQueueDelete는 nullptr를 반환하고(이미 삭제됨),haveDestroyRef는 false를 유지하며📎 src/ras/progress_monitor.cc:368, 동기화와 해제를 바로 건너뜁니다.

첫 번째 스레드가cudaStreamSynchronize를 완료한 후releaseGpuProgressCounterMonitorDestroyRef 📎 src/ras/progress_monitor.cc:402를 호출하여destroyRefs를 0으로 감소시키고, 등록 큐가 비어 있을 때만 실제로 스레드를 join하고📎 src/ras/progress_monitor.cc:225。

를 delete합니다destroyRefs만약delete g가 없다면, 첫 번째 스레드가 동기화 중에 두 번째 스레드의releaseGpuProgressCounterMonitorDestroyRef에 의해 워커가 해제되어 use-after-free가 발생할 수 있습니다. 참고로📎 src/ras/progress_monitor.cc:222-225는 전역 잠금 + 워커 잠금 내에서registrations를 감소시켜destroyRefs == 0가 비어 있는지 확인과

Q3:rasDiagnosticsSummarizePeerPayloads의 원자성을 보장합니다.checkHeader->payloadBytes != checkHeader->nRecords * checkHeader->recordStride 📎 src/ras/diagnostics.cc:451-454는 첫 번째 스캔 시recordStride = 0를 검증합니다. 만약 어떤 악의적이거나 손상된 peer가nRecords = 0를 보내고

라면, 이 검증이 통과할까요? 이후에 무슨 일이 발생하나요?:recordStride <= 0참고 해석📎 src/ras/diagnostics.cc:451는 첫 번째 조건에 의해 차단되어ncclInternalError, 를 반환합니다recordStride = 0. 따라서

는 통과하지 않습니다.recordStride > 0하지만 만약nRecords = 0이고payloadBytes = 0라면,rasDiagnosticsAccountCheckRecords이므로 검증이 통과합니다.nRecords == 0는📎 src/ras/diagnostics.cc:378에 대해 직접 성공을 반환하고combined, 를 업데이트하지 않습니다recordsBytes == 0. 이후 할당 시📎 src/ras/diagnostics.cc:473는payloadBytes > 0를 할당하지 않고, 복사 시📎 src/ras/diagnostics.cc:490가 거짓이면 건너뜁니다summarize. 최종적으로records = nullptr, recordsBytes = 0는

를 수신하며, 각 검사의 summarize 구현은 빈 입력을 처리해야 합니다.nRecords > INT_MAX / recordStride진짜 위험은📎 src/ras/diagnostics.cc:453의 검사에 있습니다nRecords * recordStride——이것은nRecords = 2^31, recordStride = 2정수 오버플로가 동등성 검증을 우회하는 것을 방지합니다. 만약 이 검사를 제거하면, 공격자는payloadBytes = 0를 구성할 수 있고, 곱이 0으로 오버플로되어rasDiagnosticsAccountCheckRecords와 같아지며, 검증 통과 후nRecords는 거대한

를 누적하여 이후 할당이나 복사가 범위를 벗어나게 됩니다.

전 장을 관통하는 설계 원칙은 제어 평면과 데이터 평면의 분리, 상태 버전 관리를 위한 해시 사용, 계층적 타임아웃 처리, 참조 카운팅을 통한 동시성 수명 주기 보호입니다. 이러한 원칙 덕분에 RAS는 통신 성능을 저하시키지 않으면서 장애 발견과 자가 치유를 구현할 수 있습니다. 통신 성능의 또 다른 핵심 지지대인 메모리 관리 역시 정교한 엔지니어링 트레이드오프가 필요합니다: NCCL 통신 전에 왜 메모리 등록이 필요한가? 등록 캐시는 성능에 어떻게 영향을 미치는가? 다음 장에서는 allocator, 등록 캐시, 사용자 버퍼 등록을 깊이 파고들어 이러한 질문에 대한 답을 밝히겠습니다.

CHAPTER 18

제18장: 제18장: 메모리 할당과 VRAM 관리: allocator, 등록 캐시, 사용자 등록 메모리 최적화

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제18 / 25장

제18장: 메모리 할당과 VRAM 관리: allocator, 등록 캐시, 사용자 등록 메모리 최적화

이전 장에서 RAS 서브시스템이 제어 평면에서 데이터 평면과 독립적으로 작동하며 해시로 버전을 관리하고 참조 카운팅으로 수명 주기를 보호하는 방법을 살펴보았습니다. 이번 장에서는 NCCL의 세 번째 기둥인 메모리 관리로 들어갑니다. 통신 성능의 상한은 종종 알고리즘 자체가 아니라 '데이터를 NIC가 직접 읽고 쓸 수 있는가'에 달려 있습니다. NCCL은 이를 위해 세 계층의 메커니즘을 구축했습니다: 하위 계층에서는ncclSpace과ncclShadowPool를 사용하여 주소 공간과 섀도 객체를 관리하고, 중간 계층에서는ncclMemManager를 사용하여 동적 메모리의 가져오기/내보내기와 일시 중단/재개를 추적하며, 상위 계층에서는ncclCommRegister를 사용하여 사용자 버퍼를 캐시에 등록하여 매 통신마다 메모리를 반복적으로 pin하는 것을 방지합니다. 이번 장에서는 이 세 가지 메커니즘을 계층별로 분해하여 'NCCL 통신 전에 왜 메모리 등록이 필요한가'와 '등록 캐시가 성능에 어떻게 영향을 미치는가'에 답하겠습니다.

18.1 ncclSpace: 주소 공간을 가득 참/비어 있음이 교차하는 세그먼트로 분할

직관적 모델

0부터 오른쪽으로 무한히 뻗어나가는 주차 공간 번호 라인을 상상해 보세요. 어떤 공간에는 차가 주차되어 있고(할당됨), 어떤 공간은 비어 있습니다(할당되지 않음).ncclSpace는 이 번호 라인의 '주차 공간 상태 기록부'입니다. 각 주차 공간을 기록하는 것이 아니라 '상태가 반전되는 경계 지점'만 기록합니다. 이것이 없다면 NCCL은 대칭 메모리의 가상 주소 범위를 관리할 때 각 바이트마다 플래그 비트를 유지해야 하며, 메모리 오버헤드가 주소 공간에 비례하여 완전히 용납할 수 없게 됩니다.

데이터 구조와 메모리 레이아웃

ncclSpace의 정의는 매우 간결합니다📎 src/include/allocator.h:20-24:

c
struct ncclSpace {
  int count;        // cuts[] 中有效元素个数
  int capacity;     // cuts[] 已分配容量
  int64_t* cuts;    // 升序排列的边界点数组
};

핵심 통찰은 소스 주석에 명확히 적혀 있습니다📎 src/allocator.cc:151-153:cuts[]는 음이 아닌 정수 축을 '가득 참'과 '비어 있음'이 교차하는 세그먼트로 나누며, 분할점은 오름차순으로 정렬되고 마지막 분할점 이후의 세그먼트는 반드시 비어 있습니다(할당되지 않은 프런티어). 이로부터i번째 세그먼트가 가득 찼는지 판단하는 공식을 도출할 수 있습니다:

code
isFull(i) = (i%2 != ncuts%2)

이 공식의 의미는 세그먼트의 가득 참/비어 있음 상태가 '세그먼트 인덱스의 홀짝성'과 '분할점 총 개수의 홀짝성'에 의해 함께 결정된다는 것입니다.ncuts가 짝수일 때 제0 세그먼트(cuts[0]이전)는 비어 있고,ncuts가 홀수일 때 제0 세그먼트는 가득 찼습니다. 이 불변량은 전체 모듈을 관통합니다.

단계별 워크스루: 한 번의 할당이 cuts[]를 어떻게 변경하는가

시나리오 대입: 초기ncclSpace가 비어 있음(count=0),ncclSpaceTryAlloc(a, limit=1000, size=100, align=1, &outOffset)。

호출 📎 src/allocator.cc:209。i = a->count % 21단계: 첫 번째 빈 세그먼트 찾기count=0, 이때i=0, 따라서

, 제0 세그먼트부터 스캔 시작. 📎 src/allocator.cc:212-213。i==02단계: 세그먼트 경계 계산lo=0;i==a->count일 때hi=limit=1000일 때[0, 1000)。

. 따라서 빈 세그먼트는 📎 src/allocator.cc:214-215。off = alignUp(0, 1) = 0,0 + 100 <= 10003단계: 정렬 및 용량 확인

성립, 할당 성공. 📎 src/allocator.cc:217-2234단계: 분할점 삽입i==0.insertSegment(a, 0, 0, 100)。insertSegment(헤드에 삽입)이므로 느린 경로index=0로 가서lo=0, hi=100 📎 src/allocator.cc:172-174위치에 두 개의 분할점📎 src/allocator.cc:185-203을 삽입한 후 '인접 중복 값 필터링'을 실행합니다📎 src/allocator.cc:182-184。

. 필터링 로직은 매우 정교합니다: 읽기/쓰기 이중 커서로 스캔하며 중복 값을 만나면 쓰기 커서를 되돌려 쌍으로 된 중복 값을 삭제합니다. 쌍으로 중복된다는 것은 빈 세그먼트가 두 개의 가득 찬 세그먼트 사이에 끼어 있다는 의미이므로 병합할 수 있기 때문입니다. 그러나 선행 0은 특례로 별도로 삭제할 수 있습니다cuts = [0, 100],count=2할당 후isFull(0) = (0%2 != 2%2) = false. 이때[0,0), 제0 세그먼트([0,100), 비어 있음)는 비어 있고, 제1 세그먼트(

)는 가득 찼습니다. 정확합니다. 📎 src/allocator.cc:239-2675단계: 해제ncclSpaceFree(a, 0, 100).cuts[count-1] <= offset호출. 먼저📎 src/allocator.cc:231-237가 성립하는지 확인100 <= 0, 즉i = 1 - count%2 = 1 - 0 = 1 📎 src/allocator.cc:246,cuts[1]=100 > 0가 거짓이므로 계속. 첫 번째 가득 찬 세그먼트 위치i=1。lo = cuts[0] = 0,hi = cuts[1] = 100, 따라서offset < lo || hi < offset+size 📎 src/allocator.cc:252,0<0.100<100거짓,lo==offset거짓 확인, 통과.offset+size==hi이고offset+size != hi이므로 두 빠른 경로 모두 충족되지 않음(첫 번째는lo != offset요구, 두 번째는insertSegment(a, 1, 0, 100) 📎 src/allocator.cc:264요구), 느린 경로cuts = [0, 0, 100, 100]로 이동. 삽입 후[],count=0, 필터링 후

로 변합니다. 초기 상태로 복귀.insertSegment이 '삽입 후 필터링' 설계는 할당/해제 시 복잡한 세그먼트 병합 로직을 피하고 복잡성을

한 곳에 집중시킵니다.

설계 고려사항과 프로덕션 함정왜 size_t 대신 int64_t를 사용하는가?ncclSpace는 '포인터'가 아닌 '오프셋'을 관리하며, 오프셋은 음수가 될 수 있고(실제 사용에서는 그렇지 않지만), CUDA의CUdeviceptr너비와 일치해야 하기 때문입니다. 부호 있는 타입을 사용하면 디버깅 시 범위 초과를 발견하기 쉽습니다.

성능 함정:ncclSpaceFree의 주석은 "This could be binary search, but since allocate is linear there's no point"라고 직언합니다📎 src/allocator.cc:245. 이는 할당과 해제가 모두 O(n) 스캔임을 의미합니다. 특정 통신 도메인이 많은 작은 세그먼트를 빈번히 할당/해제하면cuts[]가 팽창하여 매 작업이 느려집니다. 프로덕션 환경에서는 반복적으로 등록/해제하는 대신 이미 등록된 버퍼를 최대한 재사용해야 합니다.

정렬 오버플로 위험:alignUp(lo, align)는lo가INT64_MAX에 가깝고align가 클 때 오버플로할 수 있습니다. 소스 코드에는 명시적 검사가 없는데,limit가 호출자에 의해 합리적인 범위 내에 있음이 보장되기 때문입니다.

18.2 ncclShadowPool: 디바이스 객체와 호스트 섀도의 페어링 관리

직관적 모델

GPU kernel은 디바이스에서 실행되며, 호스트 메모리에 있는 C++ 객체(예:ncclDevComm의 메타데이터)에 직접 접근할 수 없다.ncclShadowPool는 마치 「번역가」와 같다: 각 디바이스 측 객체에 대해 디바이스 메모리 한 블록을 할당하고, 동시에 호스트 측에 대응하는 「섀도」 메모리를 할당하며, 「디바이스 주소 → 호스트 주소」 매핑 테이블을 유지한다. 호스트가 특정 디바이스 객체의 설정을 수정해야 할 때, 먼저 호스트 섀도를 수정한 후 디바이스로 복사한다. 이것이 없다면, kernel이 메타데이터를 읽을 때마다cudaMemcpy를 통해 호스트에서 가져와야 하므로 지연이 허용 불가능할 정도로 높아진다.

데이터 구조와 메모리 레이아웃

두 개의 핵심 구조체📎 src/allocator.cc:272-277:

c
struct ncclShadowPage {   // 最多 64 个对象的连续块
  struct ncclShadowPage* next;
  int objSize;
  uint64_t freeMask;      // 位图,1=空闲,0=已占用
  void* devObjs;
};
struct ncclShadowObject {
  struct ncclShadowObject* next;
  void* devObj;
  void* hostObj;
  struct ncclShadowPage* page;  // null 表示直接分配在 CUDA mempool
};

ncclShadowPool자체📎 src/include/allocator.h:42-47:

c
struct ncclShadowPool {
  int count, hbits;                       // 对象数、哈希位数
  struct ncclShadowObject** table;        // 哈希桶数组
  cudaMemPool_t memPool;                  // 可选的 CUDA 内存池
  struct ncclShadowPage* pages;           // 页链表
};

핵심 설계 포인트:freeMask는 uint64_t이므로, 페이지당 최대 64개의 객체를 담을 수 있다. 이는 임의로 선택된 것이 아니다 — 64비트는 정확히 하나의 캐시 라인 너비이며,popFirstOneBit는 단일__builtin_ctzll명령으로 첫 번째 빈 슬롯을 찾을 수 있어 루프가 필요 없다.

해시 테이블 증가 전략: 소스 주석 「Maintain 2:1 object:bucket ratio」📎 src/allocator.cc:368, 즉 객체 수가 버킷 수의 두 배를 초과하면 확장한다. 초기hbits=4(16개 버킷)📎 src/allocator.cc:363, 매번 두 배로 증가.

Step-by-Step Walkthrough: 한 번의 할당이 페이지 또는 직결을 선택하는 방법

시나리오 대입:ncclShadowPoolAlloc(pool, size=1024, &devObj, &hostObj, stream)。

첫 번째 단계: 지연 초기화 📎 src/allocator.cc:347-366. 만약hbits==0이면, 먼저 디바이스가 메모리 풀📎 src/allocator.cc:352을 지원하는지 조회하고, 지원하면cudaMemPool_t을 생성하고,maxSize를 파라미터SHADOW_MEMPOOL_MAX_SIZE(기본 1GB)로 설정한다.📎 src/allocator.cc:359그런 다음 16개 버킷의 해시 테이블을 할당한다.

두 번째 단계: 확장 필요 여부 확인 📎 src/allocator.cc:369-386. 만약count+1 > 2<<hbits이면, 두 배 크기의 버킷 배열을 할당하고, 이전 테이블을 순회하며 재삽입한다(hashInsert는ncclHashPointer로 버킷 인덱스📎 src/allocator.cc:333-337를 계산), 이전 테이블을 해제한다.

세 번째 단계: 페이지 경로 또는 직결 경로 결정 📎 src/allocator.cc:390. 판단 조건(64<<10)/size >= 3, 즉size <= 21845일 때 페이지 경로를 탄다.size=1024,65536/1024=64 >= 3의 경우, 페이지 경로를 탄다.

네 번째 단계: 페이지 내 객체 크기 계산 📎 src/allocator.cc:391-392。shift = max(0, log2Down(1024)+1-4) = max(0, 10+1-4) = 7。pageObjSize = ((1024 + 127) >> 7) << 7 = 1024. 즉 페이지 내 객체 크기를 2의 거듭제곱으로 128바이트의 배수로 정렬한다.

다섯 번째 단계: 페이지 검색 또는 생성 📎 src/allocator.cc:393-415.pool->pages연결 리스트를 순회하며objSize == pageObjSize인 페이지를 찾는다. 없으면 새 페이지를 생성한다:pageSize = min(65536, 64*1024) = 65536,freeMask = uint64_t(-1) >> (64 - 65536/1024) = uint64_t(-1) >> 0 = 全 1(64개 슬롯 모두 비어 있음)📎 src/allocator.cc:400.cudaMallocFromPoolAsync또는cudaMalloc로 디바이스 메모리📎 src/allocator.cc:403-404를 할당하고,cudaMemsetAsync를 0으로 초기화📎 src/allocator.cc:405。

여섯 번째 단계: 페이지에서 슬롯 가져오기 📎 src/allocator.cc:408-412。popFirstOneBit(&page->freeMask)첫 번째 빈 비트를 찾고,devObj = page->devObjs + slot * pageObjSize. 만약freeMask이 0이 되면(페이지 가득 참), 페이지를 빈 리스트에서 제거📎 src/allocator.cc:411。

일곱 번째 단계: 호스트 섀도 객체 할당 📎 src/allocator.cc:423-428。malloc(sizeof(ncclShadowObject) + alignof(max_align_t)-1 + size), 여기서alignof(max_align_t)-1바이트를 추가로 할당하여 정렬 패딩에 사용한다.hostObj = alignUp((char*)(obj+1), alignof(max_align_t)), 즉 객체 헤더 이후를 최대 정렬 경계로 정렬한다. 그런 다음memset(hostObj, 0, size)를 0으로 초기화.

여덟 번째 단계: 해시 테이블에 삽입하고 카운트 갱신 📎 src/allocator.cc:429-430。

동시성 제어와 하드웨어 상호작용

ncclShadowPool자체에는 잠금이 없다. 이는 단일 스레드 컨텍스트에서만 사용할 수 있거나, 호출자가 상호 배제를 보장해야 함을 의미한다. NCCL의 실제 사용을 보면, 주로 통신 도메인 초기화 단계에서 호출되며, 이때는 단일 스레드이다.

cudaMallocFromPoolAsync와cudaFreeAsync는 비동기 작업이며,stream파라미터에 의존하여 순서를 보장한다📎 src/allocator.cc:403,459。ncclShadowPoolDestruct는 모든 리소스를 해제한 후 호출되어cudaStreamSynchronize(stream) 📎 src/allocator.cc:333-337, 모든 비동기 해제가 완료된 후 메모리 풀을 파괴하도록 보장한다.

프로덕션 함정 회피 가이드

함정 1: 페이지 내 객체 크기 정렬로 인한 메모리 낭비。pageObjSize를 2의 거듭제곱으로 정렬하면, 만약size=1000,shift = log2Down(1000)+1-4 = 9+1-4 = 6,pageObjSize = ((1000+63)>>6)<<6 = 1024. 각 객체당 24바이트 낭비, 페이지 내 64개 객체당 1536바이트 낭비. 대량의 작은 객체의 경우 이 오버헤드는 무시할 수 없다.

함정 2:ncclShadowPoolFree가 객체를 찾지 못할 때의 동작 📎 src/allocator.cc:442-445. 이는ncclInternalError를 반환하고 경고를 출력하지만,어떤 리소스도 해제하지 않는다. 호출자가 반환값을 무시하면 메모리 누수가 발생한다. 프로덕션 코드는 반드시 반환값을 확인해야 한다.

함정 3:ncclShadowPoolDestruct에서freeMask==0인 페이지가 회수됨 📎 src/allocator.cc:301-306. 여기서freeMask를 1로 설정한다(전부 1이 아님). 이는 첫 번째 슬롯만 비어 있음으로 표시함을 의미한다. 이는 「가득 찬 페이지」를 다시pool->pages연결 리스트에 넣기 위한 것이지만, 페이지 내 다른 슬롯은 여전히 점유되어 있다 — 실제로 이 객체들은 곧 해제될 것이므로 이 작업은 안전하다. 그러나 소멸 과정에서 동시 접근이 있으면 일관성 없는 상태를 읽게 된다.

18.3 ncclMemManager: 동적 메모리의 참조 카운팅과 일시 중단/복구

직관적 모델

훈련 작업은 며칠 동안 실행될 수 있으며, 그 동안 GPU가 다른 작업에 의해 선점되거나 체크포인트를 수행해야 할 수 있다.ncclMemManager는 마치 「메모리 관리인」과 같다: 모든 동적 할당 메모리(scratch/offload)를 기록하고, 필요할 때 GPU 메모리를 「일시 중단」(물리 페이지 unmap, 가상 주소 유지)하고, 데이터를 CPU로 백업하며, 복구 시 물리 페이지를 재할당하고, 재매핑하고, 데이터를 복원한다. 이것이 없다면, 작업이 선점된 후 처음부터 다시 시작해야 하므로 수 시간의 훈련 진행을 낭비한다.

데이터 구조와 메모리 레이아웃

ncclMemManager의 핵심 필드(초기화 코드에서 추론)📎 src/mem_manager.cc:32-60:

필드타입의미
entriesncclDynMemEntry*동적 메모리 엔트리 연결 리스트 헤드
numEntriesint연결 리스트 길이
releasedint0=활성, 1=일시 중단됨
refCountint참조 카운트(여러 comm이 공유 가능)
totalPersistsize_t영구 메모리 총량(원자적)
totalScratchsize_tscratch 메모리 총량(원자적)
totalOffloadsize_toffload 메모리 총량(원자적)
cpuBackupUsagesize_tCPU 백업 메모리 총량
lockstd::mutexentries 연결 리스트 보호
initializedint원자적 플래그, 파괴된 mutex 접근 방지

메모리 레이아웃의 핵심 설계:lock는std::mutex이지만,ncclMemManager는ncclCalloc로 할당되므로(C 스타일), placement new로 명시적으로📎 src/mem_manager.cc:39를 생성해야 하고, 소멸 시 명시적으로~mutex() 📎 src/mem_manager.cc:120를 호출해야 한다. 이는 C/C++ 혼합 프로그래밍의 전형적인 함정이다.

원자적 변수와 잠금의 역할 분담: 통계 필드(totalPersist등)는 원자적 연산으로 갱신하며 잠금이 필요 없다;entries연결 리스트는lock로 보호한다. 이렇게 하면 통계 조회(ncclCommMemStats)는 잠금 없이📎 src/mem_manager.cc:1117-1130를 읽을 수 있고, 연결 리스트 작업은 반드시 잠금을 보유해야 한다.

Step-by-Step Walkthrough: 일시 중단 및 재개 전체 흐름

일시 중단 흐름 ncclCommMemSuspend 📎 src/mem_manager.cc:418-540:

1단계: 사전 검사 📎 src/mem_manager.cc:419-430. 메모리 관리자가 비활성화되었는지, comm이 비어 있는지, 이미 일시 중단되었는지 확인한다.

2단계: 디바이스 동기화 및 barrier 📎 src/mem_manager.cc:440-441。cudaDeviceSynchronize()모든 GPU 작업이 완료되었는지 확인한 후bootstrapBarrier모든 rank가 동기화되었는지 확인한다. barrier tag는0xBEEF。

3단계: 첫 번째 스캔 — 모든 peer가 가져온 버퍼를 unmap 📎 src/mem_manager.cc:444-465. 각isImportedFromPeer && state==Active항목에 대해cuMemUnmap을 호출하여 매핑을 해제하고📎 src/mem_manager.cc:451handle을 해제하며📎 src/mem_manager.cc:456상태를Released。

로 변경한다. 4단계: 두 번째 스캔 — 로컬 메모리 offload 📎 src/mem_manager.cc:468-526. peer가 가져온 항목과 이미 해제된 항목은 건너뛴다.ncclMemOffload유형에 대해서는 먼저 CPU 백업📎 src/mem_manager.cc:484을 할당한 후cudaMemcpyGPU에서 CPU로 복사한다📎 src/mem_manager.cc:492.ncclMemScratch유형에 대해서는 통계만 누적한다. 그런 다음 shareable FD를 닫고📎 src/mem_manager.cc:508-513,cuMemUnmap 📎 src/mem_manager.cc:516,cuMemRelease 📎 src/mem_manager.cc:519, 상태를Released。

로 변경한다. 5단계: 일시 중단 표시 📎 src/mem_manager.cc:528。

재개 흐름 ncclCommMemResume 📎 src/mem_manager.cc:550-942:

1단계: 로컬 메모리 복원 📎 src/mem_manager.cc:577-668. 각!isImportedFromPeer && state==Released항목에 대해 다시cuMemCreate 📎 src/mem_manager.cc:599,ncclCuMemMapAndSetAccess을 동일한 가상 주소📎 src/mem_manager.cc:602에 매핑하고, peer 접근 권한📎 src/mem_manager.cc:610-626을 복원하며, offload 유형에 대해서는 CPU 백업에서 데이터를 복원하고📎 src/mem_manager.cc:632-643, FABRIC handle을 다시 내보낸다📎 src/mem_manager.cc:646-658。

. 2단계: barrier 동기화 📎 src/mem_manager.cc:671-679. tag는 여전히0xBEEF。

이다. 3단계: 새 handle 정보 교환 📎 src/mem_manager.cc:688-816. 각 rank가 브로드캐스트해야 할 로컬 버퍼 수를 집계하고📎 src/mem_manager.cc:689-696,bootstrapAllGather을 사용하여 카운트를 교환하고📎 src/mem_manager.cc:710, 오프셋을 계산한 후📎 src/mem_manager.cc:724-728먼저bootstrapSend한 다음bootstrapRecv을 수행한다 (주석에 명확히 "send first, then receive to avoid deadlock"이라고 되어 있음).📎 src/mem_manager.cc:783)。

4단계: peer 버퍼 재가져오기 📎 src/mem_manager.cc:822-911. 각isImportedFromPeer && state==Released항목에 대해 교환 결과에서 일치하는 handle 정보를 찾는다📎 src/mem_manager.cc:829-835. POSIX FD 유형은 hostHash가 동일한지 확인해야 하며📎 src/mem_manager.cc:853-859, 그런 다음 proxy를 통해 FD를 가져와📎 src/mem_manager.cc:866,cuMemImportFromShareableHandle을 가져온다📎 src/mem_manager.cc:873. FABRIC 유형은 직접 가져온다📎 src/mem_manager.cc:878. 그런 다음ncclCuMemMapAndSetAccess을 다시 매핑한다📎 src/mem_manager.cc:893。

. 5단계: 최종 barrier 📎 src/mem_manager.cc:916-928. tag는0xCAFE이며, 앞의0xBEEF과 구분된다.

동시성 제어 및 하드웨어 상호작용

참조 카운팅으로 수명 주기 보호:ncclMemManagerDestroy을 먼저 감소시키고refCount 📎 src/mem_manager.cc:76, 여전히 0보다 크면 현재 comm의 포인터만 제거하고📎 src/mem_manager.cc:81, 리소스는 해제하지 않는다. 이는 여러 comm이 동일한 메모리 관리자를 공유할 수 있게 한다 (예: split_share 시나리오).

원자적 initialized 플래그: 모든 작업 전에COMPILER_ATOMIC_LOAD(&manager->initialized, memory_order_acquire) 📎 src/mem_manager.cc:136,242,338,358을 확인하여 이미 파괴된 mutex에 접근하는 것을 방지한다. 파괴 시memory_order_release을 사용하여 0을 저장하고📎 src/mem_manager.cc:87, 이전 쓰기 작업이 다른 스레드에 가시적임을 보장한다.

CUDA VMM API 사용:cuMemCreate/cuMemMap/cuMemUnmap/cuMemRelease은 CUDA 가상 메모리 관리 API로, 물리 메모리와 가상 주소를 분리할 수 있게 한다. 이것이 일시 중단/재개의 기초이다 — 일시 중단 시 물리 페이지를 unmap하지만 가상 주소는 유지하고, 재개 시 동일한 가상 주소에 다시 매핑하므로 이미 설정된 모든 포인터 관계를 수정할 필요가 없다.

프로덕션 함정 회피 가이드

함정 1: split_share 통신 도메인은 일시 중단을 지원하지 않음 📎 src/mem_manager.cc:1014-1018. 만약refCount > 1이면, 바로ncclInvalidUsage을 반환한다. 여러 comm이 메모리 관리자를 공유할 때 하나의 comm을 일시 중단하면 다른 comm의 메모리에 영향을 미치기 때문이다.

함정 2: POSIX FD 크로스 노드 무효화 📎 src/mem_manager.cc:853-859. POSIX 파일 디스크립터는 동일 노드 내에서만 유효하므로, 크로스 노드 재개 시 반드시 건너뛰어야 한다. 소스 코드는hostHash을 비교하여 동일 노드인지 판단한다.

함정 3: offload 데이터 복원 실패 시 백업 유지 📎 src/mem_manager.cc:635. 만약cudaMemcpy이 CPU에서 GPU로 복원에 실패하면, 소스 코드는 경고를 출력하고cpuBackup을 유지하며 해제하지 않는다. 이는 호출자에게 재시도 기회를 주기 위한 것이지만, 재시도하지 않으면 CPU 메모리가 누수된다.

함정 4:ncclMemUntrackDynamic에서의 use-after-free 위험. 소스 코드는 잠금 상태에서 항목을 찾고, 필요한 정보를 저장하고, 항목📎 src/mem_manager.cc:302을 해제한 후, 잠금 외부에서 통계📎 src/mem_manager.cc:311-327를 업데이트한다. 이 순서는 올바르지만, 만약info포인터가 호출자의 스택 메모리를 가리키고 호출자가 잠금 외부에서 읽는다면,info의 수명 주기가 전체 함수를 커버하는지 확인해야 한다.

mermaid
flowchart TD
    start["ncclCommMemSuspend(comm)"] --> check{"manager->released?"}
    check -->|"是"| err1["返回 ncclInvalidUsage"]
    check -->|"否"| sync["cudaDeviceSynchronize()"]
    sync --> barrier1["bootstrapBarrier(tag=0xBEEF)"]
    barrier1 --> pass1["第一遍: 遍历 entries"]
    pass1 --> cond1{"isImportedFromPeer && Active?"}
    cond1 -->|"是"| unmap1["cuMemUnmap + cuMemRelease"]
    cond1 -->|"否"| skip1["跳过"]
    unmap1 --> pass2["第二遍: 遍历 entries"]
    skip1 --> pass2
    pass2 --> cond2{"memType == Offload?"}
    cond2 -->|"是"| backup["ncclCudaHostCalloc + cudaMemcpy D2H"]
    cond2 -->|"否"| scratch["累加 releasedScratch"]
    backup --> unmap2["cuMemUnmap + cuMemRelease"]
    scratch --> unmap2
    unmap2 --> mark["manager->released = 1"]
    mark --> done["返回 ncclSuccess"]
    err1 --> done

위 그림은 일시 중단 흐름의 제어 흐름을 보여준다. 두 가지 핵심 분기에 주목하라: 첫 번째 스캔은 peer가 가져온 버퍼만 처리하고, 두 번째 스캔은 로컬 버퍼만 처리하며, 순서는 바꿀 수 없다 — 반드시 peer 메모리에 대한 참조를 먼저 해제한 후 로컬 메모리를 해제해야 한다.

18.4 등록 캐시: ncclRegister가 중복 pin을 방지하는 방법

직관적 모델

네트워크 카드가 GPU 메모리를 직접 읽고 쓰려면 (GPUDirect RDMA), 먼저 이 메모리를 "등록"해야 한다 — 네트워크 카드에게 "이 주소에 직접 접근할 수 있다"고 알려주는 것이다. 등록 과정은 페이지 pin, IOMMU 매핑 설정을 포함하며 오버헤드가 크다 (밀리초 수준). 매번 AllReduce마다 재등록하면, 작은 메시지 통신의 지연이 등록 오버헤드에 완전히 묻히게 된다.ncclRegister은 "등록 캐시"이다: 이미 등록된 주소 범위를 정렬된 배열에 기록해 두고, 다음에 동일하거나 포함되는 버퍼를 만나면 바로 재사용하고 재등록하지 않는다.

데이터 구조와 메모리 레이아웃

ncclRegCache의 핵심은 정렬된 배열slots이며, 각 요소는ncclReg*。ncclReg의 핵심 필드이다 (사용에서 추론):

필드유형의미
begAddruintptr_t페이지 정렬된 시작 주소
endAddruintptr_t페이지 정렬된 끝 주소
localRefsint로컬 참조 카운트
graphRefsint그래프 참조 카운트
stateint등록 상태 비트 (NET/NVLS/COLLNET/IPC)
netHandleHeadncclRegNetHandles*네트워크 handle 연결 리스트
ipcInfosncclIpcInfo**IPC 정보 배열

페이지 정렬:begAddr = (uintptr_t)data & -pageSize 📎 src/register/register.cc:31,endAddr = ((uintptr_t)data + size + pageSize - 1) & -pageSize 📎 src/register/register.cc:32。-pageSize은pageSize의 2의 보수이며, 이는 「pageSize의 배수로 내림 정렬」과 동일합니다. 이렇게 하는 이유는 등록의 최소 단위가 페이지이기 때문입니다. 1바이트만 등록하더라도 전체 페이지를 등록해야 합니다.

Step-by-Step Walkthrough: 한 번의 등록이 어떻게 캐시에 적중하는가

시나리오 대입:ncclCommRegister(comm, buff=0x7f0000001000, size=4096, &handle)。

1단계: 파라미터 검사 및 페이지 정렬 📎 src/register/register.cc:18-24。CommCheckcomm 유효성을 검증합니다. 가정:pageSize=4096,begAddr = 0x7f0000001000 & -4096 = 0x7f0000001000,endAddr = (0x7f0000001000 + 4096 + 4095) & -4096 = 0x7f0000002000。

2단계: 시스템 메모리 검사 📎 src/register/register.cc:36-64. 만약ncclCuMemEnable()이면 주소 범위와 메모리 유형을 조회합니다. 만약memType == CU_MEMORYTYPE_HOST이면 CPU 메모리이므로 등록을 건너뜁니다📎 src/register/register.cc:58-61. 그렇지 않으면 Sysmem 세그먼트가 있는지 확인합니다📎 src/register/register.cc:50-55。

3단계: 캐시를 순회하며 삽입 위치 찾기 📎 src/register/register.cc:66-89. 루프slot은 0부터 시작:

  • 만약slot == population(끝에 도달) 또는begAddr < slots[slot]->begAddr(현재 주소가 캐시 항목보다 앞)이면 새 항목을 생성해야 함📎 src/register/register.cc:67。
  • 만약slots[slot]->begAddr <= begAddr && slots[slot]->endAddr >= endAddr이면 현재 버퍼가 기존 항목에 완전히 포함되므로 참조 카운트를 직접 증가📎 src/register/register.cc:83-87。

4단계: 새 항목 생성 📎 src/register/register.cc:68-82. 캐시가 가득 차면 확장(초기 32, 이후 두 배씩)📎 src/register/register.cc:70.memmove을 사용하여slot위치에 공간을 확보📎 src/register/register.cc:73,ncclCalloc새 항목 할당📎 src/register/register.cc:74,begAddr/endAddr설정,isGraph에 따라graphRefs또는localRefs를 1로 설정📎 src/register/register.cc:78-79,population++, handle 반환.

5단계: 등록 해제 📎 src/register/register.cc:172-195。commDeregister먼저 handle에 해당하는 slot을 찾고📎 src/register/register.cc:180, 참조 카운트를 감소📎 src/register/register.cc:185-186. 아직 참조가 남아 있으면 바로 반환📎 src/register/register.cc:187. 그렇지 않으면regCleanup을 호출하여 모든 하위 등록을 정리📎 src/register/register.cc:188, 항목을 해제하고,memmove로 빈 공간을 채움📎 src/register/register.cc:190,population--。

설계 고찰과 프로덕션 함정

왜 해시 테이블 대신 정렬된 배열을 사용하는가?등록 조회는 「범위 포함」 조회이지 정확한 매칭이 아니기 때문입니다. 정렬된 배열은 이진 탐색을 지원하며(소스 코드는 선형 스캔을 사용하지만), 메모리 지역성이 좋습니다. 해시 테이블은 「이 주소가 더 큰 범위에 포함되는가」와 같은 조회를 효율적으로 처리할 수 없습니다.

regCleanup의 상태 비트 설계 📎 src/register/register.cc:95-134。state은 비트 마스크로, 각 비트가 하나의 등록 유형(NET/NVLS/COLLNET/IPC)에 대응합니다. 정리 시 비트별로 검사하여 완료된 등록만 정리합니다. 이 설계는 부분 등록 성공, 부분 실패 상황을 허용합니다 — 예를 들어 네트워크 등록은 성공했지만 IPC 등록이 실패한 경우, 정리 시 네트워크 부분만 정리합니다.

프로덕션 함정: 등록 캐시가 메모리 해제를 인지하지 못함. 사용자가 버퍼를 등록한 후 등록 해제 없이cudaFree해버리면, 캐시에 해당 항목이 여전히 남아 있습니다. 다음 할당이 같은 주소를 재사용할 수 있어 캐시는 적중하지만 실제 메모리는 이미 무효화됩니다. NCCL의 규약은 등록과 등록 해제가 반드시 짝을 이루어야 하며, 사용자가 등록 기간 동안 메모리가 해제되지 않도록 보장해야 합니다.

ncclCommRegister의 건너뛰기 조건 📎 src/register/register.cc:150-159. 만약LocalRegister=0또는P2pUsesMemcpy=1이면 바로NULLhandle을 반환합니다. 이는 특정 구성(예: P2P가 RDMA 대신 memcpy를 사용)에서 등록이 완전히 건너뛰어진다는 의미입니다. 호출자는 handle이 NULL인지 반드시 확인해야 합니다.

18.5 집합 통신 등록: coll_reg가 다양한 알고리즘에 대해 등록 전략을 선택하는 방법

직관적 모델

다양한 집합 통신 알고리즘은 서로 다른 전송 경로를 사용합니다: NVLS는 NVLink SHARP, Ring은 P2P 또는 네트워크, Tree는 트리 토폴로지를 사용합니다. 각 경로는 서로 다른 등록 방식이 필요합니다: NVLS는 NVLS 하드웨어에 등록, 네트워크는 NIC에 등록, IPC는 상대 GPU에 등록해야 합니다.coll_reg.cc은 「등록 전략 라우터」입니다: 알고리즘, 프로토콜, 버퍼 유형에 따라 어떤 등록 함수를 호출할지 결정합니다. 이것이 없다면 각 알고리즘이 자체적으로 등록 로직을 구현해야 하여 코드 중복과 오류 가능성이 높아집니다.

Step-by-Step Walkthrough: Ring 알고리즘의 등록 결정

시나리오 대입:ncclRegisterCollBuffers(comm, info, outRegBufSend, outRegBufRecv, cleanupQueue, regNeedConnect), 여기서info->algorithm == NCCL_ALGO_RING,info->protocol == NCCL_PROTO_SIMPLE。

1단계: 사전 검사 📎 src/register/coll_reg.cc:155-157.regBufType = NCCL_REGULAR_BUFFER,regNeedConnect = true설정. 만약LocalRegister=0이고 영구 그래프 등록이 아니면 바로 종료.

2단계: Ring 분기 진입 📎 src/register/coll_reg.cc:338.recvRegRecord/sendRegRecord을 NULL로 초기화,sendNetConns/sendNetHandles/recvNetConns/recvNetHandles/srecvNetHandles배열 할당📎 src/register/coll_reg.cc:356-360。

3단계: 기존 등록 기록 찾기 📎 src/register/coll_reg.cc:351-355。ncclRegFind캐시에서 recv/send 버퍼를 찾습니다. recv를 찾지 못하고 영구 그래프 등록이 아니면 종료📎 src/register/coll_reg.cc:352. 크로스 노드이고 send를 찾지 못하고 영구 그래프 등록이 아니면 종료📎 src/register/coll_reg.cc:354。

4단계: 모든 channel을 순회하며 peer 수집 📎 src/register/coll_reg.cc:362-393. 각 channel에 대해ring.prev과ring.next을 검사. 연결 플래그에NCCL_DIRECT_NIC이 포함되면recvNetConns/sendNetConns 📎 src/register/coll_reg.cc:370-379에 기록.NCCL_P2P_READ | NCCL_P2P_WRITE이 포함되면 peer를peerRanks배열에 추가📎 src/register/coll_reg.cc:382-391。

5단계: IPC 등록 📎 src/register/coll_reg.cc:394-407. 만약nPeers > 0 && comm->isAllDirectP2p이면 먼저 그래프 등록📎 src/register/coll_reg.cc:395-399을 시도하고, 실패하면 로컬 등록📎 src/register/coll_reg.cc:400-403을 시도. 성공하면regBufType = NCCL_IPC_REG_BUFFER 📎 src/register/coll_reg.cc:406。

설정 📎 src/register/coll_reg.cc:409-4576단계: 네트워크 등록!comm->useNetPXN && comm->useGdr && netDeviceType != UNPACK.📎 src/register/coll_reg.cc:415-418이고 AllReduce의 PreMulSum/SumPostDiv가 아닌지 확인📎 src/register/coll_reg.cc:419-430. 먼저 그래프 등록📎 src/register/coll_reg.cc:431-442을 시도하고, 실패하면 로컬 등록regBufType |= NCCL_NET_REG_BUFFER. 성공하면📎 src/register/coll_reg.cc:445-452。

설정, handle 배열 저장 📎 src/register/coll_reg.cc:551-5547단계: 채널 수 조정

. IPC 등록만 있고 단일 노드이며 채널 수가 17-24 사이이면 16으로 줄입니다. 이는 IPC 등록 후의 대역폭 특성에 맞추기 위함입니다.

설계 고찰과 프로덕션 함정왜 NVLS와 Ring의 등록 순서가 반대인가?📎 src/register/coll_reg.cc:86-94NVLS 분기는 그래프 등록을 먼저 시도하고 로컬 등록을 나중에 하며📎 src/register/coll_reg.cc:395-403, Ring 분기는 로컬을 먼저 하고 그래프를 나중에 합니다

isMloPartBufRdmaCapable. 이는 NVLS의 그래프 등록이 성공할 가능성이 더 높고(NVLS 하드웨어가 영구 버퍼에 최적화되어 있음), Ring의 로컬 등록이 더 가볍기 때문입니다. 📎 src/register/coll_reg.cc:14-37. 주석은 "등록 결정은 전역적이어야 하며, communicator 전체에 걸친 보장을 사용해야 한다"를 강조한다.📎 src/register/coll_reg.cc:20. 이는 특정 rank의 버퍼가 RDMA를 지원하더라도, 통신 도메인 내에 지원하지 않는 rank가 하나라도 있으면 전체 통신 도메인이 등록되지 않는다는 것을 의미한다. 이는 일부 rank만 등록되고 일부는 등록되지 않아 발생하는 불일치를 방지하기 위함이다.

프로덕션 함정: 등록 실패 시의 조용한 성능 저하。ncclRegisterCollBuffers등록 실패 시 오류를 발생시키지 않고, 단지 설정하지 않을 뿐이다.regBufType의 해당 비트를. 이는 통신이 여전히 작동하지만 성능이 저하된다는 것을 의미한다. 프로덕션 환경에서 성능이 기대에 미치지 못하는 경우,NCCL_REG로그를 확인하여 등록이 성공했는지 확인해야 한다.

mermaid
flowchart LR
    subgraph input["输入"]
        task["ncclTaskColl<br/>algorithm=RING<br/>protocol=SIMPLE"]
    end
    subgraph ipc["IPC 注册路径"]
        find["ncclRegFind<br/>查找缓存"]
        collect["遍历 channel<br/>收集 peerRanks"]
        ipcReg["ncclIpcLocalRegisterBuffer<br/>或 GraphRegister"]
    end
    subgraph net["网络注册路径"]
        checkGdr{"useGdr &&<br/>!useNetPXN?"}
        netReg["ncclNetLocalRegisterBuffer<br/>或 GraphRegister"]
    end
    subgraph output["输出"]
        regType["info->regBufType<br/>NCCL_IPC_REG_BUFFER<br/>NCCL_NET_REG_BUFFER"]
        handles["info->sendNetHandles<br/>info->recvNetHandles"]
    end
    task --> find
    find --> collect
    collect --> ipcReg
    ipcReg --> regType
    find --> checkGdr
    checkGdr -->|"是"| netReg
    checkGdr -->|"否"| regType
    netReg --> regType
    netReg --> handles

위 그림은 Ring 알고리즘下에서 두 개의 병렬 등록 경로를 보여준다: IPC 경로는 동일 노드 P2P 연결을 처리하고, 네트워크 경로는 노드 간 RDMA 연결을 처리한다. 두 경로는 독립적으로 실행되며, 최종적으로 모두info->regBufType。

18.6 프로덕션 함정 회피와 장애 복구 체인

함정 1: 등록 캐시와 메모리 풀의 상호작용

를 사용하여ncclMemAlloc메모리를 할당할 때, 내부적으로 CUDA VMM API📎 src/allocator.cc:38-94를 사용한다. 이러한 할당 방식으로 생성된 물리 메모리는gpuDirectRDMACapable플래그📎 src/allocator.cc:54를 가지며, 이는 본질적으로 RDMA를 지원한다는 것을 의미한다. 그러나ncclMemFree해제 시, 메모리 관리자가 이미 파괴된 경우cudaFree폴백 경로📎 src/allocator.cc:130-132를 사용한다. 이로 인해 VMM으로 할당된 메모리가 잘못하여cudaFree로 해제될 수 있다. 프로덕션 환경에서는 반드시ncclMemAlloc/ncclMemFree를 쌍으로 사용하고, 메모리 관리자 파괴 후에 해제하지 않도록 해야 한다.

함정 2: 일시 중단 기간 중의 통신 요청

ncclCommMemSuspend실행 중에 새로운 통신 요청이 도착하면 어떻게 될까? 소스 코드는 일시 중단 전에cudaDeviceSynchronize() 📎 src/mem_manager.cc:440를 호출하여 이미 큐에 들어간 모든 GPU 작업이 완료되도록 보장한다. 그러나 host 측 통신 요청이 큐에 들어가고 있는 경우, 명시적인 보호가 없다. 프로덕션 환경에서는 일시 중단 전에 모든 통신 스레드를 중지하거나, group 시맨틱을 사용하여 일시 중단 작업이 다른 작업과 직렬화되도록 해야 한다.

함정 3: FABRIC handle의 호환성

ncclMemAlloc는 CUDA 12.3+에서 FABRIC handle📎 src/allocator.cc:60-71을 사용하려고 시도한다. 만약cuMemCreate이CUDA_ERROR_NOT_PERMITTED또는CUDA_ERROR_NOT_SUPPORTED를 반환하면, POSIX FD📎 src/allocator.cc:63-65로 폴백한다. 그러나 복구 시 handle 타입이 FABRIC인데 내보내기가 실패하면, 직접 오류를 발생시키고 unmap📎 src/mem_manager.cc:649-655한다. 이는 혼합 환경(일부 GPU는 FABRIC을 지원하고 일부는 지원하지 않음)에서 일시 중단/복구가 실패할 수 있음을 의미한다.

함정 4: 참조 카운트 누수

ncclRegister캐시에 적중할 때마다 참조 카운트📎 src/register/register.cc:84-85가 증가한다. 호출자가 N번 등록했지만 M번만 등록 해제한 경우(M < N), 참조 카운트는 결코 0이 되지 않으며,regCleanup는 결코 호출되지 않고, 내부 등록 리소스가 누수된다. 프로덕션 코드는 반드시ncclCommRegister/ncclCommDeregister。

mermaid
sequenceDiagram
    participant App as 应用层
    participant Reg as ncclRegister
    participant Cache as ncclRegCache
    participant Net as ncclNetLocalRegisterBuffer
    participant GPU as CUDA Driver

    App->>Reg: ncclCommRegister(comm, buff, size, &handle)
    Reg->>Reg: begAddr = data & -pageSize
    Reg->>Cache: 遍历 slots 查找包含范围
    alt 缓存命中
        Cache-->>Reg: 返回已有 ncclReg*
        Reg->>Reg: localRefs++
    else 缓存未命中
        Reg->>Cache: memmove 腾出插入位置
        Reg->>Cache: ncclCalloc 新条目
        Reg->>Reg: localRefs = 1
    end
    Reg-->>App: 返回 handle
    App->>Net: 首次注册时调用
    Net->>GPU: cuMemExportToShareableHandle
    GPU-->>Net: 返回 handle
    Net-->>App: 注册完成

복사

이 장의 생각과 자가 점검ncclSpaceFreeQ1: 만약if (a->count == 0 || a->cuts[a->count - 1] <= offset)에서📎 src/allocator.cc:231-237검사

를 제거하면, 어떤 시나리오에서 범위를 벗어난 접근이 발생하는가?참고 해석a->count == 0: 이 검사는 두 가지 역할을 한다. 첫째,cuts[-1]는 빈 배열 접근a->cuts[a->count-1] <= offset을 방지한다. 둘째,offset는count == 0가 할당된 범위를 초과하는 것을 방지한다. 만약 제거하면,a->cuts[a->count - 1]일 때,cuts[-1]는count > 0를 읽게 되며, 이는 정의되지 않은 동작으로 힙 메타데이터를 읽거나 세그멘테이션 오류를 발생시킬 수 있다. 더 은밀한 것은,offset이더라도while (a->cuts[i] <= offset) i += 2가 마지막 분할점보다 크면, 이후의📎 src/allocator.cc:247루프i에서cuts[]가 계속 증가하여 범위를 벗어날 때까지 진행되는데, 이는offset에ncclSpace보다 큰 요소가 존재하지 않기 때문이다. 프로덕션에서의 발생 시나리오는: 호출자가 한 번도 할당된 적 없는 오프셋을 전달한 경우(예: 버퍼가 외부에서 해제된 후 다시 free를 호출), 또는offset가 동시에 수정되어 상태가 불일치하게 된 경우이다. 수정 방법은 이 검사를 유지하고, 오류 반환 시count와

Q2: ncclMemManagerDestroy를 출력하여 문제 해결을 용이하게 하는 것이다.refCount에서, 만약📎 src/mem_manager.cc:78-83가 감소한 후에도 여전히 0보다 크면, 현재 comm의 포인터만 지우고 리소스는 해제하지 않는다ncclMemTrack. 만약 이때 다른 comm이

를 호출하고 있다면, 어떤 일이 발생하는가?:ncclMemTrack참고 해석manager->initialized 📎 src/mem_manager.cc:136는 먼저refCount > 0를 검사한다.initialized = 0일 때manager->lock를 설정하지 않으므로, 검사를 통과한다. 그런 다음entries를 획득하고📎 src/mem_manager.cc:188-192연결 리스트refCount > 0를 수정한다. 이는 안전한데,ncclMemManagerDestroy는 적어도 하나의 comm이 참조를 보유하고 있음을 의미하며, 메모리 관리자가 파괴되지 않기 때문이다. 진정한 위험은: 마지막 comm이refCount를 호출할 때,initialized = 0 📎 src/mem_manager.cc:87가 0으로 감소하면,ncclMemTrack를 설정하고 모든 리소스를 해제한다. 만약 이때 다른 스레드가initialized에서 이미manager->lock검사를 통과했지만 아직 락을 획득하지 못했다면, 해제된memory_order_acquire/release에 접근하여 use-after-free가 발생한다. 소스 코드는

를 쌍으로 맞춰 이 문제를 완화하지만, 엄밀히 말하면 여전히 경쟁 창이 존재한다. 프로덕션 환경에서는 메모리 관리자를 파괴하기 전에 모든 통신 스레드가 중지되었는지 확인해야 한다.ncclCommMemResumeQ3:📎 src/mem_manager.cc:853-859에서 POSIX FD 타입의 peer 버퍼가 노드 간에서 건너뛰어진다restoredPeerCount. 만약 모든 peer 버퍼가 건너뛰어지면,manager->released는 0이지만,📎 src/mem_manager.cc:913는 여전히 0으로 설정된다

. 이는 어떤 결과를 초래하는가?:manager->released = 0참고 해석state는 메모리 관리자가 복구가 완료되었다고 간주함을 나타낸다. 그러나 건너뛰어진 peer 버퍼가 있으면, 그들의ncclDynMemStateReleased,handle는 여전히ncclCommMemStats이고 여전히 0이다. 이후 통신이 이 버퍼들에 접근하면 CUDA 오류(매핑되지 않은 가상 주소 접근)가 발생한다. 더 심각한 것은,ncclStatGpuMemSuspended가📎 src/mem_manager.cc:1130를 조회하면 0(활성)을 반환entries에서. 올바른 방법은 일시 중단 시 크로스 노드 POSIX FD 항목을 복구 불가능으로 표시하거나, 복구 시 조용히 건너뛰는 대신 오류를 반환하는 것입니다. 프로덕션 환경에서 POSIX FD를 사용하고 크로스 노드인 경우, FABRIC 핸들로 전환하거나 일시 중단/복구가 단일 노드 내에서만 수행되도록 보장해야 합니다.

메모리 관리는 NCCL 성능의 보이지 않는 기둥입니다:ncclSpace극도로 간결한 분할점 배열로 주소 공간을 관리하고,ncclShadowPool64비트 비트맵과 해시 테이블로 디바이스/호스트 객체 페어링을 관리하며,ncclMemManager참조 카운팅과 CUDA VMM API로 일시 중단 복구를 구현하고,ncclRegister정렬된 배열로 등록 결과를 캐시하여 중복 pin을 방지합니다. 이 네 가지 계층 메커니즘이 함께 "통신 전에 메모리를 재등록할 필요가 없다"는 핵심 성능 보장을 뒷받침합니다. 다음 장에서는 디바이스 측 통신자와 ABI 호환성으로 들어가,devcomm어떻게 이 호스트 측 메모리 레이아웃을 GPU kernel이 접근 가능한 구조로 매핑하는지 살펴보겠습니다.

위 그림은 등록의 타이밍을 보여줍니다: 캐시 히트 시에는 참조 카운트만 증가시키고 하위 등록을 호출하지 않으며, 캐시 미스 시에만 새 항목을 생성하고 하위 등록을 트리거합니다. 여기까지 호스트 측 메모리 관리 메커니즘이 명확해졌습니다. 하지만 통신은 결국 GPU에서 발생하며, kernel은 상대 rank의 주소와 연결 상태에 직접 접근해야 합니다. 다음 장에서는 디바이스 측 통신자와 ABI 호환성으로 들어가, devcomm이 어떻게 호스트 측 ncclComm의 메타데이터를 디바이스 측 접근 가능한 구조로 매핑하는지, 그리고 버전화된 ABI가 어떻게 신구 kernel과 라이브러리의 호환성을 보장하는지 살펴보겠습니다.

CHAPTER 19

제 19 장: 제 19 장: 디바이스 측 통신 도메인과 ABI 호환성: devcomm과 kernel의 통신 계약

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 19 / 25 장

제 19 장: 디바이스 측 통신 도메인과 ABI 호환성: devcomm과 kernel의 통신 계약

이전 장에서 우리는 호스트 측 ncclMemManager가 참조 카운팅과 CUDA VMM API로 통신 버퍼의 수명 주기를 관리하는 것을 보았습니다. 하지만 통신이 실제로 발생하는 곳은 GPU kernel입니다——kernel 내의 스레드는 알아야 합니다: 나는 어느 rank인가? 상대 rank의 버퍼는 어느 가상 주소에 있는가? 연결이 준비되었는가? 이 정보는 호스트 측 ncclComm 구조에 있지만, kernel은 호스트 포인터를 직접 역참조할 수 없습니다. 만약 NCCL이 kernel이 매번 파라미터 전달이나 전역 메모리 조회를 통해 이 메타데이터를 얻도록 한다면, 매 통신마다 추가 지연과 대역폭 오버헤드를 지불해야 합니다. 더 나쁜 것은, kernel 코드가 한 번 컴파일되면 접근하는 필드 오프셋이 고정된다는 것입니다——라이브러리 업그레이드 후 ncclComm의 레이아웃이 변경되면 구 kernel은 잘못된 데이터를 읽게 됩니다. 이것이 devcomm이 해결해야 할 핵심 문제입니다: 호스트 측 통신 도메인의 핵심 메타데이터를 안정적이고 버전화된 메모리 레이아웃으로 디바이스 측 접근 가능한 구조에 매핑하는 것입니다. src/devcomm 디렉토리의 devcomm_v22902.cc, devcomm_v22907.cc, devcomm_v23000.cc, devcomm_v23100.cc가 바로 이 버전화된 ABI의 구체적 구현입니다. 각 파일은 하나의 NCCL 버전 구간에 대응하며, 해당 구간 내 ncclDevComm의 정확한 메모리 레이아웃과 신구 버전 간의 필드 복사 로직을 정의합니다. 이 장에서는 순서대로 분석합니다: 디바이스 측 통신자의 핵심 데이터 구조가 어떻게 생겼는지, 버전화된 ABI의 등록과 매칭 메커니즘이 어떻게 작동하는지, 신구 버전 간에 필드 수준 변환을 어떻게 수행하는지, 그리고 이 메커니즘의 프로덕션 환경에서의 경계와 함정은 무엇인지.

一、디바이스 측 통신자의 핵심 구조: ncclDevComm의 메모리 레이아웃

직관적 모델

ncclDevComm을 "워크스테이션 카드"로 상상해 보세요: 각 GPU kernel이 시작될 때마다 카드를 받는데, 거기에는 "너는 3번 rank, 총 8개 rank, 너의 LSA 그룹에는 4개 rank, 상대 버퍼 기저 주소는 0x7f..."라고 적혀 있습니다. 이 카드는 충분히 작아야 하고(kernel 파라미터에 들어갈 수 있어야 함), 동시에 모든 핵심 정보를 포함해야 합니다. 만약 이 카드가 없다면, kernel은 호스트 측에서 반복적으로 파라미터를 전달받아 매 통신마다 재조립해야 합니다——지연이 높고 오류가 발생하기 쉽습니다.

데이터 구조와 메모리 레이아웃

ncclDevComm_v23000을 예로 들면, 전체 정의는📎 src/devcomm/devcomm_v23000.cc:25-62:

c
struct ncclDevComm_v23000 {
  unsigned int magic;          // 偏移 0,魔数校验
  unsigned int version;        // 偏移 4,版本号

  int rank, nRanks;            // 偏移 8, 12
  uint32_t nRanks_rcp32;       // 偏移 16,nRanks 的倒数(定点数)
  int lsaRank, lsaSize;        // 偏移 20, 24
  uint32_t lsaSize_rcp32;      // 偏移 28

  ncclDevCommWindowTable_t windowTable;  // 偏移 32
  ncclWindow_t resourceWindow;           // 偏移 40
  ncclResourceWindow_vidmem_v23000_t resourceWindow_inlined;  // 偏移 48
  ncclGinBarrierHandle_t hybridWorldGinBarrier;  // 偏移 112
  ...
};

📎 src/devcomm/devcomm_v23000.cc:64-93一连串static_assert으로 각 필드의 오프셋을 고정합니다. 이것은 장식이 아닙니다——ABI 호환성의 컴파일 타임 계약입니다. 만약 어떤 필드의 오프셋이 컴파일러 정렬 정책 변화로 인해 이동하면, 런타임에 디버깅하기 어려운 메모리 오정렬이 발생하는 대신 컴파일이 실패합니다.

몇 가지 핵심 필드의 설계 동기:

〔설계 추론과 아키텍처 트레이드오프〕

nRanks_rcp32와lsaSize_rcp32: 이것은nRanks와lsaSize의 역수로, 32비트 고정소수점으로 표현된다. kernel에서 rank를 buffer 오프셋으로 변환하는 나눗셈 연산을 할 때, GPU의 정수 나눗셈은 매우 느리므로 역수를 곱한 뒤 시프트하는 방식으로 상당히 가속할 수 있다. 이는 전형적인 「공간을 시간으로 바꾸는」 기법이다 — 4바이트를 더 저장해서 매 나눗셈마다 발생하는 수십 클럭 사이클을 절약한다.

resourceWindow_inlined: 이것은 인라인 윈도우 디스크립터이며, 타입은ncclResourceWindow_vidmem_v23000_t이다. 주의:📎 src/devcomm/devcomm_v23000.cc:11-18에서의 정의:

c
typedef struct ncclResourceWindow_vidmem_v23000 {
  char reserved1[8];
  char* lsaFlatBase;
  char reserved2[8];
  uint32_t stride4G;
  uint32_t mcOffset4K;
  char reserved3[32];  // NOTE: shrunk from 40 in 2.30u1 to reclaim 8 bytes
} ncclResourceWindow_vidmem_v23000_t;

여기서reserved1、reserved2、reserved3은패딩 필드로, 자리를 차지하기 위해 사용된다. 왜 패딩이 필요한가? 왜냐하면ncclDevComm_v23000의 레이아웃은 반드시 어떤 「기준 버전」과 오프셋이 일치해야 하므로, 일부 필드가 현재 버전에서 더 이상 사용되지 않더라도 자리 표시를 유지하여 이후 필드의 오프셋이 변하지 않도록 해야 한다.📎 src/devcomm/devcomm_v23000.cc:11-18의 주석은 명확히 설명한다: 2.30u1에서reserved3을 40바이트에서 32바이트로 축소하여 8바이트를hybridWorldGinBarrier에게 확보했다. 이것은레이아웃 재배치이다 — 패딩 영역을 축소함으로써 전체 크기를 변경하지 않으면서 새 필드를 끼워 넣는다.

📎 src/devcomm/devcomm_v23000.cc:11-18의static_assert이 추가로 검증한다:lsaFlatBase、stride4G、mcOffset4K세 필드의 오프셋은 반드시 「현재 버전」의ncclWindow_vidmem과 일치해야 하며, 전체 구조체 크기는 64바이트이다. 이는resourceWindow_inlined이 v23000과 현재 버전 사이에서바이너리 호환임을 의미한다 — 직접 memcpy할 수 있다.

버전화된 구조체의 패밀리

비교ncclDevComm_v22902 📎 src/devcomm/devcomm_v22902.cc:38-62과ncclDevComm_v22907 📎 src/devcomm/devcomm_v22907.cc:13-41을 보면 필드의 진화를 알 수 있다:

필드v22902v22907v23000
magic/version없음없음있음 (오프셋 0/4)
ginContextCountuint8_tuint32_tuint32_t
ginNetDeviceTypes[4][NCCL_GIN_MAX_CONNECTIONS][NCCL_GIN_MAX_CONNECTIONS]
ginIsRailed없음bool분할됨ginConnectionsRailed + ginContextsRailed
hybridWorldGinBarrier없음없음있음 (오프셋 112)
구조체 크기200224240
〔설계 추론 및 아키텍처 트레이드오프〕

이 진화 경로는 NCCL의 버전 전략을 드러낸다:필요할 때만 필드를 추가하고, 가능한 한 패딩 영역을 활용한다. v22902에서 v22907로 가면서ginSignalBase、ginCounterBase、ginContextBase、ginIsRailed등 GIN 관련 필드가 추가되었고; v22907에서 v23000으로 가면서magic/version검증 필드와hybridWorldGinBarrier이 추가되었으며, 동시에ginIsRailed을 두 개의 더 정밀한 플래그 비트로 분할했다.

---

二、버전화된 ABI의 등록과 매칭: ncclDevCommCompat 구조

직관적 모델

버전화된 ABI를 일종의 「번역 플러그인」 세트라고 상상해 보자: 애플리케이션이 NCCL 2.29.2로 컴파일되었지만 런타임에 링크된 라이브러리는 2.31.0인 경우, 라이브러리는 「2.29.2의 kernel이 어떤ncclDevComm레이아웃을 기대하는지」를 알아야 하며, 그런 다음 현재 버전의ncclDevComm을 이전 레이아웃으로 번역한다. 각 버전 구간은 하나의 번역 플러그인에 대응하며, 전역 테이블에 등록된다.

핵심 구조: ncclDevCommCompat

각devcomm_vXXXXX.cc파일 끝에는ncclDevCommCompat구조체가 정의되어 있다. v23000을 예로 들면📎 src/devcomm/devcomm_v23000.cc:192-199:

c
struct ncclDevCommCompat ncclDevCommCompat_v23000 = {
  NCCL_VERSION(2, 30, 0),               // minVersion
  NCCL_VERSION(2, 30, 7),               // maxVersion
  nullptr,                              // commPropertiesFilter
  ncclDevCommRequirementsFilter_v23000, // devCommRequirementsFilter
  ncclDevCommCopyNewToOld_v23000,       // devCommCopyNewToOld
  ncclDevCommCopyOldToNew_v23000,       // devCommCopyOldToNew
};

여섯 필드의 의미:

1. minVersion / maxVersion: 이 플러그인이 담당하는 버전 구간. v23000은 2.30.0부터 2.30.7까지 커버한다.

2. commPropertiesFilter: 선택적 필터로,ncclCommProperties에서 이전 버전에 노출되는 기능 플래그를 조정하는 데 사용된다. v23000은nullptr으로 설정되어 있으며, 필터링이 필요 없음을 나타낸다.

3. devCommRequirementsFilter: 애플리케이션이 요청한 디바이스 측 리소스가 이전 버전과 호환되는지 확인한다. v23000의 구현📎 src/devcomm/devcomm_v23000.cc:95-98은 단지ginType을comm->sharedRes에서reqs。

4. devCommCopyNewToOld으로 복사한다: 현재 버전의ncclDevComm을 이전 버전 레이아웃으로 복사한다.

5. devCommCopyOldToNew: 이전 버전 레이아웃을 다시 현재 버전으로 복사한다.

버전 구간의 구분

네 파일의 버전 구간:

파일minVersionmaxVersion비고
devcomm_v22902.cc2.29.22.29.3최초의 버전화 구현
devcomm_v22907.cc2.29.52.29.7GIN 필드 추가, 그러나 GIN 하위 호환성은 제공하지 않음
devcomm_v23000.cc2.30.02.30.7magic/version 검증 추가
devcomm_v23100.cc2.31.0현재 버전모든 필터가 nullptr이며, 완전 호환을 나타낸다

📎 src/devcomm/devcomm_v23100.cc:10-17의 v23100 플러그인은 모든 콜백이nullptr이며, 이는 2.31.0부터ncclDevComm의 레이아웃이 이미 안정화되어 어떠한 변환도 필요하지 않음을 의미한다.

〔설계 추론 및 아키텍처 트레이드오프〕

v22902와 v22907 사이의 버전 구간에 「틈」이 있음에 주의하라 (2.29.4와 2.29.6에는 대응하는 플러그인이 없다). 이는 아마도 해당 버전들이 릴리스되지 않았거나, 그 레이아웃이 인접 버전과 완전히 동일하여 재사용할 수 있기 때문일 것이다.

매칭 흐름

애플리케이션이ncclCommGetDeviceHandle또는 유사한 API를 호출할 때, NCCL은 다음을 해야 한다:

1. 애플리케이션이 컴파일 시 삽입한 NCCL 버전 번호를 읽는다 (reqs->version)。

을 통해)ncclDevCommCompat2. 전역

테이블에서 해당 버전을 커버하는 플러그인을 찾는다.devCommCopyNewToOld3. 찾으면 플러그인의

을 호출하여 현재 레이아웃을 이전 레이아웃으로 변환한다.

4. 찾지 못하면 오류를 반환하거나 기본 동작을 사용한다.

mermaid
flowchart TD
    start["应用请求设备侧通信器"] --> read_ver["读取 reqs->version<br/>(应用编译时版本)"]
    read_ver --> find_compat{"在 ncclDevCommCompat 表中<br/>查找覆盖该版本的插件?"}
    find_compat -->|找到| check_filter["调用 devCommRequirementsFilter<br/>检查资源请求兼容性"]
    find_compat -->|未找到| err_unsupported["返回 ncclInvalidUsage<br/>版本不兼容"]
    check_filter --> filter_ok{"过滤器返回<br/>ncclSuccess?"}
    filter_ok -->|是| copy_new_to_old["调用 devCommCopyNewToOld<br/>把当前布局转为旧布局"]
    filter_ok -->|否| err_gin["返回 ncclInvalidUsage<br/>GIN 资源不兼容"]
    copy_new_to_old --> done["返回旧布局 ncclDevComm"]
    err_unsupported --> done_err["应用收到错误"]
    err_gin --> done_err

---

복사

三、필드 수준 변환: 신구 레이아웃은 어떻게 상호 변환되는가

직관적 모델ncclDevComm버전 변환은 「번역」과 같다: 새 버전의rank은 현대 중국어 문장이고, 이전 버전의 레이아웃은 고문이다. 번역기는 필드별로 대응해야 한다 — 어떤 필드는 직접 대응되고 (rank는ginConnectionStride > 1에 대응), 어떤 필드는 「의역」이 필요하며 (ginConnectionsRailed = true을

으로 번역), 어떤 필드는 이전 버전에 존재하지 않는다 (그냥 버린다).

NewToOld 변환: 현재 버전에서 이전 버전으로ncclDevCommCopyNewToOld_v23000을 예로 들면📎 src/devcomm/devcomm_v23000.cc:114-152:

c
static ncclResult_t ncclDevCommCopyNewToOld_v23000(ncclComm_t comm, void* oldDevComm,
                                                   struct ncclDevComm const* newDevComm) {
  struct ncclDevComm_v23000* old = (struct ncclDevComm_v23000*)oldDevComm;

  memset(old, '\0', sizeof(*old));  // 先清零,防止未初始化字段泄露
  old->magic = newDevComm->magic;
  old->version = newDevComm->version;
  old->rank = newDevComm->rank;
  ...
  old->ginConnectionsRailed = (newDevComm->ginConnectionStride > 1);
  old->ginStrongLegacySignals = newDevComm->ginStrongLegacySignals;
  old->ginContextsRailed = (newDevComm->ginContextStride > 1);
  ...
}

핵심 단계:

1. memset0으로 초기화 📎 src/devcomm/devcomm_v23000.cc:118: 이것은 안전 보호이다 — 이전 구조체에는 새 버전에 존재하지 않는 필드가 있을 수 있으며, 0으로 초기화하면 초기화되지 않은 메모리가 디바이스 측으로 유출되는 것을 방지할 수 있다.

2. 직접 필드 복사:rank、nRanks、lsaRank등은 직접 대입한다.

3. 인라인 윈도우 변환: 호출ncclDevCommCopyResourceWindowNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:100-105, 필드별로 복사lsaFlatBase、stride4G、mcOffset4K。

4. 의미 변환:ginConnectionsRailed = (newDevComm->ginConnectionStride > 1) 📎 src/devcomm/devcomm_v23000.cc:142. 새 버전은ginConnectionStride(정수 스텝)으로 railed 여부를 나타내고, 이전 버전은 불리언 값을 사용한다. 스텝이 1보다 크면 연결이 railed임을 나타낸다.

5. 배열 복사:memcpy복사ginNetDeviceTypes과ginHandles배열📎 src/devcomm/devcomm_v23000.cc:135-136。

OldToNew 변환: 이전 버전에서 현재 버전으로

역변환은📎 src/devcomm/devcomm_v23000.cc:154-190:

c
static ncclResult_t ncclDevCommCopyOldToNew_v23000(ncclComm_t comm, struct ncclDevComm* newDevComm,
                                                   void const* oldDevComm) {
  struct ncclDevComm_v23000 const* old = (struct ncclDevComm_v23000 const*)oldDevComm;

  newDevComm->magic = old->magic;
  ...
  newDevComm->ginConnectionStride = old->ginConnectionsRailed ? old->lsaSize : 1;
  newDevComm->ginContextStride = old->ginContextsRailed ? old->lsaSize : 1;
  ...
}
〔설계 추론 및 아키텍처 트레이드오프〕

주의📎 src/devcomm/devcomm_v23000.cc:180-181의 의미 변환: 이전 버전에서ginConnectionsRailed이 참이면, 새 버전의ginConnectionStride은 다음과 같이 설정된다lsaSize;그렇지 않으면 1로 설정한다. 여기서는lsaSize를 스텝 크기로 사용하는데, railed 모드에서 각 LSA 그룹 내의 rank가 하나의 GIN 연결을 공유하므로 스텝 크기는 LSA 그룹의 크기와 같기 때문이다.

v22902의 특수 처리

ncclDevCommCopyOldToNew_v22902 📎 src/devcomm/devcomm_v22902.cc:149-167에는 중요한 주석이 있다:

c
// Note: this callback will be used with v22907 as well because, prior to 2.30.0, ncclDevComm was unversioned,
// so v22902 and v22907 variants are indistinguishable.
〔설계 추론 및 아키텍처 트레이드오프〕

이는 2.30.0 이전에는ncclDevComm이 없었음을 의미한다magic/version필드가 없으므로 라이브러리는 구 구조체가 v22902인지 v22907인지 구분할 수 없다. 따라서 v22907의devCommCopyOldToNew은nullptr 📎 src/devcomm/devcomm_v22907.cc:128로 설정되고, 실제로는 v22902의 버전이 사용된다. 둘 다 GIN 하위 호환성을 지원하지 않으므로 GIN 관련 필드의 차이는 정확성에 영향을 미치지 않는다.

리소스 윈도우의 버전 관리

ncclWindow_vidmem_v22902의 정의는devcomm_v22902.h에 있으며(이 장에서는 해당 파일 내용을 제공하지 않음), 하지만📎 src/devcomm/devcomm_v22902.cc:141과📎 src/devcomm/devcomm_v22902.cc:164에서 볼 수 있듯이 v22902는ncclDevCommCopyResourceWindow_v22902를 사용하여 윈도우를 변환한다. 이 함수는devcomm_v22902.h에 선언되어 있으며, 구체적인 구현은 이 장의 소스 코드에 표시되지 않는다.

📎 src/devcomm/devcomm_v23000.cc:11-18의static_assert은 v23000의 윈도우 레이아웃이 현재 버전과 일치함을 검증하므로, v23000의 변환 함수는 필드별로 직접 복사할 수 있다.

---

4. 능력 필터링과 리소스 검사: 구 kernel이 지원되지 않는 기능에 접근하는 것을 방지

직관적 모델

버전 변환은 단순한 "필드 이사"가 아니다 — 구 버전이 애플리케이션이 요청한 기능을 지원하는지도 확인해야 한다. 예를 들어, 2.29.2로 컴파일된 kernel이 GIN 리소스를 요청하지만, 2.29.2의ncclDevComm레이아웃에서 GIN 필드가 불완전하면 직접 변환 시 kernel이 쓰레기 데이터를 읽게 된다. 따라서 변환 전에 이러한 요청을 차단하는 "필터"가 필요하다.

commPropertiesFilter: 능력 플래그 필터링

ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77:

c
static ncclResult_t ncclCommPropertiesFilter_v22907(ncclComm_t comm, struct ncclCommProperties* props) {
  // We don't provide backwards compatibility for GIN with 2.29.7.  If a communicator needs it, we indicate that
  // the Device API is not available.
  props->deviceApiSupport = (props->deviceApiSupport && ncclTeamLsa(comm).nRanks == comm->nRanks);
  props->ginType = NCCL_GIN_TYPE_NONE;
  props->railedGinType = NCCL_GIN_TYPE_NONE;
  return ncclSuccess;
}

세 가지 작업:

1. deviceApiSupport강등: LSA 그룹의 rank 수가 전체 rank 수와 다르면(즉, 노드 간 통신이 존재하면) 디바이스 API를 비활성화한다. 이는 2.29.7의 GIN이 노드 간을 지원하지 않기 때문이다.

2. ginType을 NONE으로 설정: 애플리케이션에 "이 버전은 GIN을 지원하지 않는다"고 명확히 알린다.

3. railedGinType을 NONE으로 설정: 위와 동일.

ncclCommPropertiesFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:86-96와 유사하지만 세부 사항이 하나 더 있다:

c
// v22902 ncclCommProperties is _almost_ compatible with newer ones, with the exception of ginType, which in that
// version was based on uint_8, not an int.
((struct ncclCommProperties_v22902*)props)->ginType = NCCL_GIN_TYPE_NONE_v22902;

📎 src/devcomm/devcomm_v22902.cc:13-17은 v22902의 GIN 타입 열거형을 정의한다:

c
typedef enum : uint8_t {
  NCCL_GIN_TYPE_NONE_v22902 = 0,
  NCCL_GIN_TYPE_PROXY_v22902 = 2,
  NCCL_GIN_TYPE_GDAKI_v22902 = 3,
} ncclGinType_t_v22902;

이는uint8_t타입이며, 새 버전에서ginType은int이다. 따라서 v22902의 필터는props를ncclCommProperties_v22902*로 강제 변환한 후uint8_t타입의ginType。📎 src/devcomm/devcomm_v22902.cc:35-36에 기록해야 한다.static_assert의ginType은

이 오프셋 34에 있고 구조체 크기가 40바이트임을 검증한다.

ncclDevCommRequirementsFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:79-98devCommRequirementsFilter: 리소스 요청 검사

c
static ncclResult_t ncclDevCommRequirementsFilter_v22907(ncclComm_t comm, ncclDevCommRequirements_t* reqs) {
  bool requestedGinResources =
    reqs->ginSignalCount > 0 || reqs->ginCounterCount > 0 || reqs->barrierCount > 0 || reqs->railGinBarrierCount > 0;
  struct ncclDevResourceRequirements* node = reqs->resourceRequirementsList;
  while (!requestedGinResources && node != nullptr) {
    requestedGinResources = node->ginSignalCount > 0 || node->ginCounterCount > 0;
    node = node->next;
  }
  if (requestedGinResources && (reqs->ginConnectionType != NCCL_GIN_CONNECTION_NONE || reqs->ginForceEnable)) {
    // 打印警告并返回错误
    return ncclInvalidUsage;
  }
  return ncclSuccess;
}

복사

1. 논리는 두 단계로 나뉜다::reqs->ginSignalCount、ginCounterCount、barrierCount、railGinBarrierCount최상위 요청 확인

2. 중 하나라도 0보다 크면 GIN 리소스를 요청한 것이다.리소스 요구 사항 연결 리스트 순회resourceRequirementsList: 최상위에서 요청하지 않았다면ginSignalCount연결 리스트를 계속 순회하며 각 노드의ginCounterCount。

과ginConnectionType을 확인한다NONE실제로 GIN 리소스를 요청했고ginForceEnable이ncclInvalidUsage가 아니거나

ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126이 참이면barrierCount을 반환하고 경고를 출력하여 애플리케이션이 재컴파일해야 함을 알린다.

c
// Prior to 2.29.4, a non-zero barrierCount did not imply GIN, but it does since.
if (reqs->barrierCount) {
  reqs->lsaBarrierCount = std::max(reqs->lsaBarrierCount, reqs->barrierCount);
  reqs->barrierCount = 0;
}
// Strangely, neither did railGinBarrierCount.
reqs->railGinBarrierCount = 0;
의 의미 변화를 처리한다:

복사barrierCount〔설계 추론 및 아키텍처 트레이드오프〕barrierCount2.29.4 이전에는barrierCount이 LSA barrier만 나타내며 GIN 요구 사항을 암시하지 않았다. 2.29.4부터lsaBarrierCount이 GIN 요구 사항을 암시한다. 구 버전과의 호환성을 위해 필터는barrierCount을railGinBarrierCount。

로 변환하고

mermaid
sequenceDiagram
    participant App as 应用层
    participant Host as Host 侧 NCCL 库
    participant Compat as ncclDevCommCompat 插件
    participant Dev as 设备侧 ncclDevComm

    App->>Host: ncclCommGetDeviceHandle(comm, &devComm)
    Host->>Host: 读取 reqs->version(应用编译版本)
    Host->>Compat: 查找覆盖该版本的插件
    Compat-->>Host: 返回 ncclDevCommCompat_vXXXXX
    Host->>Compat: devCommRequirementsFilter(comm, reqs)
    alt 请求了不支持的 GIN 资源
        Compat-->>Host: ncclInvalidUsage
        Host-->>App: 返回错误 + 警告日志
    else 资源兼容
        Compat-->>Host: ncclSuccess
        Host->>Compat: devCommCopyNewToOld(comm, oldDevComm, newDevComm)
        Compat->>Compat: memset(old, 0, sizeof(*old))
        Compat->>Compat: 逐字段拷贝 + 语义转换
        Compat-->>Host: ncclSuccess
        Host->>Dev: 返回旧布局 ncclDevComm
        Dev-->>App: 设备侧可访问的通信器
    end

---

을 0으로 초기화한다

아래 시퀀스 다이어그램은 애플리케이션 요청부터 버전 변환까지의 전체 상호작용을 보여준다:

복사5. 프로덕션 함정 회피 가이드와 장애 복구 체인ncclGinPut)。

함정 1: GIN 리소스 요청과 구 버전 kernel의 충돌:ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126시나리오ginForceEnable: 애플리케이션이 NCCL 2.29.2로 컴파일되었지만 런타임에 2.31.0 라이브러리에 링크되었다. 애플리케이션이 kernel에서 GIN 관련 디바이스 측 API(예:ginSignalCount > 0를 호출했다ncclInvalidUsage무슨 일이 발생하는가

code
The application was compiled with too old version of NCCL. It was compiled with NCCL version 2.29.2, but is
running with NCCL library version 2.31.0. Because of its use of GIN device kernels, it needs to be recompiled,
preferably with the same NCCL version that it will be running with.

또는을 감지하면ncclDevComm_v22902을 반환하고 경고를 출력한다:ginContextCount、ginNetDeviceTypes、ginHandles복사

근본 원인: 2.29.2의

레이아웃에서 GIN 필드(

등)가 2.31.0의 레이아웃과 호환되지 않는다. 강제 변환하면 kernel이 잘못된 오프셋을 읽어 정의되지 않은 동작이 발생한다.올바른 방법ncclTeamLsa(comm).nRanks != comm->nRanks)。

: 애플리케이션은 런타임 라이브러리와 동일(또는 호환)한 NCCL 버전으로 재컴파일해야 한다. 재컴파일할 수 없다면 kernel에서 GIN API 사용을 피해야 한다.:ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77함정 2: 노드 간 통신 시 디바이스 API가 조용히 비활성화됨props->deviceApiSupport시나리오false: 애플리케이션이 2.29.7로 컴파일되었고, 통신 도메인에 노드 간 rank가 포함됨(

무슨 일이 발생하는가이

을로 설정한다. 애플리케이션이 이 플래그를 확인하면 디바이스 API를 사용할 수 없음을 알 수 있지만, 확인하지 않고 디바이스 측 API를 직접 호출하면 정의되지 않은 동작이 발생한다.ncclCommProperties.deviceApiSupport근본 원인false: 2.29.7의 GIN은 노드 간을 지원하지 않는다. LSA(Local SHARP Aggregation) 그룹 내의 rank만 디바이스 측 API를 사용할 수 있다.

올바른 방법

: 애플리케이션은 초기화 후:ncclDevCommCopyNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:118을 확인하고,memset(old, '\0', sizeof(*old))。

이면 host 측 API로 폴백해야 한다.함정 3: memset 제로화와 초기화되지 않은 필드 누출ginSignalBase、ginCounterBase시나리오

이 복사 전에: 개발자가 버전 변환을 수동으로 구현하면서 0으로 초기화하는 것을 잊으면, kernel이 임의의 값을 읽을 수 있으며, 이는 간헐적 오류로 나타나 재현과 디버깅이 어렵습니다.

올바른 방법: 변환 전에 항상 대상 구조체 전체를 0으로 초기화하십시오. NCCL의 모든CopyNewToOld구현은 이 패턴을 따릅니다📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。

함정 4: 버전 구간 공백으로 인한 매칭 실패

시나리오: 애플리케이션이 NCCL 2.29.4로 컴파일되었습니다. 버전 구간 표를 확인하십시오:

파일minVersionmaxVersion
v229022.29.22.29.3
v229072.29.52.29.7

2.29.4에 대응하는 플러그인이 없습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

무슨 일이 발생하는가: 매칭 로직이 구간별로 엄격하게 조회하면 2.29.4는 매칭에 실패하여 오류를 반환합니다. 그러나 실제 구현에서는 "최근접 매칭" 전략이 있을 수 있으며, 2.29.4는 v22902 또는 v22907 플러그인으로 라우팅될 수 있습니다.

올바른 방법: 애플리케이션은 가능한 한 런타임 라이브러리와 동일한 주 버전 번호를 사용해야 합니다. 버전을 넘어야 하는 경우, 대상 버전 구간에 호환되는 플러그인이 있는지 테스트해야 합니다.

장애 복구 체인

버전 변환이 실패할 때 NCCL의 오류 복구 체인:

1. 필터가 오류를 반환:devCommRequirementsFilter반환ncclInvalidUsage。

2. 상위 API가 오류를 포착:ncclCommGetDeviceHandle반환 값을 확인하고, 만약ncclSuccess가 아니면devComm구조체를 채우지 않습니다.

3. 애플리케이션 처리: 애플리케이션은 반환 값을 확인하고, 실패하면 host 측 API로 폴백하거나 통신을 종료해야 합니다.

4. 로그 기록: NCCL은WARN레벨 로그를 출력하며, 컴파일 버전과 런타임 버전을 포함하여 문제를 파악하는 데 도움을 줍니다.

〔설계 추론 및 아키텍처 트레이드오프〕

현재 NCCL은 "자동 다운그레이드" 메커니즘을 제공하지 않습니다. 버전 변환이 실패해도 host 측 API로 자동 폴백하지 않습니다. 애플리케이션이 자체적으로 폴백 로직을 구현해야 합니다.

---

설계 고찰

왜 "안정적인 ABI" 대신 버전화된 구조체를 사용하는가?

〔설계 추론 및 아키텍처 트레이드오프〕

대안은 "절대 변하지 않는"ncclDevComm레이아웃을 설계하고 모든 새 필드를 간접 포인터로 접근하는 것입니다. 그러나 이는 두 가지 문제를 야기합니다: 첫째, 간접 접근이 지연을 증가시키고(kernel이 추가 역참조 필요), 둘째, 패딩 영역을 활용한 레이아웃 최적화가 불가능합니다. NCCL이 버전화된 구조체를 선택한 것은 "성능"과 "호환성" 사이의 트레이드오프입니다. 각 버전 구간 내의 kernel은 최적 레이아웃을 얻고, 버전 간에는 변환 계층을 통해 호환성을 보장합니다.

왜 v22907의devCommCopyOldToNew를 nullptr로 설정했는가?

📎 src/devcomm/devcomm_v22902.cc:153-155의 주석이 그 이유를 설명합니다: 2.30.0 이전에는ncclDevComm에 버전 필드가 없어서 v22902와 v22907의 구버전 레이아웃을 구분할 수 없습니다. 둘 다 GIN 하위 호환성을 지원하지 않으므로 GIN 필드의 차이가 정확성에 영향을 미치지 않아 v22902의 변환 함수를 재사용합니다.

왜nRanks_rcp32는 부동소수점 대신 고정소수점을 사용하는가?

〔설계 추론 및 아키텍처 트레이드오프〕

GPU의 부동소수점 나눗셈 정밀도는1/nRanks를 정확히 표현하기에 충분하지 않을 수 있습니다, 특히nRanks가 2의 거듭제곱이 아닐 때. 고정소수점(32비트 정수로 표현된 소수)은 충분한 정밀도를 제공하며, 정수 곱셈이 부동소수점 곱셈보다 빠릅니다.

---

이 장 요약

이 장에서는src/devcomm디렉터리의 버전화된 ABI 구현을 분석했습니다:

1. ncclDevComm의 메모리 레이아웃: 각 버전은 정확한 필드 오프셋을 가지며,static_assert로 컴파일 타임에 검증됩니다. 주요 필드에는rank、nRanks、nRanks_rcp32、lsaRank、lsaSize、windowTable、resourceWindow등이 있습니다.

2. 버전화된 ABI 등록: 각 버전 구간은 하나의ncclDevCommCompat구조체에 대응하며,minVersion、maxVersion, 필터 함수, 변환 함수를 포함합니다.

3. 필드 수준 변환:CopyNewToOld과CopyOldToNew는 필드별로 복사하며, 의미 변화를 처리합니다(예:ginConnectionStride > 1를ginConnectionsRailed = true)。

4. 으로 변환):commPropertiesFilter능력 필터링devCommRequirementsFilter은 구버전에 노출되는 능력 플래그를 조정하고,

5. 은 리소스 요청이 구버전과 호환되는지 확인합니다.프로덕션 함정

: GIN 리소스 요청과 구버전 kernel의 충돌, 크로스 노드 통신 시 디바이스 API 비활성화, memset 0 초기화의 필요성, 버전 구간 공백으로 인한 매칭 실패.nccl_device다음 장에서는 디바이스 측 API와 커널 융합으로 들어가,

헤더 파일이 디바이스 측 함수를 어떻게 구성하는지, 그리고 kernel fusion이 여러 집합 통신 작업을 하나의 kernel에서 실행하도록 어떻게 병합하는지 살펴봅니다.

이 장 생각해보기와 자가 테스트ncclDevCommCopyNewToOld_v23000Q1: 만약memset(old, '\0', sizeof(*old))에서

를 제거하면, 어떤 시나리오에서 kernel이 잘못된 데이터를 읽게 되는가? v22902와 v23000의 필드 차이를 결합하여 분석하십시오.:

ncclDevComm_v22902참고 해석📎 src/devcomm/devcomm_v22902.cc:84의 구조체 크기는 200바이트ncclDevComm_v23000이고,📎 src/devcomm/devcomm_v23000.cc:95-98는 240바이트ginSignalBase입니다. v22902에는ginCounterBase(오프셋 176),ginContextBase(오프셋 184),

(오프셋 204) 등의 필드가 있으며, 이 필드들은 v23000에 존재하지 않거나 의미가 다릅니다.memset만약old를 제거하면, v23000에서 v22902로 변환할 때,ginSignalBase、ginCounterBase구조체에서 v23000에 존재하지 않는 필드(예:

  • )는 스택의 쓰레기 값을 유지합니다. kernel이 이 필드들을 읽으면(예: 구버전 kernel의 GIN 코드 경로), 임의의 값을 얻게 되어:
  • 신호 기저 주소 오류, GIN 작업이 잘못된 메모리 위치에 기록됩니다.
  • 카운터 기저 주소 오류, 카운터 오버플로 또는 언더플로를 초래합니다.

memset극단적인 경우, 불법 메모리 접근을 유발하여 kernel이 충돌할 수 있습니다.CopyNewToOld0 초기화는 명시적으로 할당되지 않은 모든 필드가 0이 되도록 보장하며, 이는 안전한 기본값입니다. NCCL의 모든📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。

구현은 이 단계를 포함합니다ncclDevCommCompat플러그인. NCCL이 이러한 상황을 어떻게 처리할 수 있는지, 그리고 애플리케이션이 어떻게 회피해야 하는지 분석하십시오.

참고 해석:

버전 구간 표:

  • v22902:2.29.2 - 2.29.3
  • v22907:2.29.5 - 2.29.7
  • v23000:2.30.0 - 2.30.7
  • v23100: 2.31.0 - 현재

2.29.4는 v22902와 v22907 사이의 공백에 위치합니다. 가능한 처리 방식:

1. 최근접 매칭: NCCL은 요청 버전보다 작거나 같은 최대 구간, 즉 v22902를 선택할 수 있습니다. 그러나 v22902의maxVersion은 2.29.3이므로, 엄밀히 말해 2.29.4를 커버하지 않습니다.

2. 오류 반환: 매칭 로직이 구간을 엄격히 따르는 경우, 2.29.4는 매칭에 실패하여ncclInvalidUsage。

3. 을 반환합니다. 상향 매칭: 요청 버전보다 크거나 같은 최소 구간, 즉 v22907을 선택합니다. 그러나 v22907의minVersion은 2.29.5이므로, 역시 2.29.4를 커버하지 않습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

실제 구현에서 NCCL은 '내결함성' 전략을 가질 수 있습니다 — 정확한 매칭을 찾을 수 없으면 인접 구간의 플러그인을 사용하려고 시도합니다. 그러나 이것이 신뢰할 수 있는 보장은 아닙니다.

애플리케이션의 회피 방법:

  • 런타임 라이브러리와 동일한 주 버전 번호(예: 2.31.x)를 사용합니다.
  • 반드시 버전을 넘나들어야 한다면, 대상 버전 구간에 대응하는 호환 플러그인이 있는지 테스트합니다.
  • 초기화 후ncclCommProperties.deviceApiSupport을 확인하고, 만약false이면 host 측 API로 폴백합니다.
Q3: ncclDevCommRequirementsFilter_v22902에 다음과 같은 로직이 있습니다:if (reqs->barrierCount) { reqs->lsaBarrierCount = std::max(reqs->lsaBarrierCount, reqs->barrierCount); reqs->barrierCount = 0; }. 이 변환이 왜 필요한지, 그리고 변환하지 않으면 어떤 일이 발생하는지 설명하십시오.

참고 해석:

📎 src/devcomm/devcomm_v22902.cc:117-121의 주석에 따르면: 「Prior to 2.29.4, a non-zero barrierCount did not imply GIN, but it does since.」

2.29.4 이전에는,barrierCount이 LSA barrier의 수만 나타내며 GIN 요구사항을 암시하지 않았습니다. 2.29.4부터는,barrierCount이 GIN 요구사항을 암시합니다(즉, barrier를 요청하면 GIN 리소스가 필요함을 의미합니다).

애플리케이션이 2.29.2로 컴파일되면,barrierCount > 0을 설정하여 LSA barrier 요구사항을 나타낼 수 있지만, 이것이 GIN 요구사항을 암시한다는 것을 알지 못합니다. NCCL 라이브러리(2.31.0)가 새로운 의미론에 따라 직접 처리하면, 애플리케이션이 GIN 리소스를 요청한 것으로 간주하고,ncclDevCommRequirementsFilter_v22902이 GIN 요청을 감지하여ncclInvalidUsage을 반환합니다 — 이것은 오탐입니다.

변환 로직은barrierCount을lsaBarrierCount로 변환하고(둘 중 최댓값을 취함),barrierCount을 0으로 설정합니다. 이렇게 하면:

  • lsaBarrierCount은 애플리케이션의 barrier 요구사항을 보존합니다.
  • barrierCount = 0은 GIN 요구사항의 오탐을 방지합니다.
  • railGinBarrierCount = 0도 마찬가지로, 구버전에서는 이것이 GIN 요구사항을 암시하지 않기 때문입니다.

변환하지 않으면, 애플리케이션이 2.29.2로 컴파일되고barrierCount > 0을 설정한 경우, 잘못 거부되어 디바이스 API를 사용할 수 없습니다.

여기까지, 우리는 devcomm이 버전화된 ABI를 통해 host 측 통신 도메인의 핵심 메타데이터를 디바이스 측에 안전하게 매핑하여, kernel이 host 포인터 없이도 rank, 주소, 연결 상태를 얻을 수 있게 하는 방법을 살펴보았습니다. 이 메커니즘은 kernel이 통신 도메인에 접근하는 기본적인 문제를 해결했지만, 디바이스 측의 능력은 이에 그치지 않습니다. 사용자가 자신의 kernel에서 직접 통신 프리미티브를 호출하거나, 통신과 연산을 동일한 kernel에 융합하고자 할 때는 더 상위의 디바이스 측 API와 커널 융합 기술이 필요합니다. 다음 장에서는 nccl_device 디렉터리와 관련 예제를 깊이 살펴보며, ncclBarrier, ncclLsaBarrier, ncclGinBarrier 등의 디바이스 측 API가 어떻게 사용자 kernel이 통신에 참여하게 하는지, 그리고 커널 융합이 어떻게 시작 오버헤드를 줄이는지 탐구하여, NCCL을 라이브러리에서 프로그래밍 모델로 발전시킵니다.

CHAPTER 20

제 20 장: 제 20 장: 디바이스 측 네이티브 API와 연산자 융합: nccl_device와 kernel fusion 실전

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 20 / 25 장

제 20 장: 디바이스 측 네이티브 API와 연산자 융합: nccl_device와 kernel fusion 실전

지난 장에서 우리는 devcomm이 호스트 측 ncclComm의 메타데이터를 어떻게 버전화하여 디바이스 측에 매핑해서 커널이 rank, 주소, 연결 상태를 읽을 수 있게 하는지 살펴보았다. 하지만 "메타데이터를 읽을 수 있다"와 "통신을 시작할 수 있다"는 전혀 다른 문제다. 메타데이터만 있다면 사용자 커널은 기껏해야 주소를 직접 계산하고 플래그를 직접 쓸 수 있을 뿐, rank 간 동기화나 머신 간 신호 전달이 필요해지면 결국 호스트 측으로 돌아가 ncclAllReduce 같은 집합 API를 호출해야 한다. 그리고 그런 호출은 매번 커널 실행 한 번, 호스트-디바이스 왕복 한 번을 의미한다. 이번 장에서 분석할 src/nccl_device 디렉터리는 바로 NCCL이 "호출되는 라이브러리"에서 "프로그래밍 가능한 모델"로 나아가는 핵심이다. 이것이 제공하는 것은 새로운 집합 통신 알고리즘이 아니라 디바이스 측 프리미티브 집합이다. 즉 사용자 자신의 커널 내부에서 ncclBarrier, ncclLsaBarrier, ncclGinBarrier 같은 동기화 연산을 호출할 수 있게 해서 "통신"과 "계산"을 같은 커널 안에 넣고 중간의 실행 오버헤드를 없앤다. 이번 장의 소스 자료는 이 프리미티브 집합의 호스트 측 요구사항 선언(CreateRequirement)과 팀(Team) 추상화에 초점을 맞추며, 이것이 바로 디바이스 측 API의 입구다. 이번 장을 이해하는 핵심 전제: 디바이스 측 API의 설계 철학은 "호스트 측에서 자원 요구사항을 선언하고, 디바이스 측에서 자원을 소비한다"이다. 호스트 측은 배리어를 직접 생성하지 않고 NCCL에 "nBarriers개의 배리어가 필요하고, 팀에는 team.nRanks명의 멤버가 있다"고 알려주며, NCCL은 이를 바탕으로 필요한 버퍼 수와 GIN 신호 수를 계산한 뒤 디바이스 측에서 이 자원들을 인스턴스화한다. 이러한 "선언-소비" 분리가 디바이스 측 코드가 호스트 포인터 없이 동작할 수 있는 근본 이유다.

1. Team 추상화: 디바이스 측 API의 좌표계

직관적 모델

다국적 기업의 조직 구조를 상상해 보자. 이메일을 보내려면 먼저 "누구에게 보내는가"를 알아야 한다. 전사(World)에 보내는지, 같은 사무실 동료(LSA)에게 보내는지, 아니면 같은 사업 라인의 사무실 간 팀(Rail)에 보내는지.ncclTeam_t바로 이 "수신자 범위"의 기술자다. Team 추상화가 없다면 각 디바이스 측 API가 "내가 이 통신 도메인에서 몇 번째이고, 총 몇 명인지"를 매번 다시 계산해야 해서 코드가 중복되고 오류가 나기 쉽다.

데이터 구조와 메모리 레이아웃

ncclTeam_t는 디바이스 측 API의 좌표계이며, 세 개의 필드가 하나의등차수열:

필드의미비유
nRanks팀 내 멤버 총수그룹에 몇 명이 있는가
rank현재 rank의 팀 내 번호내가 그룹에서 몇 번째인가
stride팀 내 인접 멤버가 world에서 가지는 보폭그룹에서 인접한 두 사람의 학번 차이는 얼마인가

stride는 가장 간과되기 쉽지만 가장 핵심적인 필드다. World 팀에서는stride = 1, 모든 rank가 연속으로 배열되기 때문이다. 하지만 Rail 팀에서는stride = lsaSize, 같은 rail 위의 rank가 world에서lsaSize개마다 한 번씩 나타나기 때문이다.

📎 src/nccl_device/core.cc:13-19는 World 팀의 구성을 보여준다. 바로comm->nRanks과comm->rank,stride를 1로 고정한다. 이것은ncclDevrInitOnce가 필요 없는 유일한 팀인데, 정보가 전부 호스트 측comm안에 있기 때문이다.

📎 src/nccl_device/core.cc:22-33는 LSA 팀이다. L26의ncclDevrInitOnce(comm)에 주목하라. 이것은 디바이스 측 자원 초기화의 멱등 진입점이다. L23-25의 주석은 매우 중요하다:여기서는 의도적으로 오류를 무시한다. 초기화가 실패하면 반환된 team은 "쓰레기 값"이지만, 다음에 실제로 자원이 필요한 API 호출이 다시ncclDevrInitOnce를 트리거하고 오류를 보고하기 때문이다. 이것은 "지연 오류 보고" 전략으로, 팀 조회 같은 경량 작업에서 무거운 오류를 던지는 것을 피한다.

시나리오 기반 Walkthrough: World에서 Rail로의 좌표 변환

8카드 머신 하나를 가정하고,lsaSize = 4(4카드마다 하나의 LSA 도메인),nRanks = 8. 이제ncclTeamRail가 어떻게 구성되는지 보자:

📎 src/nccl_device/core.cc:70-79에서,nRanks = 8 / 4 = 2,rank = comm->rank / 4,stride = 4. 현재 rank가 5라면 Rail 팀에서의rank = 5 / 4 = 1,stride = 4, 즉 Rail 팀의 멤버는 world의 rank 1과 rank 5라는 뜻이다.

이제ncclTeamRankToWorld의 환산 공식을 보자:

📎 src/nccl_device/core.cc:82-84의comm->rank + (rank - team.rank) * team.stride는상대 오프셋계산이다. 먼저 대상 rank가 현재 rank에 대해 팀 내에서 가지는 오프셋(rank - team.rank)을 계산하고, 여기에 보폭stride을 곱한 뒤 현재 rank의 world 번호를 더한다. 이 공식은 모든 팀에 통용되는데,stride가 이미 팀의 배열 규칙을 인코딩하고 있기 때문이다.

ncclTeamRankToLsa는 다르다:

📎 src/nccl_device/core.cc:87-92는comm->devrState.lsaSelf + (rank - team.rank) * team.stride을 사용한다. 여기서는lsaSelf가 아니라comm->rank을 사용한다는 점에 주목하라. LSA 번호는 디바이스 측 자원 초기화 후에야 알 수 있고, world rank와 다를 수 있기 때문이다.

mermaid
flowchart TD
    start["用户调用 ncclTeamRail(comm)"] --> init{"ncclDevrInitOnce(comm)<br/>成功?"}
    init -->|"否"| empty["返回 ncclTeam_t{}<br/>空团队"]
    init -->|"是"| calc["计算 nRanks = comm->nRanks / lsaSize<br/>rank = comm->rank / lsaSize<br/>stride = lsaSize"]
    calc --> ret["返回 ncclTeam_t"]
    empty --> caller["调用方继续<br/>下一个 API 会报错"]
    ret --> caller

이 그림은 "지연 오류 보고" 전략의 실행 경로를 보여준다. 초기화 실패 시 빈 팀을 반환하지만 호출자를 중단시키지 않으며, 오류는 다음에 실제로 자원이 필요한 API(예:ncclLsaBarrierCreateRequirement)에서 드러난다.

설계 고찰과 함정

왜ncclTeamWorld는ncclDevrInitOnce?를 호출하지 않는가? World 팀의 정보는 전적으로 호스트 측에서 오기 때문이다comm, 어떤 장치 측 자원도 필요하지 않습니다. 강제로 호출하면 순수 host 쿼리 작업이 장치 측 초기화에 의존하게 되어 불필요한 실패 지점이 늘어납니다.

함정 포인트:ncclTeamRankToLsa초기화 실패 시 반환-1(📎 src/nccl_device/core.cc:87-92), 그리고ncclTeamRankToWorld는 절대 실패하지 않습니다. 호출자가 이 두 함수를 혼용하면서 반환값을 확인하지 않으면, LSA 초기화 실패 시-1를 합법적인 rank로 사용하여 범위를 벗어난 접근이 발생할 수 있습니다. 프로덕션 코드에서는ncclTeamRankToLsa의 반환값을 실패할 수 있는 작업으로 처리해야 합니다.

---

2. Barrier 요구사항 선언: host 측에서 장치 자원을 어떻게 "예약"하는가

직관적 모델

장치 측 API의 자원 할당은회의실 예약과 같습니다: 회의실에 바로 뛰어들어 회의할 수 없고, 먼저 프런트(host 측CreateRequirement)에 신청서를 제출해야 합니다 — "회의 3건, 각 8명 참석". 프런트는 이를 바탕으로 필요한 공간 크기(bufferSize), 필요한 의자 수(ginSignalCount)를 계산한 뒤 공간 번호(outBufferHandle)를 부여합니다. 이러한 예약 메커니즘이 없으면 장치 측 kernel은 자신의 barrier 버퍼가 어디에 있고 얼마나 큰지 알 수 없어 안전하게 읽고 쓸 수 없습니다.

데이터 구조와 메모리 레이아웃

세 가지 barrier의CreateRequirement함수는 동일한 패턴을 공유합니다:요구사항 구조체 초기화 → 버퍼 크기/정렬 채우기 → 출력 핸들 포인터 채우기. 하지만 자원 유형은 다릅니다:

Barrier 유형자원 유형크기 공식정렬
LSA Barrier버퍼(3*n + n*team.nRanks) * sizeof(uint32_t)alignof(uint32_t)
CFT Barrier버퍼(3*n + n*team.nRanks) * NCCL_CFT_BARRIER_GRANNCCL_CFT_BARRIER_ALIGN
GIN BarrierGIN 신호n * team.nRanks개 신호버퍼 미포함

먼저 LSA Barrier의 크기 공식을 보겠습니다:

📎 src/nccl_device/lsa_barrier.cc:14-22의(3 * nBarriers + nBarriers * team.nRanks) * sizeof(uint32_t)는 두 부분으로 분해할 수 있습니다:

  • 3 * nBarriers: 각 barrier에 3개의uint32_t제어 필드가 필요합니다 ([INFERENCE] 일반적으로 "도달 카운트", "라운드", "상태 플래그").
  • nBarriers * team.nRanks: 각 barrier는 팀 내 각 멤버를 위해 하나의uint32_t도달 슬롯을 예약해야 합니다.

따라서 단일 barrier의 총 크기는3 + team.nRanks개uint32_t입니다. 이 공식은 LSA와 CFT에서 완전히 동일하며, 다만 CFT는NCCL_CFT_BARRIER_GRAN를 세분화 단위로 사용합니다 (더 큰 경계로 정렬하기 위한 것일 수 있습니다).

GIN Barrier는 완전히 다릅니다:

📎 src/nccl_device/gin_barrier.cc:14-20는 버퍼를 할당하지 않고ginSignalCount = nBarriers * team.nRanks를 설정하며,outGinSignalStart를 핸들 내의signal0로 지정합니다. 이는 GIN barrier가 네트워크 신호 경로를 사용하여 공유 메모리 버퍼가 필요 없고, NIC가 인식할 수 있는 신호 슬롯이 필요하기 때문입니다.

시나리오 기반 Walkthrough: LSA Barrier의 완전한 예약 과정

사용자가 4카드 LSA 팀에서 2개의 barrier를 생성한다고 가정합니다:

1. 호출 ncclLsaBarrierCreateRequirement(team, 2, &handle, &req)。

2. 초기화:memset(outReq, 0, sizeof(*outReq))(📎 src/nccl_device/lsa_barrier.cc:14-22) — 설정되지 않은 필드가 확정값이 되도록 보장하여 호출자가 스택의 쓰레기 값을 읽는 것을 방지합니다.

3. barrier 수 기록:outHandle->nBarriers = 2(📎 src/nccl_device/lsa_barrier.cc:14-22)。

4. 버퍼 크기 계산:(3*2 + 2*4) * 4 = (6 + 8) * 4 = 56바이트 (📎 src/nccl_device/lsa_barrier.cc:14-22)。

5. 정렬 설정:alignof(uint32_t) = 4(📎 src/nccl_device/lsa_barrier.cc:14-22)。

6. 핸들 포인터 역채움:outReq->outBufferHandle = &outHandle->bufHandle(📎 src/nccl_device/lsa_barrier.cc:14-22) — NCCL이 실제로 버퍼를 할당한 후 주소를 핸들에 다시 쓰도록 합니다.

mermaid
flowchart LR
    subgraph host["host 侧声明阶段"]
        req["ncclLsaBarrierCreateRequirement<br/>team, nBarriers=2"]
        calc["bufferSize = (3*2 + 2*4)*4 = 56<br/>bufferAlign = 4"]
        handle["outHandle->nBarriers = 2<br/>outReq->outBufferHandle = &handle->bufHandle"]
    end
    subgraph dev["device 侧消费阶段"]
        buf["缓冲区 56 字节<br/>3 控制字段 + 4 到达槽位"]
        bar["ncclLsaBarrier 实例"]
    end
    req --> calc --> handle
    handle -.->|"NCCL 分配后回填"| buf
    buf --> bar

이 데이터 흐름도는 "선언"과 "소비"의 분리를 보여줍니다: host 측은 크기와 포인터만 계산하고, 실제 버퍼 할당과 인스턴스화는 NCCL 내부에서 발생하며, device 측 kernel은 이미 채워진 핸들을 받습니다.

설계 고찰과 함정

왜memset로 전체outReq?를 초기화하는가? 왜냐하면ncclDevResourceRequirements_t는 다중 필드 구조체이고, 각 barrier 유형은 그중 일부 필드만 채웁니다. 초기화는 사용되지 않는 필드(예: LSA barrier가 사용하지 않는ginSignalCount)가 0이 되도록 보장하며, NCCL 내부는 이를 기반으로 "이 자원은 필요 없다"고 판단합니다. 초기화하지 않으면 스택의 임의 값이 "GIN 자원 필요"로 오인되어 이전 장에서 언급한 오탐 문제가 발생할 수 있습니다.

함정 포인트:outReq->outBufferHandle = &outHandle->bufHandle는 핸들 내부 필드의 주소를 NCCL에 넘겼습니다. 이는outHandle가 NCCL이 버퍼 할당을 완료할 때까지 유효해야 함(스택 회수나 이동이 불가능)을 의미합니다. 사용자가outHandle를 조기에 해제되는 스코프에 두면, NCCL이 역채움할 때 댕글링 포인터에 쓰게 됩니다.

〔설계 추론과 아키텍처 트레이드오프〕

CFT Barrier의 세분화 차이:📎 src/nccl_device/cft_barrier.cc:13-21는NCCL_CFT_BARRIER_GRAN와NCCL_CFT_BARRIER_ALIGN로 LSA의sizeof(uint32_t)와alignof(uint32_t)를 대체했습니다. 이는 CFT(Cross-Fabric Team 또는 유사한 크로스 도메인 팀일 수 있음)의 barrier가 더 큰 정렬 세분화를 필요로 함을 보여주며, 여러 멀티캐스트 메모리 영역을 넘어야 하기 때문에 하드웨어가 주소 정렬에 더 엄격한 요구사항을 가질 수 있습니다.

---

3. 세 가지 Barrier의 의미론적 분업: LSA, CFT, GIN이 각각 담당하는 것

직관적 모델

세 가지 barrier는 세 가지 다른 범위의 "집합 신호"와 같습니다:

  • LSA Barrier: 같은 사무실 내 동료 집합, 공유 메모리 사용, 가장 빠름.
  • CFT Barrier: 사무실을 넘지만 같은 건물 내 집합, 멀티캐스트 메모리 사용, 중간.
  • GIN Barrier: 도시를 넘거나 국가를 넘는 집합, 네트워크 신호 사용, 가장 느리지만 가장 넓은 범위.

잘못된 barrier 유형을 선택해도 오류는 발생하지 않지만 막대한 성능 손실을 초래합니다 — GIN barrier로 같은 사무실 동기화를 하는 것은 옆 자리 문서를 국제 택배로 보내는 것과 같습니다.

데이터 구조와 메모리 레이아웃 비교

host 측 요구사항 선언 관점에서 보면 세 가지의 자원 요구사항은 완전히 다릅니다:

차원LSA BarrierCFT BarrierGIN Barrier
필요comm매개변수아니오아니오예
버퍼있음있음없음
GIN 신호없음없음있음
크기 단위uint32_tNCCL_CFT_BARRIER_GRAN신호 개수
출력 핸들 필드bufHandlebufHandlesignal0

GIN Barrier는 유일하게comm매개변수가 필요한 것에 주목하세요:

📎 src/nccl_device/gin_barrier.cc:14-20의 함수 시그니처에는ncclComm_t comm가 포함되지만, LSA와 CFT의 시그니처에는ncclTeam_t team. 이는 GIN 신호가 구체적인 네트워크 연결에 바인딩되어야 하기 때문이며, 네트워크 연결 정보는comm에 있습니다.

시나리오 기반 Walkthrough: GIN Barrier의 신호 할당

📎 src/nccl_device/gin_barrier.cc:14-20의 로직은 LSA보다 더 단순하지만, 시맨틱은 더 미묘합니다:

1. 클리어:memset(outReq, 0, sizeof(*outReq))(L16)。

2. 신호 수 설정:outReq->ginSignalCount = nBarriers * team.nRanks(L17) — 각 barrier는 팀 내 각 멤버를 위해 하나의 신호 슬롯을 할당해야 합니다.

3. 신호 시작 포인터 백필:outReq->outGinSignalStart = &outHandle->signal0(L18) — 여기서는bufferSize를 설정하지 않았다는 점에 주목하세요. GIN barrier는 공유 메모리 버퍼를 사용하지 않기 때문입니다.

〔설계 추론 및 아키텍처 트레이드오프〕

signal0이 이름은 핸들 안에 연속된 신호 필드 그룹이 있을 수 있음을 암시합니다(signal0, signal1, ...),outGinSignalStart는 첫 번째를 가리키며, NCCL은 이를 기반으로 어디서부터nBarriers * team.nRanks개의 신호를 할당하기 시작할지 알 수 있습니다.

동시성 제어와 하드웨어 상호작용

세 가지 barrier의 동시성 제어 메커니즘은 완전히 다릅니다:

  • LSA Barrier: 공유 메모리 기반 원자적 연산.3 + team.nRanks개의uint32_t중, 도달 슬롯은 원자적 더하기 또는 원자적 쓰기로 「내가 도착했다」를 표시하고, 제어 필드는 원자적 읽기로 「모두 도착했는가」를 확인합니다. 이는 순수 GPU 내부 동기화이며, 네트워크를 포함하지 않습니다.
  • CFT Barrier: 멀티캐스트 메모리(multimem) 기반. [INFERENCE] 멀티캐스트 메모리는 한 번의 쓰기 연산으로 여러 rank의 뷰를 동시에 갱신할 수 있으므로, CFT barrier는 더 적은 제어 필드로 더 넓은 동기화를 구현할 수 있습니다.
  • GIN Barrier: 네트워크 신호 기반.ginSignalCount개의 신호가 네트워크 카드를 통해 전송되고, 수신 측은 신호 슬롯을 폴링합니다. 이는 유일하게 크로스 머신 하드웨어를 포함하는 barrier입니다.
mermaid
sequenceDiagram
    participant K as "用户 Kernel"
    participant LSA as "LSA 共享内存"
    participant CFT as "CFT 多播内存"
    participant NIC as "网卡 GIN 信号"
    K->>LSA: "原子写到达槽位"
    LSA-->>K: "轮询所有槽位"
    Note over K,LSA: LSA barrier 完成
    K->>CFT: "多播写控制字段"
    CFT-->>K: "读多播状态"
    Note over K,CFT: CFT barrier 完成
    K->>NIC: "发送 GIN 信号"
    NIC-->>K: "轮询信号槽位"
    Note over K,NIC: GIN barrier 完成

이 시퀀스 다이어그램은 세 가지 barrier의 하드웨어 상호작용 계층을 보여줍니다: 순수 GPU 내부 동기화에서 멀티캐스트 메모리, 그리고 네트워크 카드 신호로 갈수록 지연이 순차적으로 증가하고, 커버 범위도 순차적으로 확대됩니다.

설계 고찰과 함정

왜 LSA와 CFT는comm파라미터가 필요 없는가?그들의 리소스(공유 메모리, 멀티캐스트 메모리)는 이미ncclDevrInitOnce단계에서 팀에 바인딩되었기 때문이며,team자체에 리소스 위치 정보가 내포되어 있습니다. 반면 GIN 신호는 네트워크 리소스를 동적으로 할당해야 하므로, 반드시comm를 통해 네트워크 연결 상태에 접근해야 합니다.

함정 포인트: GIN Barrier의ginSignalCount는nBarriers * team.nRanks입니다. 만약 팀이 매우 크고(예: 1024개 rank) barrier가 많으면(예: 100개), 신호 총수가 102400에 달할 수 있습니다. 네트워크 카드의 신호 슬롯은 한정된 리소스이므로, 과도한 신청은ncclDevrInitOnce실패를 초래할 수 있습니다. 프로덕션 코드는 실제로 필요한 최소 barrier 수에 따라 신청해야 하며, 한 번에 대량의 예비분을 신청해서는 안 됩니다.

---

4. 요구사항 선언에서 디바이스 측 소비까지: 전체 라이프사이클

직관적 모델

CreateRequirement은 단지 「주문」일 뿐이며, 실제 「발송」과 「수령」은 NCCL 내부와 디바이스 측 kernel에서 발생합니다. 전체 라이프사이클은온라인 쇼핑과 같습니다: 주문(CreateRequirement) → 판매자 재고 준비(NCCL 리소스 할당) → 택배 배송(리소스를 DevComm에 바인딩) → 수령 및 사용(디바이스 측 kernel에서 barrier 호출).

데이터 구조와 메모리 레이아웃: 핸들의 필드 진화

를 예로 들면,ncclLsaBarrierHandle_t은 라이프사이클에서 세 단계를 거칩니다:

단계nBarriersbufHandle기타 필드
CreateRequirement 후설정됨주소는 백필되었으나 내용은 할당되지 않음설정되지 않음
NCCL 할당 후설정됨실제 버퍼를 가리킴설정됨
디바이스 측 사용읽기 전용읽기 전용읽기 전용

📎 src/nccl_device/lsa_barrier.cc:14-22설정nBarriers,📎 src/nccl_device/lsa_barrier.cc:14-22백필bufHandle의 주소. 이 두 작업 사이에서 NCCL 내부가 버퍼의 실제 할당을 완료합니다.

시나리오 기반 Walkthrough: 완전한 barrier 사용 한 번

1. Host 측 선언: 사용자가ncclLsaBarrierCreateRequirement(team, 2, &handle, &req)를 호출하여req.bufferSize = 56。

2. 를 얻음Host 측 제출req: 사용자가ncclDevCommCreate를handle.bufHandle。

3. 에 전달(이전 장 내용), NCCL이 56바이트 버퍼를 할당하고 주소를에 기록handleDevice 측 초기화bufHandle: 사용자 kernel 시작 시 DevComm에서

4. 를 꺼내고,로 버퍼를 위치시킴ncclLsaBarrier(handle, barrierIndex)Device 측 동기화

5. : kernel이를 호출하여 버퍼의 해당 슬롯에 도달 마커를 쓰고, 다른 슬롯을 폴링

mermaid
flowchart TD
    a["ncclLsaBarrierCreateRequirement<br/>算出 bufferSize=56"] --> b["ncclDevCommCreate<br/>分配 56 字节缓冲区"]
    b --> c{"分配成功?"}
    c -->|"否"| err["返回 ncclSystemError<br/>句柄无效"]
    c -->|"是"| d["回填 handle.bufHandle<br/>指向实际缓冲区"]
    d --> e["用户 kernel 启动<br/>从 DevComm 取 handle"]
    e --> f["ncclLsaBarrier(handle, idx)<br/>写到达槽位 + 轮询"]
    f --> g{"所有 rank 到达?"}
    g -->|"否"| f
    g -->|"是"| h["barrier 返回<br/>kernel 继续"]
    err --> i["用户需检查返回值<br/>不可使用无效句柄"]

: 모든 rank가 도달하면 barrier가 반환되고 kernel이 계속 실행ncclLsaBarrierCreateRequirement복사ncclSuccess(📎 src/nccl_device/lsa_barrier.cc:14-22이 결정 다이어그램은 선언에서 사용까지의 전체 경로와 할당 실패 시의 오류 분기를 보여줍니다. 주목할 점은

자체는 항상

를 반환하며, 실제 실패는 이후 리소스 할당 단계에서 발생합니다.동시성 제어와 하드웨어 상호작용디바이스 측 barrier의 동시성 제어 핵심은

  • 원자적 연산 + 메모리 배리어입니다. LSA barrier를 예로 들면:
  • 도달 단계: 각 rank가 원자적 쓰기(또는 원자적 더하기)로 자신의 도달 슬롯을 갱신합니다. 이 단계는 반드시 release 시맨틱을 사용하여 barrier 이전의 모든 메모리 연산이 다른 rank에 가시적이도록 보장해야 합니다.
  • 폴링 단계: 각 rank가 원자적 읽기(또는 volatile 읽기)로 모든 슬롯을 확인합니다. 이 단계는 반드시 acquire 시맨틱을 사용하여 「모두 도착했다」를 확인한 후 다른 사람이 barrier 이전에 쓴 데이터를 읽을 수 있도록 보장해야 합니다.
리셋 단계

3 * nBarriers여러 제어 필드가 바로 이런 '라운드' 문제를 처리하는 데 사용될 가능성이 높다: 하나의 필드는 현재 라운드를 기록하고, 하나의 필드는 도달 카운트를 기록하며, 하나의 필드는 리셋 플래그 역할을 한다. 이렇게 하면 여러 barrier가 동일한 슬롯 그룹을 재사용하면서도 라운드가 혼동되지 않는다.

프로덕션 함정 회피 가이드

함정 1: 핸들 수명 주기 관리。outReq->outBufferHandle = &outHandle->bufHandle핸들 내부 필드의 주소를 NCCL에 넘겼다. 만약 사용자가ncclDevCommCreate반환 전에outHandle를 파괴하면, NCCL이 값을 되쓸 때 이미 해제된 메모리에 쓰게 된다. 올바른 방법은outHandle의 수명 주기를 이를 생성한 함수 스코프가 아니라 DevComm에 바인딩하는 것이다.

함정 2: barrier 수와 팀 크기의 곱。bufferSize = (3*n + n*team.nRanks) * sizeof(uint32_t)에서n*team.nRanks항목은 팀이 클 때 크기를 지배한다. 1024개 rank, 100개 barrier면100*1024*4 = 409600바이트, 약 400KB가 필요하다. 각 rank가 이만큼을 요청하면 VRAM 압박을 무시할 수 없다. 총 barrier 수가 아니라 실제 동시 사용되는 barrier 수에 맞춰 요청해야 한다.

함정 3: GIN barrier의 시그널 고갈. GIN 시그널은 NIC 자원이며 수량이 제한적이다. 여러 DevComm이 동시에 대량의 GIN 시그널을 요청하면 NIC 슬롯이 고갈될 수 있다. 프로덕션 코드는 DevComm 생성 실패 시 GIN 시그널 부족 여부를 확인하고nBarriers를 줄이거나 LSA barrier로 전환하는 것을 고려해야 한다.

함정 4: 초기화 실패의 지연 노출。ncclTeamLsa등의 함수는ncclDevrInitOnce실패 시 빈 팀(📎 src/nccl_device/core.cc:22-33)을 반환하며 오류를 보고하지 않는다. 사용자 코드가 이후 API의 반환값을 확인하지 않으면 빈 팀에서 계속 작업하여 원인 파악이 어려운 오류가 발생할 수 있다. 디바이스 측 API를 처음 사용할 때 팀의 유효성을 명시적으로 확인하는 것이 좋다(예:team.nRanks > 0)。

---

5. 커널 융합: 왜 통신과 계산을 하나의 kernel에 넣어야 하는가

직관적 모델

전통적 방식에서 한 번의 'AllReduce + 활성화 함수'에는 두 개의 kernel이 필요하다: 하나는 통신, 하나는 계산. 두 kernel 사이에는 암묵적 전역 동기화가 한 번 있다——통신 kernel이 완전히 끝나야 계산 kernel이 시작될 수 있다. 이는 마치계주와 같다: 첫 주자가 달린 후 반드시 바통을 두 번째 주자에게 넘겨야 하고, 교대 순간 둘 다 기다린다. 커널 융합은 동일한 kernel이 통신과 계산을 모두 수행하게 하여, 마치한 사람이 달리면서 신발을 갈아 신는 것처럼 교대 대기를 없앤다.

데이터 구조와 메모리 레이아웃

커널 융합의 핵심은 통신 프리미티브(예: barrier)와 계산 로직이 동일한 kernel의 레지스터와 공유 메모리를 공유한다는 것이다. 이는 다음을 의미한다:

  • 레지스터 압박: 통신 프리미티브의 원자적 연산과 폴링 루프가 레지스터를 점유하여 계산 로직의 레지스터 예산을 압박한다.
  • 공유 메모리 경쟁: LSA barrier의 버퍼가 공유 메모리에 있으면 계산 로직의 공유 메모리 요구와 경쟁한다.
  • Occupancy 영향: 융합 kernel의 occupancy는 일반적으로 순수 계산 kernel보다 낮은데, 통신 프리미티브가 추가 자원을 필요로 하기 때문이다.
〔설계 추론과 아키텍처 트레이드오프〕

디바이스 측 API의 설계(host 측에서 자원 선언, device 측에서 소비)는 바로 이러한 압박을 완화하기 위한 것이다: 자원을 host 측에서 미리 할당하면 device 측 kernel은 읽기/쓰기만 하면 되고 동적 할당이 필요 없어 레지스터 점유가 줄어든다.

시나리오 기반 Walkthrough: 융합 kernel의 실행 흐름

사용자가 'AllReduce + ReLU' 융합 kernel을 작성한다고 가정하자:

1. Host 측 준비:ncclLsaBarrierCreateRequirement를 호출하여 barrier를 요청하고,ncclDevCommCreate를 호출하여 자원을 할당한다.

2. Kernel 시작: 사용자 kernel이 DevComm과 barrier 핸들을 매개변수로 받는다.

3. 통신 단계: kernel 내에서ncclLsaBarrier를 호출하여 모든 rank를 동기화한 후, 각 rank가 데이터를 교환한다(대칭 메모리를 통한 직접 읽기/쓰기).

4. 계산 단계: 동기화 완료 후 kernel이 로컬 데이터에 직접 ReLU를 수행하며, 추가 kernel 시작이 필요 없다.

5. 완료: kernel이 종료되고, host 측은 추가 통신 kernel을 기다릴 필요가 없다.

mermaid
flowchart LR
    subgraph old["传统模式:两个 kernel"]
        k1["通信 kernel<br/>AllReduce"] --> sync["隐式全局同步<br/>kernel 边界"]
        sync --> k2["计算 kernel<br/>ReLU"]
    end
    subgraph fused["融合模式:一个 kernel"]
        f1["通信阶段<br/>ncclLsaBarrier + 数据交换"]
        f1 --> f2["计算阶段<br/>ReLU"]
    end
    old -.->|"融合后省掉"| fused

이 비교 그림은 융합의 핵심 이점을 보여준다: kernel 경계에서의 암묵적 전역 동기화를 제거한다. 전통적 방식에서 이 동기화의 비용은 두 번의 kernel 시작 지연에 GPU 파이프라인 드레인을 더한 것이다.

설계 고찰과 함정

왜 디바이스 측 API는 '융합 AllReduce'를 직접 제공하지 않는가?융합의 구체적 형태는 사용자의 계산 로직에 달려 있기 때문이다. NCCL이 제공하는 것은프리미티브(barrier, 시그널, 대칭 메모리 접근)이지완성품(융합된 AllReduce+ReLU)이 아니다. 사용자가 이 프리미티브들을 직접 조합해야 자신의 요구에 맞는 융합 kernel을 구현할 수 있다. 이것이 '라이브러리'가 아닌 '프로그래밍 모델'의 본질적 차이다.

함정 포인트: 융합 kernel의 디버깅 난이도는 분리 kernel보다 훨씬 높다. 만약 barrier 로직에 버그가 있으면 kernel이 hang(교착 상태)될 수 있는데, GPU kernel hang은 host 프로세스 hang처럼 진단하기 쉽지 않다. 융합 kernel에 타임아웃 메커니즘을 추가하거나, 먼저 소규모 팀으로 barrier 로직을 검증하는 것을 권장한다.

함정 포인트: 융합 kernel의 occupancy 하락은 통신으로 절약한 이득보다 계산 성능 손실이 더 클 수 있다. 융합을 결정하기 전에 통신 지연 감소만 보지 말고 융합 전후의 엔드투엔드 시간을 측정해야 한다.

이 장의 생각과 자가 점검

Q1: 만약ncclTeamLsa의 L26에 있는ncclDevrInitOnce호출을 제거하고, 직접comm->devrState.lsaSize과lsaSelf을 반환하면, 어떤 시나리오에서 디바이스 측 kernel이 잘못된 팀 정보를 읽게 되는가?

참고 해석:ncclDevrInitOnce은 디바이스 측 리소스 초기화의 멱등 진입점이다. 만약 이것을 제거하면,comm->devrState.lsaSize과lsaSelf은 여전히 초기값(보통 0 또는 미정의)일 수 있다. 디바이스 측 API를 처음 사용하는 시나리오에서, 사용자가ncclTeamLsa을 호출하면nRanks = 0의 빈 팀을 얻게 된다. 이후 사용자가 팀 유효성을 검사하지 않고 이 팀으로 바로ncclLsaBarrierCreateRequirement을 호출하면,bufferSize = (3*n + n*0) * 4 = 12n바이트를 계산하게 되는데——실제 필요한 것보다 작다. 왜냐하면n*team.nRanks항목이 0이 되기 때문이다. 이로 인해 버퍼 오버플로가 발생한다: barrier 런타임이team.nRanks개의 도착 슬롯에 쓰려고 시도하지만, 버퍼에는3n개의uint32_t공간만 할당되어 있다. 더 은밀한 것은, 만약lsaSelf도 0이면,ncclTeamRankToLsa이 잘못된 rank 번호를 반환하여 barrier의 도착 슬롯이 잘못된 위치에 쓰이게 되고, 모든 rank가 도착하기를 영원히 기다리지 못해 kernel이 hang될 수 있다. 이것이 바로 L23-25 주석에서 말한 「쓰레기 값을 반환하고, 다음 API에서 오류를 보고한다」 전략이 방지하려는 상황이다——단, 전제는 다음 API가 실제로 오류를 보고해야 하며, 조용히 잘못된 크기를 사용해서는 안 된다.

Q2:ncclLsaBarrierCreateRequirement의 크기 공식은(3*nBarriers + nBarriers*team.nRanks) * sizeof(uint32_t)이다. 만약 팀에 8개의 rank가 있고, 사용자가 1개의 barrier를 신청하면, 버퍼는 44바이트이다. barrier 구현에서 「3개의 제어 필드」가 각각 「도착 카운트」「라운드」「리셋 플래그」라고 가정하고 추론해보자: 8개의 rank가 동시에 도착할 때, 만약 「도착 카운트」가 비원자적++연산을 사용하면 무슨 일이 발생하는가?

참고 해석: 비원자적++은 GPU에서 「읽기-수정-쓰기」 3단계이며, 원자적 연산이 아니다. 8개의 rank가 동시에count++을 실행할 때, 여러 rank가 동일한 이전 값(예: 모두 0)을 읽고 모두 1로 다시 쓸 수 있다. 최종적으로count은 8이 아니라 1만 증가하여, barrier가 영원히 「아직 다 도착하지 않았다」고 판단하게 되고, 모든 rank가 폴링 단계에서 무한 루프에 빠진다. 이것이 바로 LSA barrier의 도착 슬롯이 반드시 원자적 연산(예:atomicAdd)을 사용하거나 각 rank가 자신의 독립 슬롯에 쓰는 방식(nBarriers * team.nRanks항목이 바로 각 rank를 위해 독립 슬롯을 예약하는 것)을 사용해야 하는 이유이다. 만약 「각 rank가 자신의 슬롯에 쓰는」 방식을 채택하면, 원자적 증가는 필요 없고 원자적 쓰기 + 메모리 배리어만 필요하다. 왜냐하면 각 슬롯에는 작성자가 하나뿐이기 때문이다. 이것은 또한 크기 공식에nBarriers * team.nRanks항목이 있는 이유를 설명한다——공간을 사용해 원자성을 확보하고, 다중 작성자 경쟁을 피하는 것이다.

Q3:ncclGinBarrierCreateRequirement은comm파라미터가 필요하지만ncclLsaBarrierCreateRequirement은 필요하지 않다. 만약 LSA barrier에도 억지로comm파라미터를 추가한다면(인터페이스 통일을 위해 가정), 어떤 설계 문제가 발생하는가? 반대로, GIN barrier에서comm파라미터를 제거하면 어떤 시나리오에서 실패하는가?

참고 해석: LSA barrier에comm파라미터를 추가하는 문제는 불필요한 의존성을 도입한다는 것이다. LSA barrier의 리소스(공유 메모리)는 이미ncclDevrInitOnce단계에서 팀에 바인딩되어 있으며,team자체가 리소스 위치를 암시한다.comm을 추가하면 순수한 팀 연산이 통신 도메인 상태에 의존하게 되어 실패 지점이 늘어나고(예:comm이 유효하지 않으면 LSA barrier도 생성할 수 없음), 「최소 권한」 원칙을 위반한다. 반대로, GIN barrier에서comm파라미터를 제거하면 실패한다. 왜냐하면 GIN 신호는 구체적인 네트워크 연결에 바인딩되어야 하기 때문이다.ncclGinBarrierCreateRequirement의ginSignalCount은 어느 네트워크 카드, 어느 QP(Queue Pair)로 신호를 보낼지 알아야 하며, 이 정보는comm의 네트워크 전송 계층 상태에 있다.comm이 없으면 NCCL은 신호가 어느 네트워크 카드의 슬롯에 할당되어야 하는지 결정할 수 없고, 신호가 대상 rank로 올바르게 라우팅된다는 것도 보장할 수 없다. 이것은 디바이스 측 API의 설계 원칙을 보여준다:리소스 요구 선언은 그것이 진정으로 필요로 하는 컨텍스트에만 의존한다——LSA는 팀 토폴로지만 필요하고, GIN은 네트워크 연결이 필요하다.

---

디바이스 측 API와 커널 융합은 NCCL을 「당신이 호출하는 라이브러리」에서 「당신이 프로그래밍하는 모델」로 바꾸었다.ncclTeam_t은 좌표계를 제공하고,CreateRequirement은 리소스 예약 메커니즘을 제공하며, 세 가지 barrier는 공유 메모리부터 네트워크 신호까지 모든 동기화 범위를 커버한다. 하지만 리소스를 선언하고 융합 kernel을 작성했다고 해서 성능이 좋은 것은 아니다——barrier의 수, 팀의 크기, 융합의 입자도, 각각의 선택이 엔드투엔드 성능에 영향을 미친다. 다음 장에서는 성능 튜닝 실전으로 들어가, tuning 파라미터가 알고리즘 선택에 어떻게 영향을 미치는지, 그리고 실제 benchmark로 튜닝 효과를 어떻게 검증하는지 살펴볼 것이다.

여기까지 우리는 devcomm 메타데이터 매핑에서 nccl_device 디바이스 측 프리미티브까지의 전 과정을 살펴보았고, NCCL이 'host 선언, device 소비' 모델을 통해 사용자 kernel이 barrier류 동기화 연산을 직접 호출하여 통신과 계산을 동일한 kernel에 융합하는 방식을 확인했습니다. 하지만 이러한 메커니즘을 파악한 후에는 더 실질적인 문제가 자연스럽게 떠오릅니다: 실제 훈련 작업의 성능이 기준에 미치지 못할 때, 알고리즘 선택이 부적절한지, 프로토콜이 맞지 않는지, 아니면 채널 수 설정이 합리적이지 않은지를 어떻게 판단할 것인가? 다음 장에서는 앞 20개 장의 메커니즘을 하나의 실행 가능한 튜닝 방법론으로 엮어, 성능 보고서, 비용 모델, 환경 변수를 결합하여 현상에서 근본 원인까지의排查 경로를 제시합니다.

CHAPTER 21

제21장: 제21장: 성능 튜닝 실전: tuning 실습, benchmark 도구 및 튜닝 방법론

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제21 / 25장

제21장: 성능 튜닝 실전: tuning 실습, benchmark 도구 및 튜닝 방법론

이전 장에서 우리는 사용자 정의 kernel이 디바이스 측 API와 NCCL 통신 프리미티브를 통해 협력하고, 심지어 통신과 계산을 동일한 kernel에 융합하는 방법을 살펴보았습니다. 이는 NCCL을 프로그래밍 모델로서의 가능성을 열어주었지만, 동시에 현실적인 문제를 제기합니다: 통신 성능이 예상에 미치지 못할 때 어디서부터 시작해야 하는가? NCCL은 수백 개의 NCCL_PARAM을 노출하지만, 실제로 한 번의 집합 통신이 어떤 경로를 탈지 결정하는 것은 사실 세 개의 손잡이뿐입니다: 알고리즘(Algo), 프로토콜(Proto), 채널 수(nChannels). 이 장에서는 앞 20개 장의 메커니즘을 하나의 실행 가능한排查 경로로 엮습니다——먼저 성능 보고서를 보고 현상을 파악하고, 다음으로 비용 모델을 읽어 NCCL이 스스로 어떻게 선택하는지 이해하고, 마지막으로 환경 변수와 benchmark로 가설을 검증합니다.

21.1 성능 보고서: 먼저 '정상' 기준선을 세우자

튜닝의 첫 단계는 파라미터를 바꾸는 것이 아니라 '정상'이 어떤 모습인지 아는 것입니다. 현재 시스템의 피크 대역폭이 얼마인지조차 모른다면, 어떤 파라미터 조정도 맹목적인 추측일 뿐입니다.

NCCL 공식은docs/perf아래에 참조 성능 데이터를 발표하는데, 그 위치는 매우 명확합니다——제품급 보장이 아니라 기대치를 정렬하기 위한 참조점입니다.

📎 docs/perf/README.md:3-14

code
NCCL publishes reference performance data to:

1. Provide reference points that help users align performance expectations.
2. Help users validate their system setup.
3. Reduce repeated requests to the NCCL team for basic performance numbers.

These results are references, and NOT product-level guarantees that the same
performance is achievable on every system. Performance depends on a complex
combination of software versions, system configuration, hardware, and operating
conditions, including factors outside NCCL's control. A difference within 5% is
generally considered acceptable variance due to differences in the underlying
systems.

여기에는 초보자가 놓치기 쉬운 두 가지 핵심 정보가 있습니다:

첫째,5% 이내의 차이는 정상 변동입니다. 이는 공식보다 3% 낮게 측정되었을 때, 서둘러 파라미터를 조정하지 말라는 의미입니다——먼저 측정 노이즈, GPU 클럭 지터, 또는 이웃 작업 간섭인지 확인하세요.

둘째,공식은 피크 대역폭만 발표하고 지연 시간은 발표하지 않습니다。

📎 docs/perf/README.md:24-24

code
We publish peak bandwidth for a selection of commonly used platforms. We do not
currently publish latency because it is typically more sensitive to factors
outside NCCL's control.
〔설계 추론과 아키텍처 트레이드오프〕

왜 지연 시간을 발표하지 않을까? 지연 시간은 시스템 상태에 극도로 민감하기 때문입니다——CPU 주파수, PCIe 링크 상태, 네트워크 카드 펌웨어 버전, 심지어 BIOS의 전원 정책까지도 영향을 미칩니다. 대역폭은 큰 메시지에서 포화되어 상대적으로 안정적이고, 지연 시간은 작은 메시지에서 수많은 미세한环节이 중첩되어 만들어지므로 어느 한环节이라도 흔들리면 증폭됩니다. 따라서 튜닝 시,큰 메시지는 대역폭을 보고, 작은 메시지는 지연 시간을 본다는 두 가지 다른排查 경로입니다.

📎 docs/perf/README.md:24-24

code
If your workload differs significantly from the published results, open an
issue in the [NCCL repository](https://github.com/NVIDIA/nccl/issues) or contact
NVIDIA Support. We will try our best to help.

排查 순서의 첫 번째: 먼저 표준 benchmark(예:nccl-tests의all_reduce_perf)를 실행하고, 결과를 공식 보고서와 비교하세요. 차이가 5% 이내라면 시스템 구성에 문제가 없고 성능 병목이 애플리케이션 계층(예: 통신 빈도, 메시지 분할 방식)에 있다는 뜻입니다; 차이가 현저하면 그제서야 NCCL 파라미터 튜닝에 들어갑니다.

21.2 비용 모델: NCCL이 스스로 알고리즘과 프로토콜을 선택하는 방법

파라미터를 조정하려면 먼저 NCCL이 기본적으로 어떻게 선택하는지 이해해야 합니다. 내부에는 '비용 모델'(cost model)이 있는데, 본질적으로 테이블 조회 + 공식 계산입니다: 주어진 메시지 크기, 토폴로지 유형, rank 수에 대해 각 '알고리즘 × 프로토콜' 조합의 소요 시간을 추정하고 가장 작은 것을 선택합니다.

직관적 모델

비용 모델을 내비게이션 소프트웨어라고 상상해 보세요. 출발지와 목적지(메시지 크기, 토폴로지)를 입력하면 내부적으로 각 경로(알고리즘/프로토콜 조합)의 시간을 추정한 후 가장 빠른 것을 추천합니다. 내비게이션의 추정은 역사적 데이터와 도로 등급에 기반하고, NCCL의 추정은 하드코딩된 지연/대역폭 파라미터 테이블에 기반합니다.

이 모델이 없다면 NCCL은 모든 시나리오에 동일한 고정 알고리즘을 사용할 수밖에 없습니다——작은 메시지는 시작 오버헤드가 너무 커서 느려지고, 큰 메시지는 대역폭 활용이 부족하여 느려지며, 시스템은 양 극단 모두에서 나쁜 성능을 보일 것입니다.

데이터 구조: 모델 테이블과 튜닝 컨텍스트

비용 모델의 핵심은modelMap배열이며, 각 요소는 하나의 '알고리즘/프로토콜/대칭 커널' 조합에 대응합니다.

📎 src/tuning/cost_model.cc:230-277

code
static struct ncclTuningModelEntry_t modelMap[] = {
    /*
Initialize default, static models here
{mod_init, mod_sim, mod_final, enabled}
Enable order: Broadcast, Reduce, AllGather, ReduceScatter, AllReduce
*/
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/LL
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/LL128
  {ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}},       // Tree/Simple
  {ncclTuningRingModelInit, ncclTuningRingModelSim, nullptr, {1, 1, 1, 1, 1}},       // Ring/LL
  ...

각 항목에는 네 개의 필드가 있습니다:mod_init(초기화 함수),mod_sim(시뮬레이션 함수),mod_final(정리 함수),enabled(5개 함수 각각의 활성화 플래그).enabled배열의 순서는{Broadcast, Reduce, AllGather, ReduceScatter, AllReduce}입니다——이 순서를 기억하세요, 나중에 코드를 읽을 때 반복적으로 사용됩니다.

〔설계 추론과 아키텍처 트레이드오프〕

핵심 관찰:Tree는 AllReduce에서만 활성화({0,0,0,0,1}), Ring은 모든 함수에서 활성화됩니다({1,1,1,1,1}). 이는 Tree 알고리즘의 장점이 AllReduce의 reduce 단계에서 병렬화가 가능하다는 것이지만, AllGather/ReduceScatter처럼 본질적으로 링 파이프라인인 연산에는 Ring이 더 자연스럽기 때문입니다.

모델의 구체적인 파라미터는ncclTunerConstants_t에 존재하며, 각 토폴로지별 기본 지연 시간과 대역폭을 포함합니다.

📎 src/tuning/cost_model.cc:142-152

code
static const ncclTunerConstants_t ncclTunerConstantsDefaults = {
    // baseLatencies
  {
    {6.8, 14.0, 8.4},  // Tree
    {6.6, 14.0, 8.4},  // Ring
    {0, 0, 0},         // Collnet Direct
    {0, 0, 0},         // Collnet Chain
    {0, 0, 0},         // NVLS
    {0, 0, 0},         // NVLS Tree
    {8.0, 8.0, 8.0}    // PAT
  },

각 알고리즘에는 세 가지 기본 지연 값이 있으며, LL / LL128 / Simple 세 가지 프로토콜에 대응합니다. 예를 들어 Ring의{6.6, 14.0, 8.4}은 LL 프로토콜 기본 지연 6.6마이크로초, LL128은 14.0, Simple은 8.4를 의미합니다. 이 숫자들은 NVIDIA가 실제 하드웨어에서 측정한 경험값입니다.

하드웨어 지연은 토폴로지 유형(NVLink / PCI / NET)별로 각각 제공됩니다.

📎 src/tuning/cost_model.cc:153-184

code
    // hwLatencies
  {
    /* NVLINK */
    {
      {0.6, 1.25, 4.0}, // Tree (LL/LL128/Simple)
      {0.6, 1.9, 3.4},  // Ring (LL/LL128/Simple)
      ...
    },
    /* PCI */
    {
      {1.0, 1.9, 4.0}, // Tree (LL/LL128/Simple)
      {1.0, 2.5, 5.7}, // Ring (LL/LL128/Simple)
      ...
    },
    /* NET */
    {
      {5.0, 8.5, 14},   // Tree (LL/LL128/Simple)
      {2.7, 4.0, 14.0}, // Ring (LL/LL128/Simple)
      ...
    },
  },

비교해보면 토폴로지 차이를 알 수 있습니다: NVLink에서 Ring/Simple의 홉당 지연은 3.4마이크로초, PCI에서는 5.7, NET에서는 14.0입니다. 이것이 크로스 머신 통신이 느린 이유입니다 — 홉마다 10마이크로초를 더 써야 합니다.

대역폭 파라미터는 GPU 아키텍처 세대별로 제공됩니다.

📎 src/tuning/cost_model.cc:183-183

code
    // llMaxBws
  {
    {39.0, 39.0, 20.4}, /* Volta-N1/Intel-N2/Intel-N4) */
    {87.7, 22.5 /*avg of ring & tree*/, 19.0}, /* Ampere-N1/AMD-N2/AMD-N4) */
    {141.0, 45.0 /*avg of ring & tree*/, 35.0}, /* Hopper-N1/AMD-N2/AMD-N4) */
    {2 * 141.2, 2 * 45.0 /*avg of ring & tree*/, 2 * 35.0}, /* Blackwell-N1/AMD-N2/AMD-N4) */
  },

각 행은 한 세대 아키텍처에 대응하며, 세 값은 각각 단일 머신(N1), 듀얼 머신(N2), 쿼드 머신(N4) 시나리오에서의 LL 프로토콜 최대 대역폭입니다. Hopper 단일 머신 141 GB/s, Blackwell은 두 배인 282 GB/s — 이것이 새 카드에서 동일한 알고리즘이 훨씬 좋은 성능을 보이는 이유를 설명합니다.

튜닝 컨텍스트: per-comm 상태

각 communicator는ncclTuningContext_t를 보유하며, 이 comm의 튜닝 상태를 저장합니다.

📎 src/include/tuning.h:81-95

code
struct ncclTuningContext_t {
  // Persistant tuning parameters tied to a communicator.
  ncclTunerConstants_t tuningConstants;
  // State of the tuning models
  // Forced function is set via env var
  int forced[NCCL_NUM_FUNCTIONS];
  // Disabled tuning models are not execute and excluded from implemetation selection.
  int enabled[NCCL_TUNING_COUNT][NCCL_NUM_FUNCTIONS];
  // Store of model contexts per communicator.
  float generalLatencies[NCCL_NUM_FUNCTIONS][NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];
  float generalBandwidths[NCCL_NUM_FUNCTIONS][NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];

  ssize_t threadThresholds[NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];
  int maxThreads[NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];
};

네 가지 핵심 필드:

  • forced[NCCL_NUM_FUNCTIONS]: 어떤 함수가 환경 변수에 의해 알고리즘/프로토콜이 강제 지정되었는지 표시합니다. 이것이NCCL_ALGO/NCCL_PROTO가 적용되는 지점입니다.
  • enabled[NCCL_TUNING_COUNT][NCCL_NUM_FUNCTIONS]: 2차원 불리언 테이블로, 특정 모델이 특정 함수에 대해 활성화되었는지 표시합니다. 비활성화된 모델은 선택에 참여하지 않습니다.
  • generalLatencies / generalBandwidths: 3차원 배열로, 「함수 × 알고리즘 × 프로토콜」별로 추정된 지연과 대역폭을 저장합니다. 이것이ncclTuningInit가 출력하는 큰 테이블의 출처입니다.
  • threadThresholds / maxThreads: 스레드 수 관련 임계값으로, 각 block이 몇 개의 스레드를 사용할지 결정합니다.

시나리오 기반 Walkthrough: AllReduce 한 번의 알고리즘 선택

다음과 같이 호출한다고 가정합니다:ncclAllReduce, 메시지 크기 1MB, 8카드 단일 머신 NVLink. NCCL 내부에서ncclTuningInput_t를 구성한 후,ncclTuningCompute。

📎 src/tuning/tuning.cc:180-202

code
ncclResult_t ncclTuningCompute(struct ncclTuningInput_t* const input, struct ncclTuningResult_t* const result) {
  ncclResult_t ret = ncclSuccess;
  TRACE(NCCL_TUNING, ...);
  struct ncclTuningResultList_t tunings;
  tunings.head = nullptr;
  struct ncclTuningResult_t bestTuning = NCCL_TUNING_RESULT_INIT;
  // Set tuning to Ring/Simple for single rank case
  if (input->comm->nRanks <= 1) {
    bestTuning.algo = NCCL_ALGO_RING;
    bestTuning.proto = NCCL_PROTO_SIMPLE;
    ...
  } else {
    NCCLCHECKGOTO(ncclTuningComputeAllTunings(input, &tunings), ret, exit);

복사

첫 번째 단계: 단일 rank는 Ring/Simple을 바로 반환하고 아무 계산도 하지 않습니다. 이것은 단락 최적화입니다 — 단일 카드는 통신이 없으므로 어떤 알고리즘을 선택해도 동일합니다.ncclTuningComputeAllTunings두 번째 단계: 다중 rank일 때

📎 src/tuning/tuning.cc:128-149

code
ncclResult_t ncclTuningComputeAllTunings(struct ncclTuningInput_t* const input,
                                         struct ncclTuningResultList_t* const tunings) {
  ncclResult_t ret = ncclSuccess;

  for (int i = 0; i < NCCL_TUNING_COUNT; i++) {
    struct ncclTuningResult_t tuning = NCCL_TUNING_RESULT_INIT;
    tuning.id = i;
    tuning.valid = 1;

    if (!(input->tuningMask & (1ULL << i))) {
      tuning.valid = 0;
      continue;
    }
    NCCLCHECK(ncclTuningExpandId(i, &tuning.algo, &tuning.proto, &tuning.symKernelId, &tuning.ceMethodId));
    NCCLCHECKGOTO(ncclTuningComputeTuning(i, input, &tuning), ret, fail);
    if (tuning.valid) NCCLCHECKGOTO(ncclTuningResultListPushFront(tunings, tuning), ret, fail);
  }

복사tuningMask여기에 정교한 설계가 있습니다:NCCL_TUNING_MASK_GENERAL_KERNELS、NCCL_TUNING_MASK_SYM_KERNELS、NCCL_TUNING_MASK_CE는 64비트 마스크로, 각 비트가 하나의 후보 조합에 대응합니다.

📎 src/include/tuning.h:17-25

code
#define NCCL_TUNING_SYM_KERNEL_ID_OFFSET (NCCL_NUM_ALGORITHMS * NCCL_NUM_PROTOCOLS)
#define NCCL_TUNING_CE_METHOD_ID_OFFSET (NCCL_TUNING_SYM_KERNEL_ID_OFFSET + ncclSymkKernelId_Count)
#define NCCL_TUNING_COUNT (NCCL_TUNING_CE_METHOD_ID_OFFSET + ncclCeMethodId_Count)

#define NCCL_TUNING_MASK_GENERAL_KERNELS ((1ULL << NCCL_TUNING_SYM_KERNEL_ID_OFFSET) - 1ULL)
#define NCCL_TUNING_MASK_SYM_KERNELS \
  ((1ULL << NCCL_TUNING_CE_METHOD_ID_OFFSET) - 1ULL - NCCL_TUNING_MASK_GENERAL_KERNELS)
#define NCCL_TUNING_MASK_CE ((1ULL << NCCL_TUNING_COUNT) - (1ULL << NCCL_TUNING_CE_METHOD_ID_OFFSET))
#define NCCL_TUNING_MASK_ALL ((1ULL << NCCL_TUNING_COUNT) - 1ULL)

복사NCCL_NUM_ALGORITHMS × NCCL_NUM_PROTOCOLS마스크 레이아웃은: 하위ncclSymkKernelId_Count비트는 전통적인 「알고리즘×프로토콜」 조합, 중간ncclTuningCompute비트는 대칭 커널, 상위 비트는 CE(Copy Engine) 방식입니다. 배열 대신 비트 마스크를 사용하는 이유는

에서 「이 후보가 이번 튜닝 범위에 포함되는지」를 빠르게 판단하기 위해서입니다.ncclTuningComputeTuning세 번째 단계: 각 후보에 대해ncclTuningCostModelSimModel。

📎 src/tuning/cost_model.cc:470-497

code
ncclResult_t ncclTuningCostModelSimModel(int id, struct ncclTuningInput_t* const input,
                                         struct ncclTuningResult_t* const result) {
  struct ncclTuningModelEntry_t* model = nullptr;
  ncclResult_t ret = ncclSuccess;
  result->forced = input->comm->tuningContext.forced[input->func];
  NCCLCHECKGOTO(getModelEntry(id, &model), ret, not_valid);
  if (model == nullptr) {
    ret = ncclInternalError;
    goto not_valid;
  }
  if (input->comm->tuningContext.enabled[id][input->func] == 0) {
    goto not_valid;
  }
  if (model->model != nullptr) {
    NCCLCHECKGOTO(model->model(input, result), ret, not_valid);
    if (result->timeUs <= 0.0) {
      goto not_valid;
    }
  } else {
    goto not_valid;
  }
exit:
  return ret;
not_valid:
  result->timeUs = NCCL_TUNING_IGNORE;
  result->valid = 0;
  goto exit;
}

로 전달됩니다.not_valid복사timeUs주의:NCCL_TUNING_IGNORE、valid태그 처리 — 어느 단계든 실패하면(모델 미존재, 비활성화, 시뮬레이션이 비양수 시간 반환),

를

📎 src/tuning/tuning.cc:155-173

code
static ncclResult_t ncclTuningSelectBestTuning(struct ncclTuningResultList_t* tunings,
                                               struct ncclTuningResult_t* const bestTuning) {
  bestTuning->timeUs = FLT_MAX;
  float bestSelectionTimeUs = FLT_MAX;
  struct ncclTuningResultListNode* node = tunings->head;
  while (node != nullptr) {
    const struct ncclTuningResult_t& tuning = node->result;
    float selectionTimeUs = tuning.selectionTimeUs > 0.0f ? tuning.selectionTimeUs : tuning.timeUs;
    TRACE(NCCL_TUNING, "A/P/S %s/%s/%s, time: %f, selection time: %f", ...);
    if (selectionTimeUs < bestSelectionTimeUs) {
      *bestTuning = tuning;
      bestSelectionTimeUs = selectionTimeUs;
    }
    node = node->next;
  }
  return ncclSuccess;
}

네 번째 단계: 모든 유효 후보 중에서 소요 시간이 가장 작은 것을 선택합니다.selectionTimeUs복사timeUs。selectionTimeUs여기에 디테일이 있습니다: 선택에는

를 사용하며, 0보다 크면 그것을 사용하고, 그렇지 않으면

mermaid
flowchart TD
    start["ncclTuningCompute(input, result)"] --> check_ranks{"comm->nRanks <= 1?"}
    check_ranks -->|是| single["bestTuning = Ring/Simple<br/>nChannels = 0"]
    check_ranks -->|否| all["ncclTuningComputeAllTunings()"]
    all --> loop{"遍历 i in NCCL_TUNING_COUNT"}
    loop -->|mask 未命中| skip["tuning.valid = 0<br/>continue"]
    loop -->|mask 命中| expand["ncclTuningExpandId(i, ...)"]
    expand --> sim["ncclTuningComputeTuning()<br/>→ ncclTuningCostModelSimModel()"]
    sim --> sim_check{"enabled[id][func] != 0<br/>且 model->model != nullptr?"}
    sim_check -->|否| invalid["timeUs = NCCL_TUNING_IGNORE<br/>valid = 0"]
    sim_check -->|是| push["ncclTuningResultListPushFront()"]
    skip --> loop
    invalid --> loop
    push --> loop
    loop -->|遍历结束| tuner_check{"comm->tuner != NULL?"}
    tuner_check -->|是| plugin["tuner->getCollInfo()<br/>覆盖 generalTable"]
    tuner_check -->|否| select["ncclTuningSelectBestTuning()"]
    plugin --> select
    select --> channels["ncclTuningGetChannels()"]
    channels --> eff{"CTAPolicy & EFFICIENCY<br/>且 NCCL_ALGO/NCCL_PROTO 未设置?"}
    eff -->|是| nvls["尝试 NVLS 覆盖<br/>ncclNvlsRegResourcesQuery()"]
    eff -->|否| done["*result = bestTuning"]
    nvls --> done
    single --> done

는 「선택 시간」으로, 추가 페널티 항목(예: 특정 시나리오에서 특정 알고리즘이 추가 오버헤드를 가지는 경우)을 포함할 수 있습니다. 이는 비용 모델에 「추정 시간」과 「선택 시간」을 분리하는 능력을 부여합니다.

플로우차트

복사NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNEL이 그림은 진입점에서 최종 결과까지의 의사결정 경로를 완전히 그려내며, 단일 rank 단락, 마스크 필터링, 모델 비활성화, tuner 플러그인 개입, CTAPolicy 오버라이드 등 모든 분기를 포함합니다.parseList21.3 환경 변수: 성능에 실제로 영향을 미치는 세 가지 노브enabled비용 모델을 이해하면 환경 변수가 어떻게 개입하는지 알 수 있습니다.

이 세 변수는

parseList를 통해 파싱된 후,

📎 src/tuning/cost_model.cc:14-32

code
// Parse a map of prefixes to a list of elements. The first prefix is
// optional and, if not present, the list of elements will be applied
// to all prefixes. Only the first list of elements can lack a
// prefix. Prefixes (if present) are followed by a colon. Lists of
// elements are comma delimited. Mappings of prefix to the lists of
// elements are semi-colon delimited.
//
// For example:
//
//     NCCL_ALGO="ring,collnetdirect;allreduce:tree,collnetdirect;broadcast:ring"
// Enable ring and collnetdirect for all functions, then select tree
// and collnetdirect for allreduce and ring for broadcast.
//
//     NCCL_PROTO="LL,Simple;allreduce:^LL"
// Enable LL and Simple for all functions, but everything except LL
// for allreduce.
//
//     NCCL_PROTO="^LL128;allreduce:LL128"
// Enable everything but LL128, but only LL128 for allreduce.

파싱 문법

1. 이 지원하는 문법은 대부분 사람들이 상상하는 것보다 복잡합니다.:NCCL_ALGO="ring,tree"복사

2. 세 가지 사용법::NCCL_ALGO="ring;allreduce:tree"전역 리스트

3. — 모든 함수가 ring과 tree만 사용합니다.:NCCL_PROTO="^LL128"함수 접두사별

^— 기본은 ring이지만 allreduce는 tree를 사용합니다.

📎 src/tuning/cost_model.cc:59-67

code
    int unset, set;
    if (elemList[0] == '^') {
      unset = 1;
      set = 0;
      elemList++;
    } else {
      unset = 0;
      set = 1;
    }

— LL128 외에는 모두 활성화합니다.^접두사가 핵심입니다 — 「unset」을 의미하며, 기본 전체 활성화에서 특정 옵션을 제외합니다.unset=1、set=0복사unset파싱 시set。

📎 src/tuning/cost_model.cc:69-96

code
    bool foundPrefix = false;
    for (int p = 0; p < nprefixes; p++) {
      if (prefix && strcasecmp(prefix, prefixElems[p]) != 0) continue;
      foundPrefix = true;
      for (int e = 0; e < nelems; e++) list[p * nelems + e] = unset;

      tokStr = strdup(elemList);
      char* tmpStr;
      char* elem = strtok_r(tokStr, ",", &tmpStr);
      while (elem) {
        int e;
        for (e = 0; e < nelems; e++) {
          if (strcasecmp(elem, elems[e]) == 0) {
            list[p * nelems + e] = set;
            forced[p] = 1;
            break;
          }
        }
        if (e == nelems) {
          WARN("Unrecognized element token \"%s\" when parsing \"%s\"", elem, str);
          ret = ncclInvalidUsage;
          goto fail;
        }
        elem = strtok_r(NULL, ",", &tmpStr);
      }

입니다. 이후 매칭되는 prefix에 대해 먼저 전체 리스트를forced[p] = 1(전체 제외)로 채우고, 나열된 요소를

로 설정합니다.

ncclTuningCostModelInit복사

📎 src/tuning/cost_model.cc:363-384

code
    for (int f = 0; f < NCCL_NUM_FUNCTIONS; f++) {
      // Disable LL128 when 1) it is not supported on the platform, and 2) user did not explicitly request it.
      // protoEnable[..] == 2 indicates that user did not set NCCL_PROTO=LL128 explicitly.
      if (proto == NCCL_PROTO_LL128 && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] == 2 &&
          !isLL128Enabled(comm->minCompCap, comm->maxCompCap, comm->graphs[algo].typeInter,
                          comm->graphs[algo].typeIntra, comm->nRanks, f, algo, comm->minDriverVersion)) {
        comm->tuningContext.enabled[i][f] = 0;
      }
      //  Check the user env vars only for functions that have a forced configuration and not already disabled.
      if (comm->tuningContext.forced[f] == 0 || comm->tuningContext.enabled[i][f] == 0) continue;
      comm->tuningContext.enabled[i][f] = 0;
      TRACE(NCCL_TUNING, "a/p/s %s/%s/%s enabled %d/%d/%d", ...);
      if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
           (proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
          (symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
        comm->tuningContext.enabled[i][f] = 1;
      }
    }

이 줄 — 사용자가 특정 요소를 명시적으로 나열하면, 해당 함수가 「강제」로 표시됩니다. 이 표시는 나중에 비용 모델이 자유롭게 선택할 수 있는지 판단하는 데 사용됩니다.

1. 강제와 비활성화의 상호작용에는 사용자 강제와 환경 변수, 플랫폼 능력의 상호작용을 처리하는 핵심 로직이 있습니다.isLL128Enabled복사protoEnable == 2이 로직의 순서가 중요합니다:

2. 먼저 LL128 플랫폼 능력 처리: 플랫폼이 LL128을 지원하지 않고(forced[f] != 0가 0 반환) 사용자가 명시적으로 요구하지 않았다면(enabled[i][f] = 0), 그런 다음 사용자가 이 조합을 허용하는지 확인한다——허용하면 다시 활성화한다.

protoEnable의 값은 세 가지가 있다: 0(사용자 제외), 1(사용자 활성화), 2(사용자 미언급, 기본 활성화). 이 삼상태 설계는 '사용자 명시적 요구'와 '플랫폼 기본'을 구분할 수 있게 한다.

환경 변수 읽기의 캐시 메커니즘

모든NCCL_PARAM매크로는 최종적으로ncclLoadParam。

📎 src/misc/param.cc:78-108

code
int64_t ncclLoadParam(char const* env, int64_t deftVal, int64_t uninitialized, int64_t* cache, int8_t* noCache) {
  static std::mutex mutex;
  std::lock_guard<std::mutex> lock(mutex);

  // noCache is only load/stored within the mutex, no need for atomic
  if (*noCache == /*uninitialized*/ -1) ncclGetCachePolicy(env, noCache);

  if (COMPILER_ATOMIC_LOAD(cache, std::memory_order_relaxed) != uninitialized) {
    return COMPILER_ATOMIC_LOAD(cache, std::memory_order_relaxed);
  }

  // Read the environment variable
  const char* str = ncclGetEnv(env);
  int64_t value = deftVal;

  if (str && strlen(str) > 0) {
    errno = 0;
    char* end = nullptr;
    value = strtoll(str, &end, 0);
    // Preserve numeric-prefix parsing while rejecting non-numeric values.
    if (errno || end == str) {
      value = deftVal;
      ATTN("Invalid value %s for %s, using default %lld.", str, env, (long long)deftVal);
    } else {
      INFO(NCCL_ENV, "%s set by environment to %lld.", env, (long long)value);
    }
  }

  if (*noCache == /*cache*/ 0) COMPILER_ATOMIC_STORE(cache, value, std::memory_order_relaxed);
  return value;
}

이 코드에는 주목할 만한 설계가 몇 가지 있다:

전역 뮤텍스:static std::mutex mutex가 전체 읽기 과정을 보호한다. 이는 모든 매개변수의 최초 읽기가 직렬화된다는 의미다. 왜 락을 사용하고 락프리가 아닌가? 매개변수 읽기는 초기화 단계에서만 발생하고 핫 패스에 있지 않으므로 락의 오버헤드는 무시할 수 있으며, 정확성이 더 중요하기 때문이다.

이중 검사: 먼저 원자적으로cache를 읽고, 이미 초기화되었다면 바로 반환한다. 이는 매개변수를 읽을 때마다 락에 들어가는 것을 피한다——락 자체는 초기화 후 거의 경쟁하지 않지만, 원자적 읽기가 더 빠르다.

캐시 전략:noCache플래그는 읽은 값을cache에 다시 쓸지 여부를 결정한다. 일부 매개변수(동적 응답이 필요한 경우 등)는 캐시를 비활성화하여 매번 환경 변수를 다시 읽을 수 있다.

오류 처리:strtoll파싱 실패 시 기본값을 사용하고ATTN경고를 출력한다.end == str의 판단에 주의하라——문자열 시작부터 숫자가 아니라면,end은str과 같아지며, 이는 숫자를 전혀 파싱하지 못했음을 의미한다.

구성 파일 지원

환경 변수는 반드시 셸에서 설정할 필요가 없으며, NCCL은 구성 파일에서 읽는 것을 지원한다.

📎 src/misc/param.cc:52-67

code
static void initEnvFunc() {
  char confFilePath[1024];
  const char* userFile = std::getenv("NCCL_CONF_FILE");
  if (userFile && strlen(userFile) > 0) {
    snprintf(confFilePath, sizeof(confFilePath), "%s", userFile);
    setEnvFile(confFilePath);
  } else {
    const char* userDir = userHomeDir();
    if (userDir) {
      snprintf(confFilePath, sizeof(confFilePath), "%s/.nccl.conf", userDir);
      setEnvFile(confFilePath);
    }
  }
  snprintf(confFilePath, sizeof(confFilePath), "/etc/nccl.conf");
  setEnvFile(confFilePath);
}

로딩 순서:NCCL_CONF_FILE로 지정된 파일(설정된 경우) →~/.nccl.conf → /etc/nccl.conf. 나중에 로드된 것이 먼저 로드된 것을 덮어쓴다(setEnvFile가ncclOsSetEnv)。

📎 src/misc/param.cc:69-72

code
void initEnv() {
  static std::once_flag once;
  std::call_once(once, initEnvFunc);
}

std::call_once복사ncclGetEnv。

21.4 채널 수: 과소평가된 성능 노브

알고리즘과 프로토콜이 '어떻게 갈지'를 결정하고, 채널 수가 '몇 개의 길을 열지'를 결정한다. 많은 사람이 튜닝할 때 앞의 두 가지만 신경 쓰고 채널 수를 무시한다——하지만 대형 메시지 시나리오에서는 채널 수가 대역폭 활용률을 결정하는 핵심인 경우가 많다.

채널 수는 어디서 오는가

ncclTuningCompute는 최적 알고리즘/프로토콜을 선택한 후ncclTuningGetChannels를 호출하여 채널 수를 계산한다.

📎 src/tuning/tuning.cc:233-235

code
  if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
    NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
  }

채널 수의 계산 로직은 이 장의 소스 자료에 없지만,ncclTuningResult_t의 필드에서 그 역할을 알 수 있다.

📎 src/include/tuning.h:42-55

code
struct ncclTuningResult_t {
  int id;
  int valid;
  float timeUs;
  float selectionTimeUs;
  int algo;
  int proto;
  int symKernelId;
  int ceMethodId;
  int nChannels;
  int maxChannels;
  int nWarps;
  int forced;
};

nChannels는 최종적으로 사용되는 채널 수이고,maxChannels는 상한이다.nWarps는 각 블록의 warp 수다.

CTAPolicy의 채널 수 오버라이드

전략을 처리하는 특별한 로직이 있다.NCCL_CTA_POLICY_EFFICIENCY복사

📎 src/tuning/tuning.cc:236-257

code
  // NCCL_CTA_POLICY_EFFICIENCY requires user (non-symmetric) buffer registration (currently unsupported with MNNVL).
  // Run after GetChannels so bestTuning.nChannels is valid. Skip when a tuner plugin owns selection
  // (same as pre-rearch). The NVLS-bit guard keeps this bias inside the candidate set: a per-call
  // algSelection may have narrowed tuningMask, so EFFICIENCY must not resurrect NVLS when excluded.
  if (input->comm->tuner == NULL && (input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY) &&
      ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL && !input->comm->MNNVL &&
      (input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)))) {
    if (input->regBuff && (input->func == ncclFuncAllGather || input->func == ncclFuncReduceScatter)) {
      if ((input->comm->nNodes > 1 && input->collNetSupport && input->nvlsSupport) ||
          (input->comm->nNodes == 1 && input->nvlsSupport)) {
        int recChannels;
        NCCLCHECKGOTO(ncclNvlsRegResourcesQuery(input->comm, input->func, &recChannels), ret, exit);
        if (recChannels <= bestTuning.nChannels) {
          bestTuning.algo = NCCL_ALGO_NVLS;
          bestTuning.proto = NCCL_PROTO_SIMPLE;
          bestTuning.nChannels = recChannels;
          bestTuning.maxChannels = recChannels;
          bestTuning.nWarps = input->comm->tuningContext.maxThreads[bestTuning.algo][bestTuning.proto] / WARP_SIZE;
        }
      }
    }
  }

: tuner 플러그인이 없을 때만 이 부분을 탄다. 플러그인이 선택권을 가질 때 NCCL은 개입하지 않는다.

1. input->comm->tuner == NULL: 사용자가 효율 우선 전략을 설정했다.

2. input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY: 사용자가 알고리즘/프로토콜을 강제하지 않았다. 강제했다면 사용자 선택을 존중한다.

3. ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL: MNNVL 시나리오는 지원되지 않는다.

4. !input->comm->MNNVL: NVLS/Simple이 후보 집합 내에 있다. 이 가드는 제외된 옵션이 '부활'하는 것을 방지한다.

5. input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE))조건을 만족하면 NVLS 등록 리소스가 지원할 수 있는 채널 수를 조회하고, 현재 선택을 초과하지 않으면 NVLS 알고리즘으로 전환한다.

〔설계 추론과 아키텍처 트레이드오프〕

왜 EFFICIENCY 전략은 NVLS를 선호하는가? NVLS(NVLink SHARP)는 스위치 하드웨어를 활용해 리덕션을 수행하여 GPU의 계산 및 통신 오버헤드를 줄일 수 있고, AllGather/ReduceScatter 같은 연산에서 효율이 더 높기 때문이다. 하지만 채널 수가 하드웨어 리소스에 제한되므로

로 실제 사용 가능량을 조회해야 한다.ncclNvlsRegResourcesQuery대칭 커널의 폴백 로직

대칭 커널(symmetric kernel)은 비교적 새로운 기능으로, 사용할 수 없을 때 범용 커널로 폴백해야 한다.

복사

📎 src/tuning/tuning.cc:258-298

code
  if ((bestTuning.symKernelId != ncclSymkKernelId_Count ||
       (input->tuningMask & NCCL_TUNING_MASK_SYM_KERNELS && bestTuning.symKernelId == ncclSymkKernelId_Count)) &&
      bestTuning.algo == NCCL_ALGO_UNDEF && bestTuning.proto == NCCL_PROTO_UNDEF) {
    bool isLLKernel = (1 << bestTuning.symKernelId) & ncclSymkLLKernelMask();
    bool isOneThreadMultiGpus = input->comm->intraRanks > 1 && !ncclParamSingleProcMemRegEnable();
    bool needFallback = bestTuning.symKernelId != ncclSymkKernelId_Count ? false : true;

    // General kernel tuning structs if fallback is needed
    struct ncclTuningResult_t generalTuning = NCCL_TUNING_RESULT_INIT;
    struct ncclTuningInput_t generalInput = *input;
    generalInput.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;

    // Fallback logic for symmetric LL kernels:
    // - If both src and dst are registered, we don't fall back if a symmetric kernel is available.
    // - Otherwise, we have to fall back to generl kernel if running the selected symmetric LL kernel is
    //   not possible (if the buffers are not registered and we manage multiple GPUs).
    // - If the user forced a symmetric kernel via NCCL_SYM_KERNEL or requested preference for using
    //   symmetric kernels even without symmetric buffers via NCCL_SYM_NOWIN_ENABLE, we respect that.
    // - Otherwise, we query the general cost model and if it selects a non-LL proto, we pick that.
    if (bestTuning.symKernelId != ncclSymkKernelId_Count) {
      if (input->winRegType == ncclSymSendRegRecvReg) {
        needFallback = false;
      } else if (isLLKernel) {
        needFallback = isOneThreadMultiGpus && input->winRegType == ncclSymSendNonregRecvNonreg;
        if (!needFallback && !result->forced) {
          needFallback = !ncclParamSymNoWinEnable() && input->winRegType == ncclSymSendNonregRecvNonreg;
          if (!needFallback) {
            NOWARN(ncclTuningCompute(&generalInput, &generalTuning), NCCL_TUNING);
            needFallback = (generalTuning.proto != NCCL_PROTO_LL);
          }
        }
      }
    }

송신 및 수신 버퍼가 모두 등록되었다면(

  • ), 폴백하지 않는다.ncclSymSendRegRecvRegLL 커널이고 단일 스레드가 여러 GPU를 관리하며 버퍼가 등록되지 않았다면, 폴백한다.
  • 사용자가
  • 를 설정하지 않았고 버퍼가 등록되지 않았다면, 폴백한다.NCCL_SYM_NOWIN_ENABLE그렇지 않으면 범용 비용 모델을 조회하여 비-LL 프로토콜을 선택했다면, 폴백한다.
  • 〔설계 추론과 아키텍처 트레이드오프〕
이 로직의 핵심은: 대칭 LL 커널은 버퍼 등록이 있어야 장점을 발휘한다. 등록되지 않으면 LL 커널의 장점(낮은 지연)이 추가 주소 변환 오버헤드로 상쇄될 수 있으므로 범용 커널로 폴백하는 것이 더 이득이다.

사용 가능한 조합이 없을 때의 오류 처리

모든 후보가 제외되면 NCCL은 오류를 보고하고 진단 정보를 제공한다.

복사

📎 src/tuning/tuning.cc:308-329

code
  if ((bestTuning.algo == NCCL_ALGO_UNDEF || bestTuning.proto == NCCL_PROTO_UNDEF) &&
      bestTuning.symKernelId == ncclSymkKernelId_Count && bestTuning.ceMethodId == ncclCeMethodId_Count) {
    char ncclAlgoEnvStr[1024] = "";
    char ncclProtoEnvStr[1024] = "";
    char ncclSymKernelIdEnvStr[1024] = "";
    const char* symKernelIdEnv = ncclGetEnv("NCCL_SYM_KERNEL");
    if (symKernelIdEnv) {
      snprintf(ncclSymKernelIdEnvStr, 1023, " NCCL_SYM_KERNEL was set to %s.", symKernelIdEnv);
    }
    const char* algoEnv = ncclGetEnv("NCCL_ALGO");
    if (algoEnv) {
      snprintf(ncclAlgoEnvStr, 1023, " NCCL_ALGO was set to %s.", algoEnv);
    }
    const char* protoEnv = ncclGetEnv("NCCL_PROTO");
    if (protoEnv) {
      snprintf(ncclProtoEnvStr, 1023, " NCCL_PROTO was set to %s.", protoEnv);
    }
    WARN("No algorithm/protocol nor symKernelId available for function %s with datatype %s.%s%s%s",
         ncclFuncToString(input->func), ncclDatatypeToString(input->datatype), ncclAlgoEnvStr, ncclProtoEnvStr,
         ncclSymKernelIdEnvStr);
    ret = (algoEnv || protoEnv || symKernelIdEnv) ? ncclInvalidUsage : ncclInternalError;
  }

),algoEnv || protoEnv || symKernelIdEnv를 반환한다——이는 사용자의 구성 문제다; 그렇지 않으면ncclInvalidUsage를 반환한다——이는 NCCL 내부 문제다(모든 후보가 예기치 않게 제외됨).ncclInternalError21.5 프로덕션 함정 회피 가이드

함정 1: 환경 변수 오타로 인한 조용한 폴백

는 인식할 수 없는 토큰을 만나면

parseList을 반환하지만, 만약ncclInvalidUsage(대문자)를 썼다면,NCCL_ALGO=RING는 올바르게 매칭한다. 진짜 위험한 것은 오타다, 예를 들어strcasecmp복사NCCL_ALGO=rnig。

📎 src/tuning/cost_model.cc:87-91

code
        if (e == nelems) {
          WARN("Unrecognized element token \"%s\" when parsing \"%s\"", elem, str);
          ret = ncclInvalidUsage;
          goto fail;
        }

를 켜지 않았다면 이 경고를 보지 못할 수 있다.NCCL_DEBUG=WARN권장 사항: 튜닝 시 항상또는NCCL_DEBUG=WARN를 설정하여 구성 파싱 결과를 볼 수 있도록 하라.NCCL_DEBUG=INFO함정 2: NCCL_ALGO와 NCCL_PROTO의 상호작용

만약

를 설정했지만NCCL_ALGO=tree를 설정하지 않았다면, NCCL은 Tree 알고리즘에서 최적 프로토콜을 선택한다. 하지만NCCL_PROTO와NCCL_ALGO=tree를 동시에 설정했고 Tree/LL 조합이 일부 함수에서 비활성화되어 있다면(예: Tree는 AllReduce에서만 활성화), '사용 가능한 조합 없음' 오류가 발생한다.NCCL_PROTO=LL복사

📎 src/tuning/cost_model.cc:379-383

code
      if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
           (proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
          (symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
        comm->tuningContext.enabled[i][f] = 1;
      }

동시에허용될 때만 조합이 활성화된다. 이는 AND 논리이지 OR이 아니다.함정 3: LL128의 플랫폼 제한

LL128은 모든 플랫폼에서 지원되지 않는다.

LL128 不是所有平台都支持。isLL128Enabled연산 능력, 드라이버 버전, 연결 유형을 확인했습니다.

📎 src/tuning/cost_model.cc:119-139

code
static int isLL128Enabled(int minCompCap, int maxCompCap, int interType, int intraType, int nRanks, int func, int algo,
                          int minDriverVersion) {
  int ret = 1;
  if (ncclParamLl128C2c() && minCompCap >= 90 && (!RUBIN_AND_LATER(minCompCap) || minDriverVersion >= 13030)) {
    // Rubin, Blackwell, and Hopper: Enable LL128 for all P2C and PXN if CUDA supports it.
    ret &= (interType <= PATH_PXN);
  } else {
    // Enable LL128 only up to PXB. Don't enable LL128 over PxN because PxN can encapsulate PxB or P2C links.
    ret &= (interType <= PATH_PXB);
    if (!ncclParamLl128C2c() && minCompCap >= 90)
      INFO(
        NCCL_GRAPH | NCCL_TUNING,
        "Disabling LL128 over all PxN connections (PXB and C2C). This ensures that no C2C link will be used by LL128.");
  }
  ret &= (intraType <= PATH_NVB);
  // Enable LL128 for interoperability between GPUs with different compcap (Hopper and above)
  ret &= (minCompCap == maxCompCap || minCompCap >= 90);
  ret &= !(minCompCap < 70 || (minCompCap == 90 && CUDART_VERSION == 11080 && func == ncclFuncAllReduce &&
                               algo == NCCL_ALGO_RING && nRanks == 2));
  return ret;
}

몇 가지 핵심 제한 사항:

  • minCompCap < 70: Volta 이전 GPU는 LL128을 지원하지 않습니다.
  • intraType <= PATH_NVB: 노드 내 연결은 반드시 NVLink 수준이어야 합니다.
  • Hopper + CUDA 11.8 + AllReduce + Ring + 2 ranks: 이는 알려진 버그 시나리오로, 명시적으로 제외됩니다.

권장 사항: 플랫폼이 LL128을 지원하지 않는 경우, 억지로 설정하지 마십시오NCCL_PROTO=LL128, 그렇지 않으면 오류가 발생합니다. NCCL이 자동으로 선택하도록 하십시오.

함정 4: 채널 수와 VRAM

채널 수가 많을수록 필요한 버퍼가 커집니다. VRAM이 부족한 상황에서는 채널이 너무 많으면 OOM이 발생할 수 있습니다.

📎 src/tuning/tuning.cc:246-253

code
        int recChannels;
        NCCLCHECKGOTO(ncclNvlsRegResourcesQuery(input->comm, input->func, &recChannels), ret, exit);
        if (recChannels <= bestTuning.nChannels) {
          bestTuning.algo = NCCL_ALGO_NVLS;
          bestTuning.proto = NCCL_PROTO_SIMPLE;
          bestTuning.nChannels = recChannels;
          bestTuning.maxChannels = recChannels;
          bestTuning.nWarps = input->comm->tuningContext.maxThreads[bestTuning.algo][bestTuning.proto] / WARP_SIZE;
        }

NVLS의 채널 수는ncclNvlsRegResourcesQuery하드웨어 리소스 조회에 의해 결정되며, 임의로 설정할 수 없습니다. 하드웨어 리소스가 부족하면 채널 수가 제한됩니다.

21.6 튜닝 의사 결정 흐름

앞의 내용을 종합하면 실행 가능한 문제 해결 흐름을 얻을 수 있습니다.

mermaid
flowchart TD
    start["性能不达标"] --> baseline["跑 nccl-tests 对比官方报告"]
    baseline --> diff{"差距 > 5%?"}
    diff -->|否| app["检查应用层:<br/>通信频率、消息切分"]
    diff -->|是| debug["设置 NCCL_DEBUG=INFO<br/>查看算法/协议选择"]
    debug --> check_algo{"选择的算法合理?"}
    check_algo -->|否| force_algo["尝试 NCCL_ALGO 强制<br/>对比不同算法"]
    check_algo -->|是| check_proto{"协议合理?"}
    check_proto -->|否| force_proto["尝试 NCCL_PROTO 强制<br/>小消息 LL,大消息 Simple"]
    check_proto -->|是| check_chan{"通道数合理?"}
    check_chan -->|否| tune_chan["调整 NCCL_NCHANNELS<br/>或检查显存限制"]
    check_chan -->|是| check_topo["检查拓扑:<br/>NCCL_TOPO_DUMP 确认链路"]
    force_algo --> verify["重新 benchmark 验证"]
    force_proto --> verify
    tune_chan --> verify
    check_topo --> verify
    verify --> improved{"性能提升?"}
    improved -->|是| done["固化配置"]
    improved -->|否| escalate["提交 issue 或联系支持"]

이 흐름의 핵심 사상은:먼저 위치를 파악하고, 그다음 파라미터를 조정하고, 마지막으로 검증한다. 처음부터 환경 변수를 마구 설정하지 마십시오.

이 장 요약

이 장에서는 NCCL의 튜닝 경로를 네 가지 계층으로 나누었습니다:

1. 기준선: 공식 성능 보고서로 기대치를 설정하고, 5% 이내는 정상 변동이며, 큰 메시지는 대역폭을, 작은 메시지는 지연 시간을 봅니다.

2. 비용 모델: NCCL 내부에서modelMap표 + 지연/대역폭 파라미터로 각 조합의 소요 시간을 추정하고, 최소인 것을 선택합니다. 이 모델을 이해하는 것이 튜닝의 전제입니다.

3. 환경 변수:NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNEL를 통해parseList파싱 후enabled표를 수정하여 특정 조합을 강제하거나 제외합니다. 구문은 전역, 함수별, 제외의 세 가지 모드를 지원합니다.

4. 채널 수: 에 의해ncclTuningGetChannels계산되며, 하드웨어 리소스와 CTAPolicy의 영향을 받습니다.

이 장 생각해보기와 자가 점검

Q1: 만약ncclTuningCompute에서 단일 rank 단락 로직(input->comm->nRanks <= 1분기)을 제거하면 어떻게 될까요? 어떤 시나리오에서 문제가 발생할까요?

참고 해석:

단일 rank 단락은📎 src/tuning/tuning.cc:191-200:

cpp
  // Set tuning to Ring/Simple for single rank case
  if (input->comm->nRanks <= 1) {
    bestTuning.algo = NCCL_ALGO_RING;
    bestTuning.proto = NCCL_PROTO_SIMPLE;
    bestTuning.symKernelId = ncclSymkKernelId_Count;
    bestTuning.ceMethodId = ncclCeMethodId_Count;
    bestTuning.nChannels = 0;
    bestTuning.maxChannels = 0;
    bestTuning.nWarps = 0;
    bestTuning.forced = 0;
  } else {
    NCCLCHECKGOTO(ncclTuningComputeAllTunings(input, &tunings), ret, exit);
    ...

이 분기를 제거하면 단일 rank 시나리오는ncclTuningComputeAllTunings로 진입하여 모든 후보 조합을 순회합니다. 문제는:

1. 성능 낭비: 단일 rank는 통신이 없으므로 모든 알고리즘의 소요 시간 추정이 순수 오버헤드이며, 어느 것을 선택해도 동일합니다. 모든 후보를 순회하는 것은 순전한 낭비입니다.

2. 결과를 선택하지 못할 수 있음: 일부 알고리즘은 단일 rank에서 모델에 의해 무효로 판정될 수 있습니다(예: Ring은 링을 형성하려면 최소 2개의 rank가 필요). 이로 인해tunings목록이 비게 되어,ncclTuningSelectBestTuning가FLT_MAX의 초기값을 반환하고, 최종적으로bestTuning.algo가 여전히NCCL_ALGO_UNDEF。

3. 오류 경로 트리거: 만약bestTuning.algo == NCCL_ALGO_UNDEF이면,📎 src/tuning/tuning.cc:308-329의 오류 처리로 진입하여 "No algorithm/protocol available" 경고를 출력하고ncclInternalError。

을 반환합니다. 따라서 이 단락은 단순한 최적화가 아니라 정확성 보장입니다 — 단일 rank 시나리오에는 반드시 확정된 기본값이 있어야 합니다.

Q2: parseList에서forced[p] = 1이 코드 줄(📎 src/tuning/cost_model.cc:83)의 역할은 무엇인가요? 만약 제거하면,NCCL_ALGO=ring의 동작에 어떤 변화가 생기나요?

참고 해석:

forced[p] = 1에서📎 src/tuning/cost_model.cc:80-85:

cpp
        for (e = 0; e < nelems; e++) {
          if (strcasecmp(elem, elems[e]) == 0) {
            list[p * nelems + e] = set;
            forced[p] = 1;
            break;
          }
        }

forced배열은ncclTuningContext_t에 정의됩니다([

핵심 노브는 단 세 개입니다: 알고리즘, 프로토콜, 채널 수. 다른 파라미터는 대부분 보조 진단이나 특정 시나리오 최적화입니다. 이 튜닝 경로를 습득하면 대부분의 시나리오에서 NCCL이 하드웨어에 근접한 성능을 내도록 할 수 있습니다. 그러나 성능 외에도 프로덕션 환경에는 또 다른 종류의 더 까다로운 문제가 있습니다: 겉보기에는 정상인 코드가 특정 조건에서 멈추거나 오류를 낼 수 있습니다. 다음 장에서는 프로덕션에서 NCCL의 전형적인 함정 사례 — 교착 상태, 타임아웃, 버전 불일치, 흔한 오용 — 를 정리하고, NCCL 내부에서 이러한 문제를 어떻게 감지하고 보고하는지 살펴봅니다.

CHAPTER 22

제22장: 제22장: 프로덕션 문제 해결과 함정: 흔한 교착 상태, 타임아웃, 버전 불일치 및 해결 방안

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제22 / 25장

제22장: 프로덕션 문제 해결과 함정: 흔한 교착 상태, 타임아웃, 버전 불일치 및 해결 방안

이전 장에서 성능 튜닝의 문제 해결 순서와 핵심 노브를 정리했지만, 프로덕션 환경의 NCCL 장애는 성능 미달이 아니라 프로그램이 직접 멈추거나 충돌하는 경우가 많습니다. 이러한 장애의 근원은 보통 특정 함수를 잘못 작성한 것이 아니라 호출 순서, 수명 주기 또는 버전 계약이 깨진 것입니다. 이 장에서는 가장 전형적인 네 가지 함정에 집중합니다: group 의미 오용으로 인한 교착 상태, 파라미터 검증 누락으로 인한 조용한 오류, ABI 버전 불일치, 그리고 타임아웃과 재시도의 경계. 우리는 src/group.cc, src/misc/argcheck.cc, src/include/checks.h, contrib/nccl_ep/nccl_ep.cc 네 가지 단서를 따라 NCCL 내부에서 오류가 발생하기 전에 어떻게 막는지 살펴봅니다.

Group 의미 오용: 왜 "GroupEnd 하나를 빼먹으면" 멈추는가

직관 모델: Group은 "장바구니"이지 "가속 스위치"가 아니다

를ncclGroupStart() / ncclGroupEnd()온라인 쇼핑의 장바구니라고 상상해 보십시오: 여러 상품(여러 통신 호출)을 장바구니에 넣고 마지막에 한 번에 결제합니다(ncclGroupEnd). 넣기만 하고 결제하지 않으면 장바구니는 영원히 공중에 떠 있게 됩니다 — NCCL 내부에서 유지하는ncclGroupDepth카운터가 0으로 돌아가지 않아, 이후 모든 통신 호출이 "아직 주문을 모으는 중"이라고 생각하고 영원히 실제로 kernel을 내려보내지 않아, 결국 전체 프로세스가 멈춥니다.

〔설계 추론과 아키텍처 트레이드오프〕

이것은 생산 환경에서 가장 흔한 교착 상태 형태입니다: 코드가 특정 예외 분기에서return되어,ncclGroupEnd을(를) 건너뛰었고, 그리고ncclGroupDepth은(는)thread_local이므로 함수가 반환되어도 자동으로 정리되지 않습니다.

데이터 구조: thread_local의 group 상태

NCCL은 group 상태를 전부 스레드 로컬 저장소에 둡니다. 이것이 교착 상태를 이해하는 핵심입니다.

📎 src/group.cc:34-34

cpp
thread_local int ncclGroupDepth = 0; // depth of ncclGroupStart nesting
thread_local ncclResult_t ncclGroupError = ncclSuccess;
thread_local struct ncclComm* ncclGroupCommHead[ncclGroupTaskTypeNum] = {nullptr};
thread_local struct ncclComm* ncclGroupCommPreconnectHead = nullptr;
thread_local struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next> ncclAsyncJobs;
thread_local int ncclGroupBlocking = -1; /* default mode */

필드별 해석:

  • ncclGroupDepth: 중첩 깊이.ncclGroupStart이 증가하고,ncclGroupEnd이 감소하며, 0까지 줄어야 실제로 전송이 트리거됩니다. 중첩 지원은 설계상 편의지만, "End 하나를 빠뜨림"이 깊이를 영원히 1에 머물게 한다는 뜻이기도 합니다.
  • ncclGroupError: 이 스레드에 누적된 group 오류. 한 번 호출이 실패하면 이후ncclGroupEnd은(는) 바로 실패 경로로 갑니다.
  • ncclGroupCommHead[]: 작업 유형(collective / rawTask / mgmtTask / symRegister)별로 그룹화된 통신 도메인 연결 리스트 헤드.
  • ncclAsyncJobs: 실행 대기 중인 비동기 작업 큐(예: preconnect, symmetric register).
  • ncclGroupBlocking:-1은(는) "아직 어떤 통신 도메인도 만나지 못함"을,0은(는) 비차단을,1은(는) 차단을 나타냅니다. 이 필드는 뒤에 나오는 "차단과 비차단 혼용" 감지의 핵심입니다.
〔설계 추론과 아키텍처 트레이드오프〕

전역 변수 대신thread_local을(를) 사용하는 동기는 직접적입니다: NCCL은 여러 스레드가 각자 독립적인 group 컨텍스트를 보유하고 서로 간섭하지 않도록 허용합니다. 대가는 — 스레드가 종료될 때 이 상태들이 자동으로 정리되지 않으며, 스레드가 group 도중에 종료되면 상태가 누출된다는 점입니다.

단계별: GroupEnd 한 번의 전체 검증 체인

시나리오 대입: 애플리케이션이ncclGroupEnd()을(를) 호출하고, 이때ncclGroupDepth이 1입니다.

첫 번째 단계, 실제로 group 안에 있는지 확인:

📎 src/group.cc:1048-1052

cpp
  if (ncclGroupDepth == 0) {
    WARN("ncclGroupEnd: not in a group call.");
    ret = ncclInvalidUsage;
    goto exit;
  }

사용자가ncclGroupStart을(를) 호출하지 않고 바로ncclGroupEnd을(를) 하면, 여기서 "not in a group call"을 출력하고ncclInvalidUsage을(를) 반환합니다. 이것이 가장 친절한 오류입니다 — 즉시 오류를 내고, 멈추지 않습니다.

두 번째 단계, 깊이를 감소시키고 최외곽인지 판단:

📎 src/group.cc:1061-1063

cpp
  if ((--ncclGroupDepth) > 0) goto exit;

  if ((ret = ncclGroupError) != ncclSuccess) goto fail;

여러 겹 중첩된 경우, 내부의End은(는) 깊이만 감소시키고 반환하며 전송을 트리거하지 않습니다. 최외곽만 계속합니다. 동시에 누적 오류를 확인합니다.

세 번째 단계, 차단 모드 일관성 검증. 이것이 "차단과 비차단 혼용" 감지 지점입니다:

📎 src/group.cc:1095-1101

cpp
  if (hasCommHead || !ncclIntruQueueEmpty(&groupJob->asyncJobs) || ncclGroupCommPreconnectHead != nullptr) {
    /* make sure ncclGroupBlocking has been set. */
    if (ncclGroupBlocking != 0 && ncclGroupBlocking != 1) {
      WARN("Invalid group blocking state %d", ncclGroupBlocking);
      ret = ncclInternalError;
      goto fail;
    }

ncclGroupBlocking은(는) 반드시{0, 1}사이에 있어야 합니다. 만약 여전히-1이면, group 안에 통신 도메인도 비동기 작업도 없다는 뜻이며, 논리적으로 여기까지 오면 안 됩니다.

네 번째 단계, 차단 모드에 따라 분기. 비차단은 스레드 비동기 전송으로, 차단은 동기 전송으로:

📎 src/group.cc:1102-1134

cpp
    if (ncclGroupBlocking == 0) {
      /* nonblocking group */
      if (!ncclIntruQueueEmpty(&groupJob->asyncJobs)) {
        ncclAsyncJob* job = ncclIntruQueueHead(&groupJob->asyncJobs);
        do {
          NCCLCHECKGOTO(ncclCommSetAsyncError(job->comm, ncclInProgress), ret, fail);
          if (job->comm->groupJob == NULL) {
            job->comm->groupJob = groupJob;
            groupJob->groupRefCount++;
          }
          job = job->next;
        } while (job);
      }
      ...
      groupJob->base.func = groupLaunchNonBlocking;
      STDTHREADCREATE_GOTO(groupJob->base.thread, ncclAsyncJobMain, ret, fail, &groupJob->base);
      groupJob->nonBlockingInit = true;
      ret = ncclInProgress;
    }

과(와)groupRefCount++에 주의:ret = ncclInProgress비차단 모드에서,ncclGroupEnd은(는) 즉시ncclInProgress을(를) 반환하고, 실제 전송은 백그라운드 스레드에서 실행됩니다. 호출자는 이후 반드시ncclCommGetAsyncError로 폴링하거나,ncclGroupJobComplete로 대기해야 합니다.

차단과 비차단 혼용: 왜 금지되는가

로 돌아가서,ncclAsyncLaunch혼용 감지를 봅니다:

📎 src/group.cc:55-64

cpp
    /* check if there are blocking and nonblocking comms at the same time in group. */
    if (comm->destroyFlag) {
      ncclGroupBlocking = 1;
    } else if (ncclGroupBlocking == -1) {
      /* first met communicator */
      ncclGroupBlocking = comm->config.blocking;
    } else if (ncclGroupBlocking != comm->config.blocking) {
      WARN("Blocking and nonblocking communicators are not allowed in the same group.");
      ret = ncclInvalidArgument;
    }
〔설계 추론과 아키텍처 트레이드오프〕

왜 혼용을 금지하는가? 차단 통신 도메인의 전송 의미는 "호출이 반환될 때 kernel이 이미 제출됨"이고, 비차단은 "호출이 반환될 때 작업이 큐에 들어갔지만 제출되지 않음"입니다. 둘이 같은 group 안에 있으면,ncclGroupEnd통일된 반환 의미를 줄 수 없습니다 — 기다릴 것인가, 기다리지 않을 것인가? NCCL은 그냥 거부하고, 문제를 API 경계에 노출합니다.

생산 함정: 세 가지 실제 시나리오

시나리오 1: 예외 분기에서 GroupEnd를 빠뜨림.코드가ncclGroupStart과(와)ncclGroupEnd사이에서 예외를 던지거나 조기에return,ncclGroupDepth이 1에 멈춥니다. 이후 모든 통신 호출이 "주문 모으기" 상태에 들어가 영원히 전송되지 않습니다. 진단 방법:ncclGroupEnd전에ncclGroupDepth을(를) 출력하거나,gdb로 해당 thread_local 변수를 관찰합니다.

시나리오 2: 스레드를 넘나들며 같은 comm 사용.group 상태가thread_local이므로, 스레드 A가ncclGroupStart을(를) 호출한 뒤 스레드 B가ncclAllReduce을(를) 호출해도 A의 group에 들어가지 않습니다. A와 B가 같은 comm을 조작하면 "일부 호출은 group 안, 일부는 group 밖"이라는 혼란이 생깁니다. NCCL은 이런 상황을 감지하지 않습니다. 하나의 comm이 어느 시점에든 한 스레드에 의해서만 조작된다고 가정하기 때문입니다.

시나리오 3: CUDA graph capture와 group의 상호작용.doLaunches안의 감지를 봅니다:

📎 src/group.cc:448-455

cpp
    if (capturingYes && capturingNo) {
      // We have entered barriers but are aborting without leaving them. Thus
      // these comms are permanently trashed. We need a good mechanism for
      // tracking and reporting that.
      WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
      result = ncclInvalidUsage;
      goto failure;
    }

주석이 아주 직설적입니다: 일단 barrier에 들어갔다가 중도 포기하면, 이 comm들은 "영구 손상"됩니다. 그래서 규칙은 — 한 group 안의 모든 통신 도메인은 전부 capture 중이거나, 전부 capture 중이 아니어야 합니다. 혼용하면 comm 상태가 불일치하게 되고, NCCL은 현재 좋은 복구 메커니즘이 없습니다.

mermaid
flowchart TD
    start["ncclGroupEnd()"] --> depth_check{"ncclGroupDepth == 0?"}
    depth_check -->|是| err_usage["WARN not in a group call<br/>return ncclInvalidUsage"]
    depth_check -->|否| dec["--ncclGroupDepth"]
    dec --> nested{"depth > 0?"}
    nested -->|是| exit_ok["goto exit 返回"]
    nested -->|否| err_check{"ncclGroupError == success?"}
    err_check -->|否| fail_clean["groupCleanup 清理所有 comm 与 asyncJobs"]
    err_check -->|是| blocking_check{"ncclGroupBlocking in {0,1}?"}
    blocking_check -->|否| err_internal["WARN Invalid group blocking state<br/>return ncclInternalError"]
    blocking_check -->|是| mode_split{"ncclGroupBlocking == 0?"}
    mode_split -->|是 非阻塞| async_launch["STDTHREADCREATE groupLaunchNonBlocking<br/>ret = ncclInProgress"]
    mode_split -->|否 阻塞| sync_launch["groupLaunch 同步下发<br/>delete groupJob"]
    async_launch --> reset["groupLocalResetJobState"]
    sync_launch --> reset
    reset --> exit_ok
    fail_clean --> reset

매개변수 검증과 조용한 오류: ArgCheck가 "정상처럼 보이는" 호출을 어떻게 막는가

직관 모델: ArgCheck는 "공항 보안 검색"

매개변수 검증은 공항 보안 검색과 같습니다: 더 빨리 날게 해주지는 않지만, "짐처럼 보이지만 실제로는 위험물"인 것들을 막아줍니다. 이것이 없으면, 잘못된 장치 포인터 하나가 GPU kernel이 쓰레기 데이터를 읽게 하거나, 더 나쁘게는 — 조용히 남의 VRAM을 망가뜨립니다.

데이터 구조: 검증 모드와 전역 검사 큐

NCCL의 매개변수 검증은 "매번 전부 검사"가 아니라 모드로 나뉩니다. 핵심은comm->checkMode:

📎 src/misc/argcheck.cc:227-251

cpp
  if (info->comm->checkMode != ncclCheckModeDefault) {
    if ((info->coll == ncclFuncSend || info->coll == ncclFuncRecv)) {
      if (info->count > 0) NCCLCHECK(CudaPtrCheck(info->recvbuff, info->comm, "buff", info->opName));
    } else if (info->coll == ncclFuncPutSignal || info->coll == ncclFuncSignal || info->coll == ncclFuncWaitSignal) {
      // One-sided RMA ops specify the remote destination via peerWin, not sendbuff/recvbuff,
      // so the standard CUDA pointer checks do not apply here.
      INFO(NCCL_COLL, "%s : skipping sendbuff/recvbuff pointer check (one-sided RMA uses peerWin)", info->opName);
    } else {
      // Check CUDA device pointers
      if (info->coll != ncclFuncBroadcast || info->comm->rank == info->root) {
        NCCLCHECK(CudaPtrCheck(info->sendbuff, info->comm, "sendbuff", info->opName));
      }
      if (info->coll != ncclFuncReduce || info->comm->rank == info->root) {
        NCCLCHECK(CudaPtrCheck(info->recvbuff, info->comm, "recvbuff", info->opName));
      }
    }

    if (info->comm->checkMode == ncclCheckModeDebugGlobal) {
      struct ncclArgsInfo* argsInfo;
      NCCLCHECK(ncclCalloc(&argsInfo, 1));
      argsInfo->info = *info;
      argsInfo->next = NULL;
      ncclIntruQueueEnqueue(&info->comm->argsInfoQueue, argsInfo);
    }
  }

세 가지 모드:

  • ncclCheckModeDefault: 가장 저렴한 검사만 수행(root 범위, datatype 범위, op 범위), CUDA API는 건드리지 않음.
  • 비기본 모드:CudaPtrCheck을(를) 호출하며, 이는 실제로cudaPointerGetAttributes을(를) 호출하므로 성능 오버헤드가 있음.
  • ncclCheckModeDebugGlobal: 로컬 검사 외에도ncclInfo을(를) 밀어 넣음argsInfoQueue, group이 끝날 때 rank 간 전역 일관성 검사를 수행한다.
〔설계 추론 및 아키텍처 트레이드오프〕

이 설계는 성능과 정확성의 트레이드오프다:cudaPointerGetAttributes는 동기 CUDA 호출이라 핫 패스에서 매 통신마다 호출하면 작은 메시지에서 현저히 느려진다. 그래서 기본 모드에서는 "제로 코스트" 검사만 하고, 비용이 큰 포인터 검증은 디버그 모드에 맡긴다.

Step-by-Step: CudaPtrCheck의 3중 방어선

시나리오 대입: 사용자가sendbuff를 전달하면, NCCL이 디버그 모드에서 이를 검증한다.

첫 번째 계층, 포인터가 유효한지:

📎 src/misc/argcheck.cc:12-18

cpp
ncclResult_t CudaPtrCheck(const void* pointer, struct ncclComm* comm, const char* ptrname, const char* opname) {
  cudaPointerAttributes attr;
  cudaError_t err = cudaPointerGetAttributes(&attr, pointer);
  if (err != cudaSuccess || attr.devicePointer == NULL) {
    WARN("%s : %s %p is not a valid pointer", opname, ptrname, pointer);
    return ncclInvalidArgument;
  }

cudaPointerGetAttributes는 유효하지 않은 포인터에 대해 에러를 반환하거나devicePointer가 NULL이다. 이는 "호스트 스택 주소를 전달"하거나 "해제된 포인터를 전달"하는 것을 막는다.

두 번째 계층, 디바이스가 일치하는지:

📎 src/misc/argcheck.cc:19-26

cpp
#if CUDART_VERSION >= 10000
  if (attr.type == cudaMemoryTypeDevice && attr.device != comm->cudaDev) {
#else
  if (attr.memoryType == cudaMemoryTypeDevice && attr.device != comm->cudaDev) {
#endif
    WARN("%s : %s allocated on device %d mismatchs with NCCL device %d", opname, ptrname, attr.device, comm->cudaDev);
    return ncclInvalidArgument;
  }

이것이 가장 은밀한 함정이다: 포인터는 유효한 GPU 포인터지만 다른 GPU에 속한다. 다중 GPU 머신에서 사용자가cudaSetDevice를 잊으면 잘못 전달하기 쉽다. NCCL은 여기서 명확히 거부한다.

세 번째 계층, 통신 도메인 객체 무결성:

📎 src/misc/argcheck.cc:38-45

cpp
ncclResult_t CommCheck(struct ncclComm* comm, const char* opname, const char* ptrname) {
  NCCLCHECK(PtrCheck(comm, opname, ptrname));
  if (comm->startMagic != NCCL_MAGIC || comm->endMagic != NCCL_MAGIC) {
    WARN("Error: corrupted comm object detected");
    return ncclInvalidArgument;
  }
  return ncclSuccess;
}

startMagic / endMagic는ncclComm구조체의 앞뒤에 배치된 센티넬 값이다. 사용자가 와일드 포인터를 전달하거나 comm이 이미 해제되었다면 magic이 맞지 않는다. 이것은 "메모리 손상 감지"의 고전적 기법이다 — 두 개의 센티넬로 구조체를 감싸서, 어떤 범위 초과 쓰기라도 둘 중 하나를 손상시킬 수 있다.

전역 일관성 검사: registrationCheck의 rank 간 검증

이것은 NCCL에서 가장 "무거운" 검증으로,ncclCheckModeDebugGlobal에서만 트리거된다. 이것이 검사하는 것은 — 모든 rank의 대칭 메모리 등록 상태가 일치하는지다.

📎 src/misc/argcheck.cc:95-111

cpp
  NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, bufInfo, sizeof(struct symBufInfo) * 2), ret, fail);

  cmpBufInfo[0] = bufInfo[0];
  cmpBufInfo[1] = bufInfo[1];
  for (int r = 1; r < comm->nRanks; r++) {
    int infoIdx = r * 2;
    if (cmpBufInfo[0].isSymRegistered != bufInfo[infoIdx].isSymRegistered ||
        cmpBufInfo[1].isSymRegistered != bufInfo[infoIdx + 1].isSymRegistered) {
      if (comm->rank == 0) {
        WARN("Coll %s size %ld symmetric registration check failed on rank %d: sendReg %d recvReg %d mismatch with "
             "rank 0 sendReg %d recvReg %d",
             info->opName, size, r, bufInfo[infoIdx].isSymRegistered, bufInfo[infoIdx + 1].isSymRegistered,
             cmpBufInfo[0].isSymRegistered, cmpBufInfo[1].isSymRegistered);
      }
      ret = ncclInvalidArgument;
      goto fail;
    }

이것은 bootstrap의allGather를 통해 각 rank의(isSymRegistered, bigOffset, userOffset)를 수집한 다음, rank별로 비교한다. 만약 rank 0의 send buffer가 대칭 메모리로 등록되었는데 rank 3은 등록하지 않았다면, 여기서 에러가 발생한다.

〔설계 추론 및 아키텍처 트레이드오프〕

왜 이 검사가 중요한가? 대칭 메모리(symmetric memory)는 모든 rank가 동일한 가상 주소 집합으로 버퍼에 접근할 것을 요구한다. 만약 어떤 rank의 buffer가 등록되지 않았다면, kernel에서 계산된 주소가 잘못되어 쓰레기 값을 읽거나 범위를 초과한다. 이런 오류는 런타임에 "결과가 가끔 틀림"으로 나타나며 극히 디버깅하기 어렵다. NCCL은 API 경계에서 allGather 한 번의 비용으로 이를 막기로 선택했다.

프로덕션 함정

함정 1: 기본 모드에서는 포인터 오류가 보고되지 않는다.사용자가 디버그 모드를 켜지 않고 잘못된 디바이스의 포인터를 전달하면, NCCL은ArgsCheck단계에서 에러를 보고하지 않고 kernel 실행 시에야 발견한다 — 이때는 이미 다른 rank의 VRAM을 손상시켰을 수 있다. 개발 단계에서는NCCL_DEBUG=WARN와checkMode디버그를 사용할 것을 권장한다.

함정 2:ncclCheckModeDebugGlobal의 allGather 오버헤드.매 통신마다 bootstrap allGather를 수행하면 작은 메시지 고빈도 시나리오에서 병목이 된다. 이 모드는 디버깅에만 적합하며 프로덕션에는 사용할 수 없다.

함정 3: userRedOp의 생명주기.이 부분을 보라:

📎 src/misc/argcheck.cc:220-225

cpp
  int opIx = int(ncclUserRedOpMangle(info->comm, info->op)) - int(ncclNumOps);
  if (ncclNumOps <= info->op &&
      (info->comm->userRedOpCapacity <= opIx || info->comm->userRedOps[opIx].freeNext != -1)) {
    WARN("%s : reduction operation %d unknown to this communicator", info->opName, info->op);
    return ncclInvalidArgument;
  }

사용자 정의 reduction op는 comm에 등록된다. 만약 사용자가 "한때 등록되었지만 이미 해제된" op를 전달하면,freeNext != -1는 그것이 이미 회수되었음을 감지한다. 이것은 "댕글링 op 핸들"을 방지하는 검사다.

에러 전파 매크로: NCCLCHECK 패밀리가 "에러를 잃지 않는" 방법

직관적 모델: 에러 전파 매크로는 "바통"

NCCL의 에러 처리는 일련의 매크로 릴레이에 의존한다: 하위 함수가ncclResult_t를 반환하면, 상위에서NCCLCHECK로 검사하고, 성공이 아니면 즉시 반환한다. 이것은 계주와 같다 — 바통(에러 코드)이 끝까지 전달되어야 하며, 어느 한 구간이라도 놓치면 전체 체인이 끊어진다.

데이터 구조: 매크로 패밀리 전체 조망

📎 src/include/checks.h:148-166

cpp
#define NCCLCHECK(call) \
  do { \
    ncclResult_t RES = call; \
    if (RES != ncclSuccess && RES != ncclInProgress) { \
      /* Print the back trace*/ \
      if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
      return RES; \
    } \
  } while (0)

#define NCCLCHECKGOTO(call, RES, label) \
  do { \
    RES = call; \
    if (RES != ncclSuccess && RES != ncclInProgress) { \
      /* Print the back trace*/ \
      if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
      goto label; \
    } \
  } while (0)

핵심 세부사항:ncclInProgress는 "에러 아님"으로 간주된다. 이것이 비차단 통신의 핵심이다 —ncclGroupEnd가ncclInProgress를 반환하면 "작업이 제출되었고 아직 완료되지 않음"을 의미하며, 호출자는 에러로 처리하지 말고 계속 폴링해야 한다.

NCCLCHECK는 직접return,NCCLCHECKGOTO로 점프한다. 후자는 리소스 정리가 필요한 시나리오에 사용된다.label정리 경로: NCCLCHECKIGNORE는 첫 번째 에러를 보존

복사

📎 src/include/checks.h:168-177

cpp
// Report failure but continue - useful for cleanup paths where we want to
// attempt all cleanup steps. Preserves the first error in RES.
#define NCCLCHECKIGNORE(call, RES) \
  do { \
    ncclResult_t TMPRES = call; \
    if (TMPRES != ncclSuccess && TMPRES != ncclInProgress) { \
      if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", TMPRES); \
      if (RES == ncclSuccess) RES = TMPRES; \
    } \
  } while (0)

대기와 중단: NCCLWAIT의 abortFlag 검사

복사

📎 src/include/checks.h:196-205

cpp
#define NCCLWAIT(call, cond, abortFlagPtr) \
  do { \
    uint32_t* tmpAbortFlag = (abortFlagPtr); \
    ncclResult_t RES = call; \
    if (RES != ncclSuccess && RES != ncclInProgress) { \
      if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
      return ncclInternalError; \
    } \
    if (COMPILER_ATOMIC_LOAD(tmpAbortFlag, std::memory_order_acquire)) NEQCHECK(*tmpAbortFlag, 0); \
  } while (!(cond))

를 호출하여(진행)call를 검사하고(충족 여부), 동시에cond를 검사한다(중단 여부).abortFlag는abortFlag로 로드하여 다른 스레드가 쓴 중단 신호를 볼 수 있게 보장한다.memory_order_acquire〔설계 추론 및 아키텍처 트레이드오프〕

이 설계는 고전적 문제를 해결한다: 어떤 rank에서 에러가 발생했을 때, 다른 rank들은 여전히 그 데이터를 기다리며 죽어있을 수 있다.

는 rank 간 중단 신호를 전파하는 메커니즘이다 — 한번 설정되면 모든 대기 루프가 종료된다.abortFlag스레드 생성과 메모리 할당의 안전 매크로

복사

📎 src/include/checks.h:237-256

cpp
#define STDTHREADCREATE_IMPL(var, func, error_action, ...) \
  do { \
    try { \
      (var) = std::thread(func, __VA_ARGS__); \
    } catch (const std::exception& e) { \
      WARN("Thread creation failed: %s", e.what()); \
      error_action; \
    } \
  } while (0)

#define STDTHREADCREATE(var, func, ...) STDTHREADCREATE_IMPL(var, func, return ncclSystemError, __VA_ARGS__)

#define STDTHREADCREATE_GOTO(var, func, RES, label, ...) \
  STDTHREADCREATE_IMPL( \
    var, func, \
    do { \
      RES = ncclSystemError; \
      goto label; \
    } while (0), \
    __VA_ARGS__)

std::thread로 변환하여 예외가 C API 경계를 관통하지 않도록 한다.ncclSystemError복사

📎 src/include/checks.h:258-275

cpp
#define NEW_NOTHROW(var, x) \
  do { \
    (var) = new (std::nothrow) x{}; \
    if (!(var)) { \
      WARN("Allocation failed"); \
      return ncclSystemError; \
    } \
  } while (0)

new (std::nothrow)프로덕션 함정

함정 1:

가 성공으로 오인됨.ncclInProgress일부 사용자 코드는로 성공을 판단하지만, 비차단 모드에서 반환되는 것은if (ret == ncclSuccess)이다. 올바른 방법은ncclInProgress이거나if (ret == ncclSuccess || ret == ncclInProgress)로 조회하는 것이다.ncclCommGetAsyncError함정 2:

坑二:NCCLCHECK소멸자에서 사용한다.만약 소멸자에서 사용하면NCCLCHECK, 오류가 즉시return, 이후 정리를 건너뛴다. 사용해야 한다NCCLCHECKIGNORE。

ABI 버전 불일치: nccl_ep의 size-based 설계

직관적 모델: ABI는 "소켓 표준"

ABI(애플리케이션 바이너리 인터페이스)는 전원 소켓 표준과 같다: 라이브러리와 호출자가 "구조체가 어떻게 생겼는지"에 대한 이해가 일치하지 않으면, 미국식 플러그를 유럽식 소켓에 꽂는 것과 같다——가벼우면 작동하지 않고, 심하면 타버린다.contrib/nccl_ep영리한 설계를 사용했다: 모든 경계를 넘는 구조체는size필드로 시작한다.

데이터 구조: size + magic 이중 검증

📎 contrib/nccl_ep/nccl_ep.cc:70-76

cpp
// Size-based ABI versioning: every cross-boundary struct starts with a `size`
// field set by the caller to sizeof(struct). The library checks that against
// its own known size; any mismatch means caller and library are from different
// releases. Strict equality for now — see nccl_ep.h for the planned future
// relaxation (all-zero-trailing-bytes escape hatch).
// Immediately after `size` there is a `magic` field pre-filled by NCCL_EP_*_INIT
// to catch unininitialized structures.

설계 요점:

  • size필드는 호출자가sizeof(struct)로 채우고, 라이브러리는 그것이 자신이 인식하는 size와 같은지 확인한다.
  • magic필드는NCCL_EP_*_INIT매크로가 미리 채우며, "초기화되지 않은" 구조체를 잡아내는 데 사용된다.
  • 현재는 엄격한 동등 비교이며, 향후 "꼬리가 모두 0이면 더 작은 size를 허용"하는 완화 모드를 지원할 계획이다.

Step-by-Step: EP_REQUIRE_STRUCT의 검증 흐름

📎 contrib/nccl_ep/nccl_ep.cc:77-80

cpp
#define EP_REQUIRE_STRUCT(ptr) \
    do { \
        assert( \
            (ptr) != nullptr && (ptr)->size == sizeof(*(ptr)) && \

이 매크로는ncclEpDispatch、ncclEpCombine등의 진입점에서 호출된다:

📎 contrib/nccl_ep/nccl_ep.cc:2827-2830

cpp
    EP_REQUIRE_STRUCT(inputs);
    EP_REQUIRE_STRUCT(outputs);
    EP_OPTIONAL_LAYOUT_INFO(layout_info);
    EP_OPTIONAL_STRUCT(config);

inputs과outputs는 필수 매개변수이며,EP_REQUIRE_STRUCT;layout_info과config는 선택적 매개변수이고,EP_OPTIONAL_*。

버전 안전한 필드 읽기: layoutInfoRecvTopkIdxKind

이것이 가장 정교한 부분이다——"호출자의 구조체가 더 작을 수 있는" 상황에서 필드를 안전하게 읽는 방법.

📎 contrib/nccl_ep/nccl_ep.cc:139-144

cpp
// Safe field reader for ncclEpLayoutInfo_t::recv_topk_idx_kind. Returns AUTO
// when the caller's struct (size) does not cover the field, preserving the
// pre-flag default.
static inline ncclEpExpertIdKind_t layoutInfoRecvTopkIdxKind(const ncclEpLayoutInfo_t* lip) {
    if (lip == nullptr) return NCCL_EP_EXPERT_ID_AUTO;
    constexpr size_t field_end = offsetof(ncclEpLayoutInfo_t, recv_topk_idx_kind) + sizeof(ncclEpExpertIdKind_t);
    if (lip->size < field_end) return NCCL_EP_EXPERT_ID_AUTO;
    return lip->recv_topk_idx_kind;
}

로직은: 호출자의size이 "해당 필드가 끝나는 오프셋"보다 작으면, 호출자가 구버전 구조체를 사용하는 것이므로 이 필드가 존재하지 않아 기본값AUTO을 반환한다. 그렇지 않으면 정상적으로 읽는다.

〔설계 추론 및 아키텍처 트레이드오프〕

이것은 ABI 호환의 표준 기법이다: 새 필드는 구조체 끝에만 추가할 수 있으며, 읽을 때size로 필드 존재 여부를 판단한다. 이렇게 하면 구버전 호출자가 구버전 구조체를 사용해도 새 라이브러리가 올바르게 처리할 수 있다.

버전 번호 확인: 소프트 경고이지 하드 거부가 아님

📎 contrib/nccl_ep/nccl_ep.cc:1393-1400

cpp
    if (in_config->version != NCCL_EP_API_VERSION) {
        fprintf(
            stderr,
            "NCCL EP WARN: ncclEpGroupConfig_t.version=%u, library API_VERSION=%u; "
            "behavior may differ across versions.\n",
            in_config->version,
            (unsigned)NCCL_EP_API_VERSION);
    }

여기서 주목할 점은WARN이지return error가 아니라는 것이다. 버전 번호 불일치는 경고일 뿐인데, 왜냐하면size검사가 이미 메모리 레이아웃 안전성을 보장하기 때문이다. 버전 번호는 "동작이 다를 수 있음"에 대한 힌트에 더 가깝다.

프로덕션 함정

함정 1: INIT 매크로로 초기화하는 것을 잊음.만약 사용자가 수동으로memset구조체를 0으로 하면,magic이 0이 되고,EP_REQUIRE_STRUCT이 실패한다.NCCL_EP_*_INIT매크로를 반드시 사용해야 한다.

함정 2: 버전 간 동적 라이브러리 혼용.만약 애플리케이션이 새 버전libnccl_ep.so에 링크되어 있지만 헤더 파일이 구버전이면,sizeof(struct)이 불일치하게 되어,EP_REQUIRE_STRUCT이 즉시 오류를 보고한다. 이것은 설계 의도이다——조용한 오류보다 빠른 실패가 낫다.

함정 3:EP_OPTIONAL_LAYOUT_INFO의 범위 검사.이 부분을 보자:

📎 contrib/nccl_ep/nccl_ep.cc:114-123

cpp
            if ((ptr)->size < kNcclEpLayoutInfoMinSize || (ptr)->size > sizeof(*(ptr))) { \
                fprintf( \
                    stderr, \
                    "NCCL EP: ncclEpLayoutInfo_t size out of supported range: " \
                    "got %u, expected [%zu, %zu]\n", \
                    (ptr)->size, \
                    kNcclEpLayoutInfoMinSize, \
                    sizeof(*(ptr))); \
                return ncclInvalidArgument; \
            } \

layout_info은 size가[min, sizeof]범위 내에 있도록 허용하는데, 이는EP_REQUIRE_STRUCT의 엄격한 동등 비교보다 더 완화된 것이다. 그 이유는layout_info이 선택적 매개변수이고, 역사적으로 필드가 증감했기 때문이다.

mermaid
flowchart TD
    entry["ncclEpDispatch(inputs, outputs, layout_info, config)"] --> req_inputs{"EP_REQUIRE_STRUCT(inputs)<br/>size == sizeof?"}
    req_inputs -->|否| err_size["assert 失败 / 返回错误"]
    req_inputs -->|是| req_outputs{"EP_REQUIRE_STRUCT(outputs)"}
    req_outputs -->|否| err_size
    req_outputs -->|是| opt_layout{"layout_info != nullptr?"}
    opt_layout -->|否| skip_layout["跳过 layout 校验"]
    opt_layout -->|是| range_check{"size in [min, sizeof]?"}
    range_check -->|否| err_range["fprintf size out of range<br/>return ncclInvalidArgument"]
    range_check -->|是| magic_check{"magic == NCCL_EP_MAGIC?"}
    magic_check -->|否| err_magic["fprintf magic mismatch<br/>return ncclInvalidArgument"]
    magic_check -->|是| read_field["layoutInfoRecvTopkIdxKind<br/>size < field_end ? AUTO : 实际值"]
    skip_layout --> read_field
    read_field --> proceed["继续执行 dispatch 逻辑"]

타임아웃, 재시도 및 중단: NCCLWAIT에서 nccl_ep의 timeout_cycles까지

직관적 모델: 타임아웃은 "퓨즈"

분산 통신에서 하나의 rank가 멈추면 모든 rank가 무한 대기하게 된다. 타임아웃 메커니즘은 퓨즈와 같다: 정상 상황에서는 작동하지 않지만, 전류 이상이 발생하면 끊어져 전체 시스템이 타버리는 것을 방지한다.

데이터 구조: abortFlag와 timeout_cycles

NCCL 코어는abortFlag로 중단 신호를 전파한다.ncclAsyncLaunch내부의 전달을 보자:

📎 src/group.cc:49-52

cpp
    job->abortFlag = comm->abortFlag;
    job->abortFlagDev = comm->abortFlagDev;
    job->childAbortFlag = comm->childAbortFlag;
    job->childAbortFlagDev = comm->childAbortFlagDev;

각 job은 comm의 abortFlag 포인터를 보유한다. group이 오류를 감지하면:

📎 src/group.cc:118-126

cpp
        if (!job->destroyFlag &&
            (COMPILER_ATOMIC_LOAD(groupAbortFlag, std::memory_order_acquire) || errorJobAbortFlag == true)) {
          COMPILER_ATOMIC_STORE(job->abortFlag, uint32_t(1), std::memory_order_release);
          COMPILER_ATOMIC_STORE(job->abortFlagDev, uint32_t(1), std::memory_order_release);
          if (job->childAbortFlag) {
            COMPILER_ATOMIC_STORE(job->childAbortFlag, uint32_t(1), std::memory_order_release);
            COMPILER_ATOMIC_STORE(job->childAbortFlagDev, uint32_t(1), std::memory_order_release);
          }
        }

일단groupAbortFlag또는errorJobAbortFlag가 참이면, 모든 job의 abortFlag가 1로 설정된다.memory_order_release은 이전 쓰기 작업이 다른 스레드에 보이도록 보장한다.

nccl_ep의 타임아웃 설계: GPU 클럭 사이클

nccl_ep은 더 정밀한 타임아웃을 사용한다——GPU 클럭 사이클 단위.

📎 contrib/nccl_ep/nccl_ep.cc:1558-1591

cpp
    // Resolve timeout_cycles: env var > config field > compile-time default
    {
        int dev;
        int clock_khz_int;
        CUDA_CHECK(cudaGetDevice(&dev));
        CUDA_CHECK(cudaDeviceGetAttribute(&clock_khz_int, cudaDevAttrClockRate, dev));
        uint64_t clock_khz = static_cast<uint64_t>(clock_khz_int);

        uint64_t resolved = NUM_TIMEOUT_CYCLES;
        const char* source = "compile-time default";
        const uint64_t env_ms = static_cast<uint64_t>(ep_group->env.timeout_ms.value.ul);
        // Only a positive timeout overrides the default.
        const bool have_env_ms = ep_group->env.timeout_ms.is_set && env_ms > 0;

        if (have_env_ms) {
            resolved = clock_khz * 1000ULL * env_ms / 1000ULL;
            source = "NCCL_EP_TIMEOUT_MS env var";
            ...
        } else if (ep_group->config.timeout_ns != 0) {
            resolved = clock_khz * 1000ULL * (ep_group->config.timeout_ns / 1000000ULL) / 1000ULL;
            source = "config.timeout_ns";
        }

        ep_group->timeout_cycles = resolved;

우선순위는: 환경 변수NCCL_EP_TIMEOUT_MS> 구성 필드timeout_ns> 컴파일 시 기본값. 변환 공식은clock_khz * 1000 * ms / 1000, 즉 밀리초를 클럭 사이클로 변환한다.

〔설계 추론 및 아키텍처 트레이드오프〕

왜 밀리초 대신 클럭 사이클을 사용하는가? GPU kernel 내부의 대기 루프는 시스템 시간 API를 호출할 수 없고,clock64()레지스터만 읽을 수 있기 때문이다. 클럭 사이클로 타임아웃을 판단하면 kernel 내부에서 직접 비교할 수 있어 host 개입이 필요 없다.

비동기 오류 플래그: host-pinned 메모리

📎 contrib/nccl_ep/nccl_ep.cc:1767-1778

cpp
    // Allocate mask buffer and async error flag for active-mask support
    if (ep_group->config.enable_mask && ep_group->config.algorithm == NCCL_EP_ALGO_LOW_LATENCY) {
        size_t mask_bytes = ep_group->nRanks * sizeof(int);
        CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&ep_group->mask_buffer), mask_bytes));
        // Initialize all ranks as active (1 = active, 0 = masked/failed)
        std::vector<int> all_active(ep_group->nRanks, 1);
        CUDA_CHECK(
            cudaMemcpyAsync(ep_group->mask_buffer, all_active.data(), mask_bytes, cudaMemcpyHostToDevice, stream));
        CUDA_CHECK(
            cudaHostAlloc(reinterpret_cast<void**>(&ep_group->async_error_flag), sizeof(int), cudaHostAllocMapped));
        *ep_group->async_error_flag = 0;
    }

async_error_flag은cudaHostAllocMapped로 할당하는데, 이것은 host-pinned이면서 디바이스 주소 공간에 매핑된 메모리이다. GPU kernel이 쓸 수 있고, host가 읽을 수 있으며, 명시적 복사가 필요 없다.

비동기 오류 읽기: 원자적 로드

📎 contrib/nccl_ep/nccl_ep.cc:4312-4321

cpp
ncclResult_t ncclEpGetAsyncError(ncclEpGroup_t ep_group, int* error_out) {
    EP_HOST_ASSERT(ep_group != nullptr);
    if (!ep_group->config.enable_mask) {
        return ncclInvalidUsage;
    }
    EP_HOST_ASSERT(ep_group->async_error_flag != nullptr && "ncclEpGetAsyncError: enable_mask must be true");
    EP_HOST_ASSERT(error_out != nullptr);
    *error_out = __atomic_load_n(ep_group->async_error_flag, __ATOMIC_ACQUIRE);
    return ncclSuccess;
}

과__atomic_load_n를 함께 사용하여__ATOMIC_ACQUIRE, 캐시된 이전 값이 아닌 GPU가 쓴 최신 값을 읽도록 보장한다.

프로덕션 함정

함정 1: 타임아웃을 너무 짧게 설정하여 오탐 발생.만약NCCL_EP_TIMEOUT_MS이 너무 작게 설정되면, 정상적인 네트워크 지터가 타임아웃으로 오판된다. 실제 네트워크 RTT에 따라 설정할 것을 권장하며, 일반적으로 10초 이상이어야 한다.

함정 2: abortFlag 설정 후 정리하지 않음.일단 abortFlag가 1로 설정되면, comm은 "중단" 상태에 들어간다. 사용자가 이 comm을 계속 사용하려면 먼저 abortFlag를 정리해야 한다. NCCL의ncclCommAbort이 이 정리를 수행한다.

함정 3:ncclEpMaskClean의 전제 조건.이 부분을 보자:

📎 contrib/nccl_ep/nccl_ep.cc:4262-4266

cpp
    EP_HOST_ASSERT(ep_group->config.algorithm == NCCL_EP_ALGO_LOW_LATENCY);
    EP_HOST_ASSERT(
        ep_group->rdma_buffer != nullptr &&
        "ncclEpMaskClean: rdma_buffer not yet allocated; create at least one LL handle first");
    EP_HOST_ASSERT(ep_group->sync_buffer != nullptr && ep_group->sync_window != nullptr);

ncclEpMaskClean은rdma_buffer이 할당되어 있을 것을 요구한다. 만약 사용자가 group을 생성했지만 아직 어떤 LL handle도 생성하지 않았다면,rdma_buffer은 nullptr이다(LL은 지연 할당이므로). 여기서 assert가 실패한다.

이 장 요약

이 장에서는 네 가지 유형의 프로덕션 함정을 엮었다:

1. Group 시맨틱 오용:ncclGroupDepth은 thread_local이며, 누락하면ncclGroupEnd영구적인 교착 상태를 초래할 수 있습니다. 블로킹과 논블로킹 통신 도메인은 혼용할 수 없으며, CUDA graph capture는 전부 아니면 전무여야 합니다.

2. 매개변수 검증:ArgsCheck모드별 검증으로, 기본 모드에서는 제로 코스트 검사만 수행합니다.CudaPtrCheck3계층 방어선이 무효 포인터, 잘못된 디바이스, 손상된 comm을 차단합니다.registrationCheck크로스 rank 대칭 메모리 일관성 검사를 수행합니다.

3. 오류 전파:NCCLCHECK패밀리는 오류가 유실되지 않도록 보장합니다.ncclInProgress오류가 아닙니다.NCCLCHECKIGNORE정리 경로에서 첫 번째 오류를 보존하는 데 사용됩니다.NCCLWAIT폴링 중에 abortFlag를 확인합니다.

4. ABI 버전:nccl_epsize-based 설계를 사용하여, 경계를 넘는 모든 구조체가size로 시작하며,magic와 함께 초기화되지 않은 상태를 포착합니다. 새 필드는 끝에만 추가할 수 있으며, 읽을 때size로 존재 여부를 판단합니다.

5. 타임아웃과 중단: 코어는abortFlag로 중단을 전파합니다.nccl_epGPU 클럭 사이클로 타임아웃을 구현하고,async_error_flaghost-pinned 메모리로 GPU→host 비동기 알림을 구현합니다.

이 장의 생각과 자습

Q1: 만약ncclGroupEndInternal에서if ((--ncclGroupDepth) > 0) goto exit;(📎 src/group.cc:1061)를if (ncclGroupDepth > 0) goto exit;(감소하지 않음)로 바꾸면 무슨 일이 발생할까요? 중첩 group 시나리오에서 어떤 결과가 발생할까요?

참고 해석:

원래 코드는--ncclGroupDepth먼저 감소한 후 판단합니다. 만약 감소하지 않도록 바꾸면:

cpp
if (ncclGroupDepth > 0) goto exit;  // 错误版本

그러면 매번ncclGroupEnd깊이가 줄어들지 않습니다. 사용자가 다음과 같이 작성했다고 가정합니다:

cpp
ncclGroupStart();  // depth = 1
ncclGroupStart();  // depth = 2
ncclAllReduce(...);
ncclGroupEnd();    // 原版: depth = 1, 返回; 错误版: depth = 2, 返回
ncclGroupEnd();    // 原版: depth = 0, 触发下发; 错误版: depth = 2, 返回

잘못된 버전에서는 두 번째ncclGroupEnd시ncclGroupDepth가 여전히 2이고,> 0가 성립하여 바로goto exit되어, 영원히 발송이 트리거되지 않습니다. 모든 통신 호출이 "주문 모으기" 상태에 머물러 프로세스가 교착 상태에 빠집니다.

더 은밀한 것은:ncclGroupDepth는 thread_local이라 함수가 반환되어도 리셋되지 않습니다. 이후 코드가 group API를 더 이상 호출하지 않더라도, 이 스레드의 모든 통신이 무효화됩니다.

이 변경은ncclGroupStart의 페어링 의미도 훼손합니다——ncclGroupStart는 증가하고,ncclGroupEnd는 감소하지 않아 깊이가 늘기만 하고 줄지 않아 결국 오버플로됩니다(비록 int 오버플로는 20억 번 호출이 필요하므로 실제로는 논리적 교착 상태일 가능성이 더 큽니다).

Q2: CudaPtrCheck에서attr.type == cudaMemoryTypeDevice && attr.device != comm->cudaDev(📎 src/misc/argcheck.cc:20) 이 검사에서, 만약attr.type == cudaMemoryTypeDevice라는 조건을 제거하면 어떤 문제가 발생할까요? 어떤 시나리오에서 오탐이 발생할까요?

참고 답안:

cudaPointerAttributes.type에는 세 가지 가능한 값이 있습니다:cudaMemoryTypeDevice(디바이스 메모리),cudaMemoryTypeHost(호스트 메모리),cudaMemoryTypeManaged(통합 메모리).

만약attr.type == cudaMemoryTypeDevice조건을 제거하면, 다음과 같이 됩니다:

cpp
if (attr.device != comm->cudaDev) {  // 错误版本

그러면 host 메모리나 managed 메모리에 대해,attr.device가 -1 또는 0일 수 있어comm->cudaDev와 일치하지 않아 "디바이스 불일치" 오탐이 발생합니다.

구체적 시나리오: 사용자가cudaMallocManaged로 할당된 포인터를 전달합니다. managed 메모리의attr.device는 보통 할당 시의 디바이스이지만, 메모리가 다른 디바이스로 마이그레이션되면attr.device가 변할 수 있습니다. 더 흔한 것은 host 메모리(예:cudaHostAlloc로 할당된 pinned 메모리)로,attr.device가 -1이어서 어떤cudaDev와도 같지 않아 오탐이 발생합니다.

NCCL은 host 메모리를 통신 버퍼로 허용하므로(cudaMemcpy를 통해 중계), "디바이스 메모리이지만 디바이스가 잘못됨"과 "비디바이스 메모리"를 반드시 구분해야 합니다. 전자는 오류이고, 후자는 합법입니다.

Q3: layoutInfoRecvTopkIdxKind(📎 contrib/nccl_ep/nccl_ep.cc:139-144)는lip->size < field_end로 필드 존재 여부를 판단합니다. 만약 새 버전이 구조체 중간에 필드를 삽입하면(끝이 아니라), 이 판단이 어떻게 무효화될까요? 왜 ABI 설계는 새 필드를 끝에만 추가하도록 규정할까요?

참고 해석:

원래 구조체가 다음과 같다고 가정합니다:

c
struct ncclEpLayoutInfo_t {
    unsigned int size;
    unsigned int magic;
    ncclEpExpertIdKind_t recv_topk_idx_kind;  // offset = 8
};

field_end = offsetof(recv_topk_idx_kind) + sizeof(...) = 8 + 4 = 12。

만약 새 버전이magic와recv_topk_idx_kind사이에 필드를 삽입하면:

c
struct ncclEpLayoutInfo_t {
    unsigned int size;
    unsigned int magic;
    unsigned int new_field;                    // 新插入
    ncclEpExpertIdKind_t recv_topk_idx_kind;  // offset 变成 12
};

이때field_end = 12 + 4 = 16. 구 호출자의size는 12(구 구조체 크기)이고,12 < 16가 성립하여 함수가AUTO를 반환합니다——하지만 구 호출자에게는 실제로recv_topk_idx_kind필드가 있고, 단지 오프셋이 다를 뿐입니다. 이로 인해 구 호출자가 설정한recv_topk_idx_kind가 무시됩니다.

더 나쁜 것은, 구 호출자가 구 오프셋(8)에 따라recv_topk_idx_kind를 썼다면, 새 라이브러리가 새 오프셋(12)에 따라 읽어new_field의 값을 읽게 되어 완전히 뒤죽박죽이 됩니다.

그래서 ABI 설계의 철칙은:새 필드는 구조체 끝에만 추가할 수 있다입니다. 이렇게 하면 구 호출자의size가 새 필드의field_end보다 작아 함수가 올바르게 기본값을 반환하고, 새 호출자의size가 새 필드를 커버하여 정상적으로 읽습니다. 중간에 필드를 삽입하면 모든offsetof기반 버전 판단이 깨집니다.

이 장에서는 프로덕션 환경에서의 네 가지 전형적인 함정과 그 내부 방어 메커니즘을 분석했습니다. 이러한 경계 조건은 NCCL의 안정적인 운영이 핵심 구현뿐만 아니라 주변 생태계의 적응과 확장에도 의존한다는 것을 상기시켜 줍니다. 다음 장에서는 생태계와 확장으로 전환하여, nccl4py, nccl4rust, nccl_ep, nccl_ubx 같은 주변 프로젝트가 어떻게 NCCL의 능력을 더 넓은 사용자에게 전달하는지 살펴보겠습니다.

CHAPTER 23

제 23 장: 제 23 장: 생태계 확장: nccl4py, nccl4rust, nccl_ep, nccl_ubx 등 주변 프로젝트

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 23 / 25 장

제 23 장: 생태계 확장: nccl4py, nccl4rust, nccl_ep, nccl_ubx 등 주변 프로젝트

지난 장에서는 프로덕션 환경에서 발생하는 NCCL의 전형적인 장애—group 시맨틱 오용, rank 수 불일치, stream 상호작용, ABI 버전 충돌, 네트워크 타임아웃—을 살펴보았다. 이러한 문제는 대부분 C ABI를 직접 사용하는 상황에서 발생하지만, 현대 대규모 모델 학습 프레임워크는 C ABI를 직접 호출하지 않고 Python, Rust 등의 언어 바인딩이나 MoE, 초광대역 통신 등의 시나리오를 위한 확장 프로젝트를 통해 NCCL의 기능을 재사용하는 경우가 많다. 이러한 주변 프로젝트는 bindings/와 contrib/ 디렉터리에 위치하며, 실험적이고 커뮤니티에서 유지 관리되는 성격으로 핵심 라이브러리의 릴리스 품질 보증을 계승하지 않는다. 이번 장에서는 nccl4py, nccl4rust, nccl_ep, nccl_ubx, nccl_checkpoint를 하나씩 분석하며, 이들이 언어 바인딩, 디바이스 API 확장, 심볼 인터셉트라는 세 가지 경로를 통해 핵심 외부에 어떻게 풍부한 생태계를 구축하는지 살펴본다.

nccl4py: Cython 바인딩과 네임스페이스 패키지 설계

직관적 모델: C ABI를 Python이 이해할 수 있는 언어로 번역하기

NCCL 코어가 C 언어만 구사하는 외교관이고, Python 학습 스크립트가 Python만 구사하는 인턴이라고 상상해 보자. nccl4py는 바로 그 통역사다—외교관이 하는 말(NCCL의 동작)을 바꾸지 않고, 단지 「ncclAllReduce(sendbuff, recvbuff, count, ...)」를 「nccl.all_reduce(tensor)」로 번역할 뿐이다. 이러한 번역 계층이 없다면 모든 Python 프레임워크가 자체적으로 ctypes 바인딩을 작성해야 하므로 중복 작업이 발생하고 오류가 발생하기 쉽다.

계층 구조: Cython 저수준 + Python 고수준

nccl4py의 설계는 두 계층이다: 저수준은 Cython 바인딩(nccl/bindings/cynccl.pxd), 고수준은 Python API(nccl.core)이다. README에 이 계층 구조가 명확히 설명되어 있다📎 bindings/nccl4py/README.md:4-4:

nccl4py provides low-level Cython bindings and a high-level Python API

Cython 바인딩은.pxd파일 형태로 wheel과 함께 배포되어 다른 Cython 확장이 직접cimport 📎 bindings/nccl4py/README.md:39-43:

cython
from nccl.bindings cimport cynccl
〔설계 추론 및 아키텍처 트레이드오프〕

왜 Python 계층만이 아니라 Cython 계층도 노출하는가? 일부 프레임워크(예: DeepSpeed, Megatron)의 핵심 루프가 Cython에 있어서 매번 호출할 때마다 Python 인터프리터 오버헤드가 너무 크기 때문이다. 직접cimport cynccl를 사용하면 Cython 확장이 C에 가까운 제로 오버헤드로 NCCL 함수를 호출할 수 있다. 이는 「계층적 노출」의 전형적인 설계다—고수준은 일반 사용자에게, 저수준은 성능에 민감한 시나리오에 제공한다.

네임스페이스 패키지: 여러 배포판이nccl접두사를 공유

이것이 nccl4py의 가장 교묘한 설계다.nccl는 PEP 420 암시적 네임스페이스 패키지다📎 bindings/nccl4py/README.md:50-51:

nccl is a PEP 420 implicit namespace package. nccl4py provides nccl.bindings and nccl.core; other NCCL extension distributions can provide additional nccl.* subpackages.
〔설계 추론 및 아키텍처 트레이드오프〕

전통적인 Python 패키지에서는nccl/__init__.py가 전체nccl네임스페이스를 「소유」한다. nccl4py와 nccl_ep의 Python 바인딩이 모두nccl.xxx를 제공하려고 하면 충돌이 발생한다—먼저 설치한 쪽이 이긴다. PEP 420 네임스페이스 패키지는 이 문제를 해결한다:__init__.py가 없으면 여러 배포판이 각자nccl/디렉터리에 하위 패키지를 넣을 수 있고, Python 임포트 시스템이 이를 병합한다. 따라서 nccl4py는nccl.bindings와nccl.core를 제공하고, nccl_ep는nccl.ep를 제공하며, 둘은 공존할 수 있다📎 contrib/nccl_ep/README.md:80-82。

이 설계는 생태계 확장에 매우 중요하다: 향후 어떤 제3자가nccl.monitoring、nccl.profiling를 추가하려 해도 nccl4py의 코드를 수정할 필요가 없다.

CUDA 버전 선택: extra 메커니즘

설치 시nccl4py[cu12]또는nccl4py[cu13]로 CUDA 메이저 버전을 선택한다📎 bindings/nccl4py/README.md:13-17. README에서 그 이유를 설명한다: extras는 해당하는 NCCL runtime과 CUDA Python 의존성을 설치한다📎 bindings/nccl4py/README.md:19. 이미 배포된 wheel은CUDA_HOME나 로컬 CUDA Toolkit이 필요 없지만, 소스에서 컴파일하려면📎 bindings/nccl4py/README.md:20-21。

가 필요하다

〔설계 추론 및 아키텍처 트레이드오프〕

이는 Python 생태계에서 CUDA 버전 파편화를 처리하는 표준 방식이다. CUDA 12와 13의 ABI는 호환되지 않아 하나의 wheel로 모두 처리할 수 없다. extra를 사용하면 pip가 사용자 환경에 따라 올바른 바이너리 의존성을 선택하므로, 런타임에야 버전 불일치를 발견하는 상황을 피할 수 있다.

프로덕션 함정 회피__init__.py함정 1: 네임스페이스 패키지와충돌.nccl/어떤 제3자 패키지가__init__.py아래에nccl.core를 넣으면 PEP 420 네임스페이스 패키지 메커니즘이 깨져python -c "import nccl; print(nccl.__path__)"임포트가 실패한다.排查 방법:AttributeError, 만약nccl가 보고되면

가 네임스페이스 패키지가 아님을 의미한다. cynccl.pxd함정 2: Cython ABI 버전 드리프트.📎 bindings/nccl4py/README.md:32-32는 실험적 API.pxd이며, NCCL 업그레이드 시cimport cynccl가 변경될 수 있다.

에 의존하는 Cython 확장은 nccl4py 버전과 엄격히 일치해야 하며, 그렇지 않으면 컴파일 시 심볼 해석이 실패한다.

nccl4rust: RAII 소유권과 디바이스 측 경계

직관적 모델: 컴파일러가 수명 주기를 관리하도록 하기ncclCommInitRankC 언어에서는ncclCommDestroy로 communicator를 얻고, 사용 후 반드시

nccl4rust의 핵심 가치는 이 소유권 의미론을 NCCL의 C ABI 위에 씌우는 것이다.

계층 구조: 다섯 개의 crate가 각자 역할을 담당

README의 Layout 표에는 다섯 개의 crate가 나열되어 있다📎 contrib/nccl4rust/README.md:20-28:

PathPurpose
crates/nccl-sysbindgen이 생성한 원시 host ABI
crates/ncclRust 스타일 host 래핑 + RAII 소유권
crates/nccl-device-sysno_stdCUDA-Oxide 디바이스 선언
crates/nccl-device타입화DevComm、Team、Window래핑
shim/순수 C-ABI 심, 공개 헤더만 사용
〔설계 추론 및 아키텍처 트레이드오프〕

이 분리는 의도적이다. README에서 동기를 설명한다📎 contrib/nccl4rust/README.md:30-32: host 애플리케이션은nccl만 사용할 수 있고 Rust GPU 컴파일러가 필요 없다; CUDA-Oxide 커널은nccl-device을 사용한다; 원시 ABI가 필요한 소비자는-syscrate를 선택할 수 있다. 이러한 「필요에 따른 계층화」를 통해 각 사용자는 자신에게 필요한 컴파일 비용만 지불한다.

핵심 설계: 값이 아닌 포인터로 디바이스 통신자 전달

이것이 nccl4rust에서 가장 배울 만한 설계 결정이다. README의 Host/device ownership boundary 섹션📎 contrib/nccl4rust/README.md:211-219:

ncclDevCommCreate produces a versioned public structure in host memory. The host DeviceCommunicator wrapper owns that structure and destroys it before its parent communicator. CUDA-Oxide remains responsible for allocating device memory, copying those bytes, and keeping the copy alive while kernels execute. Kernels construct nccl_device::DevComm from a pointer to that device copy. Using a pointer rather than a by-value Rust mirror keeps the versioned C struct layout out of the kernel argument ABI.
〔설계 추론 및 아키텍처 트레이드오프〕

왜 Rust 구조체로 C 구조체를 미러링하지 않는가? 왜냐하면ncclDevComm_t은 버전화되어 있기 때문이다—NCCL 버전마다 필드가 다를 수 있다. 만약 커널 파라미터를 Rust 미러를 값으로 전달하면, 커널 ABI가 특정 NCCL 버전의 구조체 레이아웃에 묶이게 된다. NCCL이 구조체를 업그레이드하면 컴파일된 모든 커널을 재컴파일해야 한다. 포인터로 전달하면 주소 하나만 전달하고 커널은 포인터를 통해 접근하므로 레이아웃 변화가 ABI에 영향을 주지 않는다. 이는 이전 장에서 설명한ncclEpLayoutInfo_t의 size-based ABI와 같은 사상이다—버전 차이를 포인터 뒤로 격리한다。

안전 경계: 무엇이 unsafe인가

README의 Current API contracts 섹션에는 여섯 가지 계약이 나열되어 있다📎 contrib/nccl4rust/README.md:230-249, 그중 핵심 몇 가지:

  • 원시-syscrate는 C ABI만 미러링하며 소유권이나 수명 검증을 추가하지 않는다📎 contrib/nccl4rust/README.md:232-233
  • 현재 집합 통신 및 점대점 래핑은 원시 디바이스 포인터를 받으며unsafe 📎 contrib/nccl4rust/README.md:42-45
  • 로 선언된다. 포인터 변환 메서드는 원시 디바이스 포인터를 반환하며 오프셋 경계, 정렬, peer 멤버십, 별칭 또는 윈도우 수명을 검증할 수 없다📎 contrib/nccl4rust/README.md:242-244
〔설계 추론 및 아키텍처 트레이드오프〕

이것이 Rust로 NCCL을 바인딩하는 근본적 어려움이다: NCCL의 많은 API 계약은 「버퍼가 CUDA stream 완료 전까지 유효해야 한다」이지만, Rust의 타입 시스템은 「stream 완료」라는 비동기 이벤트를 표현할 수 없다. 그래서 이 메서드들은unsafe일 수밖에 없고, 책임을 호출자에게 돌려준다. README도 개선 방향을 지적한다📎 contrib/nccl4rust/README.md:44-45: stream-aware 버퍼 추상화가 이러한 요구사항을 안전한 API에 인코딩할 수 있다. 이는 향후 작업이다.

디바이스 측: CUDA-Oxide와 LTOIR 심

디바이스 측의 핵심 과제는: NCCL의 디바이스 API는 C++ 템플릿인데, Rust 디바이스 코드(CUDA-Oxide)는 C ABI가 필요하다. 해결책은 C++ 심📎 contrib/nccl4rust/README.md:26:

shim/ — CUDA C++ C-ABI shim built exclusively from public nccl.h and nccl_device.h

심은 LTOIR(LLVM 중간 표현)로 컴파일되어 Rust PTX와 함께 cubin으로 링크된다📎 contrib/nccl4rust/README.md:165-167. README에서 빌드 흐름을 설명한다📎 contrib/nccl4rust/README.md:158-163:

bash
make device \
  NCCL_INCLUDE_DIR="$NCCL_INCLUDE_DIR" \
  CUDA_HOME="$CUDA_HOME" \
  ARCH=90
〔설계 추론 및 아키텍처 트레이드오프〕

LTOIR은 NVIDIA의 링크 타임 최적화 중간 형식이다. LTOIR을 사용하고 직접 cubin으로 컴파일하지 않는 이유는 심과 Rust 커널이 링크 시점에 크로스 언어 최적화—예를 들어 심 함수를 Rust 커널에 인라인—를 할 수 있게 하기 위해서다. 이것이 「C++ 템플릿 + Rust 커널」 혼합 프로그래밍의 핵심 기술이다.

프로덕션 함정 회피

함정 1: NCCL 버전이 정확히 일치해야 한다.README에서 명확히 요구한다Matching NCCL 2.31 headers and runtime 📎 contrib/nccl4rust/README.md:80-81, 프로토타입이 초기 NCCL 디바이스 API 버전에서 다르게 초기화된 필드를 직접 초기화하기 때문이다. 헤더 파일과libnccl.so버전이 일치하지 않으면 디바이스 통신자 필드가 어긋난다.

함정 2: CUDA graph와 디바이스 통신자.디바이스 통신자는 host 메모리에 있는 버전화된 구조체로, 디바이스로 복사된 후 커널이 포인터로 접근한다. 만약 CUDA graph 캡처 시 디바이스 포인터를 커널 파라미터에 굽는다면, 이후 통신자를 재생성할 때 graph 내 포인터가 무효화된다. 이는 nccl_ep의 RDMA buffer 재할당 문제와 같은 근원이다.

함정 3: 안전 초기화를 원시 group과 혼용할 수 없다.README에서 경고한다📎 contrib/nccl4rust/README.md:238-239: 안전 초기화와 출력을 생성하는 관리 호출은 원시nccl-sysgroup 상태와 혼용할 수 없다, 래핑 계층이 원시 group 상태를 관찰할 수 없기 때문이다. 혼용하면 래핑 계층의 폴링 로직과 원시 group 의미론이 충돌한다.

nccl_ep: 전문가 병렬 dispatch/combine 프리미티브

직관적 모델: MoE의 「분류 센터」

MoE(Mixture of Experts) 모델에서 각 token은 top-k개의 expert로 라우팅되어야 합니다. Expert들은 서로 다른 GPU에 분산되어 있으므로 token은 GPU 간 전송이 필요합니다—이것이 dispatch입니다. Expert가 계산을 마치면 결과는 원래 token이 있던 GPU로 돌아가야 합니다—이것이 combine입니다. nccl_ep는 바로 이 "분류 센터"의 통신 엔진입니다.

이것이 없다면 모든 MoE 프레임워크가 dispatch/combine의 통신 로직을 자체 구현해야 하며, 이는 중복되고 최적화하기 어렵습니다. nccl_ep는 이를 NCCL 생태계의 표준 프리미티브로 만듭니다.

두 가지 알고리즘: LL과 HT

README는 두 가지 알고리즘을 설명합니다📎 contrib/nccl_ep/README.md:36-40:

  • Low-Latency (LL): 작은 batch, 지연 민감(LLM 추론). 직접적인 point-to-point all-to-all 통신을 사용합니다.
  • High-Throughput (HT): 큰 batch 학습 및 추론 prefill. 계층적 통신을 사용합니다—노드 내 NVLink 집계, 노드 간 RDMA. Hopper의 warp-specialized pipeline과 TMA를 활용합니다.
〔설계 추론 및 아키텍처 트레이드오프〕

이 두 알고리즘의 분기는 MoE 추론과 학습의 서로 다른 병목을 반영합니다. 추론 시에는 batch가 작아 지연이 주요 모순이므로 LL은 직접적인 point-to-point로 집계 오버헤드를 피합니다. 학습 시에는 batch가 커 대역폭이 주요 모순이므로 HT는 계층적 집계로 노드 간 트래픽을 줄입니다. 이는 전형적인 "워크로드 특성에 따라 알고리즘을 선택하는" 설계입니다.

핵심 데이터 구조: ncclEpGroupConfig_t

이것은 EP의 설정 구조체로, 필드가 매우 많습니다📎 contrib/nccl_ep/README.md:339-362. 핵심 필드:

  • size와version: ABI 버전 검사로, 이전 장에서 설명한 size-based ABI와 같은 맥락입니다📎 contrib/nccl_ep/README.md:340-341
  • algorithm: HT 또는 LL📎 contrib/nccl_ep/README.md:342
  • max_dispatch_tokens_per_rank: 단일 rank가 최대로 dispatch할 수 있는 token 수📎 contrib/nccl_ep/README.md:344
  • rdma_buffer_size: LL 모드의 RDMA 버퍼 크기📎 contrib/nccl_ep/README.md:356-356
  • alloc: 사용자 정의 디바이스 메모리 할당자📎 contrib/nccl_ep/README.md:359
〔설계 추론 및 아키텍처 트레이드오프〕

rdma_buffer_size의NCCL_EP_AUTO시맨틱은 깊이 파고들 가치가 있습니다. README는📎 contrib/nccl_ep/README.md:396-406을 설명합니다: AUTO 모드에서 버퍼는ncclEpCreateGroup시에 할당되지 않고, 첫 번째ncclEpInitHandle시에 실제(layout, num_topk)에 따라 할당됩니다. 이후 handle이 더 큰 버퍼를 필요로 하면 집단적으로 재할당됩니다. 이 "지연 할당" 설계는 사용자가 버퍼 크기를 추측하는 것을 피하게 하지만 세 가지 제약을 도입합니다📎 contrib/nccl_ep/README.md:396-406:

1. 모든 rank가 동일한(layout, num_topk)으로 동기 호출해야 함ncclEpInitHandle

2. 재할당은 이전 버퍼 내용을 버리므로,send_only에 임시 저장된 데이터가 손실됨

3. CUDA graph 캡처는 RDMA 기본 주소 포인터를 굽기 때문에, 재할당 후에는 반드시 다시 캡처해야 함

이것은 이 장에서 가장 중요한 프로덕션 함정 중 하나입니다.지연 할당은 사용성을 얻는 대신 "언제 재할당할지"의 복잡성을 사용자에게 전가합니다.

텐서 디스크립터: 정적 및 동적 두 가지 형태

ncclEpTensor_t은 경량 값 타입입니다📎 contrib/nccl_ep/README.md:310-332. README는 두 가지 사용법을 보여줍니다:

정적 디스크립터(스택 상,NCCL_EP_TENSOR_INIT_INLINE)📎 contrib/nccl_ep/README.md:806-809:

c
ncclEpTensor_t expert_counters = { NCCL_EP_TENSOR_INIT_INLINE,
                                   .ndim = 1, .datatype = ncclInt32,
                                   .data = expert_counters_data,
                                   .sizes = expert_counters_dims };

동적 디스크립터(힙 상,ncclEpTensorAlloc)📎 contrib/nccl_ep/README.md:793-798:

c
ncclEpTensor_t* topk_idx = nullptr;
{
    size_t dims[2] = { num_tokens, top_k };
    ncclEpTensorAlloc(&topk_idx, 2, ncclInt64, dims, /*config=*/NULL);
    cudaMalloc(&topk_idx->data, num_tokens * top_k * sizeof(int64_t));
}
〔설계 추론 및 아키텍처 트레이드오프〕

두 형태의 차이는sizes배열의 소유권에 있습니다. 정적 디스크립터의sizes은 호출자가 소유한 스택 배열로, 디스크립터보다 오래 살아 있어야 합니다📎 contrib/nccl_ep/README.md:325-326. 동적 디스크립터의sizes은 라이브러리가 소유한 힙 복사본으로,ncclEpTensorDestroy에 의해 해제됩니다📎 contrib/nccl_ep/README.md:514-514. 공용 구조체는ncclEpTensor_t*포인터를 보유하므로 두 형태를 같은 호출에서 혼용할 수 있습니다📎 contrib/nccl_ep/README.md:514-514. 이 설계는 단순한 시나리오에서 힙 할당이 전혀 없고, 복잡한 시나리오에서는 라이브러리 관리의 편의성을 제공합니다.

실행 모드: 동기 및 단계별

README의 Execution Modes 섹션은📎 contrib/nccl_ep/README.md:701-741두 가지 모드를 설명합니다:

동기 모드(기본): 데이터 수신 대기 시간을 포함하여 전체 작업 기간 동안 GPU 리소스를 점유합니다📎 contrib/nccl_ep/README.md:705-709。

단계별 모드(LL 전용): 작업이 send와 receive 두 단계로 분리됩니다📎 contrib/nccl_ep/README.md:718-726.send_only = 1로 시작하고, 데이터 전송이 시작되면 GPU 리소스를 해제하며, 애플리케이션은 이 리소스로 계산을 수행하고 마지막으로ncclEpComplete로 완료합니다📎 contrib/nccl_ep/README.md:728-741。

mermaid
sequenceDiagram
    participant App as 应用线程
    participant EP as ncclEpDispatch
    participant GPU as GPU 内核
    participant Net as RDMA 网卡
    App->>EP: ncclEpDispatch(send_only=1)
    EP->>GPU: 启动发送内核
    GPU->>Net: GIN put/signal 发起传输
    EP-->>App: 立即返回,释放 SM
    Note over App: 应用用释放的 SM 做计算
    App->>EP: ncclEpComplete()
    EP->>GPU: 启动接收内核
    GPU->>Net: 等待数据到达
    Net-->>GPU: 数据写入
    GPU-->>EP: 完成
    EP-->>App: 返回,数据就绪

이 타이밍 다이어그램은 단계별 모드의 핵심 가치를 보여줍니다:send_only이 시작 후 즉시 반환하고, SM 리소스가 계산에 해제되며, 애플리케이션이 다른 작업을 마친 후ncclEpComplete을 호출하여 수신 완료를 기다립니다. 이것은 "계산-통신 중첩"의 고전적인 패턴입니다.

프로덕션 함정 회피

함정 1:ncclEpInitHandle의 조건부 집단성.AUTO 모드에서,ncclEpInitHandle은 조건부 집단 호출입니다📎 contrib/nccl_ep/README.md:396-406. 만약 어떤 rank가 layout 차이로 재할당을 트리거하면, 다른 rank들도 동기적으로 참여해야 합니다. 동기화하지 않으면 교착 상태나 데이터 손상이 발생합니다.

함정 2: CUDA graph 캡처 중ncclEpInitHandle。금지📎 contrib/nccl_ep/README.md:396-406README는 명확히 경고합니다cudaStreamBeginCapture: AUTO 모드에서cudaStreamEndCapture과ncclEpInitHandle사이에

을 호출할 수 없습니다. 재할당이 RDMA 기본 주소를 변경하는데, graph 캡처는 이미 이전 포인터를 구웠기 때문입니다.함정 3: guard 오버헤드.📎 contrib/nccl_ep/README.md:299-303README는 언급합니다NCCL_EP_DISABLE_GUARD=1: EP는 기본적으로 내부 통신 버퍼에 guard를 추가하여 인접한 dispatch/combine 호출이 서로 데이터를 파괴하는 것을 방지합니다. 고급 사용자가 연속 작업이 경쟁하지 않음을 이미 보장했다면

로 비활성화하여 오버헤드를 회수할 수 있습니다. 하지만 잘못 끄면 데이터가 조용히 손상됩니다.

nccl_ubx: 집합 통신과 대칭 할당자의 융합

일반적인 집합 통신은 데이터를 옮기는 역할만 한다. 하지만 실제 모델에서는 AllReduce 이전에 잔차 덧셈을 해야 하고, 이후에 RMSNorm을 해야 하는 경우가 많다. 이런 연산을 따로 수행하면 데이터가 VRAM에서 여러 번 오가야 한다. nccl_ubx의 접근 방식은 잔차 덧셈, RMSNorm, mxfp8 양자화를 모두 집합 통신 커널에 융합하는 것이다📎 contrib/nccl_ubx/README.md:6-9. 마치 이삿짐센터가 짐만 옮기는 게 아니라 포장과 해체까지 한 번에 해주는 것과 같다.

하드웨어 전제 조건: NVLink 멀티캐스트가 반드시 있어야 함

README는 SM 9.0+ (Hopper/Blackwell)을 명시적으로 요구하며, MC 커널 경로에는 NVLink 멀티캐스트 하드웨어가 필요하다📎 contrib/nccl_ubx/README.md:24-24. SM 8.0 (A100)은 지원되지 않는데, Ampere에는 NVLink 멀티캐스트 하드웨어가 없기 때문이며,multimem.*인라인 PTX가 arch 8.0용으로 어셈블될 수 없다📎 contrib/nccl_ubx/README.md:24-24。

〔설계 추론 및 아키텍처 트레이드오프〕

이것이 ubx가 "실험적"인 이유를 설명한다——Hopper에서야 도입된 NVLink 멀티캐스트 기능에 의존하기 때문이다.multimem.*이 명령은 하나의 GPU가 단일 명령으로 여러 GPU의 대칭 주소에 데이터를 쓸 수 있게 해주며, 이것이 하드웨어 가속 집합 통신의 기초다. 이 하드웨어가 없으면 ubx의 핵심 최적화는 성립하지 않는다.

대칭 할당자: PyTorch 텐서를 NCCL 윈도우로 만들기

ubx의 핵심은 사용자 정의 대칭 할당자다📎 contrib/nccl_ubx/README.md:11-14:

A central piece of the design is a custom symmetric allocator that provides zero-copy collective input/output buffers while remaining easy to plug into existing PyTorch code: tensors are ordinary torch.Tensor instances backed by an NCCL-managed symmetric window.
〔설계 추론 및 아키텍처 트레이드오프〕

이것이 ubx에서 가장 교묘한 부분이다. NCCL의 대칭 메모리는 모든 rank가 동일한 가상 주소로 버퍼에 접근할 것을 요구한다(14장에서 설명). 하지만 PyTorch 사용자는torch.Tensor을 쓰는 데 익숙하다. ubx는torch.Tensor의 하위 저장소가 직접 NCCL 대칭 윈도우가 되도록 하여, 사용자 코드는 바꿀 필요가 없지만 집합 통신은 제로 카피가 가능하다——입출력 버퍼가 대칭 메모리 자체이므로 추가 복사가 필요 없다.

집합 통신 변형과 자동 선택

README의 Available collectives 표📎 contrib/nccl_ubx/README.md:90-90:

OpVariantsAuto-select
AllReducemc, uc, lamport, autoLamport ≤ 0.25 MB, else MC
AllToAlluc, lamport, autoLamport ≤ 0.25 MB, else UC
AllGathermc—
〔설계 추론 및 아키텍처 트레이드오프〕

세 가지 변형의 차이:mc은 NVLink 멀티캐스트 하드웨어를 사용하고,uc은 일반 유니캐스트를 사용하며,lamport은 저지연 알고리즘이다. 자동 선택은 0.25 MB를 기준으로 나뉜다——작은 메시지는 Lamport 저지연, 큰 메시지는 MC/UC 고대역폭. 이 임계값은 NCCL 코어의 튜닝 로직과 유사하지만, ubx는 고정 임계값으로 단순화했다.

융합 연산: residual + RMSNorm

README에서 언급📎 contrib/nccl_ubx/README.md:103-103:

SymmAllocator.allreduce_mc() and allreduce_lamport() accept optional gamma/residual_in parameters to fuse residual addition + RMSNorm into the same kernel.
〔설계 추론 및 아키텍처 트레이드오프〕

이것이 ubx의 핵심 셀링 포인트다. 전통적인 흐름은 AllReduce → 잔차 덧셈 → RMSNorm으로, 세 번의 VRAM 읽기/쓰기가 필요하다. 융합 후에는 한 번의 커널로 완료되어 VRAM 대역폭이 2/3 절약된다. 대역폭 제한이 있는 대규모 모델 학습에서 이는 실질적인 가속이다.

MoE 토큰 디스패치 + mxfp8 양자화

README에서 설명a2av_token_bf16_mxfp8 📎 contrib/nccl_ubx/README.md:103-103:

a single GPU kernel that routes bf16 tokens to remote ranks while quantizing them to mxfp8 (E8M0 scale per 32 elements) on the fly.
〔설계 추론 및 아키텍처 트레이드오프〕

이 커널은 "라우팅 + 양자화"를 융합한다. bf16은 16비트, mxfp8은 8비트로, 양자화 후 데이터량이 절반이 되어 노드 간 전송 대역폭 요구가 절반으로 줄어든다. 전송 전 양자화가 전송 후 양자화보다 우수하다——절약되는 것은 VRAM 대역폭이 아니라 네트워크 대역폭이다. 이것이 MoE 추론의 핵심 최적화다.

프로덕션 주의사항

함정 1:TORCH_CUDA_ARCH_LIST반드시a접미사를 붙여야 한다.README에서 강조📎 contrib/nccl_ubx/README.md:47-56:a접미사를 사용하여 전체multimem.*명령어 세트에 접근할 것을 보장하라. 일부 가속 전용 변형은 일반9.0/10.0에서 사용할 수 없으며, 향후 커널이 이 변형들을 사용하면 조용히 성능이 저하되거나 어셈블에 실패할 수 있다.

함정 2:UBX_BUILD_TIMEOUT의 런타임 오버헤드.README 설명📎 contrib/nccl_ubx/README.md:47-56: 1로 설정하면 커널 측에 spinloop 타임아웃이 컴파일되어 런타임 오버헤드가 증가한다(추가clock64()검사와 타임아웃 시printf). 행(hang) 문제를 디버깅할 때만 활성화하라.

함정 3:NCCL_NVLS_ENABLE=0의 성능 저하.README에서 이 환경 변수를 나열📎 contrib/nccl_ubx/README.md:202: 0으로 설정하면 NVLink 멀티캐스트 없이 실행할 수 있다. 하지만 MC 커널 경로가 비활성화되어 UC/Lamport 변형만 남아 성능이 크게 저하된다.

nccl_checkpoint: LD_PRELOAD 인터셉트와 상태 재생

직관적 모델: 통신 도메인 스냅샷 찍기

학습 작업이 몇 시간 동안 실행되다가 갑자기 다른 머신으로 마이그레이션해야 하거나, 복구를 위해 상태를 저장해야 할 수 있다. 일반 체크포인트는 모델 가중치와 옵티마이저 상태만 저장하지만, NCCL 통신 도메인의 상태(rank 번호, 연결, 버퍼)는 직접 직렬화할 수 없다. nccl_checkpoint의 접근 방식은 모든 NCCL 호출을 인터셉트하여 초기화 단계를 기록하고, 복구 시 이 단계들을 재생하는 것이다📎 contrib/nccl_checkpoint/README.md:3-7。

마치 가구를 조립하는 모든 단계를 녹화해두고, 이사 후 녹화 영상을 보며 다시 조립하는 것과 같다. 조립된 가구를 통째로 옮기려고 시도하는 것이 아니라.

핵심 메커니즘: LD_PRELOAD 심볼 인터셉트

README의 Design 섹션📎 contrib/nccl_checkpoint/README.md:17-20:

The application is launched with LD_PRELOAD=/path/to/libnccl-checkpoint-shim.so in the environment. This allows the library to intercept all calls to NCCL functions to capture all resource initialization steps.
〔설계 추론 및 아키텍처 트레이드오프〕

LD_PRELOAD은 Linux 동적 링커의 메커니즘이다: 애플리케이션이 공유 라이브러리를 정상적으로 로드하기 전에 지정된.so을 먼저 로드한다. 이.so에 NCCL과 동일한 이름의 심볼(예:ncclCommInitRank)이 정의되어 있으면, 동적 링커는.so의 버전을 우선 사용한다. 이렇게 하면 shim이 모든 NCCL 호출을 인터셉트하여 매개변수를 기록하고, 복구 시 재생할 수 있다.

체크포인트 흐름

README의 Python 예제📎 contrib/nccl_checkpoint/README.md:44-58전체 흐름을 보여줍니다:

python
nccl_checkpoint.checkpoint_prepare()
drv.cuCheckpointProcessLock(os.getpid(), None)
drv.cuCheckpointProcessCheckpoint(os.getpid(), None)
# CRIU dump happens here.
drv.cuCheckpointProcessRestore(os.getpid(), None)
drv.cuCheckpointProcessUnlock(os.getpid(), None)
nccl_checkpoint.checkpoint_restore()
〔설계 추론 및 아키텍처 트레이드오프〕

흐름은 네 단계로 나뉩니다:

1. checkpoint_prepare(): 모든 communicator를 파괴하여 CUDA Checkpoint와 CRIU가 프로세스 상태를 안전하게 dump할 수 있게 합니다📎 contrib/nccl_checkpoint/README.md:25-27

2. cuCheckpointProcessLock/Checkpoint: CUDA 드라이버가 프로세스를 잠그고 체크포인트를 수행합니다

3. CRIU dump: 외부 도구가 프로세스 메모리와 파일 디스크립터를 디스크에 dump합니다

4. cuCheckpointProcessRestore/Unlock + checkpoint_restore(): 프로세스를 복원하고 NCCL 설정을 재생합니다📎 contrib/nccl_checkpoint/README.md:29-31

Redis KVS: 머신 간 rendezvous

README에서 Redis가 필요한 이유를 설명합니다📎 contrib/nccl_checkpoint/README.md:33-38:

Because it is useful to restore on different hardware, IP addresses may have changed. There is no convenient way to directly inform the NCCL Checkpoint library of all peer addresses during the restore process, so the library depends on a temporary Redis Key-Value store to be made available.
〔설계 추론 및 아키텍처 트레이드오프〕

복구 시 머신이 바뀔 수 있고 IP가 변경됩니다. NCCL 통신 도메인 재구성에는 모든 peer의 새 주소를 알아야 합니다. 하지만 shim은 이 주소들을 직접 알 수 없으므로 Redis KVS를 rendezvous로 사용합니다——모든 프로세스가 새 주소를 KVS에 쓰고, KVS에서 다른 프로세스의 주소를 읽습니다. 이는 이사 후 모두가 공용 게시판에서 새 주소를 교환하기로 약속하는 것과 같습니다.

README에서 Redis가 복구 부트스트랩 단계에서만 필요하다고 설명합니다📎 contrib/nccl_checkpoint/README.md:221-221,checkpoint_restore()가 반환된 후에는 중지할 수 있습니다.

제한: 세 가지 미지원

README의 Limitations 섹션📎 contrib/nccl_checkpoint/README.md:119-129에 세 가지 제한이 나열되어 있습니다:

1. ncclWinGetUserPtr()이 반환한 포인터는 복구 후 유효하지 않습니다📎 contrib/nccl_checkpoint/README.md:125-126

2. CUDA graph 캡처를 지원하지 않습니다📎 contrib/nccl_checkpoint/README.md:136-136

3. 디바이스 API를 지원하지 않습니다——ncclDevComm객체와 디바이스에 보이는ncclWindow_t값은 복구할 수 없습니다📎 contrib/nccl_checkpoint/README.md:136-136

〔설계 추론 및 아키텍처 트레이드오프〕

세 번째 제한이 가장 심각합니다. 디바이스 API는 NCCL의 새로운 방향(19장에서 다룬 DevComm)이지만 checkpoint가 지원하지 않습니다. 이는 디바이스 API를 사용하는 애플리케이션(예: nccl_ep, nccl_ubx)이 checkpoint로 복구할 수 없음을 의미합니다. 이는 생태계 파편화의 단면입니다——새 기능은 빠르게 나오지만 안정성 도구가 따라가지 못합니다.

프로덕션 함정 회피

함정 1:NCCL_CHECKPOINT_KVS_PATH은 체크포인트 전에 설정해야 하며, 복구 시 변경할 수 없습니다.README 경고📎 contrib/nccl_checkpoint/README.md:221-221: 이 환경 변수는 체크포인트 준비 단계에서는 사용되지 않지만 체크포인트에 캡처되며, 복구 시 쉽게 수정할 수 없습니다. 따라서 반드시 체크포인트 전에 설정해야 하고, 복구 환경에서 Redis 주소가 일치해야 합니다.

함정 2:NCCL_CHECKPOINT_KVS_TIMEOUT은 shim의 Redis rendezvous만 커버합니다.README 설명📎 contrib/nccl_checkpoint/README.md:221-221: 기본 300초입니다. communicator 재생이 NCCL 전송 설정 단계에 들어가면, 하위 NCCL 전송 호출은 자체 동작을 사용하며 전송별 진단이 필요할 수 있습니다. 즉, 타임아웃은 Redis 단계만 보호하고, 전송 설정 단계에서 멈추면NCCL_DEBUG으로 문제를 찾아야 합니다.

함정 3: NCCL 버전이 일치해야 합니다.README에서 NCCL 2.31.0 이상을 요구하며📎 contrib/nccl_checkpoint/README.md:158, 또한NCCL_SRC경로의 NCCL 버전이 런타임 NCCL 라이브러리 버전과 정확히 일치할 것을 권장합니다📎 contrib/nccl_checkpoint/README.md:156-158. 버전이 일치하지 않으면 재생 시 구조체 레이아웃이 어긋납니다.

설계 사고: 생태계 확장의 세 가지 모드

이 다섯 프로젝트를 돌아보면 NCCL 생태계 확장의 세 가지 모드를 정리할 수 있습니다:

모드 1: 언어 바인딩(nccl4py, nccl4rust).핵심 과제는 소유권과 수명 주기입니다. C의 ABI에는 소유권 의미가 없으므로 바인딩 계층이 직접 보완해야 합니다. nccl4py는 Cython으로 계층화하고, nccl4rust는 RAII +unsafe경계를 사용합니다. 공통점은:버전 차이를 포인터 뒤에 격리——nccl4rust는 포인터로 DevComm을 전달하고, nccl4py는 네임스페이스 패키지로 버전을 격리합니다.

모드 2: 디바이스 API 확장(nccl_ep, nccl_ubx).핵심 과제는 ABI 버전 관리와 리소스 수명 주기입니다. nccl_ep는 size-based ABI(이전 장에서 상세 설명)를 사용하고, nccl_ubx는 대칭 할당자를 사용합니다. 공통점은:지연 할당 + 집단 재할당——nccl_ep의 RDMA buffer와 nccl_ubx의 대칭 풀 모두 필요 시 할당하지만, 재할당에는 모든 rank의 동기화가 필요합니다.

모드 3: 심볼 가로채기(nccl_checkpoint).핵심 과제는 상태 캡처와 재생입니다.LD_PRELOAD으로 모든 NCCL 호출을 가로채고, 초기화 단계를 기록하고, 복구 시 재생합니다. 이 모드는 NCCL 코어를 변경하지 않지만 기존 애플리케이션에 투명하게 체크포인트 기능을 추가할 수 있습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

세 모드의 공통 제약은NCCL 버전 호환성입니다. 모든 프로젝트가 정확히 일치하는 NCCL 버전을 요구하는데, NCCL의 ABI가 진화하기 때문입니다. 이는 NCCL 생태계의 근본적인 긴장을 반영합니다: 코어는 빠르게 반복되지만 주변 프로젝트는 안정성이 필요합니다. size-based ABI, 포인터 전달, 네임스페이스 패키지는 모두 이 긴장을 완화하는 기술적 수단입니다.

mermaid
flowchart TD
    start["用户想扩展 NCCL"] --> q1{"扩展什么?"}
    q1 -->|"语言互操作"| lang["语言绑定"]
    q1 -->|"新通信模式"| dev["设备 API 扩展"]
    q1 -->|"可靠性"| ckpt["符号拦截"]
    lang --> q2{"性能敏感?"}
    q2 -->|"是"| cython["Cython 底层 + Python 高层<br/>nccl4py"]
    q2 -->|"否"| raii["RAII 包装<br/>nccl4rust"]
    dev --> q3{"需要 MoE?"}
    q3 -->|"是"| ep["dispatch/combine<br/>nccl_ep"]
    q3 -->|"否"| ubx["融合集合通信<br/>nccl_ubx"]
    ckpt --> preload["LD_PRELOAD 拦截<br/>nccl_checkpoint"]
    cython --> abi{"ABI 版本管理"}
    raii --> abi
    ep --> abi
    ubx --> abi
    preload --> abi
    abi -->|"指针传递"| safe["版本差异隔离"]
    abi -->|"size-based"| safe
    abi -->|"命名空间包"| safe

이 결정 다이어그램은 NCCL 확장의 선택 경로를 보여줍니다. 어떤 경로를 가든 결국 ABI 버전 관리라는 핵심 문제에 직면하며, 세 가지 기술적 수단(포인터 전달, size-based ABI, 네임스페이스 패키지)은 모두 버전 차이를 안정적인 인터페이스 뒤에 격리합니다.

이 장 요약

이 장에서는 NCCL 생태계의 다섯 주변 프로젝트를 분석했습니다:

  • nccl4pyCython 계층화 + PEP 420 네임스페이스 패키지를 사용하여 Python 생태계가 충돌 없이 확장할 수 있게 한다nccl.*하위 패키지.
  • nccl4rustRAII 소유권 + 포인터로 디바이스 통신자 전달을 사용하여 버전화된 C 구조체 레이아웃을 커널 ABI 외부로 격리한다.
  • nccl_epLL/HT 이중 알고리즘 + 지연 RDMA 버퍼 할당을 사용하여 MoE에 dispatch/combine 프리미티브를 제공하지만, 조건부 집합 호출과 CUDA graph 무효화 제약을 도입한다.
  • nccl_ubx대칭 할당자 + 커널 융합을 사용하여 잔차 덧셈, RMSNorm, mxfp8 양자화를 집합 통신 커널에 접어넣지만, Hopper+의 NVLink 멀티캐스트 하드웨어에 의존한다.
  • nccl_checkpoint사용LD_PRELOAD심볼 인터셉트 + Redis rendezvous를 사용하여 크로스 머신 통신 도메인 체크포인트를 구현하지만, 디바이스 API와 CUDA graph는 지원하지 않는다.

이 장의 생각과 자습

Q1: nccl_ep의rdma_buffer_size = NCCL_EP_AUTO모드에서, 만약 rank 0이 먼저ncclEpInitHandle를 호출하고 버퍼 재할당을 트리거했는데, rank 1은 layout이 달라서 재할당을 트리거하지 않았다면, 무슨 일이 발생하는가?📎 contrib/nccl_ep/README.md:396-406의 제약을 결합하여 분석하라.

참고 해석: README에 명확히 설명되어 있다📎 contrib/nccl_ep/README.md:396-406:All ranks must call ncclEpInitHandle in lockstep with the same (layout, num_topk). AUTO 모드에서ncclEpInitHandle는 조건부 집합 호출이다 — 재할당 트리거 여부는 해당 handle의(layout, num_topk)가 현재 버퍼보다 더 큰 공간을 필요로 하는지에 달려 있다.

만약 rank 0의 layout이 더 큰 버퍼를 필요로 하여 재할당을 트리거하고, rank 1의 layout은 필요하지 않다면, rank 0은 「deregister window → free → ncclMemAlloc → register」라는 집합 연산을 실행하고📎 contrib/nccl_ep/README.md:396-406, rank 1은 실행하지 않는다. 이로 인해 두 가지 문제가 발생한다:

1. 집합 연산 불일치: NCCL의 window deregister/register는 집합 연산으로, 모든 rank가 참여해야 한다. rank 0이 일방적으로 실행하면 rank 1이 후속 통신에서 이전 window 핸들을 참조하게 되고, rank 0은 이미 새 window로 교체했으므로 통신 실패 또는 데이터 오류가 발생한다.

2. 기본 주소 불일치: 재할당 후 rank 0의 RDMA 기본 주소가 변경되었고, rank 1은 변경되지 않았다. README에서 「recorded layout offsets on every live handle are pure offsets relative to the group's rdma_buffer and resolve correctly against the new base」라고 말하지만📎 contrib/nccl_ep/README.md:396-406, 이는 모든 rank가 재할당했다는 전제 하에서만 성립한다. rank 1의 기본 주소는 변경되지 않았고 rank 0은 변경되었으므로, 크로스 rank 주소 해석이 어긋난다.

올바른 방법은: 모든 rank가 동일한(layout, num_topk)을 사용하여 동기적으로ncclEpInitHandle를 호출하여 재할당 결정이 일치하도록 보장하는 것이다. 보장할 수 없다면, 명시적rdma_buffer_size > 0모드를 사용하여ncclEpCreateGroup시 한 번에 충분히 큰 버퍼를 할당하여 런타임 재할당을 피해야 한다📎 contrib/nccl_ep/README.md:396-406。

Q2: nccl4rust는 왜 값이 아닌 포인터로ncclDevComm_t를 디바이스 커널에 전달하는가? 만약 값 전달로 변경하면, NCCL이 구조체 레이아웃을 업그레이드한 후 무슨 일이 발생하는가?📎 contrib/nccl4rust/README.md:211-219을 결합하여 분석하라.

참고 해석: README에 명확히 설명되어 있다📎 contrib/nccl4rust/README.md:217-219:Kernels construct nccl_device::DevComm from a pointer to that device copy. Using a pointer rather than a by-value Rust mirror keeps the versioned C struct layout out of the kernel argument ABI.

ncclDevComm_t는 버전화된 공용 구조체로, NCCL 버전에 따라 필드가 다를 수 있다. 만약 값 전달을 사용하면:

1. 커널 ABI가 구조체 레이아웃에 바인딩됨: 커널 파라미터를 값으로 전달할 때, 컴파일러는 전체 구조체의 바이트 레이아웃을 커널의 호출 규약에 구워 넣는다. NCCL이 구조체를 업그레이드(필드 추가, 필드 순서 변경, 정렬 변경)한 후, 이미 컴파일된 커널은 여전히 이전 레이아웃으로 파라미터를 해석하여 필드가 어긋난다.

2. 모든 커널을 재컴파일해야 함: NCCL 업그레이드마다 디바이스 통신자를 사용하는 모든 커널을 재컴파일해야 한다. 대량의 머신에 배포된 훈련 작업의 경우 이는 막대한 운영 부담이다.

3. 크로스 버전 비호환: 만약 host 측에서 새 NCCL로 통신자를 생성하고, 디바이스 측 커널이 이전 NCCL로 컴파일되었다면, 값 전달은 커널이 잘못된 필드를 읽게 만든다.

포인터 전달을 사용하면 8바이트 주소 하나만 전달하고, 커널은 포인터를 통해 구조체에 접근한다. NCCL이 구조체 레이아웃을 업그레이드할 때, host 측에서 새 버전으로 통신자를 생성하고 디바이스로 복사하기만 하면, 커널이 포인터를 통해 접근하는 것은 새 레이아웃이다. 커널 자체는 재컴파일이 필요 없는데, 그 파라미터가 단지 주소이기 때문이다. 이것은 버전 차이를 포인터 뒤에 격리한다 —포인터는 안정적이고, 포인터가 가리키는 내용은 변할 수 있다。

이것은 nccl_ep의 size-based ABI와 동일한 설계 철학이다: 한 층의 간접을 통해 변하기 쉬운 버전 세부 사항을 안정적인 인터페이스 뒤에 격리한다.

Q3: nccl_checkpoint는LD_PRELOAD을 사용하여 NCCL 호출을 인터셉트하는데, 만약 애플리케이션이 nccl4py와 nccl_checkpoint를 동시에 링크하면, nccl4py의 Cython 바인딩이 직접libnccl.so의 심볼을 호출하는데,LD_PRELOAD이 인터셉트할 수 있는가? 심볼 해석 순서를 분석하라.

참고 해석: 이는 심볼 해석 순서에 달려 있다.LD_PRELOAD의 메커니즘은: 동적 링커가 애플리케이션이 정상적으로 의존하는 공유 라이브러리를 로드하기 전에, 먼저LD_PRELOAD에 지정된.so를 로드한다. 애플리케이션(또는 그것이 의존하는 라이브러리)이 심볼을 참조할 때, 동적 링커는 「먼저 로드된 것이 먼저 해석된다」는 순서로 탐색한다——LD_PRELOAD의.so가libnccl.so。

보다 우선한다. 따라서 이론적으로 nccl4py의 Cython 바인딩이ncclCommInitRank를 호출할 때, 동적 링커는 먼저libnccl-checkpoint-shim.so에 있는 동일 이름 심볼을 찾아 인터셉트에 성공한다.

하지만 몇 가지 경계 상황이 있다:

1. 직접dlopen + dlsym: 만약 nccl4py가dlopen("libnccl.so")를 사용한 후dlsym로 함수 포인터를 얻는다면,LD_PRELOAD는 인터셉트할 수 없다. 왜냐하면dlsym는 지정된.so에서 직접 심볼을 찾으며, 전역 심볼 테이블을 거치지 않기 때문이다. README에서는 C 애플리케이션이dlsym를 사용하여ncclCheckpointPrepare 📎 contrib/nccl_checkpoint/README.md:109-109를 해석한다고 언급하지만, 그것은 체크포인트 자체의 심볼을 해석하는 것이지 NCCL 심볼이 아니다.

2. 심볼 바인딩 시점: 만약 nccl4py가LD_PRELOAD가生效하기 전에 NCCL 심볼을 바인딩했다면(예를 들어__attribute__((constructor))안에서), 인터셉트가 실패할 수 있다. 하지만 정상적인 경우LD_PRELOAD는 프로세스 시작 시에生效하며, 어떤 사용자 코드보다도 이르다.

3. RTLD_DEEPBIND: 만약 nccl4py가dlopen를 사용할 때RTLD_DEEPBIND를 지정하면, 심볼 탐색은libnccl.so내부에서 우선 해석되어LD_PRELOAD를 우회한다. 이것은 흔한 함정이다.

4. 정적 링크: 만약 nccl4py가 NCCL을 정적 링크했다면,LD_PRELOAD는 완전히 무효하다. 왜냐하면 심볼이 이미 컴파일 시점에 해석되었기 때문이다.

따라서 결론은:정상적인 동적 링크 시나리오에서는LD_PRELOAD가 nccl4py의 호출을 인터셉트할 수 있다, 하지만 nccl4py가dlopen + RTLD_DEEPBIND를 사용하거나 정적 링크를 했다면 인터셉트가 실패한다. 프로덕션 사용 시에는LD_DEBUG=bindings로 심볼 바인딩을 검증하여 NCCL 호출이 shim에 의해 인터셉트되는지 확인해야 한다.

다음 장에서는 아키텍처 진화와 미래 방향으로 전환하여, NCCL이 어떻게 집합 통신 라이브러리에서 프로그래밍 가능한 통신 엔진으로 진화하는지 살펴본다.

이러한 주변 프로젝트들은 언어 바인딩, 디바이스 API 확장, 심볼 인터셉트를 통해 NCCL 핵심 능력이 다양한 시나리오에서 재사용되는 방식을 보여준다. 그리고 모든 프로젝트를 관통하는 핵심 제약은 NCCL ABI 버전 호환성이다——size-based ABI, 포인터 전달, 네임스페이스 패키지는 모두 버전 차이를 안정적인 인터페이스 뒤로 격리하는 기술적 수단이다. 이러한 수단을 이해하는 것은 이러한 주변 프로젝트를 안전하게 사용하기 위한 전제 조건이다. 이러한 확장 프로젝트들이 끊임없이 핵심의 경계를 시험할 때, NCCL 자체도 조용히 진화하고 있다: 고정 집합 연산에서 프로그래밍 가능한 통신 엔진으로, host proxy에서 GPU 직접 발송으로, 등록 버퍼에서 대칭 메모리로. 다음 장에서는 소스 코드에 남아 있는 진화의 흔적을 바탕으로, 이러한 변화가 상위 프레임워크의 통신 방식을 어떻게 재편할지 논의한다.

CHAPTER 24

제24장: 제24장: 아키텍처 진화와 미래 방향: 정적 통신에서 프로그래밍 가능한 통신으로

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전체 진행률: 제 24 / 25 장

제24장: 아키텍처 진화와 미래 방향: 정적 통신에서 프로그래밍 가능한 통신으로

이전 장에서 우리는 커뮤니티가 NCCL 핵심을 중심으로 주변 생태계를 어떻게 구축하는지 보았다: Python 바인딩, Rust 바인딩, 전문가 병렬 통신, 초대역폭 프리미티브, 통신 체크포인트. 이러한 프로젝트들은 모두 NCCL의 안정적인 API를 재사용하지만, 그들의 요구는 이미 전통적인 집합 통신의 범주를 넘어섰다——전문가 병렬은 세밀한 점대점 송수신이 필요하고, 체크포인트는 통신 상태의 일시정지/재개가 필요하며, 초대역폭 프리미티브는 표준 집합 연산을 우회하여 네트워크를 직접 조작해야 한다. 이러한 요구는 동일한 문제를 가리킨다: NCCL의 고정 집합 연산 모델이 더 유연한 통신 요구에 의해 팽창되고 있다. 이 장에서는 더 이상 단일 모듈을 보지 않고, 소스 코드에 이미 나타난 진화의 흔적에서 출발하여 NCCL이 어디로 향하고 있는지 논의한다. 구체적으로, 우리는 세 가지 얽힌 진화의 힘을 분석할 것이다: 통신 프리미티브가 고정 집합에서 프로그래밍 가능으로——src/rma/rma.cc의 RMA 작업 스케줄링은 상위 계층이 AllReduce만 호출하는 것이 아니라 Put/Signal/WaitSignal 프리미티브를 조합할 수 있게 한다; 네트워크 발신이 host proxy에서 GPU 직접 발송으로——src/gin/gin_host.cc의 GIN 백엔드 관리는 GPU kernel이 직접 네트워크 카드를 구동하게 한다; 메모리 모델이 등록 버퍼에서 대칭 메모리로——src/sym_kernels.cc의 대칭 메모리 kernel 선택은 모든 rank가 동일한 가상 주소 세트로 서로의 버퍼에 접근하게 한다. 이 세 가지 힘은 고립되어 있지 않으며, 동일한 인프라를 공유한다: src/nccl_device/core.cc의 team 추상화와 src/devcomm/devcomm_v23100.cc의 버전화된 DevComm. 이들이 어떻게 맞물리는지 이해하면, NCCL이 「집합 통신 라이브러리」에서 「프로그래밍 가능한 통신 엔진」으로 진화하는 논리를 이해하게 된다.

一、프로그래밍 가능한 통신 프리미티브: RMA가 「고정 레시피」를 「뷔페」로 바꾸는 방법

직관적 모델

전통적인 NCCL의 집합 통신은 고정 세트 메뉴와 같습니다: AllReduce를 주문하면 주방에서 AllReduce 절차대로 다 만들어 줍니다. 하지만 전문가 병렬(MoE) 시나리오에서는 각 토큰을 서로 다른 전문가에게 보내야 하는데, 전송 패턴을 컴파일 시점에는 전혀 알 수 없습니다—이것은 뷔페와 같아서 무엇을, 얼마나, 언제 가져갈지 스스로 결정해야 합니다.

RMA는 NCCL이 상위 계층에 제공하는 「뷔페 테이블」입니다: Put(데이터를 상대방 메모리에 쓰기), Signal(상대방에게 알림), WaitSignal(상대방 신호 대기). 상위 프레임워크는 이 세 가지 원시 연산을 자유롭게 조합하여 임의의 통신 패턴을 구현할 수 있습니다.

RMA가 없다면 MoE의 all-to-all은 여러 번의 소규모 집합 연산으로만 시뮬레이션할 수 있으며, 매번 전체 kernel 시작과 동기화 과정을 거쳐야 하므로 지연이 허용할 수 없을 정도로 높아집니다.

데이터 구조와 메모리 레이아웃

RMA의 핵심 데이터 구조는ncclTaskRma(작업 설명)과ncclRmaArgs(계획 매개변수)입니다. 먼저ncclRmaArgs의 필드를 살펴보겠습니다. 이것은scheduleRmaTasksToPlan에서 초기화됩니다.

📎 src/rma/rma.cc:166-171

cpp
plan->isRma = true;
plan->rmaArgs = ncclMemoryStackAlloc<struct ncclRmaArgs>(&comm->memScoped);
plan->rmaArgs->func = firstTask->func;
plan->rmaArgs->nRmaTasks = 0;
plan->rmaArgs->nRmaTasksProxy = 0;
plan->rmaArgs->nRmaTasksCe = 0;

여기서 핵심 필드는nRmaTasksProxy과nRmaTasksCe입니다. 이들은 RMA 작업을 두 가지 실행 경로로 나눕니다:

  • CE 경로(Copy Engine, 복사 엔진): 대상 rank가 LSA(Local Symmetric Access, 로컬 대칭 접근) 범위 내에 있으면 GPU의 복사 엔진으로 직접 완료할 수 있으며 네트워크가 필요하지 않습니다.
  • Proxy 경로: 대상 rank가 LSA 범위 내에 없으면 반드시 host proxy 스레드가 네트워크를 구동해야 합니다.
〔설계 추론과 아키텍처 트레이드오프〕

이러한 이분법적 설계 동기는 매우 직접적입니다: LSA 범위 내 통신은 NVLink 또는 PCIe를 통해 대역폭이 높고 지연이 낮아 CE 비동기 복사가 가장 효율적이며, 크로스 머신 통신은 반드시 네트워크 카드를 거쳐야 하므로 proxy 스레드만이 구동할 수 있습니다. 두 유형의 작업을 분리하여 스케줄링해야 CE와 proxy가 직렬 대기하지 않고 병렬로 실행될 수 있습니다.

ncclTaskRma자체에는peers、nsignals、signalIdxs세 개의 배열 포인터가 포함되어 있으며, 각각 상대방 rank, 신호 수, 신호 인덱스를 기록합니다. WaitSignal 작업의 경우 하나의 작업이 여러 peer를 기다릴 수 있고, Put/Signal 작업의 경우 하나의 작업이 하나의 peer만 대상으로 합니다.

단계별 살펴보기: WaitSignal 한 번의 스케줄링

구체적인 시나리오를 대입해 보겠습니다: rank 0이ncclWaitSignal을 호출하여 rank 1과 rank 3의 신호를 기다립니다. rank 1은 LSA 범위 내에 있고, rank 3은 그렇지 않다고 가정합니다.

첫 번째 단계: 첫 번째 비어 있지 않은 컨텍스트 큐를 찾습니다.

📎 src/rma/rma.cc:148-158

cpp
int ctx = -1;
for (int i = 0; i < comm->config.numRmaCtx; i++) {
  if (!ncclIntruQueueEmpty(&planner->rmaTaskQueues[i])) {
    ctx = i;
    break;
  }
}
if (ctx == -1) return ncclSuccess;

RMA 작업은 context별로 큐를 나누며, 각 context는 독립적인 RMA 채널입니다. 여기서 작업이 있는 첫 번째 context를 찾아 그 큐를 가져옵니다.

두 번째 단계: 첫 번째 작업을 꺼내 유형을 판단합니다.

📎 src/rma/rma.cc:163-168

cpp
struct ncclTaskRma* firstTask = ncclIntruQueueDequeue(ctxQueue);
plan->isRma = true;
plan->rmaArgs = ncclMemoryStackAlloc<struct ncclRmaArgs>(&comm->memScoped);
plan->rmaArgs->func = firstTask->func;

firstTask->func은ncclFuncWaitSignal이므로 WaitSignal 분기로 들어갑니다.

세 번째 단계: LSA 도달 가능성에 따라 peer를 분할합니다.

📎 src/rma/rma.cc:187-204

cpp
for (int i = 0; i < firstTask->npeers; i++) {
  int peerRank = firstTask->peers[i];
  bool lsaAccessible = isLsaAccessible(comm, peerRank);
  if (lsaAccessible) {
    peersCe[npeersCe] = peerRank;
    nsignalsCe[npeersCe] = firstTask->nsignals[i];
    signalIdxsCe[npeersCe] = firstTask->signalIdxs[i];
    npeersCe++;
  } else {
    peersProxy[npeersProxy] = peerRank;
    nsignalsProxy[npeersProxy] = firstTask->nsignals[i];
    signalIdxsProxy[npeersProxy] = firstTask->signalIdxs[i];
    npeersProxy++;
  }
}

isLsaAccessible이comm->devrState.lsaRankList을 순회하며 peer가 LSA 팀 내에 있는지 판단합니다. rank 1은 LSA 내에 있으므로 CE 목록에 들어가고, rank 3은 그렇지 않으므로 Proxy 목록에 들어갑니다.

네 번째 단계: CE와 Proxy 각각에 대해 새 작업을 하나씩 생성합니다.

📎 src/rma/rma.cc:206-246

cpp
if (npeersCe > 0) {
  struct ncclTaskRma* waitSignalTaskCe = ...;
  waitSignalTaskCe->peers = peersCe;
  waitSignalTaskCe->npeers = npeersCe;
  ncclIntruQueueEnqueue(&plan->rmaTaskQueueCe, waitSignalTaskCe);
  plan->rmaArgs->nRmaTasksCe = 1;
}
if (npeersProxy > 0) {
  struct ncclTaskRma* waitSignalTaskProxy = ...;
  waitSignalTaskProxy->peers = peersProxy;
  waitSignalTaskProxy->npeers = npeersProxy;
  ncclIntruQueueEnqueue(&plan->rmaTaskQueueProxy, waitSignalTaskProxy);
  plan->rmaArgs->nRmaTasksProxy = 1;
}

원래의 WaitSignal 작업 하나가 둘로 나뉩니다: CE 작업은 rank 1을 기다리고, Proxy 작업은 rank 3을 기다립니다. 두 작업은 병렬로 실행될 수 있습니다—CE 경로는 GPU에서 기다리고, Proxy 경로는 host 스레드에서 기다립니다.

다섯 번째 단계: 원래 작업을 해제합니다.

📎 src/rma/rma.cc:249-251

cpp
planner->nTasksRma -= 1;
ncclMemoryPoolFree(&comm->memPool_ncclTaskRma, firstTask);

원래 작업은 이미 두 개의 새 작업으로 나뉘었으므로 메모리 풀에 반환하여 해제합니다.

동시성 제어와 하드웨어 상호작용

RMA의 병렬 실행은ncclRmaWaitSignal에서 나타납니다.

📎 src/rma/rma.cc:43-74

cpp
if (plan->rmaArgs->nRmaTasksProxy > 0 && plan->rmaArgs->nRmaTasksCe > 0) {
  cudaStream_t ceStream = comm->rmaState.rmaCeState.ceStream;
  cudaEvent_t ceEvent = comm->rmaState.rmaCeState.ceEvent;
  CUDACHECKGOTO(cudaEventRecord(ceEvent, stream), ret, fail);
  CUDACHECKGOTO(cudaStreamWaitEvent(ceStream, ceEvent, 0), ret, fail);
  NCCLCHECKGOTO(ncclRmaProxyWaitLaunch(comm, plan, stream), ret, fail);
  NCCLCHECKGOTO(ncclRmaCeWaitLaunch(comm, plan, ceStream), ret, fail);
  CUDACHECKGOTO(cudaEventRecord(ceEvent, ceStream), ret, fail);
  CUDACHECKGOTO(cudaStreamWaitEvent(stream, ceEvent, 0), ret, fail);
}

이 코드는 CUDA event로 스트림 간 동기화를 수행합니다: 먼저 입력 스트림에 event를 기록하고, CE 스트림이 이 event를 기다리게 한 다음, 두 스트림에서 각각 proxy와 CE 작업을 시작하고, 마지막으로 입력 스트림이 CE 스트림의 event를 기다리게 합니다. 이렇게 두 경로가 병렬로 진행되지만 외부적으로는 하나의 동기 작업으로 나타납니다.

〔설계 추론과 아키텍처 트레이드오프〕

여기서의 설계 트레이드오프는: 병렬 실행이 지연을 줄일 수 있지만 추가적인 event 기록과 스트림 동기화 오버헤드를 도입한다는 것입니다. 작은 메시지의 경우 이 오버헤드가 병렬 이득을 초과할 수 있고, 큰 메시지의 경우 병렬 이득이 두드러집니다. NCCL은 여기서 적응형 판단을 하지 않고 일괄적으로 병렬 경로를 사용합니다—RMA의 전형적인 시나리오가 바로 큰 메시지의 세밀한 통신이기 때문입니다.

프로덕션 함정 회피 가이드

함정 1: LSA 도달 가능성 판단 오류로 인해 작업이 잘못된 경로로 갑니다. isLsaAccessible이lsaRankList을 순회하는데, 만약lsaSize이 0이면(예: 단일 rank 통신 도메인), 모든 peer가 도달 불가능으로 판정되어 전부 Proxy 경로로 갑니다. 이는 소규모 테스트에서는 드러나지 않지만 대규모 배포에서는 성능이 급락할 수 있습니다.排查 방법은scheduleRmaTasksToPlan의 INFO 로그에서nRmaTasksProxy과nRmaTasksCe의 비율을 보는 것입니다.

함정 2: WaitSignal 작업 분할 후 peer 배열의 수명 주기.CE 경로의peersCe은ncclMemoryStackAlloc으로 할당되며 수명 주기는comm->memScoped을 따릅니다; Proxy 경로의peersProxy은ncclCalloc으로 할당되며 작업 실행 완료 후 수동으로free. Proxy 작업 생성에 실패하면,fail분기가 이 배열들을 해제합니다.

📎 src/rma/rma.cc:302-308

cpp
exit:
  return ret;
fail:
  free(peersProxy);
  free(nsignalsProxy);
  free(signalIdxsProxy);
  goto exit;

함정 3: Put/Signal 작업의 크로스 context 배치.Put/Signal 분기에서 NCCL은 모든 context의 put/signal 작업을 동일한 plan으로 가져오지만, WaitSignal을 만나면 중단합니다.

📎 src/rma/rma.cc:279-295

cpp
for (int c = 0; c < comm->config.numRmaCtx; c++) {
  struct ncclIntruQueue<struct ncclTaskRma, &ncclTaskRma::next>* q = &planner->rmaTaskQueues[c];
  while (!ncclIntruQueueEmpty(q)) {
    struct ncclTaskRma* task = ncclIntruQueueHead(q);
    if (!isRmaPutOrSignal(task->func)) break;
    ncclIntruQueueDequeue(q);
    ...
  }
}

이 설계의 의도는 하나의 kernel 실행으로 모든 context의 put/signal을 처리하여 실행 오버헤드를 줄이는 것입니다. 그러나 각 context의 큐는 첫 번째 WaitSignal까지만 소비하여 per-context FIFO 순서를 보장합니다. 상위 계층에서 동일한 context 내에서 put과 waitSignal을 번갈아 호출하면 배치 효과가 크게 떨어집니다 — 이는 RMA 사용 시 주의해야 할 패턴입니다.

---

2. GPU 직접 네트워크 전송: GIN이 kernel이 host proxy를 우회하는 방법

직관적 모델

전통적인 NCCL 네트워크 통신은 편지 보내기와 같습니다: GPU kernel이 데이터를 버퍼에 넣으면, host proxy 스레드가 데이터를 NIC에 전달하고, NIC가 전송합니다. GIN은 GPU kernel이 직접 상대방의 우편함에 편지를 넣는 것입니다 — kernel이 NIC의 전송 큐에 직접 쓰고, NIC가 GPU 메모리를 직접 읽습니다.

GIN이 없다면 매번 네트워크 통신이 host 메모리를 경유해야 하므로 지연이 최소 한 번의 PCIe 왕복만큼 추가됩니다. MoE와 같은 세밀한 통신에서는 이 지연이 치명적입니다.

데이터 구조와 메모리 레이아웃

GIN의 핵심 상태는ncclGinState이며, 여러 백엔드(backend)와 여러 DevComm을 관리합니다. 먼저 백엔드 버전 호환성 테이블을 살펴보겠습니다.

📎 src/gin/gin_host.cc:27-33

cpp
const int proxyBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 3), NCCL_VERSION(2, 30, 5), NCCL_VERSION(2, 32, 0)};
const int gdakiBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 3), NCCL_VERSION(2, 30, 5)};
const int gpiBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 5)};
constexpr int efaGdaBackendMinVersions[] = {0, NCCL_VERSION(2, 31, 0), NCCL_VERSION(2, 32, 0)};

이 배열들의 인덱스는 백엔드 버전 번호이고, 값은 호환되는 최소 NCCL 버전입니다. 예를 들어proxyBackendMinVersions[3]은 백엔드 버전 3에 대응하며 NCCL 2.32.0 이상이 필요합니다. 이 설계를 통해 NCCL은 컴파일 타임에 바인딩하는 대신 런타임에 디바이스 코드 버전에 따라 적절한 백엔드 버전을 선택할 수 있습니다.

〔설계 추론 및 아키텍처 트레이드오프〕

이러한 버전 호환성 테이블의 설계 동기는: GIN 백엔드(NIC 드라이버, 펌웨어)와 NCCL 라이브러리의 버전 진화 속도가 다르다는 것입니다. 버전 요구사항을 하드코딩하면 어느 한쪽이 업그레이드될 때마다 호환성이 깨집니다. 배열로 버전 매핑을 하면 런타임에 동적으로 선택하여 구 버전 백엔드와의 하위 호환성을 유지할 수 있습니다.

ncclGinStateDevComm은 각 DevComm의 GIN 상태이며,contextCount、backendIndex、ginCtx[]、devHandles[]등의 필드를 포함합니다. 이는 연결 리스트로 연결되어ginState->devComms에 매달립니다.

단계별 워크스루: GIN 연결 설정 과정

시나리오를 가정합니다: rank 0이 통신 도메인을 초기화하고 GIN 연결을 설정해야 합니다.

1단계: GIN 활성화 및 지원 여부 확인.

📎 src/gin/gin_host.cc:96-107

cpp
if (ginState->connected) return ncclSuccess;
if (ncclParamGinEnable() == 0) {
  WARN("GIN is disabled.");
  return ncclInternalError;
}
if (!ginState->supported) {
  WARN("GIN not supported.");
  return ncclInvalidUsage;
}

ncclParamGinEnable()은 환경 변수NCCL_GIN_ENABLE를 읽으며, 기본값은 1입니다. 사용자가 명시적으로 비활성화하면 바로 오류를 반환합니다.

2단계: 대칭 메모리 지원 확인.

📎 src/gin/gin_host.cc:111-114

cpp
if (!comm->symmetricSupport) {
  WARN("Communicator does not support symmetric memory!");
  return ncclInternalError;
}

GIN은 대칭 메모리에 의존합니다 — GPU kernel이 상대방 버퍼의 가상 주소를 알아야 하므로, 대칭 메모리만이 주소 일관성을 보장할 수 있습니다.

3단계: 로컬 GIN 디바이스 목록 가져오기.

📎 src/gin/gin_host.cc:116-122

cpp
int nLocalGinDevs;
int localGinDevs[NCCL_TOPO_MAX_NODES];
NCCLCHECK(ncclTopoGetLocalGinDevs(comm, localGinDevs, &nLocalGinDevs));
if (nLocalGinDevs > NCCL_GIN_MAX_CONNECTIONS) {
  ATTN("Found %d local devices, but GIN supports at most %d connections. Using the first %d connections.",
       nLocalGinDevs, NCCL_GIN_MAX_CONNECTIONS, NCCL_GIN_MAX_CONNECTIONS);
}

ncclTopoGetLocalGinDevs은 토폴로지 그래프에서 GIN을 지원하는 모든 NIC를 찾습니다.NCCL_GIN_MAX_CONNECTIONS를 초과하면 앞의 몇 개만 가져오고 경고를 출력합니다.

4단계: GIN 팀 계산.

📎 src/gin/gin_host.cc:138-149

cpp
ginTeam = ncclTeamWorld(comm);
if (ginState->ginConnectionType != NCCL_GIN_CONNECTION_FULL) {
  ginTeam = {
    .nRanks = comm->nRanks / comm->contiguousRanksPerHost,
    .rank = comm->rank / comm->contiguousRanksPerHost,
    .stride = comm->contiguousRanksPerHost,
  };
}
for (int r = 0; r < ginTeam.nRanks; r++) {
  int worldRank = ncclTeamRankToWorld(comm, ginTeam, r);
  handles[r] = allHandles + worldRank * NCCL_NET_HANDLE_MAXSIZE;
}

연결 유형이 FULL이면 GIN 팀은 전체 월드 팀이고, 그렇지 않으면 각 host의 첫 번째 rank만 연결합니다(rail 연결).ncclTeamRankToWorld은 팀 내 rank를 월드 rank로 변환합니다.

5단계: 백엔드별 연결 설정.

📎 src/gin/gin_host.cc:151-202

cpp
for (int backendIdx = 0; backendIdx < ginState->numActiveBackends; backendIdx++) {
  backend = &ginState->backends[backendIdx];
  NCCLCHECKGOTO(backend->ncclGin->devices(&ndev), ret, fail);
  ...
  for (int commIdx = 0; commIdx < backend->ginCommCount; commIdx++) {
    NCCLCHECKGOTO(backend->ncclGin->listen(...), ret, fail);
    NCCLCHECKGOTO(backend->ncclGin->getProperties(...), ret, fail);
    NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, allHandles, NCCL_NET_HANDLE_MAXSIZE), ret, fail);
    NCCLCHECKGOTO(backend->ncclGin->connect(...), ret, fail);
    NCCLCHECKGOTO(backend->ncclGin->closeListen(...), ret, fail);
  }
}

각 백엔드는 먼저devices를 호출하여 디바이스 수를 가져온 다음, 각 연결에 대해 listen→getProperties→allGather→connect→closeListen 과정을 수행합니다.bootstrapAllGather은 모든 rank 간에 handle을 교환하여 각 rank가 상대방의 연결 정보를 알 수 있게 합니다.

동시성 제어와 하드웨어 상호작용

GIN의 진행 스레드는 핵심 동시성 메커니즘입니다.

📎 src/gin/gin_host.cc:56-87

cpp
void* ncclGinProgress(struct ncclGinState* ginState, int threadIdx) {
  if (ncclOsCpuCount(ginState->cpuAffinity)) {
    ncclOsSetAffinity(ginState->cpuAffinity);
  }
  while (1) {
    if (ginState->proxyThreadStopSignal.load()) return NULL;
    if (ginState->writePending.load()) {
      std::this_thread::yield();
      continue;
    }
    {
      std::shared_lock<std::shared_timed_mutex> rlock(ginState->devCommRwMutex);
      struct ncclGinStateDevComm* dc = ginState->devComms;
      while (dc) {
        struct ncclGinBackendState* backend = &ginState->backends[dc->backendIndex];
        for (int commIdx = threadIdx; commIdx < backend->ginCommCount; commIdx += ginState->proxyNthreads) {
          if (dc->devHandles[commIdx]->needsProxyProgress) {
            ncclResult_t ret = backend->ncclGin->ginProgress(dc->ginCtx[commIdx]);
            if (ret != ncclSuccess) {
              COMPILER_ATOMIC_STORE(&ginState->asyncResult, ret, std::memory_order_release);
              return NULL;
            }
          }
        }
        dc = dc->next;
      }
    }
    std::this_thread::yield();
  }
}

여기에는 몇 가지 핵심 설계가 있습니다:

1. CPU 친화성:ncclOsSetAffinity은 진행 스레드를 지정된 CPU 코어에 바인딩하여 스레드 마이그레이션으로 인한 캐시 무효화를 방지합니다.

2. 쓰기 잠금 백오프:writePending은 원자적 플래그로, 메인 스레드가devComms연결 리스트를 수정하려 할 때 먼저 설정하면 진행 스레드가 이를 보고 자발적으로 yield하여 잠금 경쟁을 방지합니다.

3. 읽기-쓰기 잠금:devCommRwMutex은shared_timed_mutex이며, 진행 스레드는 읽기 잠금을 보유하고 연결 리스트를 순회하며, 메인 스레드는 쓰기 잠금을 보유하고 연결 리스트를 수정합니다.

4. 스레드 분담: 스레드 t는 연결 t, t+proxyNthreads, t+2*proxyNthreads, ...를 담당하며, stride 루프를 통해 부하 분산을 구현합니다.

📎 src/gin/gin_host.cc:43-47

cpp
static void ginProgressWriteLock(struct ncclGinState* ginState) {
  ginState->writePending.store(true);
  ginState->devCommRwMutex.lock();
}
static void ginProgressWriteUnlock(struct ncclGinState* ginState) {
  ginState->devCommRwMutex.unlock();
  ginState->writePending.store(false);
}

이 쓰기 잠금 구현은 작성자가 하나(메인 스레드)만 있다고 가정하므로 추가 뮤텍스가 필요하지 않습니다.writePending은 먼저 플래그를 설정한 후 잠금을 획득하여, 진행 스레드가 잠금을 획득하기 전에 쓰기 의도를 볼 수 있고 자발적으로 물러날 수 있게 합니다.

프로덕션 함정 회피 가이드

함정 1: GIN 연결 수 불일치로 인한 AllGather 교착.각 rank의ginCommCount은 다를 수 있으며(로컬 NIC 수에 따라), NCCL은bootstrapAllGather을 통해 모든 rank의 최솟값을 취합니다.

📎 src/gin/gin_host.cc:176-180

cpp
ginCommCountHandles[comm->rank] = backend->ginCommCount;
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, ginCommCountHandles, sizeof(int)), ret, fail);
for (int r = 0; r < comm->nRanks; r++) {
  backend->ginCommCount = std::min(backend->ginCommCount, ginCommCountHandles[r]);
}

특정 rank의 NIC 수가 다른 rank보다 적으면 모든 rank가 최소값으로 떨어진다. 이는 연결 대칭성을 보장하지만 NIC 자원을 낭비한다.

함정 2: proxyNthreads가 ginCommCount를 초과하여 스레드가 공회전함.사용자가 설정한 경우NCCL_GIN_PROXY_NTHREADS보다 큰ginCommCount, 초과 스레드는 stride 루프에서 공회전한다.

📎 src/gin/gin_host.cc:181-183

cpp
// After cross-rank min, proxyNthreads may exceed ginCommCount if ranks disagree
// on NCCL_GIN_PROXY_NTHREADS (atypical — env vars are normally uniform across a job).
// Extra threads simply idle in the stride loop; no correctness issue.

이는 정확성 문제는 아니지만 CPU 자원을 낭비한다.排查 방법은NCCL_GIN_PROXY_NTHREADS이 실제 NIC 수보다 큰지 확인하는 것이다.

함정 3: DevComm 해제 시의 경쟁 상태. ncclGinDevCommFree먼저 연결 리스트에서 DevComm을 제거한 후 context를 파괴한다.

📎 src/gin/gin_host.cc:464-475

cpp
ginProgressWriteLock(ginState);
if (prevDc) prevDc->next = dc->next;
else ginState->devComms = dc->next;
ginProgressWriteUnlock(ginState);
struct ncclGinBackendState* backend = &ginState->backends[dc->backendIndex];
for (int commIdx = 0; commIdx < backend->ginCommCount; commIdx++) {
  NCCLCHECK(backend->ncclGin->destroyContext(dc->ginCtx[commIdx]));
}

제거 후 진행 스레드는 이 DevComm을 더 이상 볼 수 없으므로 context 파괴는 안전하다. 그러나 파괴 과정에서 in-flight 네트워크 작업이 있으면 정의되지 않은 동작이 발생할 수 있다 — 이는 GIN 사용 시 보장해야 할 사항이다: DevComm 해제 전에 모든 작업이 완료되었는지 확인해야 한다.

---

3. 대칭 메모리 kernel: 「등록 버퍼」에서 「통합 주소 공간」으로

직관적 모델

전통적인 NCCL의 버퍼는 「등록제」이다: 각 rank가 자신의 버퍼를 등록하고, 통신 시 handle을 통해 주소를 교환한다. 대칭 메모리는 「통합 주소 공간」이다: 모든 rank가 동일한 가상 주소 집합을 약속하고, rank 0의 주소 A와 rank 1의 주소 A는 각자의 물리 메모리를 가리키지만 코드에서는 동일한 주소로 접근할 수 있다.

이는 마치 모두가 「3열 5번 좌석」이 각자 집에서 같은 위치를 가리키기로 약속하여, 물건을 찾을 때 「너희 집 3열 5번 좌석이 어디야」라고 먼저 묻지 않아도 되는 것과 같다.

대칭 메모리가 없으면 각 kernel이 먼저 상대 주소를解析해야 하므로 명령 오버헤드와 레지스터 압력이 증가한다.

데이터 구조와 메모리 레이아웃

대칭 메모리 kernel의 핵심은 kernel mask — 현재 통신 도메인에서 어떤 kernel을 사용할 수 있는지 표시하는 비트맵이다.

📎 src/sym_kernels.cc:17-63

cpp
constexpr uint32_t kernelMask_STMC =
  1 << ncclSymkKernelId_AllGather_LLMC | 1 << ncclSymkKernelId_AllGather_STMC |
  ...
constexpr uint32_t kernelMask_LDMC = ...;
constexpr uint32_t kernelMask_LL = ...;
constexpr uint32_t kernelMask_AG = ...;
constexpr uint32_t kernelMask_AR = ...;
constexpr uint32_t kernelMask_RS = ...;
constexpr uint32_t kernelMask_LSA = ...;
constexpr uint32_t kernelMask_Gin = ...;
constexpr uint32_t kernelMask_Tma = ...;

각 mask는 32비트 정수이며, i번째 비트가 1이면 kernel i를 사용할 수 있음을 의미한다. 이 mask들은 다양한 차원으로 그룹화된다:

  • 프로토콜별:STMC(Simple TMA Multimem Copy)、LDMC(Low-latency Direct Multimem Copy)、LL(Low Latency)
  • 연산별:AG(AllGather)、AR(AllReduce)、RS(ReduceScatter)
  • 하드웨어별:LSA(Local Symmetric Access)、Gin(GPU-Initiated Networking)、Tma(Tensor Memory Accelerator)
〔설계 추론 및 아키텍처 트레이드오프〕

이 비트맵 설계의 장점은 비트 연산으로 사용 가능한 kernel을 빠르게 필터링할 수 있다는 것이다. 예를 들어kmask &= ~kernelMask_STMC한 줄로 모든 STMC kernel을 비활성화할 수 있으며, 리스트를 순회할 필요가 없다.

Step-by-Step Walkthrough: kernel mask 계산 한 번

시나리오를 대입해보자: rank 0이 AllReduce를 실행하고, 데이터 타입은 float16, 메시지 크기는 1MB, 통신 도메인은 8개 rank, 모두 NVLink로 연결됨.

첫 번째 단계: 연산에 해당하는 기본 mask를 가져온다.

📎 src/sym_kernels.cc:304-306

cpp
uint32_t kmask = kernelMask_coll(coll);

kernelMask_coll(ncclFuncAllReduce)가 반환된다kernelMask_AR, 5개의 AllReduce kernel을 포함한다.

두 번째 단계: STMC와 LDMC 사용 가능성을 확인한다.

📎 src/sym_kernels.cc:308-334

cpp
bool hasSTMC = comm->symkState.hasLsaMultimem;
bool hasLDMC = false;
if (comm->symkState.hasLsaMultimem) {
  switch (ty) {
  case ncclFloat16:
  case ncclBfloat16:
    hasLDMC = red == ncclDevSum || red == ncclDevMinMax || red == ncclDevSumPostDiv;
    break;
  ...
  }
}
if (!hasSTMC) kmask &= ~kernelMask_STMC;
if (!hasLDMC) kmask &= ~kernelMask_LDMC;

hasLsaMultimem는ncclSymkInitOnce에서 계산되며, NVLS 대칭 멀티캐스트가 사용 가능하고 LSA 팀이 2개 rank보다 커야 한다. float16은 LDMC를 지원하므로hasLsaMultimem가 참이면 LDMC kernel이 유지된다.

세 번째 단계: 메시지 크기 제한을 확인한다.

📎 src/sym_kernels.cc:336-342

cpp
size_t nBytes = alignUp(nElts * ncclTypeSize(ty), NCCL_SYM_KERNEL_CELL_SIZE);
size_t nBusBytes = (coll == ncclFuncAllReduce ? 1 : comm->nRanks) * nBytes;
if (nBusBytes >= (size_t(2) << 30)) kmask &= ~kernelMask_LL;
if (nBusBytes >= 32 * (size_t(2) << 30)) kmask = 0;

LL kernel은 32비트 정수로 요소 수를 추적하므로, 버스 바이트 수가 2GB를 초과하면 비활성화된다. 64GB를 초과하면 모든 kernel이 비활성화된다(32비트 정수 오버플로).

네 번째 단계: TMA 사용 가능성을 확인한다.

📎 src/sym_kernels.cc:344-345

cpp
if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
if (!symAligned16B) kmask &= ~kernelMask_Tma;

TMA는 SMEM 용량과 컴퓨팅 능력 10.0+가 필요하며, 버퍼가 16바이트 정렬되어야 한다.

다섯 번째 단계: GIN 요구사항을 확인한다.

📎 src/sym_kernels.cc:347-350

cpp
bool hasGin = ncclParamSymGinKernelsEnable() != 0;
if (!hasGin) kmask &= ~kernelMask_Gin;
bool needGin = ncclTeamLsa(comm).nRanks < comm->nRanks;
kmask &= needGin ? kernelMask_Gin : ~kernelMask_Gin;

LSA 팀이 모든 rank를 커버하면 GIN이 필요 없다; 그렇지 않으면 GIN kernel만 유지된다.

동시성 제어와 하드웨어 상호작용

대칭 메모리 kernel의 초기화는 DevComm 생성과 자원 할당을 포함한다.

📎 src/sym_kernels.cc:185-264

cpp
ncclResult_t ncclSymkInitOnce(struct ncclComm* comm) {
  NCCLCHECK(ncclDevrInitOnce(comm));
  struct ncclSymkState* symk = &comm->symkState;
  if (!symk->initialized) {
    symk->initialized = true;
    struct ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
    symk->hasLsaMultimem = ncclNvlsSymmetricMultimemEnabled(comm) && ncclTeamLsa(comm).nRanks > 2 && !comm->p2pCrossClique;
    reqs.lsaMultimem = symk->hasLsaMultimem;
    reqs.lsaBarrierCount = ncclSymkMaxBlocks;
    ...
    NCCLCHECK(ncclDevrCommCreateInternal(comm, &reqs, &symk->kcomm.devComm, /*isInternal=*/true, /*deviceCodeVersion=*/NCCL_VERSION_CODE));
  }
  return ncclSuccess;
}

여기서 핵심은ncclDevrCommCreateInternal이며, 이는 LSA 멀티캐스트, GIN inbox/outbox, 신호 등의 자원을 포함하는 내부 DevComm을 생성한다.reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAIL는 GIN이 rail 연결 모드를 사용하도록 지정한다.

📎 src/sym_kernels.cc:257-261

cpp
symk->kcomm.workStarted = comm->profiler.symWorkStarted;
symk->kcomm.workCompleted = comm->profiler.symWorkCompleted;
symk->kcomm.workPhases = comm->profiler.symWorkPhases;

대칭 메모리 kernel은 독립적인 profiler 버퍼를 사용하여 일반 kernel의 workCounter와 교차하지 않도록 한다.

프로덕션 함정 회피 가이드

함정 1: TMA kernel의 SMEM 요구사항.TMA는 warp당 약 8KB의 SMEM scratch가 필요하며, 16개 warp면 128KB이다.

📎 src/sym_kernels.cc:135-142

cpp
bool ncclSymkTmaAvailable(struct ncclComm* comm) {
  if (comm->maxSharedMemOptin < ncclTmaShmemScratchWarpSize() * 16) {
    return false;
  }
  return comm->minCompCap >= 100 && ncclParamSymTmaEnable();
}

GPU의 SMEM 용량이 부족하면(예: MIG 인스턴스), TMA kernel이 비활성화된다.排查 방법은maxSharedMemOptin이ncclTmaShmemScratchWarpSize() * 16。

보다 작은지 확인하는 것이다.함정 2: GIN chunk size의 경계.

📎 src/sym_kernels.cc:148-153

cpp
static constexpr size_t ncclSymkRsGinDefaultChunkBytes = 128 << 10;
static constexpr size_t ncclSymkRsGinMinChunkBytes = 128;
static constexpr size_t ncclSymkRsGinMaxChunkBytes = size_t(1) << 30;
size_t ncclSymkRsGinChunkBytes() {
  int64_t param = ncclParamSymRsGinChunkSize();
  size_t chunkBytes = param > 0 ? (size_t)param : ncclSymkRsGinDefaultChunkBytes;
  chunkBytes = std::max(ncclSymkRsGinMinChunkBytes, std::min(chunkBytes, ncclSymkRsGinMaxChunkBytes));
  return pow2Down(chunkBytes);
}

복사NCCL_SYM_RS_GIN_CHUNK_SIZE사용자가 설정한

함정 3: 대칭 메모리 등록 유형 불일치. ncclGetSymRegTypesendWin과 recvWin의NCCL_WIN_COLL_SYMMETRIC플래그에 따라 등록 유형을 판단합니다.

📎 src/sym_kernels.cc:395-412

cpp
if (!isSendSymmReg && !isRecvSymmReg) {
  *winRegType = ncclSymSendNonregRecvNonreg;
} else if (isSendSymmReg && !isRecvSymmReg) {
  *winRegType = ncclSymSendRegRecvNonreg;
} else if (!isSendSymmReg && isRecvSymmReg) {
  *winRegType = ncclSymSendNonregRecvReg;
} else if (isSendSymmReg && isRecvSymmReg) {
  *winRegType = ncclSymSendRegRecvReg;
}

send와 recv의 등록 유형이 일치하지 않으면 kernel은 다른 코드 경로를 거쳐야 합니다. 이는 성능에 영향을 주지만 오류를 발생시키지는 않습니다.

---

4. Team 추상화와 버전화된 DevComm: 진화의 기반 인프라

직관적 모델

Team 추상화는 '그룹화'와 같습니다: 월드 팀은 전체 학급, LSA 팀은 짝꿍, Rail 팀은 같은 열의 좌석입니다. 서로 다른 통신 모드에는 서로 다른 그룹화 관점이 필요합니다.

버전화된 DevComm은 '번역가'와 같습니다: 서로 다른 버전의 디바이스 코드는 서로 다른 '방언'을 사용하며, DevComm 호환 레이어가 번역을 담당하여 신구 코드가 서로를 이해할 수 있게 합니다.

Team 추상화가 없다면 모든 kernel이 자체적으로 rank 매핑을 계산해야 합니다; 버전화된 DevComm이 없다면 ABI 변경 시 모든 디바이스 코드를 재컴파일해야 합니다.

데이터 구조와 메모리 레이아웃

Team은 간단한 삼중항입니다:nRanks、rank、stride。

📎 src/nccl_device/core.cc:13-19

cpp
ncclTeam_t ncclTeamWorld(ncclComm_t comm) {
  ncclTeam_t ans;
  ans.nRanks = comm->nRanks;
  ans.rank = comm->rank;
  ans.stride = 1;
  return ans;
}

월드 팀의 stride는 1입니다. 모든 rank가 연속적으로 배열되기 때문입니다.

📎 src/nccl_device/core.cc:70-79

cpp
ncclTeam_t ncclTeamRail(ncclComm_t comm) {
  if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};
  ncclTeam_t ans;
  ans.nRanks = comm->nRanks / comm->devrState.lsaSize;
  ans.rank = comm->rank / comm->devrState.lsaSize;
  ans.stride = comm->devrState.lsaSize;
  return ans;
}

Rail 팀의 stride는lsaSize입니다. 각 rail의 rank가 LSA 팀 크기만큼 떨어져 있기 때문입니다.

버전화된 DevComm의 핵심은ncclDevCommCompat구조입니다.

📎 src/devcomm/devcomm_v23100.cc:10-17

cpp
struct ncclDevCommCompat ncclDevCommCompat_v23100 = {
  NCCL_VERSION(2, 31, 0), // minVersion
  NCCL_VERSION_CODE, // maxVersion
  nullptr,           // commPropertiesFilter
  nullptr,           // devCommRequirementsFilter
  nullptr,           // devCommCopyNewToOld
  nullptr,           // devCommCopyOldToNew
};

이 구조는 버전 2.31.0의 호환성 규칙을 정의합니다.minVersion와maxVersion는 적용 가능한 버전 범위를 정의하고, 뒤의 네 함수 포인터는 속성 필터링과 구조 변환 로직을 정의합니다. 모두 nullptr이면 이 버전에 특별한 호환 요구사항이 없음을 의미합니다.

단계별 살펴보기: 한 번의 Team 변환

시나리오를 가정합니다: rank 5가 8개 rank 통신 도메인에 있고, LSA 팀 크기는 4입니다. rank 5의 Rail 팀 내 rank를 계산해야 합니다.

1단계: DevR 상태 초기화.

📎 src/nccl_device/core.cc:70-79

cpp
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};

ncclDevrInitOnceLSA 팀, CFT 팀 등의 파생 정보를 계산합니다. 실패하면 빈 팀을 반환합니다.

2단계: Rail 팀 매개변수 계산.

📎 src/nccl_device/core.cc:70-79

cpp
ncclTeam_t ans;
ans.nRanks = comm->nRanks / comm->devrState.lsaSize;  // 8 / 4 = 2
ans.rank = comm->rank / comm->devrState.lsaSize;       // 5 / 4 = 1
ans.stride = comm->devrState.lsaSize;                  // 4

rank 5의 Rail 팀 내 rank는 1이고, 팀에는 2개의 rank가 있으며, stride는 4입니다.

3단계: 월드 rank로 변환.

📎 src/nccl_device/core.cc:82-84

cpp
int ncclTeamRankToWorld(ncclComm_t comm, ncclTeam_t team, int rank) {
  return comm->rank + (rank - team.rank) * team.stride;
}

Rail rank 0을 월드 rank로 변환하려면:5 + (0 - 1) * 4 = 1. 검증: rank 1과 rank 5는 같은 rail에 있습니다 (간격 4).

동시성 제어와 하드웨어 상호작용

Team 추상화 자체는 무상태이므로 동시성 제어가 필요하지 않습니다. 하지만ncclDevrInitOnce는 지연 로딩되며, 첫 호출 시 모든 파생 정보를 계산합니다.

📎 src/nccl_device/core.cc:22-33

cpp
ncclTeam_t ncclTeamLsa(ncclComm_t comm) {
  if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};
  ncclTeam_t ans;
  ans.nRanks = comm->devrState.lsaSize;
  ans.rank = comm->devrState.lsaSelf;
  ans.stride = 1;
  return ans;
}

주석에 "Ignoring errors since if it fails ncclDevrInitOnce will try again"이라고 쓰여 있습니다 — 초기화가 실패하면 빈 팀을 반환하고, 다음 호출 시 재시도합니다.

프로덕션 함정 회피 가이드

함정 1: Team 변환의 stride 가정. ncclTeamRankToWorld팀 내 rank가 등차수열이라고 가정합니다.

📎 src/nccl_device/core.cc:82-84

cpp
int ncclTeamRankToWorld(ncclComm_t comm, ncclTeam_t team, int rank) {
  return comm->rank + (rank - team.rank) * team.stride;
}

팀이 등차수열이 아니면 (예: 사용자 정의 임의 그룹화), 이 함수는 잘못 계산합니다. NCCL은 현재 규칙적 팀만 지원합니다.

함정 2: 버전화된 DevComm의 널 포인터. ncclDevCommCompat_v23100의 모든 함수 포인터가 nullptr이면 특별한 호환 로직이 없음을 의미합니다. 향후 버전에서 변환이 필요하면 이 함수들을 반드시 구현해야 하며, 그렇지 않으면 신구 코드가 상호 운용될 수 없습니다.

함정 3: CFT 팀의 계층 모드. ncclTeamCft는 세 가지 모드를 지원합니다: FLAT, HIER_MULTIMEM, HIER_LSA.

📎 src/nccl_device/core.cc:36-55

cpp
if (mode == NCCL_CFT_TEAM_FLAT) return flatTeam;
int innerSize;
if (mode == NCCL_CFT_TEAM_HIER_MULTIMEM) {
  innerSize = comm->devrState.cftMcSize;
} else if (mode == NCCL_CFT_TEAM_HIER_LSA) {
  innerSize = comm->devrState.lsaSize;
} else {
  return ncclTeam_t{};
}
return ncclTeamOuterFactor(flatTeam, innerSize);

유효하지 않은 모드를 전달하면 빈 팀을 반환합니다. CFT 팀을 사용할 때는 모드가 올바른지 확인해야 합니다.

---

설계 고찰

NCCL이 왜 RMA, GIN, 대칭 메모리 세 가지 진화 경로를 동시에 지원하는가?

〔설계 추론 및 아키텍처 트레이드오프〕

이 세 경로는 서로 다른 계층의 문제를 해결합니다:

  • RMA'통신 모드 고정' 문제를 해결합니다 — 상위 계층이 프리미티브를 조합하여 임의의 통신 모드를 구현할 수 있게 합니다.
  • GIN'네트워크 지연 높음' 문제를 해결합니다 — GPU가 직접 네트워크 카드를 구동하여 host proxy를 우회합니다.
  • 대칭 메모리'주소 해석 오버헤드' 문제를 해결합니다 — kernel이 통합 주소로 직접 상대방 메모리에 접근할 수 있게 합니다.

이들은 대체 관계가 아니라 상호 보완 관계입니다. RMA는 GIN을 하위 전송으로 사용할 수 있고, GIN은 대칭 메모리에 의존하여 주소 일관성을 제공합니다. 세 가지가 함께 '프로그래밍 가능한 통신 엔진'의 기반 인프라를 구성합니다.

버전화된 DevComm의 설계 철학은 무엇인가?

〔설계 추론 및 아키텍처 트레이드오프〕

버전화된 DevComm의 핵심 사상은 'ABI 안정, API 진화'입니다. 디바이스 코드(kernel)는 컴파일 후 바이너리에 내장되어 NCCL 라이브러리 업그레이드에 따라 재컴파일될 수 없습니다. 따라서 NCCL은 구 디바이스 코드가 신 라이브러리에서 실행될 수 있음을 보장해야 합니다.ncclDevCommCompat구조가 바로 호환 레이어의 진입점입니다: 신 라이브러리가 디바이스 코드 버전에 따라 적절한 호환 규칙을 선택하고, 필요 시 구조 변환을 수행합니다.

---

이 장 요약

이 장에서는 소스 코드의 진화 흔적에서 출발하여, NCCL이 집합 통신 라이브러리에서 프로그래밍 가능한 통신 엔진으로 나아가는 세 가지 동력을 분석했습니다:

1. RMA(src/rma/rma.cc): Put/Signal/WaitSignal 프리미티브 조합을 통해 상위 계층이 임의의 통신 모드를 구현할 수 있게 한다. 핵심 설계는 LSA 도달 가능성에 따라 작업을 CE와 Proxy 두 경로로 나누어 병렬 실행하는 것이다.

2. GIN(src/gin/gin_host.cc): GPU 직접 네트워크 전송을 통해 host proxy를 우회한다. 핵심 설계는 다중 백엔드 관리, 버전 호환성 테이블, 진행 스레드 풀이다.

3. 대칭 메모리 kernel(src/sym_kernels.cc): 통합 주소 공간을 통해 주소 해석 오버헤드를 제거한다. 핵심 설계는 kernel mask 비트맵과 TMA/GIN 하드웨어 가속이다.

4. Team 추상화와 버전화된 DevComm(src/nccl_device/core.cc、src/devcomm/devcomm_v23100.cc): 진화를 위한 인프라를 제공한다. Team은 그룹 관점을 제공하고, 버전화된 DevComm은 ABI 호환성을 제공한다.

이러한 변화가 상위 프레임워크에 미치는 영향은 심대하다: PyTorch의 ProcessGroup은 RMA 프리미티브를 직접 호출하여 사용자 정의 통신 모드를 구현할 수 있고; Megatron의 전문가 병렬화는 GIN을 활용하여 all-to-all 지연을 줄일 수 있으며; 대칭 메모리는 kernel 코드를 더 간결하게 만든다.

이 장의 고찰과 자가 점검

Q1: 만약scheduleRmaTasksToPlan에서 WaitSignal 분기의 LSA 도달 가능성 판단을 제거하고, 모든 peer가 Proxy 경로를 타게 하면 어떤 결과가 발생하는가? 어떤 시나리오에서 성능 재앙이 촉발되는가?

참고 해석:

LSA 도달 가능성 판단은📎 src/rma/rma.cc:187-204에 있으며, peer를 CE와 Proxy 두 그룹으로 나눈다. 이 판단을 제거하면 모든 peer가 Proxy 경로를 타게 되어,nRmaTasksCe은 항상 0이 된다.

결과는: CE 경로가 전혀 사용되지 않고, 모든 WaitSignal이 host proxy 스레드를 통해 네트워크를 폴링한다. LSA 범위 내의 peer(동일 머신 NVLink 연결)의 경우, 원래 GPU 복사 엔진으로 비동기 대기할 수 있었지만 이제 host 스레드 폴링으로 바뀌어 지연이 마이크로초 수준에서 밀리초 수준으로 상승한다.

성능 재앙 시나리오: MoE 훈련에서 각 token은 여러 전문가의 신호를 기다려야 한다. 모든 신호가 Proxy를 타면 host 스레드가 병목이 되어 GPU가 대량의 시간을 host 폴링 대기에 소비한다. 8카드 전 NVLink 머신에서 이 퇴화는 특히 두드러진다 — 원래 모든 통신이 CE를 탈 수 있었지만 이제 전부 host로 몰린다.

진단 방법:scheduleRmaTasksToPlan의 INFO 로그를 보면, 만약nRmaTasksCe이 항상 0이고nRmaTasksProxy이 매우 크면 LSA 판단에 문제가 있음을 의미한다.

Q2:ncclGinProgress에서writePending플래그와devCommRwMutex읽기-쓰기 락의 협력에서, 만약writePending검사를 제거하고 읽기-쓰기 락만 유지하면 어떤 문제가 발생하는가?

참고 해석:

writePending검사는📎 src/gin/gin_host.cc:63-66에 있으며, 진행 스레드가 메인 스레드가 쓰려고 할 때 능동적으로 yield하게 한다. 이 검사를 제거하면 진행 스레드는 직접 읽기 락을 획득하려고 시도한다.

문제는:std::shared_timed_mutex의 읽기 락은 공유되므로 여러 진행 스레드가 동시에 보유할 수 있다. 메인 스레드가 쓰기 락을 얻으려면 모든 읽기 락이 해제될 때까지 기다려야 한다. 높은 부하에서 진행 스레드가 빈번히 읽기 락을 획득하면 메인 스레드가 장시간 쓰기 락을 얻지 못해ncclGinDevCommSetup또는ncclGinDevCommFree이 블록될 수 있다.

더 심각한 것은: 만약 메인 스레드가ginProgressWriteLock에서 먼저writePending을 설정한 후 락을 획득하는데, 진행 스레드가writePending을 검사하지 않으면, 진행 스레드가 메인 스레드의 설정 후에도 읽기 락을 획득할 수 있어 메인 스레드의 대기 시간이 예측 불가능해진다.

writePending의 역할은 "소프트 알림"이다: 진행 스레드에게 "내가 쓰려고 하니 잠시 양보하라"고 알린다. 이는 단순히 락의 공정성에 의존하는 것보다 더 효율적인데, 진행 스레드가 락에서 블록되는 대신 능동적으로 yield할 수 있기 때문이다.

Q3:ncclSymkMask에서, 만약nBusBytes >= 32 * (size_t(2) << 30)시 모든 kernel을 비활성화하면(kmask = 0), 이때ncclSymkAvailable이 false를 반환하고, NCCL은 어떤 경로로 폴백하는가? 이 폴백 경로는 어떤 성능 영향을 미치는가?

참고 해석:

kmask = 0은📎 src/sym_kernels.cc:342에 있으며, 이때ncclSymkAvailable이 false를 반환한다(📎 src/sym_kernels.cc:354-361)。

폴백 경로는: NCCL이 전통적인 집합 통신 kernel(비대칭 메모리 kernel)을 사용한다. 이 kernel들은 등록 버퍼 방식으로 상대방 메모리에 접근하며, 먼저 주소를 해석해야 하므로 명령 오버헤드가 더 크다.

성능 영향: 초대형 메시지(64GB 버스 바이트 초과)의 경우, 전통 kernel의 주소 해석 오버헤드 비중이 매우 작은데, 데이터 전송 자체가 지배적이기 때문이다. 그러나 경계 상황(막 64GB를 초과)에서는 전통 kernel이 대칭 메모리 kernel보다 10-20% 느릴 수 있다.

이 제한의 근본 원인은: 대칭 메모리 kernel이 32비트 정수로 unrolled loop chunk를 추적하고, 각 chunk가 최소 32바이트이므로 최대 주소 지정 범위가 32 * 2^31 = 64GB이다. 이 범위를 초과하면 정수 오버플로가 발생한다.

실제 생산에서 단일 집합 통신이 64GB를 초과하는 시나리오는 드물지만(보통 그래디언트 누적 후의 all-reduce), 불가능하지는 않다. 이런 시나리오를 만나면 분할 통신이나 전통 kernel 사용을 고려할 수 있다.

---

장말 전환

이 장에서 우리는 NCCL이 "고정 집합 연산"에서 "프로그래밍 가능한 통신 엔진"으로 나아가고 있음을 보았다: RMA는 프리미티브 조합을 제공하고, GIN은 GPU 직접 전송을 제공하며, 대칭 메모리는 통합 주소 공간을 제공하고, Team과 버전화된 DevComm은 인프라를 제공한다.

이러한 진화는 고립된 것이 아니라, 공동으로 하나의 목표를 향한다:상위 프레임워크가 더 낮은 지연과 더 높은 유연성으로 사용자 정의 통신 패턴을 구현할 수 있게 한다. PyTorch, Megatron 같은 프레임워크에게 이는 NCCL 위에서 직접 MoE all-to-all, 파이프라인 병렬화, 전문가 병렬화 등 복잡한 통신 패턴을 구축할 수 있으며, NCCL을 우회해 자체적으로 네트워크 계층을 구현할 필요가 없다는 것을 의미한다.

다음 장은 전书的 마지막 장이다. 우리는 AllReduce 한 번의 전체 경로를 다시 한 번 따라가 볼 것이다——ncclAllReduce호출부터 시작해 작업 큐잉, 알고리즘 선택, kernel 시작, proxy 진행, 네트워크 전송을 거쳐 결과가 반환될 때까지. 이번 회고는 앞선 24개 장의 지식 포인트를 연결해 완전한 인지 지도를 형성할 것이다.

여기까지 우리는 NCCL이 고정 집합 연산에서 프로그래밍 가능한 통신 엔진으로 진화하는 세 가지 주된 흐름을 보았다: RMA 프리미티브 조합, GPU 직접 네트워크 전송, 대칭 메모리 모델, 그리고 이를 뒷받침하는 team 추상화와 버전화된 DevComm. 이러한 메커니즘은 함께 더 유연하고 하드웨어 능력에 더 가까운 통신의 미래를 가리킨다. 그러나 아키텍처가 어떻게 진화하든, AllReduce 한 번의 전체 경로는 항상 NCCL을 이해하는 초석이다. 다음 장에서는 새로운 코드를 도입하지 않고, 제3장부터 제10장까지의 엔드투엔드 흐름을 다시 한 번 연결해 설명할 것이다—— ncclAllReduce 호출부터 통신 도메인 구축, 토폴로지 탐색, 알고리즘 선택, 작업 큐잉, kernel 시작, 디바이스 측 프리미티브 실행, 결과 기록까지. 당신은 각 장에 흩어져 있던 메커니즘을 다시 조립해 완전한 멘탈 모델로 만들고, "문제가 생기면 어느 장을 봐야 하는가"에 대한 색인을 얻게 될 것이다.

CHAPTER 25

제25장: 제25장: 전경 회고와 사고: 하나의 AllReduce 궁극의 여정과 설계 정수

공식 소스: NVIDIA/nccl · 버전: Commit @12df1a11 · 전서 진행도: 제25 / 25장

제25장: 전경 회고와 사고: 하나의 AllReduce 궁극의 여정과 설계 정수

이전 장에서 우리는 소스 코드 속 진화의 흔적을 바탕으로 NCCL이 고정 집합 연산에서 프로그래밍 가능한 것으로, host proxy에서 GPU 직접 전송으로, 등록 버퍼에서 대칭 메모리로 나아가는 아키텍처 트렌드를 전망했다. 이제 이러한 트렌드를 구체적인 실행 흐름 속에 다시 넣어 검증할 때이다. 이 장은 새로운 코드를 도입하지 않고, 제3장부터 제10장까지의 엔드투엔드 경로를 다시 연결한다—— ncclAllReduce 한 줄 호출에서 시작해 결과가 비디오 메모리에 기록될 때까지. 읽고 나면 당신은 명확히 답할 수 있어야 한다: AllReduce 한 번은 도대체 어떤 함수들을 거치는가? 각 함수는 어느 파일, 어느 줄에 있는가? 문제가 생기면 어느 장을 펼쳐야 하는가?

一、초기화: 통신 도메인은 어떻게 "자라나는가"

직관적 모델

통신 도메인을 "단체 채팅방"이라고 상상해 보자. 당신이ncclCommInitRank을 호출하는 것은 "단체 채팅방 가입 신청"이며, NCCL은 이때 그룹 멤버 명단(peerInfo), 누가 누구와 어느 선으로 연결되는지(토폴로지 그래프), 각 선에 몇 개의 파이프라인(channel)을 열지 전부 확정해야 한다.만약 이 단계가 틀리면, 이후 모든 통신이 틀린다——마치 단체 채팅방에 누군가 초대되지 않았다면, 당신이 보낸 메시지는 영원히 한 사람이 덜 받는 것과 같다.

데이터 구조와 메모리 레이아웃

통신 도메인의 핵심 구조는ncclComm이며, 그 초기화는 두 단계로 나뉜다:commAlloc은 "골격 할당"을 담당하고,initTransportsRank은 "살점 채우기"를 담당한다.

commAlloc에서 가장 주목할 만한 것은공유 자원 참조 카운트설계이다. 하위 통신 도메인(split/shrink로 생성)이 부모 통신 도메인 자원을 재사용할 때, 복사본을 만드는 것이 아니라 동일한ncclSharedResources을 공유하고 참조 카운트를 증가시킨다:

📎 src/init.cc:533-555

cpp
if (parent == NULL || !parent->shareResources) {
    struct ncclSharedResources* sharedRes;
    NEW_NOTHROW(sharedRes, ncclSharedResources);
    sharedRes->owner = comm;
    ...
    comm->sharedRes = sharedRes;
    sharedRes->refCount = 1;
    NCCLCHECK(ncclNetInit(comm));
    NCCLCHECK(ncclRmaInit(comm));
    NCCLCHECK(ncclGinInit(comm));
} else {
    comm->sharedRes = parent->sharedRes;
    ncclAtomicRefCountIncrement(&parent->sharedRes->refCount);
    NCCLCHECK(ncclNetInitFromParent(comm, parent));
    NCCLCHECK(ncclRmaInitFromParent(comm, parent));
}

이 코드의 의도는 매우 명확하다: 네트워크 플러그인, RMA, GIN 같은 "무거운 자원"은 한 번만 초기화되고, 하위 통신 도메인은 직접 빌려 쓴다.refCount은 원자적 연산으로 증가시켜 멀티스레드에서 중복 해제되지 않도록 보장한다.

또 다른 핵심 포인트는commAlloc에서의채널 초기화이다. 모든 채널은 먼저 "미초기화"(id = -1)로 표시되고, 이후setupChannel이 되어서야 실제로 내용을 채운다:

📎 src/init.cc:607-608

cpp
// Mark channels as non initialized.
for (int c = 0; c < MAXCHANNELS; c++) comm->channels[c].id = -1;

이-1은 센티넬 값이다. 어떤 코드든 미초기화된 채널을 잘못 사용하면,id == -1이 즉시 문제를 드러내며, 무작위 메모리를 읽는 일은 없다.

Step-by-Step: ncclCommInitRank에서 initTransportsRank까지

사용자가ncclCommInitRank을 호출한 후, 실제 실행 흐름은 이렇다:

1. ncclCommInitRank은 먼저ncclInitEnv을 호출해 환경 플러그인을 로드하고, 그다음ncclGroupStartInternal을 호출해 group 시맨틱에 들어간다(이는 "한 번의 group에서 여러 통신 도메인 초기화"를 지원하기 위함이다).

2. 이어서ncclCommInitRankDev을 호출하는데, 이는 매개변수 검증,comm구조 할당, config 파싱을 수행한 뒤실제 초기화 작업을 비동기 job에 넘긴다:

📎 src/init.cc:2923-2929

cpp
if (ncclParamEnqueueRearchEnable()) {
    NCCLCHECKGOTO(ncclMgmtTaskEnqueue((struct ncclAsyncJob*)job, ncclCommInitRankFunc, ncclCommInitJobFree, comm), res, fail);
} else {
    NCCLCHECKGOTO(ncclAsyncLaunch((struct ncclAsyncJob*)job, ncclCommInitRankFunc, NULL, ncclCommInitJobFree, comm), res, fail);
}

여기서ncclParamEnqueueRearchEnable()분기를 주목하라——이것은 NCCL이 진행 중인 "enqueue 리팩터링"의 흔적이다. 기본적으로ncclAsyncLaunch을 타고, 리팩터링을 켜면ncclMgmtTaskEnqueue을 탄다. 두 경로 모두 결국ncclCommInitRankFunc。

3. ncclCommInitRankFunc을 호출한다.

📎 src/init.cc:2119-2127

cpp
timers[TIMER_INIT_TOTAL] = clockNano();
CUDACHECKGOTO(cudaSetDevice(cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&maxSharedMem, cudaDevAttrMaxSharedMemoryPerBlockOptin, cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&archMajor, cudaDevAttrComputeCapabilityMajor, cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&archMinor, cudaDevAttrComputeCapabilityMinor, cudaDev), res, fail);
cudaArch = 100 * archMajor + 10 * archMinor;

timers[TIMER_INIT_KERNELS] = clockNano();
NCCLCHECKGOTO(ncclInitKernelsForDevice(cudaArch, maxSharedMem, &maxLocalSizeBytes), res, fail);

cudaArch = 100 * archMajor + 10 * archMinor복사

4. 그런 다음 일반 초기화인지 split/shrink/grow인지에 따라 다른 bootstrap 경로를 탑니다:

📎 src/init.cc:2136-2191

cpp
if (job->parent && !job->isGrow) {
    // SPLIT/SHRINK: use bootstrapSplit
    ...
    NCCLCHECKGOTO(bootstrapSplit(comm->commHash, comm, job->parent, job->color, job->key, parentRanks), res, fail);
} else {
    // GROW or NORMAL INIT: use bootstrapInit
    ...
    NCCLCHECKGOTO(bootstrapInit(job->nId, (struct ncclBootstrapHandle*)job->commId, comm, job->parent), res, fail);
}

5. 마지막으로initTransportsRank를 호출합니다. 이것은 전체 초기화에서 가장 무거운 함수입니다(약 800줄). 내부적으로 두 번의 AllGather를 수행합니다:

  • AllGather1: 교환합니다ncclPeerInfo(각 rank의 디바이스 정보, host hash, pid hash, GPU UUID 등):

📎 src/init.cc:1236-1239

cpp
NCCLCHECKGOTO(ncclCalloc(&comm->peerInfo, nranks + 1), ret, fail); // Extra rank to represent CollNet root
NCCLCHECKGOTO(fillInfo(comm, comm->peerInfo + rank, comm->commHash), ret, fail);
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, comm->peerInfo, sizeof(struct ncclPeerInfo)), ret, fail);
COMPILER_ATOMIC_STORE(&comm->peerInfoValid, true, std::memory_order_release);

주의nranks + 1이 할당 — 추가된 한 자리는 CollNet root를 위해 사용됩니다.peerInfoValidrelease 시맨틱으로 저장하여, 다른 스레드가 이 플래그를 볼 때 peerInfo의 내용이 이미 가시적임을 보장합니다.

  • AllGather3: 토폴로지 계산 결과(각 rank가 계산한 ring/tree 구조, 대역폭, 채널 수 등)를 교환한 후, 모든 rank의최솟값으로 정렬합니다:

📎 src/init.cc:1687-1703

cpp
for (int i = 0; i < nranks; 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 < NCCL_NUM_ALGORITHMS; 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);
    }
    ...
}

대역폭은 min, 타입은 max를 취합니다. 이것은 "목통 원리"입니다: 전체 통신 도메인의 성능은 가장 느린 rank에 의해 결정됩니다. 정렬하지 않으면 다른 rank가 서로 다른 알고리즘을 선택하여 통신 데드락이 발생할 수 있습니다.

초기화 흐름도

mermaid
flowchart TD
    api["ncclCommInitRank()"] --> env["ncclInitEnv()"]
    env --> grp["ncclGroupStartInternal()"]
    grp --> dev["ncclCommInitRankDev()"]
    dev --> alloc["ncclCalloc(comm) + parseCommConfig()"]
    alloc --> launch{"ncclParamEnqueueRearchEnable()?"}
    launch -->|是| mgmt["ncclMgmtTaskEnqueue(ncclCommInitRankFunc)"]
    launch -->|否| async["ncclAsyncLaunch(ncclCommInitRankFunc)"]
    mgmt --> func["ncclCommInitRankFunc()"]
    async --> func
    func --> kernels["ncclInitKernelsForDevice(cudaArch)"]
    kernels --> branch{"job->parent && !job->isGrow?"}
    branch -->|是 split/shrink| split["bootstrapSplit()"]
    branch -->|否 grow/normal| init["bootstrapInit()"]
    split --> transports["initTransportsRank()"]
    init --> transports
    transports --> ag1["bootstrapAllGather(peerInfo)"]
    ag1 --> topo["ncclTopoGetSystem() + ncclTopoComputePaths()"]
    topo --> graphs["ncclTopoCompute(ringGraph/treeGraph/nvlsGraph)"]
    graphs --> ag3["bootstrapAllGather(allGather3Data)"]
    ag3 --> align["min/max 对齐所有 rank 的图参数"]
    align --> connect["setupChannel() + ncclTransportRingConnect()"]
    connect --> devcomm["devCommSetup()"]
    devcomm --> done["initState = ncclSuccess"]

설계 고민과 함정

왜 초기화를 비동기로 해야 하는가?멀티 rank 초기화는 프로세스 간 동기화(bootstrap)가 필요하기 때문에, 동기 실행하면 호출 스레드가 블로킹됩니다. 비동기화하면 사용자가 group 내에서 여러 통신 도메인을 동시에 초기화하여 병렬로 진행할 수 있습니다.

함정 포인트:initTransportsRank끝에 intra-node barrier가 있습니다:

📎 src/init.cc:1968-1971

cpp
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);

이 barrier는 같은 머신의 모든 rank가 리소스 할당을 완료해야 계속 진행하도록 보장합니다. 만약 어떤 rank가devCommSetup에서 막히면(예: VRAM 부족), 다른 rank들은 여기서 무한 대기합니다. 프로덕션 환경에서 "초기화 hang"을 만나면, 첫 번째로 확인할 것은 특정 rank의devCommSetup실패 여부입니다.

2. 태스크 인큐: API 호출에서 내부 태스크 객체까지

직관적 모델

사용자가ncclAllReduce를 호출하는 것은 식당에서 주문하는 것과 같습니다.ncclEnqueueCheck는 서빙 직원으로, 당신의 주문을 주방이 이해할 수 있는 "작업 지시서"(ncclTaskColl)로 번역하여comm->planner라는 "주문 풀"에 넣습니다.이 계층이 없으면 NCCL은 여러 호출을 하나의 kernel 실행으로 병합할 수 없습니다—매번 주문할 때마다 개별적으로 불을 켜는 것처럼 효율이 극히 낮습니다.

데이터 구조와 메모리 레이아웃

태스크 인큐의 핵심은ncclKernelPlanner이며, 이것은comm->planner에 붙어 있습니다. 주요 필드는 다음과 같습니다:

  • collSorter: 트래픽 크기순으로 정렬된 집합 통신 태스크 큐
  • collTaskQueue: 최종 정렬된 태스크 큐
  • peers[]: 각 peer의 send/recv 큐(P2P용)
  • wipPlan: 구축 중인 kernel plan

태스크 객체ncclTaskColl의 주요 필드는collTaskAppend에서 채워집니다:

📎 src/enqueue/enqueue.cc:2800-2847

cpp
struct ncclTaskColl* t = ncclMemoryPoolAlloc<struct ncclTaskColl>(&comm->memPool_ncclTaskColl, &comm->memPermanent);
t->func = info->coll;
t->sendbuff = info->sendbuff;
t->recvbuff = info->recvbuff;
t->count = info->count;
t->root = info->root;
t->datatype = info->datatype;
size_t elementSize = ncclTypeSize(t->datatype);
if (t->func == ncclFuncAllGather || t->func == ncclFuncBroadcast) {
    t->count *= elementSize;
    t->datatype = ncclInt8;
    elementSize = 1;
}
t->trafficBytes = t->count * elementSize * ncclFuncTrafficPerByte(t->func, comm->nRanks);
...
t->aggIsolate = ncclCollConfigNeedAggIsolate(&info->collConfig) || info->collConfig.CTAPolicy != comm->config.CTAPolicy;
NCCL_CONFIG_SET(t, minCTAs, ncclParamMinCTAs(), info->collConfig.minCTAs, comm->config.minCTAs, 1, MAXCHANNELS);
NCCL_CONFIG_SET(t, maxCTAs, ncclParamMaxCTAs(), (std::min(info->collConfig.maxCTAs, comm->config.maxCTAs)), comm->config.maxCTAs, 1, MAXCHANNELS);
...
planner->nTasksColl += 1;
ncclTaskCollSorterInsert(&planner->collSorter, t, t->trafficBytes);

몇 가지 세부 사항에 주의:

1. AllGather/Broadcast의 특수 처리: count에 요소 크기를 곱하고, datatype을ncclInt8로 변경합니다. 이 두 연산의 시맨틱은 "바이트를 운반"하는 것이므로 원래 타입을 신경 쓸 필요가 없기 때문입니다.

2. trafficBytes의 계산:ncclFuncTrafficPerByte은 각 바이트가 몇 번 전송되어야 하는지 반환합니다. AllReduce는 2(reduce + broadcast), AllGather는 nRanks를 반환합니다:

📎 src/enqueue/enqueue.cc:123-134

cpp
static inline int ncclFuncTrafficPerByte(ncclFunc_t func, int nRanks) {
  switch (func) {
  case ncclFuncAllReduce:
    return 2;
  case ncclFuncAllGather:
    return nRanks;
  case ncclFuncReduceScatter:
    return nRanks;
  default:
    return 1;
  }
}

3. NCCL_CONFIG_SET매크로: 이것은 "env > per-call > comm" 3단계 설정 해석입니다. 환경 변수가 최우선이고, 다음은 단일 호출의 config, 마지막은 통신 도메인 수준의 기본값입니다.

단계별: ncclAllReduce의 인큐 경로

1. ncclEnqueueCheck먼저 통신 도메인 검증과 group 진입을 합니다:

📎 src/enqueue/enqueue.cc:3478-3495

cpp
ncclResult_t ncclEnqueueCheck(struct ncclInfo* info) {
  ncclResult_t ret = CommCheck(info->comm, info->opName, "comm");
  if (ret != ncclSuccess) return ncclGroupErrCheck(ret);
  if (info->comm->revokedFlag) {
    WARN("%s: communicator was revoked", info->opName);
    return ncclGroupErrCheck(ncclInvalidUsage);
  }
  ...
  NCCLCHECK(ncclGroupStartInternal());
  ret = ncclSuccess;
  int devOld = -1;
  NCCLCHECKGOTO(ncclCommEnsureReady(info->comm), ret, fail);

2. 그런 다음taskAppend를 호출하며, 이것은 연산 타입에 따라 분기합니다:

📎 src/enqueue/enqueue.cc:3337-3348

cpp
static ncclResult_t taskAppend(struct ncclComm* comm, struct ncclInfo* info) {
  ncclFunc_t collAPI = info->coll;
  bool hasLaunchCompletionEvent = ncclInfoHasLaunchCompletionEvent(info);

  if (ncclParamEnqueueRearchEnable()) {
    NCCLCHECK(rawTaskAppend(comm, info));
  } else if (info->coll == ncclFuncSend || info->coll == ncclFuncRecv) {
    NCCLCHECK(p2pTaskAppend(comm, info, info->coll, collAPI, (void*)info->recvbuff, info->count, info->datatype, info->root, true));
  } else if (info->coll == ncclFuncPutSignal || info->coll == ncclFuncSignal || info->coll == ncclFuncWaitSignal) {
    NCCLCHECK(rmaTaskAppend(comm, info));
  } else {
    ...
  }
}

AllReduce의 경우 마지막else분기를 타며, 최종적으로collTaskAppend。

3. collTaskAppend를 호출하여 태스크를collSorter에 삽입하고,trafficBytes로 정렬합니다. 정렬의 목적은 스케줄러가 큰 태스크를 우선 처리하여 작은 태스크가 채널 리소스를 파편화하는 것을 방지하기 위함입니다.

태스크 인큐 데이터 흐름

mermaid
flowchart LR
    api["ncclAllReduce()"] --> info["ncclInfo 填充"]
    info --> enq["ncclEnqueueCheck()"]
    enq --> check["CommCheck + ncclCommEnsureReady()"]
    check --> append["taskAppend()"]
    append --> coll["collTaskAppend()"]
    coll --> task["ncclTaskColl 分配"]
    task --> sorter["ncclTaskCollSorterInsert(collSorter)"]
    sorter --> prepare["ncclPrepareTasks()"]
    prepare --> algo["ncclGetAlgoInfo() 选算法"]
    algo --> schedule["scheduleCollTasksToPlan()"]
    schedule --> plan["ncclKernelPlan"]

설계 고민과 함정

왜ncclMemoryPoolAlloc대신malloc?를 사용하는가? 태스크 객체의 수명이 짧고 할당이 빈번하기 때문입니다. 메모리 풀은 매번malloc/free의 시스템 콜 오버헤드를 방지합니다. 주의:ncclMemoryPoolAlloc의 두 번째 인자는&comm->memPermanent—이는 태스크 객체가 통신 도메인 소멸 시에 일괄 해제되고, 각 태스크별로 개별 해제되지 않음을 의미합니다.

함정 포인트:ncclPrepareTasks에는 크기가 비슷한(4배 이내) 태스크를 병합하는 "집계" 로직이 있습니다:

📎 src/enqueue/enqueue.cc:506-512

cpp
// We aggregate operations that are within 4X size of each other.
while (aggEnd != nullptr && aggEnd->trafficBytes < 4 * aggBeg->trafficBytes && !aggBeg->aggIsolate && !aggEnd->aggIsolate) {
    agg.count += aggEnd->count;
    agg.trafficBytes += aggEnd->trafficBytes;
    aggEnd = aggEnd->next;
}

이 집계는 알고리즘 선택을 더 안정적으로 만들기 위함입니다—각 작은 태스크가 개별적으로 알고리즘을 선택하면 서로 다른 알고리즘들이 나와 kernel이 파편화될 수 있습니다. 하지만aggIsolate플래그는 집계를 방지하며, "반드시 개별 스케줄링되어야 하는" 태스크(예: per-call config가 있는 것)에 사용됩니다.

3. 알고리즘 선택: 비용 모델이 최적해를 고르는 방법

직관적 모델

알고리즘 선택은 내비게이션 소프트웨어가 경로를 고르는 것과 같습니다. NCCL의 "비용 모델"(tuning 모듈)은 주어진 메시지 크기와 토폴로지에서 각 알고리즘/프로토콜 조합의 소요 시간을 추정한 후, 가장 빠른 것을 선택합니다.비용 모델이 없으면 NCCL은 한 가지 알고리즘만 하드코딩할 수밖에 없어, 작은 메시지에서는 대역폭을 낭비하고 큰 메시지에서는 지연을 낭비합니다。

데이터 구조와 메모리 레이아웃

알고리즘 선택의 진입점은ncclGetAlgoInfo:

📎 src/enqueue/enqueue.cc:2159-2185

cpp
ncclResult_t ncclGetAlgoInfo(struct ncclComm* comm, struct ncclTaskColl* info, int collNetSupport, int nvlsSupport,
                             int numPipeOps, ncclSimInfo_t* simInfo) {
  size_t elementSize = ncclTypeSize(info->datatype);
  size_t nBytes = elementSize * ncclFuncMaxSendRecvCount(info->func, comm->nRanks, info->count);
  info->algorithm = NCCL_ALGO_UNDEF;
  info->protocol = NCCL_PROTO_UNDEF;
  struct ncclTuningInput_t input;
  input.comm = comm;
  input.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;
  uint64_t effAlgMask = comm->tuningContext.forced[info->func] ? 0 : info->algMask;
  if (effAlgMask != 0) {
    input.tuningMask = effAlgMask & NCCL_TUNING_MASK_GENERAL_KERNELS;
  }
  input.CTAPolicy = info->CTAPolicy;
  input.func = info->func;
  input.redOp = info->opHost;
  input.devRedOp = info->opDev.op;
  input.datatype = info->datatype;
  input.nBytes = nBytes;
  input.numPipeOps = numPipeOps;
  input.collNetSupport = collNetSupport;
  input.nvlsSupport = nvlsSupport;
  input.count = info->count;
  NCCLCHECK(ncclGetRegBuff(comm, info, &input.regBuff));
  ...
}

주의:effAlgMask의 로직—환경 변수가 알고리즘을 강제 지정한 경우(comm->tuningContext.forced[info->func]가 비영), 사용자의algMask를 무시하고 환경 변수를 사용합니다. 이것이 "env > per-call" 우선순위의 구현입니다.

그런 다음ncclTuningCompute를 호출하여 최적 결과를 얻습니다:

📎 src/enqueue/enqueue.cc:2213-2224

cpp
} else {
    NCCLCHECK(ncclTuningCompute(&input, &bestTuning));
}
INFO(NCCL_TUNING, "Best tuning, algorithm, %s, protocol, %s", ncclAlgoToString(bestTuning.algo), ncclProtoToString(bestTuning.proto));
info->algorithm = bestTuning.algo;
info->protocol = bestTuning.proto;
info->nWarps = bestTuning.nWarps;
if (simInfo) simInfo->estimatedTime = bestTuning.timeUs;
TRACE(NCCL_COLL, "%ld Bytes -> Algo %d proto %d time %f", nBytes, info->algorithm, info->protocol, bestTuning.timeUs);
info->nMaxChannels = bestTuning.maxChannels == 0 ? info->nMaxChannels : bestTuning.maxChannels;

Step-by-Step: 한 번의 AllReduce 알고리즘 선택

8카드 단일 머신, 메시지 크기 1MB, AllReduce를 가정:

1. nBytes = 1MB,numPipeOps은 현재 plan에 이미 존재하는 작업 수입니다.

2. collNetSupport과nvlsSupport은ncclGetCollNetSupport과ncclNvlsTransportEnabled에 의해 결정됩니다.

3. ncclTuningCompute사용 가능한 모든 (algo, proto) 조합을 순회하며 비용 모델로 시간을 추정합니다.

4. 1MB 단일 머신 시나리오에서는 일반적으로 NVLS 또는 Tree+LL128이 승리합니다.

5. 결과를 다시 씁니다info->algorithm、info->protocol、info->nWarps。

알고리즘 선택 결정 다이어그램

mermaid
flowchart TD
    start["ncclGetAlgoInfo()"] --> nbytes["计算 nBytes = elementSize * count"]
    nbytes --> forced{"comm->tuningContext.forced[func]?"}
    forced -->|是| envMask["effAlgMask = 0, 用环境变量强制"]
    forced -->|否| userMask{"info->algMask != 0?"}
    userMask -->|是| useUser["tuningMask = algMask"]
    userMask -->|否| full["tuningMask = GENERAL_KERNELS"]
    envMask --> compute["ncclTuningCompute(input, bestTuning)"]
    useUser --> compute
    full --> compute
    compute --> result{"bestTuning.algo == UNDEF?"}
    result -->|是| fallback["重算全量菜单"]
    fallback --> force{"forceAlgSelection?"}
    force -->|是| err["返回 ncclInvalidArgument"]
    force -->|否| auto["回退到自动选择"]
    result -->|否| assign["info->algorithm = bestTuning.algo"]
    auto --> assign
    assign --> done["返回 ncclSuccess"]

설계 고찰과 함정

왜 알고리즘 선택이 "rank 간 정렬"되어야 하는가?서로 다른 rank가 다른 알고리즘을 선택하면 통신 패턴이 맞지 않아 교착 상태가 발생합니다. 그래서initTransportsRank에서 min/max로 모든 그래프 파라미터를 정렬하여 모든 rank의 비용 모델 입력이 일치하도록 보장합니다.

함정 포인트:ncclGetAlgoInfo에는 "재계산" 로직이 있습니다——사용자가algMask을 지정했지만 일치하는 알고리즘이 없으면, 먼저 조용히 전체 메뉴를 재계산한 후 하드 오류인지 소프트 폴백인지 판단합니다:

📎 src/enqueue/enqueue.cc:2192-2208

cpp
NOWARN(ncclTuningCompute(&input, &bestTuning), NCCL_TUNING);
if (bestTuning.algo == NCCL_ALGO_UNDEF) {
    input.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;
    bestTuning = NCCL_TUNING_RESULT_INIT;
    bestTuning.maxChannels = 0;
    NCCLCHECK(ncclTuningCompute(&input, &bestTuning));
    if (info->forceAlgSelection) {
        WARN("algSelection: no algorithm in the selected set is available for %s", ncclFuncToString(info->func));
        return ncclInvalidArgument;
    }
    INFO(NCCL_TUNING, "algSelection: selected set unavailable for %s; falling back to automatic selection", ncclFuncToString(info->func));
}

NOWARN매크로가 일시적으로 경고를 억제합니다. "일치하는 알고리즘 없음"은 정상적인 상황일 수 있기 때문입니다(사용자가 선택한 집합이 실제로 사용 불가능할 수 있음).forceAlgSelection이 참일 때만 오류를 보고합니다.

4. 작업 스케줄링과 kernel plan 구축

직관적 모델

작업 스케줄링은 여러 주문을 몇 개의 생산 라인에 할당하는 것과 같습니다.scheduleCollTasksToPlan은 각 작업이 몇 개의 채널을 사용하고 각 채널이 얼마나 많은 데이터를 처리할지 결정하며, 최종적으로ncclKernelPlan을 생성합니다——이것이 GPU에 전달할 "작업 지시서"입니다.

데이터 구조와 메모리 레이아웃

ncclKernelPlan의 핵심 필드:

  • channelMask: 이 plan이 사용하는 채널(비트맵)
  • workBytes: 모든 work 구조의 총 바이트 수
  • nWorkBatches: work batch 수
  • kernelArgs: kernel 시작 파라미터
  • workStorageType: work 데이터 저장 위치(args/fifo/persistent)

finishPlan은 work 데이터의 저장 위치를 결정합니다:

📎 src/enqueue/enqueue.cc:244-255

cpp
// If we can fit everything into the kernel args we do so.
if (sizeof(ncclDevKernelArgs) + batchBytes + workBytes <= comm->workArgsBytes) {
    plan->workStorageType = ncclDevWorkStorageTypeArgs;
}
plan->kernelArgsSize = sizeof(struct ncclDevKernelArgs) + batchBytes;
plan->kernelArgsSize += (plan->workStorageType == ncclDevWorkStorageTypeArgs) ? workBytes : 0;
plan->kernelArgsSize = alignUp(plan->kernelArgsSize, 16);
plan->kernelArgs = (struct ncclDevKernelArgs*)ncclMemoryStackAlloc(&comm->memScoped, plan->kernelArgsSize, /*align=*/16);
plan->kernelArgs->comm = comm->devComm;
plan->kernelArgs->channelMask = plan->channelMask;
plan->kernelArgs->workStorageType = plan->workStorageType;

세 가지 저장 유형의 트레이드오프:

  • Args: 가장 빠르지만 kernel 파라미터 크기가 제한됨(일반적으로 4KB)
  • Fifo: 링 버퍼, 중간 크기에 적합
  • Persistent: 독립적인 디바이스 메모리 할당, CUDA Graph 시나리오에 적합

Step-by-Step: scheduleCollTasksToPlan의 채널 할당

1. 먼저 이 plan이 얼마나 많은 작업을 담을 수 있는지 추정:

📎 src/enqueue/enqueue.cc:654-687

cpp
do {
    size_t workBytes = 0;
    struct ncclTaskColl* task = ncclIntruQueueHead(&planner->collTaskQueue);
    struct ncclWorkList* workNode = ncclIntruQueueHead(&planner->collWorkQueue);
    while (task != nullptr) {
        int nBatches = divUp(nPlanColls, 4); // Rough guess: 4 colls per batch.
        if (!ncclTestBudget(budget, nBatches, workBytes + workNode->size)) goto plan_full;
        bool taskAggIsolate = task->aggIsolate;
        if (taskAggIsolate && nPlanColls > 0) goto plan_full;
        nPlanColls += 1;
        workBytes += workNode->size;
        int kind = 2 * task->isCollnet + task->isNvls;
        trafficBytes[kind] += std::max(MinTrafficPerChannel, task->trafficBytes);
        ...
    }
plan_full:;
} while (0);

2. 그런 다음 트래픽에 따라 채널을 작업에 할당합니다. 비 CollNet 작업의 경우 "cell" 단위로 분할:

📎 src/enqueue/enqueue.cc:742-759

cpp
int trafficPerByte = ncclFuncTrafficPerByte(task->func, comm->nRanks);
if (task->protocol == NCCL_PROTO_LL) trafficPerByte *= 4;
size_t cellSize = divUp(divUp(MinTrafficPerChannel, (size_t)trafficPerByte), 16) * 16;
int elementsPerCell = cellSize / elementSize;
size_t cells = divUp(task->count * elementSize, cellSize);
size_t trafficPerElement = elementSize * trafficPerByte;
size_t trafficPerCell = cellSize * trafficPerByte;
size_t cellsPerChannel = std::min(cells, divUp(trafficPerChannel, trafficPerCell));
size_t cellsLo;
if (channelId + 1 == nMaxChannels[kind]) {
    cellsLo = cells;
} else {
    cellsLo = std::min(cells, divUp((trafficPerChannel - currentTraffic), trafficPerCell));
}
int nMidChannels = (cells - cellsLo) / cellsPerChannel;
size_t cellsHi = (cells - cellsLo) % cellsPerChannel;
int nChannels = (cellsLo != 0 ? 1 : 0) + nMidChannels + (cellsHi != 0 ? 1 : 0);

이 코드는 데이터를 "저/중/고" 세 구간으로 나눕니다:countLo、countMid、countHi. 저 구간과 고 구간은 경계 채널이고, 중 구간은 중간 채널입니다. 이렇게 분할하는 이유는 각 채널이 처리하는 데이터량을 최대한 균등하게 하기 위해서입니다.

3. 마지막으로calcCollChunking을 호출하여 각 채널의 chunk 크기를 계산합니다:

📎 src/enqueue/enqueue.cc:2228-2275

cpp
static ncclResult_t calcCollChunking(struct ncclComm* comm, struct ncclTaskColl* info, int nChannels, size_t nBytes,
                                     uint32_t* outChunkSize, uint32_t* outDirectFlags, struct ncclProxyOp* proxyOp) {
  ncclPattern_t pattern;
  size_t grainSize = ncclProtoGrainSize(info->protocol);
  switch (info->func) {
  case ncclFuncAllReduce:
    pattern = info->algorithm == NCCL_ALGO_NVLS           ? ncclPatternNvls :
              info->algorithm == NCCL_ALGO_NVLS_TREE      ? ncclPatternNvlsTree :
              info->algorithm == NCCL_ALGO_COLLNET_DIRECT ? ncclPatternCollnetDirect :
              info->algorithm == NCCL_ALGO_COLLNET_CHAIN  ? ncclPatternCollnetChain :
              info->algorithm == NCCL_ALGO_TREE           ? ncclPatternTreeUpDown :
                                                            ncclPatternRingTwice;
    break;
  ...
  }
  int stepSize = comm->buffSizes[info->protocol] / NCCL_STEPS;
  int chunkSteps = (info->protocol == NCCL_PROTO_SIMPLE && info->algorithm == NCCL_ALGO_RING) ? info->chunkSteps : 1;
  int sliceSteps = (info->protocol == NCCL_PROTO_SIMPLE && info->algorithm == NCCL_ALGO_RING) ? info->sliceSteps : 1;
  int chunkSize = stepSize * chunkSteps;
  if (info->protocol == NCCL_PROTO_LL) chunkSize /= 2;
  if (info->protocol == NCCL_PROTO_LL128) chunkSize = (chunkSize / NCCL_LL128_LINEELEMS) * NCCL_LL128_DATAELEMS;
  ...
}

스케줄링 흐름도

mermaid
flowchart TD
    prep["ncclPrepareTasks()"] --> sort["collSorter 按 trafficBytes 排序"]
    sort --> agg["按 (fn,op,ty) 聚合任务"]
    agg --> algo["ncclGetAlgoInfo() 选算法"]
    algo --> bins["按 isCollnet/isNvls 分箱"]
    bins --> sched["scheduleCollTasksToPlan()"]
    sched --> budget{"ncclTestBudget()?"}
    budget -->|否| full["plan_full: 停止添加"]
    budget -->|是| kind{"task->isCollnet?"}
    kind -->|是| collnet["calcCollChunking + 全通道分配"]
    kind -->|否| cells["cell 切分: countLo/Mid/Hi"]
    collnet --> batch["ncclAddWorkBatchToPlan()"]
    cells --> batch
    batch --> proxy["ncclAddProxyOpIfNeeded()"]
    proxy --> finish["finishPlan()"]
    finish --> storage{"workBytes 能放进 args?"}
    storage -->|是| args["ncclDevWorkStorageTypeArgs"]
    storage -->|否| fifo["ncclDevWorkStorageTypeFifo"]

설계 고찰과 함정

왜 CollNet 작업을 별도로 처리하는가?CollNet은 네트워크 스위치로 리덕션을 수행하므로 채널 할당 로직이 일반 ring/tree와 완전히 다릅니다. CollNet 작업은 사용 가능한 모든 채널을 직접 점유하는 반면, 일반 작업은 트래픽에 따라 분할해야 합니다.

함정 포인트:ncclTestBudget의 추정은 대략적인 공식nBatches = divUp(nPlanColls, 4)을 사용합니다——매 4개의 집합 연산마다 하나의 batch가 생성된다고 가정합니다. 이 추정은 부정확할 수 있으므로 이후에 정밀 검사가 있습니다:

📎 src/enqueue/enqueue.cc:711-714

cpp
// Ensure room for worst case of one new batch per channel
if (!ncclTestBudget(budget, plan->nWorkBatches + nChannels, plan->workBytes + workNode->size)) {
    return ncclSuccess;
}

정밀 검사가 실패하면 직접 반환하고(오류 보고 없이), 상위 계층이 새 plan을 열도록 합니다.

5. Kernel 시작과 디바이스 측 실행

직관적 모델

Kernel 시작은 작업 지시서를 공장에 전달하는 것과 같습니다.ncclLaunchKernel이ncclKernelPlan을 CUDA kernel 시작 파라미터로 변환한 후cuLaunchKernelEx을 호출합니다. 디바이스 측 kernel은 작업 지시서를 받은 후 알고리즘에 따라 데이터 이동을 실행합니다.

데이터 구조와 메모리 레이아웃

ncclLaunchKernel의 핵심 단계:

📎 src/enqueue/enqueue.cc:1886-1909

cpp
ncclResult_t ncclLaunchKernel(struct ncclComm* comm, struct ncclKernelPlan* plan) {
  ncclResult_t ret = ncclSuccess;
  struct ncclKernelPlanner* planner = &comm->planner;
  int nChannels = countOneBits(plan->channelMask);
  void* sym = plan->kernelFn;
  dim3 grid = {(unsigned)nChannels, 1, 1};
  dim3 block = {(unsigned)plan->threadPerBlock, 1, 1};
  int smem = plan->isSymColl ? plan->kernelDynSmem : ncclShmemDynamicSize(comm->cudaArch);
  cudaStream_t launchStream = planner->streams->stream;
  ...
  void* extra[] = {CU_LAUNCH_PARAM_BUFFER_POINTER, plan->kernelArgs, CU_LAUNCH_PARAM_BUFFER_SIZE, &plan->kernelArgsSize, CU_LAUNCH_PARAM_END};
  ...
  CUfunction fn;
  CUDACHECKGOTO(cudaGetFuncBySymbol(&fn, sym), ret, do_return);

주의grid.x = nChannels——각 채널당 하나의 block.block.x = plan->threadPerBlock——각 block의 스레드 수는 작업에 의해 결정됩니다.

Step-by-Step: plan에서 kernel 시작까지

1. 먼저uploadWork을 호출하여 work 데이터를 대상 위치(args/fifo/persistent)에 씁니다:

📎 src/enqueue/enqueue.cc:1365-1407

cpp
static ncclResult_t uploadWork(struct ncclComm* comm, struct ncclKernelPlan* plan) {
  if (plan->isSymColl || plan->isCeColl || plan->isRma) return ncclSuccess;
  size_t workBytes = plan->workBytes;
  size_t batchBytes = plan->nWorkBatches * sizeof(struct ncclDevWorkBatch);
  void* fifoBufHost;
  uint32_t fifoCursor, fifoMask;
  switch (plan->workStorageType) {
  case ncclDevWorkStorageTypeArgs:
    plan->kernelArgs->workBuf = nullptr;
    fifoBufHost = (void*)plan->kernelArgs;
    fifoCursor = sizeof(ncclDevKernelArgs) + batchBytes;
    fifoMask = ~0u;
    break;
  case ncclDevWorkStorageTypeFifo:
    fifoBufHost = comm->workFifoBuf;
    fifoCursor = comm->workFifoProduced;
    fifoMask = comm->workFifoBytes - 1;
    NCCLCHECK(waitWorkFifoAvailable(comm, fifoCursor + workBytes));
    plan->kernelArgs->workBuf = comm->workFifoBufDev;
    break;
  ...
  }
}

2. 그런 다음 CUDA launch 속성을 구성합니다. sm90+의 경우 cluster 차원을 설정합니다:

📎 src/enqueue/enqueue.cc:1929-1936

cpp
if (clusterSize) {
    // Grid dimension must be divisible by clusterSize
    if (grid.x % clusterSize) clusterSize = 1;
    launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
    launchAttrs[attrs++].value.clusterDim = {clusterSize, 1, 1};
    launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE;
    launchAttrs[attrs++].value.clusterSchedulingPolicyPreference = CU_CLUSTER_SCHEDULING_POLICY_SPREAD;
}

3. 마지막으로cuLaunchKernelEx:

📎 src/enqueue/enqueue.cc:1992

cpp
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);

디바이스 측: runRing의 실행

디바이스 측 kernel은 작업 지시서를 받은 후 알고리즘에 따라 해당RunWorkColl특수화를 호출합니다. Ring AllReduce를 예로 들면:

📎 src/device/all_reduce.h:14-83

cpp
template <typename T, typename RedOp, typename Proto>
__device__ __forceinline__ void runRing(int tid, int nthreads, struct ncclDevWorkColl* work) {
  ncclRing* ring = &ncclShmem.channel.ring;
  int ringIx = ring->index;
  const int nranks = ncclShmem.comm.nRanks;
  ssize_t gridOffset;
  ssize_t channelCount;
  ssize_t chunkCount;
  ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);
  const ssize_t loopCount = nranks * chunkCount;
  ...
  Primitives<T, RedOp, FanSymmetric<1>, 1, Proto, 0> prims(tid, nthreads, &ring->prev, &ring->next, work->sendbuff, work->recvbuff, work->redOpArg, 0, 0, 0, work);

  for (ssize_t elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount) {
    ssize_t remCount = channelCount - elemOffset;
    ssize_t chunkOffset;
    if (remCount < loopCount) chunkCount = alignUp(divUp(remCount, nranks), 16 / sizeof(T));
    auto modRanks = [&] __device__(int r) -> int { return r - (r >= nranks ? nranks : 0); };

    // step 0: push data to next GPU
    chunk = modRanks(ringIx + nranks - 1);
    chunkOffset = chunk * chunkCount;
    offset = gridOffset + elemOffset + chunkOffset;
    nelem = (int)min(chunkCount, remCount - chunkOffset);
    prims.directSend(offset, offset, nelem);

    // k-2 steps: reduce and copy to next GPU
    for (int j = 2; j < nranks; ++j) {
      chunk = modRanks(ringIx + nranks - j);
      chunkOffset = chunk * chunkCount;
      offset = gridOffset + elemOffset + chunkOffset;
      nelem = (int)min(chunkCount, remCount - chunkOffset);
      prims.directRecvReduceDirectSend(offset, offset, nelem);
    }

    // step k-1: reduce this buffer and data, which will produce the final result
    chunk = ringIx + 0;
    chunkOffset = chunk * chunkCount;
    offset = gridOffset + elemOffset + chunkOffset;
    nelem = (int)min(chunkCount, remCount - chunkOffset);
    prims.directRecvReduceCopyDirectSend(offset, offset, nelem, /*postOp=*/true);

    // k-2 steps: copy to next GPU
    for (int j = 1; j < nranks - 1; ++j) {
      chunk = modRanks(ringIx + nranks - j);
      chunkOffset = chunk * chunkCount;
      offset = gridOffset + elemOffset + chunkOffset;
      nelem = (int)min(chunkCount, remCount - chunkOffset);
      prims.directRecvCopyDirectSend(offset, offset, nelem);
    }

    // Make final copy from buffer to dest.
    chunk = modRanks(ringIx + 1);
    chunkOffset = chunk * chunkCount;
    offset = gridOffset + elemOffset + chunkOffset;
    nelem = (int)min(chunkCount, remCount - chunkOffset);
    prims.directRecv(offset, nelem);
  }
}

Ring AllReduce의 고전적인 두 단계:

  • Reduce-Scatter 단계(앞 nranks-1 단계): 각 rank가 자신의 데이터를 다음으로 보내고, 동시에 이전의 데이터를 받아 리덕션합니다.
  • AllGather 단계(뒤 nranks-1 단계): 리덕션된 결과를 링을 따라 전파합니다.

modRanks이 람다는 링 인덱스 랩어라운드를 처리합니다:r >= nranks일 때 nranks를 뺍니다.

Kernel 시작 타이밍 다이어그램

mermaid
sequenceDiagram
    participant Host as Host 线程
    participant Plan as ncclKernelPlan
    participant CUDA as CUDA Driver
    participant Kernel as GPU Kernel
    participant Proxy as Proxy 线程

    Host->>Plan: ncclLaunchPrepare()
    Plan->>Plan: scheduleCollTasksToPlan()
    Plan->>Plan: finishPlan() 分配 kernelArgs
    Host->>Plan: ncclLaunchKernelBefore_NoUncapturedCuda()
    Plan->>Plan: uploadWork() 写 work 数据
    Host->>CUDA: cuLaunchKernelEx(fn, grid, block, smem)
    CUDA->>Kernel: 启动 nChannels 个 block
    Kernel->>Kernel: runRing() 执行 Ring AllReduce
    Host->>Plan: ncclLaunchKernelAfter_NoCuda()
    Plan->>Proxy: hostStreamPlanTask() + uploadProxyOps()
    Proxy->>Proxy: ncclProxyStart() 推进网络 I/O
    Kernel-->>Host: kernel 完成
    Host->>Plan: ncclLaunchFinish()
    Plan->>Plan: reclaimPlan() 释放资源

설계 고찰과 함정

왜cuLaunchKernelEx대신cudaLaunchKernel?을 사용하는가? launch 속성(cluster 차원, mem sync domain, launch completion event)을 설정해야 하기 때문입니다. 이러한 속성은 CUDA 12.0+에서만 지원됩니다.

함정 포인트:uploadWorkpersistent 모드에 대한 처리는 매우 복잡하다 — 이는 메모리 할당, 데이터 복사, 이벤트 기록을 수행해야 하며, CUDA Graph 캡처 모드에서도 올바르게 동작해야 한다:

📎 src/enqueue/enqueue.cc:1445-1478

cpp
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);
NCCLCHECKGOTO(ncclStrongStreamAcquire(ncclCudaGraphNone(comm->config.graphUsageMode), &comm->sharedRes->deviceStream, /*concurrent=*/false, &deviceStream), result, fail);
if (comm->memPool) {
    CUDACHECKGOTO(cudaMallocAsync(&fifoBufDev, workBytes, comm->memPool, deviceStream), result, fail);
} else {
    CUDACHECKGOTO(cudaMalloc(&fifoBufDev, workBytes), result, fail);
}
plan->workBufPersistent = fifoBufDev;
plan->kernelArgs->workBuf = fifoBufDev;
CUDACHECKGOTO(cudaMemcpyAsync(fifoBufDev, fifoBufHost, workBytes, cudaMemcpyDefault, deviceStream), result, fail);
cudaEvent_t memcpyDone;
CUDACHECKGOTO(cudaEventCreateWithFlags(&memcpyDone, cudaEventDisableTiming), result, fail);
CUDACHECKGOTO(cudaEventRecord(memcpyDone, deviceStream), result, fail);

cudaThreadExchangeStreamCaptureMode는 캡처 모드에서 임시로 relaxed 모드로 전환하여 메모리 할당을 허용하기 위한 것이다. 복사가 완료되면 이벤트를 기록하고, 이후ncclCommPollEventCallbacks를 통해 회수한다.

6. 프로덕션 함정 회피 가이드

함정 1: 초기화 hang

현상:ncclCommInitRank이 멈추고 반환되지 않는다.

排查:NCCL_DEBUG=INFO로그를 보고, 마지막으로 출력된 rank를 찾는다. 모든 rank가 "Init START"를 출력했지만 "Init COMPLETE"가 없다면,initTransportsRank에서 멈춘 것이다.

일반적인 원인:

  • 특정 rank의devCommSetup실패 (메모리 부족, CUDA 오류)
  • bootstrap 네트워크 불통 (방화벽, 포트 점유)
  • rank마다 NCCL 버전 불일치

소스 코드 근거:initTransportsRank끝의 intra-node barrier는 모든 로컬 rank를 대기한다:

📎 src/init.cc:1968-1971

cpp
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);

함정 2: work FIFO 오버플로

현상: kernel 시작 후 hang되거나,ncclInternalError。

을(를) 보고한다:waitWorkFifoAvailable원인

📎 src/enqueue/enqueue.cc:1333-1349

cpp
static ncclResult_t waitWorkFifoAvailable(struct ncclComm* comm, uint32_t desiredProduced) {
  bool hasRoom = (desiredProduced - comm->workFifoConsumed) <= comm->workFifoBytes;
  if (!hasRoom) {
    while (true) {
      // Check abort flag to break deadlock when abort is signaled
      if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
        return ncclInternalError;
      }
      NCCLCHECK(ncclCommPollEventCallbacks(comm, /*waitSome=*/true));
      hasRoom = (desiredProduced - comm->workFifoConsumed) <= comm->workFifoBytes;
      if (hasRoom) break;
      std::this_thread::yield();
    }
  }
  return ncclSuccess;
}

복사

abort flag 검사를 주의하라 — 이것이 유일한 탈출 경로다. abort도 설정되지 않으면 무한 루프에 빠진다.회피NCCL_WORK_FIFO_BYTES:

을(를) 늘리거나, 단일 group 내 작업 수를 줄인다.

함정 3: CUDA Graph 캡처 실패현상

: CUDA Graph 캡처 중 NCCL을 호출하면 "operation not permitted" 오류가 발생한다.원인cudaMalloc: 캡처 모드에서는 특정 CUDA 작업(예:cudaThreadExchangeStreamCaptureMode)을 수행할 수 없다. NCCL은

을(를) 사용하여 임시로 모드를 전환하지만, 모든 작업을 우회할 수 있는 것은 아니다.:uploadWork소스 코드 근거

📎 src/enqueue/enqueue.cc:1445

cpp
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);

복사회피NCCL_GRAPH_MIXING_SUPPORT=1:

로 graph 혼합 모드를 활성화하거나, work buffer를 사전 할당한다.

이 장 요약

1. 이 장에서는 AllReduce 한 번의 전체 경로를 다시 살펴보았다::ncclCommInitRank → ncclCommInitRankFunc → initTransportsRank초기화

2. , 통신 도메인 구축, 토폴로지 탐색, 그래프 파라미터 정렬.:ncclEnqueueCheck → taskAppend → collTaskAppend작업 큐잉ncclTaskColl。

3. , API 호출을:ncclGetAlgoInfo → ncclTuningCompute으로 변환

4. 알고리즘 선택:ncclPrepareTasks → scheduleCollTasksToPlan → finishPlan, 비용 모델로 최적의 (algo, proto)를 선택.ncclKernelPlan。

5. 작업 스케줄링:ncclLaunchKernel → cuLaunchKernelEx, 작업을 채널에 할당하고

6. 생성:runRing / runTreeUpDown / runNvlsKernel 시작

, plan을 CUDA 시작 파라미터로 변환.

디바이스 측 실행initTransportsRank, 알고리즘에 따라 데이터 이동 수행.

이 장 생각해보기와 자가 점검Q1: 만약nChannels、bwIntra、bwInter에서 AllGather3 이후의 min/max 정렬 로직(L1690-L1698)을 제거하면, 어떤 시나리오에서 통신 교착이 발생하는가? 왜인가?

참고 해석

여기까지, 우리는 AllReduce 한 번의 전체 경로에 대한 회고를 완료했다. 초기화, 토폴로지 탐색, 알고리즘 선택, 작업 큐잉, kernel 시작부터 디바이스 측 실행과 네트워크 전송까지, 각 단계는 앞선 장들의 심층 분석에 대응한다. 이 경로图는 NCCL을 이해하는 골격일 뿐만 아니라 문제를排查하는 색인이기도 하다: 초기화 실패는 3, 4장, 알고리즘 선택 오류는 5장, 작업 큐잉 오류는 6, 7장, kernel 시작 실패는 8장, 디바이스 측 hang은 9, 10장, 네트워크 문제는 12, 13장을 확인하라. NCCL이 프로그래머블 통신, GPU 직접 전송, 대칭 메모리로 진화함에 따라, 이 경로는 계속 확장될 것이다 — 그리고 당신은 이미 그것을 추적하는 방법을 터득했다.

← 이전 장: 제24장

맨 위로 ↑ 아무리 복잡한 프로젝트도 사실 좋은 책 한 권이면 읽을 수 있다
위치 · 🇺🇸 EN · ⚡ 소스 파일 로딩 중... · 🇰🇷 한국어 · Commit 영구 불변 고정 · 🇪🇸 ES · 🇩🇪 DE · 🇫🇷 FR · 🇧🇷 PT · 🇷🇺 RU