Chapitre 1 : Chapitre 1 : Exécution et phénomènes : observer le comportement externe à partir d'un AllReduce
Chapitre 1 : Exécution et phénomènes : observer le comportement externe à partir d'un AllReduce
Avant de plonger dans le moindre code du kernel, commençons par faire tourner NCCL et observer son comportement exposé vers l'extérieur. Ce chapitre ne lit pas le kernel, il ne fait qu'une chose : établir un référentiel vérifiable — toute analyse ultérieure des mécanismes internes devra finalement pouvoir expliquer le comportement externe observé ici.
1.1 Structure d'ingénierie de NCCL vue depuis le point d'entrée de build
Modèle intuitif
Le système de build ressemble aux plans de construction d'un immeuble : il ne détermine pas qui y habite, mais il détermine quelles pièces existent et où donnent les portes. Si le point d'entrée de build est chaotique, vous ne pouvez même pas franchir le premier pas : « le faire tourner ». NCCL fournit à la fois un Makefile et un CMake comme deux points d'entrée de build ; comprendre leurs différences est la première étape pour comprendre l'organisation d'ingénierie de ce projet.
Structure des deux points d'entrée de build
LeMakefilede niveau supérieur est une couche de dispatch très fine ; il ne compile lui-même aucun fichier source, mais transmet le travail aux Makefile de chaque sous-répertoire.
📎 Makefile:44-45définit la règle de motifsrc.%, transmettant des cibles telles quesrc.build、src.installàsrc/Makefile:
src.%:
${MAKE} -C src $* BUILDDIR=${ABSBUILDDIR}📎 Makefile:47-48définit la cibleexamples, qui dépend desrc.build, puis entre dans le répertoiredocs/examplespour construire les exemples :
examples: src.build
${MAKE} -C docs/examples NCCL_HOME=${ABSBUILDDIR}Notez la relation de dépendance ici : la construction des exemples dépend de l'achèvement préalable desrc.build, car les exemples doivent se lier à la bibliothèque NCCL, et la variable d'environnementNCCL_HOMEtransmet le répertoire des artefacts de build au Makefile des exemples. C'est la contrainte d'ordre de build « d'abord la bibliothèque, ensuite les exemples ».
📎 Makefile:29liste tous les ensembles de cibles nettoyables :
TARGETS := src pkg nccl4py ir📎 Makefile:30utilise la syntaxe de référence de substitution de GNU Make${TARGETS:%=%.clean}pour développersrc pkg nccl4py irensrc.clean pkg.clean nccl4py.clean ir.clean, définissant ainsi tous les objectifs de nettoyage en une seule fois. C'est une technique courante dans les Makefile : « piloter les règles par les données » — pour ajouter un module, il suffit d'ajouter un mot dansTARGETS.
Point d'entrée CMake : d'où vient le numéro de version
Le point d'entrée CMake est bien plus complexe que le Makefile, car il doit gérer le multi-plateforme, la détection de version CUDA, le choix d'architecture, etc. Nous ne nous intéressons ici qu'aux parties directement liées à « faire tourner » le projet.
📎 CMakeLists.txt:5-11montre la provenance du numéro de version — il n'est pas codé en dur dans CMakeLists.txt, mais lu depuismakefiles/version.mkpuis extrait par expression régulière :
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}")Centraliser le numéro de version dansversion.mkpermet aux deux systèmes de build, Makefile et CMake, de partager la même source de version, évitant ainsi le piège classique d'ingénierie de « versions incohérentes entre deux systèmes de build ».NCCL_VERSION_CODELa formule de calcul deMAJOR*10000 + MINOR*100 + PATCHreste cohérente avec la macroNCCL_VERSIONdu fichier d'en-tête.
📎 CMakeLists.txt:14-20Ces numéros de version sont injectés dans tous les fichiers source C++ viaadd_compile_definitions:
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-25déclare les langages du projet comme CUDA, CXX et C :
project(NCCL VERSION ${NCCL_MAJOR}.${NCCL_MINOR}.${NCCL_PATCH}
LANGUAGES CUDA CXX C)Choix de l'architecture CUDA : pourquoi la valeur par défaut est si complexe
📎 CMakeLists.txt:140-171est un long bloc de logique qui détermineCMAKE_CUDA_ARCHITECTURESen fonction de la version de CUDA. Prenons l'exemple de CUDA 12.8 et supérieur :
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()La motivation de cette logique est la suivante : le PTX des nouvelles architectures (comme 100, 120) n'est reconnu que par les toolchains CUDA récentes ; si l'on force une nouvelle architecture avec un ancien CUDA, la compilation échoue directement. La liste d'architectures par défaut doit donc s'ajuster dynamiquement selon la version de CUDA. Pour le lecteur, cela signifie :Si vous ne définissez pas explicitementCMAKE_CUDA_ARCHITECTURES, le binaire compilé contiendra un fatbin avec une longue liste d'architectures, ce qui allongera considérablement le temps de compilation. En production, on spécifie généralement explicitement l'architecture cible pour accélérer la build.
Diagramme de décision du processus de build
La figure ci-dessous montre le chemin de décision complet depuis l'exécution demakejusqu'à la production d'un exemple exécutable :
flowchart TD
start["执行 make 或 make examples"] --> check_ir{"EMIT_LLVM_IR 或<br/>NCCL_EMIT_LTO_IR 非 0?"}
check_ir -->|是| add_ir["IR_GOALS 加入 llvm_ir/ltoir<br/>default 依赖 ir-emit"]
check_ir -->|否| only_src["default 仅依赖 src.build"]
add_ir --> src_build["make -C src build<br/>BUILDDIR=build"]
only_src --> src_build
src_build --> build_ok{"src.build 成功?"}
build_ok -->|否| fail["构建失败,终止"]
build_ok -->|是| is_examples{"目标是 examples?"}
is_examples -->|是| ex_build["make -C docs/examples<br/>NCCL_HOME=build"]
is_examples -->|否| done["产出 libnccl.so"]
ex_build --> ex_ok{"示例链接成功?"}
ex_ok -->|否| fail
ex_ok -->|是| runnable["产出可执行示例"]La branche clé de ce diagramme est de savoir siIR_GOALSest non vide — cela détermine si la build par défaut déclenche en plus la génération de LLVM IR. Pour les lecteurs qui veulent simplement « faire tourner » le projet, il suffit de garderEMIT_LLVM_IR=0pour emprunter le chemin le plus court.
1.2 Prérequis d'un programme minimal exécutable
Modèle intuitif
Écrire un programme NCCL, c'est comme organiser une conférence téléphonique multipartite. Il faut d'abord vérifier : combien de personnes participent (nombre de devices), qui est chacun (rank), et quelle ligne utiliser pour parler (stream). S'il manque un seul de ces éléments, la conférence ne peut pas démarrer. Dans cette section, à travers l'exemple01_communicators, nous allons voir à quoi ressemblent ces trois prérequis dans le code.
Structures de données : trois tableaux portent tout l'état
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:88-92définit les variables centrales de l'exemple :
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 useCela illustre le cœur du modèle de programmation NCCL mono-processus multi-GPU :un domaine de communication, un stream et un numéro de device par GPU. Les trois tableaux ont pour longueurnum_gpus, et l'indiceicorrespond aui-ième GPU.
ncclComm_test défini dans le fichier d'en-tête comme un pointeur opaque.📎 src/nccl.h.in:36en donne le type réel :
typedef struct ncclComm* ncclComm_t;Le « pointeur opaque » (opaque pointer) est une technique classique du langage C pour réaliser l'encapsulation : le fichier d'en-tête n'expose que le type pointeurstruct ncclComm*, le code utilisateur ne peut pas accéder aux champs internes de la structure, et toutes les opérations doivent passer par les fonctions de l'API. Ainsi, NCCL peut modifier librement la disposition interne dencclCommsans casser l'ABI. Pour les lecteurs débutants, on peut le comprendre comme « vous recevez un handle boîte noire, que vous ne pouvez manipuler que via l'interface officielle ».
Étape par étape : de la détection des devices à la création du domaine de communication
Première étape : détecter le nombre de devices. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:96-104appellecudaGetDeviceCountet vérifie s'il vaut 0 :
CUDACHECK(cudaGetDeviceCount(&num_gpus));
if (num_gpus == 0) {
fprintf(stderr, "ERROR: No CUDA devices found on this system\n");
...
return 1;
}Ce que fait cette étape : demander au runtime CUDA « combien de GPU y a-t-il sur cette machine ». Si le retour est 0, cela signifie qu'aucun device n'est disponible et le programme se termine directement — c'est la condition de garde la plus en amont.
Deuxième étape : allouer la mémoire hôte et remplir la liste des devices. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:114-121alloue trois tableaux et vérifie que l'allocation a réussi :
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-136remplitdevices[i] = iavec une boucle et affiche les propriétés de chaque device :
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]);
...
}Troisième étape : créer un stream pour chaque GPU. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:140-145est essentiel :
for (int i = 0; i < num_gpus; i++) {
CUDACHECK(cudaSetDevice(devices[i]));
CUDACHECK(cudaStreamCreate(&streams[i]));
}Attention :cudaSetDevicedoit être appelé avantcudaStreamCreate. C'est une règle fondamentale de la programmation CUDA :un stream appartient au device actif courant; si l'on ne change pas d'abord de device, le stream sera créé sur le mauvais GPU. C'est l'un des pièges les plus fréquents pour les débutants.
Quatrième étape : créer le domaine de communication. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:169est l'appel central de tout l'exemple :
NCCLCHECK(ncclCommInitAll(comms, num_gpus, devices));ncclCommInitAllest l'entrée pratique pour le scénario mono-processus multi-GPU. Le fichier d'en-tête📎 src/nccl.h.in:301-301en donne le contrat :
/* 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);Signification des trois paramètres :commest le tableau préalloué des domaines de communication,ndevest le nombre de devices,devlistest la liste des numéros de device (si NULL est passé, on utilise lesndevpremiers devices). Après le retour de l'appel,comms[i]est le domaine de communication dui-ième device, dont le rank esti。
Cinquième étape : vérifier les propriétés du domaine de communication. 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:185-189effectue la vérification avec trois API de requête :
NCCLCHECK(ncclCommUserRank(comms[i], &rank));
NCCLCHECK(ncclCommCount(comms[i], &size));
NCCLCHECK(ncclCommCuDevice(comms[i], &device));Ces trois API sont définies dans le fichier d'en-tête comme📎 src/nccl.h.in:396、📎 src/nccl.h.in:400、📎 src/nccl.h.in:404. Elles répondent respectivement à trois questions : qui suis-je (rank), combien sommes-nous (size), sur quelle carte suis-je (device).
Diagramme de séquence du processus de création du domaine de communication
sequenceDiagram
participant App as 应用主线程
participant CUDA as CUDA Runtime
participant NCCL as NCCL 库
App->>CUDA: cudaGetDeviceCount(&num_gpus)
CUDA-->>App: num_gpus = N
loop i in 0..N-1
App->>CUDA: cudaSetDevice(devices[i])
App->>CUDA: cudaStreamCreate(&streams[i])
CUDA-->>App: streams[i]
end
App->>NCCL: ncclCommInitAll(comms, N, devices)
Note over NCCL: 内部为每个设备建立通信域<br/>分配 rank 0..N-1
NCCL-->>App: comms[0..N-1]
loop i in 0..N-1
App->>NCCL: ncclCommUserRank(comms[i], &rank)
NCCL-->>App: rank = i
App->>NCCL: ncclCommCount(comms[i], &size)
NCCL-->>App: size = N
endCe diagramme de séquence révèle un point clé :ncclCommInitAllest unappel bloquant synchrone, qui effectue en interne toute la coordination entre devices ; au retour, tous les domaines de communication sont prêts.
Réflexion de conception : pourquoi ncclCommInitAll est nécessaire
Dans un scénario multi-processus, chaque processus ne gère qu'un seul GPU, et il suffit d'initialiser individuellement avecncclCommInitRankchacun. Mais dans un scénario mono-processus multi-GPU, si l'utilisateur doit appeler manuellementncclCommInitRankpour chaque carte, il doit gérer « la synchronisation entre plusieurs ranks » — or dans un processus unique il n'y a qu'un seul thread, incapable de faire progresser simultanément l'initialisation de plusieurs ranks, ce qui provoquerait un interblocage.ncclCommInitAllEncapsule cette coordination à l'intérieur de la bibliothèque, en utilisant des mécanismes internes (généralement du multi-threading ou une machine à états) pour réaliser l'initialisation synchronisée de tous les ranks, exposée à l'utilisateur comme un simple appel synchrone. C'est la raison fondamentale de l'existence de la « fonction de commodité ».
1.3 Comportement externe complet d'un AllReduce
Modèle intuitif
AllReduce est l'opération la plus couramment utilisée en communication collective : chaque participant contribue une part de données, et tout le monde obtient la somme de toutes les données. C'est comme calculer la note totale d'un travail de groupe — chacun annonce sa propre note, et à la fin chacun a en main la note totale de toute la classe. Dans cette section, nous suivons03_collectives/01_allreducel'exemple, pour observer le comportement externe complet d'un AllReduce, de l'appel à la vérification du résultat.
Structures de données : tampon de données et initialisation
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:59-63définit les variables essentielles :
int num_gpus = 0;
ncclComm_t *comms;
cudaStream_t *streams;
float **sendbuff;
float **recvbuff;Attention,sendbuffetrecvbuffsont desfloat**— des pointeurs vers des tableaux de pointeurs. Chaquesendbuff[i]est l'adresse de la mémoire de l'appareil sur lai-ième GPU.
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:99définit la taille des données :
const size_t size = 32 * 1024 * 1024; // 32M floats for demonstration32M de float, 4 octets chacun, soit un tampon d'envoi de 128 Mo et un tampon de réception de 128 Mo, un exemplaire par carte.
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:101-120est la boucle d'initialisation pour chaque appareil :
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);
}L'astuce de ce code : on met d'abord à zéro tout le tampon d'envoi, puis on définit uniquementle premier élémentài(la valeur de rank de cet appareil). Ainsi, après la somme AllReduce, le résultat du premier élément est0 + 1 + 2 + ... + (num_gpus-1), et tous les autres éléments sont à 0. Lors de la vérification, il suffit de contrôler le premier élément pour confirmer si l'AllReduce est correct.
Étape par étape : appel AllReduce et vérification
Première étape : enveloppe Group. 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136est l'appel central :
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());Ici, il y a undétail extrêmement important: le commentaire📎 docs/examples/03_collectives/01_allreduce/c/main.cc:128-129précise explicitement :
// NOTE: ncclGroupStart and ncclGroupEnd are essential to avoid
// deadlock when using ncclCommInitAll and multiple communication calls.Pourquoi faut-il utiliser Group ? Le fichier d'en-tête📎 src/nccl.h.in:844-864donne l'explication :
/* 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.
*/La contradiction fondamentale est la suivante : la communication collective exige la participation simultanée de tous les ranks, mais dans un thread unique on ne peut appelerncclAllReducequ'un par un. Si le premier appelncclAllReducebloque en attendant les autres ranks, alors que les appels des autres ranks n'ont pas encore été émis, il y aura interblocage. Le rôle du mécanisme Group est :ncclGroupStarttous les appels suivants ne font qu'un « enregistrement », sans démarrage effectif ;ncclGroupEndce n'est qu'au moment de
que toutes les opérations enregistrées sont soumises ensemble, leur permettant de progresser en parallèle. C'est comme commander à emporter : on ajoute d'abord tous les plats au panier, puis on règle tout en une fois, au lieu de commander plat par plat. 📎 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]));
}Copie📎 src/nccl.h.in:854-856Le fichier d'en-têtencclGroupEndsouligne :garantit seulement que l'opération estmise en file dans le stream, pas que l'opération estterminée
. Il faut donc synchroniser explicitement le stream pour pouvoir lire les résultats en toute sécurité. 📎 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);
}
}Copie0 + 1 + ... + (N-1) = N*(N-1)/2La valeur attendue est la somme d'une suite arithmétique
. Chaque carte doit recevoir la même valeur — c'est précisément la définition d'AllReduce.
flowchart LR
subgraph dev0["GPU 0 (rank 0)"]
s0["sendbuff[0]<br/>首元素=0"]
r0["recvbuff[0]"]
end
subgraph dev1["GPU 1 (rank 1)"]
s1["sendbuff[1]<br/>首元素=1"]
r1["recvbuff[1]"]
end
subgraph dev2["GPU 2 (rank 2)"]
s2["sendbuff[2]<br/>首元素=2"]
r2["recvbuff[2]"]
end
s0 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce["归约求和<br/>0+1+2=3"]
s1 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce
s2 -->|ncclAllReduce<br/>ncclFloat ncclSum| reduce
reduce -->|广播结果| r0
reduce -->|广播结果| r1
reduce -->|广播结果| r2CopierecvbuffCe diagramme montre les deux phases d'AllReduce : d'abord la réduction (reduce), puis la diffusion (broadcast). Le
de chaque rank obtient finalement le même résultat.
〔Inférence de conception et compromis architecturaux〕ncclGroupStart/ncclGroupEndSi l'on retire
for (int i = 0; i < num_gpus; i++) {
ncclAllReduce(sendbuff[i], recvbuff[i], size, ncclFloat, ncclSum,
comms[i], streams[i]);
}CopiencclAllReduceDans un thread unique, lors de la première itération appelant
, NCCL doit attendre que tous les ranks aient lancé AllReduce pour progresser. Mais les appels des autres ranks ne sont pas encore exécutés dans la boucle, donc le premier appel n'attendra jamais les autres ranks, et il y aura interblocage. Le mécanisme Group sépare « l'initiation » et « l'exécution », permettant à tous les appels des ranks d'être d'abord enregistrés, puis exécutés ensemble, évitant fondamentalement l'interblocage en thread unique.
1.4 Cycle de vie du domaine de communication et nettoyage des ressources
Modèle intuitif
Le domaine de communication est comme une réunion. Avant la réunion, il faut signer la présence (initialisation) ; après la réunion, il faut lever la séance (destruction). Si l'ordre de dissolution est incorrect — par exemple, verrouiller la salle de réunion avant que les gens soient partis — cela posera problème. Dans cette section, nous examinons l'ordre de destruction du domaine de communication NCCL, et pourquoi cet ordre ne peut pas être inversé.
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:176-183Les deux phases de la destruction : Finalize et Destroy
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]));
}Copie📎 src/nccl.h.in:309-309Le fichier d'en-têtencclCommFinalizeexplique la sémantique de
/* 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-313CopiencclCommDestroy:
/* Frees local resources associated with communicator object. */
ncclResult_t ncclCommDestroy(ncclComm_t comm);〔Inférence de conception et compromis architecturaux〕ncclCommFinalizePourquoi la destruction se fait-elle en deux étapes ?est uneopération globalencclCommDestroy— elle nécessite la participation de tous les ranks, pour s'assurer qu'il n'y a pas de communication en cours.est uneopération localencclCommDestroy— elle ne libère que les ressources de ce processus, sans blocage. Cette conception découple « attendre le silence de tous les ranks » et « libérer les ressources locales » : la première peut prendre beaucoup de temps (il faut attendre les pairs réseau), la seconde est une opération purement locale. S'il n'y avait qu'un seul
, il devrait assumer ces deux responsabilités à la fois, soit bloquer trop longtemps, soit ne pas pouvoir garantir le silence global.
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:221-249La chaîne complète de l'ordre de destruction📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:218-219présente l'ordre complet de nettoyage, le commentaire
// IMPORTANT: Proper cleanup is critical for NCCL applications
// Resources must be cleaned up in the correct order to avoid issuesCopie
L'ordre est :📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:224-227)
2. Finaliser + Détruire le domaine de communication (📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240)
3. Détruire le stream CUDA (📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:246-249)
4. Libérer la mémoire hôte (📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:253-255)
Machine à états du domaine de communication
ncclCommFinalizeLa documentation de mentionne explicitement les transitions d'état, ce qui satisfait les conditions d'admission d'une machine à états :
stateDiagram-v2
[*] --> Active : ncclCommInitAll() 成功
Active --> InProgress : ncclCommFinalize()<br/>刷新在途通信
InProgress --> Quiescent : 全局静默<br/>相关资源释放
Quiescent --> Destroyed : ncclCommDestroy()<br/>释放本地资源
Destroyed --> [*]
Active --> Aborted : ncclCommAbort()<br/>中止在途操作
Aborted --> [*]La transition clé de cette machine à états estInProgress -> Quiescent: elle est déclenchée par l'événement « silence global », et non directement par un appel de fonction. Cela signifie quencclCommFinalizeaprès le retour de , le domaine de communication peut encore être dans l'étatInProgress, et il faut interrogerncclCommGetAsyncErrorpour savoir quand il entre dansQuiescent。
Réflexion de conception : pourquoi l'ordre de destruction ne peut pas être inversé
Si l'on détruit d'abord le stream CUDA puis le domaine de communication, quels problèmes cela poserait-il ? Le domaine de communication peut détenir en interne une référence au stream (par exemple pour la notification d'achèvement d'opérations asynchrones). Si le stream est détruit en premier, le domaine de communication accédera à un stream déjà détruit lors du Finalize, ce qui entraînera un comportement indéfini. De même, si l'on libère d'abord la mémoire hôte (le tableaucomms) puis détruit le domaine de communication,ncclCommDestroyon obtient alors un pointeur sauvage. C'est pourquoi l'ordre doit être « d'abord synchroniser, puis détruire le domaine de communication, puis détruire le stream, et enfin libérer la mémoire hôte » —les relations de dépendance déterminent que l'ordre de destruction doit être l'inverse de l'ordre de création。
1.5 Guide de production pour éviter les pièges
Piège n°1 : oublier le Group provoque un interblocage
C'est le piège le plus fréquemment rencontré par les débutants. Dans un scénario mono-processus multi-GPU, si l'on appelle directement en bouclencclAllReducesans ajouter de Group, le programme se bloquera dès le premier appel. Les symptômes sont : le programme se fige, l'utilisation du CPU est proche de 0, et aucune sortie n'apparaît.
Méthode de diagnostic : utilisergdbpour s'attacher au processus et vérifier si la pile d'appels s'arrête sur la logique d'attente interne de NCCL. Si c'est le cas, vérifier si l'on a omisncclGroupStart/ncclGroupEnd。
Piège n°2 : oublier de synchroniser le stream avant de lire le résultat
📎 src/nccl.h.in:854-856indique explicitement quencclGroupEndgarantit seulement la mise en file, pas l'achèvement. Si l'on omet la synchronisation du stream de📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142et qu'on lit directementrecvbuff, on lira des données non terminées.
Les symptômes sont : des résultats tantôt corrects tantôt erronés, ou la lecture de zéros partout. Cela est dû au fait quecudaMemcpyest synchrone par défaut, mais ce qu'il synchronise estle stream courant, alors que l'AllReduce peut s'exécuter sur un autre stream. Méthode de diagnostic : ajoutercudaStreamSynchronizeavant de lire le résultat ; si le problème disparaît, c'est ce piège.
Piège n°3 : un ordre de destruction incorrect provoque une erreur de segmentation
Si l'onncclCommDestroyavantcudaFreelessendbuff/recvbuff, le domaine de communication peut encore accéder à ces buffers lors du Finalize, provoquant une erreur de segmentation ou une corruption de données.
Les symptômes sont : un plantage du programme lors de la phase de sortie, ou une lecture occasionnelle de données corrompues. Méthode de diagnostic : vérifier l'ordre du code de nettoyage et s'assurer que la destruction du domaine de communication précède la libération de toutes les ressources CUDA.
Piège n°4 : confusion entre numéro de device et rank
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:198-200dispose d'une vérification :
if (device != devices[i]) {
printf(" [WARNING: Expected device %d]", devices[i]);
}rank et device sont deux concepts différents. rank est le numéro logique au sein du domaine de communication (de 0 à nRanks-1), device est le numéro physique du GPU. DansncclCommInitAlll'usage par défaut de ,devices[i] = i, donc rank et device sont exactement égaux. Mais si l'on passe undevlistpersonnalisé (par exemple{2, 0, 1}), rank 0 correspond alors au device 2. Confondre ces deux concepts conduit à envoyer les données au mauvais GPU.
Résumé de ce chapitre
Dans ce chapitre, nous avons accompli trois choses :
1. Point d'entrée de construction: nous avons compris le mécanisme de transfert du Makefile, la source du numéro de version de CMake et la logique de sélection de l'architecture CUDA. La conclusion clé est quemake examplesconstruit d'abord la bibliothèque puis les exemples,NCCL_HOMEet transmet le répertoire des artefacts de construction aux exemples.
2. Les trois éléments d'un programme minimal exécutable: le nombre de devices (cudaGetDeviceCount), le rank (attribué automatiquement parncclCommInitAll), le stream (un par GPU).ncclCommInitAllest un point d'entrée pratique pour le mono-processus multi-GPU ; il encapsule l'initialisation synchronisée multi-rank à l'intérieur de la bibliothèque.
3. Le comportement externe complet d'un AllReduce: dencclGroupStartqui englobe plusieursncclAllReduceappels, à la soumission parncclGroupEnd, puis à l'attente d'achèvement parcudaStreamSynchronize, et enfin la vérification du résultat. Le mécanisme de Group est la clé pour éviter les interblocages dans les scénarios mono-thread multi-GPU.
4. Cycle de vie du domaine de communication:ncclCommFinalize(silence global) +ncclCommDestroy(libération locale) en deux phases de destruction, ainsi que la contrainte d'ordre « d'abord synchroniser, puis détruire le domaine de communication, puis détruire le stream, et enfin libérer la mémoire hôte ».
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime les ncclGroupStart/ncclGroupEnd de📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136et qu'on les remplace par des appels directs en boucle à ncclAllReduce, que se passera-t-il dans un scénario mono-processus multi-GPU ? Pourquoi ?
Analyse de référence: un interblocage se produira. Le fichier d'en-tête📎 src/nccl.h.in:844-864en explique la raison : les appels de communication collective peuvent exécuter une synchronisation inter-CPU, nécessitant la participation simultanée de tous les ranks. Dans un contexte mono-thread, lors du premier appel dencclAllReduce(comms[0], ...)dans l'itération de la boucle, NCCL doit attendre que les autres ranks lancent également l'AllReduce pour progresser. Mais les appels des autres ranks ne sont pas encore exécutés dans la boucle (car le thread courant est bloqué sur le premier appel), donc le premier appel ne verra jamais les autres ranks, d'où l'interblocage.
Le rôle du mécanisme de Group est de séparer « l'initiation » et « l'exécution » :ncclGroupStartaprès , tous les appels ne font qu'enregistrer,ncclGroupEndau moment de , toutes les opérations enregistrées sont soumises ensemble, leur permettant de progresser en parallèle. Cela évite fondamentalement l'interblocage mono-thread.
Méthode de vérification : après avoir supprimé le Group, exécuter le programme et utilisergdbattach pour examiner la pile, cela s'arrêtera sur la logique d'attente interne de NCCL, avec un taux d'occupation CPU proche de 0.
Q2: 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142Le cudaStreamSynchronize peut-il être remplacé par cudaDeviceSynchronize ? Quelles sont les différences sémantiques entre les deux ? Dans quels scénarios ce remplacement poserait-il problème ?
Analyse de référence: On peut utilisercudaDeviceSynchronizepour remplacer, mais la sémantique diffère.cudaStreamSynchronize(streams[i])n'attend que l'achèvement des opérations sur le stream spécifié ;cudaDeviceSynchronizeattend l'achèvement des opérations surtousles streams du périphérique courant.
Dans un scénario multi-GPU mono-processus,cudaDeviceSynchronizene synchronise que le périphérique courant (déterminé parcudaSetDevice), il faut donc l'utiliser avec une bouclecudaSetDevice(i). Si l'on ometcudaSetDevice,cudaDeviceSynchronize, seul le périphérique par défaut (généralement device 0) sera synchronisé, et l'AllReduce des autres périphériques pourrait ne pas être terminé.
Le fichier d'en-tête📎 src/nccl.h.in:854-856souligne quencclGroupEndne garantit que la mise en file d'attente, pas l'achèvement, la synchronisation est donc indispensable. UtilisercudaStreamSynchronizeest plus précis, car il n'attend que les streams concernés et n'attend pas par erreur des opérations non liées. Le problème aveccudaDeviceSynchronizeest que : s'il y a d'autres kernels de longue durée non liés sur le périphérique, ils seront attendus par erreur, ce qui réduit les performances.
Q3: 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240L'ordre de destruction de
est « d'abord Finalize tous les domaines de communication, puis Destroy tous les domaines de communication ». Si l'on change en « pour chaque domaine de communication, d'abord Finalize puis Destroy » (c'est-à-dire effectuer les deux opérations dans une même boucle), quel problème cela poserait-il ?Analyse de référence
ncclGroupStart();
for (i) ncclCommFinalize(comms[i]);
ncclGroupEnd();
for (i) ncclCommDestroy(comms[i]);ncclCommFinalizeCopier
for (i) {
ncclCommFinalize(comms[i]);
ncclCommDestroy(comms[i]);
}CopierncclCommFinalize(comms[0])de la première itération bloquera en attendant que tous les ranks soient silencieux, mais les Finalize des autres domaines de communication n'ont pas encore été lancés, ce qui provoque un deadlock — c'est le même type de problème que le deadlock de Q1.
De plus, le fichier d'en-tête📎 src/nccl.h.in:309-309indique quencclCommFinalizeau retour, le domaine de communication peut encore être dans l'étatncclInProgress, et il faut attendre le silence global pour entrer dansncclSuccess. Si l'on appelle immédiatementncclCommDestroy, on pourrait libérer les ressources locales alors que le domaine de communication n'est pas encore complètement silencieux, ce qui entraînerait un comportement indéfini. La bonne pratique est, après Finalize, de sonderncclCommGetAsyncErrorpour confirmer l'état, puis de Destroy.
Ces comportements externes constituent le référentiel pour toutes les analyses de code source ultérieures. Dans le chapitre 2, nous établirons le modèle mental central : le quintet domaine de communication, canal, algorithme, protocole, couche de transport, et nous verrons comment NCCL organise ces concepts en interne.
Chapitre 2 : Chapitre 2 : Modèle d'abstraction central : opérateurs de communication, topologie, algorithmes, protocoles et couche de transport
Chapitre 2 : Modèle d'abstraction central : opérateurs de communication, topologie, algorithmes, protocoles et couche de transport
Dans le chapitre précédent, nous avons fait tourner NCCL et observé le comportement externe des trois API ncclCommInitRank, ncclAllReduce, ncclCommDestroy. Mais le comportement externe n'est que la partie émergée de l'iceberg — lorsque ncclAllReduce retourne, que se passe-t-il réellement sur le GPU ? Par quel chemin passent les données ? Pourquoi le même AllReduce présente-t-il des différences de performance énormes selon les machines ? Pour répondre à ces questions, il faut d'abord établir le vocabulaire commun de NCCL. Ce chapitre décomposera une à une les cinq abstractions centrales : domaine de communication (ncclComm), canal (channel), algorithme (algorithm), protocole (protocol), couche de transport (transport). Ces cinq concepts traversent tout le livre, et chaque chapitre ultérieur les utilisera dans son analyse. Comprendre leurs relations, c'est comprendre le squelette de NCCL.
2.1 Domaine de communication ncclComm : le contexte de communication d'un processus
Modèle intuitif
ImaginezncclCommcomme un « groupe de discussion » : chaque processus qui rejoint le groupe obtient un ID de groupe, et ensuite tous les messages sont envoyés dans ce groupe. Combien de personnes dans le groupe (nRanks), qui je suis (rank), par quelle route (channels), avec quelles règles (config), tout est enregistré dans cet objet de groupe de discussion.
SansncclComm, NCCL ne saurait pas « qui communique avec qui » ni « où envoyer les données » — chaque appel d'API devrait renégocier la liste des ranks et reconstruire les connexions, un coût insupportable.
Structure de données et disposition mémoire
ncclCommest la structure la plus centrale de tout NCCL, définie danssrc/include/comm.h. Elle est extrêmement volumineuse (près de 300 lignes), nous examinerons les champs clés regroupés par fonction.
Identité et sentinelles de cycle de vie
📎 src/include/comm.h:576-580définitstartMagic,📎 src/include/comm.h:879-881définitendMagic. Ces deux champs ne sont pas des clés de sécurité, mais des sentinelles de détection de dépassement mémoire. À l'emplacement📎 src/include/comm.h:883-885se trouvent deuxstatic_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");Ces deux assertions forcent à la compilationstartMagicà se trouver à l'adresse de début de la structure,endMagicà la fin. À l'exécution, en vérifiant si ces deux nombres magiques ont été altérés, on peut rapidement déterminer si le pointeurncclCommest valide — ce qui est très utile pour diagnostiquer les bugs de type « accès à un domaine de communication détruit via un pointeur sauvage » dans un environnement multithread.
Rank et informations de topologie
📎 src/include/comm.h:628-629définitranketnRanks— mon numéro dans le domaine de communication et le nombre total de participants.📎 src/include/comm.h:644-652définit les champs liés au nœud :node(numéro du nœud où je me trouve),nNodes(nombre total de nœuds),localRank(numéro au sein du nœud),localRanks(nombre de GPU dans le nœud), ainsi que trois tables de correspondancerankToNode、rankToLocalRank、localRankToRank。
Ces trois tables de mappage constituent la base de l'algorithme sensible à la topologie. Par exemple, l'algorithme Ring doit savoir « si mon prochain rank se trouve dans le même nœud » pour décider s'il passe par NVLink ou par le réseau. Sans ces tables de mappage, chaque sélection d'algorithme devrait réinterroger le graphe de topologie, ce qui entraînerait des coûts considérables.
Canaux et tampons
📎 src/include/comm.h:593-593définitchannels[MAXCHANNELS]—— c'est le tableau de tous les canaux dans le domaine de communication.📎 src/include/comm.h:674-676définit le nombre de canaux :nChannels(nombre de canaux de connexion),collChannels(nombre de canaux de mise en file pour la communication collective),nvlsChannels(nombre de canaux NVLS).
📎 src/include/comm.h:691-693définit la taille des tampons :buffSizes[NCCL_NUM_PROTOCOLS](taille du tampon pour chaque protocole),p2pChunkSize(taille de bloc P2P),nvlsChunkSize(taille de bloc NVLS).
buffSizesL'index du tableau correspond à la valeur d'énumération du protocole (LL/LL128/Simple), ce qui signifie que chaque protocole dispose d'une configuration de taille de tampon indépendante. Le protocole LL nécessite un petit tampon pour réduire la latence, tandis que le protocole Simple nécessite un grand tampon pour augmenter la bande passante — ce tableau permet à ces deux besoins de coexister.
File de travail et FIFO
📎 src/include/comm.h:719-728définit les champs liés à la FIFO de travail :workFifoBytes(taille de la FIFO, puissance de 2),workFifoBuf(tampon FIFO côté hôte),workFifoBufDev(tampon FIFO côté périphérique),workFifoProduced(nombre d'octets produits),workFifoConsumed(nombre d'octets consommés).
Il s'agit d'un tampon circulaire producteur-consommateur typique. Le côté hôte (producteur) écrit les descriptions de travail dans la FIFO, et le kernel GPU (consommateur) les lit et les exécute.workFifoBytesdoit être une puissance de 2, ce qui permet d'utiliser un masque binaire au lieu d'une opération modulo pour accélérer le calcul d'index.
Barrière de synchronisation intra-processus
📎 src/include/comm.h:731-731définit le mécanisme de synchronisation multi-domaines de communication au sein d'un processus :
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 intraComm0Noter queintraPad1etintraPad2ont une taille de64 - sizeof(uint64_t), soit 56 octets. Ajouté au champuint64_tprécédent, chaque groupe de champs occupe exactement 64 octets — c'est une ligne de cache (Cache Line).
Il s'agit d'une technique typique deremplissage de ligne de cache (Cache Line Padding).intraBarrierCounteretintraBarrierGatesont lus et écrits à haute fréquence par plusieurs threads ; s'ils partagent la même ligne de cache, cela provoque dufaux partage (False Sharing): la modification deintraBarrierCounterpar un thread invalide le cache deintraBarrierGated'un autre thread, entraînant une chute brutale des performances. Les séparer sur différentes lignes de cache à l'aide d'un remplissage de 56 octets est une technique standard en programmation concurrente haute performance.
État d'erreur asynchrone
📎 src/include/comm.h:705-705définitasyncResult—— ce champ enregistre l'état des opérations asynchrones du domaine de communication. Dans le chapitre précédent, nous avons mentionné quencclCommFinalizepeut encore être dans l'étatncclInProgressau retour, ce qui est suivi via ce champ.
Parcours guidé par scénario : de ncclCommInitRank au remplissage de la structure
Lorsque l'utilisateur appellencclCommInitRank(&comm, nranks, commId, rank), NCCL alloue en interne une structurencclCommet la remplit champ par champ. Suivons ce processus pour voir comment les champs clés sont définis :
Première étape : allocation et mise à zéro
NCCL utilisencclCallocpour allouerncclComm, garantissant que tous les champs sont initialisés à 0. À ce stade,startMagicetendMagicsont définis àNCCL_MAGIC(📎 src/include/comm.h:563-569défini comme0x0280028002800280, le commentaire indiquant « Nickel atomic number is 28 »).
Deuxième étape : remplissage des informations d'identité
rank、nRanks、cudaDevest obtenu à partir des paramètres et de l'API CUDA.commHashest obtenu par hachage dencclCommId, utilisé pour la vérification de cohérence dans les communications réseau ultérieures.
Troisième étape : construction du graphe de topologie
NCCL appelle le module de détection de topologie pour énumérer tous les GPU, cartes réseau et commutateurs PCI, et construit le champtopo(📎 src/include/comm.h:595-595). Ce graphe de topologie détermine la sélection ultérieure des algorithmes et la planification des chemins.
Quatrième étape : initialisation des canaux
channels[MAXCHANNELS]Le tableau est initialisé un par un. Leidde chaque canal est défini à l'index du tableau,peerset les pointeursdevPeerssont alloués.
Cinquième étape : établissement des connexions de transport
En fonction du graphe de topologie, NCCL sélectionne la couche de transport (P2P/SHM/NET) pour chaque paire de ranks, et appelle les callbackssetupetconnectcorrespondants. Les informations de connexion sont stockées danschannels[i].peers[j].
Sixième étape : définition du nombre magique
Enfin,endMagicest défini àNCCL_MAGIC, marquant l'achèvement de l'initialisation de la structure.
Réflexions de conception et pièges en production
PourquoincclCommest-il si grand ?
ncclCommcontient près de 300 champs, car il porte l'état complet d'un domaine de communication. La philosophie de conception de NCCL est « initialiser une fois, réutiliser plusieurs fois » — lors de l'initialisation, toutes les informations potentiellement utiles sont calculées et stockées, et à l'exécution, on consulte directement la table pour éviter les recalculs. Le coût est une occupation mémoire relativement importante (environ quelques Ko par domaine de communication), mais par rapport à la mémoire GPU et à la bande passante réseau, cette mémoire est négligeable.
Piège 1 : partage d'un domaine de communication entre plusieurs threads
ncclCommn'est pas thread-safe. Si deux threads appellent simultanémentncclCommsur le mêmencclAllReduce,workFifoProduced, des champs comme celui-ci entreront en compétition, entraînant une corruption des données. La bonne pratique est d'utiliser un domaine de communication indépendant par thread, ou de sérialiser les appels avec un verrou externe.
Piège 2 : accès après destruction
ncclCommDestroyAprès la libération de la mémoire de la structure, si un thread détient encore un pointeur et y accède, il lira de la mémoire libérée.startMagicetendMagicpeuvent aider à détecter ce cas — si le nombre magique ne correspond pas, cela signifie que le pointeur n'est plus valide.
Piège 3 : faux partage de ligne de cache
Dans un scénario multi-processus (un rank par processus), le remplissage deintraBarrierCounteretintraBarrierGateest particulièrement important. Si le remplissage est omis, les opérations de barrière de plusieurs processus interféreront mutuellement, faisant passer la latence de synchronisation de l'ordre de la nanoseconde à celui de la microseconde.
2.2 Canal channel : découper une communication en plusieurs pipelines
Modèle intuitif
Lors d'un déménagement, on n'ouvre pas une seule chaîne de transport, mais plusieurs simultanément, chacune responsable d'une partie des cartons, ce qui permet de déménager plus rapidement dans l'ensemble.channelC'est le « tapis roulant » de NCCL — il divise les données d'une communication collective en plusieurs parties, chaque canal transportant indépendamment une partie, avançant en parallèle pour améliorer l'utilisation de la bande passante.
Sans channel, toutes les données ne peuvent emprunter qu'un seul chemin, les multiples liaisons physiques entre GPU (plusieurs cartes réseau, plusieurs groupes NVLink) ne peuvent pas être utilisées simultanément, et l'utilisation de la bande passante chute considérablement.
Structure de données et disposition mémoire
ncclChannelDéfini dans📎 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;
};Analyse des champs clés
peers/devPeers: pointe vers les informations de connexion de tous les ranks dans ce canal.peersest la vue côté hôte,devPeersest la vue côté device (accédée directement par le kernel GPU).ring: description topologique de l'algorithme Ring — prédécesseur et successeur de chaque rank.tree: description topologique de l'algorithme Tree — nœud parent et liste des nœuds enfants.collnetChain/collnetDirect: deux variantes topologiques de l'algorithme CollNet.nvls: description topologique de NVLink SHARP.id: index du canal, de 0 ànChannels-1。workFifoProduced: pointeur de production du FIFO de travail de ce canal.
Noter quering、tree、collnetChain、collnetDirect、nvlsces cinq champs sontparallèles— un même canal peut contenir simultanément les descriptions topologiques de plusieurs algorithmes. Au moment de l'exécution, le champ à utiliser est déterminé selon l'algorithme choisi. Cette conception permet de changer d'algorithme sans reconstruire le canal, il suffit de changer le champ lu.
Calcul du nombre de canaux
Le nombre de canaux est défini dansncclComm(📎 src/include/comm.h:674-676):
int nChannels; // connection nChannels
int collChannels; // enqueue nChannels
int nvlsChannels; // enqueue nChannelsnChannelsest le nombre de connexions réellement établies,collChannelsest le nombre de canaux utilisés lors de la mise en file des communications collectives,nvlsChannelsest le nombre de canaux dédiés à NVLS. Les trois peuvent différer — par exemple, certains canaux sont utilisés uniquement pour le P2P et non pour les communications collectives.
Ordonnancement des canaux P2P
📎 src/include/channel.h:21-33définit lancclP2pChannelBaseForRoundfonction, utilisée pour calculer l'adresse de base du canal utilisé à chaque round dans la communication 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));
}La logique de cette fonction est : dans un scénario multi-nœuds, la communication P2P est ordonnancée par « groupes », les ranks d'un même groupe utilisant des canaux adjacents ; dans un scénario mono-nœud, chaque round est directement mappé à un canal.reverseBitsest une opération de reversal de bits, utilisée pour disperser l'attribution des canaux et éviter la concentration des points chauds.
Walkthrough guidé par scénario : comment un AllReduce attribue les canaux
Supposons 8 ranks et 4 canaux, exécutant un AllReduce. Les données sont découpées en 4 parties, chaque partie étant prise en charge par un canal.
Première étape : sélection de l'algorithme
Le module tuning de NCCL sélectionne l'algorithme (par exemple Ring) et le protocole (par exemple Simple) en fonction de la taille du message et de la topologie.
Deuxième étape : attribution des canaux
ncclTaskCollLa structure (📎 src/include/comm.h:212-273) est créée, dans laquelle le champnChannelsest défini à 4 (les champs📎 src/include/comm.h:254-254)。channelLoetchannelHi(📎 src/include/comm.h:256-257) marquent la plage de canaux utilisée par cette tâche.
Troisième étape : découpage des données
Chaque canal est responsable decount / nChannelséléments. Le canal 0 traite les éléments 0 à count/4-1, le canal 1 traite les éléments count/4 à count/2-1, et ainsi de suite.
Quatrième étape : exécution parallèle
Les kernels GPU des 4 canaux sont lancés simultanément, chacun exécutant Ring AllReduce sur sa propre tranche de données. Comme il n'y a pas de dépendance de données entre les canaux, ils peuvent être totalement parallèles.
Cinquième étape : fusion des résultats
Une fois tous les canaux terminés, le recv buffer de chaque rank contient le résultat complet de l'AllReduce.
Contrôle de concurrence et interaction matérielle
Mappage des canaux aux ressources GPU
Chaque canal est généralement lié à un CUDA stream indépendant ou à une file matérielle GPU. Ainsi, les kernels de différents canaux peuvent s'exécuter en concurrence sur le GPU, exploitant pleinement les ressources SM (Streaming Multiprocessor).
Mappage des canaux aux équipements réseau
Dans un scénario multi-cartes réseau, différents canaux peuvent être liés à différentes cartes réseau. Par exemple, avec 4 canaux et 2 cartes réseau, les canaux 0 et 1 passent par la carte réseau A, les canaux 2 et 3 par la carte réseau B. Ainsi, la bande passante des deux cartes réseau peut être utilisée.
Choix du nombre de canaux
Le nombre de canaux n'est pas forcément meilleur quand il est plus élevé. L'augmentation du nombre de canaux entraîne :
- plus de surcoût de lancement de kernel
- plus de surcoût d'établissement de connexion
- une synchronisation plus complexe
Le module tuning de NCCL sélectionne automatiquement le nombre optimal de canaux en fonction de la taille du message. Les petits messages utilisent peu de canaux (réduction du surcoût), les gros messages en utilisent davantage (amélioration de la bande passante).
Guide de production pour éviter les pièges
Scénario piège 1 : configuration inappropriée du nombre de canaux
Si l'on définit manuellementNCCL_NCHANNELStrop grand, dans un scénario de petits messages, le surcoût de lancement de kernel dépassera le gain, et les performances diminueront au contraire. Il est recommandé de laisser NCCL choisir automatiquement, sauf besoin d'optimisation clairement identifié.
Scénario piège 2 : inadéquation entre canaux et topologie
Si le nombre de canaux dépasse le nombre de liaisons physiques, certains canaux partageront des liaisons et ne pourront pas réaliser un véritable parallélisme. Par exemple, avec 2 cartes réseau et 8 canaux, seuls 2 canaux peuvent réellement transmettre simultanément, les 6 autres font la queue.
Scénario piège 3 : conflit de canaux P2P
ncclP2pChannelBaseForRoundL'opérationreverseBitsde📎 src/include/channel.h:32-32, si elle est mal implémentée, entraînera le mappage de plusieurs rounds sur le même canal, provoquant une sérialisation.reverseBits(base, log2Up(comm->p2pnChannels))Le
2.3 Algorithme algorithm : organisation topologique de Tree/Ring/CollNet/NVLS/PAT
Modèle intuitif
De Pékin à Shanghai, on peut prendre le train à grande vitesse, l'avion ou conduire soi-même ; chaque mode convient à des distances et des nombres de personnes différents. Les algorithmes de NCCL sont exactement ces « modes de déplacement » — Ring convient à une bande passante stable pour les gros messages, Tree convient à une faible latence pour les petits messages, CollNet exploite le déchargement par carte réseau, NVLS exploite l'accélération matérielle NVLink SHARP, et PAT est une variante parallélisée de NVLS.
Sans sélection d'algorithme, NCCL ne pourrait communiquer que selon un mode fixe, incapable de s'adapter aux différentes tailles de messages et topologies, et les performances en seraient fortement dégradées.
Structures de données et disposition mémoire
Algorithme Ring
Le cœur de l'algorithme Ring est lancclRingstructure (danssrc/include/comm.hréférencée viachannels[i].ring).📎 src/include/collectives.h:81-116définit laRingAlgorithmclasse de base :
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() {};
};Analyse des champs clés
refCount: compteur de références, utilisé pour le partage de l'objet algorithme entre le thread proxy et le kernel GPU.nRanks: nombre de nœuds sur l'anneau.nStepsPerLoop: nombre de pas par cycle. AllReduce est2*(nRanks-1)*chunkSteps(📎src/include/collectives.h:218-218)。chunkSteps/sliceSteps: pas de blocs et pas de tranches, contrôlant la granularité du pipeline.sliceSize/loopSize/channelSize: taille de tranche, taille de cycle, taille de canal.sendbuff/recvbuff: pointeurs de tampon d'envoi et de réception.sendMhandle/recvMhandle/srecvMhandle: handle mémoire, utilisé pour l'enregistrement réseau.
Opérations atomiques du compteur de références
📎 src/include/collectives.h:106-108illustreincRefCountetdecRefCount:
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);
}incRefCountutilisememory_order_relaxed— incrémenter le compteur de références ne nécessite pas de synchronisation, il suffit de garantir l'atomicité.decRefCountutilisememory_order_release— décrémenter le compteur de références nécessite de s'assurer que les écritures précédentes sont visibles par les autres threads (car cela peut déclencher la destruction de l'objet).
RingARAlgorithm : implémentation Ring de AllReduce
📎 src/include/collectives.h:118-234définitRingARAlgorithm, héritant deRingAlgorithm. Les méthodes principales sontgetNextSendAddretgetNextRecvAddr。
📎 src/include/collectives.h:126-167la logique degetNextSendAddr:
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 ...
}Le cœur de ce code estle calcul d'adresse: étant donné le pas actuelcurStep, calculer quelle tranche de quel bloc de données doit être envoyée.chunkIdLe calcul de(ringIndex + nRanks - 1 - chunkStage) % nRanksimplémente la propagation inverse sur l'anneau — chaque rank reçoit les données de son prédécesseur, les traite puis les envoie à son successeur.
Algorithme PAT
PAT (Parallel Aggregated Tree) est une variante parallélisée de NVLS.📎 src/include/collectives.h:416-423définitncclPatStep:
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-435définitncclPatPeer:
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;
};L'idée centrale de l'algorithme PAT estd'agréger plusieurs petites étapes en une grande étape, réduisant ainsi les surcoûts de synchronisation.ncclPatStepdécrit les dimensions d'envoi/réception, les décalages, le nombre d'éléments, etc. d'une étape d'agrégation.ncclPatPeerdécrit l'état de connexion et les pointeurs de tampon d'un nœud pair.
Parcours guidé par scénario : évolution des étapes de Ring AllReduce
Supposons 4 ranks (0, 1, 2, 3), chacun avec 4 éléments, exécutant un Ring AllReduce.
Phase Reduce-Scatter
- Étape 0 : rank 0 envoie l'élément 0 à rank 1, rank 1 envoie l'élément 1 à rank 2, rank 2 envoie l'élément 2 à rank 3, rank 3 envoie l'élément 3 à rank 0.
- Étape 1 : chaque rank additionne l'élément reçu avec l'élément local correspondant, puis l'envoie au rank suivant.
- Étape 2 : poursuite de l'accumulation et de la transmission.
- Étape 3 : à ce stade, chaque rank possède un résultat de réduction complet (rank 0 a le résultat de l'élément 3, rank 1 a le résultat de l'élément 0, etc.).
Phase AllGather
- Étapes 4-6 : chaque rank propage le long de l'anneau le résultat de réduction qu'il possède, et finalement tous les ranks possèdent le résultat complet.
📎 src/include/collectives.h:218-218LenStepsPerLoop = 2 * (nRanks - 1) * chunkStepsde(nRanks-1)*chunkStepscorrespond exactement à ce flux : Reduce-Scatter nécessite(nRanks-1)*chunkStepspas, AllGather nécessite également2*(nRanks-1)*chunkStepspas, soit au total
pas.
Réflexions de conception et pièges en production
〔Inférences de conception et compromis architecturaux〕
L'algorithme Ring a une utilisation de bande passante élevée (chaque lien transmet), mais la latence croît linéairement avec le nombre de ranks. L'algorithme Tree a une latence logarithmique, mais une faible utilisation de bande passante (seuls certains liens travaillent). NCCL choisit automatiquement selon la taille du message : Tree pour les petits messages (sensible à la latence), Ring pour les gros messages (sensible à la bande passante).
〔Inférences de conception et compromis architecturaux〕
Si l'on force manuellement l'utilisation de Ring pour de petits messages, la latence augmentera significativement. Il est recommandé de laisser le module tuning choisir automatiquement, sauf si des données d'analyse de performance précises justifient une intervention manuelle.
Piège de scénario deux : matériel NVLS non pris en charge📎 src/include/comm.h:755-755NVLS nécessite un support matériel spécifique (NVLink SHARP). Si le matériel ne le prend pas en charge mais que le code force l'utilisation de NVLS, il y aura un repli vers Ring ou Tree, mais possiblement accompagné de fluctuations de performance.nvlsSupportLe champ
de
indique si le matériel prend en charge NVLS.aggFactorPiège de scénario trois : configuration du facteur d'agrégation de l'algorithme PAT📎 src/include/collectives.h:537-560LeaggFactorde l'algorithme PAT
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、nranksCopier
〔Inférences de conception et compromis architecturaux〕
Un
Pour envoyer un colis, on peut choisir « livraison express intra-ville », « livraison le lendemain » ou « courrier ordinaire », avec des vitesses et des coûts différents. Les protocoles de NCCL sont ces « modes d'envoi » — LL (Low Latency) convient à la transmission de petits messages à faible latence, LL128 convient à la transmission de messages moyens alignés sur 128 octets, et Simple convient à la transmission de gros messages à haut débit.
Sans sélection de protocole, NCCL ne pourrait utiliser qu'une stratégie fixe pour déplacer les données, sans pouvoir équilibrer latence et bande passante.
Structures de données et disposition mémoire
Énumération des protocoles
📎 src/include/comm.h:55-57définit les seuils de threads liés aux protocoles :
#define NCCL_LL_THREAD_THRESHOLD 8
#define NCCL_LL128_THREAD_THRESHOLD 8
#define NCCL_SIMPLE_THREAD_THRESHOLD 64Ces seuils déterminent combien de threads chaque protocole utilise. LL et LL128 utilisent 8 threads (faible latence, peu de threads suffisent), Simple utilise 64 threads (haut débit, nécessite plus de threads pour le transfert parallèle).
Tampons de protocole
📎 src/include/comm.h:691-691définitbuffSizes[NCCL_NUM_PROTOCOLS]——chaque protocole a une taille de tampon indépendante.
Structures FIFO liées aux protocoles
📎 src/include/comm.h:59-83définitncclSendMemetncclRecvMem:
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];
};
};ncclSendMemetncclRecvMemsont les structures de mémoire partagée pour l'envoi et la réception.headettailsont les pointeurs de lecture/écriture du tampon circulaire,pad1garantit qu'ils sont sur des lignes de cache différentes.connFifoLe tableau stocke les informations de connexion pour chaque étape (mode, offset, taille, pointeur), défini dans📎 src/include/collectives.h:72-77:
struct ncclConnFifo {
int mode;
ssize_t offset;
ssize_t size;
void* ptr;
};Logique de sélection des protocoles
La sélection du protocole est effectuée par le module tuning, en tenant compte des facteurs suivants :
- Taille des messages : LL pour les petits messages, LL128 pour les moyens, Simple pour les gros.
- Topologie : les connexions NVLink conviennent à LL128, les connexions réseau conviennent à Simple.
- Capacités matérielles : certaines architectures GPU sont optimisées pour des protocoles spécifiques.
Parcours guidé par scénario : transfert de données avec le protocole LL
Supposons l'utilisation du protocole LL pour transmettre 1 Ko de données.
Première étape : écriture des données dans le tampon d'envoi
Le côté hôte écrit les données danssendbuff, puis met à jour le pointeurncclSendMem.headpour notifier le kernel GPU de la présence de nouvelles données.
Deuxième étape : lecture des données par le kernel GPU
Le kernel GPU interroge le pointeurhead, et après avoir détecté de nouvelles données, lit les données depuissendbuff.
Troisième étape : transmission des données
Le kernel GPU envoie les données au rank cible via NVLink ou le réseau.
Quatrième étape : réception des données par le rank cible
Le kernel GPU du rank cible écrit les données dansrecvbuff, puis met à jour le pointeurncclRecvMem.tail.
Cinquième étape : lecture des données par le côté hôte
Le côté hôte interroge le pointeurtail, et après avoir détecté de nouvelles données, lit les données depuisrecvbuff.
Contrôle de concurrence et interaction matérielle
Mécanisme de faible latence du protocole LL
Le protocole LL utilisel'interrogation (Polling)plutôt que les interruptions pour détecter l'arrivée des données. Le kernel GPU lit continuellement le pointeurheadet traite immédiatement tout changement détecté. Cela offre une latence plus faible que les interruptions, mais consomme des ressources de calcul GPU.
Alignement sur 128 octets du protocole LL128
Le protocole LL128 exige que les données soient alignées sur 128 octets, de sorte que chaque transmission remplisse exactement une ligne de cache. Les avantages de l'alignement sont :
- Réduction des écritures partielles de lignes de cache (Partial Cache Line Write)
- Amélioration de l'utilisation de la bande passante mémoire
- Simplification de la logique de traitement matérielle
Transfert par lots du protocole Simple
Le protocole Simple utilisele transfert par lotsmode : accumuler une certaine quantité de données avant de les envoyer en une seule fois, réduisant ainsi le nombre de synchronisations. Cela convient aux scénarios de gros messages, car les frais de synchronisation sont répartis sur une grande quantité de données.
Guide de production pour éviter les pièges
Scénario piège 1 : inadéquation entre protocole et taille de message
Si l'on force l'utilisation du protocole LL pour transmettre de gros messages, les performances chutent brutalement. Car l'objectif de conception du protocole LL est la faible latence, pas le haut débit. Les gros messages doivent utiliser le protocole Simple.
Scénario piège 2 : problème d'alignement de LL128
Si les données ne sont pas alignées sur 128 octets, le protocole LL128 revient à LL ou Simple, entraînant une instabilité des performances. Il est recommandé de s'assurer que les tampons d'envoi et de réception sont alignés sur 128 octets.
Scénario piège 3 : coût du changement de protocole
Changer dynamiquement de protocole à l'exécution entraîne des coûts supplémentaires. NCCL détermine le protocole lors de l'initialisation et ne le change plus à l'exécution. Si un changement est nécessaire, il faut réinitialiser le domaine de communication.
2.5 Couche de transport transport : canaux de transfert bas niveau P2P/SHM/NET/CollNet
Modèle intuitif
Pour aller du point A au point B, on peut marcher, faire du vélo, prendre le métro ou le taxi ; la couche de transport de NCCL correspond à ces différents « modes de déplacement ». La couche supérieure ne se soucie pas de la manière exacte, seulement de savoir si les données peuvent être livrées. P2P est la « marche » (connexion directe entre GPU d'une même machine), SHM est le « vélo » (mémoire partagée), NET est le « métro » (réseau), CollNet est le « taxi » (déchargement vers la carte réseau).
Sans abstraction de la couche de transport, les algorithmes de la couche supérieure devraient écrire du code différent pour chaque type de liaison physique, sans possibilité de réutilisation.
Structures de données et disposition mémoire
Énumération de la couche de transport
📎 src/include/transport.h:18-23définit les types de couche de transport :
#define NTRANSPORTS 4
#define TRANSPORT_UNDEFINED -1
#define TRANSPORT_P2P 0
#define TRANSPORT_SHM 1
#define TRANSPORT_NET 2
#define TRANSPORT_COLLNET 3Interface de la couche de transport
📎 src/include/transport.h:129-146définitncclTransportComm——l'interface de communication de la couche de transport :
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);
};Analyse des callbacks clés
setup: travail préparatoire avant l'établissement de la connexion, échange des paramètres de connexion.connect: établissement effectif de la connexion.free: libération des ressources de connexion.proxySharedInit: Initialiser les ressources partagées du thread proxy.proxySetup/proxyConnect: Établissement de la connexion côté thread proxy.proxyProgress: Le thread proxy fait progresser le transfert de données.proxyRegister/proxyDeregister: Enregistrement et désenregistrement de la mémoire.
Structure de la couche de transport
📎 src/include/transport.h:148-154définitncclTransport:
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;
};nameest le nom de la couche de transport (tel que "P2P", "SHM", "NET"),canConnectdétermine si cette couche de transport peut être utilisée entre deux ranks,sendetrecvsont respectivement les interfaces de communication pour les directions d'envoi et de réception.
Instances de la couche de transport
📎 src/include/transport.h:36-36déclare quatre instances de couche de transport :
extern struct ncclTransport p2pTransport;
extern struct ncclTransport shmTransport;
extern struct ncclTransport netTransport;
extern struct ncclTransport collNetTransport;📎 src/include/transport.h:36-36définit le tableau des couches de transport :
extern struct ncclTransport* ncclTransports[];Informations de nœud pair
📎 src/include/transport.h:46-74définitncclPeerInfo——métadonnées échangées entre les ranks :
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;
};Ces champs servent à déterminer quelle couche de transport peut être utilisée entre deux ranks :
hostHashidentique → même hôte → P2P ou SHM disponiblehostHashdifférent → hôte différent → NET obligatoiregdrSupport→ prise en charge de GPUDirect RDMAcudaCompCap→ capacité de calcul GPU, influence le choix du protocole
Parcours guidé par scénario : établissement d'une connexion P2P
Supposons que deux ranks se trouvent sur le même hôte, NCCL choisit la couche de transport P2P.
Première étape : échange de PeerInfo
Les deux ranks échangent via le canal bootstrapncclPeerInfo, confirmant qu'ils sont sur le même hôte et que les GPU prennent en charge P2P.
Deuxième étape : appel de canConnect
📎 src/include/transport.h:148-154decanConnectle callback est appelé, vérifie la topologie pour confirmer qu'il existe une connexion NVLink ou PCIe entre les deux GPU.
Troisième étape : appel de setup
p2pTransport.send.setupetp2pTransport.recv.setupsont appelés, préparent les paramètres de connexion (tels que les handles IPC).
Quatrième étape : appel de connect
p2pTransport.send.connectetp2pTransport.recv.connectsont appelés, établissent réellement la connexion.
Cinquième étape : enregistrement de la mémoire
Si RDMA est nécessaire, appelerproxyRegisterpour enregistrer les tampons d'envoi et de réception.
Contrôle de concurrence et interaction matérielle
Couche de transport P2P
P2P utilise le mécanisme CUDA IPC (Inter-Process Communication), permettant à un GPU d'accéder directement à la mémoire vidéo d'un autre GPU. Cela nécessite :
- Les deux GPU dans le même domaine PCIe ou domaine NVLink
- Le système d'exploitation prend en charge CUDA IPC
- Des permissions suffisantes
Couche de transport SHM
SHM utilise la mémoire partagée de l'hôte comme intermédiaire. Lorsqu'il n'y a pas de connexion directe entre deux GPU, les données sont d'abord copiées vers la mémoire de l'hôte, puis copiées vers le GPU cible. C'est plus lent que P2P, mais la compatibilité est meilleure.
Couche de transport NET
NET utilise les périphériques réseau (InfiniBand ou RoCE) pour transmettre les données. Cela nécessite :
- Le périphérique réseau prend en charge GPUDirect RDMA (optionnel, mais recommandé)
- Une configuration réseau correcte (adresse IP, masque de sous-réseau, etc.)
- Une bande passante réseau suffisante
Couche de transport CollNet
CollNet exploite la capacité de déchargement de communication collective de la carte réseau (telle que NVIDIA SHARP). La carte réseau exécute directement les opérations de réduction, réduisant la charge de calcul du GPU. Cela nécessite :
- Une carte réseau prenant en charge SHARP
- Une configuration SHARP correcte
Guide de dépannage en production
Scénario problématique un : P2P indisponible
Si deux GPU n'ont pas de NVLink et que la topologie PCIe ne prend pas en charge P2P, NCCL se rabat sur SHM. Cela entraîne une baisse de performance. On peut utiliserNCCL_P2P_DISABLE=1pour forcer la désactivation de P2P et observer les changements de performance.
Scénario problématique deux : erreur de configuration réseau
Si l'adresse IP du périphérique réseau est mal configurée, la couche de transport NET ne peut pas établir de connexion. Les erreurs courantes incluent : masque de sous-réseau incorrect, table de routage manquante, blocage par pare-feu. Il est recommandé d'utiliseribstatetibpingpour vérifier la connexion InfiniBand.
Scénario problématique trois : GPUDirect RDMA non activé
SigdrSupportvaut 0, la couche de transport NET se rabat sur le mode « copier d'abord vers la mémoire de l'hôte puis envoyer », ce qui augmente considérablement la latence. Vérifier si le modulenvidia-peermemest chargé, et si le pilote de la carte réseau prend en charge GPUDirect.
2.6 Comment les cinq composants se combinent : cycle de vie complet d'une communication
Diagramme des relations de combinaison
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"]Cycle de vie complet
Phase un : appel API
L'utilisateur appellencclAllReduce, en passant le tampon d'envoi, le tampon de réception, le nombre d'éléments, le type de données, l'opération de réduction, le domaine de communication, le stream CUDA.
Phase deux : création de tâche
NCCL crée la structurencclTaskColl(📎 src/include/comm.h:212-273), remplit les champsfunc(AllReduce)、sendbuff、recvbuff、count、datatype、opHostetc.
Phase trois : sélection de l'algorithme et du protocole
Le module Tuning sélectionne l'algorithme (Ring/Tree/NVLS) et le protocole (LL/LL128/Simple) en fonction de la taille du message, de la topologie et des capacités matérielles. Le résultat de la sélection est écrit dansncclTaskCollles champsalgorithmetprotocol(📎 src/include/comm.h:227-227)。
Phase quatre : attribution des canaux
En fonction de l'algorithme et du protocole, déterminer le nombre de canaux et la plage de canaux utilisés.nChannels、channelLo、channelHiLe champ📎 src/include/comm.h:254-257)。
est défini (
Phase cinq : sélection de la couche de transportchannels[i].peers[j]En fonction de la topologie, sélectionner la couche de transport (P2P/SHM/NET/CollNet) pour chaque paire de ranks. Les informations de connexion sont stockées dans
.
Phase six : lancement du kernelncclKernelPlan(📎 src/include/comm.h:357-410NCCL construit
Phase sept : exécution de la communication
Le kernel GPU lit la FIFO de travail, exécute les transferts de données et les opérations de réduction. Les threads Proxy font progresser les E/S réseau de manière asynchrone.
Phase huit : achèvement
Une fois tous les canaux terminés,asyncResultest défini surncclSuccess. L'utilisateur peut interroger l'état viancclCommGetAsyncError.
Réflexions de conception
Pourquoi le quintette est-il nécessaire ?
Ces cinq abstractions résolvent chacune des problèmes de dimensions différentes :
ncclComm: résout la question « qui communique avec qui ».channel: résout la question « comment paralléliser ».algorithm: résout la question « quelle topologie utiliser ».protocol: résout la question « quelle stratégie utiliser ».transport: résout la question « quel lien physique emprunter ».
Leur combinaison orthogonale permet à NCCL de s'adapter à diverses configurations matérielles et tailles de messages, sans avoir à écrire du code spécifique pour chaque combinaison.
La flexibilité des combinaisons
Le nombre de combinaisons du quintette est :
- Algorithmes : 5 types (Tree/Ring/CollNet/NVLS/PAT)
- Protocoles : 3 types (LL/LL128/Simple)
- Couches de transport : 4 types (P2P/SHM/NET/CollNet)
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on remplace📎 src/include/comm.h:731-731dansintraPad1[64 - sizeof(uint64_t)]parintraPad1[0](c'est-à-dire en supprimant le remplissage de ligne de cache), quels problèmes de performance apparaîtraient dans un scénario multi-processus ? Pourquoi ?
Analyse de référence:
Après suppression du remplissage, lesintraBarrierPhase、intraBarrierCounter、intraBarrierGatetrois champs seraient étroitement alignés en mémoire, partageant très probablement la même ligne de cache (généralement 64 octets).
Dans un scénario multi-processus, chaque processus possède sa propre copie dencclComm, maisintraComm0etintraBarrierCounterdu domaine de communication leader pointé parintraBarrierGatesont lus et écrits par tous les processus. Lorsque le processus A appellencclCommIntraBarrierInpour mettre à jourintraBarrierCounter(📎 src/include/comm.h:943-959), cela invalide la ligne de cache deintraBarrierGatedu processus B. Le processus B, en interrogeantncclCommIntraBarrierOutdansintraBarrierGate(📎 src/include/comm.h:962-977), doit recharger depuis la mémoire à chaque invalidation de cache, la latence passant de l'ordre de la nanoseconde à celui de la microseconde.
C'est le problème dufaux partage (False Sharing). Le remplissage de 56 octets garantit que chaque champ occupe exclusivement une ligne de cache, éliminant le faux partage.
Q2 : Si l'on remplace📎 src/include/collectives.h:106-108deincRefCountdememory_order_relaxedparmemory_order_seq_cst, quel serait l'impact ? Pourquoi l'auteur a-t-il choisirelaxed?
Analyse de référence:
memory_order_seq_cstimposerait une cohérence séquentielle globale, nécessitant l'insertion d'une barrière mémoire à chaque incrément du compteur de références, entraînant une baisse de performance.
incRefCountne nécessite que la garantie d'atomicité, sans synchroniser d'autres opérations mémoire. Car incrémenter le compteur de références ne déclenche pas la destruction de l'objet et ne dépend pas des écritures d'autres threads.memory_order_relaxedsatisfait exactement ce besoin — garantissant uniquement l'atomicité, sans insérer de barrière.
En comparaison,decRefCount(📎 src/include/collectives.h:109-111) utilisememory_order_release, car décrémenter le compteur de références peut déclencher la destruction de l'objet, et il faut s'assurer que les écritures précédentes sont visibles par les autres threads.
C'est une application classique du modèle mémoire C++ : choisir l'ordre mémoire le plus faible selon la sémantique de l'opération, maximisant la performance sous réserve de garantir la correction.
Q3 : Si l'on remplace📎 src/include/channel.h:32-32dereverseBits(base, log2Up(comm->p2pnChannels))par un retour direct debase % comm->p2pnChannels, dans quels scénarios cela entraînerait-il une baisse de performance ? Pourquoi ?
Analyse de référence:
reverseBitsest une opération d'inversion de bits, utilisée pour disperser l'attribution des canaux. Un modulo direct rendrait l'attribution des canaux régulière : le round 0 utilise le canal 0, le round 1 utilise le canal 1, ..., le round N utilise le canal N%p2pnChannels.
Dans un scénario multi-nœuds, si les communications P2P de plusieurs ranks se déroulent simultanément, une attribution régulière des canaux concentrerait les points chauds — certains canaux étant utilisés simultanément par plusieurs ranks, tandis que d'autres restent inactifs. Cela provoquerait une congestion des liens, réduisant l'utilisation globale de la bande passante.
reverseBitsdisperse l'attribution des canaux, faisant en sorte que différents rounds utilisent des canaux apparemment aléatoires, répartissant uniformément la charge. C'est une technique classique d'équilibrage de charge.
De plus,reverseBitsest une pure opération sur bits, plus rapide que l'opération modulo (le modulo nécessite une instruction de division, tandis que les opérations sur bits ne nécessitent que quelques instructions).
---
Dans le chapitre suivant, nous approfondirons l'implémentation interne dencclCommInitRank, pour voir comment NCCL, à partir d'une structurencclCommvide, construit progressivement le graphe de topologie, initialise les canaux, établit les connexions de transport, et finalement construit un domaine de communication utilisable. Le modèle mental du quintette établi dans ce chapitre sera concrétisé un par un dans le chapitre suivant.
Ces cinq abstractions n'existent pas isolément : le domaine de communication est le conteneur, le canal est l'unité d'exécution parallèle, l'algorithme détermine comment les données sont réduites, le protocole spécifie comment les données sont encodées, la couche de transport est responsable du déplacement des données. Leur combinaison — 5 dimensions, chacune avec 3 à 4 choix — constitue l'espace de recherche pour l'optimisation des performances de NCCL. Alors, comment cet objet de domaine de communication est-il construit à partir de zéro ? Dans le chapitre suivant, nous approfondirons la chaîne d'appels de ncclCommInitRank, pour voir comment NCCL effectue la détection des périphériques, la découverte de topologie et l'attribution des canaux lors de la phase d'initialisation, et révélerons le moment d'affectation des champs clés tels que comm->rank, comm->nRanks, comm->channels.
Chapitre 3 : Chapitre 3 : Entrée en jeu de l'initialisation : comment ncclCommInitRank transforme un groupe de processus isolés en un domaine de communication
Chapitre 3 : Entrée en jeu de l'initialisation : comment ncclCommInitRank transforme un groupe de processus isolés en un domaine de communication
Dans le chapitre précédent, nous avons établi cinq abstractions fondamentales qui traversent tout l'ouvrage : ncclComm, channel, algorithm, protocol et transport, qui constituent ensemble le vocabulaire commun de « une communication = plusieurs channels × un algorithm × un protocol × plusieurs transports ». Maintenant, nous devons répondre à une question plus fondamentale : comment cet objet ncclComm est-il construit à partir de rien ? Lorsque vous appelez ncclCommInitRank, NCCL doit accomplir en quelques centaines de millisecondes une série d'opérations complexes : confirmer que tous les ranks sont présents, échanger les informations sur les périphériques, sonder la topologie de la machine, calculer les chemins de données, allouer la mémoire GPU et la mémoire hôte, et finalement empaqueter tout cela dans un objet ncclComm. Ce chapitre suivra cette chaîne d'appels, depuis le point d'entrée de l'API jusqu'au dernier capillaire de initTransportsRank.
3.1 Point d'entrée de l'API : l'enveloppe synchrone et le noyau asynchrone de ncclCommInitRank
Modèle intuitif
ncclCommInitRankEn apparence, il s'agit de « créer un domaine de communication », mais en réalité, il s'agit de « lancer une tâche en arrière-plan, puis (par défaut) attendre qu'elle se termine ». C'est comme lorsque vous commandez au restaurant : l'action de commander (l'appel API) retourne instantanément, mais la cuisine (l'initialisation réelle) se fait en arrière-plan. Le « mode bloquant » par défaut vous fait simplement attendre au comptoir que le plat soit prêt, tandis que le « mode non bloquant » vous donne un numéro de commande, vous permettant d'aller faire autre chose en attendant.
Sans cette conception asynchrone, NCCL ne pourrait pas, pendant l'initialisation, coopérer avec des scénarios tels que la capture de CUDA Graph ou l'initialisation parallèle de plusieurs domaines de communication — toutes les initialisations deviendraient des opérations bloquantes sérialisées, impossibles à chevaucher avec le code utilisateur.
Structures de données et disposition mémoire
Regardons d'abord le point d'entrée de l'API lui-même.ncclCommInitRankC'est une enveloppe synchrone extrêmement fine :
📎 src/init.cc:2946-2970
Elle fait quatre choses : appelerncclInitEnv()charger le plugin de variables d'environnement, activer les marqueurs de performance NVTX, lire le numéro de périphérique CUDA actuel, puis appelerncclGroupStartInternal()entrer dans la sémantique de groupe, et enfin déléguer le travail réel àncclCommInitRankDev。
NotezncclGroupStartInternal() / ncclGroupEndInternal()cette paire d'appels — même si vous n'initialisez qu'un seul domaine de communication, NCCL l'enveloppe dans la sémantique de groupe. Cela permet de traiter de manière unifiée le scénario où « l'utilisateur initialise plusieurs domaines de communication dans un groupe », évitant d'écrire deux ensembles de chemins de code pour un seul domaine et pour plusieurs domaines.
La véritable validation des paramètres et l'allocation des objets se trouvent dansncclCommInitRankDev:
📎 src/init.cc:2851-2943
Cette fonction est le « poste de coordination central » de toute la chaîne. Elle effectue d'abord la validation des paramètres (plage denId, validité denranks/myrank), puis alloue la structurencclCommelle-même, ainsi que trois champs liés au mécanisme d'abandon :abortFlag(drapeau atomique côté hôte),abortFlagDev(copie en mémoire fixe visible côté périphérique),abortFlagRefCount(compteur de références, car les sous-domaines de communication issus d'un split peuvent partager l'abortFlag du domaine parent).
Il y a ici un détail digne d'attention —comm->startMagic = comm->endMagic = NCCL_MAGIC:
📎 src/init.cc:2886-2886
cette paire de valeurs magiques encadre comme un « sceau » le début et la fin de la structurencclComm. Toute écriture hors limites ou corruption de la structure brisera cette paire de magic, et les opérations ultérieures pourront les vérifier pour détecter un écrasement mémoire. C'est une protection d'intégrité mémoire peu coûteuse mais efficace.
Step-by-Step Walkthrough
LorsquencclCommInitRankDevarrive à la fin, il construit unncclCommInitRankAsyncJobet lance la tâche asynchrone :
📎 src/init.cc:2896-2929
jobLa structure porte tous les paramètres nécessaires à l'initialisation. Notez quejob->commIdestcopié, plutôt que de référencer directement lecommId:
📎 src/init.cc:2903-2910
passé par l'utilisateur. Pourquoi copier ? Le commentaire du code source donne la réponse :ncclUniqueIdetncclBootstrapHandleont des exigences d'alignement différentes ; le tableau passé par l'utilisateur peut ne pas être correctement aligné sur la frontière requise parncclBootstrapHandle. Copier vers une mémoire nouvellement allouée garantit l'alignement. C'est un piège classique de « compatibilité ABI » — l'utilisateur voitncclUniqueId, en interne on le traite commencclBootstrapHandle, les deux ayant la même taille mais un alignement différent.
Enfin, selon la valeur dencclParamEnqueueRearchEnable(), la tâche est soit placée dans la file de gestion, soit lancée directement viancclAsyncLaunch:
📎 src/init.cc:2922-2929
ncclAsyncLaunchcrée un nouveau thread qui exécutencclCommInitRankFunc. En mode bloquant (par défaut), l'appelant attend dansncclGroupEndInternal()que ce thread se termine ; en mode non bloquant, l'appelant retourne immédiatement, et l'utilisateur interroge ensuite l'état viancclCommGetAsyncError.
Réflexion de conception
Le cœur de la conception ici est « API synchrone + implémentation asynchrone ». Pourquoi ne pas laisserncclCommInitRankexécuter directement toutes les initialisations de manière synchrone ? Parce que NCCL doit prendre en charge le mode non bloquant dencclCommInitRankConfig, et le mode non bloquant exige que l'initialisation s'exécute dans un thread d'arrière-plan. Si le chemin synchrone et le chemin asynchrone étaient deux ensembles de code, le coût de maintenance doublerait. En unifiant tout en asynchrone, le chemin synchrone n'est plus que « lancer puis attendre immédiatement », et il n'y a qu'un seul code.
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 --> func3.2 Bootstrap : le premier canal de contrôle entre les ranks
Modèle intuitif
Le Bootstrap est le « groupe WeChat de réunion » de NCCL. Avant que la communication formelle ne commence, tous les ranks doivent d'abord établir un canal de contrôle pour échanger des métadonnées telles que « qui je suis, sur quelle machine je me trouve, quel modèle est mon GPU, quelle est l'adresse de ma carte réseau ». Sans bootstrap, les ranks sont un groupe d'inconnus qui ne se connaissent pas et ne peuvent coordonner aucune communication.
Si le bootstrap échoue ou expire, l'initialisation de tout le domaine de communication se bloquera — c'est l'une des causes les plus courantes de blocage de NCCL en production.
Structures de données et disposition mémoire
L'état central du Bootstrap est conservé dans la structurebootstrapState:
📎 src/bootstrap.cc:527-546
Cette structure comporte plusieurs champs clés qui méritent d'être détaillés :
ring: une union, soit un handle de périphérique réseau (net.sendComm/net.recvComm), soit une paire de sockets (socket.send/socket.recv). Cela correspond à deux modes de bootstrap : le mode par défaut basé sur les sockets et le modeNCCL_OOB_NET_ENABLEbasé sur un périphérique réseau.listen: informations sur l'écouteur, également sous deux formes : réseau et socket.peerP2pAddresses/peerProxyAddresses: tableau des adresses P2P et proxy de tous les ranks, rempli via ring allgather.unexpectedConnections: une liste chaînée qui met en cache les connexions « reçues mais pas encore appariées ». C'est une conception clé du protocole bootstrap — comme le récepteur ne peut pas prédire qui se connectera en premier, il doit stocker les connexions non appariées.asyncSendQueue+asyncSendLock+asyncSendCond: file d'attente d'envoi asynchrone et ses primitives de synchronisation, utilisées pour l'envoi concurrent en mode chiffrement TLS.
bootstrapStateL'allocation debootstrapInitse produit au début de
📎 src/bootstrap.cc:769-776
Notez la lignecomm->bootstrap = state— l'état bootstrap est attaché au domaine de communication, et toutes les opérations bootstrap ultérieures y accèdent viacomm->bootstrap.
Step-by-Step Walkthrough
bootstrapInitest la fonction principale du bootstrap. Décomposons-la dans l'ordre d'exécution :
Première étape : déterminer la valeur magic.magic est le « mot de passe » de la communication bootstrap ; seuls les ranks possédant le même magic peuvent se connecter entre eux.
📎 src/bootstrap.cc:778-788
En cas d'initialisation normale (handles != NULL), magic provient du premier handle ; en cas de split/grow (parent != NULL), magic est dérivé viahashCombine(parent->magic, parent->childCount). Cela garantit que chaque sous-domaine de communication possède un magic unique.
Deuxième étape : créer le socket d'écoute.Chaque rank a besoin de deux points d'écoute : un pour les connexions aux voisins ring (STATE_LISTEN(state, socket)), un pour les connexions root (listenSockRoot):
📎 src/bootstrap.cc:797-831
Il y a ici une répartition clé : le socket d'écoute ring utilisecomm->magic, tandis que le socket d'écoute root utiliseBOOTSTRAP_HANDLE(handles, curr_root)->magic. Pourquoi ? Parce que root est le coordinateur global, tous les ranks doivent s'y connecter, donc il utilise un magic unifié ; tandis que les voisins ring sont point à point, le magic propre au domaine de communication suffit.
Troisième étape : connexion échelonnée.Lorsque le nombre de ranks est très élevé, tous les ranks se connectant simultanément à root provoqueraient une tempête de connexions. NCCL utiliseNCCL_UID_STAGGER_RATEetNCCL_UID_STAGGER_THRESHOLDpour contrôler l'échelonnement :
📎 src/bootstrap.cc:833-843
Lorsque le nombre de ranks dont un root est responsable dépasse un seuil (256 par défaut), chaque rank calcule un délai en microsecondes en fonction de son ID local sous ce root, puis dort. C'est une limitation de débit simple mais efficace de type « token bucket ».
Quatrième étape : envoyer ses informations de connexion au root.Chaque rank envoie son adresse d'écoute au root :
📎 src/bootstrap.cc:845-867
Après avoir reçu les informations de tous les ranks, le root effectue un « appariement en anneau » — il envoie l'adresse du rank i au rank i-1, et l'adresse du rank i+1 au rank i. Ainsi, chaque rank connaît ses voisins précédent et suivant sur le ring.
Cinquième étape : établir les connexions ring.Chaque rank se connecte à son voisin « suivant » tout en acceptant la connexion de son voisin « précédent » :
📎 src/bootstrap.cc:885-894
IcisocketRingConnectutilise en internebootstrapConcurrent— en mode chiffrement TLS, connect et accept doivent être exécutés simultanément, sinon il y a interblocage (car la poignée de main TLS nécessite la participation des deux parties). En mode non chiffré, connect puis accept sont exécutés séquentiellement.
Sixième étape : AllGather de toutes les adresses.Une fois le ring établi, on effectue un allgather des adresses P2P, proxy et UDS de tous les ranks viaringAllInfo:
📎 src/bootstrap.cc:934-938
ringAllInfoappelle en internebootstrapAllGather, ce dernier utilisant en mode socketsocketRingAllGather— un algorithme de ring allgather bidirectionnel, où N ranks ne nécessitent que N/2 étapes :
📎 src/bootstrap.cc:1363-1412
Cet algorithme bidirectionnel est l'optimisation clé des performances du bootstrap. Le ring allgather unidirectionnel traditionnel nécessite N-1 étapes, la version bidirectionnelle réduit ce nombre de moitié. Chaque étape envoie et reçoit simultanément dans les deux directions, en utilisantsocketDoubleSendRecvpour regrouper 4 opérations (2 envois, 2 réceptions) en un seul appel système.
Contrôle de concurrence et interactions bas niveau
Le contrôle de concurrence du Bootstrap comporte plusieurs niveaux :
Premier niveau : vérification d'abort.Toutes les boucles bloquantes vérifient périodiquement abortFlag :
📎 src/bootstrap.cc:150-159
BOOTSTRAP_N_CHECK_ABORTest fixé à 10000, ce qui signifie qu'on vérifie le drapeau abort toutes les 10000 itérations. Ce nombre est un compromis entre performance et réactivité — vérifier trop fréquemment nuit aux performances, vérifier trop rarement retarde la réponse à abort.
Deuxième niveau : file d'attente d'envoi asynchrone.En mode chiffrement TLS,bootstrapSendne peut pas être exécuté de manière synchrone (car la poignée de main TLS nécessite la participation du récepteur), donc NCCL place les opérations d'envoi dans un thread séparé :
📎 src/bootstrap.cc:1161-1217
Il y a ici un mécanisme subtil de garantie d'ordre.bootstrapAsyncSendMainAvant l'envoi, on vérifie si la file contient un « envoi antérieur vers le même (peer, tag) » :
📎 src/bootstrap.cc:1124-1152
Pourquoi faut-il garantir l'ordre d'envoi pour un même (peer, tag) ? Les commentaires du code source l'expliquent clairement : le récepteur fait correspondre les connexions par (peer, tag), et si deux messages destinés au même (peer, tag) arrivent dans le désordre, le récepteur les fera correspondre incorrectement. Pendant l'initialisation de NVLS, plusieurs diffusions sont effectuées vers le même peer avec le même tag, donc cette garantie d'ordre est indispensable.
Troisième couche : la file des connexions inattendues.Le récepteur ne peut pas prédire qui se connectera en premier, doncsocketAcceptil stocke les connexions non correspondantes dans uneunexpectedConnectionsliste chaînée :
📎 src/bootstrap.cc:1276-1300
Cette conception résout un problème distribué classique : plusieurs ranks peuvent initier simultanément une connexion vers toi, mais tonbootstrapRecvordre d'appel est fixe. Si les connexions non correspondantes étaient simplement rejetées, l'émetteur expirerait ; si l'on bloquait en attendant, un interblocage pourrait survenir. Les stocker dans une file est l'approche la plus sûre.
Guide pour éviter les pièges en production
Piège n°1 : un timeout de bootstrap provoque un blocage de l'initialisation.Si un rank ne peut pas se connecter au root à cause d'un problème réseau, tous les autres ranks attendront indéfiniment surncclSocketAcceptouncclSocketRecv. NCCL n'a pas de mécanisme de timeout de bootstrap intégré ; la seule voie de secours est abortFlag. En production, il est recommandé de définirNCCL_UID_STAGGER_RATEpour atténuer les tempêtes de connexions dans les clusters à grande échelle.
Piège n°2 :NCCL_COMM_IDconflit avec les handles multiples.Lorsque l'utilisateur définit la variable d'environnementNCCL_COMM_ID, NCCL force la réduction denIdà 1 :
📎 src/init.cc:2912-2921
Cela signifie que la fonctionnalité multi-handle dencclCommInitRankScalableest silencieusement désactivée. Si tu utilises l'initialisation scalable tout en définissantNCCL_COMM_ID, le comportement sera différent de ce que tu attends.
Piège n°3 : interblocage en mode TLS.En mode chiffrement TLS, si connect et accept ne s'exécutent pas en parallèle, les deux parties resteront bloquées lors de la poignée de main TLS.bootstrapConcurrentC'est précisément pour résoudre ce problème :
📎 src/bootstrap.cc:648-669
En mode non chiffré, exécution séquentielle (send puis recv) ; en mode chiffré, un thread est lancé pour gérer send, et le thread principal gère 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: 收集所有 rank 的监听地址
Root-->>R0: rootSend(rank2.addr) 下一个邻居
Root-->>R1: rootSend(rank0.addr) 下一个邻居
Root-->>R2: rootSend(rank1.addr) 下一个邻居
R0->>R1: socketRingConnect(connect to next)
R1->>R2: socketRingConnect(connect to next)
R2->>R0: socketRingConnect(connect to next)
Note over R0,R2: Ring 建立完成
R0->>R1: socketRingAllGather 双向交换
R1->>R2: socketRingAllGather 双向交换
R2->>R0: socketRingAllGather 双向交换
Note over R0,R2: 所有地址交换完成3.3 commAlloc : le squelette mémoire de l'objet de domaine de communication
Modèle intuitif
commAllocest la « livraison brute » du domaine de communication — il alloue la mémoire de la structure, initialise tous les champs à des valeurs par défaut sûres, crée les objets CUDA et les primitives de synchronisation nécessaires, mais n'a pas encore rempli les informations de topologie, la configuration des canaux, les connexions de transport et autres contenus de « finition ». Si l'on comparencclCommà un immeuble,commAllocc'est le terrassement et le coulage de la structure,initTransportsRankc'est l'aménagement intérieur.
Sans l'initialisation decommAlloc, les accès ultérieurs du code à des champs non initialisés entraîneraient des comportements imprévisibles — par exemple, sicomm->channels[c].idavait une valeur aléatoire, la logique d'initialisation des canaux interpréterait mal l'état des canaux.
Structures de données et disposition mémoire
commAllocLa signature et la vérification initiale de
📎 src/init.cc:512-526
Il vérifie d'abord la validité dendevet derank, puis construit deux piles mémoire (memPermanentetmemScoped), définitranketnRanks. Ces deux piles mémoire constituent l'infrastructure de gestion mémoire de NCCL —memPermanentest utilisé pour les allocations dont le cycle de vie est identique à celui du domaine de communication,memScopedest utilisé pour les allocations temporaires.
Vient ensuite la détection du périphérique CUDA :
📎 src/init.cc:528-531
cudaGetDevicerécupère le numéro du périphérique courant,ncclCudaCompCaprécupère la capacité de calcul. Le commentaire du code source est très direct : « Try to create a CUDA object right away. If there is something wrong with the device we're on, better know it early. » — exposer les problèmes de périphérique le plus tôt possible, pour éviter de ne les découvrir qu'à la fin de l'initialisation.
Puis l'allocation ou l'héritage des ressources partagées :
📎 src/init.cc:533-555
Il y a ici une branche importante : siparent == NULL || !parent->shareResources, on crée un nouveauncclSharedResources; sinon, on hérite des ressources partagées du domaine de communication parent et on incrémente le compteur de références.ncclSharedResourcescontient les flux de périphérique, les flux hôtes, les événements de lancement, les événements scratch, etc. — ces ressources peuvent être réutilisées par les sous-domaines de communication dans les scénarios de split, évitant ainsi les créations répétées.
Notons la lignesharedRes->refCount = 1— le compteur de références initial est 1, il est incrémenté à chaque partage par split, et la destruction réelle n'a lieu que lorsque la dernière référence est libérée.
Vient ensuite l'initialisation du réseau, de RMA et de GIN :
📎 src/init.cc:547-549
Ces trois sous-systèmes sont respectivement responsables du transport réseau, de l'accès mémoire distant et de la communication réseau initiée par le GPU. Leur ordre d'initialisation est important —ncclNetInitdoit précéderncclRmaInit, car RMA dépend du plugin réseau.
L'initialisation du gestionnaire mémoire :
📎 src/init.cc:567-576
Là encore, il y a deux chemins : partagé ou nouvellement créé.ncclMemManagerest responsable de la gestion du pool mémoire CUDA et du cache d'enregistrement.
Marqueur d'initialisation des canaux :
📎 src/init.cc:607-608
Cette ligne définit leidde tous les canaux à -1, indiquant « non initialisé ». Ensuite,setupChannelvérifiera cette valeur pour décider si une initialisation est nécessaire.
Construction des files d'interruption :
📎 src/init.cc:619-632
NCCL utilise des files intrusives (intrusive queue) pour gérer diverses tâches. Ces files sont toutes construites vides lors de la phasecommAlloc, et sont utilisées directement lors de l'ajout ultérieur de tâches.
Création du pool mémoire CUDA :
📎 src/init.cc:636-652
Si le périphérique prend en charge les pools mémoire (cudaDevAttrMemoryPoolsSupported), on crée un pool mémoire de type pinned et on définit le seuil de libération à la valeur maximale (~uint64_t(0)), ce qui signifie « ne jamais libérer automatiquement ». Cela évite que le runtime CUDA récupère de la mémoire à l'insu de NCCL.
Step-by-Step Walkthrough
Suivons un scénario d'initialisation concret : une machine avec 8 GPU, un rank par processus, initialisation normale.
1. commAlloc(comm, NULL, 8, rank)est appelé,parent == NULL。
2. La vérification passe,comm->rank = rank,comm->nRanks = 8。
3. cudaGetDevicerenvoie le numéro du périphérique courant,comm->compCapest défini.
4. Création d'un nouveauncclSharedResources, avec un compteur de références de 1.
5. ncclNetInitInitialiser le plugin réseau (peut être Socket ou IB).
6. ncclMemManagerInitCréer le gestionnaire de mémoire.
7. getBusIdObtenir l'ID du bus PCI,ncclNvmlDeviceGetHandleByPciBusIdObtenir le handle NVML.
8. dmaBufSupportedDétecter le support DMA-BUF.
9. AllouerconnectSend / connectRecvle tableau de bits.
10. Tous les canauxiddéfinis à -1.
11. Construire toutes les files d'interruption.
12. Créer le pool de mémoire CUDA.
Réflexions sur la conception
commAllocLe design le plus intéressant dans est le principe « échouer tôt ». Il appellecudaGetDeviceau début de la fonction, plutôt que d'attendre plus tard lorsqu'il a besoin des informations du périphérique. L'avantage est que si le périphérique a un problème (par exemple, s'il est monopolisé par un autre processus), l'erreur est exposée dès le début de l'initialisation, plutôt que d'être découverte après avoir alloué une grande quantité de mémoire.
Une autre conception estpreconnectNextl'initialisation de :
📎 src/init.cc:598-598
reinterpret_cast<struct ncclComm*>(0x1)est une valeur sentinelle utilisée pour marquer l'état de la « prochaine pré-connexion ». Cette technique d'utiliser une valeur de pointeur invalide comme marqueur d'état est courante en programmation système — elle économise plus de mémoire qu'un champ booléen supplémentaire, mais il faut faire attention à ne pas la déréférencer.
3.4 initTransportsRank : découverte de topologie et allocation de canaux
Modèle intuitif
initTransportsRankest le « cœur » de l'initialisation. Il accomplit trois tâches majeures : échanger les informations de périphérique et de topologie de tous les ranks via deux AllGather ; calculer les structures de graphe des algorithmes ring/tree/collnet/nvls en fonction de ces informations ; enfin, établir toutes les connexions de transport. Si l'on compare le domaine de communication à un système de transport urbain,initTransportsRankest le processus de planification de toutes les routes, échangeurs et lignes de bus.
Sans cette étape, NCCL ne saurait pas par quel chemin les données doivent passer — il pourrait faire prendre un détour aux données, ou ne pas trouver de chemin accessible du tout.
Structures de données et disposition mémoire
initTransportsRanka de très nombreuses variables locales, examinons les principales :
📎 src/init.cc:1163-1179
Ici, on extraitcomm->graphsles différentes structures de graphe du tableau pour créer des alias.graphsLe tableau est indexé par algorithme, noter quenvlsGraphest utilisé deux fois (NVLS et NVLSTree partagent la même structure de graphe).
Deux structures temporaires clés :
📎 src/init.cc:1181-1206
graphInfoconserve les informations de graphe d'un seul rank pour un algorithme donné (nombre de canaux, bande passante, type, etc.),allGatherInfoest l'unité de données de l'AllGather, contenant les informations de graphe de tous les algorithmes plus les informations de rank de topologie.
Step-by-Step Walkthrough
Phase un : AllGather1 — échange d'informations sur les périphériques.
📎 src/init.cc:1234-1239
Chaque rank appellefillInfopour remplir son proprencclPeerInfo, puis échange viabootstrapAllGather.fillInfoLes informations remplies incluent : numéro de rank, numéro de périphérique CUDA, numéro de périphérique NVML, version de NCCL, hash git, hash d'hôte, hash de processus, UUID GPU, ID de bus, taille de mémoire vidéo, version de pilote, etc.
📎 src/init.cc:888-982
Noterinfo->hostHash = getHostHash() + commHashetinfo->pidHash = getPidHash() + commHash— le hash d'hôte et le hash de pid ont tous deux le commHash ajouté. C'est pour distinguer différents domaines de communication sur la même machine.
Une fois l'AllGather terminé, chaque rank parcourt les informations de tous les pairs et calcule les attributs globaux :
📎 src/init.cc:1250-1303
Cette boucle fait beaucoup de choses : détecter les incompatibilités de version, compter le nombre de nœuds, calculercuMemSupportl'intersection de , détecter si plusieurs ranks utilisent le même GPU, calculer l'intersection des masques de type GIN, etc. NoternNodesla méthode de comptage de — il incrémente à chaque fois qu'un hostHash différent est rencontré, ce qui suppose que les ranks sont disposés de manière contiguë par nœud.
Phase deux : découverte de topologie.
📎 src/init.cc:1390-1403
Ces six étapes constituent le processus central de la découverte de topologie :ncclTopoGetSysteménumère les périphériques système pour construire le graphe de topologie,ncclTopoComputePathscalcule les chemins GPU vers NIC,ncclTopoTrimSystemsupprime les périphériques inaccessibles, recalcule les chemins,ncclTopoSearchInitinitialise l'état de recherche, et enfin imprime la topologie.
Phase trois : calcul des graphes.
📎 src/init.cc:1421-1468
Calculer séquentiellement les cinq graphes : ring, tree, collnet chain, collnet direct, nvls. Chaque graphe a des contraintes différentes de pattern et de nombre de canaux. NotertreeGraph->minChannels = ringGraph->nChannels— le nombre de canaux de tree est contraint d'être identique à celui de ring, afin d'assurer l'alignement des canaux entre les différents algorithmes.
Phase quatre : AllGather3 — échange d'informations de graphe.
📎 src/init.cc:1490-1533
Chaque rank remplit ses informations de graphe dansallGather3Data[rank], puis effectue à nouveaubootstrapAllGather. Les informations échangées cette fois incluent : pattern/nChannels/bwIntra/bwInter/typeIntra/typeInter/crossNic pour chaque algorithme, architecture CPU, nombre de canaux P2P, nombre de périphériques réseau, nombre de périphériques CollNet, etc.
Une fois l'AllGather3 terminé, chaque rank parcourt les informations de graphe de tous les pairs et prend les valeurs minimales/maximales pour aligner :
📎 src/init.cc:1687-1703
Noter la stratégie d'alignement ici :nChannels、sameChannels、bwIntra、bwInterprendre le minimum,typeIntra、typeInter、crossNicprendre le maximum. Pourquoi ? Parce que le nombre de canaux et la bande passante sont limités par le lien le plus faible, tandis que le type et crossNic doivent être unis pour garantir la compatibilité.
Phase cinq : établissement des connexions de transport.
📎 src/init.cc:1811-1892
Il y a deux branches ici :runtimeConnlorsque vrai, on ne fait que la configuration des canaux sans établir les connexions (connexion différée à l'exécution), sinon on établit immédiatement toutes les connexions. L'ordre de connexion est : ring → tree → NVLS → PAT → NVLS tree → CollNet.
Contrôle de concurrence et interaction matérielle
initTransportsRankIl y a plusieurs points notables de concurrence/interaction matérielle dans :
Configuration de l'affinité CPU :
📎 src/init.cc:1406-1412
NCCL lie le thread actuel à un cœur CPU proche du GPU, garantissant que l'allocation de mémoire hôte se fait sur le nœud NUMA local. Cela réduit la latence des accès inter-NUMA.
Initialisation NVLS :
📎 src/init.cc:1419-1419
ncclNvlsInitDétection du support NVLink SHARP. NVLS permet au commutateur d'exécuter directement les opérations reduce, réduisant considérablement la latence d'AllReduce.
Création du thread Proxy :
📎 src/init.cc:1780-1786
Le thread Proxy est responsable de la progression asynchrone des E/S réseau. Il est créé dansinitTransportsRank, et par la suite toutes les opérations réseau passent par le proxy.
Guide de production pour éviter les pièges
Piège 1 : Nombre d'interfaces réseau incompatible.Si le nombre de cartes réseau locales diffère entre les ranks, NCCL renvoie une erreur :
📎 src/init.cc:1576-1596
Sauf si l'on définitNCCL_IGNORE_NET_MISMATCH=1. C'est courant dans les clusters hétérogènes — certains nœuds ont 8 cartes réseau, d'autres seulement 4. Ignorer cette incompatibilité peut entraîner une baisse de performance, car le nombre de canaux sera limité par le nœud le plus faible.
Piège 2 : Plusieurs ranks partagent le même GPU.Si deux ranks ont le même UUID de GPU, NCCL refuse l'initialisation :
📎 src/init.cc:1291-1296
Sauf si l'on définitNCCL_MULTI_RANK_GPU_ENABLE=1. Cette vérification empêche les problèmes de performance causés par une mauvaise configuration de l'utilisateur.
Piège 3 : Nombre de nœuds CollNet insuffisant.CollNet nécessite au moinsNCCL_COLLNET_NODE_THRESHOLDnœuds pour être activé :
📎 src/init.cc:1720-1728
Le seuil par défaut est 2. En environnement mono-nœud, CollNet est automatiquement désactivé.
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 : la magie à la compilation du système de variables d'environnement
Modèle intuitif
NCCL_PARAMest l'« usine à commutateurs de configuration » de NCCL. Il utilise une macro pour générer une fonction à la compilation, qui lit la variable d'environnement lors du premier appel à l'exécution et met le résultat en cache. C'est comme un interrupteur d'éclairage domestique — vous actionnez le bouton (appel de la fonction), la lumière s'allume (retour de la valeur de configuration), puis l'état de l'interrupteur est mémorisé, sans avoir à le réactiver à chaque fois.
Sans ce mécanisme, NCCL devrait appeler manuellementgetenvet analyser la chaîne à chaque endroit utilisant la configuration, rendant le code extrêmement verbeux et sujet aux erreurs.
Structures de données et disposition mémoire
NCCL_PARAMDéfinition de la macro :
📎 src/include/param.h:22-31
Cette macro génère après expansion une fonctionncclParam##name(), contenant trois variables statiques :
uninitialized = INT64_MIN: valeur sentinelle, indiquant « pas encore initialisé ».noCache: indicateur à trois états, -1 signifie non initialisé, 0 signifie mis en cache, 1 signifie non mis en cache.cache: la valeur mise en cache, initialementuninitialized。
La logique de la fonction est : sicacheest encoreuninitialized, appelerncclLoadParampour charger ; sinon retourner directementcache。COMPILER_EXPECT(..., false)indique au compilateur que cette branche est rarement empruntée, optimisant le chemin chaud.
ncclLoadParamImplémentation de :
📎 src/misc/param.cc:78-108
Il utilise un mutex pour protéger tout le processus de chargement, vérifie d'abord la politiquenoCache, puis vérifie si le cache est valide, ensuite lit la variable d'environnement et l'analyse. En cas d'échec d'analyse, la valeur par défaut est utilisée et un avertissement est affiché.
Step-by-Step Walkthrough
PrenonsNCCL_PARAM(BuffSize, "BUFFSIZE", -2)comme exemple :
📎 src/init.cc:1007-1007
Après expansion de la macro, cela génère :
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;
}Lors du premier appel,cache == uninitialized, entre dansncclLoadParam. Il lit la variable d'environnementNCCL_BUFFSIZE, et si elle n'est pas définie, retourne la valeur par défaut -2. Puis, selon la politiquenoCache, décide s'il faut mettre en cache.
noCacheLa politique est déterminée parncclParamIsCacheDisabled:
📎 src/misc/param.cc:74-76
Si le nom de la variable d'environnement correspond à un certain motif (par exemple se terminant par_), pas de mise en cache, relecture à chaque fois. Cela permet à l'utilisateur de modifier dynamiquement certaines configurations à l'exécution.
Réflexions sur la conception
L'élégance de cette conception réside dans l'« abstraction à coût nul » : sur le chemin chaud, il n'y a qu'un chargement atomique et une comparaison, sans verrou ni analyse de chaîne. Le chemin froid (premier chargement) paie le coût complet.COMPILER_EXPECTindique au compilateur de placer le chemin chaud au début du cache d'instructions, améliorant encore les performances.
Une autre conception est le triple état denoCache. -1 signifie « pas encore décidé », 0 signifie « mis en cache », 1 signifie « non mis en cache ». Cette décision n'est prise qu'une seule fois lors du premier chargement, puis ne change plus.
Guide de production pour éviter les pièges
Piège 1 : Faute de frappe dans la variable d'environnement.Si l'utilisateur écritNCCL_BUFSIZEau lieu deNCCL_BUFFSIZE, NCCL ne signale pas d'erreur et utilise simplement la valeur par défaut. Il est recommandé d'utiliserNCCL_DEBUG=ENVpour afficher toutes les variables d'environnement reconnues.
Piège 2 : Ordre de chargement deNCCL_CONF_FILE.NCCL charge successivement$NCCL_CONF_FILE(ou~/.nccl.conf) et/etc/nccl.conf:
📎 src/misc/param.cc:52-67
Les fichiers chargés plus tard écrasent ceux chargés plus tôt. Si les deux fichiers définissent la même variable,/etc/nccl.confla valeur de
prend effet.noCachePiège 3 : Sécurité des threads de la variable. Le commentaire du code source dit « noCache is only load/stored within the mutex, no need for atomic » :
📎 src/misc/param.cc:74-76
Cela signifie que la lecture et l'écriture denoCachesont protégées par le mutex, sans besoin d'opérations atomiques. Mais la lecture decacheest sans verrou (chemin chaud), donc un chargement atomique est utilisé.
3.6 devCommSetup : mapper le domaine de communication sur le périphérique
Modèle intuitif
devCommSetupest la « projection côté périphérique » du domaine de communication. Les kernels GPU s'exécutent sur le périphérique et ne peuvent pas accéder directement à la structurencclCommen mémoire hôte. NCCL doit donc copier les champs clés du domaine de communication dans une mémoire accessible au périphérique, formantncclDevComm. C'est comme photocopier l'annuaire de l'entreprise et le placer sur le poste de chaque employé — les employés n'ont plus besoin d'aller à l'accueil demander le numéro de leurs collègues à chaque fois.
SansdevCommSetup, le kernel GPU ne peut pas connaître son rank, la configuration des canaux, la taille des buffers, etc., et le kernel de communication collective ne peut tout simplement pas démarrer.
Structures de données et disposition mémoire
devCommSetuputilise une structure temporairencclKernelCommAndChannelspour empaqueter les données à copier vers le périphérique :
📎 src/init.cc:712-746
Cette structure contientncclDevComm(domaine de communication côté périphérique) et le tableau de canaux. La fonction remplit d'abord la structure temporaire avec les données côté hôte, puis effectue une copiecudaMemcpyAsyncvers le périphérique en une seule fois.
Remplissage des champs clés :
📎 src/init.cc:734-746
Notercomm->devComm = &devCommAndChans->comm— lecomm->devCommcôté hôte pointe versncclDevCommen mémoire du périphérique. Lors du lancement ultérieur du kernel,comm->devCommsera passé en paramètre.
Remplissage des informations de canal :
📎 src/init.cc:829-843
Les pointeurs peers, ring, tree, collnetChain, collnetDirect, nvls de chaque canal sont copiés côté device. Attentionring.userRanksnécessite unecudaMemcpyAsyncsupplémentaire, car il s'agit d'un tableau.
Step-by-Step Walkthrough
1. Obtention du flux device :ncclStrongStreamAcquireObtention d'un strong stream, garantissant l'exécution ordonnée des copies asynchrones suivantes.
2. Allocation de la mémoire device :ncclCudaCallocAsyncAllocation dedevCommAndChans。
3. Remplissage de la structure temporaire côté hôte : définition de rank, nRanks, node, nNodes, abortFlag, buffSizes, etc.
4. Allocation et copie du tableaurankToLocalRank.
5. Calcul deworkFifoBytes: déterminé selon l'état CC (Confidential Computing).
6. Allocation du buffer workFifo : en mode GDR, utiliserncclGdrCudaCalloc, sinon utiliserncclCudaHostCalloc。
7. Allocation des compteurs du profiler.
8. Allocation des compteurs de progression (si activés).
9. Remplissage des informations de canal.
10. Copie unique vers le device :ncclCudaMemcpyAsync(devCommAndChans, &tmpCommAndChans, 1, deviceStream)。
11. Libération du strong stream et synchronisation.
Réflexions de conception
devCommSetupLe design le plus remarquable danscudaMemcpyest la « copie par lots ». NCCL n'appelle pascudaMemcpyAsyncséparément pour chaque champ, mais empaquette tous les champs dans une structure temporaire, réalisant l'opération en un seul
. Cela réduit considérablement le nombre d'appels à l'API CUDA et les surcoûts de synchronisation.workFifoBytesUn autre design est la gestion CC de
📎 src/init.cc:750-763
: en mode CC (Confidential Computing),workFifoBytesest mis à 0, car la copie GDR n'est pas disponible en mode CC. Il s'agit d'une dégradation élégante face à une limitation matérielle.
Guide de production pour éviter les pièges
Piège 1 :devCommSetupdoit être appelé avant la barrier.Les commentaires du code source expliquent la raison :
📎 src/init.cc:1950-1952
S'il est appelé après la barrier, certains threads peuvent avoir déjà commencé à lancer le kernel NCCL, alors que la mémoire device n'est pas encore entièrement allouée, ce qui provoque un deadlock.
Piège 2 :workFifoBytesdoit être une puissance de 2.Sinon, NCCL émet un avertissement et utilise la valeur par défaut :
📎 src/init.cc:757-762
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la logique de détection « plusieurs ranks utilisent le même GPU » dans📎 src/init.cc:1291-1296, dans quels scénarios cela poserait-il problème ? Pourquoi NCCL refuse-t-il cette configuration par défaut ?
Analyse de référence:
Ce code détecte si les UUID GPU de deux ranks sur le même hôte sont identiques. S'ils sont identiques et queNCCL_MULTI_RANK_GPU_ENABLE=0(par défaut), il retournencclInvalidUsage。
Après suppression de cette vérification, plusieurs ranks partageraient le même GPU. Cela entraînerait :
1. Conflits de transfert P2P: le transfert P2P de NCCL suppose que chaque rank dispose exclusivement d'un GPU. Si deux ranks partagent un GPU, ils écrivent simultanément dans le même buffer du même GPU, provoquant des races de données et des résultats erronés.
2. Conflits d'allocation de canaux:comm->channelsLes ressources de canal (buffers, FIFO) dans
3. sont allouées par rank. Les ranks partageant un GPU se disputeraient les mêmes ressources.Catastrophe de performance
: même sans problème de correction, deux ranks partageant la puissance de calcul et la bande passante mémoire d'un seul GPU subiraient une chute drastique de performance.NCCL_MULTI_RANK_GPU_ENABLE=1NCCL refuse cette configuration par défaut pour « échouer rapidement » — plutôt que de laisser l'utilisateur perdre des heures à déboguer une mauvaise configuration, il vaut mieux signaler clairement l'erreur dès l'initialisation.
est une porte de sortie destinée aux utilisateurs qui savent précisément ce qu'ils font (par exemple dans un scénario MPS).📎 src/bootstrap.cc:1129-1134Q2 : Si l'on supprime la logique d'attente « d'un envoi antérieur vers le même (peer, tag) » dans
, dans quels scénarios le récepteur ferait-il une correspondance erronée ?:
Analyse de référence
Ce code attend dans le thread d'envoi asynchrone jusqu'à ce qu'il n'y ait plus d'envoi antérieur vers le même (peer, tag) dans la file.socketAcceptAprès suppression de cette attente, deux envois vers le même (peer, tag) pourraient s'exécuter concurremment, avec un ordre d'arrivée indéterminé chez le récepteur. Le
📎 src/bootstrap.cc:1291-1292
du récepteur fait correspondre les connexions par (peer, tag) :bootstrapSendSi l'émetteur A appelle
en premier mais arrive en dernier, et que l'émetteur B appelle en dernier mais arrive en premier, le récepteur prendra le message de B pour la réponse de A. Cela provoque un décalage des données — le récepteur croit recevoir la réponse à la première requête, alors qu'il s'agit de la seconde.
Les commentaires du code source indiquent explicitement ce scénario : « NVLS setup broadcasts to the same peers with the same tag several times during init ». Pendant l'initialisation NVLS, des diffusions multiples vers le même peer avec le même tag ont lieu ; si l'ordre est inversé, la configuration NVLS serait complètement désordonnée.
Le coût de cette garantie d'ordre est la sérialisation des envois vers le même (peer, tag). Mais les envois vers des (peer, tag) différents restent concurrents, donc le débit global n'est pas affecté.📎 src/init.cc:1691-1697Q3 : Si l'on modifie la stratégie d'alignement dans
de « min pour nChannels, max pour typeIntra » à « tout en min » ou « tout en max », quels problèmes cela poserait-il respectivement ?:
Analyse de référencenChannels、sameChannels、bwIntra、bwInterLa stratégie actuelle est :typeIntra、typeInter、crossNicprendre le min,
prendre le max.:typeIntraSi tout est en mintypeInterPrendre le min entraînerait une dégradation du type de transfert pour certains ranks. Par exemple, si le rank A prend en charge P2P (typeIntra=P2P) et que le rank B ne prend en charge que SHM (typeIntra=SHM), après avoir pris le min, tous les ranks utiliseraient SHM. Mais la valeur d'énumération de SHM peut être inférieure à celle de P2P, et prendre le min sélectionnerait un type incorrect. En réalitétypeIntraest un masque de bits ou une énumération, et prendre le max sert à sélectionner le type « le plus capable ».
Si l'on prend le max partout:nChannelsPrendre le max entraînerait l'attribution à certains ranks d'un nombre de canaux dépassant leurs capacités. Par exemple, si le rank A ne peut prendre en charge que 4 canaux et que le rank B en prend en charge 8, après avoir pris le max, tous les ranks tenteraient d'utiliser 8 canaux, et le rank A échouerait ou subirait une baisse de performance.bwIntraPrendre le max rendrait l'estimation de bande passante trop optimiste, et le module de tuning pourrait choisir un algorithme inadapté.
L'essence de cette stratégie d'alignement est :Les contraintes de ressources prennent l'intersection (min), les énumérations de capacités prennent l'union (max). Le nombre de canaux et la bande passante sont des contraintes de « limite supérieure », il faut donc prendre la valeur la plus conservatrice ; le type de transfert est une énumération de « capacités », prendre la valeur maximale garantit que tous les ranks peuvent trouver un mode de transfert compatible.
Dans le chapitre suivant, nous approfondirons la découverte de topologie et la recherche de graphes, pour voir comment NCCL énumère les GPU, les cartes réseau et les commutateurs PCI d'une machine, construit une carte topologique complète, et recherche sur cette carte les structures ring et tree optimales. La communication bootstrap, le squelette mémoire commAlloc et le flux principal initTransportsRank établis dans ce chapitre seront détaillés un par un dans le chapitre suivant en ce qui concerne leurs détails topologiques.
Jusqu'ici, nous avons parcouru de manière complète la chaîne d'appels de ncclCommInitRank, et vu clairement tout le processus de construction de l'objet ncclComm à partir de zéro. Mais il y a un maillon clé du processus d'initialisation que nous n'avons fait qu'effleurer : comment NCCL détecte-t-il les GPU et les cartes réseau à l'intérieur d'une machine, et décide-t-il en conséquence par quel chemin les données doivent passer ? C'est précisément le sujet que le chapitre suivant approfondira — la découverte de topologie et la recherche de graphes. Nous décomposerons comment src/graph/topo.cc énumère les dispositifs PCI/NVLink/cartes réseau et construit la carte topologique, comment src/graph/search.cc recherche le chemin optimal sur cette carte, et comment src/graph/rings.cc et trees.cc concrétisent les résultats de recherche en topologies d'algorithmes Ring et Tree. Une fois ce mécanisme compris, vous comprendrez pourquoi NCCL peut automatiquement sélectionner l'algorithme approprié sur différentes machines.
Chapitre 4 : Chapitre 4 : Découverte de topologie et recherche de graphes : comment NCCL « voit » l'interconnexion physique d'un système multi-GPU
Chapitre 4 : Découverte de topologie et recherche de graphes : comment NCCL « voit » l'interconnexion physique d'un système multi-GPU
Dans le chapitre précédent, nous avons suivi la chaîne d'appels de ncclCommInitRank en descendant couche par couche, et nous avons vu à quel moment le champ comm->topo est rempli, mais sans détailler sa structure interne. Alors, comment NCCL « voit »-il exactement les GPU et les cartes réseau d'une machine, et comment les organise-t-il en informations topologiques exploitables ? Ce chapitre décomposera les trois maillons clés de ce processus : topo.cc est chargé d'énumérer les dispositifs physiques sous forme de graphe, search.cc recherche le chemin optimal sur ce graphe, et rings.cc et trees.cc concrétisent les résultats de recherche en deux topologies d'algorithmes, Ring et Tree. Ce n'est qu'en comprenant la coopération de ces trois éléments que l'on peut saisir pourquoi NCCL peut automatiquement sélectionner l'algorithme approprié sur différentes machines.
La carte topologique : représenter la machine comme un « plan de métro »
Modèle intuitif
Imaginez que vous êtes un livreur venant d'arriver dans une ville inconnue. Vous devez livrer un colis du point A au point B, mais vous ne savez pas quel chemin est le plus rapide. Vous avez besoin d'une carte — sur laquelle sont indiquées toutes les stations (GPU, cartes réseau, CPU, commutateurs PCI) ainsi que les connexions entre les stations (NVLink, PCIe, réseau). La carte topologique de NCCL est cette carte.
Sans cette carte, NCCL ne pourrait que supposer aveuglément que « la bande passante est identique entre tous les GPU », ce qui pourrait encore passer sur une machine à 8 cartes entièrement interconnectées en NVLink, mais dès qu'on rencontre une topologie complexe avec NUMA croisé, commutateurs PCI croisés, ou un mélange NVLink + PCIe, il choisirait le mauvais chemin, en poussant dans le PCIe lent des données qui devraient passer par NVLink, ce qui diviserait directement les performances par deux.
Structures de données et disposition mémoire
Le cœur de la carte topologique estncclTopoSystem, qui stocke tous les dispositifs groupés par type de nœud. Les types de nœuds sont définis dans le tableautopoNodeTypeStr:
📎 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"};Ces trois tableaux définissent respectivement les représentations sous forme de chaînes des types de nœuds, des types de liens et des types de chemins. Notez l'ordre detopoPathTypeStr— il sert également de classement de la qualité des chemins : plus l'indice est petit, plus le chemin est rapide.LOC(local) le plus rapide,DIS(déconnecté) le plus lent. Cet ordre sera utilisé à plusieurs reprises dans les recherches ultérieures pour comparer la qualité des chemins.
Chaque nœud est représenté parncclTopoNodeet est initialisé avec des champs différents selon son type lors de la création. Prenons l'exemple d'un nœud 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) {
...Voici quelques points de conception clés. Premièrement, les nœuds sont stockés dans un tableau préalloué (system->nodes[type].nodes), et non dans une liste chaînée. Cela signifie que les nœuds sont disposés de manière contiguë en mémoire, ce qui est favorable au cache lors du parcours. Deuxièmement,NCCL_TOPO_MAX_NODESest une limite stricte, au-delà de laquelle une erreur est signalée — cela vise à empêcher une croissance illimitée en cas d'anomalie topologique. Troisièmement, chaque nœud possède un champid, qui est un entier de 64 bits, dont les 32 bits supérieurs correspondent au systemId (identifiant l'hôte) et les 32 bits inférieurs au localId (numéro du périphérique au sein de l'hôte).
Les connexions entre les nœuds sont représentées parncclTopoLink.ncclTopoConnectNodesest chargé d'établir des connexions bidirectionnelles :
📎 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;
}Cette fonction effectue trois choses. Premièrement, elle vérifie s'il existe déjà un lien vers la même cible et du même type — si c'est le cas, elle accumule la bande passante (link->bw += bw). Cela gère le cas où plusieurs NVLink sont connectés au même GPU : 4 NVLink à 25 GB/s chacun, soit 100 GB/s après agrégation. Deuxièmement, si aucun lien n'est trouvé, elle en ajoute un nouveau. Troisièmement, après insertion, les liens sont triés par bande passante décroissante, de sorte que les liens à haute bande passante soient vus en priorité lors des parcours ultérieurs.
La motivation du tri par bande passante décroissante est de permettre à l'algorithme de recherche de découvrir les chemins à haute bande passante le plus tôt possible, afin de converger plus rapidement vers une meilleure solution. La recherche est soumise à une limite de temps (nous verrons plus loinNCCL_SEARCH_TIMEOUT), et le tri permet de consacrer le budget de temps limité aux chemins les plus prometteurs.
Parcours pas à pas guidé par un scénario
Plaçons-nous maintenant dans un scénario concret : un serveur 8 GPU A100, chaque carte étant entièrement interconnectée via NVLink, avec en outre 4 cartes réseau Mellanox ConnectX-6 installées dans des emplacements PCIe. Lors de l'initialisation de NCCL,ncclTopoGetSystemest appelé ; il lit les informations des périphériques depuis un fichier XML (généré parnvidia-topologydou par NCCL lui-même), puis construit le graphe topologique.
Première étape : analyser le nœud CPU.ncclTopoAddCpulit depuis le XML l'architecture, le fabricant et le modèle du CPU, puis crée le nœud 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;
}Le nœud CPU est la racine de l'arbre topologique. Chaque CPU porte en dessous de lui un sous-arbre PCI et des nœuds NIC.ncclTopoAddPcitraite récursivement l'arbre PCI, créant un nœud GPU lorsqu'il rencontre un GPU et un nœud NIC lorsqu'il rencontre une NIC.
Deuxième étape : ajouter les connexions NVLink. Notons quencclTopoAddGpune lit que les propriétés de base du GPU, et le commentaire indique explicitement « 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;
}Pourquoi procéder en deux passes ? Parce que NVLink est une connexion entre GPU, et il faut que les nœuds GPU des deux extrémités existent déjà pour établir le lien. La première passe crée tous les nœuds, la seconde passencclTopoAddNvLinksles connecte ensuite.
Troisième étape : traiter les périphériques réseau.ncclTopoAddNicparcourt les sous-nœuds net/gin/rma sous la NIC et appelle respectivement les fonctions d'ajout correspondantes. Prenons l'exemple dencclTopoAddNet:
📎 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;
}Notons la conversionmbps / 8000.0: mbps désigne les mégabits par seconde ; en divisant par 8000, on obtient des GB/s (car 1 GB/s = 8000 Mbps). Si la carte réseau rapporte speed = -1 (ce qui arrive avec certaines cartes réseau virtuelles), on utilise par défaut 10000 Mbps = 1,25 GB/s.
Quatrième étape : traitement de finalisation.ncclTopoGetSystemFromXmlAprès avoir ajouté tous les nœuds et liens,
📎 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));ncclTopoFlattenBcmSwitchestraite le cas particulier des commutateurs PCIe Broadcom Gen4 — ils se présentent comme des commutateurs à deux niveaux, mais disposent en réalité de la bande passante complète, et il faut les « aplatir » pour éviter d'induire en erreur l'algorithme de recherche.ncclTopoConnectCpusconnecte tous les nœuds CPU entre eux (les accès inter-NUMA passent par des liens SYS).ncclTopoSortSystemtrie les liens de manière à placer les liens descendants PCI en premier, ce qui facilite le parcours.
Réflexions de conception et pièges en production
Pourquoi utiliser XML comme format intermédiaire ?Parce que la découverte de topologie doit être partagée entre processus — chaque rank ne sonde que les GPU qu'il gère, puis échange les XML via le bootstrap, et enfin les fusionne en une topologie complète. XML est un format texte auto-descriptif, pratique pour le débogage (on peut le dumper pour l'examiner) et pour la compatibilité de version.
Piège n° 1 :ncclTopoGetNodene signale pas d'erreur lorsqu'il ne trouve pas de nœud.Regardez cette fonction :
📎 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;
}S'il ne trouve rien, il renvoiencclSuccessmais*nodereste inchangé (l'appelant l'initialise généralement à NULL). L'appelant doit vérifier lui-même*node == NULL. Cette conception favorise les oublis de vérification — si l'appelant oublie de vérifier, un déréférencement ultérieur provoquera un crash.
Piège n° 2 :ncclTopoConnectNodesL'accumulation de bande passante peut provoquer un débordement.S'il existe un grand nombre de liens entre la même paire de nœuds (par exemple dans un scénario NVSwitch),link->bw += bwpeut s'accumuler jusqu'à une valeur très élevée. Bien que la précision d'un float soit suffisante, si le nombre de liens est anormalement élevé, la logique de tri peut poser problème.
Piège n° 3 :ncclTopoRemoveNodeLa correction des pointeurs dansLors de la suppression d'un nœud, tous les liens pointant vers le nœud supprimé doivent être retirés, et les pointeurs vers les nœuds situés après le nœud supprimé doivent être décalés vers l'avant :
📎 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--;
}
}
}
}
...Il y a ici une subtilité :node->links[l].remNode--corrige les pointeurs. Comme les nœuds sont stockés dans un tableau contigu, après la suppression d'un nœud, les adresses des nœuds suivants sont toutes décalées d'unsizeof(struct ncclTopoNode)vers l'avant. Par conséquent, tous les pointeurs vers les nœuds situés après le nœud supprimé doivent être décrémentés de un. Cette opération dansmemmoveExécuté auparavant, l'ordre est crucial.
Recherche de chemin : trouver la « route optimale » sur le graphe
Modèle intuitif
Avoir une carte ne suffit pas, il vous faut aussi un algorithme de navigation. La recherche de chemin de NCCL se divise en deux niveaux : le premier niveau est le prétraitement, qui calcule le plus court chemin entre toutes les paires de nœuds (BFS) ; le second niveau est la recherche sur graphe, qui essaie différentes structures Ring/Tree sur les résultats du prétraitement pour trouver celle avec la bande passante la plus élevée.
Sans recherche de chemin, NCCL ne pourrait que coder en dur des séquences fixes comme « GPU 0 connecté à GPU 1 connecté à GPU 2... », ce qui choisirait des chemins lents sur des topologies non uniformes.
Structures de données et disposition mémoire
La structure de données centrale de la recherche de chemin estncclTopoLinkList, qui stocke le chemin complet d'un nœud source à un nœud cible :
struct ncclTopoLinkList {
struct ncclTopoLink* list[NCCL_TOPO_MAX_HOPS]; // 路径上的链路
int count; // 跳数
float bw; // 瓶颈带宽
int type; // 路径类型(PATH_LOC, PATH_NVL, ...)
int capacity; // list 数组的容量
};Chaque nœud possède un tableaupaths[type]qui stocke les chemins vers tous les nœuds de ce type. Par exemple, lepaths[NET]d'un nœud GPU stocke les chemins vers toutes les cartes réseau.
Le calcul de chemin est effectué parncclTopoSetPaths, qui est un 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;
}Le BFS part debaseNodeet s'étend couche par couche. À chaque nouveau nœud atteint, il calcule la bande passante goulot du chemin (std::min(path->bw, link->bw)) et le type de chemin. Le calcul du type de chemin a quelques règles spéciales :
- Si le chemin passe par deux commutateurs PCI, le type est promu en
PATH_PXB - Si le chemin passe par le CPU, le type est promu en
PATH_PHB - Si le chemin passe par un nœud DEV et qu'il s'agit de NVLink, le type est promu en
PATH_NVB
La condition de mise à jour est « chemin meilleur » : type meilleur, ou type identique mais bande passante plus élevée, ou type et bande passante identiques mais moins de sauts.
Parcours pas à pas guidé par scénario
Examinons maintenant la recherche de second niveau.ncclTopoComputeest le point d'entrée, il essaie différentes combinaisons de paramètres et appellencclTopoSearchRecpour effectuer la recherche.
Le cœur de la recherche est la fonction récursivencclTopoSearchRecGpu. Elle part d'un GPU, essaie d'atteindre le GPU suivant, jusqu'à parcourir tous les GPU pour former un chemin :
📎 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);
}
...
}Cette fonction a plusieurs branches clés :
1. step == ngpus: tous les GPU ont été parcourus, formant un chemin complet. À ce moment, on incrémentenChannels, on compare le graphe actuel avec le meilleur graphe sauvegardé, et si meilleur on le sauvegarde. Puis on appelle récursivementncclTopoSearchRecpour essayer de rechercher le channel suivant.
2. step == backToNet: il faut revenir à la carte réseau. Cela se produit en mode Ring (le dernier GPU doit se reconnecter à la carte réseau de départ) ou en mode Tree (le premier GPU doit se connecter à la carte réseau).
3. step < ngpus - 1: continuer vers le GPU suivant. Ici on appellencclTopoSearchNextGpuSortpour trier les GPU candidats.
4. step == backToFirstRank: en mode Ring, le dernier GPU doit se reconnecter au premier GPU.
5. else: le chemin se termine, on passe au tour suivant.
ncclTopoSearchNextGpuSortdétermine l'ordre d'essai des GPU suivants :
📎 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);
...
}Il attribue un score à chaque GPU candidat, la règle de tri est : d'abord comparer interBw (bande passante vers la carte réseau), puis interPciBw, puis interNhops, puis intraBw, et enfin intraNhops. Cette priorité reflète l'objectif d'optimisation de NCCL : la communication inter-machines est le goulot d'étranglement, donc on privilégie les GPU avec une bande passante élevée vers la carte réseau.
Réflexions de conception et pièges en production
Pourquoi la recherche a-t-elle un timeout ?Regardez ces constantes :
📎 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)L'espace de recherche est exponentiel — chaque channel a O(ngpus!) permutations. Une machine à 8 cartes représente 40320 permutations, une à 16 cartes en représente 2 billions. Il faut limiter le temps de recherche.NCCL_SEARCH_TIMEOUTest de 16384 itérations,NCCL_SEARCH_GLOBAL_TIMEOUTest de 524288. Après le timeout, on retourne la meilleure solution actuelle.
Piège un :ncclTopoFollowPathLa déduction de bande passante deest un effet de bord global.
📎 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;
}followPathCopierbwmodifie lefollowPathde chaque lien sur le chemin (déduction de la bande passante déjà utilisée). Si la recherche échoue, il faut appeler-bwavec
pour restaurer. Ce modèle « déduction-restauration » est très propice aux erreurs dans une recherche récursive — si une branche oublie de restaurer, les recherches suivantes verront une bande passante erronée.ncclTopoCompareGraphsPiège deux :La logique de comparaison denChannels * bwIntraest très subtile.
📎 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;
...Copier
〔Inférence de conception et compromis architecturaux〕
Pourquoi préférer les channels pairs ? Parce que l'algorithme Ring s'apparie mieux avec un nombre pair de channels — chaque channel peut être divisé en deux moitiés, une dans le sens horaire et une dans le sens antihoraire, réduisant la congestion réseau.
Ring et Tree : transformer les résultats de recherche en topologie algorithmique
Modèle intuitif
L'algorithme de recherche trouve un ensemble de chemins, mais l'algorithme a besoin d'un ordre explicite de « qui envoie à qui ». Ring enchaîne tous les ranks en un anneau, chaque rank reçoit du précédent et envoie au suivant. Tree est un arbre, les données descendent depuis la racine ou remontent depuis les feuilles.
Sans ces deux modules, l'algorithme de recherche ne trouverait qu'un tas de chemins, sans pouvoir indiquer au kernel GPU comment envoyer concrètement les données.ncclBuildRingsStructures de données et disposition mémoire
📎 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;
}:prevCopiernextL'entrée estringset le tableaunext(prédécesseur et successeur de chaque rank), la sortie est le tableau
(l'ordre complet des ranks pour chaque channel). Il part du rank actuel, suit le pointeurncclGetBtreesur un tour complet, vérifie le retour au point de départ, et vérifie que tous les ranks ont été visités.
📎 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;
}:bitCopier(rank ^ bit) | (bit << 1)Cette fonction construit un arbre binaire avec des opérations bit à bit. L'idée centrale est : trouver le bit non nul le plus basrank - (bit >> 1)du rank, le nœud parent estrank + (bit >> 1), le fils gauche est
, le fils droit est
Prenons l'exemple d'un Ring à 8 cartes. Supposons que les résultats de recherche donnent pour chaque rank lenextpointeur :
rank 0 -> rank 1
rank 1 -> rank 2
...
rank 7 -> rank 0ncclBuildRingsEn partant du rank 0, on visite successivement 1, 2, ..., 7, puis on revient à 0. Générérings[0..7] = {0, 1, 2, 3, 4, 5, 6, 7}。
Pour Tree,ncclGetBtreeon calcule le nœud parent et les nœuds enfants pour chaque rank. Prenons le rank 1 comme exemple :
bit= 1 (le bit non nul le plus bas est le bit 0)up = (1 ^ 1) | (1 << 1) = 0 | 2 = 2up >= nranks? 2 < 8, doncup = 2parentChildType = (1 < 2) ? 0 : 1 = 0(c'est le premier enfant du nœud parent)lowbit = 0, doncdown0 = -1down1 = -1
Donc le nœud parent du rank 1 est le rank 2, et il n'a pas de nœuds enfants. Cela correspond à la structure de l'arbre dans les commentaires : le rank 1 est une feuille.
Réflexions de conception et pièges en production
Pourquoi Tree utilise-t-il des opérations bit à bit au lieu de construire explicitement l'arbre ?Parce que chaque rank n'a besoin de connaître que son nœud parent et ses nœuds enfants, sans nécessiter la structure globale de l'arbre. Les opérations bit à bit permettent de calculer ces informations en temps O(1), évitant ainsi les coûts de stockage et de synchronisation de l'arbre entier.
Piège 1 :ncclBuildRingsLa vérification de peut être ignorée.Si lenexttableau contient un cycle (par exemple rank 0 -> rank 1 -> rank 0), la boucle se terminera aprèsnranksitérations, maiscurrent != rankla vérification capturera ce problème. Cependant, si la longueur du cycle est exactement unnranksfacteur de , et ne contient pas tous les ranks,rankFoundla vérification capturera.
Piège 2 :ncclGetDtreeLe traitement des ranks impairs dans .Pour un nombre impair de ranks, le second arbre est un « décalage » plutôt qu'un « miroir » :
📎 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;
}Le Double Tree (arbre double) est l'implémentation de l'algorithme Tree de NCCL — deux arbres fonctionnent simultanément, l'un responsable de la première moitié des données, l'autre de la seconde moitié, améliorant ainsi l'utilisation de la bande passante. Avec un nombre impair de ranks, le miroir entraînerait un mappage incomplet des ranks, c'est pourquoi on utilise un décalage.
La coordination des trois : de la topologie à l'algorithme
Relions maintenant les trois modules. L'ensemble du processus peut être représenté par un schéma :
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["生成最终算法拓扑"]Ce schéma illustre le processus complet, de la découverte de la topologie à la génération de l'algorithme. Notez quencclTopoSearchRecGpuest une fonction récursive qui essaie continuellement différents ordres de GPU jusqu'à expiration du délai ou jusqu'à trouver la solution optimale.
Regardons un diagramme de séquence plus fin, illustrant les interactions entre les modules pendant le processus de recherche :
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) 恢复带宽Ce diagramme de séquence montre la boucle centrale de la recherche : sélectionner la carte réseau -> essayer le GPU -> recherche récursive -> comparer les résultats -> restaurer la bande passante.
Résumé de ce chapitre
Ce chapitre a décomposé les trois étapes de la topologie consciente de NCCL :
1. Découverte de la topologie(topo.cc) : lire les informations des périphériques depuis le XML, créer les nœuds GPU/CPU/PCI/NIC, établir les liens NVLink/PCIe/réseau, formant ainsi un graphe topologique complet.
2. Recherche de chemin(search.cc + paths.cc) : d'abord précalculer les chemins les plus courts entre toutes les paires de nœuds avec BFS, puis utiliser une recherche récursive pour essayer différentes structures Ring/Tree et trouver la solution offrant la bande passante la plus élevée.
3. Génération de la topologie de l'algorithme(rings.cc + trees.cc) : convertir les résultats de recherche en un ordre de ranks spécifique. Ring utilisencclBuildRingspour générer un anneau, Tree utilisencclGetBtreepour générer un arbre binaire.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on remplace dansncclTopoConnectNodesl'accumulation de bande passantelink->bw += bwparlink->bw = std::max(link->bw, bw), dans quels scénarios cela entraînerait-il une baisse de performance ? Pourquoi ?
Analyse de référence: L'accumulation de bande passante traite le cas de plusieurs liens parallèles. Prenons l'exemple de 4 NVLink à 25 GB/s chacun : après accumulation, on obtient 100 GB/s, alors qu'avec max on n'obtient que 25 GB/s. DansncclTopoSetPaths, la bande passante du chemin eststd::min(path->bw, link->bw). Si la bande passante d'un lien est sous-estimée, la bande passante de tout le chemin sera sous-estimée. Cela conduirancclTopoCompareGraphsà choisir un mauvais graphe — il pourrait choisir une solution avec plus de canaux mais une bande passante inférieure par canal, ce qui donnerait en réalité de moins bonnes performances. Scénario concret : 8 cartes A100 entièrement interconnectées par NVLink, avec 4 NVLink entre chaque paire de GPU. L'accumulation donne 100 GB/s, le max donne 25 GB/s. L'algorithme de recherche considérerait alors que NVLink et PCIe Gen4 x16 (environ 25 GB/s) ont la même bande passante, et pourrait choisir un chemin passant par PCIe.
Q2: ncclTopoSearchRecGpuDans(*time)--s'exécute à l'entrée de la fonction. Si la recherche expire (*time <= 0), la fonction retourne directement. Dans quels cas cette conception peut-elle faire entrer la recherche dans une boucle infinie ? Comment corriger cela ?
Analyse de référence:(*time)--est décrémenté à l'entrée. Si*timea une valeur initiale de 0 ou négative, la fonction retourne directement sans décrémenter. Mais si*timeest un grand nombre positif, chaque récursion le décrémente, et il finira par atteindre 0. Le problème est le suivant : si la profondeur de récursion d'une branche est très grande, mais qu'après chaque décrémentation*timereste supérieur à 0, la recherche continue. Le vrai risque est la bouclencclTopoSearchRecdansgoto search— sitimen'est pas correctement réinitialisé dans la boucle, cela peut boucler indéfiniment. Regardons la logiquencclTopoComputedansglobalTimeout:globalTimeout -= times'exécute à chaquesearchétiquette. SiglobalTimeoutdevient négatif, celagoto done. Mais sitimeest réinitialisé àNCCL_SEARCH_TIMEOUT,globalTimeoutil pourrait ne jamais devenir négatif. La correction consiste à s'assurer queglobalTimeoutest décrémenté après chaque recherche, et qu'il existe une limite supérieure stricte.
Q3: ncclTopoFollowPathest appelé en cas d'échec de la recherche pourfollowPath(path, node1, step, -bw, &step)restaurer la bande passante. Si une branche récursive retourne avant la restauration (par exempleNCCLCHECKGOTOsaute àexit), que se passe-t-il ? Comment détecter ce problème ?
Analyse de référence: Si la restauration est ignorée, la bande passante des liens sur le chemin restera dans l'état déduit. Les recherches suivantes verront une bande passante erronée et pourraient manquer la solution optimale. Méthode de détection : dansncclTopoComputeUne fois terminé, parcourir tous les liens et vérifier si la bande passante correspond à la valeur initiale. Si une incohérence est détectée, cela indique qu'une restauration a été omise. Méthode de correction : utiliser un objet garde de style RAII qui restaure automatiquement la bande passante lors de sa destruction. Alternativement, sauvegarder un instantané de la bande passante de tous les liens avant chaque recherche, puis restaurer après la recherche. L'approche actuelle de NCCL consiste à, à chaquencclTopoFollowPathpoint d'appel, apparier manuellement les appels directs et inverses, ce qui est sujet aux erreurs. Une conception plus robuste consisterait à encapsuler la déduction et la restauration de la bande passante dans une fonction, garantissant ainsi qu'elles apparaissent par paires.
Dans le prochain chapitre, nous approfondirons le module tuning pour voir comment NCCL, en fonction des résultats de recherche topologique et de la taille des messages, effectue le choix final entre les algorithmes Ring, Tree, CollNet, etc. Le graphe topologique, les résultats de recherche de chemin et les modèles d'algorithmes établis dans ce chapitre serviront d'entrées au module tuning.
Grâce à la construction du graphe dans topo.cc, à la recherche de chemin dans search.cc, ainsi qu'à la génération de topologies dans rings.cc et trees.cc, NCCL met en œuvre une philosophie de conception qui consiste à décrire n'importe quelle topologie avec une structure de graphe générique, à trouver la solution optimale avec un algorithme de recherche configurable, et à générer l'algorithme final avec des modèles simples. Ce mécanisme permet à NCCL de sélectionner automatiquement l'algorithme approprié sur diverses machines, depuis une station de travail à 2 GPU jusqu'à un cluster de 10 000 GPU. Cependant, le graphe topologique ne fournit que des chemins candidats pour les algorithmes ; déterminer quel chemin emprunter et quel protocole utiliser pour une communication donnée nécessite des décisions plus fines. Dans le prochain chapitre, nous nous concentrerons sur le répertoire src/tuning pour voir comment le module tuning combine le modèle de coût et l'estimation des algorithmes afin de faire le choix final entre Ring/Tree/NVLS/PAT et LL/LL128/Simple.
Chapitre 5 : Chapitre 5 : Sélection des algorithmes et protocoles : comment le module tuning décide du chemin de communication
Chapitre 5 : Sélection des algorithmes et protocoles : comment le module tuning décide du chemin de communication
Dans le chapitre précédent, nous avons décomposé la capacité de perception topologique de NCCL : de l'énumération des dispositifs dans src/graph/topo.cc pour construire le graphe topologique, à la recherche du chemin optimal dans src/graph/search.cc, puis à la concrétisation des résultats de recherche en topologies d'algorithmes Ring et Tree dans rings.cc et trees.cc. Mais le graphe topologique ne répond qu'à la question « par où les données peuvent passer » ; il ne répond pas à « par où cette communication devrait passer ». Sur une même machine, un AllReduce de 4 Ko et un AllReduce de 400 Mo peuvent avoir des solutions optimales complètement différentes : le premier privilégie la latence, le second la bande passante ; le premier peut choisir Tree/LL, le second peut choisir Ring/Simple ou NVLS. Le module tuning est celui qui « tranche ». Ses entrées sont la taille du message, le nombre de ranks, le graphe topologique (produit du chapitre précédent) et les variables d'environnement utilisateur ; sa sortie est un ncclTuningResult_t, qui indique quel algorithme (algo) utiliser, quel protocole (proto), combien de channels ouvrir et combien de warps utiliser. Dans ce chapitre, nous décomposons le répertoire src/tuning dans l'ordre suivant : « ordonnancement global → modèle de coût → estimation de chaque algorithme → décision finale ». La question centrale est unique : comment NCCL, parmi des dizaines de combinaisons (algorithme, protocole), utilise un modèle mathématique purement CPU pour sélectionner la plus rapide en quelques microsecondes ?
I. tuning.cc : ordonnancement global et squelette décisionnel
Modèle intuitif
Imaginez le module tuning comme une entreprise dedéménagement. Le client (une communication collective) arrive et dit « je veux déménager 100 Mo de marchandises, de 8 entrepôts vers 8 entrepôts ». Le dispatcheur (ncclTuningCompute) ne va pas réellement faire le déménagement pour essayer, mais sort unegrille tarifaire(modèle de coût), estime un « temps prévu » pour chaque option (Ring/LL, Tree/Simple, NVLS/Simple…), puis choisit le devis le plus court pour le client.
Sans ce dispatcheur, NCCL ne pourrait que coder en dur « AllReduce utilise toujours Ring », ce qui serait écrasé par Tree dans les scénarios de petits messages et par NVLS dans les scénarios NVLink à grande échelle.Le coût serait une performance réduite de moitié, voire pire, dans des scénarios spécifiques.
Structures de données et disposition mémoire
Le support de la décision estncclTuningResult_t, et l'ensemble des candidats estncclTuningResultList_t(une liste simplement chaînée). Les nœuds de la liste sont définis danstuning_int.h, mais la logique de push se trouve danstuning.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;
}Notez qu'il s'agit ici d'uneinsertion en tête: chaque candidat valide calculé est inséré en tête de liste. Cela signifie que l'ordre de la liste et l'ordre des id sontinversés. Pourquoi utiliser une liste chaînée plutôt qu'un tableau ? Parce que le nombre de candidats est déterminé à la compilation parNCCL_TUNING_COUNT, mais les candidats réellement valides sont dynamiques (influencés partuningMask, les capacités de la plateforme, les variables d'environnement utilisateur) ; la liste chaînée permet de « n'attacher que les valides », évitant de vérifier répétitivementvalidlors du parcours. Le coût est que chaque décision nécessitencclCallocune fois, mais le tuning se produit sur le chemin de mise en file, à une fréquence peu élevée, ce coût d'allocation est donc acceptable.
ncclTuningResult_tLes deux champs les plus critiques sonttimeUs(temps estimé, en microsecondes) etselectionTimeUs(temps utilisé pour la sélection, pouvant être écrasé par le plugin tuner). La logique de sélection ne regarde que ce dernier :
📎 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;
}Il y a un détail ici :bestTuning->timeUsest d'abord initialisé àFLT_MAX, puis on parcourt. Si la liste chaînée est vide (tous les candidats sont invalides),bestTuningconserveraNCCL_TUNING_RESULT_INITla valeur initiale, algo/proto étant tous deuxUNDEF. Ce « résultat vide » sera traité spécialement par l'appelant — voir la branche d'erreur plus loin.
Step-by-Step Walkthrough : le flux de décision d'un AllReduce
Supposons que l'application appellencclAllReduce, message de 1 Mo, 8 ranks sur une seule machine NVLink. SuivonsncclTuningComputepas à pas.
Étape 0 : court-circuit mono-rank.SinRanks <= 1, aucune communication n'est nécessaire, on retourne directement Ring/Simple, avec le nombre de channels fixé à 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 {Ce court-circuit est important : en mono-rank, toute estimation d'algorithme diviserait parnRanks-1ou un équivalent, risquant de produire un NaN ou une division par zéro.D'abord le filet de sécurité, ensuite les calculs, c'est un exemple typique de programmation défensive.
Étape 1 : énumérer tous les candidats.entre dansncclTuningComputeAllTunings, qui parcourtNCCL_TUNING_COUNTids :
📎 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);
}
...
}Notons quetuningMaskest un masque de 64 bits, dont le i-ème bit indique « si la i-ème combinaison (algo, proto) est autorisée ». Ce masque est calculé plus haut selon les capacités de la plateforme, les variables d'environnement utilisateur et le type de fonction.Le masque est un « pré-filtrage grossier », le modèle de coût est un « calcul précis »— on élimine d'abord ce qui est totalement impossible (par exemple NVLS sur une machine PCI), puis on calcule le temps pour le reste.
ncclTuningExpandIddéploie l'id unidimensionnel en (algo, proto, symKernelId, ceMethodId). Cette relation de mapping doit être strictement cohérente aveccost_model.ccdansmodelMaple tableau
, sinon le modèle sera mal calculé. ncclTuningComputeTuningÉtape 2 : calculer le coût un par un.
📎 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;
}copieÉtape 3 : intervention du plugin tuner (optionnel).timeUsSi l'utilisateur a installé un plugin tuner (par exemple un optimiseur maison de certains fournisseurs cloud), NCCL empaquette lesgeneralTable[algo][proto]de tous les candidats dans un tableau bidimensionnel
📎 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];
}
}copieNCCL_TUNING_IGNOREIci
est une valeur sentinelle indiquant « cette combinaison n'a pas été calculée / n'est pas applicable ». Le plugin peut ne modifier que les cases qui l'intéressent, les autres restant à IGNORE, et NCCL les ignorera.Étape 4 : choisir le meilleur.ncclTuningSelectBestTuningappelleselectionTimeUs, parcourt la liste chaînée et prend celle dont
est minimale.Étape 5 : calculer le nombre de channels.
📎 src/tuning/tuning.cc:233-235
if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
}ncclTuningGetChannelscopietuning_int.hDansminChannels, la logique consiste à interpoler entremaxChannelset
selon la taille du message et le type d'algorithme. Le nombre de channels influence directement la bande passante : plus il y a de channels, plus le parallélisme est élevé, mais plus le coût de démarrage de chaque channel est important.Étape 6 : biais CTA Policy (priorité NVLS).NCCL_CTA_POLICY_EFFICIENCYSi l'utilisateur a défini
📎 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;
...copieLe commentaire de ce code est crucial :GetChannelsle biais EFFICIENCY doit être exécuté après, car il a besoin debestTuning.nChannels; et il faut vérifier si le bit NVLS danstuningMaskest autorisé, sinon on « ressusciterait » un algorithme exclu par la couche supérieure. C'est un piège typique dedépendance à l'ordre des états。
Étape 7 : repli vers le kernel symétrique.Si le kernel sélectionné est symétrique (symKernelId), mais que le buffer n'est pas enregistré ou que la plateforme ne le supporte pas, il faut revenir à un kernel ordinaire. Cette logique se trouve danstuning.cc:258-298, c'est l'endroit le plus tortueux de tout le chapitre, nous le traiterons spécifiquement dans la section 5.
Étape 8 : erreur si aucune solution.Si tous les candidats sont invalides, algo/proto étant UNDEF, NCCL émet un WARN et renvoie un code d'erreur différent selon que l'utilisateur a défini ou non des variables d'environnement :
📎 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;
}Pourquoi distinguer les codes d'erreur ?Si l'utilisateur a définiNCCL_ALGO=ringmais que la plateforme actuelle ne supporte pas ring (par exemple certaines topologies spéciales), c'est uneerreur de configuration utilisateur(ncclInvalidUsage) ; si l'utilisateur n'a défini aucune variable d'environnement et qu'aucun algorithme ne peut être choisi, c'est unbug interne de NCCL(ncclInternalError). Cette distinction est essentielle pour le dépannage.
Diagramme du flux principal de décision
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 : registre de modèles et matrice d'activation
Modèle intuitif
cost_model.ccest legrand livredu tuning. Il maintient une tablemodelMap, chaque ligne correspondant à une combinaison (algo, proto), enregistrant « quelle est la fonction d'initialisation de cette combinaison, quelle est la fonction de simulation, et pour quelles fonctions elle est activée ». Il est également chargé de parser la variable d'environnement utilisateurNCCL_ALGO/NCCL_PROTO/NCCL_SYM_KERNEL, traduisant l'intention de l'utilisateur en une matrice d'activationenabled[i][f].
Sans cette table, chaque ajout d'un nouvel algorithme obligerait à modifier tout le flux principal de tuning, et le code deviendrait un vrai gâchis.L'approche pilotée par tabletransforme « ajouter un algorithme » en « ajouter une ligne ».
Structures de données : modelMap et matrice d'activation
modelMapest un tableau statique, chaque élément étantncclTuningModelEntry_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
...
};Chaque entrée possède quatre champs :init(initialisation, calcule latency/bandwidth et les stocke dans comm),model(simulation, calcule le timeUs final selon la taille du message),finalize(nettoyage),enabled[5](pour savoir si les cinq fonctions Broadcast/Reduce/AllGather/ReduceScatter/AllReduce sont activées).
AttentionenabledL'ordre du tableau est commenté à la L234 :Enable order: Broadcast, Reduce, AllGather, ReduceScatter, AllReduce. Cet ordre doit être cohérent avecncclFunc_tl'énumération, sinon il y aura confusion.
Pourquoi séparer init et sim ?Parce que ce qui est calculé dans init (latency, bandwidth)ne dépend que des propriétés statiques de comm(topologie, nombre de ranks, compCap), et est indépendant de la taille concrète des messages. Dans une communication, tuning peut être appelé plusieurs fois consécutivement (par exemple s'il y a plusieurs op dans un group), init ne s'exécute qu'une fois, sim s'exécute à chaque fois. C'est une optimisation typique de « précalcul + requête rapide ».
Step-by-Step : analyse des variables d'environnement et construction de la matrice d'activation
Étape 1 : tout activé par défaut, LL128 particulier. ncclTuningCostModelInitAu début, tous les proto sont mis à 1 (activé), mais LL128 est mis à 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;
}
}Pourquoi LL128 est 2 et non 1 ?Parce que LL128 n'est pas « activé par défaut », mais «activé sous condition». 2 est un marqueur spécial indiquant que « l'utilisateur ne l'a pas demandé explicitement, il sera déterminé plus tard parisLL128Enabledselon les capacités de la plateforme ». 1 signifie « activé inconditionnellement », 0 signifie « désactivé ». Cette conception à trois états se reflète dans la condition à la 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;
}Étape 2 : analyse des variables d'environnement utilisateur.Si l'utilisateur a définiNCCL_ALGOouNCCL_SYM_KERNEL, on remet d'abord algo et symKernel à zéro (car l'utilisateur a spécifié une liste blanche) :
📎 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));
}Attention, proto n'est pas remis à zéro — car la valeur par défaut de proto est 1/2, quand l'utilisateur définitNCCL_PROTO=LL,parseListmettra LL à 1 et les autres à 0 (à cause de la logiqueunset). Cette asymétrie est intentionnelle : algo est entièrement activé par défaut mais doit être restreint après spécification par l'utilisateur, la restriction de proto est gérée en interne parparseList.
Étape 3 : syntaxe de parseList.Cette fonction supporte une syntaxe assez complexe, des exemples sont donnés dans les commentaires :
📎 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.^Le préfixe indique « négation » :
📎 src/tuning/cost_model.cc:59-67
int unset, set;
if (elemList[0] == '^') {
unset = 1;
set = 0;
elemList++;
} else {
unset = 0;
set = 1;
}DoncNCCL_PROTO="^LL128;allreduce:LL128"signifie : désactiver LL128 globalement, mais activer LL128 en exception pour AllReduce.
Étape 4 : fusion de la matrice enabled.Enfin, on parcourt tous les model, et on effectue un ET logique entremodel->enabled[f]et les interrupteurs utilisateur :
📎 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;
}La logique est :Ce n'est que lorsque l'utilisateur a défini une configuration forced pour une fonction que la configuration utilisateur écrase la valeur par défaut du modèle. Si l'utilisateur n'a rien défini,forced[f] == 0, directementcontinue, on conserve leenabledpropre au modèle. C'est la priorité « spécification explicite de l'utilisateur > valeur par défaut du modèle ».
Point d'entrée unifié de la simulation de modèle
Tous les modèles finissent par être appelés viancclTuningCostModelSimModel:
📎 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;
}Triple filtrage :id hors limites → modèle désactivé → le modèle renvoie un temps non positif, si une couche ne passe pas, on va dansnot_valid, on mettimeUsàNCCL_TUNING_IGNORE(une sentinelle négative),valid = 0. L'appelant, en voyantvalid == 0, ne l'insérera pas dans la liste chaînée de candidats.
Réflexion de conception
modelMapIl y a un avertissement clé dans les commentaires de
📎 src/tuning/cost_model.cc:229
// IMPORTANT: this table need must be consistent with the algRegistry in src/config/algorithm_registry.ccCela signifie quemodelMapl'ordre desindicesdoit être strictement cohérent avec l'ordre d'enregistrement des algorithmes dansalgorithm_registry.cc. Si quelqu'un insère un nouvel algorithme dans le registry mais oublie de modifiermodelMap, tous les id seront décalés, et tuning choisira un algorithme complètement erroné.C'est le piège classique de la conception pilotée par table : le contrat implicite.Une approche plus robuste serait d'utiliser le nom de l'énumération comme key plutôt que l'indice, mais cela sacrifierait un peu d'optimisation à la compilation.
---
III. ring.cc : estimation du coût de l'algorithme Ring
Modèle intuitif
L'algorithme Ring dispose N ranks en anneau, les données circulent le long de l'anneau tour après tour. Son modèle de coût doit répondre à deux questions :Combien de données sont transmises à chaque étape (bandwidth)、Combien d'étapes au total (latency)。
L'intuition de Ring est «pipeline» : imaginez N personnes debout en cercle qui se passent un seau d'eau, chaque personne, après avoir reçu le seau, verse un peu d'eau puis le passe à la suivante. Le seau fait un tour, et l'eau de tout le monde est bien mélangée. Plus le seau tourne vite (bandwidth élevée), plus le cercle est petit (moins d'étapes), plus l'ensemble est rapide.
Structure de données : table latency/bandwidth
Le modèle Ring n'introduit pas de nouvelle structure, il écrit les résultats d'estimation danscomm->tuningContext.generalLatencies[c][algo][proto]etgeneralBandwidths[c][algo][proto]. Ce sont deux tableaux tridimensionnels : fonction × algorithme × protocole.
À l'initialisation, tout est d'abord mis à -1.0 (sentinelle, signifiant « non calculé ») :
📎 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;Cette sentinelle -1.0 est vérifiée à l'étape 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;
}Pourquoi utiliser -1.0 plutôt que 0 ?Parce que 0 est une valeur de bandwidth légale (bien que physiquement impossible), tandis que -1.0 indique clairement « non initialisé ». La comparaison flottante avec==est sûre ici, car -1.0 est exactement représentable.
Step-by-Step : estimation de la bandwidth de Ring
Étape 1 : déterminer si l'on utilise la bandwidth intra ou inter.Mono-machine (nNodes==1) utilise intra, multi-machine utilise 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;nStepsest le nombre d'étapes nécessaires à l'algorithme, pour Ring, AllReduce est2*(nRanks-1), les autres sontnRanks-1。busBwest la « bandwidth de bus » = bandwidth d'un lien unique × nombre de channels.
Étape 2 : appliquer la réduction selon le protocole.Le protocole LL n'utilise que la moitié de la bande passante (à cause de l'overhead des flags LL), LL128 en utilise 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/128C'est parce que dans LL128, 8 octets sur 128 sont des flags, la charge utile n'est que de 120 octets. Ce chiffre provient directement de la conception du protocole.
Étape 3 : Calculer la bande passante effective.Notez qu'ici on multiplie parnRanks / 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;Pourquoi multiplier parnRanks / nSteps?C'est la caractéristique centrale de l'algorithme Ring : la quantité de données réellement transportée par chaque rank estnBytes * nSteps / nRanks(car les données doivent faire plusieurs tours de l'anneau). Donc « bande passante effective » = bande passante du bus × nRanks / nSteps. Pour AllReduce, nSteps = 2(nRanks-1), donc bande passante effective ≈ busBw/2.
Étape 4 : Calculer la latence.La latence se divise en deux parties : intra et 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;Notez le traitement spécial des lignes L57-58 : lorsquemaxLocalRanks == 1(chaque nœud n'a qu'un seul rank), la latence inter-node de Ring utilisela latence NET de Tree. Le commentaire dit qu'il s'agit de « preserve the pre-refactor model » — c'est-à-dire une « bizarrerie » délibérément conservée pour maintenir la cohérence avec le comportement d'avant le refactoring.Ce genre de bagage historique est très courant dans les systèmes matures. Quand vous lisez du code source et voyez le mot « preserve », soyez particulièrement prudent : cela signifie souvent qu'il y a ici une contrainte de compatibilité qu'on ne peut pas toucher.
Étape 5 : Accumuler selon le type de fonction.Les modèles de latence de Reduce/Broadcast et AllReduce/AllGather/ReduceScatter sont différents :
📎 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;
}sameChannelsest une propriété topologique, indiquant si « les étapes intra et inter sur l'anneau utilisent le même ensemble de channels ». Si ce n'est pas le cas, la latence doit être multipliée parnSteps(il faut attendre à chaque étape).netOverheadest l'overhead de post réseau ; le protocole Simple doit multiplier par 3 (car Simple a trois allers-retours réseau : send, recv, ack).
Pièges en production : l'effet plateau de Ring/Simple
ncclTuningRingModelSimIl y a une section de code dédiée au traitement du « plateau » :
📎 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
}Qu'est-ce qu'un plateau ?Dans Ring/Simple, lorsque le message atteint une certaine taille, la latence ne croît plus linéairement avec le message, mais se « bloque » sur un palier — car à ce moment le goulot d'étranglement passe de « l'overhead de démarrage » à « la bande passante », et la bande passante est déjà saturée. Ce phénomène est particulièrement marqué sur Blackwell NVLink (car la bande passante NVLink est très élevée, la part de latence est plus grande). Le code utiliseplateauFactor(1.4 ou 1.9) multiplié à la latence pour simuler cet effet de « latence amplifiée ».
bytesPerRankPerChannel >= 64est la condition de déclenchement : chaque rank doit transmettre au moins 64 octets par channel, sinon le plateau ne se produit pas. Ces 64 octets proviennent de la taille des flags du protocole LL.
Scénario piège: Si vous exécutez un AllReduce de 1MB sur Blackwell et constatez que la latence réelle est 40% supérieure à la prédiction du modèle, ne croyez pas à un bug — c'est l'effet plateau, et le modèle l'a déjà pris en compte. Si vous modifiez manuellement à la baisseplateauFactor, le modèle sous-estimera la latence, ce qui conduira à un mauvais choix d'algorithme.
---
IV. tree.cc et nvls.cc : estimation du coût de Tree et NVLS
Modèle intuitif
L'algorithme Treeest une «diffusion en arbre» : le nœud racine distribue les données aux nœuds enfants, qui les distribuent à leur tour aux nœuds petits-enfants. Son avantage est lefaible nombre d'étapes(log N au lieu de N), adapté aux petits messages ; son inconvénient est lafaible utilisation de la bande passante(chaque nœud non-feuille doit relayer, la bande passante effective réelle n'est que de la moitié).
NVLS(NVLink SHARP) est la «multidiffusion matérielle» : le switch copie directement les données vers plusieurs GPU, sans relais logiciel. Son avantage est unebande passante élevée et une faible latence, mais il nécessite un matériel spécifique (Hopper ou supérieur) et une configuration spécifique.
Modèle Tree : ne sert qu'à AllReduce
Le modèle Tree a une limitation stricte —il n'est activé que pour 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;
}Pourquoi ?Parce que l'implémentation Tree de NCCL ne supporte qu'AllReduce (les autres opérations collectives n'ont pas de version Tree). C'est une contrainte d'implémentation, pas une limitation théorique.enabled[c] = 0est un « désactivation stricte », plus radical quegeneralBandwidths = -1— le premier fait directement retournerncclTuningCostModelSimModelà L480, le second ne vérifie qu'au moment de la fonction sim.not_validEstimation de la bande passante de Tree
Copier:
📎 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;, plus agressif que celui de Ring1/3.8.0.5Pourquoi l'efficacité LL de Tree est-elle plus faible ?Parce que chaque nœud intermédiaire de Tree doit à la fois recevoir et envoyer, l'overhead des flags LL est amplifié sous trafic bidirectionnel.Ce chiffre provient de mesures réelles.1/3.8Estimation de la latence de Tree
Copier:
📎 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 *est le nombre d'étapes intra-node (nombre de ranks par nœud moins un),(nRanks/nNodes - 1)est le nombre d'étapes inter-node (hauteur de l'arbre).log2i(nNodes)Facteur de correction de Tree
Tree 的修正因子: Le modèle Tree est multiplié par un facteur lors de la phase simtreeCorrectionFactor:
📎 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];treeCorrectionFactorest une table de 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), c'est-à-dire que la taille du message est prise en log2 avec une unité de 64 octets. Les indices 0-23 de la table correspondent à 64B jusqu'à 64B×2^23 ≈ 512MB.Cette table est la « courbe d'efficacité Tree » mesurée empiriquement: pour les petits messages, l'efficacité est de 1.0 (dominée par la latence), pour les messages moyens, l'efficacité chute à 0.4-0.5 (la bande passante n'est pas saturée), et pour les grands messages, elle remonte à 1.0 (bande passante saturée). Ce « creux intermédiaire » est une caractéristique inhérente à l'algorithme Tree.
Modèle NVLS : le coût du multicast matériel
Le modèle NVLS vérifie d'abord si le matériel supporte :
📎 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;
}Ensuite, une série de contraintes strictes : seul le protocole Simple est supporté, NVLSTree n'est pas supporté sur une seule machine, et NVLS multi-machine nécessite 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;
}Estimation de la bande passante NVLSutilise un facteur d'efficacité :
📎 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 est à 0.85, tandis que Blackwell descend à 0.74.Pourquoi l'efficacité est-elle plus faible sur la nouvelle génération de matériel ?Parce que la bande passante NVLink de Blackwell est plus élevée, mais la capacité de traitement des switches NVLS n'a pas augmenté proportionnellement, ce qui entraîne une baisse de l'efficacité relative. Ce chiffre est mesuré empiriquement, pas une valeur théorique.
Dans le calcul de la bande passante, il y a un facteur(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) / nChannelscar NVLS doit réserver un channel pour la synchronisation.(ppn - 1) / ppnest le surcoût de AllGather/ReduceScatter (chaque rank doit attendre les données du rank précédent).
Pièges en production : les contraintes strictes de NVLS
Le modèle NVLS dispose également d'une couche de vérification à l'exécution lors de la phase 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_ARITYest le nombre maximum de GPU que le groupe multicast NVLS peut contenir. Si ce nombre est dépassé, NVLS n'est pas disponible.Scénario piège: dans un domaine NVLink de 16 cartes exécutant AllGather, siNCCL_MAX_NVLS_ARITYvaut 8, NVLS sera désactivé et le tuning reviendra à Ring. Si vous ne connaissez pas cette limitation, vous vous demanderez « pourquoi NVLS n'est pas utilisé alors que le matériel le supporte ».
---
V. Repli des kernels symétriques et chaîne de récupération d'erreurs
Modèle intuitif
Le kernel symétrique (symmetric kernel) est une nouvelle fonctionnalité de NCCL : lorsque les buffers de tous les ranks sont enregistrés dans la mémoire symétrique, le kernel peut accéder à la mémoire distante avec des instructions plus efficaces. Maissi le buffer n'est pas enregistré, ou si la plateforme ne le supporte pas, il faut revenir à un kernel ordinaire. Cette logique de repli est la partie la plus complexe du tuning.
Étape par étape : décision de repli
La logique de repli se trouve danstuning.cc:258-298. Décomposons-la.
Étape 1 : déterminer si un repli est nécessaire.Condition d'entrée :
📎 src/tuning/tuning.cc:258-263
À ce stade, la chaîne de décision du module tuning est claire : il reçoit la topologie et les paramètres de communication, et via le modèle de coût et l'estimation d'algorithmes, produit en quelques microsecondes la combinaison optimale (algorithme, protocole, channel, warp). Mais la sélection n'est que le début — comment ce résultat de décision est-il utilisé en aval ? Dans le chapitre suivant, nous entrerons dans le corps de src/enqueue/enqueue.cc pour voir comment un appel ncclAllReduce passe par la validation des paramètres, la détermination algorithme/protocole, le découpage en channels, et génère finalement les structures ncclInfo et ncclTaskColl. C'est le chapitre clé où le livre passe du « point de vue utilisateur » au « point de vue moteur » ; vous découvrirez ce qu'un appel de communication collective est traduit côté host, ainsi que la frontière avec le lancement du kernel qui suit.
Chapitre 6 : Chapitre 6 : Panorama de la soumission d'opérateurs : comment ncclAllReduce devient une tâche kernel exécutable
Chapitre 6 : Panorama de la soumission d'opérateurs : comment ncclAllReduce devient une tâche kernel exécutable
Dans le chapitre précédent, nous avons parcouru le module tuning et avons appris que NCCL sélectionne en quelques microsecondes la combinaison (algorithme, protocole, channel, warp) pour une communication collective. Mais le résultat de cette sélection n'est qu'un ensemble de nombres — il doit être « traduit » en un objet de description de tâche compréhensible par le kernel GPU pour pouvoir être réellement exécuté. Ce chapitre entre dans le corps principal de src/enqueue/enqueue.cc et répond à une question centrale : lorsque l'utilisateur appelle ncclAllReduce, que se passe-t-il réellement côté host ? De ncclAllReduce à ncclEnqueueCheck, en passant par la validation des paramètres, la détermination de l'algorithme/protocole, le découpage en channels, pour finalement générer les structures ncclInfo et ncclTaskColl. C'est le chapitre clé de tout l'ouvrage qui bascule du « point de vue utilisateur » au « point de vue moteur ». Si l'on compare NCCL à un restaurant, alors le module enqueue est le « système de prise de commande en salle » : l'utilisateur (couche applicative) dit « je veux un AllReduce », et la salle le traduit en un bon de travail exécutable par la cuisine (kernel GPU) — quel numéro de feu, quelle casserole utiliser, en combien de fournées. Sans cette couche de traduction, la cuisine ne saurait pas du tout quel plat préparer.
I. Entrée : comment ncclAllReduce construit ncclInfo
Modèle intuitif
ncclAllReduceest la fonction API appelée directement par l'utilisateur. Sa responsabilité est extrêmement simple :empaqueter les paramètres bruts fournis par l'utilisateur dans une structurencclInfo, puis la transmettre àncclEnqueueCheck. C'est comme lorsque vous vous rendez au guichet d'une banque : le guichetier remplit d'abord votre demande dans un formulaire standard, puis la transmet au système backend.
Sans cette couche, chaque API de communication collective devrait gérer elle-même la validation des paramètres, la sémantique de group, l'instrumentation profiler — le code deviendrait dupliqué au point d'être ingérable.
Structure de données : disposition mémoire de ncclInfo
ncclInfoest le vecteur central qui traverse tout le flux enqueue. Sa définition se trouve danssrc/include/info.h:
📎 src/include/info.h:17-44
Cette structure possède plus de 20 champs, que l'on peut répartir en quatre groupes fonctionnels :
| Groupe de champs | Champ | Rôle |
|---|---|---|
| Paramètres de communication collective | coll, sendbuff, recvbuff, count, datatype, op, root | Décrit « quoi faire » |
| Domaine de communication et stream | comm, stream | Décrit « où le faire » |
| Détails de l'algorithme | chunkSteps, sliceSteps | Décrit « comment découper » |
| Opérations unilatérales | peerWinOffset, peerWin, sigIdx, ctx, flags, nDesc, signalDescs | Spécifique à RMA |
| Configuration utilisateur | collConfig | Copie privée copiée depuis la config utilisateur |
Notez le commentaire decollConfig:"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. C'est une conception clé — le pointeur de config passé par l'utilisateur peut être détruit avantncclGroupEnd, donc NCCL en fait une copie dansncclInfo.
Step-by-Step : la chaîne d'appels de ncclAllReduce
PrenonsncclAllReducecomme exemple, et traçons le chemin complet de l'appel utilisateur jusqu'à la construction dencclInfo.
Étape 1 : l'utilisateur appelle ncclAllReduce.L'entrée se trouve danssrc/collectives.cc:
📎 src/collectives.cc:206-211
Ici, trois choses sont faites :
1. NVTX3_FUNC_WITH_PARAMSposer un marqueur NVTX (pour la visualisation dans des outils comme Nsight)
2. appelerncclAllReduceConfigImpl, en passantconfig = nullptr
3. retourner le résultat
Étape 2 : ncclAllReduceConfigImpl construit ncclInfo.C'est l'étape clé :
📎 src/collectives.cc:192-202
Notez l'utilisation ici de l'initialisation agrégée de style C :
struct ncclInfo info = {ncclFuncAllReduce, "AllReduce",
sendbuff, recvbuff, count, datatype, op, 0, comm, stream,
ALLREDUCE_CHUNKSTEPS, ALLREDUCE_SLICESTEPS};Les champs correspondent un à un dans l'ordre de déclaration dencclInfo.ALLREDUCE_CHUNKSTEPSetALLREDUCE_SLICESTEPSsont définis danssrc/include/collectives.h:
📎 src/include/collectives.h:19-20
NCCL_STEPSest le nombre de pas dans le buffer circulaire (généralement 8 ou 16), donc le chunkSteps d'AllReduce estNCCL_STEPS/2, et sliceSteps estNCCL_STEPS/4. Cela signifie qu'un chunk contient 2 slices.
Étape 3 : analyser la config utilisateur. ncclParseCollConfiganalyse lencclCollConfig_t*passé par l'utilisateur dansinfo.collConfig. Siconfig == nullptr, ce champ reste initialisé à zéro.
Étape 4 : transmettre à ncclEnqueueCheck.C'est la véritable entrée du module enqueue.
Réflexion de conception : pourquoi utiliser l'initialisation agrégée plutôt qu'une affectation champ par champ ?
L'initialisation agrégée présente deux avantages : premièrement, le compilateur vérifie si le nombre de champs correspond (un champ manquant déclenche un avertissement), deuxièmement, le code est plus compact. Mais l'inconvénient est quel'ordre des champs doit être strictement cohérent avec la déclaration de la structure— si quelqu'un insère un champ au milieu dencclInfo, tous les points d'initialisation agrégée seront silencieusement décalés. C'est un risque de maintenance implicite dans le code NCCL.
Piège en production : cycle de vie de config
Un scénario de piège réel : l'utilisateur écrit ce code :
ncclCollConfig_t config = {...};
ncclAllReduceConfig(..., &config);
// config 在这里被销毁(比如是栈变量,函数返回了)Si NCCL ne copiait pas la config dansncclInfo, alors lors dencclGroupEnd, l'accès àinfo.collConfiglirait de la mémoire déjà libérée.src/include/info.h:41-43Le commentaire desert précisément à expliquer cette conception —。
---
la config est analysée et copiée dès la phase task append, et ne dépend plus ensuite du pointeur utilisateur
II. ncclEnqueueCheck : validation des paramètres et sémantique de group
ncclEnqueueCheckModèle intuitifest la « vanne principale » du module enqueue. Toutes les API de communication collective convergent finalement ici. Sa responsabilité est :valider la légalité des paramètres, gérer la sémantique de group, appeler taskAppend pour générer les tâchesncclEnqueueCheck。
. Si on le compare au contrôle de sécurité d'un aéroport, alors chaque fonction API est un comptoir d'enregistrement — l'enregistrement ne fait qu'accepter les bagages, le véritable contrôle de sécurité se trouve dans
Étape par étape : le flux d'exécution de ncclEnqueueCheck
📎 src/enqueue/enqueue.cc:3478-3527
Décomposons progressivement :
Étape 1 : CommCheck valide le domaine de communication. CommCheck(info->comm, info->opName, "comm")Vérifie si le pointeur comm est non nul et s'il a été initialisé. Si comm a été révoqué (par exemple, une erreur sur un rank), retourne directement une erreur :
📎 src/enqueue/enqueue.cc:3480-3485
Étape 2 : gérer la profondeur du profiler.Si on est déjà à l'intérieur d'un group (profilerGroupDepth > 0), incrémente le compteur de profondeur. Cela sert à gérer correctement les appels implicites àncclGroupStartInternal/ncclGroupEndInternal.
Étape 3 : entrer dans le group interne. ncclGroupStartInternal()est le mécanisme de group interne de NCCL.Point clé: même si l'utilisateur n'appelle pas explicitementncclGroupStart, NCCL crée un group implicite pour chaque appel d'API. Cela garantit l'atomicité d'un appel unique.
Étape 4 : s'assurer que comm est prêt. ncclCommEnsureReady(info->comm)Attend que l'initialisation du domaine de communication soit terminée (par exemple, bootstrap terminé, connexions établies).
Étape 5 : validation des paramètres par ArgsCheck.C'est l'étape de validation la plus complexe :
📎 src/enqueue/enqueue.cc:3497-3503
Attention au traitement decheckMode: s'il s'agit dencclCheckModeDebugGlobal,ArgsCheck, info est mis en file d'attente, et la validation globale est effectuée au moment dencclGroupEnd(par exemple, vérifier que les count de tous les ranks sont cohérents).
Étape 6 : appeler taskAppend.C'est l'étape de conversion centrale :
📎 src/enqueue/enqueue.cc:3513
Étape 7 : incrémenter opCount.Après chaque mise en file d'attente réussie,comm->opCount++. Ce compteur sert à faire correspondre les opérations send/recv, et constitue aussi la base de la timeline du profiler.
Étape 8 : quitter le group. ncclGroupEndInternal()Si depth descend à 0, cela déclenche la véritable opération de group (ordonnancement, lancement du kernel).
Contrôle de concurrence : sémantique de group et sûreté de thread
ncclGroupStartInternal/ncclGroupEndInternalutilise le stockage local au thread (TLS) pour maintenir l'état du group. Cela signifie queplusieurs appels d'API dans le même thread seront fusionnés en un seul group, mais les appels de threads différents sont indépendants. C'est la base du support du multi-thread par NCCL.
Un piège facile à rencontrer : si l'utilisateur appelle une API CUDA non-NCCL entrencclGroupStartetncclGroupEnd(par exemplecudaMemcpy), cela peut provoquer des problèmes d'ordre des streams. Le mécanisme de group de NCCL suppose que les opérations du group se trouvent sur le même ensemble de streams.
Chaîne de récupération d'erreur
ncclEnqueueCheckLa gestion des erreurs de
📎 src/enqueue/enqueue.cc:3524-3526
possède une conception ingénieuse :taskAppendSincclCommSetAsyncErroréchoue, et que comm est en mode non bloquant,
---
est appelé pour enregistrer l'erreur. Ainsi, les appels d'API suivants retourneront immédiatement une erreur au lieu de continuer à essayer. C'est le mécanisme de propagation asynchrone des erreurs.
III. taskAppend : le carrefour de la distribution des tâches
taskAppendModèle intuitifinfo->collest le « hub de trafic » du module enqueue. Selon la valeur de
, il distribue les tâches vers différents chemins de traitement : P2P, RMA, CE, ou communication collective ordinaire. C'est comme un centre de tri postal — selon l'adresse sur l'enveloppe, il dépose la lettre dans différentes boîtes aux lettres.
Sans cette couche de distribution, tous les types d'opérations devraient s'entasser dans un énorme if-else, et le code serait difficile à maintenir.
📎 src/enqueue/enqueue.cc:3337-3476
Étape par étape : la logique de distribution de taskAppend ncclParamEnqueueRearchEnable()Étape 1 : déterminer si la nouvelle architecture est activée.rawTaskAppendest un commutateur de variable d'environnement (0 par défaut). S'il est activé, on passe par le chemin
— c'est le nouveau modèle de tâches en cours de développement par NCCL.Étape 2 : distribution P2P.p2pTaskAppend:
📎 src/enqueue/enqueue.cc:3343-3345
S'il s'agit de Send/Recv, appelerÉtape 3 : distribution RMA.rmaTaskAppend:
📎 src/enqueue/enqueue.cc:3346-3347
S'il s'agit de PutSignal/Signal/WaitSignal, appeler if (info->count == 0) return ncclSuccess;Étape 4 : retour anticipé pour communication collective vide.
— une communication collective avec count à 0 est directement abandonnée. ncclCollConfigGetAlgMaskÉtape 5 : validation du choix d'algorithme.
📎 src/enqueue/enqueue.cc:3357-3358
Valide si le choix d'algorithme fourni par l'utilisateur est légal :Étape 6 : vérification du type FP8.
📎 src/enqueue/enqueue.cc:3360-3366
La réduction FP8 nécessite sm90+ : hostToDevRedOpÉtape 7 : conversion de l'opération de réduction.ncclRedOp_tConvertit lencclDevRedOpFull:
📎 src/enqueue/enqueue.cc:3370-3371
côté host encôté device.comm->nRanks == 1Étape 8 : retour anticipé pour un seul rank.ncclLaunchOneRankSi
📎 src/enqueue/enqueue.cc:3373-3377
, appeler directementpour exécuter la réduction locale, sans générer de tâche :
📎 src/enqueue/enqueue.cc:3378-3470
Étape 9 : chemin multi-rank.
collTaskAppendC'est la branche la plus complexe, incluant le routage CE, la dégradation AllToAll/Gather/Scatter, ainsi que la communication collective ordinaire :ncclTaskCollStructure de données : les champs de ncclTaskColl
📎 src/enqueue/enqueue.cc:2757-2851
est l'endroit où
| est généré. Regardons sa logique centrale : | Affectation des champs clés : | Champ |
|---|---|---|
func | info->coll | Source |
sendbuff/recvbuff | info->sendbuff/recvbuff | Signification |
count | info->count | Type de communication collective |
datatype | info->datatype | Pointeur de buffer |
trafficBytes | count * elementSize * ncclFuncTrafficPerByte | Nombre d'éléments |
opHost/opDev | info->op/opDev | Type de données |
chunkSteps/sliceSteps | info->chunkSteps/sliceSteps | Estimation du trafic |
minCTAs/maxCTAs/nvlsCTAs | Opération de réduction | Nombre d'étapes de découpage |
algMask | ncclCollConfigGetAlgMask | Analyse de configuration |
Limite de ressourcestrafficBytesMasque de sélection d'algorithme
📎 src/enqueue/enqueue.cc:2813
ncclFuncTrafficPerByteAttention au calcul de
📎 src/enqueue/enqueue.cc:123-134
:
retourne le multiplicateur de trafic pour chaque type de communication collective :
📎 src/enqueue/enqueue.cc:2808-2812
AllReduce retourne 2 (car il faut reduce + broadcast), AllGather/ReduceScatter retourne nRanks, les autres retournent 1.ncclInt8. C'est une optimisation :Ces deux opérations n'impliquent pas de réduction, il n'est donc pas nécessaire de se soucier du type de données ; un traitement uniforme en octets permet de simplifier la logique du kernel。
Piège en production : l'ordre de résolution de CTAPolicy
📎 src/enqueue/enqueue.cc:3390-3397
La résolution de CTAPolicy a une priorité subtile :env > per-call > comm. Et de plusNCCL_CTA_POLICY_ZEROest prioritaire surNCCL_CTA_POLICY_EFFICIENCY. Si l'utilisateur définit ces deux indicateurs en même temps, ZERO prend effet.
Un scénario de piège réel : l'utilisateur a définiNCCL_CTA_POLICY=EFFICIENCY, mais a constaté que le chemin CE n'était pas utilisé. La raison est que le routage CE exige queCTAPolicy & NCCL_CTA_POLICY_ZEROsoit vrai, or EFFICIENCY ne satisfait pas cette condition.
---
IV. ncclPrepareTasks : de la liste de tâches à la file de planification
Modèle intuitif
ncclPrepareTasksest le « préprocesseur » du module enqueue. Il répartit la liste de tâches en désordre dans des compartiments selon (func, op, datatype), puis calcule l'algorithme et le protocole pour chaque compartiment. C'est comme un bibliothécaire — il trie d'abord les livres rendus par catégorie, puis décide sur quelle étagère placer chaque catégorie de livres.
Sans cette étape, lescheduleCollTasksToPlansuivant devrait calculer l'algorithme séparément pour chaque tâche, ce qui serait extrêmement inefficace.
Step-by-Step : la logique de compartimentage de ncclPrepareTasks
📎 src/enqueue/enqueue.cc:423-642
Étape 1 : conversion des tâches Broadcast.S'il n'y a qu'un seul broadcast peer, convertir la tâche broadcast en tâche coll :
📎 src/enqueue/enqueue.cc:430-461
Notez qu'ici on copie les champs debcastTaskvers le nouveauncclTaskColl, et on calculetrafficBytes. Puis on libère la tâche originale depuismemPool_ncclTaskBcast.
Étape 2 : compartimentage par (func, op, datatype).Les tâches sortent du sorter par ordre décroissant de size, puis sont réparties dans le tableautasksByFnOpTy:
📎 src/enqueue/enqueue.cc:464-487
Calcul de l'indice :((int)task->func * ncclNumDevRedOps + (int)task->opDev.op) * ncclNumTypes + (int)task->datatype. C'est une linéarisation d'un tableau tridimensionnel.
Étape 3 : agrégation et sélection d'algorithme.Pour chaque compartiment, agréger les tâches de taille similaire (dans un facteur 4), puis appelerncclGetAlgoInfo:
📎 src/enqueue/enqueue.cc:503-547
Étape 4 : compartimentage par (collnet, nvls).Selon le type d'algorithme, répartir les tâches danscollBins[2][2]:
📎 src/enqueue/enqueue.cc:517-544
Étape 5 : concaténation de la file finale.Concaténer les quatre compartiments enplanner->collTaskQueue:
📎 src/enqueue/enqueue.cc:553-557
Structure de données : ncclTaskCollSorter
ncclTaskCollSorterest un trieur par insertion trié selontrafficBytes.ncclTaskCollSorterInsertinsère la tâche à la bonne position,ncclTaskCollSorterDequeueAllretire toutes les tâches dans l'ordre.
La motivation de conception de ce trieur est :priorité de planification aux grosses tâches. Comme les grosses tâches ont un temps de transfert long, les démarrer en premier permet de mieux superposer calcul et communication.
Contrôle de concurrence : runtimeConn et établissement de connexion
📎 src/enqueue/enqueue.cc:572-583
Sicomm->runtimeConnest vrai (mode de connexion à l'exécution), et qu'un channel d'un algorithme n'est pas encore initialisé, alors marqueralgoNeedConnect. Cela déclenchera l'établissement de la connexion par la suite.
Piège en production : conditions aux limites de l'agrégation
📎 src/enqueue/enqueue.cc:507-508
La condition d'agrégation estaggEnd->trafficBytes < 4 * aggBeg->trafficBytes, et aucune des deux tâches ne définitaggIsolate. Si l'utilisateur définit une per-call config (par exemplemaxCTAs),aggIsolatesera mis à true, cette tâche ne sera pas agrégée.
Un scénario de piège réel : l'utilisateur a défini pour un certain AllReducemaxCTAs=4, en s'attendant à ce qu'il n'utilise que 4 CTA. Mais à cause de la logique d'agrégation, cette tâche peut fusionner avec une tâche adjacente, entraînant un nombre de CTA réellement utilisé non conforme aux attentes. La solution est de définiraggIsolate— NCCL l'a déjà géré danscollTaskAppend:
📎 src/enqueue/enqueue.cc:2821-2822
---
V. scheduleCollTasksToPlan : découpage des channels et contrôle du budget
Modèle intuitif
scheduleCollTasksToPlanest le « planificateur » du module enqueue. Il répartit les tâches sur des channels spécifiques et calcule le découpage des données pour chaque channel. C'est comme le système de planification de production d'une usine — il décide ce que fait chaque ligne de production et en quelle quantité.
Sans cette étape, le kernel GPU ne saurait pas quelle partie des données il doit traiter.
Step-by-Step : algorithme de découpage des channels
📎 src/enqueue/enqueue.cc:644-947
Étape 1 : estimation du budget.On estime d'abord le nombre de tâches pouvant entrer dans ce plan :
📎 src/enqueue/enqueue.cc:648-689
ncclTestBudgetVérifier si le nombre d'octets de travail dépasse le budget :
📎 src/enqueue/enqueue.cc:343-349
Étape 2 : calcul du trafic de chaque channel.Selon le kind (collnet/nvls), calculertrafficPerChannel:
📎 src/enqueue/enqueue.cc:701-707
Étape 3 : chemin Collnet.S'il s'agit d'un algorithme collnet, l'allocation des channels est relativement simple :
📎 src/enqueue/enqueue.cc:709-739
Étape 4 : découpage en cell du chemin normal.C'est la partie la plus complexe. NCCL découpe les données en « cell », chaque cell étant une unité de transfert minimale :
📎 src/enqueue/enqueue.cc:740-845
Variables clés :
cellSize: nombre d'octets par cell, au moinsMinTrafficPerChannel(32KB)cells: nombre total de cellscellsPerChannel: nombre de cells traitées par chaque channelcellsLo/cellsHi: nombre de cells des channels de début et de fin (peut être incomplet)
Étape 5 : calcul de chunkGrains.Appeler pour chaque segment de channelcalcCollChunking:
📎 src/enqueue/enqueue.cc:811-825
Étape 6 : génération des proxyOp.Générer une opération proxy pour chaque channel :
📎 src/enqueue/enqueue.cc:844-894
Structure de données : ncclDevWorkColl
ncclDevWorkCollest le descripteur de travail côté device. Ses champs clés :
| Champ | Signification |
|---|---|
sendbuff/recvbuff | Pointeur de buffer |
channelLo/channelHi | Plage de channels |
cbd.countLo/countMid/countHi | Nombre d'éléments par segment |
cbd.chunkGrainsLo/Mid/Hi | Granularité de chunk par segment |
direct | Indicateur direct |
Contrôle de concurrence : opérations bit à bit sur channelMask
📎 src/enqueue/enqueue.cc:897
Cette ligne de code définit channelMask par des opérations bit à bit :(2ull << channelHi) - (1ull << channelLo). Par exemple channelLo=2, channelHi=5, le résultat est(2<<5) - (1<<2) = 64 - 4 = 60 = 0b111100, c'est-à-dire que les bits 2-5 sont définis.
Piège en production : dépassement de budget
📎 src/enqueue/enqueue.cc:792-794
Si le budget est insuffisant, retourner directementncclSuccess, laisser la boucle externe créer un nouveau plan. C'est une stratégie de dégradation élégante——pas d'erreur, juste un traitement par lots。
Un scénario de piège réel : siNCCL_WORK_FIFO_BYTESest défini trop petit, chaque plan ne pourra contenir que très peu de tâches, augmentant le nombre de lancements de kernel et réduisant les performances.
---
Six, finishPlan : des tâches aux paramètres du kernel
Modèle intuitif
finishPlanest le "packageur" du module enqueue. Il empaquette les tâches, batchs et proxyOp en une structure de paramètres que le kernel peut lire directement. C'est comme l'emballage d'un colis——mettre les pièces détachées dans une boîte, coller le bordereau d'expédition, et attendre l'envoi.
Étape par étape : la logique d'empaquetage de finishPlan
📎 src/enqueue/enqueue.cc:236-330
Étape 1 : décider du type de stockage.Si tout le travail peut tenir dans kernel args, utiliserncclDevWorkStorageTypeArgs:
📎 src/enqueue/enqueue.cc:244-250
Étape 2 : allouer kernelArgs.Allouer depuis la pile mémoire :
📎 src/enqueue/enqueue.cc:251-255
Étape 3 : placement round-robin des batchs.Le premier batch de chaque channel doit être placé dansbatchZero[blockIdx.x]:
📎 src/enqueue/enqueue.cc:257-280
Étape 4 : fusionner les files proxyOp.Tri par fusion selon opCount :
📎 src/enqueue/enqueue.cc:282-329
Structure de données : ncclDevKernelArgs
ncclDevKernelArgsest la structure de paramètres transmise au kernel. Elle contient :
comm: communicateur côté devicechannelMask: masque de bits des channelsworkStorageType: type de stockage de travailworkBuf: pointeur du buffer de travailworkMask: masque du buffer de travail
Piège en production : ordre des batchs
📎 src/enqueue/enqueue.cc:257-259
Le commentaire est très clair : "The first batch for each channel must be located at batchZero[blockIdx.x]". Si cet ordre est incorrect, le kernel lira le mauvais batch, entraînant une corruption des données.
---
Résumé du chapitre
Dans ce chapitre, nous avons suivi le chemin complet depuisncclAllReducejusqu'àncclTaskColl:
1. ncclAllReduceconstruitncclInfo, empaquette les paramètres utilisateur
2. ncclEnqueueCheckvalide les paramètres, traite la sémantique de groupe
3. taskAppenddistribue vers différents chemins selon le type d'opération
4. collTaskAppendgénèrencclTaskColl, analyse la configuration
5. ncclPrepareTasksrépartit par (func, op, datatype), calcule l'algorithme
6. scheduleCollTasksToPlandécoupe les channels, génèrencclDevWorkColl
7. finishPlanempaquette en paramètres de kernel
Idées de conception clés :
- Découplage par couches: chaque fonction ne fait qu'une seule chose, transmet l'état via
ncclInfoetncclTaskColl - Contrôle du budget: contrôle la taille de chaque plan via
ncclTestBudget - Optimisation par agrégation: les tâches de taille similaire sont agrégées, réduisant le nombre de lancements de kernel
- Priorité de configuration:env > per-call > comm
Dans le prochain chapitre, nous entrerons danstask_sched, pour voir comment NCCL orchestre l'ordre d'exécution multi-channel et multi-kernel.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime le testcollTaskAppenddansaggIsolate(c'est-à-dire quesrc/enqueue/enqueue.cc:2821-2822retourne toujours false), dans quel scénario lemaxCTAsdéfini par l'utilisateur deviendrait-il inopérant ? Pourquoi ?
Analyse de référence:aggIsolatesert à marquer "cette tâche ne peut pas être agrégée". Si l'on supprime ce test, les tâches avec une configuration per-call définie seraient fusionnées avec les tâches adjacentes. DansncclPrepareTasksla boucle d'agrégation de (src/enqueue/enqueue.cc:507-508), la condition d'agrégation estaggEnd->trafficBytes < 4 * aggBeg->trafficBytes && !aggBeg->aggIsolate && !aggEnd->aggIsolate. SiaggIsolateest toujours false, alors même si une tâche a définimaxCTAs=4, elle pourrait être fusionnée avec une tâchemaxCTAs=32. Leaggfusionné prendra une certaine combinaison des deux (selon l'implémentation dencclGetAlgoInfo), entraînant un nombre réel de CTA utilisés non conforme aux attentes de l'utilisateur.
Plus grave encore, dansscheduleCollTasksToPlan(src/enqueue/enqueue.cc:665-666),taskAggIsolatesert à garantir que les tâches avec des ressources per-call configurées occupent seules un plan. Si ce test devient inopérant, plusieurs tâches partageront le budget de channel du plan, entraînant une allocation de ressources non conforme aux attentes.
Q2 : DansncclEnqueueCheck, sincclGroupEndInternal()retourne une erreur (par exemple l'échec de ArgsCheck d'un certain rank), mais quetaskAppenda déjà été exécuté avec succès, que se passe-t-il ? Comment NCCL garantit-il la cohérence d'état ?
Analyse de référence: regarder le flux de contrôle desrc/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());SitaskAppendréussit mais quencclGroupEndInternaléchoue,opCounta déjà été incrémenté. Cela entraînera une inadéquation de l'opCount des opérations suivantes avec le pair, pouvant déclencher un hang.
La façon dont NCCL gère cela est :ncclGroupErrCheck(ret)vérifie s'il y a une erreur, et si oui, définit l'état d'erreur de comm. Les appels API suivants détecteront cette erreur viancclCommGetAsyncErroret retourneront immédiatement. C'est une stratégie de "échec rapide"——une fois qu'une erreur survient, tout le comm entre en état d'erreur et ne tente plus de récupération.
En environnement de production, cela signifie qu'une fois qu'une erreur de groupe survient, l'utilisateur doit détruire et reconstruire le communicateur.
Q3: scheduleCollTasksToPlanL'algorithme de découpage en cellules danssrc/enqueue/enqueue.cc:740-845(cellsLo == 0) a une condition limite : lorsquechannelId, il saute le moins de channels. Si cette logique de saut a un bug (par exemple
n'est pas correctement incrémenté), quelles en seraient les conséquences ?Analyse de référencesrc/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;
}
}CopierchannelIdSi
1. n'est pas correctement incrémenté, la tâche suivante commencera son allocation à partir d'un mauvais channel. Cela entraînera :Chevauchement de channels
2. : deux tâches pourraient être allouées au même segment de données du même channelCorruption de données
3. : le kernel traitera les données en double ou en omettra: déséquilibre de charge des channels
Plus insidieux encore, ce bug peut ne se déclencher que pour une taille de message spécifique (lorsquecellsLo == 0), ce qui le rend difficile à reproduire. NCCL suit les channels déjà utilisés viaplan->channelMask |= (2ull << devWork->channelHi) - (1ull << devWork->channelLo), mais ce n'est qu'un enregistrement, cela n'empêche pas les chevauchements.
Jusqu'ici, nous avons vu comment ncclAllReduce passe d'un appel utilisateur à une série de tâches kernel exécutables : validation des paramètres, détermination de l'algorithme/du protocole, découpage en channels, génération finale de ncclInfo et ncclTaskColl. Mais la création des tâches n'est que la première étape — elles doivent encore être ordonnancées sur plusieurs channels, générer les paramètres de lancement des kernels, et gérer la soumission par lots et le tri des dépendances dans la sémantique de groupe. Le chapitre suivant approfondira src/enqueue/task_sched et src/enqueue/task_prep, pour répondre à « pourquoi un seul AllReduce lance-t-il plusieurs kernels, et comment l'ordre et les dépendances entre eux sont-ils garantis », tout en révélant comment ncclGroupStart/ncclGroupEnd dans src/group.cc fusionnent plusieurs appels API en une seule soumission.
Chapitre 7 : Chapitre 7 : Ordonnanceur de tâches : comment task_sched orchestre l'ordre d'exécution multi-channel et multi-kernel
Chapitre 7 : Ordonnanceur de tâches : comment task_sched orchestre l'ordre d'exécution multi-channel et multi-kernel
Dans le chapitre précédent, nous avons suivi ncclAllReduce jusqu'à ncclTaskColl — l'objet de description de tâche réside désormais dans comm->planner. Mais la description de tâche n'est qu'un « bon de travail », elle n'est pas encore devenue un kernel réellement exécuté sur le GPU. Ce chapitre répond à trois questions : comment plusieurs appels API sont-ils accumulés puis soumis ensemble ? Comment les tâches accumulées sont-elles réparties sur plusieurs channels ? Par quoi l'ordre et les dépendances entre plusieurs kernels sont-ils garantis ? Commençons par un modèle mental global. Imaginez NCCL comme un restaurant : ncclGroupStart/ncclGroupEnd est le « panier », l'utilisateur y dépose plusieurs plats (plusieurs appels de communication collective) ; ncclGroupEnd est la « commande », la cuisine ne commence à préparer les plats qu'à partir de la commande. Et doLaunches est le « dispatcheur de plats », il décide quels plats sont servis en premier et lesquels peuvent être préparés en parallèle. Sans la sémantique de groupe, chaque plat est commandé séparément, et la cuisine doit rallumer le feu (lancer le kernel) à chaque plat, ce qui coûte extrêmement cher ; sans l'ordonnancement par tours de doLaunches, les kernels multi-channel démarreraient dans le désordre, brisant les dépendances de données.
I. État global de la sémantique de groupe : variables thread_local et modèle du « panier »
Modèle intuitif
ncclGroupStartetncclGroupEndTous les appels de communication entre ne lancent pas immédiatement le kernel, mais sont « accumulés ». Où sont-ils accumulés ? Dans des variables globaleslocales au thread (thread_local). Pourquoi thread_local ? Parce que NCCL suppose que les appels de groupe au sein d'un même thread sont séquentiels, et que différents threads ont chacun leur propre panier, sans interférence mutuelle. Si ces états étaient des variables globales plutôt que thread_local, deux threads appelant simultanémentncclGroupStartse marcheraient dessus, entraînant la soumission des tâches d'un thread par lencclGroupEndd'un autre thread — ce serait catastrophique.
Structures de données et disposition mémoire
Regardons d'abord la définition de l'état global du groupe.
📎 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 */Décomposition champ par champ :
ncclGroupDepth: profondeur d'imbrication.ncclGroupStartpeut être appelé de manière imbriquée (bien que rare), chaquencclGroupStartincrémente de un,ncclGroupEnddécrémente de un. La soumission réelle n'a lieu que lorsque le compteur atteint 0. C'est comme un panier qui peut être imbriqué — vous ouvrez un sous-panier dans un panier, et la commande n'est réellement passée qu'au moment du règlement du panier le plus externe.ncclGroupError: si un appel quelconque au sein du groupe échoue, l'erreur est enregistrée ici et traitée uniformément lors duncclGroupEnd. Cela évite l'état incohérent où « après l'échec d'un appel, les appels suivants continuent d'ajouter des éléments au panier ».ncclGroupCommHead[ncclGroupTaskTypeNum]: têtes de liste chaînée des domaines de communication groupés par type de tâche.ncclGroupTaskTypeNumest le nombre de types de tâches (communication collective, tâches brutes, tâches de gestion, enregistrement symétrique, etc.). Chaque type a une liste chaînée, dont les nœuds sont desncclComm, reliés parcomm->groupNext[type]. Pourquoi grouper par type ? Parce que différents types de tâches ont des moments de soumission et des relations de dépendance différents — les tâches de communication collective nécessitent d'abord un preconnect, les tâches de gestion (comme destroy) doivent être exécutées en dernier.ncclGroupCommPreconnectHead: liste chaînée des domaines de communication nécessitant une préconnexion. La préconnexion consiste à « établir les connexions réseau à l'avance », pour éviter la latence causée par l'établissement de connexions au moment du lancement du kernel.ncclAsyncJobs: file de tâches asynchrones. Certaines tâches (commencclCommInitRank) sont asynchrones, elles sont placées dans cette file et lancées uniformément lors duncclGroupEnd.ncclGroupBlocking: indicateur de mode bloquant.-1signifie pas encore déterminé,0signifie non bloquant,1indique un blocage. Au sein d'un même groupe, il n'est pas permis de mélanger des domaines de communication bloquants et non bloquants, sinon une erreur est signalée.
Il y a ici une conception clé :ncclGroupCommHeadestun tableau, chaque élément étant une liste chaînée. Les nœuds de la liste sont chaînés viacomm->groupNext[type], plutôt que d'utiliser une structure de nœud de liste indépendante. Cela signifie quencclCommla structure doit réserver un champ tableaugroupNext. Cette conception de « liste chaînée intrusive » évite une allocation mémoire supplémentaire, mais au prix d'unencclCommstructure plus volumineuse.
Parcours pas à pas guidé par scénario
Scénario: l'utilisateur appellencclGroupStart(), puis appelle consécutivement deux foisncclAllReduce(respectivement pour deux domaines de communication différents commA et commB), et enfin appellencclGroupEnd()。
Première étape :ncclGroupStartqu'a-t-il fait ?
📎 src/include/group.h:63-66
inline ncclResult_t ncclGroupStartInternal() {
ncclGroupDepth++;
return ncclSuccess;
}Extrêmement simple : incrémenter la profondeur de un. Aucune allocation mémoire, aucun verrou, aucun appel système. C'est pourquoincclGroupStarta un coût quasi nul.
Deuxième étape :ncclAllReduceque se passe-t-il lorsqu'il est appelé au sein d'un groupe ?
ncclAllReduceappelle en internencclGroupCommJoin(comm, ncclGroupTaskTypeCollective), ajoutant le domaine de communication à la liste chaînée du groupe.
📎 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;
}Ce code présente plusieurs subtilités :
1. Vérification d'idempotence:if (comm->groupNext[type] == NCCL_COMM_GROUP_INVALID)garantit qu'un même domaine de communication n'est ajouté qu'une seule fois dans un même groupe. Si l'utilisateur appelle deux foisncclAllReducepour le même comm, la deuxième fois n'ajoutera pas à nouveau dans la liste chaînée, mais la tâche sera ajoutée àcomm->planner.
2. Tri par clique:intraComm0est l'identifiant d'une « entité globale ». Si plusieurs domaines de communication appartiennent à la même entité globale (par exemple issus d'une division viancclCommSplit), leurintraComm0est identique, et ils sont appelés un clique. Le code recherche d'abord le clique parintraComm0, et insère le comm à côté des nœuds frères du même clique. Si aucun clique n'est trouvé, il insère par ordre croissant decommHash. Ce tri vise à ce quedoLaunchespuisse correctement gérer la synchronisation barrier au sein du clique.
3. Portée de la pile mémoire:ncclMemoryStackPush(&comm->memScoped)alloue une nouvelle portée de pile mémoire pour ce comm dans le groupe. Toutes les tâches allouées pour ce comm (ncclTaskColl, etc.) sont allouées depuis cette pile.ncclGroupCommLeaveeffectuencclMemoryStackPoppour libérer en une seule fois toute la mémoire des tâches — c'est l'optimisation classique « allocation par lots, libération par lots », évitant le coût demalloc/freeindividuel pour chaque tâche.
4. Réinitialisation du planner:memset(&comm->planner, 0, sizeof(comm->planner))vide le planner, mais conserve les pointeurspeersetrmaTaskQueues(stockés d'abord dans des variables temporaires, puis restaurés après memset). Pourquoi les conserver ? Parce que ce sont des tableaux préalloués qui n'ont pas besoin d'être réalloués à chaque fois.bcast_infoLes min/max de sont réinitialisés àINT_MAX/INT_MIN, pour l'optimisation de fusion des tâches broadcast ultérieures.
Troisième étape :ncclGroupEndqu'a-t-il fait ?
📎 src/group.cc:1039-1164
ncclGroupEndInternalest le cœur. Analysons section par section :
📎 src/group.cc:1048-1061
if (ncclGroupDepth == 0) {
WARN("ncclGroupEnd: not in a group call.");
ret = ncclInvalidUsage;
goto exit;
}
// ...
if ((--ncclGroupDepth) > 0) goto exit;On vérifie d'abord la profondeur, puis on décrémente de un. Si après décrémentation elle est encore supérieure à 0, cela signifie qu'on est encore dans un groupe imbriqué interne, on retourne directement sans soumettre. On ne continue que lorsque la décrémentation atteint 0.
📎 src/group.cc:1063
if ((ret = ncclGroupError) != ncclSuccess) goto fail;Si un appel au sein du groupe a échoué, on saute directement au nettoyage 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);On crée unncclGroupJob, en « transférant » l'état du groupe thread_local vers l'objet job.ncclIntruQueueTransfertransfère l'intégralité de la filencclAsyncJobsversgroupJob->asyncJobs. Cette étape est cruciale : l'état thread_local est « temporaire », l'objet job est « persistant » et peut être détenu par un thread asynchrone.
📎 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;
}Mode bloquant : appel direct degroupLaunchsur le thread courant, exécution synchrone. Mode non bloquant : création d'un thread exécutantgroupLaunchNonBlocking, retour immédiat dencclInProgress. L'utilisateur interroge ensuite la progression viancclCommGetAsyncError.
Attention à la sauvegarde et restauration decudaGetDevice/cudaSetDevice:groupLaunchchange en interne le périphérique CUDA (car différents comm peuvent être sur différents GPU), puis restaure le périphérique d'origine de l'utilisateur après exécution. Cela évite que « NCCL change de périphérique en interne sans le restaurer », ce qui ferait que les appels CUDA ultérieurs de l'utilisateur s'exécutent sur le mauvais périphérique.
Réflexions de conception et pièges en production
Piège 1 : mélange de domaines de communication bloquants et non bloquants。ncclAsyncLaunchcontient une vérification :
📎 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;
}Pourquoi le mélange n'est-il pas autorisé ? Parce qu'un groupe bloquant s'exécute de manière synchrone sur le thread courant, tandis qu'un groupe non bloquant s'exécute de manière asynchrone sur un thread indépendant. En cas de mélange, il est impossible de déterminer sincclGroupEnddoit retourner de manière synchrone ou retournerncclInProgress. En production, si l'utilisateur place par inadvertance des comm bloquants et non bloquants dans le même groupe, il recevrancclInvalidArgument, mais à ce moment l'état du groupe a déjà été pollué, et il faut refairencclGroupStart。
Piège 2 :ncclGroupErrorpropagation de. Si un appel au sein du groupe échoue,ncclGroupErrorest défini,ncclGroupEndsaute vers la branche fail pour exécutergroupCleanup。groupCleanupparcourt tous les comm, libère la mémoire des plans dans le planner, réinitialise le planner, nettoie rawTaskQueue. Si cette étape n'est pas faite proprement, au prochainncclGroupStartle planner contiendra des données résiduelles, entraînant une soumission en double des tâches ou une fuite mémoire.
📎 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.
// ...
}
// ...
}
}
// ...
}Attention à la lignecomm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1). C'est une « valeur sentinelle » indiquant que « ce comm doit être reconnecté via preconnect ». Pourquoi ? Parce que lors du cleanup, on ne sait pas si le preconnect a réussi, donc on force une nouvelle vérification la prochaine fois.0x1Cette valeur est astucieuse — ce n'est pas un pointeur valide, mais elle peut servir de marqueur « non initialisé ».ncclGroupCommPreconnectvérifieif (comm->preconnectNext == reinterpret_cast<struct ncclComm*>(0x1))pour déterminer s'il faut ajouter à la liste chaînée preconnect.
---
II. Préparation des tâches :ncclPrepareTaskscomment transformer une description de tâche en unité ordonnançable
Modèle intuitif
ncclPrepareTasksC'est l'étape de « préparation des ingrédients ». Les ingrédients dans le panier (description de la tâche) sont encore crus ; il faut d'abord les laver, les couper et les apprêter (déterminer l'algorithme, le protocole, le découpage des channels) avant de pouvoir les mettre à la poêle (lancer le kernel). Si l'on saute cette étape et que l'on lance directement le kernel, celui-ci ne saura pas comment découper les données ni quel chemin emprunter, et plantera immédiatement.
Parcours pas à pas guidé par scénarios
ncclPrepareTasksest appelé dansgroupLaunchLegacy:
📎 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;
}ncclPrepareTasksproduit deux choses :algoNeedConnectle tableau (quels algorithmes nécessitent l'établissement d'une connexion) etneedConnectle flag (si une connexion est nécessaire). SineedConnectest vrai et que cuMem est pris en charge, un preconnect job est créé et exécuté de manière asynchrone.
ncclPrepareTasksQue fait-il en interne ? Il parcourtcomm->plannerles tâches, détermine pour chacune l'algorithme et le protocole, puis appelletaskAppendpour ajouter la tâche au plan du planner. Cette logique a déjà été détaillée dans le chapitre précédent et ne sera pas répétée ici.
Points clés :ncclPrepareTasksestappelé comm par comm, mais le preconnect estexécuté par lots par clique. Pourquoi ? VoirgroupLaunchLegacyles commentaires dans :
📎 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);Le commentaire est très clair :effectuer le preconnect clique par clique afin d'éviter que le split des shared comms ne connecte simultanément le même ensemble de connexions, ce qui provoquerait une race condition. Si deux comms sont issus du split d'un même comm parent, ils peuvent partager certaines connexions. Si le preconnect est parallèle, deux threads peuvent tenter d'établir la même connexion en même temps, entraînant des connexions en double ou un état de connexion incohérent. L'exécution séquentielle par clique garantit qu'un seul clique établit des connexions à la fois.
Contrôle de concurrence et interactions bas niveau
asyncJobLaunchest le cœur du lancement des tâches asynchrones :
📎 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;
}Ce code comporte plusieurs choix de conception clés :
1. Optimisation mono-job: s'il n'y a qu'un seul job dans la file, aucun thread n'est créé et l'exécution se fait directement dans le thread courant. Cela évite le surcoût de création et de join de thread. Pour un group mono-comm, c'est le cas courant.
2. Machine à états atomique:job->stateest une variable atomique avec trois états :ncclGroupJobRunning、ncclGroupJobDone、ncclGroupJobJoined. Après exécution, le thread de travail utiliseCOMPILER_ATOMIC_STORE(..., std::memory_order_release)pour passer àDone; le thread principal utiliseCOMPILER_ATOMIC_LOAD(..., std::memory_order_acquire)pour lire. L'appariement release/acquire garantit que toutes les écritures mémoire du thread de travail sont visibles par le thread principal.
3. Attente active + micro-sommeil: le thread principal interroge l'état de tous les jobs ; s'il reste des jobs en cours,sleep_for(1us)puis continue d'interroger. Pourquoi 1 microseconde plutôt qu'une variable de condition ? Parce que le preconnect est une tâche courte (généralement de quelques dizaines de microsecondes à quelques millisecondes), et le coût de réveil d'une variable de condition peut être supérieur à celui de l'attente active. Un sommeil de 1 microseconde évite le gaspillage CPU d'un spin pur.
4. Propagation d'erreur et abort: si un job échoue,errorJobAbortFlagest défini, et leabortFlagde tous les jobs suivants est atomiquement mis à 1. Le thread de travail vérifieabortFlagpendant l'exécution et, s'il détecte un abort, se retire prématurément. C'est un mécanisme de « fail-fast » qui évite qu'après l'échec d'un job, les autres continuent de tourner inutilement.
Diagramme Mermaid : flux de contrôle de la soumission de 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---
III.doLaunches: ordonnancement par tours multi-channel et multi-kernel
Modèle intuitif
doLaunchesest le « dispatcheur de plats ». La cuisine (GPU) dispose de plusieurs feux (channels), et chaque plat (kernel plan) doit être servi dans l'ordre. Mais les plats de comms différents peuvent être servis en parallèle, tandis que ceux d'un même comm doivent l'être dans l'ordre. Le dispatcheur doit garantir que : les comms d'un même clique avancent de manière synchronisée (via barrier), et les cliques différents peuvent avancer indépendamment.
Structures de données et disposition mémoire
doLaunchesLes structures de données centrales dencclKernelPlansontcomm->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;
}Copier
Parcours pas à pas guidé par scénariosScénariointraComm0: deux comms (commA et commB) appartiennent au même clique (
identique), chaque comm ayant 3 kernel plans à lancer.
Première boucle : parcours des cliquesdo-whileLa boucle externecliqueHeadparcourt tous les cliques.do-whileest le premier comm du clique courant. La boucle internecomm->intraComm0 == cliqueHead->intraComm0)。
parcourt tous les comms du clique (
cudaSetDevice(comm->cudaDev)pour chaque comm :ncclLaunchPrepare(comm): basculer vers le GPU correspondant à ce comm.ncclCommIntraBarrierIn(comm, 1): préparer le lancement, notamment configurer le flux CUDA, vérifier les ressources, etc.
: entrer dans la barrier, valeur initiale à 1.
while (true)Deuxième boucle : ordonnancement par tours
La bouclemoreRoundsexécute des « tours ». À chaque tour, chaque comm du clique lance un kernel plan.
- Le point clé réside dans le calcul de(
useBarrier == true):moreRounds = 0 != ncclCommIntraBarrierOut(comm)。ncclCommIntraBarrierOut:en mode avec barrierest unencclCommIntraBarrierInopération de réduction barrier inter-commmoreRounds. Elle attend que tous les comms du clique aient appelémoreRounds, puis renvoie le résultat de réduction de toutes les valeurs d'entrée (ici un OU logique). Si un comm a encore des plans non lancés, le résultat de réduction vaut 1, - est true, et on passe au tour suivant. Si tous les comms n'ont plus de plans non lancés, le résultat de réduction vaut 0,:
moreRounds |= comm->planner.unlaunchedPlansHead != nullptr. Vérifier directement si chaque comm a encore des plans non démarrés. Notez qu'ici on utilise|=, tant qu'un comm a encore un plan,moreRoundsest true.
Pourquoi un barrier est-il nécessaire ? Parce que les comms au sein d'un clique sont des « frères », ils peuvent partager des ressources GPU ou des connexions réseau. Si un comm lance 3 kernels et qu'un autre n'en lance qu'1, le comm ayant terminé en premier entre dansncclLaunchFinish, libère les ressources, tandis que l'autre comm utilise encore ces ressources, provoquant un use-after-free. Le barrier garantit que tous les comms du clique avancent de manière synchrone : soit ils lancent tous le N-ième tour, soit ils entrent tous dans le final round.
Branche de lancement de 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);
}Trois types de plans :
isCeColl: communication collective CollNet (utilisation du déchargement par carte réseau pour la communication collective).isRma: tâches RMA (Remote Memory Access).- Par défaut : kernel GPU ordinaire.
Chaque type a une fonction de lancement différente, mais toutes suivent le modèle « Before -> Launch -> After » :
ncclLaunchKernelBefore_NoUncapturedCuda: préparation avant lancement (configuration des paramètres du kernel, téléversement vers le device, etc.).ncclLaunchKernel: lancement effectif du kernel (cudaLaunchKernel)。ncclLaunchKernelAfter_NoCuda: nettoyage après lancement (mise à jour de l'état, libération des ressources temporaires).
Final round
LorsquemoreRoundsest false, exécuterncclLaunchFinish(comm). Cette étape effectue le nettoyage final : libération de la mémoire du plan, mise à jour de l'état du comm, notification du thread proxy, etc.
Contrôle de concurrence et interaction matérielle
ncclCommIntraBarrierIn/Outest la primitive de synchronisation des comms au sein d'un clique. Son implémentation implique des opérations atomiques et de l'attente active.Inécrit la valeur dans la mémoire partagée,Outattend que tous les comms aient écrit puis lit le résultat de la réduction. Ce barrier estinter-processus(si les comms sont dans des processus différents), et peut reposer en interne sur la mémoire partagée ou le réseau.
Pourquoi utiliser un barrier plutôt qu'un simple « vérifier si tous les comms ont encore des plans » ? Parce que la « vérification » n'est pas atomique : quand commA vérifie, commB a encore un plan, commA décide de continuer ; mais commB lance immédiatement son dernier plan après la vérification de commA et entre dans le final round. commA lance encore des kernels, alors que commB a déjà libéré les ressources partagées. Le barrier transforme « vérification » et « décision » en une opération atomique, éliminant cette condition de course.
Guide de production pour éviter les pièges
Piège 1 : utilisation mixte de 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;
}Si une partie des comms du clique est en mode CUDA graph capture et l'autre non, une erreur est directement signalée. Le commentaire dit « these comms are permanently trashed » — parce qu'ils sont entrés dans le barrier sans en sortir, l'état de barrier de ces comms est définitivement incohérent et ils ne pourront plus être utilisés par la suite. C'est uneerreur irrécupérable, l'utilisateur doit reconstruire le domaine de communication. En production, si l'utilisateur mélange des comms en graph capture et hors capture, il recevrancclInvalidUsage, mais plus grave encore, le comm est déjà corrompu.
Piège 2 :useBarrierdépendance de configuration de。useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup. Si l'utilisateur définitNCCL_LAUNCH_MODE=GROUP, on emprunte le chemin avec barrier ; sinon on emprunte le chemin sans barrier. Dans le chemin sans barrier,moreRoundsutilise|=pour accumuler, mais chaque comm juge indépendamment. Si commA a encore un plan et que commB n'en a pas, commB entre dans le final round et exécutencclLaunchFinish, tandis que commA lance encore des kernels. Dans certains scénarios c'est sûr (pas de ressources partagées entre les comms), mais si des threads proxy ou des connexions réseau sont partagés, cela peut poser problème. C'est pourquoi le mode barrier est recommandé par défaut.
---
Quatre,groupLaunchLegacychaîne d'exécution complète de
Step-by-Step Walkthrough guidé par scénario
groupLaunchLegacyest le flux de soumission complet en mode bloquant. Exécution dans l'ordre :
Phase 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);Pour chaque comm nécessitant un preconnect, créer unncclP2PPreconnectFuncjob, puis les lancer en lot.ncclP2PPreconnectFuncappelle en internencclTransportP2pSetuppour établir la connexion P2P.
Phase 2 : enregistrement de la mémoire symétrique
📎 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
}
}L'enregistrement de la mémoire symétrique (ncclCommWindowRegisteretc.) est exécuté en lot par clique.
Phase 3 : preconnect de communication collective
📎 src/group.cc:810-870
if (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr) {
// 按 clique 逐个 prepare + preconnect
// 然后 ncclTasksRegAndEnqueue
// 然后 debug check
}C'est la phase centrale. AppelerncclPrepareTasksAndCollPreconnectclique par clique, puisasyncJobLaunchexécute le preconnect. Une fois le preconnect terminé, appelerncclTasksRegAndEnqueuepour enregistrer la tâche dans le plan et générer les paramètres de lancement du kernel.
Phase 4 :doLaunches
📎 src/group.cc:872-874
if ((!simInfo) && (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr)) {
NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeCollective], ncclGroupTaskTypeCollective), ret, fail);
}Lancer tous les plans de kernel.
Phase 5 : nettoyage
📎 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;
}
}Nettoyer les jobs asynchrones, puis parcourir tous les comms et appelerncclGroupCommLeave. Notez le comptage dereclaimSteps: chaqueGROUP_MAX_RECLAIM_STEPS(10) appels de groupe, avec interrogation des callbacks une fois par cycle. Cela permet d'éviter la surcharge d'interroger les callbacks à chaque groupe, tout en garantissant que les callbacks ne s'accumulent pas indéfiniment.
Diagramme Mermaid :groupLaunchLegacyflux de données de
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---
Cinq,groupLaunchEnqueueRearch: le planificateur de la nouvelle architecture
Modèle intuitif
groupLaunchEnqueueRearchest la nouvelle architecture de planification en cours de développement par NCCL. Elle divise la préparation des tâches, la planification et le lancement en phases plus fines, gérées par une file de jobs asynchrones. Actuellement, les modules de planificateur et de lanceur « ne sont pas encore implémentés », et un repli vers le 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);
}Flux d'exécution de la nouvelle architecture :
1. Gestion des tâches:ncclMgmtTaskJobFunctraite lesmgmtTaskQueuetâches dans (comme destroy).
2. Préparation des tâches:ncclTaskPrepareJobFuncappellencclTaskPrepare。
3. Planification et lancement: repli versdoLaunches。
La nouvelle architecture utilisencclGroupJobLaunchà la place deasyncJobLaunch, ajoutant des vérifications d'état plus strictes :
📎 src/group.cc:113-116
} else {
/* safety check */
assert(state == ncclGroupJobJoined);
}La version legacy utiliseWARNau lieu deassert, la nouvelle architecture utiliseassert. Cela montre que la nouvelle architecture exige une plus grande rigueur dans la machine à états.
Réflexions sur la conception
La motivation de la nouvelle architecture est ledécouplage: legroupLaunchLegacydu legacy regroupe toutes les phases dans une seule fonction, ce qui rend la maintenance et l'extension difficiles. La nouvelle architecture divise chaque phase en types de jobs indépendants, chaînés via une file. Mais actuellement, le planificateur et le lanceur ne sont pas encore implémentés, donc c'est juste « le cadre en premier ».
ncclParamEnqueueRearchEnable()contrôle si l'on utilise la nouvelle architecture ou le 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);
}L'utilisateur peut basculer via la variable d'environnementNCCL_ENQUEUE_REARCH_ENABLE. En production, il est recommandé de conserver la valeur par défaut (legacy), car la nouvelle architecture est encore en développement.
---
Six, groupes non bloquants et gestion asynchrone des erreurs
Parcours pas à pas guidé par scénarios
Le cœur des groupes non bloquants estncclGroupJobCompleteetncclGroupJobAbort:
📎 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;
}Conception clé :
1. joinedIndicateur atomique: utilisation deCOMPILER_ATOMIC_EXCHANGEpour garantir qu'un seul thread peut exécuter la logique de join. Si deux threads appellentncclGroupJobCompletesimultanément, un seul effectuera réellement le join, l'autre passera directement. Cela empêche le double-join.
2. Comptage de références:groupRefCountenregistre combien de comm sont associés à ce group job. Chaque comm incrémente le compteur de références dansncclGroupEndInternal:
📎 src/group.cc:1108-1111
if (job->comm->groupJob == NULL) {
job->comm->groupJob = groupJob;
groupJob->groupRefCount++;
}Ce n'est que lorsque tous les comm ont appeléncclGroupJobCompleteouncclGroupJobAbort, et que le compteur de références atteint 0, que le group job est supprimé. Cela garantit que le cycle de vie du group job couvre tous les comm associés.
3. Sémantique d'abort:ncclGroupJobAbortdéfinit d'abordabortFlag, puis effectue le join. Le thread de travail vérifieabortFlagpendant l'exécution, et s'il détecte un abort, il se retire prématurément. C'est une « annulation coopérative » — il ne s'agit pas de tuer le thread de force, mais de laisser le thread vérifier l'indicateur et se retirer lui-même.
Guide pour éviter les pièges en production
Piège 3 : interrogation des erreurs des groupes non bloquants. Un groupe non bloquant retournencclInProgress, l'utilisateur doit interroger la progression viancclCommGetAsyncError. Si l'utilisateur oublie d'interroger et appelle directement la communication suivante, il peut rencontrer une erreurncclInProgress. Plus grave encore, si le group job est encore en cours d'exécution et que l'utilisateur appellencclCommDestroy, cela provoquera un use-after-free. NCCL empêche cette situation via le pointeurcomm->groupJobet le comptage de références :ncclCommDestroyvérifie d'abordcomm->groupJob, et s'il y a un group job non terminé, il attendra ou signalera une erreur.
Piège 4 :ncclGroupJobCompletevaleur de retour de. Si l'exécution du group job échoue,ncclAsyncJobCompleteretourne un code d'erreur. MaisncclGroupJobCompletene retourne ce code d'erreur qu'au premier appel, les appels suivants retournentncclSuccess(carjoinedest déjà true). L'utilisateur doit vérifier la valeur de retour lors du premier appel, sinon il perdra l'information d'erreur.
---
Résumé de ce chapitre
Dans ce chapitre, nous avons décomposé la chaîne de planification complète de NCCL, de la « description de tâche » au « lancement du kernel » :
1. Sémantique de Group:ncclGroupStart/ncclGroupEndaccumule les tâches via une variable thread_local,ncclGroupEndsoumet le tout en une fois. Le mode bloquant s'exécute de manière synchrone, le mode non bloquant crée un thread pour une exécution asynchrone.
2. Préparation des tâches:ncclPrepareTasksdétermine l'algorithme/le protocole,ncclPrepareTasksAndCollPreconnecteffectue un preconnect clique par clique pour éviter les races conditions des split comms.
3. Planification par tours:doLaunchesregroupe par clique, synchronise les comm au sein d'une clique avec une barrière, et lance un kernel plan par tour, jusqu'à ce que tous les plans soient lancés.
4. Tâches asynchrones:asyncJobLaunchgère les jobs asynchrones avec une machine à états atomique et de l'attente active, supportant l'échec rapide et l'abort.
5. Nouvelle architecture:groupLaunchEnqueueRearchest un nouveau framework de planification en cours de développement, actuellement en repli vers le legacydoLaunches。
Le prochain chapitre abordera le dernier kilomètre du lancement du kernel :ncclLaunchKernelcomment transformerncclKernelPlanen un kernel réellement exécuté sur le GPU, et comment le côté device lit les métadonnées deDevComm.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprimencclGroupCommJoindansncclMemoryStackPush(&comm->memScoped), que se passerait-il ? Dans quels scénarios cela entraînerait-il une fuite mémoire ou une corruption de données ?
Analyse de référence:ncclMemoryStackPushpour comm dans group
À ce stade, la description de la tâche est devenue un plan de lancement exécutable : la sémantique de group fusionne plusieurs appels API en une seule soumission, le découpage en channels répartit la tâche sur plusieurs flux d'exécution, et l'ordonnancement des tours de doLaunches garantit l'ordre et les dépendances entre les kernels. Mais un plan reste un plan : comment la description de tâche côté host se transforme-t-elle en une grid sur le GPU ? Dans le chapitre suivant, nous plongerons dans ncclLaunchKernel pour examiner la préparation des paramètres, la sélection des variantes de kernel et l'appel à cudaLaunchKernel, accomplissant ainsi le dernier saut de l'host vers le device.
Chapitre 8 : Chapitre 8 : Lancement du kernel et exécution côté device : de l'appel côté host au démarrage des blocs de threads GPU
Chapitre 8 : Lancement du kernel et exécution côté device : de l'appel côté host au démarrage des blocs de threads GPU
Dans le chapitre précédent, nous avons décomposé comment la tâche est répartie sur plusieurs channels, comment les paramètres de lancement du kernel sont générés, ainsi que les mécanismes de soumission par lots et d'ordonnancement des dépendances sous la sémantique de group. Maintenant, le plan de lancement est prêt, mais il ne s'agit encore que d'une structure de données côté host. La question centrale à laquelle ce chapitre répond est :ncclKernelPlanComment cela devient-il une grid réellement en cours d'exécution sur le GPU ? Nous allons suivre la chaîne d'appels dencclLaunchKernelpour voir comment les paramètres sont insérés dans les kernel args, comment les variantes de kernel sont sélectionnées,cuLaunchKernelExcomment il est appelé, et comment, côté device,ncclKernelMainlit la description du travail depuis la mémoire partagée et la distribue aux implémentations concrètes.
Du Plan à la Grid : panorama du chemin de lancement
Avant d'entrer dans les détails, établissons un modèle mental global. ConsidérezncclKernelPlancomme un « plan de construction » : il enregistre le nombre de channels à lancer (nombre de blocks), le nombre de threads par block, les work à exécuter et la fonction kernel à utiliser. EtncclLaunchKernelest l'action de « l'équipe de construction qui entre en scène » — elle traduit les informations du plan enCUlaunchConfigcompréhensibles par le pilote CUDA, puis appellecuLaunchKernelExpour réellement lancer la grid sur le GPU.
Sans cette couche, toute l'orchestration côté host (découpage en channels, organisation des batchs, ordonnancement des proxy op du chapitre précédent) ne serait que théorie : aucun kernel ne s'exécuterait sur le GPU et la communication n'aurait jamais lieu. C'est le dernier maillon du squelette de bout en bout, et aussi la frontière entre host et device.
L'ensemble du chemin de lancement peut se résumer en trois phases :
1. Préparation des paramètres(finishPlan + uploadWork) : organiser les structures work, les descripteurs de batch et les kernel args dans une zone de mémoire contiguë, en décidant s'ils sont placés dans les paramètres du kernel, dans la FIFO ou dans un buffer persistant.
2. Lancement du kernel(ncclLaunchKernel) : calculer les dimensions grid/block, assembler les launch attributes (CGA cluster, mem sync domain, launch completion event), appelercuLaunchKernelEx。
3. Point d'entrée côté device(ncclKernelMain) : chaque block détermine son channelId en fonction deblockIdx.xcharge le work batch depuis les args ou la FIFO vers la mémoire partagée, puis le distribue viancclDevFuncTablevers l'implémentation concrète de l'algorithme/protocole.
La figure ci-dessous montre le flux de contrôle complet du plan à la grid, y compris les branchements décisifs clés :
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_launchCette figure ancre les trois fonctions centrales de ce chapitre :finishPlan、uploadWork、ncclLaunchKernel. Nous allons maintenant les décomposer une par une.
Préparation des paramètres : comment une structure work trouve sa place
Modèle intuitif
finishPlanLe rôle de est similaire à celui d'un « emballeur » dans un centre de tri de colis. Face à un ensemble de structures work dispersées (une par opération collective ou p2p), il doit décider : ces work doivent-ils être insérés dans les paramètres du kernel, ce « sac à dos personnel », ou placés sur la FIFO, ce « tapis roulant », ou encore dans un buffer persistant, cet « entrepôt » ?
Si cette décision est mauvaise — par exemple, si un work trop volumineux est forcé dans les paramètres du kernel alors qu'il n'y rentre pas — le lancement du kernel échouera directement. Si le work est placé au mauvais endroit, le device lira des données corrompues et le résultat de la communication sera totalement erroné.
Structures de données et disposition mémoire
Regardons d'abordncclDevKernelArgsla structure de , qui est l'« enveloppe » entre host et device :
📎 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 数组
};Cette structure ne comporte que 5 champs, mais chacun porte une information cruciale.channelMaskest un masque de 64 bits, chaque bit correspondant à un channel ; le device calcule via__popcllle channelId correspondant àblockIdx.xdétermine d'où le device lit le work :workStorageTypesignifie que le work se trouve dans les paramètres du kernel,Argssignifie qu'il se trouve dans le buffer circulaire,Fifosignifie qu'il se trouve dans le buffer persistant.Persistent 表示在持久化缓冲区里。
ncclDevWorkBatchest un descripteur de batch, il indique au côté device « où se trouve le work de ce channel et combien il y en a » :
📎 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
};offsetBitsetest un masque de 64 bits, chaque bit correspond à une structure work. Le côté device utilise__popcetfns(find n-th set) pour localiser l'offset de chaque work.nextJumpetnextExtendsservent à chaîner plusieurs batchs — lorsque les works sont trop nombreux pour tenir dans un seul batch, on crée des « batchs étendus ».
Step-by-Step Walkthrough
Prenons maintenant un scénario concret : un AllReduce est découpé en 4 channels, chaque channel contient 2 structures work, soit 8 works au total.
Première étape :finishPlandétermine le type de stockage.
📎 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;Le critère clé ici est : sisizeof(ncclDevKernelArgs) + batchBytes + workBytespeut tenir danscomm->workArgsBytes(généralement 4KB), on place directement les works dans les paramètres du kernel. Sinon, les works sont placés dans le FIFO ou dans un buffer persistant, et seuls les descripteurs de batch sont placés dans les paramètres du kernel.
Pourquoi privilégier le placement dans les paramètres du kernel ? Parce que les paramètres du kernel sont transmis via la mémoire constante (constant memory) dans le driver CUDA, et le côté device les lit avec l'instructionld.param, bien plus rapide qu'une lecture du FIFO depuis la mémoire globale. Pour les petits messages (faible volume de works), cela réduit significativement la latence.
Deuxième étape : placer les batchs dans kernel args en alternant par channel.
📎 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);
}La logique de ce code est le « round-robin » : à chaque tour, on prend un batch de chaque channel qui en possède encore, et on les place dans le tableaubatchZeropar ordre croissant de numéro de channel. L'objectif est de garantir que « le premier batch de chaque channel se trouve àbatchZero[blockIdx.x]» — chaque block côté device accède directement à son premier batch viablockIdx.x, sans avoir besoin de rechercher.
nextJumpLe champbatchIx += batch.nextJumpenregistre l'offset du batch suivant du même channel par rapport au batch courant. Le côté device peut sauter au batch suivant via
, formant ainsi une liste chaînée.uploadWorkTroisième étape :
📎 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;
}
// ...
}copie
1. fifoCursorIl y a ici plusieurs points clés :Sémantique deArgs: pour le typekernelArgs, il s'agit d'un offset par rapport à l'adresse de début deFifo; pour le typePersistent, il s'agit d'un offset par rapport à l'adresse de base du FIFO ; pour le type
2. offsetBase, il commence à 0.:finishPlanCorrection deoffsetBase: dansuploadWork, leArgsdu batch est relatif à la position de début des works du plan (à partir de 0).sizeof(ncclDevKernelArgs) + batchBytesdoit le convertir en un offset relatif à l'emplacement de stockage réel. Pour le typeFifo, on ajoutecomm->workFifoProduced。
3. ; pour le type, on ajoutealignas(16)Copie alignée sur 16 octetsCOMPILER_ASSUME_ALIGNED: les structures work sont toutes alignées sur 16 octets (
4. ), donc la copie se fait par unités de 16 octets.indique au compilateur que cette adresse est alignée sur 16 octets, lui permettant de générer des instructions vectorisées plus efficaces.FifoAttente FIFOwaitWorkFifoAvailable: pour le typecomm->abortFlag,
effectue un spin-wait jusqu'à ce que le FIFO ait suffisamment d'espace. Cette attente vérifie
Réflexions de conception et pièges en production〔Inférence de conception et compromis architecturaux〕
ArgsPourquoi avoir trois types de stockage ?FifoC'est un compromis entre espace et latence :Persistent: le plus rapide (mémoire constante), mais capacité limitée (4KB). Adapté aux petits messages et au faible nombre de works.cudaMemcpy: grande capacité (buffer circulaire), mais la lecture côté device passe par la mémoire globale. Adapté aux messages moyens.
: utilisé pour les scénarios de capture CUDA Graph. Comme la capture de graph ne permet pas de faire, il faut préallouer un buffer persistant, y copier les works, puis faire lire le kernel depuis cet emplacement.waitWorkFifoAvailablePiège 1 : débordement du FIFO provoquant un deadlock.abortFlagSi📎 src/enqueue/enqueue.cc:1333-1349ne vérifie pas
if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
return ncclInternalError;
}vérifie explicitement l'abort flag :offsetBitsetcopie offsetBitsetPiège 2 : débordement de1ull << (offset / workSize).NCCL_MAX_DEV_WORK_BATCH_BYTESest sur 64 bits, supportant au maximum 64 works dans un batch. Si l'on dépasse 64,ncclDevWorkColldéborde. Dans le code source,
limite la taille du batch (1024 octets), et la plus petite structure work est(environ 80 octets), donc au maximum 12 works, pas de débordement.uploadWorkPiège 3 : fuite mémoire en mode Persistent.PersistentDans la branchefifoBufHostdencclOsAlignedAlloc,uploadWork_cleanup_fnest alloué viacudaMemcpyAsyncet doit être libéré dansfail. Sicleanupéchoue, le labelfifoBufHostvérifie si📎 src/enqueue/enqueue.cc:1483-1485est null, et si c'est le cas, libère directement
. Cette chaîne de récupération d'erreur est visible dans
Lancement de kernel : de CUlaunchConfig à cuLaunchKernelEx
ncclLaunchKernelLe rôle de est similaire à une « console de contrôle de lancement de fusée ». Il reçoit un plan déjà chargé en carburant (données work), calcule les paramètres de vol de la fusée (dimensions grid/block), configure diverses options de lancement (cluster, mem sync domain, completion event), puis appuie sur le bouton de lancement (cuLaunchKernelEx)。
Si cette étape échoue — par exemple si la dimension grid est mal calculée — un nombre incorrect de blocks sera lancé sur le GPU, entraînant que le travail de certains channels ne sera jamais exécuté et que la communication restera bloquée.
Structures de données et disposition mémoire
CUlaunchConfigest la structure de configuration de lancement de l'API CUDA driver, que NCCL construit sur la pile :
📎 src/enqueue/enqueue.cc:1916-1917
CUlaunchConfig launchConfig = {0};
CUlaunchAttribute launchAttrs[6] = {};
int attrs = 0;launchAttrsest un tableau de 6 éléments maximum, chaque élément étant unCUlaunchAttribute. NCCL ajoute conditionnellement différentes propriétés en fonction des capacités matérielles et de la version du driver :
CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION: dimension du CGA cluster (sm90+)CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE: politique d'ordonnancement du clusterCU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAIN: domaine de synchronisation mémoire (CUDA 12.0+)CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT: événement de fin de lancement (CUDA 12.3+)CU_LAUNCH_ATTRIBUTE_PROGRAMMATIC_STREAM_SERIALIZATION: sérialisation de flux programmatique (sym kernel)CU_LAUNCH_ATTRIBUTE_NVLINK_UTIL_CENTRIC_SCHEDULING: ordonnancement centré sur l'utilisation NVLink (CUDA 13.0+)
Step-by-Step Walkthrough
Première étape : calculer les dimensions grid et 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);nChannelsest le nombre de bits positionnés danschannelMask, c'est-à-dire le nombre de blocks que ce plan doit lancer. Chaque block est responsable d'un channel.threadPerBlockest calculé dansscheduleCollTasksToPlanviaplan->threadPerBlock = std::max(plan->threadPerBlock, task->nWarps * WARP_SIZE)en prenant le maximum denWarps * 32。
smemparmi toutes les tasks. est la taille de la mémoire partagée dynamique. Pour un kernel ordinaire, c'estncclShmemDynamicSize(comm->cudaArch), une constante de compilation qui dépend de l'architecture (sm70+ c'estncclShmemScratchWarpSize * (NCCL_MAX_NTHREADS / WARP_SIZE)). Pour un sym kernel, c'estplan->kernelDynSmem, car les besoins en mémoire partagée du sym kernel peuvent différer.
Deuxième étape : assembler les paramètres du 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};C'est une méthode de passage de paramètres de l'API CUDA driver :CU_LAUNCH_PARAM_BUFFER_POINTERindique au driver que « les paramètres ne sont pas passés un par un, mais sous forme d'un bloc mémoire contigu »,CU_LAUNCH_PARAM_BUFFER_SIZEindique au driver la taille de ce bloc. L'avantage est que NCCL peut passerncclDevKernelArgset le tableau batch suivant en une seule fois, sans avoir à empaqueter chaque paramètre individuellement.
Troisième étape : ajouter les 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;
}Le CGA (Cooperative Group Array) est une fonctionnalité matérielle introduite avec sm90, permettant de regrouper plusieurs blocks en un cluster. Les blocks d'un cluster peuvent être garantis d'être ordonnancés simultanément sur un ensemble de SM et peuvent accéder mutuellement à leur mémoire partagée. NCCL utilise cette fonctionnalité pour implémenter des algorithmes nécessitant une synchronisation inter-blocks comme NVLS.
Noter la protectionif (grid.x % clusterSize) clusterSize = 1;: la dimension du cluster doit diviser exactement la dimension du grid, sinon le driver renverra une erreur. Sigrid.xn'est pas divisible parclusterSize, on dégénère en n'utilisant pas de cluster.
Quatrième étape : ajouter le 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_EVENTest une fonctionnalité introduite avec CUDA 12.3 : le driver enregistre un événement lorsque le kernel commence réellement son exécution (et non lorsque l'appel côté host retourne). Ceci est crucial pour implémenter l'« ordre implicite » (implicit order) — NCCL doit garantir que plusieurs kernels s'exécutent dans l'ordre, sans pour autant bloquer le host en attente.
getImplicitOrderLa logique est : si l'utilisateur a définilaunchOrderImplicit, et que la version du driver est suffisamment récente, on utilisencclImplicitOrderLaunch(ordonnancement par launch event) ; sinon on utilisencclImplicitOrderSerial(ordonnancement par completion event, c'est-à-dire exécution séquentielle).
Cinquième étape : appelercuLaunchKernelEx。
📎 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);
}cuLaunchKernelExest une nouvelle API introduite avec CUDA 12.0, supportant les launch attributes. Pour les anciens drivers (< 11.8), NCCL revient à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);
}Contrôle de concurrence et interaction matérielle
Mécanisme de relais du Launch completion event.Lorsqu'on utilisencclImplicitOrderLaunchet que l'utilisateur a fournilaunchCompletionEvent, NCCL ne peut pas transmettre directement l'event de l'utilisateur au driver, car le driver ne supporte qu'un seul launch completion event. L'approche de NCCL est :
1. Transmettrecomm->sharedRes->launchEventau driver.
2. AttendrerelayStreamsurlaunchEvent。
3. Enregistrer l'event de l'utilisateur surrelayStream.
Ainsi l'event de l'utilisateur sera déclenché après le début réel de l'exécution du kernel, et non au retour de l'appel côté host.
Mem Sync Domain。 📎 src/enqueue/enqueue.cc:1938-1942Sur sm90+, NCCL définitCU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAINàcudaLaunchMemSyncDomainRemote. C'est le mécanisme de domaine de synchronisation mémoire introduit par l'architecture Hopper, utilisé pour isoler les barrières mémoire de différents kernels et réduire les surcoûts de synchronisation inutiles.
Guide des pièges en production
Piège 1 : dimension de cluster non divisible entraînant un échec de lancement.Sigrid.xn'est pas divisible parclusterSize, le driver renverraCUDA_ERROR_INVALID_VALUE. Le code source protège viaif (grid.x % clusterSize) clusterSize = 1;, mais cela signifie aussi que la fonctionnalité cluster est silencieusement désactivée. Si l'utilisateur attend le gain de performance apporté par le cluster, il faut vérifier la relation entrecgaClusterSizeetnChannels.
Piège 2 : version de driver non satisfaisante rendant le kernel indisponible. ncclInitKernelsForDevicevérifie les exigences de pilote de chaque kernel lors de l'initialisation :
📎 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;
}Si la version du pilote est insuffisante, le pointeur du kernel sera mis à null. Par la suite, si le planificateur sélectionne ce kernel,cuLaunchKernelExéchouera. Le tuner de NCCL devrait éviter de sélectionner des kernels indisponibles, mais si l'utilisateur force la spécification d'un algorithme (NCCL_ALGO), ce problème pourrait être déclenché.
Piège 3 :launchCompletionEventcomportement sur les anciens pilotes.Si la version du pilote < 12.3, NCCL enregistrera un event avant le lancement du kernel, ce qui signifie que l'event sera déclenché avant que le kernel ne commence réellement son exécution, et non au moment où le kernel commence véritablement. Cela peut invalider les hypothèses de timing du code utilisateur.
Point d'entrée côté device : de blockIdx à l'implémentation concrète
Modèle intuitif
ncclKernelMainest le « hall d'entrée » de chaque block sur le GPU. Lorsqu'un block est planifié sur un SM pour commencer son exécution, il entre d'abord dans ce hall et accomplit trois choses : déterminer son identité (quel channel je suis), récupérer sa tâche (charger le work batch), puis se rendre au guichet correspondant (appeler l'implémentation concrète de l'algorithme).
Sans ce point d'entrée, chaque variante de kernel devrait gérer elle-même les questions « qui suis-je, que dois-je faire », et le code serait massivement dupliqué.ncclKernelMainimplémente le modèle « entrée générique + exécution spécialisée » via les paramètres de templateSpecializedFnIdetSpecializedRunWorkBatch.
Structures de données et disposition mémoire
La disposition de la mémoire partagée côté device est essentielle pour comprendrencclKernelMain.ncclShmemDataest l'« établi » partagé par tous les blocks :
📎 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;
};La disposition de cette structure a été soigneusement conçue :
argsest placé en premier, car il est copié depuis les paramètres du kernel et nécessite un alignement de 16 octets.commetchannelsont également alignés sur 16 octets, car ils sont copiés viacopyToShmem16avec des instructions vectorisées.workStorageest la zone de stockage temporaire de la structure work, sa taille estncclMaxDevWorkBatchBytes()(16KB pour sm90+).groupsLe tableau sert à stocker les informations de connexion de chaque group,NCCL_MAX_GROUPSvaut 16.
Step-by-Step Walkthrough
Première étape : copier les kernel args vers la mémoire partagée.
📎 src/device/common.h:426-428
if (tid < sizeof(ncclDevKernelArgs) / sizeof(uint32_t)) {
((uint32_t*)&ncclShmem.args)[tid] = ((uint32_t*)args)[tid];
}Ici, on utilise lessizeof(ncclDevKernelArgs) / 4premiers threads, chaque thread copie un mot de 32 bits. Pourquoi copier vers la mémoire partagée ? Parce que les paramètres du kernel se trouvent dans la mémoire constante ; bien que l'accès soit rapide, lorsque chaque thread doit y accéder, il y a un surcoût de diffusion. Après copie vers la mémoire partagée, tous les threads accèdent au même bloc de mémoire partagée, ce qui est plus efficace.
Deuxième étape : déterminer 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();La logique de ce code est : pour chaque channel activé (args->channelMask & (1ull << tid)), calculer combien de channels activés le précèdent (__popcll), et si ce nombre est égal àblockIdx.x, alors le block courant est responsable de ce channel.
Par exemple :channelMask = 0b1011(les channels 0, 1, 3 ont du travail).blockIdx.x = 0Le blockblockIdx.x = 1est responsable du channel 0 (0 activé avant),blockIdx.x = 2Le block
est responsable du channel 1 (1 activé avant),
📎 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();est responsable du channel 3 (2 activés avant).
- Troisième étape : charger comm et channel dans la mémoire partagée.Copier
ncclKernelCommIci, les threads sont divisés en trois groupes : - Le 0e warp: charge
ncclDevChannel(métadonnées du communicateur) dans la mémoire partagée. - Le 1er warp: charge
copyToShmem16du channel courant (métadonnées du channel) dans la mémoire partagée.
📎 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");
}
}: chargent le work batch dans la mémoire partagée.ld.v2.u64est une fonction de copie de 16 octets implémentée avec du PTX inline :st.shared.v2.u64Copier__cvta_generic_to_sharedElle utilise
pour charger 16 octets depuis la mémoire globale, et
loadWorkBatchToShmempour stocker dans la mémoire partagée.workStorageconvertit une adresse générique en adresse de mémoire partagée (l'espace d'adressage de la mémoire partagée est de 32 bits).
📎 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();
// ...
}
}est la partie la plus complexe. Sa tâche est de copier la structure work pointée par le descripteur de batch depuis la mémoire globale (ou les paramètres du kernel) versfnsOfBitsetde la mémoire partagée.offsetBitsetCopierfnsLe cœur de ce code est de calculerfnsOfBitset[nWorksBelow]。
: pour le n-ième bit activé dans
📎 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;
}pour cela, mais elle se déploie en de nombreuses instructions SASS. L'approche de NCCL est d'utiliser la mémoire partagée : chaque lane vérifie si son bit est activé, si oui, calcule combien de bits activés le précèdent, puis écrit son numéro de lane dansArgsVient ensuite la copie proprement dite :(char*)args + offsetCopierld.param.v2.u64Il y a ici une optimisation clé : pour le typeFifo, le code source écrit directement(char*)ncclShmem.args.workBuf + (offset & workMask), et le compilateur reconnaîtra qu'il s'agit d'une lecture depuis les paramètres du kernel et générera l'instructionld.v2.u64. Pour le type
, le code source écrit
📎 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..
Le commentaire souligne particulièrement qu'il ne faut pas fusionner ces deux cas :
📎 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();
}Si le compilateur ne peut pas déterminer si le pointeur pointe vers l'espace des paramètres ou l'espace global, il fera déborder toute la structure de paramètres (4KB) vers la mémoire locale de chaque thread, et les performances chuteront drastiquement.SpecializedFnIdCinquième étape : exécuter le work.funcIdCopierSpecializedRunWorkBatch().run()Il y a ici une optimisation importante : sincclDevFuncTable[ncclShmem.funcId]()correspond au
ncclDevFuncTabledu batch courant, appeler directementgenerate.pyGénérer :
📎 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")Réflexions de conception et pièges en production
Pourquoi utiliser__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__indique au compilateur que ce paramètre est en lecture seule et peut être placé dans la mémoire constante. Ainsi, lors de la lecture côté device, l'instructionld.paramest utilisée, ce qui est plus rapide que la lecture depuis la mémoire globale. Le commentaire mentionne que cela casse cuda-gdb, donc ce n'est activé que sur sm70+.
Piège 1 :workStoragedébordement. workStorageLa taille dencclMaxDevWorkBatchBytes(), sm90+ est de 16KB. SinWorks * workSizedépasse cette valeur, il y aura un dépassement d'écriture. Dans le code source,NCCL_MAX_DEV_WORK_BATCH_BYTESlimite la taille du batch côté host, mais il n'y a pas de vérification supplémentaire côté device. Si la contrainte côté host est contournée (par exemple en modifiant une variable d'environnement), cela entraînera un dépassement de la mémoire partagée.
Piège 2 :__syncthreads()L'absence de provoque une course de données.AprèsloadWorkBatchToShmem, il doit y avoir un__syncthreads()pour que tous les threads voient leworkStoragecomplet. Dans le code source,📎 src/device/common.h:479il y a__syncthreads(); // publish ncclShmem. Si cette synchronisation est supprimée, certains threads pourraient commencer à lire avant queworkStoragene soit entièrement écrit, ce qui entraînerait la lecture de données corrompues.
Piège 3 : le moment de la vérification d'abort. while (ncclShmem.aborted == 0)ne vérifie l'abort qu'au début de chaque batch. Si un batch prend beaucoup de temps à s'exécuter, le signal d'abort pourrait mettre longtemps à prendre effet. C'est un compromis de conception : des vérifications plus fréquentes augmentent la surcharge, mais la réponse est plus rapide.
Sélection des variantes de kernel : comment generate.py génère la liste des kernels
Modèle intuitif
generate.pyLe rôle de est similaire à celui d'un « planificateur de ligne de production d'une usine automobile ». Il fait face à un espace combinatoire gigantesque (7 types d'opérations d'ensemble × 5 types d'opérations de réduction × 12 types de données × 7 algorithmes × 3 protocoles) et doit décider : quelles combinaisons nécessitent la génération de kernels spécialisés ? Lesquelles peuvent partager un kernel générique ?
Si un kernel est généré pour chaque combinaison, le temps de compilation et la taille du binaire exploseraient. Si un seul kernel générique est généré, l'exécution serait ralentie par les appels via pointeurs de fonction et les branchements.generate.pyLa solution de est le « kernel représentatif » : générer un kernel pour chaque classe d'équivalence, et distribuer à l'exécution via une table de pointeurs de fonction.
Structures de données et disposition mémoire
generate.pygénère trois fichiers clés :
1. device_table.cu: côté devicencclDevFuncTable, qui mappe funcId vers la fonction device concrète.
2. host_table.cc: côté hostncclDevKernelList、ncclDevKernelForFunc、ncclDevFuncRowToIdet autres tables.
3. Les différents<coll>_<op>_<ty>.cu: implémentations concrètes des kernels.
Step-by-Step Walkthrough
Première étape : énumérer toutes les lignes de fonctions.
📎 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)Cet ordre d'énumération doit correspondre à la formule de calcul dencclDevFuncId():
📎 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];
}ncclDevFuncIdcalcule le « numéro de ligne », puis viancclDevFuncRowToIdle mappe vers l'« ID de fonction principale ». La raison de ce mappage est que : de nombreuses lignes peuvent être mappées vers la même fonction principale (par exemple toutes les lignes deAllReduce Sum i32sont mappées vers la fonction principale deAllReduce Sum u32).
Deuxième étape : calculer les fonctions principales et les fonctions 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_primarymappe les entiers signés vers les entiers non signés (car l'addition/multiplication est identique pour les deux) :
📎 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_kernelmappe plusieurs fonctions principales vers le même kernel (par exemple tous les algorithmes deAllGathersont mappés versAllGather 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 kfnTroisième étape : générer les définitions de 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_ncclDevKernelAprès expansion de la macro :
📎 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); \
}Donc chaque kernel est une fonction__global__, appelantncclKernelMain, avec comme paramètres de templatespecializedFnIdetRunWorkBatch<coll, ty, redop<ty>, algo, proto>。
Réflexions de conception et pièges en production
Pourquoi utiliser un « kernel représentatif » plutôt qu'un kernel par combinaison ?Compromis entre temps de compilation et taille du binaire. L'espace combinatoire complet est de 7 × 5 × 12 × 7 × 3 ≈ 8820 kernels, chaque kernel nécessitant quelques secondes de compilation, soit plusieurs heures au total. De plus, la taille du binaire atteindrait plusieurs centaines de Mo. En mappant vers des kernels représentatifs, le nombre de kernels réellement générés est réduit à quelques dizaines.
Piège 1 :NCCL_EXACT_KERNEL_NAMESprovoque une explosion de la compilation.Si cette variable d'environnement est définie,best_kernelrenvoie la fonction originale, et un kernel est généré pour chaque combinaison. C'est utile en développement (on peut contrôler précisément quel kernel est compilé), mais en production cela entraîne des temps de compilation excessifs.
Piège 2 :required_cudavérification de version.Certains kernels nécessitent une version CUDA ou une architecture spécifique :
📎 src/device/generate.py:130-154
À ce stade, le kernel a été lancé sur le GPU et le côté device a également obtenu la description du travail. Mais ce qui détermine réellement la performance, c'est la façon dont les données sont déplacées à l'intérieur du device. Le chapitre suivant explorera en profondeur les trois primitives de protocole sous src/device : LL, LL128 et Simple, pour comprendre pourquoi la même logique AllReduce nécessite trois ensembles de primitives de transfert, et leurs différences en matière de synchronisation, de disposition des buffers et de sémantique des flags.
Chapitre 9 : Chapitre 9 : Primitives de communication côté device : implémentation du transfert de données des trois protocoles LL, LL128, Simple
Chapitre 9 : Primitives de communication côté device : implémentation du transfert de données des trois protocoles LL, LL128, Simple
Dans le chapitre précédent, nous avons suivi comment le côté host traduit un AllReduce en un kernel __global__, et nous avons vu que le point d'entrée côté device ncclKernelMain effectue la répartition selon l'algorithme et le protocole. Mais la répartition ne fait que sélectionner les outils ; ce qui détermine réellement la performance, c'est la manière dont ces outils exécutent le transfert de données. Ce chapitre explore en profondeur les trois ensembles de primitives de transfert sous src/device : LL, LL128 et Simple, en analysant une à une leur implémentation de transfert de données, afin de comprendre les compromis entre latence et bande passante des différents protocoles.
Pourquoi un même AllReduce nécessite trois ensembles de primitives de transfert
Établissons d'abord un modèle intuitif. Imaginez une usine à chaîne de montage : la matière première (les données utilisateur) entre d'un côté, le produit fini sort de l'autre, et au milieu plusieurs postes de travail (rank) doivent échanger des produits semi-finis. Il existe trois façons de transférer les produits semi-finis :
- LL(Low Latency): comme deux personnes qui se passent un mot face à face ; au moment où on le tend, l'autre sait immédiatement « c'est pour toi », avec un coût de handshake quasi nul. Mais le mot est très petit, on ne peut transmettre que 8 octets de données utiles à la fois. Adapté aux petits messages.
- LL128: on remplace le mot par un post-it de 128 octets, transmettant 120 octets de données utiles à la fois, mais le post-it doit être placé aligné sur 16 octets, sinon il faut d'abord le « remettre en page » dans la mémoire partagée. Adapté aux messages moyens.
- Simple: comme un casier de livraison ; on dépose d'abord le colis dans le casier (tampon FIFO), puis on envoie une notification « le casier n° N contient un colis ». Le coût de handshake est élevé, mais on peut transférer beaucoup à la fois. Adapté aux gros messages.
Que se passerait-il s'il n'y avait qu'un seul ensemble de primitives ? Avec LL uniquement, les gros messages étoufferaient la bande passante car « chaque message doit attendre la confirmation du flag par l'autre partie » ; avec Simple uniquement, les petits messages verraient leur latence exploser à cause du coût fixe « écrire dans la FIFO + envoyer une notification + attendre la notification ». C'est précisément la raison pour laquelle la courbe de performance de NCCL présente des points d'inflexion nets autour de 8 Ko et 128 Ko.
Les trois ensembles de primitives partagent le même squelette de templatePrimitives<T, RedOp, Fan, Direct, Proto, P2p, isNetOffload>, et viaProtoce paramètre de template, on spécialise trois versions📎 src/device/primitives.h:117-117。ProtoLL、ProtoLL128、ProtoSimpleles trois structures portent chacune leurs constantes et méthodes de calcul liées au protocole📎 src/device/primitives.h:25-75, le code de l'algorithme n'appelle queprims.send()、prims.recvReduceSend()ce type d'interface unifiée, sans se soucier du protocole sous-jacent.
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"]Ce schéma explique « pourquoi une même logique AllReduce nécessite trois ensembles de primitives de transfert » : la couche algorithme est indépendante du protocole, les différences de protocole sont encapsulées dansPrimitivesles trois spécialisations de
LL : transfert sans handshake avec flag intégré dans la ligne de données
Modèle intuitif
L'idée centrale de LL est :intégrer « les données » et le marqueur « les données sont-elles prêtes » dans la même unité de lecture-écriture de 16 octets. Le récepteur n'a pas besoin de « message de notification » supplémentaire ; il lui suffit de scruter le champ flag dans la ligne de données ; si le flag correspond, cela signifie que les données sont arrivées. C'est comme imprimer directement la « signature du destinataire » sur l'enveloppe lors de l'envoi d'une lettre : le facteur voit la signature et sait s'il doit livrer, sans avoir besoin d'un bordereau de réception séparé.
Sans cette conception, le récepteur devrait d'abord attendre une notification « les données ont été écrites », puis revenir lire les données, soit deux allers-retours mémoire, doublant la latence.
Structures de données et disposition mémoire
L'unité de transfert de LL estunion ncclLLFifoLine, comme on peut le voir dans l'assemblage destoreLLsa disposition📎 src/device/prims_ll.h:154-158:
st.volatile.global.v4.u32 [%0], {%1,%2,%3,%4};
// 写入 4 个 u32:data1, flag, data2, flagUnncclLLFifoLinefait 16 octets, disposé en[data1(4B) | flag(4B) | data2(4B) | flag(4B)]. Les données utiles ne font que 8 octets (data1 + data2), les 8 autres octets sont entièrement du flag. C'est pourquoiProtoLL::calcBytePerGrain()renvoiesizeof(uint64_t)— « One 16-byte line has 8-bytes of data »📎 src/device/primitives.h:55-57。
Champs clés (spécialisation LL dePrimitives)📎 src/device/prims_ll.h:20-42:
| Champ | Type | Rôle |
|---|---|---|
recvStep[i] / sendStep[i] | uint64_t[MaxRecv/MaxSend] | Compteur de pas par peer, détermine l'offset du tampon et la valeur du flag |
recvBuff[i] / sendBuff[i] | ncclLLFifoLine* | Pointe vers l'adresse de base du tampon FIFO de chaque peer |
recvConnHeadPtr | volatile uint64_t* | Pointeur global côté réception « jusqu'à quel pas j'ai consommé » |
sendConnHeadPtr | volatile uint64_t* | Pointeur global côté envoi « jusqu'à quel pas le pair a consommé » |
sendConnHeadCache | uint64_t | Met en cache la dernière valeur de head lue, pour éviter de lire la mémoire globale à chaque fois |
L'offset du tampon est calculé parrecvOffset(i) = (recvStep[i] % NCCL_STEPS) * stepLines📎 src/device/prims_ll.h:44-46,NCCL_STEPSest le nombre de slots du tampon circulaire,stepLinesest le nombre de lignes par slot. La valeur du flag est calculée parrecvFlag(i) = NCCL_LL_FLAG(recvStep[i] + 1)📎 src/device/prims_ll.h:56-58, noter que+1— car la valeur initiale du flag est 0, le flag du premier pas doit être 1 pour se distinguer de « non écrit ».
Parcours guidé par scénario : un recvReduceSend
Supposons que le rank 0 exécute dans un Ring AllReducerecvReduceSend: recevoir les données du rank précédent, faire un reduce avec les données locales, puis envoyer au rank suivant. La chaîne d'appels estrecvReduceSend(inpIx, eltN) → LLGenericOp<1, 1, Input, -1>(inpIx, -1, eltN, false) 📎 src/device/prims_ll.h:403-405。
Première étape : attendre que le tampon d'envoi soit disponible. waitSendvérifiesendConnHeadCache + NCCL_STEPS < sendConnHead + 1 📎 src/device/prims_ll.h:73-89. Cela signifie : si la progression de consommation du pair (head) est trop en retard par rapport à moi, cela indique que le tampon circulaire est presque plein, il faut attendre.NCCL_STEPSest le nombre total de slots du tampon,sendConnHead + 1est le slot que je vais occuper. Pendant l'attente, on scrute*sendConnHeadPtrpour mettre à jour le cache, et on appelle périodiquementcheckAbortpour vérifier si un abort a eu lieu📎 src/device/prims_ll.h:73-89。
Deuxième étape : charger les données locales. DataLoader::loadBegintraite le problème d'alignement📎 src/device/prims_ll.h:200-216. Lorsquesizeof(T) <= 2(par exemple half ou int8), l'adresse source peut ne pas être alignée sur 4 octets, donc on lit d'abord aligné sur 4 octets dansu4[0..2], on enregistremisalign, puis dansloadFinishon utilise__funnelshift_rpour effectuer un décalage au niveau de l'octet et reconstituer la valeur 64 bits correcte📎 src/device/prims_ll.h:218-225. C'est une technique typique de « lecture alignée + recomposition par décalage », qui évite la pénalité de performance des accès non alignés.
Troisième étape : lire les données du pair et attendre le flag. readLLest le cœur📎 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));Il utiliseld.volatile.global.v4.u32Lire 16 octets en une seule fois (4 u32), puis vérifier si les deux champs flag sont tous deux égaux aux valeurs attendues.volatileLe mot-clé garantit que le compilateur n'optimisera pas cette lecture ni ne la mettra en cache dans un registre — car le pair peut écrire de nouvelles données à tout moment. Les deux flags doivent correspondre, car l'écrivainstoreLLécrit 4 u32 en une fois, ce qui théoriquement peut être divisé en deux écritures de 8 octets ; les deux flags doivent correspondre pour garantir l'intégrité des 16 octets.
Quatrième étape : reduce puis envoi.Après réception de peerData,applyReduce(redOp, peerData, data)effectuer la réduction📎 src/device/prims_ll.h:279. PuisstoreLL(sendPtr(i) + offset, data, sendFlag(i))écrire le résultat dans le tampon d'envoi📎 src/device/prims_ll.h:295-296. Attention à l'ordre d'envoi : envoyer d'abordi=1..MaxSend(généralement le pair réseau), puis enfini=0(généralement le pair local)📎 src/device/prims_ll.h:291-297. Le commentaire est très clair : « Send : inter-node, then intra-node, then local » — envoyer d'abord le lent (réseau), le laisser voler en arrière-plan, puis envoyer le rapide (local), ainsi le pair local n'attend pas le réseau.
Cinquième étape : avancer le step et post. incRecv(i)Incrémenter le pas de réception📎 src/device/prims_ll.h:91-93,postRecv()écrirerecvConnHeaddans le pointeur global📎 src/device/prims_ll.h:94-97, notifier au pair « j'ai déjà consommé ce step ». Le côté envoiincSenda une logique spéciale📎 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));
}Lorsque le step atteint la frontièreNCCL_LL_CLEAN_MASK, il faut écrire toutes les lignes du slice entier avec le flag actuel (données remplies de 0). Pourquoi ? Parce que le flag est réutilisé de manière cyclique ; si le flag précédent d'une ligne se trouve justement être égal à la valeur attendue cette fois, le récepteur pourrait croire à tort que les données sont prêtes. Cette opération de « cleanup » uniformise les flags de toutes les lignes avec la nouvelle valeur, éliminant toute ambiguïté.
Contrôle de concurrence et interaction matérielle
La synchronisation de LL repose entièrement survolatilelecture/écriture + polling de flag, sans verrou.barrier()utilise__syncwarp()(en cas de warp unique) oubarrier_sync(15 - group, nthreads)(en cas de multi-warp)📎 src/device/prims_ll.h:63-69。15 - groupest le numéro de barrière ; NCCL utilise différents numéros de barrière pour isoler différents groupes, évitant les interférences mutuelles.
checkAbortest la clé pour éviter les boucles infinies📎 src/device/primitives.h:154-164: tous lesNCCL_SPINS_BEFORE_CHECK_ABORT(10000) spins, on ne litabortFlagqu'une seule fois, évitant de lire fréquemment la mémoire globale et de ralentir le chemin critique. Une fois abort détecté, définirncclShmem.abortedet mettre en cache ; toutes les boucles d'attente suivantes sortiront rapidement.
Pièges en production
Piège 1 : fausse disponibilité due au wraparound du flag.SiNCCL_LL_CLEAN_MASKla logique de cleanup est supprimée, après une longue exécution (step dépassant le cycle du mask), le récepteur peut lire un flag résiduel du tour précédent, juger à tort les données prêtes et lire des données corrompues. Ce type de bug est extrêmement difficile à reproduire, car il dépend du fait que le step revienne exactement à une valeur spécifique.
Piège 2 :MaxRecv == 0le piège de compilation.Dans le codeMaxRecv = Fan::MaxRecv > 1 ? Fan::MaxRecv : 1 📎 src/device/prims_ll.h:13, car même en envoi seul sans réception, un tampon de réception de longueur MaxRecv est alloué ; si MaxRecv vaut 0, cela provoque un échec de compilation d'un tableau de longueur nulle. Sur WindowsMaxSenda le même traitement📎 src/device/prims_ll.h:14-19。
LL128 : échanger un alignement de 128 octets contre une charge utile plus élevée
Modèle intuitif
Le point faible de LL est que la charge utile n'est que de 50 % (sur 16 octets, 8 octets sont des flags). L'idée de LL128 est :concentrer les flags dans les 8 derniers octets de chaque bloc de 128 octets, les 120 premiers octets étant entièrement des données. Ainsi la charge utile passe de 50 % à 93,75 %. Le coût est qu'il faut garantir l'alignement sur 128 octets, sinon il faut effectuer un « réarrangement en mémoire partagée ».
Structures de données et disposition mémoire
L'unité de transfert de LL128 estuint64_t(8 octets), mais organisée en « line » de 128 octets.NCCL_LL128_LINEELEMSest le nombre d'éléments 64 bits par line (16),NCCL_LL128_DATAELEMSest le nombre d'éléments de données parmi eux (15), le dernier élément contenant le flag.
Constantes clés📎 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));WireWordPerSliceest le nombre de mots 64 bits transférés en une fois par un warp,DataEltPerSliceest le nombre d'éléments de données utiles parmi eux (moins un élément flag par line).
Le mécanisme de flag de LL128 diffère de LL :seul le 7e thread sur 8 (flagThread) est chargé de vérifier le flag 📎 src/device/prims_ll128.h:373。flagThread = ((tid % 8) == 7). Pourquoi ? Parce que le flag est un par 128 octets, et un warp a 32 threads ; chaque groupe de 8 threads traite 128 octets (8 threads × 16 octets = 128 octets), donc sur 8 threads, un seul doit lire le flag.
Walkthrough guidé par scénario : un recvReduceSendCopy
Chaîne d'appels :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。
Première étape : charger les données locales dans les registres. loadRegsBeginDeux cas📎 src/device/prims_ll128.h:99-142:
- Alignement sur 16 octets: directement
load128vers les registres, sans transit par la mémoire partagée. AttentionflagThreadne charge que la moitié des données (g % 2 == 0), car son autre moitié de registres est réservée au flag📎src/device/prims_ll128.h:109-114。 - Non aligné: charger d'abord la zone alignée en mémoire partagée
ncclScratchForWarp(warpInBlock),__syncwarp()puis relire depuis la mémoire partagée vers les registres avec le bon décalage📎src/device/prims_ll128.h:115-141。
Deuxième étape : attendre et lire les données du pair. recvReduceSendCopyla boucle d'attente dans📎 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));Point clé : seulflagThreadvérifie le flag, puis utilise__any_syncpour un vote au niveau warp — dès qu'un flagThread détecte une non-concordance de flag, tout le warp continue à spinner. C'est plus économe en instructions que si chaque thread vérifiait le flag.
Troisième étape : réarrangement des registres. loadRegsFinishdéplacer le registre flag de flagThread vers un registre libre📎 src/device/prims_ll128.h:145-151. Le commentaire explique cette conception : « By deferring register shuffle here we've overlapped spinning on first peer's data with memory loads of src data » — en différant le réarrangement des registres jusqu'après l'attente, on fait chevaucher le temps d'attente avec le chargement des données locales.
Quatrième étape : reduce et envoi.Après réception des données, effectuerapplyReduce 📎 src/device/prims_ll128.h:227-230, puisstore128écrire dans le tampon d'envoi📎 src/device/prims_ll128.h:274-287. Noter que lors de l'envoiflagThread ? flag : v[u+1]— flagThread écrit le flag, les autres threads écrivent les données.
Cinquième étape : avancer le step.Contrairement à LL, l'avancement du step de LL128 se fait uniformément à la fin deGenericOpvia📎 src/device/prims_ll128.h:324-332, et non dansrecvReduceSendCopy. De plus,postSendutilise__threadfence_system()(SM90+) ou__threadfence() 📎 src/device/prims_ll128.h:87-96, garantissant que les données sont visibles pour les autres GPU/cartes réseau avant de mettre à jour le pointeur tail.
Contrôle de concurrence et interaction matérielle
Lebarrier()de LL128 utilise toujoursbarrier_sync(15 - group, nthreads) 📎 src/device/prims_ll128.h:64-66, contrairement à LL qui dispose d'une optimisation mono-warp. Car le transfert de données de LL128 est au niveau warp, nécessitant une synchronisation inter-warp.
loadRegsBeginLe réarrangement en mémoire partagée dans__syncwarp()synchronise via📎 src/device/prims_ll128.h:129, garantissant que tous les threads ont fini d'écrire en mémoire partagée avant la lecture.
Pièges en production
Piège 1 : falaise de performance en cas d'accès non aligné.Si le tampon utilisateur n'est pas aligné sur 16 octets, chaque transfert doit passer par la mémoire partagée comme intermédiaire, ce qui peut entraîner une baisse de performance de plus de 30 %. En production, il faut s'assurer que les tampons d'entrée et de sortie sont alloués alignés sur 16 octets.
Piège 2 :flagThreadpression sur les registres deflagThread ne charge que la moitié des données, ce qui signifie que son utilisation des registres diffère des autres threads. Si le compilateur n'alloue pas correctement les registres, cela peut provoquer un débordement des registres vers la mémoire locale, entraînant une chute brutale des performances.
Simple : utiliser FIFO + notification pour un haut débit sur les gros messages
Modèle intuitif
Le protocole Simple ressemble à un casier de livraison : l'expéditeur place les données dans un tampon FIFO (le casier), puis met à jour un pointeur step « j'ai déposé dans le casier n° N » (envoi de notification) ; le destinataire interroge le pointeur step, et en voyant une nouvelle valeur, va récupérer le colis dans le casier correspondant. Le coût de la poignée de main est élevé (il faut écrire le pointeur + lire le pointeur), mais on peut transférer beaucoup de données en une fois, ce qui convient aux gros messages.
Structures de données et disposition mémoire
Les champs de Simple sont bien plus complexes que ceux de LL/LL128📎 src/device/prims_simple.h:28-46:
| Champ | Type | Rôle |
|---|---|---|
flags | int | Drapeaux de bits, encodant le rôle (WaitRecv/WaitSend/PostRecv/PostSend), le mode Direct, NetReg, etc. |
step | uint64_t | Step courant |
connStepPtr | uint64_t* | Pointeur vers le step de l'homologue de la connexion |
connStepCache | uint64_t | Cache la dernière valeur de step lue |
connEltsFifo | T* | Adresse de base du tampon FIFO |
connStepSize | int | Nombre d'octets par step |
directBuff | T* | Pointeur de tampon direct en mode Direct |
flagsDéfinition des bits de📎 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;C'est une conception typique « remplacer plusieurs champs bool par des opérations bit à bit », économisant des registres. Chaque thread se voit attribuer un rôletidselon son📎 src/device/prims_simple.h:651-666: lesnrecvpremiers threads sont WaitRecv, lesnsendsuivants sont WaitSend, lesnrecvderniers sont PostRecv, et lesnsendavant-derniers sont PostSend.
Parcours guidé par scénario : un recvReduceSend
Chaîne d'appels :recvReduceSend(inpIx, eltN) → genericOp<0, 0, 1, 1, Input, -1> 📎 src/device/prims_simple.h:994-996。
Première étape : calculer la taille de slice. sliceSize = max(divUp(nelem, 16 * SlicePerChunk) * 16, sliceSize / 32) 📎 src/device/prims_simple.h:185-186. Cette formule garantit que la slice est au moins alignée sur 16 octets et pas trop petite.
Deuxième étape : boucle worker.Seuls les threadstid < nworkersentrent dans la boucle principale📎 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— on réserve un warp pour le chevauchement entre threadfence et copy.
Troisième étape : attendre l'homologue. waitPeerest le📎 src/device/prims_simple.h:103-164:
while (connStepCache + (isSendNotRecv ? NCCL_STEPS : 0) < step + StepPerSlice) {
connStepCache = loadStepValue(connStepPtr);
if (checkAbort(flags, Aborted, spins)) break;
}isSendNotRecvDistingue l'envoi et la réception : en envoi, on attend que « l'homologue a consommé » (head) ; en réception, on attend que « l'homologue a produit » (tail).NCCL_STEPSest le nombre de slots du tampon,StepPerSliceest le nombre de steps par slice.
Après l'attente, définirptrs[index] 📎 src/device/prims_simple.h:123-158selon le mode Direct. Le mode Direct permet de lire/écrire directement dans le tampon de l'homologue, en contournant le FIFO, économisant une copie.
Quatrième étape : reduceCopy.Choisir différentsreduceCopyselon la combinaison Direct, appeler📎 src/device/prims_simple.h:241-277. La branche la plus complexe est lorsquesrcs[0] && dsts[0]existent tous les deux📎 src/device/prims_simple.h:258-271, appelantreduceCopy<Unroll, RedOp, T, MultimemSrcs, Recv+Src, Recv*MaxRecv+Src, MultimemDsts, Send+Dst, Send*MaxSend+Dst, PreOpSrcs>, dont les paramètres signifient : lire depuisRecv*MaxRecv+Srcsources, réduire puis écrire versSend*MaxSend+Dstdestinations.
Cinquième étape : postPeer. postPeerMettre à jour le pointeur 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);Le côté envoi doit, avant de mettre à jour le step,fence_acq_rel_sys(), garantissant que les écritures de données sont visibles pour les autres GPU/cartes réseau. Le côté réception n'a pas besoin de fence, car le destinataire ne fait que notifier « j'ai consommé », ce qui n'implique pas de visibilité des données.
Contrôle de concurrence et interaction matérielle
La synchronisation de Simple utilisest_relaxed_sys_globalpour écrire le pointeur step📎 src/device/prims_simple.h:167-175, etloadStepValuepour lire📎 src/device/prims_simple.h:86-100。loadStepValueEn SM90+ avecNvlsMinPollingactivé, on utilise l'instructionmultimem.ld_reduce.acquire.sys.global.min.u64, qui est l'interrogation active accélérée matériellement de NVLink SHARP.📎 src/device/prims_simple.h:86-100Différence entre
barrier()etsubBarrier()📎 src/device/prims_simple.h:49-55:barrier()synchronise tous lesnthreadsthreads,subBarrier()ne synchronise que lesnworkersthreads worker.subBarrierLe numéro de barrière de15 - group - (nworkers != nthreads ? 1 : 0)estbarrier(), et lorsque le nombre de workers diffère du nombre total de threads, on utilise une barrière différente pour éviter un conflit avec
Pièges en production
Piège 1 : attente de destruction en mode NetRegMode.Le destructeur contient une logique spéciale📎 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) { ... }
}En mode NetRegMode, le tampon d'envoi est directement accédé par la carte réseau ; il faut attendre que le thread proxy confirme l'envoi (size mis à -1) avant de pouvoir retourner, sinon le kernel suivant pourrait écraser les données en cours de lecture par la carte réseau.
Piège 2 : interblocage de sendrecv en DirectRead.Le destructeur contient également un segment📎 src/device/prims_simple.h:814-824:
if ((flags & DirectRead) && (flags & RoleWaitSend) && P2p) {
while (*tail > *head) { ... }
}En mode DirectRead de sendrecv, l'émetteur doit attendre que le récepteur ait fini de lire les données avant de pouvoir retourner. Si le récepteur, pour une raison quelconque, ne fait pas progresser tail, l'émetteur se retrouve en interblocage. Cette attente doit être effectuée aprèsbarrier(), sinon il pourrait y avoir une compétition avec le thread post.
Piège 3 :roundUpprovoquant un saut de step. loadRecvConnetloadSendConncontiennent tous deuxstep = roundUp(step, SlicePerChunk * StepPerSlice) 📎 src/device/prims_simple.h:486, 533. Cela aligne le step sur les frontières de slice, mais si le step précédent n'est pas aligné, les slots sautés ne seront pas correctement initialisés. Le code ajoute dansloadRecvConnune instruction*connStepPtr = steppour restituer le credit📎 src/device/prims_simple.h:489。
Comparaison et sélection des trois primitives
flowchart LR
subgraph LL["LL 协议"]
ll_data["ncclLLFifoLine 16B<br/>data1(4B)+flag(4B)+data2(4B)+flag(4B)"]
ll_sync["flag 内嵌数据行<br/>轮询 flag 匹配"]
end
subgraph LL128["LL128 协议"]
ll128_data["128B line<br/>15×8B data + 1×8B flag"]
ll128_sync["flagThread 每8线程1个<br/>__any_sync 投票"]
end
subgraph Simple["Simple 协议"]
simple_data["FIFO 缓冲区<br/>connEltsFifo + step*connStepSize"]
simple_sync["step 指针 + fence<br/>loadStepValue 轮询"]
end
ll_data --> ll_sync
ll128_data --> ll128_sync
simple_data --> simple_sync| Dimension | LL | LL128 | Simple |
|---|---|---|---|
| Taux de charge utile | 50% | 93.75% | ~100% |
| Mode de synchronisation | flag intégré, polling | flagThread + vote warp | pointeur step + fence |
| Exigence d'alignement | Aucune (avec réorganisation par décalage) | 16 octets | Aucune |
| Taille de message applicable | Petite (< 8KB) | Moyenne (8KB ~ 128KB) | Grande (> 128KB) |
| Disposition du tampon | ncclLLFifoLine[] | uint64_t[]par ligne de 128B | T[] FIFO |
| Support Direct | Aucun (PrimitivesWithoutDirectdégradé) | Aucun (idem à gauche) | Support complet |
LL et LL128 héritent tous deux dePrimitivesWithoutDirect 📎 src/device/prims_ll.h:9-10, src/device/prims_ll128.h:13-14, car leur disposition de tampon ne permet pas la lecture/écriture directe de la mémoire du pair. Simple, en revanche, implémente complètement le mode Direct, supportant la connexion directe P2P et NVLS.
Réflexions de conception
Pourquoi le flag de LL doit-il être dupliqué deux fois ?Parce que les écritures en mémoire globale du GPU ne garantissent pas l'atomicité.storeLLPour écrire 16 octets, le matériel peut les diviser en deux écritures de 8 octets. Si un seul flag était placé, le récepteur pourrait considérer les données comme prêtes alors qu'elles ne sont écrites qu'à moitié. Les deux flags sont situés respectivement dans la première et la seconde moitié des 16 octets ; ce n'est que lorsque les deux écritures sont terminées que les deux flags correspondent tous les deux.
Pourquoi Simple doit-il réserver un warp ? 📎 src/device/prims_simple.h:625-626Le commentaire dit « For send operations, we need an extra warp to overlap the threadfence and the copy ».fence_acq_rel_sys()est une opération coûteuse ; si tous les threads attendent la fin du fence avant de continuer, cela gaspille beaucoup de temps. Réserver un warp dédié au fence permet aux autres warps de continuer à transférer le lot de données suivant.
Pourquoi l'avancement du step de LL128 se fait-il à la fin de GenericOp plutôt que dans recvReduceSendCopy ?Parce que le transfert de LL128 est au niveau warp, et plusieurs warps peuvent traiter différents slices en parallèle. Si l'on avançait le step dansrecvReduceSendCopy, chaque warp l'avancerait une fois, ce qui ferait avancer le step plusieurs fois. Le placer à la fin deGenericOppour un avancement unifié garantit que chaque slice n'avance qu'une seule fois.
Résumé de ce chapitre
Ce chapitre a approfondi l'implémentation des trois primitives de transfert :
1. LL: utiliserncclLLFifoLinede 16 octets pour intégrer le flag dans la ligne de données ; le récepteur n'a qu'à faire du polling sur la correspondance du flag pour confirmer que les données sont prêtes. Charge utile de 50 %, adapté aux petits messages. Le cœur estreadLLdeld.volatile.global.v4.u32etstoreLLdest.volatile.global.v4.u32。
2. LL128: concentrer le flag dans les 8 derniers octets de chaque bloc de 128 octets, portant la charge utile à 93,75 %. UtiliserflagThread(1 pour 8 threads) pour vérifier le flag,__any_syncpour le vote warp. En cas de non-alignement, passer par une réorganisation en mémoire partagée.
3. Simple: utiliser un tampon FIFO + notification par pointeur step pour un haut débit sur les grands messages.flagsencodage des rôles par bits de drapeau,waitPeerpolling du step,postPeermise à jour du step et fence. Support complet du mode Direct.
Les trois primitives partagent le même squelette de template, spécialisé via le paramètre de templateProto. La couche algorithmique n'appelle qu'une interface unifiée et ne se soucie pas du protocole sous-jacent. C'est la réponse à « pourquoi la même logique AllReduce nécessite trois primitives de transfert » : différentes tailles de message nécessitent différentes stratégies de synchronisation et dispositions de tampon ; les trois primitives sont respectivement optimisées pour les petits, moyens et grands messages.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la logique de cleanup dansincSend(📎 src/device/prims_ll.h:99-106), dans quel scénario cela déclencherait-il une corruption de données ? Pourquoi ?
Analyse de référence: la logique de cleanup, lors desendStep[i] & NCCL_LL_CLEAN_MASK == NCCL_LL_CLEAN_MASK, réécrit toutes les lignes du slice entier avec le flag courant (données remplies à 0). Si on la supprime, lorsque le step revient à la frontièreNCCL_LL_CLEAN_MASK, le flag de certaines lignes pourrait encore être la valeur du tour précédent. Si le flag du tour précédent se trouve être égal au flag attendu par le récepteur pour ce tour, le récepteur croira à tort que les données sont prêtes et lira les données résiduelles du tour précédent. C'est un problème ABA typique. La condition de déclenchement est une exécution prolongée (step dépassantNCCL_LL_CLEAN_MASKcycles) et un flag qui revient exactement à la même valeur. Ce type de bug est extrêmement difficile à reproduire, car il nécessite un alignement précis des steps.
Q2 : Dans le destructeur du protocole Simple, l'attente en mode NetRegMode (📎 src/device/prims_simple.h:794-804) et l'attente en mode DirectRead (📎 src/device/prims_simple.h:814-824) protègent respectivement contre quoi ? Si l'on supprime l'une des deux, que se passe-t-il dans un scénario à forte concurrence ?
Analyse de référence: En mode NetRegMode, on attend que le thread proxy metteconnFifo[prevStep].sizeà -1, ce qui indique que la carte réseau a terminé l'envoi. Si on la supprime, le kernel suivant pourrait écraser le tampon d'envoi en cours de lecture DMA par la carte réseau, provoquant la lecture de données corrompues. En mode DirectRead, on attend que le récepteur fasse avancer le tail (*tail > *head), ce qui indique que le récepteur a fini de lire le tampon direct. Si on la supprime, l'émetteur pourrait écraser le tampon avant que le récepteur ait fini de le lire, faisant lire au récepteur les nouvelles données au lieu des anciennes. Dans un scénario à forte concurrence, ces deux attentes sont indispensables ; en supprimer une provoque une course de données. La différence est que NetRegMode protège contre « la lecture par la carte réseau », tandis que DirectRead protège contre « la lecture par le GPU distant ».
Q3 : LeloadRegsBeginde LL128 emprunte le chemin de réorganisation via mémoire partagée (📎 src/device/prims_ll128.h:115-141) en cas de non-alignement. De combien ce chemin est-il plus lent que le chemin aligné ? Pourquoi NCCL n'exige-t-il pas directement que les tampons utilisateur soient alignés sur 16 octets ?
Analyse de référence: Le chemin non aligné ajoute trois étapes : écriture en mémoire partagée,__syncwarp(), lecture depuis la mémoire partagée. Bien que la bande passante de la mémoire partagée soit élevée,__syncwarp()est un point de synchronisation qui bloque le warp jusqu'à ce que tous les threads aient terminé l'écriture. En estimation grossière, le chemin non aligné est 20 à 40 % plus lent que le chemin aligné, selon les conflits de bancs de mémoire partagée. NCCL n'impose pas l'alignement car l'utilisateur peut passer des tampons à offset arbitraire (par exemple des tranches de tenseur), et l'alignement forcé limiterait la flexibilité de l'API. La stratégie de NCCL est « chemin rapide si aligné, chemin lent mais correct si non aligné ». En production, il est recommandé d'allouer les tampons alignés sur 16 octets pour emprunter le chemin rapide.
Nous maîtrisons désormais les mécanismes de transfert de données des trois primitives LL, LL128 et Simple, qui offrent aux algorithmes de niveau supérieur des moyens flexibles de régler les performances. Le chapitre suivant plongera dans le cœur des algorithmes de communication collective, pour voir comment AllReduce, AllGather, ReduceScatter, etc. appellent ces primitives, et comment Ring, Tree, CollNet et d'autres algorithmes organisent les flux de données, pour finalement réaliser une communication collective de bout en bout.
Chapitre 10 : Chapitre 10 : Cœur des algorithmes de communication collective : implémentation côté device de AllReduce, AllGather, ReduceScatter
Chapitre 10 : Cœur des algorithmes de communication collective : implémentation côté device de AllReduce, AllGather, ReduceScatter
Le chapitre précédent a décomposé les trois primitives de protocole LL, LL128 et Simple ; ce sont les « moteurs » du transfert de données, mais le moteur lui-même ne sait pas quoi transférer, où le transférer, ni dans quel ordre. Ce chapitre examine le groupe de fichiers de kernels d'algorithmes sous src/device, qui sont la « boîte de vitesses » — ils traduisent les sémantiques de communication collective AllReduce, AllGather, ReduceScatter en une série d'appels de primitives comme prims.directSend, prims.directRecvReduceDirectSend. En une phrase, la contradiction centrale de ce chapitre : pourquoi un même AllReduce nécessite-t-il quatre implémentations côté device totalement différentes — Ring, Tree, CollNet, NVLS ? La réponse se trouve dans l'adéquation entre « topologie du flux de données » et « capacités matérielles ». Ring utilise un minimum de bande passante réseau pour un pipeline en deux phases, Tree compresse la latence à log(n) par réduction en arbre, et CollNet/NVLS déchargent la réduction sur la carte réseau ou le commutateur NVLink. Ce chapitre les décompose une par une.
10.1 Ring AllReduce : comment le pipeline en deux phases s'implante dans le kernel
Modèle intuitif : un « relais » sur une chaîne de montage circulaire
Imaginez n ouvriers disposés en cercle, chacun tenant une caisse de matières premières. L'objectif d'AllReduce est que chacun obtienne finalement le « produit fini mélangeant toutes les matières premières ». L'algorithme Ring procède en deux phases : la première (reduce-scatter) fait circuler chaque caisse le long de l'anneau, en y mélangeant ses propres matières premières à chaque station ; après n-1 stations, chacun détient exactement une part de « mélange complet » du produit fini, mais seulement 1/n de la part ; la seconde phase (all-gather) fait circuler ces parts de produit fini le long de l'anneau, chacun complétant toutes les parts.
Sans Ring, l'approche la plus naïve est que chaque rank envoie ses données au root, le root réduit puis diffuse — la bande passante réseau du root devient le goulot d'étranglement, et plus n est grand, plus c'est lent. La subtilité de Ring réside dans :Le volume d'envoi et de réception de chaque rank est de 2(n-1)/n fois la quantité de données, réparti uniformément sur tous les liens indépendamment de n。
Structure de données et disposition mémoire
L'état central de l'algorithme Ring se trouve dansncclRingla structure (définie dans device.h, non détaillée dans ce chapitre),runRingon n'en extrait que deux champs :
ring->index: la position logique de ce rank dans l'anneau, utilisée pour calculer « quel chunk traiter à l'étape j ».ring->prev/ring->next: les numéros des ranks prédécesseur et successeur, utilisés commePrimitivesparamètres recv/send peer du constructeur.
Les paramètres clés de découpage sont calculés parncclCollCbdPart(📎 src/device/all_reduce.h:21-22):
ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);Cette fonction découpe les données de tout le domaine de communication par channel et produit trois valeurs :gridOffset(l'offset de début des données dont ce channel est responsable dans tout le buffer),channelCount(le nombre total d'éléments dont ce channel est responsable),chunkCount(le nombre d'éléments du chunk attribué à chaque rank).chunkCountC'est la granularité de l'algorithme Ring — un chunk est déplacé à chaque étape.
loopCount = nranks * chunkCount(📎 src/device/all_reduce.h:23) représente la quantité de données traitée pour « un tour complet ». La boucle externefor (elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount)(📎 src/device/all_reduce.h:34) signifie : si la quantité de données du channel dépasse ce qu'un tour peut traiter, on exécute plusieurs tours.
Step-by-Step Walkthrough : le flux d'appel complet d'un Ring AllReduce
Scénario : 4 ranks (nranks=4), leringIx=0,chunkCount=100,channelCount=400de ce rank (exactement un tour).
Étape 0 : pousser « son propre chunk » vers le GPU suivant(📎 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);modRanksest un lambda qui effectue une soustraction modulo nranks (📎 src/device/all_reduce.h:40)。ringIx + nranks - 1représente « le numéro du chunk précédent de ce rank ». Pourquoi l'étape 0 envoie-t-elle le chunk 3 ? Parce que dans la phase reduce-scatter du Ring, chaque rank envoie d'abord la portion de données qu'il « ne doit pas conserver » (c'est-à-dire le chunk du rank prédécesseur).directSendenvoie seulement sans recevoir, car aucune donnée n'a encore été reçue à ce moment.
Étapes 1 à nranks-2 : recevoir, réduire et retransmettre(📎 src/device/all_reduce.h:50-56)
for (int j = 2; j < nranks; ++j) {
chunk = modRanks(ringIx + nranks - j);
...
prims.directRecvReduceDirectSend(offset, offset, nelem);
}directRecvReduceDirectSendest la primitive centrale du Ring : recevoir un chunk depuispreveffectuer une réduction avec les données locales (par exemple une addition), puis envoyer le résultat ànext. Noter queoffsetetnelemsont recalculés à chaque itération — car le chunk traité diffère à chaque étape. j va de 2 à nranks-1, soit nranks-2 étapes.
Étape nranks-1 : recevoir le dernier chunk et le réduire, produisant le résultat final(📎 src/device/all_reduce.h:58-64)
chunk = ringIx + 0;
...
prims.directRecvReduceCopyDirectSend(offset, offset, nelem, /*postOp=*/true);LepostOp=truede cette étape est crucial : après la réduction, il faut exécuter une opération post-traitement (par exemple la division pour la moyenne).directRecvReduceCopyDirectSendcomporte unCopyde plus que l'étape précédente — il écrit le résultat de la réduction à la fois dans le recvbuff local et vers next. À ce stade, la phase reduce-scatter se termine, chaque rank détient un chunk « complètement réduit ».
Phase all-gather : nranks-2 étapes de simple retransmission(📎 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);
}Noter qu'ici on utilisedirectRecvCopyDirectSend, sansReduce— car les données sont déjà réduites, il suffit de copier et retransmettre.
Dernière étape : recevoir le dernier chunk(📎 src/device/all_reduce.h:75-81)
chunk = modRanks(ringIx + 1);
...
prims.directRecv(offset, nelem);Reçoit seulement sans envoyer, complétant le dernier bloc.
L'ensemble du flux peut être résumé par le graphe de flux de contrôle suivant :
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 --> loopRéflexion de conception : pourquoi l'ordre des chunks du Ring « recule »
Noter la régularité de la numérotation des chunks : l'étape 0 envoieringIx-1, l'étape j traiteringIx-j, la dernière étape traiteringIx+0. C'est une progressiondans le sens antihoraire. Pourquoi ? Parce que chaque rank du Ring ne conserve que « le chunk dont il est responsable de la réduction » (c'est-à-direringIx+0), les autres chunks ne font que passer. La progression antihoraire garantit : lorsqu'un chunk fait un tour complet et revient à son point de départ, il a exactement accompli nranks réductions, produisant le résultat final. Si la progression était horaire, le chunk terminerait sa réduction sur le mauvais rank.
Piège en production :remCount < loopCountle piège d'alignement lorsque
📎 src/device/all_reduce.h:38Il y a une ligne de code facile à négliger :
if (remCount < loopCount) chunkCount = alignUp(divUp(remCount, nranks), 16 / sizeof(T));Lorsque les données restantes ne suffisent pas pour un tour, chunkCount doit être recalculé, etalignUp(..., 16/sizeof(T))force un alignement sur 16 octets. Pourquoi ? Parce que le protocole LL128 exige un alignement sur 128 octets, et le protocole Simple a aussi des besoins d'alignement pour l'accès vectorisé. Si l'on retire cet alignement, les chunks non alignés empruntent le chemin lent, avec une baisse de performance de 20-40 %. En production, si l'on constate des fluctuations de performance de Ring AllReduce en fin de petits messages, c'est souvent que cet alignement n'a pas pris effet — vérifier sichannelCountest un multiple entier denranks * 16/sizeof(T).
10.2 Tree AllReduce : réduire la latence à log(n) grâce à la réduction en arbre
Modèle intuitif : le « rapport hiérarchique » en entreprise
La latence du Ring est O(n) — les données doivent faire un tour complet. Lorsque n est très grand (par exemple 1024 GPU), même si la bande passante est répartie uniformément, la latence devient insupportable. L'algorithme Tree adopte une autre approche : comme la structure organisationnelle d'une entreprise, chaque rank ne communique qu'avec son « nœud parent » et ses « nœuds enfants ». Dans la phase de réduction, les nœuds feuilles remontent les données, les nœuds parents fusionnent les données des nœuds enfants ; dans la phase de diffusion, c'est l'inverse, le nœud racine redescend le résultat. La latence passe de O(n) à O(log n).
Sans Tree, la latence de l'AllReduce dans les grands clusters augmenterait linéairement avec le nombre de ranks, et le temps d'itération d'entraînement serait écrasé par la communication.
Structures de données et disposition mémoire
L'état de Tree se trouve dansncclTree:
tree->up: rank du nœud parent (-1 indique que ce rank est la racine).tree->down[]: tableau des nœuds enfants, au maximumNCCL_MAX_TREE_ARITY(typiquement 3, soit binaire + local).
runTreeUpDownetrunTreeSplitsont deux variantes. La première utilise un mode en deux phases « tout réduire d'abord, tout diffuser ensuite », la seconde divise les threads en deux moitiés, une moitié effectuant la réduction et l'autre la diffusion, réalisant ainsi un chevauchement pipeline.
Step-by-Step Walkthrough : les trois branches de runTreeUpDown
runTreeUpDownLe premier bloc de code de📎 src/device/all_reduce.h:96-118est la phase de réduction (
), qui se divise en trois cas selon la position de ce rank dans l'arbre :tree->up == -1)(📎 src/device/all_reduce.h:99-104)
prims.directRecvReduceCopy(offset, offset, nelem, /*postOp=*/true);CopierpostOp=trueLe nœud racine ne fait que recevoir, sans envoyer : il reçoit les données de tous les nœuds enfants, les réduit et les écrit dans recvbuff.
Exécute l'opération post.tree->down[0] == -1)(📎 src/device/all_reduce.h:105-110)
prims.directSend(offset, offset, nelem);Copier
Le nœud feuille ne fait qu'envoyer, sans recevoir : il envoie ses propres données au nœud parent.(📎 src/device/all_reduce.h:111-117)
prims.directRecvReduceDirectSend(offset, offset, nelem);Copier
Reçoit des nœuds enfants, réduit, envoie au nœud parent.📎 src/device/all_reduce.h:120-142Phase de diffusion (directSendFromOutput) : logique symétrique : le nœud racinedirectRecv(envoie depuis recvbuff), le nœud feuilledirectRecvCopyDirectSend。
, le nœud intermédiaire
runTreeUpDownrunTreeSplit : implémenter le pipeline réduction-diffusion par division des threadsrunTreeSplitLe problème de📎 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;
}divise les threads en deux groupes (
Copiertid < nthreadsSplitLe protocole Simple divise en deux parts égales ; les protocoles LL/LL128 divisent selon un ratio 7:3, car « recevoir des données de 3 sources pour effectuer une réduction » est plus intensif en calcul que « envoyer vers 3 cibles », donc le groupe de réduction reçoit plus de threads.📎 src/device/all_reduce.h:175-202Ensuite📎 src/device/all_reduce.h:203-224les threads deProto::MaxGroupWidtheffectuent la réduction vers le haut (📎 src/device/all_reduce.h:189), et les autres threads effectuent la diffusion vers le bas (0 * Proto::MaxGroupWidth). Les deux groupes se distinguent par l'offset📎 src/device/all_reduce.h:210pour identifier leurs groupes de communication respectifs (1 * Proto::MaxGroupWidth)。
de
etdirectRecvReduceDirectSenddetree->upRéflexion de conception : pourquoi le nœud racine de Tree nécessite un traitement spécialif (tree->up == -1)Le nœud racine de la réduction arborescente est le « point de convergence » : son volume de réception est multiplié par le nombre de nœuds enfants, et son volume d'envoi est nul (phase de réduction). Si le nœud racine passait aussi par letree->down[0] == -1générique, il tenterait d'envoyer vers
(-1), provoquant un dépassement de limites. Il faut donc le traiter séparément avec la branche
. De même pour le jugementdu nœud feuille.Piège en production : le problème du « nœud racine point chaud » de l'algorithme TreerunTreeSplitLe nœud racine de Tree supporte tout le trafic de réduction ; si le GPU où se trouve le nœud racine est justement un nœud lent (par exemple avec une bande passante PCIe limitée), tout l'AllReduce est ralenti. La réponse de NCCL est :FanSymmetric<NCCL_MAX_TREE_ARITY_TOP>(📎 src/device/all_reduce.h:168Chaque channel choisit une racine différente
, répartissant la charge du nœud racine sur plusieurs ranks. C'est pourquoi
dans
la branche du nœud racine utilise
) — il doit traiter simultanément la réduction de plusieurs nœuds enfants. En production, si l'on constate une performance inégale de Tree AllReduce, vérifier si la distribution des nœuds racines des channels est uniforme.
10.3 AllGather et ReduceScatter : les variantes « mi-parcours » du Ring
all_gather.hModèle intuitif : AllReduce divisé en deux moitiésrunRing(📎 src/device/all_gather.h:14-88AllGather et ReduceScatter sont essentiellement les deux phases d'AllReduce, chacune devenant une API indépendante. AllGather ne fait que « collecter » — chaque rank contribue une part de données, et finalement tout le monde obtient l'ensemble des données. ReduceScatter ne fait que « réduire + disperser » — tout le monde contribue des données, et après réduction chacun obtient une part.
Sans ces deux API indépendantes, un utilisateur voulant faire « réduire puis collecter » ou « collecter puis réduire » ne pourrait qu'appeler AllReduce puis découper manuellement, gaspillant la moitié de la bande passante.(📎 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);
}LeinputBuf + dataOffset == outputBuf + offsetdedirectSend) est plus simple qu'AllReduce : pas de réduction, seulement de la copie-transfert.directCopySendÉtape 0 : pousser ses propres données vers le GPU suivant
Copier(📎 src/device/all_gather.h:62-67)
prims.directRecvCopyDirectSend(offset, offset, nelem);, cela signifie que l'entrée et la sortie sont le même bloc mémoire (AllGather in-place), on fait directement(📎 src/device/all_gather.h:69-74)
prims.directRecv(offset, nelem);(copier d'abord vers la sortie puis envoyer).
📎 src/device/all_gather.h:28-36Étapes intermédiaires nranks-2 : pur transfert
if (isNetOffload) {
workNthreads = WARP_SIZE;
chunkCount = NCCL_MAX_NET_SIZE;
} else {
workNthreads = nthreads;
}Dernière étape : recevoir le dernier blocisNetOffload=trueCopier📎 src/device/all_gather.h:76-82isNetOffload : un seul warp pilote le réseau + plusieurs warps copient en parallèle
Il y a une branche spéciale dansbarrier_sync(14, nthreads)(📎 src/device/all_gather.h:87:__syncthreads()。
Copier
reduce_scatter.hLorsquerunRing(📎 src/device/reduce_scatter.h:14-56(mode single RPN + enregistrement réseau), un seul warp pilote la communication Ring, les autres warps effectuent en parallèle la « copie des données source vers le buffer cible » (
). Cela permet, en AllGather non in-place, de chevaucher le coût de copie et le coût de communication.(📎 src/device/reduce_scatter.h:39-42)
rankDest = ringRanks[nranks - 1];
offset = dataOffset + rankDest * count;
prims.send(offset, nelem);), et le commentaire l'explique clairement : il faut attendre que tous les warps aient terminé, sinon le work suivant pourrait réutiliser outputBuf et provoquer une compétition. On utilise la barrière 14 pour éviter la barrière propre à prims et(📎 src/device/reduce_scatter.h:44-49)
prims.recvReduceSend(offset, nelem);Le(📎 src/device/reduce_scatter.h:61-64)
prims.recvReduceCopy(offset, dataOffset, nelem, /*postOp=*/true);Attention à la dernière étaperecvReduceCopyil y a deux offsets :offset(source de réception) etdataOffset(entrée locale), le résultat de la réduction est écrit dansdataOffset。
Diagramme comparatif des flux de données
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 -.->|"拆解"| RSPièges en production : les limites du jugement in-place
📎 src/device/all_gather.h:55le jugement in-place deinputBuf + dataOffset == outputBuf + offsetdépend de l'égalité exacte des pointeurs. Si le sendbuff et le recvbuff fournis par l'utilisateur ont un décalage mais correspondent logiquement au même bloc de mémoire, ce jugement échoue, ce qui conduit à emprunter ledirectCopySendchemin — bien que correct, cela ajoute une copie supplémentaire. En production, il est recommandé de s'assurer que sendbuff et recvbuff sont parfaitement identiques lors d'un AllGather in-place.
10.4 CollNet et NVLS : décharger la réduction sur le matériel
Modèle intuitif : laisser le « switch » aider au calcul
Ring et Tree font tous deux « le GPU calcule lui-même la réduction ». CollNet et NVLS adoptent une approche différente : décharger l'opération de réduction sur la carte réseau (CollNet) ou sur le switch NVLink (NVLS). Le GPU se charge uniquement d'envoyer les données, le matériel effectue la réduction puis rediffuse. C'est comme passer de « chaque ouvrier mélange lui-même les ingrédients » à « envoyer les ingrédients à un mixeur central, le mixeur mélange puis redistribue ».
Sans déchargement matériel, l'opération de réduction occupe les ressources SM du GPU, et la latence de réduction ne peut pas être masquée.
Répartition des threads dans CollNet Direct
RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_COLLNET_DIRECT, ...>lerun(📎 src/device/all_reduce.h:249-386) divise les threads en quatre groupes :
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;Les quatre groupes de threads sont respectivement responsables de : Scatter (répartir les données vers chaque rail), Reduce (réduire puis envoyer au réseau), Gather (collecter depuis chaque rail), Bcast (diffuser après réception depuis le réseau).COLLNET_COPY_THREADS = 96(📎 src/device/all_reduce.h:250) est le nombre fixe de threads de copie.
netRegUsed : disposition des buffers en mode enregistrement réseau
📎 src/device/all_reduce.h:280-288il y a une branche critique :
if (work->netRegUsed) {
offsetBase = bid * chunkSize;
maxNelems = size;
peerOffset = nChannels * chunkSize;
} else {
offsetBase = bid * direct->nHeads * chunkSize;
maxNelems = direct->nHeads * chunkSize;
peerOffset = chunkSize;
}netRegUsedmode, les buffers sont disposés de manière contiguë par channel (bid * chunkSize), l'offset peer estnChannels * chunkSize; en mode non enregistré, ils sont disposés par head (bid * nHeads * chunkSize), l'offset peer estchunkSize. Cette différence provient du fait que le mode enregistrement réseau exige des buffers contigus pour permettre le DMA de la carte réseau.
Allocation des warps dans NVLS
RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_NVLS, ...>lerun(📎 src/device/all_reduce.h:391-523) utilise une allocation de warps plus fine :
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;regUsedmode, scatter/gather n'occupent chacun qu'1 warp (car le matériel NVLS opère directement sur la mémoire enregistrée), reduce occupe la majorité ; en mode non enregistré, scatter/gather occupent chacun environ la moitié, reduce s'ajuste selon le nombre de ranks (≤6 utilise 7 warps, sinon 5 warps).
Diagramme d'interaction temporelle
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: 广播到所有 rankPièges en production : ledirect->out == -1piège de CollNet
📎 src/device/reduce_scatter.h:521il y a une ligne :
if (direct->out == -1) __trap();Si la connexion out de CollNet n'est pas établie (-1), directement__trap()fait planter le kernel. C'est de la programmation défensive — CollNet dépend de la carte réseau, si l'initialisation de la carte réseau échoue, out sera -1, et continuer l'exécution entraînerait un comportement indéfini. En production, si vous voyez un kernel trap, vérifiez si la carte réseau CollNet est correctement initialisée.
10.5 Broadcast et Reduce : les deux opérations collectives les plus simples
Broadcast : diffusion en éventail depuis le root
broadcast.hlerunRing(📎 src/device/broadcast.h:14-64) la logique est très directe : le nœud root envoie les données, les autres nœuds relaient, le dernier nœud ne fait que recevoir.
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);
}Trois branches : root envoie, le prédécesseur du root reçoit, les nœuds intermédiaires relaient. Attention,nextRank == rootvérifie que « le suivant de ce nœud est le root », c'est-à-dire que ce nœud est le dernier sur l'anneau — il ne fait que recevoir sans envoyer.
Reduce : convergence vers le root
reduce.hlerunRing(📎 src/device/reduce.h:14-53) est l'opération inverse de 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 == rootle nœud ne fait qu'envoyer (c'est le prédécesseur du root), le root ne fait que recevoir et réduire, les nœuds intermédiaires reçoivent, réduisent et relaient simultanément.
Réflexion de conception : pourquoi Broadcast/Reduce utilisent aussi Ring
Broadcast et Reduce pourraient théoriquement utiliser Tree pour une latence plus faible, mais NCCL choisit Ring car :ces deux opérations portent généralement sur de petits volumes de données, l'implémentation Ring est plus simple, et permet de réutiliser le chemin de code Ring d'AllReduce. La complexité de Tree (sélection du nœud racine, découpage des threads) n'apporte pas de gain notable dans les scénarios à petits messages.
Pièges en production : le goulot d'étranglement de bande passante du nœud root dans Broadcast
Le nœud root de Broadcast doit envoyer toutes les données, si le root est un nœud lent, tout le Broadcast est ralenti. La réponse de NCCL est :Broadcast supporte aussi plusieurs channels, le root de chaque channel peut être différent. Mais attention,work->rootest global, tous les channels partagent le même root — c'est déterminé par la sémantique de Broadcast (une seule source). En production, si Broadcast est lent, vérifiez la bande passante réseau du nœud root.
10.6 Matrice de sélection d'algorithme : spécialisation du template RunWorkColl
Tous les kernels d'algorithme sont enregistrés via la spécialisation du templateRunWorkColl(📎 src/device/all_reduce.h:228-788). Chaque spécialisation correspond à une combinaison « fonction × algorithme × protocole » :
| Fonction | Algorithme | Protocole | Emplacement de spécialisation |
|---|---|---|---|
| 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 |
Attention :CollNet et NVLS ne prennent en charge que le protocole SIMPLE. En effet, ces deux algorithmes reposent sur le déchargement matériel, et le mécanisme de synchronisation à faible latence de LL/LL128 est incompatible avec le déchargement matériel — la latence de la réduction matérielle est bien supérieure au polling de flag de LL, et utiliser LL augmente au contraire les surcoûts.
Logique intrinsèque de la sélection de protocole
- LL: petits messages (< 8KB), priorité à la faible latence. Ring et Tree sont tous deux pris en charge.
- LL128: messages moyens (8KB - 1MB), alignement sur 128 octets. Ring et Tree sont tous deux pris en charge.
- SIMPLE: gros messages (> 1MB), priorité à la bande passante. Tous les algorithmes sont pris en charge.
Pièges en production : limitations de combinaison protocole-algorithme
Si l'utilisateur force la spécification deNCCL_PROTO=LLmais que l'algorithme est CollNet, NCCL reviendra à SIMPLE lors de la phase de tuning. En production, si vous constatez que le paramètre de protocole ne prend pas effet, vérifiez si l'algorithme prend en charge ce protocole.
Réflexion de conception : pourquoi la même logique AllReduce nécessite autant d'implémentations
En revisitant ce chapitre, AllReduce dispose de six implémentations algorithmiques : Ring, Tree, CollNet Direct, CollNet Chain, NVLS, NVLS Tree. Ce n'est pas de la redondance, maisla solution optimale pour différentes topologies matérielles et tailles de messages:
- Ring: universel, adapté aux gros messages, utilisation de bande passante maximale.
- Tree: adapté aux clusters à grande échelle, latence O(log n).
- CollNet: adapté aux clusters disposant de cartes réseau supportant la réduction, décharge le calcul GPU.
- NVLS: adapté au NVLink full-connect d'un nœud unique, réduction par multicast matériel.
Le module de tuning de NCCL (chapitre 5) sélectionne automatiquement en fonction de la taille des messages, du nombre de ranks et de la topologie. L'implémentation côté device doit seulement garantir que « chaque combinaison est correcte » ; la logique de sélection se trouve côté host.
Résumé de ce chapitre
Ce chapitre a décomposésrc/deviceles six fichiers de noyaux algorithmiques sous
1. Ring AllReduce(📎 src/device/all_reduce.h:14-83) : pipeline en deux phases, reduce-scatter + all-gather, n-1 étapes par phase.
2. Tree AllReduce(📎 src/device/all_reduce.h:86-225) : réduction en arbre, latence O(log n),runTreeSplitutilise la division des threads pour réaliser le pipeline réduction-diffusion.
3. AllGather(📎 src/device/all_gather.h:14-88) : Ring en une seule phase, prend en charge in-place et netOffload.
4. ReduceScatter(📎 src/device/reduce_scatter.h:14-56) : Ring en une seule phase, correspond à la phase reduce-scatter d'AllReduce.
5. Broadcast/Reduce(📎 src/device/broadcast.h:14-64、📎 src/device/reduce.h:14-53) : la variante Ring la plus simple.
6. CollNet/NVLS(📎 src/device/all_reduce.h:247-635) : déchargement matériel, ne prend en charge que le protocole SIMPLE.
Réflexions et auto-évaluation de ce chapitre
Q1 : Dans la phase reduce-scatter de Ring AllReduce, l'étape 0 utilisedirectSend, les étapes intermédiaires utilisentdirectRecvReduceDirectSend, et la dernière étape utilisedirectRecvReduceCopyDirectSend. Si l'on supprimepostOp=truede la dernière étape, dans quels scénarios des résultats erronés seront-ils produits ?
Analyse de référence:postOp=truedéclenche les opérations postposées (comme la division lors du calcul de la moyenne). PrenonsncclAvgcomme exemple : la réduction est une somme, et postOp est la division par nranks. Si l'on supprimepostOp, la dernière étape ne fait que la réduction sans la division, et recvbuff contient la « somme » et non la « moyenne ». Dans la phase reduce-scatter, chaque rank ne conserve que le résultat final d'un chunk, et ce chunk est précisémentringIx+0(📎 src/device/all_reduce.h:60). Si postOp est absent, la somme de ce chunk n'est pas divisée par nranks, et la phase all-gather suivante propagera cette « somme » erronée à tous les ranks. Attention : seule la dernière étape nécessite postOp, car seule cette étape produit un résultat de « réduction complète » ; les réductions des étapes intermédiaires sont des sommes partielles et ne nécessitent pas postOp. En production, si vous constatez que le résultat d'AllReduce est supérieur d'un facteur nranks, vérifiez si postOp est correctement transmis.
Q2: runTreeSplitSous le protocole LL/LL128, les threads sont répartis selon un ratio 7:3 (📎 src/device/all_reduce.h:163), tandis que sous le protocole Simple, ils sont répartis selon un ratio 1:1 (📎 src/device/all_reduce.h:157). Que se passerait-il si l'on forçait le protocole LL à passer également à 1:1 ?
Analyse de référence: le groupe de réduction de LL/LL128 doit recevoir des données d'au plus 3 nœuds enfants et effectuer la réduction (📎 src/device/all_reduce.h:187deFanAsymmetric<NCCL_MAX_TREE_ARITY, 1>), ce qui est intensif en calcul ; le groupe de diffusion ne fait que de la copie et du transfert (📎 src/device/all_reduce.h:208deFanAsymmetric<1, NCCL_MAX_TREE_ARITY>), ce qui est léger en calcul. La répartition 7:3 permet au groupe de réduction d'avoir suffisamment de threads pour traiter la réduction à 3 voies, tandis que le groupe de diffusion a moins de threads mais en quantité suffisante. Si l'on passait à 1:1, le groupe de réduction manquerait de threads et la réduction deviendrait un goulot d'étranglement ; le groupe de diffusion aurait un excès de threads, ce qui serait du gaspillage. Plus grave encore, le polling de flag du protocole LL est une attente active, et un nombre excessif de threads augmenterait la contention sur les flags. En production, si vous constatez des performances anormales de Tree AllReduce sous le protocole LL, vérifiez si le calcul denthreadsSplita été modifié.
Q3 : Dans le modeisNetOffloadd'AllGather, un seul warp pilote la communication Ring (📎 src/device/all_gather.h:32), et les autres warps copient en parallèle (📎 src/device/all_gather.h:76-82). Si l'on supprime le dernierbarrier_sync(14, nthreads)(📎 src/device/all_gather.h:87), dans quels scénarios des conditions de course sur les données se produiraient-elles ?
Analyse de référence:barrier_syncGarantir que tous les warps (y compris les warps de communication et les warps de copie) terminent ce work avant de passer au work suivant. Si on le retire, le warp de communication pourrait commencer la communication du work suivant alors que le warp de copie n'a pas encore fini d'écrire dans outputBuf, et le work suivant pourrait réutiliser le même outputBuf. Scénario concret : deux AllGather consécutifs, le warp de copie du premier est encore en train d'écrire à la fin de outputBuf, le warp de communication du second a déjà commencé à écrire de nouvelles données dans outputBuf, ce qui écrase les données du premier. Le commentaire le dit clairement : « otherwise, we can have contention if next work will use the outputBuf in this work ». On utilise la barrier 14 plutôt que la barrier par défaut pour éviter les barriers internes de prims et__syncthreads(), afin de prévenir les deadlocks. En production, si les résultats d'AllGather présentent des erreurs intermittentes, vérifier si la barrier du cheminisNetOffloada été optimisée.
Jusqu'ici, nous avons vu comment le kernel d'algorithme côté device organise le flux de données. Chaque algorithme appelle les primitives du chapitre précédent viaPrimitives, la couche algorithme ne se soucie que de « qui envoie à qui, quel chunk envoyer, réduction ou copie ». Le chapitre suivant plongera dans l'abstraction de la couche transport, pour voir comment P2P, SHM, NET, NVLS s'unifient en un ensemble d'interfaces, et comment les threads proxy côté host collaborent avec le kernel côté device pour réaliser la communication inter-nœuds.
Règle fondamentale : tous les algorithmes appellent les primitives via la classe template Primitives, l'algorithme ne s'occupe que de la « topologie du flux de données », les primitives s'occupent du « transport des données ». Cette stratification permet à un nouvel algorithme de n'implémenter que la logique de topologie, sans se soucier de la synchronisation sous-jacente. Mais quelle que soit la topologie, les données doivent finalement transiter par les liens physiques. Le chapitre suivant plongera dans le répertoire src/transport, pour voir comment NCCL utilise une interface transport unifiée pour masquer les différences entre P2P, SHM, NET, NVLS, ainsi que la sémantique setup/connect/send/recv de chaque transport. C'est la base pour comprendre la communication inter-nœuds.
Chapitre 11 : Chapitre 11 : Abstraction de la couche transport : comment P2P, SHM, NET, NVLS s'unifient sous une même interface
Chapitre 11 : Abstraction de la couche transport : comment P2P, SHM, NET, NVLS s'unifient sous une même interface
Dans le chapitre précédent, nous avons plongé dans le kernel d'algorithme, voyant comment Ring AllReduce découpe les données puis effectue une réduction en deux phases, et comment Tree AllReduce utilise une structure arborescente pour réduire la latence — mais ces algorithmes ne définissent que la vue logique du « qui envoie à qui, quel chunk envoyer ». Les données doivent finalement traverser de véritables liens physiques : NVLink, PCIe, mémoire partagée ou carte réseau. Ce chapitre décortique le répertoire src/transport, pour voir comment NCCL utilise une interface unifiée ncclTransport pour masquer les quatre canaux physiques P2P, SHM, NET, NVLS sous un même visage, accomplissant le dernier kilomètre de la topologie algorithmique au transport physique.
I. Interface unifiée : comment ncclTransport masque quatre canaux physiques
Modèle intuitif
Imaginez une société de logistique : que le client envoie un colis intra-ville (P2P), une transmission intra-bâtiment (SHM), un transport inter-provincial (NET) ou une ligne dédiée directe (NVLS), l'accueil ne remplit qu'un seul « bordereau d'expédition ». Ce bordereau est la structurencclTransport— elle stipule que chaque mode de transport doit fournircanConnect、setup、connect、freeet d'autres actions fixes. Sans cette couche d'abstraction, les algorithmes de niveau supérieur devraient écrire quatre ensembles deif-elsepour déterminer quel lien emprunter, et l'ajout d'un nouveau matériel obligerait à modifier tous les algorithmes.
Structures de données et disposition mémoire
NCCL utilise un tableau global pour enregistrer tous les transports, l'ordre étant la priorité :
📎 src/transport.cc:15-20
struct ncclTransport* ncclTransports[NTRANSPORTS] = {
&p2pTransport,
&shmTransport,
&netTransport,
&collNetTransport,
};L'ordre du tableau détermine l'ordre de sélection : P2P en premier, puis SHM, puis NET, enfin CollNet. Chaque transport est décrit par une structurencclTransport, qui contient un pointeur de fonctioncanConnectet deuxncclTransportComm(un pour send, un pour recv). Prenons P2P comme exemple :
📎 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}};ncclTransportCommL'ordre des champs desetupest fixe, ce sont des « slots de cycle de vie » :connect(préparer les ressources),free(échanger les informations de connexion),proxySharedInit(libérer),proxySetup、proxyConnect、proxyFree、proxyProgress、proxyRegister、proxyDeregister(initialisation du partage proxy),proxyProgress. Notez que le slotNULLde P2P estproxyProgress— car P2P passe par une lecture/écriture directe de la mémoire GPU du pair, sans nécessiter de thread proxy host pour transporter les données ; tandis que lesendProxyProgress/recvProxyProgressde NET est
, car l'I/O de la carte réseau doit être pilotée par un thread host.
Walkthrough guidé par scénario : comment une connexion sélectionne un transportselectTransport:
📎 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==1indique la direction send,type==0indique la direction recv. La boucle interroge successivement chaque transport aveccanConnect: retourneret=1signifie « je peux faire ce travail », pointe immédiatementconnector->transportCommvers la direction correspondante de ce transport, et appelle sonsetup. Si tous les transports retournent 0, affiche un avertissement et retournencclSystemError。
canConnectLa logique de décision reflète les « frontières de territoire » de chaque transport. Prenons P2P comme exemple :
📎 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;
}
...Chaîne de décision de P2P : on demande d'abord à la topologie « y a-t-il un chemin P2P entre les deux rank » ; s'il y a des sauts intermédiaires (intermediateRank != -1) et que CE memcpy est activé, on abandonne P2P au profit de SHM/NET ; si la topologie suggère de passer par le réseau (useNet), on abandonne aussi ; enfin on vérifie si c'est le même hôte. La décision de SHM est plus simple :
📎 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 exige le même hôte (hostHashidentique) et le partage du même/dev/shm(shmDevidentique, utilisé pour la communication entre conteneurs). NET retourne presque toujours 1, et ne vérifie si le net intra-node est désactivé que sur le même hôte :
📎 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 est le « filet de sécurité » — tant que personne devant ne prend le relais, il le prend. LecanConnectde NVLS retourne directement 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 ne passe pas par le chemin de connexion peer-to-peer conventionnel, il établit un groupe multicast séparément viancclNvlsSetup, donccanConnectretourne toujours 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 --> doneRéflexions de conception
Pourquoi utiliser « ordre du tableau + vote canConnect » plutôt qu'une table de routage explicite ? Parce que la topologie est dynamique : la même machine peut, en raison deNCCL_P2P_DISABLE, de l'isolation des conteneurs, de la disponibilité de CUDA IPC et d'autres facteurs, rendre P2P indisponible, et dans ce cas rétrograder automatiquement vers SHM ou NET. Le mécanisme de vote permet à chaque transport de juger lui-même « si je peux le faire » ; ajouter un transport ne nécessite que d'ajouter une entrée dans le tableau, sans modifier la logique de sélection. C'est l'incarnation du principe ouvert-fermé dans la programmation système.
II. P2P : les quatre formes de la connexion directe entre GPU d'une même machine
Modèle intuitif
P2P consiste à « se passer directement des choses entre voisins » — le GPU 0 lit et écrit directement la mémoire du GPU 1, sans passer par le CPU ni la carte réseau. Sans P2P, la communication multi-GPU sur une même machine doit passer par la mémoire hôte, doublant la latence et réduisant de moitié la bande passante.
Structures de données et disposition mémoire
P2P comporte quatre formes internes, distinguées parenum p2pType:
📎 src/transport/p2p.cc:19-24
enum p2pType {
P2P_DIRECT,
P2P_INTERMEDIATE,
P2P_IPC,
P2P_CUMEM
};P2P_DIRECT: GPU différents dans le même processus, accès direct par pointeur (le plus rapide).P2P_INTERMEDIATE: pas de connexion directe entre les deux GPU, nécessite un transfert via un GPU intermédiaire.P2P_IPC: inter-processus, utilisation du traditionnelcudaIpcOpenMemHandlepour importer la mémoire de l'autre partie.P2P_CUMEM: inter-processus, import via l'API cuMem (cuMemExportToShareableHandle), prenant en charge une gestion mémoire plus fine.
Structure de ressource principale :
📎 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/recvDevMemest une union — l'émetteur ne se soucie que desendDevMem, le récepteur ne se soucie que derecvDevMem, partageant un même bloc mémoire.sendMemIpc/recvMemIpcconserve le handle de mémoire importée de l'autre partie,sendMemSameProc/recvMemSameProcmarque s'il s'agit du même processus (détermine si on utilisencclCuMemFreeAddroucudaIpcCloseMemHandle)。
lors de la libération)p2pConnectInfoLa structure d'information de connexion
📎 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_assertCopierCONNECT_SIZEgarantit que l'information de connexion ne dépasse pasread(taille de tampon fixe pour un échange unique de bootstrap).read=1Le champ détermine le flux de données :read=0indique que le récepteur lit activement la mémoire de l'émetteur (P2P Read),
indique que l'émetteur écrit activement dans la mémoire du récepteur (P2P Write).
Walkthrough guidé par scénario : établissement de P2P SendselectTransportLorsquep2pSendSetup:
📎 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);
...CopiersendSizePoints clés :ncclSendMemEn mode P2P Read, il faut ajouter en plus la taille du tampon du protocole SIMPLE — car en mode lecture, le tampon SIMPLE de l'émetteur est lu directement par le récepteur, et doit être alloué avecALIGN_SIZE(sendSize, CUDA_IPC_MIN)dans le même bloc de mémoire partageable.
garantit que la taille est alignée sur la granularité minimale de CUDA IPC.intermediateRankEnsuite, selon
📎 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_PIDCopier
📎 src/transport/p2p.cc:334-335
#define P2P_SAME_PID(MYINFO, PEERINFO) \
((MYINFO->hostHash == PEERINFO->hostHash) && (MYINFO->pidHash == PEERINFO->pidHash))CopierP2P_DIRECTMême processus, direct non désactivé et memcpy non activé, c'est le plus rapide
— on prend directement le pointeur de l'autre partie. Sinon, on passe par 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));
}ncclProxyCallBlockingCopierp2pSendProxySetupest un RPC synchrone : le thread host envoie un message au thread proxy, le thread proxy appellencclP2pBuffpour allouer un tampon partageable, et renvoiep2pMap(contenant le handle IPC). Puis
p2pMapmappe le tampon de l'autre partie dans l'espace d'adressage local.
📎 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;
}CopiercudaDeviceEnablePeerAccessMême processus, GPU différents : d'aborddirectPtrouvre le canal P2P, puis on utilise directementncclP2pImportShareableBuffer(car l'espace d'adressage est partagé dans le même processus). Inter-processus : on appelle
pour importer le handle mémoire de l'autre partie.
Contrôle de concurrence et interaction matériellencclSendMem/ncclRecvMemLa synchronisation de P2P repose surhead/taildansheadle pointeurtailL'émetteur écrit
📎 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;
}headpour dire à l'émetteur « jusqu'où j'ai lu ». C'est un producteur-consommateur sans verrou typique :sendDevMem,tailCopierremDevMempointe vers le local
pointe vers l'autre partie
. Le kernel GPU réalise la synchronisation inter-GPU en lisant et écrivant ces deux pointeurs, sans intervention du CPU.Guide de production pour éviter les piègesp2pSendConnect:
📎 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];
}
}Regarderread=1CopiersendDevMem==NULLSincclInternalErrormaisNCCL_P2P_READ_ENABLE=1, retourne directementNCCL_P2P_USE_CUDA_MEMCPY=1. En production, si vous voyez cette erreur, vérifiez si
et p2pSendFreesont définis simultanément — leurs sémantiques sont en conflit.sendMemSameProcPiège 2 : ordre de libération inter-processus.
📎 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));
}
}
...détermine le mode de libération :ncclCuMemFreeAddrCopierncclCudaFree(libérer la mémoire physique). Une inversion provoque une fuite mémoire ou un use-after-free.
III. SHM : la bataille du « qui fournit la mémoire » dans la mémoire partagée
Modèle intuitif
SHM est comme « deux processus partageant un tableau blanc » — l'émetteur écrit, le récepteur lit. Mais chez qui placer le tableau blanc ? Chez l'émetteur (sender-side), et le récepteur vient lire ; ou chez le récepteur (receiver-side), et l'émetteur vient écrire ? C'est le problème que le paramètreNCCL_SHM_LOCALITYdoit résoudre.
Structures de données et disposition mémoire
📎 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;
};AttentionhostMemetdevHostMemapparaissent par paires :hostMemest un pointeur côté host,devHostMemest un pointeur côté device (via UVA ou mappage cuMem).remHostMem/devRemHostMemest le mappage local de la mémoire partagée du pair.
Walkthrough guidé par scénario : choix de la locality pour SHM
shmSendSetupdétermine la taille de mémoire à allouer selon la 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, l'émetteur alloue le tampon de données (shmSizeplus tous les tampons de protocole) ; sinon, il n'alloue que la structure de contrôlencclSendMem.req.legacyindique si c'est le même processus — même processus peut utiliser lemmaptraditionnel, inter-processus nécessite cuMem ou un fichier/dev/shm.
shmSendConnectdécide selon la locality sibuffspointe vers le local ou le pair :
📎 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:buffspointe vers le localdevHostMem(l'émetteur écrit dans sa propre mémoire) ;SHM_RECV_SIDE:buffspointe vers le pairdevRemHostMem(l'émetteur écrit dans la mémoire du récepteur).headpointe toujours vers le local,tailpointe toujours vers le pair — car l'émetteur met à jourhead, le récepteur met à jourtail。
Réflexions de conception
PourquoiSHM_RECV_SIDEpar défaut ? Parce que le récepteur doit généralement copier les données de la mémoire partagée vers sa propre mémoire GPU ; si la mémoire partagée est locale au récepteur, le chemin de copie est plus court (mémoire locale → GPU local), évitant les accès inter-NUMA. Bien que l'émetteur écrive dans une mémoire distante, ce qui ajoute une écriture inter-nœuds, l'émetteur est généralement un GPU à forte charge de calcul, et l'écriture peut se faire de manière asynchrone.
Guide de production pour éviter les pièges
Piège : le/dev/shmentre conteneurs n'est pas partagé. shmCanConnectVérifierinfo1->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;Si deux conteneurs montent des/dev/shm,shmDevdifférents, SHM se dégrade automatiquement en NET. En production, si une communication sur le même hôte passe par le réseau, vérifier si les montages/dev/shmdes conteneurs sont cohérents.
IV. NET : table de mappage et progression du proxy pour la transmission réseau
Modèle intuitif
NET est comme une « livraison inter-villes » — les données sont empaquetées et confiées à la carte réseau, qui les envoie au pair via fibre optique. Mais la carte réseau ne connaît pas les adresses de mémoire GPU ; il faut une « table de mappage d'adresses » pour traduire les adresses virtuelles GPU en adresses physiques compréhensibles par la carte réseau. Cette table estconnectMap。
Structures de données et disposition mémoire
📎 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;
};connectMapest un système de « banque de mémoire » :memsle tableau a 5 emplacements (NCCL_NET_MAP_MEMS=5), correspondant respectivement à host mem, dev mem, shared host mem, shared dev mem, GDC mem.offsetsChaque champ dans
est un entier de 32 bits, les 3 bits de poids fort encodent « quelle banque », les 29 bits de poids faible encodent « l'offset dans la banque ».
📎 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)Copieoffsets.sendMemAprès expansion : prendre les 2 bits de poids fort demems[bank].gpuPtrcomme index de banque, ajouter àconnectMapl'offset des 29 bits de poids faible pour obtenir le pointeur réel. Cet encodage compresse « quelle région mémoire + offset dans la région » dans un entier de 32 bits, économisant la taille de transmission de
.
sendProxyConnectWalkthrough guidé par scénario : établissement du mappage dans sendProxyConnect
📎 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 > 1CopieactiveConnectactive la « connexion partagée » : plusieurs channels réutilisent la même connexion réseau, réduisant le nombre de connexions.
Le tableau garantit qu'un seul local rank initie la connexion, évitant les doublons.
📎 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_POINTERCopieconnectMap:
📎 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);CopiesizeTampon non partagé : écrire leoffsetsde la banque courante comme offset danssize += memSize, puis
— c'est un bump allocator. Tampon partagé : écrire directement le numéro de banque, offset à 0 (car le tampon partagé entier est une seule banque).
📎 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]));
}
...CopiecuMemGetHandleForAddressRangePrivilégier le chemin DMA-BUF (regMrobtient le fd, le transmet au plugin de la carte réseau) ; en cas d'échec, revenir à
(GDR nv_peermem traditionnel).
sendProxyProgressContrôle de concurrence et interaction matérielle : pipeline en trois étapes de sendProxyProgress
📎 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));
...- postCopie
sendMem->head: le thread proxy met à jour - transmit, indiquant au GPU « le tampon est prêt, tu peux écrire les données ».
recvMem->tail: vérifier siconnFifo[buffSlot].size != -1a progressé (le GPU a fini d'écrire), vérifierncclNet->isend(la taille des données est renseignée), puis appeler - donepour lancer l'envoi asynchrone.
ncclNet->test: appelersendMem->headpour vérifier la fin de l'envoi, mettre à jour
wc_store_fence()et rendre le tampon.gdcSyncest une barrière de fusion d'écriture — dans le cas GDRCopy, après que le CPU a écrit
, il faut vider le tampon de fusion d'écriture, sinon le GPU ne verra pas la mise à jour.
Guide de production pour éviter les piègesPiège 1 : validation du flag du protocole 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;
}
}
}
}Copiethreadfence()Parce que le GPU n'a appelé queuseGdrEst-ce correct — dans le chemin GDR, les données atterrissent directement dans la mémoire vidéo, sans nécessiter de vérification ligne par ligne.
Piège 2 : l'ordre mémoire du flush GDRCopy.Le côté récepteur, dansrecvProxyProgresscontient un morceau d'assembleur inline ingénieux :
📎 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
}mfencegarantit que la lecture du poll CQE ne sera pas réordonnée avant la lecture du flush ;mov (%0), %%eaxforce le lancement d'une lecture PCIe, faisant stagner le CPU jusqu'à ce que toutes les écritures PCIe posted précédentes (y compris le DMA de la carte réseau) soient validées. C'est la clé, dans le scénario GDRCopy, pour éviter que « la carte réseau dise avoir fini d'écrire alors que les données sont encore dans le buffer PCIe ». Si l'on retire ce segment, le récepteur peut lire des données obsolètes.
sequenceDiagram
participant GPU as GPU Kernel
participant SM as ncclSendMem
participant Proxy as sendProxyProgress
participant NIC as ncclNet->isend
participant Peer as 对端网卡
GPU->>SM: 写数据到 buffs[p]
GPU->>SM: 更新 recvMem->tail
Proxy->>SM: 读 recvTail, connFifo[buffSlot].size
Proxy->>Proxy: 检查 ready (LL128 flag / GDR)
Proxy->>NIC: isend(comm, buff, size, mhandle)
NIC->>Peer: DMA 发送
Proxy->>NIC: test(request, &done)
NIC-->>Proxy: done=1
Proxy->>SM: 更新 sendMem->head (归还缓冲区)
Proxy->>GPU: 下一轮 postV. NVLS : groupes multicast et liaison mémoire UC/MC
Modèle intuitif
NVLS est une « station de radio » — un rank écrit des données dans le groupe multicast, et le matériel les copie automatiquement vers tous les abonnés. L'AllReduce traditionnel nécessite N-1 transferts point à point, tandis que NVLS ne requiert qu'une écriture multicast + une lecture multicast. Sans NVLS, la latence de l'AllReduce à grande échelle croît linéairement avec le nombre de ranks.
Structures de données et disposition mémoire
Le cœur de NVLS est la liaison entre « mémoire UC (unicast) » et « mémoire MC (multicast) ».nvlsAllocBindUcAllouer de la mémoire UC et la lier au groupe 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);
...Processus :cuMemCreateallouer la mémoire physique →cuMemMapmapper vers une adresse virtuelle →cuMemSetAccessdéfinir les permissions d'accès GPU →ncclMcPartitionBindMemlier la mémoire physique UC à l'offset spécifié du groupe MC. Après liaison, tout rank écrivant à une adresse MC verra le matériel copier les données vers toute la mémoire UC liée.
AttentionbootstrapIntraNodeBarrieravantcuMulticastBindMem— le commentaire indique que c'est pour « mitigate the possible hang in cuMulticastBindMem during abort ». C'est une défense au niveau matériel : si un rank abort pendant la liaison, les autres ranks peuvent se bloquer danscuMulticastBindMemScénario guidé : disposition des buffers de ncclNvlsBufferSetup
Copier
📎 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;
...buffers (la moitié pour le reduce, la moitié pour le broadcast).2 * nChannelsetsend[1]sont dans la direction reduce (UC → MC),recv[0]etrecv[1]sont dans la direction broadcast (MC → UC).send[0]est la mémoire UC locale,dataUc.ptrest l'adresse mappée du groupe MC.dataPartition.ptrRéflexions de conception
〔Inférences de conception et compromis architecturaux〕
de NVLS retourne 0 ? Parce que NVLS n'est pas un transfert point à point — c'est un modèle multicast « un vers plusieurs ».canConnectLa boucle deselectTransportest conçue pour les connexions point à point ; l'établissement de connexion NVLS passe par un chemin indépendantncclNvlsSetupMettre NVLS dans le tableauncclTransportssert uniquement à unifier l'interfacefree(nvlsSendFree/nvlsRecvFree), la logique de connexion réelle étant totalement indépendante.
Guide de production pour éviter les pièges
Piège : MNNVL ne prend pas en charge l'enregistrement de buffer NVLS.VoirncclNvlsSetup:
À ce stade, NCCL, via la couche d'abstraction ncclTransport, unifie avec succès les quatre canaux hétérogènes P2P, SHM, NET et NVLS en une interface cohérente, le noyau algorithmique n'ayant plus besoin de se soucier de savoir si le sous-jacent est NVLink ou une carte réseau. Mais la couche transport ne résout que « comment abstraire les canaux », sans encore répondre à « comment les données sont pilotées de manière asynchrone ». Le chapitre suivant se concentrera sur src/proxy.cc et src/include/proxy.h, pour voir comment le thread proxy fait progresser de manière asynchrone les envois/réceptions réseau côté host, formant une relation producteur-consommateur avec le kernel GPU, et dévoilant le mécanisme clé de l'asynchronisme de NCCL.
Chapitre 12 : Chapitre 12 : Ordonnancement asynchrone des threads proxy : comment proxy.cc découple l'I/O de l'exécution du kernel
Chapitre 12 : Ordonnancement asynchrone des threads proxy : comment proxy.cc découple l'I/O de l'exécution du kernel
Le chapitre précédent a décomposé la couche d'abstraction transport, montrant comment NCCL masque les différences P2P/SHM/NET/NVLS avec une interface unifiée. Mais la couche transport ne répond qu'à « par quel canal passent les données », sans encore répondre à « comment les données sont pilotées de manière asynchrone ». Si le kernel GPU se bloque directement en attente du réseau, les unités de calcul seront étranglées par l'I/O. Ce chapitre se concentre sursrc/proxy.ccetsrc/include/proxy.hpour voir comment NCCL utilise des threads host indépendants pour extraire l'I/O réseau du chemin d'exécution du kernel, formant une relation producteur-consommateur avec le GPU.
12.1 Pourquoi des threads proxy : commençons par « qui attend le réseau »
Modèle intuitif
Imaginez un restaurant : la cuisine (GPU kernel) ne s'occupe que de préparer les plats, le serveur (proxy thread) se charge de les apporter aux clients (pair réseau). Si l'on demandait au chef d'apporter lui-même les plats, il devrait arrêter de cuisiner à chaque trajet, et la cadence de service s'effondrerait. Le proxy de NCCL est ce serveur dédié — le kernel ne fait qu'écrire des données dans un buffer partagé et en lire, tandis que tout le sale boulot d'émission/réception réseau est confié aux threads proxy côté host.
Que se passerait-il sans proxy ? Le GPU kernel est massivement parallèle en SIMT ; un warp bloqué sur du polling réseau gaspillerait la puissance de calcul de tout un SM ; plus fatal encore, l'émission/réception réseau implique des appels système socket, du polling verbs, la soumission de descripteurs DMA — des opérations impossibles à exécuter dans du code device. NCCL doit donc déplacer l'I/O réseau vers le host, en faisant échanger au kernel et au proxy des signaux « données prêtes » via une FIFO en mémoire partagée.
La répartition des deux types de threads
NCCL lance côté host deux types de threads proxy aux responsabilités bien distinctes :
- Thread Service(
ncclProxyService) : traite les requêtes du plan de contrôle — établissement de connexion, enregistrement mémoire, interrogation de FD. Il écoute un socket, reçoit les requêtes RPC du rank local, et fait progresser de manière asynchrone les opérations setup/connect, etc. - Thread Progress(
ncclProxyProgress) : traite le plan de données — c'est lui qui pilote réellement l'émission/réception réseau. Il récupère les proxy op depuis le pool en mémoire partagée, appelle le callbackproxyProgressdu transport pour faire avancer le transfert de données.
📎 src/include/proxy.h:343-345affichencclProxyStatedétient à la foisthread(Service) etthreadUDS(service UDS), tandis que le handle du thread Progress est caché dansprogressState.thread📎 src/include/proxy.h:261-261。
Établissement de la relation producteur-consommateur
📎 src/proxy.cc:2130-2166LencclProxyCreatederefCount == 1est le lieu de naissance des threads : lorsqueproxyState(création de la première comm), il copie les champs clés de la comm dansproxyProgressInit, puis lance le thread Service et le thread UDS. Notez que le thread Progress n'est pas lancé ici — il est démarré paresseusement par📎 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)"]copietcomm->proxyProgressCe schéma ancre la véritable branche de démarrage des threads : le thread Progress n'est créé que si
est non nul (c'est-à-dire que ce transport nécessite une progression du plan de données).
12.2 Structures de données et disposition mémoire : pool de mémoire partagée et pool d'op
Panorama des structures centrales
Le modèle de concurrence du proxy repose sur deux blocs de mémoire partagée ; comprendre leur disposition mémoire est le prérequis pour comprendre tout le mécanisme.ncclProxyOpsPool(📎 src/include/proxy.h:218-226Premier bloc :/dev/shm). C'est la « boîte de dépôt de tâches » entre le thread principal et le thread Progress, partagée entre processus via
| Champ | Type | Rôle |
|---|---|---|
ops[] | ncclProxyOp[] | Tableau d'op préalloué, tailleMAX_OPS_PER_PEER * NCCL_MAX_LOCAL_RANKS |
nextOps | volatile int | Index de tête de la liste chaînée des op en attente, -1 signifie vide |
nextOpsEnd | volatile int | Index de queue de la liste chaînée des op en attente |
freeOps[] | volatile int[] | Tête de la liste chaînée des op libres pour chaque local rank |
syncObjectsInitialized | int | Indique si le mutex/cond a été initialisé |
mutex / cond | std::mutex / std::condition_variable | Primitives de synchronisation inter-processus |
MAX_OPS_PER_PEERDéfinition de📎 src/include/proxy.h:218-2262 * MAXCHANNELS * 2 * NCCL_MAX_DEV_WORK_P2P_PER_BATCHest
. Le commentaire explique pourquoi c'est multiplié par 2 : chaque p2p work contient un proxy op send et un recv, d'où la multiplication par 2 ; la seconde multiplication par 2 sert à pouvoir stocker deux tours complets d'opérations, sinon impossible de « déposer la moitié, libérer la moitié ».ncclProxyArgs(📎 src/include/proxy.h:174-209Deuxième bloc :ncclProxyPool). C'est la « description d'op à l'exécution » utilisée en interne par le thread Progress, allouée depuis
, non partagée entre processus.
subs[NCCL_PROXY_MAX_SUBS]Champs clés :NCCL_PROXY_MAX_SUBS = MAXCHANNELS📎src/include/proxy.h:55-55: tableau de sous-opérations,progress. Les opérations de même type de plusieurs channels sont agrégées dans plusieurs sub d'un même args.proxyProgress: pointeur de fonction, pointant vers le callback📎src/include/proxy.h:176-176。next/nextPeer/proxyAppendPtrdu transportstate:ncclProxyOpNone/ncclProxyOpReady/ncclProxyOpProgress: trois pointeurs de liste chaînée, formant une organisation complexe des op.📎src/include/proxy.h:48-52。
Tri-état
ncclProxyPool 📎 src/proxy.cc:50-53Conception en couches du pool mémoirePROXYARGS_ALLOCATE_SIZEest une unité d'allocation par lots ; chaque pool contientNCCL_MAX_OPS(soitncclProxyArgs。allocateArgs 📎 src/proxy.cc:207-231) de
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
copiencclProxyArgs〔Inférences de conception et compromis architecturaux〕subs[MAXCHANNELS]La motivation de conception ici est :requests[NCCL_STEPS]la structure
est très volumineuse (contient
ncclProxyOpsPooltableau, chaque sub ayant lui-mêmenextOps、nextOpsEnd、freeOps[]), si chaque op était malloc individuellement, cela causerait une grave fragmentation mémoire et un surcoût d'allocation. L'allocation par lots + réutilisation par liste chaînée libre amortit le coût d'allocation jusqu'à le rendre quasi nul. Le commentaire « Make sure we allocate the memory close to the network thread » suggère qu'il s'agit d'affinité NUMA — le pool est créé lors de la première allocation du thread Progress, naturellement proche du CPU sur lequel ce thread s'exécute.volatile intFaux partage et variables atomiques
LesncclLocalOpAppenddans📎 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();
}. Ils sont lus et écrits simultanément par le thread principal et le thread Progress, mais NCCL ne protège pas tous les accès par des verrous — il utilise des opérations atomiques + ordonnancement mémoire pour garantir la correction.atomic_exchangeRegardonsfreeOps[tpLocalRank]Défini à -1 et récupère l'ancienne valeur — c'est un « prélèvement préemptif » : celui qui réussit l'échange en premier obtient toute la liste libre. Lorsque le thread Progress restitue un op, il utilise une boucle 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));On utilise ici acquire/release plutôt que seq_cst, car il suffit de garantir que « l'écriture du pointeur next du nœud de liste » soit visible pour le préleveur, sans nécessiter d'ordre global.freeOps[]Chaque élément du tableau correspond à un local rank, naturellement répartis près de différentes lignes de cache, ce qui réduit le faux partage.
12.3 Plan de contrôle : établissement de connexion et mécanisme RPC
Modèle intuitif
Le thread Service agit comme une « réceptionniste » : lorsqu'un rank local veut établir une connexion réseau, il ne se connecte pas directement lui-même, mais envoie une requête RPC au thread Service, qui exécute setup/connect en son nom. Pourquoi ? Parce que l'établissement de connexion réseau (notamment la création de QP verbs, l'enregistrement mémoire) peut bloquer, et certaines ressources (comme le listen socket) doivent être détenues par un seul thread. En centralisant le plan de contrôle sur le thread Service, le thread principal peut continuer ses autres tâches de manière non bloquante.
Encodage des requêtes RPC
ncclProxyCallAsync 📎 src/proxy.cc:1369-1394C'est l'émetteur du RPC. Il envoie séquentiellement via socket : type, pointeur de connexion, 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
Notez la dernière étape : après avoir envoyé la requête, il enregistre immédiatement l'opId dans laexpectedResponsesfile. C'est la clé du RPC asynchrone — l'appelant n'attend pas la réponse, mais enregistre d'abord « j'attends la réponse pour cet opId », puis utilisencclPollProxyResponsepour interroger.
Implémentation de la file de réponses par liste chaînée
expectedProxyResponseEnqueue 📎 src/proxy.cc:97-117Utilise une liste simplement chaînée pour stocker les op en attente de réponse.expectedProxyResponseStore 📎 src/proxy.cc:67-95Lors de la réception d'une réponse, fait correspondre par opId, copie les données de réponse via memcpy dans lerespBuffpréalloué, marquedone = true。expectedProxyResponseDequeue 📎 src/proxy.cc:119-141Lors de l'interrogation, recherche les réponses terminées et les retire.
Il y a un détail ici :expectedProxyResponseStoreVérifie sirespSizecorrespond à📎 src/proxy.cc:72-75, sinon signalencclInternalError. C'est de la programmation défensive — si le demandeur et le répondeur ont une compréhension incohérente de la taille de réponse, cela indique un protocole corrompu, et il faut échouer immédiatement plutôt que de continuer silencieusement.
Boucle principale du thread Service
ncclProxyService 📎 src/proxy.cc:1789-2016Le cœur est une boucle poll. Il utilisepollfdsun tableau pour gérer toutes les connexions, y compris le listen socket et le socket de chaque 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
timeoutLe choix de est très réfléchi : s'il y a des op asynchrones en cours (asyncOpCount > 0), le timeout est fixé à 0 (interrogation non bloquante), car il faut appeler fréquemmentproxyProgressAsyncpour les faire avancer ; sinon il est fixé à 500ms, pour éviter de brûler le CPU à vide. Le commentaire « never let proxy service thread blocks in poll, or it cannot receive abortFlag »📎 src/proxy.cc:1847-1847précise pourquoi on ne peut pas bloquer indéfiniment — il faut se réveiller périodiquement pour vérifier abortFlag.
Avancement des op asynchrones
proxyProgressAsync 📎 src/proxy.cc:1626-1700C'est le cœur de l'avancement des opérations asynchrones par le thread Service. Il distribue selon le type d'op vers différents callbacks de transport :
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
Chaque callback porte undoneparamètre de sortie. Sidone == 0, cela signifie que l'opération n'est pas encore terminée (par exemple la connexion réseau est encore en poignée de main), retournencclInProgress, et la prochaine boucle continue l'avancement. Sidone == 1, alors envoie l'en-tête de réponse + le corps de réponse au demandeur📎 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 取回结果Ce diagramme de séquence ancresendProxyConnectdans*done = 0; return ncclInProgressla vraie branche📎 src/transport/net.cc:913-916。
12.4 Plan de données : comment le thread Progress pilote l'envoi/réception réseau
Modèle intuitif
Le thread Progress est un « opérateur de convoyeur » : il surveille la FIFO dans le tampon partagé, dès que le GPU a écrit les données (size != -1 dans la FIFO), il appelle immédiatementisendpour envoyer les données ; dès que le réseau a fini de recevoir les données, il met à jour recvTail pour notifier le GPU qu'il peut lire. Tout le processus synchronise le GPU et le proxy via les pointeurs head/tail dans la FIFO, sans aucun verrou.
Soumission des op : du thread principal au thread Progress
Le thread principal, dansncclProxySaveOp 📎 src/proxy.cc:591-761, détermine selon le pattern quels proxy op sont nécessaires, puis viaSaveProxy → ncclLocalOpAppendécrit les op dans le pool de mémoire partagée.
ncclLocalOpAppend 📎 src/proxy.cc:488-554Le flux de :
1. DepuisproxyOps->freeOpoupool->freeOps[tpLocalRank]prend un slot d'op libre.
2. memcpy(op, proxyOp, sizeof(struct ncclProxyOp))Copie le contenu de l'op dans la mémoire partagée📎 src/proxy.cc:515-515。
3. Accroche l'op àproxyOps->nextOpsla fin de la liste chaînée.
4. Si le nombre d'op accumulés atteintMAX_OPS_PER_PEER, déclenche une soumission par lots📎 src/proxy.cc:525-551。
La logique de soumission par lots est très subtile : elle ne peut pas simplement envoyer tous les op, car « plusieurs op du même opCount doivent être soumis ensemble, sinon cela casse l'agrégation sub de proxyArgs ». Donc elle trouve la dernière frontière où opCount change, et ne soumet que jusqu'à celle-ci📎 src/proxy.cc:529-548。
La soumission se fait viancclProxyPost 📎 src/proxy.cc:476-486, qui verrouille, met à jourpool->nextOps、notify_oneet réveille le thread Progress.
Boucle principale du thread Progress
ncclProxyProgress 📎 src/proxy.cc:951-1011La structure de :
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
Il y a ici une optimisation de performance à noter :proxyOpAppendCounterle compteur📎 src/proxy.cc:974-974. Le commentaire explique📎 src/proxy.cc:969-973: appeler trop fréquemmentncclProxyGetPostedOpsentraîne une régression des performances de communication pour les petits messages, donc tous lesProgressAppendOpFreq(par défaut 8) fois avant de récupérer un nouvel op.
Agrégation des op : ProxyAppend
ProxyAppend 📎 src/proxy.cc:437-474Détermine si un op doit être « ajouté à un sub existant d'args » ou « créer un nouvel args ». Le critère de décision estconnection->shared && args->opCount == op->opCount 📎 src/proxy.cc:443-443— les opérations de plusieurs channels sur la même connexion et le même opCount sont agrégées.
Valeur de l'agrégation : les opérations de même type sur plusieurs channels sont fusionnées en un seul args, le thread Progress peut faire avancer tous les channels en une seule boucle, ce qui réduit les surcoûts d'appel de fonction et les invalidations de cache.ncclProxyOpToArgs 📎 src/proxy.cc:368-435Lors de l'ajout d'un sub, on vérifiesliceSteps、chunkSteps、protocol、dtype、redOp、collsi elles sont cohérentes📎 src/proxy.cc:401-406, sinon une erreur est signalée — c'est la ligne de défense contre les agrégations erronées.
sendProxyProgress : machine à états à quatre phases côté envoi
sendProxyProgress 📎 src/transport/net.cc:1324-1491C'est le cœur du côté envoi. Il progresse sub par sub, chaque sub ayant quatre compteurs :posted、transmitted、done。
Phase un : initialisation 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;baseest le numéro de départ du step,ROUNDUPgarantit l'alignement surchunkSteps。resources->stepaccumulation, pour réserver l'espace du prochain op.
Phase deux : Post du buffer au 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;
}
}maxDepthest la profondeur du pipeline📎 src/transport/net.cc:1343-1343, limite le nombre de steps simultanément in-flight. En mode shared, le proxy met à joursendHeadpour dire au GPU « ce slot peut être écrit ».
Phase trois : vérifier si le GPU a fini d'écrire, lancer 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;
}
}
}Le test clé ici estconnFifo[buffSlot].size != -1 && *recvTail > tail— après que le GPU a écrit les données, il met à jour la taille de la FIFO et recvTail, le proxy ne lance isend que lorsque ces deux conditions sont satisfaites. Pour le protocole LL, comme il a une sémantique « zéro copie », il n'est pas nécessaire d'attendre recvTail.
Phase quatre : vérifier la fin de l'envoi, mettre à jour 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;
}
}
}testAprès que
recvProxyProgress : les quatre phases côté réception
recvProxyProgress 📎 src/transport/net.cc:1493-1788est plus complexe, car il implique un regroupement par sub (multirecv est utilisé quand plusieurs subs partagent le même recvComm).
Phase un : regroupement par recvComm lors du 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;
}Ce code regroupe les subs utilisant le mêmerecvCommet enregistregroupSize. Pourquoi regrouper ? Parce queirecvsupporte la réception de plusieurs buffers en une fois (multirecv), fusionner les requêtes de même comm en un seul appel réduit significativement le surcoût du plugin.
Phase deux : lancer 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;
}
}
}ignoreCompletionOptimisation📎 src/transport/net.cc:1608-1610: pour la réception d'un seul buffer avec les protocoles LL/LL128, la notification de completion est optionnelle (car les données portent elles-mêmes un flag), on peut sauter la vérification de completion.
Phase trois : vérifier la fin de la réception, mettre à jour 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;
}
...
}Après la fin de la réception, on réinitialise la taille de la FIFO, puis on entre dans la phase flush (le scénario GDRDMA nécessite un flush pour garantir la visibilité des données).
Phase quatre : attendre la consommation par le GPU, mettre à jour 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;
}
}Ici, on litsendHeadpour déterminer si le GPU a déjà consommé les données.irecvConsumedest un callback vers le plugin, lui indiquant que « le buffer de cette requête de réception a été consommé et peut être réutilisé ».
Vue d'ensemble du flux de données
flowchart LR
subgraph GPU["GPU Kernel"]
gpu_write["写入数据到 buff"]
gpu_fifo["更新 connFifo.size<br/>和 recvTail"]
end
subgraph SHM["共享内存 FIFO"]
fifo["ncclConnFifo<br/>size / offset"]
head["sendMem->head"]
tail["recvMem->tail"]
end
subgraph PROXY["Progress 线程"]
check["检查 size != -1<br/>且 recvTail > tail"]
isend["ncclNet->isend()"]
test["ncclNet->test()"]
update["更新 sendHead"]
end
gpu_write --> gpu_fifo
gpu_fifo --> fifo
gpu_fifo --> tail
fifo --> check
tail --> check
check -->|数据就绪| isend
isend --> test
test -->|发送完成| update
update --> head
head -->|GPU 可复用 slot| gpu_writeCe diagramme de flux de données montre la boucle fermée formée par le GPU et le proxy via la FIFO et les pointeurs head/tail : le GPU écrit les données → met à jour tail → le proxy détecte et lance isend → test confirme la completion → met à jour head → le GPU réutilise le slot.
12.5 Contrôle de concurrence, barrières mémoire et interaction matérielle
Ordre mémoire de la FIFO sans verrou
La synchronisation entre le proxy et le GPU repose entièrement surncclConnFifoet les pointeurs head/tail, sans aucun verrou. Cela exige un contrôle extrêmement rigoureux de l'ordre mémoire.
Côté envoi, le proxy, après quetesta retourné done📎 src/transport/net.cc:1460-1473:
connFifo[buffSlot].size = -1;
std::atomic_thread_fence(std::memory_order_seq_cst);
...
*sendHead = sub->base + sub->done;La barrière seq_cst garantit que la réinitialisation de la taille est visible par le GPU avant que la mise à jour de head ne le soit. Si l'ordre était inversé, le GPU pourrait voir le nouveau head mais l'ancienne taille, et croire à tort que le slot contient des données.
Côté réception, le proxy, avant de mettre à jour 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;
}Même logique : d'abord une barrière pour garantir la visibilité de l'écriture des données, puis la mise à jour de tail pour notifier le GPU qu'il peut lire.
Mécanisme de flush de GDRCOPY
Lors de l'utilisation de GDRDMA, la NIC écrit directement dans la mémoire GPU, mais l'opération d'écriture peut ne pas encore être validée sur le bus PCIe. Le proxy doit effectuer un flush actif pour garantir la visibilité des données. VoirrecvProxyProgressla logique de flush dans📎 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)));
}
}Le commentaire du chemin x86 est absolument remarquable📎 src/transport/net.cc:1668-1674:mfenceEmpêcher le load de CQE-poll d'être réordonné avant le flush load ;mov (%0), %%eaxForcer une lecture PCIe, faisant stagner le CPU jusqu'à ce que tous les PCIe posted write précédents (y compris le NIC DMA) soient soumis au endpoint. C'est un contrôle d'ordre mémoire au niveau matériel, plus hardcore que n'importe quelle fence logicielle.
Coopération entre variables atomiques et stop/abort
Condition de sortie du thread 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 == 1Maisstate->active != NULLcontinue de s'exécuter pendant — c'est pour un « arrêt gracieux » : les op déjà soumises doivent être menées à terme, sinon le GPU n'obtiendra jamais les données. Seulsstop == 2(abort) ouabortFlag != 0forcent la sortie.
ncclProxyProgressDestroy 📎 src/proxy.cc:1039-1065Processus d'arrêt de :
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();Verrouiller d'abord puis store stop, ensuite notify — c'est le modèle standard pour éviter le lost wakeup. Le thread Progress détient le verrou lors depool->cond.waitet vérifie le prédicat📎 src/proxy.cc:850-851, garantissant de ne pas manquer le réveil.
12.6 Guide de production pour éviter les pièges et chaîne de récupération après défaillance
Piège 1 : Fuite de connexion empêchant le thread Service de se terminer
ncclProxyServiceLa condition de la boucle principale de eststop == PROXY_RUNNING || npeers > 0 📎 src/proxy.cc:1842-1842. Le commentaire explique📎 src/proxy.cc:1843-1845: même si le comm local est abort, tant qu'il reste des connexions peer, le thread proxy ne peut pas se terminer, sinon un segfault peut survenir.
Scénario de diagnostic: si un rank plante sans notifier son pair, le thread Service du pair restera bloqué dans la boucle denpeers > 0. Il faut alors s'appuyer surabortFlagou un mécanisme de timeout. En production, si un processus est vu bloqué surncclProxyService, vérifier d'abord si un rank pair s'est terminé anormalement.
Piège 2 : Inadéquation de la file de réponses entraînant une fuite mémoire
expectedProxyResponseStoreRetourne en cas d'inadéquation d'opIdncclInternalError 📎 src/proxy.cc:93-94. Mais si la réponse arrive alors que le demandeur a déjà abandonné (par exemple par timeout), cette réponse restera éternellement dans la file,respBufffuite.
Mesures défensives:expectedProxyResponseFree 📎 src/proxy.cc:55-65Nettoie toute la file lors dencclProxyDestroy📎 src/proxy.cc:2226-2226. Mais c'est un dernier recours ; en fonctionnement normal, il ne devrait pas y avoir de résidus.
Piège 3 : Initialisation de head à une valeur négative en mode shared
sendProxyConnectDans📎 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);En mode shared, head est initialisé à-NCCL_STEPS, ce qui signifie que le GPU n'a initialement aucun credit pour écrire. Le proxy doit augmenter progressivement head lors de la phase post pour « distribuer des credits ». Si cet initialisation est oubliée, le GPU croira à tort avoir des credits et écrira dans des slots non prêts, entraînant une corruption des données.
Piège 4 : Vérification du flag du protocole LL128
sendProxyProgressDans la vérification ready de 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;
}
}
}
}Lorsque les données sont en sysmem (non GDR), le GPU n'appelle quethreadfence(), le proxy doit vérifier ligne par ligne le flag pour confirmer l'intégrité des données. Si cette vérification est ignorée et qu'on isend directement, on risque d'envoyer des données tronquées. C'est un piège spécifique à LL128.
Chaîne de récupération après défaillance
LorsqueproxyProgressAsyncretourne autre chose quencclSuccess/ncclInProgress, le thread Service ferme la connexion et nettoie toutes les async op de ce peer📎 src/proxy.cc:1929-1937📎 src/proxy.cc:1984-1995. Ce nettoyage est un « drain complet » — il ne nettoie pas seulement l'op échouée, mais vide toute la file asyncOps du peer, empêchant les op résiduelles de référencer une connexion déjà libérée.
Lorsqu'un thread Progress rencontre une erreur📎 src/proxy.cc:979-983, il écrit le code d'erreur dansproxyState->asyncResultet quitte la boucle. Le thread principal peut ensuite détecter l'erreur en vérifiant ce champ.
Résumé de ce chapitre
Dans ce chapitre, nous avons décomposé le mécanisme complet des threads proxy de NCCL :
1. Répartition des deux types de threads: le thread Service gère le plan de contrôle RPC (établissement de connexion, enregistrement mémoire), le thread Progress gère le plan de données (progression des envois/réceptions réseau).
2. Pool de mémoire partagée:ncclProxyOpsPoolTransmet les op entre processus,ncclProxyArgsagrège les opérations de plusieurs channels dans le thread Progress.
3. Synchronisation FIFO sans verrou: le GPU et le proxy échangent les signaux de disponibilité des données viaconnFifoet les pointeurs head/tail, en utilisant seq_cst fence pour garantir l'ordre mémoire.
4. Machine à états à quatre phases: les compteurs posted → transmitted → received → done de send/recv pilotent le pipeline.
5. Flush au niveau matériel: dans le scénario GDRDMA, utilisermfence+ lecture PCIe pour forcer la soumission des posted write.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la logique de mise à jour desendProxyProgressdanssub->done == sub->nstepslors desendHead(c'est-à-dire sans notifier le GPU que le slot est libéré), dans quel scénario un deadlock se déclencherait-il ? Pourquoi ?
Analyse de référence:sendHeadC'est le seul critère pour que le GPU détermine « quels slots peuvent être réutilisés ». Voir📎 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;
}Si l'on supprime ce passage, le head du GPU reste bloqué à sa valeur initiale (en mode shared c'est-NCCL_STEPS, en non-shared c'est 0). Le kernel GPU vérifiewaitSendlors dehead + NCCL_STEPS > steppour considérer qu'il y a des credits disponibles. Si head n'avance pas, le GPU se bloquera éternellement en attente de credits après avoir rempliNCCL_STEPSslots, tandis que le proxy attend que le GPU écrive de nouvelles données pour pouvoir isend — deadlock classique producteur-consommateur. En mode shared, c'est encore plus grave, car le head initial est négatif et le GPU n'a aucun credit dès le départ.
Q2: ncclLocalOpAppendLorsque l'op cumulé atteintMAX_OPS_PER_PEERle déclenchement de l'envoi par lots se produit, mais le code « n'envoie délibérément pas tous les op du dernier opCount ». Si l'on modifiait le code pour simplement envoyer tous les op, quel mécanisme serait brisé ?
Analyse de référence: voir📎 src/proxy.cc:525-548les commentaires et la logique de
// 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;
}
}ProxyAppendla logique d'agrégation de📎 src/proxy.cc:443-443dépend deargs->opCount == op->opCountpour déterminer s'il faut ajouter un sub. Si plusieurs channel op du même opCount sont répartis sur deux lots d'envoi, le premier lot crée un args, et lorsque le second lot arrive,args->opCountn'est déjà plus égal à l'opCount du nouvel op (car args a peut-être déjà été avancé), ce qui fait que les sub qui devraient être agrégés sont divisés en args indépendants. Cela réduit non seulement les performances, mais peut aussi casserncclProxyOpToArgsdansnChannels/nPeersla logique de prise de min📎 src/proxy.cc:399-400, entraînant un calcul erroné du nombre de canaux.
Q3: recvProxyProgressla phase Ready derecvCommréordonne et regroupe les sub selonirecv. Si l'on supprime cette logique de regroupement et que chaque sub appelle indépendammentmaxRecvs > 1, quelles seraient les conséquences sur une carte réseau de
Analyse de référence: voir📎 src/transport/net.cc:1495-1538la logique de regroupement de📎 src/transport/net.cc:1613-1614et l'appel multirecv de
NCCLCHECK(proxyState->ncclNet->irecv(resources->netRecvComm, subCount, ptrs, sizes, tags, mhandles, phandles,
requestPtr));maxRecvsest le « nombre maximum de buffers qu'un seul irecv peut recevoir » déclaré par le plugin de carte réseau📎 src/transport/net.cc:1525-1525. LorsquemaxRecvs > 1, le plugin (comme IB) prend en charge la réception de plusieurs buffers en un seul WQE, ce qui réduit considérablement le coût du doorbell et le coût de traitement des CQE. Si l'on supprime le regroupement et que chaque sub fait un irecv indépendant,subCountvaut toujours 1, le plugin dégénère en mode mono-buffer et le débit diminue. Plus critique encore,recvRequestsCacheetirecvConsumedles mécanismes📎 src/transport/net.cc:1616-1617sont conçus pour multirecv — en mode mono-buffer, ces logiques de cache deviennent inopérantes, ce qui peut entraîner des fuites de requêtes.
Jusqu'ici, nous avons compris comment le thread proxy découple les E/S réseau de l'exécution du kernel, permettant au calcul GPU et à la communication de véritablement se paralléliser. Mais le proxy n'est qu'un pilote ; l'implémentation concrète du transport réseau sous-jacent reste à révéler. Dans le prochain chapitre, nous plongerons dansnet_ib, pour voir comment NCCL encapsule l'API verbs pour implémenter le transport InfiniBand, et comment GPUDirect RDMA permet à la carte réseau de lire et écrire directement dans la mémoire GPU.
Chapitre 13 : Chapitre 13 : Transport réseau InfiniBand : comment net_ib encapsule verbs et GPUDirect RDMA
Chapitre 13 : Transport réseau InfiniBand : comment net_ib encapsule verbs et GPUDirect RDMA
Dans le chapitre précédent, nous avons vu comment le thread proxy extrait les E/S réseau du kernel GPU, permettant au calcul et à la communication de véritablement se paralléliser. Mais le proxy n'est qu'un « pilote » — il appelle les interfaces abstraites ncclNet->isend/irecv, sans savoir s'il s'agit en dessous de TCP, InfiniBand ou autre chose. Dans ce chapitre, nous levons cette couche d'abstraction et entrons dans src/transport/net_ib et src/misc/ibvwrap.cc, pour voir comment NCCL encapsule la bibliothèque C libibverbs en une table de symboles enfichable, comment établir une Queue Pair (QP), et comment GPUDirect RDMA permet à la carte réseau de contourner la mémoire hôte pour lire et écrire directement dans la mémoire GPU.
13.1 Pourquoi NCCL n'appelle pas directement libibverbs
Modèle intuitif : la table de symboles est une « prise électrique enfichable »
Imaginez que vous ayez acheté un appareil électrique importé, dont la forme de la fiche ne correspond pas à la prise de votre maison. Vous avez deux choix : soit démonter l'appareil pour modifier le câblage (directement#include <infiniband/verbs.h>et lier-libverbs), soit acheter un adaptateur universel (chargement dynamique des symboles à l'exécution). NCCL a choisi la seconde option.
La motivation centrale de ce choix estla flexibilité de déploiement: NCCL, en tant que bibliothèque, est chargé par des frameworks de haut niveau comme PyTorch, TensorFlow, etc., et ne peut pas supposer que l'environnement d'exécution dispose forcément delibibverbs.so. Si l'édition de liens était effectuée à la compilation, alors sur une machine sans pilote InfiniBand, toute la bibliothèque NCCL ne pourrait pas être chargée — même si vous ne voulez utiliser NVLink que pour une communication mono-machine. Grâce audlopenà l'exécution + la résolution de symboles, NCCL peut se dégrader élégamment sur une machine sans IB.
Si cette couche d'encapsulation manquait, la catastrophe à laquelle le système ferait face serait :une tâche d'entraînement mono-machine purement NVLink planterait directement parce que la machine n'a pas de pilote IB installé. C'est extrêmement courant dans les environnements cloud et sur les machines de développement.
Structures de données et disposition mémoire : le conteneur de table de symboles
La structure de données centrale estncclIbvSymbols, définie dansibvsymbols.h(ce chapitre ne contient pas ce fichier, mais sa structure peut être déduite de son utilisation). C'est un conteneur pur de pointeurs de fonctions, chaque champ correspondant à une fonction 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);
// ... 数十个函数指针
};Il n'existe qu'une seule instance globale, accompagnée destd::once_flagpour garantir une initialisation thread-safe :
📎 src/misc/ibvwrap.cc:26-29
static std::once_flag initOnceFlag;
static ncclResult_t initResult;
struct ncclIbvSymbols ibvSymbols;La conception ici est très sobre :initOnceFlageststd::once_flag,initResultRésultat d'initialisation du cache,ibvSymbolsest la table de symboles globale. Les trois ont une durée de stockage statique, leur cycle de vie s'étend sur tout le processus.
Pourquoi utiliserstd::once_flagau lieu depthread_once? Parce que le code C++ de NCCL dépend déjà de<mutex>et<thread>, utiliser la bibliothèque standard est plus cohérent.call_onceLa sémantique dewrap_ibv_symbols()est la suivante : quel que soit le nombre de threads appelant simultanémentinitResult, le lambda ne s'exécute qu'une seule fois, les autres threads se bloquent en attendant, puis tous obtiennent le même
. C'est bien plus sûr qu'un double-checked locking (DCLP) écrit à la main — DCLP a un fameux piège de réordonnancement sous le modèle mémoire C++.
Étape par étape : le flux complet de résolution des symboleswrap_ibv_symbols():
📎 src/misc/ibvwrap.cc:26-29
ncclResult_t wrap_ibv_symbols(void) {
std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
return initResult;
}buildIbvSymbolsCopieribvsymbols.ccest défini dansdlopen("libibverbs.so")(non inclus dans ce chapitre), son rôle est d'ouvrir la bibliothèque avecdlsympuis d'appeler
pour chaque nom de fonction afin de remplir les pointeurs. Si un symbole est introuvable, le champ correspondant reste NULL.CHECK_NOT_NULLCette conception « autorisant NULL » traverse toute la couche d'encapsulation. Regardez la macro
📎 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; \
}Chaque fonction d'encapsulation vérifie si le symbole correspondant est non nul avant l'appel. Cela signifie :si une ancienne version de libibverbs manque une nouvelle fonction, NCCL ne plantera pas au chargement, mais signalera l'erreur seulement lorsqu'elle sera réellement utilisée. C'est la clé de la dégradation progressive.
Réflexion de conception : les trois responsabilités de l'encapsulation par macro
ibvwrap.ccdéfinit 7 macros, qui ne sont pas de simples sucres syntaxiques, mais assument trois responsabilités :
1. Protection contre les pointeurs nuls:CHECK_NOT_NULLintercepte l'état non initialisé
2. Normalisation des codes d'erreur: traduire les multiples conventions d'erreur de libibverbs (retourner -1, retourner errno, retourner un pointeur NULL) enncclResult_t
3. Points de journalisation: en cas d'échec,WARNaffiche le nom de la fonction et errno
RegardezIBV_PTR_CHECK_ERRNOcette macro la plus complexe :
📎 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;Après expansion, elle fait quatre choses : vérifier que le symbole est non nul, exécuter l'appel, écrire la valeur de retour dansretval(généralement retourné via un paramètre pointeur commeibv_pd*, etc.), déterminer si elle est égale à la valeur d'erreur. Notezstrerror(errno)— les fonctions de libibverbs retournant un pointeur (commeibv_alloc_pd) retournent NULL en cas d'échec et définissenterrno, donc lireerrnoici est correct.
Tandis queIBV_INT_CHECKest utilisé pour les fonctions retournant un 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;Ici on ne lit paserrno, car ce type de fonctions (commeibv_fork_init) retourne directement -1 pour indiquer l'échec, l'information d'erreur est déjà perdue.
Cette approche « une macro différente par fonction » semble fastidieuse, mais elle est nécessaire : les conventions d'erreur de l'API libibverbs sont extrêmement hétérogènes, certaines retournent 0/-1, d'autres une valeur errno, d'autres un pointeur. Si on forçait l'uniformisation, on perdrait au contraire l'information d'erreur. NCCL choisit de « traduire fidèlement », en gardant la complexité dans la couche d'encapsulation, pour que la couche supérieurenet_ib.ccn'ait qu'à vérifierncclSuccess。
13.2 ibvcore.h : le contrat ABI sans dépendance aux en-têtes
Modèle intuitif : un traducteur avec son propre dictionnaire
ibvcore.hest un fichier étrange — il redéfinit les structures, énumérations et constantes essentielles de libibverbsune nouvelle fois. Pourquoi ? Parce que NCCL doit utiliser ces types sans#include <infiniband/verbs.h>.
Cela résout un vrai problème d'ingénierie :infiniband/verbs.hle contenu dedlopendiffère selon les distributions et les versions de pilotes. Si NCCL l'incluait directement, il serait lié à une version précise à la compilation. En définissant lui-même un « sous-ensemble minimal nécessaire », NCCL peut se passer des en-têtes IB à la compilation et charger n'importe quelle version de la bibliothèque à l'exécution via
Si cette couche manquait, la catastrophe serait :impossible de compiler NCCL sur une machine sanslibibverbs-dev. Alors qu'en réalité, à l'exécution, la bibliothèque peut être fournie via.rdma-coreDisposition mémoire des structures clés
Nous sélectionnons quelques structures essentielles à la compréhension de RDMA pour les analyser.
: identifiant global
ibv_gidCopier
📎 src/include/ibvcore.h:58-64
union ibv_gid {
uint8_t raw[16];
struct {
uint64_t subnet_prefix;
uint64_t interface_id;
} global;
};utiliseibvGetGidStrpour le formater :inet_ntop(AF_INET6, ...)Copier
📎 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_assertetibv_gidont la même taille, afin quein6_addrpuisse interpréter correctement ces 16 octets.inet_ntop: handle d'enregistrement mémoire
ibv_mrCopier
📎 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;
};est l'adresse de début de la mémoire enregistrée (peut être de la mémoire hôte, ou une adresse de mémoire GPU mappée vers l'hôte),addrest la longueur.length(local key) etlkey(remote key) sont les « clés » utilisées par la carte réseau pour vérifier les droits d'accès — l'émetteur inclutrkeydans le WQE, le récepteur vérifie aveclkey.rkey〔Inférences de conception et compromis architecturaux〕
est une adresse virtuelle. Le processus d'enregistrement permet au pilote d'« épingler » (pin) la table de pages de cette plage d'adresses virtuelles, d'établir le mapping IOMMU, et de retourneraddrcomme handle pour les références ultérieures. L'enregistrement est coûteux (implique un parcours de table de pages et une programmation IOMMU), donc NCCL met en cache les MR pour éviter de réenregistrer à chaque transfert.lkey/rkey: requête de travail d'envoi
ibv_send_wrCopier
📎 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;
};est une étiquette définie par l'utilisateur (retournée telle quelle à la complétion),wr_idest la scatter-gather list,sg_listdétermine le type d'opération (RDMA_WRITE, SEND, etc.),opcode 决定操作类型(RDMA_WRITE、SEND 等),wr.rdma.remote_addretwr.rdma.rkeySpécifient l'adresse cible et la clé d'accès du pair distant.
ibv_sgeDécrit un segment de mémoire locale :
📎 src/include/ibvcore.h:698-702
struct ibv_sge {
uint64_t addr;
uint32_t length;
uint32_t lkey;
};Attentionaddrestuint64_tet non un pointeur — car le WQE est lu par le matériel de la carte réseau, il doit être au format fixe de 64 bits.
Fonctions inline : le chemin rapide contournant la table de symboles
Certaines fonctions sont implémentées en inline par NCCL plutôt que de passer par la table de symboles. Par exempleibv_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);
}Il appelle directement via le pointeur de fonctionqp->context->ops.post_send. C'est la conception classique de libibverbs :ibv_contextcontient uneopsstructure, incluant tous les pointeurs de fonctions d'opération, remplie par le pilote spécifique.
Pourquoipost_sendpasse paropsplutôt que par la table de symboles ? Parce quepost_sendestchemin de donnéesune fonction chaude sur le , appelée à chaque envoi. Si elle passait par la table de symboles globale résolue pardlsym, cela ajouterait un adressage indirect supplémentaire. Alors que viaqp->context->ops, le compilateur peut faire de meilleures optimisations, et ce pointeur est fixé dès la création du QP. En comparaison,ibv_modify_qpest une fonction de chemin de contrôle, appelée moins fréquemment, passer par la table de symboles n'a pas d'importance.
L'encapsulation de NCCLwrap_ibv_post_sendest également inline :
📎 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;
}AttentionIBV_SUCCESSest défini comme 0 :
📎 src/include/ibvwrap.h:23-25
typedef enum ibv_return_enum {
IBV_SUCCESS = 0,
} ibv_return_t;Réflexion de conception : la « détection de version » pour la compatibilité ABI
ibvcore.hcontient un morceau de code ingénieux de détection de version ABI :
📎 src/include/ibvcore.h:81
static void *__VERBS_ABI_IS_EXTENDED = ((uint8_t *)NULL) - 1;C'est un « pointeur magique » — de valeur(uint8_t*)0 - 1, soit0xFFFFFFFFFFFFFFFF. Il est utilisé comme valeur marqueur du champibv_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));
}Siabi_compatest égal à cette valeur magique, cela signifie que la bibliothèque sous-jacente supporte l'ABI étendu, et à ce moment on peut via la techniquecontainer_ofdéduire à partir deibv_contextque le dernier champ duverbs_context。verbs_contextexterne estibv_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'est la technique classique d'implémentation de l'« héritage » en langage C :verbs_context« hérite » deibv_context, en plaçant la classe de base à la fin, on peut utilisercontainer_ofpour déduire le pointeur de la classe dérivée à partir du pointeur de la classe de base.szLe champ enregistre la taille de la structure, utilisé pour la compatibilité de version — une nouvelle version de la bibliothèque peut étendre la structure, l'ancien code vérifie viaszsi un champ existe.
verbs_get_ctx_opLa macro encapsule davantage cette vérification :
📎 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; })Elle vérifie trois choses : s'il s'agit de l'ABI étendu, si la structure est suffisamment grande pour contenir ce champ, et si ce champ est non nul. Ce n'est que si tout est satisfait qu'un pointeur valide est retourné. C'est la base pour queibv_query_port_expuisse être appelé en toute sécurité :
📎 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;
}Si la bibliothèque sous-jacente ne supporte pas l'extensionquery_port, elle retourne -1, et l'appelantwrap_ibv_query_portreviendra à l'ancienne API :
📎 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;
}Attentionmemset(port_attr, 0, sizeof(*port_attr))— il faut d'abord mettre à zéro avant le repli, car l'ancienne API ne remplira pas les nouveaux champs commeactive_speed_ex, et sans mise à zéro on lirait des valeurs parasites de la pile.
13.3 Machine à états QP et l'art de la retentative de modify_qp
Modèle intuitif : le QP est le processus complet d'« un appel téléphonique »
Queue Pair (QP) est l'unité de base de la communication RDMA, il contient la file d'envoi (SQ) et la file de réception (RQ). Établir un QP c'est comme passer un appel : d'abord composer le numéro (RESET→INIT), attendre que l'autre décroche (INIT→RTR), confirmer que les deux peuvent s'entendre (RTR→RTS), puis on peut parler.
Si la machine à états QP tombe en erreur, la catastrophe est :la carte réseau ne peut pas établir la connexion, toutes les communications inter-machines échouent, la tâche d'entraînement se bloque ou plante. Et les transitions d'état QP sont précisément l'endroit le plus susceptible de poser problème — gigue réseau, changements de GID, erreurs de connexion inter-rail peuvent tous causer l'échec deibv_modify_qp.
Énumération et transitions d'état
📎 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
};C'est la machine à états QP standard de RDMA. LeibvQpStateNamede NCCL traduit l'énumération en chaînes lisibles pour les logs :
📎 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;
// ...
}
}Le diagramme d'état ci-dessous correspond précisément à l'énumération et à la sémantique de transition dans le code source :
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) [错误恢复]Attention aux étatsIBV_QPS_SQD(SQ Drained) etIBV_QPS_SQE(SQ Error). SQD sert à la fermeture gracieuse — vider la file d'envoi avant de transitionner. SQE indique une erreur de la file d'envoi. NCCL n'entre pas activement dans ces deux états sur le chemin normal, mais doit les reconnaître lors de la gestion des erreurs.
Étape par étape : la logique de retentative de modify_qp
wrap_ibv_modify_qpest la fonction la plus complexe de ce chapitre, elle implémente un mécanisme complet de retentative :
📎 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;
}Décomposition étape par étape :
Première étape : lecture des paramètres。maxCnt = IbMQpRetryCnt() + 1, la valeur par défaut est 34 retentatives, donc au maximum 35 tentatives.timeOutpar défaut 100 millisecondes.
Deuxième étape : entrer dans la boucle de retentative. La première foisattempts == 0, pas de sleep, appel direct. Ensuite à chaque échec,sleepTime = timeOut * attempts— c'est unbackoff linéaire, la 1ère retentative attend 100ms, la 2ème attend 200ms, la 34ème attend 3400ms.
Troisième étape : déterminer s'il faut réessayer。IBV_MQP_RETRY_ERRNO_ALL(ret)décide s'il faut continuer :
📎 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))Par défaut, on ne réessaie que pourETIMEDOUT.IBV_ERR_EQcorrespond à la fois aux valeurs positives et négatives, car différents pilotes peuvent retournerETIMEDOUTou-ETIMEDOUT. SiNCCL_IB_MQP_RETRY_ALL=1est défini, on réessaie pour toute erreur non nulle.
Quatrième étape : imprimer les informations de diagnostic en cas d'échec。ibvModifyQpLogcollecte le nom du périphérique, le numéro de port, l'état actuel, l'état cible, les GID local/distant :
📎 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");
// ...
}Attention à la conception ingénieuse de la macroQP_ATTR:
📎 src/misc/ibvwrap.cc:295
#define QP_ATTR(attr, userAttr, userFlag, mask) ((userFlag & mask) ? (userAttr) : (attr))Elle utilise en priorité les attributs passés par l'utilisateur (si le bit correspondant est défini dansattr_mask), sinon elle se replie sur les attributs actuels trouvés parquery_qp. Ainsi, même siquery_qpéchoue, on peut obtenir des informations partielles à partir des paramètres utilisateur.
Cinquième étape : donner des indications en cas d'échec。printIbModifyQpHintdonne des suggestions de dépannage pour les codes d'erreur courants :
📎 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.");
// ...
}
}Ces indications sont le fruit de l'expérience de production.ETIMEDOUTLa cause la plus courante est un problème de connexion inter-rail — dans un réseau multi-rail, si la NIC 0 du rank A tente de se connecter à la NIC 1 du rank B, et qu'elles ne sont pas sur le même rail, il y aura un timeout.EINVALC'est généralement une erreur de configuration d'index GID, ou un changement de GID en cours d'exécution (par exemple une réinitialisation de la carte réseau).
Contrôle de concurrence et interaction matérielle
wrap_ibv_modify_qpn'est pas verrouillé en soi — il suppose que l'appelant garantit qu'un même QP ne sera pas modifié simultanément par plusieurs threads. Cela est vérifié dans NCCL : l'établissement du QP se produit lors de la phase d'initialisation, effectuée par un seul thread.
Mais dans la boucle de retry,std::this_thread::sleep_formérite attention. Il cède le CPU, mais ne libère aucun verrou (puisqu'il n'en détient pas). Lorsque cette fonction est appelée dans le thread proxy, le sleep bloque la progression du proxy — si l'établissement du QP reste bloqué, toute la communication s'arrête. C'est pourquoi le nombre de retries par défaut est de 34, pour un temps total d'environ 60 secondes — suffisant pour couvrir une brève fluctuation réseau, mais sans attente infinie.
13.4 Enregistrement mémoire : la porte d'entrée de GPUDirect RDMA
Modèle intuitif : donner une « carte d'accès » à la carte réseau
Pour que la carte réseau puisse lire et écrire directement en mémoire, elle doit d'abord « connaître » ce bloc mémoire. L'enregistrement mémoire (ibv_reg_mr) consiste à donner une carte d'accès à la carte réseau — lui indiquer la plage d'adresses physiques de ce bloc mémoire, et retourner unelkey(clé locale) et unerkey(clé distante). Ensuite, lorsque la carte réseau effectue du DMA, elle accède via cette clé.
En l'absence d'enregistrement mémoire, la catastrophe est :la carte réseau ne peut accéder à aucune mémoire, le RDMA ne fonctionne pas du tout. Le problème plus insidieux est : si l'on enregistre de la mémoire hôte mais que l'on souhaite accéder à la mémoire GPU, la carte réseau lira des données erronées ou déclenchera une erreur de protection.
Trois chemins d'enregistrement
NCCL encapsule trois fonctions d'enregistrement mémoire, correspondant à différents cas d'usage :
Chemin un : enregistrement ordinaire
📎 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");
}C'est le chemin standard,addrest l'adresse virtuelle,accessest le flag de permissions d'accès (IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITEetc.).
Chemin deux : enregistrement avec IOVA spécifiée
📎 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) permet de spécifier l'adresse vue par la carte réseau. Utile dans les scénarios nécessitant un mapping d'adresses fixe. Noter queret == NULLretourne directement un succès — c'est un « appel de sondage », qui vérifie seulement l'existence de la fonction, sans réellement enregistrer.
Chemin trois : enregistrement DMA-BUF (la clé de 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");
}C'est le cœur de GPUDirect RDMA.fdest un descripteur de fichier DMA-BUF — il représente un bloc de mémoire GPU. NCCL obtient ce fd via une API CUDA commecuMemGetHandleForAddressRange, puis le passe àibv_reg_dmabuf_mr. Le pilote de la carte réseau mappe directement la mémoire GPU via le mécanisme DMA-BUF, sans copie via la mémoire hôte.
DMA-BUF est le framework de partage de buffers du noyau Linux. Le pilote GPU (comme nvidia.ko de NVIDIA) exporte la mémoire GPU en DMA-BUF, le pilote de la carte réseau (comme mlx5) l'importe, et établit le mapping IOMMU. Tout le processus se déroule dans le noyau, l'espace utilisateur ne transmet qu'un fd. C'est le mécanisme sous-jacent permettant à la carte réseau de lire et écrire directement dans la mémoire GPU.
Enregistrement direct vs enregistrement encapsulé
Noter qu'il existe deux versions « 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);
}Elles retournent directementibv_mr*au lieu dencclResult_t, et n'affichent pas de log WARN. Pourquoi ?
Parce que ces deux fonctions sont utilisées pourla détection de capacités。ncclIbDmaBufSupport()appellewrap_direct_ibv_reg_dmabuf_mrpour tester si la carte réseau supporte DMA-BUF. En cas d'échec, il s'attend à obtenirerrno == EOPNOTSUPPpour déterminer « non supporté » plutôt que « erreur ». Si un WARN était affiché ici, cela inonderait les logs sur les machines ne supportant pas DMA-BUF. La version direct délègue donc la responsabilité de la gestion d'erreur à l'appelant.
Flags de permissions d'accès
📎 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),
};Ces flags sont des masques de bits, combinables.LOCAL_WRITEautorise l'écriture locale (nécessaire pour la réception de données),REMOTE_WRITEautorise l'écriture distante (nécessaire pour la cible d'un RDMA WRITE),REMOTE_READautorise la lecture distante (nécessaire pour la cible d'un RDMA READ).
IBV_ACCESS_RELAXED_ORDERINGest un flag d'optimisation de performance — il permet à la carte réseau d'accéder avec un ordonnancement mémoire plus relâché, ce qui peut améliorer le débit, mais nécessite que la couche applicative garantisse la correction.
Flux de données : le chemin complet de la mémoire GPU à la carte réseau
La figure ci-dessous montre le flux de données d'une écriture RDMA inter-machines, en ancrant les structures impliquées dans ce chapitre :
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)"]Chaque nœud de la figure correspond à un type réel du code source :ibv_mrprovient de📎 src/include/ibvcore.h:402-410,ibv_send_wrprovient de📎 src/include/ibvcore.h:704-738,ibv_qpprovient de📎 src/include/ibvcore.h:787-802。
13.5 Achèvement du travail et diagnostic d'erreurs
Modèle intuitif : le bon de livraison
Le RDMA est asynchrone — après votrepost_send, vous ne connaissez pas immédiatement le résultat. Une fois l'opération terminée, la carte réseau place un Work Completion (WC) dans la Completion Queue (CQ), comme le livreur déposant un bon de livraison dans votre boîte aux lettres. Vous devez activementpoll_cqpour le récupérer.
En l'absence de diagnostic WC, la catastrophe est :en cas d'échec de communication, vous savez seulement que « ça a échoué », sans savoir « pourquoi ». Les codes d'erreur RDMA sont au nombre de plus de 20, chacun correspondant à une cause racine différente.
Structure 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_idest l'étiquette que vous remplissez lors du post,statusest l'état d'achèvement,opcodeest le type d'opération,byte_lenest le nombre d'octets réellement transférés.qp_numetsrc_qpservent à identifier quel QP a terminé dans les scénarios multi-QP.
Traduction des codes d'état
ibvWcStatusStrtraduit l'énumération d'état en chaîne de caractères :
📎 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";
}
}Signification de ces codes d'état :
| Code d'état | Signification | Cause racine courante |
|---|---|---|
IBV_WC_SUCCESS | Succès | — |
IBV_WC_LOC_LEN_ERR | Erreur de longueur locale | La longueur du SGE dépasse la plage du MR |
IBV_WC_LOC_ACCESS_ERR | Erreur d'accès local | lkey invalide ou permissions insuffisantes |
IBV_WC_REM_ACCESS_ERR | Erreur d'accès distant | rkey invalide ou MR distant déjà désenregistré |
IBV_WC_RETRY_EXC_ERR | Retries épuisés | Réseau injoignable ou QP distant non prêt |
IBV_WC_RNR_RETRY_EXC_ERR | Retries RNR épuisés | Le pair n'a pas posté de recv |
IBV_WC_RESP_TIMEOUT_ERR | Délai de réponse dépassé | Le pair ne répond pas |
IBV_WC_RNR_RETRY_EXC_ERR(Receiver Not Ready) est l'un des problèmes les plus courants en production. Cela signifie que l'émetteur a envoyé des données, mais que le récepteur n'a pas préalablement posté suffisamment de buffers recv. Dans NCCL, cela se produit généralement lors de la phase d'établissement de connexion — les états QP des deux côtés sont désynchronisés, l'un a déjà commencé à envoyer, l'autre n'est pas encore prêt à recevoir.
Traduction des opcodes
ibvWcOpcodeStretibvWrOpcodeStrtraduisent respectivement l'opcode de complétion et l'opcode de requête :
📎 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";
// ...
}
}AttentionIBV_WC_RECVa pour valeur1 << 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
};PourquoiIBV_WC_RECVest1 << 7et non une valeur séquentielle ? Parce que la complétion de réception et la complétion d'envoi sont deux types d'opérations différentes, et utiliser les bits de poids fort permet au code d'utiliseropcode & IBV_WC_RECVpour déterminer rapidement « s'agit-il d'une complétion de réception ». C'est une convention de conception de l'API libibverbs.
Sondage du CQ
wrap_ibv_poll_cqest en ligne :
📎 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;
}Il est appelé viacq->context->ops.poll_cq, et commepost_sendemprunte le chemin rapideops. La valeur de retourdoneest le nombre de WC sondés cette fois, 0 signifie aucune nouvelle complétion, une valeur négative signifie une erreur.
poll_cqestun sondage actif— il ne bloque pas, il retourne immédiatement. Le thread proxy de NCCL l'appellera en boucle jusqu'à obtenir un événement de complétion. C'est la clé de la faible latence : contrairement au mode piloté par interruptions, le sondage actif évite le coût des changements de contexte d'interruption. Le prix à payer est une utilisation élevée du CPU, mais dans les scénarios de calcul haute performance, c'est acceptable.
13.6 Guide pour éviter les pièges en production
Piège 1 : Délai d'expiration de connexion inter-rail
Symptôme:ibv_modify_qpretourneETIMEDOUT, échec après 34 tentatives.
Cause racine: Dans un réseau multi-rail, chaque GPU est généralement lié à une NIC spécifique. Si le GPU 0 du rank A est lié à la NIC 0, le GPU 0 du rank B est lié à la NIC 1, et que la NIC 0 et la NIC 1 ne sont pas sur le même rail (c'est-à-dire qu'elles sont connectées à des commutateurs différents), alors l'établissement du QP expirera.
Diagnostic: Le code source donne déjà un indice :
📎 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;DéfinirNCCL_CROSS_NIC=0peut forcer la communication sur le même rail. Si cela résout le problème, il s'agit bien d'un problème inter-rail.
Chaîne de récupération: Le mécanisme de retry de NCCL (34 tentatives, backoff linéaire) laisse suffisamment de temps au réseau pour récupérer. Mais si la cause racine est une erreur de configuration topologique, le retry est inutile, il faut corriger la configurationNCCL_IB_HCAouNCCL_CROSS_NIC.
Piège 2 : Index GID incorrect
Symptôme:ibv_modify_qpretourneEINVAL。
Cause racine:NCCL_IB_GID_INDEXa forcé un index GID inexistant, ou le GID de la carte réseau a changé en cours d'exécution (par exemple, la carte RoCE a réobtenu une IP).
Diagnostic:
📎 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;DéfinirNCCL_IB_GID_INDEX=-1pour activer la détection automatique. Vérifier égalementdmesgpour les événements de changement de GID.
Piège 3 : DMA-BUF non supporté entraînant un repli sur la copie host
Symptôme: GPUDirect RDMA n'a pas pris effet, les performances sont inférieures aux attentes.
Cause racine: Le pilote de la carte réseau ou le noyau ne supporte pas DMA-BUF,wrap_direct_ibv_reg_dmabuf_mrretourne NULL et définiterrno = 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);
}Noter le commentaire :ncclIbDmaBufSupport()dépend de ceerrnopour déterminer si c'est supporté. SiEOPNOTSUPPn'est pas défini ici, la couche supérieure interprétera à tort comme « erreur » plutôt que « non supporté ».
Diagnostic: Vérifier la version du noyau (nécessite 5.12+), la version du pilote de la carte réseau, et si le modulenvidia-peermemest chargé. Si ce n'est vraiment pas supporté, NCCL se repliera sur la mémoire host comme intermédiaire, les performances diminueront mais les fonctionnalités resteront normales.
Piège 4 : Cache MR et fuite mémoire
L'enregistrement mémoire est une opération coûteuse (implique la programmation IOMMU), NCCL met en cacheibv_mr. Mais si la stratégie de cache est inappropriée, cela entraîne deux problèmes : premièrement, une fuite mémoire (le MR n'est jamais désenregistré), deuxièmement, une invalidation du cache (la mémoire est libérée mais le MR pointe encore vers l'ancienne adresse).
wrap_ibv_dereg_mrest le point d'entrée de désenregistrement :
📎 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");
}En production, si les tâches d'entraînement créent/détruisent fréquemment des domaines de communication, et que les MR ne sont pas correctement désenregistrés, cela entraîne une expansion de la table de mapping IOMMU, déclenchant finalement un échec deibv_reg_mr(retourneENOMEM). La méthode de diagnostic consiste à surveiller le nombre de mappings sous/sys/kernel/debug/iommu.
Réflexion de conception : pourquoi la couche d'encapsulation est si « épaisse »
En revisitant ce chapitre,ibvwrap.cccontient 509 lignes,ibvcore.hen contient 1134. Pour une couche d'encapsulation qui « ne fait qu'appeler libibverbs », c'est un volume considérable. Pourquoi ?
Trois raisons :
Premièrement, la complexité de la gestion des erreurs. Les conventions d'erreur de l'API libibverbs sont extrêmement hétérogènes, NCCL doit écrire une macro pour chaque convention et l'utiliser correctement dans chaque fonction. Ce n'est pas de la sur-ingénierie, mais le coût nécessaire d'une « traduction fidèle ».
Deuxièmement, la charge de la compatibilité ABI。ibvcore.hredéfinit toutes les structures, et doit aussi gérer la détection de version deverbs_context. C'est pour ne pas dépendre des en-têtes IB à la compilation, et rester compatible avec n'importe quelle version à l'exécution.
Troisièmement, la valeur des informations de diagnostic。ibvModifyQpLog、printIbModifyQpHint、ibvWcStatusStrCes fonctions ne sont pas appelées sur le chemin normal, mais leur valeur est énorme lors du dépannage. NCCL choisit de « pré-intégrer » les informations de diagnostic dans la couche d'encapsulation, plutôt que de les collecter à la volée en cas d'erreur.
Le coût de cette « encapsulation épaisse » est un volume de code important et un coût de maintenance élevé. Mais le bénéfice est que la couche supérieurenet_ib.ccpeut être écrite avec une interfacencclResult_tunifiée, sans avoir à se soucier des diverses bizarreries de libibverbs. C'est un design typique d'« isolation de la complexité ».
Résumé de ce chapitre
Dans ce chapitre, nous avons approfondi la couche d'encapsulation du transport InfiniBand de NCCL, les points clés étant :
1. Encapsulation de la table de symboles:ncclIbvSymbolsViadlopen + dlsymchargement à l'exécution de libibverbs, avecstd::once_flaggarantissant une initialisation thread-safe. Cela permet à NCCL de se charger même sur des machines sans pilote IB.
2. Contrat ABI:ibvcore.hredéfinit les types principaux de libibverbs, via__VERBS_ABI_IS_EXTENDEDpointeurs magiques etverbs_contextdecontainer_oftechnique pour la détection de version.
3. Machine à états QP:wrap_ibv_modify_qpimplémente 34 tentatives de retrait linéaire, pourETIMEDOUTetEINVALfournit des indices de diagnostic.
4. GPUDirect RDMA:wrap_ibv_reg_dmabuf_mrVia le mécanisme DMA-BUF, permet à la carte réseau de mapper directement la mémoire GPU,wrap_direct_ibv_reg_dmabuf_mrutilisé pour la détection de capacités.
5. Diagnostic d'erreurs:ibvWcStatusStr、ibvWcOpcodeStr、ibvWrOpcodeStrtraduit les codes d'erreur matériels en chaînes lisibles, un outil clé pour le dépannage en production.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on remplacewrap_ibv_symbolsdansstd::call_oncepar unif (initResult == ncclSuccess) return initResult;double-checked locking ordinaire, dans quels scénarios de concurrence cela poserait-il problème ?
Analyse de référence: voir📎 src/misc/ibvwrap.cc:26-29:
ncclResult_t wrap_ibv_symbols(void) {
std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
return initResult;
}Si l'on remplace par un double-checked locking naïf, le problème réside dansla réorganisation mémoire。buildIbvSymbolsrempliraibvSymbolsles différents champs, puis écrirainitResult. En l'absence de barrière mémoire, le CPU ou le compilateur peut réorganiserinitResult = ncclSuccessvers `
Jusqu'ici, nous avons vu clairement comment NCCL encapsule libibverbs via net_ib en une couche de transport enfichable, et utilise GPUDirect RDMA pour permettre à la carte réseau d'accéder directement à la mémoire GPU. Ce mécanisme résout les goulots d'étranglement de latence et de bande passante pour la communication inter-machines. Mais la communication intra-machine est tout aussi cruciale — dans le prochain chapitre, nous aborderons la mémoire symétrique et NVLS, pour voir comment NCCL exploite le multicast NVLink pour réaliser des communications collectives accélérées par le matériel. Vous découvrirez alors que le mécanisme RDMA de ce chapitre et NVLS sont complémentaires : le premier gère l'inter-machines, le second l'intra-machine.
Chapitre 14 : Chapitre 14 : Mémoire symétrique et NVLS : accélération multicast et adressage direct côté dispositif LSA
Chapitre 14 : Mémoire symétrique et NVLS : accélération multicast et adressage direct côté dispositif LSA
Dans le chapitre précédent, nous avons suivi un AllReduce inter-machines, observant comment les données passent de la mémoire GPU via la carte réseau jusqu'au GPU distant ; ce chemin résout la communication entre machines. Mais dans les clusters d'IA modernes, le volume de communication entre GPU d'une même machine, voire d'un même domaine NVLink, est tout aussi énorme — la synchronisation des gradients en entraînement parallèle de données, l'échange de valeurs d'activation en parallélisme tensoriel, se produisent majoritairement en intra-machine. Si la communication intra-machine emprunte encore le flux inter-machines GPU→mémoire→carte réseau→carte réseau distante→mémoire→GPU, c'est comme envoyer un colis en ville par fret aérien, gaspillant inutilement de la latence. Ce chapitre décompose précisément les deux outils que NCCL prépare pour la communication intra-machine : la mémoire symétrique et NVLS. La première permet à chaque rank d'accéder aux buffers de tous les ranks via le même ensemble d'adresses virtuelles, la seconde exploite la capacité multicast du matériel NVSwitch pour effectuer la réduction. Leur combinaison permet de compresser la latence des communications collectives de petits messages jusqu'à approcher la limite matérielle.
14.1 Mémoire symétrique : faire que « 3e rangée, 5e siège » désigne le même emplacement chez tout le monde
Modèle intuitif
Imaginez une classe qui doit échanger des cahiers. La méthode traditionnelle : chacun numérote ses cahiers, puis crie « Zhang San, mon 5e cahier est pour toi ; Li Si, mon 8e cahier est pour toi » — chacun doit retenir « qui a mis son cahier où, et lequel ». C'est la communication ordinaire : les adresses sontrelatives et privées, pour accéder aux données d'un pair, il faut d'abord connaître le mapping d'adresses du pair.
La mémoire symétrique adopte une autre approche : toute la classe convient que la coordonnée « 3e rangée, 5e siège » pointe vers le même emplacement physique chez chacun. Ainsi, pour que Zhang San prenne le 5e cahier de Li Si, il suffit de dire « chez Li Si, 3e rangée, 5e siège », sans aucune traduction d'adresse. C'est le cœur de la mémoire symétrique :le buffer de chaque rank est mappé à la même adresse virtuelle dans l'espace d'adressage de tous les ranks。
Que se passerait-il sans mémoire symétrique pour la communication collective intra-machine ? Chaque rank accédant au buffer d'un pair devrait passer par une « traduction d'adresse » — consultation de table, calcul d'offset, voire communication inter-processus pour confirmer la relation de mapping. Pour les petits messages (quelques Ko), le coût de cette traduction peut dépasser celui du transfert des données elles-mêmes. La mémoire symétrique élimine totalement ce coût, ce qui est précisément la raison fondamentale de sa « réduction significative de la latence des petits messages ».
Structures de données et disposition mémoire
Le type d'enregistrement de la mémoire symétrique est décrit parncclSymRegType_t,ncclGetSymRegTypeselon que les fenêtres send/recv portent le flagNCCL_WIN_COLL_SYMMETRIC, classe l'état d'enregistrement en quatre catégories.
📎 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;
}Ces quatre états déterminent quel chemin le kernel emprunte ensuite : enregistrement entièrement symétrique (SendRegRecvReg) emprunte le chemin LSA le plus rapide, entièrement non enregistré (SendNonregRecvNonreg) emprunte le chemin ordinaire, les états mixtes nécessitent un traitement spécial.winFlagsdansNCCL_WIN_COLL_SYMMETRICle bit
est le marqueur « cette fenêtre a-t-elle été enregistrée de manière symétrique ». L'entrée d'initialisation de la mémoire symétrique estncclSymkInitOnce, qui fait une chose clé : déterminer si le domaine de communication actuel supporte le multicast 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;hasLsaMultimemLes trois conditions sont indispensables : le multicast symétrique NVLS est activé, le nombre de rangs de l'équipe LSA est supérieur à 2 (deux rangs communiquent plus rapidement en point à point direct, sans nécessiter de multicast), et il n'y a pas de franchissement de clique (le multicast NVSwitch n'est pas disponible en cas de franchissement de clique). Cette évaluation détermine directement sireqs.lsaMultimemest positionné, ce qui influence ensuite l'allocation des ressources du communicateur côté périphérique.
Procédure pas à pas guidée par scénario
Supposons que nous lancions un AllReduce, avec une taille de message de 4 Ko, 8 rangs dans le même domaine NVLink.ncclSymkMaskdétermine quels kernels sont disponibles.
📎 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;Première étape :kernelMask_collen fonction du type de collectif (AllReduce), extraire l'ensemble de kernels candidatskernelMask_AR. Deuxième étape : vérifierhasLsaMultimem, si le multicast est pris en charge, déterminer ensuite si le type de données et l'opération de réduction prennent en charge LDMC (Load-Multicast). Troisième étape : utiliser un masque de bits pour éliminer les fonctionnalités non prises en charge —kmask &= ~kernelMask_STMCéliminer tous les kernels ne prenant pas en charge STMC.
Ensuite vient la limite de taille :
📎 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;Il y a ici deux limites strictes : les kernels de la série LL utilisent des entiers 32 bits pour suivre le nombre d'éléments, donc lorsque le nombre d'octets de bus dépasse 2 Go, les kernels LL sont éliminés ; lorsque cela dépasse 64 Go, tous les kernels sont éliminés (kmask = 0). C'est un cas typique de « échanger la largeur de bits contre la performance » — un index 32 bits économise des registres et des instructions par rapport à 64 bits, mais au prix d'une limite supérieure de taille de message.
Enfin, la vérification de la disponibilité de TMA et 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 nécessite que la capacité SMEM soit suffisante (ncclSymkTmaAvailablevérifiermaxSharedMemOptin) et un alignement de 16 octets. GIN n'est nécessaire que lorsque « le nombre de rangs de l'équipe LSA est inférieur au nombre total de rangs » — autrement dit, GIN n'a de sens que lorsque le domaine de communication franchit la frontière LSA (nécessite de passer par le réseau). Si tout le domaine de communication est dans le LSA, les kernels GIN sont éliminés.
Contrôle de concurrence et interaction matérielle
La résolution d'adresse de la mémoire symétrique aboutit finalement côté périphérique.ncclSymkMakeDevWorktraduit la description de tâche côté hôte en éléments de travail lisibles côté périphérique.
📎 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;
}Noter le calcul deinputOff: si sendWin existe (fenêtre d'enregistrement symétrique), le décalage estsendbuff - sendWin->userPtr— c'estle décalage dans la fenêtre, le côté périphérique obtientinputWin(adresse de base de la fenêtre) plusinputOffpour calculer l'adresse réelle. Si sendWin n'existe pas, le décalage est directement l'adresse absolue desendbuff. Cette conception permet au kernel côté périphérique d'utiliser la même logique pour traiter les tampons enregistrés et non enregistrés.
ncclSymkInitOnceinitialise également les besoins en ressources liés à GIN, notamment inbox, outbox, accumulation buffer et 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_ginutilise le modèle de réglage pour calculer le nombre de blocs et la taille de tampon nécessaires, puis est limité à l'intervalle[minCTAs, maxCTAs].rsGinAccumBytesPerBlockest la taille du tampon d'accumulation par bloc, alignée sur 128 octets — c'est la taille de ligne de cache, pour éviter le faux partage.
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"]Ce schéma décrit complètement la chaîne de décision dencclSymkMask: à partir du type de collectif, en passant successivement par cinq filtres — prise en charge du multicast, type de données, limite de taille, disponibilité TMA, besoin GIN — et renvoie finalement un masque de bits. Chaque filtre peut éliminer un lot de kernels, ce qui illustre la philosophie de NCCL : « sélectionner le kernel optimal selon le scénario ».
Guide de production pour éviter les pièges
Piège 1 : le multicast échoue silencieusement en cas de franchissement de clique. hasLsaMultimemLa troisième condition de!comm->p2pCrossCliqueestncclNvlsSymmetricMultimemEnabled. Si votre cluster est configuré avec MNNVL (Multi-Node NVLink), mais que certains rangs franchissent une clique, le multicast sera désactivé et les performances se dégraderont silencieusement vers le chemin ordinaire. Pour diagnostiquer, consulter la sortie de journal de
Piège 2 : exigence implicite d'alignement de 16 octets. ncclSymkMaskDansif (!symAligned16B) kmask &= ~kernelMask_Tma;— si le tampon utilisateur n'est pas aligné sur 16 octets, le kernel TMA est éliminé. TMA est le moteur de copie le plus rapide sur Hopper/Blackwell ; en être privé signifie une baisse de performance. En production, les tampons fournis par l'utilisateur proviennent souvent decudaMalloc, naturellement alignés ; mais s'ils proviennent d'un allocateur personnalisé ou d'un découpage, le piège peut se présenter.
Piège 3 : la limite de 2 Go.Les kernels LL utilisent des index 32 bits ; au-delà de 2 Go d'octets de bus, ils sont éliminés. Pour l'entraînement de grands modèles, le gradient d'un seul AllReduce peut dépasser cette valeur ; dans ce cas, NCCL basculera automatiquement vers le protocole STMC ou Simple. Ce n'est pas un bug, mais si vous avez spécifié manuellement le protocole LL, vous obtiendrezncclInvalidArgument。
---
14.2 NVLS : laisser le matériel NVSwitch effectuer la réduction à votre place
Modèle intuitif
L'AllReduce traditionnel est une « réduction logicielle » : chaque GPU envoie les données à son voisin, le voisin effectue l'addition, puis transmet — les données font des allers-retours entre les GPU, et l'addition est exécutée sur les SM. C'est comme 8 personnes qui se passent des papiers pour calculer une somme : chacune doit lire, additionner, puis transmettre.
NVLS adopte une autre approche : la puce NVSwitch intègre des capacités demulticast et de réduction. Vous écrivez les données dans une adresse multicast, NVSwitch les diffuse automatiquement à tous les membres et effectue l'addition dans le matériel. C'est comme si 8 personnes écrivaient des nombres sur le même tableau blanc, et le tableau blanc affiche automatiquement la somme — le GPU n'écrit qu'une fois et ne lit qu'une fois, tout le transport et l'addition intermédiaires sont effectués par le matériel du switch.
Sans NVLS, la bande passante de l'AllReduce intra-nœud serait limitée par les liaisons point à point entre GPU, et les SM devraient consacrer un grand nombre de cycles à l'addition. NVLS décharge ces deux tâches sur le matériel, permettant aux SM d'effectuer d'autres calculs.
Structures de données et disposition mémoire
Le cœur de NVLS est legroupe multicast (MC group)。ncclMcGroupLa structure décrit l'état complet d'un groupe multicast.
📎 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
};Quatre champs :handleest le handle de l'objet multicast CUDA,baseest l'adresse de base de l'adresse virtuelle multicast,capacityest la taille totale du mappage,devest le numéro de périphérique local (utilisé pour le débinding). Notez qu'il n'y a pas de verrou ici — la création et la destruction du groupe multicast se font aux phases d'initialisation/destruction, pas sur le chemin critique.
Le groupe multicast est découpé en plusieurspartitions, chaque partition étant une tranche immuable.ncclMcPartitiondécrit une partition.
📎 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;
}Chaque partition porte son propreoffset、size、ptr, ainsi que lemcHandle、minGranularity、devdu groupe auquel elle appartient. Cette conception « autonome » permet aux partitions d'être transmises indépendamment aux fonctions de binding, sans avoir à consulter à nouveau les informations du groupe.
Procédure pas à pas guidée par scénario
Supposons que 8 ranks veuillent établir un domaine NVLS.ncclMcGroupBuildPartitionsest responsable de la création du groupe multicast et du découpage en partitions.
📎 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;
}Première étape : accumuler les tailles de toutes les requêtes pour obtenir la taille totale du groupe multicast. Deuxième étape : interroger la granularité recommandée et la granularité minimale de CUDA — ce sont des contraintes matérielles, l'adresse et la taille de l'objet multicast doivent être des multiples entiers de la granularité. Troisième étape : allocation par bump — chaque requête découpe un bloc, l'offset et la taille étant alignés sur la granularité recommandée.ALIGN_SIZE(capacity, align)garantit que l'offset de début de chaque tranche est un offset de binding valide.
Ensuite vient la création et l'importation inter-ranks :
📎 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 crée l'objet multicast, puis diffuse le shareable handle via bootstrap ; les autres ranks reçoivent le handle et l'importent.cuMulticastAddDeviceajoute le périphérique local au groupe multicast. Notez cette barrière — le commentaire est très clair :cuMemMapbloque jusqu'à ce que tous les périphériques aient rejoint, si un peer échoue avantcuMulticastAddDevice, les survivants resteront bloqués danscuMemMap. Cette barrière permet à l'échec d'être capturé par le flag abort avant le blocage.
Enfin, le mappage et la configuration des droits d'accès :
📎 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);L'ensemble de l'adresse virtuelle multicast n'est réservé et mappé qu'une seule fois, chaque tranche de consommateur étant une vue de cette adresse virtuelle. C'est la conception « un seul mappage, plusieurs tranches » — plus économe en ressources que de créer un objet multicast séparé pour chaque consommateur.
Contrôle de concurrence et interaction matérielle
Le binding est l'opération la plus critique de NVLS.ncclMcPartitionBindMemlie un handle mémoire UC (unicast) à un certain offset du groupe multicast.
📎 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;
}La première ligne de défense est la vérification des limites :offsetInPartition + bindSize > partition->sizedéclenche une erreur. Le commentaire explique la raison — la granularité de la mémoire UC peut être plus grande que la partition MC, si l'alignement UC dépasse la limite de la partition MC, cela empiétera sur la partition du consommateur suivant. C'est le piège typique de « incompatibilité entre deux granularités ».
cuMulticastBindMemest un appel matériel, le commentaire indique qu'il « blocks until all ranks have been added to the group » — c'est l'endroit le plus susceptible de poser problème avec NVLS. Si Fabric Manager est mal configuré ou si le firmware NVSwitch a un problème, cela se bloquera ou retournera une erreur ici. Le message d'erreur suggère directement à l'utilisateur deNCCL_NVLS_ENABLE=0, c'est la sortie de secours standard en environnement de production.
Il existe également une variante « tentative de binding », utilisée pour l'enregistrement de buffers utilisateur :
📎 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;
}Il y a ici une classification d'erreurs ingénieuse :CUDA_ERROR_INVALID_VALUE、NOT_SUPPORTED、NOT_PERMITTEDest classé commencclMcBindStatusNoSupport— c'est unedéfaillance permanente, indiquant que ce buffer lui-même ne supporte pas le binding multicast. Tandis que les autres erreurs (en particulierOUT_OF_MEMORY) sont classées commencclMcBindStatusTransient— c'est unedéfaillance temporaire, qui peut être retentée. Cette distinction est cruciale : si l'on traite un OOM comme une défaillance permanente, on abandonnera à tort un enregistrement qui aurait pu réussir ; si l'on traite une erreur de paramètre comme une défaillance temporaire, on retentera indéfiniment.
Guide de production pour éviter les pièges
Piège 1 : une mauvaise configuration de Fabric Manager provoque le blocage decuMulticastBindMem.C'est la panne de production la plus classique de NVLS. Le message d'erreur pointe explicitement vers Fabric Manager ou NVSwitch. Étapes de diagnostic : d'abordNCCL_NVLS_ENABLE=0confirmer que le problème disparaît, puis vérifier les logs de Fabric Manager et la version du firmware NVSwitch.
Piège 2 : incompatibilité de granularité UC/MC. ncclMcPartitionBindMemLa vérification des limites de
capturera ce problème, mais si vous voyez un avertissement « UC/MC granularity mismatch », cela signifie que la taille UC d'une requête, après alignement, dépasse la partition MC. Cela se produit généralement lorsque la taille de la requête est proche de la limite de granularité. ncclMcGroupBuildPartitionsPiège 3 : fuite de ressources après l'échec de création du groupe multicast.CUCALLLe chemin d'échec deCUCHECK:
📎 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;Le commentaire explique la raison : si l'opération de cleanup elle-même échoue, on ne peut pas pour autant sauter la libération du handle MC — le slot MC est une ressource rare, une fuite entraînerait l'échec des créations ultérieures. C'est un design typique de « le chemin de nettoyage doit faire de son mieux ».
sequenceDiagram
participant R0 as "Rank 0 (localRank=0)"
participant R1 as "Rank 1..N-1"
participant BS as "bootstrapIntraNode"
participant CU as "CUDA Driver"
R0->>CU: "cuMulticastCreate(mcHandle, prop)"
CU-->>R0: "mcHandle"
R0->>BS: "bootstrapIntraNodeBroadcast(shareableHandle)"
BS-->>R1: "shareableHandle"
R1->>CU: "cuMemImportFromShareableHandle(mcHandle)"
CU-->>R1: "mcHandle"
R0->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
R1->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
R0->>BS: "bootstrapIntraNodeBarrier()"
R1->>BS: "bootstrapIntraNodeBarrier()"
Note over R0,R1: "barrier 防止 cuMemMap 阻塞时 peer 失败"
R0->>CU: "cuMemAddressReserve(base, capacity)"
R0->>CU: "cuMemMap(base, capacity, mcHandle)"
R0->>CU: "cuMemSetAccess(base, capacity, desc)"
R0->>CU: "cuMulticastBindMem(mcHandle, mcOffset, ucHandle)"
CU-->>R0: "绑定完成,硬件多播就绪"Ce diagramme de séquence décrit le flux complet d'un groupe multicast, de sa création à son binding. Le point clé est cette barrière — elle découple « l'échec d'un peer » et « le blocage de cuMemMap », évitant que les survivants ne se retrouvent bloqués.
---
14.3 La fusion de la mémoire symétrique et de NVLS : comment les pointeurs LSA sont résolus côté device
Modèle intuitif
La mémoire symétrique résout le problème de « cohérence d'adresses », NVLS résout le problème de « réduction matérielle ». Mais pour qu'ils coopèrent réellement, un mécanisme clé est encore nécessaire :Comment le côté device sait-il qu'une adresse est symétrique et peut emprunter le chemin multicast ?
La réponse réside dans le pointeur LSA (Load-Store Accessible). LSA est l'abréviation de « accessible en chargement-stockage », ce qui signifie que la mémoire pointée par ce pointeur peut être accédée directement par le GPU avec des instructions load/store ordinaires — qu'elle soit physiquement locale ou distante. Si l'adresse tombe dans un groupe multicast, le load/store sera intercepté et diffusé par le matériel NVSwitch.
Structures de données et disposition mémoire
ncclSymkDevWorkest le descripteur de travail côté device, il porte les informations clés de la mémoire symétrique.
📎 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;
}inputWinest l'adresse virtuelle côté device de la fenêtre (vidmem),inputOffest l'offset du buffer dans la fenêtre. Après que le kernel côté device a obtenu ces deux valeurs, il calculeinputWin + inputOffpour obtenir l'adresse réelle. Si cette adresse tombe dans le groupe multicast, le matériel gère automatiquement la diffusion.
ncclSymkInitOnceconfigure également la barrière LSA et les ressources 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;lsaBarrierCountest défini àncclSymkMaxBlocks— un slot de barrière par block. LLA2A est l'abréviation de all-to-all à faible latence, utilisé pour l'échange rapide de données dans le domaine LSA.ncclLLA2ACalcSlotscalcule le nombre de slots nécessaires en fonction du nombre de ranks, du nombre de threads et de la taille maximale des éléments.
Parcours pas à pas guidé par scénario
Supposons qu'un AllReduce utiliseAllReduce_AGxLLMC_Rkernel (AllGather + LL + MC + Reduce). Le flux de travail de ce kernel est :
1. Phase AllGather: chaque rank écrit ses propres données dans le groupe multicast, le matériel NVSwitch diffuse à tous les ranks.
2. Phase Reduce: chaque rank lit les données de tous les ranks depuis le groupe multicast et effectue la réduction localement.
ncclSymkMaskvérifie si ce kernel est disponible.kernelMask_LLcontientAllReduce_AGxLLMC_R, mais à condition quehasLsaMultimemsoit vrai (sinonkernelMask_STMCest effacé, etAllReduce_AGxLLMC_Rappartient à l'ensemble STMC).
Attendez, il y a un détail ici :kernelMask_STMCcontientAllReduce_AGxLLMC_R? Regardons le code source :
📎 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;Oui,AllReduce_AGxLLMC_Rest danskernelMask_STMC. Donc sihasLsaMultimemest faux, ce kernel sera éliminé. Cela explique pourquoi la mémoire symétrique et NVLS doivent travailler ensemble — sans multicast, tous les kernels de la série MC sont indisponibles.
Après que le côté device a obtenuncclSymkDevWork, il calcule l'adresse en fonction deinputWinetinputOff. Si l'adresse est dans le groupe multicast, les instructions load/store seront interceptées par NVSwitch. C'est le processus de résolution du pointeur LSA :Aucune traduction logicielle nécessaire, le matériel détermine automatiquement en fonction de la plage d'adresses。
Contrôle de concurrence et interaction matérielle
Le mécanisme de synchronisation de NVLS dépend descredit (crédits)。ncclNvlsSetupinitialise la partition de 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);
}Le groupe multicast est découpé en trois partitions :creditPartition(credit),dataPartition(données),ubPartition(buffer utilisateur). La partition credit sert à la synchronisation — chaque channel a des pointeurs head/tail indépendants, partagés via le groupe multicast.
L'initialisation des credits se fait dans la boucle suivante :
📎 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;Chaque combinaison de head et de channel a une zone de credit indépendante.headettailsont des pointeurs 64 bits,memSizefait 64 octets (size_t memSize = 64;), donc head et tail occupent chacun 32 octets — exactement une demi-ligne de cache.NCCL_NVLS_MIN_POLLLe flag
permet au récepteur d'utiliser le mode de polling minimal, réduisant la charge CPU.
Guide de production pour éviter les piègesPiège 1 : compétition head/tail dans la partition credit.nvlsCTAsPlusieurs channels partagent le même groupe multicast, mais chaque channel a une zone de credit indépendante. Si le nombre de channels est mal configuré (par exemplencclNvlsChannelsdéfini trop grand), la zone de credit gonfle et occupe un espace d'adresses multicast précieux.
📎 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;
}Copiercomm->nNodesAttention,peerInfo[i].hostHashn'est pas encore initialisé à ce stade, donc le code utilise
pour déterminer manuellement s'il s'agit d'un environnement multi-nœuds. C'est un piège classique d'ordre d'initialisation — on ne peut pas dépendre d'un champ qui n'a pas encore été calculé. 📎 src/transport/nvls.cc:516-517
// MNNVL does not support NVLS buffer registration
if (!comm->MNNVL && comm->nvlsResources->nvlsShmemHandle == NULL) {Copier
Piège 3 : le comptage de références des ressources partagées. ncclNvlsSetupPrise en charge du partage des ressources NVLS entre domaines de communication parent et enfant :
📎 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);
}Le domaine de communication enfant réutilise les ressources du domaine parent, le compteur de références est incrémenté de un.ncclNvlsFreeLa libération réelle n'a lieu que lorsque le compteur de références atteint zéro. Si la gestion du compteur de références est erronée, cela entraîne une libération prématurée ou une fuite de ressources. AttentionnvlsChunkSizeetnvlsTreeMaxChunkSizedoivent hériter des valeurs du domaine de communication parent — car les tampons sont disposés selon ces valeurs, les modifier provoquerait des erreurs de calcul d'adresse.
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
endCe diagramme de flux de données illustre la chaîne complète depuis les tâches côté host jusqu'à l'exécution côté device. La branche critique estlsa{"地址在多播组内?"}— si oui, on passe par la multidiffusion et la réduction matérielles NVSwitch ; si non, on utilise la mémoire locale du GPU. Cette décision est prise automatiquement par le matériel en fonction de la plage d'adresses, sans intervention logicielle.
---
14.4 Réflexion de conception : pourquoi la mémoire symétrique réduit-elle la latence des petits messages
Revenons à la question centrale du début de ce chapitre : pourquoi la mémoire symétrique réduit-elle significativement la latence des petits messages ?
Premièrement, elle élimine le surcoût de traduction d'adresses.Dans la communication traditionnelle, chaque rank doit consulter une table et calculer un décalage pour accéder au tampon d'un pair. La mémoire symétrique permet à tous les ranks d'utiliser le même jeu d'adresses, et le kernel côté device calcule directementbase + offset. Pour les petits messages, ce surcoût de traduction représente une proportion élevée.
Deuxièmement, elle élimine les allers-retours de messages de contrôle.La communication traditionnelle nécessite l'échange d'informations de contrôle du type « dans quel tampon de toi je vais écrire ». Avec la mémoire symétrique, les adresses sont convenues à l'avance, aucune négociation à l'exécution n'est nécessaire.
Troisièmement, elle rend possible la multidiffusion matérielle.Ce n'est que lorsque les adresses sont symétriques que NVSwitch peut effectuer la multidiffusion avec le même jeu d'adresses. Si l'adresse de chaque rank est différente, le matériel ne peut pas savoir où diffuser.
Quatrièmement, elle réduit la charge de réduction des SM.NVLS décharge l'addition sur le NVSwitch, le SM n'a qu'à émettre une écriture et une lecture. Pour les petits messages, le surcoût en instructions du SM est la principale source de latence.
La combinaison de ces quatre facteurs fait passer la latence des petits messages de « l'ordre de la microseconde » à « l'ordre de la sub-microseconde ».
D'un point de vue ingénierie, la conception de la mémoire symétrique incarne une philosophie centrale de NCCL :repousser la complexité vers la phase d'initialisation, rendre le chemin critique aussi simple que possible. La négociation d'adresses, la création des groupes de multidiffusion et l'allocation des crédits sont tous effectués à l'initialisation ; à l'exécution, le kernel n'a besoin que du calcul d'adresse et des load/store les plus simples. Cette conception « lourde à l'initialisation, légère à l'exécution » est un modèle courant des bibliothèques de communication haute performance.
---
Résumé du chapitre
Ce chapitre a décomposé les deux piliers de la communication intra-nœud de NCCL :
1. Mémoire symétrique: viancclSymkInitOnceetncclSymkMaskon établit des tampons à adresses cohérentes, permettant à chaque rank d'accéder aux données de tous les ranks avec le même jeu d'adresses.ncclSymkMakeDevWorktraduit les tâches côté host en work items côté device,inputWin + inputOffest la formule centrale de la résolution d'adresses.
2. Multidiffusion NVLS: viancclMcGroupBuildPartitionson crée un groupe de multidiffusion,ncclMcPartitionBindMemlie la mémoire UC au groupe de multidiffusion,cuMulticastBindMemest l'appel matériel. Le groupe de multidiffusion est découpé en trois partitions credit, data et ub, utilisées respectivement pour la synchronisation, le transfert de données et l'enregistrement des tampons utilisateur.
3. Résolution de pointeur LSA: le côté device détermine automatiquement, selon la plage d'adresses, s'il faut emprunter le chemin de multidiffusion, sans traduction logicielle.NCCL_NVLS_MIN_POLLLe flag optimise le surcoût de polling.
4. Gestion des erreurs:ncclMcPartitionTryBindAddrdistingue les échecs permanents des échecs temporaires,ncclMcGroupBuildPartitionsle chemin fail deCUCALLutilise
pour garantir la libération des ressources.
Réflexions et auto-évaluation du chapitrencclMcPartitionBindMemQ1 : si l'on supprime la vérification de limitesif (offsetInPartition + bindSize > partition->size)dans
, dans quels scénarios un dépassement de mémoire se produirait-il ? Pourquoi cette vérification ne peut-elle pas être remplacée par « UC et MC ont la même granularité » ?Analyse de référence📎 src/transport/multicast.cc:200-208:
Chapitre suivant : Chapitre 15 →
Progression de l'ouvrage : Chapitre 15 / 25
Chapitre 15 : RMA et GIN : évolution de l'accès mémoire distant et de la communication directe GPU
Dans le chapitre précédent, nous avons vu que la mémoire symétrique permet à chaque rank d'accéder aux tampons de tous les ranks avec le même jeu d'adresses, et que NVLS pousse la réduction accélérée par matériel à son paroxysme grâce à la capacité de multidiffusion de NVSwitch. Mais la communication collective n'est pas tout — lorsque l'application a besoin d'opérations mémoire distantes point à point, ou souhaite que le kernel GPU initie directement des requêtes réseau, RMA et GIN entrent en scène. RMA fournit un accès mémoire distant avec sémantique put/get, tandis que GIN permet au GPU de contourner le thread proxy du host pour interagir directement avec le réseau. Ce chapitre, dans l'ordre « d'abord RMA puis GIN », décompose couche par couche les structures de données, la logique d'ordonnancement, le contrôle de concurrence et les pièges de production de ces deux mécanismes.
Le modèle à double canal de RMA : répartition des rôles entre CE et Proxy
Imaginez un système de livraison international : la livraison intra-ville (les ranks accessibles via LSA) peut être effectuée directement par des camions de livraison locaux, tandis que la livraison inter-villes (les ranks non accessibles via LSA) doit être confiée à un transitaire aérien. Le RMA de NCCL fonctionne exactement selon ce modèle — une même opération put est routée, selon que le rank cible se trouve ou non dans le groupe LSA (Load-Store Accessible), vers deux chemins d'exécution totalement différents : le chemin CE (Copy Engine, moteur de copie) et le chemin Proxy (thread mandataire).
Sans ce mécanisme de répartition, toutes les opérations RMA passeraient par le thread proxy, et même un put intra-machine devrait transiter par un thread hôte, ajoutant inutilement un aller-retour hôte-device en latence. À l'inverse, si toutes les opérations passaient par le CE, les opérations inter-machines ne pourraient pas exploiter la capacité asynchrone du plugin réseau.
Structures de données et disposition mémoire
La structure centrale de planification du RMA estncclRmaArgs, qui enregistre le résultat de la répartition des tâches RMA dans un plan. Les champs clés incluent :
| Champ | Signification |
|---|---|
func | Type d'opération (PutSignal / Signal / WaitSignal) |
nRmaTasks | Nombre total de tâches |
nRmaTasksProxy | Nombre de tâches empruntant le chemin proxy |
nRmaTasksCe | Nombre de tâches empruntant le chemin CE |
Chaque plan maintient en interne deux files intrusives :rmaTaskQueueCeetrmaTaskQueueProxy, contenant respectivement les tâches des deux chemins.📎 src/rma/rma.cc:166-171
La logique pour déterminer si un rank est accessible via LSA est très directe — parcourir le tableaulsaRankListpour effectuer une recherche linéaire.📎 src/rma/rma.cc:34-41Cette recherche est exécutée une fois par peer lors de la planification des tâches, avec une complexité O(lsaSize), un coût négligeable pour un groupe LSA typique de petite taille (généralement 2 à 8 ranks).
Flux de planification pas à pas
Lorsqu'une application appelle une opération RMA put, la tâche entre dansplanner->rmaTaskQueues[ctx]。scheduleRmaTasksToPlan, chargé de répartir les tâches de la file dans un plan.📎 src/rma/rma.cc:141-296
Première étape : trouver la première file de contexte non vide. NCCL prend en charge plusieurs contextes RMA (configurés parnumRmaCtx), chaque contexte ayant sa propre file.📎 src/rma/rma.cc:148-155
Deuxième étape : extraire la première tâche et déterminer son type d'opération. S'il s'agit d'un WaitSignal, on applique la logique de division spéciale ; s'il s'agit d'un Put/Signal, on applique la logique de fusion par lots.📎 src/rma/rma.cc:163-168
Pour une tâche WaitSignal, le planificateur doit diviser la liste des peers en deux groupes selon l'accessibilité LSA : le groupe CE et le groupe Proxy.📎 src/rma/rma.cc:187-204Après la division, deux nouvelles structuresncclTaskRmasont créées, chacune détenant le tableau de peers du groupe correspondant.📎 src/rma/rma.cc:207-246La tâche originale est libérée.📎 src/rma/rma.cc:251
Pour les tâches Put/Signal, la logique est plus complexe — le planificateur parcourt les files de tous les contextes et regroupe toutes les tâches put/signal consécutives dans un même plan, jusqu'à rencontrer un WaitSignal.📎 src/rma/rma.cc:279-295L'objectif de cette conception est clairement expliqué dans les commentaires : faire couvrir par un seul lancement de kernel tous les put/signal de tous les contextes, permettre au proxy de lancer en une fois toutes les requêtes asynchrones avant toute opération bloquante, et au chemin CE de soumettre par lots les copies et signaux de tous les contextes.📎 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["按 isLsaAccessible 拆分 peers"]
ws_ce{"npeersCe > 0?"}
ws_proxy{"npeersProxy > 0?"}
ws_ce_task["创建 CE WaitSignal 任务"]
ws_proxy_task["创建 Proxy WaitSignal 任务"]
ws_free["释放原始 firstTask"]
put_check{"firstTask 的 peer LSA 可达?"}
put_ce["入队 rmaTaskQueueCe"]
put_proxy["入队 rmaTaskQueueProxy"]
batch_loop["遍历所有 ctx 队列, 拉取连续 put/signal"]
batch_check{"isRmaPutOrSignal(task->func)?"}
batch_route{"isLsaAccessible(comm, task->peer)?"}
batch_ce["入队 CE, nRmaTasksCe++"]
batch_proxy["入队 Proxy, nRmaTasksProxy++"]
done["记录 INFO 日志, 返回"]
start --> find_ctx
find_ctx -->|否| no_task
find_ctx -->|是| dequeue
dequeue --> check_func
check_func -->|是| ws_split
ws_split --> ws_ce
ws_ce -->|是| ws_ce_task
ws_ce -->|否| ws_proxy
ws_ce_task --> ws_proxy
ws_proxy -->|是| ws_proxy_task
ws_proxy -->|否| ws_free
ws_proxy_task --> ws_free
ws_free --> done
check_func -->|否| put_check
put_check -->|是| put_ce
put_check -->|否| put_proxy
put_ce --> batch_loop
put_proxy --> batch_loop
batch_loop --> batch_check
batch_check -->|否, 遇到 WaitSignal| done
batch_check -->|是| batch_route
batch_route -->|是| batch_ce
batch_route -->|否| batch_proxy
batch_ce --> batch_loop
batch_proxy --> batch_loopExécution parallèle et synchronisation des flux
Une fois la planification terminée,ncclLaunchRmadistribue selon le champfuncversncclRmaPutouncclRmaWaitSignal。📎 src/rma/rma.cc:109-131
En prenantncclRmaPutcomme exemple, lorsqu'un plan contient à la fois des tâches proxy et CE, les deux chemins doivent s'exécuter en parallèle. L'approche de NCCL est la suivante : enregistrer un event sur le flux d'entrée, faire attendre cet event au flux CE, puis lancer simultanément les opérations sur les deux flux, et enfin enregistrer un autre event sur le flux CE, que le flux d'entrée attend.📎 src/rma/rma.cc:80-96Cette chaîne d'events garantit que : les opérations CE ne commencent pas avant que les dépendances du flux d'entrée soient prêtes, et les opérations suivantes du flux d'entrée ne commencent pas avant la fin du CE.
S'il n'y a que des tâches proxy ou uniquement des tâches CE, les opérations correspondantes sont lancées directement sur le flux d'entrée, sans synchronisation de flux supplémentaire.📎 src/rma/rma.cc:97-101
Réflexions de conception et pièges en production
Piège 1 : le caractère statique de la détermination de l'accessibilité LSA. isLsaAccessibleLa listecomm->devrState.lsaRankListest interrogée lors de la planification ; cette liste ne change plus après l'initialisation du domaine de communication. Si la topologie change en cours d'exécution (par exemple une dégradation due à une panne NVLink), la liste LSA ne sera pas mise à jour automatiquement, ce qui peut amener des opérations qui devraient passer par le proxy à emprunter le chemin CE, déclenchant des erreurs irrécupérables.
Piège 2 : la garantie FIFO de la fusion par lots.La logique de fusion par lots ne récupère que les tâches put/signal consécutives et s'arrête à la rencontre d'un WaitSignal.📎 src/rma/rma.cc:283Cela garantit l'ordre FIFO au sein de chaque contexte, mais des tâches de contextes différents peuvent être fusionnées dans un même plan. Si l'application dépend de l'ordre des opérations entre contextes, elle doit utiliser explicitement WaitSignal pour établir une barrière.
Piège 3 : chemin de fuite mémoire.Dans la branche WaitSignal, sinpeersProxy == 0, le code libère les trois tableauxpeersProxy、nsignalsProxy、signalIdxsProxy.📎 src/rma/rma.cc:239-244Mais sinpeersCe == 0et quenpeersProxy > 0,peersCeles tableaux tels quencclMemoryStackAllocsont alloués via📎 src/rma/rma.cc:176-178Cette asymétrie peut facilement dérouter le lecteur, mais elle est en réalité correcte — la mémoire allouée sur la pile est gérée de manière unifiée parcomm->memScoped.
Contexte RMA Proxy : signaux, files d'attente et tampon circulaire sans verrou
Modèle intuitif
Le contexte Proxy ressemble à un « centre de tri postal » : le GPU place les colis à envoyer (requêtes put) dans la boîte de réception (tampon circulaire), le thread proxy retire les colis de la boîte de réception et les remet à la société de livraison (plugin réseau), et une fois la livraison effectuée, la société de livraison appose un cachet sur le bordereau de réception (signal). Tout au long de ce processus, le GPU et le thread proxy communiquent via des structures de données sans verrou, évitant ainsi une contention de verrous coûteuse.
Structures de données et disposition mémoire
ncclRmaProxyCtxest la structure hôte du contexte proxy, dont les champs principaux incluent :
Zone de signaux (signalsDev): un bloc de mémoire alloué sur le GPU, de taillenRanks * numRmaSig * sizeof(uint64_t)。📎 src/rma/rma_proxy.cc:120-123Chaque rank possèdenumRmaSigemplacements de signaux, destinés à recevoir les signaux provenant de ce rank. Lors de l'enregistrement de cette mémoire auprès du plugin réseau, les indicateursNCCL_NET_MR_FLAG_FORCE_SO(ordre fort forcé) etNCCL_NET_MR_FLAG_SIGNAL_NEVER_RESET(signal jamais réinitialisé) sont appliqués.📎 src/rma/rma_proxy.cc:125-127L'indicateur d'ordre fort garantit la relation d'ordre entre put et signal — si le put est émis avant le signal, le réseau doit garantir que le signal n'est écrit qu'après l'arrivée des données du put.
Zone de numéros de séquence (opSeqs/readySeqs/doneSeqs): un ensemble par rank, alloué viaallocMemCPUAccessible, pouvant être de la mémoire GDR (GPU Direct RDMA) ou de la mémoire hôte ordinaire.📎 src/rma/rma_proxy.cc:132-137Ces trois numéros de séquence suivent respectivement : le numéro d'opération soumis, le numéro d'opération prêt, le numéro d'opération terminé.
Tampon circulaire sans verrou (circularBuffers): un tableau de pointeurs de taillenRanks * queueSize, avec une file circulaire indépendante par rank.📎 src/rma/rma_proxy.cc:163-164Les tableaux associéspis(Producer Index) etcis(Consumer Index) comportent chacunnRankséléments.📎 src/rma/rma_proxy.cc:165-166La taille de la file doit être une puissance de 2, de sorte que le rebouclage d'indice puisse utiliser l'opération bit-à-bit& (queueSize - 1)au lieu du modulo.📎 src/rma/rma_proxy.cc:156-160
File InProgress: une liste chaînée intrusive par peer, contenant les descripteurs soumis au plugin réseau mais pas encore terminés.📎 src/rma/rma_proxy.cc:170-175Il s'agit d'une file à consommateur unique, accessible uniquement par le thread proxy, sans nécessité d'opérations atomiques.
Étape par étape : de la création du contexte à l'avancement de la progression
Création du contexte:ncclRmaProxyCreateContextTout d'abord, créer le contexte réseau via le plugin RMA.📎 src/rma/rma_proxy.cc:229Ensuite, appelerncclRmaProxyCtxAllocpour allouer les ressources telles que signaux, numéros de séquence, tampons circulaires, etc.📎 src/rma/rma_proxy.cc:231Puis appelerncclRmaProxyCtxAllocGraphpour allouer les ressources nécessaires au mode de capture de graphe — signaux accessibles par le CPU, tampons de flush, files persistantes.📎 src/rma/rma_proxy.cc:232
Le mode de capture de graphe existe parce que CUDA Graph exige que toutes les opérations soient rejouables. En mode normal, les signaux sont en mémoire GPU et le proxy les lit via GDR ; en mode de capture de graphe, les signaux sont en mémoire accessible par le CPU, et le proxy peut les lire et écrire directement, évitant ainsi l'incertitude du GDR.📎 src/rma/rma_proxy.cc:184-190
Thread de progression:ncclRmaProxyProgressThreadest la boucle principale du proxy.📎 src/rma/rma_proxy.cc:354-389Il détermine son comportement en fonction du mot d'étatrmaProgress:
rmaProgress == 1: mode d'avancement normal, parcourt tous les contextes proxy et appellencclRmaProxyProgress。📎src/rma/rma_proxy.cc:361-372rmaProgress == 2: mode pause, utilisé pour la récupération de ressources. Après confirmation de la pause, le thread attend sur une variable de condition.📎src/rma/rma_proxy.cc:373-378rmaProgress == -1: signal de sortie, le thread retourne.📎src/rma/rma_proxy.cc:379-380rmaProgress == 0: attente inactive.📎src/rma/rma_proxy.cc:381-382
SincclRmaProxyProgressretourne une erreur, le thread écrit le code d'erreur dansasyncResult, définitrmaProgress = -2, puis quitte.📎 src/rma/rma_proxy.cc:365-369Ce code d'erreur sera lu par le thread principal lors de l'appel ultérieur àncclCommGetAsyncError.
Contrôle de concurrence et ordre mémoire
Le modèle de concurrence du RMA proxy est « producteur unique - consommateur unique » : le kernel GPU est le producteur, le thread proxy est le consommateur. Le PI du tampon circulaire est mis à jour par le GPU, le CI par le proxy. Comme il s'agit d'un producteur unique et d'un consommateur unique, aucune opération CAS n'est nécessaire, seul un ordre mémoire correct est requis.
L'indicateur d'ordre fort de la zone de signauxNCCL_NET_MR_FLAG_FORCE_SOest essentiel.📎 src/rma/rma_proxy.cc:127Sans cet indicateur, le plugin réseau pourrait réordonner put et signal, ce qui ferait que le récepteur verrait le signal avant l'arrivée des données et lirait des données corrompues.
NCCL_NET_MR_FLAG_SIGNAL_NEVER_RESETL'indicateur indique au plugin réseau : une fois le signal écrit, il ne sera jamais réinitialisé.📎 src/rma/rma_proxy.cc:127Cela permet au plugin d'optimiser le chemin d'écriture du signal — pas besoin de remettre à zéro avant chaque écriture.
Pièges en production
Piège 1 : la taille de la file n'est pas une puissance de 2.Si l'utilisateur définit viaNCCL_RMA_PROXY_QUEUE_SIZEune valeur qui n'est pas une puissance de 2, le code revient à la valeur par défaut et imprime un log INFO.📎 src/rma/rma_proxy.cc:156-159Ce repli est silencieux (seulement au niveau INFO), et est facilement ignoré en production. Si l'utilisateur s'attend à une file plus grande pour absorber les pics de trafic, mais que la valeur par défaut est effectivement utilisée, cela peut entraîner une contre-pression.
Piège 2 : chaîne de repli en cas d'échec d'enregistrement DMA-BUF. ncclRmaProxyRegMrSymL'enregistrement de la mémoire CUDA comporte trois niveaux de repli : d'abord tenter le DMA-BUF en mode DataDirect, en cas d'échec tenter le DMA-BUF non DataDirect, et en cas de nouvel échec, revenir auregMrSym。📎 src/rma/rma_proxy.cc:76-108ordinaire. Les commentaires avertissent spécifiquement : si un MR emprunte le chemin non DataDirect, tous les autres MR doivent faire de même, car un usage mixte briserait les garanties d'ordre de GIN.📎 src/gin/gin_host_proxy.cc:429-430Cette contrainte n'est pas vérifiée explicitement dans le chemin RMA, ce qui constitue un risque potentiel.
Piège 3 : retard de propagation des erreurs du thread de progression.LorsquencclRmaProxyProgressretourne une erreur, le thread définitasyncResultet quitte.📎 src/rma/rma_proxy.cc:366-369Mais le thread principal peut être en train d'exécuter un kernel de longue durée et ne vérifiera pas immédiatementasyncResult. Pendant ce temps, les opérations RMA suivantes continueront d'être mises en file mais ne seront pas traitées, jusqu'à ce que le thread principal détecte l'erreur. C'est le délai inhérent à la propagation asynchrone des erreurs ; l'application doit appeler régulièrementncclCommGetAsyncErrorpour réduire cette fenêtre.
Architecture GIN : le GPU initie directement les requêtes réseau
Modèle intuitif
Dans le mode traditionnel, pour que le GPU envoie des données réseau, il doit passer par le chemin « GPU → mémoire hôte → thread proxy → carte réseau ». L'objectif de GIN (GPU-Initiated Networking) est de permettre au GPU d'écrire directement dans la file d'envoi de la carte réseau, comme le CPU écrit directement dans les registres MMIO de la carte réseau. Cela nécessite que la carte réseau prenne en charge les écritures doorbell initiées par le GPU, ainsi qu'un protocole de communication entre le GPU et les threads proxy.
Structures de données et disposition mémoire
La structure de données centrale de GIN estginProxyHostGpuCtx, qui représente un contexte de communication GPU-hôte :
| Champ | Type | Signification |
|---|---|---|
queues | ncclGinProxyGfd_t* | File GFD, taillenRanks * queueSize |
pis | uint32_t* | Indice producteur (écrit par le GPU) |
cis | uint32_t* | Indice consommateur (écrit par le proxy) |
cisShadow | uint32_t* | Copie fantôme de CI (locale au proxy) |
sis | uint32_t* | Indice vu (local au proxy) |
states | ginProxyGfdState* | État de chaque slot GFD |
inlines | uint64_t* | Tampon de données en ligne |
Le GFD (GIN Forwarding Descriptor) est un descripteur de requête écrit par le GPU à destination du proxy. Chaque GFD est composé de plusieurs qwords, contenant le type d'opération, l'adresse source, l'adresse de destination, la taille, les informations de signal, etc.📎 src/gin/gin_host_proxy.cc:158-163
queuesL'allocation mémoire du tableauallocMemCPUAccessibleprésente un détail crucial : il est alloué viaforceHost=true, mais avec le paramètre📎 src/gin/gin_host_proxy.cc:564. Cela signifie que la file elle-même se trouve dans la mémoire hôte, et le GPU y écrit via PCIe. En revanche, le tableaucisest alloué dans une mémoire accessible au GPU (probablement GDR), car le proxy doit le mettre à jour fréquemment.📎 src/gin/gin_host_proxy.cc:565-566
cisShadowetsissont des copies locales des threads proxy, évitant de lire à chaque foiscis。📎 src/gin/gin_host_proxy.cc:44-47qui peut se trouver dans la mémoire GPU. Ce n'est que lorsquecisShadowavance quecis。
est mis à jour par lots.
ncclGinProxyProgressÉtape par étape : interrogation et traitement des GFD📎 src/gin/gin_host_proxy.cc:648-669
est la boucle principale du proxy GIN.proxyGinPollCompletionsPremière étape : pour chaque contexte, appeler d'abord📎 src/gin/gin_host_proxy.cc:653
pour vérifier l'état d'achèvement des requêtes soumises.pollBatchDeuxième étape : pour chaque rang cible, interroger les GFD par lots.📎 src/gin/gin_host_proxy.cc:654-655
contrôle le nombre maximal de GFD traités à chaque fois.proxyGinPollGfdTroisième étape :📎 src/gin/gin_host_proxy.cc:176-182vérifie si un nouveau GFD est en tête de file. Le critère est de savoir si le bit flag en tête du GFD est non nul.📎 src/gin/gin_host_proxy.cc:194-202Si oui, copier d'abord le premier qword (l'en-tête), puis attendre que les qwords restants soient prêts.📎 src/gin/gin_host_proxy.cc:206-208
Une fois la copie terminée, mettre à zéro le GFD dans la file pour éviter un traitement en double.proxyGinProcessGfdQuatrième étape :📎 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_onlyCopie
proxyGinPollCompletionsAchèvement de l'interrogation et mise à jour des compteurs📎 src/gin/gin_host_proxy.cc:113-156
est chargé de vérifier l'état d'achèvement des requêtes soumises.cisShadowPour chaque rang cible, desisà📎 src/gin/gin_host_proxy.cc:117parcourir tous les états GFD vus mais non consommés.rmaBackend->testSi l'état n'est pas terminé, appeler📎 src/gin/gin_host_proxy.cc:122pour vérifier.📎 src/gin/gin_host_proxy.cc:132-141
Si terminé et que l'opération porte un indicateur de compteur, mettre à jour la valeur du compteur.📎 src/gin/gin_host_proxy.cc:133-135
La mise à jour du compteur utilise des chargements et stockages atomiques, mais le commentaire explique pourquoi l'addition atomique n'est pas nécessaire : le kernel GPU n'autorise pas la réinitialisation du compteur tant qu'il y a des opérations non terminées, donc il n'y a pas de concurrence.state->done && i == cisShadow[targetRank]La mise à jour de CI dispose d'un mécanisme de « trous autorisés » : CI n'avance que lorsque📎 src/gin/gin_host_proxy.cc:145-151. Cela garantit que CI est monotone croissant, et même si certains GFD se terminent en premier, cela ne sautera pas les GFD non terminés.
Contrôle de concurrence et barrières mémoire
Le modèle de concurrence du proxy GIN est plus complexe que celui du proxy RMA, car il existe plusieurs threads proxy (contrôlés parGIN_PROXY_NTHREADS).📎 src/gin/gin_host.cc:90
ncclGinProgressDans📎 src/gin/gin_host.cc:72, chaque thread est responsable d'un ensemble de connexions : le thread t traite les connexions t, t+proxyNthreads, t+2*proxyNthreads, ....
Cette méthode d'attribution garantit que chaque connexion n'est traitée que par un seul thread, évitant ainsi la concurrence au niveau des connexions.ginProgressWriteLockLa modification de la liste chaînée devComms nécessite une protection par verrou en écriture.writePendingdéfinit d'abord le flag📎 src/gin/gin_host.cc:43-47, puis acquiert le verrou en écriture.writePendingLe thread de progression vérifie📎 src/gin/gin_host.cc:63-66au début de chaque boucle, et cède le CPU si c'est vrai.
writePendingCette conception évite que le thread de progression soit bloqué par un verrou en écriture alors qu'il détient un verrou en lecture.std::atomic<bool>utilise📎 src/gin/gin_host.cc:43-47, mais le commentaire indique que cette logique suppose qu'il n'y a qu'un seul écrivain.
Dans le cas d'utilisation de NCCL, seul le thread principal modifie la liste chaînée devComms, donc cette hypothèse est valide.
Pièges en production queuesPiège 1 : emplacement mémoire de la file GFD.forceHost=true),📎 src/gin/gin_host_proxy.cc:564est forcé d'être alloué dans la mémoire hôte (cisCela signifie que l'écriture du GFD par le GPU doit passer par le bus PCIe. Si la fréquence d'écriture des GFD est élevée (scénario de petits messages), la bande passante PCIe peut devenir un goulot d'étranglement. En comparaison,📎 src/gin/gin_host_proxy.cc:565-566
est alloué dans une mémoire accessible au GPU, car le proxy doit le mettre à jour fréquemment.Piège 2 : reconstruction des données en ligne.📎 src/gin/gin_host_proxy.cc:298-305La logique de reconstruction détermine quels qword lire en fonction de size : si size ≤ 4, seuls les 32 bits inférieurs sont lus ; si size > 4, les 64 bits inférieurs sont lus ; si size > 6, les 16 bits supérieurs sont lus en plus. Cette logique de segmentation doit correspondre strictement à la logique d'écriture côté GPU ; toute incohérence entraînera une corruption des données.
Piège trois : progression multithread et allocation de connexions.Si différents ranks définissent des valeurs différentes deGIN_PROXY_NTHREADS, après un AllGather prenant la valeur minimale, certains threads peuvent ne se voir attribuer aucune connexion.📎 src/gin/gin_host.cc:181-183Les commentaires indiquent que ces threads tourneront à vide dans la boucle stride, ce qui ne causera pas de problème de correction, mais gaspillera des ressources CPU.
Sélection du backend GIN et compatibilité des versions
Modèle intuitif
GIN prend en charge plusieurs backends : Proxy (simulation logicielle basée sur le plugin RMA), GDAKI (GPU Direct Async Kernel Initiated), GPI (GPU-Initiated), EFA GDA (GPU Direct Async d'AWS EFA). C'est comme une même API pouvant avoir plusieurs implémentations — la version en simulation logicielle offre la meilleure compatibilité mais des performances moyennes, tandis que la version avec déchargement matériel offre les meilleures performances mais nécessite la prise en charge d'une carte réseau spécifique.
Matrice des versions de backend
Chaque backend possède un tableau de compatibilité de versions, où l'index est le numéro de version du backend et la valeur est la version minimale de NCCL requise pour cette version.📎 src/gin/gin_host.cc:27-33
| Backend | Version 0 | Version 1 | Version 2 | Version 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 | - |
Logique de sélection de version : parcourir le tableau de versions, trouver la première entrée dont la version requise est supérieure à la version actuelle du code de l'appareil ; la version précédente est alors la version disponible.📎 src/gin/gin_host.cc:300-304
Processus de sélection du backend
ncclGinDevCommSetupParcourir tous les backends actifs et tenter de créer un DevComm avec chaque backend.📎 src/gin/gin_host.cc:427-442Les conditions de sélection incluent : correspondance du type GIN demandé (ou non spécifié), et satisfaction des capacités de signalisation requises.📎 src/gin/gin_host.cc:430-435
ncclGinValidateSignalRequestVérifier deux capacités : signal fort (supportsStrongSignals) et signal VA (supportsVASignals)。📎 src/gin/gin_host.cc:230-243Si la demande exige un signal fort mais que le backend ne le prend pas en charge, ce backend est ignoré.
Établissement de connexion et calcul du stride
ncclGinConnectOnceÉtablir une connexion GIN.📎 src/gin/gin_host.cc:92-228
Le type de connexion détermine le stride : en mode FULL, le stride est de 1 (connexion à tous les ranks) ; en mode RAIL, le stride est decontiguousRanksPerHost(connexion uniquement aux ranks du même rail).📎 src/gin/gin_host.cc:139-145
DansginDevCommSetupWithBackend, la logique de validation du stride est très stricte :
- Le stride demandé ne peut pas être 0.📎
src/gin/gin_host.cc:318-323 - Le stride demandé ne peut pas être supérieur au stride de la rail team.📎
src/gin/gin_host.cc:324-330 - Le stride demandé doit être un multiple du stride déjà connecté.📎
src/gin/gin_host.cc:331-337
La motivation de ces contraintes est que : la barrière hiérarchique suppose que GIN est au moins connecté en RAIL.📎 src/gin/gin_host.cc:325Si le stride ne satisfait pas ces conditions, le chemin de communication entre certains ranks peut ne pas exister.
Pièges en production
Piège un : incompatibilité de version du backend.Si la version du code de l'appareil est inférieure à la version minimale requise par le backend,backendVersionrestera à une valeur inférieure.📎 src/gin/gin_host.cc:301-303Cela peut rendre certaines nouvelles fonctionnalités indisponibles (par exemple, un signal qui ne se réinitialise jamais), mais ne causera pas d'erreur. Cependant, si la version du code de l'appareil est supérieure à toutes les versions connues,backendVersionprendra la valeur maximale, ce qui peut déclencher un comportement indéfini.
Piège deux : les limites de la validation du stride.SirequestedStride % connectedStride != 0, la création échoue.📎 src/gin/gin_host.cc:331-337Cette vérification suppose que connectedStride est une puissance de 2 (1 en mode FULL,contiguousRanksPerHosten mode RAIL). SicontiguousRanksPerHostn'est pas une puissance de 2 (par exemple 3), la vérification de multiple peut rejeter un stride légitime.
Réflexions et auto-évaluation de ce chapitre
Q1 : DansscheduleRmaTasksToPlande la branche WaitSignal, si l'on supprime la ligneplan->rmaArgs->nRmaTasks = (npeersCe > 0 ? 1 : 0) + (npeersProxy > 0 ? 1 : 0)et qu'on la remplace directement par 1, dans quel scénario cela poserait-il problème ?
Analyse de référence: Regardez📎 src/rma/rma.cc:248。nRmaTasksenregistre le nombre réel de tâches mises en file. Si tous les peers sont accessibles via LSA (npeersProxy == 0), une seule tâche CE est réellement mise en file,nRmaTasksdevrait être 1. Si tous les peers sont inaccessibles (npeersCe == 0), une seule tâche Proxy est réellement mise en file,nRmaTasksdevrait aussi être 1. Mais si les peers sont répartis de manière mixte, les deux tâches sont mises en file,nRmaTasksdevrait être 2.
Si l'on remplace cette ligne parplan->rmaArgs->nRmaTasks = 1, dans un scénario de distribution mixte,nRmaTaskssous-estimera le nombre réel de tâches. Ensuite,ncclRmaWaitSignaldans le jugementplan->rmaArgs->nRmaTasksProxy > 0 && plan->rmaArgs->nRmaTasksCe > 0fonctionnera toujours correctement (car on utilisenRmaTasksProxyetnRmaTasksCe),📎 src/rma/rma.cc:47), mais tout code dépendant denRmaTaskspour l'estimation des ressources ou les statistiques de journalisation obtiendra des résultats erronés. Plus grave encore, si le code ultérieur utilisenRmaTaskspour allouer des tableaux ou calculer le nombre d'itérations, cela pourrait entraîner un débordement de tampon ou des tâches manquées.
Q2 : DansproxyGinPollGfd, si l'on déplacehostGpuCtx->sis[targetRank]++après l'appel àproxyGinProcessGfd, dans quel scénario de concurrence cela entraînerait-il un traitement en double du GFD ?
Analyse de référence: Regardez📎 src/gin/gin_host_proxy.cc:228。sisest l'« index déjà vu », indiquant le nombre de GFD que le proxy a déjà vus et commencé à traiter.proxyGinPollGfdIncrémentesisimmédiatement après avoir copié le GFD, puis retourne 1 pour indiquer le succès. L'appelantncclGinProxyProgressappelleproxyGinPollGfddans une boucle ; si le retour est 1, il continue à traiter le GFD suivant.📎 src/gin/gin_host_proxy.cc:648-669
Si l'on déplacesis++aprèsproxyGinProcessGfd, alors pendant l'exécution deproxyGinProcessGfd(qui peut impliquer des appels asynchrones du plugin réseau),sispointe toujours vers le GFD actuel. Si à ce moment le GPU écrit un nouveau GFD dans le même emplacement (car la file est circulaire,pispeut déjà avoir bouclé),proxyGinPollGfdverra à nouveau cet emplacement, maissisn'aura pas avancé, entraînant un traitement en double du même emplacement.
Plus dangereux encore,proxyGinPollGfdAprès avoir copié le GFD, la file de GFD est remise à zéro.📎 src/gin/gin_host_proxy.cc:206-208Sisisn'a pas avancé, le prochain sondage verra le GFD remis à zéro (flag à 0),isGfdAvailablerenvoie false, ce qui entraîne la perte du GFD. Cela provoque une attente côté GPU pour une requête qui ne sera jamais traitée, aboutissant finalement à un interblocage.
Q3 : DansncclRmaProxyProgressThread, sirmaProgress == 2la branche oublie d'appelerrmaProxyState->cond.notify_one(), dans quel scénario cela provoquerait-il un blocage permanent du thread principal ?
Analyse de référence: Regardez📎 src/rma/rma_proxy.cc:373-378。rmaProgress == 2est à l'état « requête de pause », utilisé pour la récupération de ressources. Le thread principal définitrmaProgress = 2puis attend que le thread de progression confirme la pause. Le thread de progression attend danscond.wait(lock), et le thread principal doit appelercond.notify_one()pour le réveiller.📎 src/rma/rma_proxy.cc:377
Si le thread de progression, après avoir définirmaProgress = 0, oublienotify_one(), le thread principal attendra indéfiniment sur la variable de condition. Mais plus crucial encore, lorsque le thread de progression attend danscond.wait(lock), le thread principal doit d'abord acquérir le verrou pour définirrmaProgress = 2. Si le thread de progression ne libère pas le verrou avantwait, le thread principal ne peut pas acquérir le verrou, formant un interblocage.
L'ordre correct est : le thread de progression définitrmaProgress = 0, appellenotify_one()pour réveiller le thread principal, puis appellecond.wait(lock)pour libérer le verrou et attendre. Le thread principal, une fois réveillé, acquiert le verrou, définitrmaProgress = 2, appellenotify_one()pour réveiller le thread de progression, puis attend la confirmation du thread de progression. Le thread de progression, une fois réveillé, définitrmaProgress = 0, effectue à nouveaunotify_one(), puiswait. Dans ce protocole de handshake, l'absence denotify_one()à n'importe quelle étape entraînera un blocage permanent.
Des sémantiques put/get de RMA à la communication réseau initiée par le GPU avec GIN, nous avons parcouru une étape clé de l'évolution de NCCL vers un moteur d'accès mémoire distant universel. Mais quelle que soit l'ingéniosité du mécanisme, il doit finalement s'interfacer avec des backends réseau externes, des stratégies de réglage et des collecteurs de performance via le système de plugins. Le chapitre suivant entre dans le monde des plugins, pour voir comment NCCL charge dynamiquement les extensions net, tuner, profiler, env, etc., sans modifier le code principal, et révèle les points clés de la mise en œuvre de l'extensibilité de l'écosystème à travers les exemples de google-fastsocket et google-CoMMA.
Chapitre 16 : Chapitre 16 : Écosystème de plugins et variables d'environnement : comment net, tuner, profiler, env étendent le comportement de NCCL
Chapitre 16 : Écosystème de plugins et variables d'environnement : comment net, tuner, profiler, env étendent le comportement de NCCL
Dans le chapitre précédent, nous avons vu comment NCCL étend ses capacités de communication des opérations collectives à l'accès distant point à point via RMA et GIN, permettant même au GPU d'initier directement des requêtes réseau. Cette évolution vers de nouveaux matériels et des scénarios à faible latence impose des exigences plus élevées en matière de flexibilité du moteur de communication : si chaque adaptation à un nouveau réseau, une nouvelle stratégie de réglage ou un nouvel outil de collecte nécessitait de recompiler le code principal, NCCL aurait du mal à suivre les changements de l'écosystème. Ce chapitre décortique les répertoires src/plugin et plugins, et répond à une question centrale : comment NCCL peut remplacer le backend réseau, la stratégie de réglage, le collecteur de performance et la source de configuration sans recompiler le code principal.
16.1 Chargeur de plugins : comment plugin_open.cc transforme un .so en backend utilisable
Modèle intuitif
Considérezplugin_open.cccomme « l'agence de recrutement » de NCCL : elle dispose d'une liste de postes (NET, GIN, RMA, TUNER, PROFILER, ENV), chaque poste correspondant à un nom de bibliothèque candidate. Lorsque NCCL a besoin d'une personne pour un poste, l'agence va dans l'ordre fixe chercher sur le marché des talents (l'éditeur de liens dynamique), signe le contrat si elle trouve (dlopen), enregistre « cette personne n'existe pas » sinon, et renvoie finalement un handle. Sans cette couche d'intermédiation, NCCL devrait coder en dur le backend réseau dans le binaire, et tout fabricant de carte réseau voulant s'intégrer devrait modifier le code source de NCCL — c'est précisément la catastrophe que le système de plugins vise à éliminer.
Structures de données et disposition mémoire
Tout l'état du chargeur se résume à six tableaux parallèles, dont l'indice est l'énumération du type de plugin :
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]; // 日志子系统位掩码Les indices de ces sept tableaux doivent être strictement alignés,pluginNames[type]、pluginPrefix[type]、subsys[type]décrit le même type de plugin.📎 src/plugin/plugin_open.cc:18-29définitNUM_LIBS = 6, l'ordre des types est{"NET", "GIN", "RMA", "TUNER", "PROFILER", "ENV"}, le préfixe est{"libnccl-net", "libnccl-gin", "libnccl-rma", "libnccl-tuner", "libnccl-profiler", "libnccl-env"}。
On utilise ici des tableaux parallèles plutôt qu'un tableau de structures, afin queopenPluginLibcette fonction unique puisse servir simultanément six types de plugins — le type ne servant que d'indice, la logique est entièrement réutilisée. Le coût est que l'ajout d'un nouveau type de plugin nécessite de modifier synchroniquement six tableaux, et le compilateur ne peut pas vous aider à détecter les oublis.
subsysLe tableau détermine l'attribution des logs : NET/GIN/RMA sont tous rattachés àNCCL_INIT | NCCL_NET, TUNER àNCCL_INIT | NCCL_TUNING, PROFILER uniquement àNCCL_INIT, ENV àNCCL_INIT | NCCL_ENV。📎 src/plugin/plugin_open.cc:26-29Ainsi, lors deNCCL_DEBUG_SUBSYS=NETon ne verra que les logs des plugins réseau, sans être noyé dans les logs de réglage.
Parcours pas à pas : le voyage complet d'unncclOpenNetPluginLib("mlx5")Supposons que l'utilisateur définisse
, NCCL appelleNCCL_NET_PLUGIN=mlx5lors de l'initialisation, qui transmet directement àncclOpenNetPluginLib("mlx5")Première étape : construire le nom de bibliothèque candidate.openPluginLib(ncclPluginTypeNet, "mlx5")。📎 src/plugin/plugin_open.cc:132-134
Comme unnon vide est passé, on emprunte la branchelibName,snprintf(libName_, MAX_STR_LEN, "%s", libName)devientlibName_Notez qu'à ce stade ce n'est pas encore un nom de fichier de bibliothèque valide — il n'a ni préfixe ni"mlx5"。📎 src/plugin/plugin_open.cc:85-89suffixe..soDeuxième étape : première tentative d'ouverture.
est appelé. tryOpenLib("mlx5", ...)Après être entré dans📎 src/plugin/plugin_open.cc:91, on vérifie d'abord sitryOpenLibest vide ou de longueur nulle, puis il y a une branche spéciale : si le nom commence parname, on metSTATIC_PLUGINàname 置为 nullptr。📎 src/plugin/plugin_open.cc:37-39Ceci est la sentinelle utilisée pour les plugins liés statiquement à NCCL —dlopen(nullptr)Sous Linux, renvoie le handle du programme principal, permettant ainsi àdlsymde trouver les symboles du plugin dans la table des symboles du programme principal.
Ensuite, appellencclOsDlopen(name)。📎 src/plugin/plugin_open.cc:41car"mlx5"n'est ni un chemin ni un nom de bibliothèque valide,dlopenéchouera. Après l'échec, le code récupèrencclOsDlerror()la chaîne d'erreur, et effectue un jugement précis : si la chaîne d'erreur contient à la foisnameet"No such file or directory", alors définit*errsurENOENT。📎 src/plugin/plugin_open.cc:42-55L'objectif de ce jugement est de distinguer « le fichier n'existe pas du tout » de « le fichier existe mais le chargement a échoué » — le premier cas signifie simplement que le nom candidat est incorrect et qu'il faut essayer silencieusement le candidat suivant ; le second est une véritable erreur qui doit être journalisée.
Troisième étape : traitement après le premier échec.Retour àopenPluginLib,libHandles[type]est vide, etopenErr == ENOENT, donc ajoute"mlx5"àeNoEntNameList。📎 src/plugin/plugin_open.cc:97-101Cette liste finira par former un journal « Could not find: mlx5 libnccl-net-mlx5.so ».
Quatrième étape : deuxième tentative — ajout de préfixe.Le code vérifie silibNamen'est ni un chemin (ne contient pas/) ni un nom de bibliothèque (ne commence pas parlib, ne se termine pas par.so).📎 src/plugin/plugin_open.cc:105-107 "mlx5"La condition est remplie, donc assemble"libnccl-net-mlx5.so"et réessaie.📎 src/plugin/plugin_open.cc:108Cette foisdlopenréussit,libHandles[type]est assigné,libNames[type]enregistre le nom de la bibliothèque,ncclPluginLibPaths[type]viagetLibPathobtient le chemin absolu, la fonction retourne le handle.📎 src/plugin/plugin_open.cc:110-115
Cinquième étape : obtention du chemin absolu. getLibPathSous Linux, utilisedlinfo(handle, RTLD_DI_LINKMAP, &lm)pour extrairelink_map, puisstrdup(lm->l_name)。📎 src/plugin/plugin_open.cc:65-69Ce chemin apparaîtra dans tous les journaux suivants, permettant à l'utilisateur de voir d'un coup d'œil quel fichier a été chargé — lors du diagnostic en production de « pourquoi un mauvais plugin a été chargé », cette ligne de journal est la scène de crime principale.
Le flux de décision complet est le suivant :
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"]Réflexions de conception et pièges en production
L'ordre des noms candidats est la priorité.On essaie d'abord le nom brut fourni par l'utilisateur, puis le nom avec préfixe. Cela signifie que si le répertoire courant contient un fichier nommémlx5, il sera chargé en priorité — c'est une surface d'attaque potentielle, et en production il faut éviter de placer dansLD_LIBRARY_PATHdes exécutables portant le même nom que le plugin.
STATIC_PLUGINLa sémantique deLorsqueNCCL_NET_PLUGIN=STATIC_PLUGIN,tryOpenLibmet le nom à vide,dlopen(nullptr)ouvre le programme principal,dlsymcherche dans la table des symboles du programme principal des symboles tels quencclNet_v12.📎 src/plugin/plugin_open.cc:37-39Cela permet de lier statiquement le plugin dans le binaire NCCL, évitant le déploiement de.so, au prix de la perte de la capacité de remplacement à l'exécution.
Comptage de références et déchargement. ncclClosePluginLibSeulement lorsquelibHandles[type] == handleeffectue réellementdlclose, et vide le chemin et le nom.📎 src/plugin/plugin_open.cc:176-186Cette comparaison d'égalité empêche de fermer par erreur un handle qui a déjà été remplacé. Les plugins GIN et RMA réutilisent le handle de la bibliothèque NET viancclGetGinPluginLib/ncclGetNetPluginLib, en appelant à nouveaudlopenle même nom de bibliothèque pour incrémenter le compteur de références.📎 src/plugin/plugin_open.cc:156-164C'est la sémantique de comptage de références dedlopen— la même bibliothèque ouverte deux fois nécessitedlclosedeux fois pour être réellement déchargée.
16.2 net.cc : machine à états et cycle de vie des plugins réseau
Modèle intuitif
net.ccest le « centre de调度 » des plugins réseau. Il maintient un tableau de bibliothèques de plugins, chaque bibliothèque ayant son propre état (non chargé, échec de chargement, en attente de chargement, en attente d'initialisation, activé). Lorsqu'un nouveau domaine de communication (communicator) naît, le centre de调度 parcourt tous les plugins candidats, tente de les initialiser un par un, le premier qui réussit est « attribué » à ce domaine de communication, et tous les autres plugins externes sont désactivés. Sans cette machine à états, NCCL ne pourrait pas gérer des problèmes concrets tels que « le plugin est chargé mais l'appareil est indisponible », « lequel choisir quand plusieurs plugins coexistent », « comment décharger en toute sécurité à la destruction du domaine de communication ».
Structures de données et disposition mémoire
La structure centrale estnetPluginLib_t:
| Champ | Type | Signification |
|---|---|---|
name | char[255] | Nom de la bibliothèque de plugins |
dlHandle | void* | Handle dlopen |
ncclNet | ncclNet_t* | Table de fonctions réseau |
ncclNetVer | int | Numéro de version de l'API réseau |
ncclCollNet | ncclCollNet_t* | Table de fonctions de déchargement de communication collective |
ncclNetPluginState | Énumération | État du plugin réseau |
ncclCollNetPluginState | Énumération | État du plugin CollNet |
ncclNetPluginRefCount | int | Compteur de références |
netPhysDevs/netVirtDevs | int | Nombre d'appareils physiques/virtuels |
collNetPhysDevs/collNetVirtDevs | int | Nombre d'appareils CollNet |
📎 src/plugin/net.cc:63-76définit ces champs. Noter quencclNetetncclCollNetsont deux tables de fonctions séparées, et les états sont également deux énumérations séparées — un plugin peut fournir des fonctionnalités réseau sans fournir le déchargement CollNet.
L'énumération d'état a cinq valeurs :Disabled = -2(échec d'initialisation),LoadFailed = -1(échec de chargement),LoadReady = 0(en attente de chargement),InitReady = 1(chargé en attente d'initialisation),Enabled = 2(activé).📎 src/plugin/net.cc:54-60utilise des nombres négatifs pour représenter les états d'échec, de sorte qu'une comparaison comme « état >= InitReady » exprime naturellement « au moins chargé ».
L'état global est constitué de trois variables :pluginCountenregistre le nombre total de plugins,netPluginLibs[NCCL_NET_MAX_PLUGINS]est le tableau de plugins,netPluginMutexprotège l'accès concurrent,initPluginLibsOnceFlaggarantit que l'initialisation n'est effectuée qu'une seule fois.📎 src/plugin/net.cc:78-81
Step-by-Step Walkthrough : le voyage complet d'unncclNetInit(comm)Premier pas : initialisation unique.
garantit que la liste des plugins n'est construite qu'une seule fois. std::call_once(initPluginLibsOnceFlag, initPluginLibsOnceFunc)lit la variable d'environnement📎 src/plugin/net.cc:360 initPluginLibsOnceFunc, si non définie ajoute par défautNCCL_NET_PLUGIN, puis enregistre deux plugins intégrés"libnccl-net.so"etncclNetIbL'analyse de la variable d'environnement utilisencclNetSocket。📎 src/plugin/net.cc:288-340
pour découper par virgule, supportant plusieurs noms de plugins.strtok_rdispose d'une vérification de capacité : le nombre de plugins externes ne peut pas dépasser📎 src/plugin/net.cc:303-324, l'excédent est ignoré et journalisé.NCCL_NET_MAX_PLUGINS - NCCL_NET_NUM_INTERNAL_PLUGINSLes plugins intégrés sont fixés à 2 (IB et Socket), donc les plugins externes sont au maximum📎 src/plugin/net.cc:307-311au maximum.NCCL_NET_MAX_PLUGINS - 2Deuxième étape : parcours verrouillé.
protège tout le processus de parcours. std::lock_guard<std::mutex> lock(netPluginMutex)Pour chaque index de plugin, on vérifie d'abord s'il s'agit d'un plugin externe et s'il est dans l'état📎 src/plugin/net.cc:361, si oui on appelleLoadReadyTroisième étape : chargement du plugin.ncclNetPluginLoad。📎 src/plugin/net.cc:364-367
appelle ncclNetPluginLoadpour obtenir le handle, puis essaie de la version la plus élevée à la plus bassencclOpenNetPluginLibjusqu'àgetNcclNet_v12, la première version retournant un résultat non nul est adoptée.getNcclNet_v6Les tableaux de versions📎 src/plugin/net.cc:103-112et les tableaux de pointeurs de fonctionsncclNetVersionsont triés par ordre décroissant, garantissant l'utilisation prioritaire de l'API la plus récente.getNcclNetSi aucune version n'obtient📎 src/plugin/net.cc:41-43
, cela signifie que cette bibliothèque n'est pas un plugin réseau valide. On vérifie alors sincclNeta été explicitement défini : si oui, on utilise le niveauNCCL_NET_PLUGINpour l'avertissement (l'utilisateur l'a explicitement demandé mais cela a échoué) ; si non, on utiliseATTN 级别告警(用户明确要求却失败);若没设置,用 INFOniveau (il s'agit juste d'une tentative par défaut qui échoue).📎 src/plugin/net.cc:115-125Cette distinction est importante — un échec de configuration explicite de l'utilisateur doit être visible.
Quatrième étape : initialiser le plugin.Retour àncclNetInit, pour l'état>= InitReadyet dont le nom correspond àcomm->config.netName, appeler le pluginncclNetPluginInit。📎 src/plugin/net.cc:369-372 ncclNetPluginInitpour faire deux choses : appeler la fonctioninitdu plugin afin d'établir le contexte du domaine de communication, et lors de la première initialisation, appelerdevicespour détecter le nombre de devices.📎 src/plugin/net.cc:186-236
Attention aux conditions d'appel deinit:pluginLib->ncclNetPluginState >= ncclNetPluginStateInitReady。📎 src/plugin/net.cc:190Les commentaires indiquent explicitement que « chaque nouveau domaine de communication doit appeler init pour définir le contexte correct ».📎 src/plugin/net.cc:189Mais la détection des devices n'est effectuée qu'une seule fois lors de== InitReady.📎 src/plugin/net.cc:201Cette distinction « init appelé à chaque fois, devices appelé une seule fois » est une optimisation de performance — la détection des devices peut être très lente, mais le contexte doit être indépendant pour chaque domaine de communication.
Cinquième étape : allocation et désactivation.Après une initialisation réussie, appelerncclNetPluginAssignToComm, qui assigne lencclNetdu plugin àcomm->ncclNet, incrémente le compteur de références, définitcomm->netPluginIndex。📎 src/plugin/net.cc:238-255. Après une allocation réussie, appeler immédiatementncclNetPluginDisableOtherExternalpour désactiver tous les autres plugins externes.📎 src/plugin/net.cc:377-380
La logique de désactivation comporte un jugement clé : ce n'est que lorsque le plugin alloué est un plugin externe (pluginIndex >= pluginCount - NCCL_NET_NUM_INTERNAL_PLUGINS) que les autres plugins externes sont désactivés.📎 src/plugin/net.cc:257-259Si c'est le plugin IB intégré qui est alloué, les plugins externes restent inchangés — cela laisse des options pour les domaines de communication suivants.
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"]Contrôle de concurrence et interaction matérielle
netPluginMutexprotège toutes les lectures et écritures surnetPluginLibs.ncclNetInit、ncclNetFinalizeTous sont verrouillés.📎 src/plugin/net.cc:361📎 src/plugin/net.cc:411-416Mais les commentaires de fonctions commencclNetGetDevCountindiquent « pas besoin de verrou, car l'appelant est déjà dans le verrou dencclTopoGetSystem».📎 src/plugin/net.cc:418-429C'est une convention de type « verrou détenu par la couche supérieure », qui réduit le coût des verrous imbriqués, au prix que l'appelant doit respecter la convention.
ncclGpuGdrSupportillustre l'interaction directe entre le plugin et le matériel : il alloue un buffer GPU de 2 Mo, établit une connexion loopback via lelisten/connect/acceptdu plugin, puis tente d'enregistrer la mémoire GPU avecregMr.📎 src/plugin/net.cc:464-535Si l'enregistrement réussit, cela signifie que la carte réseau prend en charge GPUDirect RDMA. Ce résultat de détection est mis en cache dansgdrSupportMatrix[32], indexé par numéro de device CUDA.📎 src/plugin/net.cc:478-480
Noter quegdrSupportMatrixeststaticde📎 src/plugin/net.cc:478, partagé entre les domaines de communication. Cela signifie que plusieurs domaines de communication dans le même processus réutiliseront le résultat de détection, évitant des détections coûteuses répétées. Mais la taille du tableau est codée en dur à 32, les machines avec plus de 32 GPU provoqueront un dépassement — c'est une hypothèse de limite supérieure implicite.
Guide de production pour éviter les pièges
Piège 1 : le plugin se charge avec succès mais le nombre de devices est zéro. ncclNetPluginInitVérifierdevices(&ndev) != ncclSuccess || ndev <= 0, sinon sauter vers la branche d'échec.📎 src/plugin/net.cc:202En cas d'échec, appelerfinalizepour nettoyer le contexte déjà établi, réinitialiser le nombre de devices àNCCL_UNDEF_DEV_COUNT, définir l'état àDisabled。📎 src/plugin/net.cc:229-234. Si ce nettoyage n'est pas effectué, les domaines de communication suivants verront un plugin « initialisé mais sans device », provoquant des erreurs difficiles à diagnostiquer.
Piège 2 :initréussit maisdeviceséchoue.Le code utilise le flaginitCompletedpour suivre siinita réussi.📎 src/plugin/net.cc:178-184📎 src/plugin/net.cc:198Dans la branche d'échec, ce n'est que siinitCompletedest vrai quefinalize。📎 src/plugin/net.cc:230est appelé. Cela empêche d'appelerfinalizesur un contexte non initialisé — beaucoup de pluginsfinalizene vérifient pas les pointeurs nuls, un appel erroné provoquerait un crash.
Piège 3 : comptage de références lors de la destruction du domaine de communication. ncclNetPluginFinalizeAppeler d'abord lefinalizedu plugin, puis décrémenter le compteur de références, et enfin décharger la bibliothèque lorsque le compteur de références atteint zéro et qu'il s'agit d'un plugin externe.📎 src/plugin/net.cc:342-355 ncclNetPluginUnloadVérifier quedlHandleest non nul et que le compteur de références est zéro pour effectuer réellementdlclose。📎 src/plugin/net.cc:84-101. Après déchargement, réinitialiser les champs mais conservername, afin de pouvoir le réutiliser lors d'un rechargement.📎 src/plugin/net.cc:84-101
16.3 tuner.cc et profiler.cc : contrats différents entre plugins de stratégie et plugins d'observation
Modèle intuitif
Le plugin Tuner ressemble aux « préférences d'itinéraire d'un logiciel de navigation » — il ne change pas la façon dont la voiture roule, il change seulement quel chemin est choisi. Le plugin Profiler ressemble à un « enregistreur de conduite » — il n'intervient pas dans la conduite, il enregistre seulement ce qui s'est passé. Leur point commun est qu'ils s'intègrent tous deux via une table de fonctions ; la différence est que Tuner est un objet de stratégie léger « une instance par domaine de communication », tandis que Profiler nécessite un thread indépendant pour consommer de manière asynchrone les événements générés par le GPU.
tuner.cc : un singleton global minimaliste
L'état du Tuner est extrêmement simple : un mutex, un compteur de références, un handle de bibliothèque, un pointeur de symbole, une variable d'état.📎 src/plugin/tuner.cc:24-37Pas de tableau de plugins, pas de coexistence multi-plugins — il n'y a qu'un seul tuner global.
ncclTunerPluginLoadLa logique est « premier chargement, réutilisation ensuite » : si l'état estLoadSuccess, assigner directement le symbole àcomm->tuneret incrémenter le compteur de références.📎 src/plugin/tuner.cc:53-57Sinon, lire la variable d'environnementNCCL_TUNER_PLUGIN, si elle est"none", échouer directement.📎 src/plugin/tuner.cc:59-63
La négociation de version passe de v6 à v2, en essayant une par une.📎 src/plugin/tuner.cc:75-87Noter qu'il n'y a pas de v1 ici — l'API tuner n'a une structure de table de fonctions stable qu'à partir de v2.
Un détail intéressant : sincclOpenTunerPluginLibrenvoie vide, le code tentencclGetNetPluginLib(ncclPluginTypeTuner)。📎 src/plugin/tuner.cc:65-70. Cela signifie que le tuner peut être empaqueté dans la bibliothèque du plugin net — cela réduit la complexité de déploiement, un seul.sofournit à la fois les fonctions réseau et de réglage.
profiler.cc : thread de consommation d'événements asynchrone
Profiler est le plugin le plus complexe de ce chapitre, car il doit traiter les événements générés de manière asynchrone par le GPU. La structure centrale estncclProfilerThread:
| champ | type | rôle |
|---|---|---|
thread | std::thread | thread de consommation |
mutex | std::mutex | protège la file |
cond | condition_variable | réveille en cas de nouveau travail |
condIterationInactive | condition_variable | attend la fin de l'itération |
stop | int | flag d'arrêt |
refCount | int | compteur de références du domaine de communication |
cudaDev | int | device CUDA lié |
abortFlag | volatile uint32_t* | flag d'abandon |
iterationActive | bool | si en cours d'itération |
pending/pendingTail | liste chaînée | travail en attente |
active/activeTail | liste chaînée | travail en cours de traitement |
opStack/opPool | pool de mémoire | allocation d'objets de travail |
inflight/maxInflightSeen/maxInflight | size_t | observation de la contre-pression |
droppedOps | uint64_t | compteur d'échecs d'allocation |
📎 src/plugin/profiler.cc:38-69définit cette structure. Noter quependingetactivesont deux listes chaînées indépendantes : le producteur ajoute àpending, le thread de consommation, dans le verrou, concatènependingàactive, puis parcourtactive。📎 src/plugin/profiler.cc:56-59
iterationActiveen dehors du verrou. Le flagtrueest la clé de la correction concurrente : le thread de consommation le définit àfalsepour pouvoir démonter l'état du domaine de communication.📎 src/plugin/profiler.cc:52-55
Step-by-Step Walkthrough : génération et consommation d'un événement KernelCh
Première étape : mise en file côté hôte.Lorsqu'un kernel plan est soumis,ncclProfilerPostPlanWorkon parcourt les tâches d'ensemble du plan, et pour chaque tâche ayant activéncclProfileKernelCh, on appelle selon la plage de canauxprofilerPostWorkInternal。📎 src/plugin/profiler.cc:1315-1331
profilerPostWorkInternalon incrémente d'abordcomm->profiler.workCounter[channelId], puis on appelleprofilerEnqueueOp。📎 src/plugin/profiler.cc:1259-1266Le commentaire souligne que cette incrémentation doit être « exactement une fois par appel, même en cas d'échec d'allocation », afin de rester synchronisé avec le kernel du dispositif.📎 src/plugin/profiler.cc:1259-1266
Deuxième étape : allocation de l'objet de travail. profilerEnqueueOpSous verrou, on alloue depuis le pool mémoirencclProfilerWorkOp, on remplit les champs tels que numéro de canal, compteur de travail, masque d'activation, handle d'événement de tâche, contexte du domaine de communication, etc.📎 src/plugin/profiler.cc:1199-1223En cas d'échec d'allocation, on incrémentedroppedOpset on journalise, maison nerevient pas en arrière surworkCounter— c'est la clé pour rester synchronisé avec le dispositif.📎 src/plugin/profiler.cc:1202-1207
Après une allocation réussie, on ajoute l'objet à la fin de la liste chaînéepending, on incrémenteinflight, on met à jourmaxInflightSeen, et on réveille le thread consommateur.📎 src/plugin/profiler.cc:1225-1239
Troisième étape : attente du thread consommateur. ncclProfilerThreadFuncOn appelle en bouclewaitForAction。📎 src/plugin/profiler.cc:1074-1077 waitForActionon attend la variable de condition sous verrou, jusqu'à ce quependingouactivesoit non vide, ou qu'un signal d'arrêt/abandon soit reçu.📎 src/plugin/profiler.cc:1017-1031
Une fois réveillé, il appelleappendWorkToActiveQueuepour concaténerpendingà la fin deactive, définititerationActive = true, et retourneNCCL_PROFILER_THREAD_PROGRESS。📎 src/plugin/profiler.cc:1017-1031
Quatrième étape : traitement du travail. profilerProgressOpsEn dehorsdu verrouon parcourt la liste chaînéeactive.📎 src/plugin/profiler.cc:958-999Pour chaque objet de travail, on vérifie si le dispositif a déjà écrit l'horodatage de démarrage :wc <= op->workStarted[ch].data[slot].counter。📎 src/plugin/profiler.cc:972Noter qu'on utilise<=et non==, car le dispositif va cycler surMAX_PROFILER_EVENTS_PER_CHANNELslots, et si l'hôte est en retard, le dispositif a pu déjà écraser ce slot.📎 src/plugin/profiler.cc:969-971
Si la condition de démarrage est remplie, on appellencclProfilerStartKernelChEventpour notifier le plugin.📎 src/plugin/profiler.cc:973On vérifie ensuite la condition d'achèvement ; si elle est remplie, on déclenche d'abord l'événement de phase, puis on appellencclProfilerStopKernelChEvent。📎 src/plugin/profiler.cc:978-985
Les objets de travail terminés sont retirés de la liste chaînée et collectés dans la listerecycled.📎 src/plugin/profiler.cc:987-991
Cinquième étape : récupération et publication. cleanupAndStopSous verrou, on récupère la listerecycled, on publie le nouveauactiveTail, on effaceiterationActiveet on notifie les attendeurs.📎 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() 回收对象, 清除 iterationActiveContrôle de concurrence et contre-pression
NCCL_PROFILER_DEFAULT_MAX_INFLIGHTest défini commeMAXCHANNELS * MAX_PROFILER_EVENTS_PER_CHANNEL * 4。📎 src/plugin/profiler.cc:32-32Il s'agit d'un « plafond souple » — le dépasser n'empêche pas la mise en file, cela ne fait que journaliser.📎 src/plugin/profiler.cc:1233-1238Le commentaire explique que maintenir la mise en file sert à apparier les événements KernelCh avec leurs événements de tâche parente.📎 src/plugin/profiler.cc:32-32
La journalisation se déclenche sur les puissances de 2 :(pt->inflight & (pt->inflight - 1)) == 0。📎 src/plugin/profiler.cc:1233Cela garantit que la journalisation n'a lieu que lorsque inflight vaut 1, 2, 4, 8..., évitant ainsi le spam.
La stratégie de repli du thread consommateur se trouve dansupdateProgressInterval: en cas de progrès, on réessaie immédiatement ; sans progrès, on double à partir de 1 microseconde, avec un plafond de 10 microsecondes.📎 src/plugin/profiler.cc:1054-1057Cette conception équilibre latence et occupation CPU.
Guide pour éviter les pièges en production
Piège un : fuite de travail lors de la destruction. ncclProfilerThreadDestroyOn attend d'abord queiterationActivedevienne faux, puis on appelleprofilerPurgeByContextpour effacer tout travail en attente référençant ce contexte de domaine de communication.📎 src/plugin/profiler.cc:1162-1169Sans cet effacement, le callback du plugin recevrait un pointeur vers un contexte détruit, provquant un use-after-free.
Piège deux : vidage lors de l'arrêt.Lorsqu'un signal d'arrêt est reçu mais queactiveest non vide, on retourneNCCL_PROFILER_THREAD_CLEANUP_AND_STOP,cleanupAndStopavec le paramètredrainStuckà vrai, récupérant directement tout le travail restant.📎 src/plugin/profiler.cc:1029📎 src/plugin/profiler.cc:1036-1050Le commentaire indique que le kernel de ces travaux ne s'exécutera jamais, donc on les jette directement.📎 src/plugin/profiler.cc:1034-1035
Piège trois : liaison au dispositif CUDA.Au démarrage du thread consommateur, on appellecudaSetDevice(pt->cudaDev)。📎 src/plugin/profiler.cc:1054-1057Le commentaire explique : le thread lui-même ne lit que la mémoire fixée de l'hôte, mais le plugin peut effectuer des appels de pilote dépendant du contexte, d'où cette liaison défensive.📎 src/plugin/profiler.cc:1054-1057Un échec de liaison ne fait que journaliser sans interrompre, car le thread lui-même ne dépend pas de CUDA.📎 src/plugin/profiler.cc:1065-1070
16.4 Exemples officiels : points clés de l'implémentation de google-fastsocket et google-CoMMA
Modèle intuitif
Les exemples officiels sont des « implémentations de référence » de l'API plugin.google-fastsocketIls montrent comment remplacer le TCP du noyau par une pile réseau en espace utilisateur ;google-CoMMAils montrent comment implémenter un plugin profiler pour collecter les performances de communication. Leur existence prouve que l'API plugin est suffisamment expressive pour répondre à des besoins réels.
google-fastsocket : remplacer le backend réseau
FastSocket est une pile réseau en espace utilisateur open source de Google, qui contourne la pile TCP/IP du noyau via la famille d'adressesAF_FABRIC. En tant que plugin net de NCCL, il doit implémenter toutes les fonctions dencclNet_t:init、devices、getProperties、listen、connect、accept、regMr、isend、irecv、test、closeSendetc.
Le point d'implémentation clé réside dans legetPropertiesretourné parptrSupport: si FastSocket prend en charge GPUDirect RDMA, il faut le définir àNCCL_PTR_HOST|NCCL_PTR_CUDA; sinon on ne peut le définir qu'àNCCL_PTR_HOST, et NCCL copiera les données GPU vers la mémoire hôte avant l'envoi.📎 plugins/net/README.md:245-245
connectLe contrat « non bloquant » deacceptetsendComm/recvCommest le principal point difficile de l'implémentation du plugin : ils doivent retourner immédiatement, en mettantNULLà📎 plugins/net/README.md:299-311, laissant NCCL appeler en boucle jusqu'au succès.
Cela exige que le plugin maintienne en interne une machine à états de connexion, plaçant la poignée de main coûteuse en arrière-plan.
〔Inférence de conception et compromis architecturaux〕ncclProfiler_tCoMMA (Collective Memory Monitoring Agent) est le collecteur de performances de communication de Google. En tant que plugin profiler, il implémente la table de fonctionsinit、finalize、startEvent、stopEvent、recordEventState。
init:ncclProfilerEventMaskreçoit un pointeur📎 src/plugin/profiler.cc:341, et le plugin sélectionne les événements auxquels s'abonner en écrivant dans ce masque.📎 src/plugin/profiler.cc:285-307
startEventLes types d'événements pris en charge par NCCL incluent Group, Coll, P2p, ProxyOp, ProxyStep, ProxyCtrl, KernelCh, KernelPhase, NetPlugin, etc.stopEventretourne un handle d'événement, et lesrecordEventStateet📎 src/plugin/profiler.cc:392📎 src/plugin/profiler.cc:400-407ultérieurs utilisent ce handle pour associer les événements.
Le plugin peut utiliser le handle pour stocker son propre état, réalisant l'appariement d'événements et les statistiques de durée.
Réflexions de conceptionParce que l'API net implique du code côté périphérique (ncclNetDeviceHandle), une incompatibilité de version provoque un plantage du noyau ; tandis que tuner/profiler est purement côté hôte, une incompatibilité de version entraîne au pire une fonctionnalité manquante.📎 src/plugin/net.cc:153-176montre commentncclNetCheckDeviceVersionvérifier le type et la version du périphérique, et renvoyerncclInternalError。
Pourquoi le profiler a-t-il besoin d'un thread indépendant ?Parce que le callback du profiler peut bloquer (par exemple écrire dans un fichier, envoyer une requête réseau) ; s'il est appelé dans le thread hôte, cela ralentit la communication.📎 src/plugin/profiler.cc:950-952Le commentaire indique explicitement que « le callback du plugin peut bloquer, donc il ne doit pas être appelé en tenant le verrou ».
16.5 Guide de production pour éviter les pièges et chaîne de récupération après incident
Piège n°1 : une incompatibilité de version du plugin provoque un plantage du noyau
ncclNetCheckDeviceVersionVérifierprops.netDeviceTypeetprops.netDeviceVersion。📎 src/plugin/net.cc:153-176Si la versionNCCL_NET_DEVICE_UNPACKrapportée par le plugin est incohérente avec la versionNCCL_NET_DEVICE_UNPACK_VERSIONutilisée lors de la compilation de NCCL, renvoyerncclInternalErroret émettre un avertissement.📎 src/plugin/net.cc:153-176Cette vérification est appelée dansncclNetPluginAssignToComm; en cas d'échec, le plugin n'est pas affecté au domaine de communication.📎 src/plugin/net.cc:241
Chaîne de récupération: incompatibilité de version →ncclNetCheckDeviceVersionrenvoie une erreur →ncclNetPluginAssignToCommrenvoieisAssigned = false → ncclNetInitcontinue d'essayer le plugin suivant → peut finalement revenir au plugin Socket intégré.
Piège n°2 : le thread du profiler ne peut pas se terminer
Si le plugin profiler bloque dansstopEvent, le thread consommateur reste bloqué dansprofilerProgressOps,iterationActiveest toujours vrai,ncclProfilerThreadDestroyattend indéfiniment.📎 src/plugin/profiler.cc:1166Il s'agit d'un risque réel d'interblocage.
Chaîne de récupération:comm->abortFlagest défini →waitForActiondétecte l'abandon → renvoieCLEANUP_AND_STOP → cleanupAndStopvide la file d'attente.📎 src/plugin/profiler.cc:1017-1031Mais si le thread est déjà bloqué dans le callback du plugin, le drapeau d'abandon ne peut pas l'interrompre — c'est la responsabilité de l'implémenteur du plugin, le callback doit avoir un délai d'expiration.
Piège n°3 : fuite du compteur de références du plugin tuner
ncclTunerPluginLoadIncrémentetunerPluginRefCount。📎 src/plugin/tuner.cc:98 ncclTunerPluginUnloaden cas de succès, décrémente lorsquecomm->tunerPluginLoadedest vrai.📎 src/plugin/tuner.cc:111-123Si un domaine de communication charge le tuner mais quetunerPluginLoadedest remis à zéro par accident lors de la destruction, le compteur de références ne revient jamais à zéro et la bibliothèque du plugin n'est jamais déchargée.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on remplace dansncclNetPluginLoadla boucle « essayer de la version la plus élevée à la plus basse » par « n'essayer que la version la plus élevée », dans quel scénario un plugin auparavant utilisable ne pourrait-il plus être chargé ?
Analyse de référence: voir📎 src/plugin/net.cc:108-112. La boucle parcourtNCCL_NET_VERSION_COUNTversions, de v12 à v6, et la première qui renvoie une valeur non nulle est adoptée. Si l'on n'essaie que v12, un ancien plugin n'implémentant que v11 échouera au chargement.
Cette conception vise la rétrocompatibilité : après la mise à niveau du cœur de NCCL pour prendre en charge v12, il peut toujours charger un plugin ne fournissant que v11. Les auteurs de plugins sont encouragés à fournir des symboles pour plusieurs versions (voir📎 plugins/net/README.md:35-37), afin qu'un même.sopuisse servir plusieurs versions de NCCL.
Si l'on supprime la tentative de dégradation, après une mise à niveau de NCCL par l'utilisateur, l'ancien plugin deviendrait soudainement indisponible, avec un repli possible uniquement vers le plugin Socket intégré, entraînant une forte baisse de performance. C'est précisément la raison d'être de la négociation de version.
Q2 : DansprofilerProgressOps, si l'on remplacewc <= op->workStarted[ch].data[slot].counterparwc == op->workStarted[ch].data[slot].counter, dans quel scénario de forte concurrence l'événement ne se déclencherait-il jamais ?
Analyse de référence: voir📎 src/plugin/profiler.cc:969-972. Le commentaire indique explicitement que le périphérique boucle surMAX_PROFILER_EVENTS_PER_CHANNELemplacements. Si la vitesse de consommation de l'hôte est en retard sur la vitesse de production du périphérique, le périphérique a peut-être déjà écrasé l'emplacementwc + Navec le compteurwc % MAX_PROFILER_EVENTS_PER_CHANNEL。
; à ce moment-là, la valeur deop->workStarted[ch].data[slot].counterestwc + N, tandis queop->workCounterestwc. Un test avec==échouerait, l'événement ne se déclencherait jamais, l'objet de travail resterait indéfiniment dans la liste chaînéeactive,inflightne ferait qu'augmenter sans jamais diminuer, finissant par épuiser le pool de mémoire.
Avec<=, ce cas est correctement géré : tant que le compteur écrit par le périphérique n'est pas inférieur à la valeur attendue, l'événement est considéré comme prêt. C'est une condition de correction typique d'un « tampon circulaire producteur-consommateur ».
Q3 : Si l'on supprime dansncclProfilerThreadDestroyla boucle d'attente queiterationActivedevienne faux, dans quel ordre temporel le plugin profiler accéderait-il à un contexte de domaine de communication déjà libéré ?
Analyse de référence: voir📎 src/plugin/profiler.cc:1162-1166. Le commentaire indique quencclProfilerPluginFinalizedétruit immédiatement lencclProfilerThreadDestroydu domaine de communication après le retour deprofilerContext。
. Lorsque le thread consommateur appelle le callback du plugin dansprofilerProgressOps, il transmetop->profilerContext。📎 src/plugin/profiler.cc:938Si le thread de destruction ne attend pas queiterationActivedevienne faux avant de revenir,ncclProfilerPluginFinalizelibérerait le contexte, alors que le thread consommateur pourrait être en train d'utiliser ce contexte pour appeler le plugin — use-after-free.
iterationActiveLe protocole de synchronisation detrueest le suivant : le thread consommateur, après avoir définifalse。📎 src/plugin/profiler.cc:1028📎 src/plugin/profiler.cc:1054-1057sous verrou, libère le verrou pour appeler le plugin ; le thread de destruction attend sous verrou qu'il redevienne
Ce protocole garantit que le contexte reste valide pendant toute la durée du callback du plugin.
Après suppression de l'attente, le thread de destruction peut revenir au moment où le thread consommateur vient d'entrer dans le callback du plugin, ce qui fait que le plugin obtient un pointeur suspendu. C'est une condition de course typique entre « cycle de vie et accès concurrent ».
Le système de plugins fait passer NCCL d'un modèle fermé à un modèle ouvert : backend réseau, stratégie de réglage, collecteur de performance et source de configuration peuvent tous être remplacés sans modifier le code du cœur. Mais les plugins introduisent aussi de nouvelles surfaces de défaillance — incompatibilité de version, conditions de course de cycle de vie, fuite de compteur de références. Le chapitre suivant nous fera entrer dans le sous-système RAS et diagnostic, pour voir comment NCCL détecte les pannes, surveille la progression et réalise l'auto-réparation dans les tâches d'entraînement de longue durée.
Chapitre 17 : Chapitre 17 : Mécanismes RAS et tolérance aux pannes : détection des défaillances de liaison, battement de cœur et dégradation gracieuse
Chapitre 17 : Mécanismes RAS et tolérance aux pannes : détection des défaillances de liaison, battement de cœur et dégradation gracieuse
Dans le chapitre précédent, nous avons vu comment le système de plugins permet de tracer une frontière entre le chemin de communication principal et les composants remplaçables, afin de pouvoir substituer le backend réseau, les stratégies d'optimisation et les collecteurs de performance sans modifier le code principal. Mais l'extensibilité n'est qu'une dimension de la disponibilité en production ; une autre question tout aussi cruciale se pose : lorsqu'un AllReduce tourne depuis 72 heures et que la carte réseau d'une machine tombe silencieusement en panne, comment NCCL peut-il le détecter, l'isoler et continuer ? Le sous-système RAS est précisément la ligne de démarcation qui fait passer NCCL de « fonctionnel » à « prêt pour la production ». Ce chapitre décompose la conception sous-jacente de la détection des pannes, de la surveillance de progression et des mécanismes d'auto-réparation.
17.1 Contrôleur RAS : un coordinateur global avec un thread RAS par processus
Modèle intuitif
Imaginez RAS comme la « salle de permanence » de tout le job. Chaque processus NCCL (chaque rank) ouvre une salle de permanence lors de l'initialisation, avec un thread dédié à l'intérieur. La création, la destruction et les demandes de diagnostic de tous les communicateurs doivent d'abord être enregistrées auprès de la salle de permanence ; les salles de permanence communiquent ensuite entre elles via un réseau RAS indépendant pour s'informer mutuellement de « qui est encore en vie, qui est déjà mort ».
Sans cette salle de permanence, NCCL ne pourrait détecter les pannes que via les timeouts du chemin de communication lui-même — or les timeouts sur le chemin de communication sont à la fois lents et sujets aux faux positifs (une simple fluctuation réseau peut être interprétée comme la mort d'un nœud). RAS sépare la « détection des pannes » du plan de données vers le plan de contrôle, en utilisant un canal de battement de cœur léger et un canal de diagnostic indépendants pour déterminer l'état de santé.
Structures de données et disposition mémoire
L'état central de RAS est dispersé dans les variables globales deras.cc; nous allons les décomposer une par une :
| Variable | Type | Rôle |
|---|---|---|
rasInitMutex | std::mutex | Protège l'initialisation du singleton RAS |
rasInitialized | bool | Indique si l'initialisation a eu lieu |
rasInitRefCount | int | Compteur de références, égal au nombre de comm actifs |
rasNetListeningSocket | struct ncclSocket | Socket d'écoute du réseau RAS |
rasNotificationPipe[2] | ncclSocketPairDescriptor | Pipe de notification du thread local → thread RAS |
rasPfds | struct pollfd* | Tableau poll de la boucle d'événements principale |
ncclComms | struct ncclComm** | Tableau de pointeurs vers tous les communicateurs |
📎 src/ras/ras.cc:49-61définit ces états globaux. Notez querasInitRefCountutilisencclAtomicRefCountIncrementpour incrémenter/décrémenter📎 src/ras/ras.cc:129, tandis querasInitializedutilise un booléen simple avec un double-checked locking pour protéger📎 src/ras/ras.cc:103-105— c'est le schéma typique « initialisé une fois, en lecture seule ensuite ».
ncclCommsLa stratégie d'allocation du tableauRAS_INCREMENT * 8mérite attention : il ne croît pas à la demande, mais est étendu à chaque fois de📎 src/ras/ras.cc:139-140(soit 32 emplacements)nullptr. Le tableau autorise des trous📎 src/ras/ras.cc:135-137。
(mis à zéro lors de la destruction d'un comm), et un nouveau comm réutilise le premier trou
Parcours guidé par scénario : de l'initialisation du comm au démarrage du thread RASncclRasCommInitPremière étape :est appelé.📎 src/ras/ras.cc:101C'est la première fonction RAS appelée lors de l'initialisation de chaque commrasInitialized. Elle vérifie d'abord
; si non initialisé, elle entre en section critique :rasNetListeningSocket1. Initialiser📎 src/ras/ras.cc:108-109
avec l'adresse de l'interface réseau bootstrap, le port étant mis à 0 pour laisser le noyau l'attribuer aléatoirement📎 src/ras/ras.cc:113
2. Écouter sur ce socket📎 src/ras/ras.cc:118
3. Créer le pipe de notification local📎 src/ras/ras.cc:120
4. Initialiser le sous-système de diagnosticrasThreadMain5. Démarrer le thread📎 src/ras/ras.cc:121
6. Enregistreratexit(rasTerminate)pour garantir le nettoyage à la sortie du processus📎 src/ras/ras.cc:126
Deuxième étape : enregistrer le comm.Que ce soit la première initialisation ou non, le pointeurcommest écrit dans le tableauncclComms, et📎 src/ras/ras.cc:142est mis à falsencclCommsSorted— car l'ordre du tableau a changé, le tri précédent n'est plus valide.📎 src/ras/ras.cc:143Troisième étape : remplir le port.
La fonction copie enfin(incluant le port attribué par le noyau) versrasNetListeningSocket.addr, afin que l'appelant puisse savoir sur quel port le réseau RAS écoute.myRank->addr 📎 src/ras/ras.cc:146Boucle d'événements principale : multiplexage piloté par poll
est le cœur du thread RAS
rasThreadMain. Elle enregistre d'abord trois fd fixes : le pipe de notification, le socket d'écoute du réseau RAS, le socket d'écoute client📎 src/ras/ras.cc:633. Puis elle entre dans une boucle infinie :📎 src/ras/ras.cc:641-652Copier
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-728est limité de manière stricte à 1000 ms maximumtimeoutMs— même si📎 src/ras/ras.cc:664est très éloigné, il faut se réveiller une fois par seconde pour garantir la ponctualité de la vérification des timeouts.nextWakeupLa logique de distribution des événements utilise la valeur du fd comme routage
: s'il s'agit du pipe de notification, appeler📎 src/ras/ras.cc:684-715; s'il s'agit d'un socket d'écoute, accepter ; sinon, parcourir les listes chaînéesrasLocalHandleetrasSocketsHeadpour trouver le socket correspondant à traiter.rasClientsHeadMécanisme de notification locale : pipe + structure à taille fixe
Le thread NCCL local et le thread RAS communiquent via un socketpair. La structure de notification
est de taille fixerasNotification, et📎 src/ras/ras.cc:35-46garantit de ne pas dépasserstatic_assert— ceci afin d'assurer l'atomicité de l'écriture (POSIX garantit que les écritures inférieures à PIPE_BUF sont atomiques).PIPE_BUF 📎 src/ras/ras.cc:47L'émetteur
utiliserasLocalNotifypour sérialiser les écritures de plusieurs threads utilisateurrasNotificationMutex, puis écrit en boucle jusqu'à ce que tout soit écrit📎 src/ras/ras.cc:224-237. Le récepteur📎 src/ras/ras.cc:224-237lit également en boucle toute la structurerasLocalHandle, et retourne📎 src/ras/ras.cc:247-256en cas de lecture d'EOFncclSystemError 📎 src/ras/ras.cc:251-253。
Trois types de notification :RAS_ADD_RANKS(nouveau rank rejoint),RAS_RUN_DIAG(exécuter un diagnostic),RAS_TERMINATE(terminaison)📎 src/ras/ras.cc:28-32。
Envoi/réception de messages : préfixe de longueur + progression incrémentale
Le format de ligne des messages RAS est « 4 octets de longueur + corps du message »📎 src/ras/ras_internal.h:110-117. À l'envoi,rasConnSendMsgenvoie d'abord la longueur puis le corps du message📎 src/ras/ras.cc:362-390, en utilisantmeta->offsetpour enregistrer la progression, ce qui permet de reprendre l'envoi partiel lors de la prochaine itération. À la réception,rasMsgRecvreçoit d'abord la longueur, alloue un tampon selon la longueur, puis reçoit le corps du message📎 src/ras/ras.cc:393-412。
Il y a un détail ici :rasMsgAllocalloue une structurerasMsgMeta, le champmsgse trouve à la fin de la structure, et l'offset est calculé viaoffsetof. Lors de la libération, on calcule en sens inverse📎 src/ras/ras.cc:313-319。释放时反向计算 📎 src/ras/ras.cc:323-328. Cette disposition « métadonnées en amont » permet aux messages de porter des informations locales telles que la progression d'envoi et l'heure de mise en file, sans occuper le format de ligne.
Réflexions de conception
Pourquoi utiliser poll plutôt qu'epoll ?La complexité O(n) de poll est acceptable dans le scénario RAS — le nombre de connexions RAS est bien inférieur à celui des connexions du plan de données, et le thread RAS lui-même n'est pas sur le chemin critique des performances. La portabilité multiplateforme de poll est également meilleure (compatibilité Windows).
Pourquoi utiliser un pipe plutôt qu'une variable de condition pour la notification ?Le pipe peut s'intégrer de manière transparente dans la boucle poll, permettant au thread RAS d'utiliser unpollen attente de toutes les sources d'événements. Avec une variable de condition, il faudrait un mécanisme supplémentaire pour réveiller poll.
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 --> poll17.2 Surveillance de la progression : utiliser DMA pour transférer les compteurs GPU vers l'hôte
Modèle intuitif
La surveillance de la progression ressemble au « compte-tours » sur le tableau de bord d'une voiture. Il ne participe pas à la conduite (ne participe pas à la communication), mais copie en continu les compteurs de progression internes du GPU vers la mémoire de l'hôte, permettant à celui-ci de déterminer « si ce domaine de communication est bloqué ». Sans lui, lorsqu'un AllReduce se bloque, vous ne voyez que « le programme ne retourne pas », sans savoir si le GPU calcule, attend le réseau, ou est complètement en interblocage.
Structures de données et disposition mémoire
Chaque périphérique CUDA correspond à unncclGpuProgressCounterMonitorthread de travail📎 src/ras/progress_monitor.cc:35-52:
| champ | type | rôle |
|---|---|---|
cudaDev | int | numéro de périphérique CUDA associé |
thread | std::thread | thread de travail |
mutex / cv | std::mutex / condition_variable | protéger l'état mutable et le réveil |
running / shouldStop | bool | indicateur de cycle de vie du thread |
copyInFlight | bool | s'il y a une copie DMA en cours |
copyStallWarned | bool | si une alerte a déjà été émise pour ce blocage |
copyStartNs | uint64_t | heure de début de cette copie |
sideStream | cudaStream_t | flux non bloquant dédié |
copyDone | cudaEvent_t | événement de fin de copie |
warningMutex | std::mutex | protéger l'horodatage d'alerte |
lastStaleWarnNs / lastErrorWarnNs | uint64_t | horodatage de limitation de débit |
destroyRefs | int | compteur de références de destruction |
registrations | file intrusive | liste des comm enregistrés sur ce périphérique |
📎 src/ras/progress_monitor.cc:59-62précise l'ordre des verrous :gpuProgressCounterMonitorsMuavantncclGpuProgressCounterMonitor::mutex. C'est une convention clé pour éviter les interblocages.
tableau globalgpuProgressCounterMonitors[kRasMaxCudaDevices]indexé par numéro de périphérique📎 src/ras/progress_monitor.cc:59-62。
Parcours guidé par scénario : une copie de compteur
Première étape : enregistrement. ncclProgressCounterMonitorInitest appelé📎 src/ras/progress_monitor.cc:319. SideviceCountersBlockest vide, retour immédiat (ce comm ne participe pas à la surveillance)📎 src/ras/progress_monitor.cc:323. Sinon, sous le verrou global, rechercher ou créer le worker de ce périphérique📎 src/ras/progress_monitor.cc:328-335, puis mettre le comm en file dansregistrations 📎 src/ras/progress_monitor.cc:339。
Deuxième étape : démarrage du thread de travail. createGpuProgressCounterMonitorcrée le worker, définitcudaSetDevice, créesideStream(cudaStreamNonBlocking) etcopyDoneévénement📎 src/ras/progress_monitor.cc:280-282, démarre le thread puis attend au maximum 2000 ms pour confirmer querunningpasse à true📎 src/ras/progress_monitor.cc:287-303。
Troisième étape : boucle de copie. progressCounterMonitorLooplie d'abord le périphérique, définit le mode de capture de flux relaxed (pour ne pas perturber la capture de graphe de l'application)📎 src/ras/progress_monitor.cc:97-121, puis entre dans la boucle principale :
1. AttendrepollIntervalMs(1000 ms par défaut)📎 src/ras/progress_monitor.cc:132-136
2. Si la copie précédente est encore en cours, utilisercudaEventQuerypour vérifier📎 src/ras/progress_monitor.cc:140. SicudaErrorNotReadyet dépasse le seuil stale (5000 ms par défaut), émettre une alerte de limitation de débit📎 src/ras/progress_monitor.cc:141-154
3. Parcourir tous les comm enregistrés, et pour chacun appelercudaMemcpyAsyncpour copierdeviceCountersBlockvershostCountersBlock 📎 src/ras/progress_monitor.cc:170-185
4. Si une copie a réussi, enregistrercopyDonel'événement et définircopyInFlight 📎 src/ras/progress_monitor.cc:194-202
Contrôle de concurrence et limitation de débit
La limitation de débit des alertes est implémentée parprogressCounterMonitorShouldWarn📎 src/ras/progress_monitor.cc:78-87: sous la protection dewarningMutex, vérifier si le temps écoulé depuis la dernière alerte dépassewarnIntervalNs, et seulement alors mettre à jour et retourner true. Par défautstaleWarnSecest de 600 secondes📎 src/ras/progress_monitor.cc:27, soit au maximum une alerte du même type toutes les 10 minutes.
Les paramètres ont des bornes inférieures : intervalle poll minimum 50 ms📎 src/ras/progress_monitor.cc:29, seuil stale minimum 1000 ms📎 src/ras/progress_monitor.cc:30. Cela évite qu'une configuration trop agressive de l'utilisateur ne fasse tourner le CPU à vide.
Destruction : comptage de références + synchronisation de flux
ncclProgressCounterMonitorDestroyLa logique de destruction de📎 src/ras/progress_monitor.cc:352-354:
est l'une des conceptions concurrentes les plus raffinées de ce chapitreregistrations1. Sous le verrou global + le verrou du worker, supprimer le comm de📎 src/ras/progress_monitor.cc:368
2. Si la suppression réussit,destroyRefs++et définirhaveDestroyRef 📎 src/ras/progress_monitor.cc:371-372
3. Si la liste d'enregistrement devient vide, retirer du tableau global et définirshouldStop 📎 src/ras/progress_monitor.cc:373-376
4. Après libération du verrou,cudaStreamSynchronize(g->sideStream)vider les copies qui pourraient encore référencer le tampon de ce comm📎 src/ras/progress_monitor.cc:393
5. EnfinreleaseGpuProgressCounterMonitorDestroyRefdécrémente le compteur de références ; lorsque celui-ci atteint zéro et que la file est vide, join le thread et supprime📎 src/ras/progress_monitor.cc:219-246
Pourquoi avons-nous besoin dedestroyRefs?? Parce quecudaStreamSynchronizes'exécute hors verrou, et pendant ce temps un autre thread pourrait également détruire le même worker. Le comptage de références garantit que seul le dernier destructeur effectue réellement le join et le delete.
sequenceDiagram
participant App as 应用线程
participant Mon as 监控线程
participant GPU as CUDA 设备
App->>Mon: ncclProgressCounterMonitorInit(comm)
Mon->>Mon: 查找/创建 worker
Mon->>Mon: registrations 入队 comm
loop 每 pollIntervalMs
Mon->>GPU: cudaEventQuery(copyDone)
GPU-->>Mon: cudaErrorNotReady / cudaSuccess
Mon->>GPU: cudaMemcpyAsync(hostCounters, deviceCounters, D2H, sideStream)
Mon->>GPU: cudaEventRecord(copyDone, sideStream)
end
App->>Mon: ncclProgressCounterMonitorDestroy(comm)
Mon->>Mon: registrations 删除 comm, destroyRefs++
Mon->>GPU: cudaStreamSynchronize(sideStream)
GPU-->>Mon: 拷贝排空完成
Mon->>Mon: releaseGpuProgressCounterMonitorDestroyRef
Mon->>Mon: join 线程, delete workerPièges en production
Piège 1 :cudaSetDeviceun échec entraîne une défaillance silencieuse de la surveillance.Si au démarrage du threadcudaSetDeviceéchoue, le worker définitshouldStopet quitte📎 src/ras/progress_monitor.cc:97-107, mais le comm qui l'a enregistré croit toujours que la surveillance fonctionne. Le miroir des compteurs restera alors obsolète jusqu'à ce que l'échec soit révélé à l'étape Init. Pour diagnostiquer, vérifiez dans les logs deNCCL_RASla présence de « progress-counter mirrors will remain stale ».
Piège 2 : conflit de capture de graphe.Si le thread de surveillance appelle l'API CUDA alors que l'application effectue une capture de flux, cela pollue le graphe capturé. Le code utilisecudaThreadExchangeStreamCaptureMode(cudaStreamCaptureModeRelaxed)pour contourner📎 src/ras/progress_monitor.cc:110-111, c'est une protection indispensable.
17.3 Cadre de diagnostic : distribution des vérifications pilotée par table
Modèle intuitif
Le cadre de diagnostic ressemble à un « forfait de bilan de santé » à l'hôpital. Chaque élément de vérification (modèle de GPU, état ECC, santé NVLink, erreurs XID, etc.) est un « service d'examen » indépendant, et le cadre se charge de collecter les résultats de vérification de chaque rank et de les synthétiser en un rapport. Sans lui, l'exploitation ne pourrait que recourir ànvidia-smipour diagnostiquer manuellement machine par machine, ce qui est totalement irréaliste sur un cluster de mille GPU.
Structure de données : table de distribution des vérifications
Le cœur est une table de distribution statiquerasDiagnosticsChecks 📎 src/ras/diagnostics.cc:63-77, chaque entrée associe un ID de vérification et deux callbacks :collectLocal(collecte locale) etsummarize(agrégation). 11 vérifications au total : modèle GPU, version du pilote CUDA, ECC, NVLink, environnement NCCL, topologie RDMA, mode IOMMU, ATS, XID/SXID, version du pilote NVIDIA, chemin.
rasDiagnosticsGetCheckeffectue une triple validation : plage d'ID, correspondance d'ID d'entrée, callback non nul📎 src/ras/diagnostics.cc:104-128. C'est de la programmation défensive — pour éviter qu'une entrée mal modifiée ne provoque un appel de pointeur nul.
Walkthrough guidé par scénario : le cycle de vie complet d'un diagnostic
Première étape : construire le payload local. rasDiagnosticsCollectLocalPeerPayloadécrire d'abord l'en-tête peer📎 src/ras/diagnostics.cc:226-227, puis parcourir la table de distribution, et pour chaque entrée appelerrasDiagnosticsAppendCheckPayload 📎 src/ras/diagnostics.cc:229-231。
rasDiagnosticsAppendCheckPayloadappelercollectLocalpour obtenirrasDiagnosticsLocalData, utiliserncclUniquePtrpour prendre possession des records📎 src/ras/diagnostics.cc:191-192, valider les métadonnées📎 src/ras/diagnostics.cc:193, si le nombre d'enregistrements est 0 alors passer📎 src/ras/diagnostics.cc:194, sinon écrire l'en-tête de vérification + les données d'enregistrement📎 src/ras/diagnostics.cc:196-201。
Deuxième étape : lancer la communication collective. rasDiagnosticsStartconstruireRAS_COLL_DIAGla requête📎 src/ras/diagnostics.cc:532-537, émettre viarasNetSendCollReq📎 src/ras/diagnostics.cc:539, l'état du client passe àRAS_CLIENT_DIAG_FINI 📎 src/ras/diagnostics.cc:541。
Troisième étape : fusionner les réponses. rasCollDiagMergeajouter le payload de chaque peer au buffer de collecte📎 src/ras/diagnostics.cc:310-337. Noter qu'il effectue de nombreuses vérifications de dépassement : limite du nombre de peers📎 src/ras/diagnostics.cc:320-324, limite de taille totale📎 src/ras/diagnostics.cc:325-328。
Quatrième étape : agrégation. rasDiagnosticsSummarizePeerPayloadsest un double balayage📎 src/ras/diagnostics.cc:399:
- Premier balayage : valider chaque en-tête peer et en-tête de vérification, cumuler le nombre d'enregistrements et d'octets par type de vérification📎
src/ras/diagnostics.cc:418-470 - allouer le buffer de fusion pour chaque type de vérification📎
src/ras/diagnostics.cc:472-476 - Deuxième balayage : copier les enregistrements de chaque peer dans le buffer correspondant📎
src/ras/diagnostics.cc:479-497 - enfin appeler pour chaque type de vérification
summarize📎src/ras/diagnostics.cc:499-506
État du client et annulation
L'état du diagnostic réside dansrasDiagnosticsClientState📎 src/ras/diagnostics.cc:242-245, attaché àrasClient->diagnostics.rasDiagnosticsCancelTargetremplace le reporter par noop lors de la fermeture du socket client📎 src/ras/diagnostics.cc:286-293, pour éviter d'écrire vers un socket fermé après la fin d'un diagnostic asynchrone📎 src/ras/diagnostics.cc:48-52。
Réflexions de conception
Pourquoi utiliser un double balayage ?Parce que le payload est de longueur variable, seul le premier balayage permet de calculer la taille de buffer nécessaire pour chaque type de vérification. Un seul balayage nécessiterait soit une croissance dynamique (plusieurs realloc), soit une préallocation excessive. Le double balayage échange une allocation précise contre la déterminisme.
Pourquoi l'en-tête de vérification contientrecordStride? 📎 src/ras/diagnostics.cc:197Parce que les structures d'enregistrement des différentes vérifications ont des tailles différentes, et lors de l'agrégation il faut connaître le pas pour copier et valider correctement.rasDiagnosticsAccountCheckRecordsforce la cohérence du stride pour une même vérification📎 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 Gestion des pairs : tableau trié + synchronisation par hachage
Modèle intuitif
peers.ccmaintient la « liste de toute la classe ». Chaque thread RAS conserve une copie identique de la liste, enregistrant l'adresse, le PID et les GPU gérés de chaque processus NCCL. Quand un nouveau membre rejoint ou qu'un membre « disparaît », le changement est diffusé via le réseau RAS. La liste utilise une valeur de hachage comme numéro de version, pour éviter une synchronisation complète à chaque fois.
Structures de données et disposition mémoire
Deux tableaux principaux :
rasPeers: tous les peers connus, triés par adresse📎src/ras/peers.cc:18-19. Inclut les peers morts.rasDeadPeers: adresses des peers morts, stockées séparément📎src/ras/peers.cc:37-38。
Pourquoi stocker les peers morts séparément ? 📎 src/ras/peers.cc:25-28Le commentaire derasPeersl'explique clairement :rasDeadPeersest essentiellement statique et très grand à grande échelle, tandis querasPeersest dynamique et beaucoup plus petit. Les stocker séparément évite de transmettre l'énorme tableau
rasPeerInfoà chaque synchronisation.📎 src/ras/ras_internal.h:110-117:
| structure | champ | type |
|---|---|---|
addr | ncclSocketAddress | description |
pid | ncclPid_t | adresse réseau (clé de tri) |
cudaDevs | uint64_t | ID de processus |
nvmlDevs | uint64_t | masque de bits des devices CUDA (affecté par CUDA_VISIBLE_DEVICES) |
hostHash / pidHash | uint64_t | masque de bits des devices NVML (non affecté) |
extrait de comm, en soustrayant commHash pour le rendre indépendant du domaine de communicationrasPeersHashdeux hachagesrasDeadPeersHashet📎 src/ras/peers.cc:21📎 src/ras/peers.cc:37-38。
sont le cœur de la synchronisation
Walkthrough guidé par scénario : un nouveau rank rejoint rasRanksConvertToPeersPremière étape : conversion.rasRankInitconvertit le tableaurasPeerInfo 📎 src/ras/peers.cc:104en📎 src/ras/peers.cc:114. D'abord trier par adresse + cudaDev📎 src/ras/peers.cc:127-130, ignorer les adresses vides📎 src/ras/peers.cc:134-139。
, fusionner les processus multi-GPU de même adresse (OR des masques de bits) rasPeersUpdateDeuxième étape : mise à jour du tableau local.📎 src/ras/peers.cc:197est l'algorithme de fusion le plus complexe de ce chapitre📎 src/ras/peers.cc:202-229. Il calcule d'abord la taille du nouveau tableau📎 src/ras/peers.cc:244-361, puis fusionne les deux tableaux triésrankPeers. Point clé : durant la fusion, transformer📎 src/ras/peers.cc:301-308en « différence » — ne conserver que les bits GPU réellement nouveaux📎 src/ras/peers.cc:393-402, puis supprimer les entrées sans contribution
. Ainsi le volume de données diffusées est minimal. rasNetUpdatePeersTroisième étape : propagation.rasNextLinkpropager dans les deux directionsrasPrevLinket📎 src/ras/peers.cc:430-450, puis reconstruire les connexions📎 src/ras/peers.cc:443-444。
Quatrième étape : envoyer la mise à jour. rasConnSendPeersUpdatevérifier d'abord le hachage📎 src/ras/peers.cc:500-508: si le pair connaît déjà le hachage actuel alors passer. Le message contientpeersHashetdeadPeersHash 📎 src/ras/peers.cc:521-524, et si après fusion le hachage ne correspond toujours pas, le destinataire renvoie📎 src/ras/peers.cc:608-653。
Déclaration et propagation des peers morts
rasPeerDeclareDeadajoute l'adresse àrasDeadPeers, recalcule le hachage après tri📎 src/ras/peers.cc:793-812。rasMsgHandleBCDeadPeertraite les messages de peers morts diffusés📎 src/ras/ras.cc:578-591: si inconnu localement alors déconnecter et déclarer mort, sinon marquer*pDone = truearrêter la rediffusion.
rasDeadPeersUpdatefusionne les anciennes et nouvelles listes de peers morts par tri fusion📎 src/ras/peers.cc:838-893. Noter qu'il utilisememmoveplutôt quememcpy 📎 src/ras/peers.cc:855, car la source et la destination peuvent se chevaucher.
Reconstruction des connexions : éviter la course aux connexions dupliquées
rasLinkReinitConnsreconstruit les connexions de liens après la mise à jour des peers📎 src/ras/peers.cc:680. Stratégie centrale : initier la connexion depuis le côté ayant la plus petite adresse📎 src/ras/peers.cc:706-711, pour éviter que les deux côtés initient simultanément et créent des doublons.
rasLinkCalculatePeercalcule l'index du prochain peer, en ignorant les peers morts📎 src/ras/peers.cc:743-785. Pour le fallback il y a une optimisation supplémentaire : ignorer les peers du même nœud que le fallback précédent📎 src/ras/peers.cc:743-785, pour éviter d'attendre un par un lors d'une panne de nœud entier.
Pièges en production
Piège 1 : le piège de l'endianness dans la comparaison d'adresses. ncclSocketsComparetrie par famille d'adresses → adresse → port📎 src/ras/peers.cc:960-990. Le commentaire indique qu'on ne peut pas simplementmemcmptoute la structure, car l'ordre de disposition mémoire diffère de l'ordre de tri attendu📎 src/ras/peers.cc:957-959. Les adresses IPv4 et les ports peuvent être comparés octet par octet en ordre réseau, mais pas le champ famille d'adresses.
Piège 2 :myPeerIdxinvalide.Lorsque le tableau grandit,myPeerIdxchange📎 src/ras/peers.cc:22-23。rasPeersUpdatele mettre à jour de manière synchrone pendant le processus de fusion📎 src/ras/peers.cc:312📎 src/ras/peers.cc:358, et en cas d'échec de mise à jour, revenir à la recherche binaire📎 src/ras/peers.cc:374-388。
Piège 3 : les collisions de hachage entraînent des omissions de synchronisation.Le hachage ne sert qu'à déterminer « faut-il synchroniser », pas à la correction . Même si une collision de hachage fait sauter la synchronisation, les échanges keep-alive ultérieurs transporteront quand même le hachage, et la convergence finira par se produire.
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 Réflexion de conception : la frontière entre RAS et le chemin de communication principal
La décision de conception la plus centrale du sous-système RAS estun découplage complet du plan de données. Le thread RAS ne participe à aucun transfert de données de communication collective ; il ne fait que trois choses : maintenir la liste des peers, détecter la santé des connexions, exécuter des diagnostics. Ce découplage apporte plusieurs avantages :
1. Isolation des pannes: un crash du thread RAS ne provoque pas directement l'échec de la communication (bien qu'il fasse perdre la capacité de perception des pannes)
2. Aucune perte de performance: le trafic de heartbeat et de synchronisation du RAS passe par un réseau indépendant, sans occuper la bande passante du plan de données
3. Observabilité: les diagnostics et la surveillance peuvent s'exécuter en parallèle pendant la communication
Le prix à payer estla cohérence d'état: l'état comm vu par le RAS peut être en retard par rapport au plan de données.ncclRasCommInitetncclRasCommFiniprotègentncclCommsMutexvia📎 src/ras/ras.cc:77-77, mais le thread RAS ne fait qu'un snapshot lors de la lecture, sans garantie de cohérence forte.
Une autre conception clé estla stratification des timeouts。ras_internal.hdéfinit tout un ensemble de constantes de timeout📎 src/ras/ras_internal.h:214-249: intervalle keep-alive 1 seconde, seuil d'avertissement 5 secondes, seuil d'erreur 20 secondes, seuil de mort d'un peer 60 secondes. Cette stratification permet au système d'adopter différentes actions selon différents niveaux de gravité — d'abord avertir, puis tenter une connexion de secours, et enfin seulement déclarer la mort.
17.6 Résumé de ce chapitre
Ce chapitre a décomposé les quatre modules centraux du sous-système NCCL RAS :
ras.cc: thread RAS singleton + boucle d'événements poll, recevant les notifications locales via un pipe et échangeant des messages avec les autres ranks via un réseau indépendantprogress_monitor.cc: un thread de travail par device, utilisant le DMA pour transférer les compteurs de progression GPU vers l'hôte, avec alerte de limitation et destruction par comptage de référencesdiagnostics.cc: framework de distribution de vérifications piloté par table, avec deux passes de balayage agrégeant les payloads de diagnostic de chaque rankpeers.cc: gestion de la liste des peers par tableau trié + synchronisation par hachage, les peers morts étant stockés séparément pour économiser la bande passante
Réflexions et auto-évaluation de ce chapitre
Q1:rasLocalNotifyutiliserasNotificationMutexpour sérialiser les écritures, maisrasLocalHandlen'a pas de verrou correspondant lors de la lecture. Pourquoi est-ce sûr ? Si l'on retirestatic_assert(sizeof(struct rasNotification) <= PIPE_BUF), dans quels scénarios cela poserait-il problème ?
Analyse de référence: la sûreté vient de la garantie POSIX d'atomicité des écritures dans un pipe — les écritures inférieures àPIPE_BUFsont atomiques📎 src/ras/ras.cc:47。rasLocalNotifyl'écriture en boucle de📎 src/ras/ras.cc:224-237ne s'entrelace pas avec d'autres écritures lorsqu'elle peut être effectuée en une seule écriture.rasLocalHandlela lecture en boucle de📎 src/ras/ras.cc:247-256peut lire des données partielles, mais comme l'écriture est atomique, ce qui est lu est nécessairement le préfixe d'un message complet, et la prochaine lecture complète le reste.
Après avoir retiréstatic_assert, sirasNotificationdépassePIPE_BUF, l'écriture peut être scindée en plusieurs écritures non atomiques. Lorsque deux threads écrivent concurremment, leurs octets peuvent s'entrelacer, ce qui fait que le thread RAS lit des données malformées résultant de la concaténation de deux notifications.msg.typepeut provenir du thread A tandis quemsg.addRanks.ranksprovient du thread B, déclenchantrasLocalHandlela branche de type inconnu📎 src/ras/ras.cc:267-269ou pire, un déréférencement de pointeur sauvage.
Q2:ncclProgressCounterMonitorDestroyexécutecudaStreamSynchronize 📎 src/ras/progress_monitor.cc:381-400seulement après avoir libéré le verrou. Que se passe-t-il si, pendant la synchronisation, un autre thread appelle aussi Destroy pour détruire le même comm ?destroyRefsComment prévenir le problème ?
Analyse de référence:destroyRefsest un comptage de références empêchant le worker d'être supprimé trop tôt. Après que le premier thread a supprimé le comm,destroyRefs++ 📎 src/ras/progress_monitor.cc:371, à ce momenthaveDestroyRef = true. Lorsque le deuxième thread tente de supprimer le même comm,ncclIntruQueueDeleterenvoie nullptr (déjà supprimé),haveDestroyRefreste false📎 src/ras/progress_monitor.cc:368, sautant directement la synchronisation et la libération.
Après que le premier thread a terminécudaStreamSynchronize, il appellereleaseGpuProgressCounterMonitorDestroyRef 📎 src/ras/progress_monitor.cc:402, décrémentedestroyRefsjusqu'à 0, et seulement si la file d'enregistrement est vide, il joint réellement le thread et delete📎 src/ras/progress_monitor.cc:225。
S'il n'y avait pasdestroyRefs, le premier thread pourrait, pendant la synchronisation, voir son worker libéré par ledelete gdu deuxième thread, provoquant un use-after-free. Notez quereleaseGpuProgressCounterMonitorDestroyRefdécrémente📎 src/ras/progress_monitor.cc:222-225sous le verrou global + le verrou du worker, garantissant l'atomicité de la vérificationregistrationsvide et dedestroyRefs == 0.
Q3:rasDiagnosticsSummarizePeerPayloadsvalidecheckHeader->payloadBytes != checkHeader->nRecords * checkHeader->recordStride 📎 src/ras/diagnostics.cc:451-454lors de la première passe de balayage. Si un peer malveillant ou corrompu envoierecordStride = 0avecnRecords = 0, cette validation passerait-elle ? Que se passerait-il ensuite ?
Analyse de référence:recordStride <= 0serait intercepté par la première condition📎 src/ras/diagnostics.cc:451, renvoyantncclInternalError. DoncrecordStride = 0ne passerait pas.
Mais sirecordStride > 0etnRecords = 0, alorspayloadBytes = 0, la validation passe.rasDiagnosticsAccountCheckRecordspournRecords == 0renvoie directement un succès📎 src/ras/diagnostics.cc:378, sans mettre à jourcombined. Lors de l'allocation ultérieure,recordsBytes == 0n'alloue pas📎 src/ras/diagnostics.cc:473, lors de la copiepayloadBytes > 0est faux et saute📎 src/ras/diagnostics.cc:490. Finalementsummarizereçoitrecords = nullptr, recordsBytes = 0, et l'implémentation summarize de chaque vérification doit gérer une entrée vide.
Le vrai risque réside dansnRecords > INT_MAX / recordStridela vérification📎 src/ras/diagnostics.cc:453— cela empêchenRecords * recordStrideun dépassement d'entier de contourner la validation d'égalité. Si l'on retire cette vérification, un attaquant peut construirenRecords = 2^31, recordStride = 2, dont le produit déborde à 0, égal àpayloadBytes = 0, et après validationrasDiagnosticsAccountCheckRecordsaccumulerait un énormenRecords, entraînant un dépassement de borne lors de l'allocation ou de la copie ultérieure.
RAS permet à NCCL de disposer, lors d'entraînements de longue durée, d'une capacité de perception des pannes et d'auto-guérison, mais il repose sur un réseau de contrôle indépendant du plan de données. Dans le prochain chapitre, nous entrerons dans le sous-système de gestion de la mémoire, pour voir comment NCCL optimise l'allocation de mémoire vidéo et les coûts d'enregistrement RDMA via l'allocator, le cache d'enregistrement et l'enregistrement de buffers utilisateur — c'est le troisième pilier, au-delà de la performance et de la fiabilité.
Le principe de conception qui traverse tout ce chapitre est le suivant : découplage du plan de contrôle et du plan de données, versionnage de l'état par hachage, gestion des timeouts par couches, et protection du cycle de vie par comptage de références pour la concurrence. Ces principes permettent à RAS de réaliser la détection de pannes et l'auto-réparation sans pénaliser les performances de communication. Or, un autre pilier essentiel des performances de communication — la gestion mémoire — exige lui aussi des compromis d'ingénierie minutieux : pourquoi NCCL doit-il enregistrer la mémoire avant une communication ? Comment le cache d'enregistrement influence-t-il les performances ? Dans le prochain chapitre, nous plongerons dans l'allocator, le cache d'enregistrement et l'enregistrement des buffers utilisateur pour lever le voile sur ces questions.
Chapitre 18 : Chapitre 18 : Allocation mémoire et gestion de la mémoire GPU : allocator, cache d'enregistrement et optimisation de la mémoire utilisateur enregistrée
Chapitre 18 : Allocation mémoire et gestion de la mémoire GPU : allocator, cache d'enregistrement et optimisation de la mémoire utilisateur enregistrée
Dans le chapitre précédent, nous avons vu comment le sous-système RAS fonctionne indépendamment du plan de données sur le plan de contrôle, en utilisant le hachage pour le versionnage et le comptage de références pour protéger le cycle de vie. Ce chapitre aborde le troisième pilier de NCCL — la gestion mémoire. La limite supérieure des performances de communication ne dépend souvent pas de l'algorithme lui-même, mais de la capacité des données à être lues et écrites directement par la carte réseau. NCCL a construit pour cela trois couches de mécanismes : la couche inférieure utilisencclSpaceetncclShadowPoolpour gérer l'espace d'adressage et les objets fantômes, la couche intermédiaire utilisencclMemManagerpour suivre l'import/export de la mémoire dynamique ainsi que la suspension/reprise, et la couche supérieure utilisencclCommRegisterpour enregistrer les buffers utilisateur dans le cache, évitant ainsi de réépingler la mémoire à chaque communication. Ce chapitre décompose ces trois mécanismes couche par couche, et répond à la question « pourquoi NCCL doit-il enregistrer la mémoire avant une communication » ainsi qu'à « comment le cache d'enregistrement influence les performances ».
18.1 ncclSpace : découper l'espace d'adressage en segments alternant plein/vide
Modèle intuitif
Imaginez une ligne infinie de numéros de places de parking, partant de 0 et s'étendant vers la droite. Certaines places sont occupées (allouées), d'autres sont vides (non allouées).ncclSpaceest le « registre d'état des places » de cette ligne de numéros — il n'enregistre pas chaque place, mais uniquement les points de basculement où l'état change. Sans lui, NCCL devrait maintenir un bit de marquage pour chaque octet lors de la gestion des intervalles d'adresses virtuelles de la mémoire symétrique, ce qui entraînerait une surcharge mémoire proportionnelle à l'espace d'adressage, totalement inacceptable.
Structure de données et disposition mémoire
ncclSpaceLa définition de est extrêmement minimale📎 src/include/allocator.h:20-24:
struct ncclSpace {
int count; // cuts[] 中有效元素个数
int capacity; // cuts[] 已分配容量
int64_t* cuts; // 升序排列的边界点数组
};L'idée centrale est clairement écrite dans les commentaires du code source📎 src/allocator.cc:151-153:cuts[]découpe l'axe des entiers non négatifs en segments alternant « plein » et « vide », les points de découpe étant triés par ordre croissant, et le segment après le dernier point de découpe est nécessairement vide (frontière non allouée). On peut en déduire la formule permettant de déterminer si leie segment est plein :
isFull(i) = (i%2 != ncuts%2)Cette formule signifie que l'état plein/vide d'un segment est déterminé conjointement par la parité de l'index du segment et la parité du nombre total de points de découpe. Lorsquencutsest pair, le segment 0 (avantcuts[0]) est vide ; lorsquencutsest impair, le segment 0 est plein. Cet invariant traverse tout le module.
Déroulé pas à pas : comment une allocation modifie cuts[]
Mise en situation : initialementncclSpaceest vide (count=0), on appellencclSpaceTryAlloc(a, limit=1000, size=100, align=1, &outOffset)。
Première étape : localiser le premier segment vide 📎 src/allocator.cc:209。i = a->count % 2, icicount=0, donci=0, on commence le balayage à partir du segment 0.
Deuxième étape : calculer les bornes du segment 📎 src/allocator.cc:212-213。i==0lorsquelo=0;i==a->countlorsquehi=limit=1000. Donc le segment vide est[0, 1000)。
Troisième étape : aligner et vérifier la capacité 📎 src/allocator.cc:214-215。off = alignUp(0, 1) = 0,0 + 100 <= 1000est vrai, l'allocation réussit.
Quatrième étape : insérer les points de découpe 📎 src/allocator.cc:217-223. Commei==0(insertion en tête), on emprunte le chemin lentinsertSegment(a, 0, 0, 100)。insertSegmentinsère deux points de découpe àindex=0, puis exécute le « filtrage des valeurs dupliquées adjacentes »lo=0, hi=100 📎 src/allocator.cc:172-174. La logique de filtrage est très ingénieuse : elle utilise un double curseur lecture/écriture pour balayer, et en cas de valeur dupliquée, elle recule le curseur d'écriture, supprimant les paires de valeurs dupliquées — car une paire de doublons signifie qu'un segment vide est encadré par deux segments pleins et peut donc être fusionné. Mais les zéros en tête sont un cas particulier, pouvant être supprimés individuellement📎 src/allocator.cc:185-203Après allocation📎 src/allocator.cc:182-184。
. À ce stadecuts = [0, 100],count=2, le segment 0 (isFull(0) = (0%2 != 2%2) = false, vide) est vide ; le segment 1 ([0,0)) est plein. Correct.[0,100)Cinquième étape : libération
. On appelle 📎 src/allocator.cc:239-267. On vérifie d'abord sincclSpaceFree(a, 0, 100)est vraicuts[count-1] <= offset, c'est-à-dire📎 src/allocator.cc:231-237est faux, on continue. On localise le premier segment plein100 <= 0, donci = 1 - count%2 = 1 - 0 = 1 📎 src/allocator.cc:246,cuts[1]=100 > 0. On vérifiei=1。lo = cuts[0] = 0,hi = cuts[1] = 100faux,offset < lo || hi < offset+size 📎 src/allocator.cc:252,0<0faux, on passe. Comme100<100etlo==offset, aucun des deux chemins rapides n'est satisfait (le premier exigeoffset+size==hi, le second exigeoffset+size != hi), on emprunte le chemin lentlo != offset. Après insertioninsertSegment(a, 1, 0, 100) 📎 src/allocator.cc:264, après filtrage cela devientcuts = [0, 0, 100, 100]. Retour à l'état initial.[],count=0Cette conception « insertion puis filtrage » évite d'effectuer une logique complexe de fusion de segments lors de l'allocation/libération, en concentrant la complexité dans
à un seul endroit.insertSegmentRéflexions de conception et pièges en production
Pourquoi utiliser int64_t plutôt que size_t ?
Parce quegère des « offsets » et non des « pointeurs », les offsets pouvant être négatifs (bien qu'en pratique cela n'arrive pas), et il doit être cohérent avec la largeur dencclSpacede CUDA. L'utilisation d'un type signé facilite la détection de dépassements lors du débogage.CUdeviceptrPiège de performance
Le commentaire de dit explicitement « This could be binary search, but since allocate is linear there's no point »:ncclSpaceFree. Cela signifie que l'allocation et la libération sont toutes deux des balayages O(n). Si un domaine de communication alloue et libère fréquemment un grand nombre de petits segments,📎 src/allocator.cc:245va gonfler et chaque opération ralentira. En production, il faut réutiliser autant que possible les buffers déjà enregistrés, plutôt que d'enregistrer/désenregistrer de manière répétée.cuts[]Risque de débordement d'alignement
peut déborder lorsque:alignUp(lo, align)est proche deloet queINT64_MAXest grand. Le code source ne vérifie pas explicitement, caralignest garanti par l'appelant dans une plage raisonnable.limit 由调用方保证在合理范围内。
18.2 ncclShadowPool : gestion de l'appariement entre objets de périphérique et ombres hôtes
Modèle intuitif
Les kernels GPU s'exécutent sur le périphérique et ne peuvent pas accéder directement aux objets C++ en mémoire hôte (par exemple les métadonnées dansncclDevComm).ncclShadowPoolagit comme un « traducteur » : il alloue un bloc de mémoire GPU pour chaque objet côté périphérique, alloue simultanément un bloc de mémoire « ombre » correspondant côté hôte, et maintient une table de correspondance « adresse périphérique → adresse hôte ». Lorsque l'hôte doit modifier la configuration d'un objet de périphérique, il modifie d'abord l'ombre hôte, puis copie vers le périphérique. Sans lui, chaque lecture de métadonnées par un kernel devrait passer parcudaMemcpypour extraire depuis l'hôte, avec une latence inacceptable.
Structures de données et disposition mémoire
Deux structures principales📎 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
};ncclShadowPoollui-même📎 src/include/allocator.h:42-47:
struct ncclShadowPool {
int count, hbits; // 对象数、哈希位数
struct ncclShadowObject** table; // 哈希桶数组
cudaMemPool_t memPool; // 可选的 CUDA 内存池
struct ncclShadowPage* pages; // 页链表
};Points de conception clés :freeMaskest un uint64_t, donc chaque page contient au maximum 64 objets. Ce n'est pas un choix arbitraire — 64 bits correspondent exactement à la largeur d'une ligne de cache,popFirstOneBitpermet de trouver le premier emplacement libre avec une seule instruction__builtin_ctzll, sans boucle.
Stratégie de croissance de la table de hachage: commentaire du code source « Maintain 2:1 object:bucket ratio »📎 src/allocator.cc:368, c'est-à-dire que l'expansion a lieu lorsque le nombre d'objets dépasse le double du nombre de buckets. Initialhbits=4(16 buckets)📎 src/allocator.cc:363, doublement à chaque fois.
Step-by-Step Walkthrough : comment une allocation choisit entre page et connexion directe
Mise en situation :ncclShadowPoolAlloc(pool, size=1024, &devObj, &hostObj, stream)。
Première étape : initialisation paresseuse 📎 src/allocator.cc:347-366. Sihbits==0, vérifier d'abord si le périphérique prend en charge le pool mémoire📎 src/allocator.cc:352, si oui créercudaMemPool_t, définirmaxSizesur le paramètreSHADOW_MEMPOOL_MAX_SIZE(1 Go par défaut)📎 src/allocator.cc:359. Puis allouer une table de hachage de 16 buckets.
Deuxième étape : vérifier si une expansion est nécessaire 📎 src/allocator.cc:369-386. Sicount+1 > 2<<hbits, allouer un tableau de buckets doublé, parcourir l'ancienne table pour réinsérer (hashInsertutiliserncclHashPointerpour calculer l'index de bucket📎 src/allocator.cc:333-337), libérer l'ancienne table.
Troisième étape : décider entre le chemin page et le chemin direct 📎 src/allocator.cc:390. Condition de décision(64<<10)/size >= 3, c'est-à-dire quesize <= 21845emprunte le chemin page. Poursize=1024,65536/1024=64 >= 3, emprunte le chemin page.
Quatrième étape : calculer la taille d'objet dans la page 📎 src/allocator.cc:391-392。shift = max(0, log2Down(1024)+1-4) = max(0, 10+1-4) = 7。pageObjSize = ((1024 + 127) >> 7) << 7 = 1024. La taille d'objet dans la page est alignée sur une puissance de 2, multiple de 128 octets.
Cinquième étape : rechercher ou créer une page 📎 src/allocator.cc:393-415. Parcourir la liste chaînéepool->pages, chercher la page deobjSize == pageObjSize. Si absente, créer une nouvelle page :pageSize = min(65536, 64*1024) = 65536,freeMask = uint64_t(-1) >> (64 - 65536/1024) = uint64_t(-1) >> 0 = 全 1(64 emplacements tous vides)📎 src/allocator.cc:400. UtilisercudaMallocFromPoolAsyncoucudaMallocpour allouer la mémoire GPU📎 src/allocator.cc:403-404, etcudaMemsetAsyncmettre à zéro📎 src/allocator.cc:405。
Sixième étape : prendre un emplacement dans la page 📎 src/allocator.cc:408-412。popFirstOneBit(&page->freeMask)trouver le premier bit libre,devObj = page->devObjs + slot * pageObjSize. SifreeMaskdevient 0 (page pleine), retirer la page de la liste chaînée libre📎 src/allocator.cc:411。
Septième étape : allouer l'objet ombre hôte 📎 src/allocator.cc:423-428。malloc(sizeof(ncclShadowObject) + alignof(max_align_t)-1 + size), noter qu'icialignof(max_align_t)-1octets supplémentaires sont alloués pour le remplissage d'alignement.hostObj = alignUp((char*)(obj+1), alignof(max_align_t)), c'est-à-dire qu'après l'en-tête d'objet, l'alignement se fait sur la plus grande frontière d'alignement. Puismemset(hostObj, 0, size)mise à zéro.
Huitième étape : insérer dans la table de hachage et mettre à jour les compteurs 📎 src/allocator.cc:429-430。
Contrôle de concurrence et interaction matérielle
ncclShadowPoollui-mêmen'a pas de verrou. Cela signifie qu'il ne peut être utilisé que dans un contexte mono-thread, ou que l'appelant doit garantir l'exclusion mutuelle. D'après l'utilisation réelle dans NCCL, il est principalement appelé lors de la phase d'initialisation du domaine de communication, qui est mono-thread.
cudaMallocFromPoolAsyncetcudaFreeAsyncsont des opérations asynchrones, dépendant du paramètrestreampour garantir l'ordre📎 src/allocator.cc:403,459。ncclShadowPoolDestructest appelé après la libération de toutes les ressourcescudaStreamSynchronize(stream) 📎 src/allocator.cc:333-337, assurant que toutes les libérations asynchrones sont terminées avant de détruire le pool mémoire.
Guide de production pour éviter les pièges
Piège 1 : gaspillage mémoire dû à l'alignement de la taille d'objet dans la page。pageObjSizealigné sur une puissance de 2, sisize=1000,shift = log2Down(1000)+1-4 = 9+1-4 = 6,pageObjSize = ((1000+63)>>6)<<6 = 1024. Chaque objet gaspille 24 octets, 64 objets par page gaspillent 1536 octets. Pour de nombreux petits objets, ce coût n'est pas négligeable.
Piège 2 :ncclShadowPoolFreecomportement lorsque l'objet n'est pas trouvé 📎 src/allocator.cc:442-445. Il retournencclInternalErroret imprime un avertissement, maisne libère aucune ressource. Si l'appelant ignore la valeur de retour, cela provoque une fuite mémoire. Le code de production doit vérifier la valeur de retour.
Piège 3 :ncclShadowPoolDestructdansfreeMask==0la page de 📎 src/allocator.cc:301-306est recycléefreeMask. Noter qu'icipool->pagesest mis à 1 (et non tous à 1), ce qui signifie que seul le premier emplacement est marqué comme libre. C'est pour remettre la « page pleine » dans la liste chaînée
, mais les autres emplacements de la page restent occupés — en réalité ces objets vont bientôt être libérés, donc cette opération est sûre. Mais si un accès concurrent a lieu pendant la destruction, un état incohérent sera lu.
18.3 ncclMemManager : comptage de références et reprise après suspension de la mémoire dynamique
Modèle intuitifncclMemManagerUne tâche d'entraînement peut durer plusieurs jours, pendant lesquels le GPU peut être préempté par d'autres tâches, ou un checkpoint peut être nécessaire.
agit comme un « gestionnaire de mémoire » : il enregistre toute la mémoire allouée dynamiquement (scratch/offload), et lorsque nécessaire, « suspend » la mémoire GPU (unmap des pages physiques, conserve les adresses virtuelles), sauvegarde les données sur le CPU, puis lors de la reprise, réalloue les pages physiques, remappe et restaure les données. Sans lui, après préemption, la tâche ne peut que repartir de zéro, gaspillant des heures de progression d'entraînement.
ncclMemManagerStructures de données et disposition mémoire📎 src/mem_manager.cc:32-60:
| champs principaux de | (déduits du code d'initialisation) | Champ |
|---|---|---|
entries | ncclDynMemEntry* | Type |
numEntries | int | Signification |
released | int | Tête de liste chaînée des entrées de mémoire dynamique |
refCount | int | Longueur de la liste chaînée |
totalPersist | size_t | 0=actif, 1=suspendu |
totalScratch | size_t | Compteur de références (plusieurs comm peuvent partager) |
totalOffload | size_t | Total de mémoire persistante (atomique) |
cpuBackupUsage | size_t | Total de mémoire scratch (atomique) |
lock | std::mutex | Total de mémoire offload (atomique) |
initialized | int | Total de mémoire de sauvegarde CPU |
Protège la liste chaînée entries:lockIndicateur atomique, empêche l'accès à un mutex détruitstd::mutexConception clé de la disposition mémoirencclMemManagerest unncclCalloc, mais📎 src/mem_manager.cc:39est alloué avec~mutex() 📎 src/mem_manager.cc:120(style C), donc il faut utiliser placement new pour construire explicitement
, et appeler explicitementlors de la destruction. C'est un piège classique de la programmation mixte C/C++.totalPersistRépartition des rôles entre variables atomiques et verrousentries: les champs statistiques (locketc.) sont mis à jour par opérations atomiques, sans besoin de verrou ;ncclCommMemStatsla liste chaînée est protégée par📎 src/mem_manager.cc:1117-1130. Ainsi les requêtes statistiques (
Procédure pas à pas : flux complet de suspension et de reprise
Flux de suspension ncclCommMemSuspend 📎 src/mem_manager.cc:418-540:
Première étape : vérifications préalables 📎 src/mem_manager.cc:419-430. Vérifier si le gestionnaire de mémoire est désactivé, si comm est vide, et si une suspension est déjà en cours.
Deuxième étape : synchronisation des périphériques et barrier 📎 src/mem_manager.cc:440-441。cudaDeviceSynchronize()S'assurer que toutes les opérations GPU sont terminées, puisbootstrapBarriers'assurer que tous les ranks sont synchronisés. Le tag de barrier est0xBEEF。
Troisième étape : premier parcours — unmap de tous les buffers importés par les peers 📎 src/mem_manager.cc:444-465. Pour chaque entrée deisImportedFromPeer && state==Active, appelercuMemUnmappour défaire le mapping📎 src/mem_manager.cc:451, libérer le handle📎 src/mem_manager.cc:456, l'état passe àReleased。
Quatrième étape : deuxième parcours — offload de la mémoire locale 📎 src/mem_manager.cc:468-526. Ignorer les entrées importées par les peers et celles déjà libérées. Pour le typencclMemOffload, allouer d'abord une sauvegarde CPU📎 src/mem_manager.cc:484, puiscudaMemcpycopier du GPU vers le CPU📎 src/mem_manager.cc:492. Pour le typencclMemScratch, accumuler uniquement les statistiques. Ensuite fermer le shareable FD📎 src/mem_manager.cc:508-513,cuMemUnmap 📎 src/mem_manager.cc:516,cuMemRelease 📎 src/mem_manager.cc:519, l'état passe àReleased。
Cinquième étape : marquer comme suspendu 📎 src/mem_manager.cc:528。
Flux de reprise ncclCommMemResume 📎 src/mem_manager.cc:550-942:
Première étape : restaurer la mémoire locale 📎 src/mem_manager.cc:577-668. Pour chaque entrée de!isImportedFromPeer && state==Released, refairecuMemCreate 📎 src/mem_manager.cc:599,ncclCuMemMapAndSetAccessle mapping vers la même adresse virtuelle📎 src/mem_manager.cc:602, restaurer les permissions d'accès peer📎 src/mem_manager.cc:610-626, restaurer les données depuis la sauvegarde CPU pour le type offload📎 src/mem_manager.cc:632-643, réexporter le handle FABRIC📎 src/mem_manager.cc:646-658。
Deuxième étape : synchronisation barrier 📎 src/mem_manager.cc:671-679. Le tag reste0xBEEF。
Troisième étape : échanger les informations des nouveaux handles 📎 src/mem_manager.cc:688-816. Compter combien de buffers locaux chaque rank doit diffuser📎 src/mem_manager.cc:689-696, utiliserbootstrapAllGatherpour échanger les compteurs📎 src/mem_manager.cc:710, calculer les offsets📎 src/mem_manager.cc:724-728, puis d'abordbootstrapSendensuitebootstrapRecv(le commentaire précise explicitement « send first, then receive to avoid deadlock »📎 src/mem_manager.cc:783)。
Quatrième étape : réimporter les buffers des peers 📎 src/mem_manager.cc:822-911. Pour chaque entrée deisImportedFromPeer && state==Released, rechercher les informations de handle correspondantes dans les résultats de l'échange📎 src/mem_manager.cc:829-835. Le type POSIX FD nécessite de vérifier si le hostHash est identique📎 src/mem_manager.cc:853-859, puis obtenir le FD via le proxy📎 src/mem_manager.cc:866,cuMemImportFromShareableHandleimporter📎 src/mem_manager.cc:873. Le type FABRIC s'importe directement📎 src/mem_manager.cc:878. EnsuitencclCuMemMapAndSetAccessrefaire le mapping📎 src/mem_manager.cc:893。
Cinquième étape : barrier final 📎 src/mem_manager.cc:916-928. Le tag est0xCAFE, à distinguer des précédents0xBEEF.
Contrôle de concurrence et interaction matérielle
Protection du cycle de vie par comptage de références:ncclMemManagerDestroyDécrémenter d'abordrefCount 📎 src/mem_manager.cc:76, si le résultat est toujours supérieur à 0, effacer uniquement le pointeur du comm actuel📎 src/mem_manager.cc:81, sans libérer les ressources. Cela permet à plusieurs comm de partager le même gestionnaire de mémoire (par exemple dans le scénario split_share).
Indicateur atomique initialized: vérifierCOMPILER_ATOMIC_LOAD(&manager->initialized, memory_order_acquire) 📎 src/mem_manager.cc:136,242,338,358avant toute opération, pour éviter d'accéder à un mutex déjà détruit. Lors de la destruction, utilisermemory_order_releasepour stocker 0📎 src/mem_manager.cc:87, garantissant que les écritures précédentes sont visibles par les autres threads.
Utilisation de l'API CUDA VMM:cuMemCreate/cuMemMap/cuMemUnmap/cuMemReleaseest l'API de gestion de mémoire virtuelle de CUDA, qui permet de séparer la mémoire physique de l'adresse virtuelle. C'est la base de la suspension/reprise — lors de la suspension, on unmap les pages physiques mais on conserve l'adresse virtuelle ; lors de la reprise, on refait le mapping vers la même adresse virtuelle, de sorte que toutes les relations de pointeurs déjà établies n'ont pas besoin d'être modifiées.
Guide pour éviter les pièges en production
Piège 1 : le domaine de communication split_share ne supporte pas la suspension 📎 src/mem_manager.cc:1014-1018. SirefCount > 1, retourner directementncclInvalidUsage. Car lorsque plusieurs comm partagent le gestionnaire de mémoire, suspendre un comm affecte la mémoire des autres comm.
Piège 2 : invalidation des POSIX FD entre nœuds 📎 src/mem_manager.cc:853-859. Les descripteurs de fichiers POSIX ne sont valides qu'au sein d'un même nœud, et doivent être ignorés lors d'une reprise inter-nœuds. Le code source utilisehostHashune comparaison pour déterminer s'il s'agit du même nœud.
Piège 3 : conserver la sauvegarde en cas d'échec de restauration des données offload 📎 src/mem_manager.cc:635. SicudaMemcpyla restauration du CPU vers le GPU échoue, le code source affiche un avertissement et conservecpuBackup, sans le libérer. Cela vise à donner à l'appelant une chance de réessayer, mais sans nouvelle tentative, cela provoquera une fuite de mémoire CPU.
Piège 4 :ncclMemUntrackDynamicrisque de use-after-free dans. Le code source, en tenant le verrou, trouve l'entrée, sauvegarde les informations nécessaires, libère l'entrée📎 src/mem_manager.cc:302, puis met à jour les statistiques hors du verrou📎 src/mem_manager.cc:311-327. Cet ordre est correct, mais si le pointeurinfopointe vers la mémoire de pile de l'appelant et que l'appelant lit hors du verrou, il faut s'assurer que le cycle de vie deinfocouvre toute la fonction.
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 --> doneLa figure ci-dessus montre le flux de contrôle du processus de suspension. Noter deux branches clés : le premier parcours ne traite que les buffers importés par les peers, le second ne traite que les buffers locaux, et l'ordre ne peut pas être inversé — il faut d'abord défaire les références à la mémoire des peers, puis libérer la mémoire locale.
18.4 Cache d'enregistrement : comment ncclRegister évite le pin répété
Modèle intuitif
Pour que la carte réseau lise et écrive directement dans la mémoire GPU (GPUDirect RDMA), il faut d'abord « enregistrer » cette mémoire — dire à la carte réseau « tu peux accéder directement à cette adresse ». Le processus d'enregistrement implique le pin des pages et l'établissement de mappings IOMMU, avec un coût élevé (de l'ordre de la milliseconde). Si l'on réenregistre à chaque AllReduce, la latence des petites communications serait complètement noyée par le coût d'enregistrement.ncclRegisterest précisément un « cache d'enregistrement » : il consigne les plages d'adresses déjà enregistrées dans un tableau ordonné, et lors de la prochaine rencontre d'un buffer identique ou inclus, il le réutilise directement sans réenregistrer.
Structure de données et disposition mémoire
ncclRegCacheLe cœur deslotsest un tableau ordonnéncclReg*。ncclReg, chaque élément étant
| les champs clés (déduits de l'usage) : | Champ | Type |
|---|---|---|
begAddr | uintptr_t | Signification |
endAddr | uintptr_t | Adresse de début alignée sur page |
localRefs | int | Adresse de fin alignée sur page |
graphRefs | int | Compteur de références local |
state | int | Compteur de références du graphe |
netHandleHead | ncclRegNetHandles* | Bits d'état d'enregistrement (NET/NVLS/COLLNET/IPC) |
ipcInfos | ncclIpcInfo** | Tableau d'informations IPC |
Alignement de page:begAddr = (uintptr_t)data & -pageSize 📎 src/register/register.cc:31,endAddr = ((uintptr_t)data + size + pageSize - 1) & -pageSize 📎 src/register/register.cc:32。-pageSizeestpageSizele complément à deux, équivalent à « aligner vers le bas sur un multiple de pageSize ». La raison : la granularité minimale d'enregistrement est la page, même si l'on n'enregistre qu'1 octet, il faut enregistrer une page entière.
Step-by-Step Walkthrough : comment un enregistrement atteint le cache
Mise en situation :ncclCommRegister(comm, buff=0x7f0000001000, size=4096, &handle)。
Première étape : vérification des paramètres et alignement de page 📎 src/register/register.cc:18-24。CommCheckvalide la validité de comm. SupposonspageSize=4096,begAddr = 0x7f0000001000 & -4096 = 0x7f0000001000,endAddr = (0x7f0000001000 + 4096 + 4095) & -4096 = 0x7f0000002000。
Deuxième étape : vérification de la mémoire système 📎 src/register/register.cc:36-64. SincclCuMemEnable(), interroger la plage d'adresses et le type de mémoire. SimemType == CU_MEMORYTYPE_HOST, il s'agit de mémoire CPU, ignorer l'enregistrement📎 src/register/register.cc:58-61. Sinon, vérifier s'il existe un segment Sysmem📎 src/register/register.cc:50-55。
Troisième étape : parcourir le cache pour trouver la position d'insertion 📎 src/register/register.cc:66-89. Boucleslotà partir de 0 :
- Si
slot == population(fin atteinte) oubegAddr < slots[slot]->begAddr(l'adresse actuelle précède l'entrée de cache), il faut créer une nouvelle entrée📎src/register/register.cc:67。 - Si
slots[slot]->begAddr <= begAddr && slots[slot]->endAddr >= endAddr, le tampon actuel est entièrement contenu dans une entrée existante, incrémenter directement le compteur de références📎src/register/register.cc:83-87。
Quatrième étape : créer une nouvelle entrée 📎 src/register/register.cc:68-82. Si le cache est plein, l'agrandir (initialement 32, puis doubler)📎 src/register/register.cc:70. Utilisermemmovepourslotlibérer de l'espace à la position📎 src/register/register.cc:73,ncclCallocallouer une nouvelle entrée📎 src/register/register.cc:74, définirbegAddr/endAddr, selonisGraphdéfinirgraphRefsoulocalRefsà 1📎 src/register/register.cc:78-79,population++, retourner le handle.
Cinquième étape : désenregistrement 📎 src/register/register.cc:172-195。commDeregistertrouver d'abord le slot correspondant au handle📎 src/register/register.cc:180, décrémenter le compteur de références📎 src/register/register.cc:185-186. S'il reste des références, retourner directement📎 src/register/register.cc:187. Sinon, appelerregCleanuppour nettoyer tous les enregistrements sous-jacents📎 src/register/register.cc:188, libérer l'entrée, utilisermemmovepour combler le trou📎 src/register/register.cc:190,population--。
Réflexions de conception et pièges en production
Pourquoi utiliser un tableau ordonné plutôt qu'une table de hachage ?Parce que la requête d'enregistrement est une requête « d'inclusion de plage », pas une correspondance exacte. Le tableau ordonné supporte la recherche binaire (bien que le code source utilise un balayage linéaire), et bénéficie d'une bonne localité mémoire. Une table de hachage ne peut pas traiter efficacement ce type de requête « cette adresse est-elle contenue dans une plage plus grande ».
regCleanupConception des bits d'état de 📎 src/register/register.cc:95-134。stateest un masque de bits, chaque bit correspondant à un type d'enregistrement (NET/NVLS/COLLNET/IPC). Lors du nettoyage, on vérifie bit par bit et on ne nettoie que les enregistrements terminés. Cette conception permet les cas où certains enregistrements réussissent et d'autres échouent — par exemple, l'enregistrement réseau réussit mais l'enregistrement IPC échoue, le nettoyage ne nettoie alors que la partie réseau.
Piège en production : le cache d'enregistrement ne perçoit pas la libération de mémoire. Si l'utilisateur enregistre un tampon, puis lecudaFreesans le désenregistrer, l'entrée reste dans le cache. La prochaine allocation peut réutiliser la même adresse, entraînant un succès de cache alors que la mémoire est en fait invalide. La convention de NCCL est : enregistrement et désenregistrement doivent être appariés, l'utilisateur est responsable de garantir que la mémoire n'est pas libérée pendant l'enregistrement.
ncclCommRegisterCondition de saut de 📎 src/register/register.cc:150-159. SiLocalRegister=0ouP2pUsesMemcpy=1, retourner directementNULLhandle. Cela signifie que dans certaines configurations (par exemple P2P via memcpy plutôt que RDMA), l'enregistrement est complètement ignoré. L'appelant doit vérifier si le handle est NULL.
18.5 Enregistrement de communication collective : comment coll_reg choisit la stratégie d'enregistrement pour différents algorithmes
Modèle intuitif
Différents algorithmes de communication collective empruntent différents chemins de transport : NVLS passe par NVLink SHARP, Ring passe par P2P ou le réseau, Tree passe par une topologie en arbre. Chaque chemin nécessite un mode d'enregistrement différent : NVLS doit s'enregistrer auprès du matériel NVLS, le réseau auprès de la carte réseau, IPC auprès du GPU pair.coll_reg.ccest le « routeur de stratégie d'enregistrement » : il décide quelles fonctions d'enregistrement appeler selon l'algorithme, le protocole et le type de tampon. Sans lui, chaque algorithme devrait implémenter sa propre logique d'enregistrement, avec duplication de code et risque d'erreurs.
Step-by-Step Walkthrough : décision d'enregistrement pour l'algorithme Ring
Mise en situation :ncclRegisterCollBuffers(comm, info, outRegBufSend, outRegBufRecv, cleanupQueue, regNeedConnect), oùinfo->algorithm == NCCL_ALGO_RING,info->protocol == NCCL_PROTO_SIMPLE。
Première étape : vérifications préalables 📎 src/register/coll_reg.cc:155-157. DéfinirregBufType = NCCL_REGULAR_BUFFER,regNeedConnect = true. SiLocalRegister=0et qu'il ne s'agit pas d'un enregistrement de graphe persistant, sortir directement.
Deuxième étape : entrer dans la branche Ring 📎 src/register/coll_reg.cc:338. InitialiserrecvRegRecord/sendRegRecordà NULL, allouer le tableausendNetConns/sendNetHandles/recvNetConns/recvNetHandles/srecvNetHandles📎 src/register/coll_reg.cc:356-360。
Troisième étape : rechercher un enregistrement existant 📎 src/register/coll_reg.cc:351-355。ncclRegFindrechercher les tampons recv/send dans le cache. Si recv n'est pas trouvé et qu'il ne s'agit pas d'un enregistrement de graphe persistant, sortir📎 src/register/coll_reg.cc:352. Si multi-nœuds et send non trouvé et qu'il ne s'agit pas d'un enregistrement de graphe persistant, sortir📎 src/register/coll_reg.cc:354。
Quatrième étape : parcourir tous les channels pour collecter les peers 📎 src/register/coll_reg.cc:362-393. Pour chaque channel, vérifierring.prevetring.next. Si le flag de connexion contientNCCL_DIRECT_NIC, enregistrer dansrecvNetConns/sendNetConns 📎 src/register/coll_reg.cc:370-379. S'il contientNCCL_P2P_READ | NCCL_P2P_WRITE, ajouter le peer au tableaupeerRanks📎 src/register/coll_reg.cc:382-391。
Cinquième étape : enregistrement IPC 📎 src/register/coll_reg.cc:394-407. SinPeers > 0 && comm->isAllDirectP2p, essayer d'abord l'enregistrement de graphe📎 src/register/coll_reg.cc:395-399, en cas d'échec essayer l'enregistrement local📎 src/register/coll_reg.cc:400-403. En cas de succès, définirregBufType = NCCL_IPC_REG_BUFFER 📎 src/register/coll_reg.cc:406。
Sixième étape : enregistrement réseau 📎 src/register/coll_reg.cc:409-457. Vérifier!comm->useNetPXN && comm->useGdr && netDeviceType != UNPACKet non AllReduce avec PreMulSum/SumPostDiv📎 src/register/coll_reg.cc:415-418. Essayer d'abord l'enregistrement de graphe📎 src/register/coll_reg.cc:419-430, en cas d'échec l'enregistrement local📎 src/register/coll_reg.cc:431-442. En cas de succès, définirregBufType |= NCCL_NET_REG_BUFFER, sauvegarder le tableau de handles📎 src/register/coll_reg.cc:445-452。
Septième étape : ajuster le nombre de channels 📎 src/register/coll_reg.cc:551-554. Si seul IPC est enregistré, mono-nœud, et que le nombre de channels est entre 17 et 24, le réduire à 16. Ceci afin de correspondre aux caractéristiques de bande passante après enregistrement IPC.
Réflexions de conception et pièges en production
Pourquoi l'ordre d'enregistrement de NVLS et Ring est-il inversé ?La branche NVLS essaie d'abord l'enregistrement de graphe puis l'enregistrement local📎 src/register/coll_reg.cc:86-94, tandis que la branche Ring fait d'abord le local puis le graphe📎 src/register/coll_reg.cc:395-403. C'est parce que l'enregistrement de graphe NVLS a plus de chances de réussir (le matériel NVLS est optimisé pour les tampons persistants), tandis que l'enregistrement local de Ring est plus léger.
isMloPartBufRdmaCapableDécision globale de 📎 src/register/coll_reg.cc:14-37. Les commentaires soulignent que « la décision d'enregistrement doit être globale, en utilisant des garanties à l'échelle du communicateur »📎 src/register/coll_reg.cc:20. Cela signifie que même si le tampon d'un rank donné prend en charge RDMA, tant qu'un seul rank du domaine de communication ne le prend pas en charge, l'ensemble du domaine de communication ne s'enregistre pas. Cela permet d'éviter les incohérences dues à l'enregistrement de certains ranks et au non-enregistrement d'autres.
Piège en production : dégradation silencieuse en cas d'échec d'enregistrement。ncclRegisterCollBuffersEn cas d'échec d'enregistrement, aucune erreur n'est signalée, le bit correspondant deregBufTypen'est simplement pas défini. Cela signifie que la communication fonctionne toujours, mais avec une baisse de performance. En production, si les performances sont inférieures aux attentes, il faut vérifier les journaux deNCCL_REGpour confirmer si l'enregistrement a réussi.
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 --> handlesLa figure ci-dessus illustre deux chemins d'enregistrement parallèles dans l'algorithme Ring : le chemin IPC gère les connexions P2P sur le même nœud, le chemin réseau gère les connexions RDMA inter-nœuds. Les deux chemins s'exécutent indépendamment et convergent finalement versinfo->regBufType。
18.6 Pièges en production et chaîne de récupération après incident
Piège 1 : interaction entre le cache d'enregistrement et le pool mémoire
Lors de l'utilisation dencclMemAllocpour allouer de la mémoire, on passe par l'API CUDA VMM📎 src/allocator.cc:38-94. La mémoire physique créée par ce mode d'allocation porte le flaggpuDirectRDMACapable📎 src/allocator.cc:54, ce qui signifie qu'elle prend naturellement en charge RDMA. Mais lors de la libération parncclMemFree, si le gestionnaire de mémoire a déjà été détruit, on emprunte le chemin de replicudaFree📎 src/allocator.cc:130-132. Cela peut entraîner une libération erronée viacudaFreede la mémoire allouée par VMM. En production, il faut impérativement s'assurer quencclMemAlloc/ncclMemFreesont utilisés par paires, et ne pas libérer après la destruction du gestionnaire de mémoire.
Piège 2 : requêtes de communication pendant la suspension
ncclCommMemSuspendPendant l'exécution de , que se passe-t-il si de nouvelles requêtes de communication arrivent ? Le code source appellecudaDeviceSynchronize() 📎 src/mem_manager.cc:440avant la suspension pour s'assurer que toutes les opérations GPU déjà mises en file sont terminées. Mais si des requêtes de communication côté host sont en cours de mise en file, il n'y a pas de protection explicite. En production, il faut arrêter tous les threads de communication avant la suspension, ou utiliser la sémantique de groupe pour garantir que l'opération de suspension est sérialisée avec les autres opérations.
Piège 3 : compatibilité des handles FABRIC
ncclMemAllocSur CUDA 12.3+, on tente d'utiliser un handle FABRIC📎 src/allocator.cc:60-71. SicuMemCreaterenvoieCUDA_ERROR_NOT_PERMITTEDouCUDA_ERROR_NOT_SUPPORTED, on revient au POSIX FD📎 src/allocator.cc:63-65. Mais lors de la reprise, si le type de handle est FABRIC mais que l'export échoue, une erreur est signalée directement et un unmap est effectué📎 src/mem_manager.cc:649-655. Cela signifie qu'en environnement mixte (certains GPU prennent en charge FABRIC, d'autres non), la suspension/reprise peut échouer.
Piège 4 : fuite de compteur de références
ncclRegisterChaque succès du cache incrémente le compteur de références📎 src/register/register.cc:84-85. Si l'appelant enregistre N fois mais ne désenregistre que M fois (M < N), le compteur de références ne reviendra jamais à zéro,regCleanupne sera jamais appelé, et les ressources d'enregistrement sous-jacentes fuient. Le code de production doit strictement apparierncclCommRegister/ncclCommDeregister。
sequenceDiagram
participant App as 应用层
participant Reg as ncclRegister
participant Cache as ncclRegCache
participant Net as ncclNetLocalRegisterBuffer
participant GPU as CUDA Driver
App->>Reg: ncclCommRegister(comm, buff, size, &handle)
Reg->>Reg: begAddr = data & -pageSize
Reg->>Cache: 遍历 slots 查找包含范围
alt 缓存命中
Cache-->>Reg: 返回已有 ncclReg*
Reg->>Reg: localRefs++
else 缓存未命中
Reg->>Cache: memmove 腾出插入位置
Reg->>Cache: ncclCalloc 新条目
Reg->>Reg: localRefs = 1
end
Reg-->>App: 返回 handle
App->>Net: 首次注册时调用
Net->>GPU: cuMemExportToShareableHandle
GPU-->>Net: 返回 handle
Net-->>App: 注册完成Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la vérificationncclSpaceFreedeif (a->count == 0 || a->cuts[a->count - 1] <= offset)dans📎 src/allocator.cc:231-237, dans quel scénario cela déclencherait-il un accès hors limites ?
Analyse de référence: cette vérification a deux rôles. Premièrement,a->count == 0empêche l'accès à un tableau videcuts[-1]. Deuxièmement,a->cuts[a->count-1] <= offsetempêche queoffsetdépasse la plage allouée. Si on la supprime, lorsquecount == 0,a->cuts[a->count - 1]liracuts[-1], ce qui est un comportement indéfini, pouvant lire des métadonnées du tas ou déclencher une erreur de segmentation. Plus insidieux encore, même sicount > 0, sioffsetest supérieur au dernier point de découpe, la bouclewhile (a->cuts[i] <= offset) i += 2suivante📎 src/allocator.cc:247incrémenteraijusqu'au dépassement, carcuts[]ne contient aucun élément supérieur àoffset. Le scénario déclencheur en production est : l'appelant a passé un offset jamais alloué (par exemple, le tampon a été libéré en externe puis free est appelé à nouveau), ouncclSpacea été modifié concurremment provoquant une incohérence d'état. La correction consiste à conserver cette vérification et, en cas d'erreur retournée, à afficheroffsetetcountpour faciliter le diagnostic.
Q2: ncclMemManagerDestroyDans , sirefCountaprès décrémentation reste supérieur à 0, on efface uniquement le pointeur du comm courant sans libérer les ressources📎 src/mem_manager.cc:78-83. Si à ce moment un autre comm appellencclMemTrack, que se passe-t-il ?
Analyse de référence:ncclMemTrackvérifie d'abordmanager->initialized 📎 src/mem_manager.cc:136. CommerefCount > 0ne définit pasinitialized = 0, la vérification passe. Ensuite, il acquiertmanager->locket modifie la liste chaînéeentries📎 src/mem_manager.cc:188-192. C'est sûr, carrefCount > 0signifie qu'au moins un comm détient encore une référence, le gestionnaire de mémoire ne sera pas détruit. Le vrai risque est le suivant : si le dernier comm appellencclMemManagerDestroy,refCountdécrémente à 0, il définitinitialized = 0 📎 src/mem_manager.cc:87et libère toutes les ressources. Si à ce moment un autre thread dansncclMemTracka déjà passé la vérificationinitializedmais n'a pas encore acquis le verrou, il accédera àmanager->lockdéjà libéré, provoquant un use-after-free. Le code source atténue ce problème par l'appariementmemory_order_acquire/release, mais strictement parlant, il subsiste une fenêtre de course. En production, il faut s'assurer que tous les threads de communication sont arrêtés avant de détruire le gestionnaire de mémoire.
Q3 : DansncclCommMemResume, les tampons peer de type POSIX FD sont ignorés lors du passage inter-nœuds📎 src/mem_manager.cc:853-859. Si tous les tampons peer sont ignorés,restoredPeerCountvaut 0, maismanager->releasedest quand même défini à 0📎 src/mem_manager.cc:913. Quelles en sont les conséquences ?
Analyse de référence:manager->released = 0indique que le gestionnaire de mémoire considère la reprise comme terminée. Mais si des tampons peer ont été ignorés, leurstaterestencclDynMemStateReleased,handlereste 0. Si une communication ultérieure accède à ces tampons, cela déclenchera une erreur CUDA (accès à une adresse virtuelle non mappée). Plus grave encore,ncclCommMemStatsl'interrogation dencclStatGpuMemSuspendedrenverra 0 (actif)📎 src/mem_manager.cc:1130, alors qu'en réalité une partie de la mémoire n'a pas été restaurée. La racine du problème est que : les POSIX FD inter-nœuds ne devraient tout simplement pas être importés — avant la suspension, ces tampons ne devraient pas exister dansentriesDans ce cas, la bonne approche consiste à marquer les entrées POSIX FD inter-nœuds comme non récupérables lors de la suspension, ou à renvoyer une erreur lors de la reprise plutôt que de les ignorer silencieusement. En production, si vous utilisez des POSIX FD et que vous êtes inter-nœuds, vous devriez passer à un handle FABRIC ou vous assurer que la suspension/reprise ne s'effectue qu'au sein d'un seul nœud.
La gestion de la mémoire est le pilier invisible des performances de NCCL :ncclSpaceGestion de l'espace d'adressage à l'aide d'un tableau minimaliste de points de découpe,ncclShadowPoolGestion de l'appariement des objets device/host à l'aide d'un bitmap 64 bits et d'une table de hachage,ncclMemManagerImplémentation de la suspension/reprise à l'aide du comptage de références et de l'API CUDA VMM,ncclRegisterMise en cache des résultats d'enregistrement dans un tableau ordonné pour éviter les pin répétés. Ces quatre couches de mécanismes soutiennent ensemble la garantie de performance clé selon laquelle « aucune réinscription de mémoire n'est nécessaire avant la communication ». Le chapitre suivant abordera le communicateur côté device et la compatibilité ABI, pour voir commentdevcommces dispositions mémoire côté host sont mappées vers des structures accessibles par les kernels GPU.
La figure ci-dessus illustre la chronologie de l'enregistrement : en cas de succès du cache, seul le compteur de références est incrémenté, sans appel à l'enregistrement sous-jacent ; en cas d'échec du cache, une nouvelle entrée est créée et l'enregistrement sous-jacent est déclenché. À ce stade, le mécanisme de gestion de la mémoire côté host est clair. Mais la communication se produit finalement sur le GPU, et le kernel doit accéder directement aux adresses et à l'état de connexion des ranks distants. Le chapitre suivant abordera le communicateur côté device et la compatibilité ABI, pour voir comment devcomm mappe les métadonnées de ncclComm côté host vers des structures accessibles côté device, et comment l'ABI versionnée garantit la compatibilité entre les anciens et nouveaux kernels et la bibliothèque.
Chapitre 19 : Chapitre 19 : Domaine de communication côté device et compatibilité ABI : le contrat de communication entre devcomm et le kernel
Chapitre 19 : Domaine de communication côté device et compatibilité ABI : le contrat de communication entre devcomm et le kernel
Dans le chapitre précédent, nous avons vu que le ncclMemManager côté host gère le cycle de vie des tampons de communication à l'aide du comptage de références et de l'API CUDA VMM. Mais l'endroit où la communication se produit réellement est le kernel GPU — les threads du kernel doivent savoir : quel rank suis-je ? À quelle adresse virtuelle se trouve le tampon du rank distant ? La connexion est-elle prête ? Ces informations se trouvent dans la structure ncclComm côté host, mais le kernel ne peut pas déréférencer directement un pointeur host. Si NCCL obligeait le kernel à récupérer ces métadonnées à chaque fois via des paramètres ou des requêtes en mémoire globale, chaque communication entraînerait une latence et une consommation de bande passante supplémentaires. Pire encore, une fois le code du kernel compilé, les décalages des champs auxquels il accède sont figés — si la disposition de ncclComm change après une mise à jour de la bibliothèque, l'ancien kernel lira des données erronées. C'est le problème central que devcomm doit résoudre : mapper les métadonnées clés du domaine de communication côté host, avec une disposition mémoire stable et versionnée, vers des structures accessibles côté device. Les fichiers devcomm_v22902.cc, devcomm_v22907.cc, devcomm_v23000.cc, devcomm_v23100.cc dans le répertoire src/devcomm sont les implémentations concrètes de cet ABI versionné. Chaque fichier correspond à une plage de versions de NCCL, définit la disposition mémoire exacte de ncclDevComm pour cette plage, ainsi que la logique de copie des champs entre anciennes et nouvelles versions. Ce chapitre décomposera successivement : à quoi ressemblent les structures de données centrales du communicateur côté device, comment fonctionnent le mécanisme d'enregistrement et de correspondance de l'ABI versionné, comment s'effectue la conversion au niveau des champs entre anciennes et nouvelles versions, ainsi que les limites et les pièges de ce mécanisme en production.
I. Structure centrale du communicateur côté device : disposition mémoire de ncclDevComm
Modèle intuitif
ConsidérezncclDevCommcomme une « carte de poste » : au lancement de chaque kernel GPU, une carte est remise, sur laquelle est imprimé « tu es le rank 3, il y a 8 ranks au total, ton groupe LSA contient 4 ranks, l'adresse de base du tampon distant est à 0x7f... ». Cette carte doit être suffisamment petite (pour tenir dans les paramètres du kernel), tout en contenant toutes les informations clés. Si cette carte n'existait pas, le kernel devrait se reposer sur des paramètres transmis à plusieurs reprises par le host, à réassembler à chaque communication — latence élevée, propice aux erreurs.
Structures de données et disposition mémoire
PrenonsncclDevComm_v23000comme exemple, sa définition complète se trouve dans📎 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-93Une série destatic_assertfige le décalage de chaque champ. Ce n'est pas décoratif — c'est un contrat de compilation pour la compatibilité ABI. Si le décalage d'un champ se déplace en raison d'un changement de stratégie d'alignement du compilateur, la compilation échouera, plutôt que de produire à l'exécution un décalage mémoire difficile à déboguer.
Motivation de conception de quelques champs clés :
nRanks_rcp32etlsaSize_rcp32: c'estnRanksetlsaSizeL'inverse de , représenté en nombre à virgule fixe de 32 bits. Lorsque le kernel effectue l'opération de division pour calculer le décalage de rank vers buffer, la division entière du GPU est très lente ; la méthode consistant à multiplier par l'inverse puis à décaler permet un gain de vitesse significatif. C'est un cas typique de « sacrifier l'espace pour gagner du temps » — stocker 4 octets supplémentaires pour économiser les dizaines de cycles d'horloge de chaque division.
resourceWindow_inlined: il s'agit d'un descripteur de fenêtre en ligne, de typencclResourceWindow_vidmem_v23000_t. Notez📎 src/devcomm/devcomm_v23000.cc:11-18sa définition dans :
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;Ici,reserved1、reserved2、reserved3est unchamp de remplissage, utilisé comme espace réservé. Pourquoi un remplissage est-il nécessaire ? Parce que la disposition dencclDevComm_v23000doit rester cohérente en décalage avec une « version de référence », même si certains champs ne sont plus utilisés dans la version actuelle, il faut conserver l'espace réservé pour garantir que les décalages des champs suivants restent inchangés.📎 src/devcomm/devcomm_v23000.cc:11-18Le commentaire de indique clairement : 2.30u1 réduitreserved3de 40 octets à 32 octets, libérant 8 octets pourhybridWorldGinBarrier. Il s'agit d'uneréorganisation de la disposition— en réduisant la zone de remplissage, on insère de nouveaux champs sans modifier la taille globale.
📎 src/devcomm/devcomm_v23000.cc:11-18Lestatic_assertde confirme davantage :lsaFlatBase、stride4G、mcOffset4Kles décalages des trois champs doivent être cohérents avec lencclWindow_vidmemde la « version actuelle », et la taille totale de la structure doit être de 64 octets. Cela signifie queresourceWindow_inlinedestbinairement compatibleentre v23000 et la version actuelle — un memcpy direct est possible.
La famille des structures versionnées
En comparantncclDevComm_v22902 📎 src/devcomm/devcomm_v22902.cc:38-62etncclDevComm_v22907 📎 src/devcomm/devcomm_v22907.cc:13-41, on peut observer l'évolution des champs :
| Champ | v22902 | v22907 | v23000 |
|---|---|---|---|
magic/version | Aucun | Aucun | Présent (décalage 0/4) |
ginContextCount | uint8_t | uint32_t | uint32_t |
ginNetDeviceTypes | [4] | [NCCL_GIN_MAX_CONNECTIONS] | [NCCL_GIN_MAX_CONNECTIONS] |
ginIsRailed | Aucun | bool | Scindé enginConnectionsRailed + ginContextsRailed |
hybridWorldGinBarrier | Aucun | Aucun | Présent (décalage 112) |
| Taille de la structure | 200 | 224 | 240 |
Ce chemin d'évolution révèle la stratégie de versionnage de NCCL :n'ajouter des champs que lorsque c'est nécessaire, et exploiter autant que possible la zone de remplissage. De v22902 à v22907, des champs liés à GIN tels queginSignalBase、ginCounterBase、ginContextBase、ginIsRailedont été ajoutés ; de v22907 à v23000, le champ de vérificationmagic/versionethybridWorldGinBarrieront été ajoutés, tout en scindantginIsRaileden deux indicateurs plus précis.
---
II. Enregistrement et correspondance de l'ABI versionnée : la structure ncclDevCommCompat
Modèle intuitif
Imaginez l'ABI versionnée comme un ensemble de « plugins de traduction » : lorsqu'une application est compilée avec NCCL 2.29.2 mais liée à l'exécution à la bibliothèque 2.31.0, la bibliothèque doit savoir « quelle disposition dencclDevCommle kernel 2.29.2 attend », puis traduire lencclDevCommde la version actuelle vers l'ancienne disposition. Chaque intervalle de version correspond à un plugin de traduction, enregistré dans une table globale.
Structure centrale : ncclDevCommCompat
Chaquedevcomm_vXXXXX.ccfichier définit à la fin une structurencclDevCommCompat. Prenons v23000 comme exemple📎 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
};Signification des six champs :
1. minVersion / maxVersion: l'intervalle de version dont ce plugin est responsable. v23000 couvre 2.30.0 à 2.30.7.
2. commPropertiesFilter: filtre optionnel, utilisé pour ajuster les indicateurs de capacité exposés aux anciennes versions dansncclCommProperties. v23000 est défini ànullptr, indiquant qu'aucun filtrage n'est nécessaire.
3. devCommRequirementsFilter: vérifie si les ressources côté device demandées par l'application sont compatibles avec l'ancienne version. L'implémentation de v23000📎 src/devcomm/devcomm_v23000.cc:95-98se contente de copierginTypedepuiscomm->sharedResversreqs。
4. devCommCopyNewToOld: copier lencclDevCommde la version actuelle vers l'ancienne disposition.
5. devCommCopyOldToNew: copier l'ancienne disposition vers la version actuelle.
Division des intervalles de version
Intervalles de version des quatre fichiers :
| Fichier | minVersion | maxVersion | Remarque |
|---|---|---|---|
devcomm_v22902.cc | 2.29.2 | 2.29.3 | La plus ancienne implémentation versionnée |
devcomm_v22907.cc | 2.29.5 | 2.29.7 | Ajout des champs GIN, mais sans rétrocompatibilité GIN |
devcomm_v23000.cc | 2.30.0 | 2.30.7 | Ajout de la vérification magic/version |
devcomm_v23100.cc | 2.31.0 | Version actuelle | Tous les filtres sont nullptr, indiquant une compatibilité totale |
📎 src/devcomm/devcomm_v23100.cc:10-17Tous les callbacks du plugin v23100 denullptrsont , ce qui signifie qu'à partir de 2.31.0, la disposition dencclDevCommest déjà stable et ne nécessite aucune conversion.
Notez qu'il existe un « trou » entre les intervalles de version de v22902 et v22907 (2.29.4 et 2.29.6 n'ont pas de plugin correspondant). Cela peut être dû au fait que ces versions n'ont pas été publiées, ou que leur disposition est identique à celle des versions adjacentes et peut être réutilisée.
Processus de correspondance
Lorsqu'une application appellencclCommGetDeviceHandleou une API similaire, NCCL doit :
1. Lire le numéro de version NCCL intégré à la compilation de l'application (viareqs->version)。
2. Rechercher dans la table globalencclDevCommCompatle plugin couvrant cette version.
3. Si trouvé, appeler ledevCommCopyNewToOlddu plugin pour convertir la disposition actuelle en ancienne disposition.
4. Si non trouvé, renvoyer une erreur ou utiliser le comportement par défaut.
Le diagramme ci-dessous illustre ce processus de correspondance et de conversion :
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. Conversion au niveau des champs : comment convertir entre anciennes et nouvelles dispositions
Modèle intuitif
La conversion de version est comme une « traduction » : lencclDevCommde la nouvelle version est un article en chinois moderne, la disposition de l'ancienne version est un texte en chinois classique. Le traducteur doit faire correspondre champ par champ — certains champs correspondent directement (rankàrank), certains nécessitent une « traduction libre » (ginConnectionStride > 1traduit enginConnectionsRailed = true), certains champs n'existent pas dans l'ancienne version (simplement abandonnés).
Conversion NewToOld : de la version actuelle vers l'ancienne version
PrenonsncclDevCommCopyNewToOld_v23000comme exemple📎 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);
...
}Étapes clés :
1. memsetMise à zéro de 📎 src/devcomm/devcomm_v23000.cc:118: c'est une protection de sécurité — l'ancienne structure peut contenir des champs qui n'existent pas dans la nouvelle version ; la mise à zéro empêche la fuite de mémoire non initialisée vers le côté device.
2. Copie directe des champs:rank、nRanks、lsaRankaffectations directes telles que .
3. Conversion de fenêtre en ligne: appel dencclDevCommCopyResourceWindowNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:100-105, copie champ par champ delsaFlatBase、stride4G、mcOffset4K。
4. Conversion sémantique:ginConnectionsRailed = (newDevComm->ginConnectionStride > 1) 📎 src/devcomm/devcomm_v23000.cc:142. La nouvelle version utiliseginConnectionStride(un pas entier) pour indiquer si railed, l'ancienne version utilise une valeur booléenne. Lorsque le pas est supérieur à 1, cela indique que la connexion est railed.
5. Copie de tableau:memcpycopie des tableauxginNetDeviceTypesetginHandles📎 src/devcomm/devcomm_v23000.cc:135-136。
Conversion OldToNew : de l'ancienne version vers la version actuelle
La conversion inverse se trouve dans📎 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;
...
}Notez la conversion sémantique de📎 src/devcomm/devcomm_v23000.cc:180-181: si dans l'ancienne versionginConnectionsRailedest vrai, alors dans la nouvelle versionginConnectionStrideest défini àlsaSize; sinon, définir à 1. Ici, on utiliselsaSizecomme pas, car en mode railed, les ranks au sein de chaque groupe LSA partagent une connexion GIN, et le pas est égal à la taille du groupe LSA.
Traitement spécial de v22902
ncclDevCommCopyOldToNew_v22902 📎 src/devcomm/devcomm_v22902.cc:149-167Il y a un commentaire important :
// 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.Cela signifie qu'avant la version 2.30.0,ncclDevCommn'a pas le champmagic/version, donc la bibliothèque ne peut pas distinguer si une ancienne structure est v22902 ou v22907. Par conséquent, ledevCommCopyOldToNewde v22907 est défini ànullptr 📎 src/devcomm/devcomm_v22907.cc:128, et c'est en réalité la version de v22902 qui est utilisée. Comme aucune des deux ne prend en charge la rétrocompatibilité GIN, les différences dans les champs liés à GIN n'affectent pas la correction.
Versionnage de la fenêtre de ressources
ncclWindow_vidmem_v22902La définition dedevcomm_v22902.hse trouve dans📎 src/devcomm/devcomm_v22902.cc:141(le contenu de ce fichier n'est pas fourni dans ce chapitre), mais d'après📎 src/devcomm/devcomm_v22902.cc:164etncclDevCommCopyResourceWindow_v22902, on peut voir que v22902 utilisedevcomm_v22902.hpour la conversion de fenêtre. Cette fonction est déclarée dans
📎 src/devcomm/devcomm_v23000.cc:11-18, mais son implémentation spécifique n'est pas montrée dans le code source de ce chapitre.static_assertLe
---
de
valide que la disposition de fenêtre de v23000 est cohérente avec la version actuelle, donc la fonction de conversion de v23000 peut copier champ par champ directement.
IV. Filtrage des capacités et vérification des ressources : empêcher les anciens kernels d'accéder à des fonctionnalités non prises en chargencclDevCommModèle intuitif
La conversion de version ne consiste pas seulement à « déplacer des champs » — il faut aussi vérifier si l'ancienne version prend en charge les fonctionnalités demandées par l'application. Par exemple, un kernel compilé avec 2.29.2 demande des ressources GIN, mais dans la disposition de
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;
}commPropertiesFilter : filtrage des indicateurs de capacité
1. deviceApiSupportCopierTrois opérations :
2. ginTypeRétrograder: si le nombre de ranks du groupe LSA n'est pas égal au nombre total de ranks (c'est-à-dire qu'il existe une communication inter-nœuds), désactiver l'API device. En effet, le GIN de 2.29.7 ne prend pas en charge l'inter-nœuds.
3. railedGinTypeDéfinir à NONE: indiquer explicitement à l'application que « cette version ne prend pas en charge GIN ».
ncclCommPropertiesFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:86-96Définir à NONE
// 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-17Similaire, mais avec un détail supplémentaire :
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;définit l'énumération des types GIN de v22902 :uint8_tCopierginTypeNoter qu'il s'agit du typeint, alors que dans la nouvelle version,propsestncclCommProperties_v22902*. Donc le filtre de v22902 doit convertir de forceuint8_tenginType。📎 src/devcomm/devcomm_v22902.cc:35-36, puis écrire dans lestatic_assertde typeginType. Le
de
ncclDevCommRequirementsFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:79-98valide que
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;
}devCommRequirementsFilter : vérification des demandes de ressources
1. Vérifie si l'application a demandé des ressources GIN ::reqs->ginSignalCount、ginCounterCount、barrierCount、railGinBarrierCountCopier
2. La logique se décompose en deux étapes :Vérifier la requête de niveau supérieurresourceRequirementsListSi l'un d'eux est supérieur à 0, cela signifie que des ressources GIN sont demandées.ginSignalCountParcourir la liste chaînée des besoins en ressourcesginCounterCount。
: si le niveau supérieur n'a pas de requête, continuer à parcourir la liste chaînéeginConnectionType, et vérifier pour chaque nœudNONEetginForceEnableSi des ressources GIN sont effectivement demandées, et quencclInvalidUsagen'est pas
ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126ou quebarrierCountest vrai, alors retourner
// 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;:barrierCountCopierbarrierCount〔Inférence de conception et compromis architecturaux〕barrierCountAvant 2.29.4,lsaBarrierCountne représentait que la barrière LSA, sans impliquer de besoin GIN. À partir de 2.29.4,barrierCountimplique un besoin GIN. Pour assurer la compatibilité avec les anciennes versions, le filtre convertitrailGinBarrierCount。
en
sequenceDiagram
participant App as 应用层
participant Host as Host 侧 NCCL 库
participant Compat as ncclDevCommCompat 插件
participant Dev as 设备侧 ncclDevComm
App->>Host: ncclCommGetDeviceHandle(comm, &devComm)
Host->>Host: 读取 reqs->version(应用编译版本)
Host->>Compat: 查找覆盖该版本的插件
Compat-->>Host: 返回 ncclDevCommCompat_vXXXXX
Host->>Compat: devCommRequirementsFilter(comm, reqs)
alt 请求了不支持的 GIN 资源
Compat-->>Host: ncclInvalidUsage
Host-->>App: 返回错误 + 警告日志
else 资源兼容
Compat-->>Host: ncclSuccess
Host->>Compat: devCommCopyNewToOld(comm, oldDevComm, newDevComm)
Compat->>Compat: memset(old, 0, sizeof(*old))
Compat->>Compat: 逐字段拷贝 + 语义转换
Compat-->>Host: ncclSuccess
Host->>Dev: 返回旧布局 ncclDevComm
Dev-->>App: 设备侧可访问的通信器
end---
et
Le diagramme de séquence ci-dessous montre l'interaction complète depuis la requête de l'application jusqu'à la conversion de version :
CopierV. Guide de production pour éviter les pièges et chaîne de récupération après incidentncclGinPut)。
Piège 1 : conflit entre les demandes de ressources GIN et les kernels d'anciennes versions:ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126ScénarioginForceEnable: l'application est compilée avec NCCL 2.29.2, mais est liée à l'exécution à la bibliothèque 2.31.0. L'application appelle dans le kernel des API côté device liées à GIN (commeginSignalCount > 0Ce qui se passencclInvalidUsagedétecte
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., retourne, et affiche un avertissement :ncclDevComm_v22902CopierginContextCount、ginNetDeviceTypes、ginHandlesCause racine
: dans la disposition dede 2.29.2, les champs GIN (
, etc.) sont incompatibles avec la disposition de 2.31.0. Si l'on force la conversion, le kernel lira des offsets incorrects, entraînant un comportement indéfini.
Bonne pratique: l'application doit être recompilée avec la même version de NCCL (ou une version compatible) que la bibliothèque d'exécution. Si la recompilation est impossible, il faut éviter d'utiliser les API GIN dans le kernel.ncclTeamLsa(comm).nRanks != comm->nRanks)。
Piège 2 : l'API device est silencieusement désactivée lors de communications inter-nœuds:ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77Scénarioprops->deviceApiSupport: l'application est compilée avec 2.29.7, et le domaine de communication contient des ranks inter-nœuds (falseCe qui se passe
définità
. Si l'application vérifie cet indicateur, elle saura que l'API device n'est pas disponible ; mais si elle ne le vérifie pas et appelle directement l'API côté device, elle obtiendra un comportement indéfini.Cause racinencclCommProperties.deviceApiSupport: le GIN de 2.29.7 ne prend pas en charge l'inter-nœuds. Seuls les ranks au sein d'un groupe LSA (Local SHARP Aggregation) peuvent utiliser l'API côté device.falseBonne pratique
: l'application doit vérifier
après l'initialisation, et si c'est:ncclDevCommCopyNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:118, revenir à l'API côté host.memset(old, '\0', sizeof(*old))。
Piège 3 : mise à zéro par memset et fuite de champs non initialisésScénarioginSignalBase、ginCounterBaseexécute
avant la copie: Si le développeur implémente manuellement la conversion de version et oublie de mettre à zéro, le kernel peut lire des valeurs aléatoires, se manifestant par des erreurs intermittentes — difficiles à reproduire et à déboguer.
Bonne pratique: Toujours mettre à zéro toute la structure cible avant la conversion. Toutes les implémentationsCopyNewToOldde NCCL suivent ce modèle📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。
Piège quatre : échec de correspondance dû à un trou dans les intervalles de version
Scénario: L'application est compilée avec NCCL 2.29.4. Consultez le tableau des intervalles de version :
| Fichier | minVersion | maxVersion |
|---|---|---|
| v22902 | 2.29.2 | 2.29.3 |
| v22907 | 2.29.5 | 2.29.7 |
2.29.4 n'a pas de plugin correspondant.
Ce qui se passe: Si la logique de correspondance recherche strictement par intervalle, 2.29.4 échouera à correspondre et retournera une erreur. Mais dans l'implémentation réelle, il peut y avoir une stratégie de « correspondance la plus proche » — 2.29.4 pourrait être routé vers le plugin v22902 ou v22907.
Bonne pratique: L'application devrait utiliser autant que possible le même numéro de version majeure que la bibliothèque d'exécution. Si un croisement de versions est nécessaire, il faut tester si l'intervalle de version cible dispose d'un plugin compatible correspondant.
Chaîne de récupération d'erreur
Lorsqu'une conversion de version échoue, la chaîne de récupération d'erreur de NCCL :
1. Le filtre retourne une erreur:devCommRequirementsFilterretournencclInvalidUsage。
2. L'API de niveau supérieur capture l'erreur:ncclCommGetDeviceHandlevérifie la valeur de retour, si nonncclSuccess, ne remplit pas la structuredevComm.
3. Traitement par l'application: L'application doit vérifier la valeur de retour, et en cas d'échec, revenir à l'API côté host ou terminer la communication.
4. Journalisation: NCCL imprime des logs de niveauWARN, incluant la version de compilation et la version d'exécution, pour aider à localiser le problème.
Actuellement, NCCL ne fournit pas de mécanisme de « dégradation automatique » — si la conversion de version échoue, il ne revient pas automatiquement à l'API côté host. L'application doit implémenter elle-même la logique de repli.
---
Réflexion de conception
Pourquoi utiliser des structures versionnées plutôt qu'une « ABI stable » ?
Une alternative serait de concevoir une dispositionncclDevComm« immuable », où tous les nouveaux champs sont accédés via des pointeurs indirects. Mais cela pose deux problèmes : premièrement, l'accès indirect augmente la latence (le kernel doit effectuer un déréférencement supplémentaire), deuxièmement, il est impossible d'exploiter les zones de remplissage pour optimiser la disposition. NCCL choisit les structures versionnées, ce qui est un compromis entre « performance » et « compatibilité » — les kernels de chaque intervalle de version obtiennent une disposition optimale, et la compatibilité inter-versions est assurée par une couche de conversion.
Pourquoi ledevCommCopyOldToNewde v22907 est-il défini à nullptr ?
📎 src/devcomm/devcomm_v22902.cc:153-155Les commentaires de expliquent la raison : avant 2.30.0,ncclDevCommn'avait pas de champ de version, donc les anciennes dispositions de v22902 et v22907 ne peuvent pas être distinguées. Comme aucune des deux ne prend en charge la rétrocompatibilité GIN, la différence des champs GIN n'affecte pas la correction, donc la fonction de conversion de v22902 est réutilisée.
PourquoinRanks_rcp32utilise-t-il des nombres à virgule fixe plutôt que des nombres à virgule flottante ?
La précision de la division en virgule flottante du GPU peut être insuffisante pour représenter exactement1/nRanks, en particulier lorsquenRanksn'est pas une puissance de 2. Les nombres à virgule fixe (décimales représentées par des entiers 32 bits) peuvent fournir une précision suffisante, et la multiplication entière est plus rapide que la multiplication en virgule flottante.
---
Résumé de ce chapitre
Ce chapitre a décomposé l'implémentation de l'ABI versionnée dans le répertoiresrc/devcomm:
1. ncclDevCommLa disposition mémoire de: chaque version a des décalages de champs précis, vérifiés à la compilation parstatic_assert. Les champs clés incluentrank、nRanks、nRanks_rcp32、lsaRank、lsaSize、windowTable、resourceWindow, etc.
2. Enregistrement de l'ABI versionnée: chaque intervalle de version correspond à une structurencclDevCommCompat, contenantminVersion、maxVersion, la fonction de filtre et la fonction de conversion.
3. Conversion au niveau des champs:CopyNewToOldetCopyOldToNewcopient champ par champ et gèrent les changements sémantiques (commeginConnectionStride > 1converti enginConnectionsRailed = true)。
4. Filtrage des capacités:commPropertiesFilterajuste les indicateurs de capacité exposés aux anciennes versions,devCommRequirementsFiltervérifie si les demandes de ressources sont compatibles avec les anciennes versions.
5. Pièges en production: conflit entre les demandes de ressources GIN et les kernels d'anciennes versions, désactivation de l'API de périphérique lors de la communication inter-nœuds, nécessité de la mise à zéro par memset, échec de correspondance dû à un trou dans les intervalles de version.
Dans le prochain chapitre, nous aborderons l'API côté périphérique et la fusion de kernels, pour voir commentnccl_deviceles fichiers d'en-tête organisent les fonctions côté périphérique, et comment la fusion de kernels combine plusieurs opérations de communication collective en un seul kernel.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprimencclDevCommCopyNewToOld_v23000lememset(old, '\0', sizeof(*old))de , dans quel scénario le kernel lirait-il des données erronées ? Analysez en combinant les différences de champs entre v22902 et v23000.
Analyse de référence:
ncclDevComm_v22902La taille de la structure de est de 200 octets📎 src/devcomm/devcomm_v22902.cc:84, tandis quencclDevComm_v23000est de 240 octets📎 src/devcomm/devcomm_v23000.cc:95-98. v22902 contientginSignalBase(décalage 176),ginCounterBase(décalage 184),ginContextBase(décalage 204) et d'autres champs, qui n'existent pas ou ont une sémantique différente dans v23000.
Si l'on supprimememset, lors de la conversion de v23000 vers v22902,oldles champs de la structure qui n'existent pas dans v23000 (commeginSignalBase、ginCounterBase) conserveront des valeurs parasites de la pile. Si le kernel lit précisément ces champs (par exemple le chemin de code GIN de l'ancien kernel), il obtiendra des valeurs aléatoires, entraînant :
- Une adresse de base de signal erronée, les opérations GIN écrivant à un emplacement mémoire incorrect.
- Une adresse de base de compteur erronée, provoquant un débordement ou un sous-débordement du compteur.
- Dans des cas extrêmes, cela peut déclencher un accès mémoire illégal, provoquant un crash du kernel.
memsetLa mise à zéro par assure que tous les champs non explicitement assignés sont à 0, ce qui est une valeur par défaut sûre. Toutes les implémentationsCopyNewToOldde NCCL incluent cette étape📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。
Q2 : Supposons que l'application soit compilée avec NCCL 2.29.4 et liée à l'exécution à la bibliothèque 2.31.0. Selon le tableau des intervalles de version de ce chapitre, 2.29.4 n'a pas de correspondancencclDevCommCompatplugin. Veuillez analyser comment NCCL pourrait gérer cette situation et comment les applications devraient l'éviter.
Analyse de référence:
Table des plages de versions :
- 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 - actuel
2.29.4 se situe dans l'écart entre v22902 et v22907. Traitements possibles :
1. Correspondance la plus proche: NCCL pourrait choisir la plus grande plage inférieure ou égale à la version demandée, soit v22902. Mais lemaxVersionde v22902 est 2.29.3, ce qui, strictement parlant, ne couvre pas 2.29.4.
2. Retour d'erreur: si la logique de correspondance suit strictement les plages, 2.29.4 échouera à la correspondance et retournerancclInvalidUsage。
3. Correspondance vers le haut: choisir la plus petite plage supérieure ou égale à la version demandée, soit v22907. Mais leminVersionde v22907 est 2.29.5, ce qui ne couvre pas non plus 2.29.4.
Dans l'implémentation réelle, NCCL pourrait avoir une stratégie de « tolérance aux pannes » — si aucune correspondance exacte n'est trouvée, essayer d'utiliser le plugin d'une plage adjacente. Mais ce n'est pas une garantie fiable.
Méthodes d'évitement pour les applications :
- Utiliser le même numéro de version majeure que la bibliothèque d'exécution (par exemple 2.31.x).
- Si un changement de version est inévitable, tester si la plage de versions cible dispose d'un plugin compatible correspondant.
- Après l'initialisation, vérifier
ncclCommProperties.deviceApiSupport, si c'estfalse, revenir à l'API côté host.
Q3: ncclDevCommRequirementsFilter_v22902contient une logique :if (reqs->barrierCount) { reqs->lsaBarrierCount = std::max(reqs->lsaBarrierCount, reqs->barrierCount); reqs->barrierCount = 0; }. Veuillez expliquer pourquoi cette conversion est nécessaire et ce qui se passerait sans elle.
Analyse de référence:
📎 src/devcomm/devcomm_v22902.cc:117-121Le commentaire de indique : « Prior to 2.29.4, a non-zero barrierCount did not imply GIN, but it does since. »
Avant 2.29.4,barrierCountindiquait uniquement le nombre de barrières LSA, sans impliquer de besoin GIN. À partir de 2.29.4,barrierCountimplique un besoin GIN (c'est-à-dire que demander une barrière signifie nécessiter des ressources GIN).
Lorsqu'une application est compilée avec 2.29.2, elle peut avoir définibarrierCount > 0pour indiquer un besoin de barrière LSA, sans savoir que cela impliquerait un besoin GIN. Si la bibliothèque NCCL (2.31.0) traite directement selon la nouvelle sémantique, elle considérera que l'application a demandé des ressources GIN, puisncclDevCommRequirementsFilter_v22902détectera la demande GIN et retournerancclInvalidUsage— c'est un faux positif.
La logique de conversion transformebarrierCountenlsaBarrierCount(en prenant le maximum des deux), et remet à zérobarrierCount. Ainsi :
lsaBarrierCountpréserve le besoin de barrière de l'application.barrierCount = 0évite les faux positifs de besoin GIN.railGinBarrierCount = 0De même, car dans les anciennes versions, il n'impliquait pas non plus de besoin GIN.
Sans conversion, une application compilée avec 2.29.2 et ayant définibarrierCount > 0serait incorrectement rejetée et ne pourrait pas utiliser l'API device.
Jusqu'ici, nous avons vu comment devcomm, via un ABI versionné, mappe en toute sécurité les métadonnées clés du domaine de communication côté host vers le côté device, permettant au kernel d'obtenir le rank, les adresses et l'état de connexion sans pointeur host. Ce mécanisme résout le problème fondamental d'accès du kernel au domaine de communication, mais les capacités côté device vont bien au-delà. Lorsque l'utilisateur souhaite appeler directement des primitives de communication dans son propre kernel, voire fusionner communication et calcul dans un même kernel, des API côté device de plus haut niveau et des techniques de fusion de kernels sont nécessaires. Le chapitre suivant explorera en profondeur le répertoire nccl_device et les exemples associés, pour découvrir comment les API côté device telles que ncclBarrier, ncclLsaBarrier, ncclGinBarrier permettent au kernel utilisateur de participer à la communication, et comment la fusion de kernels réduit les coûts de lancement, poussant ainsi NCCL d'une bibliothèque vers un modèle de programmation.
Chapitre 20 : Chapitre 20 : API natives côté device et fusion d'opérateurs : pratiques de nccl_device et kernel fusion
Chapitre 20 : API natives côté device et fusion d'opérateurs : pratiques de nccl_device et kernel fusion
Dans le chapitre précédent, nous avons vu comment devcomm mappe de manière versionnée les métadonnées du ncclComm côté host vers le côté device, permettant au kernel de lire le rank, les adresses et l'état des connexions. Mais « pouvoir lire les métadonnées » et « pouvoir initier une communication » sont deux choses différentes. Si l'on dispose uniquement des métadonnées, le kernel utilisateur peut tout au plus calculer lui-même les adresses et écrire lui-même des indicateurs ; dès qu'il s'agit de synchronisation inter-rank ou de transmission de signaux inter-machines, il faut revenir côté host appeler des API collectives comme ncclAllReduce — et chaque appel de ce type implique un lancement de kernel et un aller-retour host-device. Le répertoire src/nccl_device que ce chapitre décompose est précisément la clé du passage de NCCL de « bibliothèque appelée » à « modèle programmable ». Ce qu'il fournit n'est pas un nouvel algorithme de communication collective, mais un ensemble de primitives côté device : permettre au kernel de l'utilisateur d'appeler en interne des opérations de synchronisation telles que ncclBarrier, ncclLsaBarrier, ncclGinBarrier, afin d'intégrer « communication » et « calcul » dans un même kernel et d'éliminer les frais de lancement intermédiaires. Le matériel source de ce chapitre se concentre sur la déclaration des besoins côté host (CreateRequirement) et l'abstraction Team de ce groupe de primitives, qui constituent précisément le point d'entrée de l'API côté device. Un prérequis essentiel pour comprendre ce chapitre : la philosophie de conception de l'API côté device est « le côté host déclare les besoins en ressources, le côté device consomme les ressources ». Le côté host ne crée pas directement de barrier, mais indique à NCCL « j'ai besoin de nBarriers barrières, l'équipe compte team.nRanks membres » ; NCCL calcule en conséquence le nombre de buffers et de signaux GIN nécessaires, puis instancie ces ressources côté device. Cette séparation « déclaration-consommation » est la raison fondamentale pour laquelle le code côté device peut fonctionner sans pointeur host.
I. Abstraction Team : le système de coordonnées de l'API côté device
Modèle intuitif
Imaginez l'organigramme d'une multinationale. Pour envoyer un e-mail, il faut d'abord savoir « à qui » — à toute l'entreprise (World), aux collègues du même bureau (LSA), ou à l'équipe inter-bureaux d'une même ligne métier (Rail).ncclTeam_tC'est le descripteur de ce « périmètre de destinataires ». Sans l'abstraction Team, chaque API côté device devrait recalculer elle-même « quel est mon rang dans ce domaine de communication et combien nous sommes au total », ce qui entraînerait une duplication de code et une forte propension aux erreurs.
Structures de données et disposition mémoire
ncclTeam_tC'est le système de coordonnées de l'API côté device ; ses trois champs définissent unesuite arithmétique:
| Champ | Signification | Analogie |
|---|---|---|
nRanks | Nombre total de membres dans l'équipe | Combien de personnes dans le groupe |
rank | Numéro du rank actuel au sein de l'équipe | Mon numéro dans le groupe |
stride | Pas entre membres adjacents de l'équipe dans le world | Différence de numéro d'étudiant entre deux voisins dans le groupe |
strideC'est le champ le plus facilement négligé mais le plus crucial. Dans l'équipe World,stride = 1, car tous les ranks sont disposés consécutivement ; mais dans l'équipe Rail,stride = lsaSize, car les ranks d'un même rail n'apparaissent dans le world que tous leslsaSize.
📎 src/nccl_device/core.cc:13-19Montre la construction de l'équipe World : on prend directementcomm->nRanksetcomm->rank,stridefixé à 1. C'est la seule équipe qui n'a pas besoin dencclDevrInitOnce, car ses informations se trouvent entièrement côté host danscomm.
📎 src/nccl_device/core.cc:22-33Est l'équipe LSA. Notez lencclDevrInitOnce(comm)de L26 — c'est le point d'entrée idempotent de l'initialisation des ressources côté device. Les commentaires de L23-25 sont très importants :on ignore délibérément l'erreur, car si l'initialisation échoue, l'équipe retournée est une « valeur poubelle », mais le prochain appel d'API nécessitant réellement des ressources déclenchera à nouveauncclDevrInitOnceet signalera l'erreur. C'est une stratégie de « rapport d'erreur différé », qui évite de lever des erreurs lourdes sur des opérations légères comme la consultation d'équipe.
Parcours guidé par scénario : transformation de coordonnées de World à Rail
Supposons une machine à 8 cartes,lsaSize = 4(un domaine LSA par 4 cartes),nRanks = 8. Voyons commentncclTeamRailest construit :
📎 src/nccl_device/core.cc:70-79DansnRanks = 8 / 4 = 2,rank = comm->rank / 4,stride = 4. Si le rank actuel est 5, alors sonrank = 5 / 4 = 1,stride = 4dans l'équipe Rail signifie que les membres de l'équipe Rail sont les ranks 1 et 5 du world.
Regardons maintenantncclTeamRankToWorldla formule de conversion :
📎 src/nccl_device/core.cc:82-84Lecomm->rank + (rank - team.rank) * team.stridedeest undécalage relatif(rank - team.rank)Calcul : on calcule d'abord le décalage du rank cible par rapport au rank actuel au sein de l'équipestride, puis on multiplie par le passtride, et on ajoute le numéro world du rank actuel. Cette formule est universelle pour toutes les équipes, car
ncclTeamRankToLsaencode déjà la règle de disposition de l'équipe.
📎 src/nccl_device/core.cc:87-92En revanche,comm->devrState.lsaSelf + (rank - team.rank) * team.strideest différent :lsaSelfutilisecomm->rank. Notez qu'ici on utilise
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— car la numérotation LSA n'est connue qu'après l'initialisation des ressources côté device et peut différer du world rank.ncclLsaBarrierCreateRequirementCopier
Ce schéma révèle le chemin d'exécution de la stratégie de « rapport d'erreur différé » : en cas d'échec d'initialisation, une équipe vide est retournée, mais l'appelant n'est pas interrompu ; l'erreur sera exposée au prochain appel d'API nécessitant réellement des ressources (comme
).ncclTeamWorldRéflexions de conception et piègesncclDevrInitOnce?Pourquoicomm, sans nécessiter aucune ressource côté dispositif. Si on l'appelle de force, une opération de requête purement hôte dépendra de l'initialisation côté dispositif, ajoutant des points de défaillance inutiles.
Points de piège:ncclTeamRankToLsaretourne en cas d'échec d'initialisation-1(📎 src/nccl_device/core.cc:87-92), tandis quencclTeamRankToWorldn'échoue jamais. Si l'appelant mélange ces deux fonctions sans vérifier les valeurs de retour, il peut obtenir-1en cas d'échec d'initialisation LSA et l'utiliser comme un rang valide, provoquant un accès hors limites. Dans le code de production, il faut traiter la valeur de retour dencclTeamRankToLsacomme une opération susceptible d'échouer.
---
II. Déclaration des besoins de Barrier : comment le côté hôte « réserve » les ressources du dispositif
Modèle intuitif
L'allocation de ressources de l'API côté dispositif ressemble àréserver une salle de réunion: on ne peut pas faire irruption directement dans la salle pour tenir la réunion, il faut d'abord soumettre une demande à l'accueil (côté hôteCreateRequirement) — « Je veux tenir 3 réunions, chacune avec 8 participants ». L'accueil calcule alors la taille de salle nécessaire (bufferSize), le nombre de chaises requis (ginSignalCount), puis vous donne le numéro de salle (outBufferHandle). Sans ce mécanisme de réservation, le kernel côté dispositif ne saurait pas où se trouve son tampon de barrier ni quelle est sa taille, et ne pourrait pas lire/écrire en toute sécurité.
Structures de données et disposition mémoire
Les trois fonctionsCreateRequirementdes barriers partagent le même modèle :mettre à zéro la structure de besoins → remplir la taille/alignement du tampon → remplir le pointeur de handle de sortie. Mais leurs types de ressources diffèrent :
| Type de Barrier | Type de ressource | Formule de taille | Alignement |
|---|---|---|---|
| LSA Barrier | Tampon | (3*n + n*team.nRanks) * sizeof(uint32_t) | alignof(uint32_t) |
| CFT Barrier | Tampon | (3*n + n*team.nRanks) * NCCL_CFT_BARRIER_GRAN | NCCL_CFT_BARRIER_ALIGN |
| GIN Barrier | Signal GIN | n * team.nRankssignaux | Ne concerne pas de tampon |
Regardons d'abord la formule de taille du LSA Barrier :
📎 src/nccl_device/lsa_barrier.cc:14-22le(3 * nBarriers + nBarriers * team.nRanks) * sizeof(uint32_t)peut être décomposé en deux parties :
3 * nBarriers: chaque barrier nécessite 3 champs de contrôleuint32_t([INFERENCE] généralement « compteur d'arrivée », « tour », « indicateur d'état »).nBarriers * team.nRanks: chaque barrier doit réserver un emplacement d'arrivéeuint32_tpour chaque membre de l'équipe.
Donc la taille totale d'un barrier est de3 + team.nRanksunitésuint32_t. Cette formule est identique en LSA et CFT, sauf que CFT utiliseNCCL_CFT_BARRIER_GRANcomme unité de granularité (peut-être pour s'aligner sur une frontière plus grande).
Le GIN Barrier est complètement différent :
📎 src/nccl_device/gin_barrier.cc:14-20n'alloue pas de tampon, mais définitginSignalCount = nBarriers * team.nRanks, et fait pointeroutGinSignalStartvers lesignal0dans le handle. C'est parce que le GIN barrier passe par le chemin de signal réseau, n'a pas besoin de tampon mémoire partagée, mais a besoin d'emplacements de signal reconnaissables par la carte réseau.
Parcours guidé par scénario : une réservation complète de LSA Barrier
Supposons que l'utilisateur veuille créer 2 barriers sur une équipe LSA de 4 cartes :
1. Appel ncclLsaBarrierCreateRequirement(team, 2, &handle, &req)。
2. Mise à zéro:memset(outReq, 0, sizeof(*outReq))(📎 src/nccl_device/lsa_barrier.cc:14-22) — garantit que les champs non définis ont une valeur déterministe, évitant que l'appelant ne lise des données parasites sur la pile.
3. Enregistrer le nombre de barriers:outHandle->nBarriers = 2(📎 src/nccl_device/lsa_barrier.cc:14-22)。
4. Calculer la taille du tampon:(3*2 + 2*4) * 4 = (6 + 8) * 4 = 56octets (📎 src/nccl_device/lsa_barrier.cc:14-22)。
5. Définir l'alignement:alignof(uint32_t) = 4(📎 src/nccl_device/lsa_barrier.cc:14-22)。
6. Remplir le pointeur de handle:outReq->outBufferHandle = &outHandle->bufHandle(📎 src/nccl_device/lsa_barrier.cc:14-22) — permet à NCCL d'écrire l'adresse dans le handle après l'allocation réelle du tampon.
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 --> barCe diagramme de flux de données illustre la séparation entre « déclaration » et « consommation » : le côté hôte ne fait que calculer la taille et les pointeurs, l'allocation et l'instanciation réelles du tampon se produisent à l'intérieur de NCCL, et le kernel côté dispositif reçoit un handle déjà rempli.
Réflexions de conception et pièges
Pourquoi utilisermemsetpour mettre à zéro tout leoutReq?Parce quencclDevResourceRequirements_test une structure multi-champs, et que différents types de barrier n'en remplissent qu'une partie. La mise à zéro garantit que les champs inutilisés (commeginSignalCountnon utilisé par LSA barrier) sont à 0, et NCCL en déduit en interne « cette ressource n'est pas nécessaire ». Sans mise à zéro, des valeurs aléatoires sur la pile pourraient être interprétées à tort comme « ressource GIN nécessaire », déclenchant le problème de faux positif mentionné au chapitre précédent.
Points de piège:outReq->outBufferHandle = &outHandle->bufHandleconfie à NCCL l'adresse d'un champ interne du handle. Cela signifie queoutHandledoit rester valide jusqu'à ce que NCCL termine l'allocation du tampon (ne peut pas être récupéré par la pile ni déplacé). Si l'utilisateur placeoutHandledans une portée qui sera libérée prématurément, NCCL écrira dans un pointeur sauvage lors du remplissage.
Différence de granularité du CFT Barrier:📎 src/nccl_device/cft_barrier.cc:13-21utiliseNCCL_CFT_BARRIER_GRANetNCCL_CFT_BARRIER_ALIGNà la place desizeof(uint32_t)etalignof(uint32_t)du LSA. Cela indique que le barrier CFT (peut-être Cross-Fabric Team ou une équipe inter-domaines similaire) nécessite une granularité d'alignement plus grande, peut-être parce qu'il doit traverser plusieurs régions mémoire multicast, et que le matériel impose des exigences d'alignement d'adresse plus strictes.
---
III. Répartition sémantique des trois types de Barrier : ce que gèrent respectivement LSA, CFT et GIN
Modèle intuitif
Les trois types de barrier ressemblent à trois « rassemblements » de portées différentes :
- LSA Barrier: rassemblement de collègues dans le même bureau, via mémoire partagée, le plus rapide.
- CFT Barrier: rassemblement entre bureaux mais dans le même bâtiment, via mémoire multicast, intermédiaire.
- GIN Barrier: rassemblement entre villes voire entre pays, via signal réseau, le plus lent mais avec la couverture la plus large.
Choisir le mauvais type de barrier ne provoque pas d'erreur, mais entraîne une perte de performance énorme — utiliser un GIN barrier pour une synchronisation dans le même bureau équivaut à envoyer un courrier international pour un document du poste voisin.
Comparaison des structures de données et de la disposition mémoire
Du point de vue de la déclaration des besoins côté hôte, les besoins en ressources des trois sont radicalement différents :
| Dimension | LSA Barrier | CFT Barrier | GIN Barrier |
|---|---|---|---|
Nécessite le paramètrecomm | Non | Non | Oui |
| Tampon | Oui | Oui | Non |
| Signal GIN | Non | Non | Oui |
| Unité de taille | uint32_t | NCCL_CFT_BARRIER_GRAN | Nombre de signaux |
| Champ de handle de sortie | bufHandle | bufHandle | signal0 |
Notons que GIN Barrier est le seul à nécessiter le paramètrecomm:
📎 src/nccl_device/gin_barrier.cc:14-20la signature de la fonction inclutncclComm_t comm, tandis que les signatures de LSA et CFT n'ont quencclTeam_t team. Cela est dû au fait que les signaux GIN doivent être liés à une connexion réseau spécifique, et les informations de connexion réseau se trouvent danscomm.
Parcours guidé par scénario : allocation des signaux pour la barrière GIN
📎 src/nccl_device/gin_barrier.cc:14-20La logique est plus simple que celle de LSA, mais la sémantique est plus subtile :
1. Remise à zéro:memset(outReq, 0, sizeof(*outReq))(L16)。
2. Définition du nombre de signaux:outReq->ginSignalCount = nBarriers * team.nRanks(L17) — chaque barrière doit allouer un emplacement de signal pour chaque membre de l'équipe.
3. Remplissage du pointeur de début de signal:outReq->outGinSignalStart = &outHandle->signal0(L18) — noter qu'icibufferSizen'est pas défini, car la barrière GIN n'utilise pas de tampon en mémoire partagée.
signal0Ce nom suggère que le handle pourrait contenir un ensemble de champs de signaux contigus (signal0, signal1, ...),outGinSignalStartpointe vers le premier, NCCL sait ainsi où commencer l'allocation denBarriers * team.nRankssignaux.
Contrôle de concurrence et interaction matérielle
Les mécanismes de contrôle de concurrence des trois types de barrières sont complètement différents :
- LSA Barrier: opérations atomiques basées sur la mémoire partagée.
3 + team.nRanksParmi lesuint32_t, l'arrivée à un emplacement est marquée par un ajout atomique ou une écriture atomique pour signaler « je suis arrivé », et les champs de contrôle sont lus atomiquement pour vérifier « si tout le monde est arrivé ». Il s'agit d'une synchronisation purement interne au GPU, sans implication du réseau. - CFT Barrier: basé sur la mémoire multicast (multimem). [INFERENCE] La mémoire multicast permet à une seule opération d'écriture de mettre à jour simultanément la vue de plusieurs ranks, donc la barrière CFT pourrait utiliser moins de champs de contrôle pour réaliser une synchronisation plus large.
- GIN Barrier: basé sur les signaux réseau.
ginSignalCountLes signaux sont envoyés via la carte réseau, et le récepteur interroge les emplacements de signaux. C'est la seule barrière impliquant du matériel inter-machines.
sequenceDiagram
participant K as "用户 Kernel"
participant LSA as "LSA 共享内存"
participant CFT as "CFT 多播内存"
participant NIC as "网卡 GIN 信号"
K->>LSA: "原子写到达槽位"
LSA-->>K: "轮询所有槽位"
Note over K,LSA: LSA barrier 完成
K->>CFT: "多播写控制字段"
CFT-->>K: "读多播状态"
Note over K,CFT: CFT barrier 完成
K->>NIC: "发送 GIN 信号"
NIC-->>K: "轮询信号槽位"
Note over K,NIC: GIN barrier 完成Ce diagramme de séquence illustre les niveaux d'interaction matérielle des trois types de barrières : de la synchronisation purement interne au GPU, à la mémoire multicast, puis aux signaux de carte réseau, la latence augmente progressivement et la portée s'élargit également.
Réflexions de conception et pièges
Pourquoi LSA et CFT n'ont-ils pas besoin du paramètrecomm?Parce que leurs ressources (mémoire partagée, mémoire multicast) ont déjà été liées à l'équipe lors de la phasencclDevrInitOnce,teamlui-même implique les informations de localisation des ressources. En revanche, les signaux GIN nécessitent une allocation dynamique de ressources réseau, et doivent accéder à l'état de connexion réseau viacomm.
Pièges: LeginSignalCountde la barrière GIN estnBarriers * team.nRanks, si l'équipe est très grande (par exemple 1024 ranks) et qu'il y a beaucoup de barrières (par exemple 100), le nombre total de signaux atteindra 102400. Les emplacements de signaux de la carte réseau sont une ressource limitée, une demande excessive peut entraîner un échec dencclDevrInitOnce. Le code de production devrait demander le nombre minimal de barrières réellement nécessaires, plutôt que de demander un grand nombre de ressources de réserve en une seule fois.
---
IV. Du déclaratif de besoin à la consommation côté device : cycle de vie complet
Modèle intuitif
CreateRequirementn'est qu'une « commande », la véritable « expédition » et « réception » se produisent à l'intérieur de NCCL et dans le kernel côté device. Le cycle de vie complet ressemble à unachat en ligne: vous passez commande (CreateRequirement) → le vendeur prépare le stock (NCCL alloue les ressources) → livraison par coursier (ressources liées au DevComm) → vous signez et utilisez (le kernel côté device appelle la barrière).
Structures de données et disposition mémoire : évolution des champs du handle
PrenonsncclLsaBarrierHandle_tcomme exemple, il passe par trois phases dans son cycle de vie :
| Phase | nBarriers | bufHandle | Autres champs |
|---|---|---|---|
| Après CreateRequirement | Défini | L'adresse est remplie, mais le contenu n'est pas alloué | Non défini |
| Après allocation NCCL | Défini | Pointe vers le tampon réel | Défini |
| Utilisation côté device | Lecture seule | Lecture seule | Lecture seule |
📎 src/nccl_device/lsa_barrier.cc:14-22DéfinitnBarriers,📎 src/nccl_device/lsa_barrier.cc:14-22Remplit l'adresse debufHandle. Entre ces deux opérations, NCCL effectue en interne l'allocation réelle du tampon.
Parcours guidé par scénario : une utilisation complète de barrière
1. Déclaration côté host: l'utilisateur appellencclLsaBarrierCreateRequirement(team, 2, &handle, &req), obtientreq.bufferSize = 56。
2. Soumission côté host: l'utilisateur remetreqàncclDevCommCreate(contenu du chapitre précédent), NCCL alloue un tampon de 56 octets et écrit l'adresse danshandle.bufHandle。
3. Initialisation côté device: au lancement du kernel utilisateur, on extraithandledu DevComm, et on localise le tampon avecbufHandle.
4. Synchronisation côté device: le kernel appellencclLsaBarrier(handle, barrierIndex), écrit la marque d'arrivée dans l'emplacement correspondant du tampon, et interroge les autres emplacements.
5. Achèvement côté device: une fois tous les ranks arrivés, la barrière retourne et le kernel continue son exécution.
flowchart TD
a["ncclLsaBarrierCreateRequirement<br/>算出 bufferSize=56"] --> b["ncclDevCommCreate<br/>分配 56 字节缓冲区"]
b --> c{"分配成功?"}
c -->|"否"| err["返回 ncclSystemError<br/>句柄无效"]
c -->|"是"| d["回填 handle.bufHandle<br/>指向实际缓冲区"]
d --> e["用户 kernel 启动<br/>从 DevComm 取 handle"]
e --> f["ncclLsaBarrier(handle, idx)<br/>写到达槽位 + 轮询"]
f --> g{"所有 rank 到达?"}
g -->|"否"| f
g -->|"是"| h["barrier 返回<br/>kernel 继续"]
err --> i["用户需检查返回值<br/>不可使用无效句柄"]Ce diagramme de décision montre le chemin complet de la déclaration à l'utilisation, ainsi que la branche d'erreur en cas d'échec d'allocation. Noter quencclLsaBarrierCreateRequirementlui-même retourne toujoursncclSuccess(📎 src/nccl_device/lsa_barrier.cc:14-22), l'échec réel se produit lors de la phase d'allocation des ressources ultérieure.
Contrôle de concurrence et interaction matérielle
Le cœur du contrôle de concurrence de la barrière côté device estopérations atomiques + barrières mémoire. Prenons l'exemple de la barrière LSA :
- Phase d'arrivée: chaque rank met à jour son propre emplacement d'arrivée par écriture atomique (ou ajout atomique). Cette étape doit utiliser la sémantique release, garantissant que toutes les opérations mémoire précédant la barrière sont visibles pour les autres ranks.
- Phase d'interrogation: chaque rank vérifie tous les emplacements par lecture atomique (ou lecture volatile). Cette étape doit utiliser la sémantique acquire, garantissant qu'après avoir vu « tout le monde est arrivé », on peut lire les données écrites par les autres avant leur barrière.
- Phase de réinitialisation: après l'achèvement de la barrière, les emplacements doivent être réinitialisés pour la prochaine utilisation. Le contrôle de concurrence de cette étape est le plus subtil — si la réinitialisation est trop rapide, elle peut écraser les marques de ranks qui n'ont pas encore lu.
3 * nBarriersCes champs de contrôle servent probablement à gérer ce problème de « tours » : un champ enregistre le tour actuel, un champ enregistre le compteur d'arrivées, et un champ sert de drapeau de réinitialisation. Ainsi, plusieurs barrières peuvent réutiliser le même ensemble d'emplacements sans confondre les tours.
Guide pour éviter les pièges en production
Piège 1 : gestion du cycle de vie des handles。outReq->outBufferHandle = &outHandle->bufHandleL'adresse des champs internes du handle a été transmise à NCCL. Si l'utilisateur détruitncclDevCommCreateavant le retour deoutHandle, NCCL écrira dans de la mémoire déjà libérée lors du remplissage. La bonne pratique consiste à lier le cycle de vie deoutHandleau DevComm, plutôt qu'à la portée de la fonction qui l'a créé.
Piège 2 : produit du nombre de barrières par la taille de l'équipe。bufferSize = (3*n + n*team.nRanks) * sizeof(uint32_t)Dansn*team.nRanks, le terme domine la taille pour les grandes équipes. 1024 ranks et 100 barrières nécessitent100*1024*4 = 409600octets, soit environ 400 Ko. Si chaque rank en demande autant, la pression sur la mémoire GPU n'est pas négligeable. Il faut allouer en fonction du nombre de barrières réellement utilisées en concurrence, et non du nombre total de barrières.
Piège 3 : épuisement des signaux GIN barrier. Les signaux GIN sont des ressources de la carte réseau, en nombre limité. Si plusieurs DevComm demandent simultanément un grand nombre de signaux GIN, les emplacements de la carte réseau peuvent être épuisés. Le code de production doit vérifier, en cas d'échec de création d'un DevComm, s'il s'agit d'un manque de signaux GIN, et envisager de réduirenBarriersou de passer à une barrière LSA.
Piège 4 : exposition tardive des échecs d'initialisation。ncclTeamLsaDes fonctions telles quencclDevrInitOncerenvoient une équipe vide (📎 src/nccl_device/core.cc:22-33) en cas d'échec deteam.nRanks > 0)。
---
V. Fusion de kernels : pourquoi intégrer communication et calcul dans un seul kernel
Modèle intuitif
Dans le mode traditionnel, un « AllReduce + fonction d'activation » nécessite deux kernels : un pour la communication, un pour le calcul. Entre les deux kernels se produit une synchronisation globale implicite — le kernel de communication doit se terminer complètement avant que le kernel de calcul puisse démarrer. C'est commeune course de relais: le premier coureur doit passer le témoin au second, et à l'instant du passage, les deux attendent. La fusion de kernels consiste à faire exécuter communication et calcul par un même kernel, commeune personne qui change de chaussures en courant, éliminant l'attente du passage de relais.
Structures de données et disposition mémoire
La clé de la fusion de kernels réside dans le fait que les primitives de communication (comme les barrières) et la logique de calcul partagent les registres et la mémoire partagée d'un même kernel. Cela implique :
- Pression sur les registres: les opérations atomiques et les boucles de polling des primitives de communication occupent des registres, réduisant le budget de registres de la logique de calcul.
- Concurrence pour la mémoire partagée: si le tampon d'une barrière LSA est placé en mémoire partagée, il entre en concurrence avec les besoins en mémoire partagée de la logique de calcul.
- Impact sur l'occupancy: l'occupancy d'un kernel fusionné est généralement inférieure à celle d'un kernel de calcul pur, car les primitives de communication nécessitent des ressources supplémentaires.
La conception de l'API côté device (déclaration des ressources côté host, consommation côté device) vise précisément à atténuer ces pressions : les ressources sont préallouées côté host, et le kernel côté device n'a qu'à lire et écrire, sans allocation dynamique, ce qui réduit l'occupation des registres.
Parcours guidé par scénario : flux d'exécution d'un kernel fusionné
Supposons que l'utilisateur veuille écrire un kernel fusionné « AllReduce + ReLU » :
1. Préparation côté host: appel dencclLsaBarrierCreateRequirementpour demander une barrière, appel dencclDevCommCreatepour allouer les ressources.
2. Lancement du kernel: le kernel utilisateur reçoit le DevComm et le handle de barrière en paramètres.
3. Phase de communication: le kernel appellencclLsaBarrierpour synchroniser tous les ranks, puis chaque rank échange des données (lecture/écriture directe via la mémoire symétrique).
4. Phase de calcul: une fois la synchronisation terminée, le kernel applique directement ReLU aux données locales, sans lancement de kernel supplémentaire.
5. Fin: le kernel se termine, le host n'a pas besoin d'attendre un kernel de communication supplémentaire.
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 -.->|"融合后省掉"| fusedCe schéma comparatif illustre le gain essentiel de la fusion : l'élimination de la synchronisation globale implicite aux frontières des kernels. En mode traditionnel, cette synchronisation coûte la latence de deux lancements de kernel plus le vidage du pipeline GPU.
Réflexions de conception et pièges
Pourquoi l'API côté device ne fournit-elle pas directement un « AllReduce fusionné » ?Parce que la forme concrète de la fusion dépend de la logique de calcul de l'utilisateur. NCCL fournit desprimitives(barrières, signaux, accès mémoire symétrique), et non desproduits finis(AllReduce+ReLU fusionné). L'utilisateur doit combiner lui-même ces primitives pour réaliser un kernel fusionné adapté à ses besoins. C'est la différence fondamentale entre un « modèle de programmation » et une « bibliothèque ».
Pièges: Le débogage d'un kernel fusionné est bien plus difficile que celui d'un kernel séparé. Si la logique de barrière comporte un bug, cela peut provoquer un blocage du kernel (interblocage), et un blocage de kernel GPU n'est pas aussi facile à diagnostiquer qu'un blocage de processus hôte. Il est recommandé d'ajouter un mécanisme de timeout dans le kernel fusionné, ou de valider d'abord la logique de barrière avec une petite équipe.
Points de piège: La baisse d'occupancy du kernel fusionné peut entraîner une perte de performance de calcul supérieure au gain des économies de communication. Avant de décider de fusionner, il faut mesurer le temps de bout en bout avant et après la fusion, plutôt que de se contenter d'observer la réduction de la latence de communication.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime l'appelncclTeamLsaà la ligne L26 dansncclDevrInitOnceet qu'on retourne directementcomm->devrState.lsaSizeetlsaSelf, dans quels scénarios le kernel côté device lirait-il des informations de team erronées ?
Analyse de référence:ncclDevrInitOnceest le point d'entrée idempotent pour l'initialisation des ressources côté device. Si on le supprime,comm->devrState.lsaSizeetlsaSelfpeuvent encore avoir leurs valeurs initiales (généralement 0 ou indéfinies). Dans un scénario de première utilisation de l'API côté device, l'appel utilisateur àncclTeamLsaobtiendra une team vide denRanks = 0. Si ensuite l'utilisateur ne vérifie pas la validité de la team et utilise directement cette team pour appelerncclLsaBarrierCreateRequirement, cela calculerabufferSize = (3*n + n*0) * 4 = 12noctets — moins que nécessaire, car le termen*team.nRanksdevient 0. Cela provoquera un débordement de tampon : à l'exécution, la barrière tentera d'écrireteam.nRanksslots d'arrivée, mais le tampon n'a alloué que3nespaces deuint32_t. Plus insidieux encore, silsaSelfvaut aussi 0,ncclTeamRankToLsaretournera un numéro de rank erroné, ce qui fera écrire les slots d'arrivée de la barrière au mauvais endroit, et il se peut qu'on n'atteigne jamais l'arrivée de tous les ranks, provoquant un blocage du kernel. C'est précisément la situation que la stratégie « retourner une valeur poubelle, la prochaine API signalera l'erreur » mentionnée dans les commentaires L23-25 cherche à prévenir — mais à condition que la prochaine API signale effectivement une erreur, et non qu'elle utilise silencieusement une taille erronée.
Q2:ncclLsaBarrierCreateRequirementLa formule de taille de(3*nBarriers + nBarriers*team.nRanks) * sizeof(uint32_t)est++. Si la team compte 8 ranks et que l'utilisateur demande 1 barrière, le tampon fait 44 octets. En supposant que les « 3 champs de contrôle » dans l'implémentation de la barrière soient respectivement « compteur d'arrivée », « tour » et « indicateur de réinitialisation », déduisez : lorsque 8 ranks arrivent simultanément, que se passe-t-il si le « compteur d'arrivée » utilise une opération
non atomique ?Analyse de référence++: Unecount++non atomique est sur GPU une séquence « lire-modifier-écrire » en trois étapes, ce n'est pas une opération atomique. Lorsque 8 ranks exécutent simultanémentcount, il peut arriver que plusieurs ranks lisent la même ancienne valeur (par exemple tous lisent 0), puis écrivent tous 1. Finalement,atomicAddn'augmente que de 1 au lieu de 8, ce qui fait que la barrière considère toujours que « tout le monde n'est pas encore arrivé », et tous les ranks bouclent indéfiniment dans la phase de polling. C'est pourquoi les slots d'arrivée d'une barrière LSA doivent utiliser des opérations atomiques (commenBarriers * team.nRanks) ou que chaque rank écrive dans son propre slot indépendant (le termenBarriers * team.nRankssert précisément à réserver un slot indépendant pour chaque rank). Si l'on adopte la solution « chaque rank écrit dans son propre slot », on n'a pas besoin d'addition atomique, seulement d'écriture atomique + barrière mémoire, car chaque slot n'a qu'un seul écrivain. Cela explique aussi pourquoi la formule de taille contient le terme
Q3:ncclGinBarrierCreateRequirement— il échange de l'espace contre de l'atomicité, évitant la compétition entre plusieurs écrivains.commnécessite le paramètrencclLsaBarrierCreateRequirementalors quecommn'en a pas besoin. Si l'on ajoutait de force le paramètrecommà la barrière LSA (en supposant que ce soit pour unifier l'interface), quel problème de conception cela introduirait-il ? Inversement, si l'on retirait le paramètre
à la barrière GIN, dans quels scénarios échouerait-elle ?Analyse de référencecomm: Le problème d'ajouter le paramètrencclDevrInitOnceà la barrière LSA est que cela introduit une dépendance inutile. Les ressources de la barrière LSA (mémoire partagée) sont déjà liées à la team lors de la phaseteam, etcommimplique en soi la localisation des ressources. Ajoutercommferait dépendre une opération purement liée à la team de l'état du domaine de communication, augmentant les points de défaillance (par exemple, sicommest invalide, la barrière LSA ne peut pas non plus être créée), et violerait le principe du « moindre privilège ». Inversement, retirer le paramètrencclGinBarrierCreateRequirementà la barrière GIN ferait échouer celle-ci, car le signal GIN doit être lié à une connexion réseau concrète.ginSignalCountLecommdecommdoit savoir vers quelle carte réseau et quel QP (Queue Pair) envoyer le signal, ces informations se trouvent dans l'état de la couche de transport réseau de. Sans, NCCL ne peut pas déterminer à quel slot de quelle carte réseau le signal doit être attribué, ni garantir que le signal sera correctement routé vers le rank cible. Cela illustre un principe de conception des API côté device :
---
la déclaration des besoins en ressources ne dépend que du contexte dont elle a réellement besoinncclTeam_t— LSA n'a besoin que de la topologie de la team, GIN a besoin de la connexion réseau.CreateRequirementL'API côté device et la fusion de kernels transforment NCCL de « une bibliothèque que vous appelez » en « un modèle que vous programmez ».
Jusqu'ici, nous avons parcouru l'ensemble du processus allant du mapping des métadonnées devcomm aux primitives côté device de nccl_device, et vu comment NCCL, via le modèle « déclaration côté host, consommation côté device », permet aux kernels utilisateur d'appeler directement des opérations de synchronisation de type barrier, fusionnant communication et calcul dans un même kernel. Mais une fois ces mécanismes maîtrisés, une question plus concrète surgit naturellement : lorsque les performances d'une tâche d'entraînement réelle ne sont pas au rendez-vous, comment déterminer s'il s'agit d'un mauvais choix d'algorithme, d'une inadéquation de protocole, ou d'une configuration irrationnelle du nombre de canaux ? Le chapitre suivant enchaînera les mécanismes des 20 chapitres précédents en une méthodologie de tuning opérationnelle, combinant rapports de performance, modèle de coût et variables d'environnement, pour fournir un chemin de diagnostic allant du symptôme à la cause racine.
Chapitre 21 : Chapitre 21 : Tuning de performance en pratique : opérations de tuning, outils de benchmark et méthodologie de tuning
Chapitre 21 : Tuning de performance en pratique : opérations de tuning, outils de benchmark et méthodologie de tuning
Dans le chapitre précédent, nous avons vu comment un kernel personnalisé utilisateur peut coopérer avec les primitives de communication NCCL via l'API côté device, allant jusqu'à fusionner communication et calcul dans un même kernel. Cela ouvre la possibilité d'utiliser NCCL comme modèle de programmation, mais soulève aussi un problème concret : lorsque les performances de communication ne sont pas à la hauteur des attentes, par où commencer ? NCCL expose des centaines de NCCL_PARAM, mais ce qui détermine réellement le chemin emprunté par une communication collective se résume en fait à trois boutons : l'algorithme (Algo), le protocole (Proto) et le nombre de canaux (nChannels). Ce chapitre enchaîne les mécanismes des 20 chapitres précédents en un chemin de diagnostic opérationnel — d'abord consulter le rapport de performance pour localiser le phénomène, puis lire le modèle de coût pour comprendre comment NCCL choisit lui-même, et enfin utiliser les variables d'environnement et les benchmarks pour valider vos hypothèses.
21.1 Rapport de performance : établir d'abord la ligne de base « normale »
La première étape du tuning n'est pas de modifier des paramètres, mais de savoir à quoi ressemble « la normale ». Si vous ne savez même pas quelle est la bande passante de pointe de votre système actuel, tout réglage de paramètres est une supposition à l'aveugle.
NCCL publie officiellement des données de performance de référence sousdocs/perf, dont la vocation est très claire — non pas une garantie de niveau produit, mais un point de référence pour aligner les attentes.
📎 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.Il y a ici deux informations clés que les débutants ont tendance à négliger :
Premièrement,un écart inférieur à 5 % relève des fluctuations normales. Cela signifie que si vous mesurez 3 % de moins que l'officiel, ne vous précipitez pas pour régler les paramètres — vérifiez d'abord s'il s'agit de bruit de mesure, de gigue d'horloge GPU, ou d'interférence d'une tâche voisine.
Deuxièmement,l'officiel ne publie que la bande passante de pointe, pas la latence。
📎 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.Pourquoi la latence n'est-elle pas publiée ? Parce que la latence est extrêmement sensible à l'état du système — la fréquence CPU, l'état du lien PCIe, la version du firmware de la carte réseau, et même la politique d'alimentation du BIOS l'affectent. La bande passante sature sous les gros messages et reste relativement stable ; la latence, sous les petits messages, résulte de la superposition d'innombrables micro-étapes, et toute gigue d'un maillon est amplifiée. Ainsi, lors du tuning,gros messages : regarder la bande passante ; petits messages : regarder la latence, ce sont deux chemins de diagnostic distincts.
📎 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.Première règle de l'ordre de diagnostic: exécuter d'abord un benchmark standard (commenccl-testsdeall_reduce_perf), puis comparer le résultat au rapport officiel. Si l'écart est inférieur à 5 %, la configuration système est correcte et le goulot de performance se situe dans votre couche applicative (par exemple la fréquence de communication, la manière de découper les messages) ; si l'écart est significatif, alors seulement on entre dans le tuning des paramètres NCCL.
21.2 Modèle de coût : comment NCCL choisit lui-même algorithme et protocole
Pour régler les paramètres, il faut d'abord comprendre comment NCCL choisit par défaut. Il dispose en interne d'un « modèle de coût » (cost model), essentiellement une table de consultation + un calcul de formule : étant donné la taille du message, le type de topologie et le nombre de ranks, il estime le temps de chaque combinaison « algorithme × protocole » et choisit la plus petite.
Modèle intuitif
Imaginez le modèle de coût comme un logiciel de navigation. Vous saisissez le point de départ et d'arrivée (taille du message, topologie), il estime en interne le temps de chaque itinéraire (combinaison algorithme/protocole), puis recommande le plus rapide. L'estimation de la navigation se base sur des données historiques et la catégorie de route ; celle de NCCL se base sur une table de paramètres latence/bande passante codée en dur.
Sans ce modèle, NCCL ne pourrait utiliser qu'un algorithme fixe pour tous les scénarios — les petits messages ralentiraient à cause d'un surcoût de démarrage excessif, les gros messages ralentiraient à cause d'une utilisation insuffisante de la bande passante, et le système serait mauvais aux deux extrêmes.
Structure de données : table du modèle et contexte de tuning
Le cœur du modèle de coût est le tableaumodelMap, chaque élément correspondant à une combinaison « algorithme/protocole/kernel symétrique ».
📎 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
...Chaque entrée possède quatre champs :mod_init(fonction d'initialisation),mod_sim(fonction de simulation),mod_final(fonction de nettoyage),enabled(les indicateurs d'activation des 5 fonctions respectives).enabledL'ordre du tableau{Broadcast, Reduce, AllGather, ReduceScatter, AllReduce}est
〔Inférence de conception et compromis architecturaux〕Observation clé :({0,0,0,0,1}Tree n'est activé que sur AllReduce{1,1,1,1,1}). Cela s'explique par le fait que l'avantage de l'algorithme Tree réside dans le fait que la phase de réduction d'AllReduce peut être parallélisée, mais pour des opérations essentiellement de type pipeline circulaire comme AllGather/ReduceScatter, Ring est plus naturel.
Les paramètres spécifiques du modèle se trouvent dansncclTunerConstants_t, incluant la latence de base et la bande passante pour chaque topologie.
📎 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
},Chaque algorithme possède trois valeurs de latence de base, correspondant aux trois protocoles LL / LL128 / Simple. Par exemple, pour Ring,{6.6, 14.0, 8.4}signifie : latence de base du protocole LL 6,6 microsecondes, LL128 14,0, Simple 8,4. Ces chiffres sont des valeurs empiriques mesurées par NVIDIA sur du matériel réel.
La latence matérielle est donnée séparément selon le type de topologie (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)
...
},
},Une comparaison permet de voir les différences de topologie : sur NVLink, la latence par saut de Ring/Simple est de 3,4 microsecondes, sur PCI de 5,7, sur NET de 14,0. C'est pourquoi la communication inter-machines est lente — chaque saut coûte 10 microsecondes supplémentaires.
Les paramètres de bande passante sont donnés par génération d'architecture 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) */
},Chaque ligne correspond à une génération d'architecture, les trois valeurs étant la bande passante maximale du protocole LL dans les scénarios mono-machine (N1), bi-machine (N2) et quadri-machine (N4). Hopper mono-machine 141 GB/s, Blackwell double à 282 GB/s — cela explique pourquoi le même algorithme performe bien mieux sur les nouvelles cartes.
Contexte de réglage : état par-comm
Chaque domaine de communication (communicator) détient unncclTuningContext_t, conservant l'état de réglage de ce 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];
};Quatre champs clés :
forced[NCCL_NUM_FUNCTIONS]: marque quelles fonctions ont leur algorithme/protocole forcé par variable d'environnement. C'est le point d'application deNCCL_ALGO/NCCL_PROTO.enabled[NCCL_TUNING_COUNT][NCCL_NUM_FUNCTIONS]: table booléenne bidimensionnelle, marquant si un modèle est activé pour une fonction donnée. Les modèles désactivés ne participent pas à la sélection.generalLatencies/generalBandwidths: tableau tridimensionnel, stockant la latence et la bande passante estimées par « fonction × algorithme × protocole ». C'est la source du grand tableau imprimé parncclTuningInit.threadThresholds/maxThreads: seuils liés au nombre de threads, déterminant combien de threads utiliser par block.
Walkthrough guidé par scénario : sélection d'algorithme pour un AllReduce
Supposons que vous appeliezncclAllReduce, taille de message 1MB, 8 cartes mono-machine NVLink. En interne, NCCL construira unncclTuningInput_t, puis appellerancclTuningCompute。
📎 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);Première étape : un seul rank retourne directement Ring/Simple, sans aucun calcul. C'est une optimisation par court-circuit — une seule carte n'a pas de communication, peu importe l'algorithme choisi.
Deuxième étape : en multi-rank, appelerncclTuningComputeAllTunings, parcourant toutes les combinaisons candidates.
📎 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);
}Il y a ici une conception ingénieuse :tuningMaskest un masque de 64 bits, chaque bit correspondant à une combinaison candidate.NCCL_TUNING_MASK_GENERAL_KERNELS、NCCL_TUNING_MASK_SYM_KERNELS、NCCL_TUNING_MASK_CEdélimitent respectivement différentes catégories de candidats.
📎 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)La disposition du masque est : les bits de poids faibleNCCL_NUM_ALGORITHMS × NCCL_NUM_PROTOCOLSsont les combinaisons traditionnelles « algorithme × protocole », les bits intermédiairesncclSymkKernelId_Countsont les kernels symétriques, les bits de poids fort sont les méthodes CE (Copy Engine). L'utilisation d'un masque de bits plutôt qu'un tableau vise à déterminer rapidement dansncclTuningComputesi « ce candidat est dans la portée de ce réglage ».
Troisième étape : pour chaque candidat, appelerncclTuningComputeTuning, qui redirige versncclTuningCostModelSimModel。
📎 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;
}Noter le traitement du labelnot_valid: tout échec d'une étape (modèle inexistant, désactivé, simulation retournant un temps non positif) mettratimeUsàNCCL_TUNING_IGNORE、validà 0. Ce candidat est alors exclu de la sélection ultérieure.
Quatrième étape : sélectionner celui avec le temps le plus faible parmi tous les candidats valides.
📎 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;
}Il y a ici un détail : la sélection utiliseselectionTimeUs, si elle est supérieure à 0 on l'utilise, sinon on retombe surtimeUs。selectionTimeUsest le « temps de sélection », pouvant inclure des pénalités supplémentaires (par exemple certains algorithmes nécessitant un surcoût dans des scénarios spécifiques). Cela donne au modèle de coût la capacité de séparer « temps estimé » et « temps de sélection ».
Diagramme de flux
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 --> doneCe diagramme illustre complètement le chemin de décision de l'entrée au résultat final, incluant le court-circuit mono-rank, le filtrage par masque, la désactivation de modèle, l'intervention du plugin tuner, la couverture par CTAPolicy et toutes les branches.
21.3 Variables d'environnement : les trois boutons qui influencent réellement les performances
En comprenant le modèle de coût, on comprend comment les variables d'environnement interviennent.NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNELCes trois variables, après analyse parparseList, modifient directement la tableenabled, désactivant tous les candidats non conformes à l'intention de l'utilisateur.
Syntaxe d'analyse
parseListLa syntaxe supportée par
📎 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.Copier
1. Trois utilisations ::NCCL_ALGO="ring,tree"Liste globale
2. — toutes les fonctions n'utilisent que ring et tree.:NCCL_ALGO="ring;allreduce:tree"Par préfixe de fonction
3. — ring par défaut, mais allreduce utilise tree.:NCCL_PROTO="^LL128"Syntaxe d'exclusion
^— tout sauf LL128 est activé.
📎 src/tuning/cost_model.cc:59-67
int unset, set;
if (elemList[0] == '^') {
unset = 1;
set = 0;
elemList++;
} else {
unset = 0;
set = 1;
}est essentiel — il signifie « unset », c'est-à-dire exclure une option de l'activation par défaut.^Copierunset=1、set=0Lors de l'analyse versunset,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);
}(tout exclure), puis on met les éléments listés àforced[p] = 1Copier
Noter la ligne
ncclTuningCostModelInit— dès que l'utilisateur liste explicitement un élément, la fonction correspondante est marquée comme « forcée ». Ce marquage servira ensuite à déterminer si le modèle de coût est autorisé à choisir librement.
📎 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;
}
}Il y a dans
1. une logique clé traitant l'interaction entre le forçage utilisateur, les variables d'environnement et les capacités de la plateforme.CopierisLL128EnabledL'ordre de cette logique est important :protoEnable == 2Traiter d'abord la capacité de plateforme LL128
2. : si la plateforme ne supporte pas LL128 (retourne 0) et que l'utilisateur ne l'a pas explicitement demandé (forced[f] != 0), désactiver directement.enabled[i][f] = 0), puis vérifie si l'utilisateur autorise cette combinaison — si autorisée, réactive-la.
protoEnablea trois valeurs : 0 (exclu par l'utilisateur), 1 (activé par l'utilisateur), 2 (non mentionné par l'utilisateur, activé par défaut). Cette conception à trois états permet de distinguer « exigence explicite de l'utilisateur » et « défaut de la plateforme ».
Mécanisme de cache pour la lecture des variables d'environnement
Toutes lesNCCL_PARAMmacros passent finalement parncclLoadParam。
📎 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;
}Ce code présente plusieurs conceptions dignes d'attention :
Verrou mutex global:static std::mutex mutexprotège l'ensemble du processus de lecture. Cela signifie que la première lecture de tous les paramètres est séquentielle. Pourquoi utiliser un verrou plutôt qu'un accès sans verrou ? Parce que la lecture des paramètres n'a lieu qu'à la phase d'initialisation, pas sur le chemin critique ; le coût du verrou est négligeable, et la correction est plus importante.
Double vérification: d'abord une lecture atomique decache, si déjà initialisé, retourne directement. Cela évite d'entrer dans le verrou à chaque lecture de paramètre — bien que le verrou lui-même ne soit presque plus contesté après l'initialisation, la lecture atomique est plus rapide.
Stratégie de cache:noCacheL'indicateur détermine si la valeur lue doit être réécrite danscache. Certains paramètres (comme ceux nécessitant une réponse dynamique) peuvent désactiver le cache et relire la variable d'environnement à chaque fois.
Gestion des erreurs:strtollEn cas d'échec d'analyse, utilise la valeur par défaut et afficheATTNun avertissement. Noterend == strle jugement — si la chaîne ne commence pas par un chiffre,endsera égal àstr, indiquant qu'aucun nombre n'a été analysé.
Prise en charge des fichiers de configuration
Les variables d'environnement ne doivent pas nécessairement être définies depuis le shell ; NCCL prend en charge la lecture depuis un fichier de configuration.
📎 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);
}Ordre de chargement :NCCL_CONF_FILEle fichier spécifié (s'il est défini) →~/.nccl.conf → /etc/nccl.conf. Ce qui est chargé plus tard écrase ce qui a été chargé avant (carsetEnvFileappellencclOsSetEnv)。
📎 src/misc/param.cc:69-72
void initEnv() {
static std::once_flag once;
std::call_once(once, initEnvFunc);
}std::call_oncegarantit que le fichier de configuration n'est chargé qu'une seule fois, même si plusieurs threads appellent pour la première fois simultanémentncclGetEnv。
21.4 Nombre de canaux : le bouton de performance sous-estimé
L'algorithme et le protocole déterminent « comment circuler », le nombre de canaux détermine « combien de voies ouvrir ». Beaucoup de personnes ne se concentrent que sur les deux premiers lors de l'optimisation, ignorant le nombre de canaux — mais dans les scénarios de messages volumineux, le nombre de canaux est souvent la clé pour déterminer l'utilisation de la bande passante.
D'où vient le nombre de canaux
ncclTuningComputeAprès avoir sélectionné le meilleur algorithme/protocole, appellencclTuningGetChannelspour calculer le nombre de canaux.
📎 src/tuning/tuning.cc:233-235
if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
}La logique de calcul du nombre de canaux ne figure pas dans le matériel source de ce chapitre, mais on peut voir son rôle à partir des champs dencclTuningResult_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;
};nChannelsest le nombre de canaux finalement utilisé,maxChannelsest la limite supérieure.nWarpsest le nombre de warps par bloc.
Remplacement du nombre de canaux par CTAPolicy
Il existe une logique spéciale pour traiter la stratégieNCCL_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;
}
}
}
}Les conditions de garde de ce code sont très denses et méritent d'être interprétées une par une :
1. input->comm->tuner == NULL: cette section n'est exécutée que s'il n'y a pas de plugin tuner. Lorsque le plugin a le droit de choisir, NCCL n'intervient pas.
2. input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY: l'utilisateur a défini une stratégie priorisant l'efficacité.
3. ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL: l'utilisateur n'a pas forcé l'algorithme/protocole. S'il l'a forcé, respecter son choix.
4. !input->comm->MNNVL: le scénario MNNVL n'est pas pris en charge.
5. input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)): NVLS/Simple est dans l'ensemble des candidats. Cette garde empêche de « ressusciter » des options exclues.
Une fois les conditions remplies, interroge le nombre de canaux que les ressources enregistrées NVLS peuvent prendre en charge ; si cela ne dépasse pas la sélection actuelle, bascule vers l'algorithme NVLS.
Pourquoi la stratégie EFFICIENCY privilégie-t-elle NVLS ? Parce que NVLS (NVLink SHARP) utilise le matériel du commutateur pour effectuer la réduction, ce qui réduit la charge de calcul et de communication des GPU et est plus efficace pour des opérations comme AllGather/ReduceScatter. Mais son nombre de canaux est limité par les ressources matérielles, il faut doncncclNvlsRegResourcesQueryinterroger la quantité réellement disponible.
Logique de repli du noyau symétrique
Le noyau symétrique (symmetric kernel) est une fonctionnalité plus récente ; lorsqu'il n'est pas disponible, il faut revenir au noyau générique.
📎 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);
}
}
}
}Arbre de décision de repli :
- Si les tampons d'envoi et de réception sont tous deux enregistrés (
ncclSymSendRegRecvReg), pas de repli. - S'il s'agit d'un noyau LL, que plusieurs GPU sont gérés par un seul thread et que les tampons ne sont pas enregistrés, repli.
- Si l'utilisateur n'a pas défini
NCCL_SYM_NOWIN_ENABLEet que les tampons ne sont pas enregistrés, repli. - Sinon, interroge le modèle de coût générique ; s'il choisit un protocole non LL, repli.
Le cœur de cette logique est : le noyau LL symétrique nécessite l'enregistrement des tampons pour tirer parti de ses avantages. Sans enregistrement, l'avantage du noyau LL (faible latence) peut être annulé par le surcoût de traduction d'adresses, il est donc plus rentable de revenir au noyau générique.
Gestion des erreurs en l'absence de combinaison disponible
Si tous les candidats sont exclus, NCCL signale une erreur et fournit des informations de diagnostic.
📎 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;
}Le choix du code d'erreur est réfléchi : si l'utilisateur a défini une variable d'environnement (algoEnv || protoEnv || symKernelIdEnv), retournencclInvalidUsage— c'est un problème de configuration de l'utilisateur ; sinon retournencclInternalError— c'est un problème interne à NCCL (tous les candidats ont été exclus par erreur).
21.5 Guide pour éviter les pièges en production
Piège 1 : une faute de frappe dans la variable d'environnement provoque un repli silencieux
parseListEn rencontrant un token non reconnu, retournencclInvalidUsage, mais si vous écrivezNCCL_ALGO=RING(en majuscules),strcasecmpcorrespondra correctement. Le vrai danger, ce sont les fautes de frappe, par exempleNCCL_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;
}Ici, un WARN sera affiché et une erreur retournée. Mais si vous n'avez pas activéNCCL_DEBUG=WARN, vous pourriez ne pas voir cet avertissement.Recommandation: lors de l'optimisation, définissez toujoursNCCL_DEBUG=WARNouNCCL_DEBUG=INFO, pour être sûr de voir le résultat de l'analyse de la configuration.
Piège 2 : interaction entre NCCL_ALGO et NCCL_PROTO
Si vous définissezNCCL_ALGO=treemais pasNCCL_PROTO, NCCL choisira le meilleur protocole sous l'algorithme Tree. Mais si vous définissez à la foisNCCL_ALGO=treeetNCCL_PROTO=LL, et que la combinaison Tree/LL est désactivée sur certaines fonctions (par exemple Tree n'est activé que sur AllReduce), cela déclenchera une erreur « aucune combinaison disponible ».
📎 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;
}Ce n'est que lorsque l'algorithme et le protocole sontsimultanémentautorisés que la combinaison est activée. C'est une logique AND, pas OR.
Piège 3 : limitations de plateforme de LL128
LL128 n'est pas pris en charge sur toutes les plateformes.isLL128EnabledVérification de la capacité de calcul, de la version du pilote et du type de connexion.
📎 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;
}Quelques limitations clés :
minCompCap < 70: Les GPU antérieurs à Volta ne prennent pas en charge LL128.intraType <= PATH_NVB: La connexion intra-machine doit être de niveau NVLink.- Hopper + CUDA 11.8 + AllReduce + Ring + 2 ranks : il s'agit d'un scénario de bug connu, explicitement exclu.
Recommandations: Si votre plateforme ne prend pas en charge LL128, ne forcez pasNCCL_PROTO=LL128, sinon cela déclenchera une erreur. Laissez NCCL choisir automatiquement.
Piège n°4 : nombre de canaux et mémoire GPU
Plus le nombre de canaux est élevé, plus les buffers nécessaires sont grands. Dans les scénarios où la mémoire GPU est limitée, un nombre excessif de canaux peut provoquer un 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;
}Le nombre de canaux NVLS est déterminé parncclNvlsRegResourcesQueryla requête des ressources matérielles, il n'est pas défini arbitrairement. Si les ressources matérielles sont insuffisantes, le nombre de canaux sera limité.
21.6 Processus de décision d'optimisation
En reliant les éléments précédents, on obtient un processus de diagnostic exploitable.
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 或联系支持"]L'idée centrale de ce processus est :d'abord localiser, puis ajuster les paramètres, enfin valider. Ne définissez pas des variables d'environnement au hasard dès le départ.
Résumé du chapitre
Ce chapitre décompose le chemin d'optimisation de NCCL en quatre niveaux :
1. Ligne de base: Utilisez les rapports de performance officiels pour établir les attentes ; une variation inférieure à 5 % est normale ; pour les gros messages, regardez la bande passante, pour les petits messages, la latence.
2. Modèle de coût: En interne, NCCL utilise la tablemodelMap+ les paramètres de latence/bande passante pour estimer le temps de chaque combinaison et choisir la plus petite. Comprendre ce modèle est un prérequis pour l'ajustement des paramètres.
3. Variables d'environnement:NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNELAprès analyse viaparseList, modifient la tableenabledpour forcer ou exclure des combinaisons spécifiques. La syntaxe prend en charge trois modes : global, par fonction et exclusion.
4. Nombre de canaux: Calculé parncclTuningGetChannels, influencé par les ressources matérielles et CTAPolicy.
Réflexions et auto-évaluation du chapitre
Q1 : Si l'on supprime la logique de court-circuit pour un seul rank dansncclTuningCompute(brancheinput->comm->nRanks <= 1), que se passe-t-il ? Dans quels scénarios cela poserait-il problème ?
Analyse de référence:
Le court-circuit pour un seul rank dans📎 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);
...Si l'on supprime cette branche, le scénario à un seul rank entrera dansncclTuningComputeAllTuningset parcourra toutes les combinaisons candidates. Le problème est le suivant :
1. Gaspillage de performance: Un seul rank n'a pas de communication, l'estimation du temps de tous les algorithmes est un pur surcoût, le choix n'a pas d'importance. Parcourir tous les candidats est un pur gaspillage.
2. Risque de ne sélectionner aucun résultat: Certains algorithmes peuvent être jugés invalides par le modèle en configuration mono-rank (par exemple Ring nécessite au moins 2 ranks pour former une boucle), ce qui conduit à une listetuningsvide,ncclTuningSelectBestTuningrenvoie la valeur initiale deFLT_MAX, et finalementbestTuning.algoresteNCCL_ALGO_UNDEF。
3. Déclenchement du chemin d'erreur: SibestTuning.algo == NCCL_ALGO_UNDEF, on entre dans la gestion d'erreur de📎 src/tuning/tuning.cc:308-329, un avertissement "No algorithm/protocol available" est affiché etncclInternalError。
est renvoyé. Ce court-circuit n'est donc pas seulement une optimisation, c'est aussi une garantie de correction — le scénario mono-rank doit avoir une valeur par défaut déterminée.
Q2: parseListDansforced[p] = 1, quel est le rôle de la ligne de code📎 src/tuning/cost_model.cc:83(NCCL_ALGO=ring) ? Si on la supprime, comment le comportement de
change-t-il ?:
forced[p] = 1Analyse de référence📎 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;
}
}forcedCopierncclTuningContext_tLe tableau
est défini dans
Retour en haut ↑
Version : Commit @12df1a11
Progression du livre : Chapitre 22 / 25
Chapitre 22 : Dépannage en production et pièges courants : interblocages, timeouts, incompatibilités de version et solutions de diagnostic
Dans le chapitre précédent, nous avons passé en revue l'ordre de diagnostic et les leviers clés de l'optimisation des performances, mais les défaillances de NCCL en production ne se traduisent souvent pas par des performances insuffisantes, mais par un blocage ou un crash direct du programme. La cause racine de ces défaillances n'est généralement pas une erreur dans une fonction, mais la violation de l'ordre d'appel, du cycle de vie ou du contrat de version. Ce chapitre se concentre sur quatre catégories de pièges parmi les plus typiques : les interblocages dus à un mauvais usage de la sémantique de group, les erreurs silencieuses dues à l'absence de validation des paramètres, les incompatibilités de version ABI, et les limites des timeouts et des tentatives de reprise. Nous suivrons quatre pistes — src/group.cc, src/misc/argcheck.cc, src/include/checks.h et contrib/nccl_ep/nccl_ep.cc — pour voir comment NCCL intercepte ces problèmes avant qu'ils ne surviennent.
Mauvais usage de la sémantique de Group : pourquoi "oublier un GroupEnd" provoque un blocagencclGroupStart() / ncclGroupEnd()Modèle intuitif : Group est un "panier d'achat", pas un "interrupteur d'accélération"ncclGroupEndImaginezncclGroupDepthcomme un panier d'achat en ligne : vous y placez plusieurs articles (plusieurs appels de communication), puis vous payez en une seule fois (
C'est la forme d'interblocage la plus courante en production : le code, dans une branche d'exception,return, sautencclGroupEnd, etncclGroupDepthestthread_local, il ne sera pas nettoyé automatiquement lors du retour de la fonction.
Structure de données : l'état du group en thread_local
NCCL place tout l'état du group dans le stockage local au thread, c'est la clé pour comprendre l'interblocage.
📎 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 */Interprétation champ par champ :
ncclGroupDepth: profondeur d'imbrication.ncclGroupStarts'incrémente,ncclGroupEndse décrémente, et ce n'est que lorsqu'il atteint 0 que la soumission est réellement déclenchée. Le support de l'imbrication est une commodité de conception, mais cela signifie aussi qu'un « End oublié » fera que la profondeur restera bloquée à 1 pour toujours.ncclGroupError: les erreurs de group accumulées par ce thread. Dès qu'un appel échoue, lesncclGroupEndsuivants emprunteront directement le chemin d'échec.ncclGroupCommHead[]: les têtes de liste chaînée des domaines de communication, regroupées par type de tâche (collective / rawTask / mgmtTask / symRegister).ncclAsyncJobs: la file des tâches asynchrones en attente d'exécution (par exemple preconnect, symmetric register).ncclGroupBlocking:-1signifie « aucun domaine de communication encore rencontré »,0signifie non bloquant,1signifie bloquant. Ce champ est le cœur de la détection ultérieure du « mélange bloquant et non bloquant ».
La motivation d'utiliserthread_localplutôt qu'une variable globale est directe : NCCL permet à plusieurs threads de détenir chacun un contexte de group indépendant, sans interférence mutuelle. Le coût : ces états ne sont pas nettoyés automatiquement à la sortie du thread ; si le thread se termine au milieu d'un group, l'état fuit.
Step-by-Step : la chaîne complète de validation d'un GroupEnd
Mise en situation : l'application appellencclGroupEnd(), à ce momentncclGroupDepthvaut 1.
Première étape, vérifier si l'on est réellement dans un group :
📎 src/group.cc:1048-1052
if (ncclGroupDepth == 0) {
WARN("ncclGroupEnd: not in a group call.");
ret = ncclInvalidUsage;
goto exit;
}Si l'utilisateur n'a pas appeléncclGroupStartet fait directementncclGroupEnd, ici sera affiché « not in a group call » et retournéncclInvalidUsage. C'est l'erreur la plus conviviale — signaler immédiatement, sans blocage.
Deuxième étape, décrémenter la profondeur et déterminer s'il s'agit du niveau le plus externe :
📎 src/group.cc:1061-1063
if ((--ncclGroupDepth) > 0) goto exit;
if ((ret = ncclGroupError) != ncclSuccess) goto fail;Si plusieurs niveaux sont imbriqués, leEndinterne se contente de décrémenter la profondeur et retourne, sans déclencher la soumission. Seul le niveau le plus externe continue. En même temps, les erreurs accumulées sont vérifiées.
Troisième étape, valider la cohérence du mode bloquant. C'est le point de détection du « mélange bloquant et non bloquant » :
📎 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;
}ncclGroupBlockingdoit être entre{0, 1}. S'il est encore-1, cela signifie que le group ne contient ni domaine de communication ni tâche asynchrone, et logiquement on ne devrait pas arriver ici.
Quatrième étape, bifurquer selon le mode bloquant. Le non bloquant passe par la soumission asynchrone du thread, le bloquant par la soumission synchrone :
📎 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;
}Attention àgroupRefCount++etret = ncclInProgress: en mode non bloquant,ncclGroupEndretourne immédiatementncclInProgress, la véritable soumission s'exécute dans un thread d'arrière-plan. L'appelant doit ensuite utiliserncclCommGetAsyncErrorpour interroger, ouncclGroupJobCompletepour attendre.
Mélange bloquant et non bloquant : pourquoi c'est interdit
Revenons àncclAsyncLaunch, regardons la détection de mélange :
📎 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;
}Pourquoi interdire le mélange ? Parce que la sémantique de soumission d'un domaine de communication bloquant est « le kernel est soumis au retour de l'appel », tandis que le non bloquant est « la tâche est mise en file mais non soumise au retour de l'appel ». Si les deux se trouvent dans le même group,ncclGroupEndne peut pas fournir une sémantique de retour unifiée — faut-il attendre ou non ? NCCL choisit de refuser directement, exposant le problème à la frontière de l'API.
Pièges de production : trois scénarios réels
Scénario un : une branche d'exception omet le GroupEnd.Le code, entrencclGroupStartetncclGroupEnd, lève une exception ou fait unreturn,ncclGroupDepthanticipé, la profondeur reste bloquée à 1. Tous les appels de communication suivants entrent en état d'« accumulation » et ne sont jamais soumis. Méthode de diagnostic : afficherncclGroupEndavantncclGroupDepth, ou utilisergdbpour observer cette variable thread_local.
Scénario deux : utilisation du même comm à travers les threads.Comme l'état du group estthread_local, après que le thread A a appeléncclGroupStart, le thread B appelantncclAllReducen'entrera pas dans le group de A. Si A et B opèrent sur le même comm, il se produira un désordre où « une partie des appels est dans le group, une autre en dehors ». NCCL ne détecte pas ce cas, car il suppose qu'un comm n'est opéré que par un seul thread à un instant donné.
Scénario trois : interaction entre CUDA graph capture et group.Regardons la détection dansdoLaunches:
📎 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;
}Le commentaire est très explicite : une fois entré dans la barrier puis abandonné en cours de route, ces comm sont « définitivement corrompus ». La règle est donc — tous les domaines de communication d'un group doivent soit tous être dans le capture, soit tous ne pas y être. Le mélange entraîne une incohérence d'état des comm, et NCCL n'a actuellement pas de bon mécanisme de récupération.
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 --> resetValidation des paramètres et erreurs silencieuses : comment ArgCheck bloque les appels « qui semblent normaux »
Modèle intuitif : ArgCheck est le « contrôle de sécurité à l'aéroport »
La validation des paramètres est comme le contrôle de sécurité à l'aéroport : elle n'est pas chargée de vous faire voler plus vite, mais elle peut bloquer ces choses « qui ressemblent à des bagages mais sont en réalité des produits dangereux ». Sans elle, un pointeur avec un mauvais device ferait lire des données corrompues au kernel GPU, ou pire — écrire silencieusement dans la mémoire d'autrui.
Structure de données : mode de validation et file de vérification globale
La validation des paramètres de NCCL n'est pas « tout vérifier à chaque fois », mais fonctionne par mode. Le cœur estcomm->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);
}
}Trois modes :
ncclCheckModeDefault: ne fait que les vérifications les moins coûteuses (plage de root, plage de datatype, plage d'op), sans toucher à l'API CUDA.- Mode non par défaut : appelle
CudaPtrCheck, ce qui appelle réellementcudaPointerGetAttributes, avec un coût de performance. ncclCheckModeDebugGlobal: en plus des vérifications locales, metncclInfodansargsInfoQueue, effectuer une vérification de cohérence globale inter-rank à la fin du groupe.
Cette conception est un compromis entre performance et exactitude :cudaPointerGetAttributesest un appel CUDA synchrone ; l'appeler à chaque communication sur le chemin critique ralentirait considérablement les petits messages. Le mode par défaut ne fait donc qu'une vérification « à coût nul », laissant la validation coûteuse des pointeurs au mode débogage.
Étape par étape : les trois lignes de défense de CudaPtrCheck
Mise en situation : l'utilisateur passe unsendbuff, NCCL le valide en mode débogage.
Première couche, validité du pointeur :
📎 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;
}cudaPointerGetAttributesrenvoie une erreur pour un pointeur invalide, oudevicePointervaut NULL. Cela bloque le cas « on a passé une adresse de pile hôte » ou « on a passé un pointeur déjà libéré ».
Deuxième couche, correspondance du périphérique :
📎 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;
}C'est le piège le plus sournois : le pointeur est un pointeur GPU valide, mais il appartient à un autre GPU. Sur une machine multi-GPU, si l'utilisateur oubliecudaSetDevice, il est très facile de se tromper. NCCL refuse explicitement ici.
Troisième couche, intégrité de l'objet domaine de communication :
📎 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 / endMagicest une valeur sentinelle placée au début et à la fin de la structurencclComm. Si l'utilisateur passe un pointeur sauvage, ou si comm a déjà été libéré, le magic ne correspond plus. C'est la technique classique de « détection de corruption mémoire » — encadrer la structure avec deux sentinelles, toute écriture hors limites étant susceptible d'en corrompre une.
Vérification de cohérence globale : la validation inter-rank de registrationCheck
C'est la validation la plus « lourde » de NCCL, déclenchée uniquement sousncclCheckModeDebugGlobal. Elle vérifie si l'état d'enregistrement de la mémoire symétrique est cohérent sur tous les ranks.
📎 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;
}Elle collecte leallGatherde chaque rank via le(isSymRegistered, bigOffset, userOffset)du bootstrap, puis les compare rank par rank. Si le send buffer du rank 0 a enregistré de la mémoire symétrique et que le rank 3 ne l'a pas fait, une erreur est signalée ici.
Pourquoi cette vérification est-elle importante ? La mémoire symétrique (symmetric memory) exige que tous les ranks accèdent aux buffers via le même ensemble d'adresses virtuelles. Si le buffer d'un rank n'est pas enregistré, l'adresse calculée dans le kernel est fausse, ce qui entraîne des lectures de données parasites ou un dépassement de limites. Ce type d'erreur se manifeste à l'exécution par des « résultats parfois incorrects », extrêmement difficiles à diagnostiquer. NCCL choisit de la bloquer à la frontière de l'API au prix d'un allGather.
Pièges en production
Piège un : en mode par défaut, les erreurs de pointeur ne sont pas signalées.Si l'utilisateur n'active pas le mode débogage et passe un pointeur vers un mauvais périphérique, NCCL ne signalera pas d'erreur à l'étapeArgsCheck, mais ne le découvrira qu'à l'exécution du kernel — alors qu'il a peut-être déjà corrompu la mémoire d'un autre rank. Il est recommandé d'utiliserNCCL_DEBUG=WARNaveccheckModeen débogage pendant le développement.
Piège deux :ncclCheckModeDebugGloballe coût de l'allGather.Chaque communication effectue un bootstrap allGather, ce qui devient un goulot d'étranglement dans les scénarios de petits messages à haute fréquence. Ce mode ne convient qu'au débogage, pas à la production.
Piège trois : le cycle de vie de userRedOp.Regardez ce passage :
📎 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;
}L'op de réduction personnalisée de l'utilisateur est enregistrée sur comm. Si l'utilisateur passe un op « qui a été enregistré mais déjà libéré »,freeNext != -1détectera qu'il a été recyclé. C'est une vérification pour empêcher les « handles d'op suspendus ».
Macros de propagation d'erreurs : comment la famille NCCLCHECK garantit que « les erreurs ne se perdent pas »
Modèle intuitif : les macros de propagation d'erreurs sont un « témoin de relais »
La gestion des erreurs de NCCL repose sur un relais de macros : la fonction de bas niveau renvoiencclResult_t, la couche supérieure vérifie avecNCCLCHECKet retourne immédiatement en cas d'échec. C'est comme une course de relais — le témoin (le code d'erreur) doit être transmis jusqu'au bout ; si un relais le lâche, toute la chaîne est rompue.
Structures de données : vue d'ensemble de la famille de macros
📎 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)Détails clés :ncclInProgressest considéré comme « non-erreur ». C'est le cœur de la communication non bloquante —ncclGroupEndrenvoiencclInProgresssignifie « tâche soumise, pas encore terminée » ; l'appelant doit continuer à interroger plutôt que traiter cela comme une erreur.
NCCLCHECKsaute directement àreturn,NCCLCHECKGOTOverslabel. Ce dernier est utilisé pour les scénarios nécessitant le nettoyage des ressources.
Chemin de nettoyage : NCCLCHECKIGNORE conserve la première erreur
📎 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)Le commentaire est clair : sur le chemin de nettoyage, il faut « tenter toutes les étapes de nettoyage » sans être interrompu par la première erreur. Mais le code d'erreur doit conserver le premier — car la première erreur est généralement la cause racine la plus utile au diagnostic.
Attente et abandon : vérification de abortFlag dans 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))C'est le modèle d'attente par interrogation : à chaque itération, appelercall(faire progresser), vérifiercond(si satisfait), et vérifierabortFlag(si abandonné).abortFlagutilisememory_order_acquirepour le chargement, garantissant la visibilité du signal d'abandon écrit par d'autres threads.
Cette conception résout un problème classique : lorsqu'un rank tombe en erreur, les autres ranks peuvent encore attendre indéfiniment ses données.abortFlagest le mécanisme de propagation du signal d'abandon entre ranks — une fois défini, toutes les boucles d'attente se terminent.
Macros sécurisées pour la création de threads et l'allocation mémoire
📎 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::threadun échec de construction lève une exception (par exemple, nombre de threads dépassé). Cette macro convertit l'exception enncclSystemError, évitant que l'exception ne traverse la frontière de l'API C.
📎 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)renvoie nullptr en cas d'échec d'allocation plutôt que de lever une exception. C'est la pratique standard du code C++ à la frontière de l'API C.
Pièges en production
Piège un :ncclInProgressest pris à tort pour un succès.Certains codes utilisateurs écriventif (ret == ncclSuccess)pour juger du succès, mais en mode non bloquant, ce qui est renvoyé estncclInProgress. La bonne pratique estif (ret == ncclSuccess || ret == ncclInProgress), ou d'utiliserncclCommGetAsyncErrorpour interroger.
Piège deux :NCCLCHECKà utiliser dans le destructeur.Si utilisé dans le destructeurNCCLCHECK, l'erreur va directementreturn, en sautant le nettoyage ultérieur. Il faut utiliserNCCLCHECKIGNORE。
Incompatibilité de version ABI : la conception basée sur la taille de nccl_ep
Modèle intuitif : l'ABI est une « norme de prise »
L'ABI (Application Binary Interface) est comme une norme de prise électrique : si la bibliothèque et l'appelant ont une compréhension différente de « à quoi ressemble la structure », c'est comme brancher une prise américaine dans une prise européenne — au mieux ça ne fonctionne pas, au pire ça brûle.contrib/nccl_eputilise une conception astucieuse : chaque structure traversant les frontières commence par un champsize.
Structure de données : double validation 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.Points clés de la conception :
sizeLe champ est rempli par l'appelant avecsizeof(struct), la bibliothèque vérifie s'il est égal à la taille qu'elle connaît.magicLe champ est pré-rempli par la macroNCCL_EP_*_INIT, pour détecter les structures « non initialisées ».- Actuellement c'est une égalité stricte, il est prévu à l'avenir de supporter un mode permissif où « si la queue est entièrement à zéro, une taille plus petite est autorisée ».
Step-by-Step : le processus de validation de EP_REQUIRE_STRUCT
📎 contrib/nccl_ep/nccl_ep.cc:77-80
#define EP_REQUIRE_STRUCT(ptr) \
do { \
assert( \
(ptr) != nullptr && (ptr)->size == sizeof(*(ptr)) && \Cette macro est appelée aux points d'entrée tels quencclEpDispatch、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);inputsetoutputssont des paramètres obligatoires, utiliserEP_REQUIRE_STRUCT;layout_infoetconfigsont des paramètres optionnels, utiliserEP_OPTIONAL_*。
Lecture de champ sûre en version : layoutInfoRecvTopkIdxKind
C'est la partie la plus ingénieuse — comment lire un champ en toute sécurité lorsque « la structure de l'appelant peut être plus petite ».
📎 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;
}La logique est : si lesizede l'appelant est inférieur à « l'offset de fin de ce champ », cela signifie que l'appelant utilise une ancienne version de la structure, ce champ n'existe pas, retourner la valeur par défautAUTO. Sinon, lire normalement.
C'est la méthode standard de compatibilité ABI : les nouveaux champs ne peuvent être ajoutés qu'à la fin de la structure, et lors de la lecture on utilisesizepour déterminer si le champ existe. Ainsi, les anciens appelants utilisent l'ancienne structure, et la nouvelle bibliothèque peut aussi la traiter correctement.
Vérification du numéro de version : avertissement logiciel plutôt que rejet strict
📎 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);
}Notez qu'ici c'estWARNet nonreturn error. L'incompatibilité de numéro de version n'est qu'un avertissement, car la vérificationsizegarantit déjà la sécurité de la disposition mémoire. Le numéro de version est plutôt une indication que « le comportement peut différer ».
Pièges en production
Piège 1 : oublier d'initialiser avec la macro INIT.Si l'utilisateur met manuellementmemsetla structure à 0,magicsera 0,EP_REQUIRE_STRUCTéchouera. Il faut utiliser la macroNCCL_EP_*_INIT.
Piège 2 : mélanger des bibliothèques dynamiques de versions différentes.Si l'application est liée à une nouvelle version delibnccl_ep.so, mais que l'en-tête est une ancienne version,sizeof(struct)sera incohérent,EP_REQUIRE_STRUCTsignalera immédiatement une erreur. C'est l'intention de conception — échouer rapidement vaut mieux qu'une erreur silencieuse.
Piège 3 :EP_OPTIONAL_LAYOUT_INFOla vérification de plage.Regardez ce passage :
📎 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_infoautorise une taille dans la plage[min, sizeof], ce qui est plus permissif que l'égalité stricte deEP_REQUIRE_STRUCT. La raison est quelayout_infoest un paramètre optionnel, et que historiquement les champs ont été ajoutés et supprimés.
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 逻辑"]Timeout, retry et abandon : de NCCLWAIT au timeout_cycles de nccl_ep
Modèle intuitif : le timeout est un « fusible »
Dans la communication distribuée, un rank bloqué fait que tous les ranks attendent indéfiniment. Le mécanisme de timeout est comme un fusible : en temps normal il n'agit pas, mais dès que le courant est anormal il fond, évitant que tout le système ne brûle.
Structure de données : abortFlag et timeout_cycles
Le cœur de NCCL utiliseabortFlagpour propager le signal d'abandon. Regardez la transmission dansncclAsyncLaunch:
📎 src/group.cc:49-52
job->abortFlag = comm->abortFlag;
job->abortFlagDev = comm->abortFlagDev;
job->childAbortFlag = comm->childAbortFlag;
job->childAbortFlagDev = comm->childAbortFlagDev;Chaque job détient le pointeur abortFlag de comm. Quand le group détecte une erreur :
📎 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);
}
}Dès quegroupAbortFlagouerrorJobAbortFlagest vrai, l'abortFlag de tous les jobs est mis à 1.memory_order_releasegarantit que les écritures précédentes sont visibles pour les autres threads.
La conception du timeout de nccl_ep : cycles d'horloge GPU
nccl_eputilise un timeout plus fin — en unités de cycles d'horloge 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;La priorité est : variable d'environnementNCCL_EP_TIMEOUT_MS> champ de configurationtimeout_ns> valeur par défaut à la compilation. La formule de conversion estclock_khz * 1000 * ms / 1000, c'est-à-dire convertir les millisecondes en cycles d'horloge.
Pourquoi utiliser des cycles d'horloge plutôt que des millisecondes ? Parce que la boucle d'attente dans le kernel GPU ne peut pas appeler l'API d'heure système, elle ne peut que lire le registreclock64(). En utilisant les cycles d'horloge pour le jugement de timeout, le kernel peut comparer directement, sans intervention du host.
Indicateur d'erreur asynchrone : mémoire 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_flagutilisecudaHostAllocMappedpour allouer, c'est de la mémoire host-pinned mappée dans l'espace d'adressage du device. Le kernel GPU peut y écrire, le host peut la lire, sans copie explicite.
Lecture des erreurs asynchrones : chargement atomique
📎 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;
}Utiliser__atomic_load_navec__ATOMIC_ACQUIRE, pour garantir que la valeur lue est la plus récente écrite par le GPU, et non une ancienne valeur en cache.
Pièges en production
Piège 1 : un timeout trop court provoque des faux positifs.SiNCCL_EP_TIMEOUT_MSest défini trop petit, une gigue réseau normale sera interprétée à tort comme un timeout. Il est recommandé de le définir selon le RTT réseau réel, généralement pas moins de 10 secondes.
Piège 2 : abortFlag non nettoyé après avoir été défini.Une fois abortFlag mis à 1, comm entre dans l'état « abandonné ». Si l'utilisateur veut continuer à utiliser ce comm, il doit d'abord nettoyer abortFlag. LencclCommAbortde NCCL fait ce nettoyage.
Piège 3 :ncclEpMaskCleanla précondition de. Regardez ce passage :
📎 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);ncclEpMaskCleanexige querdma_buffersoit déjà alloué. Si l'utilisateur a créé un group mais n'a encore créé aucun handle LL,rdma_bufferest nullptr (car LL est alloué paresseusement), ici l'assert échouera.
Résumé de ce chapitre
Ce chapitre relie quatre types de pièges en production :
1. Mauvaise utilisation de la sémantique de Group:ncclGroupDepthest thread_local, si on ometncclGroupEndCela entraîne un blocage permanent ; les domaines de communication bloquants et non bloquants ne peuvent pas être mélangés ; la capture de graphe CUDA doit être tout ou rien.
2. Validation des paramètres:ArgsCheckValidation par mode, le mode par défaut n'effectue que des vérifications à coût nul ;CudaPtrCheckTrois lignes de défense bloquent les pointeurs invalides, les appareils erronés et les comm corrompus ;registrationCheckEffectuer une vérification de cohérence de la mémoire symétrique entre les ranks.
3. Propagation des erreurs:NCCLCHECKLa famille garantit qu'aucune erreur n'est perdue ;ncclInProgressCe n'est pas une erreur ;NCCLCHECKIGNOREUtilisé pour conserver la première erreur dans le chemin de nettoyage ;NCCLWAITVérifier abortFlag lors du polling.
4. Version ABI:nccl_epConception basée sur la taille, chaque structure traversant les frontières commence parsizeen tête, avecmagicpour capturer les non-initialisés ; les nouveaux champs ne peuvent être ajoutés qu'à la fin, et lors de la lecture on utilisesizepour déterminer s'ils existent.
5. Délai d'attente et abandon: le cœur utiliseabortFlagpour propager l'abandon ;nccl_epUtiliser les cycles d'horloge GPU pour le délai d'attente,async_error_flagUtiliser la mémoire host-pinned pour réaliser la notification asynchrone GPU→host.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si dansncclGroupEndInternalon remplaceif ((--ncclGroupDepth) > 0) goto exit;(📎 src/group.cc:1061) parif (ncclGroupDepth > 0) goto exit;(sans décrémentation), que se passe-t-il ? Quelles seraient les conséquences dans un scénario de groupes imbriqués ?
Analyse de référence:
Le code original--ncclGroupDepthdécrémente d'abord puis vérifie. Si on change pour ne pas décrémenter :
if (ncclGroupDepth > 0) goto exit; // 错误版本Alors à chaquencclGroupEndla profondeur ne diminue jamais. Supposons que l'utilisateur écrive :
ncclGroupStart(); // depth = 1
ncclGroupStart(); // depth = 2
ncclAllReduce(...);
ncclGroupEnd(); // 原版: depth = 1, 返回; 错误版: depth = 2, 返回
ncclGroupEnd(); // 原版: depth = 0, 触发下发; 错误版: depth = 2, 返回Dans la version erronée, au deuxièmencclGroupEndlencclGroupDepthreste à 2,> 0est vrai, directementgoto exit, et le déclenchement ne se produit jamais. Tous les appels de communication restent dans l'état "d'accumulation", le processus se bloque.
Plus insidieux encore :ncclGroupDepthest thread_local, il n'est pas réinitialisé par le retour de fonction. Même si le code suivant n'appelle plus l'API group, toutes les communications sur ce thread deviennent invalides.
Cette modification brise aussi la sémantique d'appariement dencclGroupStart—ncclGroupStartincrémente,ncclGroupEndne décrémente pas, la profondeur ne fait qu'augmenter, et finit par déborder (bien qu'un débordement d'int nécessite 2 milliards d'appels, en pratique c'est plus probablement un blocage logique).
Q2: CudaPtrCheckDansattr.type == cudaMemoryTypeDevice && attr.device != comm->cudaDev(📎 src/misc/argcheck.cc:20) cette vérification, si on supprimeattr.type == cudaMemoryTypeDevicecette condition, quel serait le problème ? Dans quels scénarios y aurait-il des faux positifs ?
Réponse de référence:
cudaPointerAttributes.typea trois valeurs possibles :cudaMemoryTypeDevice(mémoire device),cudaMemoryTypeHost(mémoire host),cudaMemoryTypeManaged(mémoire unifiée).
Si on supprimeattr.type == cudaMemoryTypeDevicela condition, cela devient :
if (attr.device != comm->cudaDev) { // 错误版本Alors pour la mémoire host ou la mémoire managed,attr.devicepeut être -1 ou 0, ce qui ne correspond pas àcomm->cudaDev, et déclencherait un faux positif "appareil non correspondant".
Scénario concret : l'utilisateur passe un pointeur alloué parcudaMallocManaged. Leattr.devicede la mémoire managed est généralement l'appareil au moment de l'allocation, mais si la mémoire est migrée vers un autre appareil,attr.devicepeut changer. Plus courant encore, la mémoire host (par exemple la mémoire pinned allouée parcudaHostAlloc),attr.devicevaut -1, différent de toutcudaDev, ce qui provoque un faux positif.
NCCL autorise la mémoire host comme tampon de communication (viacudaMemcpycomme intermédiaire), il faut donc distinguer "mémoire device mais mauvais appareil" et "mémoire non-device". Le premier est une erreur, le second est légitime.
Q3: layoutInfoRecvTopkIdxKind(📎 contrib/nccl_ep/nccl_ep.cc:139-144) on utiliselip->size < field_endpour déterminer si un champ existe. Si la nouvelle version insère un champ au milieu de la structure (et non à la fin), comment cette vérification échoue-t-elle ? Pourquoi la conception ABI impose-t-elle que les nouveaux champs ne soient ajoutés qu'à la fin ?
Analyse de référence:
Supposons que la structure originale soit :
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。
Si la nouvelle version insère un champ entremagicetrecv_topk_idx_kind:
struct ncclEpLayoutInfo_t {
unsigned int size;
unsigned int magic;
unsigned int new_field; // 新插入
ncclEpExpertIdKind_t recv_topk_idx_kind; // offset 变成 12
};À ce momentfield_end = 12 + 4 = 16. Lesizede l'ancien appelant est 12 (taille de l'ancienne structure),12 < 16est vrai, la fonction retourneAUTO— mais l'ancien appelant possède en fait le champrecv_topk_idx_kind, seulement avec un offset différent. Cela fait que lerecv_topk_idx_kinddéfini par l'ancien appelant est ignoré.
Pire encore, si l'ancien appelant écritrecv_topk_idx_kindselon l'ancien offset (8), la nouvelle bibliothèque lit selon le nouvel offset (12), et lira la valeur denew_field, complètement incohérent.
Donc la règle d'or de la conception ABI est :les nouveaux champs ne peuvent être ajoutés qu'à la fin de la structure. Ainsi lesizede l'ancien appelant est inférieur aufield_enddu nouveau champ, la fonction retourne correctement la valeur par défaut ; lesizedu nouvel appelant couvre le nouveau champ, la lecture est normale. L'insertion d'un champ au milieu brise toutes les vérifications de version basées suroffsetof.
Ce chapitre a analysé quatre types de pièges typiques en environnement de production et leurs mécanismes de défense internes. Ces conditions limites nous rappellent que le fonctionnement stable de NCCL dépend non seulement de l'implémentation centrale, mais aussi de l'adaptation et de l'extension de l'écosystème environnant. Le chapitre suivant se tournera vers l'écosystème et les extensions, pour voir comment nccl4py, nccl4rust, nccl_ep, nccl_ubx et d'autres projets périphériques apportent les capacités de NCCL à un public plus large.
Chapitre 23 : Chapitre 23 : Extension de l'écosystème : nccl4py, nccl4rust, nccl_ep, nccl_ubx et autres projets périphériques
Chapitre 23 : Extension de l'écosystème : nccl4py, nccl4rust, nccl_ep, nccl_ubx et autres projets périphériques
Dans le chapitre précédent, nous avons examiné les défaillances typiques de NCCL en environnement de production — mauvaise utilisation de la sémantique de group, incompatibilité du nombre de ranks, interactions avec les streams, conflits de version ABI et délais d'attente réseau. La plupart de ces problèmes surviennent dans des scénarios d'utilisation directe de l'ABI C, alors que les frameworks modernes d'entraînement de grands modèles n'appellent généralement pas directement l'ABI C, mais réutilisent les capacités de NCCL via des bindings Python, Rust ou d'autres langages, ou grâce à des projets d'extension ciblant le MoE, la communication à ultra-haute bande passante, etc. Ces projets périphériques sont placés dans les répertoires bindings/ et contrib/, avec un positionnement expérimental et maintenu par la communauté, sans hériter des garanties de qualité de publication de la bibliothèque principale. Ce chapitre analyse un par un nccl4py, nccl4rust, nccl_ep, nccl_ubx et nccl_checkpoint, pour voir comment ils construisent un écosystème riche en dehors du cœur via trois voies : les bindings de langage, l'extension de l'API device et l'interception de symboles.
nccl4py : bindings Cython et conception de package à espace de noms
Modèle intuitif : traduire l'ABI C en quelque chose que Python comprend
Imaginez que le cœur de NCCL est un diplomate qui ne parle que le langage C, et qu'un script d'entraînement Python est un stagiaire qui ne parle que Python. nccl4py est ce traducteur — il ne change pas ce que dit le diplomate (le comportement de NCCL), il traduit simplement «ncclAllReduce(sendbuff, recvbuff, count, ...)» en «nccl.all_reduce(tensor)». Sans cette couche de traduction, chaque framework Python devrait écrire ses propres bindings ctypes, un travail répétitif et source d'erreurs.
Structure en couches : bas niveau Cython + haut niveau Python
La conception de nccl4py est à deux niveaux : la couche basse est le binding Cython (nccl/bindings/cynccl.pxd), la couche haute est l'API Python (nccl.core). Le README précise explicitement cette stratification📎 bindings/nccl4py/README.md:4-4:
nccl4py provides low-level Cython bindings and a high-level Python API
Le binding Cython est distribué sous forme de fichier.pxdavec le wheel, pour que d'autres extensions Cython puissent directementcimport 📎 bindings/nccl4py/README.md:39-43:
from nccl.bindings cimport cyncclPourquoi exposer la couche Cython et pas seulement la couche Python ? Parce que certains frameworks (comme DeepSpeed, Megatron) ont leur boucle principale en Cython, et passer par l'interpréteur Python à chaque appel coûte trop cher. Directementcimport cyncclpermet aux extensions Cython d'appeler les fonctions NCCL avec une surcharge quasi nulle, proche du C. C'est un design typique d'« exposition en couches » — la couche haute pour les utilisateurs ordinaires, la couche basse pour les scénarios sensibles aux performances.
Package à espace de noms : plusieurs distributions partagent le préfixenccl
C'est la conception la plus ingénieuse de nccl4py.ncclest un package à espace de noms implicite 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.
Dans un package Python traditionnel,nccl/__init__.py« possède » tout l'espace de nomsnccl. Si nccl4py et le binding Python de nccl_ep veulent tous deux fournirnccl.xxx, il y aura conflit — le premier installé gagne. Le package à espace de noms PEP 420 résout ce problème : sans__init__.py, plusieurs distributions peuvent chacune placer des sous-packages dans le répertoirenccl/, et le système d'import de Python les fusionnera. Ainsi nccl4py fournitnccl.bindingsetnccl.core, nccl_ep fournitnccl.ep, les deux peuvent coexister📎 contrib/nccl_ep/README.md:80-82。
Cette conception est cruciale pour l'extension de l'écosystème : à l'avenir, tout tiers voulant ajouternccl.monitoring、nccl.profilingn'aura pas besoin de modifier le code de nccl4py.
Sélection de version CUDA : mécanisme des extras
À l'installation, utilisernccl4py[cu12]ounccl4py[cu13]pour choisir la version majeure de CUDA📎 bindings/nccl4py/README.md:13-17. Le README explique la raison : les extras installent le runtime NCCL et les dépendances CUDA Python correspondants📎 bindings/nccl4py/README.md:19. Les wheels publiés n'ont pas besoin deCUDA_HOMEni du CUDA Toolkit local, mais la compilation depuis les sources nécessite📎 bindings/nccl4py/README.md:20-21。
C'est la pratique standard de l'écosystème Python pour gérer la fragmentation des versions CUDA. Les ABI de CUDA 12 et 13 sont incompatibles, on ne peut pas utiliser un seul wheel pour tout couvrir. L'utilisation d'extras permet à pip de choisir les bonnes dépendances binaires selon l'environnement de l'utilisateur, évitant de ne découvrir l'incompatibilité de version qu'à l'exécution.
Pièges en production
Piège un : conflit entre package à espace de noms et__init__.py.Si un package tiers placenccl/sous__init__.py, le mécanisme de package à espace de noms PEP 420 est cassé, entraînant l'échec de l'import denccl.core. Méthode de diagnostic :python -c "import nccl; print(nccl.__path__)", si l'erreurAttributeErrorapparaît, cela signifie quenccln'est pas un package à espace de noms.
Piège deux : dérive de version ABI Cython. cynccl.pxdest une API expérimentale📎 bindings/nccl4py/README.md:32-32, lors d'une mise à niveau de NCCL,.pxdpeut changer. Les extensions Cython dépendant decimport cynccldoivent correspondre strictement à la version de nccl4py, sinon la résolution de symboles à la compilation échoue.
nccl4rust : propriété RAII et frontière côté device
Modèle intuitif : laisser le compilateur gérer le cycle de vie pour vous
En C, vousncclCommInitRankobtenez un communicator, et devezncclCommDestroyaprès usage. Oublier de détruire provoque une fuite, détruire trop tôt provoque un crash. Le mécanisme RAII (Resource Acquisition Is Initialization) de Rust fait que le compilateur appelle automatiquement le destructeur quand la variable sort de portée — comme une carte de chambre d'hôtel, le système règle automatiquement le compte au moment du départ, sans passer manuellement à la réception.
La valeur fondamentale de nccl4rust est d'appliquer cette sémantique de propriété à l'ABI C de NCCL.
Structure en couches : cinq crates avec des rôles distincts
Le tableau Layout du README liste cinq crates📎 contrib/nccl4rust/README.md:20-28:
| Path | Purpose |
|---|---|
crates/nccl-sys | L'ABI hôte brute générée par bindgen |
crates/nccl | Wrapper hôte de style Rust + propriété RAII |
crates/nccl-device-sys | no_stdDéclarations de device CUDA-Oxide |
crates/nccl-device | TypageDevComm、Team、WindowWrapper |
shim/ | Shims purement C-ABI, utilisant uniquement les en-têtes publics |
Cette séparation est délibérée. Le README explique la motivation📎 contrib/nccl4rust/README.md:30-32: une application hôte peut utiliser uniquementncclsans nécessiter le compilateur GPU Rust ; les kernels CUDA-Oxide utilisentnccl-device; les consommateurs ayant besoin de l'ABI brute peuvent choisir le-syscrate. Cette « stratification à la demande » permet à différents utilisateurs de ne payer que le coût de compilation dont ils ont besoin.
Décision de conception clé : passer le communicateur de device par pointeur plutôt que par valeur
C'est la décision de conception la plus instructive de nccl4rust. La section Host/device ownership boundary du 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.
Pourquoi ne pas refléter les structures C avec des structures Rust ? Parce quencclDevComm_test versionné — les champs peuvent différer selon les versions de NCCL. Si les paramètres du kernel passaient une copie Rust par valeur, l'ABI du kernel serait liée à la disposition des structures d'une version spécifique de NCCL. Dès que NCCL met à jour la structure, tous les kernels déjà compilés devraient être recompilés. En passant par pointeur, on ne transmet qu'une adresse, le kernel y accède via le pointeur, et les changements de disposition n'affectent pas l'ABI. C'est la même approche que l'ABI basée sur la taille dencclEpLayoutInfo_tabordée au chapitre précédent —isoler les différences de version derrière un pointeur。
Frontière de sécurité : ce qui est unsafe
La section Current API contracts du README liste six contrats📎 contrib/nccl4rust/README.md:230-249, dont les principaux :
- Le crate
-sysbrut ne fait que refléter l'ABI C, sans ajouter de vérification de propriété ou de durée de vie📎contrib/nccl4rust/README.md:232-233 - Les wrappers actuels de communication collective et point à point acceptent des pointeurs de device bruts, déclarés comme
unsafe📎contrib/nccl4rust/README.md:42-45 - Les méthodes de traduction de pointeurs renvoient des pointeurs de device bruts, sans possibilité de vérifier les limites d'offset, l'alignement, l'appartenance au peer, l'aliasing ou la durée de vie de la fenêtre📎
contrib/nccl4rust/README.md:242-244
C'est la difficulté fondamentale des bindings Rust pour NCCL : de nombreux contrats d'API de NCCL exigent que « le buffer reste valide jusqu'à la fin du stream CUDA », mais le système de types de Rust ne peut pas exprimer cet événement asynchrone qu'est « la fin du stream ». Ces méthodes ne peuvent donc être queunsafe, renvoyant la responsabilité à l'appelant. Le README indique également une direction d'amélioration📎 contrib/nccl4rust/README.md:44-45: une abstraction de buffer stream-aware pourrait encoder ces exigences dans une API sûre. C'est un travail futur.
Côté device : CUDA-Oxide et shims LTOIR
Le défi central côté device est que l'API device de NCCL est un template C++, tandis que le code device Rust (CUDA-Oxide) nécessite une ABI C. La solution est un shim C++📎 contrib/nccl4rust/README.md:26:
shim/— CUDA C++ C-ABI shim built exclusively from publicnccl.handnccl_device.h
Le shim est compilé en LTOIR (représentation intermédiaire LLVM), puis lié avec le PTX Rust pour former un cubin📎 contrib/nccl4rust/README.md:165-167. Le README décrit le processus de build📎 contrib/nccl4rust/README.md:158-163:
make device \
NCCL_INCLUDE_DIR="$NCCL_INCLUDE_DIR" \
CUDA_HOME="$CUDA_HOME" \
ARCH=90LTOIR est le format intermédiaire d'optimisation à l'édition de liens de NVIDIA. Utiliser LTOIR plutôt que de compiler directement en cubin permet au shim et aux kernels Rust de bénéficier d'optimisations inter-langages à l'édition de liens — par exemple l'inlining des fonctions du shim dans les kernels Rust. C'est la technologie clé de la programmation hybride « template C++ + kernel Rust ».
Pièges en production
Piège un : la version de NCCL doit correspondre exactement.Le README exige explicitementMatching NCCL 2.31 headers and runtime 📎 contrib/nccl4rust/README.md:80-81, car le prototype initialise directement des champs différents dans les versions antérieures de l'API device de NCCL. Une incohérence entre les en-têtes et la version delibnccl.soentraîne un décalage des champs du communicateur de device.
Piège deux : CUDA graph et communicateur de device.Le communicateur de device est une structure versionnée en mémoire hôte, copiée vers le device puis accédée par le kernel via pointeur. Si la capture d'un CUDA graph intègre le pointeur de device dans les paramètres du kernel, la recréation ultérieure du communicateur invalidera le pointeur dans le graph. C'est de même nature que le problème de réallocation de buffer RDMA de nccl_ep.
Piège trois : l'initialisation sûre ne peut pas être mélangée avec le groupe brut.Le README avertit📎 contrib/nccl4rust/README.md:238-239: l'initialisation sûre et les appels de gestion produisant une sortie ne peuvent pas être mélangés avec l'état de groupenccl-sysbrut, car la couche wrapper ne peut pas observer l'état du groupe brut. Le mélange entraîne un conflit entre la logique de polling de la couche wrapper et la sémantique du groupe brut.
nccl_ep : primitives dispatch/combine pour l'expert parallel
Modèle intuitif : le « centre de tri » du MoE
Dans les modèles MoE (Mixture of Experts), chaque token doit être routé vers les top-k experts. Les experts sont répartis sur différents GPU, donc les tokens doivent être transférés entre GPU — c'est le dispatch. Une fois les experts calculés, les résultats doivent être renvoyés au GPU d'origine du token — c'est le combine. nccl_ep est le moteur de communication de ce « centre de tri ».
Sans lui, chaque framework MoE devrait implémenter sa propre logique de communication dispatch/combine, ce qui serait redondant et difficile à optimiser. nccl_ep en fait une primitive standard de l'écosystème NCCL.
Deux algorithmes : LL et HT
Le README décrit deux algorithmes📎 contrib/nccl_ep/README.md:36-40:
- Low-Latency (LL): petit batch, sensible à la latence (inférence LLM). Utilise une communication point-à-point all-to-all directe.
- High-Throughput (HT): grand batch pour l'entraînement et le préremplissage en inférence. Utilise une communication hiérarchique — agrégation NVLink intra-nœud, RDMA inter-nœuds. Exploite le pipeline warp-specialized et TMA de Hopper.
La divergence entre ces deux algorithmes reflète les différents goulots d'étranglement de l'inférence et de l'entraînement MoE. En inférence, le batch est petit, la latence est la contrainte principale, donc LL utilise le point-à-point direct pour éviter les surcoûts d'agrégation. En entraînement, le batch est grand, la bande passante est la contrainte principale, donc HT utilise l'agrégation hiérarchique pour réduire le trafic inter-nœuds. C'est un design typique de « choix d'algorithme selon les caractéristiques de la charge de travail ».
Structure de données centrale : ncclEpGroupConfig_t
C'est la structure de configuration d'EP, avec de nombreux champs📎 contrib/nccl_ep/README.md:339-362. Champs clés :
sizeetversion: vérification de version ABI, même origine que l'ABI basé sur la taille décrit au chapitre précédent📎contrib/nccl_ep/README.md:340-341algorithm: HT ou LL📎contrib/nccl_ep/README.md:342max_dispatch_tokens_per_rank: nombre maximum de tokens dispatchés par rank📎contrib/nccl_ep/README.md:344rdma_buffer_size: taille du buffer RDMA en mode LL📎contrib/nccl_ep/README.md:356-356alloc: allocateur de mémoire device personnalisé📎contrib/nccl_ep/README.md:359
rdma_buffer_sizeLa sémantique deNCCL_EP_AUTOde📎 contrib/nccl_ep/README.md:396-406mérite un examen approfondi. Le README expliquencclEpCreateGroup: en mode AUTO, le buffer n'est pas alloué au moment dencclEpInitHandle, mais lors du premier(layout, num_topk)en fonction du📎 contrib/nccl_ep/README.md:396-406:
réel. Lorsqu'un handle ultérieur nécessite un buffer plus grand, une réallocation collective est effectuée. Ce design d'« allocation paresseuse » évite à l'utilisateur de deviner la taille du buffer, mais introduit trois contraintes(layout, num_topk)1. Tous les ranks doivent utiliser le mêmencclEpInitHandle
appel synchronisésend_only2. La réallocation supprime le contenu de l'ancien buffer,
les données temporairement stockées seront perdues
3. La capture CUDA graph fige le pointeur de base RDMA, une nouvelle capture est nécessaire après réallocationC'est l'un des pièges de production les plus importants de ce chapitre.
L'allocation paresseuse de
ncclEpTensor_tapporte de la facilité d'utilisation, mais transfère à l'utilisateur la complexité du « quand réallouer ».📎 contrib/nccl_ep/README.md:310-332Descripteurs de tenseur : formes statique et dynamique
est un type valeur léger. Le README montre deux utilisations :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 };(sur la pile,copiencclEpTensorAlloc)📎 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));
}copiesizes〔Inférence de conception et compromis architecturaux〕sizesLa différence entre les deux formes réside dans la propriété du tableau📎 contrib/nccl_ep/README.md:325-326. Lesizesdu descripteur statique est un tableau de pile appartenant à l'appelant, qui doit vivre plus longtemps que le descripteurncclEpTensorDestroy. Le📎 contrib/nccl_ep/README.md:514-514du descripteur dynamique est une copie sur le tas appartenant à la bibliothèque, libérée parncclEpTensor_t*. La structure publique détient le pointeur📎 contrib/nccl_ep/README.md:514-514, donc les deux formes peuvent être mélangées dans le même appel
. Ce design permet zéro allocation sur le tas pour les scénarios simples, et la commodité de gestion par la bibliothèque pour les scénarios complexes.
Modes d'exécution : synchrone et par étapes📎 contrib/nccl_ep/README.md:701-741La section Execution Modes du README
décrit deux modes :Mode synchrone📎 contrib/nccl_ep/README.md:705-709。
(par défaut) : occupe les ressources GPU pendant toute l'opération, y compris le temps d'attente de réception des donnéesMode par étapes📎 contrib/nccl_ep/README.md:718-726(LL uniquement) : l'opération est divisée en deux phases, send et receivesend_only = 1. Lancé avecncclEpComplete, les ressources GPU sont libérées après le démarrage du transfert de données, l'application peut utiliser ces ressources pour du calcul, puis terminer avec📎 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: 返回,数据就绪Ce diagramme de séquence illustre la valeur essentielle du mode par étapes :send_onlyretourne immédiatement après le lancement, les ressources SM sont libérées pour le calcul, et l'application appellencclEpCompleteaprès avoir effectué d'autres travaux pour attendre la fin de la réception. C'est le modèle classique de « chevauchement calcul-communication ».
Pièges de production
Piège 1 :ncclEpInitHandlela nature collective conditionnelle deEn mode AUTO,ncclEpInitHandleest un appel collectif conditionnel📎 contrib/nccl_ep/README.md:396-406. Si un rank déclenche une réallocation en raison d'un layout différent, les autres ranks doivent y participer synchroniquement. Une désynchronisation entraîne un deadlock ou une corruption des données.
Piège 2 : interdiction dencclEpInitHandle。pendant la capture CUDA graph📎 contrib/nccl_ep/README.md:396-406Le README avertit explicitementcudaStreamBeginCapture: en mode AUTO, il ne faut pas appelercudaStreamEndCaptureentrencclEpInitHandleet
. Car la réallocation modifie l'adresse de base RDMA, et la capture graph a déjà figé l'ancien pointeur.Piège 3 : surcoût du guard.📎 contrib/nccl_ep/README.md:299-303Le README mentionneNCCL_EP_DISABLE_GUARD=1: EP ajoute par défaut un guard aux buffers de communication internes pour empêcher les appels dispatch/combine adjacents de corrompre mutuellement les données. Les utilisateurs avancés qui ont déjà garanti qu'aucune opération consécutive n'entrera en compétition peuvent utiliser
pour le désactiver et récupérer le surcoût. Mais une mauvaise désactivation entraîne une corruption silencieuse des données.
nccl_ubx : fusion de la communication collective et de l'allocateur symétrique
La communication collective ordinaire ne fait que déplacer des données. Mais dans les modèles réels, l'AllReduce est souvent précédé d'une addition résiduelle et suivi d'un RMSNorm. Si ces opérations sont effectuées séparément, les données font plusieurs allers-retours en mémoire vidéo. L'approche de nccl_ubx est : fusionner l'addition résiduelle, le RMSNorm et la quantification mxfp8 directement dans le noyau de communication collective📎 contrib/nccl_ubx/README.md:6-9. C'est comme une entreprise de déménagement qui non seulement transporte les cartons, mais vous aide aussi à les emballer et les déballer, le tout en un seul passage.
Prérequis matériel : nécessite le multicast NVLink
Le README exige explicitement SM 9.0+ (Hopper/Blackwell), et le chemin du noyau MC nécessite le matériel multicast NVLink📎 contrib/nccl_ubx/README.md:24-24. SM 8.0 (A100) n'est pas pris en charge, car Ampere n'a pas de matériel multicast NVLink,multimem.*et le PTX inline ne peut pas être assemblé pour l'arch 8.0📎 contrib/nccl_ubx/README.md:24-24。
Cela explique pourquoi ubx est « expérimental » — il dépend de la capacité de multicast NVLink introduite avec Hopper.multimem.*L'instruction permet à un GPU d'écrire des données vers les adresses symétriques de plusieurs GPU en une seule instruction, ce qui constitue la base matérielle de la communication collective accélérée. Sans ce matériel, l'optimisation centrale d'ubx ne tient pas.
Allocateur symétrique : transformer les tenseurs PyTorch en fenêtres NCCL
Le cœur d'ubx est un allocateur symétrique personnalisé📎 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.
C'est l'aspect le plus ingénieux d'ubx. La mémoire symétrique de NCCL exige que tous les ranks utilisent le même ensemble d'adresses virtuelles pour accéder aux buffers (vu au chapitre 14). Mais les utilisateurs de PyTorch ont l'habitude d'utilisertorch.Tensor. ubx fait en sorte quetorch.Tensorle stockage sous-jacent de soit directement une fenêtre symétrique NCCL, de sorte que le code utilisateur n'a pas besoin d'être modifié, mais la communication collective peut être zéro-copie — les buffers d'entrée et de sortie sont la mémoire symétrique elle-même, sans copie supplémentaire.
Variantes de communication collective et sélection automatique
Le tableau Available collectives du 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 | — |
Les différences entre les trois variantes :mcutilise le matériel multicast NVLink,ucutilise l'unicast ordinaire,lamportest un algorithme à faible latence. La sélection automatique se fait selon un seuil de 0,25 Mo — les petits messages utilisent Lamport à faible latence, les gros messages utilisent MC/UC à haut débit. Ce seuil est similaire à la logique de tuning du cœur de NCCL, mais ubx l'a simplifié en un seuil fixe.
Opérations fusionnées : residual + RMSNorm
Le README mentionne📎 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.
C'est l'argument de vente principal d'ubx. Le flux traditionnel est : AllReduce → addition résiduelle → RMSNorm, soit trois lectures/écritures en mémoire vidéo. Après fusion, un seul noyau suffit, économisant 2/3 de la bande passante mémoire. Pour l'entraînement de grands modèles limités par la bande passante, c'est une accélération bien réelle.
Dispatch de tokens MoE + quantification mxfp8
Le README décrita2av_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.
Ce noyau fusionne « routage + quantification ». Le bf16 est sur 16 bits, le mxfp8 sur 8 bits ; après quantification, le volume de données est réduit de moitié, et le besoin en bande passante pour la transmission inter-nœuds est réduit de moitié. Quantifier avant la transmission est préférable à quantifier après — on économise la bande passante réseau plutôt que la bande passante mémoire. C'est l'optimisation clé pour l'inférence MoE.
Pièges en production
Piège un :TORCH_CUDA_ARCH_LISTdoit être accompagné duasuffixe.Le README insiste sur📎 contrib/nccl_ubx/README.md:47-56: utiliser leasuffixe pour garantir l'accès à l'ensemble complet dumultimem.*jeu d'instructions. Certaines variantes spécialisées pour l'accélération ne sont pas disponibles sur le9.0/10.0ordinaire ; les futurs noyaux utilisant ces variantes subiront une baisse silencieuse de performance ou un échec d'assemblage.
Piège deux :UBX_BUILD_TIMEOUTla surcharge d'exécution deLe README précise📎 contrib/nccl_ubx/README.md:47-56: le mettre à 1 intègre un timeout de spinloop côté noyau, augmentant la surcharge d'exécution (vérifications supplémentaires declock64()etprintfen cas de timeout). À activer uniquement pour diagnostiquer les blocages.
Piège trois :NCCL_NVLS_ENABLE=0la dégradation deLe README liste cette variable d'environnement📎 contrib/nccl_ubx/README.md:202: la mettre à 0 permet de fonctionner sans multicast NVLink. Mais le chemin du noyau MC devient inopérant, ne laissant que les variantes UC/Lamport, avec une chute importante de performance.
nccl_checkpoint : interception LD_PRELOAD et rejeu d'état
Modèle intuitif : prendre un instantané du domaine de communication
Une tâche d'entraînement tourne depuis plusieurs heures, et soudain il faut migrer vers une autre machine, ou sauvegarder l'état pour pouvoir reprendre. Un checkpoint ordinaire ne sauvegarde que les poids du modèle et l'état de l'optimiseur, mais l'état du domaine de communication NCCL (numéros de rank, connexions, buffers) ne peut pas être sérialisé directement. L'approche de nccl_checkpoint est : intercepter tous les appels NCCL, enregistrer les étapes d'initialisation, et les rejouer lors de la restauration📎 contrib/nccl_checkpoint/README.md:3-7。
C'est comme filmer chaque étape du montage de vos meubles, puis les remonter en suivant la vidéo après le déménagement, plutôt que d'essayer de déplacer les meubles déjà montés en bloc.
Mécanisme central : interception de symboles LD_PRELOAD
La section Design du 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_PRELOADest un mécanisme de l'éditeur de liens dynamique Linux : charger le.sospécifié avant que l'application ne charge normalement les bibliothèques partagées. Si ce.sodéfinit des symboles portant le même nom que ceux de NCCL (par exemplencclCommInitRank), l'éditeur de liens dynamique utilisera en priorité la version du.so. Ainsi le shim peut intercepter tous les appels NCCL, enregistrer les paramètres, puis les rejouer lors de la restauration.
Flux de checkpoint
L'exemple Python du README📎 contrib/nccl_checkpoint/README.md:44-58présente le flux complet :
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()Le flux se décompose en quatre étapes :
1. checkpoint_prepare(): détruire tous les communicateurs pour permettre à CUDA Checkpoint et CRIU de dumper en toute sécurité l'état du processus📎 contrib/nccl_checkpoint/README.md:25-27
2. cuCheckpointProcessLock/Checkpoint: le pilote CUDA verrouille le processus et effectue le checkpoint
3. CRIU dump : un outil externe dumpe la mémoire du processus et les descripteurs de fichiers sur disque
4. cuCheckpointProcessRestore/Unlock + checkpoint_restore(): restaurer le processus, rejouer la configuration NCCL📎 contrib/nccl_checkpoint/README.md:29-31
Redis KVS : rendezvous inter-machines
Le README explique pourquoi Redis est nécessaire📎 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.
Lors de la restauration, la machine peut changer et l'IP aussi. La reconstruction du domaine de communication NCCL nécessite de connaître les nouvelles adresses de tous les peers. Mais le shim ne peut pas connaître directement ces adresses, donc un Redis KVS est utilisé comme rendezvous — tous les processus écrivent leur nouvelle adresse dans le KVS et lisent les adresses des autres processus depuis le KVS. C'est comme après un déménagement, où tout le monde convient d'échanger les nouvelles adresses sur un tableau d'affichage public.
Le README précise que Redis n'est nécessaire que pendant la phase d'amorçage de la restauration📎 contrib/nccl_checkpoint/README.md:221-221,checkpoint_restore()peut être arrêté après le retour.
Limitations : trois non-supportés
La section Limitations du README📎 contrib/nccl_checkpoint/README.md:119-129liste trois limitations :
1. ncclWinGetUserPtr()le pointeur retourné est invalide après restauration📎 contrib/nccl_checkpoint/README.md:125-126
2. La capture de CUDA graph n'est pas supportée📎 contrib/nccl_checkpoint/README.md:136-136
3. L'API device n'est pas supportée —ncclDevCommles objets et lesncclWindow_tvisibles par le device ne peuvent pas être restaurés📎 contrib/nccl_checkpoint/README.md:136-136
La troisième limitation est la plus grave. L'API device est la nouvelle direction de NCCL (DevComm abordé au chapitre 19), mais le checkpoint ne la supporte pas. Cela signifie que les applications utilisant l'API device (comme nccl_ep, nccl_ubx) ne peuvent pas être restaurées via checkpoint. C'est le reflet d'une fragmentation de l'écosystème — les nouvelles fonctionnalités avancent vite, mais les outils de fiabilité ne suivent pas.
Pièges en production
Piège un :NCCL_CHECKPOINT_KVS_PATHse définit avant le checkpoint, et ne peut pas être modifié lors de la restauration.Le README avertit📎 contrib/nccl_checkpoint/README.md:221-221: cette variable d'environnement n'est pas utilisée pendant la phase de préparation du checkpoint, mais elle sera capturée dans le checkpoint et ne pourra pas être facilement modifiée lors de la restauration. Il faut donc la définir avant le checkpoint, et l'adresse Redis dans l'environnement de restauration doit correspondre.
Piège deux :NCCL_CHECKPOINT_KVS_TIMEOUTne couvre que le rendezvous Redis du shim.Le README précise📎 contrib/nccl_checkpoint/README.md:221-221: par défaut 300 secondes. Une fois que le communicateur rejoue et entre dans la phase d'établissement du transport NCCL, les appels de transport NCCL sous-jacents utilisent leur propre comportement et peuvent nécessiter des diagnostics spécifiques au transport. Autrement dit, le timeout ne protège que la phase Redis ; un blocage dans la phase d'établissement du transport doit être diagnostiqué viaNCCL_DEBUG.
Piège trois : la version de NCCL doit correspondre.Le README exige NCCL 2.31.0 ou plus récent📎 contrib/nccl_checkpoint/README.md:158, et recommande queNCCL_SRCla version de NCCL dans le chemin corresponde exactement à la version de la bibliothèque NCCL à l'exécution📎 contrib/nccl_checkpoint/README.md:156-158. Une incompatibilité de version entraîne un décalage de disposition des structures lors du rejeu.
Réflexion de conception : trois modes d'extension de l'écosystème
En revisitant ces cinq projets, on peut dégager trois modes d'extension de l'écosystème NCCL :
Mode un : bindings de langage (nccl4py, nccl4rust).Le défi central est la propriété et le cycle de vie. L'ABI C n'a pas de sémantique de propriété, la couche de binding doit la compléter elle-même. nccl4py utilise une stratification Cython, nccl4rust utilise RAII +unsafefrontière. Le point commun :isoler les différences de version derrière des pointeurs— nccl4rust transmet DevComm via des pointeurs, nccl4py isole les versions via des packages de namespaces.
Mode deux : extension de l'API device (nccl_ep, nccl_ubx).Le défi central est la gestion des versions d'ABI et le cycle de vie des ressources. nccl_ep utilise une ABI basée sur la taille (size-based ABI, détaillée au chapitre précédent), nccl_ubx utilise un allocateur symétrique. Le point commun :allocation paresseuse + réallocation collective— le buffer RDMA de nccl_ep et le pool symétrique de nccl_ubx sont alloués à la demande, mais la réallocation nécessite la synchronisation de tous les ranks.
Mode trois : interception de symboles (nccl_checkpoint).Le défi central est la capture et le rejeu d'état. UtiliserLD_PRELOADpour intercepter tous les appels NCCL, enregistrer les étapes d'initialisation, et les rejouer lors de la restauration. Ce mode ne modifie pas le cœur de NCCL, mais ajoute de manière transparente une capacité de checkpoint aux applications existantes.
La contrainte commune aux trois modes estla compatibilité des versions de NCCL. Tous les projets exigent une correspondance exacte de version de NCCL, car l'ABI de NCCL évolue. Cela reflète une tension fondamentale de l'écosystème NCCL : le cœur itère rapidement, mais les projets périphériques ont besoin de stabilité. L'ABI basée sur la taille, le passage par pointeurs et les packages de namespaces sont tous des moyens techniques pour atténuer cette tension.
flowchart TD
start["用户想扩展 NCCL"] --> q1{"扩展什么?"}
q1 -->|"语言互操作"| lang["语言绑定"]
q1 -->|"新通信模式"| dev["设备 API 扩展"]
q1 -->|"可靠性"| ckpt["符号拦截"]
lang --> q2{"性能敏感?"}
q2 -->|"是"| cython["Cython 底层 + Python 高层<br/>nccl4py"]
q2 -->|"否"| raii["RAII 包装<br/>nccl4rust"]
dev --> q3{"需要 MoE?"}
q3 -->|"是"| ep["dispatch/combine<br/>nccl_ep"]
q3 -->|"否"| ubx["融合集合通信<br/>nccl_ubx"]
ckpt --> preload["LD_PRELOAD 拦截<br/>nccl_checkpoint"]
cython --> abi{"ABI 版本管理"}
raii --> abi
ep --> abi
ubx --> abi
preload --> abi
abi -->|"指针传递"| safe["版本差异隔离"]
abi -->|"size-based"| safe
abi -->|"命名空间包"| safeCe diagramme de décision illustre le chemin de choix pour étendre NCCL. Quelle que soit la voie empruntée, on finit par faire face au problème central de la gestion des versions d'ABI, et les trois moyens techniques (passage par pointeurs, ABI basée sur la taille, packages de namespaces) isolent tous les différences de version derrière une interface stable.
Résumé de ce chapitre
Ce chapitre a analysé cinq projets périphériques de l'écosystème NCCL :
- nccl4pyUtiliser Cython en couches + des packages d'espace de noms PEP 420 pour permettre à l'écosystème Python de s'étendre sans conflit
nccl.*sous-packages. - nccl4rustUtiliser la propriété RAII + le passage de pointeur pour le communicateur de périphérique, afin d'isoler la disposition versionnée des structures C en dehors de l'ABI du noyau.
- nccl_epUtiliser le double algorithme LL/HT + l'allocation paresseuse de tampons RDMA pour fournir des primitives dispatch/combine à MoE, mais cela introduit des contraintes d'appels collectifs conditionnels et d'invalidation des CUDA graphs.
- nccl_ubxUtiliser un allocateur symétrique + la fusion de noyaux pour intégrer l'addition résiduelle, RMSNorm et la quantification mxfp8 dans les noyaux de communication collective, mais cela dépend du matériel NVLink multicast de Hopper+.
- nccl_checkpointUtiliser
LD_PRELOADl'interception de symboles + le rendezvous Redis pour implémenter des points de contrôle de domaine de communication inter-machines, mais cela ne prend pas en charge l'API de périphérique ni les CUDA graphs.
Réflexions et auto-évaluation de ce chapitre
Q1 : Dans le moderdma_buffer_size = NCCL_EP_AUTOde nccl_ep, si le rank 0 appelle d'abordncclEpInitHandleet déclenche une réallocation de tampon, tandis que le rank 1 ne déclenche pas de réallocation en raison d'une disposition différente, que se passe-t-il ? Veuillez analyser en combinant avec les contraintes de📎 contrib/nccl_ep/README.md:396-406.
Analyse de référence: Le README indique explicitement📎 contrib/nccl_ep/README.md:396-406:All ranks must call ncclEpInitHandle in lockstep with the same (layout, num_topk). En mode AUTO,ncclEpInitHandleest un appel collectif conditionnel — le déclenchement ou non de la réallocation dépend de si le(layout, num_topk)de ce handle nécessite un espace plus grand que le tampon actuel.
Si la disposition du rank 0 nécessite un tampon plus grand déclenchant une réallocation, tandis que celle du rank 1 n'en a pas besoin, alors le rank 0 exécutera l'ensemble d'opérations collectives « deregister window → free → ncclMemAlloc → register »📎 contrib/nccl_ep/README.md:396-406, tandis que le rank 1 ne le fera pas. Cela entraîne deux problèmes :
1. Incompatibilité des opérations collectives: Le deregister/register de window dans NCCL est une opération collective nécessitant la participation de tous les ranks. L'exécution unilatérale du rank 0 entraînera que le rank 1 référencera l'ancien handle de window dans les communications ultérieures, alors que le rank 0 a déjà changé pour une nouvelle window, provoquant un échec de communication ou une corruption des données.
2. Incohérence des adresses de base: Après la réallocation, l'adresse de base RDMA du rank 0 a changé, tandis que celle du rank 1 n'a pas changé. Bien que le README indique « 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, cela ne tient que si tous les ranks sont réalloués. L'adresse de base du rank 1 n'a pas changé, celle du rank 0 a changé, la résolution d'adresses inter-ranks sera décalée.
La bonne pratique est : tous les ranks utilisent le même(layout, num_topk)pour appeler synchroniquementncclEpInitHandle, assurant une décision de réallocation cohérente. Si cela ne peut pas être garanti, il faut utiliser le mode expliciterdma_buffer_size > 0, en allouant un tampon suffisamment grand en une seule fois lors dencclEpCreateGroup, évitant ainsi la réallocation à l'exécution📎 contrib/nccl_ep/README.md:396-406。
Q2 : Pourquoi nccl4rust passe-t-ilncclDevComm_tpar pointeur plutôt que par valeur au noyau de périphérique ? Si l'on passait par valeur, que se passerait-il après une mise à niveau de la disposition des structures NCCL ? Veuillez analyser en combinant avec📎 contrib/nccl4rust/README.md:211-219.
Analyse de référence: Le README indique explicitement que📎 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_test une structure publique versionnée, dont les champs peuvent différer selon les versions de NCCL. Si l'on passe par valeur :
1. L'ABI du noyau lie la disposition de la structure: Lorsqu'un paramètre de noyau est passé par valeur, le compilateur intègre la disposition en octets de toute la structure dans la convention d'appel du noyau. Après une mise à niveau de la structure NCCL (ajout de champs, changement d'ordre des champs, changement d'alignement), le noyau déjà compilé interprète toujours les paramètres selon l'ancienne disposition, entraînant un décalage des champs.
2. Nécessité de recompiler tous les noyaux: Chaque mise à niveau de NCCL nécessite de recompiler tous les noyaux utilisant le communicateur de périphérique. Pour les tâches d'entraînement déployées sur un grand nombre de machines, c'est une charge opérationnelle énorme.
3. Incompatibilité entre versions: Si le côté hôte crée un communicateur avec le nouveau NCCL et que le noyau côté périphérique est compilé avec l'ancien NCCL, le passage par valeur entraînera la lecture de champs erronés par le noyau.
Le passage par pointeur ne transmet qu'une adresse de 8 octets, et le noyau accède à la structure via le pointeur. Lors d'une mise à niveau de la disposition de la structure NCCL, tant que le côté hôte crée le communicateur avec la nouvelle version et le copie vers le périphérique, le noyau accédera à la nouvelle disposition via le pointeur. Le noyau lui-même n'a pas besoin d'être recompilé, car son paramètre n'est qu'une adresse. Cela isole les différences de version derrière le pointeur —le pointeur est stable, le contenu pointé peut changer。
C'est la même philosophie de conception que l'ABI basée sur la taille de nccl_ep : utiliser une couche d'indirection pour isoler les détails de version volatils derrière une interface stable.
Q3 : nccl_checkpoint utiliseLD_PRELOADpour intercepter les appels NCCL, mais si l'application lie simultanément nccl4py et nccl_checkpoint, les bindings Cython de nccl4py appellent directement les symboles delibnccl.so,LD_PRELOADpeut-il les intercepter ? Veuillez analyser l'ordre de résolution des symboles.
Analyse de référence: Cela dépend de l'ordre de résolution des symboles.LD_PRELOADest le suivant : l'éditeur de liens dynamique, avant de charger les bibliothèques partagées dont l'application dépend normalement, charge d'abordLD_PRELOADspécifié par.so. Lorsque l'application (ou une bibliothèque dont elle dépend) référence un symbole, l'éditeur de liens dynamique effectue la recherche selon l'ordre « premier chargé, premier résolu » —LD_PRELOADde.soest prioritaire surlibnccl.so。
Donc en théorie, lorsque les bindings Cython de nccl4py appellentncclCommInitRank, l'éditeur de liens dynamique trouve d'abord le symbole de même nom danslibnccl-checkpoint-shim.so, et l'interception réussit.
Mais il existe quelques cas limites :
1. Directdlopen + dlsym: si nccl4py utilisedlopen("libnccl.so")puisdlsympour obtenir un pointeur de fonction,LD_PRELOADne peut pas intercepter, cardlsymcherche directement le symbole dans le.sospécifié, sans passer par la table des symboles globale. Le README mentionne que les applications C utilisentdlsympour résoudrencclCheckpointPrepare 📎 contrib/nccl_checkpoint/README.md:109-109, mais il s'agit de résoudre les symboles du checkpoint lui-même, pas les symboles NCCL.
2. Moment de liaison des symboles: si nccl4py lie les symboles NCCL avant queLD_PRELOADne prenne effet (par exemple dans__attribute__((constructor))), l'interception peut échouer. Mais en temps normal,LD_PRELOADprend effet au démarrage du processus, avant tout code utilisateur.
3. RTLD_DEEPBIND: si nccl4py utilisedlopenen spécifiantRTLD_DEEPBIND, la recherche de symboles est résolue en priorité à l'intérieur delibnccl.so, contournantLD_PRELOAD. C'est un piège courant.
4. Liaison statique: si nccl4py lie statiquement NCCL,LD_PRELOADest totalement inefficace, car les symboles sont déjà résolus à la compilation.
La conclusion est donc :Dans un scénario de liaison dynamique normal,LD_PRELOADpeut intercepter les appels de nccl4py, mais si nccl4py utilisedlopen + RTLD_DEEPBINDou la liaison statique, l'interception échoue. En production, il faut utiliserLD_DEBUG=bindingspour vérifier la liaison des symboles et confirmer que les appels NCCL sont interceptés par le shim.
Dans le prochain chapitre, nous nous tournerons vers l'évolution de l'architecture et les directions futures, pour voir comment NCCL évolue d'une bibliothèque de communication collective vers un moteur de communication programmable.
Ces projets périphériques démontrent, par le biais de bindings linguistiques, d'extensions d'API de dispositif et d'interception de symboles, comment les capacités fondamentales de NCCL sont réutilisées dans différents scénarios. La contrainte centrale qui traverse tous ces projets est la compatibilité des versions de l'ABI NCCL — l'ABI basée sur la taille, le passage de pointeurs et les paquets à espace de noms sont autant de moyens techniques d'isoler les différences de version derrière une interface stable. Comprendre ces moyens est la condition préalable à une utilisation sûre de ces projets périphériques. Alors que ces projets d'extension ne cessent de sonder les limites du cœur, NCCL lui-même évolue discrètement : des opérations collectives fixes vers un moteur de communication programmable, du host proxy vers l'envoi direct depuis le GPU, des buffers enregistrés vers la mémoire symétrique. Dans le prochain chapitre, nous explorerons, à partir des traces d'évolution présentes dans le code source, comment ces changements vont remodeler les modes de communication des frameworks supérieurs.
Chapitre 24 : Chapitre 24 : Évolution de l'architecture et directions futures : de la communication statique à la communication programmable
Chapitre 24 : Évolution de l'architecture et directions futures : de la communication statique à la communication programmable
Dans le chapitre précédent, nous avons vu comment la communauté construit un écosystème périphérique autour du cœur de NCCL : bindings Python, bindings Rust, communication en parallélisme expert, primitives à ultra-haute bande passante, points de contrôle de communication. Ces projets réutilisent tous l'API stable de NCCL, mais leurs exigences dépassent déjà le cadre de la communication collective traditionnelle — le parallélisme expert nécessite des échanges point à point de granularité fine, les points de contrôle nécessitent de suspendre/reprendre l'état de communication, les primitives à ultra-haute bande passante nécessitent de contourner les opérations collectives standard pour agir directement sur le réseau. Ces exigences pointent vers la même question : le modèle d'opérations collectives fixes de NCCL est en train d'être débordé par des besoins de communication plus flexibles. Dans ce chapitre, nous n'examinerons plus un module unique, mais nous partirons des traces d'évolution déjà présentes dans le code source pour discuter de la direction que prend NCCL. Concrètement, nous analyserons trois forces d'évolution entrelacées : les primitives de communication passent de collectives fixes à programmables — l'ordonnancement des tâches RMA dans src/rma/rma.cc permet aux couches supérieures de composer les primitives Put/Signal/WaitSignal, au lieu de ne pouvoir appeler qu'AllReduce ; l'initiation réseau passe du host proxy à l'envoi direct depuis le GPU — la gestion du backend GIN dans src/gin/gin_host.cc permet au kernel GPU de piloter directement la carte réseau ; le modèle mémoire passe des buffers enregistrés à la mémoire symétrique — la sélection de kernel de mémoire symétrique dans src/sym_kernels.cc permet à tous les ranks d'utiliser le même ensemble d'adresses virtuelles pour accéder aux buffers des autres. Ces trois forces ne sont pas isolées ; elles partagent la même infrastructure : l'abstraction de team dans src/nccl_device/core.cc et le DevComm versionné dans src/devcomm/devcomm_v23100.cc. Comprendre comment elles s'articulent, c'est comprendre la logique d'évolution de NCCL, de « bibliothèque de communication collective » à « moteur de communication programmable ».
I. Primitives de communication programmables : comment RMA transforme une « recette fixe » en « buffet libre-service »
Modèle intuitif
La communication collective de NCCL traditionnel ressemble à un menu fixe : vous commandez AllReduce, la cuisine exécute tout selon le processus AllReduce. Mais dans le scénario de parallélisme d'experts (MoE), chaque token doit être envoyé à différents experts, et le modèle d'envoi n'est pas connu du tout au moment de la compilation — c'est comme un buffet, vous devez décider vous-même quoi prendre, combien prendre et quand prendre.
RMA est le « comptoir du buffet » que NCCL fournit à la couche supérieure : Put (écrire des données dans la mémoire du pair), Signal (notifier le pair), WaitSignal (attendre le signal du pair). Le framework supérieur peut combiner librement ces trois primitives pour réaliser n'importe quel modèle de communication.
Sans RMA, l'all-to-all de MoE ne peut être simulé que par de multiples opérations collectives de petite taille, chacune devant passer par le processus complet de lancement de kernel et de synchronisation, avec une latence inacceptable.
Structures de données et disposition mémoire
La structure de données centrale de RMA estncclTaskRma(description de tâche) etncclRmaArgs(paramètres de plan). Regardons d'abord les champs dencclRmaArgs, il est initialisé dansscheduleRmaTasksToPlan.
📎 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;Les champs clés ici sontnRmaTasksProxyetnRmaTasksCe. Ils divisent les tâches RMA en deux chemins d'exécution :
- Chemin CE(Copy Engine, moteur de copie) : le rank cible est dans la portée LSA (Local Symmetric Access, accès symétrique local), peut être réalisé directement avec le moteur de copie du GPU, sans réseau.
- Chemin Proxy: le rank cible n'est pas dans la portée LSA, doit passer par un thread proxy hôte pour piloter le réseau.
La motivation de cette dichotomie est directe : la communication dans la portée LSA passe par NVLink ou PCIe, avec une bande passante élevée et une faible latence, la copie asynchrone par CE est la plus avantageuse ; la communication inter-machines doit passer par la carte réseau, et ne peut être pilotée que par un thread proxy. Séparer la planification des deux types de tâches permet au CE et au proxy de s'exécuter en parallèle, plutôt que d'attendre en série.
ncclTaskRmacontient lui-mêmepeers、nsignals、signalIdxstrois pointeurs de tableaux, enregistrant respectivement le rank pair, le nombre de signaux et l'index de signal. Pour les tâches WaitSignal, une tâche peut attendre plusieurs peers ; pour les tâches Put/Signal, une tâche ne cible qu'un seul peer.
Step-by-Step Walkthrough : la planification d'un WaitSignal
Prenons un scénario concret : le rank 0 appellencclWaitSignal, en attendant les signaux des ranks 1 et 3. Supposons que le rank 1 est dans la portée LSA, et le rank 3 ne l'est pas.
Première étape : trouver la première file de contexte non vide.
📎 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;Les tâches RMA sont mises en file par contexte, chaque contexte est un canal RMA indépendant. Ici, on trouve le premier contexte ayant des tâches, et on récupère sa file.
Deuxième étape : retirer la première tâche, déterminer le type.
📎 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->funcestncclFuncWaitSignal, on entre dans la branche WaitSignal.
Troisième étape : diviser les peers selon l'accessibilité LSA.
📎 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++;
}
}isLsaAccessibleparcourtcomm->devrState.lsaRankList, détermine si le peer est dans l'équipe LSA. Le rank 1 est dans LSA, va dans la liste CE ; le rank 3 n'y est pas, va dans la liste Proxy.
Quatrième étape : créer une nouvelle tâche pour CE et Proxy respectivement.
📎 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;
}La tâche WaitSignal originale est divisée en deux : la tâche CE attend le rank 1, la tâche Proxy attend le rank 3. Les deux tâches peuvent s'exécuter en parallèle — le chemin CE attend sur le GPU, le chemin Proxy attend sur le thread hôte.
Cinquième étape : libérer la tâche originale.
📎 src/rma/rma.cc:249-251
planner->nTasksRma -= 1;
ncclMemoryPoolFree(&comm->memPool_ncclTaskRma, firstTask);La tâche originale a été divisée en deux nouvelles tâches, libérée vers le pool de mémoire.
Contrôle de concurrence et interaction matérielle
L'exécution parallèle de RMA se manifeste dansncclRmaWaitSignal.
📎 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);
}Ce code utilise les événements CUDA pour la synchronisation entre flux : d'abord enregistrer un événement sur le flux d'entrée, faire attendre le flux CE cet événement, puis lancer respectivement les tâches proxy et CE sur les deux flux, et enfin faire attendre le flux d'entrée l'événement du flux CE. Ainsi les deux chemins avancent en parallèle, mais se présentent extérieurement comme une opération synchrone.
Le compromis de conception ici est : l'exécution parallèle peut réduire la latence, mais introduit un surcoût supplémentaire d'enregistrement d'événements et de synchronisation de flux. Pour les petits messages, ce surcoût peut dépasser le gain du parallélisme ; pour les grands messages, le gain du parallélisme est significatif. NCCL ne fait pas de jugement adaptatif ici, mais suit uniformément le chemin parallèle — car le scénario typique de RMA est la communication fine-grained de grands messages.
Guide de production pour éviter les pièges
Piège 1 : une erreur de jugement d'accessibilité LSA fait que la tâche prend le mauvais chemin. isLsaAccessibleparcourtlsaRankList, silsaSizeest 0 (par exemple un domaine de communication à rank unique), tous les peers seront jugés inaccessibles, et passeront tous par le chemin Proxy. Cela ne se manifestera pas lors de tests à petite échelle, mais entraînera une chute brutale des performances lors d'un déploiement à grande échelle. La méthode de diagnostic est de regarder dans les logs INFO descheduleRmaTasksToPlanle ratio denRmaTasksProxyetnRmaTasksCe.
Piège 2 : le cycle de vie du tableau peer après la division de la tâche WaitSignal.LepeersCedu chemin CE utilisencclMemoryStackAllocpour l'allocation, le cycle de vie suitcomm->memScoped; lepeersProxydu chemin Proxy utilisencclCallocpour l'allocation, et doit être manuellement [libéré] après l'exécution de la tâchefree. Si la création de la tâche Proxy échoue,failla branche libère ces tableaux.
📎 src/rma/rma.cc:302-308
exit:
return ret;
fail:
free(peersProxy);
free(nsignalsProxy);
free(signalIdxsProxy);
goto exit;Piège 3 : traitement par lots inter-context des tâches Put/Signal.Dans la branche Put/Signal, NCCL regroupe les tâches put/signal de tous les contextes dans un même plan, mais s'arrête à la rencontre d'un 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);
...
}
}L'intention de cette conception est : un seul lancement de kernel couvre les put/signal de tous les contextes, réduisant ainsi les frais de lancement. Mais la file de chaque contexte n'est consommée que jusqu'au premier WaitSignal, garantissant l'ordre FIFO par contexte. Si la couche supérieure alterne les appels à put et waitSignal dans un même contexte, l'effet de traitement par lots est fortement réduit — c'est un pattern à surveiller lors de l'utilisation de RMA.
---
II. Émission directe depuis le GPU vers le réseau : comment GIN permet au kernel de contourner le host proxy
Modèle intuitif
La communication réseau traditionnelle de NCCL ressemble à l'envoi d'une lettre : le kernel GPU place les données dans un tampon, le thread host proxy transmet les données à la carte réseau, et la carte réseau les envoie. GIN, quant à lui, permet au kernel GPU de déposer directement la lettre dans la boîte aux lettres du destinataire — le kernel écrit directement dans la file d'envoi de la carte réseau, et la carte réseau lit directement la mémoire GPU.
Sans GIN, chaque communication réseau doit transiter par la mémoire hôte, ajoutant au moins un aller-retour PCIe de latence. Pour une communication fine-grained comme MoE, cette latence est fatale.
Structures de données et disposition mémoire
L'état central de GIN estncclGinState, qui gère plusieurs backends et plusieurs DevComm. Examinons d'abord la table de compatibilité des versions de backend.
📎 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)};L'indice de ces tableaux est le numéro de version du backend, et la valeur est la version minimale de NCCL compatible. Par exemple,proxyBackendMinVersions[3]correspond au backend version 3, exigeant NCCL au minimum 2.32.0. Cette conception permet à NCCL de choisir la version de backend appropriée à l'exécution en fonction de la version du code du périphérique, plutôt qu'une liaison à la compilation.
La motivation de cette table de compatibilité des versions est la suivante : le backend GIN (pilote de carte réseau, firmware) et la bibliothèque NCCL évoluent à des rythmes différents. Si les exigences de version étaient codées en dur, la mise à niveau de l'un ou l'autre entraînerait une incompatibilité. L'utilisation d'un tableau pour le mappage des versions permet une sélection dynamique à l'exécution, assurant la rétrocompatibilité avec les anciens backends.
ncclGinStateDevCommest l'état GIN de chaque DevComm, contenantcontextCount、backendIndex、ginCtx[]、devHandles[]et d'autres champs. Il est chaîné en liste liée et attaché àginState->devComms.
Step-by-Step Walkthrough : établissement d'une connexion GIN
Plaçons-nous dans un scénario : le rank 0 initialise le domaine de communication et doit établir une connexion GIN.
Première étape : vérifier si GIN est activé et pris en charge.
📎 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()lit la variable d'environnementNCCL_GIN_ENABLE, par défaut 1. Si l'utilisateur la désactive explicitement, une erreur est renvoyée directement.
Deuxième étape : vérifier la prise en charge de la mémoire symétrique.
📎 src/gin/gin_host.cc:111-114
if (!comm->symmetricSupport) {
WARN("Communicator does not support symmetric memory!");
return ncclInternalError;
}GIN dépend de la mémoire symétrique — car le kernel GPU doit connaître l'adresse virtuelle du tampon du pair, et seule la mémoire symétrique garantit la cohérence des adresses.
Troisième étape : obtenir la liste des périphériques GIN locaux.
📎 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);
}ncclTopoGetLocalGinDevsidentifie toutes les cartes réseau prenant en charge GIN à partir du graphe de topologie. Si le nombre dépasseNCCL_GIN_MAX_CONNECTIONS, seules les premières sont retenues et un avertissement est affiché.
Quatrième étape : calculer l'équipe 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;
}Si le type de connexion est FULL, l'équipe GIN est l'équipe mondiale entière ; sinon, seul le premier rank de chaque hôte est connecté (connexion rail).ncclTeamRankToWorldconvertit les ranks de l'équipe en ranks mondiaux.
Cinquième étape : établir les connexions backend par backend.
📎 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);
}
}Chaque backend appelle d'aborddevicespour obtenir le nombre de périphériques, puis exécute le flux listen→getProperties→allGather→connect→closeListen pour chaque connexion.bootstrapAllGatheréchange les handles entre tous les ranks, de sorte que chaque rank connaisse les informations de connexion de ses pairs.
Contrôle de concurrence et interaction matérielle
Le thread de progression de GIN est le mécanisme de concurrence central.
📎 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();
}
}Voici plusieurs conceptions clés :
1. Affinité CPU:ncclOsSetAffinitylie le thread de progression à un cœur CPU spécifié, évitant l'invalidation du cache due à la migration de thread.
2. Recul du verrou d'écriture:writePendingest un indicateur atomique ; lorsque le thread principal veut modifierdevCommsla liste liée, il le positionne d'abord, et le thread de progression, le voyant, cède activement pour éviter la contention de verrou.
3. Verrou lecture-écriture:devCommRwMutexestshared_timed_mutex, le thread de progression détient le verrou de lecture pour parcourir la liste liée, et le thread principal détient le verrou d'écriture pour modifier la liste liée.
4. Répartition des threads: le thread t est responsable des connexions t, t+proxyNthreads, t+2*proxyNthreads, ..., réalisant l'équilibrage de charge via une boucle 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);
}Cette implémentation du verrou d'écriture suppose qu'il n'y a qu'un seul écrivain (le thread principal), donc aucune exclusion mutuelle supplémentaire n'est nécessaire.writePendingpositionne d'abord puis acquiert le verrou, garantissant que le thread de progression puisse voir l'intention d'écriture avant d'acquérir le verrou et reculer activement.
Guide de production pour éviter les pièges
Piège 1 : un nombre de connexions GIN non correspondant provoque un interblocage AllGather.LeginCommCountde chaque rank peut différer (selon le nombre de cartes réseau locales), NCCL prend la valeur minimale parmi tous les ranks viabootstrapAllGather.
📎 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]);
}Si le nombre de cartes réseau d'un rank est inférieur à celui des autres ranks, tous les ranks sont réduits à la valeur minimale. Cela garantit une connexion symétrique, mais gaspille les ressources des cartes réseau.
Piège 2 : proxyNthreads dépasse ginCommCount, ce qui entraîne une rotation à vide des threads.Si l'utilisateur a définiNCCL_GIN_PROXY_NTHREADSsupérieur àginCommCount, les threads excédentaires tournent à vide dans la boucle 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.Ce n'est pas un problème de correction, mais cela gaspille des ressources CPU. La méthode de diagnostic consiste à vérifier siNCCL_GIN_PROXY_NTHREADSest supérieur au nombre réel de cartes réseau.
Piège 3 : condition de course lors de la libération de DevComm. ncclGinDevCommFreeOn retire d'abord le DevComm de la liste chaînée, puis on détruit le 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]));
}Après le retrait, le thread de progression ne voit plus ce DevComm, donc la destruction du context est sûre. Mais si des opérations réseau in-flight sont en cours pendant la destruction, cela peut entraîner un comportement indéfini — c'est ce qu'il faut garantir lors de l'utilisation de GIN : avant de libérer le DevComm, il faut s'assurer que toutes les opérations sont terminées.
---
III. Kernel de mémoire symétrique : du « buffer enregistré » à « l'espace d'adressage unifié »
Modèle intuitif
Le buffer du NCCL traditionnel est un « système d'enregistrement » : chaque rank enregistre son propre buffer, et lors de la communication, les adresses sont échangées via un handle. La mémoire symétrique, quant à elle, est un « espace d'adressage unifié » : tous les ranks conviennent du même ensemble d'adresses virtuelles ; l'adresse A du rank 0 et l'adresse A du rank 1 pointent vers leurs mémoires physiques respectives, mais le code peut y accéder en utilisant la même adresse.
C'est comme si tout le monde convenait que « 3e rangée, 5e siège » désigne le même emplacement chez chacun, sans avoir à demander d'abord « où se trouve le 3e rangée, 5e siège chez toi » pour trouver quelque chose.
Sans mémoire symétrique, chaque kernel devrait d'abord résoudre l'adresse du pair, ce qui augmenterait la surcharge d'instructions et la pression sur les registres.
Structures de données et disposition mémoire
Le cœur du kernel de mémoire symétrique est le kernel mask — une bitmap qui marque quels kernels sont disponibles dans le domaine de communication actuel.
📎 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 = ...;Chaque mask est un entier de 32 bits ; le i-ème bit à 1 indique que le kernel i est disponible. Ces masks sont regroupés selon différentes dimensions :
- Par protocole:STMC(Simple TMA Multimem Copy)、LDMC(Low-latency Direct Multimem Copy)、LL(Low Latency)
- Par opération:AG(AllGather)、AR(AllReduce)、RS(ReduceScatter)
- Par matériel:LSA(Local Symmetric Access)、Gin(GPU-Initiated Networking)、Tma(Tensor Memory Accelerator)
L'avantage de cette conception par bitmap est qu'elle permet de filtrer rapidement les kernels disponibles par opérations bit à bit. Par exemple,kmask &= ~kernelMask_STMCune seule ligne suffit pour désactiver tous les kernels STMC, sans parcourir la liste.
Step-by-Step Walkthrough : un calcul de kernel mask
Prenons un scénario : le rank 0 doit exécuter un AllReduce, le type de données est float16, la taille du message est 1 Mo, le domaine de communication compte 8 ranks, tous interconnectés par NVLink.
Première étape : obtenir le mask de base correspondant à l'opération.
📎 src/sym_kernels.cc:304-306
uint32_t kmask = kernelMask_coll(coll);kernelMask_coll(ncclFuncAllReduce)retournekernelMask_AR, contenant 5 kernels AllReduce.
Deuxième étape : vérifier la disponibilité de STMC et 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;hasLsaMultimemest calculé dansncclSymkInitOnce, ce qui exige que le multicast symétrique NVLS soit disponible et que l'équipe LSA compte plus de 2 ranks. float16 prend en charge LDMC, donc sihasLsaMultimemest vrai, le kernel LDMC est conservé.
Troisième étape : vérifier la limite de taille de message.
📎 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;Le kernel LL suit le nombre d'éléments avec un entier 32 bits, donc il est désactivé lorsque le nombre d'octets sur le bus dépasse 2 Go. Si cela dépasse 64 Go, tous les kernels sont désactivés (débordement d'entier 32 bits).
Quatrième étape : vérifier la disponibilité de TMA.
📎 src/sym_kernels.cc:344-345
if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
if (!symAligned16B) kmask &= ~kernelMask_Tma;TMA nécessite une capacité SMEM et une capacité de calcul 10.0+, ainsi qu'un buffer aligné sur 16 octets.
Cinquième étape : vérifier les besoins 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;Si l'équipe LSA couvre tous les ranks, GIN n'est pas nécessaire ; sinon, seuls les kernels GIN sont conservés.
Contrôle de concurrence et interaction matérielle
L'initialisation du kernel de mémoire symétrique implique la création de DevComm et l'allocation de ressources.
📎 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;
}Le point clé ici estncclDevrCommCreateInternal, qui crée un DevComm interne contenant les ressources telles que le multicast LSA, les inbox/outbox GIN, les signaux, etc.reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAILspécifie que GIN utilise le mode de connexion 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;Le kernel de mémoire symétrique utilise un buffer de profiler indépendant pour éviter l'entrelacement avec le workCounter des kernels classiques.
Guide de production pour éviter les pièges
Piège 1 : besoins SMEM du kernel TMA.TMA nécessite environ 8 Ko de SMEM scratch par warp, soit 128 Ko pour 16 warps.
📎 src/sym_kernels.cc:135-142
bool ncclSymkTmaAvailable(struct ncclComm* comm) {
if (comm->maxSharedMemOptin < ncclTmaShmemScratchWarpSize() * 16) {
return false;
}
return comm->minCompCap >= 100 && ncclParamSymTmaEnable();
}Si la capacité SMEM du GPU est insuffisante (par exemple une instance MIG), le kernel TMA sera désactivé. La méthode de diagnostic consiste à vérifier simaxSharedMemOptinest inférieur àncclTmaShmemScratchWarpSize() * 16。
Piège 2 : limites du chunk size GIN.Le chunk size du kernel ReduceScatter GIN a des limites inférieure et supérieure.
📎 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);
}Si l'utilisateur a définiNCCL_SYM_RS_GIN_CHUNK_SIZEau-delà de 1 Go, il sera tronqué à 1 Go ; s'il est inférieur à 128 octets, il sera relevé à 128 octets. La valeur finale sera également arrondie à la puissance de 2 inférieure.
Piège 3 : Incompatibilité du type d'enregistrement de la mémoire symétrique. ncclGetSymRegTypeSelon lesNCCL_WIN_COLL_SYMMETRICindicateurs de sendWin et recvWin, déterminer le type d'enregistrement.
📎 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;
}Si les types d'enregistrement de send et recv sont incohérents, le kernel doit emprunter des chemins de code différents. Cela affecte les performances, mais ne provoque pas d'erreur.
---
IV. Abstraction Team et DevComm versionné : l'infrastructure de l'évolution
Modèle intuitif
L'abstraction Team est comme un « regroupement » : l'équipe mondiale est la classe entière, l'équipe LSA est le voisin de table, l'équipe Rail est la même colonne de sièges. Différents modes de communication nécessitent différentes perspectives de regroupement.
Le DevComm versionné est comme un « traducteur » : différentes versions du code device parlent différents « dialectes », la couche de compatibilité DevComm se charge de traduire, permettant aux anciens et nouveaux codes de se comprendre mutuellement.
Sans l'abstraction Team, chaque kernel devrait calculer lui-même le mapping des ranks ; sans le DevComm versionné, tout changement d'ABI entraînerait la recompilation de tout le code device.
Structures de données et disposition mémoire
Team est un simple triplet :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;
}Le stride de l'équipe mondiale est 1, car tous les ranks sont disposés consécutivement.
📎 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;
}Le stride de l'équipe Rail estlsaSize, car les ranks sur chaque rail sont espacés de la taille d'une équipe LSA.
Le cœur du DevComm versionné est lancclDevCommCompatstructure.
📎 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
};Cette structure définit les règles de compatibilité de la version 2.31.0.minVersionetmaxVersiondéfinissent la plage de versions applicables, les quatre pointeurs de fonction suivants définissent la logique de filtrage des propriétés et de conversion de structure. Si tous sont nullptr, cela signifie que cette version n'a pas de besoin de compatibilité particulier.
Step-by-Step Walkthrough : une conversion Team
Prenons un scénario : rank 5 dans un domaine de communication de 8 ranks, la taille de l'équipe LSA est 4. Il faut calculer le rank de rank 5 dans l'équipe Rail.
Première étape : initialiser l'état DevR.
📎 src/nccl_device/core.cc:70-79
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};ncclDevrInitOnceCalcule les informations dérivées comme l'équipe LSA, l'équipe CFT, etc. En cas d'échec, retourne une équipe vide.
Deuxième étape : calculer les paramètres de l'équipe 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; // 4Le rank de rank 5 dans l'équipe Rail est 1, l'équipe a 2 ranks, le stride est 4.
Troisième étape : reconvertir en rank mondial.
📎 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;
}Pour convertir Rail rank 0 en rank mondial :5 + (0 - 1) * 4 = 1. Vérification : rank 1 et rank 5 sont sur le même rail (intervalle de 4).
Contrôle de concurrence et interaction matérielle
L'abstraction Team elle-même est sans état, ne nécessite pas de contrôle de concurrence. MaisncclDevrInitOnceest chargé paresseusement, toutes les informations dérivées sont calculées lors du premier appel.
📎 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;
}Le commentaire dit « Ignoring errors since if it fails ncclDevrInitOnce will try again » — si l'initialisation échoue, retourne une équipe vide, le prochain appel réessaiera.
Guide de production pour éviter les pièges
Piège 1 : hypothèse de stride dans la conversion Team. ncclTeamRankToWorldsuppose que les ranks dans l'équipe forment une progression arithmétique.
📎 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;
}Si l'équipe n'est pas une progression arithmétique (par exemple un regroupement arbitraire personnalisé), cette fonction calculera faux. NCCL ne supporte actuellement que les équipes régulières.
Piège 2 : pointeur nul dans le DevComm versionné. ncclDevCommCompat_v23100Tous les pointeurs de fonction de sont nullptr, indiquant l'absence de logique de compatibilité particulière. Si une future version nécessite une conversion, ces fonctions doivent être implémentées, sinon les anciens et nouveaux codes ne pourront pas interopérer.
Piège 3 : mode hiérarchique de l'équipe CFT. ncclTeamCftsupporte trois modes : 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);Si un mode invalide est passé, retourne une équipe vide. Lors de l'utilisation de l'équipe CFT, il faut s'assurer que le mode est correct.
---
Réflexions de conception
Pourquoi NCCL doit-il supporter simultanément les trois voies d'évolution RMA, GIN et mémoire symétrique ?
Ces trois voies résolvent des problèmes à différents niveaux :
- RMArésout le problème du « mode de communication fixe » — permettant aux couches supérieures de combiner des primitives pour réaliser n'importe quel mode de communication.
- GINrésout le problème de la « latence réseau élevée » — permettant au GPU de piloter directement la carte réseau, contournant le host proxy.
- Mémoire symétriquerésout le problème du « coût de résolution d'adresse » — permettant au kernel d'accéder directement à la mémoire distante via une adresse unifiée.
Ils ne sont pas en relation de substitution, mais de complémentarité. RMA peut utiliser GIN comme transport sous-jacent, GIN dépend de la mémoire symétrique pour fournir la cohérence d'adresse. Les trois constituent ensemble l'infrastructure du « moteur de communication programmable ».
Quelle est la philosophie de conception du DevComm versionné ?
L'idée centrale du DevComm versionné est « ABI stable, API évolutive ». Le code device (kernel) est compilé et intégré au binaire, il ne peut pas être recompilé lors de la mise à niveau de la bibliothèque NCCL. Donc NCCL doit garantir que l'ancien code device peut fonctionner sur la nouvelle bibliothèque.ncclDevCommCompatLa structure est le point d'entrée de la couche de compatibilité : la nouvelle bibliothèque sélectionne les règles de compatibilité appropriées selon la version du code device, et effectue des conversions de structure si nécessaire.
---
Résumé de ce chapitre
Dans ce chapitre, en partant des traces d'évolution dans le code source, nous avons analysé les trois forces qui font passer NCCL d'une bibliothèque de communication collective à un moteur de communication programmable :
1. RMA(src/rma/rma.cc) : En combinant les primitives Put/Signal/WaitSignal, permettre aux couches supérieures d'implémenter n'importe quel modèle de communication. La conception centrale consiste à diviser les tâches en deux chemins parallèles, CE et Proxy, selon l'accessibilité LSA.
2. GIN(src/gin/gin_host.cc) : En envoyant directement depuis le GPU vers le réseau, en contournant le proxy hôte. La conception centrale comprend la gestion multi-backend, la table de compatibilité des versions et le pool de threads de progression.
3. kernel de mémoire symétrique(src/sym_kernels.cc) : En unifiant l'espace d'adressage, éliminer le surcoût de résolution d'adresses. La conception centrale repose sur le bitmap de masque de kernel et l'accélération matérielle TMA/GIN.
4. Abstraction Team et DevComm versionné(src/nccl_device/core.cc、src/devcomm/devcomm_v23100.cc) : Fournir l'infrastructure pour l'évolution. Team offre une vue de regroupement, le DevComm versionné assure la compatibilité ABI.
L'impact de ces changements sur les frameworks supérieurs est profond : le ProcessGroup de PyTorch peut appeler directement les primitives RMA pour implémenter des modèles de communication personnalisés ; le parallélisme d'experts de Megatron peut exploiter GIN pour réduire la latence all-to-all ; la mémoire symétrique simplifie le code des kernels.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprimescheduleRmaTasksToPlanle jugement d'accessibilité LSA de la branche WaitSignal dans , et que tous les peers empruntent le chemin Proxy, quelles en seraient les conséquences ? Dans quels scénarios cela déclencherait-il une catastrophe de performance ?
Analyse de référence:
Le jugement d'accessibilité LSA se trouve dans📎 src/rma/rma.cc:187-204, il divise les peers en deux groupes : CE et Proxy. Si l'on supprime ce jugement, tous les peers empruntent le chemin Proxy,nRmaTasksCereste toujours à 0.
Les conséquences sont : le chemin CE n'est plus du tout utilisé, tous les WaitSignal passent par le polling du thread proxy hôte sur le réseau. Pour les peers à portée LSA (interconnectés par NVLink sur la même machine), on pouvait initialement utiliser le moteur de copie GPU pour attendre de manière asynchrone, désormais c'est un thread hôte qui fait du polling, la latence passe de l'ordre de la microseconde à celui de la milliseconde.
Scénario de catastrophe de performance : dans l'entraînement MoE, chaque token doit attendre les signaux de plusieurs experts. Si tous les signaux passent par Proxy, le thread hôte devient le goulot d'étranglement, et le GPU passe une grande partie de son temps à attendre le polling de l'hôte. Sur une machine à 8 GPU entièrement NVLink, cette dégradation est particulièrement marquée — alors que toutes les communications pouvaient initialement passer par CE, elles sont maintenant toutes concentrées sur l'hôte.
Méthode de diagnostic : consulterscheduleRmaTasksToPlanles logs INFO de , sinRmaTasksCereste toujours à 0 alors quenRmaTasksProxyest très grand, cela indique un problème dans le jugement LSA.
Q2:ncclGinProgressDans , la combinaisonwritePendingdu flag etdevCommRwMutexdu verrou lecture-écriture, si l'on supprimewritePendingla vérification et que l'on ne conserve que le verrou lecture-écriture, quels problèmes cela poserait-il ?
Analyse de référence:
writePendingLa vérification se trouve dans📎 src/gin/gin_host.cc:63-66, elle permet au thread de progression de céder activement lorsque le thread principal veut écrire. Si l'on supprime cette vérification, le thread de progression tentera directement d'acquérir le verrou de lecture.
Le problème est le suivant :std::shared_timed_mutexle verrou de lecture de est partagé, plusieurs threads de progression peuvent le détenir simultanément. Si le thread principal veut acquérir le verrou d'écriture, il doit attendre que tous les verrous de lecture soient libérés. Sous forte charge, les threads de progression acquièrent fréquemment le verrou de lecture, le thread principal peut rester longtemps sans pouvoir obtenir le verrou d'écriture, ce qui provoque le blocage dencclGinDevCommSetupouncclGinDevCommFree.
Plus grave encore : si le thread principal positionne d'abordginProgressWriteLockdanswritePendingpuis acquiert le verrou, et que les threads de progression ne vérifient paswritePending, alors les threads de progression peuvent encore acquérir le verrou de lecture après que le thread principal l'a positionné, rendant le temps d'attente du thread principal imprévisible.
writePendingLe rôle de est une « notification souple » : dire aux threads de progression « je vais écrire, laissez-moi la place ». C'est plus efficace que de dépendre simplement de l'équité du verrou, car les threads de progression peuvent céder activement au lieu de se bloquer sur le verrou.
Q3:ncclSymkMaskDans , sinBusBytes >= 32 * (size_t(2) << 30)désactive tous les kernels (kmask = 0), alorsncclSymkAvailableretourne false, vers quel chemin NCCL va-t-il se replier ? Quel est l'impact sur les performances de ce chemin de repli ?
Analyse de référence:
kmask = 0Dans📎 src/sym_kernels.cc:342, à ce momentncclSymkAvailableretourne false (📎 src/sym_kernels.cc:354-361)。
Le chemin de repli est : NCCL utilisera les kernels de communication collective traditionnels (kernels de mémoire non symétrique). Ces kernels accèdent à la mémoire distante via des buffers enregistrés, nécessitant d'abord une résolution d'adresse, ce qui entraîne un surcoût d'instructions plus important.
Impact sur les performances : pour les très gros messages (dépassant 64 Go d'octets de bus), le surcoût de résolution d'adresse des kernels traditionnels est négligeable, car le transfert de données lui-même domine. Mais dans les cas limites (juste au-dessus de 64 Go), les kernels traditionnels peuvent être 10 à 20 % plus lents que les kernels de mémoire symétrique.
La raison fondamentale de cette limitation est que : les kernels de mémoire symétrique utilisent des entiers 32 bits pour suivre les chunks de boucle déroulée, chaque chunk faisant au moins 32 octets, donc la plage adressable maximale est de 32 * 2^31 = 64 Go. Au-delà de cette plage, il y a débordement d'entier.
En production réelle, les scénarios où une seule communication collective dépasse 64 Go sont rares (généralement un all-reduce après accumulation de gradients), mais pas impossibles. Si l'on rencontre ce scénario, on peut envisager une communication fragmentée ou l'utilisation de kernels traditionnels.
---
Transition de fin de chapitre
Dans ce chapitre, nous avons vu que NCCL évolue d'« opérations collectives fixes » vers un « moteur de communication programmable » : RMA fournit la composition de primitives, GIN fournit l'envoi direct depuis le GPU, la mémoire symétrique fournit un espace d'adressage unifié, Team et le DevComm versionné fournissent l'infrastructure.
Ces évolutions ne sont pas isolées, elles pointent toutes ensemble vers un objectif :permettent aux frameworks de niveau supérieur de mettre en œuvre des modes de communication personnalisés avec une latence plus faible et une flexibilité accrue. Pour des frameworks comme PyTorch et Megatron, cela signifie qu'ils peuvent construire directement au-dessus de NCCL des modes de communication complexes tels que le all-to-all MoE, le parallélisme pipeline, le parallélisme d'experts, sans avoir à contourner NCCL pour implémenter leur propre couche réseau.
Le chapitre suivant est le dernier du livre. Nous allons reparcourir l'intégralité de la chaîne d'un AllReduce — depuis l'appel àncclAllReduce, en passant par la mise en file des tâches, la sélection de l'algorithme, le lancement du kernel, la progression du proxy, le transfert réseau, jusqu'au retour du résultat. Cette rétrospective reliera les connaissances des 24 chapitres précédents pour former une carte cognitive complète.
À ce stade, nous avons clairement identifié les trois axes principaux de l'évolution de NCCL, passant d'opérations collectives fixes à un moteur de communication programmable : la composition de primitives RMA, l'envoi direct depuis le GPU vers le réseau, le modèle de mémoire symétrique, ainsi que l'abstraction team et le DevComm versionné qui les soutiennent. Ces mécanismes convergent vers un avenir de communication plus flexible et plus proche des capacités matérielles. Cependant, quelle que soit l'évolution de l'architecture, la chaîne complète d'un AllReduce reste la pierre angulaire de la compréhension de NCCL. Le chapitre suivant n'introduira aucun nouveau code, mais reprendra de bout en bout le flux des chapitres 3 à 10 — depuis l'appel à ncclAllReduce, jusqu'à l'établissement du domaine de communication, la recherche de topologie, la sélection d'algorithme, la mise en file des tâches, le lancement du kernel, l'exécution des primitives côté device, et l'écriture des résultats. Vous réassemblerez les mécanismes dispersés dans chaque chapitre en un modèle mental complet, et obtiendrez un index « quel chapitre consulter en cas de problème ».
Chapitre 25 : Chapitre 25 : Rétrospective globale et réflexions : le voyage ultime d'un AllReduce et l'essence de sa conception
Chapitre 25 : Rétrospective globale et réflexions : le voyage ultime d'un AllReduce et l'essence de sa conception
Dans le chapitre précédent, en nous appuyant sur les traces d'évolution dans le code source, nous avons anticipé la tendance architecturale de NCCL : passer d'opérations collectives fixes à un modèle programmable, du host proxy à l'envoi direct depuis le GPU, des buffers enregistrés à la mémoire symétrique. Il est maintenant temps de replacer ces tendances dans un flux d'exécution concret pour les vérifier. Ce chapitre n'introduit aucun nouveau code, mais relie à nouveau la chaîne de bout en bout des chapitres 3 à 10 — depuis la ligne d'appel ncclAllReduce, jusqu'à l'écriture du résultat en mémoire GPU. Après lecture, vous devriez pouvoir répondre clairement : par quelles fonctions passe un AllReduce ? Dans quel fichier et à quelle ligne se trouve chaque fonction ? Quel chapitre consulter en cas de problème ?
I. Initialisation : comment le domaine de communication « prend vie »
Modèle intuitif
Imaginez le domaine de communication comme un « groupe de discussion ». Lorsque vous appelezncclCommInitRank, c'est comme « demander à rejoindre le groupe ». NCCL doit à ce moment déterminer entièrement la liste des membres du groupe (peerInfo), qui communique avec qui par quel lien (graphe de topologie), et combien de pipelines chaque lien ouvre (channel).Si cette étape est erronée, toutes les communications suivantes seront erronées— comme si quelqu'un n'avait pas été ajouté au groupe de discussion : le message que vous envoyez ne sera jamais reçu par une personne.
Structures de données et disposition mémoire
La structure centrale du domaine de communication estncclComm, dont l'initialisation se fait en deux phases :commAllocse charge d'« allouer le squelette »,initTransportsRankse charge de « remplir la chair ».
commAllocCe qui est le plus remarquable dans, c'est la conception ducomptage de références des ressources partagéesncclSharedResources. Lorsqu'un sous-domaine de communication (créé par split/shrink) réutilise les ressources du domaine parent, il ne les copie pas, mais partage le même
📎 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));
}CopierrefCountL'intention de ce code est claire : les « ressources lourdes » telles que les plugins réseau, RMA et GIN ne sont initialisées qu'une seule fois, et les sous-domaines de communication les empruntent directement.
utilise des opérations atomiques pour incrémenter, garantissant qu'il n'y aura pas de double libération en environnement multithread.commAllocUn autre point clé estl'initialisation descanauxid = -1danssetupChannel. Tous les canaux sont d'abord marqués comme « non initialisés » (
📎 src/init.cc:607-608
// Mark channels as non initialized.
for (int c = 0; c < MAXCHANNELS; c++) comm->channels[c].id = -1;qui remplira réellement le contenu :-1Copierid == -1Ce
est une valeur sentinelle. Si du code utilise par erreur un canal non initialisé,
exposera immédiatement le problème, au lieu de lire un bloc de mémoire aléatoire.ncclCommInitRankStep-by-Step : de ncclCommInitRank à initTransportsRank
1. ncclCommInitRankAprès l'appel utilisateur àncclInitEnv, le flux d'exécution réel est le suivant :ncclGroupStartInternalappelle d'abord
pour charger les plugins d'environnement, puis appellencclCommInitRankDevpour entrer dans la sémantique de groupe (afin de permettre « l'initialisation de plusieurs domaines de communication dans un même groupe »).comm2. Ensuite,est appelé : il effectue la validation des paramètres, alloue la structure:
📎 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);
}délègue le véritable travail d'initialisation à un job asynchronencclParamEnqueueRearchEnable()CopierncclAsyncLaunchNotez ici la branchencclMgmtTaskEnqueue— c'est une trace de la « refonte de l'enqueue » en cours dans NCCL. Par défaut, on passe parncclCommInitRankFunc。
3. ncclCommInitRankFunc; une fois la refonte activée, on passe par
📎 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 * archMinorest la fonction principale de l'initialisation. Elle commence par définir le device, interroger les propriétés du GPU, et initialiser le kernel :
4. Ensuite, selon qu'il s'agit d'une initialisation normale ou d'un split/shrink/grow, on emprunte différents chemins de 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. Enfin, on appelleinitTransportsRank, c'est la fonction la plus lourde de toute l'initialisation (environ 800 lignes). En interne, elle effectue deux AllGather :
- AllGather1: échange de
ncclPeerInfo(les informations de périphérique de chaque rank, host hash, pid hash, GPU UUID, etc.) :
📎 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);Noternranks + 1cette allocation — l'emplacement supplémentaire est destiné au CollNet root.peerInfoValidest stocké avec une sémantique release, garantissant que lorsque les autres threads voient ce flag, le contenu de peerInfo est déjà visible.
- AllGather3: échange des résultats de calcul de topologie (structure ring/tree calculée par chaque rank, bande passante, nombre de canaux, etc.), puis on prend leminimumde tous les ranks pour aligner :
📎 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);
}
...
}La bande passante prend le min, le type prend le max, c'est le « principe du tonneau » : la performance de tout le domaine de communication est déterminée par le rank le plus lent. Sans alignement, différents ranks pourraient calculer des choix d'algorithme différents, entraînant un interblocage de communication.
Diagramme de flux d'initialisation
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"]Réflexions de conception et pièges
Pourquoi l'initialisation doit-elle être asynchrone ?Parce que l'initialisation multi-rank nécessite une synchronisation inter-processus (bootstrap), et si elle était exécutée de manière synchrone, elle bloquerait le thread appelant. Après asynchronisation, l'utilisateur peut initialiser plusieurs domaines de communication simultanément dans un groupe, en les faisant progresser en parallèle.
Pièges:initTransportsRankÀ la fin, il y a une barrière intra-node :
📎 src/init.cc:1968-1971
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);Cette barrière garantit que tous les ranks de la même machine ont terminé l'allocation des ressources avant de continuer. Si un rank reste bloqué dansdevCommSetup(par exemple par manque de mémoire GPU), les autres ranks attendront indéfiniment ici. En production, face à un « blocage d'initialisation », la première chose à vérifier est si ledevCommSetupd'un certain rank a échoué.
II. Mise en file des tâches : de l'appel API à l'objet tâche interne
Modèle intuitif
L'utilisateur appellencclAllReducecomme s'il commandait dans un restaurant.ncclEnqueueCheckest le serveur, il traduit votre commande en un « bon de travail » que la cuisine peut comprendre (ncclTaskColl), et le place danscomm->planner, ce « pool de commandes ».Sans cette couche, NCCL ne pourrait pas fusionner plusieurs appels en un seul lancement de kernel— allumer le feu séparément pour chaque commande serait extrêmement inefficace.
Structures de données et disposition mémoire
Le cœur de la mise en file des tâches estncclKernelPlanner, qui est attaché àcomm->planner. Les champs clés incluent :
collSorter: file de tâches de communication collective triée par volume de traficcollTaskQueue: file de tâches finalement triéepeers[]: file send/recv de chaque peer (pour P2P)wipPlan: le kernel plan en cours de construction
Les champs clés de l'objet tâchencclTaskCollsont remplis danscollTaskAppend:
📎 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);Noter quelques détails :
1. Traitement spécial d'AllGather/Broadcast: multiplier count par la taille de l'élément, et changer datatype enncclInt8. C'est parce que la sémantique de ces deux opérations est de « transporter des octets », sans se soucier du type d'origine.
2. trafficBytesCalcul de:ncclFuncTrafficPerByteretourne combien de fois chaque octet doit être transmis. AllReduce retourne 2 (reduce + broadcast), AllGather retourne 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_SETMacro: c'est une résolution de configuration à trois niveaux « env > per-call > comm ». La variable d'environnement a la priorité la plus élevée, suivie de la config de l'appel individuel, et enfin de la valeur par défaut au niveau du domaine de communication.
Step-by-Step : le chemin de mise en file de ncclAllReduce
1. ncclEnqueueCheckOn effectue d'abord la validation du domaine de communication et l'entrée dans le groupe :
📎 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. Puis on appelletaskAppend, qui dispatche selon le type d'opération :
📎 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 {
...
}
}Pour AllReduce, on emprunte la dernière brancheelse, et finalement on appellecollTaskAppend。
3. collTaskAppendpour insérer la tâche danscollSorter, en triant partrafficBytes. Le but du tri est de permettre au planificateur de traiter en priorité les grosses tâches, évitant que les petites tâches fragmentent les ressources de canaux.
Flux de données de la mise en file des tâches
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"]Réflexions de conception et pièges
Pourquoi utiliserncclMemoryPoolAllocplutôt quemalloc?Parce que les objets tâche ont un cycle de vie court et sont alloués fréquemment. Le pool mémoire évite le coût d'appel système demalloc/freeà chaque fois. Noter que le deuxième paramètre dencclMemoryPoolAllocest&comm->memPermanent— cela signifie que les objets tâche ne sont libérés globalement qu'à la destruction du domaine de communication, et non individuellement pour chaque tâche.
Pièges:ncclPrepareTasksDans
📎 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;
}CopieraggIsolateCette agrégation vise à rendre la sélection d'algorithme plus stable — si chaque petite tâche choisissait son algorithme individuellement, on pourrait aboutir à une multitude d'algorithmes différents, entraînant une fragmentation des kernels. Mais le flag
empêche l'agrégation, pour les tâches qui « doivent être planifiées individuellement » (par exemple celles avec une config per-call).
III. Sélection d'algorithme : comment le modèle de coût choisit la solution optimale
Modèle intuitifLa sélection d'algorithme ressemble au choix d'itinéraire d'un logiciel de navigation. Le « modèle de coût » de NCCL (module tuning) estime le temps d'exécution de chaque combinaison algorithme/protocole pour une taille de message et une topologie données, puis choisit le plus rapide.。
Sans modèle de coût, NCCL ne pourrait coder en dur qu'un seul ensemble d'algorithmes, gaspillant la bande passante sur les petits messages et la latence sur les gros messages.
Structures de données et disposition mémoirencclGetAlgoInfo:
📎 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));
...
}CopiereffAlgMaskNoter la logique decomm->tuningContext.forced[info->func]: si une variable d'environnement force un algorithme (algMasknon nul), on ignore le
de l'utilisateur et on utilise celui de la variable d'environnement. C'est l'expression de la priorité « env > per-call ».ncclTuningComputePuis on appelle
📎 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 : sélection de l'algorithme pour un AllReduce
Supposons 8 GPU sur une seule machine, taille de message 1 Mo, AllReduce :
1. nBytes = 1MB,numPipeOpsest le nombre de tâches déjà présentes dans le plan actuel.
2. collNetSupportetnvlsSupportsont déterminés parncclGetCollNetSupportetncclNvlsTransportEnabled.
3. ncclTuningComputeparcourt toutes les combinaisons (algo, proto) disponibles et estime le temps à l'aide du modèle de coût.
4. Pour un scénario mono-machine de 1 Mo, NVLS ou Tree+LL128 l'emportent généralement.
5. Le résultat est réécrit dansinfo->algorithm、info->protocol、info->nWarps。
Diagramme de décision de sélection d'algorithme
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"]Réflexions de conception et pièges
Pourquoi la sélection d'algorithme doit-elle être « alignée entre les ranks » ?Parce que si différents ranks choisissent des algorithmes différents, les modes de communication ne correspondent plus, ce qui provque un interblocage. DoncinitTransportsRankutilise min/max pour aligner tous les paramètres du graphe, garantissant que les entrées du modèle de coût sont identiques pour chaque rank.
Points pièges:ncclGetAlgoInfocontient une logique de « recalcul » — si l'utilisateur a spécifiéalgMaskmais qu'aucun algorithme ne correspond, le menu complet est d'abord recalculé silencieusement, puis on détermine s'il s'agit d'une erreur dure ou d'un repli souple :
📎 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));
}NOWARNLa macro supprime temporairement les avertissements, car « aucun algorithme ne correspond » peut être un cas normal (l'ensemble choisi par l'utilisateur est effectivement indisponible). L'erreur n'est signalée que lorsqueforceAlgSelectionest vrai.
IV. Ordonnancement des tâches et construction du kernel plan
Modèle intuitif
L'ordonnancement des tâches revient à répartir un tas de commandes sur plusieurs lignes de production.scheduleCollTasksToPlandétermine combien de canaux chaque tâche utilise et quelle quantité de données chaque canal traite, générant finalement unncclKernelPlan— c'est le « bon de travail » à transmettre au GPU.
Structures de données et disposition mémoire
ncclKernelPlanLes champs principaux de :
channelMask: quels canaux ce plan utilise (bitmap)workBytes: nombre total d'octets de toutes les structures worknWorkBatches: nombre de work batcheskernelArgs: paramètres de lancement du kernelworkStorageType: où sont stockées les données work (args/fifo/persistent)
finishPlandétermine l'emplacement de stockage des données work :
📎 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;Compromis entre les trois types de stockage :
- Args: le plus rapide, mais la taille des paramètres du kernel est limitée (généralement 4 Ko)
- Fifo: tampon circulaire, adapté aux tailles moyennes
- Persistent: allocation de mémoire vidéo dédiée, adaptée aux scénarios CUDA Graph
Step-by-Step : allocation des canaux dans scheduleCollTasksToPlan
1. Estimer d'abord combien de tâches ce plan peut contenir :
📎 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);2. Ensuite, répartir les canaux entre les tâches selon le trafic. Pour les tâches non-CollNet, découper en unités de « cell » :
📎 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);Ce code découpe les données en trois segments « bas/moyen/haut » :countLo、countMid、countHi. Les segments bas et haut sont des canaux de bordure, le segment moyen est un canal intermédiaire. Ce découpage vise à rendre la quantité de données traitée par chaque canal aussi uniforme que possible.
3. Enfin, appelercalcCollChunkingpour calculer la taille de chunk de chaque canal :
📎 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;
...
}Diagramme de flux d'ordonnancement
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"]Réflexions de conception et pièges
Pourquoi les tâches CollNet sont-elles traitées séparément ?Parce que CollNet utilise les commutateurs réseau pour la réduction, et la logique d'allocation des canaux est complètement différente de celle du ring/tree classique. Les tâches CollNet occupent directement tous les canaux disponibles, tandis que les tâches classiques doivent être découpées selon le trafic.
Points pièges:ncclTestBudgetL'estimation utilise une formule approximativenBatches = divUp(nPlanColls, 4)— en supposant qu'un batch est produit tous les 4 opérations collectives. Cette estimation peut être imprécise, c'est pourquoi une vérification exacte suit :
📎 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;
}Si la vérification exacte échoue, on retourne directement (sans erreur), laissant la couche supérieure ouvrir un nouveau plan.
V. Lancement du kernel et exécution côté device
Modèle intuitif
Le lancement du kernel revient à remettre le bon de travail à l'usine.ncclLaunchKerneltraduitncclKernelPlanen paramètres de lancement de kernel CUDA, puis appellecuLaunchKernelEx. Le kernel côté device, après réception du bon de travail, exécute le transfert de données selon l'algorithme.
Structures de données et disposition mémoire
ncclLaunchKernelLes étapes clés de :
📎 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);Notergrid.x = nChannels— un block par canal.block.x = plan->threadPerBlock— le nombre de threads par block est déterminé par la tâche.
Step-by-Step : du plan au lancement du kernel
1. Appeler d'aborduploadWorkpour écrire les données work à l'emplacement cible (args/fifo/persistent) :
📎 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;
...
}
}2. Ensuite, construire les attributs de lancement CUDA. Pour sm90+, les dimensions de cluster sont définies :
📎 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;
}3. Enfin, appelercuLaunchKernelEx:
📎 src/enqueue/enqueue.cc:1992
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);Côté device : exécution de runRing
Le kernel côté device, après réception du bon de travail, appelle la spécialisationRunWorkCollcorrespondante selon l'algorithme. Prenons Ring AllReduce comme exemple :
📎 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);
}
}Les deux phases classiques de Ring AllReduce :
- Phase Reduce-Scatter(les nranks-1 premières étapes) : chaque rank envoie ses données au suivant, tout en recevant les données du précédent et en effectuant la réduction.
- Phase AllGather(les nranks-1 étapes suivantes) : propager le résultat de la réduction le long de l'anneau.
modRanksCe lambda gère le rebouclage de l'index circulaire : lorsquer >= nranks, soustraire nranks.
Diagramme de séquence du lancement du kernel
sequenceDiagram
participant Host as Host 线程
participant Plan as ncclKernelPlan
participant CUDA as CUDA Driver
participant Kernel as GPU Kernel
participant Proxy as Proxy 线程
Host->>Plan: ncclLaunchPrepare()
Plan->>Plan: scheduleCollTasksToPlan()
Plan->>Plan: finishPlan() 分配 kernelArgs
Host->>Plan: ncclLaunchKernelBefore_NoUncapturedCuda()
Plan->>Plan: uploadWork() 写 work 数据
Host->>CUDA: cuLaunchKernelEx(fn, grid, block, smem)
CUDA->>Kernel: 启动 nChannels 个 block
Kernel->>Kernel: runRing() 执行 Ring AllReduce
Host->>Plan: ncclLaunchKernelAfter_NoCuda()
Plan->>Proxy: hostStreamPlanTask() + uploadProxyOps()
Proxy->>Proxy: ncclProxyStart() 推进网络 I/O
Kernel-->>Host: kernel 完成
Host->>Plan: ncclLaunchFinish()
Plan->>Plan: reclaimPlan() 释放资源Réflexions de conception et pièges
Pourquoi utilisercuLaunchKernelExau lieu decudaLaunchKernel?Parce qu'il faut définir les attributs de lancement (dimensions de cluster, mem sync domain, launch completion event). Ces attributs ne sont pris en charge qu'à partir de CUDA 12.0+.
Points pièges:uploadWorkLe traitement du mode persistent y est très complexe — il nécessite d'allouer de la mémoire GPU, de copier des données, d'enregistrer des événements, et de fonctionner correctement en mode de capture 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);cudaThreadExchangeStreamCaptureModesert à basculer temporairement en mode relaxed pendant la capture, permettant l'allocation de mémoire GPU. Une fois la copie terminée, un événement est enregistré, puis récupéré ultérieurement viancclCommPollEventCallbacks.
VI. Guide de production : pièges à éviter
Piège 1 : blocage à l'initialisation
Symptôme:ncclCommInitRankreste bloqué sans retourner.
Diagnostic: consulter lesNCCL_DEBUG=INFOlogs, trouver le dernier rank qui a affiché quelque chose. Si tous les ranks ont affiché "Init START" mais pas "Init COMPLETE", cela signifie que le blocage se situe dansinitTransportsRank.
Causes courantes:
- Échec de
devCommSetupsur un rank (mémoire GPU insuffisante, erreur CUDA) - réseau bootstrap inaccessible (pare-feu, port occupé)
- versions de NCCL incohérentes entre les ranks
Référence source:initTransportsRankLa barrière intra-nœud à la fin de
📎 src/init.cc:1968-1971
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);Piège 2 : débordement du FIFO de work
Symptôme: blocage après le lancement du kernel, ou erreurncclInternalError。
Cause:waitWorkFifoAvailableattend de l'espace FIFO, mais le consommateur (kernel) ne progresse pas.
📎 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;
}Attention à la vérification du flag abort — c'est la seule voie de secours. Si abort n'est pas non plus défini, on entre dans une boucle infinie.
Prévention: augmenterNCCL_WORK_FIFO_BYTES, ou réduire le nombre d'opérations dans un même group.
Piège 3 : échec de capture CUDA Graph
Symptôme: appeler NCCL pendant une capture CUDA Graph provoque "operation not permitted".
Cause: en mode capture, certaines opérations CUDA sont interdites (commecudaMalloc). NCCL utilisecudaThreadExchangeStreamCaptureModepour basculer temporairement de mode, mais toutes les opérations ne peuvent pas être contournées.
Référence source:uploadWorkLa branche persistent de
📎 src/enqueue/enqueue.cc:1445
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);Prévention: utiliserNCCL_GRAPH_MIXING_SUPPORT=1pour activer le mode hybride graph, ou préallouer le work buffer.
Résumé de ce chapitre
Dans ce chapitre, nous avons reparcouru la chaîne complète d'un AllReduce :
1. Initialisation:ncclCommInitRank → ncclCommInitRankFunc → initTransportsRank, établissement du domaine de communication, recherche de topologie, alignement des paramètres de graphe.
2. Mise en file des tâches:ncclEnqueueCheck → taskAppend → collTaskAppend, traduction des appels API enncclTaskColl。
3. Sélection d'algorithme:ncclGetAlgoInfo → ncclTuningCompute, choix optimal de (algo, proto) via un modèle de coût.
4. Ordonnancement des tâches:ncclPrepareTasks → scheduleCollTasksToPlan → finishPlan, répartition des tâches sur les canaux, génération dencclKernelPlan。
5. Lancement du kernel:ncclLaunchKernel → cuLaunchKernelEx, traduction du plan en paramètres de lancement CUDA.
6. Exécution côté device:runRing / runTreeUpDown / runNvls, exécution du transfert de données selon l'algorithme.
Réflexions et auto-évaluation de ce chapitre
Q1 : Si l'on supprime la logique d'alignement min/max après AllGather3 (L1690-L1698) dansinitTransportsRank, dans quels scénarios cela provoquerait-il un interblocage de communication ? Pourquoi ?
Analyse de référence: ce segment garantit que tous les ranks s'accordent sur les paramètres tels quenChannels、bwIntra、bwInterpour chaque algorithme. Sans cela, chaque rank calculerait le résultat à partir de sa propre topologie locale. Considérons un cluster hétérogène : le rank 0 sur une machine 8 GPU NVLink, le rank 8 sur une machine 4 GPU PCIe. Le rank 0 calcule 8 canaux pour le ring, le rank 8 en calcule 4. Lorsqu'ils exécutent un Ring AllReduce, le rank 0 attendra que le rank 8 envoie des données sur 8 canaux, mais le rank
Nous avons ainsi achevé le parcours complet de la chaîne d'un AllReduce. De l'initialisation, la recherche de topologie, la sélection d'algorithme, la mise en file des tâches, le lancement du kernel, jusqu'à l'exécution côté device et le transfert réseau, chaque étape correspond à l'analyse approfondie des chapitres précédents. Ce schéma de chaîne est non seulement la charpente pour comprendre NCCL, mais aussi un index pour le diagnostic : échec d'initialisation → chapitres 3 et 4, mauvais algorithme → chapitre 5, erreur de mise en file → chapitres 6 et 7, échec de lancement du kernel → chapitre 8, blocage côté device → chapitres 9 et 10, problèmes réseau → chapitres 12 et 13. À mesure que NCCL évolue vers la communication programmable, l'envoi direct GPU et la mémoire symétrique, cette chaîne continuera de s'étendre — et vous maîtrisez désormais la méthode pour la suivre.
Comprendre n'importe quel projet complexe ne nécessite en réalité qu'un bon livre
Ce livre a été entièrement compilé automatiquement par AiReadCode en scannant le dépôt open source officiel, avec des numéros de ligne de Commit réels ancrés de manière permanente.