CHAPTER 01

Глава 1: Запуск и наблюдаемые явления: взгляд на внешнее поведение через один AllReduce

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 1 из 25

Прежде чем углубляться в любой код ядра, мы сначала запустим 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, аNCCL_HOMEпеременная окружения передаёт каталог артефактов сборки в Makefile примеров. Это и есть ограничение порядка сборки: «сначала библиотека, потом примеры».

📎 Makefile:29перечислены все цели, доступные для очистки:

code
TARGETS := src pkg nccl4py ir

📎 Makefile:30используя синтаксис подстановки ссылок GNU 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— это большой фрагмент логики, определяющейCMAKE_CUDA_ARCHITECTURESв зависимости от версии CUDA. Рассмотрим 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()
〔Проектные предположения и архитектурные компромиссы〕

Мотивация этой логики такова: PTX новых архитектур (например, 100, 120) распознаётся только более новыми инструментальными цепочками 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["Добавить llvm_ir/ltoir в IR_GOALS<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 может свободно изменять внутреннее устройствоncclComm, не нарушая ABI. Для начинающих читателей это можно понять так: «вы получаете дескриптор чёрного ящика, и работать с ним можно только через официальный интерфейс».

Пошагово: от обнаружения устройств до создания домена связи

Шаг первый: обнаружение числа устройств. 📎 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]);
    ...
}

Шаг третий: создание stream для каждого GPU. 📎 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/>с назначением рангов 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, если позволить пользователю вручную вызывать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 принимающего буфера, по одной копии на каждую карту.

📎 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);
}

Изящество этого кода: сначала весь отправляющий буфер обнуляется, затем толькопервый элементустанавливается в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только «регистрируются», не запускаются фактически; только при

все зарегистрированные операции отправляются вместе, чтобы они могли выполняться параллельно. Это похоже на добавление всех блюд в корзину при заказе еды и совместную оплату в конце, а не оформление заказа по одному блюду. 📎 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. Каждая карта должна получить одинаковое значение — это и есть определение AllReduce.

"] end subgraph dev2["GPU 2 (rank 2)"] s2["sendbuff

mermaid
flowchart LR
    subgraph dev0["GPU 0 (rank 0)"]
        s0["sendbuff

Эта диаграмма демонстрирует две фазы AllReduce: сначала редукция (reduce), затем широковещательная рассылка (broadcast). Каждый rankrecvbuffв итоге получает одинаковый результат.

Проектное решение: почему используется Group, а не последовательные вызовы

〔Проектные соображения и архитектурные компромиссы〕

Если убратьncclGroupStart/ncclGroupEnd, код станет таким:

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

В однопоточном режиме при вызове на первой итерацииncclAllReduceNCCL должен дождаться, пока все 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являетсяЛокальная операция——она освобождает только ресурсы текущего процесса и не блокирует. Такая архитектура разделяет "ожидание тишины всех рангов" и "освобождение локальных ресурсов": первое может занимать длительное время (нужно дождаться сетевого партнёра), второе — чисто локальная операция. Если бы был только одинncclCommDestroy, ему пришлось бы одновременно выполнять обе эти задачи — либо блокировать слишком долго, либо не гарантировать глобальную тишину.

Полная цепочка порядка уничтожения

📎 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

Порядок таков:

1. Синхронизировать все stream (📎 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。

Проектное размышление: почему порядок уничтожения нельзя менять

〔Проектные выводы и архитектурные компромиссы〕

Что произойдёт, если сначала уничтожить CUDA stream, а потом коммуникационный домен? Коммуникационный домен может внутренне хранить ссылку на stream (например, для уведомления о завершении асинхронных операций). Если stream будет уничтожен первым, коммуникационный домен при Finalize обратится к уже уничтоженному stream, что приведёт к неопределённому поведению. Аналогично, если сначала освободить память хоста (commsмассив), а потом уничтожить коммуникационный домен,ncclCommDestroyполучит висячий указатель. Именно поэтому порядок должен быть таким: «сначала синхронизация, затем уничтожение коммуникационного домена, затем уничтожение stream, и наконец освобождение памяти хоста» —зависимости определяют, что порядок уничтожения должен быть обратным порядку создания。

1.5 Руководство по избеганию проблем в production

Проблема первая: забыли Group и получили взаимную блокировку

Это самая распространённая ошибка новичков. В сценарии с одним процессом и несколькими GPU, если просто вызыватьncclAllReduceв цикле без Group, программа заблокируется при первом же вызове. Симптомы: программа зависает, загрузка CPU близка к 0, никакого вывода.

Метод диагностики: с помощьюgdbподключиться к процессу и посмотреть, не остановился ли стек на логике ожидания внутри NCCL. Если да, проверить, не пропущен лиncclGroupStart/ncclGroupEnd。

Проблема вторая: забыли синхронизировать stream и читаете результат

📎 src/nccl.h.in:854-856Явно указано, чтоncclGroupEndгарантирует только постановку в очередь, но не завершение. Если пропустить синхронизацию stream для📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142и сразу прочитатьrecvbuff, будут прочитаны незавершённые данные.

Симптомы: результат то правильный, то неправильный, или читаются одни нули. Это происходит потому, чтоcudaMemcpyпо умолчанию синхронна, но синхронизирует онатекущий stream, а AllReduce может выполняться в другом stream. Метод диагностики: добавитьcudaStreamSynchronizeперед чтением результата; если проблема исчезнет, значит это та самая ошибка.

Проблема третья: неправильный порядок уничтожения приводит к segmentation fault

Если доncclCommDestroyвыполнитьcudaFreeдляsendbuff/recvbuff, коммуникационный домен при Finalize может всё ещё обращаться к этим буферам, что приведёт к segmentation fault или повреждению данных.

Симптомы: программа падает на этапе завершения или периодически читает мусорные данные. Метод диагностики: проверить порядок кода очистки, убедиться, что уничтожение коммуникационного домена происходит до освобождения всех ресурсов CUDA.

Проблема четвёртая: путаница между номером устройства и rank

📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:198-200Есть одна проверка:

c
if (device != devices[i]) {
    printf(" [WARNING: Expected device %d]", devices[i]);
}
〔Проектные выводы и архитектурные компромиссы〕

rank и device — это два разных понятия. rank — это логический номер внутри коммуникационного домена (от 0 до nRanks-1), device — это физический номер GPU. В использовании по умолчаниюncclCommInitAll,devices[i] = i, поэтому rank и device совпадают. Но если передать пользовательскийdevlist(например,{2, 0, 1}), то rank 0 будет соответствовать device 2. Путаница между этими понятиями приведёт к отправке данных на неправильный GPU.

Итоги главы

В этой главе мы выполнили три задачи:

1. Точка входа сборки: разобрались в механизме перенаправления Makefile, источнике номера версии в CMake и логике выбора архитектуры CUDA. Ключевой вывод:make examplesсначала собирает библиотеку, затем примеры,NCCL_HOMEпередавая каталог с результатами сборки примерам.

2. Три элемента минимально работающей программы: количество устройств (cudaGetDeviceCount), rank (автоматически распределяетсяncclCommInitAll), stream (по одному на каждый GPU).ncclCommInitAll— это удобная точка входа для одного процесса с несколькими GPU, она инкапсулирует синхронизированную инициализацию нескольких rank внутри библиотеки.

3. Полное внешнее поведение одного AllReduce: отncclGroupStart, оборачивающего несколько вызововncclAllReduce, до отправкиncclGroupEnd, затем ожидания завершенияcudaStreamSynchronizeи наконец проверки результата. Механизм Group — ключ к избеганию взаимной блокировки в сценарии с одним потоком и несколькими GPU.

4. Жизненный цикл коммуникационного домена:ncclCommFinalize(глобальная тишина) +ncclCommDestroy(локальное освобождение) — двухфазное уничтожение, а также ограничение порядка: «сначала синхронизация, затем уничтожение коммуникационного домена, затем уничтожение stream, и наконец освобождение памяти хоста».

Вопросы для размышления и самопроверки в этой главе

Q1: Если убрать ncclGroupStart/ncclGroupEnd из📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136и заменить на прямой циклический вызов ncclAllReduce, что произойдёт в сценарии с одним процессом и несколькими GPU? Почему?

Справочный разбор: произойдёт взаимная блокировка. В заголовочном файле📎 src/nccl.h.in:844-864объясняется причина: вызовы коллективной коммуникации могут выполнять межпроцессорную синхронизацию и требуют одновременного участия всех rank. В одном потоке при первом вызовеncclAllReduce(comms[0], ...)в первой итерации цикла NCCL должен дождаться, пока другие rank также инициируют AllReduce, чтобы продолжить. Но вызовы других rank ещё не выполнены в цикле (поскольку текущий поток заблокирован на первом вызове), поэтому первый вызов никогда не дождётся других rank — взаимная блокировка.

Механизм Group разделяет «инициацию» и «выполнение»:ncclGroupStartвсе вызовы послеncclGroupEndтолько регистрируются, а при

все зарегистрированные операции отправляются вместе, позволяя им выполняться параллельно. Это принципиально устраняет взаимную блокировку в одном потоке.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 на текущем устройстве.

В сценарии одного процесса с несколькими GPUcudaDeviceSynchronizeсинхронизирует только текущее устройство (определяемое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" (то есть выполнять обе операции в одном цикле), какие проблемы возникнут?Справочный разбор

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

ncclCommFinalizeКопировать

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. Если сразу после этого выполнитьncclCommGetAsyncError, можно освободить локальные ресурсы до полной тишины коммуникационного домена, что приведёт к неопределённому поведению. Правильный подход — после Finalize опрашивать

для подтверждения состояния, а затем Destroy.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 02

Глава 2: Модель базовых абстракций: коллективы, топология, алгоритмы и транспорты

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 2 из 25

Глава 2: Базовая абстрактная модель: коммуникационные операторы, топология, алгоритмы, протоколы и транспортный уровень

В предыдущей главе мы запустили NCCL и наблюдали внешнее поведение трёх API: ncclCommInitRank, ncclAllReduce, ncclCommDestroy. Но внешнее поведение — лишь вершина айсберга: что на самом деле происходит на GPU, когда ncclAllReduce возвращает управление? По какому пути идут данные? Почему один и тот же AllReduce показывает огромную разницу в производительности на разных машинах? Чтобы ответить на эти вопросы, необходимо сначала построить общий словарь NCCL. В этой главе мы последовательно разберём пять ключевых абстракций: коммуникационный домен (ncclComm), канал (channel), алгоритм (algorithm), протокол (protocol), транспортный уровень (transport). Эти пять концепций проходят через всю книгу, и анализ каждой последующей главы будет их использовать. Поняв отношения между ними, вы поймёте скелет NCCL.

2.1 Коммуникационный домен ncclComm: коммуникационный контекст процесса

Интуитивная модельncclCommПредставьтеnRanksкак «групповой чат»: каждый процесс, присоединившись к групповому чату, получает ID группы, и после этого все сообщения отправляются в этой группе. Сколько человек в группе (rank), кто я (channels), по какому маршруту (config), по каким правилам (

) — всё это записано в объекте группового чата.ncclCommБез

NCCL не знал бы, «кто с кем общается» и «куда отправляются данные» — при каждом вызове API пришлось бы заново согласовывать список rank и пересоздавать соединения, что неприемлемо по накладным расходам.

ncclCommСтруктура данных и разметка памятиsrc/include/comm.h— самая ключевая структура во всём NCCL, определена в

. Она чрезвычайно большая (почти 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— в конце. Во время выполнения можно быстро определить, валиден ли указатель

, проверив, не изменены ли эти два магических числа — это очень полезно при отладке багов типа «обращение дикого указателя к уничтоженному коммуникационному домену» в многопоточной среде.

📎 src/include/comm.h:628-629Rank и информация о топологииrankопределяетnRanksи📎 src/include/comm.h:644-652— мой номер в коммуникационном домене и общее число участников.nodeопределяет поля, связанные с узлом:nNodes(номер узла, на котором я нахожусь),localRank(общее число узлов),localRanks(номер внутри узла),rankToNode、rankToLocalRank、localRankToRank。

(число GPU внутри узла), а также три таблицы отображения

Эти три таблицы сопоставления являются основой топологически-осведомлённых алгоритмов. Например, алгоритму 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, степень двойки),workFifoBuf(буфер FIFO на стороне хоста),workFifoBufDev(буфер FIFO на стороне устройства),workFifoProduced(количество произведённых байт),workFifoConsumed(количество потреблённых байт).

〔Проектные предположения и архитектурные компромиссы〕

Это типичный кольцевой буфер производитель-потребитель. Сторона хоста (производитель) записывает дескрипторы работы в FIFO, GPU kernel (потребитель) читает и выполняет их.workFifoBytesдолжен быть степенью двойки, чтобы можно было использовать битовую маску вместо операции взятия по модулю, ускоряя вычисление индекса.

Внутрипроцессный барьер синхронизации

📎 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и заполняется поле за полем. Проследим этот процесс и посмотрим, как устанавливаются ключевые поля:

Шаг первый: выделение и обнуление

NCCL используетncclCallocдля выделенияncclComm, гарантируя, что все поля изначально равны 0. В этот моментstartMagicиendMagicустанавливаются вNCCL_MAGIC(📎 src/include/comm.h:563-569определяется как0x0280028002800280, в комментарии сказано "Nickel atomic number is 28").

Шаг второй: заполнение идентификационной информации

rank、nRanks、cudaDevполучается из параметров и CUDA API.commHashполучается хешированиемncclCommId, используется для проверки согласованности при последующей сетевой коммуникации.

Шаг третий: построение топологического графа

NCCL вызывает модуль топологического зондирования, перечисляет все GPU, сетевые карты, PCI-коммутаторы, строит полеtopo(📎 src/include/comm.h:595-595). Этот топологический граф определяет последующий выбор алгоритма и планирование путей.

Шаг четвёртый: инициализация каналов

channels[MAXCHANNELS]Массивidинициализируется по одному элементу. Для каждого каналаpeersустанавливается в индекс массива,devPeersи указатели

выделяются.

Шаг пятый: установление транспортных соединенийsetupНа основе топологического графа NCCL для каждой пары rank выбирает транспортный уровень (P2P/SHM/NET), вызывает соответствующиеconnectиchannels[i].peers[j]колбэки. Информация о соединении хранится в

.

Шаг шестой: установка магического числаendMagicНаконец,NCCL_MAGICустанавливается в

, отмечая завершение инициализации структуры.

Размышления о дизайне и подводные камни в productionncclCommПочему

такой большой?

ncclComm〔Проектные предположения и архитектурные компромиссы〕

содержит почти 300 полей, потому что он несёт всё состояние коммуникационного домена. Философия дизайна NCCL — «одна инициализация, многократное использование»: при инициализации вычисляется и сохраняется вся возможная полезная информация, во время выполнения происходит прямое обращение к таблице, избегая повторных вычислений. Цена — большее потребление памяти (около нескольких КБ на коммуникационный домен), но по сравнению с памятью GPU и пропускной способностью сети эта память ничтожна.

Подводный камень первый: многопоточное совместное использование коммуникационного домена

ncclComm〔Проектные предположения и архитектурные компромиссы〕ncclCommне является потокобезопасным. Если два потока одновременно вызываютncclAllReduce,workFifoProducedдля одного и того же

, поля вроде

ncclCommDestroyбудут конкурировать, что приведёт к повреждению данных. Правильный подход — каждый поток использует независимый коммуникационный домен, либо внешняя блокировка сериализует вызовы.startMagicПодводный камень второй: доступ после уничтоженияendMagicПосле освобождения памяти структуры

, если какой-либо поток всё ещё держит указатель и обращается к нему, он прочитает освобождённую память.

иintraBarrierCounterмогут помочь обнаружить такую ситуацию — если магическое число не совпадает, значит указатель недействителен.intraBarrierGateПодводный камень третий: ложное разделение кэш-линий

В многопроцессном сценарии (один rank на процесс),

и

заполнение особенно важно. Если опустить заполнение, операции барьера нескольких процессов будут мешать друг другу, что приведёт к росту задержки синхронизации с наносекунд до микросекунд.channelЭто «конвейерная лента» NCCL — данные одной коллективной операции разбиваются на несколько частей, каждая из которых независимо передаётся по отдельному каналу, что обеспечивает параллельное продвижение и повышает эффективность использования пропускной способности.

Без каналов все данные могли бы идти только по одному пути, несколько физических линий между GPU (несколько сетевых карт, несколько групп 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функцию, используемую для вычисления базового адреса канала, используемого в каждом round при P2P-коммуникации:

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 части, каждая часть обрабатывается одним каналом.

Шаг первый: выбор алгоритма

Модуль tuning NCCL выбирает алгоритм (например, Ring) и протокол (например, Simple) на основе размера сообщения и топологии.

Шаг второй: распределение каналов

ncclTaskCollСтруктура (📎 src/include/comm.h:212-273) создаётся, при этом полеnChannelsустанавливается в 4 (поля📎 src/include/comm.h:254-254)。channelLoиchannelHi(📎 src/include/comm.h:256-257) отмечают диапазон каналов, используемых данной задачей.

Шаг третий: разбиение данных

Каждый канал отвечает заcount / nChannelsэлементов. Канал 0 обрабатывает элементы с 0 по count/4-1, канал 1 обрабатывает элементы с count/4 по count/2-1, и так далее.

Шаг четвёртый: параллельное выполнение

GPU kernel четырёх каналов запускаются одновременно, каждый выполняет Ring AllReduce на своём срезе данных. Поскольку между каналами нет зависимостей по данным, они могут выполняться полностью параллельно.

Шаг пятый: объединение результатов

После завершения всех каналов в recv buffer каждого rank находится полный результат AllReduce.

Управление параллелизмом и взаимодействие с оборудованием

Отображение каналов на ресурсы GPU

〔Проектные предположения и архитектурные компромиссы〕

Каждый канал обычно привязывается к отдельному CUDA stream или аппаратной очереди GPU. Так kernel разных каналов могут параллельно выполняться на GPU, полностью используя ресурсы SM (потоковых мультипроцессоров).

Отображение каналов на сетевые устройства

В сценарии с несколькими сетевыми картами разные каналы могут привязываться к разным сетевым картам. Например, 4 канала и 2 сетевые карты: каналы 0 и 1 идут через сетевую карту A, каналы 2 и 3 — через сетевую карту B. Так пропускная способность обеих сетевых карт может быть использована.

Выбор количества каналов

〔Проектные предположения и архитектурные компромиссы〕

Количество каналов — не всегда чем больше, тем лучше. Увеличение числа каналов приводит к:

  • большему количеству накладных расходов на запуск kernel
  • большему количеству накладных расходов на установление соединений
  • более сложной синхронизации

Модуль tuning NCCL автоматически выбирает оптимальное количество каналов в зависимости от размера сообщения. Для маленьких сообщений используется небольшое количество каналов (снижение накладных расходов), для больших сообщений — много каналов (повышение пропускной способности).

Руководство по избеганию проблем в production

Сценарий проблемы первый: неправильная настройка количества каналов

〔Проектные предположения и архитектурные компромиссы〕

Если вручную задатьNCCL_NCHANNELSслишком большим, в сценарии с маленькими сообщениями накладные расходы на запуск kernel превысят выгоду, и производительность наоборот снизится. Рекомендуется позволить NCCL выбирать автоматически, если нет явной необходимости в тонкой настройке.

Сценарий проблемы второй: несоответствие каналов и топологии

〔Проектные предположения и архитектурные компромиссы〕

Если количество каналов превышает количество физических линий, часть каналов будет совместно использовать линии, и настоящий параллелизм не будет достигнут. Например, 2 сетевые карты и 8 каналов: фактически только 2 канала могут передавать одновременно, остальные 6 стоят в очереди.

Сценарий проблемы третий: конфликт 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: реализация Ring для AllReduce

📎 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 (чувствительность к пропускной способности).

Подводный камень первый: неверный выбор алгоритма

〔Проектные выводы и архитектурные компромиссы〕

Если вручную принудительно использовать Ring для малых сообщений, задержка значительно возрастёт. Рекомендуется позволить модулю tuning выбирать автоматически, если только нет явных данных профилирования в поддержку ручного вмешательства.

Подводный камень второй: отсутствие аппаратной поддержки NVLS

NVLS требует特定ной аппаратной поддержки (NVLink SHARP). Если аппаратура не поддерживает, но код принудительно использует NVLS, произойдёт откат к Ring или Tree, но возможны колебания производительности.📎 src/include/comm.h:755-755ВnvlsSupportполе

Подводный камень третий: конфигурация фактора агрегации алгоритма PAT

В алгоритме PATaggFactorопределяет, сколько шагов агрегируется.📎 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Слишком малоеstepSize、channelSize、nranksприводит к большим накладным расходам на синхронизацию, слишком большое — к пузырям конвейера. NCCL автоматически вычисляет оптимальное значение на основе

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

Предположим, что для передачи 1 КБ данных используется протокол LL.

Шаг первый: запись данных в буфер отправки

Хост-сторона записывает данные вsendbuff, затем обновляетncclSendMem.headуказатель, уведомляя GPU kernel о наличии новых данных.

Шаг второй: GPU kernel читает данные

GPU kernel опрашиваетheadуказатель, обнаружив новые данные, читает их изsendbuff.

Шаг третий: передача данных

GPU kernel отправляет данные целевому rank через NVLink или сеть.

Шаг четвёртый: целевой rank принимает данные

GPU kernel целевого rank записывает данные вrecvbuff, затем обновляетncclRecvMem.tailуказатель.

Шаг пятый: хост-сторона читает данные

Хост-сторона опрашиваетtailуказатель, обнаружив новые данные, читает их изrecvbuff.

Управление конкурентностью и взаимодействие с аппаратным обеспечением

Механизм низкой задержки протокола LL

〔Предположения о дизайне и архитектурные компромиссы〕

Протокол LL используетопрос (Polling)вместо прерываний для обнаружения поступления данных. GPU kernel постоянно читаетheadуказатель и при обнаружении изменения немедленно обрабатывает его. Это даёт меньшую задержку, чем метод с прерываниями, но занимает вычислительные ресурсы GPU.

Выравнивание по 128 байт в протоколе LL128

〔Предположения о дизайне и архитектурные компромиссы〕

Протокол LL128 требует выравнивания данных по 128 байт, чтобы каждая передача точно заполняла одну строку кэша. Преимущества выравнивания:

  • Уменьшение частичной записи в строку кэша (Partial Cache Line Write)
  • Повышение эффективности использования пропускной способности памяти
  • Упрощение логики обработки на аппаратном уровне

Пакетная передача в протоколе Simple

〔Предположения о дизайне и архитектурные компромиссы〕

Протокол Simple используетпакетную передачурежим: накопление определённого объёма данных и их одновременная отправка, что уменьшает число синхронизаций. Это подходит для сценариев с большими сообщениями, поскольку накладные расходы на синхронизацию распределяются на большой объём данных.

Руководство по избежанию проблем в production

Сценарий проблемы первый: несоответствие протокола и размера сообщения

〔Предположения о дизайне и архитектурные компромиссы〕

Если принудительно использовать протокол LL для передачи больших сообщений, производительность резко упадёт. Поскольку цель дизайна протокола LL — низкая задержка, а не высокая пропускная способность. Для больших сообщений следует использовать протокол Simple.

Сценарий проблемы второй: проблема выравнивания LL128

〔Предположения о дизайне и архитектурные компромиссы〕

Если данные не выровнены по 128 байт, протокол LL128 откатится к LL или Simple, что приведёт к нестабильной производительности. Рекомендуется обеспечить выравнивание буфера отправки и буфера приёма по 128 байт.

Сценарий проблемы третий: накладные расходы на переключение протокола

〔Предположения о дизайне и архитектурные компромиссы〕

Динамическое переключение протокола во время выполнения влечёт дополнительные накладные расходы. 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);
};

Разбор ключевых callback-функций

  • 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вызывается, проверяется топология для подтверждения наличия NVLink или PCIe-соединения между двумя GPU.

Шаг третий: вызов 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. Для этого требуется:

  • Оба GPU находятся в одном домене PCIe или NVLink
  • Операционная система поддерживает CUDA IPC
  • Достаточные права доступа

Транспортный уровень SHM

〔Проектные выводы и архитектурные компромиссы〕

SHM использует разделяемую память хоста в качестве промежуточного звена. Когда между двумя GPU нет прямого соединения, данные сначала копируются в память хоста, а затем в целевой GPU. Это медленнее, чем P2P, но обеспечивает лучшую совместимость.

Транспортный уровень NET

〔Проектные выводы и архитектурные компромиссы〕

NET использует сетевые устройства (InfiniBand или RoCE) для передачи данных. Для этого требуется:

  • Поддержка GPUDirect RDMA сетевым устройством (опционально, но рекомендуется)
  • Правильная конфигурация сети (IP-адрес, маска подсети и т.д.)
  • Достаточная пропускная способность сети

Транспортный уровень CollNet

〔Проектные выводы и архитектурные компромиссы〕

CollNet использует возможности разгрузки коллективных операций сетевой карты (например, NVIDIA SHARP). Сетевая карта напрямую выполняет операции редукции, снижая вычислительную нагрузку на GPU. Для этого требуется:

  • Сетевая карта с поддержкой SHARP
  • Правильная конфигурация SHARP

Руководство по избежанию проблем в production

Сценарий проблемы первый: P2P недоступен

〔Проектные выводы и архитектурные компромиссы〕

Если между двумя GPU нет NVLink и топология PCIe не поддерживает P2P, NCCL переключается на SHM. Это приводит к снижению производительности. Можно черезNCCL_P2P_DISABLE=1принудительно отключить P2P и наблюдать за изменением производительности.

Сценарий проблемы второй: ошибка конфигурации сети

〔Проектные выводы и архитектурные компромиссы〕

Если IP-адрес сетевого устройства настроен неправильно, транспортный уровень NET не может установить соединение. Типичные ошибки включают: неверная маска подсети, отсутствие маршрута в таблице маршрутизации, блокировка брандмауэром. Рекомендуется использоватьibstatиibpingдля проверки соединения InfiniBand.

Сценарий проблемы третий: GPUDirect RDMA не включён

〔Проектные выводы и архитектурные компромиссы〕

ЕслиgdrSupportравно 0, транспортный уровень NET переключается в режим «сначала копирование в память хоста, затем отправка», задержка значительно возрастает. Проверьте, загружен ли модульnvidia-peermem, и поддерживает ли драйвер сетевой карты 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"]

Полный жизненный цикл

Этап первый: вызов API

Пользователь вызываетncclAllReduce, передавая буфер отправки, буфер приёма, количество элементов, тип данных, операцию редукции, домен коммуникации, CUDA stream.

Этап второй: создание задачи

NCCL создаёт структуруncclTaskColl(📎 src/include/comm.h:212-273), заполняет поляfunc(AllReduce)、sendbuff、recvbuff、count、datatype、opHostи т.д.

Этап третий: выбор алгоритма и протокола

Модуль Tuning выбирает алгоритм (Ring/Tree/NVLS) и протокол (LL/LL128/Simple) на основе размера сообщения, топологии, возможностей оборудования. Результат выбора записывается в поляncclTaskCollиalgorithmструктурыprotocol(📎 src/include/comm.h:227-227)。

Этап четвёртый: распределение каналов

На основе алгоритма и протокола определяется количество используемых каналов и диапазон каналов.nChannels、channelLo、channelHiПоле📎 src/include/comm.h:254-257)。

устанавливается (

Этап пятый: выбор транспортного уровняchannels[i].peers[j]На основе топологии для каждой пары rank выбирается транспортный уровень (P2P/SHM/NET/CollNet). Информация о соединении хранится в

.

Этап шестой: запуск KernelncclKernelPlan(📎 src/include/comm.h:357-410NCCL создаёт

Этап семь: выполнение коммуникации

GPU kernel читает рабочий FIFO, выполняет передачу данных и операции редукции. Потоки Proxy асинхронно продвигают сетевой ввод-вывод.

Этап восемь: завершение

После завершения всех каналов,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иintraBarrierCounterлидера коммуникационного домена, на который указываетintraBarrierGate, будут читаться и записываться всеми процессами. Когда процесс A вызываетncclCommIntraBarrierInдля обновленияintraBarrierCounter(📎 src/include/comm.h:943-959), это приведёт к инвалидации кэш-линииintraBarrierGateпроцесса B. Процесс 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.

В многоузловом сценарии, если P2P-коммуникация нескольких rank выполняется одновременно, регулярное распределение каналов приведёт к концентрации горячих точек — некоторые каналы используются несколькими rank одновременно, в то время как другие простаивают. Это вызовет перегрузку каналов и снизит общую утилизацию пропускной способности.

reverseBitsперемешивает распределение каналов, заставляя разные round использовать кажущиеся случайными каналы, равномерно распределяя нагрузку. Это классический приёмбалансировки нагрузки.

Кроме того,reverseBits— это чисто битовая операция, быстрее операции взятия по модулю (взятие по модулю требует инструкции деления, битовые операции требуют всего нескольких инструкций).

---

В следующей главе мы углубимся во внутреннюю реализациюncclCommInitRank, чтобы увидеть, как NCCL, начиная с пустой структурыncclComm, постепенно строит топологический граф, инициализирует каналы, устанавливает транспортные соединения и в конечном итоге создаёт работоспособный коммуникационный домен. Ментальная модель набора из пяти компонентов, построенная в этой главе, будет шаг за шагом реализована в следующей главе.

Эти пять абстракций не существуют изолированно: коммуникационный домен — это контейнер, канал — единица параллельного выполнения, алгоритм определяет, как данные редуцируются, протокол определяет, как данные кодируются, транспортный уровень отвечает за то, как данные перемещаются. Их комбинация — 5 измерений, каждое с 3-4 вариантами — составляет пространство поиска для настройки производительности NCCL. Итак, как именно этот объект коммуникационного домена строится с нуля? В следующей главе мы углубимся в цепочку вызовов ncclCommInitRank, чтобы увидеть, как NCCL на этапе инициализации выполняет обнаружение устройств, обнаружение топологии и распределение каналов, и раскроем время присваивания таких ключевых полей, как comm->rank, comm->nRanks, comm->channels.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 03

Глава 3: Вход в инициализацию: как ncclCommInitRank формирует коммуникационный домен

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 3 из 25

В предыдущей главе мы установили пять ключевых абстракций, проходящих через всю книгу: 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
```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: объединение (union), которое либо является дескриптором сетевого устройства (net.sendComm/net.recvComm), либо парой сокетов (socket.send/socket.recv). Это соответствует двум режимам bootstrap: режиму по умолчанию на основе сокетов и режиму на основе сетевого устройстваNCCL_OOB_NET_ENABLE.
  • listen: информация о стороне прослушивания, также имеющая две формы — сетевую и сокетную.
  • peerP2pAddresses / peerProxyAddresses: массив 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 могут соединяться друг с другом.

📎 src/bootstrap.cc:778-788

При обычной инициализации (handles != NULL) magic берётся из первого handle; при split/grow (parent != NULL) magic выводится черезhashCombine(parent->magic, parent->childCount). Это гарантирует уникальный magic для каждого подчинённого коммуникационного домена.

Шаг второй: создание слушающего сокета.Каждому рангу нужны две точки прослушивания: одна для соединений с соседями по ring (STATE_LISTEN(state, socket)), другая для соединений с root (listenSockRoot):

📎 src/bootstrap.cc:797-831

Здесь есть ключевое разделение обязанностей: слушающий сокет ring используетcomm->magic, а слушающий сокет root используетBOOTSTRAP_HANDLE(handles, curr_root)->magic. Почему? Потому что root — глобальный координатор, к нему подключаются все ранги, поэтому он использует единый magic; а соседи по ring соединяются попарно, и им достаточно собственного magic коммуникационного домена.

Шаг третий: разнесённое по времени подключение.Когда количество рангов велико, одновременное подключение всех рангов к root вызывает шторм соединений. NCCL используетNCCL_UID_STAGGER_RATEиNCCL_UID_STAGGER_THRESHOLDдля управления разнесением по времени:

📎 src/bootstrap.cc:833-843

Когда число рангов, за которые отвечает некоторый root, превышает порог (по умолчанию 256), каждый ранг вычисляет задержку в микросекундах на основе своего локального ID под этим root, а затем выполняет sleep. Это простой, но эффективный механизм ограничения скорости типа «token bucket».

Шаг четвёртый: отправка root своей информации о соединении.Каждый ранг отправляет root свой адрес прослушивания:

📎 src/bootstrap.cc:845-867

Получив информацию от всех рангов, root выполняет «кольцевое сопоставление» — отправляет адрес ранга i рангу i-1, а адрес ранга i+1 — рангу i. Так каждый ранг узнаёт своих соседей по ring спереди и сзади.

Шаг пятый: установка ring-соединений.Каждый ранг подключается к своему «следующему» соседу и одновременно принимает подключение от «предыдущего» соседа:

📎 src/bootstrap.cc:885-894

ЗдесьsocketRingConnectвнутри используетbootstrapConcurrent— в режиме TLS-шифрования connect и accept должны выполняться параллельно, иначе возникнет взаимоблокировка (поскольку TLS-рукопожатие требует одновременного участия обеих сторон). В нешифрованном режиме connect и accept выполняются последовательно.

Шаг шестой: AllGather всех адресов.После установки ring черезringAllInfoвыполняется allgather всех P2P-адресов, proxy-адресов и UDS-адресов всех рангов:

📎 src/bootstrap.cc:934-938

ringAllInfoвнутри вызываетbootstrapAllGather, который в режиме сокетов используетsocketRingAllGather— двунаправленный алгоритм ring allgather, требующий всего N/2 шагов для N рангов:

📎 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, что означает проверку флага abort каждые 10000 итераций цикла. Это число — компромисс между производительностью и отзывчивостью: слишком частая проверка снижает производительность, слишком редкая — увеличивает задержку реакции на abort.Второй уровень: очередь асинхронной отправки.bootstrapSendВ режиме TLS-шифрования

📎 src/bootstrap.cc:1161-1217

не может выполняться синхронно (поскольку TLS-рукопожатие требует участия принимающей стороны), поэтому NCCL помещает операции отправки в отдельный поток:bootstrapAsyncSendMainЗдесь есть изящный механизм гарантии порядка.

📎 src/bootstrap.cc:1124-1152

Зачем нужно гарантировать порядок отправки для одной и той же пары (peer, tag)? В комментариях к исходному коду это объясняется предельно ясно: получатель сопоставляет соединения по (peer, tag), и если два сообщения, отправленных одному и тому же (peer, tag), прибудут в обратном порядке, получатель сопоставит их неправильно. Во время инициализации NVLS несколько раз выполняется широковещательная рассылка одному и тому же peer с одним и тем же tag, поэтому эта гарантия порядка необходима.

Третий уровень: очередь неожиданных соединений.Получатель не может предсказать, кто подключится первым, поэтомуsocketAcceptпомещает несовпадающие соединения вunexpectedConnectionsсвязанный список:

📎 src/bootstrap.cc:1276-1300

Эта архитектура решает классическую проблему распределённых систем: несколько рангов могут одновременно инициировать соединение с вами, но вашbootstrapRecvпорядок вызовов фиксирован. Если несовпадающие соединения просто отбрасывать, отправитель получит тайм-аут; если блокироваться в ожидании, может возникнуть взаимоблокировка. Помещение в очередь — самый безопасный подход.

Руководство по избежанию проблем в production

Проблема первая: тайм-аут bootstrap приводит к зависанию инициализации.Если какой-либо ранг из-за проблем с сетью не может подключиться к root, все остальные ранги будут бесконечно ждать наncclSocketAcceptилиncclSocketRecv. В NCCL нет встроенного механизма тайм-аута bootstrap, единственный путь к спасению — abortFlag. В production рекомендуется установитьNCCL_UID_STAGGER_RATEдля смягчения шторма подключений в крупномасштабных кластерах.

Проблема вторая:NCCL_COMM_IDконфликтует с несколькими handle.Когда пользователь устанавливаетNCCL_COMM_IDпеременную окружения, NCCL принудительно понижаетnIdдо 1:

📎 src/init.cc:2912-2921

Это означает, чтоncclCommInitRankScalableфункция нескольких handle будет молча отключена. Если вы используете scalable-инициализацию и при этом установилиNCCL_COMM_ID, поведение будет отличаться от ожидаемого.

Проблема третья: взаимоблокировка в режиме 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: Сбор адресов прослушивания всех рангов
    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: Кольцо построено
    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, один ранг на процесс, нормальная инициализация.

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. getBusIdПолучение ID шины PCI,ncclNvmlDeviceGetHandleByPciBusIdПолучение дескриптора NVML.

8. dmaBufSupportedПроверка поддержки DMA-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

Этап первый: AllGather1 — обмен информацией об устройствах.

📎 src/init.cc:1234-1239

Каждый rank вызываетfillInfoзаполняет свойncclPeerInfo, затем обменивается черезbootstrapAllGather.fillInfoЗаполняемая информация включает: номер rank'а, номер устройства CUDA, номер устройства NVML, версию NCCL, git hash, хеш хоста, хеш процесса, UUID GPU, ID шины, размер видеопамяти, версию драйвера и т.д.

📎 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'и расположены последовательно по узлам.

Этап второй: обнаружение топологии.

📎 src/init.cc:1390-1403

Эти шесть шагов — ядро процесса обнаружения топологии:ncclTopoGetSystemперечисляет системные устройства и строит граф топологии,ncclTopoComputePathsвычисляет пути от GPU к NIC,ncclTopoTrimSystemудаляет недостижимые устройства, снова вычисляет пути,ncclTopoSearchInitинициализирует состояние поиска и, наконец, выводит топологию.

Этап третий: вычисление графов.

📎 src/init.cc:1421-1468

Последовательно вычисляются пять графов: ring, tree, collnet chain, collnet direct, nvls. Каждый граф имеет свои ограничения по pattern и числу каналов. Обратите внимание наtreeGraph->minChannels = ringGraph->nChannels— число каналов для tree ограничено так, чтобы совпадать с ring, это делается для выравнивания каналов между разными алгоритмами.

Этап четвёртый: 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 нужно объединять, чтобы обеспечить совместимость.

Этап пятый: установка транспортных соединений.

📎 src/init.cc:1811-1892

Здесь есть две ветви:runtimeConnесли истинно, выполняется только настройка каналов без соединений (соединения откладываются до времени выполнения), иначе все соединения устанавливаются немедленно. Порядок соединений: ring → tree → NVLS → PAT → NVLS tree → CollNet.

Управление параллелизмом и взаимодействие с оборудованием

initTransportsRankВ есть несколько заслуживающих внимания точек параллелизма/взаимодействия с оборудованием:

Настройка привязки к CPU:

📎 src/init.cc:1406-1412

NCCL привязывает текущий поток к ядрам CPU, близким к GPU, обеспечивая выделение памяти хоста на локальном узле NUMA. Это снижает задержку при кросс-NUMA доступе.

Инициализация NVLS:

📎 src/init.cc:1419-1419

ncclNvlsInitПроверка поддержки NVLink SHARP. NVLS позволяет коммутатору напрямую выполнять операцию reduce, значительно снижая задержку AllReduce.

Создание прокси-потоков:

📎 src/init.cc:1780-1786

Прокси-потоки отвечают за асинхронное продвижение сетевого ввода-вывода. Они создаются вinitTransportsRank, после чего все сетевые операции выполняются через прокси.

Руководство по избежанию проблем в production

Проблема первая: несовпадение количества сетевых устройств.Если количество локальных сетевых карт различается у разных rank, NCCL выдаст ошибку:

📎 src/init.cc:1576-1596

Если только не установленоNCCL_IGNORE_NET_MISMATCH=1. Это часто встречается в гетерогенных кластерах — на некоторых узлах 8 сетевых карт, на других только 4. Игнорирование несовпадения может привести к снижению производительности, так как количество каналов будет ограничено самым слабым узлом.

Проблема вторая: несколько rank используют один и тот же GPU.Если UUID GPU у двух rank совпадают, NCCL откажется инициализироваться:

📎 src/init.cc:1291-1296

Если только не установленоNCCL_MULTI_RANK_GPU_ENABLE=1. Эта проверка предотвращает проблемы производительности из-за ошибочной конфигурации пользователя.

Проблема третья: недостаточное количество узлов 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 означает "не кэшировать". Это решение принимается только один раз при первой загрузке и больше не меняется.

Руководство по избежанию проблем в production

Проблема первая: опечатка в переменной окружения.Если пользователь написалNCCL_BUFSIZEвместоNCCL_BUFFSIZE, NCCL не выдаст ошибку, а просто использует значение по умолчанию. Рекомендуется использоватьNCCL_DEBUG=ENVдля просмотра всех распознанных переменных окружения.

Проблема вторая:NCCL_CONF_FILEпорядок загрузки.NCCL последовательно загружает$NCCL_CONF_FILE(или~/.nccl.conf) и/etc/nccl.conf:

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

Файл, загруженный позже, перезаписывает загруженный ранее. Если оба файла устанавливают одну и ту же переменную,/etc/nccl.confзначение вступит в силу.

Проблема третья: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. Это как скопировать корпоративную телефонную книгу и положить на рабочее место каждого сотрудника — сотруднику не нужно каждый раз бежать к стойке регистрации, чтобы узнать телефон коллеги.

БезdevCommSetupGPU kernel не смог бы узнать свой rank, конфигурацию каналов, размер буфера и другую информацию, и ядро коллективной коммуникации вообще не могло бы запуститься.

Структура данных и размещение в памяти

devCommSetupИспользует временную структуруncclKernelCommAndChannelsдля упаковки данных, которые нужно скопировать на устройство:

📎 src/init.cc:712-746

Эта структура содержитncclDevComm(коммуникационный домен на стороне устройства) и массив каналов. Функция сначала заполняет временную структуру данными со стороны хоста, затем однимcudaMemcpyAsyncна устройство.

Заполнение ключевых полей:

📎 src/init.cc:734-746

Обратите внимание наcomm->devComm = &devCommAndChans->comm— на стороне хостаcomm->devCommуказывает наncclDevCommв памяти устройства. При последующем запуске ядра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. Освобождение strong stream и синхронизация.

Размышления о дизайне

devCommSetupСамое примечательное в этом дизайне — «пакетное копирование». NCCL не вызываетcudaMemcpyотдельно для каждого поля, а упаковывает все поля во временную структуру и выполняет всё однимcudaMemcpyAsync. Это значительно сокращает количество вызовов CUDA API и накладные расходы на синхронизацию.

Ещё один аспект дизайна —workFifoBytesобработка CC:

📎 src/init.cc:750-763

В режиме CC (Confidential Computing)workFifoBytesустанавливается в 0, поскольку копирование GDR в режиме CC недоступно. Это изящная деградация, обусловленная аппаратным ограничением.

Руководство по избеганию проблем в production

Проблема первая:devCommSetupдолжен вызываться до barrier.Комментарий в исходном коде объясняет причину:

📎 src/init.cc:1950-1952

Если вызвать после barrier, некоторые потоки могут уже начать запуск ядра NCCL, а память устройства ещё не выделена полностью, что приведёт к взаимоблокировке.

Проблема вторая:workFifoBytesдолжно быть степенью двойки.Если это не так, NCCL выдаст предупреждение и использует значение по умолчанию:

📎 src/init.cc:757-762

Вопросы для размышления и самопроверки по этой главе

Q1: Если убрать логику обнаружения «несколько rank используют один и тот же GPU» в📎 src/init.cc:1291-1296, в каких сценариях это приведёт к проблемам? Почему NCCL по умолчанию отклоняет такую конфигурацию?

Разбор ответа:

Этот код проверяет, совпадают ли GPU UUID двух rank на одном хосте. Если совпадают иNCCL_MULTI_RANK_GPU_ENABLE=0(по умолчанию), возвращаетсяncclInvalidUsage。

После удаления этой проверки несколько rank будут совместно использовать один GPU. Это приведёт к:

1. Конфликтам P2P-передачи: P2P-передача NCCL предполагает, что каждый rank монопольно владеет одним GPU. Если два rank совместно используют GPU, они одновременно записывают данные в один и тот же буфер одного и того же GPU, что приводит к гонкам данных и неверным результатам.

2. Конфликтам распределения каналов:comm->channelsРесурсы каналов (буферы, FIFO) в

3. распределяются по rank. Rank, совместно использующие GPU, будут конкурировать за одни и те же ресурсы.Катастрофическому падению производительности

: даже если нет проблем с корректностью, два rank, совместно использующие вычислительную мощность и пропускную способность памяти одного GPU, приведут к резкому снижению производительности.NCCL_MULTI_RANK_GPU_ENABLE=1NCCL по умолчанию отклоняет такую конфигурацию ради «быстрого отказа» — вместо того чтобы позволить пользователю потратить часы на отладку ошибочной конфигурации, лучше сразу выдать явную ошибку при инициализации.

предназначен для тех пользователей, которые точно знают, что делают (например, в сценариях MPS), как аварийный выход.📎 src/bootstrap.cc:1129-1134Q2: Если убрать логику ожидания «более ранней отправки с тем же (peer, tag)» в

, в каких сценариях это приведёт к ошибке сопоставления на стороне получателя?:

Разбор ответа

Этот код в потоке асинхронной отправки ожидает, пока в очереди не останется более ранних отправок тому же (peer, tag).socketAcceptПосле удаления этого ожидания две отправки одному и тому же (peer, tag) могут выполняться параллельно, и порядок их прибытия к получателю становится неопределённым. Получатель в

📎 src/bootstrap.cc:1291-1292

сопоставляет соединения по (peer, tag):bootstrapSendЕсли отправитель A вызвал

раньше, но прибыл позже, а отправитель 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) по-прежнему выполняются параллельно, поэтому общая пропускная способность не страдает.📎 src/init.cc:1691-1697Q3: Если в

изменить стратегию выравнивания с «nChannels берётся min, typeIntra берётся max» на «всё берётся min» или «всё берётся max», к каким проблемам это приведёт в каждом случае?:

Разбор ответаnChannels、sameChannels、bwIntra、bwInterТекущая стратегия:typeIntra、typeInter、crossNicберётся min,

берётся max.:typeIntraЕсли всё брать по mintypeInterВзятие min приведёт к тому, что тип передачи некоторых rank будет понижен. Например, rank A поддерживает P2P (typeIntra=P2P), rank B поддерживает только SHM (typeIntra=SHM), после взятия min все rank используют SHM. Но значение перечисления SHM может быть меньше, чем у P2P, и взятие min выберет неправильный тип. ФактическиtypeIntraэто битовая маска или перечисление, взятие max нужно для выбора типа с "наибольшими возможностями".

Если везде брать max:nChannelsВзятие max приведёт к тому, что некоторым rank будет назначено больше каналов, чем они могут поддерживать. Например, rank A поддерживает только 4 канала, rank B поддерживает 8, после взятия max все rank попытаются использовать 8 каналов, rank A потерпит неудачу или производительность упадёт.bwIntraВзятие max приведёт к слишком оптимистичной оценке пропускной способности, и модуль 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 способен автоматически выбирать подходящий алгоритм на разных машинах.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 04

Глава 4: Обнаружение топологии и поиск по графу: картографирование связности мульти-GPU

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 4 из 25

В предыдущей главе мы, следуя по цепочке вызовов 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 GPU, полностью соединёнными через 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 ГБ/с каждый, после агрегации получается 100 ГБ/с. Во-вторых, если не найдено, добавляется новая связь. В-третьих, после вставки связи сортируются по убыванию пропускной способности, чтобы при последующем обходе в первую очередь встречались высокоскоростные связи.

〔Предположения о дизайне и архитектурные компромиссы〕

Мотивация сортировки по убыванию пропускной способности заключается в том, чтобы алгоритм поиска как можно раньше обнаруживал высокоскоростные пути и быстрее сходился к лучшему решению. Поиск имеет ограничение по времени (далее мы увидимNCCL_SEARCH_TIMEOUT), и сортировка позволяет потратить ограниченный временной бюджет на более перспективные пути.

Пошаговое рассмотрение на конкретном сценарии

Теперь рассмотрим конкретный сценарий: сервер с 8 картами A100, каждая карта полностью соединена через NVLink, плюс 4 сетевые карты Mellanox ConnectX-6, установленные в слоты PCIe. При инициализации NCCLncclTopoGetSystemвызывается, он читает информацию об устройствах из 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обходит дочерние узлы net/gin/rma под NIC и вызывает соответствующие функции добавления. Рассмотрим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 — это мегабиты в секунду, деление на 8000 даёт ГБ/с (поскольку 1 ГБ/с = 8000 Мбит/с). Если сетевая карта сообщает speed = -1 (так бывает у некоторых виртуальных сетевых карт), то по умолчанию принимается 10000 Мбит/с = 1.25 ГБ/с.

Четвёртый шаг — завершающая обработка.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 — они представляются как двухуровневые коммутаторы, но на самом деле имеют полную пропускную способность, и их нужно "сплющить", чтобы не вводить в заблуждение алгоритм поиска.ncclTopoConnectCpusсоединяет все узлы CPU друг с другом (доступ через NUMA идёт по связям SYS).ncclTopoSortSystemсортирует связи так, чтобы нисходящие связи PCI шли первыми, что удобно для обхода.

Размышления о дизайне и подводные камни в продакшене

〔Предположения о дизайне и архитектурные компромиссы〕

Почему используется XML в качестве промежуточного формата?Потому что обнаружение топологии требует межпроцессного обмена — каждый rank обнаруживает только свои управляемые GPU, затем через bootstrap обменивается XML и в конце объединяет их в полную топологию. XML — это самоописывающий текстовый формат, удобный для отладки (можно сделать dump и посмотреть) и совместимый по версиям.

Подводный камень первый: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. Такой дизайн легко приводит к пропуску проверки — если вызывающий забудет проверить, последующее разыменование приведёт к краху.

Подводный камень второй:ncclTopoConnectNodesнакопление пропускной способности может привести к переполнению.Если между одной и той же парой узлов существует множество связей (например, в сценарии NVSwitch),link->bw += bwможет накопить очень большое значение. Хотя точности float достаточно, если количество связей аномально велико, логика сортировки может дать сбой.

Подводный камень третий: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). Поэтому все указатели на узлы, находящиеся после удаляемого, должны быть уменьшены на единицу. Эта операция вmemmoveвыполняется раньше, порядок критичен.

Поиск пути: поиск "оптимального маршрута" на графе

Интуитивная модель

Одной карты недостаточно — нужен ещё алгоритм навигации. Поиск пути в NCCL делится на два уровня: первый — предобработка, вычисление кратчайших путей между всеми парами узлов (BFS); второй — поиск по графу, перебор различных структур Ring/Tree на результатах предобработки для нахождения варианта с наибольшей пропускной способностью.

Без поиска пути NCCL мог бы лишь жёстко задавать фиксированный порядок вроде "GPU 0 соединён с GPU 1, GPU 1 с GPU 2...", что на неоднородной топологии привело бы к выбору медленных маршрутов.

Структуры данных и layout памяти

Ключевая структура данных поиска пути — это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], хранящий пути ко всем узлам данного типа. Например,paths[NET]для GPU-узла хранит пути ко всем сетевым картам.

Вычисление путей выполняет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
  • Если путь проходит через CPU, тип повышается доPATH_PHB
  • Если путь проходит через узел DEV и является NVLink, тип повышается доPATH_NVB

Условие обновления — "лучший путь": лучший тип, или тот же тип, но выше пропускная способность, или тот же тип и пропускная способность, но меньше число переходов.

Пошаговый разбор на основе сценариев

Теперь рассмотрим второй уровень поиска.ncclTopoCompute— точка входа, она перебирает различные комбинации параметров и вызываетncclTopoSearchRecдля поиска.

Ядро поиска — рекурсивная функцияncclTopoSearchRecGpu. Она стартует с некоторого GPU и пытается перейти к следующему GPU, пока не обойдёт все GPU, образуя путь:

📎 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: все GPU пройдены, сформирован полный путь. Здесь инкрементируетсяnChannels, текущий граф сравнивается с сохранённым оптимальным графом, и если он лучше — сохраняется. Затем рекурсивно вызываетсяncclTopoSearchRecдля попытки поиска следующего channel.

2. step == backToNet: нужно вернуться к сетевой карте. Это происходит в режиме Ring (последний GPU должен соединиться обратно с исходной сетевой картой) или в режиме Tree (первый GPU должен подключиться к сетевой карте).

3. step < ngpus - 1: продолжаем идти к следующему GPU. Здесь вызываетсяncclTopoSearchNextGpuSortдля сортировки кандидатов GPU.

4. step == backToFirstRank: в режиме Ring последний GPU должен соединиться обратно с первым GPU.

5. else: путь завершён, переход к следующему раунду.

ncclTopoSearchNextGpuSortопределяет порядок перебора следующих GPU:

📎 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)

Пространство поиска экспоненциально — для каждого channel существует O(ngpus!) перестановок. Для 8-карточной машины это 40320, для 16-карточной — 2 триллиона. Необходимо ограничивать время поиска.NCCL_SEARCH_TIMEOUTсоставляет 16384 итерации,NCCL_SEARCH_GLOBAL_TIMEOUT— 524288. По истечении таймаута возвращается текущее оптимальное решение.

Подводный камень первый:ncclTopoFollowPathвычитание пропускной способности — глобальный побочный эффект.Посмотрим на эту функцию:

📎 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логика сравнения очень тонкая.Она в первую очередь сравнивает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 можно разделить на две половины, одна по часовой стрелке, другая против, что снижает перегрузку сети.

Ring и Tree: превращение результатов поиска в топологию алгоритма

Интуитивная модель

Алгоритм поиска находит набор путей, но алгоритму нужен явный порядок "кто кому отправляет". Ring выстраивает все rank в кольцо, каждый rank принимает от предыдущего и отправляет следующему. Tree — это дерево, данные текут от корня вниз или собираются от листьев вверх.

Без этих двух модулей алгоритм поиска просто нашёл бы кучу путей и не смог бы сообщить GPU-ядру, как конкретно отправлять данные.

Структуры данных и layout памяти

Построение Ring выполняетncclBuildRings:

📎 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(предшественник и преемник каждого rank), на выходе — массивrings(полный порядок rank для каждого channel). Он стартует с текущего rank, идёт по указателямnextпо кругу, проверяя возврат к началу и то, что все rank посещены.

Построение Tree выполняетncclGetBtree:

📎 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;
}

Эта функция строит двоичное дерево с помощью битовых операций. Основная идея: найти младший ненулевой бит rankbit, родитель —(rank ^ bit) | (bit << 1), левый потомок —rank - (bit >> 1), правый потомок —rank + (bit >> 1). ASCII-диаграмма в комментариях наглядно показывает эту структуру.

Пошаговый разбор на основе сценариев

Возьмём пример Ring с 8 картами. Предположим, результаты поиска дают для каждого рангаnextуказатель:

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

ncclBuildRingsНачиная с ранга 0, последовательно посещаем 1, 2, ..., 7 и возвращаемся к 0. Сгенерированныйrings[0..7] = {0, 1, 2, 3, 4, 5, 6, 7}。

Для Tree,ncclGetBtreeдля каждого ранга вычисляем родительский и дочерние узлы. Возьмём ранг 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

Таким образом, родительский узел ранга 1 — ранг 2, дочерних узлов нет. Это соответствует структуре дерева из комментария: ранг 1 — лист.

Размышления о дизайне и подводные камни в продакшене

〔Предположения о дизайне и архитектурные компромиссы〕

Почему в Tree используются битовые операции вместо явного построения дерева?Потому что каждому рангу нужно знать только своего родителя и дочерние узлы, а не глобальную структуру дерева. Битовые операции позволяют вычислить эту информацию за O(1), избегая накладных расходов на хранение и синхронизацию всего дерева.

Подводный камень первый:ncclBuildRingsпроверка может быть пропущена.Еслиnextмассив содержит цикл (например, rank 0 -> rank 1 -> rank 0), цикл завершится послеnranksитераций, ноcurrent != rankпроверка поймает эту проблему. Однако если длина цикла恰好 являетсяnranksделителем и не включает все ранги,rankFoundпроверка поймает.

Подводный камень второй:ncclGetDtreeобработка нечётных рангов.Для нечётного числа рангов второе дерево использует "сдвиг", а не "зеркальное отражение":

📎 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 (двойное дерево) — это реализация алгоритма Tree в NCCL: два дерева работают одновременно, одно отвечает за первую половину данных, другое — за вторую, повышая эффективность использования пропускной способности. При нечётном числе рангов зеркальное отражение приводит к неполному отображению рангов, поэтому используется сдвиг.

Взаимодействие трёх компонентов: от топологии к алгоритму

Теперь свяжем три модуля вместе. Весь процесс можно представить одной схемой:

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) восстановление пропускной способности

Эта временная диаграмма показывает основной цикл поиска: выбор сетевого адаптера -> попытка 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): преобразование результатов поиска в конкретный порядок рангов; Ring используетncclBuildRingsдля генерации кольца, Tree используетncclGetBtreeдля генерации бинарного дерева.

Вопросы для размышления и самопроверки

Q1: Если вncclTopoConnectNodesнакопление пропускной способностиlink->bw += bwзаменить наlink->bw = std::max(link->bw, bw), в каких сценариях это приведёт к снижению производительности? Почему?

Разбор ответа: Накопление пропускной способности обрабатывает случай нескольких параллельных линий. Например, 4 линии NVLink по 25 ГБ/с каждая: при накоплении получается 100 ГБ/с, при взятии max — только 25 ГБ/с. ВncclTopoSetPathsпропускная способность пути равнаstd::min(path->bw, link->bw), и если пропускная способность линии занижена, вся пропускная способность пути будет занижена. Это приведёт к тому, чтоncclTopoCompareGraphsвыберет неправильный граф — возможно, вариант с большим числом каналов, но меньшей пропускной способностью каждого канала, что на практике даст худшую производительность. Конкретный сценарий: 8 карт A100, полностью соединённых через NVLink, между каждой парой GPU — 4 линии NVLink. Накопление даёт 100 ГБ/с, взятие max — 25 ГБ/с. Алгоритм поиска будет считать, что NVLink и PCIe Gen4 x16 (около 25 ГБ/с) имеют одинаковую пропускную способность, и может выбрать путь через 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После завершения выполняется обход всех связей и проверка, соответствует ли пропускная способность исходному значению. Если обнаружено несоответствие, это означает, что восстановление было пропущено. Способ исправления: использовать guard-объект в стиле 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.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 05

Глава 5: Тюнинг и выбор протокола: как модуль tuning определяет оптимальные пути и каналы

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 5 из 25

В предыдущей главе мы разобрали возможности NCCL по учёту топологии: от перечисления устройств и построения графа топологии в src/graph/topo.cc, до поиска оптимальных путей в src/graph/search.cc, и далее до конкретизации результатов поиска в топологии алгоритмов Ring и Tree в rings.cc и trees.cc. Но граф топологии отвечает лишь на вопрос «по какому пути могут идти данные», а не на вопрос «по какому пути должно идти данное взаимодействие». На одной и той же машине оптимальное решение для AllReduce размером 4KB и AllReduce размером 400MB может быть совершенно различным: в первом случае важна задержка, во втором — пропускная способность; в первом случае может быть выбран Tree/LL, во втором — Ring/Simple или NVLS. Модуль tuning — это тот, кто «принимает решение». Его входные данные — размер сообщения, количество рангов, граф топологии (результат предыдущей главы) и переменные окружения пользователя; выходные — структура ncclTuningResult_t, содержащая информацию о том, какой алгоритм (algo) использовать, какой протокол (proto), сколько каналов задействовать и сколько warp'ов. В этой главе мы разберём каталог src/tuning в порядке «общее управление → модель стоимости → оценка для каждого алгоритма → финальное решение». Ключевой вопрос только один: как NCCL среди десятков комбинаций (алгоритм, протокол) с помощью чисто CPU-математической модели за микросекунды выбирает самую быструю?

I. 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один раз, но tuning происходит на пути постановки в очередь и нечасто, так что эти накладные расходы на выделение приемлемы.

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 рангов, одна машина NVLink. Мы пройдём вместе сncclTuningComputeвесь путь.

Шаг 0: короткое замыкание для одного ранга.Если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 {

Это короткое замыкание очень важно: при одном ранге любая оценка алгоритма будет делиться наnRanks-1и подобные величины, что легко приводит к NaN или делению на ноль.Сначала подстраховка, потом расчёты, это типичный пример защитного программирования.

Шаг 1: перечисление всех кандидатов.Вход вncclTuningComputeAllTunings, который перебираетNCCL_TUNING_COUNTid:

📎 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разворачивает одномерный id в (algo, proto, symKernelId, ceMethodId). Это отображение должно строго соответствоватьcost_model.ccвmodelMapмассиву

, иначе модель будет вычислена неверно. ncclTuningComputeTuningШаг 2: вычисление стоимости по очереди.

📎 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 (опционально).timeUsЕсли пользователь установил плагин tuner (например, собственный оптимизатор некоторых облачных провайдеров), NCCL упаковывает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.

📎 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Если пользователь установил

📎 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;
          ...

копироватьКомментарий к этому фрагменту кода очень важен:GetChannelsсмещение EFFICIENCY должно выполняться после, потому что нужно использоватьbestTuning.nChannels; и необходимо проверить, разрешён ли бит NVLS вtuningMask, иначе можно «воскресить» алгоритм, исключённый на верхнем уровне. Это типичнаяловушка зависимости порядка состояний。

Шаг 7: откат симметричного kernel.Если выбран симметричный kernel (symKernelId), но buffer не зарегистрирован или платформа не поддерживает, нужно откатиться к обычному kernel. Эта логика находится вtuning.cc:258-298, это самое запутанное место во всей главе, мы разберём его отдельно в пятом разделе.

Шаг 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"]

---

II. 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 несколько раз (например, в группе несколько op), init выполняется один раз, sim — каждый раз. Это типичная оптимизация «предвычисление + быстрый запрос».

Пошагово: разбор переменных окружения и построение матрицы переключателей

Шаг 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 равен 2, а не 1?Потому что 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 (поскольку пользователь указал белый список):

📎 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,parseListустанавливает LL в 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]и пользовательскими переключателями:

📎 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(отрицательный sentinel),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 выберет совершенно неправильный алгоритм.Это классическая ловушка таблично-управляемого дизайна: неявный контракт.Более надёжный подход — использовать в качестве ключа имя перечисления, а не индекс, но это пожертвует небольшой частью оптимизации на этапе компиляции.

---

Три, ring.cc: оценка стоимости алгоритма Ring

Интуитивная модель

Алгоритм Ring выстраивает N rank'ов в кольцо, данные передаются по кольцу круг за кругом. Его модель стоимости должна ответить на два вопроса:Сколько данных передаётся на каждом шаге (пропускная способность)、Сколько всего шагов (задержка)。

Интуиция Ring — это «конвейер»: представьте N человек, стоящих в кругу и передающих ведро с водой; каждый, получив ведро, выливает немного воды и передаёт следующему. Ведро делает круг, и вода у всех перемешивается. Чем быстрее вращается ведро (выше пропускная способность), чем меньше круг (меньше шагов), тем быстрее всё в целом.

Структуры данных: таблицы latency/bandwidth

Модель Ring не вводит новых структур, она записывает результаты оценки вcomm->tuningContext.generalLatencies[c][algo][proto]иgeneralBandwidths[c][algo][proto]. Это трёхмерные массивы: функция × алгоритм × протокол.

При инициализации всё сначала устанавливается в -1.0 (sentinel, означающий «не вычислено»):

📎 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;

Этот sentinel -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;
  }

Почему используется -1.0, а не 0?Потому что 0 — это допустимое значение пропускной способности (хотя физически невозможно), а -1.0 явно означает «не инициализировано». Сравнение с плавающей точкой через==здесь безопасно, потому что -1.0 точно представимо.

Пошагово: оценка пропускной способности 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), 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/128Это потому, что в LL128 каждые 128 байт содержат 8 байт флагов, а полезная нагрузка составляет всего 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: объём данных, фактически передаваемых каждым рангом, равен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(в каждом узле только 1 ранг), для inter-node задержки Ring используетсяNET-задержка Tree. В комментарии сказано, что это «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).

Производственные подводные камни: эффект plateau в Ring/Simple

ncclTuningRingModelSimВ

📎 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— это условие срабатывания: каждый ранг должен передать как минимум 64 байта на канал, иначе plateau не наступает. Эти 64 байта происходят из размера флага протокола LL.

Сценарий подводного камня: если вы запускаете AllReduce размером 1MB на Blackwell и обнаруживаете, что фактическая задержка на 40% выше предсказанной моделью, не думайте, что это баг — это эффект plateau, и модель уже его учла. Если вы вручную уменьшитеplateauFactor, модель будет недооценивать задержку, что приведёт к выбору неправильного алгоритма.

---

IV. tree.cc и nvls.cc: оценка стоимости Tree и NVLS

Интуитивная модель

Алгоритм Tree— это «древовидная широковещательная рассылка»: корневой узел распределяет данные дочерним узлам, дочерние — внучатым. Его преимущество —малое число шагов(log N вместо 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;
    }
〔Проектные выводы и архитектурные компромиссы〕

Почему?Потому что реализация Tree в NCCL поддерживает только AllReduce (для других коллективных операций нет версии Tree). Это ограничение реализации, а не теоретическое ограничение.enabled[c] = 0— это «жёсткое отключение», более радикальное, чемgeneralBandwidths = -1— первое заставляетncclTuningCostModelSimModelвернуться на L480, второе проверяется только внутри sim-функции.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, что жёстче, чем0.5у Ring.Почему эффективность LL у Tree ниже?Потому что каждый промежуточный узел Tree должен и принимать, и отправлять, а накладные расходы на флаги LL усиливаются при двунаправленном трафике.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)— число внутриузловых шагов (число рангов в узле минус один),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), то есть размер сообщения берётся log2 с единицей измерения 64 байта. Индексы таблицы 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.Почему эффективность нового поколения оборудования ниже?Потому что пропускная способность NVLink у Blackwell выше, но вычислительная способность коммутатора 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 нужно оставить один channel для синхронизации.(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— максимальное число GPU, которое может вместить группа многоадресной рассылки NVLS. Если это число превышено, NVLS недоступен.Сценарий подводного камня: запуск AllGather в домене NVLink на 16 карт, еслиNCCL_MAX_NVLS_ARITYравно 8, NVLS будет отключён, и tuning откатится к Ring. Если вы не знаете этого ограничения, будете думать: «NVLS ведь аппаратно поддерживается, почему не используется».

---

V. Откат симметричного kernel и цепочка восстановления после ошибок

Интуитивная модель

Симметричный kernel (symmetric kernel) — новая возможность NCCL: когда буферы всех rank зарегистрированы в симметричной памяти, kernel может обращаться к памяти партнёра более эффективными инструкциями. Ноесли буфер не зарегистрирован или платформа не поддерживается, необходимо откатиться к обычному 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.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 06

Глава 6: Диспетчеризация задач AllReduce: от вызова API до постановки задач в очередь

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 6 из 25

В предыдущей главе мы завершили модуль tuning и узнали, что NCCL в течение микросекунд выбирает комбинацию (алгоритм, протокол, channel, warp) для одной коллективной операции. Но сам результат выбора — это всего лишь набор чисел; его нужно «перевести» в объект описания задачи, понятный GPU kernel, чтобы он мог быть реально выполнен. В этой главе мы входим в основную часть src/enqueue/enqueue.cc и отвечаем на ключевой вопрос: что происходит на стороне host, когда пользователь вызывает ncclAllReduce? От ncclAllReduce до ncclEnqueueCheck, через проверку параметров, определение алгоритма/протокола, разбиение на channel, в итоге генерируются структуры ncclInfo и ncclTaskColl. Это ключевая глава, в которой книга переключается с «точки зрения пользователя» на «точку зрения движка». Если сравнить NCCL с рестораном, то модуль enqueue — это «система приёма заказов на ресепшене»: пользователь (прикладной уровень) говорит «я хочу AllReduce», а ресепшен переводит это в рабочий заказ, который может выполнить кухня (GPU kernel) — какая плита, какая кастрюля, на сколько партий разбить. Без этого слоя трансляции кухня вообще не знала бы, какое блюдо готовить.

I. Входная точка: как ncclAllReduce конструирует ncclInfo

Интуитивная модель

ncclAllReduce— это API-функция, вызываемая пользователем напрямую. Её задача предельно проста:упаковать сырые параметры, переданные пользователем, в структуруncclInfo, а затем передать её вncclEnqueueCheck. Это как прийти в банк к окошку: оператор сначала вносит вашу потребность в стандартный бланк, а потом передаёт его в бэк-офисную систему.

Без этого слоя каждый API коллективной коммуникации должен был бы сам обрабатывать проверку параметров, семантику group, точки профилирования — код дублировался бы до состояния, когда его невозможно поддерживать.

Структура данных: размещение 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, signalDescsСпецифично для RMA
Пользовательская конфигурация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.

Пошагово: цепочка вызовов ncclAllReduce

Рассмотрим на примереncclAllReduce, проследив полный путь от вызова пользователем до конструированияncclInfo.

Шаг 1: пользователь вызывает ncclAllReduce.Входная точка находится вsrc/collectives.cc:

📎 src/collectives.cc:206-211

Здесь делаются три вещи:

1. NVTX3_FUNC_WITH_PARAMS1. Ставится метка NVTX (для визуализации в Nsight и подобных инструментах)

2. ВызываетсяncclAllReduceConfigImpl, передаваяconfig = nullptr

3. Возвращается результат

Шаг 2: ncclAllReduceConfigImpl конструирует ncclInfo.Это ключевой шаг:

📎 src/collectives.cc:192-202

Обратите внимание, что здесь используется агрегатная инициализация в стиле C:

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— это число шагов в кольцевом буфере (обычно 8 или 16), поэтому для AllReduce chunkSteps равенNCCL_STEPS/2, а sliceSteps равенNCCL_STEPS/4. Это означает, что один chunk содержит 2 slice.

Шаг 3: разбор пользовательского config. ncclParseCollConfigРазбирает переданный пользователемncclCollConfig_t*вinfo.collConfig. Еслиconfig == nullptr, это поле остаётся инициализированным нулём.

Шаг 4: передача в ncclEnqueueCheck.Это настоящая входная точка модуля enqueue.

Размышление о дизайне: почему используется агрегатная инициализация, а не присваивание по полям?

〔Проектные предположения и архитектурные компромиссы〕

У агрегатной инициализации два преимущества: во-первых, компилятор проверяет соответствие количества полей (при нехватке одного поля будет предупреждение), во-вторых, код компактнее. Но недостаток в том, чтопорядок полей должен строго соответствовать объявлению структуры— если кто-то вставит поле в серединуncclInfo, все точки агрегатной инициализации молча сместятся. Это скрытый риск сопровождения в коде NCCL.

Производственная ловушка: жизненный цикл config

Реальный сценарий попадания в ловушку: пользователь пишет такой код:

c
ncclCollConfig_t config = {...};
ncclAllReduceConfig(..., &config);
// config 在这里被销毁(比如是栈变量,函数返回了)

Если бы NCCL не копировал config вncclInfo, то приncclGroupEndобращение кinfo.collConfigчитало бы уже освобождённую память.src/include/info.h:41-43Комментарий ккак раз и объясняет это проектное решение —。

---

config разбирается и копируется на этапе task append, после чего больше не зависит от пользовательского указателя

II. ncclEnqueueCheck: проверка параметров и семантика group

ncclEnqueueCheckИнтуитивная модель— это «главный шлюз» модуля enqueue. Все API коллективной коммуникации в конечном итоге стекаются сюда. Его задача:проверять корректность параметров, обрабатывать семантику group, вызывать taskAppend для генерации задачиncclEnqueueCheck。

. Если сравнить это с досмотром в аэропорту, то каждая API-функция — это стойка регистрации: регистрация только принимает багаж, а настоящий досмотр происходит в

Step-by-Step: поток выполнения ncclEnqueueCheck

📎 src/enqueue/enqueue.cc:3478-3527

Разберём пошагово:

Шаг 1: CommCheck — проверка коммуникационного домена. CommCheck(info->comm, info->opName, "comm")Проверяется, что указатель comm не пуст и инициализирован. Если comm отозван (например, из-за ошибки на каком-либо rank), сразу возвращается ошибка:

📎 src/enqueue/enqueue.cc:3480-3485

Шаг 2: обработка глубины profiler.Если уже внутри group (profilerGroupDepth > 0), счётчик глубины увеличивается. Это нужно для корректной обработки неявныхncclGroupStartInternal/ncclGroupEndInternalвызовов.

Шаг 3: вход во внутренний group. ncclGroupStartInternal()— это внутренний механизм group в NCCL.Ключевой момент: даже если пользователь явно не вызывалncclGroupStart, NCCL создаёт неявный group для каждого вызова API. Это гарантирует атомарность отдельного вызова.

Шаг 4: обеспечение готовности comm. ncclCommEnsureReady(info->comm)Ожидается завершение инициализации коммуникационного домена (например, завершение bootstrap, установление соединений).

Шаг 5: ArgsCheck — проверка параметров.Это самый сложный шаг проверки:

📎 src/enqueue/enqueue.cc:3497-3503

Обратите внимание на обработкуcheckMode: если этоncclCheckModeDebugGlobal,ArgsCheck, info ставится в очередь, а глобальная проверка (например, согласованность count на всех rank) выполняется во времяncclGroupEnd.

Шаг 6: вызов taskAppend.Это ключевой шаг преобразования:

📎 src/enqueue/enqueue.cc:3513

Шаг 7: увеличение opCount.После каждого успешного добавления в очередьcomm->opCount++. Этот счётчик используется для сопоставления операций send/recv, а также служит основой временной шкалы profiler.

Шаг 8: выход из group. ncclGroupEndInternal()Если depth становится равным 0, запускается реальная групповая операция (планирование, запуск kernel).

Управление конкурентностью: семантика group и потокобезопасность

〔Проектные предположения и архитектурные компромиссы〕

ncclGroupStartInternal/ncclGroupEndInternalДля поддержания состояния group используется thread-local storage (TLS). Это означает, чтонесколько вызовов API в одном потоке объединяются в один group, но вызовы из разных потоков независимы. Это основа поддержки многопоточности в NCCL.

Частая ловушка: если пользователь междуncclGroupStartиncclGroupEndвызывает CUDA API, не относящийся к NCCL (например,cudaMemcpy), это может привести к проблемам с порядком stream. Механизм group в NCCL предполагает, что все операции внутри group выполняются на одном наборе stream.

Цепочка восстановления после ошибок

ncclEnqueueCheckОбработка ошибок в

📎 src/enqueue/enqueue.cc:3524-3526

имеет изящный дизайн:taskAppendЕслиncclCommSetAsyncErrorзавершается неудачно и comm находится в неблокирующем режиме, вызывается

---

для записи ошибки. Тогда последующие вызовы API сразу возвращают ошибку, а не продолжают попытки. Это механизм асинхронного распространения ошибок.

Три, taskAppend: перекрёсток распределения задач

taskAppendИнтуитивная модельinfo->coll— это "транспортный узел" модуля enqueue. В зависимости от значения

он направляет задачи по разным путям обработки: P2P, RMA, CE или обычные коллективные коммуникации. Это как сортировочный центр почты — по адресу на конверте письмо попадает в нужный почтовый ящик.

Без этого слоя распределения все типы операций пришлось бы запихнуть в один огромный if-else, и код стал бы трудно поддерживаемым.

📎 src/enqueue/enqueue.cc:3337-3476

Step-by-Step: логика распределения в taskAppend ncclParamEnqueueRearchEnable()Шаг 1: определение, включена ли новая архитектура.rawTaskAppend— это переключатель через переменную окружения (по умолчанию 0). Если включён, используется путь

— это новая модель задач, которая сейчас разрабатывается в NCCL.Шаг 2: распределение P2P.p2pTaskAppend:

📎 src/enqueue/enqueue.cc:3343-3345

Если это Send/Recv, вызываетсяШаг 3: распределение RMA.rmaTaskAppend:

📎 src/enqueue/enqueue.cc:3346-3347

Если это PutSignal/Signal/WaitSignal, вызывается if (info->count == 0) return ncclSuccess;Шаг 4: досрочный возврат для пустой коллективной коммуникации.

— коллективная коммуникация с count = 0 просто отбрасывается. ncclCollConfigGetAlgMaskШаг 5: проверка выбора алгоритма.

📎 src/enqueue/enqueue.cc:3357-3358

Проверяется допустимость выбора алгоритма, переданного пользователем:Шаг 6: проверка типа FP8.

📎 src/enqueue/enqueue.cc:3360-3366

Редукция FP8 требует sm90+: hostToDevRedOpШаг 7: преобразование операции редукции.ncclRedOp_tПреобразованиеncclDevRedOpFull:

📎 src/enqueue/enqueue.cc:3370-3371

на стороне host вна стороне устройстваcomm->nRanks == 1Шаг 8: досрочный возврат для одного rank.ncclLaunchOneRankЕсли

📎 src/enqueue/enqueue.cc:3373-3377

, напрямую вызываетсядля выполнения локальной редукции, без создания задачи:

📎 src/enqueue/enqueue.cc:3378-3470

Шаг 9: путь для нескольких rank.

collTaskAppendЭто самая сложная ветка, включающая маршрутизацию CE, понижение AllToAll/Gather/Scatter и обычные коллективные коммуникации:ncclTaskCollСтруктура данных: поля ncclTaskColl

📎 src/enqueue/enqueue.cc:2757-2851

— это место, где создаётся

. Рассмотрим его ключевую логику:Присваивание ключевых полей:Поле
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

AllReduce возвращает 2 (поскольку нужно reduce + broadcast), AllGather/ReduceScatter возвращают nRanks, остальные возвращают 1.ncclInt8. Это оптимизация:Эти две операции не включают редукцию, поэтому не нужно заботиться о типе данных — унифицированная побайтовая обработка упрощает логику ядра。

Производственная ловушка: порядок разбора 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равным true, а EFFICIENCY не удовлетворяет этому условию.

---

Четыре. 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: Агрегация и выбор алгоритма.Для каждой корзины агрегируются задачи близкого размера (в пределах 4 раз), затем вызываетсяncclGetAlgoInfo:

📎 src/enqueue/enqueue.cc:503-547

Шаг 4: Разбиение по корзинам (collnet, nvls).В зависимости от типа алгоритма задачи распределяются вcollBins[2][2]:

📎 src/enqueue/enqueue.cc:517-544

Шаг 5: Сборка итоговой очереди.Четыре корзины объединяются вplanner->collTaskQueue:

📎 src/enqueue/enqueue.cc:553-557

Структура данных: ncclTaskCollSorter

ncclTaskCollSorter— это вставочный сортировщик, сортирующий поtrafficBytes.ncclTaskCollSorterInsertвставляет задачу в правильную позицию,ncclTaskCollSorterDequeueAllпоследовательно извлекает все задачи.

〔Проектные выводы и архитектурные компромиссы〕

Мотивация дизайна этого сортировщика:Приоритет планирования крупных задач. Поскольку крупные задачи имеют длительное время передачи, их ранний запуск позволяет лучше перекрыть вычисления и коммуникацию.

Управление параллелизмом: runtimeConn и установление соединений

📎 src/enqueue/enqueue.cc:572-583

Еслиcomm->runtimeConnистинно (режим соединения во время выполнения), и канал некоторого алгоритма ещё не инициализирован, помечаетсяalgoNeedConnect. Это впоследствии инициирует установление соединения.

Производственная ловушка: граничные условия агрегации

📎 src/enqueue/enqueue.cc:507-508

Условие агрегации —aggEnd->trafficBytes < 4 * aggBeg->trafficBytes, и обе задачи не устанавливаютaggIsolate. Если пользователь установил per-call config (например,maxCTAs),aggIsolateбудет установлен в true, эта задача не будет агрегирована.

Реальный сценарий ловушки: пользователь установил для некоторого AllReducemaxCTAs=4, ожидая, что он будет использовать только 4 CTA. Но из-за логики агрегации эта задача может объединиться с соседней, что приведёт к несоответствию фактического количества используемых CTA ожидаемому. Решение — установитьaggIsolate— NCCL вcollTaskAppendуже обработал это:

📎 src/enqueue/enqueue.cc:2821-2822

---

Пять. scheduleCollTasksToPlan: разбиение на channel и контроль бюджета

Интуитивная модель

scheduleCollTasksToPlan— это "планировщик" модуля enqueue. Он распределяет задачи по конкретным channel и вычисляет разбиение данных для каждого channel. Это как система планирования производства на заводе — определяет, что делает каждая производственная линия и сколько.

Без этого шага GPU-ядро не знало бы, какую часть данных ему обрабатывать.

Пошагово: алгоритм разбиения на channel

📎 src/enqueue/enqueue.cc:644-947

Шаг 1: Оценка бюджета.Сначала оценивается количество задач, которые можно поместить в этот plan:

📎 src/enqueue/enqueue.cc:648-689

ncclTestBudgetПроверяется, не превышает ли объём рабочих байтов бюджет:

📎 src/enqueue/enqueue.cc:343-349

Шаг 2: Вычисление трафика для каждого channel.В зависимости от kind (collnet/nvls) вычисляетсяtrafficPerChannel:

📎 src/enqueue/enqueue.cc:701-707

Шаг 3: Путь Collnet.Если это collnet-алгоритм, распределение channel проще:

📎 src/enqueue/enqueue.cc:709-739

Шаг 4: Разбиение на cell для обычного пути.Это самая сложная часть. NCCL разбивает данные на "cell", где каждый cell — минимальная единица передачи:

📎 src/enqueue/enqueue.cc:740-845

Ключевые переменные:

  • cellSize: количество байтов на cell, минимумMinTrafficPerChannel(32KB)
  • cells: общее количество cell
  • cellsPerChannel: количество cell, обрабатываемых каждым channel
  • cellsLo/cellsHi: количество cell для первого и последнего channel (может быть неполным)

Шаг 5: Вычисление chunkGrains.Для каждого сегмента channel вызываетсяcalcCollChunking:

📎 src/enqueue/enqueue.cc:811-825

Шаг 6: Генерация proxyOp.Для каждого channel генерируется proxy-операция:

📎 src/enqueue/enqueue.cc:844-894

Структура данных: ncclDevWorkColl

ncclDevWorkColl— это дескриптор работы на стороне устройства. Его ключевые поля:

ПолеЗначение
sendbuff/recvbuffУказатель буфера
channelLo/channelHiДиапазон channel
cbd.countLo/countMid/countHiКоличество элементов в каждом сегменте
cbd.chunkGrainsLo/Mid/HiГранулярность chunk для каждого сегмента
directФлаг direct

Управление параллелизмом: битовые операции channelMask

📎 src/enqueue/enqueue.cc:897

Эта строка кода устанавливает channelMask с помощью битовых операций:(2ull << channelHi) - (1ull << channelLo). Например, channelLo=2, channelHi=5, результат —(2<<5) - (1<<2) = 64 - 4 = 60 = 0b111100, то есть биты 2-5 установлены.

Производственная ловушка: переполнение бюджета

📎 src/enqueue/enqueue.cc:792-794

Если бюджета недостаточно, сразу возвращаетсяncclSuccess, позволяя внешнему циклу создать новый plan. Это элегантная стратегия деградации —не выдавать ошибку, а просто обрабатывать пакетами。

Реальный сценарий ловушки: еслиNCCL_WORK_FIFO_BYTESзадано слишком маленьким, каждый plan сможет вместить лишь немного задач, что увеличит число запусков kernel и снизит производительность.

---

Шесть, 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: разместить batch по round-robin.Первый batch каждого channel должен быть размещён вbatchZero[blockIdx.x]:

📎 src/enqueue/enqueue.cc:257-280

Шаг 4: объединить очереди proxyOp.Слияние с сортировкой по opCount:

📎 src/enqueue/enqueue.cc:282-329

Структура данных: ncclDevKernelArgs

ncclDevKernelArgs— это структура параметров, передаваемая в kernel. Она содержит:

  • comm: коммуникатор на стороне устройства
  • 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, разбирает конфигурацию

5. ncclPrepareTasksразбивает по (func, op, datatype) на корзины, вычисляет алгоритм

6. scheduleCollTasksToPlanразбивает channel, генерируетncclDevWorkColl

7. finishPlanупаковывает в параметры kernel

Ключевые идеи дизайна:

  • Многоуровневая развязка: каждая функция делает только одно, передавая состояние черезncclInfoиncclTaskCollКонтроль бюджета
  • : черезуправляется размер каждого planncclTestBudgetОптимизация агрегации
  • : задачи близкого размера агрегируются, что уменьшает число запусков kernelПриоритет конфигурации
  • В следующей главе мы перейдём к:env > per-call > comm

и посмотрим, как NCCL организует порядок выполнения многоканальных и многокернельных операций.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. Если эта проверка перестанет работать, несколько задач будут делить бюджет channel одного plan, что приведёт к несоответствию распределения ресурсов ожиданиям.src/enqueue/enqueue.cc:665-666),taskAggIsolateQ2: В

, еслиncclEnqueueCheckвозвращает ошибку (например, ArgsCheck для какого-то rank не прошёл), но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) есть граничное условие: когдаcellsLo == 0, пропускается минимальное число channel. Если в этой логике пропуска есть баг (например,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;
  }
}

ЕслиchannelIdне увеличивается корректно, следующая задача начнёт распределяться с неправильного channel. Это приведёт к:

1. Перекрытию channel: две задачи могут быть распределены на один и тот же участок данных одного channel

2. Повреждению данных: kernel будет повторно обрабатывать или пропускать данные

3. Снижению производительности:неравномерная нагрузка на каналы

Что ещё более скрыто — такой баг может срабатывать только при определённом размере сообщения (когдаcellsLo == 0), что затрудняет воспроизведение. NCCL отслеживает использованные каналы черезplan->channelMask |= (2ull << devWork->channelHi) - (1ull << devWork->channelLo), но это лишь запись, она не предотвращает перекрытие.

Итак, мы увидели, как ncclAllReduce превращается из пользовательского вызова в цепочку исполняемых задач ядра: проверка параметров, определение алгоритма/протокола, разбиение на каналы, и в итоге генерация ncclInfo и ncclTaskColl. Но создание задач — лишь первый шаг: их ещё нужно распределить по нескольким каналам, сгенерировать параметры запуска ядра и обработать пакетную отправку и упорядочивание зависимостей в семантике группы. В следующей главе мы углубимся в src/enqueue/task_sched и src/enqueue/task_prep, чтобы ответить на вопрос «почему один AllReduce запускает несколько ядер, как гарантируется их порядок и зависимости», а также раскроем, как ncclGroupStart/ncclGroupEnd в src/group.cc объединяют несколько вызовов API в одну отправку.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 07

Глава 7: Планировщик задач и каналы: разделение нагрузки и управление очередями

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 7 из 25

В предыдущей главе мы проследили путь ncclAllReduce вплоть до ncclTaskColl — объект описания задачи уже лежит в comm->planner. Но описание задачи — это лишь «наряд», оно ещё не стало ядром, реально выполняющимся на GPU. В этой главе мы ответим на три вопроса: как несколько вызовов API накапливаются и отправляются вместе? Как накопленные задачи распределяются по нескольким каналам? Чем гарантируются порядок и зависимости между несколькими ядрами? Сначала дадим общую ментальную модель. Представьте NCCL как ресторан: ncclGroupStart/ncclGroupEnd — это «корзина», пользователь бросает в неё несколько блюд (несколько вызовов коллективной коммуникации); ncclGroupEnd — это «оформление заказа», и только тогда кухня начинает готовить по заказу. А doLaunches — это «диспетчер подачи блюд», он решает, какие блюда подать первыми, а какие можно готовить параллельно. Без семантики группы каждое блюдо заказывается отдельно, и кухне приходится заново разжигать огонь (запускать ядро) для каждого блюда, что даёт огромные накладные расходы; без циклического планирования doLaunches ядра нескольких каналов запускались бы в неправильном порядке, что нарушило бы зависимости по данным.

I. Глобальное состояние семантики Group: thread_local переменные и модель «корзины»

Интуитивная модель

ncclGroupStartиncclGroupEndВсе вызовы коммуникации между ними не запускают ядро немедленно, а «накапливаются». Где накапливаются? Накапливаются впотоколокальных (thread_local)глобальных переменных. Почему thread_local? Потому что NCCL предполагает, что вызовы групп внутри одного потока последовательны, у разных потоков свои независимые корзины, которые не мешают друг другу. Если бы это состояние было глобальными переменными, а не thread_local, два потока, одновременно вызывающиеncclGroupStart, затаптывали бы друг друга, что привело бы к отправке задач одного потокаncclGroupEndдругого потока — это катастрофично.

Структуры данных и размещение в памяти

Сначала посмотрим на определение глобального состояния группы.

📎 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увеличивает на единицу,ncclGroupEndуменьшает на единицу. Реальная отправка происходит только когда счётчик доходит до 0. Это как вложенные корзины — вы открываете подкорзину внутри корзины, и реальный заказ оформляется только при расчёте на самом внешнем уровне.
  • ncclGroupError: если любая из вызовов внутри группы завершается ошибкой, ошибка записывается здесь,ncclGroupEndобрабатывается единообразно. Это позволяет избежать несогласованного состояния, когда «после неудачи одного вызова последующие вызовы продолжают добавлять вещи в корзину».
  • ncclGroupCommHead[ncclGroupTaskTypeNum]: головы связанных списков коммуникационных доменов, сгруппированных по типу задачи.ncclGroupTaskTypeNum— это количество типов задач (коллективная коммуникация, примитивные задачи, управляющие задачи, симметричная регистрация и т.д.). Для каждого типа — свой связанный список, узлами списка являютсяncclComm, связанные черезcomm->groupNext[type]. Почему по типам? Потому что у разных типов задач разное время отправки и разные зависимости — задачи коллективной коммуникации требуют предварительного preconnect, управляющие задачи (например, destroy) должны выполняться последними.
  • ncclGroupCommPreconnectHead: связанный список коммуникационных доменов, требующих предварительного подключения. Предварительное подключение — это «заранее установить сетевые соединения», чтобы избежать задержки из-за установки соединения в момент запуска ядра.
  • ncclAsyncJobs: очередь асинхронных задач. Некоторые задачи (например,ncclCommInitRank) асинхронны, они помещаются в эту очередь и единообразно запускаются во времяncclGroupEnd.
  • ncclGroupBlocking: флаг режима блокировки.-1означает, что ещё не определено,0означает неблокирующий,1обозначает блокировку. В одной группе не допускается смешивание блокирующих и неблокирующих коммуникационных доменов, иначе будет ошибка.

Здесь есть ключевой дизайн:ncclGroupCommHead— этомассив, каждый элемент — это связный список. Узлы списка связываются черезcomm->groupNext[type], а не через отдельную структуру узла списка. Это означает, что в структуреncclCommдолжно быть зарезервировано поле массиваgroupNext. Такой дизайн «интрузивного связного списка» позволяет избежать дополнительного выделения памяти, но ценой является увеличение размера структурыncclComm.

Пошаговое прохождение, управляемое сценарием

Сценарий: пользователь вызываетncclGroupStart(), затем дважды подряд вызываетncclAllReduce(для двух разных коммуникационных доменов commA и commB), и наконец вызываетncclGroupEnd()。

Шаг первый:ncclGroupStartчто делает?

📎 src/include/group.h:63-66

cpp
inline ncclResult_t ncclGroupStartInternal() {
  ncclGroupDepth++;
  return ncclSuccess;
}

Чрезвычайно просто: глубина увеличивается на единицу. Нет выделения памяти, нет блокировок, нет системных вызовов. Именно поэтомуncclGroupStartпрактически не имеет накладных расходов.

Шаг второй:ncclAllReduceчто происходит при вызове внутри группы?

ncclAllReduceвнутри вызывает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для одного и того же comm, второй раз он не будет повторно добавлен в связный список, но задача будет добавлена вcomm->planner.

2. Сортировка clique:intraComm0— это идентификатор «глобальной сущности». Если несколько коммуникационных доменов принадлежат одной глобальной сущности (например, получены путём разделения черезncclCommSplit), ихintraComm0совпадают, и они называются clique. Код сначала поintraComm0находит clique и вставляет comm рядом с соседними узлами того же clique. Если clique не найден, вставка выполняется по возрастаниюcommHash. Эта сортировка нужна для того, чтобыdoLaunchesмог корректно обрабатывать barrier-синхронизацию внутри clique.

3. Область памяти стека:ncclMemoryStackPush(&comm->memScoped)выделяет для этого comm новую область памяти стека внутри группы. Все задачи, выделенные для этого comm (ncclTaskCollи т. д.), выделяются из этого стека.ncclGroupCommLeaveпри вызовеncclMemoryStackPopосвобождает всю память задач за один раз — это классическая оптимизация «пакетное выделение, пакетное освобождение», позволяющая избежать накладных расходов на отдельныйmalloc/freeдля каждой задачи.

4. Сброс planner:memset(&comm->planner, 0, sizeof(comm->planner))очищает planner, но сохраняет указателиpeersиrmaTaskQueues(сначала сохраняются во временные переменные, после memset восстанавливаются). Зачем сохранять? Потому что это предварительно выделенные массивы, и их не нужно каждый раз выделять заново.bcast_infomin/max сбрасываются вINT_MAX/INT_MIN, что используется для последующей оптимизации объединения 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;

Сначала проверяется глубина, затем уменьшается на единицу. Если после уменьшения она всё ещё больше 0, значит, мы всё ещё находимся во вложенной внутренней группе, и происходит прямой возврат без отправки. Продолжение выполняется только при уменьшении до 0.

📎 src/group.cc:1063

cpp
if ((ret = ncclGroupError) != ncclSuccess) goto fail;

Если хотя бы один вызов внутри группы завершился ошибкой, происходит прямой переход к очистке 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 «переносится» в объект 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;
}

Почему смешивание не допускается? Потому что блокирующая группа выполняется синхронно в текущем потоке, а неблокирующая группа — асинхронно в отдельном потоке. При смешивании невозможно определить,ncclGroupEndдолжен вернуться синхронно или вернутьncclInProgress. В продакшене, если пользователь случайно поместит блокирующий и неблокирующий comm в одну группу, он получитncclInvalidArgument, но к этому моменту состояние группы уже загрязнено, и необходимо зановоncclGroupStart。

Подводный камень 2:ncclGroupErrorраспространение. Если какой-либо вызов внутри группы завершается неудачей,ncclGroupErrorустанавливается,ncclGroupEndпереходит в ветку fail и выполняетgroupCleanup。groupCleanupпроходит по всем comm, освобождает память plan в planner, сбрасывает planner, очищает rawTaskQueue. Если этот шаг выполнен не полностью, при следующемncclGroupStartв planner останутся старые данные, что приведёт к повторной отправке задач или утечке памяти.

📎 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.
        // ...
      }
      // ...
    }
  }
  // ...
}

Обратите внимание на строкуcomm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1). Это «сторожевое значение», означающее «этот comm нужно переподключить через preconnect». Почему? Потому что при cleanup неизвестно, был ли preconnect успешным, поэтому принудительно заставляем проверить заново в следующий раз.0x1это значение очень хитрое — оно не является допустимым указателем, но может использоваться как маркер «неинициализировано».ncclGroupCommPreconnectпроверяетif (comm->preconnectNext == reinterpret_cast<struct ncclComm*>(0x1)), чтобы определить, нужно ли добавлять в связный список preconnect.

---

Во-вторых, подготовка задач:ncclPrepareTasksкак превратить описание задачи в планируемую единицу

Интуитивная модель

ncclPrepareTasksЭто этап «подготовки ингредиентов». Блюда в корзине (описание задачи) ещё сырые — их нужно сначала помыть, нарезать и подготовить (определить алгоритм, протокол, разбиение по channel), прежде чем ставить на плиту (запускать kernel). Если пропустить этот шаг и сразу запустить kernel, kernel не будет знать, как разбивать данные и по какому пути идти, и сразу упадёт.

Пошаговое прохождение на основе сценариев

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чтобы добавить задачу в 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);

Комментарий говорит ясно:preconnect выполняется по одному clique за раз, чтобы избежать гонки, когда split shared comms одновременно подключаются к одной и той же группе соединений. Если два comm были split из одного родительского comm, они могут разделять некоторые соединения. При параллельном 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устанавливается, и для всех последующих jobabortFlagатомарно устанавливается в 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

---

Три,doLaunches: циклическое планирование для нескольких channel и нескольких kernel

Интуитивная модель

doLaunches— это «диспетчер подачи блюд». На кухне (GPU) есть несколько плит (channel), и каждое блюдо (kernel plan) нужно подавать по порядку. Но блюда разных comm могут подаваться параллельно, а блюда одного comm должны подаваться строго по порядку. Диспетчер должен гарантировать: comm внутри одного clique продвигаются синхронно (через 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;
}

Пошаговое прохождение на основе сценариев

Сценарий: два comm (commA и commB) принадлежат одному clique (intraComm0одинаков), у каждого comm есть 3 kernel plan, ожидающих запуска.

Первый уровень цикла: обход clique

Внешнийdo-whileобходит все clique.cliqueHead— это первый comm текущего clique. Внутреннийdo-whileобходит все comm внутри clique (comm->intraComm0 == cliqueHead->intraComm0)。

Для каждого comm:

  • cudaSetDevice(comm->cudaDev): переключиться на GPU, соответствующий этому comm.
  • ncclLaunchPrepare(comm): подготовиться к запуску, включая настройку CUDA-потока, проверку ресурсов и т. д.
  • ncclCommIntraBarrierIn(comm, 1): войти в barrier с начальным значением 1.

Второй уровень цикла: циклическое планирование

while (true)Цикл выполняет «раунды». В каждом раунде каждый comm внутри clique запускает один kernel plan.

Ключ в вычисленииmoreRounds:

  • Есть режим с barrier(useBarrier == true):moreRounds = 0 != ncclCommIntraBarrierOut(comm)。ncclCommIntraBarrierOut— этооперация редукции barrier между comm. Она ждёт, пока все comm внутри clique вызовутncclCommIntraBarrierIn, затем возвращает результат редукции всех входных значений (здесь — логическое ИЛИ). Если у любого 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? Потому что comm внутри clique — это «братья», они могут совместно использовать ресурсы GPU или сетевые соединения. Если один comm запустил 3 kernel, а другой — только 1, то comm, завершивший запуск раньше, войдёт вncclLaunchFinish, освободит ресурсы, а другой comm всё ещё использует эти ресурсы, что приведёт к use-after-free. Barrier гарантирует, что все comm внутри clique продвигаются синхронно: либо все запускают 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 (использование сетевой карты для разгрузки при коллективной коммуникации).
  • 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— это примитив синхронизации comm внутри clique. Его реализация включает атомарные операции и активное ожидание.Inзаписывает значение в разделяемую память,Outожидает, пока все comm выполнят запись, затем считывает результат редукции. Этот barrier являетсямежпроцессным(если comm находятся в разных процессах), в основе может использоваться разделяемая память или сеть.

Почему используется barrier, а не простая «проверка, есть ли у всех comm ещё plan»? Потому что «проверка» неатомарна: когда commA проверяет, у commB ещё есть plan, commA решает продолжить; но commB сразу после проверки commA запускает последний plan и переходит в final round. CommA всё ещё запускает kernel, а commB уже освободил разделяемые ресурсы. Barrier превращает «проверку» и «решение» в одну атомарную операцию, устраняя эту гонку.

Руководство по избежанию проблем в production

Проблема 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;
}

Если часть comm внутри clique находится в режиме CUDA graph capture, а другая часть — нет, сразу возникает ошибка. В комментарии сказано «these comms are permanently trashed» — потому что они уже вошли в barrier, но не вышли, состояние barrier этих comm навсегда остаётся несогласованным, и в дальнейшем их нельзя использовать. Этонеисправимая ошибка, пользователь должен пересоздать коммуникационный домен. В production, если пользователь смешивает comm с graph capture и без него, он получитncclInvalidUsage, но более серьёзно то, что comm уже повреждён.

Проблема 2:useBarrierзависимость конфигурации。useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup. Если пользователь установилNCCL_LAUNCH_MODE=GROUP, используется путь с barrier; иначе используется путь без barrier. На пути без barriermoreRoundsнакапливается с помощью|=, но каждый comm принимает решение независимо. Если у commA ещё есть plan, а у commB нет, commB перейдёт в final round и выполнитncclLaunchFinish, тогда как commA всё ещё запускает kernel. В некоторых сценариях это безопасно (между comm нет разделяемых ресурсов), но если совместно используются proxy-потоки или сетевые соединения, это может привести к проблемам. Поэтому по умолчанию рекомендуется режим с barrier.

---

Четыре:groupLaunchLegacyполная цепочка выполнения

Пошаговое руководство, управляемое сценариями

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);

Для каждого comm, которому нужен preconnect, создаётся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 один раз. Это делается для того, чтобы избежать накладных расходов на опрос callbacks при каждом group, и в то же время гарантировать, что 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

---

Пять,groupLaunchEnqueueRearch: планировщик новой архитектуры

Интуитивная модель

groupLaunchEnqueueRearch— это новая архитектура планирования, разрабатываемая в NCCL. Она разделяет подготовку задач, планирование и запуск на более детальные этапы, управляемые через асинхронную очередь job. В настоящее время модули планировщика и лаунчера «ещё не реализованы», происходит откат к legacydoLaunches。

📎 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. Это показывает, что новая архитектура предъявляет более высокие требования к корректности конечного автомата.

Размышления о дизайне

Мотивация новой архитектуры —развязка: legacygroupLaunchLegacyобъединяет все этапы в одной функции, что затрудняет поддержку и расширение. Новая архитектура разбивает каждый этап на независимые типы 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. В production рекомендуется оставлять значение по умолчанию (legacy), так как новая архитектура всё ещё в разработке.

---

Шесть, неблокирующий 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отслеживает, сколько comm связано с этим group job. Каждый 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, досрочно завершается. Это «кооперативная отмена» — не принудительное убийство потока, а позволение потоку самому проверить флаг и выйти.

Руководство по избеганию проблем в production

Проблема 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выполняет preconnect по одному clique, избегая гонок при split comms.

3. Планирование раундов:doLaunchesгруппирует по clique, синхронизирует comm внутри clique через barrier, в каждом раунде запускается один kernel plan, пока все plan не будут запущены.

4. Асинхронные задачи:asyncJobLaunchуправляет асинхронными job через атомарный конечный автомат и busy-wait, поддерживает быстрый отказ и abort.

5. Новая архитектура:groupLaunchEnqueueRearch— это разрабатываемый новый фреймворк планирования, в настоящее время происходит откат к legacydoLaunches。

В следующей главе мы перейдём к последней миле запуска kernel:ncclLaunchKernelкак превратитьncclKernelPlanв реально исполняемый на GPU kernel, и как device-side читаетDevCommметаданные.

Вопросы для размышления и самопроверки к этой главе

Q1: Если изncclGroupCommJoinубратьncclMemoryStackPush(&comm->memScoped), что произойдёт? В каких сценариях это приведёт к утечке памяти или повреждению данных?

Справочный разбор:ncclMemoryStackPushдля comm в group

На этом описание задачи превратилось в исполняемый план запуска: семантика group объединяет несколько вызовов API в одну отправку, разбиение по channel распределяет задачи по нескольким потокам выполнения, а планирование раундов doLaunches обеспечивает порядок и зависимости между kernel. Но план — это всего лишь план. Как описание задачи на стороне host превращается в grid на GPU? В следующей главе мы углубимся в ncclLaunchKernel, рассмотрим подготовку параметров, выбор варианта kernel и вызов cudaLaunchKernel, завершив последний прыжок с host на device.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 08

Глава 8: Запуск ядер и выполнение на GPU: координация нитей и аппаратные ресурсы

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 8 из 25

В предыдущей главе мы разобрали, как задачи разбиваются на несколько channel, как генерируются параметры запуска kernel и как работает механизм пакетной отправки и упорядочивания зависимостей в семантике group. Теперь план запуска готов, но это всё ещё только структура данных на стороне host. Ключевой вопрос этой главы:ncclKernelPlanКак это превращается в реально работающий grid на GPU? Мы пройдём по цепочке вызововncclLaunchKernel, посмотрим, как параметры помещаются в kernel args, как выбирается вариант kernel,cuLaunchKernelExкак вызываетсяncclKernelMain, а также как на стороне устройства

считывает описание работы из разделяемой памяти и распределяет его по конкретным реализациям.

От плана к grid: полная картина пути запускаncclKernelPlanПрежде чем углубляться в детали, построим общую ментальную модель. ПредставимncclLaunchKernelкак «чертёж строительства»: он фиксирует, сколько channel (сколько block) нужно запустить, сколько потоков в каждом block, какие work выполнять и какую функцию kernel использовать. АCUlaunchConfig— это действие «входа строительной бригады»: он переводит информацию с чертежа в понятный драйверу CUDAcuLaunchKernelEx, а затем вызывает

, чтобы действительно отправить grid на GPU.

Без этого слоя вся диспетчеризация на стороне host (разбиение по channel, организация batch, упорядочивание proxy op из предыдущей главы) осталась бы лишь теорией, на GPU не запустился бы ни один kernel, и коммуникация никогда бы не произошла. Это последнее звено сквозного магистрального пути и граница между host и device.

1. Весь путь запуска можно обобщить тремя этапами:(finishPlan + uploadWorkПодготовка параметров

2. ): организация структур work, дескрипторов batch и kernel args в один непрерывный блок памяти, решение о том, размещать ли их в параметрах kernel, в FIFO или в персистентном буфере.(ncclLaunchKernelЗапуск kernelcuLaunchKernelEx。

3. ): вычисление размерностей grid/block, сборка атрибутов запуска (CGA cluster, mem sync domain, launch completion event), вызов(ncclKernelMainТочка входа на стороне устройстваblockIdx.x): каждый block на основеncclDevFuncTableопределяет свой channelId, загружает work batch из args или FIFO в разделяемую память, а затем через

распределяет его по конкретной реализации алгоритма/протокола.

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 (по одной на каждую коллективную или p2p-операцию) и должен решить: поместить эти work в «рюкзак» параметров kernel, на «конвейер» FIFO или на «склад» персистентного буфера?

Если это решение принято неверно — например, work слишком велик, чтобы поместиться в параметры kernel, но его всё равно туда запихивают — запуск kernel сразу завершится ошибкой. Если work размещён не в том месте, устройство прочитает мусорные данные, и результат коммуникации будет полностью неверным.ncclDevKernelArgsСтруктуры данных и разметка памяти

📎 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 数组
};

структуруchannelMask— это «конверт» между host и device:__popcllКопироватьblockIdx.xВ этой структуре всего 5 полей, но каждое несёт ключевую информацию.workStorageType— это 64-битная маска, каждый бит которой соответствует одному channel; на стороне устройства черезArgsвычисляетсяFifoсоответствующий channelId.Persistentопределяет, откуда на стороне устройства читается work:

ncclDevWorkBatch— это дескриптор batch, который сообщает устройству, «где находится работа этого channel и сколько её»:

📎 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
};

offsetBitset— это 64-битная маска, каждый бит которой соответствует одной структуре 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 кладётся напрямую в параметры kernel. Иначе work помещается в FIFO или persistent-буфер, а в параметрах kernel остаётся только дескриптор batch.

〔Проектные предположения и архитектурные компромиссы〕

Почему предпочтительно размещать в параметрах kernel? Потому что параметры kernel в драйвере 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);
}

Логика этого кода — «round-robin»: на каждом раунде из каждого channel, где ещё есть batch, берётся один batch и в порядке возрастания номера channel помещается в массивbatchZero. Цель этого — гарантировать, что «первый batch каждого channel находится вbatchZero[blockIdx.x]» — каждый block на стороне устройства черезblockIdx.xнапрямую индексирует свой первый batch без поиска.

nextJumpПолеbatchIx += batch.nextJumpхранит смещение следующего batch того же channel относительно текущего batch. Устройство через

может перейти к следующему batch, образуя связный список.uploadWorkТретий шаг:

📎 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; для типаPersistentэто смещение относительно базового адреса FIFO; для типа

2. offsetBaseоно начинается с 0.:finishPlanКоррекцияoffsetBase:uploadWorkв batchArgsзадаётся относительно начала work в plan (с 0).sizeof(ncclDevKernelArgs) + batchBytesнужно преобразовать в смещение относительно фактического места хранения. Для типаFifoдобавляетсяcomm->workFifoProduced。

3. ; для типадобавляетсяalignas(16)16-байтовое выравнивание при копированииCOMPILER_ASSUME_ALIGNED: структуры work выровнены по 16 байт (

4. ), поэтому копирование выполняется блоками по 16 байт.сообщает компилятору, что этот адрес выровнен по 16 байт, чтобы компилятор генерировал более эффективные векторизованные инструкции.FifoОжидание FIFOwaitWorkFifoAvailable: для типаcomm->abortFlag,

будет в цикле ожидать, пока в FIFO появится достаточно места. Это ожидание проверяет

, чтобы избежать взаимоблокировки при abort.

Проектные соображения и подводные камни в production〔Проектные предположения и архитектурные компромиссы〕

  • ArgsПочему существуют три типа хранения?
  • FifoЭто компромисс между объёмом и задержкой:
  • Persistent: самый быстрый (constant memory), но ограниченный по объёму (4KB). Подходит для небольших сообщений и малого числа work.cudaMemcpy: большой объём (кольцевой буфер), но чтение на стороне устройства идёт через глобальную память. Подходит для сообщений среднего размера.

: используется в сценариях захвата CUDA Graph. Поскольку при захвате graph нельзя выполнять, нужно заранее выделить persistent-буфер, скопировать туда work, а затем заставить kernel читать оттуда.waitWorkFifoAvailableПодводный камень 1: переполнение FIFO приводит к взаимоблокировке.abortFlagЕсли📎 src/enqueue/enqueue.cc:1333-1349не проверяет

c
if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
  return ncclInternalError;
}

явно проверяет abort flag:offsetBitsetкопирование offsetBitsetПодводный камень 2:1ull << (offset / workSize)переполнение.NCCL_MAX_DEV_WORK_BATCH_BYTESявляется 64-битным и поддерживает максимум 64 work в одном batch. Если их больше 64,ncclDevWorkCollпроизойдёт переполнение. В исходном коде через

ограничивается размер batch (1024 байта), а минимальная структура work —(около 80 байт), поэтому максимум 12 work, переполнения не будет.uploadWorkПодводный камень 3: утечка памяти в persistent-режиме.PersistentВ веткеfifoBufHostдляncclOsAlignedAlloc,uploadWork_cleanup_fnвыделяется черезcudaMemcpyAsyncи должен освобождаться вfail. Еслиcleanupзавершается неудачно, меткаfifoBufHostпроверяет, равен ли📎 src/enqueue/enqueue.cc:1483-1485null, и если null, сразу освобождает

. Эту цепочку восстановления после ошибок можно увидеть в

Запуск kernel: от CUlaunchConfig до cuLaunchKernelEx

ncclLaunchKernelиграет роль, аналогичную «пульту управления запуском ракеты». Он принимает plan с уже загруженным топливом (данными work), вычисляет параметры полёта ракеты (размерности grid/block), настраивает различные опции запуска (cluster, mem sync domain, completion event), а затем нажимает кнопку запуска (cuLaunchKernelEx)。

Если на этом этапе происходит ошибка — например, неверно вычислена размерность grid — на GPU будет запущено неправильное количество блоков, что приведёт к тому, что работа части каналов никогда не будет выполнена, и коммуникация зависнет.

Структуры данных и разметка памяти

CUlaunchConfig— это структура конфигурации запуска CUDA Driver 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. Каждый блок отвечает за один channel.threadPerBlockвычисляется вscheduleCollTasksToPlanчерезplan->threadPerBlock = std::max(plan->threadPerBlock, task->nWarps * WARP_SIZE), берётся максимальное значениеnWarps * 32。

smemсреди всех task — это размер динамической разделяемой памяти. Для обычного 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 Driver 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, позволяющая объединить несколько блоков в один cluster; блоки внутри cluster гарантированно одновременно планируются на одну группу SM и могут обращаться к разделяемой памяти друг друга. NCCL использует эту возможность для реализации алгоритмов, требующих межблочной синхронизации, таких как NVLS.

Обратите внимание на защиту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— это новый API, появившийся в CUDA 12.0, поддерживающий launch attributes. Для старых драйверов (< 11.8) NCCL откатывается к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);
}

Управление конкурентностью и взаимодействие с аппаратурой

Механизм relay для Launch completion event.Когда используетсяncclImplicitOrderLaunchи пользователь предоставилlaunchCompletionEvent, NCCL не может напрямую передать пользовательское event драйверу, поскольку драйвер поддерживает только один launch completion event. NCCL поступает следующим образом:

1. Передаётcomm->sharedRes->launchEventдрайверу.

2. Ожидает наrelayStreamсобытияlaunchEvent。

3. Записывает пользовательское event наrelayStream.

Таким образом, пользовательское event сработает после того, как kernel действительно начнёт выполняться, а не когда host-сторона вернётся из вызова.

Mem Sync Domain。 📎 src/enqueue/enqueue.cc:1938-1942На sm90+ NCCL устанавливаетCU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAINвcudaLaunchMemSyncDomainRemote. Это механизм доменов синхронизации памяти, введённый в архитектуре Hopper, предназначенный для изоляции барьеров памяти разных kernel и снижения избыточных накладных расходов на синхронизацию.

Руководство по подводным камням в продакшене

Подводный камень 1: размерность cluster не делится нацело, что приводит к сбою запуска.Еслиgrid.xне делится нацело наclusterSize, драйвер вернётCUDA_ERROR_INVALID_VALUE. В исходном коде предусмотрена защита черезif (grid.x % clusterSize) clusterSize = 1;, но это также означает, что возможность cluster молча отключается. Если пользователь ожидает повышения производительности от cluster, необходимо проверить соотношениеcgaClusterSizeиnChannels.

Подводный камень 2: несоответствие версии драйвера приводит к недоступности kernel. ncclInitKernelsForDeviceпроверяет требования к драйверу для каждого ядра при инициализации:

📎 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;
  }

Если версия драйвера недостаточна, указатель ядра будет установлен в null. Впоследствии, если планировщик выберет это ядро,cuLaunchKernelExпроизойдёт сбой. Тюнер NCCL должен избегать выбора недоступных ядер, но если пользователь принудительно указал алгоритм (NCCL_ALGO), это может вызвать эту проблему.

Подводный камень 3:launchCompletionEventповедение на старых драйверах.Если версия драйвера < 12.3, NCCL записывает событие перед запуском ядра, что означает, что событие сработает до начала выполнения ядра, а не в момент фактического начала выполнения ядра. Это может привести к нарушению предположений о временных характеристиках в пользовательском коде.

Точка входа на стороне устройства: от blockIdx к конкретной реализации

Интуитивная модель

ncclKernelMain— это "входной зал" для каждого блока на GPU. Когда блок планируется на SM и начинает выполнение, он сначала попадает в этот зал и выполняет три вещи: определяет свою идентичность (какой я канал), получает свою задачу (загружает пакет работы), а затем идёт в соответствующее окно для выполнения дела (вызывает конкретную реализацию алгоритма).

Без этой точки входа каждый вариант ядра должен был бы сам обрабатывать вопросы "кто я и что мне делать", что привело бы к массовому дублированию кода.ncclKernelMainЧерез параметры шаблонаSpecializedFnIdиSpecializedRunWorkBatchреализует паттерн "универсальная точка входа + специализированное выполнение".

Структуры данных и layout памяти

Layout разделяемой памяти на стороне устройства — ключ к пониманиюncclKernelMain.ncclShmemData— это "рабочий стол", общий для всех блоков:

📎 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;
};

Layout этой структуры тщательно продуман:

  • argsразмещён в самом начале, потому что он копируется из параметров ядра и требует 16-байтового выравнивания.
  • commиchannelтакже выровнены по 16 байт, потому что они копируются черезcopyToShmem16с использованием векторных инструкций.
  • workStorage— это временная область хранения для структур work, размер которой равенncclMaxDevWorkBatchBytes()(для sm90+ это 16KB).
  • groupsмассив используется для хранения информации о соединениях каждой группы,NCCL_MAX_GROUPSравно 16.

Step-by-Step Walkthrough

Шаг первый: копирование аргументов ядра в разделяемую память.

📎 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-битное слово. Зачем копировать в разделяемую память? Потому что параметры ядра находятся в константной памяти, доступ к которой хоть и быстр, но при обращении каждого потока возникает накладные расходы на широковещательную рассылку. После копирования в разделяемую память все потоки обращаются к одному и тому же блоку разделяемой памяти, что эффективнее.

Шаг второй: определение 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();

Логика этого кода такова: для каждого установленного канала (args->channelMask & (1ull << tid)) вычисляется, сколько установленных каналов идёт перед ним (__popcll), и если это количество равноblockIdx.x, то текущий блок отвечает за этот канал.

Например:channelMask = 0b1011(каналы 0, 1, 3 имеют работу).blockIdx.x = 0блок отвечает за канал 0 (перед ним 0 установленных),blockIdx.x = 1блок отвечает за канал 1 (перед ним 1 установленный),blockIdx.x = 2блок отвечает за канал 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: загружаетncclDevChannelтекущего канала (метаданные канала) в разделяемую память.
  • Остальные warp: загружают пакет работы в разделяемую память.

copyToShmem16— это функция 16-байтового копирования, реализованная с помощью inline PTX:

📎 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-битное).

Шаг четвёртый: загрузка пакета работы.

loadWorkBatchToShmem— самая сложная часть. Его задача — скопировать структуры work, на которые указывает дескриптор пакета, из глобальной памяти (или параметров ядра) в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: для n-го установленного бита вoffsetBitsetкаков его битовый индекс. В 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, и компилятор распознаёт, что это чтение из параметров ядра, и генерирует инструкцию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совпадает с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()составляетnWorks * workSize, для sm90+ — 16KB. ЕслиNCCL_MAX_DEV_WORK_BATCH_BYTESПодводный камень 2:

на стороне host ограничивается размер batch, но на стороне устройства дополнительной проверки нет. Если ограничение на стороне host будет обойдено (например, путём изменения переменной окружения), это приведёт к выходу за границы разделяемой памяти.__syncthreads()отсутствиеприводит к гонке данных.loadWorkBatchToShmemПосле__syncthreads()должен бытьworkStorageчтобы все потоки увидели полный📎 src/device/common.h:479. В исходном коде в__syncthreads(); // publish ncclShmemестьworkStorage. Если эту синхронизацию убрать, некоторые потоки могут начать чтение до того, как

Подводный камень 3: момент проверки abort. while (ncclShmem.aborted == 0)проверяет abort только в начале каждого batch. Если какой-то batch выполняется долго, сигнал abort может вступить в силу нескоро. Это компромисс в дизайне: более частые проверки увеличивают накладные расходы, но обеспечивают более быстрый отклик.

Выбор варианта kernel: как generate.py генерирует список kernel

Интуитивная модель

generate.pyРоль

Если для каждой комбинации генерировать отдельный kernel, время компиляции и размер бинарного файла взорвутся. Если генерировать только один универсальный kernel, во время выполнения всё замедлится из-за вызовов через указатели на функции и ветвлений.generate.pyРешение

Структуры данных и layout памяти

generate.pyгенерирует три ключевых файла:

1. device_table.cu: на стороне устройстваncclDevFuncTable, отображающий funcId на конкретную функцию устройства.

2. host_table.cc: на стороне hostncclDevKernelList、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, компиляция каждого занимает несколько секунд, в сумме — несколько часов. К тому же размер бинарного файла достигнет нескольких сотен 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 нужны три набора примитивов перемещения данных, а также в чём их различия в способах синхронизации, layout буферов и семантике flag.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 09

Глава 9: Примитивы устройств: низкоуровневые протоколы LL, LL128 и Simple

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 9 из 25

В предыдущей главе мы проследили, как на стороне хоста один AllReduce транслируется в __global__ kernel, и увидели, что точка входа на стороне устройства 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*указывает на базовый адрес FIFO-буфера каждого peer
recvConnHeadPtrvolatile uint64_t*глобальный указатель на стороне приёма «до какого шага уже потреблено»
sendConnHeadPtrvolatile uint64_t*глобальный указатель на стороне отправки «до какого шага уже потребил удалённый peer»
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проверка, не был ли выполнен abort📎 src/device/prims_ll.h:73-89。

Шаг второй: загрузка локальных данных. DataLoader::loadBeginобрабатывает проблему выравнивания📎 src/device/prims_ll.h:200-216. Когдаsizeof(T) <= 2(например, half или int8), исходный адрес может быть не выровнен по 4 байта, поэтому сначала выполняется чтение с выравниванием по 4 байта вu4[0..2], запоминаетсяmisalign, а затем вloadFinishс помощью__funnelshift_rвыполняется побайтовый сдвиг и собирается правильное 64-битное значение📎 src/device/prims_ll.h:218-225. Это типичный приём «выровненное чтение + пересборка сдвигом», позволяющий избежать штрафа за невыровненный доступ.

Шаг третий: чтение данных удалённой стороны и ожидание flag. readLL— это ядро📎 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— это номер barrier; NCCL использует разные номера barrier для изоляции разных групп, чтобы избежать взаимных помех.

checkAbort— ключ к защите от бесконечного цикла📎 src/device/primitives.h:154-164: каждыеNCCL_SPINS_BEFORE_CHECK_ABORT(10000) итераций спина выполняется одно чтениеabortFlag, чтобы избежать замедления горячего пути из-за частого чтения глобальной памяти. Как только обнаружен abort, устанавливаетсяncclShmem.abortedи кэшируется, все последующие циклы ожидания будут быстро завершаться.

Производственные подводные камни

Подводный камень 1: ложная готовность из-за переполнения flag.ЕслиNCCL_LL_CLEAN_MASKлогика cleanup удалена, то после длительной работы (step превышает период mask) принимающая сторона может прочитать остаточный flag предыдущего раунда, ошибочно решить, что данные готовы, и прочитать грязные данные. Такие баги крайне трудно воспроизвести, потому что они зависят от того, что step случайно переполнится до определённого значения.

Подводный камень 2:MaxRecv == 0ловушка компиляции.В кодеMaxRecv = Fan::MaxRecv > 1 ? Fan::MaxRecv : 1 📎 src/device/prims_ll.h:13, потому что даже если только отправлять и не принимать, всё равно выделяется буфер приёма длиной MaxRecv; если MaxRecv равен 0, это приведёт к ошибке компиляции массива нулевой длины. На WindowsMaxSendобрабатывается так же📎 src/device/prims_ll.h:14-19。

LL128: выравнивание по 128 байт в обмен на более высокую полезную нагрузку

Интуитивная модель

Болевая точка LL — полезная нагрузка составляет всего 50% (из 16 байт 8 байт — это flag). Идея LL128:сконцентрировать flag в последних 8 байтах каждых 128 байт, а первые 120 байт целиком отдать данным. Так полезная нагрузка повышается с 50% до 93.75%. Цена — необходимо гарантировать выравнивание по 128 байт, иначе придётся делать «переупаковку через shared memory».

Структуры данных и раскладка памяти

Единица пересылки в LL128 —uint64_t(8 байт), но организована в «line» по 128 байт.NCCL_LL128_LINEELEMS— это число 64-битных элементов на line (16),NCCL_LL128_DATAELEMS— число элементов данных среди них (15), последний элемент хранит flag.

Ключевые константы📎 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));

WireWordPerSlice— это число 64-битных слов, пересылаемых одним warp за раз,DataEltPerSlice— число элементов полезных данных среди них (минус один элемент flag на line).

Механизм flag в LL128 отличается от LL:только 7-й из каждых 8 потоков (flagThread) отвечает за проверку flag 📎 src/device/prims_ll128.h:373。flagThread = ((tid % 8) == 7). Почему? Потому что flag — один на каждые 128 байт, а в warp 32 потока, каждые 8 потоков обрабатывают 128 байт (8 потоков × 16 байт = 128 байт), поэтому из каждых 8 потоков только 1 должен читать flag.

Сценарный Walkthrough: один recvReduceSendCopy

Цепочка вызовов: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。

Первый шаг: загрузка локальных данных в регистры. loadRegsBeginЕсть два случая📎 src/device/prims_ll128.h:99-142:

  • выравнивание по 16 байт: напрямуюload128в регистры, без промежуточной передачи через shared memory. Обратите внимание, чтоflagThreadзагружает только половину данных (g % 2 == 0), потому что другая половина его регистров должна быть зарезервирована под flag📎 src/device/prims_ll128.h:109-114。
  • невыровненный случай: сначала выровненная область загружается в shared memoryncclScratchForWarp(warpInBlock),__syncwarp(), затем из shared memory считывается обратно в регистры по правильному смещению📎 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проверяет flag, затем с помощью__any_syncвыполняется голосование на уровне warp — как только один flagThread обнаружит несовпадение flag, весь warp продолжает спин. Это экономит инструкции по сравнению с проверкой flag каждым потоком.

Третий шаг: перестановка регистров. loadRegsFinishПереместить регистр flag из flagThread в свободный регистр📎 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.

Управление конкурентностью и взаимодействие с аппаратным обеспечением

В LL128barrier()всегда использует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-буфер (ячейку), затем обновляет указатель step «положено в ячейку N» (отправляет уведомление); получатель опрашивает указатель 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. postPeerОбновление указателя step📎 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 буфер отправки напрямую доступен сетевой карте, и возврат возможен только после подтверждения proxy-потоком факта отправки (size устанавливается в -1), иначе следующий kernel может перезаписать данные, которые в данный момент читаются сетевой картой.

Ловушка 2: взаимоблокировка sendrecv в DirectRead.В деструкторе есть ещё фрагмент📎 src/device/prims_simple.h:814-824:

cpp
if ((flags & DirectRead) && (flags & RoleWaitSend) && P2p) {
  while (*tail > *head) { ... }
}

В режиме DirectRead для sendrecv отправитель может вернуться только после того, как получатель полностью прочитает данные. Если получатель по какой-то причине не продвигает 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<br/>15×8B data + 1×8B flag"]
        ll128_sync["flagThread по 1 на каждые 8 потоков<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 lineT[] FIFO
Поддержка DirectНет (PrimitivesWithoutDirectдеградация)Нет (то же самое)Полная поддержка

И LL, и LL128 наследуютPrimitivesWithoutDirect 📎 src/device/prims_ll.h:9-10, src/device/prims_ll128.h:13-14, поскольку их разметка буфера не поддерживает прямое чтение и запись в память партнёра. Simple же полностью реализует режим Direct, поддерживая P2P-соединение и NVLS.

Размышления о дизайне

〔Проектные предположения и архитектурные компромиссы〕

Почему flag в LL дублируется дважды?Потому что запись в глобальную память GPU не гарантирует атомарности.storeLLПри записи 16 байт аппаратное обеспечение может разбить её на две записи по 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 могут продолжать переносить следующую партию данных.

〔Проектные предположения и архитектурные компромиссы〕

Почему продвижение step в LL128 выполняется в конце GenericOp, а не в recvReduceSendCopy?Потому что перенос в LL128 выполняется на уровне warp, и несколько warp могут параллельно обрабатывать разные slice. Если продвигать step вrecvReduceSendCopy, каждый warp продвинет его по разу, что приведёт к многократному продвижению step. Размещение в концеGenericOpобеспечивает единое продвижение, гарантируя, что каждый slice продвигается только один раз.

Итоги главы

В этой главе подробно рассмотрены реализации трёх наборов примитивов переноса:

1. LL: с помощью 16-байтногоncclLLFifoLineflag встраивается в строку данных, и получателю достаточно опрашивать совпадение flag, чтобы подтвердить готовность данных. Полезная нагрузка 50%, подходит для малых сообщений. Ключевое —readLLвld.volatile.global.v4.u32иstoreLLвst.volatile.global.v4.u32。

2. LL128: flag сосредоточены в последних 8 байтах каждых 128 байт, полезная нагрузка повышается до 93.75%. С помощьюflagThread(1 на каждые 8 потоков) проверяется flag,__any_syncвыполняется warp-голосование. При невыровненности выполняется переразметка через разделяемую память.

3. Simple: высокая пропускная способность для больших сообщений достигается за счёт FIFO-буфера + уведомления через указатель step.flagsбитовые флаги кодируют роль,waitPeerопрашивает step,postPeerобновляет step и выполняет fence. Полная поддержка режима Direct.

Все три набора примитивов используют один и тот же шаблонный каркас, специализируемый черезProtoшаблонные параметры. Алгоритмический уровень вызывает только унифицированный интерфейс и не заботится о нижележащем протоколе. Вот ответ на вопрос «почему для одной и той же логики AllReduce нужны три набора примитивов переноса»: для разных размеров сообщений нужны разные стратегии синхронизации и разметки буфера, и три набора примитивов оптимизированы соответственно для малых, средних и больших сообщений.

Вопросы для размышления и самопроверки в этой главе

Q1: Если убрать логику cleanup вincSend(📎 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_MASKflag некоторых строк может остаться значением предыдущего раунда. Если 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, что означает завершение отправки сетевой картой. Если убрать это ожидание, следующий kernel может перезаписать буфер отправки, который в данный момент читается сетевой картой через DMA, что приведёт к чтению сетевой картой грязных данных. DirectRead ожидает, пока принимающая сторона продвинет tail (*tail > *head), что означает завершение чтения прямого буфера принимающей стороной. Если убрать это ожидание, отправляющая сторона может перезаписать буфер до того, как принимающая сторона закончит чтение, что приведёт к чтению принимающей стороной новых данных вместо старых. В сценариях с высокой конкуренцией оба ожидания необходимы; удаление любого из них приведёт к гонке данных. Разница в том, что NetRegMode защищает от «чтения сетевой картой», а DirectRead — от «чтения удалённым GPU».

Q3: LL128loadRegsBeginпри невыровненности идёт через переупаковку в разделяемой памяти (📎 src/device/prims_ll128.h:115-141), насколько этот путь медленнее выровненного? Почему NCCL не требует от пользователя обязательного выравнивания буфера по 16 байт?

Справочный разбор: Невыровненный путь добавляет три шага: запись в разделяемую память,__syncwarp(), чтение из разделяемой памяти. Пропускная способность разделяемой памяти высока, но__syncwarp()является точкой синхронизации, которая блокирует warp до завершения записи всеми потоками. Грубая оценка: невыровненный путь медленнее выровненного на 20-40%, в зависимости от конфликтов банков разделяемой памяти. NCCL не требует обязательного выравнивания, потому что пользователь может передать буфер с произвольным смещением (например, срез тензора), а принудительное выравнивание ограничило бы гибкость API. Стратегия NCCL — «при выравнивании идти по быстрому пути, при невыравненности идти по медленному пути, но гарантировать корректность». В продакшене рекомендуется по возможности выделять буферы с выравниванием по 16 байт, чтобы идти по быстрому пути.

Итак, мы уже освоили механизмы перемещения данных трёх примитивов — LL, LL128 и Simple; они предоставляют алгоритмам верхнего уровня гибкие средства настройки производительности. В следующей главе мы углубимся в ядра алгоритмов коллективных коммуникаций и посмотрим, как AllReduce, AllGather, ReduceScatter и другие вызывают эти примитивы, а также как алгоритмы Ring, Tree, CollNet и другие организуют потоки данных, в конечном итоге выполняя сквозную коллективную коммуникацию.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 10

Глава 10: Ядра коллективных операций: архитектура AllReduce, AllGather и ReduceScatter

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 10 из 25

В предыдущей главе мы разобрали три протокольных примитива — LL, LL128 и Simple; они являются «двигателями» перемещения данных, но сам двигатель не знает, что перемещать, куда и в каком порядке. Рассматриваемая в этой главе группа файлов ядер алгоритмов в src/device — это «коробка передач»: они переводят семантику коллективных коммуникаций AllReduce, AllGather, ReduceScatter в последовательность вызовов примитивов вроде prims.directSend, prims.directRecvReduceDirectSend. Основное противоречие этой главы можно сформулировать одной фразой: почему для одного и того же AllReduce нужны четыре совершенно разные реализации на стороне устройства — Ring, Tree, CollNet, NVLS? Ответ кроется в соответствии между «топологией потока данных» и «аппаратными возможностями». Ring использует минимальную пропускную способность сети для двухфазного конвейера, Tree с древовидной редукцией снижает задержку до log(n), а CollNet/NVLS выгружают редукцию на сетевую карту или коммутатор NVLink. В этой главе мы разберём каждую по очереди.

10.1 Ring AllReduce: как двухфазный конвейер реализуется внутри kernel

Интуитивная модель: «эстафета» на кольцевом конвейере

Представьте n рабочих, стоящих в кругу, у каждого в руках ящик сырья. Цель AllReduce — чтобы каждый в итоге получил «готовый продукт, смешанный из всего сырья». Алгоритм Ring действует в две фазы: первая фаза (reduce-scatter) — каждый передаёт ящик по кольцу, на каждой остановке подмешивая своё сырьё; после n-1 остановок у каждого оказывается ровно одна «полностью смешанная» порция готового продукта, но лишь доля 1/n; вторая фаза (all-gather) — эти доли готового продукта снова идут по кольцу, и каждый дополняет все доли.

Без Ring самый простой подход — каждый rank отправляет данные root, root выполняет редукцию и затем рассылает — пропускная способность сети root становится узким местом, и чем больше n, тем медленнее. Изящество Ring в том, что:Объём отправки и приёма для каждого ранга составляет 2(n-1)/n от объёма данных, что равномерно распределяется по всем каналам независимо от n。

Структуры данных и размещение в памяти

Ключевое состояние алгоритма Ring находится вncclRingструктуре (определена в device.h, в этой главе не рассматривается),runRingберём только два её поля:

  • ring->index: логическая позиция данного ранга в кольце, используется для вычисления «какой chunk обрабатывать на шаге j».
  • ring->prev / ring->next: номера предшествующего и последующего рангов, используются как параметры recv/send peer для конструктораPrimitives

Ключевые параметры разбиения на блоки вычисляютсяncclCollCbdPart(📎 src/device/all_reduce.h:21-22):

code
ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);

Эта функция разбивает данные всего коммуникационного домена по каналам и возвращает три значения:gridOffset(начальное смещение данных, за которые отвечает данный канал, во всём буфере),channelCount(общее количество элементов, за которые отвечает данный канал),chunkCount(количество элементов в chunk, приходящемся на каждый ранг).chunkCount— это гранулярность алгоритма Ring: на каждом шаге передаётся один chunk.

loopCount = nranks * chunkCount(📎 src/device/all_reduce.h:23) обозначает объём данных, обрабатываемых за «полный оборот». Внешний циклfor (elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount)(📎 src/device/all_reduce.h:34) означает: если объём данных канала превышает то, что можно обработать за один оборот, выполняется несколько оборотов.

Пошаговый разбор: полный поток вызовов одного Ring AllReduce

Сценарий: 4 ранга (nranks=4), для данного ранга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— это лямбда, выполняющая вычитание по модулю nranks (📎 src/device/all_reduce.h:40)。ringIx + nranks - 1обозначает «номер предыдущего chunk для данного ранга». Почему на шаге 0 отправляется chunk 3? Потому что на фазе reduce-scatter алгоритма Ring каждый ранг сначала отправляет ту часть данных, которую он «не должен сохранять» (то есть 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: принять один chunk отprevвыполнить редукцию с локальными данными (например, сложение), затем отправить результат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 завершается, и каждый ранг имеет 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

Размышления о дизайне: почему порядок chunk в Ring идёт «в обратную сторону»

Обратите внимание на закономерность нумерации chunk: на шаге 0 отправляетсяringIx-1, на шаге j обрабатываетсяringIx-j, на последнем шаге обрабатываетсяringIx+0. Этопротив часовой стрелкипродвижение. Почему? Потому что каждый ранг в Ring сохраняет только «тот chunk, за редукцию которого он отвечает» (то естьringIx+0), остальные chunk просто проходят мимо. Продвижение против часовой стрелки гарантирует: когда некоторый chunk, совершив полный оборот, возвращается в начальную точку, ровно завершаются nranks редукций, давая окончательный результат. Если бы продвижение было по часовой стрелке, chunk завершал бы редукцию на неправильном ранге.

Производственные подводные камни: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 использует другой подход: подобно организационной структуре компании, каждый ранг взаимодействует только с «родительским узлом» и «дочерними узлами». На фазе редукции листовые узлы докладывают данные наверх, родительские узлы объединяют данные дочерних; на фазе широковещательной рассылки всё наоборот — корневой узел рассылает результат вниз. Задержка снижается с O(n) до O(log n).

Без Tree задержка AllReduce в крупномасштабных кластерах растёт линейно с числом рангов, и время итерации обучения страдает от коммуникаций.

Структуры данных и размещение в памяти

Состояние Tree находится вncclTree:

  • tree->up: ранг родительского узла (-1 означает, что данный ранг является корнем).
  • tree->down[]: массив дочерних узлов, максимумNCCL_MAX_TREE_ARITY(обычно 3, то есть двоичный + локальный).

runTreeUpDownиrunTreeSplit— это два варианта. Первый использует двухфазную схему «сначала всё reduce, затем всё broadcast», второй разделяет потоки на две половины: одна выполняет reduce, другая — broadcast, обеспечивая перекрытие конвейера.

Пошаговый разбор: три ветви runTreeUpDown

runTreeUpDownПервый блок кода — это фаза reduce (📎 src/device/all_reduce.h:96-118), в зависимости от положения данного ранга в дереве возможны три случая:

Случай A: данный ранг — корень (tree->up == -1)(📎 src/device/all_reduce.h:99-104)

code
prims.directRecvReduceCopy(offset, offset, nelem, /*postOp=*/true);

Корневой узел только принимает, но не отправляет: получает данные от всех дочерних узлов, выполняет reduce и записывает в recvbuff.postOp=trueвыполняет пост-операцию.

Случай B: данный ранг — лист (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 — это «точка сбора»: объём приёма равен числу дочерних узлов, объём отправки равен нулю (в фазе reduce). Если корневой узел также пойдёт по общемуdirectRecvReduceDirectSend, он попытается отправитьtree->up(-1), что приведёт к выходу за границы. Поэтому необходима отдельная ветвьif (tree->up == -1). Аналогично и проверкаtree->down[0] == -1для листового узла.

Производственные грабли: проблема «горячего корня» в алгоритме Tree

Корневой узел Tree несёт весь трафик reduce; если GPU, на котором находится корневой узел, оказывается медленным узлом (например, из-за ограниченной пропускной способности PCIe), весь AllReduce замедляется. Ответ NCCL:для каждого channel выбирается свой корень, что распределяет нагрузку корневого узла по нескольким рангам. Именно поэтому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 выполняет только «сбор» — каждый ранг вносит свою порцию данных, в итоге все получают все данные. ReduceScatter выполняет только «reduce + scatter» — все вносят данные, после reduce каждый получает свою порцию.

Без этих двух отдельных API пользователю при необходимости «сначала reduce, затем gather» или «сначала gather, затем 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, чтобы обойти собственный barrier prims и__syncthreads()。

Реализация ReduceScatter через Ring

reduce_scatter.hвrunRing(📎 src/device/reduce_scatter.h:14-56) — это выделенная фаза reduce-scatter из AllReduce:

Шаг 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-placeinputBuf + dataOffset == outputBuf + offsetзависит от точного равенства указателей. Если переданные пользователем sendbuff и recvbuff имеют смещение, но логически являются одной и той же областью памяти, это определение не сработает, что приведёт кdirectCopySendпути — хотя и корректному, но с дополнительным копированием. В продакшене рекомендуется при in-place AllGather убедиться, что sendbuff и recvbuff полностью совпадают.

10.4 CollNet и NVLS: выгрузка редукции на аппаратное обеспечение

Интуитивная модель: пусть «коммутатор» поможет вычислить

Ring и Tree — это «GPU сам вычисляет редукцию». CollNet и NVLS используют другой подход: выгрузка операции редукции на сетевую карту (CollNet) или коммутатор NVLink (NVLS). GPU только отправляет данные, аппаратное обеспечение выполняет редукцию и затем рассылает результат обратно. Это как переход от «каждый рабочий сам смешивает ингредиенты» к «отправить ингредиенты в центральный смеситель, смеситель смешает и раздаст».

Без аппаратной выгрузки операция редукции занимает ресурсы SM GPU, и задержку редукции невозможно скрыть.

Разделение потоков в 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. Это различие обусловлено тем, что режим сетевой регистрации требует непрерывности буферов для DMA сетевой карты.

Распределение warp в NVLS

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

Подводные камни в продакшене: ловушкаdirect->out == -1в CollNet

📎 src/device/reduce_scatter.h:521содержит строку:

code
if (direct->out == -1) __trap();

Если out-соединение CollNet не установлено (-1), прямой__trap()приводит к падению kernel. Это защитное программирование — CollNet зависит от сетевой карты, если инициализация сетевой карты не удалась, out будет -1, и продолжение выполнения приведёт к неопределённому поведению. В продакшене, если видите kernel trap, проверьте, нормально ли инициализирована сетевая карта CollNet.

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 тоже используют RingBroadcast и Reduce теоретически могут использовать Tree для меньшей задержки, но NCCL выбирает Ring потому что:объём данных этих двух операций обычно невелик, реализация Ring проще, и можно переиспользовать кодовый путь Ring из AllReduce

. Сложность Tree (выбор корневого узла, разделение потоков) при малых сообщениях не даёт заметного выигрыша.

Подводные камни в продакшене: узкое место пропускной способности root-узла в BroadcastRoot-узел Broadcast должен отправить все данные; если root — медленный узел, весь Broadcast замедляется. Ответ NCCL:Broadcast также поддерживает несколько channel, root каждого channel может быть разнымwork->root. Но обратите внимание, что

является глобальным, все channel используют один и тот же root — это определяется семантикой Broadcast (только один источник). В продакшене, если Broadcast медленный, проверьте пропускную способность сети root-узла.

10.6 Матрица выбора алгоритма: специализация шаблона RunWorkCollRunWorkCollВсе ядра алгоритмов регистрируются через специализацию шаблона📎 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, и использование 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: подходит для кластеров с сетевыми картами, поддерживающими редукцию, разгружает вычисления GPU.
  • NVLS: подходит для полносвязного NVLink в пределах одного узла, аппаратная многоадресная редукция.

Модуль tuning в NCCL (глава 5) автоматически выбирает на основе размера сообщения, числа рангов, топологии. Реализация на стороне устройства должна лишь гарантировать «корректность каждой комбинации», логика выбора — на стороне хоста.

Итоги главы

В этой главе разобраныsrc/deviceшесть файлов ядер алгоритмов:

1. Ring AllReduce(📎 src/device/all_reduce.h:14-83): двухэтапный конвейер, 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 одноэтапный, представляет собой этап reduce-scatter в AllReduce.

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: На этапе reduce-scatter в Ring AllReduce шаг 0 используетdirectSend, промежуточные шаги используютdirectRecvReduceDirectSend, последний шаг используетdirectRecvReduceCopyDirectSend. Если убратьpostOp=trueна последнем шаге, в каких сценариях возникнет ошибочный результат?

Разбор ответа:postOp=trueзапускает пост-операцию (например, деление при вычислении среднего). На примереncclAvgредукция — это суммирование, postOp — деление на nranks. Если убратьpostOp, последний шаг выполнит только редукцию без деления, в recvbuff будет храниться «сумма», а не «среднее». На этапе reduce-scatter каждый ранг сохраняет только финальный результат одного чанка, и этот чанк как разringIx+0(📎 src/device/all_reduce.h:60). Если postOp отсутствует, сумма этого чанка не делится на nranks, и последующий этап all-gather распространит эту ошибочную «сумму» на все ранги. Замечание: postOp нужен только на последнем шаге, поскольку только этот шаг даёт результат «полной редукции»; редукция на промежуточных шагах — это частичная сумма, postOp не нужен. В производственной среде, если обнаружено, что результат AllReduce завышен в nranks раз, проверьте корректность передачи postOp.

Q2: runTreeSplitВ протоколах LL/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-путевой редукции, а группе широковещания потоков меньше, но достаточно. При изменении на 1:1 группе редукции не хватит потоков, редукция станет узким местом; группа широковещания будет иметь избыток потоков, что расточительно. Что ещё серьёзнее — опрос флагов в LL-протоколе является активным ожиданием, и больше потоков увеличит конкуренцию за флаги. В производственной среде, если обнаружена аномальная производительность Tree AllReduce под LL-протоколом, проверьте, не изменены ли вычисленияnthreadsSplit.

Q3: В режимеisNetOffloadAllGather только 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 может начать коммуникацию следующей work, пока warp копирования ещё не дописал outputBuf, а следующая work может повторно использовать тот же outputBuf. Конкретный сценарий: два последовательных AllGather, warp копирования первого ещё дописывает хвост outputBuf, а коммуникационный warp второго уже начал записывать новые данные в outputBuf, что приводит к перезаписи данных первого. В комментарии ясно сказано: «otherwise, we can have contention if next work will use the outputBuf in this work». Использование barrier 14 вместо стандартного barrier необходимо, чтобы избежать внутренних barrier'ов prims и__syncthreads(), предотвращая взаимную блокировку. В production-среде, если обнаружены периодические ошибки в результатах AllGather, проверьте,isNetOffloadне оптимизирован ли barrier на пути.

На этом мы завершили рассмотрение того, как алгоритмические ядра на стороне устройства организуют поток данных. Каждый алгоритм черезPrimitivesвызывает примитивы из предыдущей главы; алгоритмический уровень заботится только о том, «кто кому отправляет, какой chunk, reduce или copy». В следующей главе мы углубимся в абстракцию транспортного уровня и посмотрим, как P2P, SHM, NET, NVLS унифицируются в единый интерфейс, а также как proxy-потоки на стороне host взаимодействуют с kernel'ами на стороне устройства для выполнения межмашинной коммуникации.

Ключевая закономерность: все алгоритмы вызывают примитивы через шаблонный класс Primitives; алгоритм отвечает только за «топологию потока данных», примитивы отвечают за «перемещение данных». Такая слоистость позволяет новым алгоритмам реализовывать только логику топологии, не заботясь о низкоуровневой синхронизации. Но как бы ни менялась топология, данные в конечном итоге должны передаваться по физическим каналам. В следующей главе мы углубимся в каталог src/transport и посмотрим, как NCCL с помощью единого интерфейса transport скрывает различия между P2P, SHM, NET, NVLS, а также семантику setup/connect/send/recv каждого transport. Это основа для понимания межмашинной коммуникации.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 11

Глава 11: Транспортный уровень: абстракции P2P, SHM, NET и NVLink SHARP

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 11 из 25

В предыдущей главе мы углубились в алгоритмические ядра и увидели, как Ring AllReduce разбивает данные и выполняет двухфазную редукцию, а Tree AllReduce с помощью древовидной структуры снижает задержку — но эти алгоритмы определяют лишь логическое представление «кто кому отправляет, какой chunk». Данные в конечном итоге должны пройти через реальные физические каналы: NVLink, PCIe, разделяемую память или сетевую карту. В этой главе мы разберём каталог src/transport и посмотрим, как NCCL с помощью единого интерфейса ncclTransport маскирует четыре физических канала P2P, SHM, NET, NVLS под одним обликом, завершая последнюю милю от алгоритмической топологии до физической передачи.

I. Единый интерфейс: как ncclTransport скрывает четыре физических канала

Интуитивная модель

Представьте логистическую компанию: независимо от того, отправляет ли клиент городскую курьерскую доставку (P2P), передачу внутри здания (SHM), междугороднюю перевозку (NET) или выделенную линию (NVLS), на стойке заполняется только одна «транспортная накладная». Эта накладная и естьncclTransportструктура — она определяет, что каждый способ доставки должен предоставлятьcanConnect、setup、connect、freeи другие фиксированные действия. Без этого слоя абстракции верхнеуровневым алгоритмам пришлось бы писать четыре набораif-elseдля определения, по какому каналу идти, и при добавлении нового оборудования пришлось бы менять все алгоритмы.

Структуры данных и layout памяти

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(инициализация общего proxy),proxySetup、proxyConnect、proxyFree、proxyProgress、proxyRegister、proxyDeregister. Обратите внимание, что слотproxyProgressу P2P —NULL— потому что P2P работает через прямой доступ GPU к памяти удалённого узла и не требует host proxy-потока для перемещения данных; а у NETproxyProgress—sendProxyProgress/recvProxyProgress, потому что I/O сетевой карты должен управляться host-потоком.

Сценарный Walkthrough: как при установке соединения выбирается transport

Когда NCCL нужно установить соединение для некоторого peer'а некоторого channel, вызывается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==1обозначает направление send,type==0обозначает направление recv. Цикл поочерёдно опрашивает каждый transportcanConnect: возвращает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: сначала спрашиваем топологию «есть ли P2P-путь между двумя rank»; если есть промежуточные переходы (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 — это «подстраховка»: если раньше никто не взялся, он берётся. У NVLScanConnectсразу возвращает 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отдельно создаёт multicast-группу, поэтому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 достаточно добавить один элемент в массив, не меняя логику выбора. Это и есть проявление принципа открытости-закрытости в системном программировании.

II. P2P: четыре формы прямого соединения GPU на одном хосте

Интуитивная модель

P2P — это «передача вещей напрямую между соседями»: GPU 0 напрямую читает и пишет память GPU 1, не проходя через CPU или сетевую карту. Без P2P коммуникация между несколькими GPU на одном хосте была бы вынуждена идти через память хоста, что удвоило бы задержку и вдвое сократило пропускную способность.

Структуры данных и размещение в памяти

Внутри 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в режиме P2P Read нужно дополнительно добавить размер буфера протокола SIMPLE — потому что в режиме чтения SIMPLE buffer отправителя напрямую читается получателем и должен быть размещён вместе сncclSendMemв одной и той же разделяемой памяти.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))

Если тот же процесс, direct не отключён и memcpy не включён, то это самый быстрыйP2P_DIRECT— напрямую берётся указатель удалённой стороны. Иначе идёт 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— это синхронный RPC: host-поток отправляет сообщение прокси-потоку, прокси-поток вызываетp2pSendProxySetupдля выделения разделяемого буфера и возвращаетncclP2pBuff(включая IPC-дескриптор). Затемp2pMapотображает буфер удалённой стороны в локальное адресное пространство.

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;
}

Один процесс, разные GPU: сначалаcudaDeviceEnablePeerAccessоткрывает P2P-канал, затем напрямую используетсяdirectPtr(поскольку адресное пространство в одном процессе общее). Межпроцессное взаимодействие: вызываетсяncclP2pImportShareableBufferдля импорта дескриптора удалённой памяти.

Управление конкурентностью и взаимодействие с оборудованием

Синхронизация P2P опирается наncclSendMem/ncclRecvMemвhead/tailуказатель. Отправитель пишетheadи сообщает получателю «докуда я записал», получатель пишетtailи сообщает отправителю «докуда я прочитал». Это типичный lock-free producer-consumer:

📎 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 через чтение и запись этих двух указателей реализует меж-GPU синхронизацию без участия CPU.

Руководство по избежанию проблем в продакшене

Проблема 1: P2P Read и memcpy взаимоисключающи.смотрит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— эти две семантики конфликтуют.

Проблема 2: порядок освобождения при межпроцессном взаимодействии. 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.

III. 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: выбор locality для SHM

shmSendSetupВ зависимости от locality определяется, сколько памяти выделять:

📎 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,写操作可以异步执行。

Руководство по избеганию проблем в production

Проблема: между контейнерами/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. Если в production обнаружено, что связь между хостами идёт по сети, проверьте, одинаково ли смонтированы/dev/shmв контейнерах.

IV. NET: таблица отображения сетевой передачи и прогресс прокси

Интуитивная модель

NET — это «междугородняя доставка» — данные упаковываются и передаются сетевой карте, которая по оптоволокну доставляет их на удалённую сторону. Но сетевая карта не понимает адреса видеопамяти GPU, нужна «таблица отображения адресов», которая транслирует виртуальные адреса GPU в физические адреса, понятные сетевой карте. Эта таблица и есть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 бита как индекс банка, кmems[bank].gpuPtrдобавляется смещение из младших 29 бит, получается фактический указатель. Эта схема кодирования упаковывает «какая область памяти + смещение внутри области» в одно 32-битное целое, экономя размер передачиconnectMap.

Сценарный Walkthrough: установление отображения в sendProxyConnect

sendProxyConnect— самая сложная функция NET, отвечающая за установление соединения с сетевой картой, выделение буферов, регистрацию памяти:

📎 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 переиспользуют одно соединение с сетевой картой, уменьшая число соединений.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текущего банка записывается как смещение вoffsets, затемsize += memSize— это bump allocator. Разделяемый буфер: напрямую записывается номер банка, смещение равно 0 (потому что разделяемый буфер целиком является одним банком).

Наконец, память регистрируется для сетевой карты:

📎 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]));
      }
      ...

Приоритетно используется путь DMA-BUF (cuMemGetHandleForAddressRangeполучает fd, передаёт плагину сетевой карты), при неудаче происходит откат кregMr(традиционный nv_peermem GDR).

Управление конкурентностью и взаимодействие с оборудованием: трёхступенчатый конвейер sendProxyProgress

sendProxyProgress— это движок перемещения данных NET, использующий трёхступенчатую схему «post → transmit → done»:

📎 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, сообщая GPU «буфер готов, можно писать данные».
  • transmit: проверяется, продвинулся лиrecvMem->tail(GPU закончил запись), проверяетсяconnFifo[buffSlot].size != -1(размер данных заполнен), затем вызываетсяncclNet->isendдля инициирования асинхронной отправки.
  • done: вызываетсяncclNet->testдля проверки завершения отправки, обновляетсяsendMem->headи возвращается буфер.

wc_store_fence()— это барьер объединения записей — в сценарии GDRCopy после записи CPU вgdcSyncнеобходимо сбросить буфер объединения записей, иначе GPU не увидит обновление.

Руководство по избеганию проблем в production

Проблема 1: проверка flag протокола LL128.Когда данные находятся в sysmem (не GDR), прокси-поток должен построчно проверять flag LL128:

📎 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;
                }
              }
            }
          }

Поскольку GPU вызвал толькоthreadfence(), данные могут всё ещё находиться в кэше L2 и не попасть в sysmem. Прокси-поток должен убедиться, что flag каждой строки корректен, прежде чем отправлять. Если в production обнаружено повреждение данных LL128, проверьтеuseGdrВерно ли это — при использовании пути GDR данные попадают напрямую в видеопамять, построчная проверка не требуется.

Ловушка 2: порядок памяти при flush в GDRCopy.На принимающей стороне в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
            }

mfenceгарантирует, что чтение при опросе CQE не будет переупорядочено перед чтением flush;mov (%0), %%eaxпринудительно инициирует одно чтение PCIe, заставляя CPU приостановиться до тех пор, пока все предыдущие posted write по PCIe (включая DMA сетевой карты) не будут зафиксированы. Это ключевой момент в сценарии GDRCopy для предотвращения ситуации «сетевая карта сообщила о завершении записи, но данные всё ещё находятся в буфере PCIe». Если убрать этот фрагмент, принимающая сторона может прочитать устаревшие данные.

mermaid
sequenceDiagram
    participant GPU as Ядро GPU
    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 / 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

Пять. NVLS: группы многоадресной рассылки и привязка памяти UC/MC

Интуитивная модель

NVLS — это «радиостанция»: один rank записывает данные в группу многоадресной рассылки, а аппаратное обеспечение автоматически копирует их всем подписчикам. Традиционному AllReduce требуется N-1 попарных передач, а NVLS достаточно 1 многоадресной записи + 1 многоадресного чтения. Без NVLS задержка крупномасштабного AllReduce линейно растёт с числом rank'ов.

Структуры данных и разметка памяти

Ядро NVLS — это привязка «памяти UC (одноадресной)» и «памяти MC (многоадресной)».nvlsAllocBindUcВыделение памяти UC и привязка к группе 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отображение в виртуальный адрес →cuMemSetAccessнастройка прав доступа GPU →ncclMcPartitionBindMemпривязка физической памяти UC к указанному смещению в группе MC. После привязки любой rank, записывающий по адресу MC, заставляет аппаратное обеспечение скопировать данные во всю привязанную память UC.

Обратите вниманиеbootstrapIntraNodeBarrierпередcuMulticastBindMem— в комментарии сказано, что это делается для «mitigate the possible hang in cuMulticastBindMem during abort». Это защита на аппаратном уровне: если какой-либо rank прервётся в процессе привязки, остальные 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буферов (половина для reduce, половина для broadcast).send[1]иrecv[0]— это направление reduce (UC → MC),recv[1]иsend[0]— направление broadcast (MC → UC).dataUc.ptr— это локальная память UC,dataPartition.ptr— адрес отображения группы MC.

Размышления о дизайне

〔Проектные выводы и архитектурные компромиссы〕

ПочемуcanConnectв NVLS возвращает 0? Потому что NVLS — это не попарная передача, а модель многоадресной рассылки «один-ко-многим».selectTransportЦикл вncclNvlsSetupпредназначен для попарных соединений, а установление соединений NVLS идёт по независимому путиncclTransports. Помещение NVLS в массивfreeнужно лишь для унификации интерфейсаnvlsSendFree/nvlsRecvFree(

), фактическая логика соединений полностью независима.

Руководство по избеганию проблем в продакшенеЛовушка: MNNVL не поддерживает регистрацию буфера NVLS.ncclNvlsSetup:

Смотрите

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 12

Глава 12: Прокси-потоки и асинхронный I/O: координация сетевых операций на хосте

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 12 из 25

Глава 12: Асинхронное планирование прокси-потоков: как proxy.cc развязывает I/O и выполнение ядраsrc/proxy.ccВ предыдущей главе мы разобрали уровень абстракции transport и увидели, как NCCL с помощью единого интерфейса скрывает различия P2P/SHM/NET/NVLS. Но транспортный уровень отвечает лишь на вопрос «по какому каналу идут данные» и пока не отвечает на вопрос «как данные управляются асинхронно». Если GPU-ядро будет напрямую блокироваться в ожидании сети, вычислительные блоки будут загублены I/O. Эта глава сосредоточена наsrc/include/proxy.hи

, и мы посмотрим, как NCCL с помощью отдельного host-потока выносит сетевой I/O из пути выполнения ядра, образуя с GPU отношение производителя-потребителя.

12.1 Зачем нужны прокси-потоки: начнём с вопроса «кто ждёт сеть»

Представьте ресторан: кухня (GPU kernel) только готовит блюда, а официант (proxy-поток) доставляет их гостям (сетевому партнёру). Если заставить повара самому разносить блюда, ему придётся останавливать готовку на каждом рейсе, и скорость выдачи резко упадёт. Proxy в NCCL — это тот самый выделенный официант: kernel только записывает данные в разделяемый буфер и читает из него, а всю грязную работу по сетевому приёму-передаче выполняет proxy-поток на стороне хоста.

〔Проектные соображения и архитектурные компромиссы〕

Что за катастрофа произошла бы без proxy? GPU kernel — это массово-параллельная SIMT-модель; блокировка одного warp на сетевом опросе приведёт к потере вычислительной мощности всего SM; что ещё более фатально — сетевой приём-передача включает системные вызовы socket, опрос verbs, отправку DMA-дескрипторов, и эти операции в принципе невозможно выполнить в device-коде. Поэтому NCCL обязан вынести сетевой ввод-вывод на хост, а kernel и proxy обмениваются сигналами «данные готовы» через FIFO в разделяемой памяти.

Разделение обязанностей двух типов потоков

NCCL запускает на стороне хоста два типа proxy-потоков с совершенно разными обязанностями:

  • Поток Service(ncclProxyService): обрабатывает запросы плоскости управления — установление соединений, регистрацию памяти, запросы FD. Он слушает socket, принимает RPC-запросы от локального rank и асинхронно продвигает операции setup/connect и т.д.
  • Поток Progress(ncclProxyProgress): обрабатывает плоскость данных — фактически управляет сетевым приёмом-передачей. Он извлекает proxy op из пула разделяемой памяти и вызываетproxyProgresscallback транспорта для продвижения перемещения данных.

📎 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не пуст (т.е. данному транспорту требуется продвижение плоскости данных), поток 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[]Голова списка свободных op для каждого local rank
syncObjectsInitializedintОтмечает, инициализированы ли mutex/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). Это «описание op времени выполнения», используемое внутри потока Progress, выделяется изncclProxyPool, не разделяется между процессами.

Ключевые поля:

  • subs[NCCL_PROXY_MAX_SUBS]: массив подопераций,NCCL_PROXY_MAX_SUBS = MAXCHANNELS 📎 src/include/proxy.h:55-55. Однотипные операции нескольких channel агрегируются в несколько sub одного args.
  • progress: указатель на функцию, указывающий наproxyProgresscallback транспорта📎 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логику взятия свободного op из freeOps📎 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));
〔Проектные предположения и архитектурные компромиссы〕

Здесь используется acquire/release, а не seq_cst, потому что нужно гарантировать только видимость «записи указателя next узла списка» для получающей стороны, а не глобальный порядок.freeOps[]Каждый элемент массива соответствует одному local rank, естественным образом распределён по разным строкам кэша, что уменьшает ложное разделение.

12.3 Плоскость управления: установление соединений и механизм RPC

Интуитивная модель

〔Проектные предположения и архитектурные компромиссы〕

Поток Service похож на «стойку регистрации»: когда локальному rank нужно установить сетевое соединение, он не подключается напрямую сам, а отправляет RPC-запрос потоку Service, который выполняет setup/connect вместо него. Почему так? Потому что установление сетевого соединения (особенно создание QP в verbs, регистрация памяти) может блокироваться, а некоторые ресурсы (например, 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, копирует данные ответа через memcpy в предварительно выделенныйrespBuff, помечает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 и socket каждого peer.

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 распределяет их по различным callback транспорта:

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

Каждый callback имеет выходной параметрdone. Еслиdone == 0, это означает, что операция ещё не завершена (например, сетевое соединение всё ещё в трёхстороннем рукопожатии), возвращается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 синхронизируются через указатели head/tail в FIFO, без необходимости в каких-либо блокировках.

Доставка 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。

3. Присоединить op кproxyOps->nextOpsхвосту связного списка.

4. Если накопленное количество op достигаетMAX_OPS_PER_PEER, инициировать пакетную доставку📎 src/proxy.cc:525-551。

Логика пакетной доставки очень тонкая: она не может просто отправить все op, потому что «несколько op с одинаковым opCount должны доставляться вместе, иначе нарушится sub-агрегация proxyArgs». Поэтому она находит последнюю границу изменения opCount и доставляет только до неё📎 src/proxy.cc:529-548。

Доставка выполняется черезncclProxyPost 📎 src/proxy.cc:476-486, который захватывает блокировку, обновляетpool->nextOps、notify_oneи пробуждает поток Progress.

Главный цикл потока Progress

ncclProxyProgress 📎 src/proxy.cc:951-1011Структура:

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Определяет, нужно ли «добавить в sub существующего args» или «создать новый args». Критерий —connection->shared && args->opCount == op->opCount 📎 src/proxy.cc:443-443— несколько операций channel одного соединения с одинаковым opCount агрегируются.

〔Проектные предположения и архитектурные компромиссы〕

Ценность агрегации: однотипные операции нескольких channel объединяются в один args, поток Progress за один цикл продвигает все channel, что снижает накладные расходы на вызовы функций и инвалидацию кэша.ncclProxyOpToArgs 📎 src/proxy.cc:368-435При добавлении sub проверяетсяsliceSteps、chunkSteps、protocol、dtype、redOp、collна согласованность📎 src/proxy.cc:401-406, при несовпадении выдаётся ошибка — это защита от ошибочной агрегации.

sendProxyProgress: четырёхфазный конечный автомат отправляющей стороны

sendProxyProgress 📎 src/transport/net.cc:1324-1491— ядро отправляющей стороны. Он продвигается по sub поочерёдно, у каждого sub четыре счётчика:posted、transmitted、done。

Фаза первая: инициализация 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;

base— начальный номер step,ROUNDUPобеспечивает выравнивание поchunkSteps。resources->stepнакопление, резервируя место для следующего op.

Фаза вторая: отправка буфера на 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 можно записывать».

Фаза третья: проверка готовности 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 обновляет size и recvTail FIFO, proxy инициирует isend только при выполнении обоих условий. Для протокола LL, поскольку он имеет семантику «zero-copy», ждать recvTail не нужно.

Фаза четвёртая: проверка завершения отправки, обновление 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: четырёхфазный цикл принимающей стороны

recvProxyProgress 📎 src/transport/net.cc:1493-1788Сложнее, так как включает группировку sub (при совместном использовании одного recvComm несколькими sub применяется multirecv).

Фаза первая: группировка по recvComm при Ready 📎 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;
}
〔Проектные предположения и архитектурные компромиссы〕

Этот фрагмент кода ставит рядом sub, использующие один и тот жеrecvComm, и записываетgroupSize. Зачем группировать? Потому чтоirecvподдерживает приём нескольких buffer за раз (multirecv), объединение запросов одного comm в один вызов значительно снижает накладные расходы плагина.

Фаза вторая: инициирование 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: для приёма одного buffer по протоколам LL/LL128 уведомление о завершении опционально (так как данные сами несут flag), проверку completion можно пропустить.

Фаза третья: проверка завершения приёма, обновление 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 необходим для гарантии видимости данных).

Фаза четвёртая: ожидание потребления 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— callback для плагина, уведомляющий «buffer этого запроса на приём потреблён, можно переиспользовать».

Полная картина потока данных

mermaid
flowchart LR
    subgraph GPU["Ядро GPU"]
        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 Управление конкурентностью, барьеры памяти и взаимодействие с аппаратурой

Порядок памяти lock-free FIFO

Синхронизация между proxy и GPU полностью опирается наncclConnFifoи указатели head/tail, без каких-либо блокировок. Это требует крайне осторожного контроля порядка памяти.

На отправляющей стороне proxy после возвратаtestdone📎 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 гарантирует, что обновление head станет видимым только после того, как сброс size станет видимым для GPU. При обратном порядке 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 о возможности чтения.

Механизм flush в GDRCOPY

При использовании GDRDMA NIC пишет напрямую в память GPU, но операция записи может ещё не быть зафиксирована на шине PCIe. Proxy должен активно выполнить flush, чтобы гарантировать видимость данных. См. логику flush вrecvProxyProgress📎 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:mfenceПредотвращает переупорядочивание загрузки CQE-poll перед загрузкой flush;mov (%0), %%eaxПринудительное чтение PCIe заставляет CPU приостановиться до тех пор, пока все предыдущие PCIe posted write (включая NIC DMA) не будут зафиксированы в endpoint. Это управление порядком памяти на аппаратном уровне, более надёжное, чем любой программный 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();

Сначала блокировка, затем store stop, потом notify — это стандартный шаблон для предотвращения lost wakeup. Поток Progress приpool->cond.waitудерживает блокировку и проверяет предикат📎 src/proxy.cc:850-851, гарантируя, что пробуждение не будет пропущено.

12.6 Руководство по избеганию проблем в production и цепочка восстановления после сбоев

Проблема первая: утечка соединений приводит к невозможности выхода потока Service

ncclProxyServiceУсловие основного цикла —stop == PROXY_RUNNING || npeers > 0 📎 src/proxy.cc:1842-1842. Комментарий объясняет📎 src/proxy.cc:1843-1845: даже если локальный comm abort, пока есть peer-соединения, поток proxy не может завершиться, иначе возможен segfault.

Сценарий диагностики: если какой-то rank упал, не уведомив партнёра, поток Service партнёра навсегда застрянет в циклеnpeers > 0. В этом случае нужно полагаться наabortFlagили механизм таймаута. В production, если процесс завис вncclProxyService, сначала проверьте, не завершился ли аварийно какой-либо peer rank.

Проблема вторая: несоответствие очереди ответов приводит к утечке памяти

expectedProxyResponseStoreПри несовпадении opId возвращаетсяncclInternalError 📎 src/proxy.cc:93-94. Но если ответ приходит, когда запрашивающая сторона уже отказалась от него (например, по таймауту), этот ответ навсегда останется в очереди,respBuffутечка.

Меры защиты:expectedProxyResponseFree 📎 src/proxy.cc:55-65ПриncclProxyDestroyочищается вся очередь📎 src/proxy.cc:2226-2226. Но это крайняя мера, в нормальной работе остатков быть не должно.

Проблема третья: в 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 должен постепенно увеличивать head на этапе post, чтобы «выдавать credit». Если забыть об этой инициализации, GPU ошибочно решит, что есть credit, и запишет в неготовый slot, что приведёт к повреждению данных.

Проблема четвёртая: проверка flag в протоколе LL128

sendProxyProgressВ📎 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, поток Service закрывает соединение и очищает все async op данного peer📎 src/proxy.cc:1929-1937. Эта очистка — «полный drain»: очищается не только отказавший op, а вся очередь asyncOps данного peer, чтобы предотвратить ссылки остаточных op на уже освобождённое соединение.📎 src/proxy.cc:1984-1995При возникновении ошибки в потоке Progress

, код ошибки записывается в📎 src/proxy.cc:979-983и цикл завершается. Основной поток впоследствии может обнаружить ошибку, проверив это поле.proxyState->asyncResultИтоги главы

В этой главе мы разобрали полный механизм прокси-потоков NCCL:

Разделение труда двух типов потоков

1. : поток Service обрабатывает RPC плоскости управления (установка соединений, регистрация памяти), поток Progress обрабатывает плоскость данных (продвижение сетевого приёма-передачи).Разделяемый пул памяти

2. передаёт op между процессами,:ncclProxyOpsPoolагрегирует операции нескольких channel внутри потока Progress.ncclProxyArgsБесслотовочная синхронизация FIFO

3. : GPU и proxy обмениваются сигналами готовности данных черези указатели head/tail, используя seq_cst fence для гарантии порядка памяти.connFifoЧетырёхфазный конечный автомат

4. : счётчики posted → transmitted → received → done для send/recv управляют конвейером.Аппаратный flush

5. : в сценарии GDRDMA используется+ чтение PCIe для принудительной фиксации posted write.mfenceВопросы для размышления и самопроверки

Q1: Если убрать логику обновления

вsendProxyProgressприsub->done == sub->nsteps(то есть не уведомлять GPU об освобождении slot), в каких сценариях возникнет взаимоблокировка? Почему?sendHeadСправочный разбор

— единственный критерий, по которому GPU определяет, какие slot можно переиспользовать. См.:sendHeadКопирование📎 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;
}

, в не-shared — 0). GPU kernel при-NCCL_STEPSпроверяетwaitSendи только тогда считает, что есть credit для записи. Если head не продвигается, GPU, заполнивhead + NCCL_STEPS > stepslot, навсегда заблокируется в ожидании credit, а proxy, в свою очередь, ждёт новых данных от GPU для isend — классическая взаимоблокировка производителя-потребителя. В shared-режиме это ещё серьёзнее, так как начальный head отрицательный, и у GPU изначально нет credit.NCCL_STEPSслотов, после чего навсегда блокируется в ожидании credit, а proxy в свою очередь ждёт, пока GPU запишет новые данные, чтобы выполнить isend — классическая взаимоблокировка производителя-потребителя. В режиме shared это ещё серьёзнее, поскольку начальное значение head отрицательное, и у GPU изначально нет credit.

Q2: ncclLocalOpAppendПри накоплении op достигаетMAX_OPS_PER_PEERзапускается пакетная отправка, но код намеренно «не отправляет все op последнего opCount». Если изменить на простую отправку всех 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. Если несколько channel op с одинаковым opCount разделены на две пакетные отправки, первая партия создаст args, а когда прибудет вторая партия,args->opCountуже не равно opCount нового op (поскольку args мог быть продвинут), что приводит к разделению sub, которые должны были быть агрегированы, на независимые args. Это не только снижает производительность, но и может нарушитьncclProxyOpToArgsвnChannels/nPeersлогику взятия min📎 src/proxy.cc:399-400, что приводит к неверному вычислению количества каналов.

Q3: recvProxyProgressфаза Ready будет переупорядочивать и группировать sub поrecvComm. Если убрать эту логику группировки и позволить каждому 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»📎 src/transport/net.cc:1525-1525. КогдаmaxRecvs > 1, плагин (например, IB) поддерживает приём нескольких буферов одним WQE, что значительно снижает накладные расходы на doorbell и обработку CQE. Если убрать группировку и каждый sub будет вызывать irecv отдельно,subCountвсегда будет равно 1, плагин деградирует до режима одного буфера, пропускная способность снизится. Что ещё важнее,recvRequestsCacheиirecvConsumedмеханизмы📎 src/transport/net.cc:1616-1617разработаны для multirecv — в режиме одного буфера эта логика кэширования перестанет работать, что может привести к утечке запросов.

Итак, мы поняли, как proxy-поток отделяет сетевой I/O от выполнения kernel, позволяя вычислениям на GPU и коммуникации действительно работать параллельно. Но proxy — лишь драйвер, конкретная реализация низкоуровневой сетевой передачи всё ещё не раскрыта. В следующей главе мы углубимся вnet_ib, чтобы увидеть, как NCCL инкапсулирует verbs API для реализации передачи InfiniBand, и как GPUDirect RDMA позволяет сетевой карте напрямую читать и записывать память GPU.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 13

Глава 13: Сетевая передача InfiniBand: прямая интеграция Verbs и GPUDirect RDMA

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 13 из 25

В предыдущей главе мы увидели, как proxy-поток отделяет сетевой I/O от GPU kernel, позволяя вычислениям и коммуникации действительно работать параллельно. Но proxy — лишь «драйвер» — он вызывает абстрактные интерфейсы ncclNet->isend/irecv, но не знает, что под ними: TCP, InfiniBand или что-то ещё. В этой главе мы приоткроем эту абстракцию и заглянем в src/transport/net_ib и src/misc/ibvwrap.cc, чтобы увидеть, как NCCL инкапсулирует библиотеку libibverbs в подключаемую таблицу символов, как создаёт 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? Потому что C++ код NCCL уже зависит от<mutex>и<thread>, использование стандартной библиотеки более согласовано.call_onceСемантика заключается в следующем: независимо от того, сколько потоков одновременно вызываютwrap_ibv_symbols(), лямбда выполняется только один раз, остальные потоки блокируются в ожидании, а затем все получают один и тот жеinitResult. Это гораздо безопаснее, чем ручная реализация двойной проверки блокировки (DCLP) — DCLP имеет известную ловушку переупорядочивания в модели памяти C++.

Пошагово: полный процесс разрешения символов

Когда 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)— функции libibverbs, возвращающие указатель (например,ibv_alloc_pd), при ошибке возвращают NULL и устанавливаютerrno, поэтому здесь чтениеerrnoкорректно.

АIBV_INT_CHECKиспользуется для функций, возвращающих int:

📎 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 при ошибке, и информация об ошибке уже потеряна.

〔Проектные предположения и архитектурные компромиссы〕

Такой подход "для каждой функции свой макрос" выглядит громоздким, но он необходим: соглашения об ошибках API libibverbs крайне неоднородны — некоторые возвращают 0/-1, некоторые возвращают значение errno, некоторые возвращают указатель. Если насильно унифицировать, можно потерять информацию об ошибке. NCCL выбирает "точный перевод", оставляя сложность на уровне инкапсуляции, чтобы верхний уровеньnet_ib.ccдолжен был только проверятьncclSuccess。

13.2 ibvcore.h: ABI-контракт без зависимости от заголовочных файлов

Интуитивная модель: переводчик со своим словарём

ibvcore.h— это необычный файл — он заново определяет основные структуры, перечисления, константы libibverbs.Почему? Потому что NCCL должен использовать эти типы без#include <infiniband/verbs.h>.

〔Проектные предположения и архитектурные компромиссы〕

Это решает реальную инженерную проблему:infiniband/verbs.hсодержимое различается в разных дистрибутивах и версиях драйверов. Если NCCL включает его напрямую, на этапе компиляции он привязывается к определённой версии. А определяя собственное "минимально необходимое подмножество", NCCL может не требовать IB-заголовков при компиляции и загружать библиотеку любой версии во время выполнения черезdlopen.

Если бы этого слоя не было, катастрофа была бы такой:на машинах без установленногоlibibverbs-devневозможно скомпилировать NCCL. Хотя фактически во время выполнения может бытьrdma-coreпредоставлен файл библиотеки.

Разметка памяти ключевых структур

Мы разберём несколько структур, наиболее важных для понимания RDMA.

ibv_gid: глобальный идентификатор

📎 src/include/ibvcore.h:58-64

c
union ibv_gid {
	uint8_t			raw[16];
	struct {
		uint64_t	subnet_prefix;
		uint64_t	interface_id;
	} global;
};

GID — это "IP-адрес" InfiniBand, 16 байт. К нему можно обращаться как к массиву из 16 байт, так и как к двум 64-битным целым. В сценарии RoCE (RDMA over Converged Ethernet) GID фактически является адресом IPv6 — именно поэтомуibvGetGidStrиспользуется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мог корректно интерпретировать эти 16 байт.

ibv_mr: дескриптор регистрации памяти

📎 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;
};

Это ядро GPUDirect RDMA.addr— это начальный адрес зарегистрированной памяти (может быть память хоста или адрес GPU-памяти, отображённой в хост),length— длина.lkey(local key) иrkey(remote key) — это "ключи", используемые сетевой картой для проверки прав доступа — отправитель включаетlkeyв WQE, получатель проверяет с помощьюrkey.

〔Проектные предположения и архитектурные компромиссы〕

Почему требуется регистрация? Потому что сетевая карта при DMA использует физические адреса, аaddr— виртуальный адрес. Процесс регистрации заставляет драйвер "закрепить" (pin) таблицу страниц этого виртуального адреса, установить отображение IOMMU и вернутьlkey/rkeyв качестве дескриптора для последующих ссылок. Регистрация дорогостояща (включает обход таблицы страниц и программирование IOMMU), поэтому NCCL кэширует MR, чтобы избежать регистрации при каждой передаче.

ibv_send_wr: рабочий запрос на отправку

📎 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— это список scatter-gather,opcodeопределяет тип операции (RDMA_WRITE, SEND и т.д.),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 считывается аппаратурой сетевой карты и должен быть в фиксированном 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— это функция пути управления, вызываемая редко, и таблица символов для неё не имеет значения.

Обёртка NCCLwrap_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; })

дополнительно инкапсулирует эту проверку: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Копироватьwrap_ibv_query_portЕсли базовая библиотека не поддерживает расширенный

📎 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;
}

откатывается к старому API:memset(port_attr, 0, sizeof(*port_attr))Копироватьactive_speed_exОбратите внимание на

— перед откатом выполняется обнуление, поскольку старый API не заполняет новые поля вроде

, и без обнуления можно было бы прочитать мусорные значения из стека.

13.3 Конечный автомат QP и искусство повторных попыток в modify_qp

Интуитивная модель: QP — это полный процесс «телефонного звонка»Queue Pair (QP) — базовая единица RDMA-связи, включающая очередь отправки (SQ) и очередь приёма (RQ). Установить QP — как позвонить по телефону: сначала набрать номер (RESET→INIT), дождаться ответа (INIT→RTR), убедиться, что обе стороны слышат друг друга (RTR→RTS), и только затем можно разговаривать.Если конечный автомат QP даёт сбой, катастрофа такова:ibv_modify_qpсетевая карта не может установить соединение, вся межмашинная связь терпит неудачу, задача обучения зависает или падает

. А переходы состояний QP как раз наиболее подвержены проблемам — дрожание сети, изменение GID, ошибки межрельсовых соединений приводят к сбою

📎 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
};

Перечисление состояний и переходы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〔Проектные предположения и архитектурные компромиссы〕IBV_QPS_SQEОбратите внимание на состояния

(SQ Drained) и

wrap_ibv_modify_qp(SQ Error). SQD используется для изящного завершения — сначала опустошается очередь отправки, затем выполняется переход. SQE означает ошибку очереди отправки. NCCL на нормальном пути не переходит в эти два состояния самостоятельно, но при обработке ошибок их необходимо распознавать.

📎 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Пошаговый разбор:timeOutШаг первый: чтение параметров

, по умолчанию 34 повторные попытки, то есть максимум 35 попыток.по умолчанию 100 миллисекунд.attempts == 0Шаг второй: вход в цикл повторных попытокsleepTime = timeOut * attempts. В первый раз, без sleep, вызов выполняется сразу. Затем при каждой неудаче— это

линейная задержка。IBV_MQP_RETRY_ERRNO_ALL(ret): 1-я повторная попытка ждёт 100 мс, 2-я — 200 мс, 34-я — 3400 мс.

📎 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или

. Если установлен。ibvModifyQpLog, повторные попытки выполняются для любой ненулевой ошибки.

📎 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");
  // ...
}

собирает имя устройства, номер порта, текущее состояние, целевое состояние, локальный/удалённый GID: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〔Проектные предположения и архитектурные компромиссы〕EINVALЭта подсказка — кристаллизация производственного опыта.

Управление параллелизмом и взаимодействие с оборудованием

wrap_ibv_modify_qpсам по себе не блокируется — он предполагает, что вызывающая сторона гарантирует, что один и тот же QP не будет одновременно изменяться несколькими потоками. В NCCL это выполняется: установка QP происходит на этапе инициализации одним потоком.

〔Проектные предположения и архитектурные компромиссы〕

Но в цикле повторных попытокstd::this_thread::sleep_forзаслуживает внимания. Он уступает CPU, но не освобождает никаких блокировок (поскольку их и не было). При вызове этой функции в прокси-потоке sleep блокирует продвижение прокси — если установка QP застрянет, вся коммуникация остановится. Именно поэтому по умолчанию число повторных попыток равно 34, а общее время составляет около 60 секунд — достаточно для покрытия кратковременных сетевых колебаний, но без бесконечного ожидания.

13.4 Регистрация памяти: вход в GPUDirect RDMA

Интуитивная модель: выдать сетевой карте «пропуск»

Чтобы сетевая карта могла напрямую читать и записывать память, она должна сначала «познакомиться» с этим участком памяти. Регистрация памяти (ibv_reg_mr) — это выдача сетевой карте пропуска: ей сообщается физический диапазон адресов этой памяти и возвращаетсяlkey(локальный ключ) иrkey(удалённый ключ). После этого при выполнении DMA сетевая карта обращается по этому ключу.

Если регистрация памяти отсутствует, катастрофа такова:сетевая карта не может получить доступ ни к какой памяти, RDMA полностью не работает. Более скрытая проблема: если зарегистрирована память хоста, но требуется доступ к памяти GPU, сетевая карта прочитает неверные данные или вызовет ошибку защиты.

Три пути регистрации

NCCL инкапсулирует три функции регистрации памяти, соответствующие разным сценариям использования:

Путь первый: обычная регистрация

📎 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и т. д.).

Путь второй: регистрация с указанием 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) позволяет указать адрес, видимый сетевой карте. Это полезно в сценариях, требующих фиксированного отображения адресов. Обратите внимание, что приret == NULLсразу возвращается успех — это «пробный вызов», который лишь проверяет наличие функции, но не выполняет реальную регистрацию.

Путь третий: регистрация 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. NCCL черезcuMemGetHandleForAddressRangeи подобные CUDA API получает этот fd, а затем передаёт его вibv_reg_dmabuf_mr. Драйвер сетевой карты через механизм DMA-BUF напрямую отображает память GPU без копирования через память хоста.

〔Проектные предположения и архитектурные компромиссы〕

DMA-BUF — это фреймворк совместного использования буферов в ядре Linux. Драйвер GPU (например, nvidia.ko от NVIDIA) экспортирует видеопамять как DMA-BUF, драйвер сетевой карты (например, mlx5) импортирует его и устанавливает отображение IOMMU. Весь процесс выполняется в ядре, в пользовательском пространстве передаётся лишь один fd. Это и есть низкоуровневый механизм «прямого чтения и записи видеопамяти GPU сетевой картой».

Прямая регистрация против инкапсулированной регистрации

Обратите внимание, что есть две «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для проверки, поддерживает ли сетевая карта 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— это флаг оптимизации производительности: он позволяет сетевой карте использовать более слабую модель памяти, что может повысить пропускную способность, но требует от приложения гарантий корректности.

Поток данных: полный путь от памяти GPU до сетевой карты

Приведённая ниже схема показывает поток данных одной межмашинной 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 Завершение работы и диагностика ошибок

Интуитивная модель: квитанция о доставке

RDMA асинхронен — послеpost_sendвы не узнаете результат немедленно. Когда сетевая карта завершает операцию, она помещает Work Completion (WC) в Completion Queue (CQ), словно курьер опускает квитанцию в ваш почтовый ящик. Вам нужно активноpoll_cqчтобы забрать её.

Если диагностика WC отсутствует, катастрофа такова:при сбое связи вы знаете только «произошёл сбой», но не знаете «почему». Кодов ошибок RDMA более 20, и каждый соответствует своей первопричине.

Структура WC

📎 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— это метка, которую вы заполняете при post,status— статус завершения,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Ошибка локальной длиныДлина SGE превышает диапазон MR
IBV_WC_LOC_ACCESS_ERRОшибка локального доступаНедействительный lkey или недостаточно прав
IBV_WC_REM_ACCESS_ERRОшибка удалённого доступаНедействительный rkey или MR на противоположной стороне уже дерегистрирован
IBV_WC_RETRY_EXC_ERRИсчерпаны повторные попыткиСеть недоступна или QP на противоположной стороне не готов
IBV_WC_RNR_RETRY_EXC_ERRИсчерпаны повторные попытки RNRУ удалённой стороны нет post recv
IBV_WC_RESP_TIMEOUT_ERRТайм-аут ответаУдалённая сторона не отвечает
〔Проектные предположения и архитектурные компромиссы〕

IBV_WC_RNR_RETRY_EXC_ERR(Receiver Not Ready) — одна из наиболее распространённых проблем в производственной среде. Она означает, что отправитель отправил данные, но получатель не выполнил предварительный post достаточного количества recv buffer. В 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быстро определить, «является ли это завершением приёма». Это соглашение API-дизайна libibverbs.

Опрос 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— этоbusy polling— она не блокируется, а немедленно возвращает управление. Поток proxy в NCCL будет многократно вызывать её в цикле, пока не получит событие завершения. Это ключ к низкой задержке: по сравнению с управлением по прерываниям, busy polling позволяет избежать накладных расходов на переключение контекста прерывания. Цена — высокая загрузка CPU, но в сценариях высокопроизводительных вычислений это приемлемо.

13.6 Руководство по избеганию проблем в производственной среде

Проблема первая: тайм-аут соединения между rail

Симптом:ibv_modify_qpвозвращаетETIMEDOUT, после 34 повторных попыток происходит сбой.

Корневая причина: В многоrail-сети каждый GPU обычно привязан к определённому NIC. Если GPU 0 ранга A привязан к NIC 0, GPU 0 ранга B привязан к 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.

Проблема вторая: ошибка индекса GID

Симптом:ibv_modify_qpвозвращаетEINVAL。

Корневая причина:NCCL_IB_GID_INDEXпринудительно указан несуществующий индекс GID, либо во время работы GID сетевой карты изменился (например, RoCE-карта повторно получила 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.

Проблема третья: отсутствие поддержки DMA-BUF приводит к откату на копирование через host

Симптом: GPUDirect RDMA не работает, производительность ниже ожидаемой.

Корневая причина: Драйвер сетевой карты или ядро не поддерживают DMA-BUF,wrap_direct_ibv_reg_dmabuf_mrвозвращает NULL и устанавливает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, верхний уровень ошибочно воспримет это как «ошибку», а не «отсутствие поддержки».

Диагностика: Проверьте версию ядра (требуется 5.12+), версию драйвера сетевой карты, а также загружен ли модульnvidia-peermem. Если поддержки действительно нет, NCCL откатится на промежуточное копирование через память host — производительность снизится, но функциональность сохранится.

Проблема четвёртая: кэш MR и утечка памяти

〔Проектные предположения и архитектурные компромиссы〕

Регистрация памяти — дорогостоящая операция (связана с программированием IOMMU), NCCL кэшируетibv_mr. Но при неправильной стратегии кэширования возникают две проблемы: во-первых, утечка памяти (MR никогда не дерегистрируется), во-вторых, инвалидация кэша (память освобождена, но MR всё ещё указывает на старый адрес).

wrap_ibv_dereg_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");
}
〔Проектные предположения и архитектурные компромиссы〕

В производственной среде, если задача обучения часто создаёт/уничтожает коммуникационные домены, а MR не дерегистрируются должным образом, это приводит к разрастанию таблицы отображений IOMMU и в конечном итоге вызывает сбойibv_reg_mr(возвращаетENOMEM). Метод диагностики — мониторинг количества отображений в/sys/kernel/debug/iommu.

Размышления о дизайне: почему слой инкапсуляции такой «толстый»

Оглядываясь на эту главу,ibvwrap.ccсодержит 509 строк,ibvcore.hсодержит 1134 строки. Для слоя инкапсуляции, который «просто вызывает libibverbs», это довольно большой объём. Почему?

〔Проектные предположения и архитектурные компромиссы〕

Три причины:

Первая — сложность обработки ошибок. Соглашения об ошибках в API libibverbs крайне неоднородны, NCCL приходится писать макрос для каждого соглашения и правильно использовать его в каждой функции. Это не избыточное проектирование, а необходимая цена «достоверного перевода».

Вторая — бремя совместимости ABI。ibvcore.hпереопределяет все структуры, а также обрабатывает определение версииverbs_context. Это делается для того, чтобы не зависеть от заголовочных файлов IB на этапе компиляции и быть совместимым с любой версией во время выполнения.

Третья — ценность диагностической информации。ibvModifyQpLog、printIbModifyQpHint、ibvWcStatusStr这些函数在正常路径下不会被调用,但在故障诊断时其价值巨大。NCCL倾向于将诊断信息“预先嵌入”封装层,而不是在错误发生时临时收集。

Цена такой «толстой инкапсуляции» — большой объём кода и высокие затраты на сопровождение. Но выгода в том, что верхний уровеньnet_ib.ccможет быть написан с использованием единого интерфейсаncclResult_t, не заботясь о различных причудах libibverbs. Это типичный дизайн «изоляции сложности».

Итоги главы

В этой главе мы углубились в уровень инкапсуляции транспорта InfiniBand в NCCL, ключевые моменты:

1. Инкапсуляция таблицы символов:ncclIbvSymbolsЧерезdlopen + dlsymзагрузка libibverbs во время выполнения, в сочетании сstd::once_flagобеспечивается потокобезопасная инициализация. Это позволяет NCCL загружаться даже на машинах без драйвера IB.

2. Контракт ABI:ibvcore.hПереопределены основные типы libibverbs, через__VERBS_ABI_IS_EXTENDEDмагический указатель иverbs_contextизcontainer_ofприём реализовано определение версии.

3. Конечный автомат QP:wrap_ibv_modify_qpРеализовано 34 линейных отката с повторными попытками, дляETIMEDOUTиEINVALвыдаются диагностические подсказки.

4. GPUDirect RDMA:wrap_ibv_reg_dmabuf_mrЧерез механизм DMA-BUF сетевой адаптер напрямую отображает память GPU,wrap_direct_ibv_reg_dmabuf_mrиспользуется для определения возможностей.

5. Диагностика ошибок:ibvWcStatusStr、ibvWcOpcodeStr、ibvWrOpcodeStrПреобразование аппаратных кодов ошибок в читаемые строки — ключевой инструмент для производственной диагностики.

Вопросы для размышления и самопроверки в этой главе

В1: Если в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 для прямого доступа сетевого адаптера к памяти GPU. Этот механизм решает проблемы задержки и пропускной способности при межмашинной коммуникации. Но внутримашинная коммуникация не менее важна — в следующей главе мы перейдём к симметричной памяти и NVLS, чтобы увидеть, как NCCL использует многоадресную рассылку NVLink для аппаратно-ускоренной коллективной коммуникации. Тогда вы обнаружите, что механизм RDMA из этой главы и NVLS дополняют друг друга: первый отвечает за межмашинную связь, второй — за внутримашинную.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 14

Глава 14: Симметричная память и многоадресная рассылка NVLS: масштабирование в NVLink

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 14 из 25

В предыдущей главе мы проследили за одним межмашинным AllReduce и увидели, как данные из памяти GPU через сетевой адаптер достигают GPU на другом конце — этот путь решает задачу коммуникации между машинами. Но в современных AI-кластерах объём коммуникации между GPU внутри одной машины и даже внутри одного домена NVLink также огромен — синхронизация градиентов при параллельном обучении по данным, обмен активациями при тензорном параллелизме, подавляющее большинство происходит внутри машины. Если внутримашинная коммуникация всё ещё идёт по межмашинному маршруту GPU→память→сетевой адаптер→сетевой адаптер на другом конце→память→GPU, это равносильно отправке посылки внутри города авиапочтой — задержка тратится впустую. В этой главе мы разберём именно два инструмента, которые NCCL подготовил для внутримашинной коммуникации: симметричную память и NVLS. Первый позволяет каждому rank использовать один и тот же набор виртуальных адресов для доступа к буферам всех rank, второй использует возможности многоадресной рассылки аппаратного обеспечения NVSwitch для выполнения редукции. В сочетании они позволяют снизить задержку коллективной коммуникации для малых сообщений почти до аппаратного предела.

14.1 Симметричная память: пусть "3-й ряд, 5-е место" указывает на одно и то же место в доме каждого

Интуитивная модель

Представьте класс, которому нужно обменяться тетрадями. Традиционный подход: каждый нумерует свои тетради, затем кричит "Чжан Сан, моя 5-я тетрадь тебе; Ли Сы, моя 8-я тетрадь тебе" — каждому нужно запоминать "чья тетрадь где лежит, какая по счёту". Это обычная коммуникация: адресаотносительные, приватные, чтобы обратиться к данным на другом конце, нужно сначала знать отображение адресов на том конце.

Симметричная память предлагает другой подход: весь класс договаривается, что координата "3-й ряд, 5-е место" в доме каждого указывает на одно и то же физическое место. Тогда Чжан Сану, чтобы взять 5-ю тетрадь Ли Сы, достаточно сказать "дом Ли Сы, 3-й ряд, 5-е место" — никакого преобразования адресов не нужно. Это и есть суть симметричной памяти:буферы каждого rank отображаются на одинаковые виртуальные адреса в адресном пространстве всех rank。

〔Проектные предположения и архитектурные компромиссы〕

Что за катастрофа была бы с внутримашинной коллективной коммуникацией без симметричной памяти? Каждый rank при обращении к буферу на другом конце проходил бы через "преобразование адресов" — поиск в таблице, вычисление смещения, возможно, ещё и межпроцессную коммуникацию для подтверждения отображения. Для малых сообщений (несколько КБ) накладные расходы на это преобразование могли бы превысить стоимость передачи самих данных. Симметричная память полностью устраняет эти накладные расходы — именно в этом коренная причина того, что она "значительно снижает задержку для малых сообщений".

Структуры данных и разметка памяти

Тип регистрации симметричной памяти описывается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;
}

Эти четыре состояния определяют, по какому пути пойдёт последующее ядро: полностью симметричная регистрация (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 больше 2 (два ранга быстрее соединяются напрямую точка-точка, многоадресная рассылка не нужна), и нет пересечения clique (при пересечении clique многоадресная рассылка NVSwitch недоступна). Это решение напрямую определяет,reqs.lsaMultimemбудет ли установлен флаг, что в свою очередь влияет на распределение ресурсов коммуникатора на стороне устройства.

Пошаговое руководство, управляемое сценарием

Предположим, мы инициируем AllReduce, размер сообщения 4 КБ, 8 рангов находятся в одном домене NVLink.ncclSymkMaskопределит, какие ядра доступны.

📎 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) извлекается набор кандидатов ядерkernelMask_AR. Шаг второй: проверяетсяhasLsaMultimem, если многоадресная рассылка поддерживается, то далее проверяется, поддерживают ли тип данных и операция редукции LDMC (Load-Multicast). Шаг третий: с помощью битовой маски удаляются неподдерживаемые функции —kmask &= ~kernelMask_STMCвсе ядра, не поддерживающие STMC, исключаются.

Затем ограничение по размеру:

📎 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 используют 32-битные целые для отслеживания количества элементов, поэтому когда количество байтов на шине превышает 2 ГБ, ядра LL исключаются; когда превышает 64 ГБ, все ядра исключаются (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 меньше общего числа рангов» — то есть GIN имеет смысл только тогда, когда коммуникационный домен выходит за границы LSA (требуется передача по сети). Если весь коммуникационный домен находится внутри LSA, ядра GIN исключаются.

Управление параллелизмом и взаимодействие с оборудованием

Разрешение адресов симметричной памяти в конечном итоге выполняется на стороне устройства.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абсолютный адрес. Такая конструкция позволяет ядру на стороне устройства использовать одну и ту же логику для обработки зарегистрированных и незарегистрированных буферов.

ncclSymkInitOnceтакже инициализирует ресурсные требования, связанные с GIN, включая inbox, outbox, буфер аккумуляции и 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с помощью модели настройки вычисляет необходимое число блоков и размер буфера, затем они ограничиваются до диапазона[minCTAs, maxCTAs].rsGinAccumBytesPerBlock— это размер буфера аккумуляции на каждый блок, выровненный до 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, — и в итоге возвращается битовая маска. Каждый фильтр может исключить группу ядер, что как раз и отражает принцип NCCL «выбор оптимального ядра под сценарий».

Руководство по избеганию проблем в production

Проблема 1: при пересечении clique многоадресная рассылка молча отключается. hasLsaMultimemТретье условие!comm->p2pCrossClique— этоncclNvlsSymmetricMultimemEnabled. Если ваш кластер настроен с MNNVL (Multi-Node NVLink), но некоторые ранги пересекают clique, многоадресная рассылка будет отключена, и производительность незаметно деградирует до обычного пути. При диагностике смотрите вывод логов

. ncclSymkMaskПроблема 2: неявное требование 16-байтового выравнивания.if (!symAligned16B) kmask &= ~kernelMask_Tma;ВcudaMalloc— если пользовательский буфер не выровнен по 16 байтам, ядро TMA исключается. TMA — самый быстрый движок копирования на Hopper/Blackwell, и его потеря означает снижение производительности. В production буферы, передаваемые пользователем, часто происходят из

, естественно выровнены; но если они происходят из пользовательского аллокатора или среза, можно наткнуться на проблему.Проблема 3: граница 2 ГБ.ncclInvalidArgument。

---

Ядра LL используют 32-битные индексы, и при превышении 2 ГБ байтов на шине они исключаются. Для обучения больших моделей градиент одного AllReduce может превысить это значение, и тогда NCCL автоматически переключится на протокол STMC или Simple. Это не баг, но если вы вручную указали протокол LL, вы получите

14.2 NVLS: пусть аппаратное обеспечение NVSwitch выполняет редукцию за вас

Интуитивная модель

Традиционный AllReduce — это «программная редукция»: каждый GPU отправляет данные соседу, сосед выполняет сложение, затем пересылает дальше — данные перемещаются между GPU туда-сюда, а сложение выполняется на SM. Это как если бы 8 человек передавали записку для вычисления суммы: каждый должен прочитать, сложить и передать дальше.NVLS предлагает другой подход: микросхема NVSwitch имеет встроенные возможности。Вы записываете данные по адресу многоадресной рассылки, NVSwitch автоматически транслирует их всем участникам и выполняет сложение на аппаратном уровне. Это как если бы 8 человек записали числа на одной доске, а доска автоматически показала сумму — GPU записывает один раз, читает один раз, а все промежуточные пересылки и сложения выполняются аппаратурой коммутатора.

Без NVLS пропускная способность внутриузлового AllReduce ограничивалась бы соединениями точка-точка между GPU, и SM тратили бы множество циклов на сложение. NVLS перекладывает обе эти задачи на аппаратуру, позволяя SM заниматься другими вычислениями.

Структуры данных и layout памяти

Ядро 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 рангов должны создать домен 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;
  }

Шаг первый: суммировать размеры всех запросов, чтобы получить общий размер группы многоадресной рассылки. Шаг второй: запросить у CUDA рекомендуемую и минимальную гранулярность — это аппаратное ограничение, адрес и размер объекта многоадресной рассылки должны быть кратны гранулярности. Шаг третий: bump-аллокация — каждому запросу выделяется блок, смещение и размер выравниваются по рекомендуемой гранулярности.ALIGN_SIZE(capacity, align)Гарантирует, что начальное смещение каждого среза является допустимым смещением для привязки.

Далее — создание и импорт между рангами:

📎 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; остальные ранги принимают handle и импортируют его.cuMulticastAddDeviceДобавляет локальное устройство в группу многоадресной рассылки. Обратите внимание на барьер — в комментарии чётко сказано:cuMemMapБлокируется до тех пор, пока все устройства не присоединятся; если какой-либо peer откажет доcuMulticastAddDevice, выжившие зависнут вcuMemMap. Этот барьер позволяет захватить отказ через флаг 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. Шаги диагностики: сначалаубедиться, что проблема исчезла, затем проверить логи Fabric Manager и версию прошивки NVSwitch.NCCL_NVLS_ENABLE=0Проблема 2: несовпадение гранулярности UC/MC.

Проверка границ в ncclMcPartitionBindMemпоймает эту проблему, но если вы видите предупреждение «UC/MC granularity mismatch», это означает, что размер UC какого-то запроса после выравнивания вышел за пределы раздела MC. Обычно это происходит, когда размер запроса близок к границе гранулярности.

Проблема 3: утечка ресурсов после неудачного создания группы многоадресной рассылки. ncclMcGroupBuildPartitionsВ пути отказаCUCALLиспользуется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 является дефицитным ресурсом, а утечка приведёт к сбою последующего создания. Это типичный пример проектирования по принципу «путь очистки должен быть best-effort».

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 предотвращает сбой peer при блокировке cuMemMap"
    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— по одному слоту barrier на каждый block. LLA2A — сокращение от low-latency 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 должны работать совместно — без мультикаста все kernel'ы серии MC недоступны.

Получив на стороне устройства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(credit),dataPartition(data),ubPartition(user buffer). Раздел 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 байта — ровно половину cache line.NCCL_NVLS_MIN_POLLфлаг заставляет получателя использовать режим минимального опроса, снижая нагрузку на CPU.

Руководство по избежанию проблем в production

Проблема 1: конкуренция head/tail в разделе credit.Несколько channel'ов совместно используют одну мультикаст-группу, но каждый channel имеет независимую область credit. Если число channel'ов настроено неправильно (например,nvlsCTAsзадано слишком большим), область credit раздувается, занимая ценное мультикаст-адресное пространство.ncclNvlsChannelsавтоматически подстраивает число channel'ов в зависимости от архитектуры GPU и числа узлов:

📎 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-регистрацию для повышения производительности, вы обнаружите, что регистрация не вступила в силу. Это аппаратное ограничение, а не bug.

Проблема 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);
  }

Дочерний коммуникационный домен повторно использует ресурсы родительского домена, счётчик ссылок увеличивается на единицу.ncclNvlsFreeТолько когда счётчик ссылок уменьшается до нуля, ресурс действительно освобождается. Если управление счётчиком ссылок работает неправильно, это приведёт к преждевременному освобождению ресурса или утечке. Обратите внимание, что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; если нет, используется локальная видеопамять. Это решение автоматически принимается аппаратным обеспечением на основе диапазона адресов и не требует вмешательства программного обеспечения.

---

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использует

для гарантии освобождения ресурсов.

Вопросы для размышления и самопроверки в этой главеncclMcPartitionBindMemQ1: Если убрать проверку границif (offsetInPartition + bindSize > partition->size)в

, в каких сценариях возникнет выход за границы памяти? Почему эту проверку нельзя заменить утверждением «гранулярность UC и MC одинакова»?Справочный разбор📎 src/transport/multicast.cc:200-208:

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 15

Глава 15: RMA и GIN: архитектура удаленной коммуникации между GPU следующего поколения

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 15 из 25

Глава 15: RMA и GIN: эволюция удалённого доступа к памяти и прямого взаимодействия GPU с сетью

В предыдущей главе мы увидели, что симметричная память позволяет каждому rank использовать один и тот же набор адресов для доступа к буферам всех rank, а NVLS с помощью возможностей многоадресной рассылки NVSwitch доводит аппаратно-ускоренную редукцию до предела. Но коллективная коммуникация — это не всё: когда приложению требуются двухточечные операции с удалённой памятью или когда GPU kernel должен напрямую инициировать сетевые запросы, на сцену выходят RMA и GIN. RMA предоставляет удалённый доступ к памяти с семантикой put/get, а GIN позволяет GPU обходить прокси-потоки host и напрямую взаимодействовать с сетью. В этой главе в порядке «сначала RMA, затем GIN» последовательно разбираются структуры данных, логика планирования, управление конкурентностью и производственные ловушки этих двух механизмов.

Двухканальная модель RMA: разделение обязанностей между CE и Proxy

Представьте систему международной курьерской доставки: городская доставка (ранги, достижимые через LSA) может быть выполнена непосредственно местным курьером, тогда как междугородняя доставка (ранги, недостижимые через LSA) должна быть передана авиационному грузовому агенту. RMA в NCCL — это именно такая модель: одна и та же операция put, в зависимости от того, находится ли целевой ранг в команде LSA (Load-Store Accessible), маршрутизируется по двум совершенно разным путям выполнения: путь CE (Copy Engine, движок копирования) и путь Proxy (прокси-поток).

如果没有这个分离机制,所有RMA操作都会走代理线程,那么同一节点内的put也会经过中间主机线程,从而在主机与设备之间的往返上增加额外延迟。反过来,如果所有操作都走CE,跨机操作就无法利用网络插件的异步能力。

Структуры данных и разметка памяти

Основной структурой планирования RMA являетсяncclRmaArgs, которая фиксирует результат разделения задач RMA в плане. Ключевые поля включают:

ПолеЗначение
funcТип операции (PutSignal / Signal / WaitSignal)
nRmaTasksОбщее количество задач
nRmaTasksProxyКоличество задач, идущих по пути proxy
nRmaTasksCeКоличество задач, идущих по пути CE

Внутри каждого плана поддерживаются две интрузивные очереди:rmaTaskQueueCeиrmaTaskQueueProxy, в которых хранятся задачи соответствующих путей.📎 src/rma/rma.cc:166-171

Логика определения, достижим ли ранг через LSA, довольно прямолинейна — перебор массиваlsaRankListс линейным поиском.📎 src/rma/rma.cc:34-41Этот поиск выполняется один раз для каждого peer при планировании задач, сложность O(lsaSize), и для типичной небольшой команды LSA (обычно 2-8 рангов) накладные расходы пренебрежимо малы.

Пошаговый процесс планирования

Когда приложение вызывает операцию RMA put, задача попадает вplanner->rmaTaskQueues[ctx]。scheduleRmaTasksToPlan, который отвечает за распределение задач из очереди по планам.📎 src/rma/rma.cc:141-296

Шаг первый: найти первую непустую очередь контекста. NCCL поддерживает несколько контекстов RMA (настраиваемых черезnumRmaCtx), каждый контекст имеет независимую очередь.📎 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 логика сложнее — планировщик обходит очереди всех контекстов и извлекает все последовательные задачи put/signal в один план, останавливаясь только при встрече с WaitSignal.📎 src/rma/rma.cc:279-295Цель этого дизайна ясно описана в комментариях: позволить одному запуску ядра охватить put/signal всех контекстов, чтобы proxy мог инициировать все асинхронные запросы за один раз до любой блокирующей операции, а путь CE — пакетно отправить копирования и сигналы всех контекстов.📎 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["Разделение peers по isLsaAccessible"]
    ws_ce{"npeersCe > 0?"}
    ws_proxy{"npeersProxy > 0?"}
    ws_ce_task["Создание задачи CE WaitSignal"]
    ws_proxy_task["Создание задачи Proxy WaitSignal"]
    ws_free["Освобождение исходного firstTask"]
    put_check{"peer у firstTask доступен через 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, когда в плане одновременно присутствуют задачи proxy и CE, оба пути должны выполняться параллельно. Подход NCCL таков: записать событие во входном потоке, заставить поток CE ждать этого события, затем одновременно запустить операции в обоих потоках и, наконец, записать ещё одно событие в потоке CE, чтобы входной поток ждал его.📎 src/rma/rma.cc:80-96Эта цепочка событий гарантирует: операции CE не начнутся до того, как зависимости входного потока будут готовы, а последующие операции входного потока не начнутся до завершения CE.

Если есть только задачи proxy или только задачи CE, соответствующая операция запускается непосредственно во входном потоке без дополнительной синхронизации потоков.📎 src/rma/rma.cc:97-101

Размышления о дизайне и производственные ловушки

Ловушка первая: статичность определения достижимости через LSA. isLsaAccessibleПри планировании запрашиваетсяcomm->devrState.lsaRankList, и этот список не меняется после инициализации коммуникационного домена. Если в процессе работы топология изменится (например, деградация из-за сбоя NVLink), список LSA не обновится автоматически, что может привести к тому, что операции, которые должны идти через proxy, по-прежнему пойдут по пути CE, вызвав неисправимую ошибку.

Ловушка вторая: гарантия FIFO при пакетном объединении.Логика пакетного объединения извлекает только последовательные задачи put/signal и останавливается при встрече с WaitSignal.📎 src/rma/rma.cc:283Это гарантирует порядок FIFO внутри каждого контекста, но задачи из разных контекстов могут быть объединены в один план. Если приложение зависит от порядка операций между контекстами, необходимо явно использовать WaitSignal для установки барьера.

Ловушка третья: путь утечки памяти.В ветке 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Размер очереди должен быть степенью двойки, чтобы зацикливание индекса можно было реализовать побитовым И& (queueSize - 1)вместо взятия по модулю.📎 src/rma/rma_proxy.cc:156-160

Очередь InProgress: по одному интрузивному связному списку на каждый peer, хранящему дескрипторы, отправленные сетевому плагину, но ещё не завершённые.📎 src/rma/rma_proxy.cc:170-175Это очередь с одним потребителем, доступ к ней имеет только поток proxy, атомарные операции не требуются.

Пошагово: от создания контекста до продвижения прогресса

Создание контекста: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Это позволяет плагину оптимизировать путь записи сигнала — не требуется обнуление перед каждой записью.

Производственные ловушки

Ловушка первая: размер очереди не является степенью двойки.Если пользователь черезNCCL_RMA_PROXY_QUEUE_SIZEзадал значение, не являющееся степенью двойки, код откатывается к значению по умолчанию и печатает INFO-лог.📎 src/rma/rma_proxy.cc:156-159Этот откат происходит молча (только уровень INFO), и в производственной среде его легко не заметить. Если пользователь ожидал большую очередь для поглощения всплесков трафика, а фактически используется значение по умолчанию, это может привести к обратному давлению.

Ловушка вторая: цепочка откатов при неудачной регистрации DMA-BUF. ncclRmaProxyRegMrSymДля регистрации памяти CUDA существует три уровня отката: сначала попытка DMA-BUF в режиме DataDirect, при неудаче — DMA-BUF без DataDirect, и только при повторной неудаче — откат к обычномуregMrSym。📎 src/rma/rma_proxy.cc:76-108В комментариях особо предупреждается: если один MR перешёл на путь без DataDirect, все остальные MR также должны это сделать, смешанное использование нарушит гарантии порядка GIN.📎 src/gin/gin_host_proxy.cc:429-430Это ограничение явно не проверяется в пути RMA и является потенциальной скрытой проблемой.

Ловушка третья: задержка распространения ошибки потока прогресса.КогдаncclRmaProxyProgressвозвращает ошибку, поток устанавливаетasyncResultи завершается.📎 src/rma/rma_proxy.cc:366-369Но основной поток может в данный момент выполнять длительное ядро и не проверять немедленноasyncResult. В течение этого времени последующие операции RMA продолжат ставиться в очередь, но не будут обрабатываться, пока основной поток не обнаружит ошибку. Это присущая асинхронному распространению ошибок задержка; приложению необходимо периодически вызыватьncclCommGetAsyncError, чтобы сократить это окно.

Архитектура GIN: GPU напрямую инициирует сетевые запросы

Интуитивная модель

В традиционной модели для отправки сетевых данных GPU должен пройти путь «GPU → память хоста → прокси-поток → сетевая карта». Цель GIN (GPU-Initiated Networking) — позволить GPU напрямую записывать в очередь отправки сетевой карты, подобно тому как CPU напрямую записывает в MMIO-регистры сетевой карты. Это требует поддержки сетевой картой doorbell-записей, инициируемых GPU, а также набора протокола связи между GPU и прокси-потоком.

Структуры данных и разметка памяти

Ключевой структурой данных GIN являетсяginProxyHostGpuCtx, представляющая контекст связи GPU-хост:

ПолеТипЗначение
queuesncclGinProxyGfd_t*Очередь GFD, размерnRanks * queueSize
pisuint32_t*Индекс производителя (запись GPU)
cisuint32_t*Индекс потребителя (запись прокси)
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, позволяющие избежать чтения при каждом обращении кcis。📎 src/gin/gin_host_proxy.cc:44-47которое может находиться в памяти GPU. Только когдаcisShadowпродвигается вперёд, выполняется пакетное обновлениеcis。

Пошагово: опрос и обработка 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.📎 src/gin/gin_host_proxy.cc:176-182Если есть, сначала скопировать первый qword (заголовок), затем дождаться готовности остальных qword.📎 src/gin/gin_host_proxy.cc:194-202После завершения копирования обнулить GFD в очереди, чтобы предотвратить повторную обработку.📎 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 имеет механизм "допускающий дыры": CI продвигается только когдаstate->done && i == cisShadow[targetRank].📎 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>, но в комментарии указано, что эта логика предполагает наличие только одного писателя.📎 src/gin/gin_host.cc:43-47В сценарии использования NCCL только главный поток изменяет связный список devComms, поэтому это предположение справедливо.

Производственные подводные камни

Подводный камень первый: расположение памяти очереди GFD. queuesПринудительно размещена в памяти хоста (forceHost=true),📎 src/gin/gin_host_proxy.cc:564Это означает, что запись GPU в GFD должна проходить через шину PCIe. Если частота записи в GFD высока (сценарий с малыми сообщениями), пропускная способность PCIe может стать узким местом. Для сравнения,cisразмещена в памяти, доступной для GPU, поскольку proxy требуется часто её обновлять.📎 src/gin/gin_host_proxy.cc:565-566

Подводный камень второй: восстановление встроенных данных.Когда GFD содержит встроенные данные, прокси должен восстановить встроенное значение из нескольких qword.📎 src/gin/gin_host_proxy.cc:298-305Логика восстановления определяет, какие qword читать, в зависимости от size: при size ≤ 4 читается только младшие 32 бита, при size > 4 читаются младшие 64 бита, при size > 6 дополнительно читаются старшие 16 бит. Эта логика сегментации должна строго соответствовать логике записи на стороне GPU; любое несоответствие приведёт к повреждению данных.

Ловушка третья: многопоточный прогресс и распределение соединений.Если для разных 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 (GPU Direct Async для AWS EFA). Это как один API с несколькими реализациями — программная эмуляция обладает наилучшей совместимостью, но посредственной производительностью, аппаратно-разгруженная версия имеет наилучшую производительность, но требует поддержки特定ных сетевых карт.

Матрица версий бэкендов

Для каждого бэкенда существует массив совместимости версий, индекс — номер версии бэкенда, значение — минимальная требуемая версия 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

ncclGinConnectOnceУстанавливаем соединение GIN.📎 src/gin/gin_host.cc:92-228

Тип соединения определяет stride: в режиме FULL stride равен 1 (соединение со всеми rank), в режиме RAIL stride равенcontiguousRanksPerHost(соединение только с rank того же rail).📎 src/gin/gin_host.cc:139-145

ВginDevCommSetupWithBackendлогика проверки stride очень строгая:

  • Запрошенный stride не может быть равен 0.📎 src/gin/gin_host.cc:318-323
  • Запрошенный stride не может быть больше stride rail team.📎 src/gin/gin_host.cc:324-330
  • Запрошенный stride должен быть кратен уже подключённому stride.📎 src/gin/gin_host.cc:331-337

Мотивация этих ограничений: иерархический барьер предполагает, что GIN как минимум подключён на уровне RAIL.📎 src/gin/gin_host.cc:325Если stride не удовлетворяет этим условиям, путь связи между некоторыми rank может отсутствовать.

Производственные ловушки

Ловушка первая: несоответствие версий бэкенда.Если версия кода устройства ниже минимальной требуемой версии бэкенда,backendVersionостанется на более низком значении.📎 src/gin/gin_host.cc:301-303Это может привести к недоступности некоторых новых функций (например, сигнал никогда не сбрасывается), но не вызовет ошибок. Однако если версия кода устройства выше всех известных версий,backendVersionпримет максимальное значение, что может вызвать неопределённое поведение.

Ловушка вторая: границы проверки stride.ЕслиrequestedStride % connectedStride != 0, создание завершается неудачей.📎 src/gin/gin_host.cc:331-337Эта проверка предполагает, что connectedStride является степенью двойки (1 для режима FULL,contiguousRanksPerHostдля режима RAIL). ЕслиcontiguousRanksPerHostне является степенью двойки (например, 3), проверка кратности может отклонить допустимый stride.

Вопросы для размышления и самопроверки к этой главе

Q1: ВscheduleRmaTasksToPlanв ветке WaitSignal, если убрать строкуplan->rmaArgs->nRmaTasks = (npeersCe > 0 ? 1 : 0) + (npeersProxy > 0 ? 1 : 0)и заменить её прямым присваиванием 1, в каких сценариях это приведёт к проблемам?

Разбор ответа: Смотрим📎 src/rma/rma.cc:248。nRmaTasksзаписывает фактическое количество поставленных в очередь задач. Если все peers достижимы через LSA (npeersProxy == 0), фактически в очередь ставится только 1 задача CE,nRmaTasksдолжно быть равно 1. Если все peers недостижимы (npeersCe == 0), фактически в очередь ставится только 1 задача Proxy,nRmaTasksтакже должно быть равно 1. Но если peers распределены смешанно, обе задачи ставятся в очередь,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— это "индекс просмотренного", обозначающий количество GFD, которые proxy уже увидел и начал обрабатывать.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не продвинулся, что приведёт к повторной обработке одного и того же слота.

Что ещё опаснее,proxyGinPollGfdПосле копирования GFD очередь GFD очищается.📎 src/gin/gin_host_proxy.cc:206-208Еслиsisне продвинулась, следующая операция опроса увидит обнулённый GFD (flag равен 0),isGfdAvailableвернёт false, что приведёт к потере 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()шага в этом протоколе рукопожатия приведёт к бессрочной блокировке.

От семантики put/get в RMA до инициируемой GPU сетевой коммуникации в GIN — мы прошли ключевой этап эволюции NCCL в сторону универсального движка удалённого доступа к памяти. Но как бы изящен ни был механизм, в конечном итоге он должен взаимодействовать с внешними сетевыми бэкендами, стратегиями тюнинга и сборщиками производительности через систему плагинов. Следующая глава погрузит нас в мир плагинов и покажет, как NCCL без изменения ядра динамически загружает расширения net, tuner, profiler, env и на примере google-fastsocket и google-CoMMA раскрывает ключевые моменты реализации расширяемости экосистемы.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 16

Глава 16: Экосистема плагинов и переменные окружения: тонкая настройка NCCL

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 16 из 25

В предыдущей главе мы увидели, как NCCL через RMA и GIN расширяет коммуникационные возможности от коллективных операций до точечного удалённого доступа, позволяя даже GPU напрямую инициировать сетевые запросы. Такая эволюция в сторону нового оборудования и сценариев с низкой задержкой предъявляет более высокие требования к гибкости коммуникационного движка: если для каждой адаптации к новой сети, новой стратегии тюнинга или новому инструменту сбора данных приходилось бы перекомпилировать ядро, NCCL было бы трудно успевать за изменениями экосистемы. В этой главе разбираются каталоги src/plugin и plugins и даётся ответ на ключевой вопрос: как NCCL без перекомпиляции ядра заменяет сетевой бэкенд, стратегию тюнинга, сборщик производительности и источник конфигурации.

16.1 Загрузчик плагинов: как plugin_open.cc превращает .so в usable бэкенд

Интуитивная модель

Представьтеplugin_open.ccкак "кадровое агентство" NCCL: у него есть список вакансий (NET, GIN, RMA, TUNER, PROFILER, ENV), каждой вакансии соответствует имя библиотеки-кандидата. Когда NCCL нужен человек на определённую позицию, агентство в фиксированном порядке идёт на рынок талантов (динамический компоновщик) искать человека, находит — подписывает контракт (dlopen), не находит — фиксирует "такого человека нет", и в итоге возвращает дескриптор. Без этого посредника NCCL мог бы только жёстко зашивать сетевой бэкенд в бинарник, и любому производителю сетевых карт для подключения пришлось бы менять исходный код 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пусто ли или имеет нулевую длину, затем есть специальная ветка: если имя начинается с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

Шаг пятый: получение абсолютного пути. getLibPathВ Linux с помощью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_PLUGIN,tryOpenLibобнуляет имя,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/collNetVirtDevsintЧисло устройств CollNet

📎 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демонстрирует прямое взаимодействие плагина с оборудованием: он выделяет 2 МБ GPU-буфера, черезlisten/connect/acceptплагина устанавливает loopback-соединение, затем пытаетсяregMrзарегистрировать память GPU.📎 src/plugin/net.cc:464-535Если регистрация успешна, это означает, что сетевая карта поддерживает GPUDirect RDMA. Результат этого зондирования кэшируется вgdrSupportMatrix[32], индексируется по номеру CUDA-устройства.📎 src/plugin/net.cc:478-480

〔Проектные предположения и архитектурные компромиссы〕

Обратите внимание, чтоgdrSupportMatrixявляетсяstaticи разделяется между коммуникационными доменами.📎 src/plugin/net.cc:478Это означает, что несколько коммуникационных доменов в одном процессе будут повторно использовать результаты зондирования, избегая повторного дорогостоящего зондирования. Но размер массива жёстко задан как 32, и на машинах с более чем 32 GPU произойдёт выход за границы — это неявное предположение о верхнем пределе.

Руководство по избеганию проблем в production

Проблема первая: плагин успешно загружен, но количество устройств равно нулю. ncclNetPluginInitПроверяетсяdevices(&ndev) != ncclSuccess || ndev <= 0и происходит переход к ветке отказа.📎 src/plugin/net.cc:202После отказа вызываетсяfinalizeдля очистки уже установленного контекста, количество устройств сбрасывается вNCCL_UNDEF_DEV_COUNT, состояние устанавливается вDisabled。📎 src/plugin/net.cc:229-234Если не выполнить эту очистку, последующие коммуникационные домены увидят плагин «инициализирован, но без устройств», что приведёт к трудно диагностируемым ошибкам.

〔Проектные предположения и архитектурные компромиссы〕

Проблема вторая:initуспешно, ноdevicesзавершается неудачей.Код использует флагinitCompletedдля отслеживания того, был лиinitуспешным.📎 src/plugin/net.cc:178-184📎 src/plugin/net.cc:198В ветке отказа только еслиinitCompletedистинно, вызываетсяfinalize。📎 src/plugin/net.cc:230Это предотвращает вызовfinalizeдля неинициализированного контекста — многие плагины вfinalizeне проверяют нулевой указатель, и ошибочный вызов приведёт к краху.

Проблема третья: счётчик ссылок при уничтожении коммуникационного домена. ncclNetPluginFinalizeСначала вызываетсяfinalizeплагина, затем уменьшается счётчик ссылок, и наконец, когда счётчик ссылок достигает нуля и плагин является внешним, выгружается библиотека.📎 src/plugin/net.cc:342-355 ncclNetPluginUnloadПроверяется, чтоdlHandleне пуст и счётчик ссылок равен нулю, только тогда действительно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, затем освобождает блокировку для вызова callback плагина; поток уничтожения должен дождаться, пока этот флаг вернётся вfalseтолько тогда можно демонтировать состояние коммуникационного домена.📎 src/plugin/profiler.cc:52-55

Пошаговый разбор: генерация и потребление одного события 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

Логирование срабатывает по степеням двойки:(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.

Руководство по избежанию проблем в продакшене

Проблема первая: утечка работы при уничтожении. ncclProfilerThreadDestroyСначала ожидается, покаiterationActiveстанет ложным, затем вызываетсяprofilerPurgeByContextдля очистки всей ожидающей работы, ссылающейся на контекст этого коммуникационного домена.📎 src/plugin/profiler.cc:1162-1169Если не выполнить эту очистку, обратный вызов плагина получит указатель на уже уничтоженный контекст, что приведёт к use-after-free.

Проблема вторая: опустошение при остановке.Когда получен сигнал остановки, но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

Проблема третья: привязка к устройству 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:341Поддерживаемые NCCL типы событий включают 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 требует отдельного потока?Поскольку callback profiler может блокироваться (например, запись в файл, сетевой запрос), вызов его в хостовом потоке замедлит коммуникацию.📎 src/plugin/profiler.cc:950-952Комментарий явно указывает: "callback плагина может блокироваться, поэтому его нельзя вызывать, удерживая блокировку".

16.5 Руководство по избеганию проблем в production и цепочка восстановления после сбоев

Проблема первая: несоответствие версий плагина приводит к падению ядра

ncclNetCheckDeviceVersionПроверитьprops.netDeviceTypeиprops.netDeviceVersion。📎 src/plugin/net.cc:153-176Если плагин сообщаетNCCL_NET_DEVICE_UNPACKверсию, несовместимую сNCCL_NET_DEVICE_UNPACK_VERSIONпри компиляции NCCL, вернутьncclInternalErrorи выдать предупреждение.📎 src/plugin/net.cc:153-176Эта проверка вызывается вncclNetPluginAssignToCommпри неудаче плагин не будет назначен коммуникационному домену.📎 src/plugin/net.cc:241

Цепочка восстановления: несоответствие версий →ncclNetCheckDeviceVersionвозвращает ошибку →ncclNetPluginAssignToCommвозвращаетisAssigned = false → ncclNetInitпродолжает попытки со следующим плагином → в итоге может откатиться к встроенному Socket-плагину.

Проблема вторая: поток profiler не может завершиться

Если плагин profiler блокируется вstopEventпотребительский поток застрянет вprofilerProgressOps,iterationActiveвсегда истинно,ncclProfilerThreadDestroyбудет ждать вечно.📎 src/plugin/profiler.cc:1166Это реальный риск взаимной блокировки.

〔Проектные предположения и архитектурные компромиссы〕

Цепочка восстановления:comm->abortFlagустановлен →waitForActionобнаруживает прерывание → возвращаетCLEANUP_AND_STOP → cleanupAndStopопустошает очередь.📎 src/plugin/profiler.cc:1017-1031Но если поток уже застрял в callback плагина, флаг прерывания не может его прервать — это ответственность реализатора плагина, callback должен иметь таймаут.

Проблема третья: утечка счётчика ссылок плагина tuner

ncclTunerPluginLoadПри успехе увеличиваетtunerPluginRefCount。📎 src/plugin/tuner.cc:98 ncclTunerPluginUnloadпри истинностиcomm->tunerPluginLoadedуменьшает.📎 src/plugin/tuner.cc:111-123Если какой-либо коммуникационный домен загрузил tuner, но при уничтоженииtunerPluginLoadedбыл случайно обнулён, счётчик ссылок никогда не обнулится, библиотека плагина никогда не выгрузится.

Вопросы для размышления и самопроверки в этой главе

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при вызове callback плагина передаёт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Этот протокол гарантирует, что во время callback плагина контекст всегда действителен.

После удаления ожидания поток уничтожения может вернуться, как только потребительский поток только что вошёл в callback плагина, что приведёт к получению плагином висячего указателя. Это типичная гонка "жизненный цикл и конкурентный доступ".

Система плагинов превратила NCCL из закрытой в открытую: сетевые бэкенды, стратегии тюнинга, сборщики производительности, источники конфигурации — всё можно заменить без изменения кода ядра. Но плагины также вносят новые поверхности отказов — несоответствие версий, гонки жизненного цикла, утечки счётчика ссылок. В следующей главе мы перейдём к подсистеме RAS и диагностики, чтобы увидеть, как NCCL обнаруживает сбои, отслеживает прогресс и обеспечивает самовосстановление в длительных задачах обучения.

Система плагинов провела чёткую границу между основным коммуникационным путём NCCL и заменяемыми компонентами; четыре типа плагинов — net, tuner, profiler, env — каждый через механизм регистрации и счётчика ссылок безопасно вмешивается в поведение во время выполнения. Но расширяемый коммуникационный движок должен не только гибко заменять компоненты, но и стабильно работать при длительном обучении — когда сетевые карты или GPU выходят из строя, как NCCL обнаруживает, отслеживает и запускает восстановление? В следующей главе мы перейдём к механизмам RAS и диагностики, чтобы увидеть, как систематически обеспечивается надёжность в production-среде.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 17

Глава 17: Механизмы RAS и отказоустойчивость: изоляция сбоев и деградация связи

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 17 из 25

В предыдущей главе мы увидели, как система плагинов позволяет провести чёткую границу между основным путём коммуникации и заменяемыми компонентами, что даёт возможность заменять сетевой бэкенд, стратегии настройки и сборщики производительности без изменения основного кода. Но расширяемость — лишь одно из измерений производственной пригодности. Другой, не менее сложный вопрос: когда один AllReduce уже работает 72 часа, а сетевая карта на какой-то машине тихо вышла из строя, на каком основании NCCL может это обнаружить, изолировать и продолжить работу? Подсистема RAS — это именно тот водораздел, который переводит NCCL от «работает» к «пригоден для производства». В этой главе мы разберём устройство механизмов обнаружения сбоев, мониторинга прогресса и самовосстановления.

17.1 Общее управление RAS: глобальный координатор с одним потоком RAS на процесс

Интуитивная модель

Представьте RAS как «дежурную комнату» всего задания. Каждый процесс NCCL (каждый rank) при инициализации открывает свою дежурную комнату, в которой сидит выделенный поток. Все коммуникационные домены (communicator) при создании, уничтожении и диагностических запросах должны сначала зарегистрироваться в дежурной комнате; дежурные комнаты, в свою очередь, через отдельную сеть RAS сообщают друг другу, «кто ещё жив, а кто уже мёртв».

Без этой дежурной комнаты NCCL мог бы полагаться только на тайм-ауты самого пути коммуникации для обнаружения сбоев — но тайм-ауты на пути коммуникации медленны и подвержены ложным срабатываниям (одно сетевое колебание может быть принято за смерть узла). RAS выносит «обнаружение сбоев» с плоскости данных на плоскость управления, используя независимый лёгкий heartbeat и диагностический канал для определения состояния здоровья.

Структуры данных и размещение в памяти

Ключевое состояние RAS разбросано по глобальным переменнымras.cc, разберём их по порядку:

ПеременнаяТипНазначение
rasInitMutexstd::mutexЗащита инициализации синглтона RAS
rasInitializedboolФлаг инициализации
rasInitRefCountintСчётчик ссылок, равный числу активных comm
rasNetListeningSocketstruct ncclSocketСлушающий сокет сети RAS
rasNotificationPipe[2]ncclSocketPairDescriptorКанал уведомлений от локального потока к потоку RAS
rasPfdsstruct pollfd*Массив poll главного цикла событий
ncclCommsstruct ncclComm**Массив указателей на все коммуникационные домены

📎 src/ras/ras.cc:49-61определяет эти глобальные состояния. Обратите внимание, чтоrasInitRefCountиспользуетncclAtomicRefCountIncrementдля увеличения/уменьшения📎 src/ras/ras.cc:129, аrasInitializedзащищён обычным bool с двойной проверкой блокировки📎 src/ras/ras.cc:103-105— это типичный паттерн «инициализация один раз, далее только чтение».

ncclCommsСтратегия выделения массиваRAS_INCREMENT * 8заслуживает внимания: он не растёт по требованию, а каждый раз расширяется на📎 src/ras/ras.cc:139-140(то есть на 32 слота)nullptr. В массиве допускаются📎 src/ras/ras.cc:135-137。

пустые ячейки (при уничтожении comm они обнуляются), новый comm повторно использует первую пустую ячейку

Сценарий пошагового разбора: от инициализации comm до запуска потока RASncclRasCommInitШаг первый:вызывается.📎 src/ras/ras.cc:101Это первая RAS-функция, вызываемая при инициализации каждого commrasInitialized. Сначала она проверяет

, и если не инициализировано, входит в критическую секцию:rasNetListeningSocket1. Инициализирует📎 src/ras/ras.cc:108-109

адресом интерфейса bootstrap-сети, порт устанавливается в 0, чтобы ядро назначило случайный📎 src/ras/ras.cc:113

2. Слушает этот сокет📎 src/ras/ras.cc:118

3. Создаёт локальный канал уведомлений📎 src/ras/ras.cc:120

4. Инициализирует диагностическую подсистемуrasThreadMain5. Запускает поток📎 src/ras/ras.cc:121

6. Регистрируетatexit(rasTerminate)для гарантии очистки при завершении процесса📎 src/ras/ras.cc:126

Шаг второй: регистрация comm.Независимо от того, первая ли это инициализация, указательcommзаписывается в массивncclComms, а📎 src/ras/ras.cc:142устанавливается в falsencclCommsSorted— поскольку порядок массива изменился, предыдущая сортировка становится недействительной.📎 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жёстко ограничен 1000 мсtimeoutMs— даже если📎 src/ras/ras.cc:664очень далеко, нужно просыпаться раз в секунду, чтобы обеспечить своевременность проверки тайм-аутов.nextWakeupЛогика диспетчеризации событий использует значение fd для маршрутизации

: если это канал уведомлений, вызывается📎 src/ras/ras.cc:684-715; если слушающий сокет — accept; иначе выполняется обходrasLocalHandleиrasSocketsHeadсписков для поиска соответствующего socket и его обработки.rasClientsHeadМеханизм локальных уведомлений: pipe + структура фиксированной длины

Локальный поток NCCL и поток RAS общаются через socketpair. Структура уведомления

имеет фиксированную длинуrasNotificationи использует📎 src/ras/ras.cc:35-46для гарантии, что она не превышаетstatic_assert— это необходимо для обеспечения атомарности записи (POSIX гарантирует атомарность записей меньше PIPE_BUF).PIPE_BUF 📎 src/ras/ras.cc:47Отправитель

используетrasLocalNotifyдля сериализации записей от нескольких пользовательских потоковrasNotificationMutex, затем выполняет запись в цикле до полного завершения📎 src/ras/ras.cc:224-237. Получатель📎 src/ras/ras.cc:224-237также читает в цикле до заполнения всей структурыrasLocalHandle, при чтении EOF возвращает📎 src/ras/ras.cc:247-256Три типа уведомлений:ncclSystemError 📎 src/ras/ras.cc:251-253。

(новый rank присоединился),RAS_ADD_RANKS(запуск диагностики),RAS_RUN_DIAG(завершение)RAS_TERMINATEПриём и передача сообщений: префикс длины + инкрементальный прогресс📎 src/ras/ras.cc:28-32。

Проводной формат сообщений RAS — «4 байта длины + тело сообщения»

. При отправке📎 src/ras/ras_internal.h:110-117сначала отправляет длину, затем тело сообщения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. Такая компоновка с «вынесением метаданных вперёд» позволяет сообщению нести локальную информацию — прогресс отправки, время постановки в очередь и т. п., — не занимая место в линейном формате.

Проектные соображения

〔Проектные выводы и архитектурные компромиссы〕

Почему используется poll, а не epoll?Сложность O(n) у poll приемлема в сценарии RAS — число RAS-соединений намного меньше числа соединений плоскости данных, а сам поток RAS не является критическим путём производительности. К тому же poll лучше переносим между платформами (совместимость с Windows).

〔Проектные выводы и архитектурные компромиссы〕

Почему для уведомления используется канал, а не условная переменная?Канал бесшовно интегрируется в цикл poll, позволяя потоку RAS использовать единыйpollожидание всех источников событий. Если бы использовалась условная переменная, потребовался бы дополнительный механизм для пробуждения poll.

mermaid
```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 Мониторинг прогресса: перенос счётчиков GPU на хост с помощью DMA

Интуитивная модель

Мониторинг прогресса похож на «тахометр» на приборной панели автомобиля. Он не участвует в управлении (не участвует в коммуникации), но постоянно копирует внутренние счётчики прогресса GPU в память хоста, позволяя хосту определить, «застрял ли этот домен коммуникации». Без него при зависании AllReduce вы увидите лишь «программа не возвращает управление», но не узнаете, вычисляет ли GPU, ждёт ли сеть или это полный дедлок.

Структуры данных и компоновка памяти

Каждому устройству CUDA соответствует одинncclGpuProgressCounterMonitorрабочий поток📎 src/ras/progress_monitor.cc:35-52:

ПолеТипНазначение
cudaDevintПривязанный номер устройства CUDA
threadstd::threadРабочий поток
mutex / cvstd::mutex / condition_variableЗащита изменяемого состояния и пробуждение
running / shouldStopboolФлаг жизненного цикла потока
copyInFlightboolЕсть ли DMA-копирование в процессе
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。

Шаг второй: запуск рабочего потока. createGpuProgressCounterMonitorсоздаёт worker, устанавливаетcudaSetDevice, создаётsideStream(cudaStreamNonBlocking) иcopyDoneсобытие📎 src/ras/progress_monitor.cc:280-282, после запуска потока ждёт не более 2000 мс подтверждения, чтоrunningстало true📎 src/ras/progress_monitor.cc:287-303。

Шаг третий: циклическое копирование. progressCounterMonitorLoopСначала привязываем устройство, устанавливаем режим relaxed для захвата потока (чтобы не мешать graph capture приложения)📎 src/ras/progress_monitor.cc:97-121, затем входим в основной цикл:

1. ОжиданиеpollIntervalMs(по умолчанию 1000 мс)📎 src/ras/progress_monitor.cc:132-136

2. Если предыдущее копирование ещё в процессе, с помощьюcudaEventQueryпроверяем📎 src/ras/progress_monitor.cc:140. ЕслиcudaErrorNotReadyи превышен порог stale (по умолчанию 5000 мс), выдаём оповещение с ограничением частоты📎 src/ras/progress_monitor.cc:141-154

3. Обходим все зарегистрированные comm, для каждого вызываемcudaMemcpyAsyncкопируяdeviceCountersBlockвhostCountersBlock 📎 src/ras/progress_monitor.cc:170-185

4. Если хоть одно копирование успешно, записываемcopyDoneсобытие и устанавливаемcopyInFlight 📎 src/ras/progress_monitor.cc:194-202

Управление параллелизмом и ограничение частоты

Ограничение частоты оповещений реализуется черезprogressCounterMonitorShouldWarn📎 src/ras/progress_monitor.cc:78-87: под защитойwarningMutexпроверяется, превышает ли время с последнего оповещенияwarnIntervalNs, и только тогда обновляется и возвращается true. По умолчаниюstaleWarnSecравно 600 секундам📎 src/ras/progress_monitor.cc:27, то есть одно оповещение данного типа не чаще раза в 10 минут.

Параметры имеют нижнюю границу: минимальный интервал poll — 50 мс📎 src/ras/progress_monitor.cc:29, минимальный порог stale — 1000 мс📎 src/ras/progress_monitor.cc:30. Это предотвращает холостое вращение CPU из-за слишком агрессивных настроек пользователя.

Уничтожение: подсчёт ссылок + синхронизация потока

ncclProgressCounterMonitorDestroyЛогика уничтожения — один из самых изящных примеров параллельного проектирования в этой главе📎 src/ras/progress_monitor.cc:352-354:

1. Под глобальной блокировкой + блокировкой worker удаляем comm изregistrations📎 src/ras/progress_monitor.cc:368

2. Если удаление успешно,destroyRefs++и устанавливаемhaveDestroyRef 📎 src/ras/progress_monitor.cc:371-372

3. Если список регистраций опустел, удаляем из глобального массива и устанавливаемshouldStop 📎 src/ras/progress_monitor.cc:373-376

4. После освобождения блокировкиcudaStreamSynchronize(g->sideStream)опустошаем возможные копирования, всё ещё ссылающиеся на буфер данного comm📎 src/ras/progress_monitor.cc:393

5. НаконецreleaseGpuProgressCounterMonitorDestroyRefуменьшаем счётчик ссылок; когда он достигает нуля и очередь пуста, присоединяем поток и удаляем📎 src/ras/progress_monitor.cc:219-246

〔Проектные выводы и архитектурные компромиссы〕

Почему нуженdestroyRefs?Потому чтоcudaStreamSynchronizeвыполняется вне блокировки, и в это время другой поток может также уничтожать тот же worker. Подсчёт ссылок гарантирует, что только последний уничтожающий действительно выполнит join и delete.

mermaid
sequenceDiagram
    participant App as поток приложения
    participant Mon as поток мониторинга
    participant GPU as устройство CUDA
    App->>Mon: ncclProgressCounterMonitorInit(comm)
    Mon->>Mon: найти/создать worker
    Mon->>Mon: поставить comm в очередь registrations
    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: удалить comm из registrations, destroyRefs++
    Mon->>GPU: cudaStreamSynchronize(sideStream)
    GPU-->>Mon: завершение опустошения копирования
    Mon->>Mon: releaseGpuProgressCounterMonitorDestroyRef
    Mon->>Mon: join потока, delete worker

Производственные подводные камни

Камень 1:cudaSetDeviceсбой приводит к молчаливому отказу мониторинга.Если при запуске потокаcudaSetDeviceзавершается неудачей, worker устанавливаетshouldStopи выходит📎 src/ras/progress_monitor.cc:97-107, но зарегистрировавший его comm по-прежнему считает, что мониторинг работает. В этом случае зеркала счётчиков останутся устаревшими до тех пор, пока сбой не проявится на этапе Init. При диагностике следует проверить, есть ли в логахNCCL_RASсообщение "progress-counter mirrors will remain stale".

Камень 2: конфликт с graph capture.Если поток мониторинга вызывает CUDA API, когда приложение выполняет stream capture, это загрязнит захватываемый граф. В коде используетсяcudaThreadExchangeStreamCaptureMode(cudaStreamCaptureModeRelaxed)для обхода📎 src/ras/progress_monitor.cc:110-111, это необходимая защита.

17.3 Фреймворк диагностики: таблично-управляемая диспетчеризация проверок

Интуитивная модель

Фреймворк диагностики похож на «комплексный медосмотр» в больнице. Каждая проверка (модель GPU, состояние ECC, здоровье NVLink, ошибки XID и т. д.) — это отдельное «отделение», а фреймворк собирает результаты проверок со всех rank и сводит их в один отчёт. Без него эксплуатация могла бы только вручную перебирать машины черезnvidia-smi, что совершенно неосуществимо на кластере из тысячи GPU.

Структура данных: таблица диспетчеризации проверок

В основе лежит статическая таблица диспетчеризацииrasDiagnosticsChecks 📎 src/ras/diagnostics.cc:63-77, каждая запись которой привязывает ID проверки и два колбэка:collectLocal(локальный сбор) иsummarize(сводка). Всего 11 проверок: модель GPU, версия драйвера CUDA, ECC, NVLink, окружение NCCL, топология RDMA, режим IOMMU, ATS, XID/SXID, версия драйвера NVIDIA, путь.

rasDiagnosticsGetCheckВыполняется тройная проверка: диапазон ID, соответствие ID записи, ненулевой указатель обратного вызова📎 src/ras/diagnostics.cc:104-128. Это защитное программирование — предотвращает вызов нулевого указателя из-за ошибочного изменения записи.

Сценарно-ориентированный разбор: полный жизненный цикл одной диагностики

Шаг первый: построение локальной полезной нагрузки. 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。

Шаг второй: инициирование коллективной коммуникации. 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。

Шаг третий: объединение ответов. rasCollDiagMergeПолезные нагрузки каждого peer добавляются в коллективный буфер📎 src/ras/diagnostics.cc:310-337. Обратите внимание на множество проверок переполнения: верхний предел числа peer📎 src/ras/diagnostics.cc:320-324, верхний предел общего размера📎 src/ras/diagnostics.cc:325-328。

Шаг четвёртый: сводка. 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.rasDiagnosticsCancelTargetПри закрытии сокета клиента reporter заменяется на noop📎 src/ras/diagnostics.cc:286-293, чтобы предотвратить запись в уже закрытый сокет после завершения асинхронной диагностики📎 src/ras/diagnostics.cc:48-52。

Размышления о дизайне

〔Проектные выводы и архитектурные компромиссы〕

Почему используется двухпроходное сканирование?Поскольку полезная нагрузка имеет переменную длину, только первый проход позволяет вычислить, какой размер буфера необходим для каждого типа проверки. Однопроходное сканирование либо требует динамического роста (множественные realloc), либо избыточного предварительного выделения. Двухпроходное сканирование обеспечивает детерминированность за счёт одной точной аллокации.

Почему в заголовке проверки присутствуетrecordStride? 📎 src/ras/diagnostics.cc:197Поскольку структуры записей разных проверок имеют разный размер, при сводке необходимо знать шаг для корректного копирования и проверки.rasDiagnosticsAccountCheckRecordsПринудительное обеспечение единообразия stride для одной проверки📎 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"]

17.4 Управление пирами: сортированный массив + хеш-синхронизация

Интуитивная модель

peers.ccПоддерживается «список всего класса». Каждый поток RAS хранит полностью идентичный список, содержащий адрес, PID и управляемые GPU каждого процесса NCCL. Когда присоединяется новый участник или кто-то «теряет связь», изменения широковещательно рассылаются по сети RAS. Список использует хеш-значение в качестве версии, что позволяет избежать полной синхронизации каждый раз.

Структуры данных и компоновка памяти

Два основных массива:

  • rasPeers: все известные peer, отсортированные по адресу📎 src/ras/peers.cc:18-19. Включает мёртвые peer.
  • rasDeadPeers: адреса мёртвых peer, хранятся отдельно📎 src/ras/peers.cc:37-38。

Почему мёртвые peer хранятся отдельно? 📎 src/ras/peers.cc:25-28Комментарий в объясняет это достаточно ясно:rasPeersВ крупном масштабе практически статичен и очень велик, тогда какrasDeadPeersдинамичен и гораздо меньше. Раздельное хранение позволяет избежать передачи огромного массива при каждой синхронизации.rasPeersСтруктура

rasPeerInfoПоле📎 src/ras/ras_internal.h:110-117:

ТипОписаниеСетевой адрес (ключ сортировки)
addrncclSocketAddressИдентификатор процесса
pidncclPid_tБитовая маска устройств CUDA (подвержена влиянию CUDA_VISIBLE_DEVICES)
cudaDevsuint64_tБитовая маска устройств NVML (не подвержена влиянию)
nvmlDevsuint64_tИзвлекается из comm, вычитается commHash для независимости от коммуникационного домена
hostHash / pidHashuint64_tДва хеша

иrasPeersHashявляются ядром синхронизацииrasDeadPeersHashСценарно-ориентированный разбор: присоединение нового rank📎 src/ras/peers.cc:21📎 src/ras/peers.cc:37-38。

Шаг первый: преобразование.

Преобразование массива rasRanksConvertToPeersвrasRankInit. Сначала сортировка по адресу + cudaDevrasPeerInfo 📎 src/ras/peers.cc:104, пропуск пустых адресов📎 src/ras/peers.cc:114, объединение многопроцессных GPU с одинаковым адресом (побитовое OR масок)📎 src/ras/peers.cc:127-130Шаг второй: обновление локального массива.📎 src/ras/peers.cc:134-139。

Представляет собой самый сложный алгоритм слияния в этой главе rasPeersUpdate. Сначала вычисляется размер нового массива📎 src/ras/peers.cc:197, затем выполняется слияние двух отсортированных массивов📎 src/ras/peers.cc:202-229. Ключевой момент: в процессе слияния📎 src/ras/peers.cc:244-361преобразуется в «разность» — сохраняются только действительно новые биты GPUrankPeers, в конце удаляются записи, не вносящие вклада📎 src/ras/peers.cc:301-308. Таким образом объём широковещательных данных минимизируется.📎 src/ras/peers.cc:393-402Шаг третий: распространение.

Распространение вдоль rasNetUpdatePeersиrasNextLinkдвух направленийrasPrevLink, затем перестроение соединений📎 src/ras/peers.cc:430-450Шаг четвёртый: отправка обновления.📎 src/ras/peers.cc:443-444。

Сначала проверяется хеш rasConnSendPeersUpdate: если удалённая сторона уже знает текущий хеш, пропуск. В сообщении передаются📎 src/ras/peers.cc:500-508иpeersHash, если после объединения на принимающей стороне хеш всё ещё не совпадает, выполняется обратная отправкаdeadPeersHash 📎 src/ras/peers.cc:521-524Объявление и распространение мёртвых peer📎 src/ras/peers.cc:608-653。

Добавление адреса в

rasPeerDeclareDead, после сортировки пересчёт хешаrasDeadPeersОбработка широковещательного сообщения о мёртвом peer📎 src/ras/peers.cc:793-812。rasMsgHandleBCDeadPeer: если локально неизвестен, разрыв соединения и объявление смерти, иначе пометка📎 src/ras/ras.cc:578-591Прекращение повторной широковещательной рассылки.*pDone = trueСлияние старого и нового списков мёртвых peer с помощью сортировки слиянием

rasDeadPeersUpdate. Обратите внимание на использование📎 src/ras/peers.cc:838-893вместоmemmove, поскольку источник и назначение могут перекрываться.memcpy 📎 src/ras/peers.cc:855Перестроение соединений: избежание гонки дублирующихся подключений

Перестроение канальных соединений после обновления peer

rasLinkReinitConns. Ключевая стратегия: инициирование соединения со стороны с меньшим адресом📎 src/ras/peers.cc:680, что предотвращает одновременное инициирование с обеих сторон и дублирование.📎 src/ras/peers.cc:706-711Вычисление индекса следующего peer с пропуском мёртвых peer

rasLinkCalculatePeer. Для fallback также есть дополнительная оптимизация: пропуск peer, находящихся на том же узле, что и предыдущий fallback📎 src/ras/peers.cc:743-785, что позволяет избежать ожидания по одному при отказе целого узла.📎 src/ras/peers.cc:743-785Производственные подводные камни

Подводный камень 1: ловушка порядка байтов при сравнении адресов.

Сортировка по семейству адресов → адресу → порту ncclSocketsCompare. В комментарии указано, что нельзя просто выполнить📎 src/ras/peers.cc:960-990для всей структуры, поскольку порядок расположения в памяти отличается от ожидаемого порядка сортировкиmemcmp. IPv4-адрес и порт в сетевом порядке байтов можно сравнивать побайтово, но поле семейства адресов — нет.📎 src/ras/peers.cc:957-959

Проблема 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. Отсутствие потерь производительности: heartbeat и трафик синхронизации RAS идут по отдельной сети, не занимая пропускную способность плоскости данных

3. Наблюдаемость: диагностика и мониторинг могут выполняться параллельно с коммуникацией

Цена —согласованность состояния: состояние comm, видимое RAS, может отставать от плоскости данных.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: по одному рабочему потоку на устройство, перенос счётчиков прогресса GPU на хост через DMA, с предупреждениями об ограничении скорости и уничтожением по подсчёту ссылок
  • diagnostics.cc: управляемая таблицей структура диспетчеризации проверок, двухпроходное сканирование для агрегации диагностических payload'ов от каждого rank'а
  • 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. Что произойдёт, если во время синхронизации другой поток также вызовет Destroy для уничтожения того же comm?cudaStreamSynchronize 📎 src/ras/progress_monitor.cc:381-400Как предотвратить проблему?destroyRefsЭталонный разбор

— это счётчик ссылок, предотвращающий слишком раннее удаление worker'а. После удаления comm первым потоком:destroyRefs, в этот моментdestroyRefs++ 📎 src/ras/progress_monitor.cc:371. Когда второй поток попытается удалить тот же comm,haveDestroyRef = trueвернёт nullptr (уже удалён),ncclIntruQueueDeleteостанется falsehaveDestroyRef, и синхронизация и освобождение будут пропущены.📎 src/ras/progress_monitor.cc:368После завершения

первым потоком вызываетсяcudaStreamSynchronize, уменьшаяreleaseGpuProgressCounterMonitorDestroyRef 📎 src/ras/progress_monitor.cc:402до 0, и только когда очередь регистрации пуста, поток действительно join'ится и delete'итсяdestroyRefsЕсли бы не было📎 src/ras/progress_monitor.cc:225。

, первый поток мог бы во время синхронизации быть освобождён вторым потоком черезdestroyRefsworker, что привело бы к use-after-free. Заметьте, чтоdelete gуменьшаетreleaseGpuProgressCounterMonitorDestroyRefпод глобальной блокировкой + блокировкой worker'а, гарантируя атомарность проверки📎 src/ras/progress_monitor.cc:222-225на пустоту иregistrationsПри первом проходе сканирования проверяетсяdestroyRefs == 0. Если какой-то вредоносный или повреждённый peer отправит

Q3:rasDiagnosticsSummarizePeerPayloadsиcheckHeader->payloadBytes != checkHeader->nRecords * checkHeader->recordStride 📎 src/ras/diagnostics.cc:451-454, пройдёт ли эта проверка? Что произойдёт дальше?recordStride = 0Эталонный разбор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 каждой проверки должны обрабатывать пустой ввод.summarizeНастоящий риск — в проверкеrecords = nullptr, recordsBytes = 0— это предотвращает

обход проверки равенства через целочисленное переполнение. Если убрать эту проверку, злоумышленник может сконструироватьnRecords > INT_MAX / recordStride, произведение переполнится в 0, станет равным📎 src/ras/diagnostics.cc:453, после прохождения проверкиnRecords * recordStrideнакопит огромныйnRecords = 2^31, recordStride = 2, что приведёт к выходу за границы при последующем выделении или копировании.payloadBytes = 0RAS даёт NCCL способность к обнаружению сбоев и самовосстановлению при длительном обучении, но он опирается на управляющую сеть, независимую от плоскости данных. В следующей главе мы перейдём к подсистеме управления памятью и посмотрим, как NCCL оптимизирует выделение видеопамяти и накладные расходы на регистрацию RDMA через allocator, кэш регистраций и регистрацию пользовательских буферов — это третья опора помимо производительности и надёжности.rasDiagnosticsAccountCheckRecordsНакопится огромныйnRecords, что приведёт к выходу за границы при последующем выделении или копировании.

RAS даёт NCCL возможность обнаружения сбоев и самовосстановления при длительном обучении, но он опирается на отдельную управляющую сеть, независимую от плоскости данных. В следующей главе мы перейдём к подсистеме управления памятью и рассмотрим, как NCCL оптимизирует выделение видеопамяти и накладные расходы на регистрацию RDMA с помощью аллокатора, кэша регистраций и регистрации пользовательских буферов — это третья опора помимо производительности и надёжности.

Сквозной принцип проектирования всей главы: разделение плоскости управления и плоскости данных, версионирование состояния через хеши, многоуровневая обработка тайм-аутов, защита жизненного цикла через подсчёт ссылок при конкурентности. Эти принципы позволяют RAS обнаруживать сбои и самовосстанавливаться, не снижая производительность связи. А другой ключевой опорой производительности связи — управлению памятью — также требуются тонкие инженерные компромиссы: почему перед связью NCCL необходимо регистрировать память? Как кэш регистрации влияет на производительность? В следующей главе мы углубимся в allocator, кэш регистрации и регистрацию пользовательских буферов, чтобы раскрыть ответы на эти вопросы.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 18

Глава 18: Аллокатор памяти и кэш регистрации: устранение накладных расходов CUDA

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 18 из 25

В предыдущей главе мы увидели, как подсистема RAS работает на плоскости управления независимо от плоскости данных, используя хеши для версионирования и подсчёт ссылок для защиты жизненного цикла. В этой главе мы переходим к третьей опоре NCCL — управлению памятью. Верхний предел производительности связи часто зависит не от самого алгоритма, а от того, «может ли сетевой адаптер напрямую читать и записывать данные». Для этого 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)。

Шаг 1: найти первый пустой сегмент 📎 src/allocator.cc:209。i = a->count % 2, в этот моментcount=0, поэтомуi=0, сканирование начинается с сегмента 0.

Шаг 2: вычислить границы сегмента 📎 src/allocator.cc:212-213。i==0приlo=0;i==a->countприhi=limit=1000. Значит, пустой сегмент —[0, 1000)。

Шаг 3: выравнивание и проверка ёмкости 📎 src/allocator.cc:214-215。off = alignUp(0, 1) = 0,0 + 100 <= 1000выполняется, выделение успешно.

Шаг 4: вставить точки разреза 📎 src/allocator.cc:217-223. Поскольку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。

После выделенияcuts = [0, 100],count=2. В этот моментisFull(0) = (0%2 != 2%2) = false, сегмент 0 ([0,0), пустой) пуст; сегмент 1 ([0,100)) полон. Верно.

Шаг 5: освобождение 📎 src/allocator.cc:239-267. Вызывается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.

Размышления о дизайне и подводные камни в продакшене

Почему используется int64_t, а не size_t?Потому чтоncclSpaceуправляет «смещениями», а не «указателями», смещение может быть отрицательным (хотя на практике этого не происходит), и требуется согласованность с ширинойCUdeviceptrв CUDA. Использование знакового типа облегчает обнаружение выхода за границы при отладке.

Ловушка производительности: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 выполняются на устройстве и не могут напрямую обращаться к C++ объектам в памяти хоста (например, к метаданным вncclDevComm).ncclShadowPoolработает как «переводчик»: для каждого объекта на стороне устройства он выделяет блок видеопамяти, одновременно выделяет соответствующий блок «теневой» памяти на стороне хоста и поддерживает таблицу сопоставления «адрес устройства → адрес хоста». Когда хосту нужно изменить конфигурацию какого-либо объекта устройства, он сначала изменяет хостовую тень, а затем копирует её на устройство. Без него каждому ядру для чтения метаданных пришлось бы извлекать их с хоста через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, каждый раз удваивается.

Пошаговый разбор: как при одном выделении выбирается страница или прямое подключение

Вводная ситуация: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(по умолчанию 1 ГБ)📎 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. То есть размер объекта внутри страницы выравнивается по степени двойки до кратного 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обнуляется📎 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)обнуляется.

Шаг восьмой: вставка в хеш-таблицу и обновление счётчиков 📎 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выравнивается по степени двойки; если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 (а не все единицы), что означает пометку только первого слота как свободного. Это делается для того, чтобы вернуть «заполненную страницу» в списокpool->pages, но остальные слоты внутри страницы по-прежнему заняты — фактически эти объекты вот-вот будут освобождены, поэтому такая операция безопасна. Однако если во время деструктора происходит конкурентный доступ, можно прочитать несогласованное состояние.

18.3 ncclMemManager: подсчёт ссылок и приостановка/восстановление динамической памяти

Интуитивная модель

Обучающая задача может выполняться несколько дней, в течение которых GPU может быть вытеснен другими задачами или потребуется создание контрольной точки.ncclMemManagerработает как «управляющий памятью»: он регистрирует всю динамически выделенную память (scratch/offload), при необходимости «приостанавливает» память GPU (отменяет отображение физических страниц, сохраняя виртуальные адреса), создаёт резервную копию данных на CPU, а при восстановлении заново выделяет физические страницы, повторно отображает их и восстанавливает данные. Без него после вытеснения задачи пришлось бы начинать с нуля, теряя многочасовой прогресс обучения.

Структуры данных и разметка памяти

ncclMemManagerключевые поля (выведены из кода инициализации)📎 src/mem_manager.cc:32-60:

ПолеТипЗначение
entriesncclDynMemEntry*Голова списка записей динамической памяти
numEntriesintДлина списка
releasedint0=активна, 1=приостановлена
refCountintСчётчик ссылок (может совместно использоваться несколькими comm)
totalPersistsize_tОбщий объём постоянной памяти (атомарный)
totalScratchsize_tОбщий объём scratch-памяти (атомарный)
totalOffloadsize_tОбщий объём offload-памяти (атомарный)
cpuBackupUsagesize_tОбщий объём резервной памяти CPU
lockstd::mutexЗащищает список entries
initializedintАтомарный флаг, предотвращающий доступ к уже уничтоженному мьютексу

Ключевые проектные решения по разметке памяти:lockявляетсяstd::mutex, ноncclMemManagerвыделяется с помощьюncclCalloc(в стиле C), поэтому необходимо явно сконструировать📎 src/mem_manager.cc:39через placement new, а при уничтожении явно вызвать~mutex() 📎 src/mem_manager.cc:120. Это классическая ловушка смешанного программирования на C/C++.

Разделение обязанностей между атомарными переменными и блокировками: статистические поля (totalPersistи т. д.) обновляются атомарными операциями и не требуют блокировки;entriesсписок защищаетсяlock. Таким образом, статистические запросы (ncclCommMemStats) могут без блокировки читать📎 src/mem_manager.cc:1117-1130, тогда как операции со списком обязаны удерживать блокировку.

Пошаговое руководство: полный процесс приостановки и возобновления

Процесс приостановки ncclCommMemSuspend 📎 src/mem_manager.cc:418-540:

Шаг первый: предварительные проверки 📎 src/mem_manager.cc:419-430. Проверяется, отключён ли менеджер памяти, пуст ли comm, не приостановлен ли уже.

Шаг второй: синхронизация устройства и barrier 📎 src/mem_manager.cc:440-441。cudaDeviceSynchronize()Убедиться, что все операции GPU завершены, затемbootstrapBarrierУбедиться, что все rank синхронизированы. Тег barrier —0xBEEF。

Шаг третий: первый проход — unmap всех буферов, импортированных от peer 📎 src/mem_manager.cc:444-465. Для каждой записиisImportedFromPeer && state==ActiveвызываетсяcuMemUnmapдля снятия отображения📎 src/mem_manager.cc:451, освобождается handle📎 src/mem_manager.cc:456, состояние меняется наReleased。

Шаг четвёртый: второй проход — offload локальной памяти 📎 src/mem_manager.cc:468-526. Пропускаются peer-импортированные и уже освобождённые записи. Для типаncclMemOffloadсначала выделяется CPU-бэкап📎 src/mem_manager.cc:484, затемcudaMemcpyкопирование с GPU на 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。

Шаг пятый: отметка о приостановке 📎 src/mem_manager.cc:528。

Процесс возобновления ncclCommMemResume 📎 src/mem_manager.cc:550-942:

Шаг первый: восстановление локальной памяти 📎 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。

Шаг второй: синхронизация barrier 📎 src/mem_manager.cc:671-679. Тег по-прежнему0xBEEF。

Шаг третий: обмен информацией о новых handle 📎 src/mem_manager.cc:688-816. Подсчитывается, сколько локальных буферов у каждого rank нужно broadcast📎 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)。

Шаг четвёртый: повторный импорт 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。

Шаг пятый: финальный barrier 📎 src/mem_manager.cc:916-928. Тег —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— это API управления виртуальной памятью CUDA, позволяющий разделить физическую память и виртуальные адреса. Это основа приостановки/возобновления — при приостановке физические страницы 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дополнению до двух, что эквивалентно «выравниванию вниз до кратного pageSize». Причина этого в том, что минимальная гранулярность регистрации — это страница: даже если регистрируется 1 байт, регистрируется целая страница.

Пошаговый разбор: как одна регистрация попадает в кэш

Вводный сценарий:ncclCommRegister(comm, buff=0x7f0000001000, size=4096, &handle)。

Шаг 1: Проверка параметров и выравнивание по страницам 📎 src/register/register.cc:18-24。CommCheckпроверяет корректность comm. Предположим,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сначала находится slot, соответствующий handle📎 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 через memcpy, а не RDMA) регистрация полностью пропускается. Вызывающая сторона должна проверять, равен ли handle NULL.

18.5 Регистрация коллективных коммуникаций: как coll_reg выбирает стратегию регистрации для разных алгоритмов

Интуитивная модель

Разные алгоритмы коллективных коммуникаций используют разные пути передачи: NVLS идёт через NVLink SHARP, Ring идёт через P2P или сеть, Tree идёт через древовидную топологию. Каждому пути нужен свой способ регистрации: NVLS требует регистрации в аппаратуре NVLS, сеть требует регистрации в сетевой карте, IPC требует регистрации на GPU-партнёре.coll_reg.cc— это «маршрутизатор стратегий регистрации»: он в зависимости от алгоритма, протокола и типа буфера решает, какие функции регистрации вызывать. Без него каждому алгоритму пришлось бы реализовывать логику регистрации самостоятельно, что привело бы к дублированию кода и ошибкам.

Пошаговый разбор: решение о регистрации для алгоритма 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。

Шаг 6: Сетевая регистрация 📎 src/register/coll_reg.cc:409-457. Проверяется!comm->useNetPXN && comm->useGdr && netDeviceType != UNPACKи отсутствие PreMulSum/SumPostDiv для AllReduce📎 src/register/coll_reg.cc:415-418. Сначала предпринимается попытка регистрации графа📎 src/register/coll_reg.cc:419-430, при неудаче — локальная регистрация📎 src/register/coll_reg.cc:431-442. Если успешно, устанавливаетсяregBufType |= NCCL_NET_REG_BUFFER, сохраняется массив handle📎 src/register/coll_reg.cc:445-452。

Шаг 7: Корректировка числа каналов 📎 src/register/coll_reg.cc:551-554. Если зарегистрирован только IPC, система одноузловая и число каналов находится в диапазоне 17–24, оно снижается до 16. Это делается для соответствия характеристикам пропускной способности после регистрации IPC.

Размышления о дизайне и подводные камни в продакшене

Почему порядок регистрации у NVLS и Ring противоположен?В ветке NVLS сначала предпринимается попытка регистрации графа, затем локальная регистрация📎 src/register/coll_reg.cc:86-94, а в ветке Ring сначала локальная, затем граф📎 src/register/coll_reg.cc:395-403. Это связано с тем, что регистрация графа для NVLS более вероятно успешна (аппаратура NVLS оптимизирована для постоянных буферов), а локальная регистрация для Ring более легковесна.

isMloPartBufRdmaCapableГлобальное решение 📎 src/register/coll_reg.cc:14-37. Комментарий подчёркивает: «Решение о регистрации должно быть глобальным, с использованием гарантий на уровне всего коммуникатора»📎 src/register/coll_reg.cc:20. Это означает, что даже если буфер некоторого ранга поддерживает RDMA, но хотя бы один ранг в коммуникационном домене не поддерживает его, весь коммуникационный домен не регистрируется. Это делается во избежание несогласованности, вызванной регистрацией части рангов и отсутствием регистрации у других.

Производственная ловушка: тихая деградация при неудачной регистрации。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, чтобы гарантировать последовательное выполнение операции приостановки относительно других операций.

ncclMemAllocЛовушка 3: совместимость FABRIC handle📎 src/allocator.cc:60-71На CUDA 12.3+ предпринимается попытка использовать FABRIC handlecuMemCreate. ЕслиCUDA_ERROR_NOT_PERMITTEDвозвращаетCUDA_ERROR_NOT_SUPPORTEDили📎 src/allocator.cc:63-65, происходит откат к POSIX FD📎 src/mem_manager.cc:649-655. Но при восстановлении, если тип handle — FABRIC, но экспорт не удался, сразу выдаётся ошибка и выполняется unmap

. Это означает, что в смешанной среде (часть GPU поддерживает FABRIC, часть — нет) приостановка/восстановление может завершиться неудачей.

ncclRegisterЛовушка 4: утечка счётчика ссылок📎 src/register/register.cc:84-85Каждое попадание в кэш увеличивает счётчик ссылокregCleanup. Если вызывающая сторона зарегистрировала N раз, но отменила регистрацию только M раз (M < N), счётчик ссылок никогда не обнулится,ncclCommRegister/ncclCommDeregister。

mermaid
sequenceDiagram
    participant App as Приложение
    participant Reg as ncclRegister
    participant Cache as ncclRegCache
    participant Net as ncclNetLocalRegisterBuffer
    participant GPU as Драйвер CUDA

    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, что является неопределённым поведением — можно прочитать метаданные кучи или вызвать segmentation fault. Что ещё более скрыто: даже еслиoffset, ноwhile (a->cuts[i] <= offset) i += 2больше последней точки разбиения, последующий цикл📎 src/allocator.cc:247iбудет продолжать увеличиватьcuts[]до выхода за границы, поскольку вoffsetне существует элемента, большегоncclSpace. В производственной среде это может сработать в случае: вызывающая сторона передала смещение, которое никогда не выделялось (например, повторный вызов free после освобождения буфера извне), илиoffsetбыло изменено параллельно, что привело к несогласованности состояния. Способ исправления — сохранить эту проверку и при возврате ошибки выводитьcountи

Q2: ncclMemManagerDestroyдля облегчения диагностики.refCountВ📎 src/mem_manager.cc:78-83, если после уменьшенияncclMemTrackвсё ещё больше 0, очищается только указатель текущего comm без освобождения ресурсов

. Что произойдёт, если в этот момент другой comm вызывает:ncclMemTrack?manager->initialized 📎 src/mem_manager.cc:136Разбор ответаrefCount > 0Сначала проверяетсяinitialized = 0. Поскольку приmanager->lockне устанавливаетсяentries, проверка проходит. Затем он получает📎 src/mem_manager.cc:188-192и модифицирует связный списокrefCount > 0ncclMemManagerDestroy. Это безопасно, посколькуrefCountозначает, что как минимум один comm всё ещё держит ссылку, и менеджер памяти не будет уничтожен. Реальный риск заключается в следующем: если последний comm при вызовеinitialized = 0 📎 src/mem_manager.cc:87уменьшаетncclMemTrackдо 0, он устанавливаетinitializedи освобождает все ресурсы. Если в этот момент другой поток вmanager->lockуже прошёл проверкуmemory_order_acquire/release, но ещё не захватил блокировку, он обратится к уже освобождённому

, что приведёт к use-after-free. Исходный код смягчает эту проблему парным использованиемncclCommMemResume, но строго говоря, окно гонки всё ещё существует. В производственной среде следует убедиться, что все коммуникационные потоки остановлены до уничтожения менеджера памяти.📎 src/mem_manager.cc:853-859Q3: ВrestoredPeerCountpeer-буферы типа POSIX FD пропускаются при межузловой передачеmanager->released. Если все peer-буферы пропущены,📎 src/mem_manager.cc:913равно 0, но

всё равно устанавливается в 0:manager->released = 0. Каковы последствия?stateРазбор ответаncclDynMemStateReleased,handleозначает, что менеджер памяти считает восстановление завершённым. Но если какие-то peer-буферы были пропущены, ихncclCommMemStatsпо-прежнемуncclStatGpuMemSuspendedпо-прежнему 0. Последующая коммуникация при обращении к этим буферам вызовет ошибку CUDA (доступ к немаппированному виртуальному адресу). Что ещё серьёзнее,📎 src/mem_manager.cc:1130запросentries中。Правильный подход — пометить записи POSIX FD как невосстанавливаемые при приостановке, либо возвращать ошибку при возобновлении вместо молчаливого пропуска. В производственной среде, если используются POSIX FD и они跨节点, следует перейти на FABRIC handle или убедиться, что приостановка/возобновление происходят только в пределах одного узла.

Управление памятью — невидимая опора производительности NCCL:ncclSpaceиспользует минималистичный массив точек разбиения для управления адресным пространством,ncclShadowPoolиспользует 64-битные битовые карты и хеш-таблицы для управления парами устройство/хост-объектов,ncclMemManagerиспользует подсчёт ссылок и CUDA VMM API для реализации приостановки и возобновления,ncclRegisterиспользует упорядоченный массив для кэширования результатов регистрации, чтобы избежать повторного pin. Эти четыре уровня механизмов совместно обеспечивают ключевую гарантию производительности — «не требуется повторная регистрация памяти перед коммуникацией». В следующей главе мы перейдём к устройство-стороннему коммуникатору и совместимости ABI и посмотрим, какdevcommотображает эти host-сторонние раскладки памяти в структуры, доступные из GPU kernel.

На рисунке выше показана временная последовательность регистрации: при попадании в кэш только увеличивается счётчик ссылок, без вызова нижележащей регистрации; при промахе кэша создаётся новая запись и запускается нижележащая регистрация. На этом host-сторонний механизм управления памятью становится ясен. Но коммуникация в конечном итоге происходит на GPU, и kernel должен напрямую обращаться к адресам и состоянию соединений удалённых rank. В следующей главе мы перейдём к устройство-стороннему коммуникатору и совместимости ABI и посмотрим, как devcomm отображает метаданные host-стороннего ncclComm в структуры, доступные на устройстве, а также как версионированный ABI обеспечивает совместимость новых и старых kernel с библиотекой.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 19

Глава 19: Устройство-сторонний коммуникационный ABI: архитектура и структуры devComm

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 19 из 25

В предыдущей главе мы видели, как host-сторонний ncclMemManager с помощью подсчёта ссылок и CUDA VMM API управляет жизненным циклом коммуникационных буферов. Но коммуникация реально происходит в GPU kernel — потоки внутри kernel должны знать: какой я rank? По какому виртуальному адресу находится буфер удалённого rank? Готово ли соединение? Эта информация находится в host-сторонней структуре ncclComm, но kernel не может напрямую разыменовывать host-указатели. Если бы NCCL заставляла kernel каждый раз получать эти метаданные через параметры или запросы к глобальной памяти, каждая коммуникация несла бы дополнительные накладные расходы по задержке и пропускной способности. Хуже того, как только код kernel скомпилирован, смещения полей, к которым он обращается, фиксируются — если после обновления библиотеки раскладка ncclComm изменится, старый kernel прочитает неверные данные. Это и есть основная проблема, которую решает devcomm: отобразить ключевые метаданные host-стороннего домена коммуникации в стабильной, версионированной раскладке памяти в структуры, доступные на устройстве. Файлы devcomm_v22902.cc, devcomm_v22907.cc, devcomm_v23000.cc, devcomm_v23100.cc в каталоге src/devcomm — это конкретные реализации данного версионированного ABI. Каждый файл соответствует диапазону версий NCCL и определяет точную раскладку памяти ncclDevComm в этом диапазоне, а также логику копирования полей между новыми и старыми версиями. В этой главе мы последовательно разберём: как выглядят ключевые структуры данных устройство-стороннего коммуникатора, как работает механизм регистрации и сопоставления версионированного ABI, как выполняется пофайловое преобразование между новыми и старыми версиями, а также границы и подводные камни этого механизма в производственной среде.

I. Ключевая структура устройство-стороннего коммуникатора: раскладка памяти ncclDevComm

Интуитивная модель

ПредставьтеncclDevCommкак «карточку рабочего места»: при запуске каждого GPU kernel выдаётся карточка, на которой напечатано «ты rank 3, всего 8 rank, в твоей LSA-группе 4 rank, базовый адрес буфера удалённой стороны — 0x7f...». Эта карточка должна быть достаточно маленькой (чтобы поместиться в параметры kernel) и при этом содержать всю ключевую информацию. Если бы этой карточки не было, kernel мог бы полагаться только на повторяющуюся передачу параметров с host-стороны, и каждая коммуникация требовала бы повторной сборки — высокая задержка, подверженность ошибкам.

Структуры данных и раскладка памяти

На примере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-битным числом с фиксированной запятой. Когда в ядре выполняется операция деления для вычисления смещения от ранга к буферу, целочисленное деление на 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было разделено на два более точных флаговых бита.

---

II. Регистрация и сопоставление версионированного ABI: структура ncclDevCommCompat

Интуитивная модель

Представьте версионированный ABI как набор «плагинов-переводчиков»: когда приложение скомпилировано с NCCL 2.29.2, но во время выполнения компонуется с библиотекой 2.31.0, библиотеке нужно знать, «какой макетncclDevCommожидает ядро 2.29.2», а затем перевести текущую версию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.7Добавлены поля GIN, но обратная совместимость с GIN не обеспечивается
devcomm_v23000.cc2.30.02.30.7Добавлена проверка magic/version
devcomm_v23100.cc2.31.0Текущая версияВсе фильтры равны nullptr, что означает полную совместимость

📎 src/devcomm/devcomm_v23100.cc:10-17В плагине v23100 дляnullptrвсе обратные вызовы равныncclDevComm, это означает, что начиная с 2.31.0 макет

уже стабилен и не требует никаких преобразований.

〔Проектные выводы и архитектурные компромиссы〕

Обратите внимание, что между v22902 и v22907 в диапазоне версий есть «пробел» (для 2.29.4 и 2.29.6 нет соответствующих плагинов). Возможно, эти версии не были выпущены, или их макет полностью совпадает с соседними версиями и может быть переиспользован.

Процесс сопоставленияncclCommGetDeviceHandleКогда приложение вызывает

или аналогичный API, NCCL необходимо:reqs->version)。

1. Считать номер версии NCCL, встроенный во время компиляции приложения (через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

---

Копировать

III. Преобразование на уровне полей: как старый и новый макеты преобразуются друг в друга

Интуитивная модель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. memsetКлючевые шаги: 📎 src/devcomm/devcomm_v23000.cc:118Обнуление

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. Новая версия использует

5. (целочисленный шаг), чтобы указать, является ли соединение railed; старая версия использует булево значение. Когда шаг больше 1, это означает, что соединение является railed.: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 каждый rank внутри группы LSA разделяет одно 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.0ncclDevCommотсутствуетmagic/versionполе, поэтому библиотека не может различить, является ли старая структура v22902 или v22907. Следовательно, для v22907devCommCopyOldToNewустановлено в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 может быть скопирована напрямую поле за полем.

---

IV. Фильтрация возможностей и проверка ресурсов: предотвращение доступа старых ядер к неподдерживаемым функциям

Интуитивная модель

Преобразование версий — это не просто «перемещение полей» — необходимо также проверить, поддерживает ли старая версия функции, запрашиваемые приложением. Например, ядро, скомпилированное с 2.29.2, запрашивает ресурс GIN, но в компоновкеncclDevCommверсии 2.29.2 поля GIN неполны, и прямое преобразование приведёт к тому, что ядро прочитает мусорные данные. Поэтому необходим «фильтр», который перехватывает такие запросы перед преобразованием.

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 не равно общему количеству рангов (то есть присутствует межнодовая коммуникация), API устройства отключается. Это связано с тем, что GIN версии 2.29.7 не поддерживает межнодовую работу.

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Определено перечисление типов GIN для v22902:

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 байт.

devCommRequirementsFilter: проверка запросов ресурсов

ncclDevCommRequirementsFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:79-98Проверяет, запрашивает ли приложение ресурсы GIN:

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Любое значение больше 0 означает, что ресурсы GIN запрошены.

2. Обход связного списка требований к ресурсам: если на верхнем уровне запрос отсутствует, продолжить обходresourceRequirementsListсвязанный список, проверка каждого узлаginSignalCountиginCounterCount。

если ресурс GIN действительно запрошен, иginConnectionTypeне являетсяNONEилиginForceEnableистинно, то возвращаетсяncclInvalidUsageи выводится предупреждение о необходимости перекомпиляции приложения.

ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126более сложный: помимо проверки GIN, также обрабатывается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;
〔Проектные допущения и архитектурные компромиссы〕

До версии 2.29.4barrierCountобозначал только барьер LSA и не подразумевал потребность в GIN. Начиная с версии 2.29.4barrierCountподразумевает потребность в GIN. Для совместимости со старыми версиями фильтр преобразуетbarrierCountвlsaBarrierCount, и обнулитьbarrierCountиrailGinBarrierCount。

Приведённая ниже диаграмма последовательности демонстрирует полный процесс взаимодействия от запроса приложения до преобразования версии:

mermaid
sequenceDiagram
    participant App as Прикладной уровень
    participant Host as Библиотека NCCL на стороне Host
    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

---

Пять. Руководство по избеганию проблем в production и цепочка восстановления после сбоев

Ловушка первая: конфликт запросов ресурсов GIN со старыми версиями kernel

Сценарий: Приложение скомпилировано с NCCL 2.29.2, но во время выполнения слинковано с библиотекой 2.31.0. Приложение вызывает в kernel API со стороны устройства, связанные с GIN (например,ncclGinPut)。

Что произойдёт:ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126ОбнаруженоginForceEnableили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.

Первопричина: в компоновке версии 2.29.2ncclDevComm_v22902поля GIN (ginContextCount、ginNetDeviceTypes、ginHandlesи т.д.) несовместимы с компоновкой версии 2.31.0. При принудительном преобразовании ядро будет читать по неверному смещению, что приведёт к неопределённому поведению.

Правильный подход: приложение должно быть перекомпилировано с той же (или совместимой) версией NCCL, что и библиотека времени выполнения. Если перекомпиляция невозможна, следует избегать использования GIN API в ядре.

Ловушка вторая: API устройства молча отключается при межузловой коммуникации

Сценарий: приложение скомпилировано с версией 2.29.7, домен коммуникации содержит межузловые ранги (ncclTeamLsa(comm).nRanks != comm->nRanks)。

Что произойдёт:ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77установитьprops->deviceApiSupportв значениеfalse. Если приложение проверяет этот флаг, оно узнает, что API устройства недоступен; но если не проверяет и напрямую вызывает API на стороне устройства, это приведёт к неопределённому поведению.

Первопричина: GIN версии 2.29.7 не поддерживает межузловую работу. Использовать API на стороне устройства могут только ранги внутри группы LSA (Local SHARP Aggregation).

Правильный подход: приложение должно после инициализации проверитьncclCommProperties.deviceApiSupport, если равноfalse, откатиться к API на стороне хоста.

Ловушка третья: обнуление через memset и утечка неинициализированных полей

Сценарий:ncclDevCommCopyNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:118Перед копированием выполнитьmemset(old, '\0', sizeof(*old))。

Почему это необходимо: в старой структуре могут быть поля, отсутствующие в новой версии (например,ginSignalBase、ginCounterBaseв v22902). Если не обнулить, эти поля сохранят мусорные значения со стека, которые ядро может ошибочно интерпретировать как валидные данные.

Подводный камень:Если разработчик вручную реализует преобразование версий и забудет обнулить память, ядро может прочитать случайные значения, что проявится в виде перемежающихся ошибок — которые трудно воспроизвести и отладить.

Правильный подход:Всегда обнуляйте всю целевую структуру перед преобразованием. Все реализацииCopyNewToOldв NCCL следуют этому шаблону📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。

Ловушка четвёртая: сбой сопоставления из-за пробелов в диапазонах версий

Сценарий:Приложение скомпилировано с 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. Обработка приложением: Приложение должно проверить возвращаемое значение и в случае неудачи откатиться к API на стороне хоста или прекратить通信.

4. Ведение журнала:NCCL выводит журнал уровняWARN, содержащий версию компиляции и версию времени выполнения, что помогает локализовать проблему.

〔Предположения о дизайне и архитектурные компромиссы〕

В настоящее время NCCL не предоставляет механизм «автоматической деградации» — если преобразование версии завершается неудачей, автоматического отката к API на стороне хоста не происходит. Приложение должно само реализовать логику отката.

---

Размышления о дизайне

Почему используются версионированные структуры, а не «стабильный ABI»?

〔Предположения о дизайне и архитектурные компромиссы〕

Альтернативой было бы спроектировать «никогда не меняющуюся»ncclDevCommраскладку, где все новые поля доступны через косвенные указатели. Но это порождает две проблемы: во-первых, косвенный доступ увеличивает задержку (ядру требуется дополнительное разыменование), во-вторых, невозможно использовать заполняющую область для оптимизации раскладки. NCCL выбрал версионированные структуры как компромисс между «производительностью» и «совместимостью» — ядро в каждом диапазоне версий получает оптимальную раскладку, а при переходе между версиями совместимость обеспечивается слоем преобразования.

Почему у v22907devCommCopyOldToNewустановлен в nullptr?

📎 src/devcomm/devcomm_v22902.cc:153-155Комментарий кncclDevCommобъясняет причину: до 2.30.0 у

не было поля версии, поэтому старые раскладки v22902 и v22907 невозможно различить. Поскольку ни одна из них не поддерживает обратную совместимость GIN, различия в полях GIN не влияют на корректность, поэтому используется функция преобразования от v22902.nRanks_rcp32Почему

использует числа с фиксированной запятой, а не с плавающей?

〔Предположения о дизайне и архитектурные компромиссы〕1/nRanksТочности деления с плавающей запятой на GPU может быть недостаточно для точного представленияnRanks, особенно когда

---

не является степенью двойки. Числа с фиксированной запятой (дробные числа, представленные 32-битными целыми) могут обеспечить достаточную точность, к тому же целочисленное умножение быстрее умножения с плавающей запятой.

Итоги главыsrc/devcommВ этой главе разобрана реализация версионированного ABI в каталоге

1. ncclDevComm:раскладка памятиstatic_assert:каждая версия имеет точные смещения полей, проверяемые на этапе компиляции с помощьюrank、nRanks、nRanks_rcp32、lsaRank、lsaSize、windowTable、resourceWindow. Ключевые поля включают

2. и т.д.Регистрация версионированного ABIncclDevCommCompat:каждому диапазону версий соответствует структураminVersion、maxVersion, содержащая

3. , функцию-фильтр и функцию преобразования.:CopyNewToOldПофайловое преобразованиеCopyOldToNewиginConnectionStride > 1выполняют пофайловое копирование и обрабатывают семантические изменения (например,ginConnectionsRailed = true)。

4. преобразуется в:commPropertiesFilterФильтрация возможностейdevCommRequirementsFilterкорректирует флаги возможностей, предоставляемые старым версиям,

5. проверяет совместимость запросов ресурсов со старыми версиями.Производственные ловушки

: конфликт запросов ресурсов GIN с ядрами старых версий, отключение API устройства при меж节点通信, необходимость обнуления через memset, сбой сопоставления из-за пробелов в диапазонах версий.nccl_deviceВ следующей главе мы перейдём к API на стороне устройства и слиянию ядер и рассмотрим, как

Заголовочные файлы организуют функции на стороне устройства, а также как слияние ядер объединяет несколько операций коллективной связи в одно ядро для выполнения.

Вопросы для размышления и самопроверки по этой главеncclDevCommCopyNewToOld_v23000Q1: Если убратьmemset(old, '\0', sizeof(*old))из

, в каких сценариях ядро прочитает некорректные данные? Проанализируйте с учётом различий полей между v22902 и v23000.:

ncclDevComm_v22902Справочный разбор📎 src/devcomm/devcomm_v22902.cc:84Размер структурыncclDevComm_v23000составляет 200 байт📎 src/devcomm/devcomm_v23000.cc:95-98, аginSignalBase— 240 байтginCounterBase. В v22902 естьginContextBase(смещение 176),

(смещение 184),memset(смещение 204) и другие поля, которых нет в v23000 или которые имеют иную семантику.oldЕсли убратьginSignalBase、ginCounterBase, то при преобразовании из v23000 в v22902 поля структуры

  • , отсутствующие в v23000 (например,
  • ), сохранят мусорные значения со стека. Если ядро случайно прочитает эти поля (например, в коде GIN старого ядра), оно получит случайные значения, что приведёт к:
  • Неверному базовому адресу сигнала — операции GIN будут записывать в неправильные области памяти.

memsetНеверному базовому адресу счётчика — это приведёт к переполнению или опустошению счётчика.CopyNewToOldВ экстремальных случаях это может вызвать недопустимый доступ к памяти и падение ядра.📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。

Обнуление гарантирует, что все поля, которым явно не присвоены значения, равны 0 — это безопасное значение по умолчанию. Все реализации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. Но у v22902maxVersion— 2.29.3, что строго говоря не покрывает 2.29.4.

2. Возврат ошибки: если логика сопоставления строго следует диапазонам, 2.29.4 не найдёт совпадения и вернётncclInvalidUsage。

3. Совпадение вверх: выбрать минимальный диапазон, больший или равный запрошенной версии, то есть v22907. Но у v22907minVersion— 2.29.5, что также не покрывает 2.29.4.

〔Проектные предположения и архитектурные компромиссы〕

В реальной реализации у NCCL может быть стратегия «отказоустойчивости» — если точное совпадение не найдено, попытаться использовать плагин из соседнего диапазона. Но это не является надёжной гарантией.

Способы обхода для приложения:

  • Использовать тот же номер мажорной версии, что и у runtime-библиотеки (например, 2.31.x).
  • При необходимости跨版本, проверить, есть ли для целевого диапазона версий соответствующий совместимый плагин.
  • После инициализации проверитьncclCommProperties.deviceApiSupport, и если он равенfalse, откатиться к host-side 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.4barrierCountобозначал только количество LSA barrier и не подразумевал потребность в GIN. Начиная с 2.29.4barrierCountподразумевает потребность в GIN (то есть запрос barrier означает необходимость ресурсов GIN).

Когда приложение компилируется с 2.29.2, оно может установитьbarrierCount > 0для обозначения потребности в LSA barrier, но не знает, что это подразумевает потребность в GIN. Если библиотека NCCL (2.31.0) обработает это напрямую по новой семантике, она решит, что приложение запросило ресурсы GIN, и затемncclDevCommRequirementsFilter_v22902обнаружит запрос GIN и вернётncclInvalidUsage— это ложное срабатывание.

Логика преобразования переводитbarrierCountвlsaBarrierCount(берётся максимум из двух) и обнуляетbarrierCount. Таким образом:

  • lsaBarrierCountсохраняет потребность приложения в barrier.
  • barrierCount = 0избегает ложного срабатывания потребности в GIN.
  • railGinBarrierCount = 0Аналогично, поскольку в старой версии он также не подразумевает потребность в GIN.

Если не выполнять преобразование, то когда приложение скомпилировано с 2.29.2 и установилоbarrierCount > 0, оно будет ошибочно отклонено и не сможет использовать device API.

На этом мы увидели, как devcomm через версионированный ABI безопасно отображает ключевые метаданные host-side коммуникационного домена на device side, позволяя kernel получать rank, адреса и состояние соединений без host-указателей. Этот механизм решает базовую проблему доступа kernel к коммуникационному домену, но возможности device side этим не ограничиваются. Когда пользователь хочет напрямую вызывать коммуникационные примитивы в своём kernel или даже объединить коммуникацию и вычисления в одном kernel, требуются более высокоуровневые device-side API и технологии слияния ядер. В следующей главе мы углубимся в каталог nccl_device и связанные примеры, чтобы изучить, как device-side API, такие как ncclBarrier, ncclLsaBarrier, ncclGinBarrier, позволяют пользовательскому kernel участвовать в коммуникации, а также как слияние ядер снижает накладные расходы на запуск, продвигая NCCL от библиотеки к модели программирования.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 20

Глава 20: Нативные API устройств и слияние ядер: вызов коммуникаций из CUDA-ядер

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 20 из 25

В предыдущей главе мы увидели, как devcomm версионированно отображает метаданные host-стороннего ncclComm на устройство, позволяя ядру читать rank, адреса и состояние соединений. Но «иметь возможность читать метаданные» и «иметь возможность инициировать коммуникацию» — это две разные вещи. Если есть только метаданные, пользовательское ядро в лучшем случае сможет само вычислить адреса и записать флаги; как только дело доходит до синхронизации между rank'ами или передачи сигналов между машинами, всё равно придётся возвращаться на host-сторону и вызывать коллективные API вроде ncclAllReduce — а каждый такой вызов означает запуск ядра и往返 между host и устройством. Каталог src/nccl_device, который мы разбираем в этой главе, — это как раз ключевой шаг NCCL от «библиотеки, которую вызывают» к «модели, которую можно программировать». Он предоставляет не новые алгоритмы коллективной коммуникации, а набор примитивов на стороне устройства: позволяя пользовательскому ядру изнутри вызывать такие операции синхронизации, как ncclBarrier, ncclLsaBarrier, ncclGinBarrier, тем самым упаковывая «коммуникацию» и «вычисления» в одно ядро и устраняя промежуточные накладные расходы на запуск. Исходные материалы этой главы сосредоточены на объявлении требований на host-стороне (CreateRequirement) и абстракции команды (Team) для этой группы примитивов — это и есть вход в API на стороне устройства. Ключевая предпосылка для понимания этой главы: философия дизайна API на стороне устройства — «host-сторона объявляет требования к ресурсам, device-сторона потребляет ресурсы». Host-сторона не создаёт барьер напрямую, а сообщает NCCL: «мне нужно nBarriers барьеров, в команде team.nRanks участников», NCCL на основе этого вычисляет, сколько нужно буферов и сколько GIN-сигналов, а затем на device-стороне инстанцирует эти ресурсы. Такое разделение «объявление-потребление» — фундаментальная причина, по которой код на стороне устройства может работать без указателей host.

I. Абстракция Team: система координат API на стороне устройства

Интуитивная модель

Представьте организационную структуру транснациональной компании. Чтобы отправить письмо, сначала нужно знать «кому» — всей компании (World), коллегам в том же офисе (LSA) или команде той же бизнес-линии в разных офисах (Rail).ncclTeam_t— это дескриптор «диапазона получателей». Без абстракции Team каждый API на стороне устройства должен был бы заново вычислять «какой я по счёту в этом коммуникационном домене и сколько всего участников», код дублировался бы и был крайне подвержен ошибкам.

Структура данных и layout в памяти

ncclTeam_t— это система координат API на стороне устройства, её три поля определяютарифметическую прогрессию:

ПолеЗначениеАналогия
nRanksОбщее число участников в командеСколько человек в группе
rankНомер текущего rank в командеМой порядковый номер в группе
strideШаг между соседними участниками команды в worldНа сколько отличаются номера студентов у двух соседних человек в группе

stride— самое легко упускаемое, но самое ключевое поле. В команде Worldstride = 1, потому что все rank'и расположены подряд; но в команде Railstride = lsaSize, потому что rank'и на одном rail появляются в world только каждыеlsaSizeштук.

📎 src/nccl_device/core.cc:13-19показывает построение команды World: напрямую берётсяcomm->nRanksиcomm->rank,strideфиксируется равным 1. Это единственная команда, которой не нуженncclDevrInitOnce, потому что вся её информация находится в host-стороннемcomm.

📎 src/nccl_device/core.cc:22-33— это команда LSA. Обратите внимание наncclDevrInitOnce(comm)в L26 — это идемпотентная точка входа для инициализации ресурсов на стороне устройства. Комментарии в L23-25 крайне важны:здесь ошибка намеренно игнорируется, потому что если инициализация не удалась, возвращённая team — это «мусорное значение», но следующий API-вызов, которому действительно нужны ресурсы, снова вызоветncclDevrInitOnceи сообщит об ошибке. Это стратегия «отложенного сообщения об ошибке», позволяющая избежать выброса тяжёлых ошибок на такой лёгкой операции, как запрос команды.

Сценарный Walkthrough: преобразование координат от World к Rail

Предположим, машина с 8 GPU,lsaSize = 4(каждые 4 GPU — один домен LSA),nRanks = 8. Посмотрим, какncclTeamRailстроится:

📎 src/nccl_device/core.cc:70-79вnRanks = 8 / 4 = 2,rank = comm->rank / 4,stride = 4. Если текущий rank — 5, то егоrank = 5 / 4 = 1,stride = 4в команде Rail означает, что члены команды Rail — это rank 1 и rank 5 в world.

Теперь посмотрим наncclTeamRankToWorldформулу пересчёта:

📎 src/nccl_device/core.cc:82-84уcomm->rank + (rank - team.rank) * team.stride— этоотносительное смещениеВычисление: сначала вычисляется смещение целевого rank относительно текущего rank внутри команды(rank - team.rank), затем умножается на шагstride, прибавляется world-номер текущего rank. Эта формула универсальна для всех команд, потому что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 полностью берётся с host-стороныcomm, не требует никаких ресурсов на стороне устройства. Принудительный вызов заставит чисто host-операцию запроса зависеть от инициализации на стороне устройства, что добавит ненужные точки отказа.

Подводные камни:ncclTeamRankToLsaпри ошибке инициализации возвращает-1(📎 src/nccl_device/core.cc:87-92), аncclTeamRankToWorldникогда не завершается ошибкой. Если вызывающий код смешивает эти две функции и не проверяет возвращаемое значение, при ошибке инициализации LSA он может получить-1и использовать его как допустимый rank, что приведёт к выходу за границы. В production-коде следует обрабатывать возвращаемое значениеncclTeamRankToLsaкак операцию, которая может завершиться ошибкой.

---

II. Объявление требований Barrier: как host-сторона «резервирует» ресурсы устройства

Интуитивная модель

Распределение ресурсов на стороне устройства через API похоже набронирование переговорной комнаты: нельзя просто ворваться в переговорную и начать совещание, сначала нужно подать заявку на ресепшене (host-сторонаCreateRequirement) — «Мне нужно провести 3 встречи, по 8 человек в каждой». Ресепшен на основании этого вычисляет, какой площади помещение потребуется (bufferSize), сколько стульев нужно (ginSignalCount), а затем выдаёт вам номер помещения (outBufferHandle). Без этого механизма бронирования kernel на стороне устройства не знал бы, где находится его буфер barrier и какого он размера, и не мог бы безопасно читать и записывать.

Структуры данных и разметка памяти

Три функцииCreateRequirementдля трёх barrier используют один и тот же шаблон:обнулить структуру требований → заполнить размер/выравнивание буфера → заполнить указатель на выходной дескриптор. Но типы их ресурсов различаются:

Тип 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 использует путь сетевых сигналов, ему не нужен буфер разделяемой памяти, а нужны слоты сигналов, распознаваемые сетевой картой.

Сценарный Walkthrough: полное бронирование одного LSA Barrier

Предположим, пользователь хочет создать 2 barrier в команде LSA из 4 GPU:

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, а kernel на стороне устройства получает уже заполненный дескриптор.

Проектные соображения и подводные камни

Почему используетсяmemsetдля обнуления всегоoutReq?Потому чтоncclDevResourceRequirements_t— это многоfield-структура, и разные типы barrier заполняют лишь часть её полей. Обнуление гарантирует, что неиспользуемые поля (например,ginSignalCount, не используемое LSA barrier) равны 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вместоsizeof(uint32_t)иalignof(uint32_t)из LSA. Это указывает на то, что barrier CFT (возможно, Cross-Fabric Team или подобная кросс-доменная команда) требует более крупной гранулярности выравнивания, вероятно, из-за необходимости пересекать области многоадресной памяти, где аппаратное обеспечение предъявляет более строгие требования к выравниванию адресов.

---

III. Семантическое разделение трёх 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 завершён
    K->>CFT: "многоадресная запись управляющего поля"
    CFT-->>K: "чтение многоадресного состояния"
    Note over K,CFT: барьер CFT завершён
    K->>NIC: "отправка сигнала GIN"
    NIC-->>K: "опрос слота сигнала"
    Note over K,NIC: барьер GIN завершён

Эта временная диаграмма показывает уровни взаимодействия с оборудованием для трёх типов barrier: от чисто внутри-GPU синхронизации к многоадресной памяти и далее к сигналам сетевого адаптера; задержка последовательно возрастает, а охват также последовательно расширяется.

Проектные соображения и подводные камни

Почему LSA и CFT не требуютcommпараметра?Потому что их ресурсы (разделяемая память, многоадресная память) уже на этапеncclDevrInitOnceпривязаны к команде,teamсам по себе неявно содержит информацию о расположении ресурсов. А сигналы GIN требуют динамического выделения сетевых ресурсов, поэтому необходимо черезcommобращаться к состоянию сетевого соединения.

Подводные камни: у GIN BarrierginSignalCountравноnBarriers * team.nRanks, и если команда большая (например, 1024 rank) и barrier'ов много (например, 100), общее количество сигналов достигнет 102400. Слоты сигналов сетевого адаптера — ограниченный ресурс, чрезмерный запрос может привести кncclDevrInitOnceсбою. Производственный код должен запрашивать минимально необходимое количество barrier'ов, а не запрашивать большое количество про запас.

---

IV. От объявления требования до потребления на стороне устройства: полный жизненный цикл

Интуитивная модель

CreateRequirementэто лишь «размещение заказа», реальная «отправка» и «получение» происходят внутри NCCL и в kernel на стороне устройства. Весь жизненный цикл похож наонлайн-покупки: вы размещаете заказ (CreateRequirement) → продавец готовит товар (NCCL выделяет ресурсы) → курьер доставляет (ресурсы привязываются к DevComm) → вы подписываете и используете (kernel на стороне device вызывает barrier).

Структуры данных и размещение в памяти: эволюция полей дескриптора

На примереncclLsaBarrierHandle_t, он проходит три этапа в жизненном цикле:

ЭтапnBarriersbufHandleДругие поля
После CreateRequirementУстановленоАдрес заполнен, но содержимое не выделеноНе установлено
После выделения NCCLУстановленоУказывает на фактический буферУстановлено
Использование на стороне DeviceТолько чтениеТолько чтениеТолько чтение

📎 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(содержание предыдущей главы), NCCL выделяет буфер размером 56 байт и записывает адрес вhandle.bufHandle。

3. Инициализация на стороне Device: при запуске пользовательского kernel из DevComm извлекаетсяhandle, с помощьюbufHandleопределяется местоположение буфера.

4. Синхронизация на стороне Device: kernel вызываетncclLsaBarrier(handle, barrierIndex), записывает метку прибытия в соответствующий слот буфера, опрашивает остальные слоты.

5. Завершение на стороне Device: после прибытия всех rank barrier возвращает управление, 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/>получает handle из DevComm"]
    e --> f["ncclLsaBarrier(handle, idx)<br/>запись в слот прибытия + опрос"]
    f --> g{"все rank прибыли?"}
    g -->|"нет"| f
    g -->|"да"| h["barrier возвращает управление<br/>kernel продолжает"]
    err --> i["пользователь должен проверить возвращаемое значение<br/>нельзя использовать недействительный дескриптор"]

Эта диаграмма решений показывает полный путь от объявления до использования, а также ветвь ошибки при сбое выделения. Обратите внимание, чтоncclLsaBarrierCreateRequirementсам по себе всегда возвращаетncclSuccess(📎 src/nccl_device/lsa_barrier.cc:14-22), реальный сбой происходит на последующем этапе выделения ресурсов.

Управление конкурентностью и взаимодействие с оборудованием

Ядро управления конкурентностью barrier на стороне устройства —атомарные операции + барьеры памяти. На примере LSA barrier:

  • Этап прибытия: каждый rank с помощью атомарной записи (или атомарного сложения) обновляет свой слот прибытия. На этом шаге обязательно должна использоваться семантика release, гарантирующая, что все операции с памятью до barrier видны другим rank.
  • Этап опроса: каждый rank с помощью атомарного чтения (или volatile-чтения) проверяет все слоты. На этом шаге обязательно должна использоваться семантика acquire, гарантирующая, что после обнаружения «все прибыли» можно прочитать данные, записанные другими до их barrier.
  • Этап сброса: после завершения barrier необходимо сбросить слоты для следующего использования. Управление конкурентностью на этом шаге наиболее тонкое — если сбросить слишком рано, можно перезаписать метки rank, которые ещё не прочитали их.
〔Проектные предположения и архитектурные компромиссы〕

3 * nBarriersНесколько управляющих полей, вероятно, предназначены для обработки таких «раундов»: одно поле записывает текущий раунд, одно поле записывает счётчик достижений, одно поле служит флагом сброса. Таким образом, несколько барьеров могут повторно использовать один и тот же набор слотов без путаницы раундов.

Руководство по избеганию проблем в production

Проблема 1: управление жизненным циклом дескриптора。outReq->outBufferHandle = &outHandle->bufHandleАдрес внутреннего поля дескриптора передаётся в NCCL. Если пользовательncclDevCommCreateуничтожил его до возвратаoutHandle, то при обратной записи NCCL произойдёт запись в освобождённую память. Правильный подход — привязать жизненный циклoutHandleк DevComm, а не к области видимости функции, создавшей его.

Проблема 2: произведение количества барьеров на размер команды。bufferSize = (3*n + n*team.nRanks) * sizeof(uint32_t)Вn*team.nRanksэлемент при больших командах начинает доминировать по размеру. 1024 ранга, 100 барьеров требуют100*1024*4 = 409600байт, примерно 400 КБ. Если каждый ранг запрашивает столько, нагрузка на видеопамять становится недопустимой. Следует запрашивать память по фактическому количеству одновременно используемых барьеров, а не по общему количеству барьеров.

Проблема 3: исчерпание сигналов GIN barrier. Сигналы GIN — это ресурс сетевой карты, их количество ограничено. Если несколько DevComm одновременно запрашивают большое количество сигналов GIN, слоты сетевой карты могут быть исчерпаны. В production-коде при неудачном создании DevComm следует проверить, не вызвано ли это нехваткой сигналов GIN, и рассмотреть возможность уменьшенияnBarriersили перехода на LSA barrier.

Проблема 4: отложенное проявление ошибок инициализации。ncclTeamLsaФункции вродеncclDevrInitOnceпри неудаче📎 src/nccl_device/core.cc:22-33возвращают пустую команду (team.nRanks > 0)。

---

), не сообщая об ошибке. Если пользовательский код не проверяет возвращаемые значения последующих API, он может продолжать работу с пустой командой, что приводит к трудно локализуемым ошибкам. Рекомендуется при первом использовании device-side API явно проверять валидность команды (например,

V. Слияние ядер: зачем запихивать коммуникацию и вычисления в одно ядро

Интуитивная модельВ традиционной модели один «AllReduce + функция активации» требует двух ядер: одно для коммуникации, одно для вычислений. Между двумя ядрами происходит неявная глобальная синхронизация — коммуникационное ядро должно полностью завершиться, прежде чем вычислительное ядро сможет начать. Это какэстафета: первый бегун должен передать палочку второму, и в момент передачи оба ждут. Слияние ядер позволяет одному и тому же ядру выполнять и коммуникацию, и вычисления, какчеловек, который бежит и одновременно меняет обувь

, устраняя ожидание при передаче.

Структуры данных и раскладка памяти

  • Ключевой момент слияния ядер: коммуникационные примитивы (такие как barrier) и вычислительная логика разделяют регистры и разделяемую память одного ядра. Это означает:Давление на регистры
  • : атомарные операции и циклы опроса коммуникационных примитивов занимают регистры, урезая бюджет регистров для вычислительной логики.Конкуренция за разделяемую память
  • : если буфер LSA barrier размещён в разделяемой памяти, он будет конкурировать с потребностями вычислительной логики в разделяемой памяти.Влияние на Occupancy
: occupancy слитого ядра обычно ниже, чем у чисто вычислительного ядра, поскольку коммуникационные примитивы требуют дополнительных ресурсов.

〔Проектные предположения и архитектурные компромиссы〕

Дизайн device-side API (объявление ресурсов на host-стороне, потребление на device-стороне) как раз направлен на смягчение этих нагрузок: ресурсы предварительно выделяются на host-стороне, device-side ядру нужно только читать и писать, без динамического запроса, что снижает занятость регистров.

Сценарный Walkthrough: поток выполнения слитого ядра

1. Предположим, пользователь хочет написать слитое ядро «AllReduce + ReLU»:Подготовка на host-сторонеncclLsaBarrierCreateRequirement: вызовncclDevCommCreateдля запроса barrier, вызов

2. для выделения ресурсов.Запуск ядра

3. : пользовательское ядро принимает DevComm и дескриптор barrier в качестве параметров.Фаза коммуникацииncclLsaBarrier: внутри ядра вызывается

4. для синхронизации всех рангов, затем ранги обмениваются данными (через прямое чтение/запись симметричной памяти).Фаза вычислений

5. : после завершения синхронизации ядро напрямую выполняет ReLU над локальными данными, без дополнительного запуска ядра.Завершение

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

Копировать

Эта сравнительная диаграмма показывает ключевую выгоду слияния: устранение неявной глобальной синхронизации на границе ядер. В традиционной модели цена этой синхронизации — задержка двух запусков ядер плюс опустошение конвейера GPU.

Проектные размышления и подводные камниПочему device-side API не предоставляет напрямую «слитый AllReduce»?Потому что конкретная форма слияния зависит от вычислительной логики пользователя. NCCL предоставляетпримитивы(barrier, сигналы, доступ к симметричной памяти), а неготовые решения

(слитый AllReduce+ReLU). Пользователь должен сам комбинировать эти примитивы, чтобы реализовать слитое ядро, соответствующее его потребностям. Это принципиальное различие между «моделью программирования» и «библиотекой».:Отладка объединённого kernel значительно сложнее, чем раздельного. Если в логике барьера есть ошибка, это может привести к зависанию kernel (взаимоблокировке), а зависание GPU kernel диагностировать не так просто, как зависание host-процесса. Рекомендуется добавить в объединённый kernel механизм тайм-аута или сначала проверить логику барьера на небольшой команде.

Подводные камни:Снижение occupancy объединённого kernel может привести к потерям производительности вычислений, превышающим выигрыш от экономии на коммуникации. Прежде чем принимать решение об объединении, следует измерить сквозное время до и после объединения, а не только снижение задержки коммуникации.

Вопросы для размышления и самопроверки в этой главе

Q1: Если убрать изncclTeamLsaв L26 вызовncclDevrInitOnceи напрямую вернутьcomm->devrState.lsaSizeиlsaSelf, в каких сценариях device-side kernel прочитает некорректную информацию о команде?

Разбор ответа:ncclDevrInitOnce— это идемпотентная точка входа для инициализации device-side ресурсов. Если её убрать,comm->devrState.lsaSizeиlsaSelfмогут остаться начальными значениями (обычно 0 или неопределёнными). В сценарии первого использования device-side API пользовательский вызовncclTeamLsaвернётnRanks = 0пустую команду. Если в дальнейшем пользователь не проверит валидность команды и напрямую вызовет с этой командойncclLsaBarrierCreateRequirement, будет вычисленоbufferSize = (3*n + n*0) * 4 = 12nбайт — меньше, чем реально необходимо, посколькуn*team.nRanksстановится равным 0. Это приведёт к переполнению буфера: во время работы барьера будет предпринята попытка записатьteam.nRanksслотов прибытия, но в буфере выделено только3nместа дляuint32_t. Что ещё более скрыто: еслиlsaSelfтакже равно 0,ncclTeamRankToLsaвернёт неправильный номер rank, из-за чего слот прибытия барьера будет записан не туда, и, возможно, никогда не дождётся прибытия всех rank, что приведёт к зависанию kernel. Именно это и должна предотвращать стратегия, описанная в комментариях к L23-25 — «вернуть мусорное значение, следующий API сообщит об ошибке», — но при условии, что следующий API действительно сообщит об ошибке, а не будет молча использовать неправильный размер.

Q2:ncclLsaBarrierCreateRequirementФормула размера имеет вид(3*nBarriers + nBarriers*team.nRanks) * sizeof(uint32_t). Если в команде 8 rank, пользователь запрашивает 1 барьер, буфер составляет 44 байта. Предположим, что в реализации барьера «3 управляющих поля» — это «счётчик прибытий», «раунд» и «флаг сброса». Проследите: что произойдёт, когда 8 rank одновременно прибудут, если «счётчик прибытий» использует неатомарную++операцию?

Разбор ответа: неатомарная++на GPU — это три шага «чтение-изменение-запись», а не атомарная операция. Когда 8 rank одновременно выполняютcount++, может возникнуть ситуация, при которой несколько rank прочитают одно и то же старое значение (например, все прочитают 0), а затем все запишут обратно 1. В итогеcountувеличится только на 1, а не на 8, из-за чего барьер всегда будет считать, что «ещё не все собрались», и все rank зациклятся на этапе опроса. Именно поэтому слот прибытия LSA barrier должен использовать атомарные операции (например,atomicAdd) или каждый rank должен писать в свой отдельный слот (пунктnBarriers * team.nRanksкак раз резервирует отдельный слот для каждого rank). Если применить схему «каждый rank пишет в свой слот», атомарное сложение не нужно — достаточно атомарной записи + барьера памяти, поскольку у каждого слота только один писатель. Это также объясняет, почему в формуле размера есть пунктnBarriers * team.nRanks— он обменивает пространство на атомарность, избегая конкуренции нескольких писателей.

Q3:ncclGinBarrierCreateRequirementтребуетcommпараметра, аncclLsaBarrierCreateRequirement— нет. Если принудительно добавитьcommпараметр и в LSA barrier (предположим, для унификации интерфейса), какие проблемы проектирования это вызовет? И наоборот, если убратьcommпараметр у GIN barrier, в каких сценариях это приведёт к сбою?

Разбор ответа: Проблема добавленияcommпараметра в LSA barrier — это внесение ненужной зависимости. Ресурсы LSA barrier (разделяемая память) уже привязаны к команде на этапеncclDevrInitOnce,teamсам по себе подразумевает расположение ресурсов. Добавлениеcommзаставит чисто командную операцию зависеть от состояния коммуникационного домена, увеличит число точек отказа (например, при недействительномcommLSA barrier также не сможет быть создан) и нарушит принцип «минимальных привилегий». И наоборот, удалениеcommпараметра у GIN barrier приведёт к сбою, потому что GIN-сигнал должен быть привязан к конкретному сетевому соединению.ncclGinBarrierCreateRequirementВginSignalCountнужно знать, на какой сетевой адаптер и какой QP (Queue Pair) отправлять сигнал; эта информация находится в состоянии сетевого транспортного уровняcomm. БезcommNCCL не сможет определить, в слот какого сетевого адаптера должен быть направлен сигнал, и не сможет гарантировать корректную маршрутизацию сигнала к целевому rank. Это отражает один из принципов проектирования device-side API:объявление потребности в ресурсах зависит только от того контекста, который действительно необходим— LSA нужна только топология команды, GIN нужно сетевое соединение.

---

Device-side API и объединение с kernel превращают NCCL из «библиотеки, которую вы вызываете» в «модель, на которой вы программируете».ncclTeam_tпредоставляет систему координат,CreateRequirementпредоставляет механизм резервирования ресурсов, а три типа барьеров охватывают весь диапазон синхронизации от разделяемой памяти до сетевых сигналов. Но объявить ресурсы и написать объединённый kernel — ещё не значит получить хорошую производительность: количество барьеров, размер команды, гранулярность объединения — каждый из этих выборов влияет на сквозную производительность. В следующей главе мы перейдём к практической настройке производительности и посмотрим, как параметры tuning влияют на выбор алгоритма и как с помощью реальных benchmark проверить эффект от настройки.

Итак, мы прошли весь путь от сопоставления метаданных devcomm до примитивов на стороне устройства nccl_device и увидели, как NCCL через модель «объявление на хосте, потребление на устройстве» позволяет пользовательскому ядру напрямую вызывать барьерные синхронизационные операции, объединяя коммуникацию и вычисления в одном ядре. Но после освоения этих механизмов естественно возникает более практичный вопрос: когда производительность реальной задачи обучения не соответствует требованиям, как определить, вызвано ли это неправильным выбором алгоритма, несоответствием протокола или нерациональной конфигурацией числа каналов? В следующей главе механизмы первых 20 глав будут объединены в практическую методологию тюнинга, которая на основе отчётов о производительности, модели стоимости и переменных окружения даст путь диагностики от симптома к первопричине.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 21

Глава 21: Практика тюнинга производительности: методология бенчмаркинга и анализ узких мест

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 21 из 25

В предыдущей главе мы увидели, как пользовательское ядро через API на стороне устройства взаимодействует с коммуникационными примитивами NCCL и даже объединяет коммуникацию и вычисления в одном ядре. Это открывает возможность использования 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), по сути это таблица поиска + вычисление по формулам: для заданного размера сообщения, типа топологии и числа рангов оценивается время каждого сочетания «алгоритм × протокол», и выбирается наименьшее.

Интуитивная модель

Представьте модель стоимости как навигатор. Вы вводите начальную и конечную точки (размер сообщения, топология), он внутренне оценивает время для каждого маршрута (сочетания алгоритм/протокол) и рекомендует самый быстрый. Оценка навигатора основана на исторических данных и классах дорог, оценка 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}—

— обратите внимание на этот порядок, он будет неоднократно использоваться при чтении кода далее.

〔Проектные предположения и архитектурные компромиссы〕Ключевое наблюдение:({0,0,0,0,1}Tree включён только для AllReduce{1,1,1,1,1}). Это связано с тем, что преимущество алгоритма Tree заключается в том, что фаза редукции AllReduce может выполняться параллельно, но для таких операций, как 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)
      ...
    },
  },

При сравнении сразу видны различия топологий: задержка на каждый переход для Ring/Simple на NVLink составляет 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) */
  },

Каждая строка соответствует одному поколению архитектуры, три значения — это максимальная пропускная способность протокола LL в сценариях одной машины (N1), двух машин (N2) и четырёх машин (N4). 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]: двумерная булева таблица, отмечающая, включена ли определённая модель для определённой функции. Отключённые модели не участвуют в выборе.
  • generalLatencies / generalBandwidths: трёхмерный массив, хранящий оценочные задержку и пропускную способность по «функция × алгоритм × протокол». Это источник той большой таблицы, которую печатает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 без каких-либо вычислений. Это короткое замыкание — на одной карте нет коммуникации, и неважно, какой алгоритм выбран.

Шаг второй: при нескольких rank вызываетсяncclTuningComputeAllTunings, перебирающий все комбинации-кандидаты.

📎 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— это 64-битная маска, каждый бит которой соответствует одной комбинации-кандидату.NCCL_TUNING_MASK_GENERAL_KERNELS、NCCL_TUNING_MASK_SYM_KERNELS、NCCL_TUNING_MASK_CEсоответственно ограничивают разные категории кандидатов.

📎 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бит — симметричные ядра, старшие биты — методы CE (Copy Engine). Использование битовой маски вместо массива нужно для быстрого определения вncclTuningCompute, «входит ли этот кандидат в область текущей настройки».

Шаг третий: для каждого кандидата вызывается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и установке 0. Этот кандидат исключается из последующего выбора.

Шаг четвёртый: из всех допустимых кандидатов выбирается наименее затратный по времени.

📎 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, если оно больше 0, используется оно, иначе происходит откат кtimeUs。selectionTimeUs— это «время выбора», которое может включать дополнительные штрафы (например, некоторые алгоритмы в определённых сценариях требуют дополнительных затрат). Это даёт модели стоимости возможность разделять «оценочное время» и «время выбора».

Блок-схема

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

Эта диаграмма полностью отображает путь принятия решений от входа до конечного результата, включая короткое замыкание для одного rank, фильтрацию по маске, отключение моделей, вмешательство плагина tuner, переопределение CTAPolicy и все остальные ветви.

21.3 Переменные окружения: три ручки, реально влияющие на производительность

Поняв модель стоимости, становится ясно, как вмешиваются переменные окружения.NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNELЭти три переменные после разбора черезparseListнапрямую изменяют таблицу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"— все функции используют только ring и tree.

2. По префиксу функции:NCCL_ALGO="ring;allreduce:tree"— по умолчанию ring, но allreduce использует tree.

3. Синтаксис исключения:NCCL_PROTO="^LL128"— включено всё, кроме LL128.

^Префикс

📎 src/tuning/cost_model.cc:59-67

code
    int unset, set;
    if (elemList[0] == '^') {
      unset = 1;
      set = 0;
      elemList++;
    } else {
      unset = 0;
      set = 1;
    }

При разборе до^,unset=1、set=0. Затем для совпадающего prefix весь список сначала заполняется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);
      }

Обратите внимание на строку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. Порядок этой логики важен:Сначала обрабатывается возможность платформы LL128isLL128Enabled: если платформа не поддерживает LL128 (protoEnable == 2возвращает 0) и пользователь явно не запросил (

2. ), то сразу отключается.Затем обрабатывается пользовательское принуждениеforced[f] != 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защищает весь процесс чтения. Это означает, что первое чтение всех параметров является последовательным. Почему используется блокировка, а не lock-free? Потому что чтение параметров происходит только на этапе инициализации, а не на горячем пути, накладные расходы блокировки можно игнорировать, а корректность важнее.

Двойная проверка: сначала атомарное чтениеcache, если уже инициализировано, возврат напрямую. Это избегает входа в блокировку при каждом чтении параметра — хотя сама блокировка после инициализации почти не конкурирует, атомарное чтение быстрее.

Стратегия кэширования:noCacheФлаг определяет, записывать ли прочитанное значение обратно вcache. Некоторые параметры (например, требующие динамического отклика) могут отключать кэширование и каждый раз перечитывать переменную окружения.

Обработка ошибок:strtollПри неудачном разборе используется значение по умолчанию и выводится предупреждениеATTN. Обратите внимание на проверкуend == str— если строка с самого начала не является числом,endбудет равноstr, что означает, что число вообще не было разобрано.

Поддержка файлов конфигурации

Переменные окружения не обязательно задавать из shell, 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;
        }
      }
    }
  }

Условия-охранники в этом коде очень плотные, стоит разобрать их по порядку:

1. input->comm->tuner == NULL: этот участок выполняется только при отсутствии плагина tuner. Когда плагин имеет право выбора, NCCL не вмешивается.

2. input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY: пользователь установил стратегию приоритета эффективности.

3. ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL: пользователь не форсировал алгоритм/протокол. Если форсировал, уважается выбор пользователя.

4. !input->comm->MNNVL: сценарий MNNVL не поддерживается.

5. input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)): NVLS/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);
          }
        }
      }
    }

Дерево решений отката:

  • Если и буфер отправки, и буфер приёма зарегистрированы (ncclSymSendRegRecvReg), откат не выполняется.
  • Если это ядро LL, и один поток управляет несколькими 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— это проблема конфигурации пользователя; иначе возвращаетсяncclInternalError— это внутренняя проблема NCCL (все кандидаты были неожиданно исключены).

21.5 Руководство по избежанию проблем в продакшене

Проблема первая: опечатка в переменной окружения приводит к тихому откату

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;
        }

Здесь будет выведено WARN и возвращена ошибка. Но если у вас не включёнNCCL_DEBUG=WARN, вы можете не увидеть это предупреждение.Рекомендация: при тюнинге всегда устанавливайтеNCCL_DEBUG=WARNилиNCCL_DEBUG=INFO, чтобы гарантированно видеть результаты разбора конфигурации.

Проблема вторая: взаимодействие NCCL_ALGO и NCCL_PROTO

Если вы установилиNCCL_ALGO=tree, но не установилиNCCL_PROTO, NCCL выберет оптимальный протокол для алгоритма Tree. Но если вы одновременно установилиNCCL_ALGO=treeиNCCL_PROTO=LL, а комбинация Tree/LL отключена для некоторых функций (например, Tree включён только для AllReduce), это вызовет ошибку «нет доступных комбинаций».

📎 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.

Проблема третья: платформенные ограничения 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: GPU до Volta не поддерживают LL128.
  • intraType <= PATH_NVB: Внутримашинное соединение должно быть уровня NVLink.
  • Hopper + CUDA 11.8 + AllReduce + Ring + 2 ранга: это известный сценарий с багом, явно исключённый.

Рекомендация: Если ваша платформа не поддерживает LL128, не устанавливайте принудительноNCCL_PROTO=LL128, иначе возникнет ошибка. Позвольте NCCL выбрать автоматически.

Ловушка четвёртая: количество каналов и видеопамять

Чем больше каналов, тем больше требуется буферов. В сценариях с ограниченной видеопамятью слишком много каналов может привести к 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веткуinput->comm->nRanks <= 1) в

, что произойдёт? В каких сценариях это приведёт к проблемам?:

Справочный анализ📎 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);
    ...

КопироватьncclTuningComputeAllTuningsЕсли убрать эту ветку, сценарий с одним рангом войдёт в

1. , перебирая все кандидатные комбинации. Проблема в том, что:Потеря производительности

2. : при одном ранге нет коммуникации, оценка времени всех алгоритмов — чистые накладные расходы, выбор любого из них одинаков. Перебор всех кандидатов — чистая трата.Возможен случай, когда результат не будет выбранtunings: некоторые алгоритмы при одном ранге могут быть признаны моделью недействительными (например, Ring требует как минимум 2 ранга для образования кольца), что приводит к пустому спискуncclTuningSelectBestTuning,FLT_MAXвозвращает начальное значениеbestTuning.algo, и в итогеNCCL_ALGO_UNDEF。

3. остаётсяСрабатывание пути ошибкиbestTuning.algo == NCCL_ALGO_UNDEF: если📎 src/tuning/tuning.cc:308-329, происходит переход в обработку ошибкиncclInternalError。

, выводится предупреждение "No algorithm/protocol available" и возвращается

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В

Копировать

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 22

Глава 22: Производственный траблшутинг: распространенные ловушки и диагностика зависаний

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 22 из 25

Официальный источник: NVIDIA/nccl

Версия: Commit @12df1a11

Прогресс книги: Глава 22 / 25

Глава 22: Производственная диагностика и подводные камни: типичные взаимоблокировки, тайм-ауты, несовпадение версий и решения по диагностикеncclGroupStart() / ncclGroupEnd()В предыдущей главе мы разобрали порядок диагностики и ключевые ручки для настройки производительности, но сбои NCCL в производственной среде зачастую связаны не с недостаточной производительностью, а с тем, что программа просто зависает или падает. Корень этих сбоев обычно не в том, что какая-то функция написана неправильно, а в нарушении порядка вызовов, жизненного цикла или версионных контрактов. Эта глава сосредоточена на четырёх наиболее типичных подводных камнях: взаимоблокировки из-за неправильного использования семантики group, тихие ошибки из-за отсутствия проверки параметров, несовпадение версий ABI, а также границы тайм-аутов и повторных попыток. Мы проследим по четырём линиям — src/group.cc, src/misc/argcheck.cc, src/include/checks.h и contrib/nccl_ep/nccl_ep.cc — и увидим, как NCCL внутренне блокирует ошибку ещё до её возникновения.ncclGroupEndНеправильное использование семантики Group: почему "забыли написать GroupEnd" приводит к зависаниюncclGroupDepthИнтуитивная модель: Group — это "корзина покупок", а не "переключатель ускорения"

Представьте

Это наиболее распространённая форма взаимоблокировки в продакшене: код в некоторой ветке обработки исключенияreturn, пропустилncclGroupEnd, аncclGroupDepthявляетсяthread_localи не очищается автоматически при возврате из функции.

Структура данных: thread_local состояние группы

NCCL хранит всё состояние группы в thread-local storage — это ключ к пониманию взаимоблокировки.

📎 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: накопленная этим потоком ошибка группы. Как только один вызов завершается неудачей, последующиеncclGroupEndсразу пойдут по пути ошибки.
  • ncclGroupCommHead[]: головы связных списков коммуникационных доменов, сгруппированных по типу задачи (collective / rawTask / mgmtTask / symRegister).
  • ncclAsyncJobs: очередь асинхронных задач, ожидающих выполнения (например, preconnect, symmetric register).
  • ncclGroupBlocking:-1означает «ещё не встретился ни один коммуникационный домен»,0означает неблокирующий,1означает блокирующий. Это поле — ядро последующего обнаружения «смешанного использования блокирующего и неблокирующего режимов».
〔Предположения о дизайне и архитектурные компромиссы〕

Использованиеthread_localвместо глобальной переменной имеет прямую мотивацию: NCCL позволяет нескольким потокам иметь независимые контексты группы, не мешая друг другу. Цена — при завершении потока это состояние не очищается автоматически; если поток завершается в середине группы, состояние утекает.

Пошагово: полная цепочка проверок одного GroupEnd

Сценарий: приложение вызываетncclGroupEnd(), в этот моментncclGroupDepthравно 1.

Шаг первый: проверка, действительно ли мы внутри группы:

📎 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, это означает, что в группе нет ни коммуникационного домена, ни асинхронной задачи, и логически мы не должны были сюда попасть.

Шаг четвёртый: ветвление в зависимости от режима блокировки. Неблокирующий идёт через асинхронную отправку в потоке, блокирующий — через синхронную отправку:

📎 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 уже отправлен», а неблокирующего — «при возврате из вызова задача уже в очереди, но ещё не отправлена». Если оба находятся в одной группе,ncclGroupEndне может дать единую семантику возврата — ждать или не ждать? NCCL выбирает прямой отказ, вынося проблему на границу API.

Продакшен-грабли: три реальных сценария

Сценарий первый: пропущен GroupEnd в ветке обработки исключения.Код междуncclGroupStartиncclGroupEndбросает исключение или досрочноreturn,ncclGroupDepthостанавливается на 1. Все последующие вызовы коммуникации переходят в состояние «накопления заказов» и никогда не отправляются. Метод диагностики: передncclGroupEndнапечататьncclGroupDepth, или с помощьюgdbнаблюдать за этой thread_local переменной.

Сценарий второй: использование одного и того же comm из разных потоков.Поскольку состояние группы являетсяthread_local, после вызоваncclGroupStartпотоком A вызовncclAllReduceпотоком B не войдёт в группу A. Если A и B работают с одним и тем же comm, возникнет путаница «часть вызовов внутри группы, часть вне группы». NCCL не обнаруживает эту ситуацию, так как предполагает, что один comm в любой момент времени используется только одним потоком.

Сценарий третий: взаимодействие CUDA graph capture и группы.Посмотрим на проверку в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 оказываются «навсегда повреждёнными». Поэтому правило таково — все коммуникационные домены в одной группе должны либо все быть в 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 читать мусорные данные или, что хуже, — тихо испортит чужую видеопамять.

Структура данных: режимы проверки и глобальная очередь проверок

Проверка параметров 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, по завершении группы выполняется глобальная проверка согласованности между рангами.
〔Проектные выводы и архитектурные компромиссы〕

Эта архитектура — компромисс между производительностью и корректностью:cudaPointerGetAttributes— это синхронный вызов CUDA, и его вызов при каждой коммуникации на горячем пути значительно замедлит передачу малых сообщений. Поэтому в режиме по умолчанию выполняется только «нулевая по стоимости» проверка, а дорогостоящая валидация указателей оставлена для режима отладки.

Пошагово: три уровня защиты CudaPtrCheck

Сценарий: пользователь передаёт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. На многокартовой машине, если пользователь забыл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

Это самая «тяжёлая» проверка в NCCL, срабатывает только приncclCheckModeDebugGlobal. Она проверяет — согласовано ли состояние регистрации симметричной памяти на всех рангах.

📎 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собирает(isSymRegistered, bigOffset, userOffset)каждого ранга, а затем сравнивает по рангам. Если send buffer ранга 0 зарегистрирован в симметричной памяти, а ранг 3 — нет, здесь будет ошибка.

〔Проектные выводы и архитектурные компромиссы〕

Почему эта проверка важна? Симметричная память (symmetric memory) требует, чтобы все ранги обращались к буферам по одному и тому же набору виртуальных адресов. Если буфер какого-то ранга не зарегистрирован, вычисленный в kernel адрес будет неверным, что приведёт к чтению мусора или выходу за границы. Такая ошибка во время выполнения проявляется как «результат иногда неверен», и её крайне сложно отладить. NCCL предпочитает заблокировать её на границе API ценой одного allGather.

Подводные камни в продакшене

Камень первый: в режиме по умолчанию ошибки указателей не сообщаются.Если пользователь не включил режим отладки и передал указатель на неправильное устройство, NCCL не сообщит об ошибке на этапеArgsCheck, а обнаружит её только при выполнении kernel — к этому моменту может быть уже испорчена память другого ранга. Рекомендуется на этапе разработки использоватьNCCL_DEBUG=WARNвместе сcheckModeдля отладки.

Камень второй:ncclCheckModeDebugGlobalнакладные расходы allGather вПри каждой коммуникации выполняется bootstrap allGather, что в сценариях с малыми сообщениями и высокой частотой становится узким местом. Этот режим подходит только для отладки, но не для продакшена.

Камень третий: жизненный цикл 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)

Комментарий говорит ясно: на пути очистки нужно «попытаться выполнить все шаги очистки», и первый ошибкой нельзя прерываться. Но код ошибки должен сохранить первую — потому что первая ошибка обычно является наиболее диагностически ценной первопричиной.

Ожидание и прерывание: проверка abortFlag в NCCLWAIT

📎 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загрузку, чтобы гарантировать видимость сигнала прерывания, записанного другим потоком.

〔Проектные выводы и архитектурные компромиссы〕

Эта архитектура решает классическую проблему: когда один ранг даёт сбой, другие ранги могут продолжать бесконечно ждать его данных.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При неудаче конструктор выбрасывает исключение (например, превышено число потоков). Этот макрос преобразует исключение вncclSystemError, предотвращая проникновение исключения через границу C API.

📎 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)При неудаче выделения возвращает nullptr вместо исключения. Это стандартная практика C++-кода на границе C API.

Подводные камни в продакшене

Камень первый:ncclInProgressошибочно принимается за успех.Некоторые пользователи пишутif (ret == ncclSuccess)для проверки успеха, но в неблокирующем режиме возвращаетсяncclInProgress. Правильный подход —if (ret == ncclSuccess || ret == ncclInProgress), либо использоватьncclCommGetAsyncErrorдля запроса.

Камень второй:NCCLCHECKиспользуется в деструкторе.Если использовать в деструктореNCCLCHECK, ошибка сразуreturn, пропуская последующую очистку. Следует использоватьNCCLCHECKIGNORE。

Несовпадение версий ABI: дизайн nccl_ep на основе size

Интуитивная модель: 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, чтобы отлавливать «неинициализированные» структуры.
  • Сейчас требуется строгое равенство, в будущем планируется поддержка «мягкого» режима: если хвост заполнен нулями, допускается меньший size.

Пошагово: процесс проверки 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уже гарантирует безопасность раскладки памяти. Номер версии скорее подсказывает, что «поведение может отличаться».

Подводные камни в продакшене

Камень первый: забыли инициализировать макросом INIT.Если пользователь вручную обнулит структуруmemset,magicбудет равно 0,EP_REQUIRE_STRUCTзавершится ошибкой. Обязательно использовать макросNCCL_EP_*_INIT.

Камень второй: смешивание динамических библиотек разных версий.Если приложение слинковано с новой версиейlibnccl_ep.so, но заголовочный файл старой версии,sizeof(struct)будет несовпадать,EP_REQUIRE_STRUCTнемедленно выдаст ошибку. Это задумано — быстрое падение лучше тихой ошибки.

Камень третий: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 до timeout_cycles в nccl_ep

Интуитивная модель: тайм-аут — это «предохранитель»

В распределённой коммуникации зависание одного 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 хранит указатель abortFlag у comm. Когда 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истинны, abortFlag всех job устанавливается в 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, а не устаревшего из кэша.

Подводные камни в продакшене

Камень первый: слишком короткий тайм-аут вызывает ложные срабатывания.ЕслиNCCL_EP_TIMEOUT_MSзадан слишком малым, нормальные сетевые колебания будут ошибочно приняты за тайм-аут. Рекомендуется задавать исходя из реального сетевого RTT, как правило, не менее 10 секунд.

Камень второй: abortFlag установлен, но не очищен.Как только abortFlag установлен в 1, comm переходит в состояние «прервано». Если пользователь хочет продолжить использовать этот comm, нужно сначала очистить abortFlag. В NCCLncclCommAbortвыполняет эту очистку.

Камень третий: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 должен быть полностью или не быть вовсе.

2. Проверка параметров:ArgsCheckПроверка по режимам, в режиме по умолчанию выполняются только проверки с нулевой стоимостью;CudaPtrCheckТри уровня защиты блокируют недействительные указатели, неверные устройства, повреждённый comm;registrationCheckВыполняется проверка согласованности симметричной памяти между рангами.

3. Распространение ошибок:NCCLCHECKСемейство гарантирует, что ошибки не теряются;ncclInProgressне является ошибкой;NCCLCHECKIGNOREиспользуется для сохранения первой ошибки в пути очистки;NCCLWAITпроверка abortFlag в цикле опроса.

4. Версия ABI:nccl_epИспользуется дизайн на основе size, каждое пересекающее границу структуры начинается сsize, в сочетании сmagicдля обнаружения неинициализированных данных; новые поля можно добавлять только в конец, при чтении используетсяsizeдля определения наличия.

5. Тайм-аут и прерывание: ядро используетabortFlagдля распространения прерывания;nccl_epиспользуется тактовая частота GPU для тайм-аута,async_error_flagиспользуется host-pinned память для реализации асинхронного уведомления GPU→host.

Вопросы для размышления и самопроверки в этой главе

Q1: Если вncclGroupEndInternalизменитьif ((--ncclGroupDepth) > 0) goto exit;(📎 src/group.cc:1061) наif (ncclGroupDepth > 0) goto exit;(без декремента), что произойдёт? Каковы будут последствия в сценарии с вложенными группами?

Справочный анализ:

Исходный код--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 требуется 2 миллиарда вызовов, на практике более вероятно логическое зависание).

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) {  // 错误版本

Тогда для памяти хоста или managed-памятиattr.deviceможет быть -1 или 0, что не совпадает сcomm->cudaDev, и будет ложное сообщение "несовпадение устройства".

Конкретный сценарий: пользователь передаёт указатель, выделенныйcudaMallocManaged. Для managed-памятиattr.deviceобычно является устройством на момент выделения, но если память мигрировала на другое устройство,attr.deviceможет измениться. Чаще встречается память хоста (например, pinned-память, выделеннаяcudaHostAlloc),attr.deviceравно -1 и не равно никакомуcudaDev, что вызовет ложное срабатывание.

NCCL допускает использование памяти хоста в качестве коммуникационного буфера (через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будет проигнорировано.

Хуже того, если старый вызывающий записалrecv_topk_idx_kindпо старому смещению (8), а новая библиотека читает по новому смещению (12), будет прочитано значениеnew_field, полная путаница.

Поэтому железное правило дизайна ABI:новые поля можно добавлять только в конец структуры. Тогда у старого вызывающегоsizeменьшеfield_endнового поля, функция корректно возвращает значение по умолчанию; у нового вызывающегоsizeпокрывает новое поле, чтение работает нормально. Вставка поля в середину нарушит все проверки версий на основеoffsetof.

В этой главе разобраны четыре типичные ошибки в производственной среде и их внутренние механизмы защиты. Эти граничные условия напоминают нам, что стабильная работа NCCL зависит не только от основной реализации, но и от адаптации и расширения окружающей экосистемы. В следующей главе мы перейдём к экосистеме и расширениям и посмотрим, как такие периферийные проекты, как nccl4py, nccl4rust, nccl_ep, nccl_ubx, несут возможности NCCL более широкому кругу пользователей.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 23

Глава 23: Расширение экосистемы: nccl4py, nccl4rust, nccl_ep и интеграция с фреймворками

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 23 из 25

В предыдущей главе мы разобрали типичные сбои NCCL в производственной среде — неправильное использование семантики group, несоответствие числа rank, взаимодействие с stream, конфликты версий ABI и сетевые таймауты. Большинство этих проблем возникает при прямом использовании C ABI, тогда как современные фреймворки обучения больших моделей часто не вызывают C ABI напрямую, а используют привязки к Python, Rust и другим языкам либо задействуют расширенные проекты для сценариев MoE, сверхширокополосной связи и т.п., чтобы переиспользовать возможности NCCL. Эти сопутствующие проекты находятся в каталогах bindings/ и contrib/, позиционируются как экспериментальные, поддерживаемые сообществом, и не наследуют гарантии качества выпуска основной библиотеки. В этой главе мы последовательно разберём nccl4py, nccl4rust, nccl_ep, nccl_ubx и nccl_checkpoint и посмотрим, как они через языковые привязки, расширения device API и перехват символов строят богатую экосистему вокруг ядра.

nccl4py: привязки Cython и дизайн namespace-пакета

Интуитивная модель: перевод 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 распространяются в составе wheel в виде файлов.pxd, чтобы другие расширения Cython могли напрямуюcimport 📎 bindings/nccl4py/README.md:39-43:

cython
from nccl.bindings cimport cynccl
〔Предположения о дизайне и архитектурные компромиссы〕

Почему стоит открывать слой Cython, а не только Python? Потому что в некоторых фреймворках (например, DeepSpeed, Megatron) основной цикл написан на Cython, и накладные расходы на интерпретатор Python при каждом вызове слишком велики. Прямойcimport cyncclпозволяет расширениям Cython вызывать функции NCCL почти с нулевыми накладными расходами, как в C. Это типичный дизайн «слоистого раскрытия» — высокий уровень для обычных пользователей, низкий для сценариев, чувствительных к производительности.

Namespace-пакет: несколько дистрибутивов совместно используют префиксnccl

Это самое остроумное решение в nccl4py.nccl— это неявный namespace-пакет 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. Если Python-привязки nccl4py и nccl_ep захотят предоставитьnccl.xxx, возникнет конфликт — победит тот, кто установился первым. Namespace-пакеты PEP 420 решают эту проблему: без__init__.pyнесколько дистрибутивов могут каждый помещать подпакеты в каталогnccl/, а система импорта Python объединит их. Так nccl4py предоставляетnccl.bindingsиnccl.core, nccl_ep предоставляетnccl.ep, и оба могут сосуществовать📎 contrib/nccl_ep/README.md:80-82。

Этот дизайн критически важен для расширения экосистемы: в будущем любой третьей стороне, желающей добавить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. ABI CUDA 12 и 13 несовместимы, один wheel не может покрыть всё. Использование extra позволяет pip выбрать правильные бинарные зависимости в соответствии с окружением пользователя и избежать обнаружения несоответствия версий только во время выполнения.

Производственные подводные камни

Камень первый: конфликт namespace-пакета с__init__.py.Если какой-либо сторонний пакет поместилnccl/под__init__.py, механизм namespace-пакета PEP 420 будет нарушен, что приведёт к сбою импортаnccl.core. Метод диагностики:python -c "import nccl; print(nccl.__path__)", если выдаётAttributeErrorзначитncclне является namespace-пакетом.

Камень второй: дрейф версий Cython ABI. cynccl.pxd— экспериментальный API📎 bindings/nccl4py/README.md:32-32, при обновлении NCCL.pxdможет измениться. Расширения Cython, зависящие отcimport cynccl, должны строго соответствовать версии nccl4py, иначе разрешение символов на этапе компиляции завершится неудачей.

nccl4rust: владение RAII и границы на стороне устройства

Интуитивная модель: пусть компилятор управляет жизненным циклом за вас

В C выncclCommInitRankполучаете communicator, а после использования обязаныncclCommDestroy. Забыли уничтожить — утечка, уничтожили раньше времени — падение. Механизм RAII (Resource Acquisition Is Initialization) в Rust заставляет компилятор автоматически вызывать деструктор, когда переменная выходит из области видимости — как карта от гостиничного номера: при выезде система автоматически рассчитывается, не нужно вручную идти на ресепшн.

Основная ценность nccl4rust заключается в том, чтобы наложить эту семантику владения на C ABI NCCL.

Слоистая структура: пять крейтов, каждый выполняет свою задачу

Таблица Layout в README перечисляет пять крейтов📎 contrib/nccl4rust/README.md:20-28:

PathPurpose
crates/nccl-sysСырой host ABI, сгенерированный bindgen
crates/ncclОбёртка host в стиле Rust + RAII-владение
crates/nccl-device-sysno_stdОбъявления устройств CUDA-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, могут выбрать-sysкрейт. Такое «слоистое по требованию» разделение позволяет разным пользователям платить только за те затраты на компиляцию, которые им нужны.

Ключевое проектное решение: передача device-коммуникатора по указателю, а не по значению

Это самое ценное для изучения проектное решение nccl4rust. Раздел Host/device ownership boundary в README📎 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.
〔Проектные выводы и архитектурные компромиссы〕

Почему бы не зеркалировать C-структуры в Rust-структурах? Потому чтоncclDevComm_tверсионируется — в разных версиях NCCL поля могут отличаться. Если параметры ядра передавать по значению как Rust-зеркало, то ABI ядра окажется привязан к layout структуры конкретной версии NCCL. Как только NCCL обновит структуру, все уже скомпилированные ядра придётся перекомпилировать. При передаче по указателю передаётся только адрес, ядро обращается через указатель, и изменение layout не влияет на ABI. Это та же идея, что иncclEpLayoutInfo_tsize-based ABI, о котором говорилось в предыдущей главе —изолировать различия версий за указателем。

Границы безопасности: что является unsafe

Раздел Current API contracts в README перечисляет шесть контрактов📎 contrib/nccl4rust/README.md:230-249, среди которых ключевые:

  • Сырой-sysкрейт только зеркалирует C ABI, не добавляя проверок владения или времени жизни📎 contrib/nccl4rust/README.md:232-233
  • Текущие обёртки коллективных коммуникаций и точка-точка принимают сырые указатели устройств и объявлены какunsafe 📎 contrib/nccl4rust/README.md:42-45
  • Методы трансляции указателей возвращают сырые указатели устройств и не могут проверить границы смещений, выравнивание, членство в peer, алиасинг или время жизни окна📎 contrib/nccl4rust/README.md:242-244
〔Проектные выводы и архитектурные компромиссы〕

Это фундаментальная трудность привязки Rust к NCCL: многие контракты API NCCL требуют, чтобы «буфер оставался действительным до завершения CUDA stream», но система типов Rust не может выразить это асинхронное событие «завершение stream». Поэтому такие методы могут быть толькоunsafe, возвращая ответственность вызывающему. README также указывает направление улучшения📎 contrib/nccl4rust/README.md:44-45: stream-aware абстракция буфера могла бы закодировать эти требования в безопасный API. Это будущая работа.

Сторона устройства: CUDA-Oxide и LTOIR-шим

Ключевая задача на стороне устройства: device API NCCL — это 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 ядра».

Производственные подводные камни

Подводный камень первый: версия NCCL должна точно совпадать.README явно требуетMatching NCCL 2.31 headers and runtime 📎 contrib/nccl4rust/README.md:80-81, потому что прототип напрямую инициализирует поля, которые различаются в ранних версиях device API NCCL. Несовпадение версий заголовочных файлов иlibnccl.soприведёт к смещению полей device-коммуникатора.

Подводный камень второй: CUDA graph и device-коммуникатор.Device-коммуникатор — это версионированная структура в host-памяти; после копирования на устройство ядро обращается к ней через указатель. Если при захвате CUDA graph указатель устройства будет зашит в параметры ядра, то последующее пересоздание коммуникатора сделает указатель в graph недействительным. Это та же проблема, что и перераспределение RDMA buffer в nccl_ep.

Подводный камень третий: безопасную инициализацию нельзя смешивать с сырой group.README предупреждает📎 contrib/nccl4rust/README.md:238-239: безопасная инициализация и управляющие вызовы, создающие вывод, нельзя смешивать с сырымnccl-sysсостоянием group, потому что слой обёртки не видит сырое состояние group. Смешивание приведёт к конфликту между логикой опроса в слое обёртки и семантикой сырой group.

nccl_ep: примитивы dispatch/combine для экспертного параллелизма

Интуитивная модель: «сортировочный центр» MoE

В моделях MoE (Mixture of Experts) каждый token должен быть маршрутизирован к top-k экспертам. Эксперты распределены по разным GPU, поэтому token необходимо передавать между GPU — это и есть dispatch. После вычислений экспертами результаты нужно вернуть на GPU, где находится исходный token — это combine. nccl_ep — это коммуникационный движок этого «сортировочного центра».

Без него каждый фреймворк MoE должен был бы сам реализовывать коммуникационную логику dispatch/combine, что приводило бы к дублированию и сложностям оптимизации. nccl_ep делает это стандартным примитивом в экосистеме NCCL.

Два алгоритма: LL и HT

README описывает два алгоритма📎 contrib/nccl_ep/README.md:36-40:

  • Low-Latency (LL): малый batch, чувствительность к задержке (инференс LLM). Используется прямая точка-точка all-to-all коммуникация.
  • High-Throughput (HT): большой batch для обучения и префилл-фазы инференса. Используется иерархическая коммуникация — агрегация внутри узла через NVLink, между узлами через RDMA. Задействуются warp-specialized pipeline и TMA архитектуры Hopper.
〔Проектные выводы и архитектурные компромиссы〕

Разделение этих двух алгоритмов отражает различные узкие места инференса и обучения MoE. При инференсе batch мал, основное противоречие — задержка, поэтому LL использует прямую точка-точку, избегая накладных расходов на агрегацию. При обучении 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: максимальное количество token для dispatch на один rank📎 contrib/nccl_ep/README.md:344
  • rdma_buffer_size: размер RDMA-буфера в режиме LL📎 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— это лёгкий value-тип📎 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Режимы выполнения: синхронный и поэтапный

Раздел Execution Modes в README

описывает два режима:📎 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, после запуска передачи данных ресурсы GPU освобождаются, приложение может использовать их для вычислений, затем завершается черезsend_only = 1копирование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Производственные ловушкиncclEpCompleteЛовушка первая:

условная коллективность

. В режиме AUTOncclEpInitHandleявляется условным коллективным вызовом. Если какой-либо rank из-за различий в layout triggers перераспределение, остальные rank должны синхронно участвовать. Отсутствие синхронизации приведёт к взаимоблокировке или повреждению данных.ncclEpInitHandleЛовушка вторая: запрет📎 contrib/nccl_ep/README.md:396-406во время захвата CUDA graph

README явно предупреждаетncclEpInitHandle。: в режиме AUTO нельзя вызывать📎 contrib/nccl_ep/README.md:396-406междуcudaStreamBeginCaptureиcudaStreamEndCapture. Поскольку перераспределение изменяет базовый адрес RDMA, а захват graph уже зафиксировал старый указатель.ncclEpInitHandleЛовушка третья: накладные расходы guard.

README упоминает: EP по умолчанию добавляет guard к внутренним коммуникационным буферам, предотвращая взаимное повреждение данных соседними вызовами dispatch/combine. Продвинутые пользователи, уже гарантирующие отсутствие конкуренции последовательных операций, могут отключить его через📎 contrib/nccl_ep/README.md:299-303для возврата накладных расходов. Но неправильное отключение приведёт к тихому повреждению данных.NCCL_EP_DISABLE_GUARD=1nccl_ubx:融合集合通信与对称分配器

Интуитивная модель: передать «упаковку и распаковку до и после переезда» той же компании-перевозчику

Интуитивная модель: передать «упаковку и распаковку до и после переезда» тоже компании-перевозчику

Обычные коллективные коммуникации только перемещают данные. Но в реальных моделях перед AllReduce часто требуется сложение с остатком, а после — RMSNorm. Если выполнять эти операции раздельно, данные будут совершать несколько лишних проходов по видеопамяти. Идея nccl_ubx: объединить сложение с остатком, RMSNorm и квантизацию mxfp8 в ядро коллективной коммуникации📎 contrib/nccl_ubx/README.md:6-9. Как служба переезда, которая не только перевозит коробки, но и помогает упаковать и распаковать — всё за один раз.

Аппаратное требование: обязательна поддержка NVLink multicast

README явно требует SM 9.0+ (Hopper/Blackwell), а путь ядра MC требует аппаратной поддержки NVLink multicast📎 contrib/nccl_ubx/README.md:24-24. SM 8.0 (A100) не поддерживается, так как Ampere не имеет аппаратной поддержки NVLink multicast,multimem.*встроенный PTX не может быть ассемблирован для arch 8.0📎 contrib/nccl_ubx/README.md:24-24。

〔Проектные предположения и архитектурные компромиссы〕

Это объясняет, почему ubx является «экспериментальным» — он зависит от возможности NVLink multicast, появившейся только в Hopper.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 требует, чтобы все ранги использовали один и тот же набор виртуальных адресов для доступа к буферам (об этом говорилось в главе 14). Но пользователи PyTorch привыкли использоватьtorch.Tensor. ubx позволяетtorch.Tensorбазовому хранилищу напрямую быть симметричным окном NCCL, так что пользовательский код не нужно менять, но коллективные коммуникации могут работать с нулевым копированием — входные и выходные буферы и есть сама симметричная память, дополнительное копирование не требуется.

Варианты коллективных коммуникаций и автоматический выбор

Таблица Available collectives в README📎 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 multicast,ucиспользует обычный unicast,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, три чтения и записи видеопамяти. После объединения всё выполняется одним ядром, экономия пропускной способности видеопамяти составляет 2/3. Для обучения больших моделей, ограниченного пропускной способностью, это реальное ускорение.

MoE token dispatch + квантизация 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 бит, после квантизации объём данных уменьшается вдвое, потребность в пропускной способности при передаче между узлами уменьшается вдвое. Квантизация перед передачей лучше, чем после — экономится пропускная способность сети, а не видеопамяти. Это ключевая оптимизация для инференса MoE.

Производственные подводные камни

Подводный камень первый:TORCH_CUDA_ARCH_LISTобязательно должен иметь суффиксa.README подчёркивает📎 contrib/nccl_ubx/README.md:47-56: используйтеaсуффикс, чтобы обеспечить доступ к полномуmultimem.*набору инструкций. Некоторые варианты, специально предназначенные для ускорения, недоступны на обычном9.0/10.0, и будущие ядра при использовании этих вариантов будут молча снижать производительность или не смогут ассемблироваться.

Подводный камень второй:UBX_BUILD_TIMEOUTнакладные расходы во время выполнения.README поясняет📎 contrib/nccl_ubx/README.md:47-56: установка в 1 приведёт к компиляции тайм-аута spinloop на стороне ядра, что увеличит накладные расходы во время выполнения (дополнительнаяclock64()проверка и при тайм-аутеprintf). Включайте только при отладке зависаний.

Подводный камень третий:NCCL_NVLS_ENABLE=0деградация.README перечисляет эту переменную окружения📎 contrib/nccl_ubx/README.md:202: установка в 0 позволяет работать без NVLink multicast. Но путь ядра MC перестанет работать, останутся только варианты UC/Lamport, производительность значительно упадёт.

nccl_checkpoint: перехват LD_PRELOAD и воспроизведение состояния

Интуитивная модель: сделать снимок коммуникационного домена

Задача обучения работала несколько часов, и вдруг нужно мигрировать на другую машину или сохранить состояние для восстановления. Обычный чекпоинт сохраняет только веса модели и состояние оптимизатора, но состояние коммуникационного домена NCCL (номера рангов, соединения, буферы) невозможно напрямую сериализовать. Идея nccl_checkpoint: перехватить все вызовы NCCL, записать шаги инициализации, а при восстановлении воспроизвести эти шаги📎 contrib/nccl_checkpoint/README.md:3-7。

Как записать каждый шаг сборки мебели, чтобы после переезда собрать её заново по записи, а не пытаться перевезти собранную мебель целиком.

Ключевой механизм: перехват символов через LD_PRELOAD

Раздел Design в README📎 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, записывать параметры, а затем воспроизводить их при восстановлении.

Процесс чекпоинта

Пример на Python в README📎 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()После возврата его можно остановить.

Ограничения: три неподдерживаемых случая

Раздел Limitations в README📎 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. Не поддерживается device API —ncclDevCommвидимые объектам и устройствамncclWindow_tзначения не могут быть восстановлены📎 contrib/nccl_checkpoint/README.md:136-136

〔Проектные выводы и архитектурные компромиссы〕

Третье ограничение — самое серьёзное. Device API — это новое направление NCCL (DevComm, рассмотренный в главе 19), но checkpoint его не поддерживает. Это означает, что приложения, использующие device API (например, nccl_ep, nccl_ubx), не могут восстанавливаться через checkpoint. Это проявление фрагментации экосистемы — новые возможности развиваются быстро, а инструменты надёжности не успевают.

Подводные камни в production

Камень первый:NCCL_CHECKPOINT_KVS_PATHЗадаётся до создания checkpoint, при восстановлении изменить нельзя.Предупреждение в README📎 contrib/nccl_checkpoint/README.md:221-221: эта переменная окружения не используется на этапе подготовки checkpoint, но будет захвачена в checkpoint, и при восстановлении её нельзя будет легко изменить. Поэтому её необходимо задать до создания checkpoint, и адрес Redis в среде восстановления должен совпадать.

Проблема вторая:NCCL_CHECKPOINT_KVS_TIMEOUTПокрывается только Redis rendezvous в shim.В README указано📎 contrib/nccl_checkpoint/README.md:221-221: по умолчанию 300 секунд. Как только communicator воспроизводится и входит в фазу установления передачи NCCL, низкоуровневые вызовы передачи NCCL используют собственное поведение и могут требовать специфичной для передачи диагностики. То есть тайм-аут защищает только фазу Redis, а зависание на фазе установления передачи нужно диагностировать черезNCCL_DEBUG.

Проблема третья: версия NCCL должна совпадать.README требует NCCL 2.31.0 или новее📎 contrib/nccl_checkpoint/README.md:158, а также рекомендуетNCCL_SRCточное совпадение версии NCCL в пути с версией библиотеки NCCL во время выполнения📎 contrib/nccl_checkpoint/README.md:156-158. Несовпадение версий приведёт к смещению компоновки структур при воспроизведении.

Размышления о дизайне: три модели расширения экосистемы

Оглядываясь на эти пять проектов, можно выделить три модели расширения экосистемы NCCL:

Модель первая: языковые привязки (nccl4py, nccl4rust).Ключевая проблема — владение и жизненный цикл. ABI C не имеет семантики владения, и слой привязки должен восполнить это самостоятельно. nccl4py использует многоуровневый подход на Cython, nccl4rust — RAII +unsafeграницы. Общее у них:Изолировать различия версий за указателями— nccl4rust передаёт DevComm через указатель, nccl4py изолирует версии через пакеты пространств имён.

Шаблон второй: расширение API устройства (nccl_ep, nccl_ubx).Ключевая задача — управление версиями ABI и жизненным циклом ресурсов. nccl_ep использует ABI на основе размера (подробно описано в предыдущей главе), nccl_ubx использует симметричный аллокатор. Общее:Ленивое выделение + коллективное перераспределение— RDMA-буфер nccl_ep и симметричный пул nccl_ubx выделяются по требованию, но перераспределение требует синхронизации всех рангов.

Шаблон третий: перехват символов (nccl_checkpoint).Ключевая задача — захват и воспроизведение состояния. ИспользуетсяLD_PRELOADперехват всех вызовов NCCL, запись шагов инициализации и их воспроизведение при восстановлении. Этот шаблон не изменяет ядро NCCL, но позволяет прозрачно добавить возможность создания контрольных точек для существующих приложений.

〔Проектные выводы и архитектурные компромиссы〕

Общее ограничение всех трёх шаблонов —совместимость версий NCCL. Все проекты требуют точного соответствия версии NCCL, поскольку ABI NCCL развивается. Это отражает фундаментальное напряжение в экосистеме NCCL: ядро быстро итеративно развивается, но периферийным проектам нужна стабильность. 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["Fusion коллективных операций<br/>nccl_ubx"]
    ckpt --> preload["Перехват через LD_PRELOAD<br/>nccl_checkpoint"]
    cython --> abi{"Управление версиями ABI"}
    raii --> abi
    ep --> abi
    ubx --> abi
    preload --> abi
    abi -->|"Передача через указатели"| safe["Изоляция различий версий"]
    abi -->|"На основе размера"| safe
    abi -->|"Пакеты пространств имён"| safe

Эта диаграмма принятия решений демонстрирует пути выбора расширения NCCL. По какому бы пути ни пошли, в конечном итоге придётся столкнуться с основной проблемой управления версиями ABI, а три технических подхода (передача указателей, ABI на основе размера, пакеты с пространствами имён) — все они изолируют различия версий за стабильным интерфейсом.

Краткое содержание главы

В этой главе проанализированы пять сопутствующих проектов экосистемы NCCL:

  • nccl4pyИспользование многоуровневой архитектуры Cython + пространства имён PEP 420 позволяет экосистеме Python расширяться без конфликтовnccl.*подпакеты.
  • nccl4rustИспользование владения RAII + передача указателя на коммуникатор устройства изолирует версионированную компоновку структур C за пределами ABI ядра.
  • nccl_epИспользование двойного алгоритма LL/HT + ленивое выделение буферов RDMA предоставляет примитивы dispatch/combine для MoE, но вводит ограничения условных коллективных вызовов и недействительности CUDA graph.
  • nccl_ubxИспользование симметричного аллокатора + слияние ядер позволяет включить сложение остатков, RMSNorm и квантование mxfp8 в ядра коллективной коммуникации, но зависит от аппаратной поддержки NVLink multicast на Hopper+.
  • nccl_checkpointИспользованиеLD_PRELOADперехвата символов + Redis rendezvous реализует контрольные точки домена коммуникации между машинами, но не поддерживает device API и CUDA graph.

Вопросы для размышления и самопроверки в этой главе

Q1: В режимеrdma_buffer_size = NCCL_EP_AUTOnccl_ep, если 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). В режиме AUTOncclEpInitHandleявляется условным коллективным вызовом — срабатывание перераспределения зависит от того, требует ли(layout, num_topk)данного handle большего пространства, чем текущий буфер.

Если layout rank 0 требует большего буфера и вызывает перераспределение, а layout rank 1 не требует, то rank 0 выполнит коллективную операцию «deregister window → free → ncclMemAlloc → register»📎 contrib/nccl_ep/README.md:396-406, а rank 1 — нет. Это приводит к двум проблемам:

1. Несогласованность коллективных операций: window deregister/register в NCCL — коллективные операции, требующие участия всех rank. Одностороннее выполнение rank 0 приведёт к тому, что rank 1 в последующей коммуникации будет ссылаться на старый дескриптор окна, тогда как rank 0 уже переключился на новое окно, что вызовет сбой коммуникации или искажение данных.

2. Несогласованность базовых адресов: после перераспределения базовый адрес RDMA rank 0 изменился, а 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 коммуникатор создан с новой версией и скопирован на устройство, ядро через указатель обратится к новой компоновке. Само ядро не требует перекомпиляции, так как его параметр — всего лишь адрес. Это изолирует различия версий за указателем —указатель стабилен, а содержимое, на которое он указывает, может меняться。

Это та же философия проектирования, что и size-based ABI в nccl_ep: использовать уровень косвенности для изоляции изменчивых деталей версий за стабильным интерфейсом.

Q3: nccl_checkpoint используетLD_PRELOADдля перехвата вызовов NCCL, но если приложение одновременно линкует nccl4py и nccl_checkpoint, а Cython-биндинг nccl4py напрямую вызывает символlibnccl.so,LD_PRELOADсможет ли перехватить? Проанализируйте порядок разрешения символов.

Справочный анализ: это зависит от порядка разрешения символов.LD_PRELOADМеханизм заключается в следующем: динамический компоновщик перед загрузкой разделяемых библиотек, от которых normalmente зависит приложение, сначала загружаетLD_PRELOADуказанные.so. Когда приложение (или библиотека, от которой оно зависит) ссылается на символ, динамический компоновщик выполняет поиск в порядке «кто загружен раньше, тот и разрешается первым» —LD_PRELOADиз.soимеет приоритет надlibnccl.so。

Поэтому теоретически, когда Cython-привязка nccl4py вызывает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, но это разрешение символов самого checkpoint, а не символов NCCL.

2. Момент привязки символов: если nccl4py привязывает символы NCCL до того, какLD_PRELOADвступит в силу (например, в__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или статическую компоновку, перехват перестанет работать. В production-использовании следует применятьLD_DEBUG=bindingsдля проверки привязки символов и подтверждения того, что вызовы NCCL перехватываются shim.

В следующей главе мы перейдём к эволюции архитектуры и будущим направлениям и посмотрим, как NCCL превращается из библиотеки коллективных коммуникаций в программируемый коммуникационный движок.

Эти сопутствующие проекты через языковые привязки, расширения device API и перехват символов демонстрируют способы повторного использования ключевых возможностей NCCL в различных сценариях. Сквозным ограничением для всех проектов остаётся совместимость версий NCCL ABI — size-based ABI, передача указателей, namespace-пакеты — всё это технические приёмы, изолирующие различия версий за стабильным интерфейсом. Понимание этих приёмов — обязательное условие безопасного использования таких сопутствующих проектов. Пока эти расширяющие проекты постоянно прощупывают границы ядра, сам NCCL незаметно эволюционирует: от фиксированных коллективных операций к программируемому коммуникационному движку, от host proxy к прямой отправке с GPU, от регистрируемых буферов к симметричной памяти. В следующей главе мы на основе следов эволюции в исходном коде рассмотрим, как эти изменения преобразят способы коммуникации верхнеуровневых фреймворков.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 24

Глава 24: Эволюция архитектуры и направления будущего: от статической связи к программируемой

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 24 из 25

В предыдущей главе мы увидели, как сообщество строит экосистему вокруг ядра NCCL: привязки Python, привязки Rust, экспертную параллельную коммуникацию, сверхширокополосные примитивы, контрольные точки коммуникации. Все эти проекты используют стабильный API NCCL, но их требования выходят за рамки традиционной коллективной коммуникации — экспертному параллелизму нужен мелкозернистый обмен точка-точка, контрольным точкам нужно приостанавливать/возобновлять состояние коммуникации, сверхширокополосным примитивам нужно обходить стандартные коллективные операции и напрямую работать с сетью. Эти требования указывают на одну проблему: фиксированная модель коллективных операций NCCL разрывается более гибкими потребностями коммуникации. В этой главе мы не будем рассматривать отдельный модуль, а, отталкиваясь от уже появившихся в исходном коде следов эволюции, обсудим, куда движется NCCL. Конкретно, мы разберём три переплетающиеся эволюционные силы: коммуникационные примитивы переходят от фиксированных коллективов к программируемым — планирование задач RMA в src/rma/rma.cc позволяет верхнему уровню комбинировать примитивы Put/Signal/WaitSignal, а не только вызывать AllReduce; инициирование сети переходит от host proxy к прямой отправке с GPU — управление бэкендом GIN в src/gin/gin_host.cc позволяет GPU-ядру напрямую управлять сетевой картой; модель памяти переходит от зарегистрированных буферов к симметричной памяти — выбор ядра симметричной памяти в src/sym_kernels.cc позволяет всем рангам использовать один и тот же набор виртуальных адресов для доступа к буферам друг друга. Эти три силы не изолированы, они используют одну и ту же инфраструктуру: абстракцию team в src/nccl_device/core.cc и версионированный DevComm в src/devcomm/devcomm_v23100.cc. Поняв, как они сцепляются, вы поймёте логику эволюции NCCL от «библиотеки коллективной коммуникации» к «программируемому коммуникационному движку».

I. Программируемые примитивы связи: как RMA превращает «фиксированное меню» в «шведский стол»

Интуитивная модель

Традиционная коллективная связь NCCL похожа на фиксированный набор: вы заказываете AllReduce, и кухня выполняет весь процесс AllReduce. Но в сценарии экспертного параллелизма (MoE) каждый токен должен быть отправлен разным экспертам, и шаблон отправки вообще неизвестен на этапе компиляции — это как шведский стол, где вы сами решаете, что взять, сколько взять и когда взять.

RMA — это тот самый «шведский стол», который NCCL предоставляет верхнему уровню: Put (записать данные в память удалённой стороны), Signal (уведомить удалённую сторону), WaitSignal (ожидать сигнал от удалённой стороны). Верхнеуровневый фреймворк может свободно комбинировать эти три примитива для реализации произвольных шаблонов связи.

Без RMA all-to-all в MoE можно было бы реализовать только через многократные мелкомасштабные коллективные операции, каждая из которых требует полного запуска ядра и процедуры синхронизации, что даёт неприемлемо высокую задержку.

Структуры данных и разметка памяти

Ключевая структура данных 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.

Пошаговый разбор: планирование одного WaitSignalncclWaitSignalРассмотрим конкретный сценарий: rank 0 вызывает

, ожидая сигналы от 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Если

равно

📎 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Шаг третий: разделить peer по доступности через LSA.comm->devrState.lsaRankListКопировать

Происходит обход

📎 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;
}

Шаг четвёртый: создать по одной новой задаче для CE и Proxy.

Копировать

📎 src/rma/rma.cc:249-251

cpp
planner->nTasksRma -= 1;
ncclMemoryPoolFree(&comm->memPool_ncclTaskRma, firstTask);

Шаг пятый: освободить исходную задачу.

Копировать

Исходная задача уже разделена на две новые задачи и возвращается в пул памяти.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, и наконец входной поток ожидает event потока CE. Таким образом, оба пути продвигаются параллельно, но внешне это выглядит как одна синхронная операция.

〔Проектные соображения и архитектурные компромиссы〕

Здесь компромисс таков: параллельное выполнение снижает задержку, но добавляет накладные расходы на запись event и синхронизацию потоков. Для малых сообщений эти накладные расходы могут превысить выгоду от параллелизма; для больших сообщений выгода от параллелизма значительна. NCCL не делает здесь адаптивного выбора, а всегда идёт по параллельному пути — потому что типичный сценарий RMA — это мелкогранулярная связь с большими сообщениями. isLsaAccessibleРуководство по избежанию проблем в productionlsaRankListПроблема 1: ошибка в определении доступности LSA приводит к выбору неправильного пути для задачи.lsaSizeПроисходит обходscheduleRmaTasksToPlan, и еслиnRmaTasksProxyравно 0 (например, домен связи с одним rank), все peer будут признаны недоступными и все пойдут по пути Proxy. Это не проявится при мелкомасштабном тестировании, но при крупномасштабном развёртывании приведёт к резкому падению производительности. Метод диагностики — посмотреть в INFO-логахnRmaTasksCeсоотношение

и.peersCeПроблема 2: жизненный цикл массива peer после разделения задачи WaitSignal.ncclMemoryStackAllocВ пути CEcomm->memScopedиспользуетpeersProxyдля выделения, и жизненный цикл следует заncclCalloc; в пути Proxyfree. Если создание задачи Proxy завершается неудачей,failветка освобождает эти массивы.

📎 src/rma/rma.cc:302-308

cpp
exit:
  return ret;
fail:
  free(peersProxy);
  free(nsignalsProxy);
  free(signalIdxsProxy);
  goto exit;

Ловушка 3: пакетная обработка задач Put/Signal между контекстами.В ветке Put/Signal NCCL объединяет задачи put/signal всех контекстов в один план, но останавливается при обнаружении 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);
    ...
  }
}

Замысел этого дизайна: один запуск ядра охватывает put/signal всех контекстов, снижая накладные расходы на запуск. Но очередь каждого контекста потребляется только до первого WaitSignal, что гарантирует порядок FIFO для каждого контекста. Если верхний уровень чередует вызовы put и waitSignal в одном контексте, эффект пакетной обработки сильно снижается — это паттерн, который нужно учитывать при использовании RMA.

---

II. Прямая отправка в сеть с GPU: как GIN позволяет ядру обойти host proxy

Интуитивная модель

Традиционная сетевая коммуникация NCCL похожа на отправку письма: ядро GPU помещает данные в буфер, поток host proxy передаёт данные сетевой карте, карта отправляет их. GIN же позволяет ядру GPU напрямую опустить письмо в почтовый ящик получателя — ядро напрямую пишет в очередь отправки сетевой карты, а карта напрямую читает память 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 (драйвер сетевой карты, прошивка) и библиотеки NCCL различаются. Если жёстко закодировать требования к версиям, любое обновление одной из сторон приведёт к несовместимости. Использование массивов для сопоставления версий позволяет динамически выбирать во время выполнения, обеспечивая обратную совместимость со старыми бэкендами.

ncclGinStateDevComm— это состояние GIN каждого DevComm, содержащееcontextCount、backendIndex、ginCtx[]、devHandles[]и другие поля. Оно связывается в связный список и прикрепляется кginState->devComms.

Пошаговый разбор: установка одного GIN-соединения

Представим сценарий: rank 0 инициализирует коммуникационный домен и должен установить GIN-соединение.

Шаг первый: проверка, включён ли и поддерживается ли 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. Если пользователь явно отключил, сразу возвращается ошибка.

Шаг второй: проверка поддержки симметричной памяти.

📎 src/gin/gin_host.cc:111-114

cpp
if (!comm->symmetricSupport) {
  WARN("Communicator does not support symmetric memory!");
  return ncclInternalError;
}

GIN зависит от симметричной памяти — поскольку ядру GPU нужно знать виртуальный адрес буфера удалённой стороны, только симметричная память гарантирует совпадение адресов.

Шаг третий: получение списка локальных устройств 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. Если их большеNCCL_GIN_MAX_CONNECTIONS, берутся только первые несколько с выводом предупреждения.

Шаг четвёртый: вычисление команды 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 — это вся мировая команда; иначе соединяется только первый rank каждого host (rail-соединение).ncclTeamRankToWorldпреобразует rank внутри команды в мировой rank.

Шаг пятый: установка соединений по каждому бэкенду.

📎 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обменивается handle между всеми rank, так что каждый 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связного списка сначала устанавливает его, а поток прогресса, увидев это, добровольно уступает, избегая конкуренции за блокировку.

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.у каждого rankginCommCountможет различаться (зависит от количества локальных сетевых карт), 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]);
}

Если количество сетевых карт у какого-либо ранга меньше, чем у других рангов, все ранги понижаются до минимального значения. Это гарантирует симметрию соединений, но приводит к неэффективному использованию ресурсов сетевых карт.

Проблема 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больше ли фактического количества сетевых карт.

Проблема 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 безопасно. Однако если во время уничтожения есть незавершённые сетевые операции, это может привести к неопределённому поведению — это то, что необходимо обеспечить при использовании GIN: перед освобождением DevComm нужно убедиться, что все операции завершены.

---

Часть третья. Ядро симметричной памяти: от «регистрируемых буферов» к «единому адресному пространству»

Интуитивная модель

Буферы традиционного NCCL основаны на «регистрации»: каждый ранг регистрирует свой буфер, а при обмене данными адреса передаются через handle. Симметричная память — это «единое адресное пространство»: все ранги договариваются об одном и том же наборе виртуальных адресов; адрес A ранга 0 и адрес A ранга 1 указывают на их собственную физическую память, но в коде можно обращаться к ним по одному и тому же адресу.

Это похоже на договорённость «3-й ряд, 5-е место» — у каждого дома оно указывает на одно и то же место, и при поиске вещей не нужно сначала спрашивать «где у тебя 3-й ряд, 5-е место».

Без симметричной памяти каждому ядру пришлось бы сначала разрешать адрес удалённой стороны, что увеличивает накладные расходы на инструкции и давление на регистры.

Структуры данных и раскладка памяти

Ядро симметричной памяти основано на kernel mask — битовой карте, которая отмечает, какие ядра доступны в текущем домене связи.

📎 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 = ...;

Каждая маска — это 32-битное целое число; если i-й бит равен 1, ядро i доступно. Эти маски сгруппированы по различным измерениям:

  • По протоколу: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)
〔Проектные предположения и архитектурные компромиссы〕

Преимущество такого битового дизайна в том, что можно быстро фильтровать доступные ядра с помощью битовых операций. Например,kmask &= ~kernelMask_STMCодной строкой можно отключить все ядра STMC, не перебирая список.

Пошаговый разбор: одно вычисление kernel mask

Возьмём сценарий: ранг 0 должен выполнить AllReduce, тип данных float16, размер сообщения 1MB, домен связи содержит 8 рангов, все соединены через NVLink.

Шаг первый: получить базовую маску, соответствующую операции.

📎 src/sym_kernels.cc:304-306

cpp
uint32_t kmask = kernelMask_coll(coll);

kernelMask_coll(ncclFuncAllReduce)возвращаетkernelMask_AR, включающую 5 ядер AllReduce.

Шаг второй: проверить доступность 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 рангов. float16 поддерживает LDMC, поэтому еслиhasLsaMultimemистинно, ядро LDMC сохраняется.

Шаг третий: проверить ограничения по размеру сообщения.

📎 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 отслеживает количество элементов с помощью 32-битного целого числа, поэтому при превышении 2GB общего числа байтов оно отключается. Если превышает 64GB, все ядра отключаются (переполнение 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 охватывает все ранги, GIN не нужен; иначе сохраняются только ядра GIN.

Управление параллелизмом и взаимодействие с аппаратным обеспечением

Инициализация ядра симметричной памяти включает создание 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, который создаёт внутренний DevComm, содержащий ресурсы LSA-мультикаста, GIN inbox/outbox, сигналы и т. д.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;

Ядро симметричной памяти использует отдельный буфер profiler, чтобы избежать чередования с workCounter обычных ядер.

Руководство по избежанию проблем в production

Проблема 1: требования TMA-ядра к SMEM.TMA требует примерно 8KB SMEM scratch на каждый warp; для 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();
}

Если ёмкости SMEM на GPU недостаточно (например, в экземпляре MIG), TMA-ядро будет отключено. Метод диагностики — проверить,maxSharedMemOptinменьше лиncclTmaShmemScratchWarpSize() * 16。

Проблема 2: границы GIN chunk size.У chunk size ядра ReduceScatter GIN есть верхняя и нижняя границы.

📎 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больше 1GB, значение будет усечено до 1GB; если меньше 128 байт, оно будет повышено до 128 байт. Итоговое значение также будет округлено вниз до степени двойки.

Проблема 3: несоответствие типов регистрации симметричной памяти. ncclGetSymRegTypeНа основе флагов sendWin и recvWinNCCL_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 не совпадают, ядро должно использовать разные пути кода. Это влияет на производительность, но не приводит к ошибкам.

---

IV. Абстракция Team и версионированный DevComm: инфраструктура для эволюции

Интуитивная модель

Абстракция Team похожа на «группировку»: мировая команда — это весь класс, команда LSA — это соседи по парте, команда Rail — это места в одном столбце. Разные режимы коммуникации требуют разных перспектив группировки.

Версионированный DevComm похож на «переводчика»: разные версии кода устройства говорят на разных «диалектах», а слой совместимости DevComm отвечает за перевод, позволяя старому и новому коду понимать друг друга.

Без абстракции Team каждое ядро должно само вычислять отображение рангов; без версионированного 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, так как все ранги расположены последовательно.

📎 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 равенlsaSize, так как ранги на каждом rail разделены размером команды 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

Рассмотрим сценарий: ранг 5 в домене коммуникации из 8 рангов, размер команды LSA равен 4. Нужно вычислить ранг ранга 5 в команде Rail.

Шаг первый: инициализация состояния DevR.

📎 src/nccl_device/core.cc:70-79

cpp
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};

ncclDevrInitOnceвычисляет производную информацию, такую как команда LSA, команда CFT и т.д. В случае неудачи возвращает пустую команду.

Шаг второй: вычисление параметров команды 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

Ранг ранга 5 в команде Rail равен 1, команда содержит 2 ранга, шаг равен 4.

Шаг третий: преобразование обратно в мировой ранг.

📎 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 в мировой ранг:5 + (0 - 1) * 4 = 1. Проверка: ранг 1 и ранг 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» — если инициализация не удалась, возвращается пустая команда, при следующем вызове будет повторная попытка.

Руководство по избеганию проблем в production

Проблема 1: предположение о шаге при преобразовании Team. ncclTeamRankToWorldпредполагает, что ранги внутри команды образуют арифметическую прогрессию.

📎 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.
  • Симметричная памятьрешает проблему «накладных расходов на разрешение адресов» — позволяет ядру напрямую обращаться к памяти удалённой стороны по унифицированному адресу.

Они не являются взаимоисключающими, а дополняют друг друга. RMA может использовать GIN в качестве нижележащего транспорта, GIN зависит от симметричной памяти для обеспечения согласованности адресов. Вместе эти три компонента образуют инфраструктуру «программируемого коммуникационного движка».

В чём заключается философия дизайна версионированного DevComm?

〔Предположения о дизайне и архитектурные компромиссы〕

Основная идея версионированного DevComm — «стабильный ABI, эволюционирующий API». Код устройства (ядро) после компиляции встраивается в бинарный файл и не может быть перекомпилирован при обновлении библиотеки NCCL. Поэтому NCCL должен гарантировать, что старый код устройства может работать с новой библиотекой.ncclDevCommCompatСтруктура является точкой входа слоя совместимости: новая библиотека выбирает подходящие правила совместимости в зависимости от версии кода устройства и при необходимости выполняет преобразование структур.

---

Резюме главы

В этой главе, отталкиваясь от следов эволюции в исходном коде, мы проанализировали три силы, движущие NCCL от библиотеки коллективных коммуникаций к программируемому коммуникационному движку:

1. RMA(src/rma/rma.cc): Через комбинацию примитивов Put/Signal/WaitSignal верхний уровень может реализовать любой режим связи. Ключевая идея дизайна — разделить задачи на два пути, CE и Proxy, которые выполняются параллельно в зависимости от достижимости LSA.

2. GIN(src/gin/gin_host.cc): Через прямой выход GPU в сеть, минуя host proxy. Ключевая идея дизайна — управление несколькими бэкендами, таблица совместимости версий, пул потоков прогресса.

3. Ядро симметричной памяти(src/sym_kernels.cc): Через единое адресное пространство устраняется накладные расходы на разрешение адресов. Ключевая идея дизайна — битовая карта маски ядра и аппаратное ускорение TMA/GIN.

4. Абстракция Team и версионированный DevComm(src/nccl_device/core.cc、src/devcomm/devcomm_v23100.cc): Предоставляет инфраструктуру для эволюции. Team даёт групповое представление, версионированный DevComm обеспечивает совместимость ABI.

Влияние этих изменений на верхнеуровневые фреймворки глубоко: ProcessGroup в PyTorch может напрямую вызывать примитивы RMA для реализации пользовательских режимов связи; экспертный параллелизм в Megatron может использовать GIN для снижения задержки all-to-all; симметричная память делает код ядер более лаконичным.

Вопросы для размышления и самопроверки в этой главе

Q1: Если убратьscheduleRmaTasksToPlanпроверку достижимости LSA в ветке WaitSignal, и все peer пойдут по пути Proxy, какие будут последствия? В каких сценариях это вызовет катастрофу производительности?

Справочный анализ:

Проверка достижимости LSA в📎 src/rma/rma.cc:187-204, она делит peer на две группы: CE и Proxy. Если убрать эту проверку, все peer пойдут по пути Proxy,nRmaTasksCeвсегда будет равно 0.

Последствия: путь CE полностью не используется, все WaitSignal опрашивают сеть через потоки host proxy. Для peer в пределах LSA (соединённых по NVLink на одной машине), которые могли бы асинхронно ожидать через копировальный движок GPU, теперь используется опрос потока host, задержка возрастает с микросекунд до миллисекунд.

Сценарий катастрофы производительности: при обучении MoE каждый token должен ждать сигналы от нескольких экспертов. Если все сигналы идут через Proxy, поток host становится узким местом, и GPU значительное время ждёт опроса host. На машине с 8 GPU, полностью соединённых NVLink, эта деградация особенно заметна — вся связь, которая могла бы идти через CE, теперь идёт через host.

Метод диагностики: смотретьscheduleRmaTasksToPlanINFO-логи, еслиnRmaTasksCeвсегда равно 0, аnRmaTasksProxyочень велико, значит, с проверкой LSA проблема.

Q2:ncclGinProgressВwritePendingфлагdevCommRwMutexи взаимодействие с блокировкой чтения-записи, если убратьwritePendingпроверку, оставив только блокировку чтения-записи, какие будут проблемы?

Справочный анализ:

writePendingПроверка в📎 src/gin/gin_host.cc:63-66, она заставляет поток прогресса активно уступать, когда главный поток собирается писать. Если убрать эту проверку, поток прогресса сразу попытается взять блокировку чтения.

Проблема в том, что:std::shared_timed_mutexблокировка чтения является разделяемой, несколько потоков прогресса могут держать её одновременно. Если главный поток хочет взять блокировку записи, он должен дождаться освобождения всех блокировок чтения. При высокой нагрузке потоки прогресса часто берут блокировку чтения, и главный поток может долго не получить блокировку записи, что приводит кncclGinDevCommSetupилиncclGinDevCommFreeблокировке.

Что ещё серьёзнее: если главный поток вginProgressWriteLockсначала устанавливаетwritePending, а затем берёт блокировку, а поток прогресса не проверяетwritePending, то поток прогресса может продолжать брать блокировку чтения после того, как главный поток установил флаг, что делает время ожидания главного потока непредсказуемым.

writePendingРоль

Q3:ncclSymkMaskВnBusBytes >= 32 * (size_t(2) << 30), если приkmask = 0все ядра отключены (ncclSymkAvailable), в этот момент

возвращает false, к какому пути откатится NCCL? Какое влияние на производительность оказывает этот путь отката?:

kmask = 0Справочный анализ📎 src/sym_kernels.cc:342ВncclSymkAvailable, в этот момент📎 src/sym_kernels.cc:354-361)。

возвращает false (

Путь отката: NCCL будет использовать традиционные ядра коллективных операций (не ядра симметричной памяти). Эти ядра обращаются к памяти peer через зарегистрированные буферы, требуя предварительного разрешения адресов, что увеличивает накладные расходы на инструкции.

Влияние на производительность: для очень больших сообщений (более 64 ГБ шинных байт) накладные расходы на разрешение адресов в традиционных ядрах составляют малую долю, поскольку сама передача данных доминирует. Но в пограничных случаях (чуть больше 64 ГБ) традиционные ядра могут быть на 10-20% медленнее, чем ядра симметричной памяти.

Корневая причина этого ограничения: ядра симметричной памяти отслеживают chunk развёрнутого цикла с помощью 32-битных целых чисел, каждый chunk не менее 32 байт, поэтому максимальный адресуемый диапазон составляет 32 * 2^31 = 64 ГБ. Превышение этого диапазона вызывает целочисленное переполнение.

---

В реальном производстве сценарии, где одна коллективная операция превышает 64 ГБ, редки (обычно это all-reduce после накопления градиентов), но не невозможны. Если такой сценарий встретится, можно рассмотреть фрагментированную связь или использование традиционных ядер.

Переход к концу главы

В этой главе мы увидели, что NCCL движется от «фиксированных коллективных операций» к «программируемому движку связи»: RMA предоставляет комбинацию примитивов, GIN обеспечивает прямой выход GPU, симметричная память предоставляет единое адресное пространство, Team и версионированный DevComm предоставляют инфраструктуру.Позволяет вышестоящим фреймворкам реализовывать пользовательские схемы коммуникации с меньшей задержкой и большей гибкостью. Для таких фреймворков, как PyTorch и Megatron, это означает, что они могут напрямую строить поверх NCCL сложные схемы коммуникации, такие как MoE all-to-all, конвейерный параллелизм, экспертный параллелизм, без необходимости обходить NCCL и реализовывать собственный сетевой уровень.

Следующая глава — последняя в книге. Мы ещё раз пройдём весь путь одного AllReduce — от вызоваncclAllReduce, через постановку задачи в очередь, выбор алгоритма, запуск ядра, продвижение прокси, передачу по сети, вплоть до возврата результата. Этот обзор свяжет знания из предыдущих 24 глав в единую карту понимания.

Итак, мы увидели три основные линии эволюции NCCL от фиксированных коллективных операций к программируемому коммуникационному движку: композиция примитивов RMA, прямая отправка в сеть с GPU, модель симметричной памяти, а также поддерживающие их абстракция team и версионированный DevComm. Эти механизмы вместе указывают на более гибкое будущее коммуникаций, более близкое к аппаратным возможностям. Однако, как бы ни развивалась архитектура, полный путь одного AllReduce всегда остаётся краеугольным камнем понимания NCCL. В следующей главе мы не будем вводить новый код, а заново пройдём сквозной поток от главы 3 до главы 10 — от вызова ncclAllReduce до установления коммуникационного домена, поиска топологии, выбора алгоритма, постановки задачи в очередь, запуска ядра, выполнения примитивов на устройстве, записи результата. Вы заново соберёте механизмы, разбросанные по главам, в целостную ментальную модель и получите индекс «при какой проблеме какую главу смотреть».

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

CHAPTER 25

Глава 25: Панорамный обзор и ретроспектива: полное путешествие одного AllReduce

Upstream: NVIDIA/nccl · Commit @12df1a11 · Прогресс: Глава 25 из 25

В предыдущей главе, основываясь на следах эволюции в исходном коде, мы рассмотрели тенденции архитектуры NCCL: от фиксированных коллективных операций к программируемости, от host proxy к прямой отправке с GPU, от регистрируемых буферов к симметричной памяти. Теперь пришло время проверить эти тенденции в конкретном потоке выполнения. Эта глава не вводит новый код, а заново связывает сквозной путь от главы 3 до главы 10 — от строки вызова ncclAllReduce до записи результата в память GPU. После прочтения вы должны чётко ответить: через какие функции проходит один AllReduce? В каком файле и на какой строке находится каждая функция? Какую главу смотреть при возникновении проблемы?

I. Инициализация: как «вырастает» коммуникационный домен

Интуитивная модель

Представьте коммуникационный домен как «групповой чат». Вы вызываетеncclCommInitRank— это «заявка на вступление в групповой чат», и NCCL должен в этот момент определить список участников (peerInfo), кто с кем по какой линии (топология), сколько конвейеров на каждую линию (channel).Если на этом шаге ошибка, вся последующая коммуникация будет ошибочной— как будто кого-то не добавили в групповой чат, и ваше сообщение всегда не дойдёт до одного человека.

Структуры данных и размещение в памяти

Основная структура коммуникационного домена —ncclComm, её инициализация делится на два этапа:commAllocотвечает за «выделение скелета»,initTransportsRankотвечает за «наполнение плотью».

commAllocВнаиболее примечателен дизайнсчётчика ссылок на разделяемые ресурсыncclSharedResources. Когда подчинённый коммуникационный домен (созданный через split/shrink) переиспользует ресурсы родительского, он не копирует их, а разделяет один и тот же

📎 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));
}

КопироватьrefCountНамерение этого кода ясно: такие «тяжёлые ресурсы», как сетевые плагины, RMA, GIN, инициализируются только один раз, а подчинённые коммуникационные домены просто заимствуют их.

использует атомарные операции для увеличения, гарантируя, что при многопоточности не будет повторного освобождения.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— сигнальное значение. Если какой-либо код ошибочно использует неинициализированный канал,

немедленно выявит проблему, а не прочитает случайный участок памяти.

Пошагово: от ncclCommInitRank до initTransportsRankncclCommInitRankПосле вызова пользователем

1. ncclCommInitRankфактический поток выполнения таков:ncclInitEnvсначала вызываетncclGroupStartInternalдля загрузки плагина окружения, затем вызывает

для входа в семантику group (это необходимо для поддержки «инициализации нескольких коммуникационных доменов в одной group»).ncclCommInitRankDev2. Затем вызываетсяcomm, который выполняет проверку параметров, выделяет структуру, разбирает config, а затем:

📎 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()Обратите внимание на веткуncclAsyncLaunch— это след正在进行 рефакторинга enqueue в NCCL. По умолчанию идёт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.peerInfoValidсохраняется с семантикой release, чтобы гарантировать, что при виде этого флага другими потоками содержимое peerInfo уже было видимым.

  • AllGather3: обмен результатами вычисления топологии (структура ring/tree, пропускная способность, количество каналов и т.д., вычисленные каждым rank), затем берётсяминимальное значениепо всем 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"]

Размышления о дизайне и подводные камни

Почему инициализация должна быть асинхронной?Поскольку многоранговая инициализация требует межпроцессной синхронизации (bootstrap), синхронное выполнение заблокировало бы вызывающий поток. После асинхронизации пользователь может одновременно инициализировать несколько коммуникационных областей в группе, продвигая их параллельно.

Подводные камни: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(например, из-за нехватки видеопамяти), остальные rank будут ждать здесь вечно. При столкновении с «зависанием инициализации» в продакшене первое, что нужно проверить — не провалился лиdevCommSetupу какого-то rank.

II. Постановка задач в очередь: от вызова API до внутреннего объекта задачи

Интуитивная модель

Пользователь вызываетncclAllReduce— это как заказать еду в ресторане.ncclEnqueueCheck— это официант, который переводит ваш заказ в понятный кухне «рабочий лист» (ncclTaskColl), помещая его вcomm->plannerэтот «пул заказов».Без этого слоя NCCL не смог бы объединять несколько вызовов в один запуск kernel— каждый заказ готовился бы на отдельном огне, что крайне неэффективно.

Структуры данных и размещение в памяти

Ядро постановки задач в очередь — этоncclKernelPlanner, который привязан кcomm->planner. Ключевые поля включают:

  • collSorter: очередь задач коллективной коммуникации, отсортированная по объёму трафика
  • collTaskQueue: итоговая отсортированная очередь задач
  • peers[]: очереди send/recv для каждого peer (используется в P2P)
  • wipPlan: строящийся план kernel

Ключевые поля объекта задачи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». Переменные окружения имеют наивысший приоритет, затем 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).

III. Выбор алгоритма: как модель стоимости находит оптимальное решение

Интуитивная модель

Выбор алгоритма похож на выбор маршрута в навигаторе. «Модель стоимости» 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 GPU на одном узле, размер сообщения 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Макрос временно подавляет предупреждение, потому что "ни один алгоритм не подошёл" может быть нормальной ситуацией (выбранный пользователем набор действительно недоступен). Ошибка выдаётся только когда

истинно.

Четыре. Планирование задач и построение kernel plan

Интуитивная модельscheduleCollTasksToPlanПланирование задач похоже на распределение кучи заказов по нескольким конвейерам.ncclKernelPlanОпределяет, сколько каналов использует каждая задача и сколько данных обрабатывает каждый канал, в итоге генерируя

— это и есть "наряд-заказ", который нужно передать GPU.

ncclKernelPlanСтруктуры данных и размещение в памяти

  • channelMaskКлючевые поля
  • workBytes: какие каналы использует этот plan (битовая карта)
  • nWorkBatches: общий размер в байтах всех структур work
  • kernelArgs: количество work batch
  • workStorageType: параметры запуска kernel

finishPlan: где хранятся данные work (args/fifo/persistent)

📎 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Компромиссы трёх типов хранения:
  • Fifo: самый быстрый, но размер параметров kernel ограничен (обычно 4KB)
  • Persistent: кольцевой буфер, подходит для средних размеров

: отдельное выделение видеопамяти, подходит для сценариев CUDA Graph

Step-by-Step: распределение каналов в scheduleCollTasksToPlan

📎 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);

Копировать

📎 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Этот код разбивает данные на три сегмента "низкий/средний/высокий":

. Низкий и высокий сегменты — это граничные каналы, средний сегмент — промежуточные каналы. Такое разбиение нужно, чтобы объём данных, обрабатываемых каждым каналом, был как можно более равномерным.calcCollChunking3. В конце вызывается

📎 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
```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)В

📎 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;
}

— предполагается, что каждые 4 коллективные операции порождают один batch. Эта оценка может быть неточной, поэтому далее следует точная проверка:

Копировать

Если точная проверка не проходит, происходит прямой возврат (без ошибки), и верхний уровень создаёт новый plan.

Пять. Запуск kernel и выполнение на стороне устройстваncclLaunchKernelИнтуитивная модельncclKernelPlanЗапуск kernel похож на передачу наряда-заказа на фабрику.cuLaunchKernelExПереводит

в параметры запуска CUDA kernel, затем вызывается

ncclLaunchKernel. Kernel на стороне устройства, получив наряд-заказ, выполняет перемещение данных согласно алгоритму.

📎 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.x = plan->threadPerBlockОбратите внимание на

— один block на каждый канал.

— количество потоков в каждом block определяется задачей.uploadWorkStep-by-Step: от plan к запуску kernel

📎 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;
  ...
  }
}

для записи данных work в целевое место (args/fifo/persistent):

📎 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;
}

2. Затем конструируются атрибуты запуска CUDA. Для sm90+ устанавливается размерность cluster:cuLaunchKernelEx:

📎 src/enqueue/enqueue.cc:1992

cpp
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);

3. В конце вызывается

КопироватьRunWorkCollСторона устройства: выполнение runRing

📎 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:

  • КопироватьКлассические две фазы Ring AllReduce:
  • Фаза Reduce-Scatter(первые nranks-1 шагов): каждый rank отправляет свои данные следующему, одновременно принимает данные от предыдущего и выполняет редукцию.

modRanksФаза AllGatherr >= nranks(последние nranks-1 шагов): распространение результата редукции по кольцу.

Эта лямбда обрабатывает зацикливание кольцевого индекса: когда

mermaid
sequenceDiagram
    participant Host as Поток Host
    participant Plan as ncclKernelPlan
    participant CUDA as Драйвер CUDA
    participant Kernel as Ядро GPU
    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 блоков
    Kernel->>Kernel: runRing() выполнение Ring AllReduce
    Host->>Plan: ncclLaunchKernelAfter_NoCuda()
    Plan->>Proxy: hostStreamPlanTask() + uploadProxyOps()
    Proxy->>Proxy: ncclProxyStart() продвижение сетевого ввода-вывода
    Kernel-->>Host: ядро завершено
    Host->>Plan: ncclLaunchFinish()
    Plan->>Plan: reclaimPlan() освобождение ресурсов

Диаграмма последовательности запуска kernel

КопироватьcuLaunchKernelExРазмышления о дизайне и подводные камниcudaLaunchKernel?Почему используется

а не:uploadWorkОбработка persistent-режима здесь очень сложна — требуется выделить видеопамять, скопировать данные, записать события, а также корректно работать в режиме захвата 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.

VI. Руководство по избежанию проблем в production

Проблема 1: Зависание при инициализации

Симптом:ncclCommInitRankзависает и не возвращает управление.

Диагностика: посмотритеNCCL_DEBUG=INFOлоги, найдите последний rank, который вывел сообщение. Если все rank вывели "Init START", но не вывели "Init COMPLETE", значит зависание произошло вinitTransportsRank.

Типичные причины:

  • СбойdevCommSetupна каком-либо rank (нехватка видеопамяти, ошибка CUDA)
  • Недоступность bootstrap-сети (firewall, занятый порт)
  • Несовпадение версий NCCL на разных rank

Основание в исходном коде:initTransportsRankВ конце

📎 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 или ошибкаncclInternalError。

Причина:waitWorkFifoAvailableожидает места в FIFO, но потребитель (kernel) не продвигается.

📎 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или уменьшите количество операций в одной группе.

Проблема 3: Сбой захвата CUDA Graph

Симптом: при вызове NCCL во время захвата CUDA Graph возникает ошибка "operation not permitted".

Причина: в режиме захвата нельзя выполнять некоторые операции CUDA (например,cudaMalloc). NCCL используетcudaThreadExchangeStreamCaptureModeдля временного переключения режима, но не все операции можно обойти.

Основание в исходном коде:uploadWorkветка persistent в

📎 src/enqueue/enqueue.cc:1445

cpp
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);

Как избежать: используйтеNCCL_GRAPH_MIXING_SUPPORT=1для включения гибридного режима graph или предварительно выделите work buffer.

Итоги главы

В этой главе мы заново прошли полный путь одного AllReduce:

1. Инициализация:ncclCommInitRank → ncclCommInitRankFunc → initTransportsRank, создание коммуникационного домена, поиск топологии, согласование параметров графа.

2. Постановка задачи в очередь:ncclEnqueueCheck → taskAppend → collTaskAppend, преобразование вызова API вncclTaskColl。

3. Выбор алгоритма:ncclGetAlgoInfo → ncclTuningCompute, выбор оптимальной пары (algo, proto) с помощью модели стоимости.

4. Планирование задач:ncclPrepareTasks → scheduleCollTasksToPlan → finishPlan, распределение задач по каналам, генерацияncclKernelPlan。

5. Запуск kernel:ncclLaunchKernel → cuLaunchKernelEx, преобразование plan в параметры запуска CUDA.

6. Выполнение на устройстве:runRing / runTreeUpDown / runNvls, выполнение перемещения данных согласно алгоритму.

Вопросы для размышления и самопроверки

Q1: Если убрать логику выравнивания min/max после AllGather3 вinitTransportsRank(L1690-L1698), в каких сценариях это приведёт к взаимоблокировке коммуникации? Почему?

Разбор ответа: этот фрагмент логики гарантирует, что все rank приходят к согласию по таким параметрам, какnChannels、bwIntra、bwInterдля каждого алгоритма. Если его убрать, каждый rank будет вычислять результат на основе своей локальной топологии. Рассмотрим гетерогенный кластер: rank 0 на машине с 8 GPU NVLink, rank 8 на машине с 4 GPU PCIe. Rank 0 вычислит 8 каналов для ring, rank 8 — 4. При выполнении Ring AllReduce rank 0 будет ждать, пока rank 8 отправит данные по 8 каналам, но rank

На этом мы завершили обзор полного пути одного AllReduce. От инициализации, поиска топологии, выбора алгоритма, постановки задач в очередь, запуска kernel до выполнения на устройстве и сетевой передачи — каждый этап соответствует углублённому анализу из предыдущих глав. Эта схема пути — не только скелет для понимания NCCL, но и индекс для диагностики проблем: при сбое инициализации смотрите главы 3 и 4, при неверном выборе алгоритма — главу 5, при ошибках постановки задач в очередь — главы 6 и 7, при сбое запуска kernel — главу 8, при зависании на устройстве — главы 9 и 10, при сетевых проблемах — главы 12 и 13. По мере развития NCCL в сторону программируемой коммуникации, GPU-инициируемых операций и симметричной памяти этот путь будет продолжать расширяться — а вы уже освоили метод его отслеживания.

Превратите любой код в понятную архитектурную книгу

Понравилась глава? Создайте книгу по своему приватному проекту

Локальная архитектура на Tauri 2 + Rust. 100% приватность офлайн, нулевая отправка кода в облако. Двухоконное чтение с неизменяемыми анкорами коммитов.

⚡ Tauri 2 · Ядро Rust · 100% Офлайн и Приватно · Проверено на 1M+ строк

Чтобы понять сложный проект, вам действительно нужна хорошая книга

Автоматически скомпилировано AiReadCode путем сканирования официального репозитория с неизменяемыми анкорами коммитов.

Поставить звезду на GitHub ★ Больше книг →