Глава 1: Запуск и наблюдаемые явления: взгляд на внешнее поведение через один AllReduce
Прежде чем углубляться в любой код ядра, мы сначала запустим NCCL и понаблюдаем за его внешним поведением. В этой главе мы не читаем ядро, а делаем только одно: создаём проверяемую систему отсчёта — любой последующий анализ внутренних механизмов в конечном итоге должен объяснять внешнее поведение, наблюдаемое здесь.
1.1 Взгляд на инженерную структуру NCCL через точку входа сборки
Интуитивная модель
Система сборки подобна строительным чертежам здания: она не определяет, кто в нём будет жить, но определяет, какие есть комнаты и куда открываются двери. Если точка входа сборки запутана, вы не сможете сделать даже первый шаг — «запустить». NCCL предоставляет две точки входа сборки — Makefile и CMake; понимание их различий — первый шаг к пониманию инженерной организации этого проекта.
Структура двух точек входа сборки
ВерхнеуровневыйMakefile— это чрезвычайно тонкий слой диспетчеризации: он сам не компилирует ни одного исходного файла, а перенаправляет работу в Makefile каждого подкаталога.
📎 Makefile:44-45определяетsrc.%шаблонные правила, перенаправляющиеsrc.build、src.installи другие цели вsrc/Makefile:
src.%:
${MAKE} -C src $* BUILDDIR=${ABSBUILDDIR}📎 Makefile:47-48определяетexamplesцель, которая зависит отsrc.build, а затем переходит вdocs/examplesкаталог для сборки примеров:
examples: src.build
${MAKE} -C docs/examples NCCL_HOME=${ABSBUILDDIR}Обратите внимание на зависимости: сборка примеров зависит от завершенияsrc.build, поскольку примерам требуется линковка с библиотекой NCCL, аNCCL_HOMEпеременная окружения передаёт каталог артефактов сборки в Makefile примеров. Это и есть ограничение порядка сборки: «сначала библиотека, потом примеры».
📎 Makefile:29перечислены все цели, доступные для очистки:
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:
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++:
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:
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 и выше:
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до получения запускаемого примера:
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определяет ключевые переменные примера:
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показывает его реальный тип:
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:
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выделяет три массива и проверяет успешность выделения:
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и печатает свойства каждого устройства:
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является ключевым:
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— это центральный вызов всего примера:
NCCLCHECK(ncclCommInitAll(comms, num_gpus, devices));ncclCommInitAll— удобная точка входа для сценария одного процесса с несколькими GPU. Заголовочный файл📎 src/nccl.h.in:301-301задаёт его контракт:
/* 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-запросов:
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).
Диаграмма последовательности процесса создания домена связи
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определены ключевые переменные:
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определён масштаб данных:
const size_t size = 32 * 1024 * 1024; // 32M floats for demonstration32M float, по 4 байта каждый, то есть 128 MB отправляющего буфера и 128 MB принимающего буфера, по одной копии на каждую карту.
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:101-120— это цикл инициализации для каждого устройства:
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— это ключевой вызов:
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явно указывает:
// NOTE: ncclGroupStart and ncclGroupEnd are essential to avoid
// deadlock when using ncclCommInitAll and multiple communication calls.Почему обязательно использовать Group? Заголовочный файл📎 src/nccl.h.in:844-864даёт объяснение:
/* 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:
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:
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
flowchart LR
subgraph dev0["GPU 0 (rank 0)"]
s0["sendbuffЭта диаграмма демонстрирует две фазы AllReduce: сначала редукция (reduce), затем широковещательная рассылка (broadcast). Каждый rankrecvbuffв итоге получает одинаковый результат.
Проектное решение: почему используется Group, а не последовательные вызовы
Если убратьncclGroupStart/ncclGroupEnd, код станет таким:
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демонстрирует стандартный процесс уничтожения:
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семантику:
/* 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:
/* 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подчёркивает:
// 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В документации явно упоминаются переходы состояний, что соответствует критериям допуска конечного автомата:
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Есть одна проверка:
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" (то есть выполнять обе операции в одном цикле), какие проблемы возникнут?Справочный разбор
ncclGroupStart();
for (i) ncclCommFinalize(comms[i]);
ncclGroupEnd();
for (i) ncclCommDestroy(comms[i]);ncclCommFinalizeКопировать
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+ строк
Глава 2: Модель базовых абстракций: коллективы, топология, алгоритмы и транспорты
Глава 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:
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。
Эти три таблицы сопоставления являются основой топологически-осведомлённых алгоритмов. Например, алгоритму 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определяет механизм синхронизации нескольких коммуникационных доменов внутри процесса:
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:
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):
int nChannels; // connection nChannels
int collChannels; // enqueue nChannels
int nvlsChannels; // enqueue nChannelsnChannels— это фактически установленное количество соединений,collChannels— количество каналов, используемых при постановке коллективной операции в очередь,nvlsChannels— количество каналов, выделенных для NVLS. Эти три значения могут различаться — например, некоторые каналы используются только для P2P, а не для коллективных операций.
Планирование P2P-каналов
📎 src/include/channel.h:21-33ОпределяетncclP2pChannelBaseForRoundфункцию, используемую для вычисления базового адреса канала, используемого в каждом round при P2P-коммуникации:
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базовый класс:
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:
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логика:
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:
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:
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логику вычисления:
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определяет пороги потоков, связанные с протоколами:
#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:
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:
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определяет типы транспортного уровня:
#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— коммуникационный интерфейс транспортного уровня:
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:
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объявляет четыре экземпляра транспортного уровня:
extern struct ncclTransport p2pTransport;
extern struct ncclTransport shmTransport;
extern struct ncclTransport netTransport;
extern struct ncclTransport collNetTransport;📎 src/include/transport.h:36-36определяет массив транспортных уровней:
extern struct ncclTransport* ncclTransports[];Информация о равноправных узлах
📎 src/include/transport.h:46-74определяетncclPeerInfo— метаданные, которыми обмениваются rank:
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 или SHMhostHashразные → разные хосты → необходимо использовать NETgdrSupport→ поддерживается ли GPUDirect RDMAcudaCompCap→ вычислительная способность 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 Как комбинируются пять компонентов: полный жизненный цикл одной коммуникации
Диаграмма взаимосвязей
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+ строк
Глава 3: Вход в инициализацию: как ncclCommInitRank формирует коммуникационный домен
В предыдущей главе мы установили пять ключевых абстракций, проходящих через всю книгу: ncclComm, channel, algorithm, protocol и transport, которые вместе образуют общий словарь «одна коммуникация = несколько channel × один algorithm × один protocol × несколько transport». Теперь мы ответим на более фундаментальный вопрос: как этот объект ncclComm вообще создаётся с нуля? Когда вы вызываете ncclCommInitRank, NCCL должен за несколько сотен миллисекунд выполнить ряд сложных операций: убедиться, что все rank на месте, обменяться информацией об устройствах, исследовать топологию машины, вычислить пути передачи данных, выделить память GPU и хоста и, наконец, упаковать всё это в объект ncclComm. В этой главе мы пройдём по этой цепочке вызовов от точки входа API вплоть до последнего капилляра initTransportsRank.
3.1 Точка входа API: синхронная оболочка и асинхронное ядро ncclCommInitRank
Интуитивная модель
ncclCommInitRankВнешне это «создание домена коммуникации», но на самом деле он делает «запуск фоновой задачи и (по умолчанию) ожидание её завершения». Это как заказ еды в ресторане: само действие заказа (вызов API) мгновенно возвращает управление, но приготовление блюда на кухне (настоящая инициализация) происходит в фоне. Режим по умолчанию — «блокирующий» — просто заставляет вас ждать у стойки, пока блюдо будет готово, а «неблокирующий» даёт вам номерок, и вы можете пока заняться другими делами.
Без этого асинхронного дизайна NCCL во время инициализации не смог бы взаимодействовать с захватом CUDA Graph, параллельной инициализацией нескольких доменов коммуникации и другими сценариями — вся инициализация превратилась бы в последовательные блокирующие операции, которые нельзя перекрыть с пользовательским кодом.
Структуры данных и размещение в памяти
Сначала посмотрим на саму точку входа API.ncclCommInitRankЭто чрезвычайно тонкая синхронная оболочка:
📎 src/init.cc:2946-2970
Она делает четыре вещи: вызываетncclInitEnv()загружает плагин переменных окружения, включает метки производительности NVTX, читает текущий номер устройства CUDA, затем вызываетncclGroupStartInternal()входит в семантику group и, наконец, делегирует фактическую работуncclCommInitRankDev。
Обратите внимание наncclGroupStartInternal() / ncclGroupEndInternal()эту пару вызовов — даже если вы инициализируете только один домен коммуникации, NCCL оборачивает его в семантику group. Это делается для единообразной обработки сценария «пользователь инициализирует несколько доменов коммуникации внутри одного group», чтобы не писать два набора кода для одного и нескольких доменов.
Настоящая проверка параметров и выделение объекта происходят вncclCommInitRankDev:
📎 src/init.cc:2851-2943
Эта функция — «главный диспетчерский пульт» всей цепочки. Сначала она проверяет параметры (nIdдиапазон,nranks/myrankкорректность), затем выделяетncclCommсаму структуру, а также три поля, связанных с механизмом прерывания:abortFlag(атомарный флаг на стороне хоста),abortFlagDev(копия в фиксированной памяти, видимая со стороны устройства),abortFlagRefCount(счётчик ссылок, поскольку дочерние домены коммуникации, полученные через split, могут разделять abortFlag родительского домена).
Здесь есть один примечательный момент —comm->startMagic = comm->endMagic = NCCL_MAGIC:
📎 src/init.cc:2886-2886
эта пара magic-значений, как «пломбы», зажата в начале и концеncclCommструктуры. Любая запись за границы или повреждение структуры нарушит эту пару magic, и последующие операции смогут обнаружить затирание памяти, проверив их. Это дешёвая, но эффективная защита целостности памяти.
Step-by-Step Walkthrough
КогдаncclCommInitRankDevдоходит до конца, она создаётncclCommInitRankAsyncJobи запускает асинхронную задачу:
📎 src/init.cc:2896-2929
jobСтруктура несёт все параметры, необходимые для инициализации. Обратите внимание, чтоjob->commIdэтокопия, а не прямая ссылка на переданный пользователемcommId:
📎 src/init.cc:2903-2910
Почему копия? Комментарий в исходном коде даёт ответ:ncclUniqueIdиncclBootstrapHandleимеют разные требования к выравниванию, и переданный пользователем массив может быть не выровнен по границе, необходимой дляncclBootstrapHandle. Копирование во вновь выделенную память гарантирует выравнивание. Это типичная «ловушка совместимости ABI» — пользователь видитncclUniqueId, а внутри это должно использоваться какncclBootstrapHandle, оба имеют одинаковый размер, но разное выравнивание.
Наконец, в зависимости от значенияncclParamEnqueueRearchEnable()задача либо попадает в очередь управления, либо запускается напрямую черезncclAsyncLaunch:
📎 src/init.cc:2922-2929
ncclAsyncLaunchсоздаёт новый поток для выполненияncclCommInitRankFunc. Если режим блокирующий (по умолчанию), вызывающая сторона ждёт завершения этого потока вncclGroupEndInternal(); если режим неблокирующий, вызывающая сторона немедленно возвращает управление, а пользователь впоследствии опрашивает состояние черезncclCommGetAsyncError.
Размышления о дизайне
Ключевая идея дизайна здесь — «синхронный API + асинхронная реализация». Почему бы не заставитьncclCommInitRankнапрямую синхронно выполнять всю инициализацию? Потому что NCCL должен поддерживать неблокирующий режимncclCommInitRankConfig, а неблокирующий режим требует выполнения инициализации в фоновом потоке. Если бы синхронный и асинхронный пути были двумя наборами кода, затраты на сопровождение удвоились бы. Всё идёт через асинхронный путь, а синхронный путь — это просто «запустить и сразу ждать», код существует в одном экземпляре.
```mermaid
flowchart TD
api["ncclCommInitRank(newcomm, nranks, commId, myrank)"]
env["ncclInitEnv() 加载环境变量插件"]
group["ncclGroupStartInternal()"]
dev["ncclCommInitRankDev(...)"]
check{"nId/nranks/myrank 合法?"}
alloc["ncclCalloc 分配 comm + abortFlag"]
parse["parseCommConfig() 解析配置"]
job["构造 ncclCommInitRankAsyncJob"]
copyid["拷贝 commId 保证对齐"]
enq{"ncclParamEnqueueRearchEnable()?"}
mgmt["ncclMgmtTaskEnqueue()"]
async["ncclAsyncLaunch() 启动后台线程"]
func["ncclCommInitRankFunc() 执行初始化"]
fail["返回 ncclInvalidArgument"]
api --> env --> group --> dev --> check
check -->|否| fail
check -->|是| alloc --> parse --> job --> copyid --> enq
enq -->|是| mgmt --> func
enq -->|否| async --> func
```3.2 Bootstrap: первый канал управления между rank
Интуитивная модель
Bootstrap — это «групповой чат перед совещанием» в NCCL. Прежде чем начнётся正式ная коммуникация, все rank должны сначала установить канал управления, чтобы обменяться метаданными: «кто я, на какой я машине, какая у меня модель GPU, какой у меня адрес сетевой карты». Без bootstrap rank — это группа незнакомцев, не способных координировать никакую коммуникацию.
Если bootstrap завершается неудачей или по тайм-ауту, инициализация всего домена коммуникации зависает — это одна из самых распространённых причин зависания NCCL в производственной среде.
Структуры данных и размещение в памяти
Основное состояние Bootstrap хранится вbootstrapStateструктуре:
📎 src/bootstrap.cc:527-546
В этой структуре есть несколько ключевых полей, которые стоит рассмотреть подробнее:
ring: объединение (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.
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 автоматически отключается.
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 --> done3.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
После раскрытия макроса генерируется:
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+ строк
Глава 4: Обнаружение топологии и поиск по графу: картографирование связности мульти-GPU
В предыдущей главе мы, следуя по цепочке вызовов ncclCommInitRank, спускались слой за слоем и увидели момент заполнения поля comm->topo, но не раскрыли его внутреннюю структуру. Итак, как же NCCL "видит" GPU и сетевые карты в машине и организует их в пригодную для использования топологическую информацию? В этой главе мы разберём три ключевых этапа этого процесса: topo.cc отвечает за перечисление физических устройств в граф, search.cc ищет на этом графе оптимальный путь, а rings.cc и trees.cc конкретизируют результаты поиска в две топологии алгоритмов — Ring и Tree. Только поняв взаимодействие этих трёх компонентов, можно осознать, почему NCCL способен автоматически выбирать подходящий алгоритм на разных машинах.
Граф топологии: рисуем машину как "схему метро"
Интуитивная модель
Представьте, что вы курьер, только что прибывший в незнакомый город. Вам нужно доставить посылку из точки A в точку B, но вы не знаете, какой путь самый быстрый. Вам нужна карта — на ней отмечены все станции (GPU, сетевые карты, CPU, PCI-коммутаторы) и связи между станциями (NVLink, PCIe, сеть). Граф топологии NCCL — это и есть такая карта.
Без этой карты NCCL может лишь слепо предполагать, что "пропускная способность между всеми GPU одинакова"; на машине с 8 GPU, полностью соединёнными через NVLink, это, возможно, ещё сойдёт, но как только встречается сложная топология с跨 NUMA,跨 PCI-коммутаторами, смесью NVLink + PCIe, будет выбран неверный путь, и данные, которые должны были идти по NVLink, попадут в медленный PCIe, а производительность упадёт вдвое.
Структуры данных и размещение в памяти
Ядром графа топологии являетсяncclTopoSystem, он хранит все устройства, сгруппированные по типам узлов. Типы узлов определены вtopoNodeTypeStrмассиве:
📎 src/graph/topo.cc:33-35
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
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
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
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
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
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
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
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
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, она хранит полный путь от некоторого исходного узла до целевого:
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
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
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, ©), 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
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
#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
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
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
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
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указатель:
rank 0 -> rank 1
rank 1 -> rank 2
...
rank 7 -> rank 0ncclBuildRingsНачиная с ранга 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 = 2up >= nranks? 2 < 8, поэтомуup = 2parentChildType = (1 < 2) ? 0 : 1 = 0(это первый ребёнок родительского узла)lowbit = 0, поэтомуdown0 = -1down1 = -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
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: два дерева работают одновременно, одно отвечает за первую половину данных, другое — за вторую, повышая эффективность использования пропускной способности. При нечётном числе рангов зеркальное отражение приводит к неполному отображению рангов, поэтому используется сдвиг.
Взаимодействие трёх компонентов: от топологии к алгоритму
Теперь свяжем три модуля вместе. Весь процесс можно представить одной схемой:
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, пока не истечёт таймаут или не будет найдено оптимальное решение.
Рассмотрим более детальную временную диаграмму, показывающую взаимодействие модулей в процессе поиска:
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, ©)
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+ строк
Глава 5: Тюнинг и выбор протокола: как модуль tuning определяет оптимальные пути и каналы
В предыдущей главе мы разобрали возможности 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
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
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
// 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
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
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
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
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
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
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). Это различие критически важно для отладки.
Блок-схема основного потока принятия решений
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
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
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
// 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
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
// 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
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
// 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
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
// 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
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
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
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
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
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
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
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
// 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
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
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
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
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
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
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
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
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
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
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+ строк
Глава 6: Диспетчеризация задач AllReduce: от вызова API до постановки задач в очередь
В предыдущей главе мы завершили модуль 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:
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
Реальный сценарий попадания в ловушку: пользователь пишет такой код:
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
— это место, где создаётся
| . Рассмотрим его ключевую логику: | Присваивание ключевых полей: | Поле |
|---|---|---|
func | info->coll | Источник |
sendbuff/recvbuff | info->sendbuff/recvbuff | Значение |
count | info->count | Тип коллективной коммуникации |
datatype | info->datatype | Указатель буфера |
trafficBytes | count * elementSize * ncclFuncTrafficPerByte | Количество элементов |
opHost/opDev | info->op/opDev | Тип данных |
chunkSteps/sliceSteps | info->chunkSteps/sliceSteps | Оценка трафика |
minCTAs/maxCTAs/nvlsCTAs | Операция редукции | Число шагов разбиения |
algMask | ncclCollConfigGetAlgMask | Разбор конфигурации |
Лимит ресурсов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: общее количество cellcellsPerChannel: количество cell, обрабатываемых каждым channelcellsLo/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: битовая маска channelworkStorageType: тип рабочего хранилища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Контроль бюджета - : черезуправляется размер каждого plan
ncclTestBudgetОптимизация агрегации - : задачи близкого размера агрегируются, что уменьшает число запусков 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Копировать
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:
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+ строк
Глава 7: Планировщик задач и каналы: разделение нагрузки и управление очередями
В предыдущей главе мы проследили путь 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
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
inline ncclResult_t ncclGroupStartInternal() {
ncclGroupDepth++;
return ncclSuccess;
}Чрезвычайно просто: глубина увеличивается на единицу. Нет выделения памяти, нет блокировок, нет системных вызовов. Именно поэтомуncclGroupStartпрактически не имеет накладных расходов.
Шаг второй:ncclAllReduceчто происходит при вызове внутри группы?
ncclAllReduceвнутри вызываетncclGroupCommJoin(comm, ncclGroupTaskTypeCollective), добавляя коммуникационный домен в связный список группы.
📎 src/include/group.h:80-116
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
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
if ((ret = ncclGroupError) != ncclSuccess) goto fail;Если хотя бы один вызов внутри группы завершился ошибкой, происходит прямой переход к очистке fail.
📎 src/group.cc:1084-1093
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
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
/* 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
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
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
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
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
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
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
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
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
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
// 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
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
if ((!simInfo) && (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr)) {
NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeCollective], ncclGroupTaskTypeCollective), ret, fail);
}Запуск всех kernel plan.
Этап 5: очистка
📎 src/group.cc:876-903
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поток данных
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
// 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
} else {
/* safety check */
assert(state == ncclGroupJobJoined);
}legacy-версия используетWARNвместоassert, новая архитектура используетassert. Это показывает, что новая архитектура предъявляет более высокие требования к корректности конечного автомата.
Размышления о дизайне
Мотивация новой архитектуры —развязка: legacygroupLaunchLegacyобъединяет все этапы в одной функции, что затрудняет поддержку и расширение. Новая архитектура разбивает каждый этап на независимые типы job, связывая их через очередь. Но поскольку планировщик и лаунчер ещё не реализованы, это пока лишь «фреймворк на перспективу».
ncclParamEnqueueRearchEnable()управляет выбором между новой архитектурой и legacy:
📎 src/group.cc:1031-1033
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
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
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+ строк
Глава 8: Запуск ядер и выполнение на GPU: координация нитей и аппаратные ресурсы
В предыдущей главе мы разобрали, как задачи разбиваются на несколько 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 в разделяемую память, а затем через
распределяет его по конкретной реализации алгоритма/протокола.
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
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
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
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
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
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 появится достаточно места. Это ожидание проверяет
Проектные соображения и подводные камни в 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не проверяет
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
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: стратегия планирования clusterCU_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
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
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
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
#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;
}
#endifCU_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
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
} 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
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
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
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
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
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
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
__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
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
// 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
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
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
#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
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
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
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
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
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
(_, 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
#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+ строк
Глава 9: Примитивы устройств: низкоуровневые протоколы LL, LL128 и Simple
В предыдущей главе мы проследили, как на стороне хоста один 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()такой унифицированный интерфейс и не заботится о том, какой протокол лежит в основе.
flowchart TD
algo["Уровень алгоритма all_reduce.h<br/>вызов prims.recvReduceSend()"] --> dispatch{"Шаблонный параметр Proto?"}
dispatch -->|ProtoLL| ll["Primitives<..., ProtoLL, ...><br/>prims_ll.h"]
dispatch -->|ProtoLL128| ll128["Primitives<..., ProtoLL128, ...><br/>prims_ll128.h"]
dispatch -->|ProtoSimple| simple["Primitives<..., ProtoSimple<...>, ...><br/>prims_simple.h"]
ll --> llop["LLGenericOp<RECV,SEND,SrcBuf,DstBuf>"]
ll128 --> ll128op["GenericOp -> recvReduceSendCopy"]
simple --> simpleop["genericOp -> waitPeer / reduceCopy / postPeer"]Этот рисунок объясняет, «почему для одной и той же логики AllReduce нужны три набора примитивов перемещения данных»: уровень алгоритма не зависит от протокола, а различия протоколов инкапсулированы вPrimitivesтрёх специализациях.
LL: перемещение с нулевым рукопожатием, где flag встроен в строку данных
Интуитивная модель
Ключевая идея LL:упаковать «данные» и признак «готовы ли данные» в одну и ту же 16-байтовую единицу чтения-записи. Принимающей стороне не нужно дополнительное «уведомительное сообщение»: достаточно опрашивать поле flag в строке данных; если flag совпадает, значит данные пришли. Это как при отправке письма напечатать «подпись получателя» прямо на конверте: почтальон, увидев подпись, сразу понимает, доставлять или нет, и не нужно отправлять отдельную квитанцию.
Без этого дизайна принимающей стороне пришлось бы сначала ждать уведомления «данные записаны», а затем возвращаться к чтению данных — два обращения к памяти, задержка удваивается.
Структуры данных и раскладка памяти
Единица перемещения данных в LL —union ncclLLFifoLine, изstoreLLассемблера видно её раскладку📎 src/device/prims_ll.h:154-158:
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 |
recvConnHeadPtr | volatile uint64_t* | глобальный указатель на стороне приёма «до какого шага уже потреблено» |
sendConnHeadPtr | volatile uint64_t* | глобальный указатель на стороне отправки «до какого шага уже потребил удалённый peer» |
sendConnHeadCache | uint64_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:
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:
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:
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 memory
ncclScratchForWarp(warpInBlock),__syncwarp(), затем из shared memory считывается обратно в регистры по правильному смещению📎src/device/prims_ll128.h:115-141。
Второй шаг: ожидание и чтение данных удалённой стороны. recvReduceSendCopyцикл ожидания внутри📎 src/device/prims_ll128.h:190-207:
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:
| Поле | Тип | Назначение |
|---|---|---|
flags | int | Битовые флаги, кодирующие роль (WaitRecv/WaitSend/PostRecv/PostSend), режим Direct, NetReg и т.д. |
step | uint64_t | Текущий шаг |
connStepPtr | uint64_t* | Указатель на step удалённой стороны соединения |
connStepCache | uint64_t | Кэш последнего прочитанного значения step |
connEltsFifo | T* | Базовый адрес FIFO-буфера |
connStepSize | int | Количество байт на шаг |
directBuff | T* | Указатель на прямой буфер в режиме Direct |
flagsОпределение битов📎 src/device/prims_simple.h:23-27:
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:
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:
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:
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:
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。
Сравнение и выбор трёх наборов примитивов
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| Измерение | LL | LL128 | Simple |
|---|---|---|---|
| Коэффициент полезной нагрузки | 50% | 93.75% | ~100% |
| Способ синхронизации | flag встроен, опрос | flagThread + warp-голосование | указатель step + fence |
| Требования к выравниванию | Нет (есть перестановка со сдвигом) | 16 байт | Нет |
| Подходящий размер сообщения | Малый (< 8KB) | Средний (8KB ~ 128KB) | Большой (> 128KB) |
| Разметка буфера | ncclLLFifoLine[] | uint64_t[]по 128B line | T[] FIFO |
| Поддержка Direct | Нет (PrimitivesWithoutDirectдеградация) | Нет (то же самое) | Полная поддержка |
И LL, и LL128 наследуютPrimitivesWithoutDirect 📎 src/device/prims_ll.h:9-10, src/device/prims_ll128.h:13-14, поскольку их разметка буфера не поддерживает прямое чтение и запись в память партнёра. Simple же полностью реализует режим Direct, поддерживая P2P-соединение и NVLS.
Размышления о дизайне
Почему 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+ строк
Глава 10: Ядра коллективных операций: архитектура AllReduce, AllGather и ReduceScatter
В предыдущей главе мы разобрали три протокольных примитива — 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):
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)
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)
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)
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)
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)
chunk = modRanks(ringIx + 1);
...
prims.directRecv(offset, nelem);Только приём, без отправки, дополняем последний блок.
Весь процесс можно обобщить следующей блок-схемой управления:
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Есть одна легко упускаемая строка кода:
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)
prims.directRecvReduceCopy(offset, offset, nelem, /*postOp=*/true);Корневой узел только принимает, но не отправляет: получает данные от всех дочерних узлов, выполняет reduce и записывает в recvbuff.postOp=trueвыполняет пост-операцию.
Случай B: данный ранг — лист (tree->down[0] == -1)(📎 src/device/all_reduce.h:105-110)
prims.directSend(offset, offset, nelem);Листовой узел только отправляет, но не принимает: отправляет свои данные родительскому узлу.
Случай C: промежуточный узел(📎 src/device/all_reduce.h:111-117)
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):
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)
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)
prims.directRecvCopyDirectSend(offset, offset, nelem);Последний шаг: принять последний блок(📎 src/device/all_gather.h:69-74)
prims.directRecv(offset, nelem);isNetOffload: один warp управляет сетью + несколько warp параллельно копируют
📎 src/device/all_gather.h:28-36имеет специальную ветвь:
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)
rankDest = ringRanks[nranks - 1];
offset = dataOffset + rankDest * count;
prims.send(offset, nelem);Средние nranks-2 шагов: приём, reduce и пересылка одновременно(📎 src/device/reduce_scatter.h:44-49)
prims.recvReduceSend(offset, nelem);Последний шаг: принять и выполнить reduce, получив окончательный результат(📎 src/device/reduce_scatter.h:61-64)
prims.recvReduceCopy(offset, dataOffset, nelem, /*postOp=*/true);Обратите внимание на последний шагrecvReduceCopyесть два offset:offset(источник приёма) иdataOffset(локальный ввод), результат редукции записывается вdataOffset。
Сравнительная схема потоков данных
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) разделяет потоки на четыре группы:
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есть ключевое ветвление:
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:
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).
Схема временных взаимодействий
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содержит строку:
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-узел отправляет данные, остальные узлы пересылают, последний узел только принимает.
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:
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(
| ). Каждая специализация соответствует комбинации «функция × алгоритм × протокол»: | Функция | Алгоритм | Протокол |
|---|---|---|---|
| AllReduce | RING | SIMPLE | 📎 src/device/all_reduce.h:230-233 |
| AllReduce | TREE | SIMPLE | 📎 src/device/all_reduce.h:238-244 |
| AllReduce | COLLNET_DIRECT | SIMPLE | 📎 src/device/all_reduce.h:249-386 |
| AllReduce | NVLS | SIMPLE | 📎 src/device/all_reduce.h:391-523 |
| AllReduce | NVLS_TREE | SIMPLE | 📎 src/device/all_reduce.h:528-634 |
| AllReduce | COLLNET_CHAIN | SIMPLE | 📎 src/device/all_reduce.h:639-759 |
| AllReduce | RING | LL | 📎 src/device/all_reduce.h:764-766 |
| AllReduce | TREE | LL | 📎 src/device/all_reduce.h:771-773 |
| AllReduce | RING | LL128 | 📎 src/device/all_reduce.h:778-780 |
| AllReduce | TREE | LL128 | 📎 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+ строк
Глава 11: Транспортный уровень: абстракции P2P, SHM, NET и NVLink SHARP
В предыдущей главе мы углубились в алгоритмические ядра и увидели, как 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
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
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
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
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
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
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
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.
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
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
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
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
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
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
#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
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
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
} 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
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
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
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
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
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
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
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
#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
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
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
#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
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
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
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
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». Если убрать этот фрагмент, принимающая сторона может прочитать устаревшие данные.
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
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
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+ строк
Глава 12: Прокси-потоки и асинхронный I/O: координация сетевых операций на хосте
Глава 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。
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 |
nextOps | volatile int | Индекс головы списка ожидающих обработки op, -1 означает пусто |
nextOpsEnd | volatile int | Индекс хвоста списка ожидающих обработки op |
freeOps[] | volatile int[] | Голова списка свободных op для каждого local rank |
syncObjectsInitialized | int | Отмечает, инициализированы ли mutex/cond |
mutex / cond | std::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логика выделения заслуживает подробного рассмотрения:
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:
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:
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.
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.
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 транспорта:
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。
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Структура:
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
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
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
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
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
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
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
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
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 этого запроса на приём потреблён, можно переиспользовать».
Полная картина потока данных
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:
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:
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:
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:
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Процедура остановки:
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:
// 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:
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:
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комментарии и логику:
// 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:
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+ строк
Глава 13: Сетевая передача InfiniBand: прямая интеграция Verbs и GPUDirect RDMA
В предыдущей главе мы увидели, как 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:
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
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
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
#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
#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
#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
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
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
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
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
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
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
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
typedef enum ibv_return_enum {
IBV_SUCCESS = 0,
} ibv_return_t;Проектное размышление: «определение версии» для совместимости ABI
ibvcore.hсодержит изящный код определения версии ABI:
📎 src/include/ibvcore.h:81
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
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
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
#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
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
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
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
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;
// ...
}
}переводит перечисление в читаемые строки для журналов:
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
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
#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
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
#define QP_ATTR(attr, userAttr, userFlag, mask) ((userFlag & mask) ? (userAttr) : (attr)):attr_maskКопироватьquery_qpОн предпочитает использовать атрибуты, переданные пользователем (если вquery_qpустановлен соответствующий бит), иначе откатывается к текущим атрибутам, полученным через
. Так даже при сбое。printIbModifyQpHintможно получить часть информации из пользовательских параметров.
📎 src/misc/ibvwrap.cc:341-358
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
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
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
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
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
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
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-записи и привязывает структуры, рассматриваемые в этой главе:
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
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
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
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
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
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
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
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
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
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:
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+ строк
Глава 14: Симметричная память и многоадресная рассылка NVLS: масштабирование в NVLink
В предыдущей главе мы проследили за одним межмашинным 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
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
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
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
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
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
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
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 байт — это размер строки кэша, чтобы избежать ложного совместного использования.
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
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
// 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
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
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
// 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
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
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
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».
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
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
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
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
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
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
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
// 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
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должны наследовать значения родительского коммуникационного домена — потому что буферы размещаются в соответствии с этими значениями, и их изменение приведёт к ошибкам вычисления адресов.
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+ строк
Глава 15: RMA и GIN: архитектура удаленной коммуникации между GPU следующего поколения
Глава 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
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-372rmaProgress == 2: режим паузы, используется для回收 ресурсов. После подтверждения паузы поток ожидает условную переменную.📎src/rma/rma_proxy.cc:373-378rmaProgress == -1: сигнал выхода, поток возвращается.📎src/rma/rma_proxy.cc:379-380rmaProgress == 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-хост:
| Поле | Тип | Значение |
|---|---|---|
queues | ncclGinProxyGfd_t* | Очередь GFD, размерnRanks * queueSize |
pis | uint32_t* | Индекс производителя (запись GPU) |
cis | uint32_t* | Индекс потребителя (запись прокси) |
cisShadow | uint32_t* | Теневая копия CI (локальная для proxy) |
sis | uint32_t* | Просмотренный индекс (локальный для proxy) |
states | ginProxyGfdState* | Состояние каждого слота GFD |
inlines | uint64_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
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 |
|---|---|---|---|---|
| Proxy | 0 | 2.30.3 | 2.30.5 | 2.32.0 |
| GDAKI | 0 | 2.30.3 | 2.30.5 | - |
| GPI | 0 | 2.30.5 | - | - |
| EFA GDA | 0 | 2.31.0 | 2.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+ строк
Глава 16: Экосистема плагинов и переменные окружения: тонкая настройка NCCL
В предыдущей главе мы увидели, как 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 — именно эту катастрофу и призвана устранить система плагинов.
Структуры данных и размещение в памяти
Всё состояние загрузчика — это шесть параллельных массивов, индексом служит перечисление типа плагина:
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Этот путь будет появляться во всех последующих логах, позволяя пользователю сразу увидеть, какой именно файл был загружен — при диагностике в продакшене вопроса «почему загрузился не тот плагин» эта строка лога является первоисточником.
Весь поток принятия решений выглядит так:
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:
| Поле | Тип | Значение |
|---|---|---|
name | char[255] | Имя библиотеки плагина |
dlHandle | void* | Дескриптор dlopen |
ncclNet | ncclNet_t* | Таблица сетевых функций |
ncclNetVer | int | Номер версии сетевого API |
ncclCollNet | ncclCollNet_t* | Таблица функций выгрузки коллективных операций |
ncclNetPluginState | Перечисление | Состояние сетевого плагина |
ncclCollNetPluginState | Перечисление | Состояние плагина CollNet |
ncclNetPluginRefCount | int | Счётчик ссылок |
netPhysDevs/netVirtDevs | int | Число физических/виртуальных устройств |
collNetPhysDevs/collNetVirtDevs | int | Число устройств 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-плагин, внешние плагины остаются как есть — это оставляет пространство для выбора в последующих коммуникационных доменах.
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:
| поле | тип | назначение |
|---|---|---|
thread | std::thread | поток потребления |
mutex | std::mutex | защита очереди |
cond | condition_variable | пробуждение при появлении новой работы |
condIterationInactive | condition_variable | ожидание завершения итерации |
stop | int | флаг остановки |
refCount | int | счётчик ссылок коммуникационного домена |
cudaDev | int | привязанное CUDA-устройство |
abortFlag | volatile uint32_t* | флаг прерывания |
iterationActive | bool | идёт ли итерация |
pending/pendingTail | связный список | ожидающая обработки работа |
active/activeTail | связный список | обрабатываемая работа |
opStack/opPool | пул памяти | распределение рабочих объектов |
inflight/maxInflightSeen/maxInflight | size_t | наблюдение обратного давления |
droppedOps | uint64_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
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+ строк
Глава 17: Механизмы RAS и отказоустойчивость: изоляция сбоев и деградация связи
В предыдущей главе мы увидели, как система плагинов позволяет провести чёткую границу между основным путём коммуникации и заменяемыми компонентами, что даёт возможность заменять сетевой бэкенд, стратегии настройки и сборщики производительности без изменения основного кода. Но расширяемость — лишь одно из измерений производственной пригодности. Другой, не менее сложный вопрос: когда один AllReduce уже работает 72 часа, а сетевая карта на какой-то машине тихо вышла из строя, на каком основании NCCL может это обнаружить, изолировать и продолжить работу? Подсистема RAS — это именно тот водораздел, который переводит NCCL от «работает» к «пригоден для производства». В этой главе мы разберём устройство механизмов обнаружения сбоев, мониторинга прогресса и самовосстановления.
17.1 Общее управление RAS: глобальный координатор с одним потоком RAS на процесс
Интуитивная модель
Представьте RAS как «дежурную комнату» всего задания. Каждый процесс NCCL (каждый rank) при инициализации открывает свою дежурную комнату, в которой сидит выделенный поток. Все коммуникационные домены (communicator) при создании, уничтожении и диагностических запросах должны сначала зарегистрироваться в дежурной комнате; дежурные комнаты, в свою очередь, через отдельную сеть RAS сообщают друг другу, «кто ещё жив, а кто уже мёртв».
Без этой дежурной комнаты NCCL мог бы полагаться только на тайм-ауты самого пути коммуникации для обнаружения сбоев — но тайм-ауты на пути коммуникации медленны и подвержены ложным срабатываниям (одно сетевое колебание может быть принято за смерть узла). RAS выносит «обнаружение сбоев» с плоскости данных на плоскость управления, используя независимый лёгкий heartbeat и диагностический канал для определения состояния здоровья.
Структуры данных и размещение в памяти
Ключевое состояние RAS разбросано по глобальным переменнымras.cc, разберём их по порядку:
| Переменная | Тип | Назначение |
|---|---|---|
rasInitMutex | std::mutex | Защита инициализации синглтона RAS |
rasInitialized | bool | Флаг инициализации |
rasInitRefCount | int | Счётчик ссылок, равный числу активных comm |
rasNetListeningSocket | struct ncclSocket | Слушающий сокет сети RAS |
rasNotificationPipe[2] | ncclSocketPairDescriptor | Канал уведомлений от локального потока к потоку RAS |
rasPfds | struct pollfd* | Массив poll главного цикла событий |
ncclComms | struct 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Копировать
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
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:
| Поле | Тип | Назначение |
|---|---|---|
cudaDev | int | Привязанный номер устройства CUDA |
thread | std::thread | Рабочий поток |
mutex / cv | std::mutex / condition_variable | Защита изменяемого состояния и пробуждение |
running / shouldStop | bool | Флаг жизненного цикла потока |
copyInFlight | bool | Есть ли DMA-копирование в процессе |
copyStallWarned | bool | Был ли уже сигнал о текущем зависании |
copyStartNs | uint64_t | Время начала текущего копирования |
sideStream | cudaStream_t | Выделенный неблокирующий поток |
copyDone | cudaEvent_t | Событие завершения копирования |
warningMutex | std::mutex | Защита временной метки оповещения |
lastStaleWarnNs / lastErrorWarnNs | uint64_t | Временная метка ограничения частоты |
destroyRefs | int | Счётчик ссылок для уничтожения |
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.
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。
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:
| Тип | Описание | Сетевой адрес (ключ сортировки) |
|---|---|---|
addr | ncclSocketAddress | Идентификатор процесса |
pid | ncclPid_t | Битовая маска устройств CUDA (подвержена влиянию CUDA_VISIBLE_DEVICES) |
cudaDevs | uint64_t | Битовая маска устройств NVML (не подвержена влиянию) |
nvmlDevs | uint64_t | Извлекается из comm, вычитается commHash для независимости от коммуникационного домена |
hostHash / pidHash | uint64_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 всё равно будет содержать хеш, и в конечном итоге произойдёт сходимость.
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 --> reinit17.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+ строк
Глава 18: Аллокатор памяти и кэш регистрации: устранение накладных расходов CUDA
В предыдущей главе мы увидели, как подсистема RAS работает на плоскости управления независимо от плоскости данных, используя хеши для версионирования и подсчёт ссылок для защиты жизненного цикла. В этой главе мы переходим к третьей опоре NCCL — управлению памятью. Верхний предел производительности связи часто зависит не от самого алгоритма, а от того, «может ли сетевой адаптер напрямую читать и записывать данные». Для этого NCCL построил трёхуровневый механизм: на нижнем уровне с помощьюncclSpaceиncclShadowPoolуправляют адресным пространством и теневыми объектами, на среднем уровне с помощьюncclMemManagerотслеживают импорт-экспорт динамической памяти и приостановку-возобновление, на верхнем уровне с помощьюncclCommRegisterрегистрируют пользовательские буферы в кэше, чтобы избежать повторного pin-а памяти при каждой связи. В этой главе мы пошагово разберём эти три механизма и ответим на вопросы «почему перед связью NCCL необходимо регистрировать память» и «как кэш регистрации влияет на производительность».
18.1 ncclSpace: разрезание адресного пространства на чередующиеся полные/пустые сегменты
Интуитивная модель
Представьте бесконечно длинную линию нумерации парковочных мест, начинающуюся с 0 и уходящую вправо. Некоторые места заняты машинами (выделены), некоторые пусты (не выделены).ncclSpace— это «журнал состояния парковочных мест» для этой линии нумерации: он не записывает каждое место, а только «граничные точки, где состояние переключается». Без него NCCL при управлении диапазонами виртуальных адресов симметричной памяти должен был бы поддерживать бит флага для каждого байта, а накладные расходы памяти были бы пропорциональны адресному пространству, что совершенно неприемлемо.
Структура данных и размещение в памяти
ncclSpaceопределяется предельно минималистично📎 src/include/allocator.h:20-24:
struct ncclSpace {
int count; // cuts[] 中有效元素个数
int capacity; // cuts[] 已分配容量
int64_t* cuts; // 升序排列的边界点数组
};Ключевая идея ясно написана в комментарии к исходному коду📎 src/allocator.cc:151-153:cuts[]разрезает ось неотрицательных целых чисел на чередующиеся «полные» и «пустые» сегменты, точки разреза упорядочены по возрастанию, а сегмент после последней точки разреза обязательно пуст (невыделенный фронт). Отсюда можно вывести формулу для определения, заполнен лиi-й сегмент:
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:
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:
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:
| Поле | Тип | Значение |
|---|---|---|
entries | ncclDynMemEntry* | Голова списка записей динамической памяти |
numEntries | int | Длина списка |
released | int | 0=активна, 1=приостановлена |
refCount | int | Счётчик ссылок (может совместно использоваться несколькими comm) |
totalPersist | size_t | Общий объём постоянной памяти (атомарный) |
totalScratch | size_t | Общий объём scratch-памяти (атомарный) |
totalOffload | size_t | Общий объём offload-памяти (атомарный) |
cpuBackupUsage | size_t | Общий объём резервной памяти CPU |
lock | std::mutex | Защищает список entries |
initialized | int | Атомарный флаг, предотвращающий доступ к уже уничтоженному мьютексу |
Ключевые проектные решения по разметке памяти: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жизненный цикл покрывает всю функцию.
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ключевые поля (выведены из использования):
| Поле | Тип | Значение |
|---|---|---|
begAddr | uintptr_t | Выровненный по странице начальный адрес |
endAddr | uintptr_t | Выровненный по странице конечный адрес |
localRefs | int | Локальный счётчик ссылок |
graphRefs | int | Счётчик ссылок графа |
state | int | Биты состояния регистрации (NET/NVLS/COLLNET/IPC) |
netHandleHead | ncclRegNetHandles* | Связный список сетевых handle |
ipcInfos | ncclIpcInfo** | Массив информации 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журналы, чтобы убедиться в успешности регистрации.
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。
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+ строк
Глава 19: Устройство-сторонний коммуникационный ABI: архитектура и структуры devComm
В предыдущей главе мы видели, как 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:
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его определение в:
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, можно увидеть эволюцию полей:
| Поле | v22902 | v22907 | v23000 |
|---|---|---|---|
magic/version | Нет | Нет | Есть (смещение 0/4) |
ginContextCount | uint8_t | uint32_t | uint32_t |
ginNetDeviceTypes | [4] | [NCCL_GIN_MAX_CONNECTIONS] | [NCCL_GIN_MAX_CONNECTIONS] |
ginIsRailed | Нет | bool | Разделён наginConnectionsRailed + ginContextsRailed |
hybridWorldGinBarrier | Нет | Нет | Есть (смещение 112) |
| Размер структуры | 200 | 224 | 240 |
Этот путь эволюции раскрывает стратегию версионирования 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:
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: копирует макет старой версии обратно в текущую версию.
Разделение диапазонов версий
Диапазоны версий четырёх файлов:
| Файл | minVersion | maxVersion | Примечание |
|---|---|---|---|
devcomm_v22902.cc | 2.29.2 | 2.29.3 | Самая ранняя версионированная реализация |
devcomm_v22907.cc | 2.29.5 | 2.29.7 | Добавлены поля GIN, но обратная совместимость с GIN не обеспечивается |
devcomm_v23000.cc | 2.30.0 | 2.30.7 | Добавлена проверка magic/version |
devcomm_v23100.cc | 2.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. Если не найден, вернуть ошибку или использовать поведение по умолчанию.
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:
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:
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есть важное примечание:
// 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:
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Аналогично, но с одной дополнительной деталью:
// 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:
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:
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изменение семантики:
// 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。
Приведённая ниже диаграмма последовательности демонстрирует полный процесс взаимодействия от запроса приложения до преобразования версии:
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, и выводится предупреждение:
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. Просмотр таблицы диапазонов версий:
| Файл | minVersion | maxVersion |
|---|---|---|
| v22902 | 2.29.2 | 2.29.3 |
| v22907 | 2.29.5 | 2.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+ строк
Глава 20: Нативные API устройств и слияние ядер: вызов коммуникаций из CUDA-ядер
В предыдущей главе мы увидели, как 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.
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_GRAN | NCCL_CFT_BARRIER_ALIGN |
| GIN Barrier | GIN-сигнал | 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 после фактического выделения буфера записал адрес обратно в дескриптор.
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 Barrier | CFT Barrier | GIN Barrier |
|---|---|---|---|
Требуется параметрcomm | Нет | Нет | Да |
| Буфер | Есть | Есть | Нет |
| GIN-сигнал | Нет | Нет | Есть |
| Единица размера | uint32_t | NCCL_CFT_BARRIER_GRAN | Количество сигналов |
| Поле выходного дескриптора | bufHandle | bufHandle | signal0 |
Обратите внимание, что 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, затрагивающий межмашинное оборудование.
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, он проходит три этапа в жизненном цикле:
| Этап | nBarriers | bufHandle | Другие поля |
|---|---|---|---|
| После 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 продолжает выполнение.
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
〔Проектные предположения и архитектурные компромиссы〕
Дизайн device-side API (объявление ресурсов на host-стороне, потребление на device-стороне) как раз направлен на смягчение этих нагрузок: ресурсы предварительно выделяются на host-стороне, device-side ядру нужно только читать и писать, без динамического запроса, что снижает занятость регистров.
Сценарный Walkthrough: поток выполнения слитого ядра
1. Предположим, пользователь хочет написать слитое ядро «AllReduce + ReLU»:Подготовка на host-сторонеncclLsaBarrierCreateRequirement: вызовncclDevCommCreateдля запроса barrier, вызов
2. для выделения ресурсов.Запуск ядра
3. : пользовательское ядро принимает DevComm и дескриптор barrier в качестве параметров.Фаза коммуникацииncclLsaBarrier: внутри ядра вызывается
4. для синхронизации всех рангов, затем ранги обмениваются данными (через прямое чтение/запись симметричной памяти).Фаза вычислений
5. : после завершения синхронизации ядро напрямую выполняет ReLU над локальными данными, без дополнительного запуска ядра.Завершение
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+ строк
Глава 21: Практика тюнинга производительности: методология бенчмаркинга и анализ узких мест
В предыдущей главе мы увидели, как пользовательское ядро через API на стороне устройства взаимодействует с коммуникационными примитивами NCCL и даже объединяет коммуникацию и вычисления в одном ядре. Это открывает возможность использования NCCL как модели программирования, но также порождает практический вопрос: когда производительность коммуникации ниже ожидаемой, с чего начать? NCCL предоставляет сотни NCCL_PARAM, но реально определяют, по какому пути пойдёт коллективная коммуникация, всего три регулятора: алгоритм (Algo), протокол (Proto) и число каналов (nChannels). В этой главе механизмы первых 20 глав объединяются в практический путь диагностики — сначала посмотреть отчёт о производительности, чтобы локализовать симптом, затем прочитать модель стоимости, чтобы понять, как выбирает сам NCCL, и наконец с помощью переменных окружения и benchmark проверить вашу гипотезу.
21.1 Отчёт о производительности: сначала建立 базовую линию «нормы»
Первый шаг тюнинга — не менять параметры, а знать, как выглядит «норма». Если вы даже не знаете, какова пиковая пропускная способность текущей системы, любой тюнинг — это слепое угадывание.
NCCL официально публикует эталонные данные о производительности вdocs/perf, и их назначение совершенно ясно — это не гарантия продуктного уровня, а опорная точка для согласования ожиданий.
📎 docs/perf/README.md:3-14
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
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
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
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
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
// 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
// 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
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
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
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
#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
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
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— это «время выбора», которое может включать дополнительные штрафы (например, некоторые алгоритмы в определённых сценариях требуют дополнительных затрат). Это даёт модели стоимости возможность разделять «оценочное время» и «время выбора».
Блок-схема
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
// 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
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
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
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
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
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
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
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
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
// 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
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
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
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
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
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
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 Процесс принятия решений по настройке
Объединив предыдущий материал, получаем практический процесс диагностики.
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:
// 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:
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+ строк
Глава 22: Производственный траблшутинг: распространенные ловушки и диагностика зависаний
Официальный источник: 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
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
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
if ((--ncclGroupDepth) > 0) goto exit;
if ((ret = ncclGroupError) != ncclSuccess) goto fail;Если вложено несколько уровней, внутреннийEndтолько уменьшает глубину и возвращается, не запуская отправку. Только самый внешний уровень продолжает. Одновременно проверяется накопленная ошибка.
Шаг третий: проверка согласованности режима блокировки. Это точка обнаружения «смешанного использования блокирующего и неблокирующего режимов»:
📎 src/group.cc:1095-1101
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
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
/* 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
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 на данный момент нет хорошего механизма восстановления.
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
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
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
#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
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
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
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
#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
// 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
#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
#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
#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
// 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
#define EP_REQUIRE_STRUCT(ptr) \
do { \
assert( \
(ptr) != nullptr && (ptr)->size == sizeof(*(ptr)) && \Этот макрос вызывается в таких точках входа, какncclEpDispatch、ncclEpCombine:
📎 contrib/nccl_ep/nccl_ep.cc:2827-2830
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
// 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
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
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— необязательный параметр, и исторически поля то добавлялись, то убирались.
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
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
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
// 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
// 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
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
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сначала уменьшает, затем проверяет. Если изменить на отсутствие декремента:
if (ncclGroupDepth > 0) goto exit; // 错误版本Тогда каждый разncclGroupEndне будет уменьшать глубину. Предположим, пользователь написал:
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условие, получится:
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 требует добавлять новые поля только в конец?
Справочный анализ:
Предположим, исходная структура:
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вставляет поле:
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+ строк
Глава 23: Расширение экосистемы: nccl4py, nccl4rust, nccl_ep и интеграция с фреймворками
В предыдущей главе мы разобрали типичные сбои 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:
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:
ncclis a PEP 420 implicit namespace package. nccl4py providesnccl.bindingsandnccl.core; other NCCL extension distributions can provide additionalnccl.*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:
| Path | Purpose |
|---|---|
crates/nccl-sys | Сырой host ABI, сгенерированный bindgen |
crates/nccl | Обёртка host в стиле Rust + RAII-владение |
crates/nccl-device-sys | no_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:
ncclDevCommCreateproduces a versioned public structure in host memory. The hostDeviceCommunicatorwrapper 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 constructnccl_device::DevCommfrom 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 publicnccl.handnccl_device.h
Шим компилируется в LTOIR (промежуточное представление LLVM) и вместе с Rust PTX линкуется в cubin📎 contrib/nccl4rust/README.md:165-167. README описывает процесс сборки📎 contrib/nccl4rust/README.md:158-163:
make device \
NCCL_INCLUDE_DIR="$NCCL_INCLUDE_DIR" \
CUDA_HOME="$CUDA_HOME" \
ARCH=90LTOIR — это промежуточный формат 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-341algorithm: HT или LL📎contrib/nccl_ep/README.md:342max_dispatch_tokens_per_rank: максимальное количество token для dispatch на один rank📎contrib/nccl_ep/README.md:344rdma_buffer_size: размер RDMA-буфера в режиме LL📎contrib/nccl_ep/README.md:356-356alloc: пользовательский аллокатор памяти устройства📎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:
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:
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。
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:
| Op | Variants | Auto-select |
|---|---|---|
| AllReduce | mc, uc, lamport, auto | Lamport ≤ 0.25 MB, else MC |
| AllToAll | uc, lamport, auto | Lamport ≤ 0.25 MB, else UC |
| AllGather | mc | — |
Различия трёх вариантов: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()andallreduce_lamport()accept optionalgamma/residual_inparameters 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Показан полный процесс:
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 на основе размера, передача через указатели, пакеты пространств имён — всё это технические средства для смягчения этого напряжения.
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+ строк
Глава 24: Эволюция архитектуры и направления будущего: от статической связи к программируемой
В предыдущей главе мы увидели, как сообщество строит экосистему вокруг ядра 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
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
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
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
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
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
planner->nTasksRma -= 1;
ncclMemoryPoolFree(&comm->memPool_ncclTaskRma, firstTask);Шаг пятый: освободить исходную задачу.
Копировать
Исходная задача уже разделена на две новые задачи и возвращается в пул памяти.ncclRmaWaitSignalУправление параллелизмом и взаимодействие с оборудованием
📎 src/rma/rma.cc:43-74
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
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
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
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
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
if (!comm->symmetricSupport) {
WARN("Communicator does not support symmetric memory!");
return ncclInternalError;
}GIN зависит от симметричной памяти — поскольку ядру GPU нужно знать виртуальный адрес буфера удалённой стороны, только симметричная память гарантирует совпадение адресов.
Шаг третий: получение списка локальных устройств GIN.
📎 src/gin/gin_host.cc:116-122
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
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
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
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
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
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
// 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
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
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
uint32_t kmask = kernelMask_coll(coll);kernelMask_coll(ncclFuncAllReduce)возвращаетkernelMask_AR, включающую 5 ядер AllReduce.
Шаг второй: проверить доступность STMC и LDMC.
📎 src/sym_kernels.cc:308-334
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
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
if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
if (!symAligned16B) kmask &= ~kernelMask_Tma;TMA требует ёмкости SMEM и вычислительной способности 10.0+, а также выравнивания буфера по 16 байт.
Шаг пятый: проверить требования GIN.
📎 src/sym_kernels.cc:347-350
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
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
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
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
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
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
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
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
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
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};ncclDevrInitOnceвычисляет производную информацию, такую как команда LSA, команда CFT и т.д. В случае неудачи возвращает пустую команду.
Шаг второй: вычисление параметров команды Rail.
📎 src/nccl_device/core.cc:70-79
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
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
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
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
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+ строк
Глава 25: Панорамный обзор и ретроспектива: полное путешествие одного AllReduce
В предыдущей главе, основываясь на следах эволюции в исходном коде, мы рассмотрели тенденции архитектуры 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
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
// 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
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
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
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
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
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 могут выбрать разные алгоритмы, что приведёт к взаимоблокировке при коммуникации.
Блок-схема инициализации
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
/* 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
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
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
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
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. Цель сортировки — чтобы планировщик в первую очередь обрабатывал крупные задачи, избегая фрагментации ресурсов каналов мелкими задачами.
Поток данных постановки задач в очередь
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
// 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
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
} 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。
Диаграмма принятия решений по выбору алгоритма
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
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: общий размер в байтах всех структур workkernelArgs: количество work batchworkStorageType: параметры запуска kernel
finishPlan: где хранятся данные work (args/fifo/persistent)
📎 src/enqueue/enqueue.cc:244-255
// 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
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
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
static ncclResult_t calcCollChunking(struct ncclComm* comm, struct ncclTaskColl* info, int nChannels, size_t nBytes,
uint32_t* outChunkSize, uint32_t* outDirectFlags, struct ncclProxyOp* proxyOp) {
ncclPattern_t pattern;
size_t grainSize = ncclProtoGrainSize(info->protocol);
switch (info->func) {
case ncclFuncAllReduce:
pattern = info->algorithm == NCCL_ALGO_NVLS ? ncclPatternNvls :
info->algorithm == NCCL_ALGO_NVLS_TREE ? ncclPatternNvlsTree :
info->algorithm == NCCL_ALGO_COLLNET_DIRECT ? ncclPatternCollnetDirect :
info->algorithm == NCCL_ALGO_COLLNET_CHAIN ? ncclPatternCollnetChain :
info->algorithm == NCCL_ALGO_TREE ? ncclPatternTreeUpDown :
ncclPatternRingTwice;
break;
...
}
int stepSize = comm->buffSizes[info->protocol] / NCCL_STEPS;
int chunkSteps = (info->protocol == NCCL_PROTO_SIMPLE && info->algorithm == NCCL_ALGO_RING) ? info->chunkSteps : 1;
int sliceSteps = (info->protocol == NCCL_PROTO_SIMPLE && info->algorithm == NCCL_ALGO_RING) ? info->sliceSteps : 1;
int chunkSize = stepSize * chunkSteps;
if (info->protocol == NCCL_PROTO_LL) chunkSize /= 2;
if (info->protocol == NCCL_PROTO_LL128) chunkSize = (chunkSize / NCCL_LL128_LINEELEMS) * NCCL_LL128_DATAELEMS;
...
}Копировать
```mermaid
flowchart TD
prep["ncclPrepareTasks()"] --> sort["collSorter 按 trafficBytes 排序"]
sort --> agg["按 (fn,op,ty) 聚合任务"]
agg --> algo["ncclGetAlgoInfo() 选算法"]
algo --> bins["按 isCollnet/isNvls 分箱"]
bins --> sched["scheduleCollTasksToPlan()"]
sched --> budget{"ncclTestBudget()?"}
budget -->|否| full["plan_full: 停止添加"]
budget -->|是| kind{"task->isCollnet?"}
kind -->|是| collnet["calcCollChunking + 全通道分配"]
kind -->|否| cells["cell 切分: countLo/Mid/Hi"]
collnet --> batch["ncclAddWorkBatchToPlan()"]
cells --> batch
batch --> proxy["ncclAddProxyOpIfNeeded()"]
proxy --> finish["finishPlan()"]
finish --> storage{"workBytes 能放进 args?"}
storage -->|是| args["ncclDevWorkStorageTypeArgs"]
storage -->|否| fifo["ncclDevWorkStorageTypeFifo"]
```Копировать
Размышления о дизайне и подводные камниПочему задачи CollNet обрабатываются отдельно?
Потому что CollNet использует сетевые коммутаторы для редукции, и логика распределения каналов полностью отличается от обычных ring/tree. Задачи CollNet напрямую занимают все доступные каналы, тогда как обычные задачи требуют разбиения по трафику.:ncclTestBudgetПодводные камниnBatches = divUp(nPlanColls, 4)В
📎 src/enqueue/enqueue.cc:711-714
// 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
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
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
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
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);3. В конце вызывается
КопироватьRunWorkCollСторона устройства: выполнение runRing
📎 src/device/all_reduce.h:14-83
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 шагов): распространение результата редукции по кольцу.
Эта лямбда обрабатывает зацикливание кольцевого индекса: когда
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
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
/* 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
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
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 путем сканирования официального репозитория с неизменяемыми анкорами коммитов.