第1章:第1章:実行と現象:1つの AllReduce から外部挙動を観察する
第1章:実行と現象:1つの AllReduce から外部挙動を観察する
いかなるカーネルコードを深掘りする前に、まず NCCL を実行し、それが外部に露出する挙動を観察する。この章ではカーネルを読まず、ただ一つのことだけを行う:検証可能な参照系を確立すること——その後のいかなる内部メカニズム分析も、最終的にはここで見た外部挙動を説明できなければならない。
1.1 ビルドエントリから見る NCCL のエンジニアリング構造
直感的モデル
ビルドシステムはビルの施工図のようなものだ:誰が住むかは決めないが、どんな部屋があり、ドアがどちらに開くかは決める。ビルドエントリが混乱していれば、「実行する」という第一歩すら踏み出せない。NCCL は Makefile と CMake の2つのビルドエントリを同時に提供しており、それらの差異を理解することが、このプロジェクトのエンジニアリング組織を理解する第一歩である。
2つのビルドエントリの構造
トップレベルのMakefileは極めて薄いディスパッチ層であり、それ自体はどのソースファイルもコンパイルせず、作業を各サブディレクトリの Makefile に転送する。
📎 Makefile:44-45はsrc.%モードルールを定義し、src.build、src.installなどのターゲットをsrc/Makefile:
src.%:
${MAKE} -C src $* BUILDDIR=${ABSBUILDDIR}📎 Makefile:47-48はexamplesターゲットを定義し、それはsrc.buildに依存し、その後docs/examplesディレクトリに入ってサンプルをビルドする:
examples: src.build
${MAKE} -C docs/examples NCCL_HOME=${ABSBUILDDIR}ここでの依存関係に注意:サンプルのビルドはsrc.buildが先に完了することに依存する。なぜならサンプルは NCCL ライブラリをリンクする必要があり、NCCL_HOME環境変数がビルド成果物ディレクトリをサンプルの Makefile に渡すからである。これが「先にライブラリ、後にサンプル」というビルド順序の制約である。
📎 Makefile:29クリーンアップ可能なすべてのターゲット集合を列挙します:
TARGETS := src pkg nccl4py ir📎 Makefile:30GNU Make の置換参照構文を使って${TARGETS:%=%.clean}をsrc pkg nccl4py irに展開しsrc.clean pkg.clean nccl4py.clean ir.clean、すべてのクリーンアップターゲットを一度に定義します。これは Makefile でよく見られる「データ駆動型ルール」のテクニックです——新しいモジュールを追加するにはTARGETSに単語を一つ追加するだけです。
CMake エントリ:バージョン番号はどこから来るのか
CMake エントリは Makefile よりもはるかに複雑です。クロスプラットフォーム、CUDA バージョン検出、アーキテクチャ選択などを処理する必要があるからです。ここでは「動かす」ことに直接関連する部分のみに注目します。
📎 CMakeLists.txt:5-11はバージョン番号の出所を示しています——それは CMakeLists.txt にハードコードされているのではなく、makefiles/version.mkから読み取って正規表現で抽出しています:
file(READ ${CMAKE_SOURCE_DIR}/makefiles/version.mk VERSION_CONTENT)
string(REGEX REPLACE ".*NCCL_MAJOR[ ]*:=[ ]*([0-9]+).*" "\\1" NCCL_MAJOR "${VERSION_CONTENT}")
...
math(EXPR NCCL_VERSION_CODE "(${NCCL_MAJOR} * 10000) + (${NCCL_MINOR} * 100) + ${NCCL_PATCH}")バージョン番号をversion.mkに集中させることで、Makefile と CMake の二つのビルドシステムが同じバージョンソースを共有し、「二つのビルドシステムでバージョン番号が不一致になる」という古典的なエンジニアリングの罠を回避しています。NCCL_VERSION_CODEの計算式MAJOR*10000 + MINOR*100 + PATCHはヘッダファイル内のNCCL_VERSIONマクロと一致しています。
📎 CMakeLists.txt:14-20これらのバージョン番号をadd_compile_definitionsを通じてすべての C++ ソースファイルに注入します:
add_compile_definitions(
NCCL_USE_CMAKE
NCCL_MAJOR=${NCCL_MAJOR}
NCCL_MINOR=${NCCL_MINOR}
NCCL_PATCH=${NCCL_PATCH}
NCCL_VERSION_CODE=${NCCL_VERSION_CODE}
)📎 CMakeLists.txt:24-25はプロジェクトの言語を CUDA、CXX、C として宣言しています:
project(NCCL VERSION ${NCCL_MAJOR}.${NCCL_MINOR}.${NCCL_PATCH}
LANGUAGES CUDA CXX C)CUDA アーキテクチャ選択:なぜデフォルト値がこんなに複雑なのか
📎 CMakeLists.txt:140-171は CUDA バージョンに基づいてCMAKE_CUDA_ARCHITECTURESを決定する一大ロジックです。CUDA 12.8 以上を例にとると:
elseif(${CUDA_MAJOR} EQUAL 12)
if(${CUDA_MINOR} LESS 8)
set(CMAKE_CUDA_ARCHITECTURES "50;60;61;70;80;90")
else()
set(CMAKE_CUDA_ARCHITECTURES "50;60;61;70;80;90;100;120")
endif()このロジックの設計動機は:新アーキテクチャ(例:100、120)の PTX は比較的新しい CUDA ツールチェーンしか認識しないため、古い CUDA に対して新アーキテクチャを強制指定するとコンパイルが直接失敗します。したがってデフォルトのアーキテクチャリストは CUDA バージョンに応じて動的に調整する必要があります。読者にとって、これは以下を意味します:もしCMAKE_CUDA_ARCHITECTURESを明示的に設定しない場合、コンパイル成果物には長いアーキテクチャリストの fatbin が含まれ、コンパイル時間が著しく長くなります。本番環境では通常、ターゲットアーキテクチャを明示的に指定してビルドを高速化します。
ビルドフロー決定図
以下の図はmakeの実行から実行可能なサンプルの生成までの完全な決定パスを示しています:
flowchart TD
start["执行 make 或 make examples"] --> check_ir{"EMIT_LLVM_IR 或<br/>NCCL_EMIT_LTO_IR 非 0?"}
check_ir -->|是| add_ir["IR_GOALS 加入 llvm_ir/ltoir<br/>default 依赖 ir-emit"]
check_ir -->|否| only_src["default 仅依赖 src.build"]
add_ir --> src_build["make -C src build<br/>BUILDDIR=build"]
only_src --> src_build
src_build --> build_ok{"src.build 成功?"}
build_ok -->|否| fail["构建失败,终止"]
build_ok -->|是| is_examples{"目标是 examples?"}
is_examples -->|是| ex_build["make -C docs/examples<br/>NCCL_HOME=build"]
is_examples -->|否| done["产出 libnccl.so"]
ex_build --> ex_ok{"示例链接成功?"}
ex_ok -->|否| fail
ex_ok -->|是| runnable["产出可执行示例"]この図の重要な分岐はIR_GOALSが非空かどうかです——これがデフォルトビルドで LLVM IR 生成を追加でトリガーするかどうかを決定します。「動かす」ことだけを望む読者は、EMIT_LLVM_IR=0を維持すれば最短パスを通れます。
1.2 最小実行可能プログラムの前提条件
直感的モデル
NCCL プログラムを書くことは、多者間電話会議を組織するようなものです。まず確認する必要があります:何人参加するか(デバイス数)、各人は誰か(rank)、どの回線で通話するか(stream)。どれか一つでも欠けると会議は開けません。この節では01_communicatorsの例を通じて、これら三つの前提条件がコード内でどのような形をしているかを明確に見ていきます。
データ構造:三つの配列がすべての状態を担う
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:88-92はサンプルの核心変数を定義しています:
int num_gpus; // Number of available CUDA devices
ncclComm_t *comms = NULL; // Array of NCCL communicators (one per GPU)
cudaStream_t *streams = NULL; // Array of CUDA streams (one per GPU)
int *devices = NULL; // Array of device IDs to useここには NCCL のシングルプロセスマルチ GPU プログラミングモデルの核心が表れています:各 GPU に一つの通信ドメイン、一つの stream、一つのデバイス番号。三つの配列の長さはすべてnum_gpusで、添字iはi番目の GPU に対応します。
ncclComm_tはヘッダファイル内で不透明ポインタとして定義されています。📎 src/nccl.h.in:36はその実際の型を示しています:
typedef struct ncclComm* ncclComm_t;「不透明ポインタ」(opaque pointer)は C 言語で情報隠蔽を実現する古典的な手法です:ヘッダファイルはstruct ncclComm*というポインタ型のみを公開し、ユーザーコードは構造体の内部フィールドにアクセスできず、すべての操作は API 関数を通じて行う必要があります。これにより NCCL は ABI を破壊することなくncclCommの内部レイアウトを自由に変更できます。初心者の読者には「あなたが手にするのはブラックボックスのハンドルであり、公式インターフェースを通じてのみ操作できる」と理解すればよいでしょう。
Step-by-Step:デバイス検出から通信ドメイン作成まで
第一步:デバイス数の検出。 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:96-104はcudaGetDeviceCountを呼び出し、0 かどうかをチェックします:
CUDACHECK(cudaGetDeviceCount(&num_gpus));
if (num_gpus == 0) {
fprintf(stderr, "ERROR: No CUDA devices found on this system\n");
...
return 1;
}このステップで何をしているか:CUDA ランタイムに「このマシンに GPU が何枚あるか」を問い合わせています。0 が返れば利用可能なデバイスがないことを意味し、プログラムは直接終了します——これが最も前置的なガード条件です。
第二步:ホストメモリの割り当てとデバイスリストの充填。 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:114-121は三つの配列を割り当て、割り当てが成功したかチェックします:
devices = (int *)malloc(num_gpus * sizeof(int));
comms = (ncclComm_t *)malloc(num_gpus * sizeof(ncclComm_t));
streams = (cudaStream_t *)malloc(num_gpus * sizeof(cudaStream_t));
if (!devices || !comms || !streams) {
fprintf(stderr, "ERROR: Failed to allocate memory for device arrays\n");
return 1;
}📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:126-136はループでdevices[i] = iを充填し、各デバイスの属性を出力します:
for (int i = 0; i < num_gpus; i++) {
devices[i] = i; // Use device i for communicator i
cudaDeviceProp prop;
CUDACHECK(cudaGetDeviceProperties(&prop, devices[i]));
printf(" GPU %d: %s (CUDA Device %d)\n", i, prop.name, devices[i]);
...
}第三步:各 GPU に stream を作成。 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:140-145が鍵です:
for (int i = 0; i < num_gpus; i++) {
CUDACHECK(cudaSetDevice(devices[i]));
CUDACHECK(cudaStreamCreate(&streams[i]));
}注意:cudaSetDeviceはcudaStreamCreateの前に呼び出す必要があります。これは CUDA プログラミングの基本ルールです:stream は現在アクティブなデバイスに属する。先にデバイスを切り替えないと、stream は誤った GPU 上に作成されます。これは初心者が最も陥りやすい罠の一つです。
第四步:通信ドメインの作成。 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:169はこのサンプル全体の核心的な呼び出しです:
NCCLCHECK(ncclCommInitAll(comms, num_gpus, devices));ncclCommInitAllはシングルプロセスマルチ GPU シナリオの便利なエントリポイントです。ヘッダファイル📎 src/nccl.h.in:301-301はその契約を示しています:
/* Creates a clique of communicators (single process version).
* This is a convenience function to create a single-process communicator clique.
* Returns an array of ndev newly initialized communicators in comm.
* comm should be pre-allocated with size at least ndev*sizeof(ncclComm_t).
* If devlist is NULL, the first ndev CUDA devices are used.
* Order of devlist defines user-order of processors within the communicator. */
ncclResult_t ncclCommInitAll(ncclComm_t* comm, int ndev, const int* devlist);三つのパラメータの意味:commは事前割り当てされた通信ドメイン配列、ndevはデバイス数、devlistはデバイス番号リストです(NULL を渡すと最初のndev個のデバイスを使用)。呼び出しが返ると、comms[i]はi番目のデバイスの通信ドメインとなり、その rank はi。
第五步:通信ドメイン属性の検証。 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:185-189は三つのクエリ API で検証します:
NCCLCHECK(ncclCommUserRank(comms[i], &rank));
NCCLCHECK(ncclCommCount(comms[i], &size));
NCCLCHECK(ncclCommCuDevice(comms[i], &device));これら三つの API のヘッダファイル内での定義はそれぞれ📎 src/nccl.h.in:396、📎 src/nccl.h.in:400、📎 src/nccl.h.in:404です。それらは三つの問いにそれぞれ答えます:私は誰か(rank)、全部で何人か(size)、私はどのカード上にいるか(device)。
通信ドメイン作成フローシーケンス図
sequenceDiagram
participant App as 应用主线程
participant CUDA as CUDA Runtime
participant NCCL as NCCL 库
App->>CUDA: cudaGetDeviceCount(&num_gpus)
CUDA-->>App: num_gpus = N
loop i in 0..N-1
App->>CUDA: cudaSetDevice(devices[i])
App->>CUDA: cudaStreamCreate(&streams[i])
CUDA-->>App: streams[i]
end
App->>NCCL: ncclCommInitAll(comms, N, devices)
Note over NCCL: 内部为每个设备建立通信域<br/>分配 rank 0..N-1
NCCL-->>App: comms[0..N-1]
loop i in 0..N-1
App->>NCCL: ncclCommUserRank(comms[i], &rank)
NCCL-->>App: rank = i
App->>NCCL: ncclCommCount(comms[i], &size)
NCCL-->>App: size = N
endこのシーケンス図が明らかにする重要な点:ncclCommInitAllは同期ブロッキング呼び出しであり、内部的にすべてのデバイス間の調整を完了し、返った時点ですべての通信ドメインが準備完了しています。
設計思考:なぜ ncclCommInitAll が必要なのか
マルチプロセスシナリオでは、各プロセスは1枚のGPUのみを管理し、ncclCommInitRankそれぞれを初期化すればよい。しかしシングルプロセス・マルチGPUシナリオでは、ユーザーが各GPUに対して手動でncclCommInitRankを呼び出す場合、「複数のrank間の同期」を処理しなければならない——シングルプロセスにはスレッドが1つしかなく、複数のrankの初期化を同時に進めることができず、デッドロックが発生する。ncclCommInitAllこの調整をライブラリ内部にカプセル化し、内部メカニズム(通常はマルチスレッドまたはステートマシン)で全rankの同期初期化を完了させ、ユーザーには単純な同期呼び出しとして公開する。これが「便利関数」が存在する根本的な理由である。
1.3 1回のAllReduceの完全な外部動作
直感的モデル
AllReduceは集合通信で最もよく使われる操作である:各参加者がデータを提供し、全員が全データの合計を受け取る。グループワークで合計点を計算するようなもの——各自が自分の点数を報告し、最終的に全員がクラス全体の合計点を手にする。この節では03_collectives/01_allreduceの例を追跡し、1回のAllReduceが呼び出しから結果検証までの完全な外部動作を見ていく。
データ構造:データバッファと初期化
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:59-63は核心的な変数を定義する:
int num_gpus = 0;
ncclComm_t *comms;
cudaStream_t *streams;
float **sendbuff;
float **recvbuff;注意sendbuffとrecvbuffはfloat**——ポインタ配列へのポインタである。各sendbuff[i]はi番目のGPU上のデバイスメモリアドレスである。
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:99はデータ規模を定義する:
const size_t size = 32 * 1024 * 1024; // 32M floats for demonstration32M個のfloat、各4バイト、つまり128 MBの送信バッファと128 MBの受信バッファ、各GPUに1つずつ。
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:101-120は各デバイスの初期化ループである:
for (int i = 0; i < num_gpus; i++) {
CUDACHECK(cudaSetDevice(i));
CUDACHECK(cudaStreamCreate(&streams[i]));
CUDACHECK(cudaMalloc((void **)&sendbuff[i], size * sizeof(float)));
CUDACHECK(cudaMalloc((void **)&recvbuff[i], size * sizeof(float)));
CUDACHECK(cudaMemset(sendbuff[i], 0, size * sizeof(float)));
float rank_value = (float)i;
CUDACHECK(cudaMemcpy(sendbuff[i], &rank_value, sizeof(float),
cudaMemcpyHostToDevice));
printf(" Device %d initialized with data value %d\n", i, i);
}このコードの巧妙な点:まず送信バッファ全体をゼロクリアし、次に最初の要素のみをi(そのデバイスのrank値)に設定する。こうすることでAllReduceの総和後、最初の要素の結果は0 + 1 + 2 + ... + (num_gpus-1)となり、残りの要素はすべて0になる。検証時には最初の要素をチェックするだけで、AllReduceが正しいかどうかを確認できる。
Step-by-Step:AllReduceの呼び出しと検証
ステップ1:Groupでラップ。 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136は核心的な呼び出しである:
NCCLCHECK(ncclGroupStart());
for (int i = 0; i < num_gpus; i++) {
NCCLCHECK(ncclAllReduce(sendbuff[i], recvbuff[i], size, ncclFloat, ncclSum,
comms[i], streams[i]));
}
NCCLCHECK(ncclGroupEnd());ここに極めて重要な詳細がある:コメント📎 docs/examples/03_collectives/01_allreduce/c/main.cc:128-129は明確に説明している:
// NOTE: ncclGroupStart and ncclGroupEnd are essential to avoid
// deadlock when using ncclCommInitAll and multiple communication calls.なぜGroupを使わなければならないのか?ヘッダファイル📎 src/nccl.h.in:844-864が説明を与えている:
/* Group semantics
*
* When managing multiple GPUs from a single thread, and since NCCL collective
* calls may perform inter-CPU synchronization, we need to "group" calls for
* different ranks/devices into a single call.
* ...
* Both collective communication and ncclCommInitRank can be used in conjunction
* of ncclGroupStart/ncclGroupEnd, but not together.
*/核心的な矛盾は:集合通信は全rankが同時に参加する必要があるが、シングルスレッドではncclAllReduceを1つずつしか呼び出せない。もし最初のncclAllReduce呼び出しが他のrankを待ってブロックし、他のrankの呼び出しがまだ発行されていなければ、デッドロックする。Groupメカニズムの役割は:ncclGroupStart以降のすべての呼び出しは「登録」のみを行い、実際には起動しない;ncclGroupEnd時に登録されたすべての操作をまとめてコミットし、それらが並行して進行できるようにする。これはフードデリバリーで全料理をカートに入れてから最後にまとめて注文するようなもので、1品ずつ注文するのではない。
ステップ2:streamの同期。 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142:
for (int i = 0; i < num_gpus; i++) {
CUDACHECK(cudaSetDevice(i));
CUDACHECK(cudaStreamSynchronize(streams[i]));
}ヘッダファイル📎 src/nccl.h.in:854-856は強調している:ncclGroupEndは操作がstreamにエンキューされたことのみを保証し、操作の完了は保証しない。したがって結果を安全に読み取るには、明示的にstreamを同期しなければならない。
ステップ3:結果の検証。 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:152-169:
float expected = (float)(num_gpus * (num_gpus - 1) / 2);
...
for (int i = 0; i < num_gpus; i++) {
float result;
CUDACHECK(cudaSetDevice(i));
CUDACHECK(cudaMemcpy(&result, recvbuff[i], sizeof(float),
cudaMemcpyDeviceToHost));
if (result != expected) {
printf(" Device %d received incorrect result: %.0f (expected %.0f)\n", i,
result, expected);
success = false;
} else {
printf(" Device %d correctly received sum: %.0f\n", i, result);
}
}期待値は等差数列の和0 + 1 + ... + (N-1) = N*(N-1)/2。各GPUは同じ値を受け取るはずである——これこそがAllReduceの定義である。
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 -->|广播结果| r2この図はAllReduceの2つの段階を示している:まずリデュース(reduce)、次にブロードキャスト(broadcast)。各rankのrecvbuffは最終的に同じ結果を得る。
設計思考:なぜ1つずつ呼び出さずGroupを使うのか
もしncclGroupStart/ncclGroupEndを除去すると、コードはこうなる:
for (int i = 0; i < num_gpus; i++) {
ncclAllReduce(sendbuff[i], recvbuff[i], size, ncclFloat, ncclSum,
comms[i], streams[i]);
}シングルスレッドでは、最初のイテレーションでncclAllReduceを呼び出すとき、NCCLは全rankがAllReduceを発起するのを待ってからでないと進行できない。しかし他のrankの呼び出しはまだループ内で実行されていないため、最初の呼び出しは永遠に他のrankを待つことができず、デッドロックする。Groupメカニズムは「発起」と「実行」を分離し、全rankの呼び出しをまずすべて登録してから一緒に実行することで、シングルスレッドのデッドロックを根本的に回避する。
1.4 通信ドメインのライフサイクルとリソースクリーンアップ
直感的モデル
通信ドメインは会議のようなものである。会議の前に受付(初期化)し、会議が終わったら解散(破棄)する。もし解散の順序が間違っていれば——例えば人がまだ退出していないのに会議室を施錠してしまえば——問題が発生する。この節ではNCCL通信ドメインの破棄順序と、なぜこの順序を逆にしてはいけないのかを見ていく。
破棄の2段階:FinalizeとDestroy
📎 docs/examples/03_collectives/01_allreduce/c/main.cc:176-183は標準的な破棄フローを示している:
NCCLCHECK(ncclGroupStart());
for (int i = 0; i < num_gpus; i++) {
NCCLCHECK(ncclCommFinalize(comms[i]));
}
NCCLCHECK(ncclGroupEnd());
for (int i = 0; i < num_gpus; i++) {
NCCLCHECK(ncclCommDestroy(comms[i]));
}ヘッダファイル📎 src/nccl.h.in:309-309はncclCommFinalizeのセマンティクスを説明している:
/* Finalize a communicator. ncclCommFinalize flushes all issued communications,
* and marks communicator state as ncclInProgress. The state will change to ncclSuccess
* when the communicator is globally quiescent and related resources are freed; then,
* calling ncclCommDestroy can locally free the rest of the resources (e.g. communicator
* itself) without blocking. */
ncclResult_t ncclCommFinalize(ncclComm_t comm);📎 src/nccl.h.in:313-313はncclCommDestroy:
/* Frees local resources associated with communicator object. */
ncclResult_t ncclCommDestroy(ncclComm_t comm);〔設計推論とアーキテクチャのトレードオフ〕ncclCommFinalizeなぜ破棄は2段階なのか?はグローバル操作ncclCommDestroy——全rankが参加する必要があり、通信中でないことを保証する。はローカル操作ncclCommDestroy——本プロセスのリソースのみを解放し、ブロックしない。この設計により「全rankの静穏を待つ」ことと「ローカルリソースの解放」が分離される:前者は時間がかかる可能性があり(ネットワーク対端を待つ必要がある)、後者は純粋にローカルな操作である。もし
が1つだけなら、両方の責務を同時に担わなければならず、長時間ブロックするか、グローバルな静穏を保証できなくなるかのどちらかである。
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:221-249破棄順序の完全なチェーン📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:218-219は完全なクリーンアップ順序を示しており、コメント
// IMPORTANT: Proper cleanup is critical for NCCL applications
// Resources must be cleaned up in the correct order to avoid issuesコピー
順序は:📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:224-227)
2. Finalize + Destroy 通信ドメイン(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240)
3. CUDA stream を破棄(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:246-249)
4. ホストメモリを解放(📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:253-255)
通信ドメインの状態機械
ncclCommFinalizeのドキュメントには状態遷移が明記されており、これは状態機械の適用条件に合致します:
stateDiagram-v2
[*] --> Active : ncclCommInitAll() 成功
Active --> InProgress : ncclCommFinalize()<br/>刷新在途通信
InProgress --> Quiescent : 全局静默<br/>相关资源释放
Quiescent --> Destroyed : ncclCommDestroy()<br/>释放本地资源
Destroyed --> [*]
Active --> Aborted : ncclCommAbort()<br/>中止在途操作
Aborted --> [*]この状態機械の重要な遷移はInProgress -> Quiescentです:これは「グローバルサイレンス」というイベントによってトリガーされ、特定の関数呼び出しによって直接トリガーされるわけではありません。つまりncclCommFinalizeが戻った後、通信ドメインはまだInProgress状態にある可能性があり、ncclCommGetAsyncErrorになるタイミングを知るにはQuiescent。
をポーリングする必要があります
〔設計推論とアーキテクチャのトレードオフ〕commsもし CUDA stream を先に破棄してから通信ドメインを破棄すると、どのような問題が発生するでしょうか?通信ドメイン内部は stream への参照を保持している可能性があります(例えば非同期操作の完了通知に使用)。stream が先に破棄されると、通信ドメインが Finalize 時にすでに破棄された stream にアクセスし、未定義動作を引き起こします。同様に、ホストメモリ(ncclCommDestroy配列)を先に解放してから通信ドメインを破棄すると、はダングリングポインタを取得してしまいます。これが、順序が「まず同期、次に通信ドメインを破棄、次に stream を破棄、最後にホストメモリを解放」でなければならない理由です——。
依存関係が、破棄順序は作成順序と逆でなければならないことを決定づけています
1.5 本番環境の落とし穴ガイド
落とし穴1:Group を忘れてデッドロックncclAllReduceこれは初心者が最もよく踏む落とし穴です。シングルプロセス・マルチGPUのシナリオで、
を Group なしで直接ループ呼び出しすると、プログラムは最初の呼び出しでデッドロックします。症状は:プログラムが固まって動かなくなり、CPU使用率がほぼ0で、何も出力されない。gdb調査方法:ncclGroupStart/ncclGroupEnd。
でプロセスに attach し、スタックが NCCL 内部の待機ロジックで止まっていないか確認します。そうであれば、
📎 src/nccl.h.in:854-856の欠落をチェックしますncclGroupEnd落とし穴2:stream の同期を忘れて結果を読む📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142はrecvbuffがエンキューするだけで、完了は保証しないと明記しています。
の stream 同期を省略して直接cudaMemcpyを読むと、未完了のデータを読んでしまいます。症状は:結果が正しかったり間違ったり、あるいは全て0が読まれる。これはがデフォルトで同期するためですが、それが同期するのはcudaStreamSynchronize現在の stream
であり、AllReduce は他の stream で実行されている可能性があるからです。調査方法:結果を読む前に
を追加し、問題が消えればこの落とし穴です。ncclCommDestroy落とし穴3:破棄順序の誤りによるセグメンテーション違反cudaFreeの前にsendbuff/recvbuffを
すると、通信ドメインが Finalize 時にまだこれらのバッファにアクセスしている可能性があり、セグメンテーション違反やデータ破損を引き起こします。
症状は:プログラムが終了段階でクラッシュする、あるいは偶発的にゴミデータを読む。調査方法:クリーンアップコードの順序を確認し、通信ドメインの破棄がすべての CUDA リソース解放より前であることを保証します。
📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:198-200落とし穴4:デバイス番号と rank の混同
if (device != devices[i]) {
printf(" [WARNING: Expected device %d]", devices[i]);
}〔設計推論とアーキテクチャのトレードオフ〕ncclCommInitAllrank と device は二つの異なる概念です。rank は通信ドメイン内の論理番号(0 から nRanks-1)、device は物理 GPU 番号です。devices[i] = iのデフォルトの使い方では、devlistなので、rank と device はちょうど等しくなります。しかしカスタムの{2, 0, 1}を渡す場合(例えば
)、rank 0 は device 2 に対応します。この二つの概念を混同すると、データが誤った GPU に送られます。
本章のまとめ
1. 本章では三つのことを完了しました:ビルドエントリmake examples:Makefile の転送メカニズムと CMake のバージョン番号の出所、CUDA アーキテクチャ選択ロジックを理解しました。重要な結論はNCCL_HOMEが先にライブラリをビルドしてからサンプルをビルドし、
2. がビルド成果物ディレクトリをサンプルに渡すことです。最小実行可能プログラムの三要素cudaGetDeviceCount:デバイス数(ncclCommInitAll)、rank(ncclCommInitAllによって自動割り当て)、stream(各 GPU に一つ)。
3. はシングルプロセス・マルチGPUの便利なエントリであり、マルチ rank の同期初期化をライブラリ内部にカプセル化します。一回の AllReduce の完全な外部動作ncclGroupStart:ncclAllReduceが複数のncclGroupEnd呼び出しを包み、cudaStreamSynchronizeが送信し、
4. が完了を待ち、最後に結果を検証します。Group メカニズムはシングルスレッド・マルチGPUシナリオでデッドロックを回避する鍵です。:ncclCommFinalize通信ドメインのライフサイクルncclCommDestroy(グローバルサイレンス)+
(ローカル解放)の二段階破棄、および「まず同期、次に通信ドメインを破棄、次に stream を破棄、最後にホストメモリを解放」という順序制約。
本章の考察とセルフチェック📎 docs/examples/03_collectives/01_allreduce/c/main.cc:130-136Q1: もし
の ncclGroupStart/ncclGroupEnd を削除し、直接ループで ncclAllReduce を呼び出すように変更した場合、シングルプロセス・マルチGPUシナリオで何が起こるか?なぜか?参考解析📎 src/nccl.h.in:844-864:デッドロックが発生します。ヘッダファイルncclAllReduce(comms[0], ...)が原因を説明しています:集合通信呼び出しは inter-CPU 同期を実行する可能性があり、すべての rank が同時に参加する必要があります。シングルスレッドでは、最初のループ反復で
を呼び出すとき、NCCL は他の rank も AllReduce を発起するのを待たなければ進めません。しかし他の rank の呼び出しはまだループ内で実行されておらず(現在のスレッドが最初の呼び出しでブロックされているため)、最初の呼び出しは永遠に他の rank を待てず、デッドロックします。ncclGroupStartGroup メカニズムの役割は「発起」と「実行」を分離することです:ncclGroupEnd以降のすべての呼び出しは登録のみを行い、
時にすべての登録された操作を一緒に送信し、それらが並行して進められるようにします。これにより根本的にシングルスレッドのデッドロックを回避します。gdbattach でスタックを見ると、NCCL 内部の待機ロジックで停止し、CPU 使用率はほぼ 0 になります。
Q2: 📎 docs/examples/03_collectives/01_allreduce/c/main.cc:139-142の cudaStreamSynchronize は cudaDeviceSynchronize で置き換えられますか?両者の意味上の違いは何ですか?どのような場面でこの置き換えが問題を引き起こしますか?
参考解析:置き換え可能ですが、cudaDeviceSynchronize意味が異なります。cudaStreamSynchronize(streams[i])は指定された stream 上の操作の完了のみを待ちます;cudaDeviceSynchronizeは現在のデバイス上のすべてのstream の操作の完了を待ちます。
シングルプロセス・マルチ GPU の場面では、cudaDeviceSynchronizeは現在のデバイス(cudaSetDeviceによって決定される)のみを同期するため、cudaSetDevice(i)ループと組み合わせて使用する必要があります。もしcudaSetDevice,cudaDeviceSynchronizeを省略すると、デフォルトデバイス(通常は device 0)のみが同期され、他のデバイスの AllReduce はまだ完了していない可能性があります。
ヘッダファイル📎 src/nccl.h.in:854-856はncclGroupEndがエンキューを保証するだけで完了を保証しないことを強調しているため、同期は必須です。cudaStreamSynchronizeの方がより正確です。なぜなら、関連する stream のみを待ち、無関係な操作を誤って待つことがないからです。cudaDeviceSynchronizeの問題点は:デバイス上に他の無関係な長時間実行カーネルがある場合、誤って待機してしまい、パフォーマンスが低下することです。
Q3: 📎 docs/examples/01_communicators/01_multiple_devices_single_process/c/main.cc:233-240の破棄順序は「まずすべての通信ドメインを Finalize し、次にすべての通信ドメインを Destroy する」です。もし「各通信ドメインに対して Finalize してから Destroy する」(つまり1つのループ内で2つの操作を完了する)に変更した場合、どのような問題が発生しますか?
参考解析:Group セマンティクスが破壊されます。現在の記述は:
ncclGroupStart();
for (i) ncclCommFinalize(comms[i]);
ncclGroupEnd();
for (i) ncclCommDestroy(comms[i]);ncclCommFinalizeが Group で包まれているため、すべての通信ドメインの Finalize が一緒にコミットされ、並行して進行できます。もし以下のように変更すると:
for (i) {
ncclCommFinalize(comms[i]);
ncclCommDestroy(comms[i]);
}最初のイテレーションのncclCommFinalize(comms[0])はすべての rank が静黙するのをブロックして待ちますが、他の通信ドメインの Finalize はまだ開始されていないため、デッドロックが発生します——これは Q1 のデッドロックと同じ種類の問題です。
また、ヘッダファイル📎 src/nccl.h.in:309-309はncclCommFinalizeが返る時点で通信ドメインがまだncclInProgress状態にある可能性があり、グローバルな静黙を待ってからncclSuccessに入る必要があることを説明しています。もし直ちにncclCommDestroyすると、通信ドメインが完全に静黙する前にローカルリソースを解放し、未定義動作を引き起こす可能性があります。正しい方法は Finalize 後にncclCommGetAsyncErrorをポーリングして状態を確認し、それから Destroy することです。
これらの外部挙動は、後続のすべてのソースコード分析の参照系を構成します。第2章ではコアメンタルモデルを構築します:通信ドメイン、チャネル、アルゴリズム、プロトコル、トランスポート層という5つの要素で、NCCL 内部がこれらの概念をどのように組織しているかを見ていきます。
第2章:第2章:コア抽象モデル:通信演算子、トポロジ、アルゴリズム、プロトコル、トランスポート層
第2章:コア抽象モデル:通信演算子、トポロジ、アルゴリズム、プロトコル、トランスポート層
前章では NCCL を動作させ、ncclCommInitRank、ncclAllReduce、ncclCommDestroy の3つの API の外部挙動を観察しました。しかし外部挙動は氷山の一角にすぎません——ncclAllReduce が返る時、GPU 上で一体何が起きているのか?データはどの経路を通ったのか?なぜ同じ AllReduce が異なるマシンで性能が大きく異なるのか?これらの問いに答えるには、まず NCCL の共通語彙を確立する必要があります。本章では5つのコア抽象を一つずつ分解します:通信ドメイン(ncclComm)、チャネル(channel)、アルゴリズム(algorithm)、プロトコル(protocol)、トランスポート層(transport)。これら5つの概念は全書を通じて登場し、後続の各章の分析で使用されます。それらの関係を理解すれば、NCCL の骨格を理解したことになります。
2.1 通信ドメイン ncclComm:プロセスの通信コンテキスト
直感モデル
ncclCommを「グループチャット」と想像してください:各プロセスがグループチャットに参加するとグループ ID を取得し、その後すべてのメッセージはこのグループ内で送信されます。グループに何人いるか(nRanks)、自分は誰か(rank)、どの回線を通るか(channels)、どのルールを使うか(config)は、すべてこのグループチャットオブジェクトに記録されています。
もしncclCommがなければ、NCCL は「誰と誰が通信するか」「データをどこに送るか」を知ることができません——毎回 API を呼び出すたびに rank リストを再ネゴシエーションし、接続を再構築する必要があり、そのオーバーヘッドは耐えられません。
データ構造とメモリレイアウト
ncclCommは NCCL 全体で最も核心的な構造体であり、src/include/comm.hで定義されています。それは極めて巨大で(約300行)、機能ごとにグループ化して主要なフィールドを見ていきます。
アイデンティティ識別とライフサイクルセンチネル
📎 src/include/comm.h:576-580はstartMagic,📎 src/include/comm.h:879-881を定義し、endMagicを定義します。これら2つのフィールドはセキュリティキーではなく、メモリ境界違反検出センチネルです。📎 src/include/comm.h:883-885の箇所に2つのstatic_assert:
static_assert(offsetof(struct ncclComm, startMagic) == 0, "startMagic must be the first field of ncclComm");
static_assert(offsetof(struct ncclComm, endMagic) == sizeof(struct ncclComm) - sizeof(uint64_t),
"endMagic must be the last field of ncclComm");これら2つのアサーションはコンパイル時にstartMagicが構造体の先頭アドレスに、endMagicが末尾に位置することを強制します。実行時にこれら2つのマジックナンバーが改ざんされていないかをチェックすることで、ncclCommポインタが有効かどうかを迅速に判断できます——これはマルチスレッド環境で「野ポインタが破棄済み通信ドメインにアクセスする」類のバグを調査する際に非常に有用です。
Rank とトポロジ情報
📎 src/include/comm.h:628-629はrankとnRanksを定義します——通信ドメイン内での自分の番号と総参加者数です。📎 src/include/comm.h:644-652はノード関連フィールドを定義します:node(自分がいるノード番号)、nNodes(総ノード数)、localRank(ノード内番号)、localRanks(ノード内 GPU 数)、および3つのマッピングテーブルrankToNode、rankToLocalRank、localRankToRank。
これら3つのマッピングテーブルはトポロジー認識アルゴリズムの基盤である。例えばRingアルゴリズムは「自分の次のrankが同一ノード内にいるか」を知ることでNVLinkを使うかネットワークを使うかを決定する。これらのマッピングテーブルがなければ、アルゴリズム選択のたびにトポロジーグラフを再クエリする必要があり、オーバーヘッドが膨大になる。
チャネルとバッファ
📎 src/include/comm.h:593-593が定義されているchannels[MAXCHANNELS]——これは通信ドメイン内の全チャネルの配列である。📎 src/include/comm.h:674-676チャネル数が定義されている:nChannels(接続チャネル数)、collChannels(集合通信エンキュー用チャネル数)、nvlsChannels(NVLSチャネル数)。
📎 src/include/comm.h:691-693バッファサイズが定義されている:buffSizes[NCCL_NUM_PROTOCOLS](プロトコルごとのバッファサイズ)、p2pChunkSize(P2Pブロックサイズ)、nvlsChunkSize(NVLSブロックサイズ)。
buffSizes配列のインデックスはプロトコル列挙値(LL/LL128/Simple)そのものであり、これは各プロトコルが独立したバッファサイズ設定を持つことを意味する。LLプロトコルはレイテンシ低減のために小さなバッファが必要で、Simpleプロトコルは帯域幅向上のために大きなバッファが必要——この配列が両方の要求を共存させる。
ワークキューとFIFO
📎 src/include/comm.h:719-728ワークFIFO関連のフィールドが定義されている:workFifoBytes(FIFOサイズ、2の冪)、workFifoBuf(ホスト側FIFOバッファ)、workFifoBufDev(デバイス側FIFOバッファ)、workFifoProduced(生産済みバイト数)、workFifoConsumed(消費済みバイト数)。
これは典型的なプロデューサー・コンシューマー型リングバッファである。ホスト側(プロデューサー)がワーク記述をFIFOに書き込み、GPU kernel(コンシューマー)が読み取って実行する。workFifoBytesは2の冪でなければならない。これにより剰余演算の代わりにビットマスクを使え、インデックス計算を高速化できる。
プロセス内同期バリア
📎 src/include/comm.h:731-731プロセス内の複数通信ドメイン同期メカニズムが定義されている:
struct ncclComm* intraComm0; // leader of intra-process comms (self possible)
struct ncclComm* intraNext; // next of intra-process comms, intraComm0 is head
int intraRank;
int intraRanks;
uint32_t intraBarrierPhase;
char intraPad1[64 - sizeof(uint64_t)];
uint64_t intraBarrierCounter; // only used if this is intraComm0
char intraPad2[64 - sizeof(uint64_t)];
uint64_t intraBarrierGate; // only used if this is intraComm0注意intraPad1とintraPad2のサイズは64 - sizeof(uint64_t)、すなわち56バイトである。前述のuint64_tフィールドを加えると、各フィールドグループはちょうど64バイト——これは1キャッシュライン(Cache Line)である。
これは典型的なキャッシュライン充填(Cache Line Padding)技術である。intraBarrierCounterとintraBarrierGateは複数スレッドから高頻度で読み書きされる。もしこれらが同じキャッシュラインを共有すると、偽共有(False Sharing)を引き起こす:あるスレッドがintraBarrierCounterを変更すると別のスレッドのintraBarrierGateキャッシュが無効化され、性能が急激に低下する。56バイトの充填でこれらを異なるキャッシュラインに隔離するのは、高性能並行プログラミングの標準手法である。
非同期エラー状態
📎 src/include/comm.h:705-705が定義されているasyncResult——このフィールドは通信ドメインの非同期操作状態を記録する。前章でncclCommFinalizeが返ったとき通信ドメインがまだncclInProgress状態である可能性があると述べたが、それはこのフィールドで追跡されている。
シナリオ駆動Walkthrough:ncclCommInitRankから構造体充填まで
ユーザーがncclCommInitRank(&comm, nranks, commId, rank)を呼び出すと、NCCL内部でncclComm構造体が割り当てられ、フィールドごとに充填される。この流れに沿って主要フィールドがどのように設定されるかを見ていく:
第一步:割り当てとゼロクリア
NCCLはncclCallocを使ってncclCommを割り当て、全フィールドが初期値0であることを保証する。この時点でstartMagicとendMagicがNCCL_MAGIC(📎 src/include/comm.h:563-569に設定される(0x0280028002800280は
と定義され、コメントには "Nickel atomic number is 28" とある)。
rank、nRanks、cudaDev第二步:アイデンティティ情報の充填commHashは引数とCUDA APIから取得される。ncclCommIdは
のハッシュから得られ、後のネットワーク通信における一貫性検証に使用される。
第三步:トポロジーグラフの構築topoNCCLはトポロジー検出モジュールを呼び出して全GPU、NIC、PCIスイッチを列挙し、📎 src/include/comm.h:595-595フィールド(
)を構築する。このトポロジーグラフが以降のアルゴリズム選択と経路計画を決定する。
channels[MAXCHANNELS]第四步:チャネルの初期化id配列が1つずつ初期化される。各チャネルのpeersが配列インデックスに設定され、devPeersと
ポインタが割り当てられる。
第五步:転送接続の確立setupトポロジーグラフに基づき、NCCLはrankの各ペアに対して転送層(P2P/SHM/NET)を選択し、対応するconnectとchannels[i].peers[j]コールバックを呼び出す。接続情報は
に格納される。
第六步:マジックナンバーの設定endMagic最後に、NCCL_MAGICが
に設定され、構造体の初期化完了をマークする。
設計上の考察と本番環境での落とし穴ncclCommなぜ
ncclComm〔設計推論とアーキテクチャのトレードオフ〕
は約300のフィールドを含む。なぜなら通信ドメインの全状態を担っているからである。NCCLの設計哲学は「一度初期化し、何度も再利用する」——初期化時に考えられる全ての情報を計算して保存し、実行時は直接テーブルを参照して再計算を避ける。代償はメモリ使用量が大きいこと(通信ドメインあたり約数KB)だが、GPUメモリやネットワーク帯域と比べればこの程度のメモリは微々たるものである。
ncclComm〔設計推論とアーキテクチャのトレードオフ〕ncclCommはスレッドセーフではない。もし2つのスレッドが同時に同じncclAllReduce,workFifoProducedに対して
などを呼び出すとフィールドが競合し、データ破損を引き起こす。正しい方法は、各スレッドが独立した通信ドメインを使用するか、外部ロックで呼び出しを直列化することである。
ncclCommDestroy落とし穴シナリオ2:破棄後のアクセスstartMagicが構造体メモリを解放した後、まだポインタを保持しているスレッドがアクセスすると、解放済みメモリを読み取ることになる。endMagicと
はこの状況の検出に役立つ——マジックナンバーが一致しなければ、ポインタが無効であることを示す。
落とし穴シナリオ3:キャッシュラインの偽共有intraBarrierCounterマルチプロセスシナリオ(各プロセスが1つのrank)では、intraBarrierGateと
の充填が特に重要である。充填を省略すると、複数プロセスのバリア操作が互いに干渉し、同期遅延がナノ秒レベルからマイクロ秒レベルに上昇する。
2.2 チャネル channel:1回の通信を複数のパイプラインに分割する
直感的モデルchannelこれはNCCLの「ベルトコンベア」である——一度の集合通信のデータを複数に分割し、各チャネルが独立して一つを運び、並列に推進することで帯域幅利用率を高める。
チャネルがなければ、すべてのデータは一つの経路しか通れず、GPU間の複数の物理リンク(複数のNIC、複数のNVLinkグループ)を同時に利用できず、帯域幅利用率は大幅に低下する。
データ構造とメモリレイアウト
ncclChannel定義場所は📎 src/include/comm.h:169-191:
struct ncclChannel {
struct ncclChannelPeer** peers;
struct ncclDevChannelPeer** devPeers;
/* devPeer pointer array used for host side access */
struct ncclDevChannelPeer** devPeersHostPtr;
struct ncclRing ring;
int* devRingUserRanks;
struct ncclTree tree;
struct ncclTree collnetChain;
struct ncclDirect collnetDirect;
struct ncclNvls nvls;
int id; // index of this channel
uint32_t workFifoProduced; // +1 successor of last used work fifo byte
/* comm split sharable resources */
struct ncclChannelPeer* collnetPeers;
struct ncclDevChannelPeer* collnetDevPeers;
struct ncclChannelPeer* nvlsPeers;
struct ncclDevChannelPeer* nvlsDevPeers;
};主要フィールドの解析
peers/devPeers:このチャネル内のすべてのrankの接続情報を指す。peersはホスト側ビュー、devPeersはデバイス側ビュー(GPUカーネルが直接アクセス)。ring:Ringアルゴリズムのトポロジ記述——各rankの前駆と後続。tree:Treeアルゴリズムのトポロジ記述——親ノードと子ノードのリスト。collnetChain/collnetDirect:CollNetアルゴリズムの2つの変種トポロジ。nvls:NVLink SHARPのトポロジ記述。id:チャネルインデックス、0からnChannels-1。workFifoProduced:このチャネルの作業FIFO生産ポインタ。
注意ring、tree、collnetChain、collnetDirect、nvlsこれら5つのフィールドは並列である——同じチャネルが同時に複数のアルゴリズムのトポロジ記述を保持できる。実行時にアルゴリズム選択に基づいてどのフィールドを使用するか決定する。この設計により、アルゴリズム切り替え時にチャネルを再構築する必要がなく、読み取るフィールドを切り替えるだけで済む。
チャネル数の計算
チャネル数はncclCommで定義される(📎 src/include/comm.h:674-676):
int nChannels; // connection nChannels
int collChannels; // enqueue nChannels
int nvlsChannels; // enqueue nChannelsnChannelsは実際に確立された接続数、collChannelsは集合通信のエンキュー時に使用されるチャネル数、nvlsChannelsはNVLS専用チャネル数。三者は異なる場合がある——例えば一部のチャネルはP2Pのみに使用され集合通信には使用されない。
P2Pチャネルスケジューリング
📎 src/include/channel.h:21-33はncclP2pChannelBaseForRound関数を定義し、P2P通信の各ラウンドで使用されるチャネルベースアドレスを計算する:
inline uint8_t ncclP2pChannelBaseForRound(struct ncclComm* comm, int p2pRound) {
int base;
if (comm->nNodes > 1) {
int localSize = comm->p2pSchedGroupSize;
int groupDelta = p2pRound / localSize;
int localDelta = p2pRound % localSize;
base = groupDelta * divUp(localSize, NCCL_MAX_DEV_WORK_P2P_PER_BATCH);
base += localDelta / NCCL_MAX_DEV_WORK_P2P_PER_BATCH;
} else {
base = p2pRound;
}
return reverseBits(base, log2Up(comm->p2pnChannels));
}この関数のロジックは:マルチノードシナリオでは、P2P通信は「グループ」単位でスケジュールされ、各グループ内のrankは隣接するチャネルを使用する;シングルノードシナリオでは、各ラウンドが直接1つのチャネルにマッピングされる。reverseBitsはビット反転操作で、チャネル割り当てを分散させ、ホットスポットの集中を避ける。
シナリオ駆動ウォークスルー:1回のAllReduceがどのようにチャネルを割り当てるか
8つのrank、4つのチャネルを仮定し、1回のAllReduceを実行する。データは4つに分割され、各部分を1つのチャネルが担当する。
ステップ1:アルゴリズム選択
NCCLのtuningモジュールがメッセージサイズとトポロジに基づいてアルゴリズム(例えばRing)とプロトコル(例えばSimple)を選択する。
ステップ2:チャネル割り当て
ncclTaskColl構造体(📎 src/include/comm.h:212-273)が作成され、そのうちnChannelsフィールドが4に設定される(📎 src/include/comm.h:254-254)。channelLoとchannelHiフィールド(📎 src/include/comm.h:256-257)がこのタスクで使用するチャネル範囲をマークする。
ステップ3:データ分割
各チャネルはcount / nChannels個の要素を担当する。チャネル0は0番目からcount/4-1番目の要素を処理し、チャネル1はcount/4番目からcount/2-1番目の要素を処理し、以下同様。
ステップ4:並列実行
4つのチャネルのGPUカーネルが同時に起動し、それぞれが自分のデータスライス上でRing AllReduceを実行する。チャネル間にデータ依存がないため、完全に並列化できる。
ステップ5:結果のマージ
すべてのチャネルが完了すると、各rankのrecvバッファには完全なAllReduce結果が入っている。
並行制御とハードウェア相互作用
チャネルとGPUリソースのマッピング
各チャネルは通常、独立したCUDAストリームまたはGPUハードウェアキューにバインドされる。これにより異なるチャネルのカーネルがGPU上で並行実行でき、SM(ストリーミングマルチプロセッサ)リソースを最大限活用できる。
チャネルとネットワークデバイスのマッピング
マルチNICシナリオでは、異なるチャネルを異なるNICにバインドできる。例えば4チャネル、2NICの場合、チャネル0と1はNIC Aを、チャネル2と3はNIC Bを使用する。これにより両方のNICの帯域幅を利用できる。
チャネル数の選択
チャネル数は多ければ多いほど良いわけではない。チャネル数の増加は以下をもたらす:
- より多くのカーネル起動オーバーヘッド
- より多くの接続確立オーバーヘッド
- より複雑な同期
NCCLのtuningモジュールはメッセージサイズに基づいて最適なチャネル数を自動選択する。小さいメッセージには少ないチャネル(オーバーヘッド削減)、大きいメッセージには多くのチャネル(帯域幅向上)。
本番環境の落とし穴回避ガイド
落とし穴シナリオ1:チャネル数の設定ミス
手動でNCCL_NCHANNELSを大きく設定しすぎると、小さいメッセージのシナリオではカーネル起動オーバーヘッドが利益を上回り、性能がかえって低下する。明確なチューニング要件がない限り、NCCLに自動選択させることを推奨する。
落とし穴シナリオ2:チャネルとトポロジの不一致
チャネル数が物理リンク数を超えると、一部のチャネルがリンクを共有し、真の並列化が実現できない。例えば2NICに8チャネルの場合、実際に同時転送できるのは2チャネルのみで、残り6チャネルは待ち行列に入る。
落とし穴シナリオ3:P2Pチャネル競合
ncclP2pChannelBaseForRoundのreverseBits操作の実装に誤りがあると、複数のラウンドが同じチャネルにマッピングされ、直列化が発生する。📎 src/include/channel.h:32-32のreverseBits(base, log2Up(comm->p2pnChannels))がチャネル割り当ての均等性を確保する。
2.3 アルゴリズム algorithm:Tree/Ring/CollNet/NVLS/PATのトポロジ構成
直感モデル
北京から上海へは高速鉄道、飛行機、または車で行くことができ、それぞれの方法が異なる距離と人数に適しています。NCCLのアルゴリズムはこれらの「移動手段」に相当します——Ringは大きなメッセージの安定した帯域幅に適し、Treeは小さなメッセージの低遅延に適し、CollNetはネットワークカードのオフロードを利用し、NVLSはNVLink SHARPハードウェアアクセラレーションを利用し、PATはNVLSの並列化変種です。
アルゴリズム選択がなければ、NCCLは固定された1つのモードでしか通信できず、異なるメッセージサイズやトポロジー構造に適応できず、パフォーマンスが大幅に低下します。
データ構造とメモリレイアウト
Ringアルゴリズム
Ringアルゴリズムの核心はncclRing構造体(src/include/comm.h内でchannels[i].ringを通じて参照)です。📎 src/include/collectives.h:81-116はRingAlgorithm基底クラスを定義しています:
class RingAlgorithm {
protected:
int refCount;
int nRanks;
int nStepsPerLoop;
int chunkSteps;
int sliceSteps;
ssize_t sliceSize;
ssize_t loopSize;
ssize_t channelSize;
uint8_t* sendbuff;
uint8_t* recvbuff;
void* sendMhandle;
void* recvMhandle;
void* srecvMhandle;
public:
virtual void getNextSendAddr(int curStep, uint8_t** sendbuffOut, size_t* sizeOut, void** mhandleOut) = 0;
virtual void getNextRecvAddr(int curStep, uint8_t** recvbuffOut, size_t* sizeOut, void** mhandleOut) = 0;
int incRefCount() {
return (int)COMPILER_ATOMIC_ADD_FETCH(&refCount, 1, std::memory_order_relaxed);
}
int decRefCount() {
return (int)COMPILER_ATOMIC_SUB_FETCH(&refCount, 1, std::memory_order_release);
}
RingAlgorithm() {
refCount = 0;
}
virtual ~RingAlgorithm() {};
};主要フィールドの解析
refCount:参照カウント。proxyスレッドとGPUカーネルがアルゴリズムオブジェクトを共有するために使用されます。nRanks:リング上のノード数。nStepsPerLoop:各ラウンドのループステップ数。AllReduceは2*(nRanks-1)*chunkSteps(📎src/include/collectives.h:218-218)。chunkSteps/sliceSteps:ブロックステップ数とスライスステップ数。パイプラインの粒度を制御します。sliceSize/loopSize/channelSize:スライスサイズ、ループサイズ、チャネルサイズ。sendbuff/recvbuff:送受信バッファポインタ。sendMhandle/recvMhandle/srecvMhandle:メモリハンドル。ネットワーク登録に使用されます。
参照カウントのアトミック操作
📎 src/include/collectives.h:106-108はincRefCountとdecRefCount:
int incRefCount() {
return (int)COMPILER_ATOMIC_ADD_FETCH(&refCount, 1, std::memory_order_relaxed);
}
int decRefCount() {
return (int)COMPILER_ATOMIC_SUB_FETCH(&refCount, 1, std::memory_order_release);
}incRefCountはmemory_order_relaxedを使用——参照カウントの増加には同期が不要で、原子性さえ保証すればよい。decRefCountはmemory_order_releaseを使用——参照カウントの減少時には、以前の書き込み操作が他のスレッドに可視であることを保証する必要がある(オブジェクトの破棄をトリガーする可能性があるため)。
RingARAlgorithm:AllReduceのRing実装
📎 src/include/collectives.h:118-234はRingARAlgorithmを定義し、RingAlgorithmから継承しています。核心メソッドはgetNextSendAddrとgetNextRecvAddr。
📎 src/include/collectives.h:126-167のgetNextSendAddrロジックです:
void getNextSendAddr(int curStep, uint8_t** sendbuffOut, size_t* sizeOut, void** mhandleOut) {
int curLoop = curStep / nStepsPerLoop;
int curLoopStage = (curStep % nStepsPerLoop) / chunkSteps;
int chunkStage = curLoopStage % nRanks;
int sliceStage = (curStep % chunkSteps) / sliceSteps;
ssize_t elemOffset = curLoop * loopSize;
ssize_t remSize = channelSize - elemOffset;
// ... 计算 chunkOffset, sliceOffset, curSliceSize ...
if (remSize < loopSize) {
curChunkSize = alignUp(divUp(remSize / elemSize, nRanks), 16 / elemSize) * elemSize;
} else {
curChunkSize = chunkSize;
}
chunkId = (ringIndex + nRanks - 1 - chunkStage) % nRanks;
chunkOffset = chunkId * curChunkSize;
nelem = std::min(remSize - chunkOffset, curChunkSize);
curSliceSize = std::max(divUp(nelem / elemSize, 16 * slicePerChunk) * 16, sliceSize / elemSize / 32) * elemSize;
sliceOffset = sliceStage * curSliceSize;
// ... 设置 sendbuffOut, sizeOut, mhandleOut ...
}このコードの核心はアドレス計算:現在のステップ数curStepが与えられたとき、どのデータブロックのどのスライスを送信すべきかを計算します。chunkIdの計算(ringIndex + nRanks - 1 - chunkStage) % nRanksはリング上の逆方向伝播を実装しています——各rankは前駆からデータを受信し、処理後に後続へ送信します。
PATアルゴリズム
PAT(Parallel Aggregated Tree)はNVLSの並列化変種です。📎 src/include/collectives.h:416-423はncclPatStep:
struct ncclPatStep {
int recvDim, sendDim, recvOffset, sendOffset, stepOffset, postRecv, postSend, nelem, last, flags;
// PAT algo computation thread step number; -1 while the slot is free.
int step;
// This PAT group's offset within the shared NVLS slot.
int nvlsOffset;
size_t inpIx, outIx;
};📎 src/include/collectives.h:425-435はncclPatPeer:
struct ncclPatPeer {
uint64_t step;
struct ncclConnInfo* conn;
struct ncclConnFifo* connFifo;
void* buff;
uint64_t* headPtr;
uint64_t* tailPtr;
uint64_t stepCache;
long long int accSize;
int connStepSize;
};PATアルゴリズムの核心思想は複数の小さなステップを1つの大きなステップに集約することで、同期オーバーヘッドを削減します。ncclPatStepは集約ステップの送受信次元、オフセット、要素数などの情報を記述します。ncclPatPeerはピアノードの接続状態とバッファポインタを記述します。
シナリオ駆動Walkthrough:Ring AllReduceのステップ演化
4つのrank(0, 1, 2, 3)があり、各rankが4つの要素を持ち、Ring AllReduceを実行すると仮定します。
Reduce-Scatterフェーズ
- ステップ0:rank 0が要素0をrank 1に送信、rank 1が要素1をrank 2に送信、rank 2が要素2をrank 3に送信、rank 3が要素3をrank 0に送信。
- ステップ1:各rankは受信した要素をローカルの対応する要素と加算し、次のrankに送信します。
- ステップ2:累加と転送を続行。
- ステップ3:この時点で各rankは完全な帰約結果を持ちます(rank 0は要素3の結果、rank 1は要素0の結果、など)。
AllGatherフェーズ
- ステップ4-6:各rankは自身が持つ帰約結果をリングに沿って伝播し、最終的にすべてのrankが完全な結果を持ちます。
📎 src/include/collectives.h:218-218のnStepsPerLoop = 2 * (nRanks - 1) * chunkStepsはまさにこのフローに対応します:Reduce-Scatterには(nRanks-1)*chunkStepsステップが必要で、AllGatherにも(nRanks-1)*chunkStepsステップが必要で、合計2*(nRanks-1)*chunkStepsステップです。
設計上の考察と本番環境の落とし穴
なぜRingとTreeが共存するのか?
Ringアルゴリズムは帯域幅利用率が高い(すべてのリンクが伝送中)ですが、遅延はrank数に線形に増加します。Treeアルゴリズムの遅延は対数レベルですが、帯域幅利用率は低い(一部のリンクのみが動作)。NCCLはメッセージサイズに基づいて自動選択します:小さなメッセージにはTree(遅延敏感)、大きなメッセージにはRing(帯域幅敏感)。
落とし穴シナリオ1:アルゴリズム選択の誤り
手動でRingを強制して小さなメッセージを処理すると、遅延が著しく増加します。明確なパフォーマンス分析データが手動介入を支持しない限り、tuningモジュールに自動選択させることを推奨します。
落とし穴シナリオ2:NVLSハードウェア非対応
NVLSには特定のハードウェアサポート(NVLink SHARP)が必要です。ハードウェアが非対応なのにコードがNVLSを強制使用すると、RingまたはTreeにフォールバックしますが、パフォーマンスの揺らぎを伴う可能性があります。📎 src/include/comm.h:755-755のnvlsSupportフィールドはハードウェアがNVLSをサポートするかどうかを示します。
落とし穴シナリオ3:PATアルゴリズムの集約因子設定
PATアルゴリズムのaggFactorは何ステップを集約するかを決定します。📎 src/include/collectives.h:537-560はaggFactorの計算ロジックを示します:
aggFactor = 1;
size_t channelSize = end - offset;
while (stepSize / (channelSize * sizeof(T) * aggFactor) >= 2 && aggFactor < nranks / 2) {
aggFactor *= 2;
aggDelta /= 2;
}
postFreq = aggFactor;
if (postFreq < parallelFactor) parallelFactor = postFreq;
int d = stepDepth;
while (d > 1 && aggFactor < nranks / 2) {
d /= 2;
aggFactor *= 2;
aggDelta /= 2;
}aggFactorが小さすぎると同期オーバーヘッドが大きくなり、大きすぎるとパイプラインバブルが発生します。NCCLはstepSize、channelSize、nranksに基づいて最適値を自動計算します。
2.4 プロトコルprotocol:LL/LL128/Simpleの3つのデータ転送戦略
直感モデル
荷物を送る際には「同城即日配送」「翌日配達」「普通郵便」を選べる。速度とコストが異なる。NCCLのプロトコルはこれらの「送り方」に相当する——LL(Low Latency)は小メッセージの低遅延転送に適し、LL128は中規模メッセージの128バイトアライメント転送に適し、Simpleは大メッセージの高帯域転送に適している。
プロトコル選択がなければ、NCCLは固定された1つの戦略でしかデータを転送できず、遅延と帯域のバランスを取ることができない。
データ構造とメモリレイアウト
プロトコル列挙
📎 src/include/comm.h:55-57プロトコル関連のスレッド閾値を定義している:
#define NCCL_LL_THREAD_THRESHOLD 8
#define NCCL_LL128_THREAD_THRESHOLD 8
#define NCCL_SIMPLE_THREAD_THRESHOLD 64これらの閾値が各プロトコルで使用するスレッド数を決定する。LLとLL128は8スレッド(低遅延、少ないスレッドで十分)、Simpleは64スレッド(高帯域、より多くのスレッドで並列転送が必要)。
プロトコルバッファ
📎 src/include/comm.h:691-691以下を定義している:buffSizes[NCCL_NUM_PROTOCOLS]——各プロトコルが独立したバッファサイズを持つ。
プロトコル関連のFIFO構造
📎 src/include/comm.h:59-83以下を定義している:ncclSendMemおよびncclRecvMem:
struct ncclSendMem {
union {
struct {
uint64_t head;
char pad1[CACHE_LINE_SIZE - sizeof(uint64_t)];
void* ptrExchange;
uint64_t redOpArgExchange[2];
char pad2[CACHE_LINE_SIZE - sizeof(void*) - 2 * sizeof(uint64_t)];
int offsFifo[NCCL_STEPS];
};
char pad3[MEM_ALIGN];
};
};
struct ncclRecvMem {
union {
struct {
uint64_t tail;
char pad1[CACHE_LINE_SIZE - sizeof(uint64_t)];
struct ncclConnFifo connFifo[NCCL_STEPS];
int flush; // For GDRCopy-based flush
};
char pad4[MEM_ALIGN];
};
};ncclSendMemおよびncclRecvMemは送信と受信の共有メモリ構造である。headおよびtailはリングバッファの読み書きポインタであり、pad1それらが異なるキャッシュライン上にあることを保証する。connFifo配列は各ステップの接続情報(モード、オフセット、サイズ、ポインタ)を格納し、以下で定義される:📎 src/include/collectives.h:72-77:
struct ncclConnFifo {
int mode;
ssize_t offset;
ssize_t size;
void* ptr;
};プロトコル選択ロジック
プロトコル選択はtuningモジュールによって行われ、考慮要素は以下の通り:
- メッセージサイズ:小メッセージはLL、中規模はLL128、大メッセージはSimple。
- トポロジ:NVLink接続はLL128に適し、ネットワーク接続はSimpleに適する。
- ハードウェア能力:一部のGPUアーキテクチャは特定のプロトコルに最適化されている。
シナリオ駆動Walkthrough:LLプロトコルのデータ転送
LLプロトコルで1KBのデータを転送すると仮定する。
第一步:データを送信バッファに書き込む
ホスト側がデータを以下に書き込み、sendbuffその後ncclSendMem.headポインタを更新し、GPU kernelに新しいデータがあることを通知する。
第二步:GPU kernelがデータを読み取る
GPU kernelがheadポインタをポーリングし、新しいデータを検出すると、sendbuffからデータを読み取る。
第三步:データ転送
GPU kernelがNVLinkまたはネットワークを介してデータをターゲットrankに送信する。
第四步:ターゲットrankがデータを受信する
ターゲットrankのGPU kernelがデータを以下に書き込み、recvbuffその後ncclRecvMem.tailポインタを更新する。
第五步:ホスト側がデータを読み取る
ホスト側がtailポインタをポーリングし、新しいデータを検出すると、recvbuffからデータを読み取る。
並行制御とハードウェア相互作用
LLプロトコルの低遅延メカニズム
LLプロトコルはポーリング(Polling)を割り込みではなくデータ到着の検出に使用する。GPU kernelはheadポインタを継続的に読み取り、変化を検出すると即座に処理する。これは割り込み方式より低遅延だが、GPU計算リソースを消費する。
LL128プロトコルの128バイトアライメント
LL128プロトコルはデータが128バイトでアライメントされていることを要求し、これにより各転送がちょうど1つのキャッシュラインを満たす。アライメントの利点は:
- 部分キャッシュライン書き込み(Partial Cache Line Write)の削減
- メモリ帯域利用率の向上
- ハードウェア処理ロジックの簡素化
Simpleプロトコルのバッチ転送
Simpleプロトコルはバッチ転送モードを使用する:一定量のデータを蓄積してから一度に送信し、同期回数を削減する。これは大メッセージシナリオに適しており、同期オーバーヘッドが大量のデータに分散される。
本番環境の落とし穴回避ガイド
落とし穴シナリオ1:プロトコルとメッセージサイズの不一致
LLプロトコルを強制的に大メッセージ転送に使用すると、性能が急激に低下する。LLプロトコルの設計目標は低遅延であり、高帯域ではないからだ。大メッセージにはSimpleプロトコルを使用すべきである。
落とし穴シナリオ2:LL128のアライメント問題
データが128バイトでアライメントされていない場合、LL128プロトコルはLLまたはSimpleにフォールバックし、性能が不安定になる。送信バッファと受信バッファの両方を128バイトでアライメントすることを推奨する。
落とし穴シナリオ3:プロトコル切り替えのオーバーヘッド
実行時にプロトコルを動的に切り替えると追加のオーバーヘッドが発生する。NCCLは初期化時にプロトコルを決定し、実行時には切り替えない。切り替えが必要な場合は、通信ドメインを再初期化する必要がある。
2.5 トランスポート層 transport:P2P/SHM/NET/CollNet 低レベル転送チャネル
直感モデル
A地点からB地点へは徒歩、自転車、地下鉄、タクシーで行ける。NCCLのトランスポート層はこれらの異なる「移動手段」である。上位層は具体的な移動方法を気にせず、届けられるかどうかだけを気にする。P2Pは「徒歩」(同一マシン内GPU直結)、SHMは「自転車」(共有メモリ)、NETは「地下鉄」(ネットワーク)、CollNetは「タクシー」(NICオフロード)。
トランスポート層の抽象化がなければ、上位アルゴリズムは各物理リンクごとに異なるコードを書く必要があり、再利用できない。
データ構造とメモリレイアウト
トランスポート層列挙
📎 src/include/transport.h:18-23トランスポート層タイプを定義している:
#define NTRANSPORTS 4
#define TRANSPORT_UNDEFINED -1
#define TRANSPORT_P2P 0
#define TRANSPORT_SHM 1
#define TRANSPORT_NET 2
#define TRANSPORT_COLLNET 3トランスポート層インターフェース
📎 src/include/transport.h:129-146以下を定義している:ncclTransportComm——トランスポート層の通信インターフェース:
struct ncclTransportComm {
ncclResult_t (*setup)(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo*, struct ncclPeerInfo*,
struct ncclConnect*, struct ncclConnector*, int channelId, int connIndex);
ncclResult_t (*connect)(struct ncclComm* comm, struct ncclConnect*, int nranks, int rank, struct ncclConnector*);
ncclResult_t (*free)(struct ncclComm* comm, struct ncclConnector*);
ncclResult_t (*proxySharedInit)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
int nChannels);
ncclResult_t (*proxySetup)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState, void* reqBuff,
int reqSize, void* respBuff, int respSize, int* done);
ncclResult_t (*proxyConnect)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState, void* reqBuff,
int reqSize, void* respBuff, int respSize, int* done);
ncclResult_t (*proxyFree)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState);
ncclResult_t (*proxyProgress)(struct ncclProxyState* proxyState, struct ncclProxyArgs*);
ncclResult_t (*proxyRegister)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
void* reqBuff, int reqSize, void* respBuff, int respSize, int* done);
ncclResult_t (*proxyDeregister)(struct ncclProxyConnection* connection, struct ncclProxyState* proxyState,
void* reqBuff, int reqSize, int* done);
};主要コールバックの解析
setup:接続確立前の準備作業、接続パラメータの交換。connect:実際の接続確立。free:接続リソースの解放。proxySharedInit:proxy スレッドの共有リソースを初期化する。proxySetup/proxyConnect:proxy スレッド側の接続確立。proxyProgress:proxy スレッドがデータ転送を進める。proxyRegister/proxyDeregister:メモリの登録と登録解除。
トランスポート層構造体
📎 src/include/transport.h:148-154は以下を定義するncclTransport:
struct ncclTransport {
const char name[8];
ncclResult_t (*canConnect)(int*, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo*,
struct ncclPeerInfo*);
struct ncclTransportComm send;
struct ncclTransportComm recv;
};nameはトランスポート層名(例:"P2P"、"SHM"、"NET")、canConnect2つの rank 間でそのトランスポート層が使用可能かどうかを判定する、sendおよびrecvはそれぞれ送信方向と受信方向の通信インターフェース。
トランスポート層インスタンス
📎 src/include/transport.h:36-36は4つのトランスポート層インスタンスを宣言する:
extern struct ncclTransport p2pTransport;
extern struct ncclTransport shmTransport;
extern struct ncclTransport netTransport;
extern struct ncclTransport collNetTransport;📎 src/include/transport.h:36-36はトランスポート層配列を定義する:
extern struct ncclTransport* ncclTransports[];ピアノード情報
📎 src/include/transport.h:46-74は以下を定義するncclPeerInfo——rank 間で交換されるメタデータ:
struct ncclPeerInfo {
int rank;
int cudaDev;
int nvmlDev;
int gdrSupport;
uint64_t hostHash;
uint64_t pidHash;
dev_t shmDev;
int64_t busId;
cudaUUID_t gpuUuid;
struct ncclComm* comm;
int cudaCompCap;
int gpuCftSupport;
size_t totalGlobalMem;
// MNNVL support
nvmlGpuFabricInfoV_t fabricInfo;
int fabricHandleSupport;
int cuMemSupport;
int version;
uint64_t supportedGinTypeBitMask;
bool crossNicSupport;
bool rmaPluginAvailable;
bool cuMemGdrSupport;
int mloPart; // MLOPart partition index, or -1 if not an MLOPart GPU
int cudaDriverVersion;
bool gpuCftMulticastSupport;
bool gpuCftCountedSupport;
uint32_t gitVersionHash;
};これらのフィールドは、2つの rank 間でどのトランスポート層が使用可能かを判定するために用いられる:
hostHash同一 → 同一ホスト → P2P または SHM が使用可能hostHash異なる → 異なるホスト → NET を使用する必要があるgdrSupport→ GPUDirect RDMA をサポートするかcudaCompCap→ GPU コンピュート能力、プロトコル選択に影響する
シナリオ駆動 Walkthrough:P2P 接続の確立
2つの rank が同一ホスト内にあり、NCCL が P2P トランスポート層を選択すると仮定する。
第一步:PeerInfo の交換
2つの rank は bootstrap チャネルを通じてncclPeerInfoを交換し、互いが同一ホストにあり、GPU が P2P をサポートすることを確認する。
第二步:canConnect の呼び出し
📎 src/include/transport.h:148-154のcanConnectコールバックが呼び出され、トポロジ図を確認して2つの GPU 間に NVLink または PCIe 接続があることを確認する。
第三步:setup の呼び出し
p2pTransport.send.setupおよびp2pTransport.recv.setupが呼び出され、接続パラメータ(IPC ハンドルなど)を準備する。
第四步:connect の呼び出し
p2pTransport.send.connectおよびp2pTransport.recv.connectが呼び出され、実際に接続を確立する。
第五步:メモリの登録
RDMA が必要な場合、proxyRegisterを呼び出して送信および受信バッファを登録する。
並行制御とハードウェア相互作用
P2P トランスポート層
P2P は CUDA IPC(Inter-Process Communication)機構を使用し、ある GPU が別の GPU のメモリに直接アクセスすることを可能にする。これには以下が必要:
- 2つの GPU が同一 PCIe ドメインまたは NVLink ドメインにあること
- オペレーティングシステムが CUDA IPC をサポートすること
- 十分な権限
SHM トランスポート層
SHM はホスト共有メモリを中継として使用する。2つの GPU 間に直接接続がない場合、データはまずホストメモリにコピーされ、次にターゲット GPU にコピーされる。これは P2P より遅いが、互換性はより良い。
NET トランスポート層
NET はネットワークデバイス(InfiniBand または RoCE)を使用してデータを転送する。これには以下が必要:
- ネットワークデバイスが GPUDirect RDMA をサポートすること(オプションだが推奨)
- 正しいネットワーク設定(IP アドレス、サブネットマスクなど)
- 十分なネットワーク帯域幅
CollNet トランスポート層
CollNet はネットワークカードの集合通信オフロード能力(NVIDIA SHARP など)を活用する。ネットワークカードが直接リダクション操作を実行し、GPU の計算負担を軽減する。これには以下が必要:
- SHARP をサポートするネットワークカード
- 正しい SHARP 設定
本番環境の落とし穴回避ガイド
落とし穴シナリオ1:P2P が使用不可
2つの GPU 間に NVLink がなく、PCIe トポロジが P2P をサポートしない場合、NCCL は SHM にフォールバックする。 これにより性能が低下する。NCCL_P2P_DISABLE=1で P2P を強制無効化し、性能変化を観察できる。
落とし穴シナリオ2:ネットワーク設定エラー
ネットワークデバイスの IP アドレス設定が誤っている場合、NET トランスポート層は接続を確立できない。 よくあるエラーには、サブネットマスクの誤り、ルーティングテーブルの欠落、ファイアウォールによるブロックがある。ibstatおよびibpingで InfiniBand 接続を確認することを推奨する。
落とし穴シナリオ3:GPUDirect RDMA が未启用
が 0 の場合、gdrSupportNET トランスポート層は「まずホストメモリにコピーしてから送信」モードにフォールバックし、遅延が著しく増加する。nvidia-peermemモジュールがロードされているか、ネットワークカードドライバが GPUDirect をサポートしているかを確認する。
2.6 五件套の組み合わせ方:一回の通信の完全なライフサイクル
組み合わせ関係図
flowchart TD
api["ncclAllReduce(sendbuff, recvbuff, count, ...)"] --> comm_lookup["查找 ncclComm"]
comm_lookup --> task_create["创建 ncclTaskColl"]
task_create --> tuning{"tuning 模块选择算法和协议"}
tuning -->|"小消息"| tree_ll["Tree + LL"]
tuning -->|"中等消息"| ring_ll128["Ring + LL128"]
tuning -->|"大消息"| ring_simple["Ring + Simple"]
tuning -->|"NVLS 可用"| nvls["NVLS + Simple"]
tree_ll --> channel_assign["分配通道"]
ring_ll128 --> channel_assign
ring_simple --> channel_assign
nvls --> channel_assign
channel_assign --> transport_select{"选择传输层"}
transport_select -->|"同机 GPU 直连"| p2p["P2P"]
transport_select -->|"同机无直连"| shm["SHM"]
transport_select -->|"跨机"| net["NET"]
transport_select -->|"CollNet 可用"| collnet["CollNet"]
p2p --> kernel_launch["启动 GPU kernel"]
shm --> kernel_launch
net --> kernel_launch
collnet --> kernel_launch
kernel_launch --> execute["执行通信"]
execute --> complete["完成,更新 asyncResult"]完全なライフサイクル
段階一:API 呼び出し
ユーザーがncclAllReduceを呼び出し、送信バッファ、受信バッファ、要素数、データ型、リダクション操作、通信ドメイン、CUDA stream を渡す。
段階二:タスク作成
NCCL がncclTaskColl構造体(📎 src/include/comm.h:212-273)を作成し、func(AllReduce)、sendbuff、recvbuff、count、datatype、opHostなどのフィールドを埋める。
段階三:アルゴリズムとプロトコルの選択
Tuning モジュールがメッセージサイズ、トポロジ構造、ハードウェア能力に基づいてアルゴリズム(Ring/Tree/NVLS)とプロトコル(LL/LL128/Simple)を選択する。選択結果はncclTaskCollのalgorithmおよびprotocolフィールドに書き込まれる(📎 src/include/comm.h:227-227)。
段階四:チャネル割り当て
アルゴリズムとプロトコルに基づいて、使用するチャネル数とチャネル範囲を決定する。nChannels、channelLo、channelHiフィールドが設定される(📎 src/include/comm.h:254-257)。
段階五:トランスポート層の選択
トポロジ図に基づいて、各 rank ペアに対してトランスポート層(P2P/SHM/NET/CollNet)を選択する。接続情報はchannels[i].peers[j]に格納される。
段階六:Kernel 起動
NCCL がncclKernelPlan(📎 src/include/comm.h:357-410)を構築し、ワークキュー、クリーンアップキュー、タスクキューなどを含む。その後 GPU kernel を起動する。
フェーズ七:通信の実行
GPU kernel はワーク FIFO を読み取り、データ転送とリダクション操作を実行する。Proxy スレッドは非同期でネットワーク I/O を進める。
フェーズ八:完了
すべてのチャネルが完了すると、asyncResultがncclSuccessに設定される。ユーザーはncclCommGetAsyncErrorで状態を照会できる。
設計上の考察
なぜ五つの抽象が必要なのか?
これら五つの抽象は、それぞれ異なる次元の問題を解決する:
ncclComm:「誰と誰が通信するか」という問題を解決する。channel:「どのように並列化するか」という問題を解決する。algorithm:「どのトポロジを使うか」という問題を解決する。protocol:「どの戦略を使うか」という問題を解決する。transport:「どの物理リンクを通るか」という問題を解決する。
これらは直交的に組み合わさることで、NCCL があらゆるハードウェア構成とメッセージサイズに適応できるようにし、組み合わせごとに専用のコードを書く必要をなくしている。
組み合わせの柔軟性
五つの抽象の組み合わせ数は:
- アルゴリズム:5 種(Tree/Ring/CollNet/NVLS/PAT)
- プロトコル:3 種(LL/LL128/Simple)
- トランスポート層:4 種(P2P/SHM/NET/CollNet)
本章の考察とセルフチェック
Q1: もし📎 src/include/comm.h:731-731のintraPad1[64 - sizeof(uint64_t)]をintraPad1[0](つまりキャッシュラインのパディングを除去)に変更した場合、マルチプロセス環境でどのような性能問題が発生するか?なぜか?
参考解析:
パディングを除去すると、intraBarrierPhase、intraBarrierCounter、intraBarrierGateの三つのフィールドがメモリ上で密接に配置され、同じキャッシュライン(通常 64 バイト)を共有する可能性が高い。
マルチプロセス環境では、各プロセスが独自のncclCommコピーを持つが、intraComm0が指す leader 通信ドメインのintraBarrierCounterとintraBarrierGateはすべてのプロセスによって読み書きされる。プロセス A がncclCommIntraBarrierInを呼び出してintraBarrierCounter(📎 src/include/comm.h:943-959)を更新すると、プロセス B のintraBarrierGateキャッシュラインが無効化される。プロセス B はncclCommIntraBarrierOut内でintraBarrierGate(📎 src/include/comm.h:962-977)をポーリングし、キャッシュが無効化されるたびにメモリから再ロードするため、レイテンシがナノ秒レベルからマイクロ秒レベルに上昇する。
これが偽共有(False Sharing)問題である。56 バイトのパディングにより各フィールドが独占的にキャッシュラインを占めることが保証され、偽共有が排除される。
Q2: もし📎 src/include/collectives.h:106-108のincRefCountをmemory_order_relaxedからmemory_order_seq_cstに変更した場合、どのような影響があるか?なぜ著者はrelaxed?
参考解析:
memory_order_seq_cstはグローバルな順序一貫性を強制し、参照カウントを増やすたびにメモリバリアを挿入する必要があり、性能が低下する。
incRefCountは原子性のみを保証すればよく、他のメモリ操作を同期する必要がない。参照カウントの増加はオブジェクトの破棄を引き起こさず、他のスレッドの書き込み操作に依存することもないためである。memory_order_relaxedはまさにこの要件を満たす——原子性のみを保証し、バリアを挿入しない。
対照的に、decRefCount(📎 src/include/collectives.h:109-111)はmemory_order_releaseを使用する。参照カウントの減少はオブジェクトの破棄を引き起こす可能性があり、以前の書き込み操作が他のスレッドに可視であることを保証する必要があるためである。
これは C++ メモリモデルの古典的な応用である:操作のセマンティクスに基づいて最も弱いメモリオーダーを選択し、正確性を保証しつつ性能を最大化する。
Q3: もし📎 src/include/channel.h:32-32のreverseBits(base, log2Up(comm->p2pnChannels))を直接base % comm->p2pnChannelsを返すように変更した場合、どのようなシナリオで性能が低下するか?なぜか?
参考解析:
reverseBitsはビット反転操作であり、チャネル割り当てを分散させるために使用される。直接剰余を取ると、チャネル割り当てに規則性が生じる:round 0 はチャネル 0、round 1 はチャネル 1、...、round N はチャネル N%p2pnChannels を使用する。
マルチノード環境で、複数の rank の P2P 通信が同時に行われる場合、規則的なチャネル割り当てはホットスポットの集中を引き起こす——一部のチャネルが複数の rank に同時に使用され、他のチャネルはアイドル状態になる。これによりリンクの輻輳が発生し、全体的な帯域幅利用率が低下する。
reverseBitsはチャネル割り当てを分散させ、異なる round が一見ランダムなチャネルを使用するようにし、負荷を均等に分散する。これは負荷分散の古典的な手法である。
また、reverseBitsは純粋なビット操作であり、剰余演算より高速である(剰余は除算命令が必要だが、ビット操作は数命令で済む)。
---
次章ではncclCommInitRankの内部実装を深く掘り下げ、NCCL が空のncclComm構造体から始めて、どのようにトポロジグラフを構築し、チャネルを初期化し、トランスポート接続を確立し、最終的に使用可能な通信ドメインを構築するかを見る。本章で確立した五つの抽象のメンタルモデルは、次章で一つずつ具体化される。
これら五つの抽象は孤立して存在するわけではない:通信ドメインはコンテナであり、チャネルは並列実行の単位であり、アルゴリズムはデータをどのようにリダクションするかを決定し、プロトコルはデータをどのようにエンコードするかを規定し、トランスポート層はデータをどのように移動させるかを担当する。それらの組み合わせ——5 つの次元、各次元に 3 から 4 の選択肢——が NCCL 性能チューニングの探索空間を構成する。では、この通信ドメインオブジェクトは一体どのようにゼロから構築されるのか?次章では ncclCommInitRank の呼び出しチェーンを深く掘り下げ、NCCL が初期化段階でデバイス検出、トポロジ発見、チャネル割り当てをどのように完了するかを見て、comm->rank、comm->nRanks、comm->channels などの重要なフィールドの代入タイミングを明らかにする。
第 3 章:第 3 章:初期化の入口:ncclCommInitRank が孤立したプロセスの群れをどのように通信ドメインに構築するか
第 3 章:初期化の入口:ncclCommInitRank が孤立したプロセスの群れをどのように通信ドメインに構築するか
前章では、本書全体を貫く5つの中核抽象——ncclComm、channel、algorithm、protocol、transport——を確立しました。これらは共に「1回の通信 = 複数のchannel × 1つのalgorithm × 1つのprotocol × 複数のtransport」という共通語彙を構成しています。ここで、より根本的な問いに答える必要があります。このncclCommオブジェクトは一体どのようにして無から構築されるのか?ncclCommInitRankを呼び出すと、NCCLは数百ミリ秒以内に一連の複雑な操作を完了する必要があります。すべてのrankの到着確認、デバイス情報の交換、マシントポロジの検出、データパスの計算、GPUメモリとホストメモリの割り当て、そして最終的にこれらすべてを1つのncclCommオブジェクトにパッケージ化します。本章では、この呼び出しチェーンに沿って、APIエントリからinitTransportsRankの最後の毛細血管まで掘り下げていきます。
3.1 APIエントリ:ncclCommInitRankの同期シェルと非同期カーネル
直感的モデル
ncclCommInitRank表面的には「通信ドメインを作成する」ですが、実際には「バックグラウンドタスクを起動し、その後(デフォルトでは)完了を待つ」という処理を行っています。これはレストランで注文するようなものです。注文という動作(API呼び出し)は瞬時に戻りますが、厨房での調理(実際の初期化)はバックグラウンドで行われます。デフォルトの「ブロッキングモード」はカウンターの前で料理ができるまで待つだけであり、「非ブロッキングモード」では受け取り番号を受け取り、その間に別のことをすることができます。
この非同期設計がなければ、NCCLは初期化中にCUDA Graphキャプチャや複数通信ドメインの並列初期化などのシナリオと連携できません——すべての初期化が直列化され、ユーザーコードと重複できないブロッキング操作になってしまいます。
データ構造とメモリレイアウト
まずAPIエントリ自体を見てみましょう。ncclCommInitRankは極めて薄い同期シェルです:
📎 src/init.cc:2946-2970
これは4つのことを行います:呼び出しncclInitEnv()環境変数プラグインのロード、NVTXパフォーマンスマーカーの有効化、現在のCUDAデバイス番号の読み取り、そして呼び出しncclGroupStartInternal()groupセマンティクスに入り、最後に実際の作業を委譲しますncclCommInitRankDev。
注意ncclGroupStartInternal() / ncclGroupEndInternal()このペアの呼び出し——たとえ1つの通信ドメインだけを初期化する場合でも、NCCLはそれをgroupセマンティクスで包みます。これは「ユーザーが1つのgroup内で複数の通信ドメインを初期化する」シナリオを統一的に処理し、単一通信ドメインと複数通信ドメインで2つのコードパスを書くことを避けるためです。
実際のパラメータ検証とオブジェクト割り当てはncclCommInitRankDevにあります:
📎 src/init.cc:2851-2943
この関数はチェーン全体の「総合ディスパッチ台」です。まずパラメータ検証(nId範囲、nranks/myrank正当性)を行い、次にncclComm構造体自体、および中止メカニズムに関連する3つのフィールドを割り当てます:abortFlag(ホスト側アトミックフラグ)、abortFlagDev(デバイス側から見える固定メモリコピー)、abortFlagRefCount(参照カウント。splitで生成された子通信ドメインが親通信ドメインのabortFlagを共有する可能性があるため)。
ここで注目すべき詳細があります——comm->startMagic = comm->endMagic = NCCL_MAGIC:
📎 src/init.cc:2886-2886
このペアのmagic値は「封印」のようにncclComm構造体の先頭と末尾を挟んでいます。範囲外書き込みや構造体の破損はこのペアのmagicを破壊し、後続の操作でそれらを検証することでメモリ踏みつけを検出できます。これは安価ですが効果的なメモリ整合性保護です。
Step-by-Step Walkthrough
ときにncclCommInitRankDev最後まで到達すると、ncclCommInitRankAsyncJobを構築し非同期タスクを起動します:
📎 src/init.cc:2896-2929
job構造体は初期化に必要なすべてのパラメータを保持します。注意job->commIdはコピーされたものであり、ユーザーが渡したcommId:
📎 src/init.cc:2903-2910
を直接参照するのではありません。なぜコピーするのか?ソースコードのコメントが答えを与えています:ncclUniqueIdとncclBootstrapHandleのアライメント要件が異なり、ユーザーが渡した配列がncclBootstrapHandleに必要な境界に正しくアライメントされていない可能性があります。新しく割り当てられたメモリにコピーすることでアライメントを保証できます。これは典型的な「ABI互換性の罠」です——ユーザーが見るのはncclUniqueIdですが、内部的にはncclBootstrapHandleとして扱う必要があり、両者は同じサイズですがアライメントが異なります。
最後に、ncclParamEnqueueRearchEnable()の値に応じて、タスクは管理キューに入るか、ncclAsyncLaunchを通じて直接起動されます:
📎 src/init.cc:2922-2929
ncclAsyncLaunchは新しいスレッドを作成してncclCommInitRankFuncを実行します。ブロッキングモード(デフォルト)の場合、呼び出し元はncclGroupEndInternal()でこのスレッドの完了を待ちます。非ブロッキングモードの場合、呼び出し元は即座に戻り、ユーザーは後でncclCommGetAsyncErrorでステータスをポーリングします。
設計上の考察
ここでの設計の核心は「同期API + 非同期実装」です。なぜncclCommInitRankにすべての初期化を直接同期的に実行させないのか?NCCLはncclCommInitRankConfigの非ブロッキングモードをサポートする必要があり、非ブロッキングモードでは初期化がバックグラウンドスレッドで実行される必要があるからです。同期パスと非同期パスが2つのコードであれば、メンテナンスコストが倍増します。非同期に統一し、同期パスは「起動後すぐに待機」するだけなので、コードは1つだけです。
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:rank間の最初の制御チャネル
直感的モデル
BootstrapはNCCLの「会議前のWeChatグループ」です。正式な通信が始まる前に、すべてのrankはまず制御チャネルを確立し、「私は誰で、どのマシンにいて、私のGPUは何モデルで、私のNICアドレスは何か」といったメタデータを交換する必要があります。bootstrapがなければ、rank間は互いに見知らぬ他人であり、いかなる通信も調整できません。
bootstrapが失敗またはタイムアウトすると、通信ドメインの初期化全体がスタックします——これは本番環境で最も一般的なNCCLハングの原因の1つです。
データ構造とメモリレイアウト
Bootstrapの核心状態はbootstrapState構造体に保存されます:
📎 src/bootstrap.cc:527-546
この構造体には、詳しく見る価値のある重要なフィールドがいくつかあります:
ring:共用体であり、ネットワークデバイスハンドル(net.sendComm/net.recvComm)か、ソケットのペア(socket.send/socket.recv)のいずれかです。これは2つのbootstrapモードに対応します:ソケットベースのデフォルトモードと、ネットワークデバイスベースのNCCL_OOB_NET_ENABLEモードです。listen:リスナー側の情報で、同様にネットワークとソケットの2つの形態があります。peerP2pAddresses/peerProxyAddresses:すべてのrankのP2Pアドレスとproxyアドレスの配列で、ring allgatherによって埋められます。unexpectedConnections:リンクリストで、「受信したがまだマッチングされていない」接続をキャッシュします。これはbootstrapプロトコルの重要な設計です——受信側は誰が先に接続してくるかを予測できないため、マッチングされていない接続をまず保存しておく必要があります。asyncSendQueue+asyncSendLock+asyncSendCond:非同期送信キューとその同期プリミティブで、TLS暗号化モードでの並行送信に使用されます。
bootstrapStateの割り当てはbootstrapInitの冒頭で行われます:
📎 src/bootstrap.cc:769-776
comm->bootstrap = stateの行に注目してください——bootstrap状態が通信ドメインに紐付けられ、以降のすべてのbootstrap操作はcomm->bootstrapを通じてアクセスされます。
Step-by-Step Walkthrough
bootstrapInitはbootstrapの主要関数です。実行順に分解してみましょう:
ステップ1:magic値の決定。magicはbootstrap通信の「合言葉」であり、同じmagicを持つrankだけが互いに接続できます。
📎 src/bootstrap.cc:778-788
通常の初期化(handles != NULL)の場合、magicは最初のhandleから取得されます。split/grow(parent != NULL)の場合、magicはhashCombine(parent->magic, parent->childCount)を通じて派生されます。これにより、各サブ通信ドメインが一意のmagicを持つことが保証されます。
ステップ2:リスニングソケットの作成。各rankには2つのリスニングエンドポイントが必要です:1つはring隣接接続用(STATE_LISTEN(state, socket))、もう1つはroot接続用(listenSockRoot):
📎 src/bootstrap.cc:797-831
ここに重要な役割分担があります:ringリスニングソケットはcomm->magicを使用し、rootリスニングソケットはBOOTSTRAP_HANDLE(handles, curr_root)->magicを使用します。なぜでしょうか?rootはグローバルコーディネーターであり、すべてのrankがそれに接続するため、統一されたmagicを使用します。一方、ring隣接はポイントツーポイントであるため、通信ドメイン自身のmagicで十分です。
ステップ3:接続の時間分散。rank数が多い場合、すべてのrankが同時にrootに接続すると接続ストームが発生します。NCCLはNCCL_UID_STAGGER_RATEとNCCL_UID_STAGGER_THRESHOLDを使用して時間分散を制御します:
📎 src/bootstrap.cc:833-843
あるrootが担当するrank数が閾値(デフォルト256)を超える場合、各rankはroot配下での自身のローカルIDに基づいて遅延マイクロ秒数を計算し、sleepします。これはシンプルですが効果的な「トークンバケット」方式のレート制限です。
ステップ4:rootへの自身の接続情報の送信。各rankは自身のリスニングアドレスをrootに送信します:
📎 src/bootstrap.cc:845-867
rootはすべてのrankの情報を受け取ると、「リングペアリング」を行います——rank iのアドレスをrank i-1に送り、rank i+1のアドレスをrank iに送ります。これにより、各rankは自身のring上の前後の隣接ノードを知ることができます。
ステップ5:ring接続の確立。各rankは自身の「次」の隣接ノードに接続し、同時に「前」の隣接ノードからの接続を受け入れます:
📎 src/bootstrap.cc:885-894
ここでsocketRingConnectは内部的にbootstrapConcurrentを使用しています——TLS暗号化モードでは、connectとacceptは並行して実行する必要があります。そうしないとデッドロックします(TLSハンドシェイクは双方が同時に参加する必要があるため)。非暗号化モードでは、connectを実行してからacceptを直列に実行します。
ステップ6:すべてのアドレスのAllGather。ringが確立された後、ringAllInfoを通じてすべてのrankのP2Pアドレス、proxyアドレス、UDSアドレスを一度にallgatherします:
📎 src/bootstrap.cc:934-938
ringAllInfoは内部的にbootstrapAllGatherを呼び出し、後者はソケットモードでsocketRingAllGatherを使用します——双方向ring allgatherアルゴリズムで、N個のrankでN/2ステップしか必要としません:
📎 src/bootstrap.cc:1363-1412
この双方向アルゴリズムはbootstrap性能の重要な最適化です。従来の単方向ring allgatherはN-1ステップ必要ですが、双方向版はステップ数を半減させます。各ステップで同時に両方向にデータを送受信し、socketDoubleSendRecvで4つの操作(2送信2受信)を1回のシステムコールにまとめます。
並行制御と低レベル相互作用
Bootstrapの並行制御にはいくつかの層があります:
第1層:abortチェック。すべてのブロッキングループは定期的にabortFlagをチェックします:
📎 src/bootstrap.cc:150-159
BOOTSTRAP_N_CHECK_ABORTは10000に設定されており、10000回のループごとにabortフラグをチェックすることを意味します。この数値は性能と応答性のトレードオフです——チェックが頻繁すぎると性能に影響し、少なすぎるとabort応答が遅延します。
第2層:非同期送信キュー。TLS暗号化モードでは、bootstrapSendを同期的に実行できません(TLSハンドシェイクは受信側も参加する必要があるため)。そのためNCCLは送信操作を独立したスレッドに配置します:
📎 src/bootstrap.cc:1161-1217
ここには巧妙な順序保証メカニズムがあります。bootstrapAsyncSendMainは送信前に、キュー内に「より早い、同じ(peer, tag)宛ての送信」がないかチェックします:
📎 src/bootstrap.cc:1124-1152
なぜ同じ (peer, tag) の送信順序を保証する必要があるのか?ソースコードのコメントが明確に説明している:受信側は (peer, tag) で接続をマッチングするため、同じ (peer, tag) 宛の2つのメッセージが到着順序で逆転すると、受信側はそれらを誤ってマッチングしてしまう。NVLS の初期化中には同じ peer に対して同じ tag で複数回ブロードキャストするため、この順序保証は必須である。
第三層:予期しない接続キュー。受信側は誰が先に接続してくるかを予測できないため、socketAcceptマッチしない接続をunexpectedConnections連結リストに格納する:
📎 src/bootstrap.cc:1276-1300
この設計は古典的な分散問題を解決している:複数の rank が同時にあなたへ接続を開始する可能性があるが、あなたのbootstrapRecv呼び出し順序は固定されている。マッチしない接続を直接破棄すると、送信側はタイムアウトする;ブロックして待機すると、デッドロックする可能性がある。キューに格納するのが最も安全な方法である。
本番環境の落とし穴回避ガイド
落とし穴1:bootstrap タイムアウトによる初期化のハング。ある rank がネットワーク問題で root に接続できない場合、他のすべての rank はncclSocketAcceptまたはncclSocketRecvで無限に待機する。NCCL には組み込みの bootstrap タイムアウト機構がなく、唯一の脱出経路は abortFlag である。本番環境ではNCCL_UID_STAGGER_RATEを設定して、大規模クラスタの接続ストームを緩和することを推奨する。
落とし穴2:NCCL_COMM_IDとマルチ handle の競合。ユーザーがNCCL_COMM_ID環境変数を設定すると、NCCL は強制的にnIdを 1 に下げる:
📎 src/init.cc:2912-2921
これはncclCommInitRankScalableのマルチ handle 機能がサイレントに無効化されることを意味する。scalable 初期化を使用していてNCCL_COMM_IDも設定している場合、動作は期待とは異なるものになる。
落とし穴3:TLS モードでのデッドロック。TLS 暗号化モードでは、connect と accept が並行して実行されないと、双方が TLS ハンドシェイクでスタックする。bootstrapConcurrentはまさにこの問題を解決するためのものである:
📎 src/bootstrap.cc:648-669
非暗号化モードでは直列実行(先に send、後に recv)、暗号化モードではスレッドを1つ起動して send を処理し、メインスレッドが recv を処理する。
sequenceDiagram
participant R0 as Rank 0
participant Root as Bootstrap Root
participant R1 as Rank 1
participant R2 as Rank 2
R0->>Root: sendToRoot(extInfo{rank=0, listenAddr})
R1->>Root: sendToRoot(extInfo{rank=1, listenAddr})
R2->>Root: sendToRoot(extInfo{rank=2, listenAddr})
Note over Root: 收集所有 rank 的监听地址
Root-->>R0: rootSend(rank2.addr) 下一个邻居
Root-->>R1: rootSend(rank0.addr) 下一个邻居
Root-->>R2: rootSend(rank1.addr) 下一个邻居
R0->>R1: socketRingConnect(connect to next)
R1->>R2: socketRingConnect(connect to next)
R2->>R0: socketRingConnect(connect to next)
Note over R0,R2: Ring 建立完成
R0->>R1: socketRingAllGather 双向交换
R1->>R2: socketRingAllGather 双向交换
R2->>R0: socketRingAllGather 双向交换
Note over R0,R2: 所有地址交换完成3.3 commAlloc:通信ドメインオブジェクトのメモリ骨格
直感的モデル
commAllocは通信ドメインの「スケルトン状態での引き渡し」である——構造体のメモリを割り当て、すべてのフィールドを安全なデフォルト値に初期化し、必要な CUDA オブジェクトと同期プリミティブを作成するが、トポロジ情報、チャネル設定、転送接続といった「内装仕上げ」の内容はまだ充填されていない。もしncclCommをビルに例えるなら、commAllocは基礎工事とフレームの打設であり、initTransportsRankが内装である。
もしcommAllocの初期化がなければ、後続のコードが未初期化フィールドにアクセスして予測不能な動作を引き起こす——例えばcomm->channels[c].idがランダム値だと、チャネル初期化ロジックがチャネル状態を誤判定する。
データ構造とメモリレイアウト
commAllocのシグネチャと冒頭の検証:
📎 src/init.cc:512-526
まずndevとrankの正当性を検証し、次に2つのメモリスタック(memPermanentとmemScoped)を構築し、rankとnRanksを設定する。この2つのメモリスタックは NCCL のメモリ管理基盤である——memPermanentはライフサイクルが通信ドメインと同じ割り当てに使用され、memScopedは一時割り当てに使用される。
次は CUDA デバイスの検出である:
📎 src/init.cc:528-531
cudaGetDeviceで現在のデバイス番号を取得し、ncclCudaCompCapで計算能力を取得する。ソースコードのコメントは非常に率直である:「Try to create a CUDA object right away. If there is something wrong with the device we're on, better know it early.」——デバイスの問題を早期に露呈させ、初期化の後半になって初めて発見することを避ける。
次は共有リソースの割り当てまたは継承である:
📎 src/init.cc:533-555
ここに重要な分岐がある:もしparent == NULL || !parent->shareResourcesなら、新しいncclSharedResourcesを作成する;そうでなければ親通信ドメインの共有リソースを継承し、参照カウントを増やす。ncclSharedResourcesにはデバイスストリーム、ホストストリーム、起動イベント、scratch イベントなどが含まれる——これらのリソースは split シナリオで子通信ドメインに再利用でき、重複作成を避けられる。
注意すべきはsharedRes->refCount = 1の行である——初期参照カウントは1で、split で共有するたびにインクリメントされ、最後の参照が解放されたときに初めて実際に破棄される。
次はネットワーク、RMA、GIN の初期化である:
📎 src/init.cc:547-549
これら3つのサブシステムはそれぞれネットワーク転送、リモートメモリアクセス、GPU 発起のネットワーク通信を担当する。それらの初期化順序には理由がある——ncclNetInitはncclRmaInitより先でなければならない。RMA がネットワークプラグインに依存するためである。
メモリマネージャの初期化:
📎 src/init.cc:567-576
同様に共有/新規作成の2つのパスがある。ncclMemManagerは CUDA メモリプールと登録キャッシュの管理を担当する。
チャネル初期化マーカー:
📎 src/init.cc:607-608
この行はすべてのチャネルのidを -1 に設定し、「未初期化」を表す。後続のsetupChannelがこの値をチェックして初期化が必要かどうかを判断する。
割り込みキューの構築:
📎 src/init.cc:619-632
NCCL は侵入型キュー(intrusive queue)を使用して各種タスクを管理する。これらのキューはcommAlloc段階ですべて空に構築され、後続のタスクがエンキューされるときにそのまま使用される。
CUDA メモリプールの作成:
📎 src/init.cc:636-652
デバイスがメモリプールをサポートしている場合(cudaDevAttrMemoryPoolsSupported)、pinned タイプのメモリプールを作成し、解放閾値を最大値(~uint64_t(0))に設定する。これは「決して自動解放しない」という意味である。CUDA ランタイムが NCCL の関知しないうちにメモリを回収するのを避けるためである。
Step-by-Step Walkthrough
具体的な初期化シナリオを追跡してみよう:単機8カード、各プロセス1 rank、通常の初期化。
1. commAlloc(comm, NULL, 8, rank)が呼び出され、parent == NULL。
2. 検証が通過し、comm->rank = rank,comm->nRanks = 8。
3. cudaGetDeviceが現在のデバイス番号を返し、comm->compCapが設定される。
4. 新しいncclSharedResourcesを作成し、参照カウントは1。
5. ncclNetInitネットワークプラグイン(Socket または IB の可能性あり)を初期化します。
6. ncclMemManagerInitメモリマネージャを作成します。
7. getBusIdPCI バス ID を取得し、ncclNvmlDeviceGetHandleByPciBusIdNVML ハンドルを取得します。
8. dmaBufSupportedDMA-BUF サポートを検出します。
9. 割り当てconnectSend / connectRecvビットマップ配列。
10. すべてのチャネルidを -1 に設定します。
11. すべての割り込みキューを構築します。
12. CUDA メモリプールを作成します。
設計上の考察
commAllocの中で最も興味深い設計は「早期失敗」の原則です。これは関数の冒頭でcudaGetDeviceを呼び出し、後でデバイス情報が必要になった時まで待ちません。この利点は、デバイスに問題がある場合(例えば他のプロセスに占有されている場合)、大量のメモリを割り当てた後に発見するのではなく、初期化の早い段階でエラーが露呈することです。
もう一つの設計はpreconnectNextの初期化です:
📎 src/init.cc:598-598
reinterpret_cast<struct ncclComm*>(0x1)は「次の事前接続」の状態をマークするためのセンチネル値です。このような不正なポインタ値を状態マーカーとして使う手法はシステムプログラミングでよく見られます——余分なブール型フィールドよりもメモリを節約できますが、デリファレンスしないよう注意が必要です。
3.4 initTransportsRank:トポロジ検出とチャネル割り当て
直感的モデル
initTransportsRankは初期化の「心臓」です。これは三つの大きなことを行います:二回の AllGather を通じてすべての rank のデバイス情報とトポロジ情報を交換し、その情報に基づいて ring/tree/collnet/nvls などのアルゴリズムのグラフ構造を計算し、最後にすべてのトランスポート接続を確立します。通信ドメインを都市の交通システムに例えるなら、initTransportsRankはすべての道路、立体交差、バス路線を計画するプロセスです。
このステップがなければ、NCCL はデータがどの経路を通るべきか分かりません——データを遠回りさせたり、到達可能な経路を全く見つけられない可能性があります。
データ構造とメモリレイアウト
initTransportsRankのローカル変数は非常に多いため、重要なものだけを見ていきます:
📎 src/init.cc:1163-1179
ここでcomm->graphs配列内の各グラフ構造を取り出し、エイリアスを確立します。graphs配列はアルゴリズムでインデックスされ、nvlsGraphが二回使われていることに注意してください(NVLS と NVLSTree は同じグラフ構造を共有します)。
二つの重要な一時構造体:
📎 src/init.cc:1181-1206
graphInfoは単一の rank の特定アルゴリズムに対するグラフ情報(チャネル数、帯域幅、タイプなど)を保持し、allGatherInfoは AllGather のデータ単位で、すべてのアルゴリズムのグラフ情報とトポロジ rank 情報を含みます。
Step-by-Step Walkthrough
フェーズ1:AllGather1——デバイス情報の交換。
📎 src/init.cc:1234-1239
各 rank はfillInfoを呼び出して自身のncclPeerInfoを埋め、次にbootstrapAllGatherを通じて交換します。fillInfoが埋める情報には:rank 番号、CUDA デバイス番号、NVML デバイス番号、NCCL バージョン、git hash、ホスト hash、プロセス hash、GPU UUID、バス ID、VRAM サイズ、ドライババージョンなどが含まれます。
📎 src/init.cc:888-982
注意info->hostHash = getHostHash() + commHashとinfo->pidHash = getPidHash() + commHash——host hash と pid hash の両方に commHash が追加されています。これは同じマシン上の異なる通信ドメインを区別するためです。
AllGather 完了後、各 rank はすべての peer の情報を走査し、グローバル属性を計算します:
📎 src/init.cc:1250-1303
このループは多くのことを行います:バージョンの不一致の検出、ノード数の集計、cuMemSupportの積集合の計算、複数の rank が同じ GPU を使用しているかの検出、GIN タイプマスクの積集合の計算など。注意nNodesの集計方法——異なる hostHash に遭遇するたびにインクリメントします。これは rank がノードごとに連続して配置されていることを前提としています。
フェーズ2:トポロジ検出。
📎 src/init.cc:1390-1403
この六つのステップはトポロジ検出の中核フローです:ncclTopoGetSystemはシステムデバイスを列挙してトポロジグラフを構築し、ncclTopoComputePathsは GPU から NIC へのパスを計算し、ncclTopoTrimSystemは到達不可能なデバイスを除去し、再度パスを計算し、ncclTopoSearchInitは検索状態を初期化し、最後にトポロジを出力します。
フェーズ3:グラフ計算。
📎 src/init.cc:1421-1468
順に ring、tree、collnet chain、collnet direct、nvls の五つのグラフを計算します。各グラフには異なる pattern とチャネル数の制約があります。注意treeGraph->minChannels = ringGraph->nChannels——tree のチャネル数は ring と同じに制約されています。これは異なるアルゴリズム間のチャネル整合性を保証するためです。
フェーズ4:AllGather3——グラフ情報の交換。
📎 src/init.cc:1490-1533
各 rank は自身のグラフ情報をallGather3Data[rank]に埋め、再びbootstrapAllGatherします。今回交換される情報には:各アルゴリズムの pattern/nChannels/bwIntra/bwInter/typeIntra/typeInter/crossNic、CPU アーキテクチャ、P2P チャネル数、ネットワークデバイス数、CollNet デバイス数などが含まれます。
AllGather3 完了後、各 rank はすべての peer のグラフ情報を走査し、最小値/最大値を取って整合させます:
📎 src/init.cc:1687-1703
ここでの整合戦略に注意:nChannels、sameChannels、bwIntra、bwInterは最小値を取り、typeIntra、typeInter、crossNicは最大値を取ります。なぜか?チャネル数と帯域幅は最も弱いリンクに制限され、タイプと crossNic は互換性を確保するために和集合を取る必要があるからです。
フェーズ5:トランスポート接続の確立。
📎 src/init.cc:1811-1892
ここには二つの分岐があります:runtimeConnが真の場合はチャネル setup のみを行い接続は行わず(実行時まで接続を遅延)、そうでなければ直ちにすべての接続を確立します。接続順序は:ring → tree → NVLS → PAT → NVLS tree → CollNet です。
並行制御とハードウェア相互作用
initTransportsRankには注目すべき並行/ハードウェア相互作用点がいくつかあります:
CPU アフィニティ設定:
📎 src/init.cc:1406-1412
NCCL は現在のスレッドを GPU 近くの CPU コアにバインドし、ホストメモリの割り当てがローカル NUMA ノードになるようにする。これにより NUMA 間アクセスのレイテンシが削減される。
NVLS 初期化:
📎 src/init.cc:1419-1419
ncclNvlsInitNVLink SHARP サポートを検出する。NVLS はスイッチが直接 reduce 操作を実行できるようにし、AllReduce レイテンシを大幅に削減する。
Proxy スレッド作成:
📎 src/init.cc:1780-1786
Proxy スレッドはネットワーク I/O を非同期に進める役割を担う。これはinitTransportsRank内で作成され、以降のすべてのネットワーク操作は proxy を経由して行われる。
本番環境の落とし穴ガイド
落とし穴1:ネットワークデバイス数の不一致。異なる rank のローカル NIC 数が異なる場合、NCCL はエラーを報告する:
📎 src/init.cc:1576-1596
ただしNCCL_IGNORE_NET_MISMATCH=1を設定した場合を除く。これは異種クラスタでよく見られる——8枚の NIC を持つノードもあれば、4枚しか持たないノードもある。不一致を無視すると、チャネル数が最も弱いノードに制限されるため、性能低下を引き起こす可能性がある。
落とし穴2:複数の rank が同じ GPU を共有。2つの rank の GPU UUID が同じ場合、NCCL は初期化を拒否する:
📎 src/init.cc:1291-1296
ただしNCCL_MULTI_RANK_GPU_ENABLE=1を設定した場合を除く。このチェックはユーザーの誤設定による性能問題を防ぐ。
落とし穴3:CollNet ノード数不足。CollNet を有効にするには少なくともNCCL_COLLNET_NODE_THRESHOLD個のノードが必要:
📎 src/init.cc:1720-1728
デフォルトの閾値は 2。単一ノード環境では CollNet は自動的に無効化される。
flowchart TD
start["initTransportsRank(comm, parent, timers)"]
ag1["AllGather1: fillInfo + bootstrapAllGather"]
check_ver{"版本匹配?"}
fail_ver["返回 ncclInvalidUsage"]
topo["ncclTopoGetSystem + ComputePaths + TrimSystem"]
graphs["计算 ring/tree/collnet/nvls 图"]
ag3["AllGather3: 交换图信息"]
align["对齐 nChannels/bwIntra/bwInter"]
setup["setupChannel 初始化所有通道"]
conn_ring["ncclTransportRingConnect"]
conn_tree["ncclTransportTreeConnect"]
conn_nvls["ncclNvlsSetup + ncclNvlsBufferSetup"]
conn_collnet{"collnetEnable?"}
conn_collnet_yes["ncclCollNetSetup + BufferSetup"]
devcomm["devCommSetup 映射到设备"]
barrier["bootstrapIntraNodeBarrier"]
done["初始化完成"]
start --> ag1 --> check_ver
check_ver -->|否| fail_ver
check_ver -->|是| topo --> graphs --> ag3 --> align --> setup
setup --> conn_ring --> conn_tree --> conn_nvls --> conn_collnet
conn_collnet -->|是| conn_collnet_yes --> devcomm
conn_collnet -->|否| devcomm
devcomm --> barrier --> done3.5 NCCL_PARAM:環境変数体系のコンパイル期の魔法
直感的モデル
NCCL_PARAMは NCCL の「設定スイッチ工場」である。マクロを使ってコンパイル期に関数を生成し、実行時に最初の呼び出しで環境変数を読み取って結果をキャッシュする。これは家の電灯のスイッチのようなもの——あなたがパチッと押すと(関数を呼び出す)、電気がつく(設定値が返る)。その後スイッチの状態は記憶され、毎回押し直す必要はない。
もしこの仕組みがなければ、NCCL は設定を使用する各箇所で手動でgetenvを呼び出して文字列を解析する必要があり、コードは極めて冗長でエラーを起こしやすくなる。
データ構造とメモリレイアウト
NCCL_PARAMマクロの定義:
📎 src/include/param.h:22-31
このマクロは展開されると関数ncclParam##name()を生成し、内部に3つの静的変数を持つ:
uninitialized = INT64_MIN:センチネル値で、「まだ初期化されていない」ことを示す。noCache:三態フラグで、-1 は未初期化、0 はキャッシュ、1 は非キャッシュを表す。cache:キャッシュされた値で、初期値はuninitialized。
関数のロジックは:もしcacheがまだuninitializedなら、ncclLoadParamを呼び出してロードする。そうでなければ直接cache。COMPILER_EXPECT(..., false)を返す。
ncclLoadParamはコンパイラにこの分岐がほとんど通らないことを伝え、ホットパスを最適化する。
📎 src/misc/param.cc:78-108
の実装:noCacheミューテックスロックでロードプロセス全体を保護し、まず
Step-by-Step Walkthrough
ポリシーを確認し、次にキャッシュが有効かどうかを確認し、その後環境変数を読み取って解析する。解析に失敗した場合はデフォルト値を使用し、警告を出力する。NCCL_PARAM(BuffSize, "BUFFSIZE", -2)を例にとると:
📎 src/init.cc:1007-1007
マクロ展開後は以下を生成する:
int64_t ncclParamBuffSize() {
constexpr int64_t uninitialized = INT64_MIN;
static int8_t noCache = -1;
static_assert(-2 != uninitialized, "...");
static int64_t cache = uninitialized;
if (COMPILER_EXPECT(COMPILER_ATOMIC_LOAD(&cache, std::memory_order_relaxed) == uninitialized, false)) {
return ncclLoadParam("NCCL_BUFFSIZE", -2, uninitialized, &cache, &noCache);
}
return cache;
}最初の呼び出し時、cache == uninitialized、ncclLoadParamに入る。これはNCCL_BUFFSIZE環境変数を読み取り、設定されていなければデフォルト値 -2 を返す。その後noCacheポリシーに基づいてキャッシュするかどうかを決定する。
noCacheポリシーはncclParamIsCacheDisabledによって決定される:
📎 src/misc/param.cc:74-76
環境変数名があるパターンに一致する場合(例えば_で終わる)、キャッシュせず毎回読み直す。これによりユーザーは実行時に一部の設定を動的に変更できる。
設計上の考察
この設計の妙は「ゼロコスト抽象化」にある:ホットパス上には1回のアトミックロードと比較しかなく、ロックも文字列解析もない。コールドパス(初回ロード)でのみ完全なコストを支払う。COMPILER_EXPECTはコンパイラにホットパスを命令キャッシュの前方に配置するよう促し、性能をさらに向上させる。
もう一つの設計はnoCacheの三態設計である。-1 は「まだ決定していない」、0 は「キャッシュ」、1 は「非キャッシュ」を表す。この決定は初回ロード時に一度だけ行われ、その後は変わらない。
本番環境の落とし穴ガイド
落とし穴1:環境変数のスペルミス。もしユーザーがNCCL_BUFSIZEではなくNCCL_BUFFSIZEと書いた場合、NCCL はエラーを報告せず、デフォルト値を使用するだけである。NCCL_DEBUG=ENVを使って認識されたすべての環境変数を確認することを推奨する。
落とし穴2:NCCL_CONF_FILEのロード順序。NCCL は順に$NCCL_CONF_FILE(または~/.nccl.conf)と/etc/nccl.conf:
📎 src/misc/param.cc:52-67
をロードする。後にロードされたファイルが先にロードされたものを上書きする。両方のファイルが同じ変数を設定している場合、/etc/nccl.confの値が有効になる。
落とし穴3:noCache変数のスレッドセーフティ。ソースコードのコメントには「noCache is only load/stored within the mutex, no need for atomic」とある:
📎 src/misc/param.cc:74-76
これはnoCacheの読み書きがミューテックスロックで保護されており、アトミック操作が不要であることを意味する。しかしcacheの読み取りはロックフリー(ホットパス)であるため、アトミックロードを使用する。
3.6 devCommSetup:通信ドメインをデバイスにマッピングする
直感的モデル
devCommSetupは通信ドメインの「デバイス側投影」である。GPU kernel はデバイス上で動作し、ホストメモリ内のncclComm構造体に直接アクセスできない。そのため NCCL は通信ドメインの重要フィールドをデバイスからアクセス可能なメモリにコピーし、ncclDevCommを形成する。これは会社の連絡先をコピーして各従業員の席に置くようなもの——従業員は毎回受付に同僚の電話番号を聞きに行く必要がない。
もしdevCommSetupがなければ、GPU kernel は自分の rank、チャネル設定、バッファサイズなどの情報を知ることができず、集合通信 kernel はそもそも起動できない。
データ構造とメモリレイアウト
devCommSetupは一時構造体ncclKernelCommAndChannelsを使ってデバイスにコピーするデータをパッケージする:
📎 src/init.cc:712-746
この構造体はncclDevComm(デバイス側通信ドメイン)とチャネル配列を含む。関数はまずホスト側のデータを一時構造体に埋め、その後一度にcudaMemcpyAsyncでデバイスへ転送する。
重要フィールドの埋め込み:
📎 src/init.cc:734-746
注意comm->devComm = &devCommAndChans->comm——ホスト側のcomm->devCommはデバイスメモリ内のncclDevCommを指す。以降の kernel 起動時にcomm->devCommが引数として渡される。
チャネル情報の充填:
📎 src/init.cc:829-843
各チャネルの peers、ring、tree、collnetChain、collnetDirect、nvls ポインタがデバイス側にコピーされる。注意ring.userRanksには追加で一度のcudaMemcpyAsyncが必要である。なぜならそれは配列だからである。
Step-by-Step Walkthrough
1. デバイスストリームの取得:ncclStrongStreamAcquire強ストリーム(strong stream)を取得し、後続の非同期コピーが順序通りに実行されることを保証する。
2. デバイスメモリの割り当て:ncclCudaCallocAsyncを割り当てるdevCommAndChans。
3. ホスト側の一時構造体の充填:rank、nRanks、node、nNodes、abortFlag、buffSizes などを設定する。
4.rankToLocalRank配列の割り当てとコピー。
5.workFifoBytesの計算:CC(Confidential Computing)状態に基づいて決定する。
6. workFifo バッファの割り当て:GDR モードではncclGdrCudaCallocを使用し、それ以外ではncclCudaHostCalloc。
を使用する
7. profiler カウンタの割り当て。
8. 進捗カウンタの割り当て(有効な場合)。
9. チャネル情報の充填。ncclCudaMemcpyAsync(devCommAndChans, &tmpCommAndChans, 1, deviceStream)。
10. デバイスへの一括コピー:
11. 強ストリームの解放と同期。
devCommSetup設計上の考察cudaMemcpyの中で最も注目すべき設計は「バッチコピー」である。NCCL は各フィールドごとに個別にcudaMemcpyAsyncを呼び出すのではなく、すべてのフィールドを一時構造体にまとめ、一度の
で完了させる。これにより CUDA API 呼び出し回数と同期オーバーヘッドが大幅に削減される。workFifoBytesもう一つの設計は
📎 src/init.cc:750-763
の CC 処理である:workFifoBytesCC(Confidential Computing)モードでは、
は 0 に設定される。なぜなら GDR コピーは CC モードでは利用できないからである。これはハードウェア制限に対するエレガントなデグレードである。
本番環境の落とし穴ガイドdevCommSetup落とし穴1:は barrier の前に呼び出す必要がある。
📎 src/init.cc:1950-1952
ソースコードのコメントがその理由を説明している:
barrier の後に呼び出すと、一部のスレッドがすでに NCCL カーネルの起動を開始している可能性があり、その時点でデバイスメモリの割り当てが完了していないため、デッドロックが発生する。workFifoBytes落とし穴2:は 2 の冪でなければならない。
📎 src/init.cc:757-762
そうでない場合、NCCL は警告を出してデフォルト値を使用する:
本章の考察とセルフチェック📎 src/init.cc:1291-1296Q1: もし
における「複数の rank が同一 GPU を使用している」ことを検出するロジックを削除した場合、どのようなシナリオで問題が発生するか?なぜ NCCL はデフォルトでこのような構成を拒否するのか?:
参考解析NCCL_MULTI_RANK_GPU_ENABLE=0このコードは同一ホスト上の2つの rank の GPU UUID が同じかどうかを検出する。もし同じであり、かつncclInvalidUsage。
(デフォルト)であれば、
1. を返す。このチェックを削除すると、複数の rank が同じ GPU を共有することになる。これにより以下が発生する:P2P 転送の衝突
2. :NCCL の P2P 転送は各 rank が1つの GPU を独占することを前提としている。2つの rank が GPU を共有すると、それらは同時に同じ GPU の同じバッファにデータを書き込み、データ競合と結果の誤りを引き起こす。:comm->channelsチャネル割り当ての衝突
3. 内のチャネルリソース(バッファ、FIFO)は rank ごとに割り当てられる。GPU を共有する rank は同じリソースを奪い合うことになる。パフォーマンスの破綻
:たとえ正確性の問題がなくても、2つの rank が1つの GPU の演算能力とメモリ帯域幅を共有すると、パフォーマンスは急激に低下する。NCCL_MULTI_RANK_GPU_ENABLE=1NCCL がデフォルトでこの構成を拒否するのは「早期失敗」のためである——ユーザーが誤った構成で何時間もデバッグに浪費するよりも、初期化時に明確にエラーを報告する方がよい。
は、自分が何をしているか明確に理解しているユーザー(例えば MPS シナリオ)のための脱出経路として用意されている。📎 src/bootstrap.cc:1129-1134Q2: もし
における「同一 (peer, tag) のより早い送信」を待つロジックを削除した場合、どのようなシナリオで受信側のマッチングエラーが発生するか?:
参考解析
このコードは非同期送信スレッドにおいて、キュー内に同一 (peer, tag) 宛てのより早い送信がなくなるまで待機する。socketAcceptこの待機を削除すると、同一 (peer, tag) 宛ての2つの送信が並行して実行される可能性があり、受信側に到達する順序が不定になる。受信側の
📎 src/bootstrap.cc:1291-1292
は (peer, tag) で接続をマッチングする:bootstrapSendもし送信者 A が先に
を呼び出したが後に到達し、送信者 B が後に呼び出したが先に到達した場合、受信側は B のメッセージを A の応答として扱う。これによりデータの齟齬が生じる——受信側は最初のリクエストの応答を受け取ったと思い込むが、実際には2番目のリクエストのものである。
ソースコードのコメントはこのシナリオを明確に指摘している:「NVLS setup broadcasts to the same peers with the same tag several times during init」。NVLS の初期化中に同一 peer へ同一 tag で複数回ブロードキャストが行われ、順序が逆転すると NVLS 設定が完全に混乱する。
この順序保証のコストは:同一 (peer, tag) の送信が直列化されることである。しかし異なる (peer, tag) の送信は依然として並行であるため、全体のスループットには影響しない。📎 src/init.cc:1691-1697Q3: もし
におけるアライメント戦略を「nChannels は min、typeIntra は max」から「すべて min」または「すべて max」に変更した場合、それぞれどのような問題が発生するか?:
参考解析nChannels、sameChannels、bwIntra、bwInter現在の戦略は:typeIntra、typeInter、crossNicは min を取り、
は max を取る。:typeIntraもしすべて min を取るとtypeIntermin を取ると、一部の rank の転送タイプが降格される可能性があります。例えば rank A が P2P(typeIntra=P2P)をサポートし、rank B が SHM(typeIntra=SHM)のみをサポートする場合、min を取るとすべての rank が SHM を使用します。しかし SHM の列挙値が P2P より小さい可能性があり、min を取ると誤ったタイプが選択されます。実際にはtypeIntraはビットマスクまたは列挙型であり、max を取るのは「最も能力の高い」タイプを選択するためです。
すべて max を取る場合:nChannelsmax を取ると、一部の rank にその能力を超えるチャネル数が割り当てられる可能性があります。例えば rank A が 4 チャネルしかサポートできず、rank B が 8 をサポートする場合、max を取るとすべての rank が 8 チャネルを使おうとし、rank A は失敗するか性能が低下します。bwIntramax を取ると帯域幅の見積もりが過度に楽観的になり、tuning モジュールが不適切なアルゴリズムを選択する可能性があります。
このアライメント戦略の本質は:リソース制約は交差集合(min)、能力列挙は和集合(max)を取る。チャネル数と帯域幅は「上限」制約であり、最も保守的な値を取る必要があります。転送タイプは「能力」の列挙であり、最大値を取ることですべての rank が互換性のある転送方式を見つけられることを保証します。
次章ではトポロジー探索とグラフ検索を深掘りし、NCCL がマシン内の GPU、NIC、PCI スイッチをどのように列挙し、完全なトポロジーグラフを構築し、そのグラフ上で最適な ring と tree 構造を探索するかを見ていきます。本章で確立した bootstrap 通信、commAlloc メモリ骨格、initTransportsRank の主要フローは、次章でそのトポロジーの詳細を一つずつ展開します。
ここまでで、ncclCommInitRank の呼び出しチェーンを完全に辿り、ncclComm オブジェクトがゼロから構築される全過程を明らかにしました。しかし初期化プロセスには、ざっと流しただけの重要な环节があります:NCCL はマシン内部の GPU と NIC をどのように検出し、それに基づいてデータがどの経路を通るべきかを決定するのか?これこそが次章で深掘りするテーマ——トポロジー探索とグラフ検索です。src/graph/topo.cc が PCI/NVLink/NIC デバイスをどのように列挙しトポロジーグラフを構築するか、src/graph/search.cc がそのグラフ上で最適経路をどのように探索するか、そして src/graph/rings.cc と trees.cc が検索結果を Ring と Tree アルゴリズムトポロジーとしてどのように具体化するかを分解します。この仕組みを理解すれば、NCCL が異なるマシンで自動的に適切なアルゴリズムを選択できる理由がわかるでしょう。
第 4 章:第 4 章:トポロジー探索とグラフ検索:NCCL がマルチ GPU システムの物理相互接続をどのように「見る」か
第 4 章:トポロジー探索とグラフ検索:NCCL がマルチ GPU システムの物理相互接続をどのように「見る」か
前章では ncclCommInitRank の呼び出しチェーンを層ごとに掘り下げ、comm->topo フィールドがいつ埋められるかを見ましたが、その内部構造は展開しませんでした。では、NCCL は一体どのようにマシン内の GPU と NIC を「見て」、それらを利用可能なトポロジー情報として組織するのでしょうか?本章ではこのプロセスの 3 つの重要な环节を分解します:topo.cc は物理デバイスをグラフとして列挙し、search.cc はそのグラフ上で最適経路を探索し、rings.cc と trees.cc は検索結果を Ring と Tree の 2 種類のアルゴリズムトポロジーとして具体化します。この 3 者の連携を理解してこそ、NCCL が異なるマシンで自動的に適切なアルゴリズムを選択できる理由がわかります。
トポロジーグラフ:マシンを一枚の「地下鉄路線図」として描く
直感モデル
あなたが未知の都市に来たばかりの配達員だと想像してください。荷物を A 地点から B 地点へ届ける必要がありますが、どの道が最速かわかりません。あなたには地図が必要です——そこにはすべての駅(GPU、NIC、CPU、PCI スイッチ)と駅間の接続(NVLink、PCIe、ネットワーク)が記されています。NCCL のトポロジーグラフがこの地図です。
もしこの地図がなければ、NCCL は「すべての GPU 間の帯域幅は同じ」と盲目的に仮定するしかなく、8 枚の NVLink 全相互接続マシンではまだ何とかなるかもしれませんが、NUMA をまたぐ場合、PCI スイッチをまたぐ場合、NVLink + PCIe の混在する複雑なトポロジーに遭遇すると、誤った経路を選択し、本来 NVLink を通るべきデータを低速な PCIe に押し込んで、性能が半減してしまいます。
データ構造とメモリレイアウト
トポロジーグラフの核心はncclTopoSystemであり、ノードタイプごとにグループ化してすべてのデバイスを格納します。ノードタイプはtopoNodeTypeStr配列で定義されています:
📎 src/graph/topo.cc:33-35
const char* topoNodeTypeStr[] = {"GPU", "PCI", "NVS", "CPU", "NIC", "NET", "GIN", "RMA", "DEV", "CXB"};
const char* topoLinkTypeStr[] = {"LOC", "NVL", "", "C2C", "PCI", "", "", "", "", "SYS", "NET"};
const char* topoPathTypeStr[] = {"LOC", "NVL", "NVB", "C2C", "PIX", "PXB", "P2C", "PXN", "PHB", "SYS", "NET", "DIS"};これら 3 つの配列はそれぞれノードタイプ、リンクタイプ、パスタイプの文字列表現を定義しています。注意すべきはtopoPathTypeStrの順序——これは同時にパス品質のソートとしても機能します:インデックスが小さいほどパスが速い。LOC(ローカル)が最速、DIS(切断)が最遅。この順序は後続の検索でパスの優劣を比較するために繰り返し使われます。
各ノードはncclTopoNodeで表され、作成時にタイプに応じて異なるフィールドが初期化されます。GPU ノードを例に取ります:
📎 src/graph/topo.cc:105-141
ncclResult_t ncclTopoCreateNode(struct ncclTopoSystem* system, struct ncclTopoNode** node, int type, uint64_t id) {
if (system->nodes[type].count == NCCL_TOPO_MAX_NODES) {
WARN("Error : tried to create too many nodes of type %d", type);
return ncclInternalError;
}
struct ncclTopoNode* n = system->nodes[type].nodes + system->nodes[type].count;
system->nodes[type].count++;
n->type = type;
n->id = id;
if (type == GPU) {
n->gpu.dev = NCCL_TOPO_UNDEF;
n->gpu.rank = NCCL_TOPO_UNDEF;
n->gpu.cudaCompCap = NCCL_TOPO_UNDEF;
n->gpu.mloPart = NCCL_TOPO_UNDEF;
} else if (type == CPU) {
...ここにはいくつかの重要な設計ポイントがあります。第一に、ノードは事前に割り当てられた配列(system->nodes[type].nodes)に格納され、リンクリストではありません。これは、ノードがメモリ内で連続して配置され、走査時にキャッシュフレンドリーであることを意味します。第二に、NCCL_TOPO_MAX_NODESはハード上限であり、超えるとエラーになります——これはトポロジ異常時に無限に増加するのを防ぐためです。第三に、各ノードにはidフィールドがあり、これは 64 ビット整数で、上位 32 ビットは systemId(どのホストかを識別)、下位 32 ビットは localId(ホスト内のデバイス番号)です。
ノード間の接続はncclTopoLinkで表されます。ncclTopoConnectNodesは双方向接続の確立を担当します:
📎 src/graph/topo.cc:179-204
ncclResult_t ncclTopoConnectNodes(struct ncclTopoNode* node, struct ncclTopoNode* remNode, int type, float bw) {
// Aggregate links into higher bw for NVLink
struct ncclTopoLink* link;
for (link = node->links; link - node->links != NCCL_TOPO_MAX_LINKS && link->remNode; link++) {
if (link->remNode == remNode && link->type == type) break;
}
if (link - node->links == NCCL_TOPO_MAX_LINKS) {
WARN("Error : too many Topo links (max %d)", NCCL_TOPO_MAX_LINKS);
return ncclInternalError;
}
if (link->remNode == NULL) node->nlinks++;
link->type = type;
link->remNode = remNode;
link->bw += bw;
// Sort links in BW descending order
struct ncclTopoLink linkSave;
memcpy(&linkSave, link, sizeof(struct ncclTopoLink));
while (link != node->links) {
if ((link - 1)->bw >= linkSave.bw) break;
memcpy(link, link - 1, sizeof(struct ncclTopoLink));
link--;
}
memcpy(link, &linkSave, sizeof(struct ncclTopoLink));
return ncclSuccess;
}この関数は 3 つのことを行います。第一に、同じターゲット、同じタイプへのリンクが既に存在するかを検索し——存在する場合、帯域幅を累加します(link->bw += bw)。これは複数の NVLink が同じ GPU に接続されている場合を処理します:4 本の NVLink が各 25 GB/s で、集約すると 100 GB/s になります。第二に、見つからなければ新しいリンクを追加します。第三に、挿入後に帯域幅の降順で並べ替え、その後の走査時に高帯域幅リンクが優先的に見られるようにします。
帯域幅を降順に並べる設計の動機は、探索アルゴリズムが高帯域幅パスを早期に発見し、より速く優れた解に収束できるようにすることです。探索にはタイムアウト制限があり(後でNCCL_SEARCH_TIMEOUTを見ます)、ソートにより限られた時間予算をより有望なパスに費やすことができます。
シナリオ駆動のステップバイステップウォークスルー
ここで具体的なシナリオを想定します:8 枚の A100 を搭載したサーバーで、各カードは NVLink で全相互接続され、さらに 4 枚の Mellanox ConnectX-6 ネットワークカードが PCIe スロットに挿されています。NCCL の初期化時に、ncclTopoGetSystemが呼び出され、XML ファイル(nvidia-topologydまたは NCCL 自身によって生成される)からデバイス情報を読み取り、トポロジグラフを構築します。
第一步、CPU ノードを解析します。ncclTopoAddCpuは XML から CPU のアーキテクチャ、ベンダー、モデルを読み取り、CPU ノードを作成します:
📎 src/graph/topo.cc:806-875
ncclResult_t ncclTopoAddCpu(struct ncclXmlNode* xmlCpu, struct ncclTopoSystem* system) {
int numaId;
NCCLCHECK(xmlGetAttrInt(xmlCpu, "numaid", &numaId));
int systemId;
NCCLCHECK(ncclGetSystemId(system, xmlCpu, &systemId));
struct ncclTopoNode* cpu;
NCCLCHECK(ncclTopoCreateNode(system, &cpu, CPU, NCCL_TOPO_ID(systemId, numaId)));
...
for (int s = 0; s < xmlCpu->nSubs; s++) {
struct ncclXmlNode* node = xmlCpu->subs[s];
if (strcmp(node->name, "pci") == 0) NCCLCHECK(ncclTopoAddPci(node, system, cpu, systemId, numaId));
if (strcmp(node->name, "nic") == 0) {
...
}
}
return ncclSuccess;
}CPU ノードはトポロジツリーのルートです。各 CPU の下には PCI サブツリーと NIC ノードがぶら下がります。ncclTopoAddPciは PCI ツリーを再帰的に処理し、GPU に遭遇すると GPU ノードを作成し、NIC に遭遇すると NIC ノードを作成します。
第二步、NVLink 接続を追加します。注意:ncclTopoAddGpuは GPU の基本属性のみを読み取り、コメントには明確に "Do not go any further, nvlinks will be added in a second pass" と書かれています:
📎 src/graph/topo.cc:590-598
ncclResult_t ncclTopoAddGpu(struct ncclXmlNode* xmlGpu, struct ncclTopoSystem* system, struct ncclTopoNode* gpu) {
NCCLCHECK(xmlGetAttrInt(xmlGpu, "rank", &gpu->gpu.rank));
NCCLCHECK(xmlGetAttrInt(xmlGpu, "sm", &gpu->gpu.cudaCompCap));
NCCLCHECK(xmlGetAttrInt(xmlGpu, "dev", &gpu->gpu.dev));
NCCLCHECK(xmlGetAttrInt(xmlGpu, "gdr", &gpu->gpu.gdrSupport));
NCCLCHECK(xmlGetAttrIntDefault(xmlGpu, "mlopart", &gpu->gpu.mlopart, NCCL_TOPO_UNDEF));
// Do not go any further, nvlinks will be added in a second pass
return ncclSuccess;
}なぜ 2 パスに分けるのか?NVLink は GPU 間の接続であり、両端の GPU ノードが既に存在しないとリンクを確立できないからです。最初のパスですべてのノードを作成し、2 番目のパスでncclTopoAddNvLinksがそれらを接続します。
第三步、ネットワークデバイスを処理します。ncclTopoAddNicは NIC 下の net/gin/rma 子ノードを走査し、それぞれ対応する追加関数を呼び出します。ncclTopoAddNetを例に取ります:
📎 src/graph/topo.cc:461-503
static ncclResult_t ncclTopoAddNet(struct ncclXmlNode* xmlNet, struct ncclXmlNode* parent,
struct ncclTopoSystem* system, struct ncclTopoNode* nic, int systemId) {
int dev;
NCCLCHECK(xmlGetAttrInt(xmlNet, "dev", &dev));
int64_t netId = NCCL_TOPO_ID(systemId, dev);
struct ncclTopoNode* net;
NCCLCHECK(ncclTopoCreateNode(system, &net, NET, netId));
net->net.dev = dev;
int mbps;
NCCLCHECKNOWARN(xmlGetAttrIntDefault(xmlNet, "speed", &mbps, 0), NCCL_GRAPH);
if (mbps <= 0) mbps = 10000; // Some NICs define speed = -1
net->net.bw = mbps / 8000.0;
...
NCCLCHECK(ncclTopoConnectNodes(nic, net, LINK_NET, net->net.bw));
NCCLCHECK(ncclTopoConnectNodes(net, nic, LINK_NET, net->net.bw));
return ncclSuccess;
}注意:mbps / 8000.0この変換:mbps はメガビット毎秒で、8000 で割ると GB/s になります(1 GB/s = 8000 Mbps のため)。ネットワークカードが speed = -1 を報告する場合(一部の仮想ネットワークカードがそうします)、デフォルトで 10000 Mbps = 1.25 GB/s とします。
第四步、仕上げ処理。ncclTopoGetSystemFromXmlはすべてのノードとリンクの追加が完了した後、いくつかのクリーンアップ作業も行います:
📎 src/graph/topo.cc:1080-1088
NCCLCHECK(ncclTopoAddNvLinks(topNode, *topoSystem, NULL, 0));
NCCLCHECK(ncclTopoAddC2c(topNode, *topoSystem, NULL, 0));
NCCLCHECK(ncclTopoAddPciLinks(topNode, *topoSystem, NULL, 0));
NCCLCHECK(ncclTopoFlattenBcmSwitches(*topoSystem));
NCCLCHECK(ncclTopoConnectCpus(*topoSystem));
NCCLCHECK(ncclTopoSortSystem(*topoSystem));ncclTopoFlattenBcmSwitchesは Broadcom Gen4 PCIe スイッチの特殊なケースを処理します——それらは自身を 2 層スイッチとして提示しますが、実際には全帯域幅であり、探索アルゴリズムが誤導されないように「平坦化」する必要があります。ncclTopoConnectCpusはすべての CPU ノードを相互接続します(NUMA を跨ぐアクセスは SYS リンクを通ります)。ncclTopoSortSystemはリンクをソートし、PCI ダウンストリームリンクを前に配置して走査を容易にします。
設計上の考察と本番環境での落とし穴
なぜ XML を中間フォーマットとして使うのか?トポロジ検出はプロセス間で共有する必要があるためです——各 rank は自分が管理する GPU のみを検出し、その後 bootstrap を通じて XML を交換し、最後に完全なトポロジに融合します。XML は自己記述的なテキストフォーマットで、デバッグが容易(ダンプして見ることができる)で、バージョン互換性もあります。
落とし穴 1:ncclTopoGetNodeノードが見つからない場合にエラーを報告しません。この関数を見てください:
📎 src/graph/topo.cc:95-103
ncclResult_t ncclTopoGetNode(struct ncclTopoSystem* system, struct ncclTopoNode** node, int type, uint64_t id) {
for (int i = 0; i < system->nodes[type].count; i++) {
if (system->nodes[type].nodes[i].id == id) {
*node = system->nodes[type].nodes + i;
return ncclSuccess;
}
}
return ncclSuccess;
}見つからなければ、ncclSuccessを返しますが、*nodeは変更されません(呼び出し側は通常 NULL で初期化します)。呼び出し側は自分で*node == NULLをチェックする必要があります。この設計は見落としやすく——呼び出し側がチェックを忘れると、その後のデリファレンスでクラッシュします。
落とし穴 2:ncclTopoConnectNodesの帯域幅累加がオーバーフローを引き起こす可能性があります。同じノードペア間に大量のリンクがある場合(例えば NVSwitch シナリオ)、link->bw += bwは非常に大きな値に累加される可能性があります。float の精度は十分ですが、リンク数が異常に多い場合、ソートロジックに問題が生じる可能性があります。
落とし穴 3:ncclTopoRemoveNodeのポインタ修正。ノードを削除する際、削除されたノードを指すすべてのリンクを削除する必要があり、削除されたノードより後のノードを指すポインタは前に移動する必要があります:
📎 src/graph/topo.cc:143-177
ncclResult_t ncclTopoRemoveNode(struct ncclTopoSystem* system, int type, int index) {
struct ncclTopoNode* delNode = system->nodes[type].nodes + index;
for (int t = 0; t < NCCL_TOPO_NODE_TYPES; t++) {
if (delNode->paths[t] != nullptr) {
WARN("Cannot remove topology node %d/%lx while paths are computed", type, delNode->id);
return ncclInternalError;
}
for (int n = 0; n < system->nodes[t].count; n++) {
struct ncclTopoNode* node = system->nodes[t].nodes + n;
if (node == delNode) continue;
for (int l = 0; l < node->nlinks; l++) {
while (l < node->nlinks && node->links[l].remNode == delNode) {
memmove(node->links + l, node->links + l + 1, (node->nlinks - l - 1) * sizeof(struct ncclTopoLink));
node->nlinks--;
}
if (l < node->nlinks && node->links[l].remNode->type == type && node->links[l].remNode >= delNode) {
node->links[l].remNode--;
}
}
}
}
...ここに微妙な点があります:node->links[l].remNode--はポインタを修正しています。ノードは連続配列に格納されているため、1 つのノードを削除すると、後続のノードのアドレスはすべて 1 つ分前に移動します(sizeof(struct ncclTopoNode))。したがって、削除されたノードより後のノードを指すすべてのポインタを 1 減らす必要があります。この操作はmemmove以前に実行され、順序が非常に重要です。
パス探索:グラフ上で「最適なルート」を見つける
直感モデル
地図があっても、ナビゲーションアルゴリズムが必要です。NCCLのパス探索は2層に分かれています:第1層は前処理で、すべてのノードペア間の最短パスを計算します(BFS)。第2層はグラフ探索で、前処理結果に対して異なるRing/Tree構造を試し、帯域幅が最も高いものを見つけます。
パス探索がなければ、NCCLは「GPU 0がGPU 1に接続し、GPU 1がGPU 2に接続し...」という固定順序をハードコードするしかなく、非均一トポロジでは低速なパスを選んでしまいます。
データ構造とメモリレイアウト
パス探索の核心的なデータ構造はncclTopoLinkListであり、あるソースノードからあるターゲットノードへの完全なパスを格納します:
struct ncclTopoLinkList {
struct ncclTopoLink* list[NCCL_TOPO_MAX_HOPS]; // 路径上的链路
int count; // 跳数
float bw; // 瓶颈带宽
int type; // 路径类型(PATH_LOC, PATH_NVL, ...)
int capacity; // list 数组的容量
};各ノードにはpaths[type]配列があり、そのタイプのすべてのノードへのパスを格納します。例えばGPUノードのpaths[NET]はすべてのNICへのパスを格納します。
パス計算はncclTopoSetPathsによって行われ、これはBFSです:
📎 src/graph/paths.cc:52-147
static ncclResult_t ncclTopoSetPaths(struct ncclTopoNode* baseNode, struct ncclTopoSystem* system) {
if (baseNode->paths[baseNode->type] == NULL) {
NCCLCHECK(ncclCalloc(baseNode->paths + baseNode->type, system->nodes[baseNode->type].count));
for (int i = 0; i < system->nodes[baseNode->type].count; i++) baseNode->paths[baseNode->type][i].type = PATH_DIS;
}
// breadth-first search to set all paths to that node in the system
struct ncclTopoNodeList nodeList;
struct ncclTopoNodeList nextNodeList = {{0}, 0};
nodeList.count = 1;
nodeList.list[0] = baseNode;
...
while (nodeList.count) {
nextNodeList.count = 0;
for (int n = 0; n < nodeList.count; n++) {
struct ncclTopoNode* node = nodeList.list[n];
struct ncclTopoLinkList* path;
NCCLCHECK(getPath(system, node, baseNode->type, baseNode->id, &path));
for (int l = 0; l < node->nlinks; l++) {
struct ncclTopoLink* link = node->links + l;
struct ncclTopoNode* remNode = link->remNode;
...
float bw = std::min(path->bw, link->bw);
...
// Update if better path type, OR same type with higher bw, OR same type/bw with strickly fewer hops.
if (newType < remPath->type || (newType == remPath->type && remPath->bw < bw) ||
(newType == remPath->type && remPath->bw == bw && remPath->count > (path->count + 1))) {
...
remPath->bw = bw;
remPath->type = newType;
...
}
}
}
memcpy(&nodeList, &nextNodeList, sizeof(nodeList));
}
return ncclSuccess;
}BFSはbaseNodeから出発し、層ごとに拡張します。新しいノードに到達するたびに、パスのボトルネック帯域幅(std::min(path->bw, link->bw))とパスタイプを計算します。パスタイプの計算にはいくつかの特別なルールがあります:
- 2つのPCIスイッチを経由する場合、タイプは
PATH_PXB - にアップグレードされます
PATH_PHB - CPUを経由する場合、タイプは
PATH_NVB
DEVノードを経由しNVLinkの場合、タイプは
更新条件は「より優れたパス」です:タイプがより良い、またはタイプが同じで帯域幅がより高い、またはタイプと帯域幅が同じでホップ数がより少ない。
シナリオ駆動のステップバイステップウォークスルーncclTopoCompute次に第2層の探索を見ます。ncclTopoSearchRecがエントリポイントで、異なるパラメータの組み合わせを試し、
を呼び出して探索を行います。ncclTopoSearchRecGpu探索の核心は再帰関数
📎 src/graph/search.cc:639-756
ncclResult_t ncclTopoSearchRecGpu(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
struct ncclTopoGraph* saveGraph, struct ncclTopoNode* gpu, int step, int backToNet,
int backToFirstRank, int forcedOrder, int* time) {
if ((*time) <= 0) return ncclSuccess;
(*time)--;
...
if (step == ngpus) {
// Determine whether we found a better solution or not
int copy = 0;
graph->nChannels++;
NCCLCHECKGOTO(ncclTopoCompareGraphs(system, graph, saveGraph, ©), ret, exit);
if (copy) {
memcpy(saveGraph, graph, sizeof(struct ncclTopoGraph));
if (graph->nChannels == graph->maxChannels) *time = -1;
}
if (graph->nChannels < graph->maxChannels) {
NCCLCHECKGOTO(ncclTopoSearchRec(system, graph, saveGraph, time), ret, exit);
}
graph->nChannels--;
ret = ncclSuccess;
goto exit;
}
graph->intra[graph->nChannels * ngpus + step] = gpu->gpu.rank;
g = gpu - system->nodes[GPU].nodes;
if (step == backToNet) {
// first get back to NIC
...
} else if (graph->pattern == NCCL_TOPO_PATTERN_NVLS) {
...
} else if (step < system->nodes[GPU].count - 1) {
// Go to next GPU
...
} else if (step == backToFirstRank) {
// Find first GPU and loop back to it
...
} else {
// Next path
NCCLCHECKGOTO(ncclTopoSearchRecGpu(system, graph, saveGraph, gpu, ngpus, -1, -1, forcedOrder, time), ret, exit);
}
...
}コピー
1. step == ngpusこの関数にはいくつかの重要な分岐があります:nChannels:すべてのGPUを巡り終え、完全なパスが形成されました。この時ncclTopoSearchRecをインクリメントし、現在のグラフと保存された最適グラフを比較し、より良ければ保存します。その後、再帰的に
2. step == backToNetを呼び出して次のchannelの探索を試みます。
3. step < ngpus - 1:NICに戻る必要があります。これはRingモード(最後のGPUが開始NICに接続し戻る)またはTreeモード(最初のGPUがNICに接続する)で発生します。ncclTopoSearchNextGpuSort:次のGPUへ進みます。ここで
4. step == backToFirstRankを呼び出して候補GPUをソートします。
5. else:Ringモードでは、最後のGPUが最初のGPUに接続し戻ります。
ncclTopoSearchNextGpuSort:パスが終了し、次のラウンドへ進みます。
📎 src/graph/search.cc:254-327
ncclResult_t ncclTopoSearchNextGpuSort(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
struct ncclTopoNode* gpu, int* next, int* countPtr, int sortNet) {
const uint64_t flag = 1ULL << (graph->nChannels);
int ngpus = system->nodes[GPU].count;
struct ncclTopoLinkList* paths = gpu->paths[GPU];
...
for (int i = 1; i < ngpus; i++) {
int g = (start + i) % ngpus;
if (paths[g].count == 0) continue; // There is no path to that GPU
if (system->nodes[GPU].nodes[g].used & flag) continue;
scores[count].g = g;
scores[count].startIndex = i;
scores[count].intraNhops = paths[g].count;
scores[count].intraBw = paths[g].bw;
if (netPaths) {
scores[count].interNhops = netPaths[g].count;
scores[count].interPciBw = gpuPciBw(system->nodes[GPU].nodes + g);
scores[count].interBw = netPaths[g].bw;
}
count++;
}
// Sort GPUs
qsort(scores, count, sizeof(struct ncclGpuScore), cmpScore);
...
}コピー
各候補GPUにスコアを付け、ソート規則は:まずinterBw(NICへの帯域幅)を比較し、次にinterPciBw、次にinterNhops、次にintraBw、最後にintraNhopsを比較します。この優先順位はNCCLの最適化目標を反映しています:クロスマシン通信がボトルネックであるため、NICへの帯域幅が高いGPUを優先的に選びます。
設計上の考察と本番での落とし穴なぜ探索にタイムアウトがあるのか?
📎 src/graph/search.cc:329-330
#define NCCL_SEARCH_GLOBAL_TIMEOUT (1ULL << 19)
#define NCCL_SEARCH_TIMEOUT (1 << 14)
#define NCCL_SEARCH_TIMEOUT_TREE (1 << 14)
#define NCCL_SEARCH_TIMEOUT_SAMECHANNELS (1 << 8)コピーNCCL_SEARCH_TIMEOUT探索空間は指数的です——各channelにはO(ngpus!)通りの順列があります。8カードマシンで40320通り、16カードで2兆通りです。探索時間を制限する必要があります。NCCL_SEARCH_GLOBAL_TIMEOUTは16384回のイテレーション、
は524288回です。タイムアウト後は現在の最適解を返します。ncclTopoFollowPath落とし穴1:の帯域幅減算はグローバルな副作用です。
📎 src/graph/search.cc:127-173
static ncclResult_t ncclTopoFollowPath(struct ncclTopoSystem* system, struct ncclTopoGraph* graph, int type1,
int index1, int type2, int index2, float mult, struct ncclTopoNode** node) {
...
bw *= mult;
// Check there is enough bandwidth on paths.
int step = 0;
NCCLCHECK(followPath(path, node1, path->count, bw, &step));
if (step < path->count) goto rewind;
// Enough bandwidth : return destination node.
graph->nHops += mult * path->count;
*node = system->nodes[type2].nodes + index2;
return ncclSuccess;
rewind:
// Not enough bandwidth : rewind and exit.
NCCLCHECK(followPath(path, node1, step, -bw, &step));
return ncclSuccess;
}followPathコピーbwはパス上の各リンクのfollowPath(使用済み帯域幅の減算)を変更します。探索が失敗した場合、-bwを呼び出して
で復元する必要があります。この「減算-復元」パターンは再帰探索でエラーが発生しやすく、ある分岐で復元を忘れると、後続の探索が誤った帯域幅を見てしまいます。ncclTopoCompareGraphs落とし穴2:の比較ロジックは非常に微妙です。nChannels * bwIntraまず
📎 src/graph/search.cc:446-477
ncclResult_t ncclTopoCompareGraphs(struct ncclTopoSystem* system, struct ncclTopoGraph* graph,
struct ncclTopoGraph* refGraph, int* copy) {
// 1. Try to get the same nChannels between Rings and Trees
if (graph->nChannels < graph->minChannels) return ncclSuccess;
const bool evenReference = refGraph->nChannels > 0 && !(refGraph->nChannels & 1);
const bool evenReferenceIsBetter = refGraph->nChannels * refGraph->bwIntra >= graph->nChannels * graph->bwIntra;
// Favor an even number of channels when aggregate bandwidth is equal or better.
if (graph->pattern != NCCL_TOPO_PATTERN_NVLS && evenReference && (graph->nChannels & 1) &&
graph->nChannels < system->nodes[NET].count && evenReferenceIsBetter)
return ncclSuccess;
...〔設計推論とアーキテクチャのトレードオフ〕
なぜ偶数channelを好むのか? Ringアルゴリズムは偶数channelでより良くペアリングできるからです——各channelを半分に分け、半分は時計回り、半分は反時計回りにして、ネットワーク輻輳を減らします。
RingとTree:探索結果をアルゴリズムトポロジに変換する
直感モデル
探索アルゴリズムが見つけるのはパスの集合ですが、アルゴリズムが必要とするのは明確な「誰が誰に送るか」の順序です。Ringはすべてのrankを1つの環に繋ぎ、各rankは前から受け取り次へ送ります。Treeは木であり、データは根から下へ流れるか、葉から上へ集約されます。
これら2つのモジュールがなければ、探索アルゴリズムは単にパスの束を見つけるだけで、GPUカーネルに具体的にどうデータを送るかを伝えられません。
データ構造とメモリレイアウトncclBuildRingsRingの構築は
📎 src/graph/rings.cc:29-74
ncclResult_t ncclBuildRings(int nrings, int* rings, int rank, int nranks, int* prev, int* next) {
ncclResult_t ret = ncclSuccess;
uint64_t* rankFound;
int rankFoundSize = DIVUP(nranks, 64);
NCCLCHECK(ncclCalloc(&rankFound, rankFoundSize));
for (int r = 0; r < nrings; r++) {
int current = rank;
for (int i = 0; i < nranks; i++) {
rankFound[current / 64] |= (1ULL << (current % 64));
rings[r * nranks + i] = current;
current = next[r * nranks + current];
}
...
if (current != rank) {
WARN("Error : ring %d does not loop back to start (%d != %d)", r, current, rank);
ret = ncclInternalError;
goto end;
}
// Check that all ranks are there
for (int i = 0; i < nranks; i++) {
uint64_t bits = rankFound[i / 64], mask = 1ULL << (i % 64);
// Fast check 64 ranks at a time
if (mask == 1 && bits == 0xffffffffffffffff) {
i += 63;
continue;
}
if ((bits & mask) == 0) {
WARN("Error : ring %d does not contain rank %d", r, i);
ret = ncclInternalError;
goto end;
}
}
memset(rankFound, 0, rankFoundSize * sizeof(uint64_t));
}
end:
free(rankFound);
return ret;
}コピーprev入力はnextとrings配列(各rankの前駆と後続)で、出力はnext配列(各channelの完全なrank順序)です。現在のrankから出発し、
ポインタに沿って一周し、起点に戻るか検証し、すべてのrankが訪問されたかチェックします。ncclGetBtreeTreeの構築は
📎 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;
}コピーbitこの関数はビット演算で二分木を構築します。核心的な考え方は:rankの最低非ゼロビット(rank ^ bit) | (bit << 1)を見つけ、親ノードはrank - (bit >> 1)、左子はrank + (bit >> 1)、右子は
です。コメント内のASCII図がこの構造を明確に示しています。
8枚のRingを例にとる。検索結果が各rankのnextポインタを与えると仮定する:
rank 0 -> rank 1
rank 1 -> rank 2
...
rank 7 -> rank 0ncclBuildRingsrank 0から出発し、順に1, 2, ..., 7を訪問し、最後に0に戻る。生成されたrings[0..7] = {0, 1, 2, 3, 4, 5, 6, 7}。
Treeについては、ncclGetBtree各rankの親ノードと子ノードを計算する。rank 1を例にとると:
bit= 1(最下位の非ゼロビットは第0ビット)up = (1 ^ 1) | (1 << 1) = 0 | 2 = 2up >= nranks? 2 < 8、したがってup = 2parentChildType = (1 < 2) ? 0 : 1 = 0(親ノードの最初の子)lowbit = 0、したがってdown0 = -1down1 = -1
よってrank 1の親ノードはrank 2で、子ノードはない。これはコメント内の木構造と一致する:rank 1は葉である。
設計上の考察と本番での落とし穴
なぜTreeは明示的に木を構築するのではなくビット演算を使うのか?なぜなら各rankは自分の親ノードと子ノードだけを知る必要があり、グローバルな木構造は不要だからである。ビット演算はO(1)時間でこれらの情報を計算でき、木全体を保存・同期するオーバーヘッドを避けられる。
落とし穴1:ncclBuildRingsの検証がスキップされる可能性がある。もしnext配列に環がある場合(例えばrank 0 -> rank 1 -> rank 0)、ループはnranks回の反復後に終了するが、current != rankチェックがこの問題を捕捉する。しかし環の長さがちょうどnranksの因子であり、かつすべてのrankを含まない場合、rankFoundチェックが捕捉する。
落とし穴2:ncclGetDtreeの奇数rank処理。奇数個のrankの場合、2番目の木は「ミラー」ではなく「シフト」である:
📎 src/graph/trees.cc:90-112
ncclResult_t ncclGetDtree(int nranks, int rank, int* s0, int* d0_0, int* d0_1, int* parentChildType0, int* s1,
int* d1_0, int* d1_1, int* parentChildType1) {
// First tree ... use a btree
ncclGetBtree(nranks, rank, s0, d0_0, d0_1, parentChildType0);
// Second tree ... mirror or shift
if (nranks % 2 == 1) {
// shift
int shiftrank = (rank - 1 + nranks) % nranks;
...
} else {
// mirror
int u, d0, d1;
ncclGetBtree(nranks, nranks - 1 - rank, &u, &d0, &d1, parentChildType1);
*s1 = u == -1 ? -1 : nranks - 1 - u;
...
}
return ncclSuccess;
}双二分木(Double Tree)はNCCLのTreeアルゴリズム実装である——2つの木が同時に動作し、一方が前半のデータを、もう一方が後半を担当し、帯域幅の利用率を高める。奇数rankの場合、ミラーではrankマッピングが不完全になるため、シフトに変更される。
三者連携:トポロジーからアルゴリズムへ
ここで3つのモジュールをつなげる。全体の流れは1枚の図で表せる:
flowchart TD
A["ncclTopoGetSystem()"] --> B["解析 XML,创建节点"]
B --> C["ncclTopoConnectNodes() 建立链路"]
C --> D["ncclTopoComputePaths() 计算所有路径"]
D --> E{"ncclTopoCompute() 搜索"}
E -->|"Ring 模式"| F["ncclTopoSearchRecNet()"]
E -->|"Tree 模式"| G["ncclTopoSearchRecNet()"]
F --> H["ncclTopoSearchRecGpu() 递归搜索"]
G --> H
H --> I{"找到更优解?"}
I -->|"是"| J["memcpy 保存到 saveGraph"]
I -->|"否"| K["继续尝试其他路径"]
J --> L["ncclBuildRings() 或 ncclGetDtree()"]
K --> H
L --> M["生成最终算法拓扑"]この図はトポロジー発見からアルゴリズム生成までの完全な流れを示している。注意すべきはncclTopoSearchRecGpuが再帰関数であり、タイムアウトするか最適解を見つけるまで異なるGPU順序を試し続けることである。
さらに細かい粒度のシーケンス図を見て、検索過程における各モジュールの相互作用を示す:
sequenceDiagram
participant Init as ncclTopoCompute
participant Search as ncclTopoSearchRec
participant Net as ncclTopoSearchRecNet
participant Gpu as ncclTopoSearchRecGpu
participant Follow as ncclTopoFollowPath
participant Compare as ncclTopoCompareGraphs
Init->>Search: ncclTopoSearchRec(system, tmpGraph, graph, &time)
Search->>Net: ncclTopoSearchRecNet(system, graph, saveGraph, backToNet, backToFirstRank, time)
Net->>Net: ncclTopoSelectNets() 选择候选网卡
Net->>Gpu: ncclTopoSearchTryGpu(..., NET, n, gpu)
Gpu->>Follow: ncclTopoFollowPath(system, graph, NET, n, GPU, g, 1, &gpu)
Follow-->>Gpu: 返回目标 GPU 节点
Gpu->>Gpu: 递归 ncclTopoSearchRecGpu(step+1)
Gpu->>Compare: ncclTopoCompareGraphs(system, graph, saveGraph, ©)
Compare-->>Gpu: copy=1 表示更优
Gpu->>Gpu: memcpy(saveGraph, graph)
Gpu->>Follow: ncclTopoFollowPath(..., -1, &gpu) 恢复带宽このシーケンス図は検索の核心ループを示している:NIC選択 -> GPU試行 -> 再帰検索 -> 結果比較 -> 帯域幅復元。
本章のまとめ
本章ではNCCLトポロジー認識の3つの段階を分解した:
1. トポロジー発見(topo.cc):XMLからデバイス情報を読み取り、GPU/CPU/PCI/NICノードを作成し、NVLink/PCIe/ネットワークリンクを確立して、完全なトポロジーグラフを形成する。
2. パス検索(search.cc + paths.cc):まずBFSで全ノードペア間の最短パスを事前計算し、次に再帰検索で異なるRing/Tree構造を試し、帯域幅が最高の方案を見つける。
3. アルゴリズムトポロジー生成(rings.cc + trees.cc):検索結果を具体的なrank順序に変換し、RingはncclBuildRingsで環を生成し、TreeはncclGetBtreeで二分木を生成する。
本章の考察とセルフチェック
Q1: もしncclTopoConnectNodesの帯域幅累加link->bw += bwをlink->bw = std::max(link->bw, bw)に変更した場合、どのようなシナリオで性能低下を引き起こすか?なぜか?
参考解析:帯域幅累加は複数の並列リンクの場合を扱う。4本のNVLinkが各25 GB/sの場合、累加すると100 GB/s、maxを取ると25 GB/sのみになる。ncclTopoSetPathsでは、パス帯域幅はstd::min(path->bw, link->bw)であり、リンク帯域幅が過小評価されると、パス全体の帯域幅が過小評価される。これによりncclTopoCompareGraphsが誤ったグラフを選択する——チャネル数は多いが各チャネルの帯域幅が低い方案を選び、実際の性能はむしろ悪化する可能性がある。具体的なシナリオ:8枚のA100が全NVLink相互接続で、各GPUペア間に4本のNVLinkがある。累加で100 GB/s、maxで25 GB/sとなる。検索アルゴリズムはNVLinkとPCIe Gen4 x16(約25 GB/s)の帯域幅が同じと見なし、PCIe経由のパスを選ぶ可能性がある。
Q2: ncclTopoSearchRecGpuでは(*time)--が関数入口で実行される。検索がタイムアウトすると(*time <= 0)、関数は直接戻る。この設計はどのような場合に検索が無限ループに陥るか?どう修正するか?
参考解析:(*time)--は入口でデクリメントされ、もし*timeの初期値が0または負の場合、関数は直接戻り、デクリメントされない。しかし*timeが非常に大きな正数の場合、再帰ごとにデクリメントされ、最終的に0になる。問題は:ある分岐の再帰深さが非常に大きいが、毎回のデクリメント後も*timeが依然として0より大きい場合、検索は続行される。真のリスクはncclTopoSearchRec内のgoto searchループである——もしtimeがループ内で正しくリセットされないと、無限ループになる可能性がある。ncclTopoCompute内のglobalTimeoutロジックを見ると:globalTimeout -= timeは毎回のsearchラベル位置で実行され、もしglobalTimeoutが負になるとgoto doneする。しかしもしtimeがNCCL_SEARCH_TIMEOUT,globalTimeoutにリセットされると、永遠に負にならない可能性がある。修正方法はglobalTimeoutが毎回の検索後にデクリメントされることを保証し、かつハード上限を設けることである。
Q3: ncclTopoFollowPathは検索失敗時にfollowPath(path, node1, step, -bw, &step)を呼び出して帯域幅を復元する。もしある再帰分岐が復元前に戻った場合(例えばNCCLCHECKGOTOがexitにジャンプした場合)、何が起こるか?このような問題をどう検出するか?
参考解析:もし復元がスキップされると、パス上のリンク帯域幅は差し引かれた状態のままになる。後続の検索は誤った帯域幅を見て、最適解を見逃す可能性がある。検出方法:ncclTopoCompute終了後、すべてのリンクを走査し、帯域幅が初期値と一致するか確認する。不一致が見つかった場合、復元漏れがあることを示す。修正方法:RAII スタイルのガードオブジェクトを使用し、デストラクタで帯域幅を自動復元する。あるいは、各検索前に全リンクの帯域幅スナップショットを保存し、検索後に復元する。NCCL の現在の方法は、各ncclTopoFollowPath呼び出しポイントで手動で順方向と逆方向の呼び出しをペアにするため、エラーが発生しやすい。より堅牢な設計は、帯域幅の減算と復元を1つの関数にカプセル化し、ペアで出現することを保証することである。
次の章では tuning モジュールを深く掘り下げ、NCCL がトポロジ検索結果とメッセージサイズに基づいて、Ring、Tree、CollNet などのアルゴリズム間で最終選択をどのように行うかを見る。本章で確立したトポロジグラフ、パス検索結果、アルゴリズムテンプレートは、tuning モジュールの入力となる。
topo.cc のグラフ構築、search.cc のパス検索、そして rings.cc と trees.cc のトポロジ生成を通じて、NCCL は汎用グラフ構造で任意のトポロジを記述し、設定可能な検索アルゴリズムで最適解を見つけ、シンプルなテンプレートで最終アルゴリズムを生成するという設計哲学を実現している。このメカニズムにより、NCCL は2カードのワークステーションから10000カードのクラスタまで、さまざまなマシンで自動的に適切なアルゴリズムを選択できる。しかし、トポロジグラフはアルゴリズムの候補パスを提供するだけで、特定の通信でどのパスを通り、どのプロトコルを使用するかについては、より精密な決定が必要である。次の章では src/tuning ディレクトリに焦点を当て、tuning モジュールがコストモデルとアルゴリズム推定を組み合わせて、Ring/Tree/NVLS/PAT および LL/LL128/Simple の間で最終選択をどのように行うかを見る。
第5章:第5章:アルゴリズムとプロトコルの選定:tuning モジュールが通信パスをどのように決定するか
第5章:アルゴリズムとプロトコルの選定:tuning モジュールが通信パスをどのように決定するか
前の章では、NCCL のトポロジ認識能力を分解した:src/graph/topo.cc でデバイスを列挙してトポロジグラフを構築し、src/graph/search.cc で最適パスを検索し、rings.cc と trees.cc で検索結果を Ring と Tree アルゴリズムのトポロジとして具体化する。しかし、トポロジグラフは「データがどのパスを通れるか」に答えるだけで、「今回の通信がどのパスを通るべきか」には答えていない。同じマシン上でも、4KB の AllReduce と 400MB の AllReduce では最適解が全く異なる可能性がある:前者はレイテンシを競い、後者は帯域幅を競う;前者は Tree/LL を選び、後者は Ring/Simple や NVLS を選ぶかもしれない。tuning モジュールこそがその「決定を下す者」である。その入力はメッセージサイズ、ランク数、トポロジグラフ(前章の産物)、ユーザー環境変数;出力は ncclTuningResult_t で、どのアルゴリズム(algo)、どのプロトコル(proto)、いくつのチャネルを開くか、いくつの warp を使うかが書かれている。この章では「全体スケジューリング → コストモデル → 各アルゴリズムの推定 → 最終決定」の順に、src/tuning ディレクトリを分解する。核心的な問題はただ一つ:NCCL はどのように数十種類の (アルゴリズム, プロトコル) の組み合わせの中から、純粋に CPU の数学モデルを用いて、マイクロ秒単位の時間で最速のものを選び出すのか?
一、tuning.cc:全体スケジューリングと決定の幹
直感的モデル
tuning モジュールを一家の引越し業者と想像してほしい。顧客(1回の集合通信)が来て、「100MB の荷物を8つの倉庫から8つの倉庫へ運びたい」と言う。ディスパッチャー(ncclTuningCompute)は実際に一度運んで試すのではなく、価格表(コストモデル)を取り出し、各方案(Ring/LL、Tree/Simple、NVLS/Simple……)に対して「予想所要時間」を見積もり、最も短いものを見積もりを顧客に提示する。
もしこのディスパッチャーがなければ、NCCL は「AllReduce は常に Ring を使う」とハードコードするしかなく、小メッセージのシナリオでは Tree に圧倒され、大規模 NVLink のシナリオでは NVLS に圧倒されるだろう。その代償は、特定のシナリオで性能が半減、あるいはそれ以上に悪化することである。
データ構造とメモリレイアウト
決定の担体はncclTuningResult_tであり、候補集合はncclTuningResultList_t(単方向連結リスト)である。リストノードはtuning_int.hで定義されているが、push ロジックはtuning.ccにある:
📎 src/tuning/tuning.cc:32-39
ncclResult_t ncclTuningResultListPushFront(struct ncclTuningResultList_t* list, struct ncclTuningResult_t result) {
struct ncclTuningResultListNode* node = nullptr;
NCCLCHECK(ncclCalloc(&node, 1));
node->result = result;
node->next = list->head;
list->head = node;
return ncclSuccess;
}ここで注意すべきは先頭挿入法である:有効な候補が計算されるたびに、リストの先頭に挿入される。これは、リストの順序と id の順序が逆であることを意味する。なぜ配列ではなく連結リストを使うのか? 候補数はコンパイル時にNCCL_TUNING_COUNTで決定されるが、実際に有効な候補は動的であり(tuningMask、プラットフォーム能力、ユーザー環境変数に影響される)、連結リストは「有効なものだけを繋ぐ」ことを可能にし、走査時に繰り返しvalidを判断するのを避ける。代償は、各決定でncclCalloc一度だけだが、tuning はエンキュー経路で発生し、頻度は高くないため、この程度の割り当てオーバーヘッドは許容できる。
ncclTuningResult_tで最も重要な2つのフィールドはtimeUs(推定所要時間、マイクロ秒)とselectionTimeUs(選択に使用される所要時間、tuner プラグインによって上書きされる可能性がある)。選択ロジックは後者のみを見る:
📎 src/tuning/tuning.cc:155-173
static ncclResult_t ncclTuningSelectBestTuning(struct ncclTuningResultList_t* tunings,
struct ncclTuningResult_t* const bestTuning) {
bestTuning->timeUs = FLT_MAX;
float bestSelectionTimeUs = FLT_MAX;
struct ncclTuningResultListNode* node = tunings->head;
while (node != nullptr) {
const struct ncclTuningResult_t& tuning = node->result;
float selectionTimeUs = tuning.selectionTimeUs > 0.0f ? tuning.selectionTimeUs : tuning.timeUs;
...
if (selectionTimeUs < bestSelectionTimeUs) {
*bestTuning = tuning;
bestSelectionTimeUs = selectionTimeUs;
}
node = node->next;
}
return ncclSuccess;
}ここに細かい点がある:bestTuning->timeUsは最初にFLT_MAXに設定され、その後走査する。リンクリストが空の場合(すべての候補が無効)、bestTuningはNCCL_TUNING_RESULT_INITの初期値を保持し、algo/proto はともにUNDEFとなる。この「空結果」は呼び出し側で特別に処理される——後述のエラー分岐を参照。
Step-by-Step Walkthrough:1回の AllReduce の意思決定フロー
アプリケーションがncclAllReduceを呼び出し、メッセージ 1MB、8 ランクの単一マシン NVLink と仮定する。我々はncclTuningComputeを追って進む。
第0步:単一ランクのショートカット。もしnRanks <= 1なら、通信は全く不要で、直接 Ring/Simple を返し、channel 数を 0 に設定する:
📎 src/tuning/tuning.cc:191-200
// Set tuning to Ring/Simple for single rank case
if (input->comm->nRanks <= 1) {
bestTuning.algo = NCCL_ALGO_RING;
bestTuning.proto = NCCL_PROTO_SIMPLE;
bestTuning.symKernelId = ncclSymkKernelId_Count;
bestTuning.ceMethodId = ncclCeMethodId_Count;
bestTuning.nChannels = 0;
bestTuning.maxChannels = 0;
bestTuning.nWarps = 0;
bestTuning.forced = 0;
} else {このショートカットは重要である:単一ランクの場合、どのアルゴリズム推定もnRanks-1のような量で除算され、NaN やゼロ除算が発生しやすい。まずフォールバックし、それから計算するは、防御的プログラミングの典型である。
第1步:すべての候補を列挙。はncclTuningComputeAllTuningsに入り、NCCL_TUNING_COUNT個の id を走査する:
📎 src/tuning/tuning.cc:128-149
ncclResult_t ncclTuningComputeAllTunings(struct ncclTuningInput_t* const input,
struct ncclTuningResultList_t* const tunings) {
ncclResult_t ret = ncclSuccess;
for (int i = 0; i < NCCL_TUNING_COUNT; i++) {
struct ncclTuningResult_t tuning = NCCL_TUNING_RESULT_INIT;
tuning.id = i;
tuning.valid = 1;
if (!(input->tuningMask & (1ULL << i))) {
tuning.valid = 0;
continue;
}
NCCLCHECK(ncclTuningExpandId(i, &tuning.algo, &tuning.proto, &tuning.symKernelId, &tuning.ceMethodId));
NCCLCHECKGOTO(ncclTuningComputeTuning(i, input, &tuning), ret, fail);
if (tuning.valid) NCCLCHECKGOTO(ncclTuningResultListPushFront(tunings, tuning), ret, fail);
}
...
}注意:tuningMaskは 64 ビットマスクであり、第 i ビットは「第 i 番目の (algo, proto) の組み合わせが許可されているか」を表す。このマスクは上位層でプラットフォーム能力、ユーザー環境変数、関数タイプに基づいて計算される。マスクは「粗いふるい」、コストモデルは「精密計算」——まず根本的に不可能なもの(例えば PCI マシンでは NVLS はあり得ない)を除外し、残りに対して時間を計算する。
ncclTuningExpandIdは一次元 id を (algo, proto, symKernelId, ceMethodId) に展開する。このマッピング関係はcost_model.cc内のmodelMap配列と厳密に一致していなければならない。そうでなければモデルを誤って計算する。
第2步:1つずつコストを計算。 ncclTuningComputeTuningは1行だけで、コストモデルに転送する:
📎 src/tuning/tuning.cc:339-343
ncclResult_t ncclTuningComputeTuning(int id, struct ncclTuningInput_t* const input,
struct ncclTuningResult_t* const result) {
NCCLCHECK(ncclTuningCostModelSimModel(id, input, result));
return ncclSuccess;
}第3步:tuner プラグインの介入(オプション)。ユーザーが tuner プラグイン(例えば一部のクラウドベンダーの独自チューナー)をインストールしている場合、NCCL はすべての候補のtimeUsを二次元テーブルgeneralTable[algo][proto]にまとめてプラグインに渡し、プラグインに上書きさせる:
📎 src/tuning/tuning.cc:203-230
if (input->comm->tuner != NULL) {
float generalTable[NCCL_NUM_ALGORITHMS][NCCL_NUM_PROTOCOLS];
for (int i = 0; i < NCCL_NUM_ALGORITHMS; i++) {
for (int j = 0; j < NCCL_NUM_PROTOCOLS; j++) {
generalTable[i][j] = NCCL_TUNING_IGNORE;
}
}
struct ncclTuningResultListNode* node = tunings.head;
while (node != nullptr) {
const struct ncclTuningResult_t& tuning = node->result;
node = node->next;
if (tuning.algo == NCCL_ALGO_UNDEF || tuning.proto == NCCL_PROTO_UNDEF) continue;
generalTable[tuning.algo][tuning.proto] = tuning.timeUs;
}
node = tunings.head;
int nMaxChannels = 0;
NCCLCHECKGOTO(input->comm->tuner->getCollInfo(input->comm->tunerContext, input->func, input->nBytes,
input->numPipeOps, (float**)generalTable, NCCL_NUM_ALGORITHMS,
NCCL_NUM_PROTOCOLS, input->regBuff, &nMaxChannels),
ret, exit);
while (node != nullptr) {
struct ncclTuningResult_t& tuning = node->result;
node = node->next;
if (tuning.algo == NCCL_ALGO_UNDEF || tuning.proto == NCCL_PROTO_UNDEF) continue;
tuning.maxChannels = nMaxChannels;
tuning.timeUs = generalTable[tuning.algo][tuning.proto];
}
}ここでNCCL_TUNING_IGNOREはセンチネル値であり、「この組み合わせは計算されていない/適用外」を意味する。プラグインは関心のあるセルのみを変更でき、他のセルは IGNORE のままにしておくと、NCCL はスキップする。
第4步:最適なものを選択。はncclTuningSelectBestTuningを呼び出し、リンクリストを走査してselectionTimeUsが最小のものを取る。
第5步:channel 数を計算。アルゴリズムを選択した後、いくつの channel を開くかを決定する必要がある:
📎 src/tuning/tuning.cc:233-235
if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
}ncclTuningGetChannelsはtuning_int.h内で、メッセージサイズとアルゴリズムタイプに基づいて、minChannelsとmaxChannelsの間で補間する。channel 数は帯域幅に直接影響する:channel が多いほど並列度は高いが、各 channel の起動オーバーヘッドも大きくなる。
第6步:CTA Policy バイアス(NVLS 優先)。ユーザーがNCCL_CTA_POLICY_EFFICIENCYを設定し、かつ現在が AllGather/ReduceScatter で buffer が登録済みの場合、NCCL は結果を NVLS に変更しようとする:
📎 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;
...このコードのコメントは非常に重要である:EFFICIENCY バイアスはGetChannelsの後に実行する必要がある。なぜならbestTuning.nChannelsを使用する必要があるからである;また、tuningMask内で NVLS ビットが許可されているかを確認する必要がある。そうでなければ、上位層で除外されたアルゴリズムを「復活」させてしまう。これは典型的な状態依存順序の罠。
第7步:対称 kernel のフォールバック。選択されたのが対称 kernel(symKernelId)だが、buffer が登録されていない、またはプラットフォームがサポートしていない場合、通常の kernel にフォールバックする必要がある。このロジックはtuning.cc:258-298にあり、章全体で最も複雑な部分であるため、第5節で専門に扱う。
第8步:解なしエラー。すべての候補が無効な場合、algo/proto はともに UNDEF となり、NCCL は WARN を出力し、ユーザーが環境変数を設定しているかどうかに応じて異なるエラーコードを返す:
📎 src/tuning/tuning.cc:308-329
if ((bestTuning.algo == NCCL_ALGO_UNDEF || bestTuning.proto == NCCL_PROTO_UNDEF) &&
bestTuning.symKernelId == ncclSymkKernelId_Count && bestTuning.ceMethodId == ncclCeMethodId_Count) {
...
WARN("No algorithm/protocol nor symKernelId available for function %s with datatype %s.%s%s%s",
ncclFuncToString(input->func), ncclDatatypeToString(input->datatype), ncclAlgoEnvStr, ncclProtoEnvStr,
ncclSymKernelIdEnvStr);
ret = (algoEnv || protoEnv || symKernelIdEnv) ? ncclInvalidUsage : ncclInternalError;
}なぜエラーコードを区別するのか?ユーザーがNCCL_ALGO=ringを設定したが現在のプラットフォームが ring をサポートしていない場合(例えば一部の特殊なトポロジ)、それはユーザー設定エラー(ncclInvalidUsage)である;ユーザーが環境変数を何も設定していないのにアルゴリズムを選択できない場合、それはNCCL 内部バグ(ncclInternalError)である。この区別はトラブルシューティングにとって極めて重要である。
意思決定の主干フローチャート
flowchart TD
start["ncclTuningCompute(input)"] --> check_rank{"comm->nRanks <= 1?"}
check_rank -->|是| single["bestTuning = Ring/Simple<br/>nChannels = 0"]
check_rank -->|否| enum["ncclTuningComputeAllTunings<br/>遍历 NCCL_TUNING_COUNT"]
enum --> mask{"tuningMask & (1<<i)?"}
mask -->|否| skip["tuning.valid = 0<br/>continue"]
mask -->|是| expand["ncclTuningExpandId(i)"]
expand --> sim["ncclTuningComputeTuning<br/>-> ncclTuningCostModelSimModel"]
sim --> valid{"result.valid?"}
valid -->|是| push["ncclTuningResultListPushFront"]
valid -->|否| skip
push --> tuner{"comm->tuner != NULL?"}
tuner -->|是| plugin["tuner->getCollInfo<br/>覆盖 generalTable"]
tuner -->|否| select
plugin --> select["ncclTuningSelectBestTuning<br/>取 selectionTimeUs 最小"]
select --> getch["ncclTuningGetChannels"]
getch --> cta{"CTA_POLICY_EFFICIENCY<br/>且 NVLS 在 mask 内?"}
cta -->|是| nvls["ncclNvlsRegResourcesQuery<br/>可能改写为 NVLS"]
cta -->|否| symk
nvls --> symk{"symKernelId 需要回退?"}
symk -->|是| fallback["ncclTuningCompute(generalInput)<br/>回退普通 kernel"]
symk -->|否| done
fallback --> done["*result = bestTuning"]
single --> done
done --> undef{"algo/proto 仍 UNDEF?"}
undef -->|是| warn["WARN + 返回<br/>InvalidUsage 或 InternalError"]
undef -->|否| ret_ok["返回 ncclSuccess"]---
二、cost_model.cc:モデルレジストリとスイッチマトリックス
直感モデル
cost_model.ccは tuning の総勘定元帳である。それはmodelMapテーブルを維持し、各行は1つの (algo, proto) の組み合わせに対応し、「この組み合わせの初期化関数は誰か、シミュレーション関数は誰か、どの関数に対して有効か」を記録する。同時に、ユーザー環境変数NCCL_ALGO/NCCL_PROTO/NCCL_SYM_KERNELを解析し、ユーザーの意図をenabled[i][f]スイッチマトリックスに変換する。
このテーブルがなければ、新しいアルゴリズムを追加するたびに tuning のメインフローを修正する必要があり、コードはぐちゃぐちゃになる。テーブル駆動により、「アルゴリズムの追加」が「1行の追加」になる。
データ構造:modelMap とスイッチマトリックス
modelMapは静的配列であり、各要素はncclTuningModelEntry_t:
📎 src/tuning/cost_model.cc:230-277
static struct ncclTuningModelEntry_t modelMap[] = {
{ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}}, // Tree/LL
{ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}}, // Tree/LL128
{ncclTuningTreeModelInit, ncclTuningTreeModelSim, nullptr, {0, 0, 0, 0, 1}}, // Tree/Simple
{ncclTuningRingModelInit, ncclTuningRingModelSim, nullptr, {1, 1, 1, 1, 1}}, // Ring/LL
...
{nullptr, nullptr, nullptr, {0}}, // CollNetDirect/LL, disabled as there is no implementation
...
};各 entry には4つのフィールドがある:init(初期化、latency/bandwidth を計算して comm に保存)、model(シミュレーション、メッセージサイズに基づいて最終的な timeUs を計算)、finalize(クリーンアップ)、enabled[5](Broadcast/Reduce/AllGather/ReduceScatter/AllReduce の5つの関数が有効かどうかに対して)。
注意enabled配列の順序コメントは L234 にあります:Enable order: Broadcast, Reduce, AllGather, ReduceScatter, AllReduce。この順序はncclFunc_t列挙と一致していなければなりません。そうでなければ取り違えが発生します。
なぜ init と sim を分けるのか?なぜなら init で計算するもの(latency、bandwidth)はcomm の静的な属性にのみ依存し(トポロジ、rank 数、compCap)、具体的なメッセージサイズとは無関係だからです。1回の通信で連続して複数回 tuning が呼ばれる可能性があります(例えば group 内に複数の op がある場合)が、init は1回だけ実行され、sim は毎回実行されます。これは典型的な「事前計算 + 高速クエリ」の最適化です。
Step-by-Step:環境変数の解析とスイッチマトリクスの構築
ステップ1:デフォルトは全て有効、LL128 は特殊。 ncclTuningCostModelInit最初に全ての proto を 1(有効)に設定しますが、LL128 は 2 に設定します:
📎 src/tuning/cost_model.cc:313-323
for (int f = 0; f < NCCL_NUM_FUNCTIONS; f++) {
for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
protoEnable[f * NCCL_NUM_PROTOCOLS + p] = p == NCCL_PROTO_LL128 ? 2 : 1;
}
for (int a = 0; a < NCCL_NUM_ALGORITHMS; a++) {
algoEnable[f * NCCL_NUM_ALGORITHMS + a] = 1;
}
for (int k = 0; k < ncclSymkKernelId_Count; k++) {
symKernelIdEnable[f * ncclSymkKernelId_Count + k] = 1;
}
}なぜ LL128 は 1 ではなく 2 なのか?なぜなら LL128 は「デフォルトで有効」ではなく、「条件付きで有効」だからです。2 は特殊なマーカーで、「ユーザーが明示的に要求しておらず、後でisLL128Enabledがプラットフォームの能力に基づいて決定する」ことを示します。1 は「無条件で有効」、0 は「無効」を意味します。この三態設計は L366 の判定に現れています:
📎 src/tuning/cost_model.cc:364-370
// Disable LL128 when 1) it is not supported on the platform, and 2) user did not explicitly request it.
// protoEnable[..] == 2 indicates that user did not set NCCL_PROTO=LL128 explicitly.
if (proto == NCCL_PROTO_LL128 && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] == 2 &&
!isLL128Enabled(comm->minCompCap, comm->maxCompCap, comm->graphs[algo].typeInter,
comm->graphs[algo].typeIntra, comm->nRanks, f, algo, comm->minDriverVersion)) {
comm->tuningContext.enabled[i][f] = 0;
}ステップ2:ユーザー環境変数の解析。ユーザーがNCCL_ALGOまたはNCCL_SYM_KERNELを設定した場合、まず algo と symKernel を全てクリアします(ユーザーがホワイトリストを指定したため):
📎 src/tuning/cost_model.cc:327-345
if ((algoStr && strlen(algoStr) > 0) || (symKernelIdStr && strlen(symKernelIdStr) > 0)) {
std::fill_n(algoEnable, NCCL_NUM_FUNCTIONS * NCCL_NUM_ALGORITHMS, 0);
std::fill_n(symKernelIdEnable, NCCL_NUM_FUNCTIONS * ncclSymkKernelId_Count, 0);
}
if (protoStr) {
INFO(NCCL_ENV, "NCCL_PROTO set by environment to %s", protoStr);
NCCLCHECK(parseList(protoStr, ncclFuncStr, NCCL_NUM_FUNCTIONS, ncclProtoStr, NCCL_NUM_PROTOCOLS, protoEnable,
comm->tuningContext.forced));
}proto はクリアされていないことに注意——proto のデフォルト値は 1/2 であり、ユーザーがNCCL_PROTO=LLを設定した場合、parseListは LL を 1 に、その他を 0 に設定します(unsetロジックのため)。この非対称性は意図的なものです:algo はデフォルトで全て有効ですがユーザー指定後は絞り込む必要があり、proto の絞り込みはparseListが内部で処理します。
ステップ3:parseList の構文。この関数はかなり複雑な構文をサポートしており、コメントに例が示されています:
📎 src/tuning/cost_model.cc:14-32
// Parse a map of prefixes to a list of elements. The first prefix is
// optional and, if not present, the list of elements will be applied
// to all prefixes. Only the first list of elements can lack a
// prefix. Prefixes (if present) are followed by a colon. Lists of
// elements are comma delimited. Mappings of prefix to the lists of
// elements are semi-colon delimited.
//
// For example:
//
// NCCL_ALGO="ring,collnetdirect;allreduce:tree,collnetdirect;broadcast:ring"
// Enable ring and collnetdirect for all functions, then select tree
// and collnetdirect for allreduce and ring for broadcast.^プレフィックスは「否定」を意味します:
📎 src/tuning/cost_model.cc:59-67
int unset, set;
if (elemList[0] == '^') {
unset = 1;
set = 0;
elemList++;
} else {
unset = 0;
set = 1;
}したがってNCCL_PROTO="^LL128;allreduce:LL128"の意味は:グローバルに LL128 を無効化するが、AllReduce では例外的に LL128 を有効化する、ということです。
ステップ4:enabled マトリクスのマージ。最後に全ての model を走査し、model->enabled[f]とユーザースイッチの論理積を取ります:
📎 src/tuning/cost_model.cc:371-383
// Check the user env vars only for functions that have a forced configuration and not already disabled.
if (comm->tuningContext.forced[f] == 0 || comm->tuningContext.enabled[i][f] == 0) continue;
comm->tuningContext.enabled[i][f] = 0;
...
if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
(proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
(symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
comm->tuningContext.enabled[i][f] = 1;
}ロジックは:ユーザーが特定の関数に対して forced 設定を行った場合にのみ、ユーザー設定でモデルのデフォルト値を上書きする。ユーザーが設定していない場合、forced[f] == 0、直接continue、モデル自身のenabledを保持します。これは「ユーザーの明示的指定 > モデルのデフォルト」という優先順位です。
モデルシミュレーションの統一エントリポイント
全てのモデルは最終的にncclTuningCostModelSimModelを通じて呼び出されます:
📎 src/tuning/cost_model.cc:470-497
ncclResult_t ncclTuningCostModelSimModel(int id, struct ncclTuningInput_t* const input,
struct ncclTuningResult_t* const result) {
struct ncclTuningModelEntry_t* model = nullptr;
ncclResult_t ret = ncclSuccess;
result->forced = input->comm->tuningContext.forced[input->func];
NCCLCHECKGOTO(getModelEntry(id, &model), ret, not_valid);
if (model == nullptr) {
ret = ncclInternalError;
goto not_valid;
}
if (input->comm->tuningContext.enabled[id][input->func] == 0) {
goto not_valid;
}
if (model->model != nullptr) {
NCCLCHECKGOTO(model->model(input, result), ret, not_valid);
if (result->timeUs <= 0.0) {
goto not_valid;
}
} else {
goto not_valid;
}
exit:
return ret;
not_valid:
result->timeUs = NCCL_TUNING_IGNORE;
result->valid = 0;
goto exit;
}三層フィルタ:id が範囲外 → モデル無効 → モデルが非正の時間を返す、いずれかの層を通過しなければnot_validに進み、timeUsをNCCL_TUNING_IGNORE(負のセンチネル値)に設定し、valid = 0。呼び出し側はvalid == 0を見ると候補リンクリストに追加しません。
設計上の考察
modelMapのコメントに重要な警告があります:
📎 src/tuning/cost_model.cc:229
// IMPORTANT: this table need must be consistent with the algRegistry in src/config/algorithm_registry.ccこれはmodelMapの添字順序がalgorithm_registry.cc内のアルゴリズム登録順序と厳密に一致しなければならないことを意味します。もし誰かが registry に新しいアルゴリズムを挿入してmodelMapの変更を忘れた場合、全ての id がずれ、tuning は完全に誤ったアルゴリズムを選択します。これはテーブル駆動設計の古典的な罠です:暗黙の契約。より堅牢な方法は添字ではなく列挙名を key として使うことですが、そうするとコンパイル時の最適化が少し犠牲になります。
---
三、ring.cc:Ring アルゴリズムのコスト推定
直感的モデル
Ring アルゴリズムは N 個の rank を環状に並べ、データが環に沿って一周ずつ伝わります。そのコストモデルは2つの問いに答える必要があります:各ステップでどれだけのデータを伝送するか(帯域幅)、合計で何ステップ必要か(遅延)。
Ring の直感は「パイプライン」です:N 人が円になってバケツを回すと想像してください。各人はバケツを受け取ったら少し水を入れて次の人に渡します。バケツが一周すると、全員の水が混ざり合います。バケツが回るのが速いほど(帯域幅が高い)、円が小さいほど(ステップ数が少ない)、全体が速くなります。
データ構造:latency/bandwidth テーブル
Ring モデルは新しい構造を導入せず、推定結果をcomm->tuningContext.generalLatencies[c][algo][proto]とgeneralBandwidths[c][algo][proto]に書き込みます。これらは三次元配列です:関数 × アルゴリズム × プロトコル。
初期化時にまず全てを -1.0(センチネル値、「未計算」を意味する)に設定します:
📎 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;-1.0 というセンチネル値は sim 段階でチェックされます:
📎 src/tuning/ring.cc:94-97
if (inputs->comm->tuningContext.generalBandwidths[inputs->func][tuning->algo][tuning->proto] == -1.0f) {
tuning->valid = 0;
return ncclSuccess;
}なぜ 0 ではなく -1.0 を使うのか?なぜなら 0 は合法的な帯域幅値(物理的には不可能ですが)であり、-1.0 は明確に「未初期化」を意味するからです。浮動小数点比較に==を使うことはここでは安全です。なぜなら -1.0 は正確に表現可能だからです。
Step-by-Step:Ring 帯域幅推定
ステップ1:intra と inter のどちらの帯域幅を使うか決定。単一ノード(nNodes==1)は intra、マルチノードは inter を使用:
📎 src/tuning/ring.cc:34-37
int nSteps = ncclTuningGetNsteps(c, comm->nRanks);
float bw = (comm->nNodes == 1 || (comm->nNodes <= 2 && comm->minCompCap < 100)) ? comm->graphs[algo].bwIntra :
comm->graphs[algo].bwInter;
float busBw = bw * comm->graphs[algo].nChannels;nStepsはアルゴリズムに必要なステップ数で、Ring の場合 AllReduce は2*(nRanks-1)、その他はnRanks-1。busBwは「バス帯域幅」= 単一リンク帯域幅 × channel 数。
ステップ2:プロトコルによる割引。LL プロトコルは帯域の半分しか使っていない(LL の flag オーバーヘッドのため)、LL128 は 92%(120/128)を使用する:
📎 src/tuning/ring.cc:38-42
if (proto == NCCL_PROTO_LL) {
busBw = std::min(llMaxBw, busBw * .5);
}
if (proto == NCCL_PROTO_LL128)
busBw = std::min(busBw * (0.92 /*120.0/128.0*/), comm->graphs[algo].nChannels * perChMaxRingLL128Bw);0.92 = 120/128これは LL128 では 128 バイトごとに 8 バイトが flag で、有効ペイロードは 120 バイトしかないためである。この数字はプロトコル設計から直接来ている。
ステップ 3:有効帯域を計算する。ここで掛けていることに注意nRanks / nSteps:
📎 src/tuning/ring.cc:44-46
comm->tuningContext.generalLatencies[c][algo][proto] =
comm->tuningContext.tuningConstants.baseLatencies[algo][proto];
comm->tuningContext.generalBandwidths[c][algo][proto] = busBw * comm->nRanks / nSteps;なぜ掛けるのかnRanks / nSteps?これは Ring アルゴリズムの核心的な特性である:各 rank が実際に運ぶデータ量はnBytes * nSteps / nRanks(データがリングを何周もするため)。したがって「有効帯域」= バス帯域 × nRanks / nSteps。AllReduce では nSteps = 2(nRanks-1) なので、有効帯域 ≈ busBw/2。
ステップ 4:遅延を計算する。遅延は intra と inter の二部分に分かれる:
📎 src/tuning/ring.cc:48-63
int intraHw, interHw;
ncclTuningGetHwIndexes(comm, algo, &intraHw, &interHw);
int hwLevel = comm->nNodes == 1 ? intraHw : interHw;
float intraLat = comm->tuningContext.tuningConstants.hwLatencies[intraHw][algo][proto];
// Preserve the pre-refactor model: with one rank per node, Ring inter-node steps use the exposed Tree NET latency.
float interLat;
if (comm->nNodes == 1) {
interLat = intraLat;
} else if (comm->maxLocalRanks == 1) {
interLat = comm->tuningContext.tuningConstants.hwLatencies[NCCL_HW_NET][NCCL_ALGO_TREE][proto];
} else {
interLat = comm->tuningContext.tuningConstants.hwLatencies[interHw][algo][proto];
}
interLat += comm->graphs[algo].latencyInter;
if (proto == NCCL_PROTO_SIMPLE) interLat += comm->graphs[algo].latencyInter;L57-58 の特殊処理に注意:maxLocalRanks == 1(各ノードに rank が 1 つだけ)の場合、Ring の inter-node 遅延はTree の NET 遅延を使用する。コメントには「preserve the pre-refactor model」とあり、つまりリファクタリング前の動作との一貫性を保つために意図的に残された「癖」である。このような歴史的負債は成熟したシステムではよく見られる。ソースコードを読むときに「preserve」という語を見かけたら特に注意が必要で、それは多くの場合、動かせない互換性制約があることを意味する。
ステップ 5:関数タイプごとに累積する。Reduce/Broadcast と AllReduce/AllGather/ReduceScatter では遅延モデルが異なる:
📎 src/tuning/ring.cc:65-87
if ((c == ncclFuncReduce || c == ncclFuncBroadcast)) {
float lat = comm->tuningContext.tuningConstants.hwLatencies[hwLevel][algo][proto];
if (comm->graphs[algo].sameChannels) {
comm->tuningContext.generalLatencies[c][algo][proto] += lat;
} else {
if (proto == NCCL_PROTO_SIMPLE)
lat =
comm->tuningContext.tuningConstants
.hwLatencies[hwLevel][NCCL_ALGO_TREE][proto]; // Add some chunk latency, waiting for proper chunk modeling
comm->tuningContext.generalLatencies[c][algo][proto] += nSteps * lat;
}
} else {
// Inter-node rings still have to launch nsteps * net overhead.
float netOverhead = 0.0;
if (comm->nNodes > 1) {
netOverhead = getNetOverhead(comm);
if (proto == NCCL_PROTO_SIMPLE) netOverhead *= 3;
}
intraLat = std::max(intraLat, netOverhead);
int nInterSteps = comm->nNodes == 1 ? 0 : c == ncclFuncAllReduce ? 2 * (comm->nNodes - 1) : comm->nNodes - 1;
comm->tuningContext.generalLatencies[c][algo][proto] +=
(nSteps - nInterSteps) * intraLat + nInterSteps * interLat;
}sameChannelsはトポロジ属性で、「リング上の intra と inter のステップが同じチャネル群を使うかどうか」を表す。異なる場合、遅延にnSteps(各ステップで待つ必要がある)を掛ける。netOverheadはネットワーク post オーバーヘッドで、Simple プロトコルでは 3 を掛ける(Simple には send、recv、ack の 3 回のネットワーク往復があるため)。
本番での落とし穴回避:Ring/Simple の plateau 効果
ncclTuningRingModelSimには「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
}plateau とは何か?Ring/Simple では、メッセージがある程度大きくなると、遅延はもはやメッセージに線形に増加せず、「プラトー」に張り付く——このときボトルネックが「起動オーバーヘッド」から「帯域」に変わり、帯域はすでに飽和しているためである。この現象は Blackwell NVLink で特に顕著である(NVLink の帯域が高すぎて、遅延の割合が大きくなるため)。コードではplateauFactor(1.4 または 1.9)を遅延に掛けて、この「遅延が増幅される」効果をシミュレートしている。
bytesPerRankPerChannel >= 64はトリガ条件である:各 rank の各チャネルが少なくとも 64 バイトを転送する必要があり、そうでなければ plateau は成立しない。この 64 バイトは LL プロトコルの flag サイズに由来する。
落とし穴シナリオ:Blackwell 上で 1MB の AllReduce を実行し、実際の遅延がモデル予測より 40% 高いと気づいても、バグだと思ってはいけない——これは plateau 効果であり、モデルはすでにそれを計算に含めている。もし手動でplateauFactorを小さくすると、モデルは遅延を過小評価し、アルゴリズムの選択を誤る。
---
四、tree.cc と nvls.cc:Tree と NVLS のコスト推定
直感的モデル
Tree アルゴリズムは「木構造ブロードキャスト」である:ルートノードがデータを子ノードに配り、子ノードがさらに孫ノードに配る。その利点はステップ数が少ないこと(N ではなく log N)で、小メッセージに適している;欠点は帯域利用率が低いこと(各非葉ノードが転送する必要があり、実際の有効帯域は半分しかない)。
NVLS(NVLink SHARP)は「ハードウェアマルチキャスト」である:スイッチが直接データを複数の GPU にコピーし、ソフトウェア転送を必要としない。その利点は帯域が高く、遅延が低いことだが、特定のハードウェア(Hopper 以上)と特定の設定が必要である。
Tree モデル:AllReduce のみを対象とする
Tree モデルには厳しい制限がある——AllReduce にのみ有効化される:
📎 src/tuning/tree.cc:21-27
for (int c = 0; c < NCCL_NUM_FUNCTIONS; c++) {
if (c != ncclFuncAllReduce) {
comm->tuningContext.generalLatencies[c][algo][proto] = -1.0;
comm->tuningContext.generalBandwidths[c][algo][proto] = -1.0;
enabled[c] = 0; // Hard disable
continue;
}なぜか?なぜなら NCCL の Tree 実装は AllReduce のみをサポートしているためである(他の集合操作には Tree バージョンがない)。これは実装上の制約であり、理論上の制限ではない。enabled[c] = 0は「ハード無効化」で、generalBandwidths = -1よりも徹底している——前者はncclTuningCostModelSimModelを L480 で即座に返すが、後者は sim 関数内でようやくチェックする。not_validTree 帯域推定
コピー:
📎 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;で、Ring の1/3.8よりも厳しい。0.5なぜ Tree の LL 効率はより低いのか?なぜなら Tree の各中間ノードは受信と送信の両方を行い、LL の flag オーバーヘッドが双方向トラフィックで増幅されるためである。この数字は実測から来ている。1/3.8Tree 遅延推定
コピー:
📎 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 *はノード内ステップ数(各ノード内の rank 数から 1 を引いたもの)、(nRanks/nNodes - 1)はノード間ステップ数(木の高さ)である。log2i(nNodes)Tree の補正因子
Tree 的修正因子:Tree モデルは sim 段階でtreeCorrectionFactor:
📎 src/tuning/tree.cc:75-79
int logSize = log2i(inputs->nBytes >> 6);
float bw = inputs->comm->tuningContext.generalBandwidths[inputs->func][tuning->algo][tuning->proto];
float lat = inputs->comm->tuningContext.generalLatencies[inputs->func][tuning->algo][tuning->proto];
if (inputs->func == ncclFuncAllReduce && logSize >= 0 && logSize < 23)
bw *= treeCorrectionFactor[tuning->proto][logSize];treeCorrectionFactorは 3×24 のテーブルです:
📎 src/tuning/cost_model.cc:223-227
float treeCorrectionFactor[NCCL_NUM_PROTOCOLS][24] = {
{1.0, 1.0, 1.0, 1.0, .9, .8, .7, .7, .7, .7, .6, .5, .4, .4, .5, .6, .7, .8, .9, 1.0, 1.0, 1.0, 1.0, 1.0},
{1.0, 1.0, 1.0, 1.0, 1.0, .9, .8, .8, .8, .7, .6, .6, .6, .6, .6, .6, .8, .9, .9, .9, .9, 1.0, 1.0, 1.0},
{.9, .9, .9, .9, .9, .9, .9, .8, .7, .6, .6, .5, .5, .5, .5, .6, .7, .8, .7, .7, .8, .9, .9, .9}
};logSize = log2(nBytes >> 6)、つまりメッセージサイズを 64 バイト単位で log2 を取ります。テーブルの添字 0-23 は 64B から 64B×2^23 ≈ 512MB に対応します。このテーブルは実測で得られた「Tree 効率曲線」です:小メッセージ時は効率 1.0(レイテンシ支配)、中メッセージ時は効率が 0.4-0.5 に落ち(帯域が使い切れていない)、大メッセージ時は 1.0 に戻ります(帯域を使い切る)。この「中間の窪み」は Tree アルゴリズム固有の特性です。
NVLS モデル:ハードウェアマルチキャストのコスト
NVLS モデルはまずハードウェアがサポートしているか確認します:
📎 src/tuning/nvls.cc:19-24
ncclResult_t ncclTuningNvlsModelInit(struct ncclComm* comm, int id, int enabled[NCCL_NUM_FUNCTIONS]) {
ncclResult_t ret = ncclSuccess;
if (!ncclNvlsTransportEnabled(comm)) {
memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
return ncclSuccess;
}次に一連のハード制約があります:Simple プロトコルのみサポート、単機では NVLSTree をサポートしない、マルチ機の NVLS には CollNet が必要:
📎 src/tuning/nvls.cc:28-41
if ((algo == NCCL_ALGO_NVLS || algo == NCCL_ALGO_NVLS_TREE) && (proto != NCCL_PROTO_SIMPLE)) {
memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
return ncclSuccess;
}
if (comm->nNodes == 1 && algo == NCCL_ALGO_NVLS_TREE) {
memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
return ncclSuccess;
}
if (comm->config.collnetEnable == 0 && algo == NCCL_ALGO_NVLS && comm->nNodes > 1) {
memset(enabled, 0, NCCL_NUM_FUNCTIONS * sizeof(int));
return ncclSuccess;
}NVLS 帯域推定効率因子を使用しています:
📎 src/tuning/nvls.cc:12-17
static const float nvlsEfficiency[NCCL_NUM_COMPCAPS] = {
0.0f, // Volta
0.0f, // Ampere
0.85f, // Hopper
0.74f, // Blackwell
};Hopper は 0.85、Blackwell は逆に 0.74 に下がります。なぜ新世代ハードウェアの効率が低いのか?Blackwell の NVLink 帯域はより高いですが、NVLS のスイッチ処理能力が同比率で向上していないため、相対効率が低下します。この数値は実測値であり、理論値ではありません。
帯域計算には(nChannels - 1) / nChannels因子があります:
📎 src/tuning/nvls.cc:62-74
int nSteps = ncclTuningGetNsteps(c, comm->nRanks);
float intraBw = comm->graphs[algo].bwIntra * nvlsEfficiency[compCapIndex] * (comm->graphs[algo].nChannels - 1) /
comm->graphs[algo].nChannels;
if (c == ncclFuncAllReduce) {
intraBw *= 2.0f;
} else {
float ppn = comm->minLocalRanks;
intraBw *= (ppn - 1) / ppn;
}
float interBw = comm->graphs[algo].bwInter * ((comm->nNodes <= 2 && algo == NCCL_ALGO_NVLS_TREE) ? 2 : 1);
bw = std::min({intraBw, interBw,
algo == NCCL_ALGO_NVLS_TREE ? (float)perChMaxNVLSTreeBw : std::numeric_limits<float>::max()});
bw = bw * comm->graphs[algo].nChannels;(nChannels - 1) / nChannelsは NVLS が同期用に 1 チャネルを確保する必要があるためです。(ppn - 1) / ppnは AllGather/ReduceScatter の追加オーバーヘッドです(各 rank が前の rank のデータを待つ必要があります)。
本番環境の落とし穴回避:NVLS のハード制約
NVLS モデルは sim 段階でさらにランタイムチェックがあります:
📎 src/tuning/nvls.cc:136-156
int nvlsSupport = inputs->nvlsSupport;
if (!nvlsSupport) {
tuning->valid = 0;
tuning->timeUs = -1.0;
return ret;
}
if (inputs->func != ncclFuncAllReduce && inputs->comm->graphs[tuning->algo].nChannels > NCCL_MAX_NVLS_ARITY) {
tuning->valid = 0;
tuning->timeUs = -1.0;
return ret;
}
if (inputs->func != ncclFuncAllReduce && inputs->comm->localRanks > NCCL_MAX_NVLS_ARITY) {
tuning->valid = 0;
tuning->timeUs = -1.0;
return ret;
}NCCL_MAX_NVLS_ARITYは NVLS マルチキャストグループが収容できる最大 GPU 数です。この数を超えると、NVLS は使用できません。落とし穴シナリオ:16 カードの NVLink ドメインで AllGather を実行する場合、NCCL_MAX_NVLS_ARITYが 8 であれば、NVLS は無効化され、tuning は Ring にフォールバックします。この制限を知らないと、「NVLS はハードウェアがサポートしているのになぜ使わないのか」と誤解するでしょう。
---
五、対称 kernel フォールバックとエラー回復チェーン
直感モデル
対称 kernel(symmetric kernel)は NCCL の新機能です:すべての rank のバッファが対称メモリに登録されると、kernel はより効率的な命令でピアメモリにアクセスできます。しかしバッファが登録されていない場合、またはプラットフォームがサポートしていない場合は、通常の kernel にフォールバックする必要があります。このフォールバックロジックは tuning の中で最も複雑な部分です。
Step-by-Step:フォールバック決定
フォールバックロジックはtuning.cc:258-298にあります。分解して見ていきましょう。
ステップ 1:フォールバックが必要か判断する。の入口条件:
📎 src/tuning/tuning.cc:258-263
ここまでで、tuning モジュールの決定チェーンは明確になりました:トポロジグラフと通信パラメータを受け取り、コストモデルとアルゴリズム推定を通じて、マイクロ秒レベルで最適な (アルゴリズム, プロトコル, channel, warp) の組み合わせを出力します。しかし選定は始まりに過ぎません——この決定結果は下流でどのように使用されるのか?次の章では src/enqueue/enqueue.cc の幹に入り、一度の ncclAllReduce 呼び出しがパラメータ検証、アルゴリズム/プロトコル決定、channel 分割を経て、最終的に ncclInfo と ncclTaskColl 構造を生成する様子を見ていきます。これは本書が「ユーザー視点」から「エンジン視点」に切り替わる重要な章であり、一度の集合通信呼び出しが host 側で何に翻訳されるのか、そしてそれが後続の kernel 起動との境界を明らかにします。
第 6 章:第 6 章:オペレータ発行全景:ncclAllReduce がいかにして実行可能な kernel タスクになるか
第 6 章:オペレータ発行全景:ncclAllReduce がいかにして実行可能な kernel タスクになるか
前章でtuningモジュールを完了し、NCCLがマイクロ秒レベルで1回の集合通信に対して(アルゴリズム、プロトコル、channel、warp)の組み合わせを選択することを理解しました。しかし選型結果自体は単なる数値の集まりであり、GPU kernelが理解できるタスク記述オブジェクトに「翻訳」されて初めて実際に実行できます。本章ではsrc/enqueue/enqueue.ccの主干に入り、核心的な問いに答えます:ユーザーがncclAllReduceを呼び出したとき、host側で一体何が起こるのか?ncclAllReduceからncclEnqueueCheckまで、パラメータ検証、アルゴリズム/プロトコル決定、channel分割を経て、最終的にncclInfoとncclTaskColl構造体を生成します。これは本書全体が「ユーザー視点」から「エンジン視点」へ切り替わる重要な章です。NCCLをレストランに例えるなら、enqueueモジュールは「フロントの注文システム」です:ユーザー(アプリケーション層)が「AllReduceを1つください」と言うと、フロントがそれを厨房(GPU kernel)が実行できる作業指示書に翻訳します——何番のコンロ、どの鍋を使うか、何バッチに分けるか。この翻訳層がなければ、厨房は何を作るべきか全く分かりません。
一、入口:ncclAllReduceがncclInfoをどのように構築するか
直感的モデル
ncclAllReduceはユーザーが直接呼び出すAPI関数です。その責務は極めて単一です:ユーザーが渡した生のパラメータを1つのncclInfo構造体にパッケージ化し、それをncclEnqueueCheckに渡します。これは銀行の窓口で手続きをするようなもので、窓口係がまずあなたの要件を標準的な申請書に記入し、その後バックエンドシステムに転送します。
この層がなければ、各集合通信APIが自分でパラメータ検証、groupセマンティクス、profiler埋め込みポイントを処理しなければならず——コードは保守不可能なほど重複します。
データ構造:ncclInfoのメモリレイアウト
ncclInfoはenqueueフロー全体を貫く核心的なキャリアです。その定義はsrc/include/info.h:
📎 src/include/info.h:17-44
にあります。この構造体には20以上のフィールドがあり、機能別に4つのグループに分けられます:
| フィールドグループ | フィールド | 役割 |
|---|---|---|
| 集合通信パラメータ | coll, sendbuff, recvbuff, count, datatype, op, root | 「何をするか」を記述 |
| 通信ドメインとストリーム | comm, stream | 「どこで行うか」を記述 |
| アルゴリズム詳細 | chunkSteps, sliceSteps | 「どのように分割するか」を記述 |
| 単辺操作 | peerWinOffset, peerWin, sigIdx, ctx, flags, nDesc, signalDescs | RMA専用 |
| ユーザー設定 | collConfig | ユーザーconfigからコピーされたプライベートコピー |
注意collConfigのコメント:"A config copied from config passed by user so older user config can be safely accessed during synchronous host scheduling (never at launch/replay)" 📎 src/include/info.h:41-43。これは重要な設計です——ユーザーが渡したconfigポインタはncclGroupEndの前に破棄される可能性があるため、NCCLはncclInfo内でコピーを作成します。
Step-by-Step:ncclAllReduceの呼び出しチェーン
私たちはncclAllReduceを例に、ユーザーの呼び出しからncclInfoの構築までの完全なパスを追跡します。
第1ステップ:ユーザーがncclAllReduceを呼び出す。入口はsrc/collectives.cc:
📎 src/collectives.cc:206-211
にあります。ここで3つのことを行います:
1. NVTX3_FUNC_WITH_PARAMSNVTXマーカーを打つ(Nsightなどのツールでの可視化用)
2.ncclAllReduceConfigImplを呼び出し、config = nullptr
を渡す
3. 結果を返す第2ステップ:ncclAllReduceConfigImplがncclInfoを構築。
📎 src/collectives.cc:192-202
これが重要なステップです:
struct ncclInfo info = {ncclFuncAllReduce, "AllReduce",
sendbuff, recvbuff, count, datatype, op, 0, comm, stream,
ALLREDUCE_CHUNKSTEPS, ALLREDUCE_SLICESTEPS};コピーncclInfoフィールドはALLREDUCE_CHUNKSTEPSの宣言順序に一対一で対応します。ALLREDUCE_SLICESTEPSとsrc/include/collectives.h:
📎 src/include/collectives.h:19-20
NCCL_STEPSはNCCL_STEPS/2で定義されています。NCCL_STEPS/4はリングバッファ内のステップ数(通常8または16)なので、AllReduceのchunkStepsは
、sliceStepsは ncclParseCollConfigです。これは1つのchunkが2つのsliceを含むことを意味します。ncclCollConfig_t*第3ステップ:ユーザーconfigを解析。info.collConfigユーザーが渡したconfig == nullptrを
に解析します。もしなら、このフィールドはゼロ初期化のままです。
第4ステップ:ncclEnqueueCheckに渡す。
設計上の考察:なぜフィールドごとの代入ではなく集約初期化を使うのか?〔設計推論とアーキテクチャトレードオフ〕集約初期化には2つの利点があります:1つはコンパイラがフィールド数が一致するかチェックする(1つ少ないと警告が出る)、もう1つはコードがよりコンパクトになることです。しかし欠点はncclInfoフィールド順序が構造体宣言と厳密に一致しなければならない
ことです——もし誰かが
の途中にフィールドを挿入すると、すべての集約初期化ポイントが静かにずれます。これはNCCLコード内の暗黙的な保守リスクです。
ncclCollConfig_t config = {...};
ncclAllReduceConfig(..., &config);
// config 在这里被销毁(比如是栈变量,函数返回了)実際の落とし穴シナリオ:ユーザーがこのようにコードを書く場合:ncclInfoコピーncclGroupEndもしNCCLがinfo.collConfig内でconfigをコピーしていなければ、src/include/info.h:41-43時ににアクセスすると解放済みメモリを読むことになります。。
---
のコメントはまさにこの設計を説明するためのものです——
configはtask append段階で解析・コピーされ、その後はユーザーポインタに依存しない
ncclEnqueueCheck二、ncclEnqueueCheck:パラメータ検証とgroupセマンティクス直感的モデルはenqueueモジュールの「総ゲート」です。すべての集合通信APIは最終的にここに集まります。その責務は:ncclEnqueueCheck。
パラメータの正当性検証、groupセマンティクスの処理、taskAppendの呼び出しによるタスク生成
Step-by-Step:ncclEnqueueCheck の実行フロー
📎 src/enqueue/enqueue.cc:3478-3527
段階的に分解していきます:
ステップ1:CommCheck で通信ドメインを検証する。 CommCheck(info->comm, info->opName, "comm")comm ポインタが非NULLか、初期化済みかを確認する。もし comm が revoke されている場合(例えばある rank でエラーが発生した場合)、直ちにエラーを返す:
📎 src/enqueue/enqueue.cc:3480-3485
ステップ2:profiler の深さを処理する。すでに group 内部にある場合(profilerGroupDepth > 0)、深さカウンタをインクリメントする。これは暗黙的なncclGroupStartInternal/ncclGroupEndInternal呼び出しを正しく処理するためである。
ステップ3:内部 group に入る。 ncclGroupStartInternal()は NCCL 内部の group メカニズムである。重要なポイント:ユーザーが明示的にncclGroupStartを呼び出さなくても、NCCL は各 API 呼び出しに対して暗黙的な group を作成する。これにより単一呼び出しの原子性が保証される。
ステップ4:comm が準備完了であることを確認する。 ncclCommEnsureReady(info->comm)通信ドメインの初期化完了(例えば bootstrap の完了、接続の確立)を待つ。
ステップ5:ArgsCheck によるパラメータ検証。これが最も複雑な検証ステップである:
📎 src/enqueue/enqueue.cc:3497-3503
注意checkModeの処理:もしncclCheckModeDebugGlobal,ArgsCheckの場合は info をキューに入れ、ncclGroupEnd時にグローバル検証(例えば全 rank の count が一致するかの確認)を行う。
ステップ6:taskAppend を呼び出す。これが核心的な変換ステップである:
📎 src/enqueue/enqueue.cc:3513
ステップ7:opCount をインクリメントする。キューへの追加が成功するたびに、comm->opCount++。このカウンタは send/recv 操作のマッチングに使用され、profiler のタイムラインの根拠にもなる。
ステップ8:group を退出する。 ncclGroupEndInternal()depth が 0 に下がると、実際の group 操作(スケジューリング、カーネル起動)がトリガーされる。
並行制御:group セマンティクスとスレッドセーフティ
ncclGroupStartInternal/ncclGroupEndInternalスレッドローカルストレージ(TLS)を使用して group 状態を維持する。これはつまり同一スレッド内の複数の API 呼び出しは1つの group に統合されるが、異なるスレッドの呼び出しは独立している。これが NCCL がマルチスレッド呼び出しをサポートする基盤である。
陥りやすい罠:ユーザーがncclGroupStartとncclGroupEndの間に NCCL 以外の CUDA API(例えばcudaMemcpy)を呼び出すと、stream の順序問題が発生する可能性がある。NCCL の group メカニズムは group 内の操作がすべて同じ stream 上にあることを前提としている。
エラー回復チェーン
ncclEnqueueCheckのエラー処理には精巧な設計がある:
📎 src/enqueue/enqueue.cc:3524-3526
もしtaskAppendが失敗し、かつ comm が非ブロッキングモードの場合、ncclCommSetAsyncErrorを呼び出してエラーを記録する。これにより後続の API 呼び出しは再試行せずに直ちにエラーを返す。これは非同期エラー伝播メカニズムである。
---
三、taskAppend:タスク分配の十字路
直感的モデル
taskAppendは enqueue モジュールの「交通ハブ」である。それはinfo->collの値に基づいて、タスクを異なる処理パスに分配する:P2P、RMA、CE、または通常の集合通信。これは郵便局の仕分けセンターのようなもので——封筒の住所に基づいて、手紙を異なるポストに投函する。
もしこの分配層がなければ、すべてのタイプの操作が1つの巨大な if-else に詰め込まれ、コードの保守が困難になる。
Step-by-Step:taskAppend の分配ロジック
📎 src/enqueue/enqueue.cc:3337-3476
ステップ1:新アーキテクチャが有効かどうかを判定する。 ncclParamEnqueueRearchEnable()は環境変数スイッチである(デフォルト 0)。有効な場合、rawTaskAppendパスを通る——これは NCCL が開発中の新しいタスクモデルである。
ステップ2:P2P 分配。Send/Recv の場合、p2pTaskAppend:
📎 src/enqueue/enqueue.cc:3343-3345
ステップ3:RMA 分配。PutSignal/Signal/WaitSignal の場合、rmaTaskAppend:
📎 src/enqueue/enqueue.cc:3346-3347
ステップ4:空の集合通信は早期リターン。 if (info->count == 0) return ncclSuccess;——count が 0 の集合通信は直接破棄される。
ステップ5:アルゴリズム選択の検証。 ncclCollConfigGetAlgMaskユーザーが渡したアルゴリズム選択が正当かどうかを検証する:
📎 src/enqueue/enqueue.cc:3357-3358
ステップ6:FP8 型チェック。FP8 リダクションには sm90+ が必要:
📎 src/enqueue/enqueue.cc:3360-3366
ステップ7:リダクション操作の変換。 hostToDevRedOphost 側のncclRedOp_tをデバイス側のncclDevRedOpFull:
📎 src/enqueue/enqueue.cc:3370-3371
ステップ8:単一 rank の早期リターン。もしcomm->nRanks == 1の場合、直接ncclLaunchOneRankを呼び出してローカルリダクションを実行し、タスクを生成する必要はない:
📎 src/enqueue/enqueue.cc:3373-3377
ステップ9:マルチ rank パス。これが最も複雑な分岐であり、CE ルーティング、AllToAll/Gather/Scatter の降格、および通常の集合通信を含む:
📎 src/enqueue/enqueue.cc:3378-3470
データ構造:ncclTaskColl のフィールド
collTaskAppendはncclTaskCollを生成する場所である。その核心的なロジックを見てみよう:
📎 src/enqueue/enqueue.cc:2757-2851
主要フィールドの代入:
| フィールド | ソース | 意味 |
|---|---|---|
func | info->coll | 集合通信タイプ |
sendbuff/recvbuff | info->sendbuff/recvbuff | バッファポインタ |
count | info->count | 要素数 |
datatype | info->datatype | データ型 |
trafficBytes | count * elementSize * ncclFuncTrafficPerByte | トラフィック推定 |
opHost/opDev | info->op/opDev | リダクション操作 |
chunkSteps/sliceSteps | info->chunkSteps/sliceSteps | 分割ステップ数 |
minCTAs/maxCTAs/nvlsCTAs | 設定解析 | リソース上限 |
algMask | ncclCollConfigGetAlgMask | アルゴリズム選択マスク |
注意trafficBytesの計算:
📎 src/enqueue/enqueue.cc:2813
ncclFuncTrafficPerByteは各集合通信のトラフィック倍数を返す:
📎 src/enqueue/enqueue.cc:123-134
AllReduce は 2 を返し(reduce + broadcast が必要なため)、AllGather/ReduceScatter は nRanks を返し、その他は 1 を返す。
設計思考:なぜ AllGather/Broadcast は int8 に変換するのか?
📎 src/enqueue/enqueue.cc:2808-2812
AllGather と Broadcast は count に elementSize を掛け、そして datatype をncclInt8。これは最適化です:これらの2つの操作はリダクションを伴わないため、データ型を気にする必要がなく、一律にバイト単位で処理することでカーネルロジックを簡素化できます。
本番での落とし穴:CTAPolicy の解析順序
📎 src/enqueue/enqueue.cc:3390-3397
CTAPolicy の解析には微妙な優先順位があります:env > per-call > comm。そしてNCCL_CTA_POLICY_ZEROがNCCL_CTA_POLICY_EFFICIENCYより優先されます。ユーザーが両方のフラグを同時に設定した場合、ZERO が有効になります。
実際の落とし穴シナリオ:ユーザーがNCCL_CTA_POLICY=EFFICIENCYを設定したが、CE パスが使用されていないことに気づきました。原因は CE ルーティングにはCTAPolicy & NCCL_CTA_POLICY_ZEROが真であることが必要ですが、EFFICIENCY はこの条件を満たしません。
---
四、ncclPrepareTasks:タスクリストからスケジューリングキューへ
直感的モデル
ncclPrepareTasksは enqueue モジュールの「プリプロセッサ」です。散在するタスクリストを (func, op, datatype) でバケット分けし、各バケットに対してアルゴリズムとプロトコルを計算します。これは図書館の司書のようなものです——返却された本をまずカテゴリ別に分類し、次に各カテゴリの本をどの棚に置くかを決定します。
このステップがなければ、後続のscheduleCollTasksToPlanは各タスクごとに個別にアルゴリズムを計算する必要があり、効率が極めて低くなります。
Step-by-Step:ncclPrepareTasks のバケット分けロジック
📎 src/enqueue/enqueue.cc:423-642
ステップ1:Broadcast タスクの変換。broadcast ピアが1つだけの場合、broadcast タスクを coll タスクに変換します:
📎 src/enqueue/enqueue.cc:430-461
ここでbcastTaskのフィールドを新しいncclTaskCollにコピーし、trafficBytesを計算します。その後memPool_ncclTaskBcastから元のタスクを解放します。
ステップ2:(func, op, datatype) によるバケット分け。タスクは sorter から size の降順で出てきて、その後tasksByFnOpTy配列に振り分けられます:
📎 src/enqueue/enqueue.cc:464-487
インデックス計算:((int)task->func * ncclNumDevRedOps + (int)task->opDev.op) * ncclNumTypes + (int)task->datatype。これは3次元配列の線形化です。
ステップ3:集約とアルゴリズム選択。各バケットについて、サイズが近いタスク(4倍以内)を集約し、その後ncclGetAlgoInfo:
📎 src/enqueue/enqueue.cc:503-547
を呼び出しますステップ4:(collnet, nvls) によるバケット分け。collBins[2][2]:
📎 src/enqueue/enqueue.cc:517-544
アルゴリズムタイプに基づいて、タスクをに振り分けますplanner->collTaskQueue:
📎 src/enqueue/enqueue.cc:553-557
ステップ5:最終キューの結合。
ncclTaskCollSorter4つのバケットをtrafficBytesに結合しますncclTaskCollSorterInsertデータ構造:ncclTaskCollSorterncclTaskCollSorterDequeueAllは
タスクを正しい位置に挿入し、すべてのタスクを順番に取り出します。〔設計推論とアーキテクチャのトレードオフ〕
このソーターの設計動機は:
📎 src/enqueue/enqueue.cc:572-583
大きなタスクを優先的にスケジューリングするcomm->runtimeConn。大きなタスクは転送時間が長いため、先に起動することで計算と通信をより良くオーバーラップできます。algoNeedConnect並行制御:runtimeConn と接続確立
もし
📎 src/enqueue/enqueue.cc:507-508
が真(ランタイム接続モード)で、あるアルゴリズムの channel がまだ初期化されていない場合、aggEnd->trafficBytes < 4 * aggBeg->trafficBytesをマークします。これにより後続で接続確立がトリガーされます。aggIsolate本番での落とし穴:集約の境界条件maxCTAs),aggIsolate集約条件は
であり、かつ両方のタスクがmaxCTAs=4を設定していないことです。ユーザーが per-call config を設定した場合(例えばaggIsolateが true に設定される)、このタスクは集約されません。collTaskAppend実際の落とし穴シナリオ:ユーザーがある AllReduce に対して
📎 src/enqueue/enqueue.cc:2821-2822
---
を設定し、4つの CTA のみを使用することを期待しました。しかし集約ロジックにより、このタスクは隣接するタスクと統合される可能性があり、実際に使用される CTA 数が期待に合わなくなります。解決策は
を設定することです——NCCL は
scheduleCollTasksToPlan内で既にこれを処理しています:
五、scheduleCollTasksToPlan:channel 分割と予算制御
直感的モデル
📎 src/enqueue/enqueue.cc:644-947
は enqueue モジュールの「スケジューラ」です。タスクを具体的な channel に割り当て、各 channel のデータ分割を計算します。これは工場の生産計画システムのようなものです——各生産ラインが何を作り、どれだけ作るかを決定します。このステップがなければ、GPU kernel は自分がどの部分のデータを処理すべきかわかりません。
📎 src/enqueue/enqueue.cc:648-689
ncclTestBudgetStep-by-Step:channel 分割アルゴリズム
📎 src/enqueue/enqueue.cc:343-349
ステップ1:予算見積もり。まずこの plan に収められるタスク数を推定します:trafficPerChannel:
📎 src/enqueue/enqueue.cc:701-707
作業バイト数が予算を超えているかチェックします:ステップ2:各 channel のトラフィックを計算。
📎 src/enqueue/enqueue.cc:709-739
kind(collnet/nvls)に基づいてを計算します
📎 src/enqueue/enqueue.cc:740-845
ステップ3:Collnet パス。
cellSizecollnet アルゴリズムの場合、channel 割り当ては比較的簡単です:MinTrafficPerChannel(32KB)cellsステップ4:通常パスの cell 分割。cellsPerChannelこれが最も複雑な部分です。NCCL はデータを「cell」に分割し、各 cell は最小転送単位です:cellsLo/cellsHi主要変数:
:各 cell のバイト数、最低:総 cell 数calcCollChunking:
📎 src/enqueue/enqueue.cc:811-825
:各 channel が処理する cell 数:先頭と末尾の channel の cell 数(満たない可能性がある)
📎 src/enqueue/enqueue.cc:844-894
ステップ5:chunkGrains の計算。
ncclDevWorkColl各 channel セグメントに対して
| を呼び出します | ステップ6:proxyOp の生成。 |
|---|---|
sendbuff/recvbuff | 各 channel に対して proxy 操作を生成します: |
channelLo/channelHi | データ構造:ncclDevWorkColl |
cbd.countLo/countMid/countHi | はデバイス側の作業記述子です。その主要フィールド: |
cbd.chunkGrainsLo/Mid/Hi | フィールド |
direct | 意味 |
バッファポインタ
📎 src/enqueue/enqueue.cc:897
channel 範囲(2ull << channelHi) - (1ull << channelLo)。例えば channelLo=2, channelHi=5 の場合、結果は(2<<5) - (1<<2) = 64 - 4 = 60 = 0b111100、つまり bit 2-5 が設定されます。
本番での落とし穴:予算オーバーフロー
📎 src/enqueue/enqueue.cc:792-794
予算が足りない場合、直接ncclSuccessを返し、外側のループに新しい plan を作成させます。これはエレガントなデグレード戦略です——エラーにはせず、バッチ処理するだけ。
実際の落とし穴シナリオ:もしNCCL_WORK_FIFO_BYTESを小さく設定しすぎると、各 plan にわずかなタスクしか収容できなくなり、kernel の起動回数が増えて性能が低下します。
---
六、finishPlan:タスクから kernel パラメータへ
直感モデル
finishPlanは enqueue モジュールの「パッケージャー」です。タスク、batch、proxyOp を kernel が直接読み取れるパラメータ構造にパッケージします。これは宅配便の梱包のようなものです——バラバラの荷物を箱に詰め、送り状を貼り、発送を待ちます。
Step-by-Step:finishPlan のパッケージングロジック
📎 src/enqueue/enqueue.cc:236-330
ステップ 1:ストレージタイプを決定する。すべての作業が kernel args に収まる場合、ncclDevWorkStorageTypeArgs:
📎 src/enqueue/enqueue.cc:244-250
ステップ 2:kernelArgs を割り当てる。メモリスタックから割り当て:
📎 src/enqueue/enqueue.cc:251-255
ステップ 3:Round-robin で batch を配置する。各 channel の最初の batch はbatchZero[blockIdx.x]:
📎 src/enqueue/enqueue.cc:257-280
ステップ 4:proxyOp キューをマージする。opCount でマージソート:
📎 src/enqueue/enqueue.cc:282-329
データ構造:ncclDevKernelArgs
ncclDevKernelArgsは kernel に渡されるパラメータ構造です。以下を含みます:
comm:デバイス側コミュニケータchannelMask:channel ビットマスクworkStorageType:ワークストレージタイプworkBuf:ワークバッファポインタworkMask:ワークバッファマスク
本番での落とし穴:batch の順序
📎 src/enqueue/enqueue.cc:257-259
コメントには明確に書かれています:"The first batch for each channel must be located at batchZero[blockIdx.x]"。この順序が間違っていると、kernel が誤った batch を読み取り、データ破損を引き起こします。
---
本章のまとめ
本章では、ncclAllReduceからncclTaskCollまでの完全なパスを追跡しました:
1. ncclAllReduceを構築しncclInfo、ユーザーパラメータをパッケージ
2. ncclEnqueueCheckパラメータを検証し、group セマンティクスを処理
3. taskAppend操作タイプに応じて異なるパスにディスパッチ
4. collTaskAppendを生成しncclTaskColl、設定を解析
5. ncclPrepareTasks(func, op, datatype) でバケット化し、アルゴリズムを計算
6. scheduleCollTasksToPlanchannel を分割し、ncclDevWorkColl
7. finishPlanを生成して kernel パラメータにパッケージ
重要な設計思想:
- 階層的疎結合:各関数は一つのことだけを行い、
ncclInfoとncclTaskCollを通じて状態を伝達 - 予算制御:
ncclTestBudgetを通じて各 plan のサイズを制御 - 集約最適化:サイズが近いタスクが集約され、kernel の起動回数を削減
- 設定の優先順位:env > per-call > comm
次章ではtask_schedに入り、NCCL がマルチ channel・マルチ kernel の実行順序をどのように編成するかを見ていきます。
本章の考察とセルフチェック
Q1: もしcollTaskAppendのaggIsolate判定を削除した場合(つまりsrc/enqueue/enqueue.cc:2821-2822が常に false を返す場合)、どのようなシナリオでユーザーが設定したmaxCTAsが無効になりますか?なぜですか?
参考解説:aggIsolateの役割は「このタスクは集約できない」とマークすることです。この判定を削除すると、per-call config を設定したタスクが隣接タスクとマージされます。ncclPrepareTasksの集約ループ(src/enqueue/enqueue.cc:507-508)では、集約条件はaggEnd->trafficBytes < 4 * aggBeg->trafficBytes && !aggBeg->aggIsolate && !aggEnd->aggIsolateです。もしaggIsolateが常に false なら、maxCTAs=4を設定したタスクでも、maxCTAs=32のタスクとマージされる可能性があります。マージ後のaggは両者のある種の組み合わせを取ります(ncclGetAlgoInfoの実装に依存)、その結果、実際に使用される CTA 数がユーザーの期待に合わなくなります。
さらに深刻なのは、scheduleCollTasksToPlanにおいて(src/enqueue/enqueue.cc:665-666),taskAggIsolateは per-call リソースが設定されたタスクが単独で一つの plan を占めることを保証するために使用されます。この判定が無効になると、複数のタスクが plan の channel 予算を共有し、リソース配分が期待に合わなくなります。
Q2:ncclEnqueueCheckにおいて、もしncclGroupEndInternal()がエラーを返した場合(例えばある rank の ArgsCheck が失敗)、しかしtaskAppendがすでに正常に実行された場合、何が起こりますか?NCCL はどのように状態の一貫性を保証しますか?
参考解説:src/enqueue/enqueue.cc:3513-3519の制御フローを見てみましょう:
NCCLCHECKGOTO(taskAppend(info->comm, info), ret, fail);
info->comm->opCount++;
exit:
if (devOld != -1) CUDACHECK(cudaSetDevice(devOld));
ncclGroupErrCheck(ret);
NCCLCHECK(ncclGroupEndInternal());もしtaskAppendが成功したがncclGroupEndInternalが失敗した場合、opCountはすでにインクリメントされています。これにより後続操作の opCount が対端と一致しなくなり、hang を引き起こす可能性があります。
NCCL の処理方法は:ncclGroupErrCheck(ret)はエラーがあるかチェックし、あれば comm のエラー状態を設定します。後続の API 呼び出しはncclCommGetAsyncErrorを通じてこのエラーを検出し、即座にリターンします。これは「フェイルファスト」戦略です——一度エラーが発生すると、comm 全体がエラー状態に入り、回復を試みなくなります。
本番環境では、group エラーが一度発生すると、ユーザーは communicator を破棄して再構築する必要があります。
Q3: scheduleCollTasksToPlanの cell 分割アルゴリズム(src/enqueue/enqueue.cc:740-845)には境界条件があります:cellsLo == 0のとき、最小の channel をスキップします。もしこのスキップロジックにバグがある場合(例えばchannelIdが正しくインクリメントされない)、どのような結果を引き起こしますか?
参考解説:src/enqueue/enqueue.cc:770-780:
if (cellsLo == 0) {
// Least channel skipped. Make the next channel the new least.
channelId += 1;
if (nMidChannels == 0) {
cellsLo = cellsHi;
cellsHi = 0;
} else {
cellsLo = cellsPerChannel;
nMidChannels -= 1;
}
}もしchannelIdが正しくインクリメントされないと、次のタスクが誤った channel から割り当てを開始します。これにより:
1. channel の重複:二つのタスクが同じ channel の同じデータ区間を割り当てられる可能性
2. データ破損:kernel がデータを重複処理または欠落させる
3. 性能低下:channel の負荷不均衡
さらに隠蔽性が高いのは、この種のバグが特定のメッセージサイズでのみ発生する可能性があることだ(cellsLo == 0のとき)。再現が難しい。NCCL はplan->channelMask |= (2ull << devWork->channelHi) - (1ull << devWork->channelLo)によって使用済みの channel を追跡するが、これは単なる記録であり、重複を防ぐことはできない。
ここまでで、ncclAllReduce がユーザーの呼び出しから一連の実行可能な kernel タスクへとどのように変化するかを明らかにした:パラメータ検証、アルゴリズム/プロトコルの決定、channel 分割、最終的に ncclInfo と ncclTaskColl を生成する。しかしタスクが作成されただけでは最初の一歩に過ぎない——それらは複数の channel にスケジュールされ、kernel 起動パラメータを生成し、group セマンティクスの下でバッチ送信と依存関係の順序付けを処理する必要がある。次の章では src/enqueue/task_sched と src/enqueue/task_prep を深掘りし、「なぜ1回の AllReduce で複数の kernel が起動されるのか、それらの間の順序と依存関係はどのように保証されるのか」に答え、同時に src/group.cc の ncclGroupStart/ncclGroupEnd がどのように複数の API 呼び出しを1回の送信に統合するかを明らかにする。
第7章:第7章:タスクスケジューラ:task_sched がマルチ channel と kernel の実行順序をどのように編成するか
第7章:タスクスケジューラ:task_sched がマルチ channel と kernel の実行順序をどのように編成するか
前の章では ncclAllReduce を ncclTaskColl まで追跡した——タスク記述オブジェクトはすでに comm->planner に入っている。しかしタスク記述は「作業指示書」に過ぎず、まだ GPU 上で実際に動く kernel にはなっていない。この章では3つの問題に答える:複数の API 呼び出しはどのようにまとめて送信されるのか?まとめられたタスクはどのように複数の channel に分割されるのか?複数の kernel 間の順序と依存関係は何によって保証されるのか?まず全体的なメンタルモデルを示す。NCCL をレストランに例えよう:ncclGroupStart/ncclGroupEnd は「ショッピングカート」であり、ユーザーはいくつかの料理(複数の集合通信呼び出し)をカートに入れる;ncclGroupEnd は「注文」であり、キッチンが注文に従って料理を作り始める。そして doLaunches は「配膳スケジューラ」であり、どの料理を先に出すか、どの料理を並行して作れるかを決定する。group セマンティクスがなければ、各料理を個別に注文し、キッチンは1品作るたびに火を起こし直す(kernel を起動する)必要があり、オーバーヘッドが膨大になる;doLaunches のラウンドスケジューリングがなければ、マルチ channel の kernel が順不同で起動され、データ依存関係が破壊される。
一、Group セマンティクスのグローバル状態:thread_local 変数と「ショッピングカート」モデル
直感的モデル
ncclGroupStartとncclGroupEndの間のすべての通信呼び出しは、即座に kernel を起動するのではなく、「蓄積」される。どこに蓄積されるのか?スレッドローカル(thread_local)のグローバル変数に蓄積される。なぜ thread_local なのか?NCCL は同一スレッド内の group 呼び出しが直列であると仮定しており、異なるスレッドはそれぞれ独立したショッピングカートを持ち、互いに干渉しない。もしこれらの状態がグローバル変数であって thread_local でなければ、2つのスレッドが同時にncclGroupStartを呼び出すと互いに踏みつけ合い、あるスレッドのタスクが別のスレッドのncclGroupEndによって送信される——これは致命的である。
データ構造とメモリレイアウト
まず group のグローバル状態定義を見る。
📎 src/group.cc:34-34
thread_local int ncclGroupDepth = 0; // depth of ncclGroupStart nesting
thread_local ncclResult_t ncclGroupError = ncclSuccess;
thread_local struct ncclComm* ncclGroupCommHead[ncclGroupTaskTypeNum] = {nullptr};
thread_local struct ncclComm* ncclGroupCommPreconnectHead = nullptr;
thread_local struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next> ncclAsyncJobs;
thread_local int ncclGroupBlocking = -1; /* default mode */フィールドごとに分解する:
ncclGroupDepth:ネスト深度。ncclGroupStartはネスト呼び出しが可能であり(一般的ではないが)、ncclGroupStartごとに1加算し、ncclGroupEndで1減算する。0になったときのみ実際に送信される。これはショッピングカートのネストのようなもの——あるカートの中に子カートを開き、最外層の精算時のみ実際に注文する。ncclGroupError:group 内のいずれかの呼び出しでエラーが発生すると、エラーがここに記録され、ncclGroupEnd時に一括処理される。これにより「1回の呼び出しが失敗した後、後続の呼び出しがまだカートに物を追加している」という不整合状態を回避する。ncclGroupCommHead[ncclGroupTaskTypeNum]:タスクタイプ別にグループ化された通信ドメインのリンクリストヘッド。ncclGroupTaskTypeNumはタスクタイプの数(集合通信、原始タスク、管理タスク、対称登録など)。各タイプに1つのリンクリストがあり、リンクリストノードはncclCommであり、comm->groupNext[type]を介して連結される。なぜタイプ別に分けるのか?タイプによってタスクの送信タイミングと依存関係が異なるため——集合通信タスクは先に preconnect する必要があり、管理タスク(destroy など)は最後に実行する必要がある。ncclGroupCommPreconnectHead:事前接続が必要な通信ドメインのリンクリスト。事前接続とは「事前にネットワーク接続を確立する」ことで、kernel 起動時に接続を確立することによる遅延を回避する。ncclAsyncJobs:非同期タスクキュー。一部のタスク(ncclCommInitRankなど)は非同期であり、このキューに入れられ、ncclGroupEnd時に一括起動される。ncclGroupBlocking:ブロッキングモードフラグ。-1は未確定を意味し、0は非ブロッキングを意味する。1ブロッキングを表す。同じ group 内でブロッキングとノンブロッキングの通信ドメインを混用することは許可されず、そうした場合はエラーとなる。
ここには重要な設計がある:ncclGroupCommHeadは配列であり、各要素は1本の連結リストである。連結リストのノードはcomm->groupNext[type]によって連結され、独立した連結リストノード構造体は使われない。これはつまり、ncclComm構造体の中にgroupNext配列フィールドをあらかじめ確保しておく必要があることを意味する。この「侵入型連結リスト」の設計により余分なメモリ割り当てを避けられるが、その代償としてncclComm構造体が大きくなる。
シナリオ駆動のステップバイステップ・ウォークスルー
シナリオ:ユーザーがncclGroupStart()を呼び出し、その後続けて2回ncclAllReduceを呼び出し(それぞれ異なる2つの通信ドメイン commA と commB に対して)、最後にncclGroupEnd()。
を呼び出す。最初のステップ:ncclGroupStartは何をしたか?
📎 src/include/group.h:63-66
inline ncclResult_t ncclGroupStartInternal() {
ncclGroupDepth++;
return ncclSuccess;
}極めて単純:深さを1増やすだけ。メモリ割り当てなし、ロックなし、システムコールなし。これがncclGroupStartがほぼゼロオーバーヘッドである理由である。
2番目のステップ:ncclAllReduceが group 内で呼び出されたときに何が起こるか?
ncclAllReduceの内部ではncclGroupCommJoin(comm, ncclGroupTaskTypeCollective)が呼び出され、通信ドメインが group の連結リストに追加される。
📎 src/include/group.h:80-116
inline void ncclGroupCommJoin(struct ncclComm* comm, int type) {
if (comm->groupNext[type] == reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID)) {
// Insert comm into ncclGroupCommHead adjacent to sibling comms. This preserves
// the users program order yet insures siblings occur consecutively. This
// is required by doLaunches() in "group.cc".
struct ncclComm** pp = &ncclGroupCommHead[type];
while (*pp != nullptr && comm->intraComm0 != (*pp)->intraComm0) pp = &(*pp)->groupNext[type];
// didn't find its clique, we need to insert it with ascending order based on commHash
if (*pp == nullptr) {
pp = &ncclGroupCommHead[type];
while (*pp != nullptr && (*pp)->commHash < comm->commHash) pp = &(*pp)->groupNext[type];
}
comm->groupNext[type] = *pp;
*pp = comm;
// Comms gets a new memory stack scope upon joining. Each task batched for
// this comm is allocated there.
if (type == ncclGroupTaskTypeCollective || type == ncclGroupTaskTypeRawTask) {
// Initialize planner
ncclMemoryStackPush(&comm->memScoped);
ncclKernelPlanner::Peer* tmp = comm->planner.peers;
ncclIntruQueue<ncclTaskRma, &ncclTaskRma::next>* tmpRmaQueues = comm->planner.rmaTaskQueues;
int numRmaCtx = comm->config.numRmaCtx;
memset(&comm->planner, 0, sizeof(comm->planner));
comm->planner.peers = tmp;
comm->planner.bcast_info.minBcastPeer = INT_MAX;
comm->planner.bcast_info.maxBcastPeer = INT_MIN;
comm->planner.rmaTaskQueues = tmpRmaQueues;
if (comm->planner.rmaTaskQueues != NULL) {
for (int i = 0; i < numRmaCtx; i++) {
ncclIntruQueueConstruct(&comm->planner.rmaTaskQueues[i]);
}
}
}
}
ncclGroupBlocking = comm->config.blocking;
}このコードにはいくつかの巧妙な点がある:
1. 冪等性チェック:if (comm->groupNext[type] == NCCL_COMM_GROUP_INVALID)は、同じ通信ドメインが同じ group 内で一度だけ追加されることを保証する。もしユーザーが同じ comm に対して2回ncclAllReduceを呼び出した場合、2回目は連結リストに重複追加されないが、タスクはcomm->plannerに追加される。
2. clique ソート:intraComm0は「グローバルエンティティ」の識別子である。複数の通信ドメインが同じグローバルエンティティに属する場合(例えばncclCommSplitによって分割されたものなど)、それらのintraComm0は同じであり、1つの clique と呼ばれる。コードはまずintraComm0によって clique を見つけ、comm を同じ clique の兄弟ノードの隣に挿入する。clique が見つからない場合は、commHashの昇順で挿入する。このソートは、doLaunchesが clique 内の barrier 同期を正しく処理できるようにするためである。
3. メモリスタックのスコープ:ncclMemoryStackPush(&comm->memScoped)は、この comm のために group 内に新しいメモリスタックのスコープを割り当てる。この comm のために割り当てられるすべてのタスク(ncclTaskCollなど)は、このスタックから割り当てられる。ncclGroupCommLeaveの際にはncclMemoryStackPopがすべてのタスクメモリを一括解放する——これは「一括割り当て、一括解放」という古典的な最適化であり、タスクごとに個別にmalloc/freeするオーバーヘッドを避ける。
4. planner のリセット:memset(&comm->planner, 0, sizeof(comm->planner))は planner をクリアするが、peersとrmaTaskQueuesのポインタは保持する(一時変数に退避し、memset 後に復元する)。なぜ保持するのか?これらは事前に割り当てられた配列であり、毎回再割り当てする必要がないからである。bcast_infoの min/max はINT_MAX/INT_MINにリセットされ、後続の broadcast タスクのマージ最適化に使用される。
3番目のステップ:ncclGroupEndは何をしたか?
📎 src/group.cc:1039-1164
ncclGroupEndInternalが核心である。段落ごとに解析する:
📎 src/group.cc:1048-1061
if (ncclGroupDepth == 0) {
WARN("ncclGroupEnd: not in a group call.");
ret = ncclInvalidUsage;
goto exit;
}
// ...
if ((--ncclGroupDepth) > 0) goto exit;まず深さをチェックし、その後1減らす。1減らした後もまだ0より大きい場合、まだネストされた内側の group にいることを意味するので、そのまま return し、コミットしない。0まで減った場合のみ続行する。
📎 src/group.cc:1063
if ((ret = ncclGroupError) != ncclSuccess) goto fail;group 内でいずれかの呼び出しでエラーが発生した場合、直接 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);を作成し、thread_local の group 状態を job オブジェクトに「転送」する。ncclGroupJobはncclIntruQueueTransferキュー全体をncclAsyncJobsに転送する。このステップは非常に重要である:thread_local 状態は「一時的」であり、job オブジェクトは「永続的」であり、非同期スレッドが保持できる。groupJob->asyncJobsコピー
📎 src/group.cc:1095-1147
if (hasCommHead || !ncclIntruQueueEmpty(&groupJob->asyncJobs) || ncclGroupCommPreconnectHead != nullptr) {
/* make sure ncclGroupBlocking has been set. */
if (ncclGroupBlocking != 0 && ncclGroupBlocking != 1) {
WARN("Invalid group blocking state %d", ncclGroupBlocking);
ret = ncclInternalError;
goto fail;
}
if (ncclGroupBlocking == 0) {
/* nonblocking group */
// ... 设置 async error 为 ncclInProgress,创建线程执行 groupLaunchNonBlocking
groupJob->base.func = groupLaunchNonBlocking;
STDTHREADCREATE_GOTO(groupJob->base.thread, ncclAsyncJobMain, ret, fail, &groupJob->base);
groupJob->nonBlockingInit = true;
ret = ncclInProgress;
} else {
/* blocking group */
int savedDev;
CUDACHECKGOTO(cudaGetDevice(&savedDev), ret, fail);
NCCLCHECKGOTO(groupLaunch(&groupJob->base, internalSimInfoPtr), ret, fail);
CUDACHECKGOTO(cudaSetDevice(savedDev), ret, fail);
if (simInfo) memcpy((void*)simInfo, (void*)internalSimInfoPtr, realSize);
delete groupJob;
}
} else {
// Free when not needed (single rank case)
delete groupJob;
}を呼び出し、同期的に完了する。ノンブロッキングモード:スレッドを作成してgroupLaunchを実行し、即座にgroupLaunchNonBlockingを返す。ユーザーは後でncclInProgressを通じて進捗を照会する。ncclCommGetAsyncError注意すべきは
の保存と復元である:cudaGetDevice/cudaSetDeviceの内部では CUDA デバイスが切り替わる(異なる comm が異なる GPU 上にある可能性があるため)、実行後にユーザーの元のデバイスを復元する。これは「NCCL 内部でデバイスを切り替えた後に戻し忘れる」ことによって、ユーザーの後続の CUDA 呼び出しが誤ったデバイスで実行されるのを防ぐためである。groupLaunch設計上の考察と本番環境での落とし穴
落とし穴1:ブロッキングとノンブロッキングの通信ドメインの混用
にはチェックがある:。ncclAsyncLaunchコピー
📎 src/group.cc:55-64
/* check if there are blocking and nonblocking comms at the same time in group. */
if (comm->destroyFlag) {
ncclGroupBlocking = 1;
} else if (ncclGroupBlocking == -1) {
/* first met communicator */
ncclGroupBlocking = comm->config.blocking;
} else if (ncclGroupBlocking != comm->config.blocking) {
WARN("Blocking and nonblocking communicators are not allowed in the same group.");
ret = ncclInvalidArgument;
}が同期的に返すべきかncclGroupEndを返すべきか判断できなくなる。本番環境では、ユーザーが誤ってブロッキングとノンブロッキングの comm を同じ group に入れた場合、ncclInProgressを受け取るが、この時点で group 状態はすでに汚染されており、再度ncclInvalidArgumentする必要がある。ncclGroupStart。
落とし穴2:ncclGroupErrorの伝播。もし group 内のいずれかの呼び出しが失敗した場合、ncclGroupErrorが設定され、ncclGroupEndは fail 分岐にジャンプしてgroupCleanup。groupCleanupを実行する。ncclGroupStartはすべての comm を走査し、planner 内の plan メモリを解放し、planner をリセットし、rawTaskQueue をクリーンアップする。もしこのステップが完全に行われないと、次回の
📎 src/group.cc:514-607
static void groupCleanup(struct ncclComm** groupCommHeadPtr,
struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncJobsPtr,
ncclResult_t error) {
struct ncclComm* comm;
for (int type = 0; type < ncclGroupTaskTypeNum; ++type) {
comm = groupCommHeadPtr[type];
groupCommHeadPtr[type] = nullptr;
while (comm != nullptr) {
struct ncclComm* next = comm->groupNext[type];
(void)ncclGroupCommLeave(comm, type);
// We don't know if preconnect succeeded or happened at all, so clear
// the flags that let `taskAppend()` skip over checking if preconnect
// is needed.
if (type == ncclGroupTaskTypeCollective || type == ncclGroupTaskTypeRawTask) {
comm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1);
for (int i = 0; i < comm->nRanks; i++) {
comm->connectSend[i] = 0UL;
comm->connectRecv[i] = 0UL;
}
// Reclaim abandoned kernel plan memory.
while (!ncclIntruQueueEmpty(&comm->planner.planQueue)) {
struct ncclKernelPlan* plan = ncclIntruQueueDequeue(&comm->planner.planQueue);
if (!plan->persistent) {
while (!ncclIntruQueueEmpty(&plan->proxyOpQueue)) {
struct ncclProxyOp* pxop = ncclIntruQueueDequeue(&plan->proxyOpQueue);
ncclMemoryPoolFree(&comm->memPool_ncclProxyOp, pxop);
}
ncclMemoryPoolFree(&comm->memPool_ncclKernelPlan, plan);
}
}
// Reset comm->planner to empty.
// ...
}
// ...
}
}
// ...
}コピーcomm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1)の0x1の行に注意。これは「センチネル値」であり、「この comm は preconnect をやり直す必要がある」ことを示す。なぜか?cleanup 時に preconnect が成功したかどうかわからないため、次回強制的に再チェックするからである。ncclGroupCommPreconnectこの値は非常に巧妙である——有効なポインタではないが、「未初期化」マーカーとして使用できる。if (comm->preconnectNext == reinterpret_cast<struct ncclComm*>(0x1))では
---
をチェックして、preconnect 連結リストに追加する必要があるかどうかを判断する。ncclPrepareTasks二、タスク準備:
がどのようにタスク記述をスケジューラブルユニットに変換するか
ncclPrepareTasksこれは「下ごしらえ」の段階です。ショッピングカートに入っている食材(タスク記述)はまだ生の状態で、まず洗って切って準備する(アルゴリズム、プロトコル、channel 分割を決定する)必要があり、それから初めて鍋に入れる(kernel を起動する)ことができます。このステップを飛ばして直接 kernel を起動すると、kernel はデータをどう分割し、どの経路を通るかを知らないため、即座にクラッシュします。
シナリオ駆動の Step-by-Step Walkthrough
ncclPrepareTasksここでgroupLaunchLegacyの中で呼び出されます:
📎 src/group.cc:705-746
static ncclResult_t ncclPrepareTasksAndCollPreconnect(
struct ncclComm* comm, ncclSimInfo_t* simInfo,
struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncCollJobs) {
if (ncclParamSingleProcMemRegEnable()) {
// 单进程内存注册模式:把 prepare 和 preconnect 合并成一个异步 job
struct ncclPrepareTasksAndCollPreconnectJob* job;
NEW_NOTHROW(job, ncclPrepareTasksAndCollPreconnectJob);
job->base.func = ncclPrepareTasksAndCollPreconnectFunc;
// ...
ncclIntruQueueEnqueue(asyncCollJobs, &job->base);
} else {
bool needConnect = false;
bool algoNeedConnect[NCCL_NUM_ALGORITHMS];
memset(algoNeedConnect, 0, sizeof(bool) * NCCL_NUM_ALGORITHMS);
CUDACHECK(cudaSetDevice(comm->cudaDev));
NCCLCHECK(ncclPrepareTasks(comm, algoNeedConnect, &needConnect, simInfo));
if (comm->cuMemSupport && needConnect) {
// 创建 preconnect job
struct ncclPreconnectJob* job;
NEW_NOTHROW(job, ncclPreconnectJob);
job->base.func = ncclCollPreconnectFunc;
// ...
ncclIntruQueueEnqueue(asyncCollJobs, &job->base);
}
}
return ncclSuccess;
}ncclPrepareTasksの出力は二つあります:algoNeedConnect配列(どのアルゴリズムが接続を確立する必要があるか)とneedConnectフラグ(接続が必要かどうか)。もしneedConnectが真で cuMem をサポートしていれば、preconnect job を作成して非同期で実行します。
ncclPrepareTasks内部で何をしているのか?それはcomm->planner内のタスクを走査し、各タスクに対してアルゴリズムとプロトコルを決定し、その後taskAppendを呼び出してタスクを planner の plan に追加します。この部分のロジックは前章で展開済みなので、ここでは繰り返しません。
重要なポイント:ncclPrepareTasksはcomm ごとに個別に呼び出されるのに対し、preconnect はclique ごとにバッチ実行されるのはなぜか?groupLaunchLegacy内のコメントを見てください:
📎 src/group.cc:818-834
do {
// We need to preconnect connections for collectives clique by clique to avoid
// race condition for split shared comms which can connect the same connections
// at the same time.
comm = cliqueHead;
do {
NCCLCHECKGOTO(ncclPrepareTasksAndCollPreconnect(comm, simInfo, &asyncCollJobs), ret, fail);
comm = comm->groupNext[ncclGroupTaskTypeCollective];
} while (comm != nullptr && comm->intraComm0 == cliqueHead->intraComm0);
// connect
NCCLCHECKGOTO(asyncJobLaunch(&asyncCollJobs, groupAbortFlag), ret, fail);
// ...
cliqueHead = comm;
} while (cliqueHead != nullptr);コメントには明確に書かれています:clique ごとに個別に preconnect することで、split shared comms が同時に同じ接続グループに接続して競合状態を引き起こすのを避ける。もし二つの comm が同じ親 comm から split されたものであれば、それらはいくつかの接続を共有している可能性があります。もし並行して preconnect すると、二つのスレッドが同時に同じ接続を確立しようとし、重複接続や接続状態の不整合を引き起こす可能性があります。clique ごとに直列実行することで、同時刻に一つの clique だけが接続を確立していることを保証します。
並行制御と低レベル相互作用
asyncJobLaunchは非同期タスク起動の中核です:
📎 src/group.cc:609-678
static ncclResult_t asyncJobLaunch(struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next>* asyncJobsMain,
volatile bool* groupAbortFlag) {
ncclResult_t ret = ncclSuccess;
bool jobsDone = false;
bool errorJobAbortFlag = false;
if (!ncclIntruQueueEmpty(asyncJobsMain)) {
struct ncclAsyncJob* job = ncclIntruQueueHead(asyncJobsMain);
if (job->next == nullptr) {
// 只有一个 job,直接在当前线程执行,避免线程创建开销
job->isThreadMain = true;
ncclAsyncJobMain(job);
job->state = ncclGroupJobJoined;
return job->result;
}
// 多个 job,每个创建一个线程
do {
STDTHREADCREATE(job->thread, ncclAsyncJobMain, job);
job = job->next;
} while (job != nullptr);
do {
jobsDone = true;
job = ncclIntruQueueHead(asyncJobsMain);
do {
ncclGroupJobState_t state = COMPILER_ATOMIC_LOAD(&job->state, std::memory_order_acquire);
if (state == ncclGroupJobRunning) {
jobsDone = false;
} else if (state == ncclGroupJobDone) {
int err;
if ((err = ncclThreadJoin(job->thread)) != ncclSuccess) {
WARN("asyncJobLaunch: failed to join thread for job");
ret = ncclSystemError;
}
job->state = ncclGroupJobJoined;
if (job->result != ncclSuccess && ret == ncclSuccess) {
ret = job->result;
errorJobAbortFlag = true;
}
} else {
// safety check
if (state != ncclGroupJobJoined) {
WARN("Async job state is %d, expected %d", state, ncclGroupJobJoined);
if (ret == ncclSuccess) ret = ncclInternalError;
errorJobAbortFlag = true;
}
}
if (!job->destroyFlag &&
(COMPILER_ATOMIC_LOAD(groupAbortFlag, std::memory_order_acquire) || errorJobAbortFlag == true)) {
COMPILER_ATOMIC_STORE(job->abortFlag, uint32_t(1), std::memory_order_release);
COMPILER_ATOMIC_STORE(job->abortFlagDev, uint32_t(1), std::memory_order_release);
if (job->childAbortFlag) {
COMPILER_ATOMIC_STORE(job->childAbortFlag, uint32_t(1), std::memory_order_release);
COMPILER_ATOMIC_STORE(job->childAbortFlagDev, uint32_t(1), std::memory_order_release);
}
}
job = job->next;
} while (job != nullptr);
// Let preconnect threads progress.
if (jobsDone == false) std::this_thread::sleep_for(std::chrono::microseconds(1));
} while (jobsDone == false);
if (ret != ncclSuccess) goto fail;
}
exit:
return ret;
fail:
goto exit;
}このコードにはいくつかの重要な設計があります:
1. 単一 job 最適化:もしキューに job が一つだけなら、スレッドを作成せず、現在のスレッドで直接実行します。これによりスレッド作成と join のオーバーヘッドを避けられます。単一 comm の group では、これが一般的なケースです。
2. アトミック状態機械:job->stateはアトミック変数で、三つの状態があります:ncclGroupJobRunning、ncclGroupJobDone、ncclGroupJobJoined。ワーカースレッドは実行完了後にCOMPILER_ATOMIC_STORE(..., std::memory_order_release)を使ってDoneに設定します;メインスレッドはCOMPILER_ATOMIC_LOAD(..., std::memory_order_acquire)で読み取ります。release/acquire のペアにより、ワーカースレッドのすべてのメモリ書き込みがメインスレッドに可視であることが保証されます。
3. ビジーウェイト + マイクロスリープ:メインスレッドはすべての job の状態をポーリングし、まだ実行中の job があれば、sleep_for(1us)後にポーリングを続けます。なぜ条件変数ではなく 1 マイクロ秒なのでしょうか?preconnect は短いタスク(通常数十マイクロ秒から数ミリ秒)であり、条件変数の起床オーバーヘッドがビジーウェイトよりも大きくなる可能性があるからです。1 マイクロ秒のスリープにより、純粋なスピンによる CPU 浪費を避けられます。
4. エラー伝播と abort:もしどれかの job が失敗すると、errorJobAbortFlagが設定され、後続のすべての job のabortFlagがアトミックに 1 に設定されます。ワーカースレッドは実行中にabortFlagをチェックし、abort されたことを検出すると早期終了します。これは「高速失敗」メカニズムであり、一つの job が失敗した後に他の job がまだ無駄に走り続けるのを避けます。
Mermaid 図:group 提交の制御フロー
flowchart TD
gs["ncclGroupStart()"] --> depth_inc["ncclGroupDepth++"]
depth_inc --> api_calls["用户调用 ncclAllReduce 等"]
api_calls --> join["ncclGroupCommJoin(comm, type)"]
join --> check_dup{"comm->groupNext[type]<br/>== NCCL_COMM_GROUP_INVALID?"}
check_dup -->|是| insert["插入 clique 链表<br/>ncclMemoryStackPush"]
check_dup -->|否| skip["跳过(已加入)"]
insert --> ge["ncclGroupEnd()"]
skip --> ge
ge --> depth_dec["--ncclGroupDepth"]
depth_dec --> depth_zero{"depth == 0?"}
depth_zero -->|否| ret_early["返回(嵌套内层)"]
depth_zero -->|是| check_err{"ncclGroupError<br/>== ncclSuccess?"}
check_err -->|否| fail_cleanup["groupCleanup()"]
check_err -->|是| create_job["创建 ncclGroupJob<br/>转移 thread_local 状态"]
create_job --> blocking{"ncclGroupBlocking?"}
blocking -->|0 非阻塞| spawn_thread["STDTHREADCREATE<br/>groupLaunchNonBlocking"]
blocking -->|1 阻塞| sync_launch["groupLaunch() 同步执行"]
spawn_thread --> ret_progress["返回 ncclInProgress"]
sync_launch --> ret_ok["返回 ncclSuccess"]
fail_cleanup --> reset["groupLocalResetJobState()"]
ret_progress --> reset
ret_ok --> reset---
三、doLaunches:マルチ channel マルチ kernel のラウンドスケジューリング
直感的モデル
doLaunchesは「配膳スケジューラー」です。厨房(GPU)には複数のコンロ(channel)があり、各料理(kernel plan)は順番に提供される必要があります。しかし異なる comm の料理は並行して提供できるかもしれませんし、同じ comm の料理は必ず順番に提供される必要があります。スケジューラーは以下を保証する必要があります:同じ clique 内の comm は同期して進行し(barrier を使用)、異なる clique 間は独立して進行できる。
データ構造とメモリレイアウト
doLaunchesの中核データ構造はncclKernelPlanとcomm->planner.unlaunchedPlansHead。
📎 src/group.cc:427-503
ncclResult_t doLaunches(struct ncclComm* head, int taskType) {
ncclResult_t result = ncclSuccess;
struct ncclComm* cliqueHead = head;
struct ncclComm* cliqueNextHead;
bool useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup;
// This outer loop iterates over cliques of comms which are siblings of the
// same global entity. We calculate a clique as all comms which have the same
// `intraComm0` value.
do {
struct ncclComm* comm = cliqueHead;
bool capturingYes = false, capturingNo = false;
do {
(ncclCudaGraphValid(comm->planner.capturingGraph) ? capturingYes : capturingNo) = true;
CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
NCCLCHECKGOTO(ncclLaunchPrepare(comm), result, failure);
if (useBarrier) ncclCommIntraBarrierIn(comm, 1);
comm = comm->groupNext[taskType];
} while (comm != nullptr && comm != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID) &&
comm->intraComm0 == cliqueHead->intraComm0);
cliqueNextHead = comm;
if (capturingYes && capturingNo) {
// We have entered barriers but are aborting without leaving them. Thus
// these comms are permanently trashed. We need a good mechanism for
// tracking and reporting that.
WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
result = ncclInvalidUsage;
goto failure;
}
while (true) {
// Iterate rounds of launches for clique.
bool moreRounds = false;
comm = cliqueHead;
do {
// Iterate clique members.
struct ncclComm* next = comm->groupNext[taskType];
if (useBarrier) {
// Barrier reduction result tells us if this was the final round.
moreRounds = 0 != ncclCommIntraBarrierOut(comm);
} else {
moreRounds |= comm->planner.unlaunchedPlansHead != nullptr;
}
if (moreRounds) {
// Pop next unlaunched kernel
struct ncclKernelPlan* plan = comm->planner.unlaunchedPlansHead;
if (plan != nullptr) {
comm->planner.unlaunchedPlansHead = plan->next;
CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
NCCLCHECKGOTO(ncclLaunchKernelBefore_NoUncapturedCuda(comm, plan), result, failure);
if (plan->isCeColl) {
NCCLCHECKGOTO(ncclLaunchCeColl(comm, plan), result, failure);
} else if (plan->isRma) {
NCCLCHECKGOTO(ncclLaunchRma(comm, plan), result, failure);
} else {
NCCLCHECKGOTO(ncclLaunchKernel(comm, plan), result, failure);
}
}
// Barrier reduction input indicates if we require further rounds.
if (useBarrier) ncclCommIntraBarrierIn(comm, comm->planner.unlaunchedPlansHead != nullptr ? 1 : 0);
if (plan != nullptr) {
NCCLCHECKGOTO(ncclLaunchKernelAfter_NoCuda(comm, plan), result, failure);
}
} else {
// Final round.
CUDACHECKGOTO(cudaSetDevice(comm->cudaDev), result, failure);
NCCLCHECKGOTO(ncclLaunchFinish(comm), result, failure);
}
comm = next;
} while (comm != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID) && comm != cliqueNextHead);
if (!moreRounds) break;
}
cliqueHead = cliqueNextHead;
} while (cliqueHead != nullptr && cliqueHead != reinterpret_cast<struct ncclComm*>(NCCL_COMM_GROUP_INVALID));
failure:
return result;
}シナリオ駆動の Step-by-Step Walkthrough
シナリオ:二つの comm(commA と commB)が同じ clique に属し(intraComm0が同じ)、各 comm には 3 つの kernel plan が起動待ちです。
第一層ループ:clique の走査
外側のdo-whileはすべての clique を走査します。cliqueHeadは現在の clique の最初の comm です。内側のdo-whileは clique 内のすべての comm を走査します(comm->intraComm0 == cliqueHead->intraComm0)。
各 comm に対して:
cudaSetDevice(comm->cudaDev):その comm に対応する GPU に切り替えます。ncclLaunchPrepare(comm):起動準備、CUDA ストリームの設定、リソースのチェックなどを含みます。ncclCommIntraBarrierIn(comm, 1):barrier に入り、初期値は 1 です。
第二層ループ:ラウンドスケジューリング
while (true)ループは「ラウンド」を実行します。各ラウンドで、clique 内の各 comm が一つの kernel plan を起動します。
鍵はmoreRoundsの計算にあります:
- barrier モードあり(
useBarrier == true):moreRounds = 0 != ncclCommIntraBarrierOut(comm)。ncclCommIntraBarrierOutはcomm 間の barrier リダクション操作です。それは clique 内のすべての comm がncclCommIntraBarrierInを呼び出すのを待ち、その後すべての入力値のリダクション結果(ここでは論理和)を返します。もしどれかの comm にまだ起動されていない plan があれば、リダクション結果は 1 となり、moreRoundsは true で、次のラウンドに進みます。もしすべての comm に未起動の plan がなければ、リダクション結果は 0 となり、moreRoundsは false で、final round に入ります。 - barrier なしモード:
moreRounds |= comm->planner.unlaunchedPlansHead != nullptr。各commに未起動のplanがまだあるかを直接確認する。ここで使われているのは|=、1つでもplanを持つcommがあれば、moreRoundsはtrueになる。
なぜbarrierが必要か?clique内のcommは「兄弟」であり、GPUリソースやネットワーク接続を共有している可能性がある。あるcommが3つのkernelを起動し、別のcommが1つしか起動していない場合、先に起動し終えたcommはncclLaunchFinishに入り、リソースを解放するが、もう一方のcommはまだそのリソースを使っているため、use-after-freeが発生する。barrierはclique内のすべてのcommが同期的に進むことを保証する。つまり、全員が第Nラウンドを起動するか、全員がfinal roundに入るかのどちらかである。
kernel起動分岐
📎 src/group.cc:477-483
if (plan->isCeColl) {
NCCLCHECKGOTO(ncclLaunchCeColl(comm, plan), result, failure);
} else if (plan->isRma) {
NCCLCHECKGOTO(ncclLaunchRma(comm, plan), result, failure);
} else {
NCCLCHECKGOTO(ncclLaunchKernel(comm, plan), result, failure);
}3種類のplanタイプ:
isCeColl:CollNet集合通信(NICオフロードで集合通信を行う)。isRma:RMA(Remote Memory Access)タスク。- デフォルト:通常のGPU kernel。
タイプごとに起動関数は異なるが、いずれも「Before -> Launch -> After」のパターンに従う:
ncclLaunchKernelBefore_NoUncapturedCuda:起動前の準備(kernelパラメータの設定、デバイスへのアップロードなど)。ncclLaunchKernel:実際のkernel起動(cudaLaunchKernel)。ncclLaunchKernelAfter_NoCuda:起動後のクリーンアップ(状態の更新、一時リソースの解放)。
Final round
moreRoundsがfalseの場合、ncclLaunchFinish(comm)を実行する。このステップでは最終的なクリーンアップを行う:planメモリの解放、comm状態の更新、proxyスレッドへの通知など。
並行制御とハードウェアとの相互作用
ncclCommIntraBarrierIn/Outはclique内のcommの同期プリミティブである。その実装にはアトミック操作とスピンウェイトが関わる。Inは値を共有メモリに書き込み、Outはすべてのcommが書き込むのを待ってからリダクション結果を読み取る。このbarrierはプロセス間のものであり(commが異なるプロセスにある場合)、内部的には共有メモリやネットワークを使用する可能性がある。
なぜ単純な「すべてのcommにplanがまだあるかを確認する」ではなくbarrierを使うのか?「確認」は非アトミックだからである:commAが確認したときcommBにはまだplanがあり、commAは続行を決める。しかしcommBはcommAの確認直後に最後のplanを起動し終えてfinal roundに入る。commAはまだkernelを起動しているのに、commBはすでに共有リソースを解放している。barrierは「確認」と「決定」を1つのアトミック操作にすることで、この競合を排除する。
本番環境の落とし穴回避ガイド
落とし穴1:CUDA graph captureの混用。
📎 src/group.cc:448-455
if (capturingYes && capturingNo) {
// We have entered barriers but are aborting without leaving them. Thus
// these comms are permanently trashed. We need a good mechanism for
// tracking and reporting that.
WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
result = ncclInvalidUsage;
goto failure;
}clique内の一部のcommがCUDA graph captureモードにあり、別の一部がそうでない場合、直ちにエラーとなる。コメントには「these comms are permanently trashed」とある——barrierに入ったが抜けていないため、これらのcommのbarrier状態は永遠に一致せず、以降使用できなくなる。これは回復不能なエラーであり、ユーザーは通信ドメインを再構築しなければならない。本番環境でユーザーがgraph captureと非captureのcommを混用すると、ncclInvalidUsageを受け取るが、さらに深刻なのはcommがすでに破損していることである。
落とし穴2:useBarrierの設定依存。useBarrier = ncclParamLaunchMode == ncclLaunchModeGroup。ユーザーがNCCL_LAUNCH_MODE=GROUPを設定した場合、barrierパスを通る。そうでなければ非barrierパスを通る。非barrierパスでは、moreRoundsは|=で累積するが、各commが独立に判断する。commAにはまだplanがあるがcommBにはない場合、commBはfinal roundに入りncclLaunchFinishを実行するが、commAはまだkernelを起動している。これは一部のシナリオでは安全である(comm間に共有リソースがない)が、proxyスレッドやネットワーク接続を共有している場合は問題を引き起こす可能性がある。そのため、デフォルトではbarrierモードの使用が推奨される。
---
四、groupLaunchLegacyの完全な実行チェーン
シナリオ駆動のStep-by-Step Walkthrough
groupLaunchLegacyはブロッキングモードでの完全なコミットフローである。順番に実行する:
フェーズ1:P2P preconnect
📎 src/group.cc:756-774
if (!simInfo && groupCommPreconnectHeadMain != nullptr) {
struct ncclComm* comm = groupCommPreconnectHeadMain;
do {
struct ncclPreconnectJob* job;
NEW_NOTHROW_GOTO(job, ncclPreconnectJob, ret, fail);
job->base.func = ncclP2PPreconnectFunc;
// ...
ncclIntruQueueEnqueue(asyncJobsMain, (struct ncclAsyncJob*)job);
struct ncclComm* next = comm->preconnectNext;
comm->preconnectNext = reinterpret_cast<struct ncclComm*>(0x1);
comm = next;
} while (comm != nullptr);
}
NCCLCHECKGOTO(asyncJobLaunch(asyncJobsMain, groupAbortFlag), ret, fail);preconnectが必要な各commに対してncclP2PPreconnectFuncジョブを作成し、その後一括起動する。ncclP2PPreconnectFuncは内部的にncclTransportP2pSetupを呼び出してP2P接続を確立する。
フェーズ2:対称メモリ登録
📎 src/group.cc:778-808
// only loop through sym alloc and register tasks
for (int type = ncclGroupTaskTypeSymRegister; type <= ncclGroupTaskTypeSymRegister; ++type) {
if (groupCommHeadMain[type]) {
// 按 clique 批量执行 ncclCommGroupRegisterSymmetric
}
}対称メモリ登録(ncclCommWindowRegisterなど)をclique単位で一括実行する。
フェーズ3:集合通信preconnect
📎 src/group.cc:810-870
if (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr) {
// 按 clique 逐个 prepare + preconnect
// 然后 ncclTasksRegAndEnqueue
// 然后 debug check
}これが核心フェーズである。cliqueごとにncclPrepareTasksAndCollPreconnectを呼び出し、その後asyncJobLaunchがpreconnectを実行する。preconnect完了後、ncclTasksRegAndEnqueueを呼び出してタスクをplanに登録し、kernel起動パラメータを生成する。
フェーズ4:doLaunches
📎 src/group.cc:872-874
if ((!simInfo) && (groupCommHeadMain[ncclGroupTaskTypeCollective] != nullptr)) {
NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeCollective], ncclGroupTaskTypeCollective), ret, fail);
}すべてのkernel planを起動する。
フェーズ5:クリーンアップ
📎 src/group.cc:876-903
while (!ncclIntruQueueEmpty(asyncJobsMain)) {
struct ncclAsyncJob* job = ncclIntruQueueDequeue(asyncJobsMain);
if (!job->destroyFlag && job->comm && !job->comm->config.blocking &&
groupCommHeadMain[ncclGroupTaskTypeCollective] == nullptr) {
(void)ncclCommSetAsyncError(job->comm, ret);
}
if (job->destructor) job->destructor((void*)job);
}
for (int type = 0; type < ncclGroupTaskTypeNum; ++type) {
while (groupCommHeadMain[type] != nullptr) {
struct ncclComm* comm = groupCommHeadMain[type];
struct ncclComm* next = comm->groupNext[type];
// Poll for callbacks sent to us from other threads.
if (comm->reclaimSteps == GROUP_MAX_RECLAIM_STEPS) {
NCCLCHECKGOTO(ncclCommPollCallbacks(comm, /*waitSome=*/false), ret, fail);
comm->reclaimSteps = 0;
} else {
comm->reclaimSteps++;
}
(void)ncclGroupCommLeave(comm, type);
if (!comm->config.blocking) {
(void)ncclCommSetAsyncError(comm, ret);
}
groupCommHeadMain[type] = next;
}
}非同期ジョブをクリーンアップし、その後すべてのcommを走査してncclGroupCommLeaveを呼び出す。なおreclaimStepsのカウントに注意:毎GROUP_MAX_RECLAIM_STEPS(10)回の group 呼び出しごとに、callbacks を一度ポーリングします。これは毎回の group で callbacks をポーリングするオーバーヘッドを避けつつ、callbacks が無限に蓄積しないことを保証するためです。
Mermaid 図:groupLaunchLegacyのデータフロー
flowchart LR
subgraph input["输入"]
preconnect["ncclGroupCommPreconnectHead"]
coll["ncclGroupCommHead[Collective]"]
sym["ncclGroupCommHead[SymRegister]"]
end
subgraph phase1["阶段1: P2P preconnect"]
p2p_job["ncclPreconnectJob<br/>func=ncclP2PPreconnectFunc"]
p2p_launch["asyncJobLaunch"]
end
subgraph phase2["阶段2: 对称内存注册"]
sym_job["ncclGroupSymmetricJob<br/>func=ncclCommGroupRegisterSymmetric"]
end
subgraph phase3["阶段3: 集合通信 prepare+preconnect"]
prep["ncclPrepareTasksAndCollPreconnect"]
coll_job["ncclPreconnectJob<br/>func=ncclCollPreconnectFunc"]
reg_enq["ncclTasksRegAndEnqueue"]
end
subgraph phase4["阶段4: kernel 启动"]
do_launch["doLaunches<br/>轮次调度"]
plan["ncclKernelPlan"]
kernel["ncclLaunchKernel"]
end
preconnect --> p2p_job --> p2p_launch
sym --> sym_job
coll --> prep --> coll_job --> reg_enq
reg_enq --> plan --> do_launch --> kernel---
五、groupLaunchEnqueueRearch:新アーキテクチャのスケジューラ
直感モデル
groupLaunchEnqueueRearchは NCCL が開発中の新しいスケジューリングアーキテクチャです。タスクの準備、スケジューリング、起動をより細かい段階に分け、非同期 job キューで管理します。現在、スケジューラとランチャーのモジュールは「未実装」で、legacy のdoLaunches。
📎 src/group.cc:991-996
// Schedule and launch tasks. Scheduler and launcher module of the enqueue framework
// is not yet implemented and falls back to the legacy launcher: a single phased
// doLaunches over the clique, run here on the user's thread.
if (!simInfo && groupCommHeadMain[ncclGroupTaskTypeRawTask] != nullptr) {
NCCLCHECKGOTO(doLaunches(groupCommHeadMain[ncclGroupTaskTypeRawTask], ncclGroupTaskTypeRawTask), ret, fail);
}新アーキテクチャの実行フロー:
1. タスクの管理:ncclMgmtTaskJobFuncがmgmtTaskQueue内のタスク(例:destroy)を処理します。
2. タスクの準備:ncclTaskPrepareJobFuncがncclTaskPrepare。
3. を呼び出しますスケジューリングと起動doLaunches。
:ncclGroupJobLaunchにフォールバックしますasyncJobLaunch新アーキテクチャでは
📎 src/group.cc:113-116
} else {
/* safety check */
assert(state == ncclGroupJobJoined);
}を置き換え、より厳密な状態チェックを追加しています:WARNコピーassertlegacy バージョンではassertではなく
を使用し、新アーキテクチャでは
を使用します。これは新アーキテクチャが状態機械の正確性に対してより高い要求を持っていることを示しています。設計上の考察新アーキテクチャの動機はgroupLaunchLegacy疎結合化
ncclParamEnqueueRearchEnable():legacy の
📎 src/group.cc:1031-1033
static ncclResult_t groupLaunch(struct ncclAsyncJob* job_, ncclSimInfo_t* simInfo = NULL) {
return ncclParamEnqueueRearchEnable() ? groupLaunchEnqueueRearch(job_, simInfo) : groupLaunchLegacy(job_, simInfo);
}が新アーキテクチャと legacy のどちらを使用するかを制御します:NCCL_ENQUEUE_REARCH_ENABLEコピー
---
ユーザーは環境変数
で切り替えられます。本番環境ではデフォルト(legacy)を維持することを推奨します。新アーキテクチャはまだ開発中だからです。
六、非ブロッキング group と非同期エラー処理ncclGroupJobCompleteシナリオ駆動の Step-by-Step WalkthroughncclGroupJobAbort:
📎 src/group.cc:1166-1190
ncclResult_t ncclGroupJobComplete(struct ncclGroupJob* groupJob) {
ncclResult_t ret = ncclSuccess;
if (groupJob && groupJob->nonBlockingInit) {
if (!COMPILER_ATOMIC_EXCHANGE(&groupJob->joined, true, std::memory_order_acq_rel)) {
ret = ncclAsyncJobComplete(&groupJob->base);
}
if (ncclAtomicRefCountDecrement(&groupJob->groupRefCount) == 0) {
delete groupJob;
}
}
return ret;
}
ncclResult_t ncclGroupJobAbort(struct ncclGroupJob* groupJob) {
if (groupJob && groupJob->nonBlockingInit) {
if (!COMPILER_ATOMIC_EXCHANGE(&groupJob->joined, true, std::memory_order_acq_rel)) {
COMPILER_ATOMIC_STORE(&groupJob->abortFlag, true, std::memory_order_relaxed);
ncclAsyncJobComplete(&groupJob->base);
}
if (ncclAtomicRefCountDecrement(&groupJob->groupRefCount) == 0) {
delete groupJob;
}
}
return ncclSuccess;
}と
1. joinedコピー重要な設計:COMPILER_ATOMIC_EXCHANGEアトミックフラグncclGroupJobComplete:
2. を使用して、1 つのスレッドだけが join ロジックを実行できることを保証します。2 つのスレッドが同時に:groupRefCountを呼び出した場合、1 つだけが実際に join し、もう 1 つは直接スキップします。これにより double-join を防ぎます。ncclGroupEndInternal参照カウント
📎 src/group.cc:1108-1111
if (job->comm->groupJob == NULL) {
job->comm->groupJob = groupJob;
groupJob->groupRefCount++;
}内で参照カウントを増やします:ncclGroupJobCompleteコピーncclGroupJobAbortすべての comm が
3. または:ncclGroupJobAbortを呼び出し、参照カウントが 0 になったときのみ、group job が削除されます。これにより group job のライフサイクルが関連するすべての comm をカバーすることが保証されます。abortFlagabort セマンティクスabortFlagはまず
を設定し、その後 join します。ワーカースレッドは実行中に
をチェックし、abort されたことを検出すると早期に終了します。これは「協調的キャンセル」です——スレッドを強制終了するのではなく、スレッド自身がフラグをチェックして終了します。本番環境の落とし穴ガイドncclInProgress落とし穴 3:非ブロッキング group のエラークエリncclCommGetAsyncError。非ブロッキング group はncclInProgressを返し、ユーザーはncclCommDestroyを通じて進捗をクエリする必要があります。ユーザーがクエリを忘れて次の通信を直接呼び出すと、comm->groupJobエラーに遭遇する可能性があります。さらに深刻なのは、group job がまだ実行中にユーザーがncclCommDestroyを呼び出すと、use-after-free が発生することです。NCCL はcomm->groupJobポインタと参照カウントによってこれを防ぎます:
はまずncclGroupJobCompleteをチェックし、未完了の group job があれば待機するかエラーを報告します。落とし穴 4:ncclAsyncJobCompleteの戻り値ncclGroupJobComplete。group job の実行が失敗した場合、ncclSuccessはエラーコードを返します。しかしjoinedは最初の呼び出しでのみこのエラーコードを返し、以降の呼び出しでは
---
を返します(
がすでに true だからです)。ユーザーは最初の呼び出しで戻り値をチェックしなければならず、そうでなければエラー情報を失います。
1. 本章のまとめ:ncclGroupStart/ncclGroupEndこの章では、NCCL の「タスク記述」から「kernel 起動」までの完全なスケジューリングチェーンを分解しました:ncclGroupEndGroup セマンティクス
2. は thread_local 変数を通じてタスクを蓄積し、:ncclPrepareTasks時に一括送信します。ブロッキングモードは同期的に実行し、非ブロッキングモードはスレッドを作成して非同期に実行します。ncclPrepareTasksAndCollPreconnectタスクの準備
3. はアルゴリズム/プロトコルを決定し、:doLaunchesは clique ごとに preconnect を行い、split comms の競合を避けます。
4. ラウンドスケジューリング:asyncJobLaunchは clique ごとにグループ化し、barrier で clique 内の comm を同期し、各ラウンドで 1 つの kernel plan を起動し、すべての plan が起動されるまで続けます。
5. 非同期タスク:groupLaunchEnqueueRearchはアトミック状態機械とビジーウェイトで非同期 job を管理し、高速失敗と abort をサポートします。doLaunches。
新アーキテクチャncclLaunchKernelは開発中の新しいスケジューリングフレームワークで、現在は legacy のncclKernelPlanにフォールバックしますDevComm次の章では kernel 起動の最後の一マイルに入ります:
がどのように
を GPU 上で実際に実行される kernel に変えるか、そしてデバイス側がどのようにncclGroupCommJoinメタデータを読み取るかです。ncclMemoryStackPush(&comm->memScoped)本章の考察とセルフチェック
Q1: もし:ncclMemoryStackPush内の
ここまでで、タスク記述は実行可能な起動計画へと変わった:group セマンティクスは複数の API 呼び出しを一度のコミットに統合し、channel 分割はタスクを複数の実行ストリームに割り当て、doLaunches のラウンドスケジューリングはカーネル間の順序と依存関係を保証する。しかし計画はあくまで計画にすぎない。host 側のタスク記述はどのようにして GPU 上の一つの grid になるのか?次の章では ncclLaunchKernel を深く掘り下げ、パラメータ準備、カーネルバリアントの選択、cudaLaunchKernel 呼び出しを見て、host から device への最後の一跳びを完成させる。
第 8 章:第 8 章:カーネル起動とデバイス側実行:host 側呼び出しから GPU スレッドブロックの起動まで
第 8 章:カーネル起動とデバイス側実行:host 側呼び出しから GPU スレッドブロックの起動まで
前の章では、タスクがどのように複数の channel に分割され、カーネル起動パラメータがどのように生成され、group セマンティクスの下でバッチコミットと依存関係の順序付けがどのように行われるかを分解した。今、起動計画は準備完了だが、それはまだ host 側のデータ構造にすぎない。本章で答える核心的な問いは:ncclKernelPlanはどのようにして GPU 上で実際に動作する grid になるのか?我々はncclLaunchKernelの呼び出しチェーンをたどり、パラメータがどのように kernel args に詰め込まれ、カーネルバリアントがどのように選択され、cuLaunchKernelExがどのように呼び出され、デバイス側のncclKernelMainがどのように共有メモリから作業記述を読み出し、具体的な実装へと分配するかを見ていく。
Plan から Grid へ:起動パスの全体像
詳細に入る前に、まず全体的なメンタルモデルを確立しよう。ncclKernelPlanを「施工図面」と想像してほしい:これは今回起動する channel の数(ブロック数)、各ブロックのスレッド数、実行する work、使用するカーネル関数を記録している。そしてncclLaunchKernelは「施工隊の現場入り」の動作である——図面上の情報を CUDA ドライバが理解できるCUlaunchConfigに翻訳し、次にcuLaunchKernelExを呼び出して grid を実際に GPU 上へ発射する。
もしこの層がなければ、host 側のすべてのスケジューリング(前章の channel 分割、バッチ編成、proxy op の順序付け)は机上の空論にすぎず、GPU 上ではどのカーネルも動作せず、通信は永遠に発生しない。これはエンドツーエンドの主干の最後の一环であり、host と device の境界線でもある。
起動パス全体は三つの段階に要約できる:
1. パラメータ準備(finishPlan + uploadWork):work 構造体、バッチ記述子、kernel args を連続したメモリ領域に組織し、カーネルパラメータ内に置くか、FIFO 内に置くか、永続化バッファ内に置くかを決定する。
2. カーネル発射(ncclLaunchKernel):grid/block 次元を計算し、起動属性(CGA cluster、mem sync domain、launch completion event)を組み立て、cuLaunchKernelEx。
3. デバイス側エントリ(ncclKernelMain):各ブロックはblockIdx.xに基づいて自身の channelId を決定し、args または FIFO から work batch を共有メモリにロードし、次にncclDevFuncTableを通じて具体的なアルゴリズム/プロトコル実装へ分配する。
以下の図は plan から grid への完全な制御フローを示し、重要な分岐判断を含んでいる:
flowchart TD
plan["ncclKernelPlan<br/>channelMask / workBytes / kernelFn"]
finish["finishPlan()<br/>决定 workStorageType"]
check_budget{"sizeof(args)+batchBytes<br/>+workBytes <= workArgsBytes?"}
args_type["workStorageType = Args<br/>work 直接放 kernel 参数"]
fifo_type["workStorageType = Fifo/Persistent<br/>work 放外部缓冲区"]
upload["uploadWork()<br/>拷贝 work 到目标缓冲区"]
launch["ncclLaunchKernel()<br/>组装 CUlaunchConfig"]
check_cluster{"compCap >= 90<br/>且 clusterSize > 0?"}
add_cluster["添加 CLUSTER_DIMENSION<br/>+ SPREAD 调度策略"]
no_cluster["不添加 cluster 属性"]
check_event{"userKernelEvent<br/>且 driver >= 12030?"}
add_event["添加 LAUNCH_COMPLETION_EVENT"]
no_event["无 completion event"]
cu_launch["cuLaunchKernelEx()<br/>发射 grid 到 GPU"]
plan --> finish --> check_budget
check_budget -->|是| args_type
check_budget -->|否| fifo_type
args_type --> upload
fifo_type --> upload
upload --> launch --> check_cluster
check_cluster -->|是| add_cluster
check_cluster -->|否| no_cluster
add_cluster --> check_event
no_cluster --> check_event
check_event -->|是| add_event
check_event -->|否| no_event
add_event --> cu_launch
no_event --> cu_launchこの図は本章の三つの核心関数を固定している:finishPlan、uploadWork、ncclLaunchKernel。次にこれらを一つずつ分解していく。
パラメータ準備:work 構造体がどのように自分の位置を見つけるか
直感的モデル
finishPlanの役割は、宅配仕分けセンターの「梱包係」に似ている。それは散在する work 構造体の山(各 collective または p2p 操作に対応する一つ)に直面し、これらの work をカーネルパラメータという「携帯バックパック」に詰め込むか、FIFO という「コンベアベルト」に載せるか、永続化バッファという「倉庫」に入れるかを決定する必要がある。
もしこの決定を誤ると——例えば work が大きすぎてカーネルパラメータに収まらないのに無理に詰め込むと——カーネル起動は直接失敗する。もし work を間違った場所に置くと、デバイス側が読み取るのはゴミデータであり、通信結果は完全に誤りとなる。
データ構造とメモリレイアウト
まずncclDevKernelArgsの構造を見てみよう。これは host と 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 数组
};この構造体はわずか 5 つのフィールドしか持たないが、各フィールドが重要な情報を担っている。channelMaskは 64 ビットマスクで、各ビットが一つの channel に対応し、デバイス側は__popcllを通じてblockIdx.xに対応する channelId を計算する。workStorageTypeはデバイス側がどこから work を読むかを決定する:Argsは work がカーネルパラメータ内にあることを示し、Fifoはリングバッファ内にあることを示し、Persistentは永続化バッファ内にあることを示す。
ncclDevWorkBatchこれは batch 記述子であり、デバイス側に「この channel の work がどこにあり、いくつあるか」を伝えます:
📎 src/include/device.h:400-421
struct alignas(16) ncclDevWorkBatch {
union {
struct {
uint32_t nextJump:14, nextExtends:1;
uint32_t workType:2, funcId : NCCL_DEV_WORK_BATCH_FUNC_ID_BITS, func : NCCL_DEV_WORK_BATCH_FUNC_BITS;
};
uint32_t flags;
};
uint32_t offsetBase; // work 在 FIFO 中的起始偏移
uint64_t offsetBitset; // 哪些 work 属于这个 channel
};offsetBitsetは 64 ビットマスクで、各ビットが 1 つの work 構造体に対応します。デバイス側は__popcとfns(find n-th set)命令を使って各 work のオフセットを特定します。nextJumpとnextExtendsは複数の batch を連結するために使われます——work が多すぎて 1 つの batch に収まらない場合、「拡張 batch」が作成されます。
Step-by-Step Walkthrough
ここで具体的なシナリオを考えます:1 回の AllReduce が 4 つの channel に分割され、各 channel に 2 つの work 構造体があり、合計 8 つの work があります。
ステップ 1:finishPlanがストレージタイプを決定します。
📎 src/enqueue/enqueue.cc:245-255
if (sizeof(ncclDevKernelArgs) + batchBytes + workBytes <= comm->workArgsBytes) {
plan->workStorageType = ncclDevWorkStorageTypeArgs;
}
plan->kernelArgsSize = sizeof(struct ncclDevKernelArgs) + batchBytes;
plan->kernelArgsSize += (plan->workStorageType == ncclDevWorkStorageTypeArgs) ? workBytes : 0;
plan->kernelArgsSize = alignUp(plan->kernelArgsSize, 16);
plan->kernelArgs =
(struct ncclDevKernelArgs*)ncclMemoryStackAlloc(&comm->memScoped, plan->kernelArgsSize, /*align=*/16);
plan->kernelArgs->comm = comm->devComm;
plan->kernelArgs->channelMask = plan->channelMask;
plan->kernelArgs->workStorageType = plan->workStorageType;ここでの重要な判断は:もしsizeof(ncclDevKernelArgs) + batchBytes + workBytesがcomm->workArgsBytes(通常 4KB)に収まるなら、work を直接 kernel パラメータに入れます。そうでなければ、work は FIFO または永続化バッファに置かれ、kernel パラメータには batch 記述子だけが入ります。
なぜ kernel パラメータを優先するのか? kernel パラメータは CUDA ドライバ内で定数メモリ(constant memory)を通じて渡されるため、デバイス側の読み取りはld.param命令を使い、グローバルメモリから FIFO を読むよりもはるかに高速です。小さいメッセージ(work 総量が少ない)では、これによりレイテンシを大幅に削減できます。
ステップ 2:batch を channel ごとに順番に kernel args に入れます。
📎 src/enqueue/enqueue.cc:257-280
uint64_t hasBatchMask = plan->channelMask;
struct ncclDevWorkBatch* batchPrev[MAXCHANNELS] = {};
struct ncclDevWorkBatch* batchZero = (struct ncclDevWorkBatch*)(plan->kernelArgs + 1);
int batchIx = 0;
while (hasBatchMask != 0) {
uint64_t tmpMask = hasBatchMask;
do {
int c = popFirstOneBit(&tmpMask);
if (!ncclIntruQueueEmpty(&wipChannels[c].workBatchQueue)) {
struct ncclWorkBatchList* batchNode = ncclIntruQueueDequeue(&wipChannels[c].workBatchQueue);
if (batchPrev[c] != nullptr) {
batchPrev[c]->nextJump = int(&batchZero[batchIx] - batchPrev[c]);
}
batchPrev[c] = &batchZero[batchIx];
batchZero[batchIx++] = batchNode->batch;
}
if (ncclIntruQueueEmpty(&wipChannels[c].workBatchQueue)) {
hasBatchMask ^= 1ull << c;
}
} while (tmpMask != 0);
}このコードのロジックは「ラウンドロビン」です:各ラウンドでまだ batch を持つ各 channel から 1 つの batch を取り、channel 番号の昇順でbatchZero配列に入れます。これを行う目的は「各 channel の最初の batch がbatchZero[blockIdx.x]に位置する」ことを保証するためです——デバイス側の各 block はblockIdx.xを通じて自分の最初の batch に直接インデックスでき、検索は不要です。
nextJumpフィールドは同じ channel の次の batch の、現在の batch に対するオフセットを記録します。デバイス側はbatchIx += batch.nextJumpによって次の batch にジャンプでき、リンクリストを形成します。
ステップ 3:uploadWorkが work をターゲットバッファにコピーします。
📎 src/enqueue/enqueue.cc:1365-1430
static ncclResult_t uploadWork(struct ncclComm* comm, struct ncclKernelPlan* plan) {
if (plan->isSymColl || plan->isCeColl || plan->isRma) return ncclSuccess;
size_t workBytes = plan->workBytes;
size_t batchBytes = plan->nWorkBatches * sizeof(struct ncclDevWorkBatch);
void* fifoBufHost;
uint32_t fifoCursor, fifoMask;
switch (plan->workStorageType) {
case ncclDevWorkStorageTypeArgs:
plan->kernelArgs->workBuf = nullptr;
fifoBufHost = (void*)plan->kernelArgs;
fifoCursor = sizeof(ncclDevKernelArgs) + batchBytes;
fifoMask = ~0u;
break;
case ncclDevWorkStorageTypeFifo:
fifoBufHost = comm->workFifoBuf;
fifoCursor = comm->workFifoProduced;
fifoMask = comm->workFifoBytes - 1;
NCCLCHECK(waitWorkFifoAvailable(comm, fifoCursor + workBytes));
plan->kernelArgs->workBuf = comm->workFifoBufDev;
break;
// ...
}
plan->kernelArgs->workMask = fifoMask;
// 修正 batch 的 offsetBase
struct ncclDevWorkBatch* batchZero = (struct ncclDevWorkBatch*)(plan->kernelArgs + 1);
for (int b = 0; b < plan->nWorkBatches; b++) {
batchZero[b].offsetBase += fifoCursor;
}
// 拷贝 work 结构体
struct ncclWorkList* workNode = ncclIntruQueueHead(&plan->workQueue);
while (workNode != nullptr) {
char* dst = (char*)fifoBufHost;
char* src = (char*)(workNode + 1);
for (int n = workNode->size; n != 0; n -= 16) {
memcpy(COMPILER_ASSUME_ALIGNED(dst + (fifoCursor & fifoMask), 16), COMPILER_ASSUME_ALIGNED(src, 16), 16);
fifoCursor += 16;
src += 16;
}
workNode = workNode->next;
}
// ...
}ここにはいくつかの重要なポイントがあります:
1. fifoCursorのセマンティクス:Argsタイプでは、これはkernelArgsの開始アドレスに対するオフセットです;Fifoタイプでは、これは FIFO ベースアドレスに対するオフセットです;Persistentタイプでは、0 から始まります。
2. offsetBaseの修正:finishPlan内の batch のoffsetBaseは plan の work 開始位置に対する相対値です(0 から始まる)。uploadWorkこれを実際のストレージ位置に対するオフセットに変換する必要があります。Argsタイプでは、sizeof(ncclDevKernelArgs) + batchBytesを加算します;Fifoタイプでは、comm->workFifoProduced。
3. 16 バイトアライメントコピー:work 構造体はすべて 16 バイトアライメントされており(alignas(16))、コピー時は 16 バイト単位で行います。COMPILER_ASSUME_ALIGNEDはコンパイラにこのアドレスが 16 バイトアライメントであることを伝え、コンパイラにより効率的なベクトル化命令を生成させます。
4. FIFO 待機:Fifoタイプでは、waitWorkFifoAvailableが FIFO に十分な空きができるまでスピン待機します。この待機はcomm->abortFlagをチェックし、abort 時のデッドロックを回避します。
設計上の考察と本番環境での落とし穴
なぜ 3 つのストレージタイプが必要なのか?これは空間とレイテンシのトレードオフです:
Args:最速(定数メモリ)だが容量が限られる(4KB)。小さいメッセージ、少量の work に適しています。Fifo:容量が大きい(リングバッファ)が、デバイス側の読み取りはグローバルメモリ経由になります。中程度のメッセージに適しています。Persistent:CUDA Graph キャプチャシナリオで使用されます。graph キャプチャ時にはcudaMemcpyを実行できないため、永続化バッファを事前に確保し、work をコピーしてから kernel にそこから読ませる必要があります。
落とし穴 1:FIFO オーバーフローによるデッドロック。もしwaitWorkFifoAvailableがabortFlagをチェックしていなければ、FIFO が満杯でコンシューマ(GPU kernel)が何らかの理由で消費を停止した場合、host は永遠にスピンし続けます。ソースコードでは📎 src/enqueue/enqueue.cc:1333-1349が abort flag を明確にチェックしています:
if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
return ncclInternalError;
}落とし穴 2:offsetBitsetのオーバーフロー。 offsetBitsetは 64 ビットで、1 つの batch 内で最大 64 個の work をサポートします。64 個を超えると、1ull << (offset / workSize)がオーバーフローします。ソースコードではNCCL_MAX_DEV_WORK_BATCH_BYTESによって batch のサイズ(1024 バイト)が制限されており、最小の work 構造体はncclDevWorkColl(約 80 バイト)なので、最大 12 個の work となり、オーバーフローしません。
落とし穴 3:Persistent モードでのメモリリーク。のuploadWorkのPersistent分岐では、fifoBufHostはncclOsAlignedAllocによって割り当てられ、uploadWork_cleanup_fnで解放する必要があります。もしcudaMemcpyAsyncが失敗すると、failラベルがcleanupが null かどうかをチェックし、null ならfifoBufHostを直接解放します。このエラー回復チェーンは📎 src/enqueue/enqueue.cc:1483-1485で確認できます。
Kernel 発射:CUlaunchConfig から cuLaunchKernelEx まで
直感的モデル
ncclLaunchKernelの役割は「ロケット打ち上げコントロールコンソール」に似ています。燃料(work データ)が既に積まれた plan を受け取り、ロケットの飛行パラメータ(grid/block 次元)を計算し、各種打ち上げオプション(cluster、mem sync domain、completion event)を設定してから、打ち上げボタンを押します(cuLaunchKernelEx)。
この段階でエラーが発生した場合——例えば grid 次元の計算を間違えた場合——GPU 上で誤った数の block が起動され、一部の channel の作業が永遠に実行されず、通信がハングします。
データ構造とメモリレイアウト
CUlaunchConfigは CUDA ドライバ API の起動設定構造体であり、NCCL はスタック上でこれを構築します:
📎 src/enqueue/enqueue.cc:1916-1917
CUlaunchConfig launchConfig = {0};
CUlaunchAttribute launchAttrs[6] = {};
int attrs = 0;launchAttrsは最大 6 要素の配列で、各要素はCUlaunchAttributeです。NCCL はハードウェア能力とドライババージョンに応じて、条件付きで異なる属性を追加します:
CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION:CGA cluster 次元(sm90+)CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE:cluster スケジューリングポリシーCU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAIN:メモリ同期ドメイン(CUDA 12.0+)CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT:起動完了イベント(CUDA 12.3+)CU_LAUNCH_ATTRIBUTE_PROGRAMMATIC_STREAM_SERIALIZATION:プログラム的ストリーム直列化(sym kernel)CU_LAUNCH_ATTRIBUTE_NVLINK_UTIL_CENTRIC_SCHEDULING:NVLink 利用率中心スケジューリング(CUDA 13.0+)
Step-by-Step Walkthrough
ステップ 1:grid と block の次元を計算する。
📎 src/enqueue/enqueue.cc:1889-1893
int nChannels = countOneBits(plan->channelMask);
void* sym = plan->kernelFn;
dim3 grid = {(unsigned)nChannels, 1, 1};
dim3 block = {(unsigned)plan->threadPerBlock, 1, 1};
int smem = plan->isSymColl ? plan->kernelDynSmem : ncclShmemDynamicSize(comm->cudaArch);nChannelsはchannelMaskでセットされているビットの数、つまりこの plan が起動する block の数です。各 block は 1 つの channel を担当します。threadPerBlockはscheduleCollTasksToPlan内でplan->threadPerBlock = std::max(plan->threadPerBlock, task->nWarps * WARP_SIZE)によって計算され、すべての task の中で最大のnWarps * 32。
smemは動的共有メモリサイズです。通常の kernel の場合、それはncclShmemDynamicSize(comm->cudaArch)であり、これはコンパイル時定数で、アーキテクチャに依存します(sm70+ ではncclShmemScratchWarpSize * (NCCL_MAX_NTHREADS / WARP_SIZE))。sym kernel の場合、それはplan->kernelDynSmemです。なぜなら sym kernel の共有メモリ要件は異なる可能性があるからです。
ステップ 2:kernel パラメータを組み立てる。
📎 src/enqueue/enqueue.cc:1902-1903
void* extra[] = {CU_LAUNCH_PARAM_BUFFER_POINTER, plan->kernelArgs, CU_LAUNCH_PARAM_BUFFER_SIZE, &plan->kernelArgsSize,
CU_LAUNCH_PARAM_END};これは CUDA ドライバ API のパラメータ受け渡し方式の 1 つです:CU_LAUNCH_PARAM_BUFFER_POINTERはドライバに「パラメータは 1 つずつ渡すのではなく、連続したメモリブロックとして渡す」ことを伝え、CU_LAUNCH_PARAM_BUFFER_SIZEはドライバにこのブロックのサイズを伝えます。この方法の利点は、NCCL がncclDevKernelArgsと後続の batch 配列を一度に渡すことができ、パラメータを 1 つずつパッケージする必要がないことです。
ステップ 3:launch attributes を追加する。
📎 src/enqueue/enqueue.cc:1929-1936
if (clusterSize) {
if (grid.x % clusterSize) clusterSize = 1;
launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_DIMENSION;
launchAttrs[attrs++].value.clusterDim = {clusterSize, 1, 1};
launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_CLUSTER_SCHEDULING_POLICY_PREFERENCE;
launchAttrs[attrs++].value.clusterSchedulingPolicyPreference = CU_CLUSTER_SCHEDULING_POLICY_SPREAD;
}CGA(Cooperative Group Array)は sm90 で導入されたハードウェア機能で、複数の block を 1 つの cluster にまとめることを可能にします。cluster 内の block は同じ SM 群に同時にスケジュールされることが保証され、互いの共有メモリにアクセスできます。NCCL はこの機能を使って、NVLS など block 間同期が必要なアルゴリズムを実装しています。
if (grid.x % clusterSize) clusterSize = 1;という保護に注意してください:cluster 次元は grid 次元を割り切れる必要があり、そうでなければドライバがエラーを返します。もしgrid.xがclusterSizeで割り切れない場合、cluster を使用しない形に退化します。
ステップ 4:launch completion event を追加する。
📎 src/enqueue/enqueue.cc:1944-1964
#if CUDART_VERSION >= 12030
enum ncclImplicitOrder implicitOrder;
NCCLCHECKGOTO(getImplicitOrder(&implicitOrder, comm, plan->persistent, driverVersion), ret, do_return);
if (implicitOrder == ncclImplicitOrderLaunch) {
launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT;
launchAttrs[attrs].value.launchCompletionEvent.event = comm->sharedRes->launchEvent;
launchAttrs[attrs].value.launchCompletionEvent.flags = 0;
attrs++;
if (userKernelEvent) {
NCCLCHECKGOTO(ncclUncapturedStreamPoolAcquire(&comm->sharedRes->uncapturedStreamPool, &relayStream), ret, do_return);
relayUserLaunchCompletionEvent = true;
userKernelEventArmed = true;
}
} else if (userKernelEvent && driverVersion >= 12030) {
launchAttrs[attrs].id = CU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENT;
launchAttrs[attrs].value.launchCompletionEvent.event = plan->launchCompletionEvent;
launchAttrs[attrs].value.launchCompletionEvent.flags = 0;
attrs++;
userKernelEventArmed = true;
}
#endifCU_LAUNCH_ATTRIBUTE_LAUNCH_COMPLETION_EVENTは CUDA 12.3 で導入された機能です:ドライバは kernel が実際に実行を開始したとき(host 側の呼び出しが戻ったときではなく)にイベントを記録します。これは「暗黙的順序」(implicit order)を実装するために極めて重要です——NCCL は複数の kernel が順番に実行されることを保証する必要がありますが、host 側でブロッキング待機はしたくありません。
getImplicitOrderのロジックは:ユーザーがlaunchOrderImplicitを設定しており、かつドライババージョンが十分に新しければ、ncclImplicitOrderLaunchを使用し(launch event で順序付け);そうでなければncclImplicitOrderSerialを使用します(completion event で順序付け、つまり直列実行)。
ステップ 5:cuLaunchKernelEx。
📎 src/enqueue/enqueue.cc:1978-1996
launchConfig.gridDimX = grid.x;
launchConfig.gridDimY = grid.y;
launchConfig.gridDimZ = grid.z;
launchConfig.blockDimX = block.x;
launchConfig.blockDimY = block.y;
launchConfig.blockDimZ = block.z;
launchConfig.sharedMemBytes = smem;
launchConfig.attrs = launchAttrs;
launchConfig.numAttrs = attrs;
launchConfig.hStream = launchStream;
if (userKernelEvent && !userKernelEventArmed) {
WARN("CUDA launch-completion events require CUDA 12.3 or newer; recording the user event before launch");
CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, launchStream), ret, do_return);
}
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);
if (relayUserLaunchCompletionEvent) {
CUDACHECKGOTO(cudaStreamWaitEvent(relayStream, comm->sharedRes->launchEvent, 0), ret, do_return);
CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, relayStream), ret, do_return);
}cuLaunchKernelExは CUDA 12.0 で導入された新しい API で、launch attributes をサポートします。古いドライバ(< 11.8)の場合、NCCL はcuLaunchKernel:
📎 src/enqueue/enqueue.cc:1998-2007
} else {
// Standard kernel launch
if (userKernelEvent) {
WARN("CUDA launch-completion events require CUDA 12.3 or newer; recording the user event before launch");
CUDACHECKGOTO(cudaEventRecord(plan->launchCompletionEvent, launchStream), ret, do_return);
}
CUCHECKGOTO(cuLaunchKernel(fn, grid.x, grid.y, grid.z, block.x, block.y, block.z, smem, launchStream, nullptr,
extra),
ret, do_return);
}並行制御とハードウェア相互作用
Launch completion event の relay メカニズム。ncclImplicitOrderLaunchを使用し、かつユーザーがlaunchCompletionEventを提供した場合、NCCL はユーザーの event を直接ドライバに渡すことができません。なぜならドライバは 1 つの launch completion event しかサポートしていないからです。NCCL の方法は:
1.comm->sharedRes->launchEventをドライバに渡す。
2.relayStream上でlaunchEvent。
を待つ。relayStream3.
上でユーザーの event を記録する。
Mem Sync Domain。 📎 src/enqueue/enqueue.cc:1938-1942これによりユーザーの event は、host 側の呼び出しが戻ったときではなく、kernel が実際に実行を開始した後にトリガーされます。CU_LAUNCH_ATTRIBUTE_MEM_SYNC_DOMAINsm90+ では、NCCL はcudaLaunchMemSyncDomainRemoteを
に設定します。これは Hopper アーキテクチャで導入されたメモリ同期ドメイン機構で、異なる kernel のメモリバリアを分離し、不要な同期オーバーヘッドを削減するために使用されます。
本番環境の落とし穴ガイド落とし穴 1:cluster 次元が割り切れないことによる起動失敗。grid.xもしclusterSizeがCUDA_ERROR_INVALID_VALUEで割り切れない場合、ドライバはif (grid.x % clusterSize) clusterSize = 1;を返します。ソースコードではcgaClusterSizeによって保護されていますが、これは cluster 機能が黙って無効化されることも意味します。ユーザーが cluster による性能向上を期待している場合は、nChannelsと
の関係を確認する必要があります。 ncclInitKernelsForDevice初期化時に各カーネルのドライバ要件をチェックします:
📎 src/enqueue/enqueue.cc:71-76
for (int k = 0; k < kcount; k++) {
if (kptrs[k] != nullptr && driverVersion < krequires[k]) {
INFO(NCCL_INIT, "Skipping %skernel %d which requires driver %d", sym ? "symmetric " : "", k, krequires[k]);
kptrs[k] = nullptr;
if (kptrsProfile != nullptr) kptrsProfile[k] = nullptr;
}ドライバのバージョンが不十分な場合、カーネルポインタは null に設定されます。その後、スケジューラがこのカーネルを選択すると、cuLaunchKernelExは失敗します。NCCL のチューナーは利用不可のカーネルの選択を避けるはずですが、ユーザーがアルゴリズムを強制指定した場合(NCCL_ALGO)、この問題が発生する可能性があります。
落とし穴 3:launchCompletionEvent古いドライバ上での動作。ドライババージョン < 12.3 の場合、NCCL はカーネル起動前に event を記録します。つまり、event はカーネルが実際に実行を開始した時ではなく、カーネルが実行を開始する前にトリガーされます。これにより、ユーザーコードのタイミングに関する仮定が崩れる可能性があります。
デバイス側エントリ:blockIdx から具体的な実装へ
直感的モデル
ncclKernelMainは GPU 上の各 block の「エントリホール」です。block が SM にスケジュールされて実行を開始すると、まずこのホールに入り、3つのことを行います:自分の身元を確定する(自分はどの channel か)、自分のタスクを受け取る(work batch をロードする)、そして対応する窓口で用事を済ませる(具体的なアルゴリズム実装を呼び出す)。
このエントリがなければ、各カーネルバリアントが「自分は誰で、何をすべきか」を自分で処理する必要があり、コードが大量に重複します。ncclKernelMainはテンプレートパラメータSpecializedFnIdとSpecializedRunWorkBatchを通じて「汎用エントリ + 特化実行」のパターンを実現しています。
データ構造とメモリレイアウト
デバイス側の共有メモリレイアウトはncclKernelMainを理解する鍵です。ncclShmemDataはすべての block が共有する「ワークベンチ」です:
📎 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;
};この構造体のレイアウトは綿密に設計されています:
argsは先頭に配置されています。これはカーネルパラメータからコピーされるため、16 バイトアライメントが必要です。commとchannelも 16 バイトアライメントされています。これらはcopyToShmem16を介してベクトル化命令でコピーされるためです。workStorageは work 構造体の一時保管領域で、サイズはncclMaxDevWorkBatchBytes()(sm90+ は 16KB)です。groups配列は各 group の接続情報を格納するために使用され、NCCL_MAX_GROUPSは 16 です。
Step-by-Step Walkthrough
ステップ 1:カーネル引数を共有メモリにコピー。
📎 src/device/common.h:426-428
if (tid < sizeof(ncclDevKernelArgs) / sizeof(uint32_t)) {
((uint32_t*)&ncclShmem.args)[tid] = ((uint32_t*)args)[tid];
}ここでは先頭のsizeof(ncclDevKernelArgs) / 4個のスレッドを使用し、各スレッドが 1 つの 32 ビットワードをコピーします。なぜ共有メモリにコピーするのか?カーネルパラメータは定数メモリにあり、アクセス速度は速いですが、各スレッドがアクセスする際にブロードキャストのオーバーヘッドが発生します。共有メモリにコピーした後は、すべてのスレッドが同じ共有メモリにアクセスするため、効率が向上します。
ステップ 2: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();このコードのロジックは:各セットされた channel(args->channelMask & (1ull << tid))について、その前にいくつのセットされた channel があるか(__popcll)を計算し、その数がblockIdx.xと等しければ、現在の block がその channel を担当します。
例を挙げます:channelMask = 0b1011(channel 0、1、3 に作業あり)。blockIdx.x = 0の block は channel 0 を担当(前に 0 個のセットビット)、blockIdx.x = 1の block は channel 1 を担当(前に 1 個のセットビット)、blockIdx.x = 2の block は channel 3 を担当(前に 2 個のセットビット)。
ステップ 3:comm と channel を共有メモリにロード。
📎 src/device/common.h:446-478
switch (tid / WARP_SIZE) {
case 0:
{
void* dst = &ncclShmem.comm;
void* src = ncclShmem.args.comm;
int bytes = sizeof(ncclKernelComm);
static_assert(sizeof(ncclKernelComm) <= 16 * WARP_SIZE,
"ncclKernelComm cannot be loaded by a single warp in one insn.");
copyToShmem16(tid, dst, src, bytes);
}
break;
case 1:
{
void* dst = &ncclShmem.channel;
void* src = &((ncclKernelCommAndChannels*)ncclShmem.args.comm)->channels[ncclShmem.channelId];
int bytes = sizeof(ncclDevChannel);
static_assert(sizeof(ncclDevChannel) <= 16 * WARP_SIZE,
"ncclDevChannel cannot be loaded by a single warp in one insn.");
copyToShmem16(tid - WARP_SIZE, dst, src, bytes);
}
break;
default:
{
int subtid = tid - 2 * WARP_SIZE;
int subtn = tn - 2 * WARP_SIZE;
loadWorkBatchToShmem(subtid, subtn, args, /*batchIx=*/blockIdx.x);
}
break;
}
__syncthreads();ここではスレッドを 3 つのグループに分けます:
- 第 0 の warp:
ncclKernelComm(通信子メタデータ)を共有メモリにロード。 - 第 1 の warp:現在の channel の
ncclDevChannel(channel メタデータ)を共有メモリにロード。 - 残りの warp:work batch を共有メモリにロード。
copyToShmem16はインライン PTX で実装された 16 バイトコピー関数です:
📎 src/device/common.h:131-139
inline __device__ void copyToShmem16(int tid, void* dst, void const* src, int bytes) {
int offset = 16 * tid;
if (offset < bytes) {
uint64_t a = 0, b = 0;
asm volatile("ld.v2.u64 {%0,%1},[%2];" : "=l"(a), "=l"(b) : "l"((char const*)src + offset) : "memory");
uint32_t udst = (uint32_t)__cvta_generic_to_shared(dst);
asm volatile("st.shared.v2.u64 [%0],{%1,%2};" ::"r"(udst + offset), "l"(a), "l"(b) : "memory");
}
}これはld.v2.u64でグローバルメモリから 16 バイトをロードし、st.shared.v2.u64で共有メモリにストアします。__cvta_generic_to_sharedは汎用アドレスを共有メモリアドレスに変換します(共有メモリアドレス空間は 32 ビットです)。
ステップ 4:work batch をロード。
loadWorkBatchToShmemは最も複雑な部分です。そのタスクは、batch 記述子が指す work 構造体をグローバルメモリ(またはカーネルパラメータ)から共有メモリのworkStorageにコピーすることです。
📎 src/device/common.h:142-260
__device__ __forceinline__ void loadWorkBatchToShmem(int tid, int tn, struct ncclDevKernelArgs const* args,
int batchIx) {
int lane = tid % WARP_SIZE;
int workCursor = 0;
while (true) {
struct ncclDevWorkBatch batch = ((struct ncclDevWorkBatch*)(args + 1))[batchIx];
uint8_t* fnsOfBitset = (uint8_t*)ncclScratchForWarp(threadIdx.x / WARP_SIZE);
__syncwarp();
if (uint32_t(batch.offsetBitset) & (1u << lane)) {
int nWorksBelow = __popc(uint32_t(batch.offsetBitset) & ((1u << lane) - 1));
fnsOfBitset[nWorksBelow] = lane;
}
int nWorksLow32 = __popc(uint32_t(batch.offsetBitset));
if (uint32_t(batch.offsetBitset >> 32) & (1u << lane)) {
int nWorksBelow = nWorksLow32;
nWorksBelow += __popc(uint32_t(batch.offsetBitset >> 32) & ((1u << lane) - 1));
fnsOfBitset[nWorksBelow] = 32 + lane;
}
int nWorks = nWorksLow32 + __popc(uint32_t(batch.offsetBitset >> 32));
__syncwarp();
// ...
}
}このコードの核心はfnsOfBitsetの計算です:offsetBitset内の n 番目のセットビットについて、そのビットインデックスは何か。PTX にはfns命令がありこれを行えますが、多くの SASS 命令に展開されます。NCCL の方法は共有メモリを使用します:各 lane が自分のビットがセットされているかチェックし、セットされていればその前にいくつのセットビットがあるかを計算し、自分の lane 番号をfnsOfBitset[nWorksBelow]。
に書き込みます。次に実際のコピーです:
📎 src/device/common.h:209-241
if (tid < nPacks) {
int srcWork = fnsOfBitset[dstWork];
ulonglong2 tmp;
if (ncclShmem.args.workStorageType == ncclDevWorkStorageTypeArgs) {
char* src = (char*)args + (batch.offsetBase + srcWork * workSize + packInWork * 16);
tmp = *(ulonglong2*)src; // becomes ld.param.v2.u64
} else {
char* src = (char*)ncclShmem.args.workBuf +
((batch.offsetBase + srcWork * workSize + packInWork * 16) & ncclShmem.args.workMask);
tmp = *(ulonglong2*)src; // becomes ld.v2.u64
}
char* dst = ncclShmem.workStorage;
dst += (workCursor + dstWork) * workSize + packInWork * 16;
*(ulonglong2*)dst = tmp;
}ここに重要な最適化があります:Args型の場合、ソースコードは直接(char*)args + offsetと書き、コンパイラはこれがカーネルパラメータからの読み取りであることを認識し、ld.param.v2.u64命令を生成します。Fifo型の場合、ソースコードは(char*)ncclShmem.args.workBuf + (offset & workMask)と書き、コンパイラはld.v2.u64命令を生成します。
コメントでは特にこの 2 つのケースを統合してはならないと強調しています:
📎 src/device/common.h:212-229
// The loads done in these two cases must be kept separate since we are
// relying on the compiler to use "ld.param" in the first one. The parameter
// space is not generically addressable, so any attempt to load through
// a pointer that *might* be parameter space backed will cause the
// compiler to spill the parameter struct (4K!) to each thread's local space
// before creating a pointer (to the spill) and decimate perf.コンパイラがポインタがパラメータ空間を指すかグローバル空間を指すかを確定できない場合、パラメータ構造体全体(4KB)を各スレッドのローカルメモリにスピルし、性能が急激に低下します。
ステップ 5:work を実行。
📎 src/device/common.h:481-497
while (ncclShmem.aborted == 0) {
profiler(START);
if (0 <= SpecializedFnId && ncclShmem.funcId == (unsigned)SpecializedFnId) {
SpecializedRunWorkBatch().run();
} else {
ncclDevFuncTable[ncclShmem.funcId]();
}
if (ncclShmem.nextBatchIx == -1) break;
int batchIx = ncclShmem.nextBatchIx;
__syncthreads();
profiler(STOP);
if (ncclShmem.comm.progressCounters != nullptr) __syncthreads();
loadWorkBatchToShmem(tid, tn, args, batchIx);
__syncthreads();
}ここに重要な最適化があります:もしSpecializedFnIdが現在の batch のfuncIdと一致すれば、直接SpecializedRunWorkBatch().run()を呼び出します。これはコンパイル時特化された関数で、関数ポインタ呼び出しのオーバーヘッドがありません。そうでなければ、ncclDevFuncTable[ncclShmem.funcId]()を介して間接呼び出しします。
ncclDevFuncTableはデバイス側の関数ポインタ配列で、generate.py生成:
📎 src/device/generate.py:261-270
out("__device__ ncclDevFuncPtr_t const ncclDevFuncTable[] = {\n")
index = 0
for fn in primary_funcs:
sym = paste("_", "ncclDevFunc", *fn)
cudart, arch = required_cuda(*fn)
if (cudart, arch) != (0, 0):
out("#if CUDART_VERSION >= %d && __CUDA_ARCH__ >= %d\n" % (cudart ,arch))
out("/*%4d*/ %s,\n" % (index, sym))
if (cudart, arch) != (0, 0):
out("#else\n" "/*%4d*/ nullptr,\n" "#endif\n" % index)
index += 1
out("nullptr};\n")設計思考と本番環境での落とし穴
なぜ__grid_constant__? 📎 src/device/common.h:19-24
#if __CUDA_ARCH__ >= 700
// __grid_constant__ appears to break cuda-gdb
#define NCCL_GRID_CONSTANT __grid_constant__
#else
#define NCCL_GRID_CONSTANT
#endif__grid_constant__このパラメータが読み取り専用であることをコンパイラに伝え、定数メモリに配置できるようにします。これにより、デバイス側での読み取り時にld.param命令を使用し、グローバルメモリからの読み取りよりも高速になります。コメントには cuda-gdb を破壊すると記載されているため、sm70+ でのみ有効化されています。
落とし穴 1:workStorageオーバーフロー。 workStorageのサイズはncclMaxDevWorkBatchBytes()、sm90+ は 16KB です。もしnWorks * workSizeがこの値を超えると、書き込みが範囲外になります。ソースコードではNCCL_MAX_DEV_WORK_BATCH_BYTESによってホスト側でバッチサイズを制限していますが、デバイス側には追加のチェックがありません。ホスト側の制約が回避された場合(例えば環境変数の変更など)、共有メモリの範囲外アクセスが発生します。
落とし穴 2:__syncthreads()の欠如によるデータ競合。の後には、すべてのスレッドが完全なloadWorkBatchToShmemを参照できるようにするために__syncthreads()が必要です。ソースコードではworkStorageに📎 src/device/common.h:479があります。この同期が削除されると、一部のスレッドが__syncthreads(); // publish ncclShmemの書き込みが完了する前に読み取りを開始し、ゴミデータを読み取る可能性があります。workStorage落とし穴 3:abort チェックのタイミング。
は各バッチの開始時にのみ abort をチェックします。あるバッチの実行時間が長い場合、abort シグナルが有効になるまでに時間がかかる可能性があります。これは設計上のトレードオフです:より頻繁なチェックはオーバーヘッドを増やしますが、応答が速くなります。 while (ncclShmem.aborted == 0)Kernel バリアントの選択:generate.py が kernel リストを生成する方法
直感的モデル
の役割は「自動車工場の生産ライン設計者」に似ています。それは巨大な組み合わせ空間(7 種の集合操作 × 5 種のリダクション操作 × 12 種のデータ型 × 7 種のアルゴリズム × 3 種のプロトコル)に直面し、決定する必要があります:どの組み合わせに専用の kernel を生成する必要があるか?どの組み合わせが汎用 kernel を共有できるか?
generate.pyすべての組み合わせに kernel を生成すると、コンパイル時間とバイナリサイズが爆発します。汎用 kernel を 1 つだけ生成すると、実行時に関数ポインタ呼び出しと分岐判定により遅くなります。
の解決策は「代表 kernel」です:各等価クラスに 1 つの kernel を生成し、実行時に関数ポインタテーブルを介してディスパッチします。generate.pyデータ構造とメモリレイアウト
は 3 つの重要なファイルを生成します:
generate.py:デバイス側の
1. device_table.cu、funcId を具体的なデバイス関数にマッピングします。ncclDevFuncTable:ホスト側の
2. host_table.ccなどのテーブル。ncclDevKernelList、ncclDevKernelForFunc、ncclDevFuncRowToId各
3. :具体的な kernel 実装。<coll>_<op>_<ty>.cuステップ 1:すべての関数行を列挙する。
Step-by-Step Walkthrough
コピー
📎 src/device/generate.py:186-199
def enumerate_func_rows():
yield ("SendRecv", None, None, None, None)
for coll in ("AllGather", "Broadcast", "AllGatherV"):
algos = algos_of_coll[coll]
for algo in algos:
for proto in all_protos:
yield (coll, None, None, algo, proto)
for coll in ("AllReduce", "Reduce", "ReduceScatter"):
algos = algos_of_coll[coll]
for redop in all_redops:
for ty in all_tys:
for algo in algos:
for proto in all_protos:
yield (coll, redop, ty, algo, proto)の計算式と一致する必要があります:ncclDevFuncId()コピー
📎 src/include/device.h:646-706
inline int ncclDevFuncId(int coll, int devRedOp, int type, int algo, int proto) {
constexpr int NumTypes = ncclNumTypes;
int row;
do {
row = 0; // ncclDevFuncIndex_P2p
if (coll == ncclFuncSendRecv) break;
row += 1;
// ...
} while (false);
return ncclDevFuncRowToId[row];
}ncclDevFuncIdを介して「メイン関数 ID」にマッピングされます。このマッピングの理由は:多くの行が同じメイン関数にマッピングされる可能性があるためです(例えばすべてのncclDevFuncRowToIdの行はAllReduce Sum i32のメイン関数にマッピングされます)。AllReduce Sum u32ステップ 2:メイン関数と kernel 関数を計算する。
コピー
📎 src/device/generate.py:211-225
func_rows = [validate(*fn) for fn in enumerate_func_rows()]
primary_funcs = sorted(set(equivalent_primary(*fn) for fn in func_rows if fn is not None))
primary_to_index = {fn: i for (i,fn) in zip(range(len(primary_funcs)), primary_funcs)}
kernel_funcs = sorted(set(best_kernel(*fn) for fn in primary_funcs))equivalent_primaryコピー
📎 src/device/generate.py:158-166
def equivalent_primary(coll, redop, ty, algo, proto):
if coll in ("AllReduce", "Reduce", "ReduceScatter"):
if redop in ("Sum","Prod","PreMulSum","SumPostDiv") and ty[0]=="i":
return (coll, redop, "u"+ty[1:], algo, proto)
if redop=="MinMax" and ty[0]=="i" and ("NVLS" not in algo):
return (coll, redop, "u"+ty[1:], algo, proto)
return (coll, redop, ty, algo, proto)best_kernelのアルゴリズムはAllGatherにマッピングされます)AllGather RING LL):
📎 src/device/generate.py:171-183
def best_kernel(coll, redop, ty, algo, proto):
def best(coll, redop, ty, algo, proto):
if coll=="Nop": return ("Generic", None, None, None, None)
if coll=="SendRecv": return ("SendRecv", None, None, None, None)
if exact_kernel_names: return (coll, redop, ty, algo, proto)
if coll in ("AllGather","Broadcast","AllGatherV"): return (coll, None, None, "RING", "LL")
return (coll, "Sum", ty, ("TREE" if algo=="TREE" else "RING"), "LL")
kfn = equivalent_primary(*best(coll, redop, ty, algo, proto))
if not func_filter(*kfn): return ("Generic", None, None, None, None)
return kfnステップ 3:kernel 定義を生成する。
📎 src/device/generate.py:458-480
(_, kfns) = name_to_kernels.get(name) or (None, [])
for kfn in kfns:
(coll, redop, ty, algo, proto) = kfn
sym = kernel_suffix(kfn)
fn_id = primary_to_index[kfn]
cudart, arch = required_cuda(*kfn)
s = "DEFINE_ncclDevKernel({sym}, ncclFunc{coll}, {redop_cxx}, {ty_cxx}, NCCL_ALGO_{algo}, NCCL_PROTO_{proto}, {fn_id})\n"
# ...
out(s.format(...))DEFINE_ncclDevKernelマクロ展開後は:
📎 src/device/common.h:507-509
#define DEFINE_ncclDevKernel(suffix, coll, redop, ty, algo, proto, specializedFnId) \
__global__ void ncclDevKernel_##suffix(ncclDevKernelArgs4K NCCL_GRID_CONSTANT const args4K) { \
ncclKernelMain<specializedFnId, RunWorkBatch<coll, ty, redop<ty>, algo, proto>>(&args4K.args); \
}したがって、各 kernel は__global__関数であり、ncclKernelMainを呼び出し、テンプレートパラメータはspecializedFnIdとRunWorkBatch<coll, ty, redop<ty>, algo, proto>。
設計思考と本番環境での落とし穴
なぜ「代表 kernel」を使用し、各組み合わせに 1 つの kernel ではないのか?コンパイル時間とバイナリサイズのトレードオフ。完全な組み合わせ空間は 7 × 5 × 12 × 7 × 3 ≈ 8820 個の kernel であり、各 kernel のコンパイルには数秒かかり、合計で数時間必要です。また、バイナリサイズは数百 MB に達します。代表 kernel にマッピングすることで、実際に生成される kernel 数は数十個に削減されます。
落とし穴 1:NCCL_EXACT_KERNEL_NAMESによるコンパイル爆発。この環境変数が設定されている場合、best_kernelは元の関数を返し、すべての組み合わせで kernel が生成されます。これは開発時には有用です(どの kernel がコンパイルされるかを正確に制御できる)が、本番環境ではコンパイル時間が長くなりすぎます。
落とし穴 2:required_cudaのバージョンチェック。一部の kernel は特定の CUDA バージョンやアーキテクチャを必要とします:
📎 src/device/generate.py:130-154
ここまでで、kernel は GPU 上で起動され、デバイス側もワーク記述を取得しました。しかし真のパフォーマンスを決定するのは、デバイス内部でどのようにデータを転送するかです。次の章では src/device 配下の 3 つのプロトコルプリミティブ:LL、LL128、Simple を深掘りし、同じ AllReduce ロジックに为什么 3 セットの転送プリミティブが必要なのか、そしてそれらの同期方式、バッファレイアウト、flag セマンティクスの違いを見ていきます。
第 9 章:第 9 章:デバイス側通信プリミティブ:LL、LL128、Simple の 3 プロトコルにおけるデータ転送実装
第 9 章:デバイス側通信プリミティブ:LL、LL128、Simple の 3 プロトコルにおけるデータ転送実装
前章では、host 側がどのように一度の AllReduce を __global__ kernel に変換するかを追跡し、デバイス側のエントリポイント ncclKernelMain がアルゴリズムとプロトコルに基づいてディスパッチを行うことを確認しました。しかし、ディスパッチは単にツールを選択するだけであり、実際に性能を決定するのはこれらのツールがどのようにデータ転送を実行するかです。本章では、src/device 配下の三つの転送プリミティブ、LL、LL128、Simple を深く掘り下げ、それぞれのデータ転送実装を一つずつ分析し、異なるプロトコルがレイテンシと帯域幅の間でどのようにトレードオフを行っているかを理解します。
なぜ同じ AllReduce に三つの転送プリミティブが必要なのか
まず直感的なモデルを構築しましょう。パイプライン工場を想像してください。原料(ユーザーデータ)が一端から入り、成品がもう一端から出て、途中にいくつかの工位(rank)があり、互いに半成品を交換する必要があります。半成品を転送する方法は三つあります:
- LL(Low Latency):二人が向かい合ってメモを手渡すように、渡すと同時に相手は「これはあなたへのものだ」と分かり、ハンドシェイクのオーバーヘッドがほぼゼロです。しかしメモは非常に小さく、一度に 8 バイトの有効データしか渡せません。小さいメッセージに適しています。
- LL128:メモを 128 バイトの付箋紙に置き換え、一度に 120 バイトの有効データを渡しますが、付箋紙は 16 バイト境界に整列して配置する必要があり、そうでなければまず共有メモリで「再レイアウト」する必要があります。中程度のメッセージに適しています。
- Simple:宅配ロッカーのように、まず荷物をロッカー(FIFO バッファ)に入れ、次に「N 番ロッカーに荷物あり」という通知を送ります。ハンドシェイクのオーバーヘッドは大きいですが、一度に多くを運べます。大きいメッセージに適しています。
もしプリミティブが一つしかなかったらどうなるでしょうか?LL だけを使うと、大きいメッセージは「各メッセージごとに相手の flag 確認を待つ」ため帯域幅が圧迫されてしまいます。Simple だけを使うと、小さいメッセージは「FIFO 書き込み + 通知送信 + 通知待ち」の固定オーバーヘッドによりレイテンシが爆発します。NCCL の性能曲線が 8KB、128KB 付近で明らかな変曲点を持つ根源はここにあります。
三つのプリミティブは同じテンプレート骨格を共有しますPrimitives<T, RedOp, Fan, Direct, Proto, P2p, isNetOffload>、Protoというテンプレートパラメータを通じて三つのバージョンに特化されます📎 src/device/primitives.h:117-117。ProtoLL、ProtoLL128、ProtoSimple三つの構造体はそれぞれプロトコル関連の定数と計算メソッドを持ち📎 src/device/primitives.h:25-75、アルゴリズムコードはprims.send()、prims.recvReduceSend()のような統一インターフェースを呼び出すだけで、基盤がどのプロトコルであるかを気にしません。
flowchart TD
algo["算法层 all_reduce.h<br/>调用 prims.recvReduceSend()"] --> dispatch{"Proto 模板参数?"}
dispatch -->|ProtoLL| ll["Primitives<..., ProtoLL, ...><br/>prims_ll.h"]
dispatch -->|ProtoLL128| ll128["Primitives<..., ProtoLL128, ...><br/>prims_ll128.h"]
dispatch -->|ProtoSimple| simple["Primitives<..., ProtoSimple<...>, ...><br/>prims_simple.h"]
ll --> llop["LLGenericOp<RECV,SEND,SrcBuf,DstBuf>"]
ll128 --> ll128op["GenericOp -> recvReduceSendCopy"]
simple --> simpleop["genericOp -> waitPeer / reduceCopy / postPeer"]この図は「なぜ同じ AllReduce ロジックに三つの転送プリミティブが必要なのか」を説明しています:アルゴリズム層はプロトコル非依存であり、プロトコルの差異はPrimitivesの三つの特化にカプセル化されています。
LL:flag をデータ行に埋め込んだゼロハンドシェイク転送
直感的モデル
LL の核心思想は:「データ」と「データが準備できたか」のマークを同じ 16 バイトの読み書きユニットに詰め込むことです。受信側は追加の「通知メッセージ」を必要とせず、データ行の flag フィールドをポーリングするだけで、flag が一致すればデータが到着したことを示します。これは手紙を送る際に「受取人の署名」を封筒に直接印刷するようなもので、郵便配達員は署名を見れば配達すべきかどうかが分かり、別に受領書を送る必要がありません。
もしこの設計がなければ、受信側はまず「データが書き込まれた」という通知を待ち、それからデータを読みに行く必要があり、二回のメモリ往復でレイテンシが倍になります。
データ構造とメモリレイアウト
LL の転送ユニットはunion ncclLLFifoLineであり、storeLLのアセンブリからそのレイアウトが分かります📎 src/device/prims_ll.h:154-158:
st.volatile.global.v4.u32 [%0], {%1,%2,%3,%4};
// 写入 4 个 u32:data1, flag, data2, flag一つのncclLLFifoLineは 16 バイトで、配置は[data1(4B) | flag(4B) | data2(4B) | flag(4B)]です。有効データは 8 バイト(data1 + data2)のみで、残りの 8 バイトはすべて flag です。これがProtoLL::calcBytePerGrain()がsizeof(uint64_t)を返す理由です——「One 16-byte line has 8-bytes of data」📎 src/device/primitives.h:55-57。
主要フィールド(Primitivesの LL 特化)📎 src/device/prims_ll.h:20-42:
| フィールド | 型 | 役割 |
|---|---|---|
recvStep[i] / sendStep[i] | uint64_t[MaxRecv/MaxSend] | 各 peer のステップカウント。バッファオフセットと flag 値を決定する |
recvBuff[i] / sendBuff[i] | ncclLLFifoLine* | 各 peer の FIFO バッファベースアドレスを指す |
recvConnHeadPtr | volatile uint64_t* | 受信側の「どこまで消費したか」のグローバルポインタ |
sendConnHeadPtr | volatile uint64_t* | 送信側の「対端がどこまで消費したか」のグローバルポインタ |
sendConnHeadCache | uint64_t | 前回読んだ head 値をキャッシュし、毎回グローバルメモリを読むのを避ける |
バッファオフセットはrecvOffset(i) = (recvStep[i] % NCCL_STEPS) * stepLinesで計算されます📎 src/device/prims_ll.h:44-46,NCCL_STEPSはリングバッファのスロット数、stepLinesは各スロットの行数です。flag 値はrecvFlag(i) = NCCL_LL_FLAG(recvStep[i] + 1)で計算されます📎 src/device/prims_ll.h:56-58、+1に注意——flag の初期値は 0 なので、最初のステップの flag は 1 でなければ「未書き込み」と区別できません。
シナリオ駆動 Walkthrough:一回の recvReduceSend
rank 0 が Ring AllReduce でrecvReduceSendを実行すると仮定します:前の rank からデータを受信し、ローカルデータと reduce し、次の rank に送信します。呼び出しチェーンはrecvReduceSend(inpIx, eltN) → LLGenericOp<1, 1, Input, -1>(inpIx, -1, eltN, false) 📎 src/device/prims_ll.h:403-405。
第一步:送信バッファが利用可能になるのを待つ。 waitSendはsendConnHeadCache + NCCL_STEPS < sendConnHead + 1 📎 src/device/prims_ll.h:73-89をチェックします。意味は:もし対端の消費進捗(head)が私より遅れすぎているなら、リングバッファがほぼ満杯であることを示し、待たなければなりません。NCCL_STEPSはバッファの総スロット数、sendConnHead + 1は私がまもなく占有するスロットです。待機中は*sendConnHeadPtrをポーリングしてcheckAbortキャッシュを更新し、定期的に📎 src/device/prims_ll.h:73-89。
を呼び出して abort されたかチェックします DataLoader::loadBegin第二步:ローカルデータをロードする。📎 src/device/prims_ll.h:200-216はアライメント問題を処理しますsizeof(T) <= 2。u4[0..2](例えば half や int8)の場合、ソースアドレスが 4 バイトアライメントでない可能性があるため、まず 4 バイトアライメントでmisalignに読み込み、loadFinishを記録し、その後__funnelshift_r内で📎 src/device/prims_ll.h:218-225を使ってバイトレベルのシフトで正しい 64 ビット値を組み立てます
。これは典型的な「アライメント読み + シフト再構成」テクニックで、非アライメントアクセスの性能ペナルティを回避します。 readLL第三步:対端データを読み、flag を待つ。📎 src/device/prims_ll.h:108-122:
do {
asm volatile("ld.volatile.global.v4.u32 {%0,%1,%2,%3}, [%4];" ...);
if (checkAbort(abort, 1, spins)) break;
} while ((flag1 != flag) || (flag2 != flag));コピーld.volatile.global.v4.u32一度に16バイト(4つのu32)を読み取り、その後2つのflagフィールドが両方とも期待値に等しいかチェックする。volatileキーワードは、コンパイラがこの読み取りを最適化で削除したりレジスタにキャッシュしたりしないことを保証する——相手側がいつでも新しいデータを書き込む可能性があるため。2つのflagが両方一致する必要があるのは、書き込み側がstoreLL一度に4つのu32を書き込み、理論的には2回の8バイト書き込みに分割される可能性があるため、2つのflagが両方一致して初めて16バイトが完全であることが保証される。
第四ステップ:reduceして送信。peerDataを受信した後、applyReduce(redOp, peerData, data)リダクションを行い📎 src/device/prims_ll.h:279。その後storeLL(sendPtr(i) + offset, data, sendFlag(i))結果を送信バッファに書き込み📎 src/device/prims_ll.h:295-296。送信順序に注意:最初にi=1..MaxSend(通常はネットワークpeer)を送信し、最後にi=0(通常はローカルpeer)を送信する📎 src/device/prims_ll.h:291-297。コメントには明確に書かれている:「Send : inter-node, then intra-node, then local」——遅い方(ネットワーク)を先に送信し、バックグラウンドで飛ばせておき、その後速い方(ローカル)を送信することで、ローカルpeerがネットワークを待たないようにする。
第五ステップ:stepを進めてpost。 incRecv(i)受信ステップをインクリメントし📎 src/device/prims_ll.h:91-93,postRecv()recvConnHeadをグローバルポインタに書き戻し📎 src/device/prims_ll.h:94-97、相手側に「このステップを消費した」と通知する。送信側のincSendには特別なロジックがあり📎 src/device/prims_ll.h:99-106:
if ((sendStep[i] & NCCL_LL_CLEAN_MASK) == NCCL_LL_CLEAN_MASK) {
for (int o = offset; o < stepLines; o += nthreads) storeLL(sendPtr(i) + o, 0, sendFlag(i));
}stepがNCCL_LL_CLEAN_MASK境界に達したとき、slice全体のすべての行を現在のflagで書き込む(データは0で埋める)。なぜか?flagは循環的に再利用されるため、ある行の前回のflagがたまたま今回の期待値と等しい場合、受信側がデータが準備完了と誤認する可能性がある。この「cleanup」操作はすべての行のflagを新しい値に統一して書き換え、曖昧さを排除する。
並行制御とハードウェア相互作用
LLの同期は完全にvolatileの読み書き+flagポーリングに依存しており、ロックはない。barrier()__syncwarp()(単一warp時)またはbarrier_sync(15 - group, nthreads)(複数warp時)を使用する📎 src/device/prims_ll.h:63-69。15 - groupはbarrier番号であり、NCCLは異なるbarrier番号で異なるgroupを分離し、相互干渉を避ける。
checkAbortは無限ループ防止の鍵📎 src/device/primitives.h:154-164:毎NCCL_SPINS_BEFORE_CHECK_ABORT(10000)回のスピンごとに一度だけabortFlagを読み取り、頻繁なグローバルメモリ読み取りでホットパスが遅くなるのを避ける。abortが検出されると、ncclShmem.abortedを設定してキャッシュし、以降のすべての待機ループが高速に終了する。
本番での落とし穴
落とし穴1:flagのラップアラウンドによる偽の準備完了。もしNCCL_LL_CLEAN_MASKのcleanupロジックが削除されると、長時間実行(stepがmask周期を超える)後、受信側が前回のラウンドの残留flagを読み取り、データが準備完了と誤判定し、ダーティデータを読む可能性がある。この種のバグは再現が極めて困難である。なぜなら、stepがちょうど特定の値にラップアラウンドすることに依存しているからだ。
落とし穴2:MaxRecv == 0のコンパイルトラップ。コード内でMaxRecv = Fan::MaxRecv > 1 ? Fan::MaxRecv : 1 📎 src/device/prims_ll.h:13、送信のみで受信しない場合でも、長さMaxRecvの受信バッファを割り当てるため、MaxRecvが0だとゼロ長配列のコンパイルエラーが発生する。WindowsではMaxSendも同様の処理📎 src/device/prims_ll.h:14-19。
LL128:128バイトアライメントでより高いペイロードを得る
直感的モデル
LLの痛点はペイロードがわずか50%(16バイトのうち8バイトがflag)であること。LL128の考え方は:flagを128バイトごとの最後の8バイトに集中させ、前の120バイトはすべてデータ。これによりペイロードが50%から93.75%に向上する。代償は128バイトアライメントを保証する必要があり、そうでなければ「共有メモリ再配置」が必要になる。
データ構造とメモリレイアウト
LL128の転送単位はuint64_t(8バイト)だが、128バイトの「line」に組織される。NCCL_LL128_LINEELEMSは各lineの64ビット要素数(16個)、NCCL_LL128_DATAELEMSはそのうちのデータ要素数(15個)で、最後の要素にflagを置く。
重要な定数📎 src/device/prims_ll128.h:292-294:
static constexpr int WireWordPerSlice = WARP_SIZE * NCCL_LL128_SHMEM_ELEMS_PER_THREAD;
static constexpr int DataEltPerSlice =
(WireWordPerSlice - WireWordPerSlice / NCCL_LL128_LINEELEMS) * (sizeof(uint64_t) / sizeof(T));WireWordPerSliceは1つのwarpが一度に転送する64ビットワード数、DataEltPerSliceはそのうちの有効データ要素数(各lineにつき1つのflag要素を引く)。
LL128のflagメカニズムはLLと異なる:8スレッドごとに7番目(flagThread)だけがflagをチェックする 📎 src/device/prims_ll128.h:373。flagThread = ((tid % 8) == 7)。なぜか?flagは128バイトごとに1つであり、1つのwarpには32スレッドあり、8スレッドごとに128バイトを処理する(8スレッド×16バイト=128バイト)ため、8スレッドごとに1つだけがflagを読む必要がある。
シナリオ駆動Walkthrough:1回のrecvReduceSendCopy
呼び出しチェーン:recvReduceSend(inpIx, eltN) → GenericOp<1, 1, Input, -1> → recvReduceSendCopy<NCCL_LL128_SHMEM_ELEMS_PER_THREAD, RECV, SEND, SrcBuf, DstBuf> 📎 src/device/prims_ll128.h:422-423, 296-333。
第一ステップ:ローカルデータをレジスタにロード。 loadRegsBegin2つのケースに分かれる📎 src/device/prims_ll128.h:99-142:
- 16バイトアライメント:直接
load128をレジスタへ、共有メモリ中継なし。注意flagThreadはデータの半分のみロードする(g % 2 == 0)。なぜなら、もう半分のレジスタはflag用に取っておく必要があるから📎src/device/prims_ll128.h:109-114。 - 非アライメント:まずアライメント領域を共有メモリにロードし
ncclScratchForWarp(warpInBlock),__syncwarp()その後共有メモリから正しいオフセットでレジスタに読み戻す📎src/device/prims_ll128.h:115-141。
第二ステップ:相手側データを待機して読み取り。 recvReduceSendCopy内の待機ループ📎 src/device/prims_ll128.h:190-207:
do {
needReload = false;
for (int u = 0; u < ELEMS_PER_THREAD; u += 2) {
load128(ptr + u * WARP_SIZE, vr[u], vr[u + 1]);
needReload |= flagThread && (vr[u + 1] != flag);
}
needReload &= (0 == checkAbort(abort, 1, spins));
} while (__any_sync(WARP_MASK, needReload));重要な点:flagThreadだけがflagをチェックし、その後__any_syncでwarpレベルの投票を行う——1つのflagThreadでもflagの不一致を検出すれば、warp全体がスピンを続ける。これはすべてのスレッドがflagをチェックするよりも命令を節約できる。
第三ステップ:レジスタ再配置。 loadRegsFinishflagThreadのflagレジスタを空きレジスタに移動する📎 src/device/prims_ll128.h:145-151。コメントはこの設計を説明している:「By deferring register shuffle here we've overlapped spinning on first peer's data with memory loads of src data」——レジスタの再配置を待機の後まで遅延させることで、待機時間とローカルデータのロードをオーバーラップさせている。
第四ステップ:reduce して送信。データを受信したらapplyReduce 📎 src/device/prims_ll128.h:227-230を行い、その後store128送信バッファに書き込む📎 src/device/prims_ll128.h:274-287。送信時に注意すべきはflagThread ? flag : v[u+1]——flagThread が flag を書き、他のスレッドがデータを書く。
第五ステップ:step を進める。LL と異なり、LL128 の step の推進はGenericOpの末尾で一括して📎 src/device/prims_ll128.h:324-332を行い、recvReduceSendCopyの中では行わない。さらにpostSendは__threadfence_system()(SM90+)または__threadfence() 📎 src/device/prims_ll128.h:87-96を使用し、データが他の GPU/ネットワークカードから可視になったことを確認してから tail ポインタを更新する。
並行制御とハードウェア相互作用
LL128 のbarrier()は常にbarrier_sync(15 - group, nthreads) 📎 src/device/prims_ll128.h:64-66を使用し、LL のような単一 warp 最適化はない。LL128 のデータ転送は warp レベルであり、warp 間の同期が必要だからである。
loadRegsBegin内の共有メモリ再配置は__syncwarp()で📎 src/device/prims_ll128.h:129を同期し、全スレッドが共有メモリへの書き込みを完了してから読み取ることを保証する。
本番環境での落とし穴
落とし穴 1:非アライメントアクセスの性能崖。ユーザーバッファが 16 バイトアライメントでない場合、毎回の転送が共有メモリを経由するため、性能が 30% 以上低下する可能性がある。本番環境では入出力バッファを 16 バイトアライメントで確保すべきである。
落とし穴 2:flagThreadのレジスタプレッシャー。flagThread はデータの半分しかロードしないため、レジスタ利用率が他のスレッドと異なる。コンパイラがレジスタを正しく割り当てられないと、レジスタがローカルメモリにスピルし、性能が急落する可能性がある。
Simple:FIFO + 通知で大メッセージの高スループットを実現
直感的モデル
Simple プロトコルは宅配ロッカーのようなもの:送信側はデータを FIFO バッファ(ロッカー)に入れ、その後「N 番ロッカーに入れた」という step ポインタを更新する(通知を送る)。受信側は step ポインタをポーリングし、新しい値を見たら対応するロッカーに取りに行く。ハンドシェイクのオーバーヘッドは大きい(ポインタの書き込み+読み取りが必要)が、一度に多くのデータを運べるため、大メッセージに適している。
データ構造とメモリレイアウト
Simple のフィールドは LL/LL128 よりはるかに複雑📎 src/device/prims_simple.h:28-46:
| フィールド | 型 | 役割 |
|---|---|---|
flags | int | ビットフラグ。役割(WaitRecv/WaitSend/PostRecv/PostSend)、Direct モード、NetReg などをエンコードする |
step | uint64_t | 現在のステップ |
connStepPtr | uint64_t* | 接続先の step ポインタを指す |
connStepCache | uint64_t | 前回読み取った step 値をキャッシュする |
connEltsFifo | T* | FIFO バッファのベースアドレス |
connStepSize | int | 各ステップのバイト数 |
directBuff | T* | Direct モードでの直接バッファポインタ |
flagsのビット定義📎 src/device/prims_simple.h:23-27:
RoleInput = 0x01, RoleOutput = 0x02, RoleWaitRecv = 0x04, RoleWaitSend = 0x08,
RolePostSend = 0x10, RolePostRecv = 0x20, Aborted = 0x40, NetRegMode = 0x80,
ConnFifoEnabled = 0x100, DirectWrite = 0x200, DirectRead = 0x400, PatMode = 0x800,
NvlsMinPolling = 0x1000, NetDeviceUnpack = 0x2000, AnyNetDeviceUnpack = 0x4000,
RoleWaitPatNvls = 0x8000, RolePostPatNvls = 0x10000;これは典型的な「複数の bool フィールドをビット演算で置き換える」設計で、レジスタを節約する。各スレッドは自身のtidに応じて役割を割り当てられる📎 src/device/prims_simple.h:651-666:最初のnrecvスレッドは WaitRecv、次のnsendスレッドは WaitSend、最後のnrecvスレッドは PostRecv、後ろからnsendスレッドは PostSend。
シナリオ駆動ウォークスルー:1 回の recvReduceSend
呼び出しチェーン:recvReduceSend(inpIx, eltN) → genericOp<0, 0, 1, 1, Input, -1> 📎 src/device/prims_simple.h:994-996。
第一ステップ:slice サイズを計算。 sliceSize = max(divUp(nelem, 16 * SlicePerChunk) * 16, sliceSize / 32) 📎 src/device/prims_simple.h:185-186。この式は slice が少なくとも 16 バイトアライメントであり、かつ小さすぎないことを保証する。
第二ステップ:worker ループ。のみtid < nworkersのスレッドがメインループに入る📎 src/device/prims_simple.h:190。nworkers = nthreads - (MaxSend > 0 && nthreads >= NCCL_SIMPLE_EXTRA_GROUP_IF_NTHREADS_GE ? WARP_SIZE : 0) 📎 src/device/prims_simple.h:626——1 つの warp を threadfence と copy のオーバーラップ用に予約する。
第三ステップ:対端を待つ。 waitPeerは核心的な📎 src/device/prims_simple.h:103-164:
while (connStepCache + (isSendNotRecv ? NCCL_STEPS : 0) < step + StepPerSlice) {
connStepCache = loadStepValue(connStepPtr);
if (checkAbort(flags, Aborted, spins)) break;
}isSendNotRecv送信と受信を区別する:送信時は「対端が消費済み」(head)を待ち、受信時は「対端が生産済み」(tail)を待つ。NCCL_STEPSはバッファのスロット数、StepPerSliceは各 slice のステップ数。
待機完了後、Direct モードに応じてptrs[index] 📎 src/device/prims_simple.h:123-158を設定する。Direct モードでは対端バッファを直接読み書きでき、FIFO をバイパスして 1 回のコピーを削減する。
第四ステップ:reduceCopy。Direct の組み合わせに応じて異なるreduceCopyを呼び出す📎 src/device/prims_simple.h:241-277。最も複雑な分岐はsrcs[0] && dsts[0]が両方存在する場合📎 src/device/prims_simple.h:258-271で、reduceCopy<Unroll, RedOp, T, MultimemSrcs, Recv+Src, Recv*MaxRecv+Src, MultimemDsts, Send+Dst, Send*MaxSend+Dst, PreOpSrcs>を呼び出す。パラメータの意味は:Recv*MaxRecv+Src個のソースから読み、reduce 後にSend*MaxSend+Dst個の宛先に書き込む。
第五ステップ:postPeer。 postPeerstep ポインタを更新する📎 src/device/prims_simple.h:167-175:
if (Send && (flags & RolePostSend) && (dataStored || (flags & ConnFifoEnabled))) {
fence_acq_rel_sys();
}
st_relaxed_sys_global(connStepPtr, step);送信側は step を更新する前にfence_acq_rel_sys()を行い、データ書き込みが他の GPU/ネットワークカードから可視であることを保証する。受信側は fence 不要。受信側は「消費済み」を通知するだけで、データの可視性に関与しないためである。
並行制御とハードウェア相互作用
Simple の同期はst_relaxed_sys_globalで step ポインタを書き込み、📎 src/device/prims_simple.h:167-175で読み取るloadStepValue。📎 src/device/prims_simple.h:86-100。loadStepValueSM90+ かつNvlsMinPolling有効時はmultimem.ld_reduce.acquire.sys.global.min.u64命令を使用する📎 src/device/prims_simple.h:86-100。これは NVLink SHARP のハードウェアアクセラレーテッドポーリングである。
barrier()とsubBarrier()の違い📎 src/device/prims_simple.h:49-55:barrier()は全nthreadsスレッドを同期し、subBarrier()はnworkers個の worker スレッドのみを同期する。subBarrierの barrier 番号は15 - group - (nworkers != nthreads ? 1 : 0)で、worker 数が総スレッド数と異なる場合に異なる barrier を使い、barrier()との衝突を避ける。
本番環境での落とし穴
落とし穴 1:NetRegMode でのデストラクタ待機。デストラクタに特殊なロジックがある📎 src/device/prims_simple.h:794-804:
if ((flags & NetRegMode) && (flags & RoleWaitSend)) {
uint64_t prevStep = step - StepPerSlice;
volatile ssize_t* ptr = &(connFifo[prevStep % NCCL_STEPS].size);
while (*ptr != -1) { ... }
}NetRegMode では、送信バッファは NIC から直接アクセスされるため、proxy スレッドが送信済み(size が -1 に設定される)を確認するまで戻ってはならない。そうでなければ、次の kernel が NIC に読み取られているデータを上書きする可能性がある。
落とし穴 2:DirectRead の sendrecv デッドロック。デストラクタにもう一段ある📎 src/device/prims_simple.h:814-824:
if ((flags & DirectRead) && (flags & RoleWaitSend) && P2p) {
while (*tail > *head) { ... }
}sendrecv の DirectRead モードでは、送信側は受信側がデータを読み終えるまで戻ってはならない。受信側が何らかの理由で tail を進めない場合、送信側はデッドロックする。この待機はbarrier()の後に行う必要がある。そうでなければ post スレッドと競合する可能性がある。
落とし穴 3:roundUpによる step の飛び。 loadRecvConnとloadSendConnの両方にstep = roundUp(step, SlicePerChunk * StepPerSlice) 📎 src/device/prims_simple.h:486, 533がある。これは step を slice 境界に整列させるが、前の step が整列していない場合、スキップされたスロットが正しく初期化されない。コードはloadRecvConnに*connStepPtr = stepを追加して credit を返却する📎 src/device/prims_simple.h:489。
三つのプリミティブの比較と選定
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| 次元 | LL | LL128 | Simple |
|---|---|---|---|
| 有効ペイロード率 | 50% | 93.75% | ~100% |
| 同期方式 | flag 埋め込み、ポーリング | flagThread + warp 投票 | step ポインタ + fence |
| 整列要件 | なし(シフト再構成あり) | 16 バイト | なし |
| 適用メッセージサイズ | 小(< 8KB) | 中(8KB ~ 128KB) | 大(> 128KB) |
| バッファレイアウト | ncclLLFifoLine[] | uint64_t[]128B line 単位 | T[] FIFO |
| Direct サポート | なし(PrimitivesWithoutDirect降格) | なし(同左) | 完全サポート |
LL と LL128 はどちらもPrimitivesWithoutDirect 📎 src/device/prims_ll.h:9-10, src/device/prims_ll128.h:13-14を継承している。なぜなら、それらのバッファレイアウトはピアメモリの直接読み書きをサポートしていないからである。Simple は Direct モードを完全に実装し、P2P 直結と NVLS をサポートする。
設計上の考察
なぜ LL の flag は二回繰り返すのか?GPU のグローバルメモリ書き込みは原子性を保証しないからである。storeLL16 バイトを書き込むとき、ハードウェアは 8 バイト書き込み二回に分割する可能性がある。flag が一つだけなら、受信側はデータが半分しか書かれていないのに準備完了と判断するかもしれない。二つの flag はそれぞれ 16 バイトの前半と後半に位置し、両方の書き込みが完了して初めて両方の flag が一致する。
なぜ Simple は warp を一つ予約するのか? 📎 src/device/prims_simple.h:625-626コメントには「For send operations, we need an extra warp to overlap the threadfence and the copy」とある。fence_acq_rel_sys()は高コストな操作であり、全スレッドが fence 完了を待ってから続行すると、大量の時間を浪費する。warp を一つ予約して fence 専用にし、他の warp は次のバッチのデータ転送を続けられる。
なぜ LL128 の step 推進は recvReduceSendCopy 内ではなく GenericOp の末尾にあるのか?LL128 の転送は warp レベルであり、複数の warp が異なる slice を並行処理する可能性があるからである。もしrecvReduceSendCopy内で step を推進すると、各 warp が一回ずつ推進し、step が複数回進んでしまう。GenericOpの末尾で統一的に推進することで、各 slice が一回だけ進むことを保証する。
本章のまとめ
本章では三つの転送プリミティブの実装を深掘りした:
1. LL:16 バイトのncclLLFifoLineで flag をデータ行に埋め込み、受信側は flag の一致をポーリングするだけでデータ準備完了を確認できる。有効ペイロード 50%、小メッセージに適する。核心はreadLLのld.volatile.global.v4.u32とstoreLLのst.volatile.global.v4.u32。
2. LL128:flag を 128 バイトごとの最後の 8 バイトに集中させ、有効ペイロードを 93.75% に向上。flagThread(8 スレッドごとに 1 つ)で flag をチェックし、__any_syncで warp 投票を行う。非整列時は共有メモリで再レイアウトする。
3. Simple:FIFO バッファ + step ポインタ通知で大メッセージの高スループットを実現。flagsビットフラグで役割をエンコードし、waitPeerで step をポーリングし、postPeerで step を更新して fence する。Direct モードを完全サポート。
三つのプリミティブは同じテンプレート骨格を共有し、Protoテンプレートパラメータで特殊化する。アルゴリズム層は統一インターフェースを呼ぶだけで、下層プロトコルを気にしない。これが「同一の AllReduce ロジックに三つの転送プリミティブが必要な理由」の答えである:異なるメッセージサイズには異なる同期戦略とバッファレイアウトが必要であり、三つのプリミティブはそれぞれ小・中・大メッセージに最適化されている。
本章の考察とセルフチェック
Q1: もしincSend内の cleanup ロジック(📎 src/device/prims_ll.h:99-106)を削除した場合、どのようなシナリオでデータ破損が発生するか?なぜか?
参考解析:cleanup ロジックはsendStep[i] & NCCL_LL_CLEAN_MASK == NCCL_LL_CLEAN_MASK時に、slice 全体のすべての行を現在の flag で書き直す(データは 0 埋め)。もし削除すると、step がNCCL_LL_CLEAN_MASK境界に回り込んだとき、一部の行の flag がまだ前回の値のままである可能性がある。前回の flag がちょうど今回の受信側が期待する flag と一致すると、受信側はデータが準備完了と誤認し、前回の残留データを読む。これは典型的な ABA 問題である。発生条件は長時間実行(step がNCCL_LL_CLEAN_MASK周期を超える)かつ flag がちょうど同じ値に回り込むこと。この種のバグは正確な step 整列が必要なため、再現が極めて困難である。
Q2: Simple プロトコルのデストラクタ内で、NetRegMode での待機(📎 src/device/prims_simple.h:794-804)と DirectRead での待機(📎 src/device/prims_simple.h:814-824)はそれぞれ何を防いでいるのか?どちらかを削除した場合、高並行シナリオで何が起こるか?
参考解析:NetRegMode が待機するのは、proxy スレッドがconnFifo[prevStep].sizeを -1 に設定することであり、NIC が送信を完了したことを示す。これを削除すると、次の kernel が NIC の DMA 読み取り中の送信バッファを上書きし、NIC がダーティデータを読み取る可能性がある。DirectRead が待機するのは、受信側が tail(*tail > *head)を進めることであり、受信側が直接バッファを読み終えたことを示す。これを削除すると、送信側が受信側の読み取り完了前にバッファを上書きし、受信側が古いデータではなく新しいデータを読み取る可能性がある。高並行シナリオでは、これら二つの待機は両方とも必須であり、どちらかを削除するとデータ競合が発生する。違いは、NetRegMode が「NIC 読み取り」を防ぎ、DirectRead が「対向 GPU 読み取り」を防ぐ点である。
Q3: LL128 のloadRegsBeginが非アライメント時に共有メモリ再配置(📎 src/device/prims_ll128.h:115-141)を経由する場合、このパスはアライメントパスよりどれだけ遅いか?なぜ NCCL はユーザーバッファが 16 バイトアライメントであることを直接要求しないのか?
参考解析:非アライメントパスには三つの追加ステップがある:共有メモリへの書き込み、__syncwarp()、共有メモリからの読み取り。共有メモリの帯域幅は高いが、__syncwarp()は同期ポイントであり、すべてのスレッドが書き込みを完了するまで warp をブロックする。大まかな推定では、非アライメントパスはアライメントパスより 20-40% 遅く、具体的には共有メモリのバンク競合状況に依存する。NCCL がアライメントを強制しないのは、ユーザーが任意のオフセットのバッファ(例えばテンソルスライス)を渡す可能性があり、強制アライメントは API の柔軟性を制限するからである。NCCL の戦略は「アライメント時は高速パス、非アライメント時は低速パスだが正確性を保証」である。本番環境では、ユーザーはできるだけ 16 バイトアライメントでバッファを割り当て、高速パスを通ることを推奨する。
ここまでで、LL、LL128、Simple の三つのプリミティブのデータ転送メカニズムを把握した。これらは上位アルゴリズムに柔軟な性能調整手段を提供する。次の章では集合通信アルゴリズムカーネルに深く入り、AllReduce、AllGather、ReduceScatter などがこれらのプリミティブをどのように呼び出すか、また Ring、Tree、CollNet などのアルゴリズムがデータフローをどのように組織し、最終的にエンドツーエンドの集合通信を完了するかを見る。
第 10 章:第 10 章:集団通信アルゴリズムカーネル:AllReduce、AllGather、ReduceScatter のデバイス側実装
第 10 章:集団通信アルゴリズムカーネル:AllReduce、AllGather、ReduceScatter のデバイス側実装
前の章では LL、LL128、Simple の三つのプロトコルプリミティブを分解した。これらはデータ転送の「エンジン」であるが、エンジン自体は何を運ぶか、どこへ運ぶか、どの順序で運ぶかを知らない。本章で見る src/device 下のこのアルゴリズムカーネルファイル群は「トランスミッション」である——これらは AllReduce、AllGather、ReduceScatter といった集合通信セマンティクスを、prims.directSend、prims.directRecvReduceDirectSend のようなプリミティブ呼び出しの連続に翻訳する。本章の核心的な矛盾を一言でまとめると:同じ AllReduce に対して、なぜ Ring、Tree、CollNet、NVLS の四つの完全に異なるデバイス側実装が必要なのか?答えは「データフロートポロジ」と「ハードウェア能力」のマッチングに隠されている。Ring は最小のネットワーク帯域で二段階パイプラインを行い、Tree は木形帰約でレイテンシを log(n) に圧縮し、CollNet/NVLS は帰約を NIC や NVLink スイッチにオフロードする。本章では一つずつ分解して見ていく。
10.1 Ring AllReduce:二段階パイプラインが kernel 内でどのように実現されるか
直感モデル:リング状パイプライン上の「リレー競走」
n 人の作業員が円形に立ち、各人が一箱の原料を持っていると想像しよう。AllReduce の目標は、最終的に全員が「すべての原料を混合した完成品」を手に入れることである。Ring アルゴリズムの方法は二段階に分かれる:第一段階(reduce-scatter)では各人が箱をリングに沿って渡し、一站ごとに自分の原料を混ぜ込み、n-1 站回った後、各人の手にはちょうど「完全混合」の完成品が一つあるが、それは 1/n の割合に過ぎない;第二段階(all-gather)ではこれらの完成品の割合が再びリングに沿って一周し、各人がすべての割合を補完する。
もし Ring がなければ、最も素朴な方法は各 rank がデータを root に送り、root が帰約してからブロードキャストする——root のネットワーク帯域がボトルネックとなり、n が大きいほど遅くなる。Ring の巧妙さは:各ランクの送信量と受信量は2(n-1)/n倍のデータ量で、nに依存せず全リンクに均等に分散される。
データ構造とメモリレイアウト
Ringアルゴリズムの中核状態はncclRing構造体内にあり(device.hで定義、本章では展開しない)、runRingそのうち2つのフィールドのみを取り出す:
ring->index:環内での本ランクの論理位置。「第jステップでどのchunkを処理すべきか」の計算に使用。ring->prev/ring->next:前駆と後続のランク番号。Primitivesコンストラクタのrecv/send peer引数として使用。
重要な分割パラメータはncclCollCbdPartで計算される(📎 src/device/all_reduce.h:21-22):
ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);この関数は通信ドメイン全体のデータをchannelで分割し、3つの値を出力する:gridOffset(本channelが担当するデータのバッファ全体における開始オフセット)、channelCount(本channelが担当する要素の総数)、chunkCount(各ランクに割り当てられるchunkの要素数)。chunkCountはRingアルゴリズムの粒度——各ステップで1つのchunkを転送する。
loopCount = nranks * chunkCount(📎 src/device/all_reduce.h:23)は「一周分」で処理されるデータ量を表す。外側ループfor (elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount)(📎 src/device/all_reduce.h:34)は:channelのデータ量が一周で処理できる量を超える場合、複数周に分けて実行することを意味する。
Step-by-Step Walkthrough:1回のRing AllReduceの完全な呼び出しフロー
シナリオ:4ランク(nranks=4)、本ランクのringIx=0,chunkCount=100,channelCount=400(ちょうど一周)。
第0ステップ:「自分のchunk」を次のGPUに送る(📎 src/device/all_reduce.h:42-47)
chunk = modRanks(ringIx + nranks - 1); // = 3
chunkOffset = chunk * chunkCount; // = 300
offset = gridOffset + elemOffset + chunkOffset;
nelem = min(chunkCount, remCount - chunkOffset);
prims.directSend(offset, offset, nelem);modRanksはlambdaで、nranksの剰余減算を行う(📎 src/device/all_reduce.h:40)。ringIx + nranks - 1は「本ランクの前のchunk番号」を表す。なぜ第0ステップでchunk 3を送るのか?Ringのreduce-scatter段階では、各ランクはまず自分が「保持すべきでない」データ(つまり前駆ランクのchunk)を送信するからである。directSend送信のみで受信しない。この時点ではまだデータを受信していないため。
第1からnranks-2ステップ:受信しながら帰約しながら転送(📎 src/device/all_reduce.h:50-56)
for (int j = 2; j < nranks; ++j) {
chunk = modRanks(ringIx + nranks - j);
...
prims.directRecvReduceDirectSend(offset, offset, nelem);
}directRecvReduceDirectSendはRingの中核プリミティブ:prevからchunkを受信し、ローカルデータと帰約(例えば加算)を行い、結果をnextに送信する。注意:offsetとnelemは各イテレーションで再計算される——各ステップで処理するchunkが異なるため。jは2からnranks-1まで、合計nranks-2ステップ。
第nranks-1ステップ:最後のchunkを受信して帰約し、最終結果を生成(📎 src/device/all_reduce.h:58-64)
chunk = ringIx + 0;
...
prims.directRecvReduceCopyDirectSend(offset, offset, nelem, /*postOp=*/true);このステップのpostOp=trueが鍵:帰約完了後に後置操作(例えば平均を取る際の除算)を実行する。directRecvReduceCopyDirectSendは前ステップよりCopyが1つ多い——帰約結果をローカルrecvbuffとnextへの送信の両方に同時に書き込む。これでreduce-scatter段階が終了し、各ランクは「完全に帰約された」chunkを1つ持つ。
all-gather段階:nranks-2ステップの純粋な転送(📎 src/device/all_reduce.h:66-73)
for (int j = 1; j < nranks - 1; ++j) {
chunk = modRanks(ringIx + nranks - j);
...
prims.directRecvCopyDirectSend(offset, offset, nelem);
}ここではdirectRecvCopyDirectSendを使用しており、Reduceはない——データは既に帰約済みで、コピー転送のみが必要なため。
最終ステップ:最後のchunkを受信(📎 src/device/all_reduce.h:75-81)
chunk = modRanks(ringIx + 1);
...
prims.directRecv(offset, nelem);受信のみで送信しない。最後の1ブロックを補完する。
全体のフローは以下の制御フロー図で要約できる:
flowchart TD
start["runRing 入口<br/>计算 chunkCount/loopCount"] --> loop{"elemOffset < channelCount?"}
loop -->|否| done["返回"]
loop -->|是| s0["step 0: directSend<br/>chunk = ringIx-1"]
s0 --> mid{"j 从 2 到 nranks-1?"}
mid -->|是| s1["directRecvReduceDirectSend<br/>chunk = ringIx-j"]
s1 --> mid
mid -->|否| s2["step nranks-1<br/>directRecvReduceCopyDirectSend<br/>postOp=true"]
s2 --> ag{"j 从 1 到 nranks-2?"}
ag -->|是| s3["directRecvCopyDirectSend<br/>纯转发"]
s3 --> ag
ag -->|否| s4["directRecv<br/>收最后一块"]
s4 --> loop設計思考:なぜRingのchunk順序は「逆走り」なのか
chunk番号の規則に注意:第0ステップでringIx-1を送信、第jステップでringIx-jを処理、最終ステップでringIx+0を処理。これは反時計回りで進む。なぜか?Ringの各ランクは「自分が帰約を担当するchunk」(つまりringIx+0)のみを保持し、他のchunkは通過するだけだからである。反時計回りの進行により:あるchunkが一周して起点に戻った時、ちょうどnranks回の帰約が完了し、最終結果が生成される。時計回りに進むと、chunkは誤ったランクで帰約が完了してしまう。
本番の落とし穴:remCount < loopCount時のアライメントトラップ
📎 src/device/all_reduce.h:38見落とされやすいコードが1行ある:
if (remCount < loopCount) chunkCount = alignUp(divUp(remCount, nranks), 16 / sizeof(T));残りデータが一周分に満たない場合、chunkCountを再計算し、かつalignUp(..., 16/sizeof(T))を強制的に16バイトアライメントする。なぜか?LL128プロトコルは128バイトアライメントを要求し、Simpleプロトコルもベクトル化アクセスのアライメント要件があるからである。このアライメントを外すと、非アライメントのchunkはスローパスを通り、性能が20-40%低下する。本番環境でRing AllReduceの小メッセージ末尾の性能ジッターが見られる場合、多くの場合このアライメントが効いていない——確認すべきはchannelCountがnranks * 16/sizeof(T)の整数倍かどうか。
10.2 Tree AllReduce:木形帰約でレイテンシをlog(n)に圧縮
直感モデル:会社の「逐級報告」
RingのレイテンシはO(n)——データが一周する必要がある。nが大きい場合(例えば1024 GPU)、帯域が均等化されてもレイテンシは耐えられない。Treeアルゴリズムは別のアプローチを取る:会社の組織図のように、各ランクは「親ノード」と「子ノード」とのみ通信する。帰約段階では、葉ノードがデータを上に報告し、親ノードが子ノードのデータをマージする;ブロードキャスト段階では逆に、根ノードが結果を下に送る。レイテンシはO(n)からO(log n)に低減される。
Treeがなければ、大規模クラスタのAllReduceレイテンシはrank数に比例して増大し、訓練イテレーション時間が通信によって圧迫される。
データ構造とメモリレイアウト
Treeの状態はncclTreeの中にある:
tree->up:親ノードのrank(-1はこのrankがルートであることを示す)。tree->down[]:子ノード配列、最大NCCL_MAX_TREE_ARITY個(典型的には3、つまり二分+ローカル)。
runTreeUpDownとrunTreeSplitは2つの変種である。前者は「まず全て帰約してから全てブロードキャスト」という2段階モードを使い、後者はスレッドを半分に分割し、半分が帰約、半分がブロードキャストを行うことでパイプラインオーバーラップを実現する。
Step-by-Step Walkthrough:runTreeUpDownの三分岐
runTreeUpDownの最初のコードブロックは帰約フェーズ(📎 src/device/all_reduce.h:96-118)であり、このrankのツリー内での位置に応じて3つのケースに分かれる:
ケースA:このrankがルート(tree->up == -1)(📎 src/device/all_reduce.h:99-104)
prims.directRecvReduceCopy(offset, offset, nelem, /*postOp=*/true);ルートノードは受信のみで送信せず、全子ノードからデータを受信、帰約し、recvbuffに書き込む。postOp=true後置操作を実行する。
ケースB:このrankがリーフ(tree->down[0] == -1)(📎 src/device/all_reduce.h:105-110)
prims.directSend(offset, offset, nelem);リーフノードは送信のみで受信せず、自身のデータを親ノードに送信する。
ケースC:中間ノード(📎 src/device/all_reduce.h:111-117)
prims.directRecvReduceDirectSend(offset, offset, nelem);子ノードから受信、帰約し、親ノードに送信する。
ブロードキャストフェーズ(📎 src/device/all_reduce.h:120-142)のロジックは対称的:ルートノードはdirectSendFromOutput(recvbuffから送信)、リーフノードはdirectRecv、中間ノードはdirectRecvCopyDirectSend。
runTreeSplit:スレッド分割による帰約-ブロードキャストパイプライン
runTreeUpDownの問題は:帰約フェーズとブロードキャストフェーズが直列で、間にグローバル同期ポイントがあることである。runTreeSplitはスレッドを2つのグループに分ける(📎 src/device/all_reduce.h:155-164):
if (Proto::Id == NCCL_PROTO_SIMPLE) {
nthreadsSplit = nthreads / 2;
if (nthreadsSplit >= 256) nthreadsSplit += 64;
} else {
nthreadsSplit = (nthreads * 7 / (10 * WARP_SIZE)) * WARP_SIZE;
}Simpleプロトコルは半分に分割;LL/LL128プロトコルは7:3に分割する。なぜなら「3つのソースからデータを受信して帰約する」方が「3つのターゲットに送信する」よりも計算集約的であるため、帰約グループにより多くのスレッドを割り当てる。
そしてtid < nthreadsSplitのスレッドが帰約上昇(📎 src/device/all_reduce.h:175-202)を行い、残りのスレッドがブロードキャスト下降(📎 src/device/all_reduce.h:203-224)を行う。2つのグループはProto::MaxGroupWidthオフセットで各自の通信グループを区別する(📎 src/device/all_reduce.h:189の0 * Proto::MaxGroupWidthと📎 src/device/all_reduce.h:210の1 * Proto::MaxGroupWidth)。
設計思考:なぜTreeのルートノードを特別扱いするのか
ツリー帰約のルートノードは「集約点」であり、その受信量は子ノード数の倍数で、送信量はゼロである(帰約フェーズ)。もしルートノードも汎用のdirectRecvReduceDirectSendを使うと、tree->up(-1)に送信しようとして範囲外アクセスが発生する。そのためif (tree->up == -1)分岐で個別に処理する必要がある。同様にリーフノードのtree->down[0] == -1判定も同様である。
本番の落とし穴:Treeアルゴリズムの「ホットルート」問題
Treeのルートノードは全ての帰約トラフィックを担うため、ルートノードのGPUがたまたま遅いノード(例えばPCIe帯域が制限されている)である場合、AllReduce全体が遅くなる。NCCLの対策は:各channelで異なるルートを選ぶことで、ルートノードの負荷を複数のrankに分散させる。これがrunTreeSplitでルートノード分岐がFanSymmetric<NCCL_MAX_TREE_ARITY_TOP>(📎 src/device/all_reduce.h:168)を使う理由である——複数の子ノードの帰約を同時に処理する必要がある。本番環境でTree AllReduceの性能が不均一な場合、channelのルートノード分布が均等か確認する。
10.3 AllGatherとReduceScatter:Ringの「半行程」変種
直感モデル:AllReduceを2つに分割
AllGatherとReduceScatterは本質的にAllReduceの2つのフェーズをそれぞれ独立したAPIにしたものである。AllGatherは「収集」のみを行う——各rankが1份のデータを提供し、最終的に全員が全データを取得する。ReduceScatterは「帰約+分散」のみを行う——全員がデータを提供し、帰約後に各人が1份を取得する。
これら2つの独立したAPIがなければ、ユーザーが「先に帰約してから収集」または「先に収集してから帰約」を行う場合、AllReduceを呼んで手動でスライスするしかなく、帯域の半分を浪費する。
AllGatherのRing実装
all_gather.hのrunRing(📎 src/device/all_gather.h:14-88)はAllReduceより簡単:帰約がなく、コピー転送のみ。
第0ステップ:自身のデータを次のGPUにプッシュ(📎 src/device/all_gather.h:51-60)
rankDest = ringRanks[0];
offset = dataOffset + rankDest * count;
if ((inputBuf + dataOffset == outputBuf + offset) || isNetOffload) {
prims.directSend(dataOffset, offset, nelem);
} else {
prims.directCopySend(dataOffset, offset, nelem);
}ここにin-place判定がある:もしinputBuf + dataOffset == outputBuf + offsetなら、入力と出力が同じメモリ(in-place AllGather)であり、直接directSend;そうでなければdirectCopySend(先に出力にコピーしてから送信)。
中間のnranks-2ステップ:純粋な転送(📎 src/device/all_gather.h:62-67)
prims.directRecvCopyDirectSend(offset, offset, nelem);最終ステップ:最後のブロックを受信(📎 src/device/all_gather.h:69-74)
prims.directRecv(offset, nelem);isNetOffload:単一warpでネットワーク駆動 + 複数warpで並列コピー
📎 src/device/all_gather.h:28-36には特殊な分岐がある:
if (isNetOffload) {
workNthreads = WARP_SIZE;
chunkCount = NCCL_MAX_NET_SIZE;
} else {
workNthreads = nthreads;
}のときisNetOffload=true(単一RPN + ネットワーク登録モード)、1つのwarpのみでRing通信を駆動し、残りのwarpは並列で「ソースデータをターゲットバッファにコピー」(📎 src/device/all_gather.h:76-82)を行う。これは非in-place AllGather時に、コピーオーバーヘッドと通信オーバーヘッドをオーバーラップさせるためである。
最後にbarrier_sync(14, nthreads)(📎 src/device/all_gather.h:87)があり、コメントで明確に説明されている:全warpが完了するまで待つ必要がある。そうでなければ次のworkがoutputBufを再利用して競合が発生する可能性がある。barrier 14を使うのはprims自身のbarrierと__syncthreads()。
を避けるためである。
reduce_scatter.hReduceScatterのRing実装runRing(📎 src/device/reduce_scatter.h:14-56の
)はAllReduceのreduce-scatterフェーズを単独で抽出したものである:(📎 src/device/reduce_scatter.h:39-42)
rankDest = ringRanks[nranks - 1];
offset = dataOffset + rankDest * count;
prims.send(offset, nelem);コピー(📎 src/device/reduce_scatter.h:44-49)
prims.recvReduceSend(offset, nelem);コピー(📎 src/device/reduce_scatter.h:61-64)
prims.recvReduceCopy(offset, dataOffset, nelem, /*postOp=*/true);最後のステップに注意recvReduceCopyには2つの offset がある:offset(受信元)とdataOffset(ローカル入力)、帰約結果はdataOffset。
データフロー比較図
flowchart LR
subgraph AllReduce["AllReduce (两阶段)"]
A1["reduce-scatter<br/>n-1 步"] --> A2["all-gather<br/>n-1 步"]
end
subgraph AG["AllGather (单阶段)"]
B1["directSend<br/>step 0"] --> B2["directRecvCopyDirectSend<br/>n-2 步"] --> B3["directRecv<br/>step n-1"]
end
subgraph RS["ReduceScatter (单阶段)"]
C1["send<br/>step 0"] --> C2["recvReduceSend<br/>n-2 步"] --> C3["recvReduceCopy<br/>step n-1"]
end
AllReduce -.->|"拆解"| AG
AllReduce -.->|"拆解"| RS本番の落とし穴:in-place 判定の境界
📎 src/device/all_gather.h:55の in-place 判定inputBuf + dataOffset == outputBuf + offsetはポインタの完全一致に依存する。ユーザーが渡した sendbuff と recvbuff にオフセットがあるが論理的には同じメモリブロックの場合、この判定は無効になり、directCopySendパスを通る——正しいがコピーが1回余分に発生する。本番環境では in-place AllGather 時に sendbuff と recvbuff が完全に一致することを確認することを推奨。
10.4 CollNet と NVLS:帰約をハードウェアにオフロード
直感的モデル:「スイッチ」に計算を手伝ってもらう
Ring と Tree はどちらも「GPU 自身が帰約を計算する」。CollNet と NVLS は発想を変えた:帰約操作をネットワークカード(CollNet)または NVLink スイッチ(NVLS)にオフロードする。GPU はデータを送信するだけで、ハードウェアが帰約を完了してからブロードキャストで戻す。これは「各作業員が自分で原料を混合する」から「原料を中央ミキサーに送り、ミキサーが混ぜてから配布する」への変化に似ている。
ハードウェアオフロードがなければ、帰約操作は GPU の SM リソースを占有し、帰約レイテンシを隠蔽できない。
CollNet Direct のスレッド分担
RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_COLLNET_DIRECT, ...>のrun(📎 src/device/all_reduce.h:249-386)はスレッドを4つのグループに分ける:
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;4つのスレッドグループがそれぞれ担当:Scatter(データを各 rail に分散)、Reduce(帰約後にネットワークへ送信)、Gather(各 rail から収集)、Bcast(ネットワークから受信後にブロードキャスト)。COLLNET_COPY_THREADS = 96(📎 src/device/all_reduce.h:250)は固定のコピースレッド数。
netRegUsed:ネットワーク登録モードでのバッファレイアウト
📎 src/device/all_reduce.h:280-288には重要な分岐がある:
if (work->netRegUsed) {
offsetBase = bid * chunkSize;
maxNelems = size;
peerOffset = nChannels * chunkSize;
} else {
offsetBase = bid * direct->nHeads * chunkSize;
maxNelems = direct->nHeads * chunkSize;
peerOffset = chunkSize;
}netRegUsedモードでは、バッファは channel ごとに連続配置され(bid * chunkSize)、peer オフセットはnChannels * chunkSize;非登録モードでは、head ごとに配置され(bid * nHeads * chunkSize)、peer オフセットはchunkSize。この差異は、ネットワーク登録モードがネットワークカードの DMA のためにバッファの連続性を要求することに起因する。
NVLS の warp 割り当て
RunWorkColl<ncclFuncAllReduce, ..., NCCL_ALGO_NVLS, ...>のrun(📎 src/device/all_reduce.h:391-523)はより精细な warp 割り当てを使用:
const int bcastWarps = hasOut ? (work->regUsed ? ((totalWarps - 2) >> 1) - 1 : 2) : 0;
const int reduceWarps = work->regUsed ? (totalWarps - bcastWarps - 2) : (hasOut ? 3 : nranks <= 6 ? 7 : 5);
const int scatterWarps = work->regUsed ? 1 : (totalWarps - reduceWarps - bcastWarps + 1) >> 1;
const int gatherWarps = work->regUsed ? 1 : (totalWarps - reduceWarps - bcastWarps) >> 1;regUsedモードでは、scatter/gather はそれぞれ 1 warp のみ(NVLS ハードウェアが登録メモリを直接操作するため)、reduce が大部分を占める;非登録モードでは、scatter/gather がそれぞれ約半分を占め、reduce は rank 数に応じて調整(≤6 なら 7 warp、それ以外は 5 warp)。
タイミング交互図
sequenceDiagram
participant App as 应用层
participant Scatter as Scatter Warps
participant NVLS as NVLS 硬件
participant Reduce as Reduce Warps
participant Bcast as Bcast Warps
App->>Scatter: prims.scatter(offset, nelem, chunkSize)
Scatter->>NVLS: 写入 NVLink SHARP 缓冲区
NVLS->>NVLS: 硬件归约 (multimem)
NVLS->>Reduce: prims.directRecvDirectSend(offset, nelem)
Reduce->>NVLS: 归约结果写回
NVLS->>Bcast: prims.directRecvDirectSend(offset, nelem)
Bcast->>App: 广播到所有 rank本番の落とし穴:CollNet のdirect->out == -1トラップ
📎 src/device/reduce_scatter.h:521に一行ある:
if (direct->out == -1) __trap();CollNet の out 接続が未確立(-1)の場合、直接__trap()すると kernel がクラッシュする。これは防御的プログラミング——CollNet はネットワークカードに依存し、ネットワークカードの初期化に失敗すると out が -1 になり、そのまま実行を続けると未定義動作を引き起こす。本番環境で kernel trap が見られたら、CollNet ネットワークカードが正常に初期化されているか確認する。
10.5 Broadcast と Reduce:最も単純な2つの集合操作
Broadcast:root からのファンアウト
broadcast.hのrunRing(📎 src/device/broadcast.h:14-64)ロジックは非常に直接的:root ノードがデータを送信し、他のノードが転送し、最後のノードは受信のみ。
if (rank == root) {
if (inputBuf == outputBuf || isNetOffload) {
prims.directSend(offset, offset, nelem);
} else {
prims.directCopySend(offset, offset, nelem);
}
} else if (nextRank == root) {
prims.directRecv(offset, nelem);
} else {
prims.directRecvCopyDirectSend(offset, offset, nelem);
}3つの分岐:root が送信、root の前駆が受信、中間ノードが転送。注意nextRank == rootが判定するのは「本ノードの次が root」、つまり本ノードがリング上の最後——受信のみで送信しない。
Reduce:root への集約
reduce.hのrunRing(📎 src/device/reduce.h:14-53)は Broadcast の逆操作:
if (prevRank == root) {
prims.send(offset, nelem);
} else if (rank == root) {
prims.recvReduceCopy(offset, offset, nelem, /*postOp=*/true);
} else {
prims.recvReduceSend(offset, nelem);
}prevRank == rootのノードは送信のみ(root の前駆)、root は受信と帰約のみ、中間ノードは受信しながら帰約し転送する。
設計思想:なぜ Broadcast/Reduce も Ring を使うのか
Broadcast と Reduce は理論上 Tree でより低レイテンシに実装できるが、NCCL が Ring を選ぶ理由は:これら2つの操作のデータ量は通常小さく、Ring の実装がより単純で、AllReduce の Ring コードパスを再利用できる。Tree の複雑さ(ルートノード選択、スレッド分割)は小メッセージシナリオでは利益が不明瞭。
本番の落とし穴:Broadcast の root ノード帯域幅ボトルネック
Broadcast の root ノードは全データを送信する必要があり、root が遅いノードだと Broadcast 全体が遅くなる。NCCL の対応は:Broadcast もマルチ channel をサポートし、各 channel の root は異なってもよい。ただし注意work->rootはグローバルであり、全 channel が同じ root を共有する——これは Broadcast のセマンティクスによる(ソースは1つだけ)。本番環境で Broadcast が遅い場合、root ノードのネットワーク帯域幅を確認する。
10.6 アルゴリズム選択マトリクス:RunWorkColl テンプレート特殊化
すべてのアルゴリズムカーネルはRunWorkCollテンプレート特殊化で登録される(📎 src/device/all_reduce.h:228-788)。各特殊化は「関数 × アルゴリズム × プロトコル」の組み合わせに対応:
| 関数 | アルゴリズム | プロトコル | 特殊化位置 |
|---|---|---|---|
| AllReduce | RING | SIMPLE | 📎 src/device/all_reduce.h:230-233 |
| AllReduce | TREE | SIMPLE | 📎 src/device/all_reduce.h:238-244 |
| AllReduce | COLLNET_DIRECT | SIMPLE | 📎 src/device/all_reduce.h:249-386 |
| AllReduce | NVLS | SIMPLE | 📎 src/device/all_reduce.h:391-523 |
| AllReduce | NVLS_TREE | SIMPLE | 📎 src/device/all_reduce.h:528-634 |
| AllReduce | COLLNET_CHAIN | SIMPLE | 📎 src/device/all_reduce.h:639-759 |
| AllReduce | RING | LL | 📎 src/device/all_reduce.h:764-766 |
| AllReduce | TREE | LL | 📎 src/device/all_reduce.h:771-773 |
| AllReduce | RING | LL128 | 📎 src/device/all_reduce.h:778-780 |
| AllReduce | TREE | LL128 | 📎 src/device/all_reduce.h:785-787 |
注意:CollNet と NVLS は SIMPLE プロトコルのみをサポートする。なぜなら、これら2つのアルゴリズムはハードウェアオフロードに依存しており、LL/LL128 の低遅延同期メカニズムはハードウェアオフロードと互換性がない——ハードウェアリダクションの遅延は LL のフラグポーリングよりもはるかに大きいため、LL を使うとかえってオーバーヘッドが増加する。
プロトコル選択の内在的ロジック
- LL:小メッセージ(< 8KB)、低遅延優先。Ring と Tree の両方がサポート。
- LL128:中メッセージ(8KB - 1MB)、128 バイトアライメント。Ring と Tree の両方がサポート。
- SIMPLE:大メッセージ(> 1MB)、帯域幅優先。すべてのアルゴリズムがサポート。
本番環境の落とし穴:プロトコルとアルゴリズムの組み合わせ制限
ユーザーが強制的にNCCL_PROTO=LLを指定したが、アルゴリズムが CollNet の場合、NCCL はチューニング段階で SIMPLE にフォールバックする。本番環境でプロトコル設定が反映されない場合は、アルゴリズムがそのプロトコルをサポートしているか確認すること。
設計上の考察:なぜ同じ AllReduce ロジックにこれほど多くの実装が必要なのか
本章を振り返ると、AllReduce には Ring、Tree、CollNet Direct、CollNet Chain、NVLS、NVLS Tree の6つのアルゴリズム実装がある。これは冗長ではなく、異なるハードウェアトポロジとメッセージサイズに対する最適解:
- Ring:汎用、大メッセージに適し、帯域幅利用率が最高。
- Tree:大規模クラスタに適し、遅延 O(log n)。
- CollNet:リダクション対応 NIC を備えたクラスタに適し、GPU 計算をオフロード。
- NVLS:単一ノードの NVLink 全結合に適し、ハードウェアマルチキャストリダクション。
NCCL のチューニングモジュール(第5章)は、メッセージサイズ、rank 数、トポロジに基づいて自動選択する。デバイス側の実装は「各組み合わせが正しいこと」を保証するだけでよく、選択ロジックは host 側にある。
本章のまとめ
本章ではsrc/device以下の6つのアルゴリズムカーネルファイルを分解した:
1. Ring AllReduce(📎 src/device/all_reduce.h:14-83):2段階パイプライン、reduce-scatter + all-gather、各段階 n-1 ステップ。
2. Tree AllReduce(📎 src/device/all_reduce.h:86-225):ツリー型リダクション、遅延 O(log n)、runTreeSplitスレッド分割でリダクション-ブロードキャストパイプラインを実現。
3. AllGather(📎 src/device/all_gather.h:14-88):Ring 単一段階、in-place と netOffload をサポート。
4. ReduceScatter(📎 src/device/reduce_scatter.h:14-56):Ring 単一段階、AllReduce の reduce-scatter 段階。
5. Broadcast/Reduce(📎 src/device/broadcast.h:14-64、📎 src/device/reduce.h:14-53):最もシンプルな Ring 変種。
6. CollNet/NVLS(📎 src/device/all_reduce.h:247-635):ハードウェアオフロード、SIMPLE プロトコルのみサポート。
本章の考察とセルフチェック
Q1: Ring AllReduce の reduce-scatter 段階では、第0ステップでdirectSendを使用し、中間ステップでdirectRecvReduceDirectSendを使用し、最終ステップでdirectRecvReduceCopyDirectSendを使用する。最終ステップのpostOp=trueを除去した場合、どのようなシナリオで誤った結果が生じるか?
参考解析:postOp=trueが後置操作(例えば平均を求める際の除算)をトリガーする。ncclAvgを例にとると、リダクションは総和、postOp は nranks で割る。postOpを除去すると、最終ステップはリダクションのみで除算を行わず、recvbuff には「平均」ではなく「総和」が格納される。reduce-scatter 段階では、各 rank は1つのチャンクの最終結果のみを保持し、このチャンクはまさにringIx+0(📎 src/device/all_reduce.h:60)。postOp が欠落している場合、このチャンクの総和は nranks で割られておらず、後続の all-gather 段階でこの誤った「総和」がすべての rank に伝播する。注意:postOp が必要なのは最終ステップのみである。なぜなら、このステップだけが「完全なリダクション」の結果を生成するからである。中間ステップのリダクションは部分和であり、postOp は不要。本番環境で AllReduce の結果が nranks 倍大きい場合は、postOp が正しく伝達されているか確認すること。
Q2: runTreeSplitLL/LL128 プロトコル下でスレッドを 7:3 に分割し(📎 src/device/all_reduce.h:163)、Simple プロトコル下では 1:1 に分割する(📎 src/device/all_reduce.h:157)。LL プロトコルも強制的に 1:1 に変更した場合、何が起こるか?
参考解析:LL/LL128 のリダクショングループは最大3つの子ノードからデータを受信してリダクションを行う(📎 src/device/all_reduce.h:187のFanAsymmetric<NCCL_MAX_TREE_ARITY, 1>)、計算集約的。ブロードキャストグループはコピー転送のみを行う(📎 src/device/all_reduce.h:208のFanAsymmetric<1, NCCL_MAX_TREE_ARITY>)、計算は軽い。7:3 分割によりリダクショングループは3路リダクションを処理するのに十分なスレッドを持ち、ブロードキャストグループはスレッドが少ないが十分である。1:1 に変更すると、リダクショングループのスレッドが不足し、リダクションがボトルネックになる。ブロードキャストグループのスレッドは過剰で無駄になる。さらに深刻なのは、LL プロトコルのフラグポーリングはビジーウェイトであり、スレッドが増えるとフラグ競合が増加する。本番環境で Tree AllReduce が LL プロトコル下で性能異常を示す場合は、nthreadsSplitの計算が変更されていないか確認すること。
Q3: AllGather のisNetOffloadモードでは、1つの warp のみで Ring 通信を駆動し(📎 src/device/all_gather.h:32)、残りの warp は並列コピーを行う(📎 src/device/all_gather.h:76-82)。最後のbarrier_sync(14, nthreads)(📎 src/device/all_gather.h:87)を除去した場合、どのようなシナリオでデータ競合が発生するか?
参考解析:barrier_syncすべての warp(通信 warp とコピー warp を含む)が本 work を完了してから次の work に進むことを保証する。これを削除すると、コピー warp がまだ outputBuf を書き終えていないうちに通信 warp が次の work の通信を開始する可能性があり、次の work が同じ outputBuf を再利用するかもしれない。具体的なシナリオ:連続する2回の AllGather で、1回目のコピー warp がまだ outputBuf の末尾を書いている最中に、2回目の通信 warp がすでに outputBuf へ新しいデータを書き始め、1回目のデータが上書きされてしまう。コメントにも明確に書かれている:「otherwise, we can have contention if next work will use the outputBuf in this work」。デフォルトの barrier ではなく barrier 14 を使うのは、prims 内部の barrier と__syncthreads()を避け、デッドロックを防ぐためである。本番環境で AllGather の結果に偶発的な誤りが見つかった場合、isNetOffloadパスの barrier が最適化で削除されていないか確認すること。
ここまでで、デバイス側のアルゴリズムカーネルがどのようにデータフローを組織するかを見てきた。各アルゴリズムはPrimitivesを通じて前章のプリミティブを呼び出し、アルゴリズム層は「誰が誰に送るか、どの chunk を送るか、リダクションかコピーか」だけを関心ごととする。次章では転送層の抽象化に踏み込み、P2P、SHM、NET、NVLS がどのように一つのインターフェースに統一されるか、そして host 側の proxy スレッドがデバイス側 kernel と協調してクロスマシン通信を完了する仕組みを見ていく。
核心的な法則:すべてのアルゴリズムは Primitives テンプレートクラスを通じてプリミティブを呼び出し、アルゴリズムは「データフローのトポロジー」のみを担当し、プリミティブは「データの移動」を担当する。この階層化により、新しいアルゴリズムを追加する際はトポロジーロジックを実装するだけでよく、低レベルの同期を気にする必要がない。しかしトポロジーがどう変わろうと、データは最終的に物理リンクを通じて転送される。次章では src/transport ディレクトリに踏み込み、NCCL が統一された transport インターフェースで P2P、SHM、NET、NVLS の差異をどのように隠蔽するか、そして各 transport の setup/connect/send/recv のセマンティクスを見ていく。これはクロスマシン通信を理解する基礎である。
第11章:第11章:転送層の抽象化:P2P、SHM、NET、NVLS をいかに同一インターフェース下に統一するか
第11章:転送層の抽象化:P2P、SHM、NET、NVLS をいかに同一インターフェース下に統一するか
前章ではアルゴリズムカーネルを深く掘り下げ、Ring AllReduce がデータを分割して二段階でリダクションする方法、Tree AllReduce が木構造によってレイテンシを抑える方法を見てきた——しかしこれらのアルゴリズムは「誰が誰に送るか、どの chunk を送るか」という論理的なビューを定義するにすぎない。データは最終的に実際の物理リンク——NVLink、PCIe、共有メモリ、またはネットワークカード——を通過しなければならない。本章では src/transport ディレクトリを分解し、NCCL が統一された ncclTransport インターフェースで P2P、SHM、NET、NVLS という4つの物理チャネルを同一の顔として隠蔽し、アルゴリズムトポロジーから物理転送までのラストワンマイルを完成させる仕組みを見ていく。
一、統一インターフェース:ncclTransport がいかに4つの物理チャネルを隠蔽するか
直感的モデル
物流会社を想像してみよう:顧客が送るのが市内速達(P2P)、建物内伝達(SHM)、県をまたぐ輸送(NET)、専用線直通(NVLS)のいずれであっても、フロントでは一枚の「送り状」を記入するだけである。この送り状がncclTransport構造体である——これは各輸送方式がcanConnect、setup、connect、freeなどの固定アクションを提供しなければならないことを規定している。この抽象化層がなければ、上位のアルゴリズムは4セットのif-elseを書いてどのリンクを通るか判断しなければならず、新しいハードウェアを追加するたびにすべてのアルゴリズムを修正する必要がある。
データ構造とメモリレイアウト
NCCL はグローバル配列で全 transport を登録し、その順序がそのまま優先度となる:
📎 src/transport.cc:15-20
struct ncclTransport* ncclTransports[NTRANSPORTS] = {
&p2pTransport,
&shmTransport,
&netTransport,
&collNetTransport,
};配列の順序が選択順序を決定する:P2P が優先、次に SHM、さらに NET、最後に CollNet。各 transport はncclTransport構造体で記述され、これにはcanConnect関数ポインタ1つと2つのncclTransportComm(send/recv それぞれ1つ)が含まれる。P2P を例にすると:
📎 src/transport/p2p.cc:1493-1498
struct ncclTransport p2pTransport = {"P2P",
p2pCanConnect,
{p2pSendSetup, p2pSendConnect, p2pSendFree, NULL, p2pSendProxySetup, NULL,
p2pSendProxyFree, NULL, p2pProxyRegister, p2pProxyDeregister},
{p2pRecvSetup, p2pRecvConnect, p2pRecvFree, NULL, p2pRecvProxySetup, NULL,
p2pRecvProxyFree, NULL, p2pProxyRegister, p2pProxyDeregister}};ncclTransportCommのフィールド順序は固定の「ライフサイクルスロット」である:setup(リソース準備)、connect(接続情報交換)、free(解放)、proxySharedInit(プロキシ共有初期化)、proxySetup、proxyConnect、proxyFree、proxyProgress、proxyRegister、proxyDeregister。P2P のproxyProgressスロットはNULLであることに注意——P2P は GPU が直接相手のメモリを読み書きするため、host プロキシスレッドがデータを運ぶ必要がない;一方 NET のproxyProgressはsendProxyProgress/recvProxyProgressである。ネットワークカード I/O は host スレッドが駆動しなければならないからである。
シナリオ駆動 Walkthrough:1回の接続がいかに transport を選択するか
NCCL がある channel のある peer のために接続を確立する必要があるとき、selectTransport:
📎 src/transport.cc:23-44
template <int type>
static ncclResult_t selectTransport(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclConnect* connect,
int channelId, int peer, int connIndex, int* transportType) {
struct ncclPeerInfo* myInfo = comm->peerInfo + comm->rank;
struct ncclPeerInfo* peerInfo = comm->peerInfo + peer;
struct ncclConnector* connector = (type == 1) ? comm->channels[channelId].peers[peer]->send + connIndex :
comm->channels[channelId].peers[peer]->recv + connIndex;
for (int t = 0; t < NTRANSPORTS; t++) {
struct ncclTransport* transport = ncclTransports[t];
struct ncclTransportComm* transportComm = type == 1 ? &transport->send : &transport->recv;
int ret = 0;
NCCLCHECK(transport->canConnect(&ret, comm, graph, myInfo, peerInfo));
if (ret) {
connector->transportComm = transportComm;
NCCLCHECK(transportComm->setup(comm, graph, myInfo, peerInfo, connect, connector, channelId, connIndex));
if (transportType) *transportType = t;
return ncclSuccess;
}
}
WARN("No transport found for rank %d[%lx] -> rank %d[%lx]", myInfo->rank, myInfo->busId, peerInfo->rank,
peerInfo->busId);
return ncclSystemError;
}type==1send 方向を表し、type==0recv 方向を表す。ループで順番に各 transport のcanConnectを問い合わせる:戻り値ret=1は「この処理を実行できる」を意味し、直ちにconnector->transportCommを該当 transport の対応方向に設定し、そのsetupを呼び出す。すべての transport が 0 を返した場合、警告を出力してncclSystemError。
canConnectを返す。判定ロジックは各 transport の「領土境界」を体現している。P2P を例にすると:
📎 src/transport/p2p.cc:129-157
ncclResult_t p2pCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
struct ncclPeerInfo* info2) {
initCeOperation();
int intermediateRank;
int isCrossClique;
NCCLCHECK(ncclTopoCheckP2p(comm, comm->topo, info1->rank, info2->rank, ret, NULL, &intermediateRank, NULL,
&isCrossClique));
if (*ret == 0) return ncclSuccess;
if (intermediateRank != -1) {
if (useMemcpy) *ret = 0;
return ncclSuccess;
}
if (!isCrossClique) {
int useNet = 0;
NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, &useNet));
if (useNet) {
*ret = 0;
return ncclSuccess;
}
}
if (info1->hostHash != comm->peerInfo[comm->rank].hostHash || info1->hostHash != info2->hostHash) {
return ncclSuccess;
}
...P2P の判定チェーン:まずトポロジに「2つの rank 間に P2P パスがあるか」を問い合わせる。中間ホップ(intermediateRank != -1)があり、かつ CE memcpy が有効な場合は P2P を諦めて SHM/NET に譲る。トポロジがネットワーク経由を推奨する場合(useNet)も諦める。最後に同一ホストかどうかを確認する。SHM の判定はより単純:
📎 src/transport/shm.cc:61-83
static ncclResult_t shmCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph,
struct ncclPeerInfo* info1, struct ncclPeerInfo* info2) {
*ret = 0;
initShmLocality();
if (ncclParamShmDisable() == 1) return ncclSuccess;
int useNet = 0;
NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, &useNet));
if (useNet) return ncclSuccess;
if (info1->hostHash != info2->hostHash) return ncclSuccess;
if (info1->shmDev != info2->shmDev) return ncclSuccess;
*ret = 1;
return ncclSuccess;
}SHM は同一ホスト(hostHashが同一)かつ同じ/dev/shm(shmDevを共有していること(同一であればコンテナ間通信に使用)を要求する。NET はほぼ常に 1 を返し、同一ホストの場合のみ intra-node net が無効化されていないか確認する:
📎 src/transport/net.cc:160-168
static ncclResult_t canConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
struct ncclPeerInfo* info2) {
*ret = 1;
if (info1->hostHash == info2->hostHash) {
NCCLCHECK(ncclTopoCheckNet(comm->topo, info1->rank, info2->rank, ret));
}
return ncclSuccess;
}NET は「フォールバック」——前の誰も引き受けなければ、これが引き受ける。NVLS のcanConnectは直接 0 を返す:
📎 src/transport/nvls.cc:21-26
ncclResult_t nvlsCanConnect(int* ret, struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* info1,
struct ncclPeerInfo* info2) {
// This transport cannot be used for p2p
*ret = 0;
return ncclSuccess;
}NVLS は通常の peer-to-peer 接続パスを通らず、ncclNvlsSetupを介して個別にマルチキャストグループを確立するため、canConnectは常に 0 を返す。
flowchart TD
start["selectTransport(comm, peer, connIndex)"] --> loop{"遍历 ncclTransports[t]"}
loop -->|t=0| p2p["p2pCanConnect()"]
p2p --> p2p_chk{"拓扑有P2P路径<br/>且非中间跳<br/>且同主机?"}
p2p_chk -->|是| use_p2p["connector->transportComm = p2pTransport<br/>调用 p2pSendSetup/p2pRecvSetup"]
p2p_chk -->|否| shm["shmCanConnect()"]
shm --> shm_chk{"同hostHash<br/>且同shmDev?"}
shm_chk -->|是| use_shm["connector->transportComm = shmTransport<br/>调用 shmSendSetup/shmRecvSetup"]
shm_chk -->|否| net["canConnect() (NET)"]
net --> net_chk{"同主机时<br/>intra-node net 启用?"}
net_chk -->|是/跨机| use_net["connector->transportComm = netTransport<br/>调用 sendSetup/recvSetup"]
net_chk -->|否| collnet["collNetTransport"]
collnet --> fail["WARN: No transport found<br/>return ncclSystemError"]
use_p2p --> done["return ncclSuccess"]
use_shm --> done
use_net --> done設計上の考察
なぜ「配列順序 + canConnect 投票」を明示的なルーティングテーブルではなく使用するのか? トポロジは動的であるため:同一マシンでもNCCL_P2P_DISABLE、コンテナ分離、CUDA IPC の可用性などの要因により P2P が利用不可となる場合があり、その際は自動的に SHM または NET に降格する。投票メカニズムにより各 transport が自ら「自分が実行できるか」を判断し、新しい transport の追加は配列に1項目追加するだけで、選択ロジックを変更する必要がない。これはまさにシステムプログラミングにおける開閉原則の具現化である。
二、P2P:同一マシン GPU 直結の4形態
直感モデル
P2P は「隣人間で直接物を渡す」——GPU 0 が GPU 1 のメモリを直接読み書きし、CPU やネットワークカードを経由しない。P2P がなければ、同一マシンのマルチ GPU 通信はホストメモリを迂回する必要があり、レイテンシは倍増し、帯域幅は半減する。
データ構造とメモリレイアウト
P2P 内部には4つの形態があり、enum p2pTypeによって区別される:
📎 src/transport/p2p.cc:19-24
enum p2pType {
P2P_DIRECT,
P2P_INTERMEDIATE,
P2P_IPC,
P2P_CUMEM
};P2P_DIRECT:同一プロセス内の異なる GPU、ポインタで直接アクセス(最速)。P2P_INTERMEDIATE:2つの GPU 間に直結がなく、中間 GPU を経由して転送が必要。P2P_IPC:プロセス間で、従来のcudaIpcOpenMemHandleを使用して相手側メモリをインポート。P2P_CUMEM:プロセス間で、cuMem API(cuMemExportToShareableHandle)を使用してインポートし、より細かい粒度のメモリ管理をサポート。
コアリソース構造体:
📎 src/transport/p2p.cc:79-94
struct p2pResources {
enum p2pType type;
union {
struct ncclSendMem* sendDevMem;
struct ncclRecvMem* recvDevMem;
};
void* sendMemIpc;
int sendMemSameProc;
void* recvMemIpc;
int recvMemSameProc;
// CE memcpy support
struct p2pShmProxyInfo proxyInfo;
struct p2pShm* shm;
struct p2pShm* devShm;
ncclShmIpcDesc_t desc;
};sendDevMem/recvDevMemは union——送信側はsendDevMemのみを気にし、受信側はrecvDevMemのみを気にし、メモリを共有する。sendMemIpc/recvMemIpcはインポートされた相手側メモリハンドルを保持し、sendMemSameProc/recvMemSameProcは同一プロセスかどうかを示す(解放時にncclCuMemFreeAddrかcudaIpcCloseMemHandle)。
を使うかを決定)。接続情報構造体p2pConnectInfoは bootstrap を介して交換される:
📎 src/transport/p2p.cc:38-44
struct p2pConnectInfo {
int rank;
int read;
struct ncclP2pBuff p2pBuff;
// Used by CE memcpy
ncclShmIpcDesc_t desc;
};
static_assert(sizeof(struct p2pConnectInfo) <= CONNECT_SIZE, "p2pConnectInfo is too large");static_assertは接続情報がCONNECT_SIZEを超えないことを保証する(bootstrap の単一交換固定バッファサイズ)。readフィールドはデータフロー方向を決定する:read=1は受信側が送信側メモリを能動的に読み取る(P2P Read)、read=0は送信側が受信側メモリに能動的に書き込む(P2P Write)。
シナリオ駆動 Walkthrough:P2P Send の確立
selectTransportが P2P を選択した後、p2pSendSetup:
📎 src/transport/p2p.cc:393-471
ncclResult_t p2pSendSetup(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* myInfo,
struct ncclPeerInfo* peerInfo, struct ncclConnect* connectInfo, struct ncclConnector* send,
int channelId, int connIndex) {
struct p2pResources* resources;
struct ncclP2pRequest req;
NCCLCHECK(ncclCalloc(&resources, 1));
send->transportResources = resources;
int useRead, intermediateRank;
NCCLCHECK(p2pGetInfo(comm, myInfo, peerInfo, &useRead, &intermediateRank));
if (useMemcpy) useRead = 0;
...
int sendSize = sizeof(struct ncclSendMem);
if (info->read) sendSize += comm->buffSizes[NCCL_PROTO_SIMPLE];
ALIGN_SIZE(sendSize, CUDA_IPC_MIN);
...重要ポイント:sendSizeは P2P Read モードで SIMPLE プロトコルバッファサイズを追加で加算する必要がある——読み取りモードでは送信側の SIMPLE buffer が受信側に直接読み取られるため、ncclSendMemと一緒に同じ共有可能メモリに割り当てる必要がある。ALIGN_SIZE(sendSize, CUDA_IPC_MIN)はサイズを CUDA IPC の最小粒度にアラインすることを保証する。
次にintermediateRankとプロセス関係に基づいて形態を選択する:
📎 src/transport/p2p.cc:416-437
if (intermediateRank == -1) {
info->rank = myInfo->rank;
if (P2P_SAME_PID(myInfo, peerInfo) && ncclParamP2pDirectDisable() == 0 && useMemcpy == 0) {
resources->type = P2P_DIRECT;
...
} else {
if (ncclCuMemEnable()) {
resources->type = P2P_CUMEM;
...
} else {
resources->type = P2P_IPC;
...
}
}
send->conn.flags |= info->read ? NCCL_P2P_READ : NCCL_P2P_WRITE;
} else {
resources->type = P2P_INTERMEDIATE;
info->rank = intermediateRank;
...
}P2P_SAME_PIDマクロは同一ホスト同一プロセスを判定する:
📎 src/transport/p2p.cc:334-335
#define P2P_SAME_PID(MYINFO, PEERINFO) \
((MYINFO->hostHash == PEERINFO->hostHash) && (MYINFO->pidHash == PEERINFO->pidHash))同一プロセスかつ direct が無効化されておらず memcpy が有効でなければ、最速のP2P_DIRECT——相手側ポインタを直接取得。そうでなければ IPC/CUMEM を使用。
その後、プロキシスレッドを介して共有可能バッファを割り当てる:
📎 src/transport/p2p.cc:457-468
NCCLCHECK(ncclProxyConnect(comm, TRANSPORT_P2P, 1, info->rank, &send->proxyConn));
if (useMemcpy) {
NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, NULL, 0, &resources->proxyInfo,
sizeof(struct p2pShmProxyInfo)));
memcpy(&info->desc, &resources->proxyInfo.desc, sizeof(ncclShmIpcDesc_t));
} else {
NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, &req, sizeof(struct ncclP2pRequest),
&info->p2pBuff, sizeof(struct ncclP2pBuff)));
NCCLCHECK(p2pMap(comm, &send->proxyConn, myInfo, comm->peerInfo + info->rank, &info->p2pBuff,
(void**)&resources->sendDevMem, &resources->sendMemIpc));
resources->sendMemSameProc = P2P_SAME_PID(myInfo, (comm->peerInfo + info->rank));
}ncclProxyCallBlockingは同期 RPC:host スレッドがプロキシスレッドにメッセージを送り、プロキシスレッドがp2pSendProxySetupを呼び出して共有可能バッファを割り当て、ncclP2pBuff(IPC ハンドルを含む)を返送する。次にp2pMapが相手側バッファをローカルアドレス空間にマッピングする。
p2pMapはコアマッピング関数:
📎 src/transport/p2p.cc:349-390
static ncclResult_t p2pMap(struct ncclComm* comm, struct ncclProxyConnector* proxyConn, struct ncclPeerInfo* myInfo,
struct ncclPeerInfo* peerInfo, struct ncclP2pBuff* p2pBuff, void** devMem, void** ipcPtr) {
if (P2P_SAME_PID(myInfo, peerInfo)) {
if (peerInfo->cudaDev != myInfo->cudaDev) {
cudaError_t err = cudaDeviceEnablePeerAccess(peerInfo->cudaDev, 0);
...
if (ncclCuMemEnable()) {
NCCLCHECK(ncclCuMemAllocAddr(devMem, &p2pBuff->ipcDesc.memHandle, p2pBuff->size));
CUCHECK(cuMemRelease(p2pBuff->ipcDesc.memHandle));
*ipcPtr = *devMem;
...
} else {
*devMem = p2pBuff->directPtr;
*ipcPtr = NULL;
}
} else {
*devMem = p2pBuff->directPtr;
*ipcPtr = NULL;
}
} else {
NCCLCHECK(ncclP2pImportShareableBuffer(comm, peerInfo->rank, p2pBuff->size, &p2pBuff->ipcDesc, devMem,
p2pBuff->directPtr, ncclMemOffload));
*ipcPtr = *devMem;
}
return ncclSuccess;
}同一プロセス異なる GPU:まずcudaDeviceEnablePeerAccessで P2P チャネルを開き、次にdirectPtrを直接使用(同一プロセスはアドレス空間を共有するため)。プロセス間:ncclP2pImportShareableBufferを呼び出して相手側メモリハンドルをインポート。
並行制御とハードウェア相互作用
P2P の同期はncclSendMem/ncclRecvMem内のhead/tailポインタに依存する。送信側はheadを書いて受信側に「どこまで書いたか」を伝え、受信側はtailを書いて送信側に「どこまで読んだか」を伝える。これは典型的なロックフリー生産者-消費者:
📎 src/transport/p2p.cc:571-576
} else {
send->conn.tail = &remDevMem->tail;
send->conn.head = &resources->sendDevMem->head;
send->conn.ptrExchange = &resources->sendDevMem->ptrExchange;
send->conn.redOpArgExchange = resources->sendDevMem->redOpArgExchange;
}headはローカルsendDevMem,tailを指し、remDevMemは相手側
を指す。GPU kernel はこれら2つのポインタを読み書きすることで CPU を介さずにクロス GPU 同期を実現する。
本番環境の落とし穴回避ガイド落とし穴 1:P2P Read と memcpy は相互排他。p2pSendConnect:
📎 src/transport/p2p.cc:551-559
for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
if (info->read && p == NCCL_PROTO_SIMPLE) {
/* For P2P Read the SIMPLE buffer is local (ncclSendMem) */
if (resources->sendDevMem == NULL) return ncclInternalError; // We should not use read + memcpy
send->conn.buffs[p] = (char*)(resources->sendDevMem + 1);
} else {
send->conn.buffs[p] = buff;
buff += comm->buffSizes[p];
}
}もしread=1だがsendDevMem==NULLなら、直接ncclInternalErrorを返す。本番環境でこのエラーが出た場合、NCCL_P2P_READ_ENABLE=1とNCCL_P2P_USE_CUDA_MEMCPY=1を同時に設定していないか確認する——これらは意味的に衝突する。
落とし穴 2:プロセス間の解放順序。 p2pSendFreeはsendMemSameProcに基づいて解放方法を決定する:
📎 src/transport/p2p.cc:624-651
ncclResult_t p2pSendFree(struct ncclComm* comm, struct ncclConnector* send) {
struct p2pResources* resources = (struct p2pResources*)send->transportResources;
if (resources) {
if (ncclCuMemEnable()) {
if (resources->sendMemIpc) {
if (resources->sendMemSameProc) {
NCCLCHECK(ncclCuMemFreeAddr(resources->sendMemIpc, comm->memManager));
} else {
NCCLCHECK(ncclCudaFree(resources->sendMemIpc, comm->memManager));
}
}
...同一プロセスではncclCuMemFreeAddrを使用(アドレスマッピングのみ解放し、物理メモリは解放しない)、プロセス間ではncclCudaFree(物理メモリを解放)。逆にするとメモリリークや use-after-free が発生します。
三、SHM:共有メモリの「誰がメモリを出すか」の争い
直感的モデル
SHM は「2つのプロセスが1枚のホワイトボードを共有する」ようなもの——送信側が書き、受信側が読む。しかしホワイトボードを誰の家に置くのか?送信側の家(sender-side)に置いて受信側が読みに来るのか、それとも受信側の家(receiver-side)に置いて送信側が書きに行くのか?これがNCCL_SHM_LOCALITYパラメータで解決すべき問題です。
データ構造とメモリレイアウト
📎 src/transport/shm.cc:28-34
struct shmSendResources {
struct ncclRecvMem* remHostMem;
struct ncclRecvMem* devRemHostMem;
ncclShmIpcDesc_t remDesc;
struct ncclSendMem* hostMem;
struct ncclSendMem* devHostMem;
};
struct shmRecvResources {
struct ncclSendMem* remHostMem;
struct ncclSendMem* devRemHostMem;
ncclShmIpcDesc_t remDesc;
struct ncclRecvMem* hostMem;
struct ncclRecvMem* devHostMem;
};注意hostMemとdevHostMemはペアで出現します:hostMemは host 側ポインタ、devHostMemはデバイス側ポインタ(UVA または cuMem マッピング経由)。remHostMem/devRemHostMemは対端共有メモリのローカルマッピングです。
シナリオ駆動 Walkthrough:SHM の locality 選択
shmSendSetuplocality に応じてどれだけメモリを確保するかを決定します:
📎 src/transport/shm.cc:88-119
static ncclResult_t shmSendSetup(struct ncclComm* comm, struct ncclTopoGraph* graph, struct ncclPeerInfo* myInfo,
struct ncclPeerInfo* peerInfo, struct ncclConnect* connectInfo,
struct ncclConnector* send, int channelId, int connIndex) {
struct shmSendResources* resources;
struct shmConnectInfo* info = (struct shmConnectInfo*)connectInfo;
size_t shmSize = sizeof(struct ncclSendMem);
struct shmRequest req;
NCCLCHECK(ncclCalloc(&resources, 1));
send->transportResources = resources;
if (shmLocality == SHM_SEND_SIDE) {
for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) shmSize += comm->buffSizes[p];
}
req.size = shmSize;
if (myInfo->hostHash == peerInfo->hostHash && myInfo->pidHash == peerInfo->pidHash) req.legacy = true;
else req.legacy = false;
NCCLCHECK(ncclProxyConnect(comm, TRANSPORT_SHM, 1, myInfo->rank, &send->proxyConn));
NCCLCHECK(ncclProxyCallBlocking(comm, &send->proxyConn, ncclProxyMsgSetup, (void*)&req, sizeof(struct shmRequest),
(void*)info, sizeof(struct shmConnectInfo)));
info->rank = comm->rank;
resources->hostMem = (struct ncclSendMem*)info->buf.hptr;
resources->devHostMem = (struct ncclSendMem*)info->buf.dptr;
...shmLocality == SHM_SEND_SIDE時、送信側はデータバッファ(shmSizeにすべてのプロトコルバッファを加えたもの)を確保します。そうでなければncclSendMem制御構造のみを確保します。req.legacy同一プロセスかどうかをマーク——同一プロセスなら従来のmmapが使え、クロスプロセスなら cuMem または/dev/shmファイルが必要です。
shmSendConnect内で locality に応じてbuffsがローカルを指すか対端を指すかを決定します:
📎 src/transport/shm.cc:153-176
static ncclResult_t shmSendConnect(struct ncclComm* comm, struct ncclConnect* connectInfo, int nranks, int rank,
struct ncclConnector* send) {
struct shmConnectInfo* info = (struct shmConnectInfo*)connectInfo;
struct shmSendResources* resources = (struct shmSendResources*)send->transportResources;
char* buff;
NCCLCHECK(ncclShmImportShareableBuffer(comm, info->rank, &info->desc, (void**)&resources->remHostMem,
(void**)&resources->devRemHostMem, &resources->remDesc));
buff = shmLocality == SHM_SEND_SIDE ? (char*)(resources->devHostMem + 1) : (char*)(resources->devRemHostMem + 1);
for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
send->conn.buffs[p] = buff;
buff += comm->buffSizes[p];
}
send->conn.tail = &resources->devRemHostMem->tail;
send->conn.head = &resources->devHostMem->head;
send->conn.stepSize = comm->buffSizes[NCCL_PROTO_SIMPLE] / NCCL_STEPS;
...SHM_SEND_SIDE:buffsはローカルdevHostMemを指す(送信側が自分のメモリに書き込む);SHM_RECV_SIDE:buffsは対端devRemHostMemを指す(送信側が受信側のメモリに書き込む)。headは常にローカルを指し、tailは常に対端を指す——送信側がheadを更新し、受信側がtail。
を更新するためです。
〔設計推論とアーキテクチャのトレードオフ〕SHM_RECV_SIDEなぜデフォルトで
なのか? 受信側は通常、共有メモリから自分の GPU メモリへデータをコピーする必要があり、共有メモリが受信側ローカルにあればコピーパスが短くなり(ローカルメモリ → ローカル GPU)、NUMA を跨ぐアクセスを避けられるからです。送信側がリモートメモリに書き込むとノードを跨ぐ書き込みが1回増えますが、送信側は通常計算集約型の GPU であり、書き込み操作は非同期で行えます。
本番環境の落とし穴回避ガイド/dev/shm落とし穴:コンテナ間で shmCanConnectが共有されない。info1->shmDev != info2->shmDev:
📎 src/transport/shm.cc:76-78
TRACE(NCCL_INIT | NCCL_SHM, "peer1 shmDev %lx peer2 shmDev %lx", info1->shmDev, info2->shmDev);
if (info1->shmDev != info2->shmDev) return ncclSuccess;コピー/dev/shm,shmDev2つのコンテナが異なる/dev/shmをマウントしている場合、異なるため、SHM は自動的に NET に降格します。本番環境で同一ホスト通信なのにネットワークを経由している場合、コンテナの
マウントが一致しているか確認してください。
四、NET:ネットワーク伝送のマッピングテーブルとプロキシ進捗
直感的モデルconnectMap。
NET は「都市間宅配便」——データをパッケージ化して NIC に渡し、NIC が光ファイバーを通じて対端へ送ります。しかし NIC は GPU メモリアドレスを認識できないため、「アドレスマッピングテーブル」で GPU 仮想アドレスを NIC が理解できる物理アドレスに変換する必要があります。このテーブルが
📎 src/transport/net.cc:73-86
struct connectMapMem {
char* gpuPtr;
char* cpuPtr;
ssize_t size;
ncclIpcDesc ipcDesc;
ncclShmIpcDesc_t attachDesc;
ncclShmIpcDesc_t createDesc;
};
struct connectMap {
int sameProcess;
int shared;
int cudaDev;
// First 3 bits of offsets determine the mem bank. 001 is host mem, 011 is dev mem, 101 is shared host mem and 111
// is shared dev mem.
struct connectMapMem mems[NCCL_NET_MAP_MEMS];
// Offsets. 3 MSBs indicate mem bank, 111 indicates NULL.
struct {
uint32_t sendMem;
uint32_t recvMem;
uint32_t buffs[NCCL_NUM_PROTOCOLS];
} offsets;
};connectMapコピーmemsは「メモリバンク」システムです:NCCL_NET_MAP_MEMS=5配列には5つのスロット(offsets)があり、それぞれ host mem、dev mem、shared host mem、shared dev mem、GDC mem に対応します。
内の各フィールドは32ビット整数で、上位3ビットが「どのバンクか」をエンコードし、下位29ビットが「バンク内オフセット」をエンコードします。
📎 src/transport/net.cc:36-46
#define NCCL_NET_MAP_OFFSET_BANK(mapStruct, offsetName) ((mapStruct)->offsets.offsetName >> 30)
#define NCCL_NET_MAP_OFFSET_NULL(mapStruct, offsetName) (((mapStruct)->offsets.offsetName >> 29) == 0)
#define NCCL_NET_MAP_GET_POINTER(mapStruct, cpuOrGpu, offsetName) \
(NCCL_NET_MAP_OFFSET_NULL(mapStruct, offsetName) ? \
NULL : \
(mapStruct)->mems[NCCL_NET_MAP_OFFSET_BANK(mapStruct, offsetName)].cpuOrGpu##Ptr + \
((mapStruct)->offsets.offsetName & NCCL_NET_MAP_MASK_OFFSET))
#define NCCL_NET_MAP_DEV_MEM(mapStruct, offsetName) (((mapStruct)->offsets.offsetName & NCCL_NET_MAP_MASK_DEVMEM) != 0)NCCL_NET_MAP_GET_POINTER(map, gpu, sendMem)コピーoffsets.sendMem展開後:mems[bank].gpuPtrの上位2ビットを bank インデックスとして取り、connectMapに下位29ビットのオフセットを加えて実際のポインタを得ます。このエンコード方式は「どのメモリ領域 + 領域内オフセット」を1つの32ビット整数に圧縮し、
の転送サイズを節約します。
sendProxyConnectシナリオ駆動 Walkthrough: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 > 1コピーactiveConnect時に「共有接続」を有効化:複数の channel が同じ NIC 接続を再利用し、接続数を削減します。
配列は1つの local rank だけが接続を開始することを保証し、重複を避けます。
📎 src/transport/net.cc:933-956
if (resources->shared == 0) {
// Only allocate dedicated buffers for ring/tree, not for p2p
for (int p = 0; p < NCCL_NUM_PROTOCOLS; p++) {
NCCL_NET_MAP_ADD_POINTER(map, 0, p != NCCL_PROTO_LL && resources->useGdr ? 1 : 0, proxyState->buffSizes[p],
buffs[p]);
resources->buffSizes[p] = proxyState->buffSizes[p];
}
} else {
// Get shared buffers
int bank = resources->useGdr ? NCCL_NET_MAP_SHARED_DEVMEM : NCCL_NET_MAP_SHARED_HOSTMEM;
struct connectMapMem* mapMem = map->mems + bank;
NCCLCHECK(sharedNetBuffersInit(proxyState, resources->useGdr, resources->tpLocalRank, 0, map->sameProcess,
proxyState->p2pnChannels, &mapMem->gpuPtr, &mapMem->cpuPtr, &mapMem->size,
&mapMem->ipcDesc));
resources->buffSizes[NCCL_PROTO_SIMPLE] = mapMem->size;
...NCCL_NET_MAP_ADD_POINTERコピーconnectMap:
📎 src/transport/net.cc:48-62
#define NCCL_NET_MAP_ADD_POINTER(mapStruct, shared, dev, memSize, offsetName) \
do { \
int bank = NCCL_NET_MAP_MASK_USED + (dev) * NCCL_NET_MAP_MASK_DEVMEM + (shared) * NCCL_NET_MAP_MASK_SHARED; \
if ((shared) == 0) { \
if (dev) { \
(mapStruct)->offsets.offsetName = bank + (mapStruct)->mems[NCCL_NET_MAP_DEVMEM].size; \
(mapStruct)->mems[NCCL_NET_MAP_DEVMEM].size += memSize; \
} else { \
(mapStruct)->offsets.offsetName = bank + (mapStruct)->mems[NCCL_NET_MAP_HOSTMEM].size; \
(mapStruct)->mems[NCCL_NET_MAP_HOSTMEM].size += memSize; \
} \
} else { \
(mapStruct)->offsets.offsetName = bank; \
} \
} while (0);に登録しますsizeコピーoffsets非共有バッファ:現在の bank のsize += memSizeをオフセットとして
に書き込み、次に
📎 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]));
}
...最後にメモリを NIC に登録します:cuMemGetHandleForAddressRangeコピーregMr優先的に DMA-BUF パスを経由(
で fd を取得し、NIC プラグインに渡す)、失敗した場合は
sendProxyProgress(従来の nv_peermem GDR)にフォールバックします。
📎 src/transport/net.cc:1324-1491
static ncclResult_t sendProxyProgress(struct ncclProxyState* proxyState, struct ncclProxyArgs* args) {
...
if (args->state == ncclProxyOpProgress) {
int p = args->protocol;
int maxDepth = std::min(NCCL_STEPS, NCCL_SHARED_STEPS / args->nsubs);
for (int s = 0; s < args->nsubs; s++) {
struct ncclProxySubArgs* sub = args->subs + s;
...
// Post buffers to the GPU
if (sub->posted < sub->nsteps && sub->posted < sub->done + maxDepth) {
...
if (resources->shared) {
...
volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
sub->posted += args->sliceSteps;
*sendHead = sub->base + sub->posted - NCCL_STEPS;
if (resources->gdcSync) wc_store_fence(); // Flush out WC write
} else {
sub->posted += args->sliceSteps;
}
...
continue;
}
// Check whether we received data from the GPU and send it to the network
if (sub->transmitted < sub->posted && sub->transmitted < sub->done + NCCL_STEPS) {
...
if (connFifo[buffSlot].size != -1 && (*recvTail > tail || p == NCCL_PROTO_LL)) {
...
if (ready) {
...
NCCLCHECK(proxyState->ncclNet->isend(resources->netSendComm, buff, size, resources->tpRank,
sub->sendMhandle, phandle, sub->requests + buffSlot));
...- postは NET のデータ転送エンジンで、「post → transmit → done」の三段式を採用しています:
sendMem->headコピー - transmit:プロキシスレッドが
recvMem->tailを更新し、GPU に「バッファの準備ができた、データを書き込んでよい」と伝えます。connFifo[buffSlot].size != -1:ncclNet->isendが進んだか(GPU が書き終えたか)を確認し、 - done(データサイズが記入済みか)を確認し、次に
ncclNet->testを呼び出して非同期送信を開始します。sendMem->head:
wc_store_fence()を呼び出して送信完了を確認し、gdcSyncを更新してバッファを返却します。
は書き込み結合バリア——GDRCopy シナリオでは、CPU が
を書き込んだ後に書き込み結合バッファをフラッシュしなければ、GPU は更新を認識できません。本番環境の落とし穴回避ガイド
📎 src/transport/net.cc:1388-1403
if (p == NCCL_PROTO_LL128) {
ready = resources->useGdr;
if (!ready) {
uint64_t flag = sub->base + sub->transmitted + 1;
int nFifoLines = DIVUP(connFifo[buffSlot].size, sizeof(uint64_t) * NCCL_LL128_LINEELEMS);
volatile uint64_t* lines = (volatile uint64_t*)buff;
ready = 1;
for (int i = 0; i < nFifoLines; i++) {
if (lines[i * NCCL_LL128_LINEELEMS + NCCL_LL128_DATAELEMS] != flag) {
ready = 0;
break;
}
}
}
}データが sysmem(非 GDR)にある場合、プロキシスレッドは LL128 flag を行ごとに確認する必要があります:threadfence()コピーuseGdr正しいか——GDR パスではデータは直接 VRAM に書き込まれ、行ごとの検証は不要。
落とし穴 2:GDRCopy flush のメモリオーダリング。受信側はrecvProxyProgressの中に巧妙なインラインアセンブリがある:
📎 src/transport/net.cc:1664-1682
if (totalSize > 0 && p == NCCL_PROTO_SIMPLE && needFlush) {
struct recvNetResources* resources = (struct recvNetResources*)(subGroup->connection->transportResources);
if (resources->gdcFlush) {
#if defined(__x86_64__)
asm volatile("mfence" ::: "memory");
asm volatile("mov (%0), %%eax" ::"l"(resources->gdcFlush) : "%eax", "memory");
#else
std::atomic_thread_fence(std::memory_order_seq_cst);
uint64_t dummy;
NCCLCHECK(ncclGdrCudaRead(resources->gdrDesc, &dummy, resources->gdcFlush, sizeof(dummy)));
#endif
}mfenceCQE poll の読み取りが flush の読み取りより前にリオーダーされないことを保証する;mov (%0), %%eaxPCIe 読み取りを強制的に 1 回発行し、CPU をストールさせて、先行するすべての PCIe posted write(NIC DMA を含む)がコミットされるまで待つ。これは GDRCopy シナリオにおいて「NIC は書き込み完了と言ったがデータはまだ PCIe バッファにある」ことを防ぐ鍵である。この部分を削除すると、受信側は古いデータを読む可能性がある。
sequenceDiagram
participant GPU as GPU Kernel
participant SM as ncclSendMem
participant Proxy as sendProxyProgress
participant NIC as ncclNet->isend
participant Peer as 对端网卡
GPU->>SM: 写数据到 buffs[p]
GPU->>SM: 更新 recvMem->tail
Proxy->>SM: 读 recvTail, connFifo[buffSlot].size
Proxy->>Proxy: 检查 ready (LL128 flag / GDR)
Proxy->>NIC: isend(comm, buff, size, mhandle)
NIC->>Peer: DMA 发送
Proxy->>NIC: test(request, &done)
NIC-->>Proxy: done=1
Proxy->>SM: 更新 sendMem->head (归还缓冲区)
Proxy->>GPU: 下一轮 post五、NVLS:マルチキャストグループと UC/MC メモリバインディング
直感的モデル
NVLS は「放送局」である——1 つの rank がマルチキャストグループにデータを書き込むと、ハードウェアが自動的にすべての購読者にコピーする。従来の AllReduce は N-1 回のポイントツーポイント転送が必要だが、NVLS は 1 回のマルチキャスト書き込み + 1 回のマルチキャスト読み取りで済む。NVLS がなければ、大規模 AllReduce のレイテンシは rank 数に比例して線形に増加する。
データ構造とメモリレイアウト
NVLS の核心は「UC(ユニキャスト)メモリ」と「MC(マルチキャスト)メモリ」のバインディングである。nvlsAllocBindUcUC メモリを割り当てて MC グループにバインドする:
📎 src/transport/nvls.cc:225-277
static ncclResult_t nvlsAllocBindUc(struct ncclComm* comm, const struct ncclMcPartition* partition, size_t size,
struct ncclNvlsUcSegment* outUc) {
CUmemAllocationProp ucprop;
...
ucprop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
ucprop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
ucprop.location.id = comm->cudaDev;
ucprop.requestedHandleTypes = ncclCuMemHandleType;
CUCHECKGOTO(cuMemGetAllocationGranularity(&ucgran, &ucprop, CU_MEM_ALLOC_GRANULARITY_RECOMMENDED), ret, fail);
ALIGN_SIZE(ucsize, ucgran);
CUCHECKGOTO(cuMemAddressReserve((CUdeviceptr*)&ucptr, ucsize, ucgran, 0U, 0), ret, fail);
CUCHECKGOTO(cuMemCreate(&ucHandle, ucsize, &ucprop, 0), ret, fail1);
CUCHECKGOTO(cuMemMap((CUdeviceptr)ucptr, ucsize, 0, ucHandle, 0), ret, fail2);
CUCHECKGOTO(cuMemSetAccess((CUdeviceptr)ucptr, ucsize, &comm->nvlsResources->accessDesc, 1), ret, fail3);
CUDACHECKGOTO(cudaMemset(ucptr, 0, ucsize), ret, fail3);
NCCLCHECKGOTO(ncclMemTrack(comm->memManager, ucptr, ucsize, ucHandle, ncclCuMemHandleType, ncclMemPersist), ret,
fail3);
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
comm->localRankToRank[0]),
ret, fail3);
NCCLCHECKGOTO(ncclMcPartitionBindMem(partition, 0 /*offsetInPartition*/, ucHandle, 0 /*memOffset*/, ucsize), ret,
fail3);
...フロー:cuMemCreate物理メモリを割り当て →cuMemMap仮想アドレスにマッピング →cuMemSetAccessGPU アクセス権限を設定 →ncclMcPartitionBindMemUC 物理メモリを MC グループの指定オフセットにバインドする。バインド後、任意の rank が MC アドレスに書き込むと、ハードウェアはデータをすべてのバインドされた UC メモリにコピーする。
注意bootstrapIntraNodeBarrierはcuMulticastBindMemより前——コメントによればこれは「mitigate the possible hang in cuMulticastBindMem during abort」のためである。これはハードウェアレベルの防御である:ある rank がバインド中に abort すると、他の rank がcuMulticastBindMemでハングする可能性がある。
シナリオ駆動 Walkthrough:ncclNvlsBufferSetup のバッファレイアウト
📎 src/transport/nvls.cc:279-368
ncclResult_t ncclNvlsBufferSetup(struct ncclComm* comm) {
...
nvlsStepSize = comm->nvlsChunkSize;
buffSize = nvlsStepSize * NCCL_STEPS;
nvlsPerRankSize = nChannels * 2 * buffSize;
nvlsTotalSize = nvlsPerRankSize * nHeads;
...
if (resources->dataUc.ptr == NULL) {
NCCLCHECKGOTO(nvlsAllocBindUc(comm, &resources->dataPartition, nvlsTotalSize, &resources->dataUc), res, fail);
}
...
for (int h = 0; h < nHeads; h++) {
int nvlsPeer = comm->nRanks + 1 + h;
for (int c = 0; c < nChannels; c++) {
struct ncclChannel* channel = comm->channels + c;
struct ncclChannelPeer* peer = channel->peers[nvlsPeer];
// Reduce UC -> MC
peer->send[1].conn.buffs[NCCL_PROTO_SIMPLE] = (char*)resources->dataUc.ptr + (h * 2 * nChannels + c) * buffSize;
peer->recv[0].conn.buffs[NCCL_PROTO_SIMPLE] =
(char*)resources->dataPartition.ptr + (h * 2 * nChannels + c) * buffSize;
// Broadcast MC -> UC
peer->recv[1].conn.buffs[NCCL_PROTO_SIMPLE] =
(char*)resources->dataUc.ptr + ((h * 2 + 1) * nChannels + c) * buffSize;
peer->send[0].conn.buffs[NCCL_PROTO_SIMPLE] =
(char*)resources->dataPartition.ptr + ((h * 2 + 1) * nChannels + c) * buffSize;
...バッファレイアウト:各 head は2 * nChannels個のバッファを持つ(半分は reduce 用、半分は broadcast 用)。send[1]とrecv[0]は reduce 方向(UC → MC)、recv[1]とsend[0]は broadcast 方向(MC → UC)。dataUc.ptrはローカル UC メモリ、dataPartition.ptrは MC グループマッピングアドレス。
設計上の考察
なぜ NVLS のcanConnectは 0 を返すのか? なぜなら NVLS はポイントツーポイント転送ではない——「1 対多」のマルチキャストモデルである。selectTransportのループはポイントツーポイント接続用に設計されており、NVLS の接続確立はncclNvlsSetupの独立パスを通る。NVLS をncclTransports配列に入れるのはfreeインターフェース(nvlsSendFree/nvlsRecvFree)を統一するためだけで、実際の接続ロジックは完全に独立している。
本番環境の落とし穴回避ガイド
落とし穴:MNNVL は NVLS buffer 登録をサポートしない。参照ncclNvlsSetup:
ここまでで、NCCL は ncclTransport 抽象層を通じて、P2P、SHM、NET、NVLS の 4 つの異種チャネルを一貫したインターフェースに統一することに成功し、アルゴリズムカーネルは基盤が NVLink か NIC かを気にする必要がなくなった。しかし転送層は「チャネルをどう抽象化するか」を解決しただけで、「データがどう非同期に駆動されるか」にはまだ答えていない。次章では src/proxy.cc と src/include/proxy.h に焦点を当て、proxy スレッドがホスト側でどのようにネットワーク送受信を非同期に進め、GPU カーネルとプロデューサー・コンシューマー関係を形成するかを見て、NCCL の非同期性の鍵となるメカニズムを明らかにする。
第 12 章:第 12 章:プロキシスレッドの非同期スケジューリング:proxy.cc が I/O とカーネル実行をどう分離するか
第 12 章:プロキシスレッドの非同期スケジューリング:proxy.cc が I/O とカーネル実行をどう分離するか
前章では transport 抽象層を分解し、NCCL がどのように統一インターフェースで P2P/SHM/NET/NVLS の差異を隠蔽するかを見た。しかし転送層は「データがどのチャネルを通るか」に答えただけで、「データがどう非同期に駆動されるか」にはまだ答えていない。GPU カーネルが直接ネットワーク待ちでブロックすると、計算ユニットは I/O に引きずられてしまう。本章ではsrc/proxy.ccとsrc/include/proxy.hに焦点を当て、NCCL がどのように独立した host スレッドでネットワーク I/O をカーネル実行パスから切り離し、GPU とプロデューサー・コンシューマー関係を形成するかを見る。
12.1 なぜプロキシスレッドが必要か:「誰がネットワークを待つか」から始める
直感的モデル
レストランを想像してほしい。厨房(GPU kernel)は料理を作るだけを担当し、配膳係(proxy スレッド)が料理をお客さん(ネットワークの対端)に運ぶ。もし料理人自身が配膳をしたら、運ぶたびに調理を止めなければならず、提供速度が急落する。NCCL の proxy はまさにその専任の配膳係である——kernel は共有バッファにデータを書き込み、バッファからデータを読むだけであり、ネットワーク送受信の面倒な作業はすべて host 側の proxy スレッドに任される。
もし proxy がなければ、システムはどのような災難に直面するだろうか? GPU kernel は SIMT の大規模並列であり、1 つの warp がネットワークポーリングでブロックされると、SM 全体の計算能力を無駄にしてしまう。さらに致命的なのは、ネットワーク送受信が socket システムコール、verbs ポーリング、DMA ディスクリプタの投入を伴い、これらの操作は device コード内では実行できないことである。したがって NCCL はネットワーク I/O を host に移し、kernel と proxy が共有メモリ内の FIFO を通じて「データ準備完了」シグナルを交換する必要がある。
2 種類のスレッドの役割分担
NCCL は host 側で 2 種類の proxy スレッドを起動し、その責務はまったく異なる:
- Service スレッド(
ncclProxyService):制御プレーンのリクエストを処理する——接続確立、メモリ登録、FD クエリ。これは 1 つの socket をリッスンし、ローカル rank からの RPC リクエストを受信し、setup/connect などの操作を非同期に進める。 - Progress スレッド(
ncclProxyProgress):データプレーンを処理する——実際にネットワーク送受信を駆動する。共有メモリプールから proxy op を取り出し、transport のproxyProgressコールバックを呼び出してデータ転送を進める。
📎 src/include/proxy.h:343-345表示ncclProxyState同時に保持するthread(Service)とthreadUDS(UDS サービス)、そして Progress スレッドのハンドルはprogressState.threadの中に隠れている📎 src/include/proxy.h:261-261。
プロデューサー・コンシューマー関係の確立
📎 src/proxy.cc:2130-2166のncclProxyCreateはスレッドが誕生する場所である:refCount == 1(最初の comm 作成)のとき、comm の重要なフィールドをproxyStateにコピーし、その後 Service スレッドと UDS スレッドを起動する。Progress スレッドはここでは起動されないことに注意——それはproxyProgressInitが最初に proxy progress を必要とする接続確立時に遅延起動する📎 src/proxy.cc:1523-1524。
flowchart TD
create["ncclProxyCreate(comm)"] --> check_ref{"proxyState->refCount == 1?"}
check_ref -->|否| skip["复用已有线程,直接返回"]
check_ref -->|是| copy["拷贝 comm 字段到 proxyState"]
copy --> start_svc["std::thread(ncclProxyService)"]
start_svc --> start_uds["std::thread(ncclProxyServiceUDS)"]
start_uds --> wait["等待连接建立请求"]
wait --> conn_init{"proxyConnInit 发现<br/>tcomm->proxyProgress != NULL?"}
conn_init -->|是| prog_init["proxyProgressInit()"]
conn_init -->|否| no_prog["不启动 Progress 线程"]
prog_init --> shm["ncclShmOpen 创建 opsPool 共享内存"]
shm --> start_prog["std::thread(ncclProxyProgress)"]この図はスレッド起動の実際の分岐を固定している:tcomm->proxyProgressが非 NULL(つまりその transport がデータプレーンの推進を必要とする)の場合にのみ、Progress スレッドが作成される。
12.2 データ構造とメモリレイアウト:共有メモリプールと op プール
中核構造体の全体像
proxy の並行モデルは 2 つの共有メモリ上に成り立っており、それらのメモリレイアウトを理解することがメカニズム全体を理解する前提である。
第一のもの:ncclProxyOpsPool(📎 src/include/proxy.h:218-226)。これはメインスレッドと Progress スレッドの間の「タスク投入箱」であり、/dev/shmを通じてプロセス間共有される。
| フィールド | 型 | 役割 |
|---|---|---|
ops[] | ncclProxyOp[] | 事前確保された op 配列、サイズMAX_OPS_PER_PEER * NCCL_MAX_LOCAL_RANKS |
nextOps | volatile int | 処理待ち op リンクリストの先頭インデックス、-1 は空を表す |
nextOpsEnd | volatile int | 処理待ち op リンクリストの末尾インデックス |
freeOps[] | volatile int[] | 各 local rank の空き op リンクリスト先頭 |
syncObjectsInitialized | int | mutex/cond が初期化済みかどうかを示す |
mutex / cond | std::mutex / std::condition_variable | プロセス間同期プリミティブ |
MAX_OPS_PER_PEERの定義📎 src/include/proxy.h:218-226は2 * MAXCHANNELS * 2 * NCCL_MAX_DEV_WORK_P2P_PER_BATCH。コメントはなぜ 2 倍なのかを説明している:各 p2p work は 1 つの send と 1 つの recv proxy op を含むため 2 を掛ける。さらに 2 を掛けるのは 2 ラウンド分の完全な操作を保存できるようにするためであり、そうでなければ「半分投入して半分解放する」ことができない。
第二のもの:ncclProxyArgs(📎 src/include/proxy.h:174-209)。これは Progress スレッド内部で使用される「実行時 op 記述」であり、ncclProxyPoolから割り当てられ、プロセス間共有されない。
重要なフィールド:
subs[NCCL_PROXY_MAX_SUBS]:サブ操作配列、NCCL_PROXY_MAX_SUBS = MAXCHANNELS📎src/include/proxy.h:55-55。複数 channel の同種操作は 1 つの args の複数の sub に集約される。progress:関数ポインタ、transport のproxyProgressコールバックを指す📎src/include/proxy.h:176-176。next/nextPeer/proxyAppendPtr:3 本のリンクリストポインタで、複雑な op 組織関係を構成する。state:ncclProxyOpNone/ncclProxyOpReady/ncclProxyOpProgress3 状態📎src/include/proxy.h:48-52。
メモリプールの階層設計
ncclProxyPool 📎 src/proxy.cc:50-53はバッチ割り当て単位であり、各 pool はPROXYARGS_ALLOCATE_SIZE(すなわちNCCL_MAX_OPS)個のncclProxyArgs。allocateArgs 📎 src/proxy.cc:207-231の割り当てロジックは詳しく見る価値がある:
if (state->pool == NULL) {
struct ncclProxyPool* newPool;
NCCLCHECK(ncclCalloc(&newPool, 1));
struct ncclProxyArgs* newElems = newPool->elems;
for (int i = 0; i < PROXYARGS_ALLOCATE_SIZE; i++) {
if (i + 1 < PROXYARGS_ALLOCATE_SIZE) newElems[i].next = newElems + i + 1;
}
state->pool = newElems;
newPool->next = state->pools;
state->pools = newPool;
}
elem = state->pool;
state->pool = state->pool->next;📎 src/proxy.cc:207-231
ここでの設計動機は:ncclProxyArgs構造体が非常に大きい(subs[MAXCHANNELS]配列を含み、各 sub にはさらにrequests[NCCL_STEPS]がある)。もし各 op を個別に malloc すると、深刻なメモリ断片化と割り当てオーバーヘッドを引き起こす。バッチ割り当て + 空きリンクリスト再利用により、割り当てコストをほぼゼロまで薄める。コメント「Make sure we allocate the memory close to the network thread」は、これが NUMA 親和性のためであることを示唆している——pool は Progress スレッドの初回割り当て時に作成され、そのスレッドが動作する CPU に自然に近くなる。
偽共有と原子変数
ncclProxyOpsPoolの中のnextOps、nextOpsEnd、freeOps[]はすべてvolatile int。それらはメインスレッドと Progress スレッドによって同時に読み書きされるが、NCCL はすべてのアクセスをロックで保護していない——代わりに原子操作 + メモリオーダーで正しさを保証する。
を見るncclLocalOpAppendの中で freeOps から空き op を取るロジック📎 src/proxy.cc:503-513:
int freeOp = -1;
while (freeOp == -1) {
freeOp = COMPILER_ATOMIC_EXCHANGE(&pool->freeOps[tpLocalRank], -1, std::memory_order_acquire);
if (freeOp == -1) std::this_thread::yield();
}メインスレッドはatomic_exchangeを使ってfreeOps[tpLocalRank]-1 に設定して古い値を取得する——これは「プリエンプティブな取得」である:先に exchange が成功した者が空きリスト全体を手に入れる。Progress スレッドが op を返却する際は CAS ループを使う📎 src/proxy.cc:898-907:
oldFree = COMPILER_ATOMIC_LOAD(&pool->freeOps[i], std::memory_order_acquire);
do {
pool->ops[freeOpEnd[i]].next = oldFree;
} while (!COMPILER_ATOMIC_COMPARE_EXCHANGE(&pool->freeOps[i], &oldFree, newFree,
std::memory_order_release,
std::memory_order_acquire));ここで seq_cst ではなく acquire/release を使うのは、「リストノードの next ポインタの書き込み」が取得側に可視であることだけを保証すればよく、グローバルな順序は不要だからである。freeOps[]配列の各要素は1つの local rank に対応し、自然に異なるキャッシュライン付近に分散するため、偽共有が減少する。
12.3 制御プレーン:接続確立と RPC メカニズム
直感的モデル
Service スレッドは「フロント受付」のようなものである:ローカル rank がネットワーク接続を確立する際、自分で直接接続するのではなく、RPC リクエストを Service スレッドに送り、それに代わって setup/connect を実行してもらう。なぜこうするのか? ネットワーク接続の確立(特に verbs の QP 作成、メモリ登録)はブロックする可能性があり、また一部のリソース(listen socket など)は単一のスレッドが保持しなければならないからである。制御プレーンを Service スレッドに集中させることで、メインスレッドはノンブロッキングで他の作業を続けられる。
RPC リクエストのエンコーディング
ncclProxyCallAsync 📎 src/proxy.cc:1369-1394は RPC の送信側である。socket を通じて順に送信する:type、connection ポインタ、reqSize、respSize、reqBuff、opId。
NCCLCHECKGOTO(ncclSocketSend(sock, &type, sizeof(int)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &proxyConn->connection, sizeof(void*)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &reqSize, sizeof(int)), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &respSize, sizeof(int)), ret, error);
if (reqSize) NCCLCHECKGOTO(ncclSocketSend(sock, reqBuff, reqSize), ret, error);
NCCLCHECKGOTO(ncclSocketSend(sock, &opId, sizeof(opId)), ret, error);
NCCLCHECK(expectedProxyResponseEnqueue(sharedProxyState, opId, respSize));📎 src/proxy.cc:1369-1394
最後のステップに注意:リクエスト送信後、直ちに opId をexpectedResponsesキューに登録する。これが非同期 RPC の鍵である——呼び出し側は返信を待たず、まず「この opId の応答を期待している」と登録し、その後ncclPollProxyResponseでポーリングする。
応答キューの連結リスト実装
expectedProxyResponseEnqueue 📎 src/proxy.cc:97-117は単方向連結リストで応答待ちの op を格納する。expectedProxyResponseStore 📎 src/proxy.cc:67-95は応答受信時に opId でマッチングし、応答データを事前割り当てされたrespBuffに memcpy し、マークするdone = true。expectedProxyResponseDequeue 📎 src/proxy.cc:119-141はポーリング時に完了した応答を検索して取り除く。
ここに細かい点がある:expectedProxyResponseStoreはrespSizeが📎 src/proxy.cc:72-75と一致するかチェックし、一致しなければncclInternalErrorを報告する。これは防御的プログラミングである——リクエスト側と応答側で応答サイズの理解が異なる場合、プロトコルの混乱を意味するため、静かに続行するのではなく即座に失敗しなければならない。
Service スレッドのメインループ
ncclProxyService 📎 src/proxy.cc:1789-2016の核心は poll ループである。それはpollfds配列で全ての接続を管理する。listen socket と各 peer の socket を含む。
while (stop == PROXY_RUNNING || npeers > 0) {
if (COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) != 0) stop = PROXY_ABORT;
int ret = 0;
const int timeout = asyncOpCount ? 0 : 500;
...
ret = poll(activePollfds, nfds_to_poll, timeout);📎 src/proxy.cc:1842-1863
timeoutの選択は慎重である:非同期 op が進行中の場合(asyncOpCount > 0)、timeout を 0(ノンブロッキングポーリング)に設定する。頻繁にproxyProgressAsyncを呼び出してそれらを進める必要があるからである;そうでなければ 500ms に設定し、空回りで CPU を焼くのを避ける。コメント「never let proxy service thread blocks in poll, or it cannot receive abortFlag」📎 src/proxy.cc:1847-1847は、なぜ無限にブロックしてはいけないかを明確にしている——周期的に目覚めて abortFlag をチェックしなければならない。
非同期 op の進行
proxyProgressAsync 📎 src/proxy.cc:1626-1700は Service スレッドが非同期操作を進める核心である。op タイプに応じて異なる transport コールバックにディスパッチする:
if (op->type == ncclProxyMsgSetup) {
res = op->connection->tcomm->proxySetup(op->connection, proxyState, op->reqBuff, op->reqSize, op->respBuff,
op->respSize, &done);
} else if (op->type == ncclProxyMsgConnect) {
res = op->connection->tcomm->proxyConnect(...);
} else if (op->type == ncclProxyMsgInit) {
res = proxyConnInit(peer, connectionPool, proxyState, ...);
}📎 src/proxy.cc:1631-1664
各コールバックはdone出力パラメータを持つ。もしdone == 0なら、操作がまだ完了していない(例えばネットワーク接続がまだ三次ハンドシェイク中)ことを意味し、ncclInProgressを返し、次のループで進行を続ける。もしdone == 1なら、応答ヘッダ+応答ボディをリクエスト側に送信する📎 src/proxy.cc:1681-1689。
sequenceDiagram
participant Main as 主线程 (ncclSend)
participant Svc as Service 线程
participant Net as 网络插件 (ncclNet)
Main->>Svc: ncclProxyCallAsync(ncclProxyMsgConnect)
Note over Main: expectedProxyResponseEnqueue(opId)
Svc->>Svc: proxyServiceInitOp 读取请求
Svc->>Net: proxyConnect() 调用 ncclNet->connect
alt connect 未完成
Net-->>Svc: netSendComm == NULL, done=0
Svc->>Svc: 返回 ncclInProgress,下次 poll 重试
else connect 完成
Net-->>Svc: netSendComm != NULL, done=1
Svc->>Main: ncclSocketSend(resp header + connectMap)
end
Main->>Main: ncclPollProxyResponse 轮询
Main->>Main: expectedProxyResponseDequeue 取回结果このシーケンス図はsendProxyConnect内の*done = 0; return ncclInProgressの実際の分岐を固定する📎 src/transport/net.cc:913-916。
12.4 データプレーン:Progress スレッドがネットワーク送受信をどう駆動するか
直感的モデル
Progress スレッドは「ベルトコンベアのオペレーター」である:共有バッファ内の FIFO を監視し、GPU がデータを書き込むと(FIFO 内の size != -1)、直ちにisendを呼び出してデータを送信する;ネットワークがデータを受信し終えると、recvTail を更新して GPU に読み取り可能を通知する。プロセス全体で GPU と proxy は FIFO 内の head/tail ポインタを通じて同期し、ロックは一切不要である。
op の投入:メインスレッドから Progress スレッドへ
メインスレッドはncclProxySaveOp 📎 src/proxy.cc:591-761内で pattern に基づいて必要な proxy op を決定し、その後SaveProxy → ncclLocalOpAppendを通じて op を共有メモリプールに書き込む。
ncclLocalOpAppend 📎 src/proxy.cc:488-554のフロー:
1.proxyOps->freeOpまたはpool->freeOps[tpLocalRank]から空き op スロットを取得する。
2. memcpy(op, proxyOp, sizeof(struct ncclProxyOp))op の内容を共有メモリ📎 src/proxy.cc:515-515。
にコピーする 3. op をproxyOps->nextOps連結リストの末尾に繋ぐ。
4. 累積した op 数がMAX_OPS_PER_PEERに達したら、バッチ投入📎 src/proxy.cc:525-551。
をトリガーする バッチ投入のロジックは非常に微妙である:単純に全ての op を送信することはできない。なぜなら「同じ opCount の複数の op は一緒に投入しなければならない。そうでなければ proxyArgs の sub 集約が壊れる」からである。そこで最後の opCount 変化の境界を見つけ、そこまでだけ投入する📎 src/proxy.cc:529-548。
投入はncclProxyPost 📎 src/proxy.cc:476-486で完了する。それはロックを取得し、pool->nextOps、notify_oneを更新し、Progress スレッドを起床させる。
Progress スレッドのメインループ
ncclProxyProgress 📎 src/proxy.cc:951-1011の構造:
do {
int idle = 1;
ncclResult_t ret = progressOps(proxyState, state, state->active, &idle);
...
if (idle || !state->active || (++proxyOpAppendCounter == ncclParamProgressAppendOpFreq())) {
int added = 0;
proxyOpAppendCounter = 0;
ret = ncclProxyGetPostedOps(proxyState, &added);
...
}
lastIdle = idle;
stopv = state->stop.load(std::memory_order_acquire);
} while ((stopv == 0 || (stopv == 1 && state->active)) &&
COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) == 0);📎 src/proxy.cc:976-1009
ここに注目すべき性能最適化がある:proxyOpAppendCounterカウンタ📎 src/proxy.cc:974-974。コメントは📎 src/proxy.cc:969-973を説明する:ncclProxyGetPostedOpsを頻繁に呼び出すと小メッセージ通信の性能が後退するため、ProgressAppendOpFreq(デフォルト 8)回ごとに新しい op を取得しに行く。
op の集約:ProxyAppend
ProxyAppend 📎 src/proxy.cc:437-474ある op が「既存の args の sub に追加する」のか「新しい args を作成する」のかを決定する。判断基準はconnection->shared && args->opCount == op->opCount 📎 src/proxy.cc:443-443——同一接続、同一 opCount の複数の channel 操作が集約される。
集約の価値:複数 channel の同種操作を一つの args に統合することで、Progress スレッドが1回のループで全 channel を進められ、関数呼び出しのオーバーヘッドとキャッシュ無効化を削減できる。ncclProxyOpToArgs 📎 src/proxy.cc:368-435sub を追加する際にsliceSteps、chunkSteps、protocol、dtype、redOp、collが一致するかを検証し📎 src/proxy.cc:401-406、一致しなければエラーを報告する——これは誤った集約を防ぐ防衛線である。
sendProxyProgress:送信側の4段階ステートマシン
sendProxyProgress 📎 src/transport/net.cc:1324-1491は送信側の中核である。sub ごとに順に進め、各 sub には4つのカウンタがある:posted、transmitted、done。
段階1:Ready の初期化 📎 src/transport/net.cc:1326-1339
sub->base = ROUNDUP(resources->step, args->chunkSteps);
resources->step = sub->base + sub->nsteps;
sub->posted = sub->transmitted = sub->done = 0;baseは step の開始番号であり、ROUNDUPがchunkSteps。resources->stepに整列することを保証し、累加して次の op のために領域を確保する。
段階2:バッファを GPU に Post する 📎 src/transport/net.cc:1355-1376
if (sub->posted < sub->nsteps && sub->posted < sub->done + maxDepth) {
int buffSlot = (sub->base + sub->posted) % NCCL_STEPS;
if (resources->shared) {
...
*sendHead = sub->base + sub->posted - NCCL_STEPS;
} else {
sub->posted += args->sliceSteps;
}
}maxDepthはパイプライン深度📎 src/transport/net.cc:1343-1343であり、同時に in-flight となる step 数を制限する。shared モードでは、proxy がsendHeadを更新することで GPU に「この slot は書き込み可能」と伝える。
段階3:GPU が書き込み完了したか確認し、isend を発行する 📎 src/transport/net.cc:1378-1452
if (sub->transmitted < sub->posted && sub->transmitted < sub->done + NCCL_STEPS) {
int buffSlot = (sub->base + sub->transmitted) % NCCL_STEPS;
volatile uint64_t* recvTail = &resources->recvMem->tail;
uint64_t tail = sub->base + sub->transmitted;
if (connFifo[buffSlot].size != -1 && (*recvTail > tail || p == NCCL_PROTO_LL)) {
int size = connFifo[buffSlot].size;
...
NCCLCHECK(proxyState->ncclNet->isend(resources->netSendComm, buff, size, resources->tpRank,
sub->sendMhandle, phandle, sub->requests + buffSlot));
if (sub->requests[buffSlot] != NULL) {
sub->transmitted += args->sliceSteps;
}
}
}ここでの重要な判断はconnFifo[buffSlot].size != -1 && *recvTail > tail——GPU がデータを書き終えると FIFO の size と recvTail を更新し、proxy はこの2つの条件が満たされたのを確認してから isend を発行する。LL プロトコルの場合、「ゼロコピー」セマンティクスであるため、recvTail を待つ必要はない。
段階4:送信完了を確認し、sendHead を更新する 📎 src/transport/net.cc:1455-1481
if (sub->done < sub->transmitted) {
int buffSlot = (sub->base + sub->done) % NCCL_STEPS;
NCCLCHECK(proxyState->ncclNet->test(sub->requests[buffSlot], &done, &size));
if (done) {
connFifo[buffSlot].size = -1;
std::atomic_thread_fence(std::memory_order_seq_cst);
sub->done += args->sliceSteps;
if (resources->shared == 0) {
volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
*sendHead = sub->base + sub->done;
}
}
}testが done を返したら、まず FIFO size を -1 にリセットし、seq_cst fence を挿入してから、sendHead を更新して GPU に「この slot は再利用可能」と通知する。fence の役割は size のリセットと head の更新の順序逆転を防ぐこと——もし head が先に更新されると、GPU が size の古い値のまま書き込みを開始する可能性がある。
recvProxyProgress:受信側の4段階
recvProxyProgress 📎 src/transport/net.cc:1493-1788はより複雑である。なぜなら sub のグループ化(複数の sub が同一の recvComm を共有する場合に multirecv を使用)が関わるからである。
段階1:Ready 時に recvComm でグループ化する 📎 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;
}このコードは同一のrecvCommを使用する sub を隣り合わせに並べ、groupSizeを記録する。なぜグループ化するのか? それはirecvが一度に複数の buffer を受信(multirecv)でき、同一 comm のリクエストを1回の呼び出しに統合することでプラグインのオーバーヘッドを大幅に削減できるからである。
段階2:irecv を発行する 📎 src/transport/net.cc:1543-1631
if (subCount) {
uint64_t step = subGroup->posted;
void** requestPtr = subGroup->requests + (step % NCCL_STEPS);
bool ignoreCompletion = ncclParamNetOptionalRecvCompletion() &&
((args->protocol == NCCL_PROTO_LL128) || (args->protocol == NCCL_PROTO_LL)) &&
(subCount == 1);
if (ignoreCompletion) *requestPtr = (void*)NCCL_NET_OPTIONAL_RECV_COMPLETION;
NCCLCHECK(proxyState->ncclNet->irecv(resources->netRecvComm, subCount, ptrs, sizes, tags, mhandles, phandles,
requestPtr));
if (*requestPtr) {
subGroup->recvRequestsCache[step % NCCL_STEPS] = *requestPtr;
subGroup->recvRequestsSubCount = subCount;
for (int i = 0; i < subGroup->groupSize; i++) {
sub->posted += args->sliceSteps;
}
}
}ignoreCompletionの最適化📎 src/transport/net.cc:1608-1610:LL/LL128 プロトコルの単一 buffer 受信では、完了通知はオプションである(データ自体に flag が付いているため)。completion チェックをスキップできる。
段階3:受信完了を確認し、recvTail を更新する 📎 src/transport/net.cc:1634-1743
NCCLCHECK(proxyState->ncclNet->test(subGroup->requests[step % NCCL_STEPS], &done, sizes));
if (done) {
for (int i = 0; i < subGroup->groupSize; i++) {
struct ncclProxySubArgs* sub = subGroup + i;
int buffSlot = (sub->base + sub->received) % NCCL_STEPS;
connFifo[buffSlot].size = -1;
sub->received += args->sliceSteps;
}
...
}受信完了後、FIFO size をリセットし、flush 段階に入る(GDRDMA シナリオではデータの可視性を保証するために flush が必要)。
段階4:GPU の消費を待ち、done を更新する 📎 src/transport/net.cc:1745-1779
if (sub->transmitted > sub->done) {
volatile uint64_t* sendHead = &resources->sendMem->head;
uint64_t done = *sendHead;
while (done > sub->base + sub->done && sub->transmitted > sub->done) {
if (subGroup->recvRequestsCache[sub->done % NCCL_STEPS]) {
if (proxyState->ncclNet->irecvConsumed) {
NCCLCHECK(proxyState->ncclNet->irecvConsumed(resources->netRecvComm, subGroup->recvRequestsSubCount,
subGroup->recvRequestsCache[sub->done % NCCL_STEPS]));
}
subGroup->recvRequestsCache[sub->done % NCCL_STEPS] = NULL;
}
sub->done += args->sliceSteps;
}
}ここではsendHeadを読むことで GPU がデータを消費したかどうかを判断する。irecvConsumedはプラグインへのコールバックであり、「この受信リクエストの buffer は消費済みで再利用可能」と伝える。
データフロー全景
flowchart LR
subgraph GPU["GPU Kernel"]
gpu_write["写入数据到 buff"]
gpu_fifo["更新 connFifo.size<br/>和 recvTail"]
end
subgraph SHM["共享内存 FIFO"]
fifo["ncclConnFifo<br/>size / offset"]
head["sendMem->head"]
tail["recvMem->tail"]
end
subgraph PROXY["Progress 线程"]
check["检查 size != -1<br/>且 recvTail > tail"]
isend["ncclNet->isend()"]
test["ncclNet->test()"]
update["更新 sendHead"]
end
gpu_write --> gpu_fifo
gpu_fifo --> fifo
gpu_fifo --> tail
fifo --> check
tail --> check
check -->|数据就绪| isend
isend --> test
test -->|发送完成| update
update --> head
head -->|GPU 可复用 slot| gpu_writeこのデータフロー図は、GPU と proxy が FIFO と head/tail ポインタを介して形成する閉ループを示している:GPU がデータを書き込む → tail を更新 → proxy が検出して isend を発行 → test で完了を確認 → head を更新 → GPU が slot を再利用。
12.5 並行制御、メモリバリア、ハードウェアとの相互作用
ロックフリー FIFO のメモリオーダー
proxy と GPU 間の同期は完全にncclConnFifoと head/tail ポインタに依存しており、ロックは一切ない。これは極めて慎重なメモリオーダー制御を要求する。
送信側では、proxy はtestが done を返した後に📎 src/transport/net.cc:1460-1473:
connFifo[buffSlot].size = -1;
std::atomic_thread_fence(std::memory_order_seq_cst);
...
*sendHead = sub->base + sub->done;seq_cst fence は size のリセットが GPU に可視になった後で初めて head の更新が可視になることを保証する。もし順序が逆になると、GPU は新しい head を見るが古い size を見てしまい、slot にデータがあると誤認する可能性がある。
受信側では、proxy は recvTail を更新する前に📎 src/transport/net.cc:1731-1736:
if (step < sub->nsteps) {
std::atomic_thread_fence(std::memory_order_seq_cst);
volatile uint64_t* recvTail = resources->gdcSync ? resources->gdcSync : &resources->recvMem->tail;
*recvTail = sub->base + sub->transmitted;
}同じ理屈である:まず fence でデータ書き込みの可視性を保証し、次に tail を更新して GPU に読み取り可能と通知する。
GDRCOPY の flush メカニズム
GDRDMA を使用する場合、NIC は GPU メモリに直接書き込むが、書き込み操作はまだ PCIe バス上でコミットされていない可能性がある。proxy が能動的に flush しなければデータの可視性は保証されない。recvProxyProgress内の flush ロジックを見る📎 src/transport/net.cc:1664-1709:
if (totalSize > 0 && p == NCCL_PROTO_SIMPLE && needFlush) {
if (resources->gdcFlush) {
#if defined(__x86_64__)
asm volatile("mfence" ::: "memory");
asm volatile("mov (%0), %%eax" ::"l"(resources->gdcFlush) : "%eax", "memory");
#else
std::atomic_thread_fence(std::memory_order_seq_cst);
uint64_t dummy;
NCCLCHECK(ncclGdrCudaRead(resources->gdrDesc, &dummy, resources->gdcFlush, sizeof(dummy)));
#endif
} else {
// iflush 路径
NCCLCHECK(proxyState->ncclNet->iflush(resources->netRecvComm, subCount, ptrs, sizes, mhandles,
subGroup->requests + (step % NCCL_STEPS)));
}
}x86 パスのコメントは非常に見事である📎 src/transport/net.cc:1668-1674:mfenceCQE-poll の load が flush load より前にリオーダーされるのを防ぐ;mov (%0), %%eaxPCIe 読み取りを強制し、CPU を停止させて、以前のすべての PCIe posted write(NIC DMA を含む)がエンドポイントにコミットされるまで待つ。これはハードウェアレベルのメモリオーダー制御であり、どのソフトウェアフェンスよりもハードコアである。
アトミック変数と stop/abort の協調
Progress スレッドの終了条件📎 src/proxy.cc:1007-1009:
stopv = state->stop.load(std::memory_order_acquire);
} while ((stopv == 0 || (stopv == 1 && state->active)) &&
COMPILER_ATOMIC_LOAD(proxyState->abortFlag, std::memory_order_acquire) == 0);stop == 1しかしstate->active != NULL時に実行を継続——これは「優雅な停止」のためである:すでに投入された op は必ず完了まで進めなければならない。そうでなければ GPU は永遠にデータを待つことになる。のみstop == 2(abort)またはabortFlag != 0のみ強制終了する。
ncclProxyProgressDestroy 📎 src/proxy.cc:1039-1065の停止フロー:
std::lock_guard<std::mutex> lock(state->opsPool->mutex);
state->stop.store(1, std::memory_order_release);
state->opsPool->cond.notify_one();
state->thread.join();まずロックを取得してから stop を store し、その後 notify——これは lost wakeup を防ぐ標準パターンである。Progress スレッドはpool->cond.wait時にロックを保持し、述語📎 src/proxy.cc:850-851をチェックすることで、ウェイクアップを見逃さないことを保証する。
12.6 本番環境の落とし穴ガイドと障害復旧チェーン
落とし穴1:接続リークにより Service スレッドが終了できない
ncclProxyServiceのメインループ条件はstop == PROXY_RUNNING || npeers > 0 📎 src/proxy.cc:1842-1842である。コメントは📎 src/proxy.cc:1843-1845を説明している:ローカルの comm が abort しても、ピア接続がまだ存在する限り、proxy スレッドは終了してはならない。そうでなければセグメンテーションフォルトが発生する可能性がある。
調査シナリオ:ある rank がクラッシュしたが対端に通知しなかった場合、対端の Service スレッドはnpeers > 0のループでずっとスタックする。この場合、abortFlagまたはタイムアウト機構に依存する必要がある。本番環境でプロセスがncclProxyServiceでハングしているのを見かけたら、まず対端の rank が異常終了していないか確認せよ。
落とし穴2:応答キューが不一致でメモリリークが発生
expectedProxyResponseStoreは opId が一致しない場合にncclInternalError 📎 src/proxy.cc:93-94を返す。しかし、応答が到着したときに要求側がすでに放棄している場合(例えばタイムアウト)、この応答は永遠にキューに残り、respBuffリークする。
防御策:expectedProxyResponseFree 📎 src/proxy.cc:55-65はncclProxyDestroy時にキュー全体をクリーンアップする📎 src/proxy.cc:2226-2226。ただしこれは最後のフォールバックであり、正常動作中に残留があってはならない。
落とし穴3:shared モードで head が負の値に初期化される
sendProxyConnect内の📎 src/transport/net.cc:999-1000:
// Don't give credits yet in shared mode.
(resources->gdcSync ? *resources->gdcSync : resources->sendMem->head) = (map->shared ? -NCCL_STEPS : 0);shared モードでは head が-NCCL_STEPSに初期化される。これは GPU が最初は書き込み可能な credit を持たないことを意味する。proxy は post 段階で徐々に head を増やして「credit を発行」する必要がある。この初期化を忘れると、GPU は credit があると誤認して未準備の slot に書き込み、データが破損する。
落とし穴4:LL128 プロトコルの flag 検証
sendProxyProgress内の LL128 の ready 判定📎 src/transport/net.cc:1388-1403:
if (p == NCCL_PROTO_LL128) {
ready = resources->useGdr;
if (!ready) {
uint64_t flag = sub->base + sub->transmitted + 1;
int nFifoLines = DIVUP(connFifo[buffSlot].size, sizeof(uint64_t) * NCCL_LL128_LINEELEMS);
volatile uint64_t* lines = (volatile uint64_t*)buff;
ready = 1;
for (int i = 0; i < nFifoLines; i++) {
if (lines[i * NCCL_LL128_LINEELEMS + NCCL_LL128_DATAELEMS] != flag) {
ready = 0;
break;
}
}
}
}データが sysmem(非 GDR)にある場合、GPU はthreadfence()のみを呼び出すため、proxy は行ごとに flag をチェックしてデータの完全性を確認しなければならない。このチェックをスキップして直接 isend すると、不完全なデータを送信する可能性がある。これは LL128 特有の罠である。
障害復旧チェーン
時にproxyProgressAsyncが非ncclSuccess/ncclInProgressを返すと📎 src/proxy.cc:1929-1937、Service スレッドは接続を閉じ、そのピアのすべての async op をクリーンアップする📎 src/proxy.cc:1984-1995。このクリーンアップは「全量 drain」である——失敗した op だけでなく、ピア全体の asyncOps キューを空にし、残留 op が解放済みの接続を参照するのを防ぐ。
Progress スレッドがエラーに遭遇すると📎 src/proxy.cc:979-983、エラーコードをproxyState->asyncResultに書き込み、ループを終了する。メインスレッドは後でこのフィールドをチェックしてエラーを検知できる。
本章のまとめ
本章では NCCL プロキシスレッドの完全なメカニズムを分解した:
1. 2種類のスレッドの役割分担:Service スレッドは制御プレーン RPC(接続確立、メモリ登録)を処理し、Progress スレッドはデータプレーン(ネットワーク送受信の推進)を処理する。
2. 共有メモリプール:ncclProxyOpsPoolはプロセス間で op を伝達し、ncclProxyArgsは Progress スレッド内で複数チャネルの操作を集約する。
3. ロックフリー FIFO 同期:GPU と proxy はconnFifoと head/tail ポインタを介してデータ準備完了シグナルを交換し、seq_cst fence でメモリオーダーを保証する。
4. 4段階ステートマシン:send/recv それぞれの posted → transmitted → received → done カウンタがパイプラインを駆動する。
5. ハードウェアレベルの flush:GDRDMA シナリオではmfence+ PCIe 読み取りで posted write を強制コミットする。
本章の考察とセルフチェック
Q1: もしsendProxyProgress内のsub->done == sub->nsteps時にsendHeadを更新するロジックを削除した場合(つまり GPU slot が解放されたことを通知しない場合)、どのようなシナリオでデッドロックが発生するか?なぜか?
参考解析:sendHeadは GPU が「どの slot が再利用可能か」を判断する唯一の根拠である。参照📎 src/transport/net.cc:1469-1473:
if (resources->shared == 0) {
volatile uint64_t* sendHead = resources->gdcSync ? resources->gdcSync : &resources->sendMem->head;
*sendHead = sub->base + sub->done;
}この部分を削除すると、GPU の head は永遠に初期値(shared モードでは-NCCL_STEPS、非 shared では 0)に留まる。GPU カーネルはwaitSend時にhead + NCCL_STEPS > stepをチェックして初めて書き込み可能な credit があると判断する。head が進まないと、GPU はNCCL_STEPS個の slot を書き終えた後、永遠に credit 待ちでブロックされ、proxy は GPU が新しいデータを書くのを待って初めて isend できる——典型的な生産者-消費者デッドロックである。shared モードではさらに深刻で、初期 head が負の値であるため、GPU は最初から credit を持たない。
Q2: ncclLocalOpAppend累積opがMAX_OPS_PER_PEERに達するとバッチ投入がトリガーされるが、コードは意図的に「最後のopCountのすべてのopを投入しない」。もし単純にすべてのopを投入するように変更した場合、どのようなメカニズムが壊れるか?
参考解析:見る📎 src/proxy.cc:525-548のコメントとロジック:
// Do not post last operations as we could have more coming with the same opCount, and posting
// them in different batches would break proxyArgs aggregation with subs.
uint64_t lastOpCount = pool->ops[proxyOps->nextOpsEnd].opCount;
int lastOp = -1;
...
for (int op = proxyOps->nextOps; op != proxyOps->nextOpsEnd; op = pool->ops[op].next) {
ops++;
if (pool->ops[op].opCount != lastOpCount) {
lastOp = op;
toSend = ops;
}
}ProxyAppendの集約ロジック📎 src/proxy.cc:443-443はargs->opCount == op->opCountに依存してsubを追加するかどうかを判断する。もし同じopCountの複数のchannel opが2つのバッチに分割されて投入されると、最初のバッチがargsを作成し、2番目のバッチが到着したときargs->opCountはすでに新しいopのopCountと等しくない(argsがすでに進められている可能性があるため)、本来集約されるべきsubが独立したargsに分割される。これはパフォーマンスを低下させるだけでなく、ncclProxyOpToArgs内のnChannels/nPeersminを取るロジック📎 src/proxy.cc:399-400を壊し、誤ったチャネル数計算を引き起こす可能性がある。
Q3: recvProxyProgressのReady段階はrecvCommに従ってsubを再ソート・グループ化する。もしこのグループ化ロジックを削除し、各subが独立してirecvを呼び出すようにした場合、maxRecvs > 1のNICでどのような結果が生じるか?
参考解析:見る📎 src/transport/net.cc:1495-1538のグループ化ロジックと📎 src/transport/net.cc:1613-1614のmultirecv呼び出し:
NCCLCHECK(proxyState->ncclNet->irecv(resources->netRecvComm, subCount, ptrs, sizes, tags, mhandles, phandles,
requestPtr));maxRecvsはNICプラグインが宣言する「1回のirecvで受信できる最大バッファ数」📎 src/transport/net.cc:1525-1525。maxRecvs > 1のとき、プラグイン(例:IB)は1つのWQEで複数のバッファを受信でき、doorbellオーバーヘッドとCQE処理コストを大幅に削減できる。もしグループ化を削除し、各subが個別にirecvすると、subCountは常に1となり、プラグインは単一バッファモードに退化し、スループットが低下する。さらに重要なのは、recvRequestsCacheとirecvConsumedメカニズム📎 src/transport/net.cc:1616-1617がmultirecv用に設計されていること——単一バッファモードではこれらのキャッシュロジックが無効になり、リクエストリークが発生する可能性がある。
ここまでで、proxyスレッドがネットワークI/Oとkernel実行をどのように分離し、GPU計算と通信を真に並行させるかを理解した。しかしproxyは単なる駆動者であり、基盤となるネットワーク転送の具体的な実装はまだ明らかにされていない。次の章ではnet_ibを深掘りし、NCCLがverbs APIをどのようにラップしてInfiniBand転送を実装しているか、そしてGPUDirect RDMAがどのようにNICにGPUメモリを直接読み書きさせるかを見ていく。
第13章:第13章:InfiniBandネットワーク転送:net_ibがverbsとGPUDirect RDMAをどのようにラップするか
第13章:InfiniBandネットワーク転送:net_ibがverbsとGPUDirect RDMAをどのようにラップするか
前の章では、proxyスレッドがネットワークI/OをGPU kernelからどのように切り離し、計算と通信を真に並行させるかを見た。しかしproxyは単なる「駆動者」であり、ncclNet->isend/irecvといった抽象インターフェースを呼び出すが、その下がTCPなのかInfiniBandなのか、それとも別のものなのかは知らない。本章ではこの抽象の層を開き、src/transport/net_ibとsrc/misc/ibvwrap.ccに入り、NCCLがlibibverbsというCライブラリをどのようにプラグイン可能なシンボルテーブルにラップするか、Queue Pair(QP)をどのように確立するか、そしてGPUDirect RDMAがどのようにNICにhostメモリをバイパスしてGPUメモリを直接読み書きさせるかを見ていく。
13.1 なぜNCCLはlibibverbsを直接呼び出さないのか
直感モデル:シンボルテーブルは「プラグイン可能な電源コンセント」
輸入電化製品を買ったが、プラグの形状が家のコンセントと合わないと想像してほしい。選択肢は2つ:電化製品を分解して配線を変える(直接#include <infiniband/verbs.h>して-libverbsをリンクする)か、万能変換プラグを買う(実行時に動的にシンボルをロードする)か。NCCLは後者を選んだ。
この選択の核心的な動機はデプロイの柔軟性:NCCLはPyTorch、TensorFlowなどの上位フレームワークにライブラリとしてロードされるため、実行環境に必ずlibibverbs.soがインストールされていると仮定できない。もしコンパイル時にハードリンクすると、InfiniBandドライバがないマシンでは、NCCLライブラリ全体がロードできなくなる——たとえNVLinkで単機通信をしたいだけでも。実行時のdlopen+ シンボル解決により、NCCLはIBのないマシンで優雅にデグレードできる。
もしこのラッピング層が欠けていたら、システムが直面する災難は:純粋なNVLinkの単機トレーニングタスクが、マシンにIBドライバがインストールされていないために直接クラッシュする。これはクラウド環境や開発機で極めて一般的である。
データ構造とメモリレイアウト:シンボルテーブルコンテナ
核心的なデータ構造はncclIbvSymbolsであり、ibvsymbols.hで定義されている(本章の資料にはこのファイルは含まれていないが、使用法からその構造を推論できる)。これは純粋な関数ポインタコンテナであり、各フィールドが1つの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);
// ... 数十个函数指针
};グローバルに1つのインスタンスのみで、std::once_flagと組み合わせてスレッドセーフな初期化を保証する:
📎 src/misc/ibvwrap.cc:26-29
static std::once_flag initOnceFlag;
static ncclResult_t initResult;
struct ncclIbvSymbols ibvSymbols;ここでの設計は非常に抑制が効いている:initOnceFlagはstd::once_flag,initResultキャッシュ初期化結果、ibvSymbolsはグローバルシンボルテーブルです。三者はすべて静的記憶域期間を持ち、ライフサイクルはプロセス全体に及びます。
なぜstd::once_flagではなくpthread_onceを使うのか?NCCL の C++ コードはすでに<mutex>と<thread>に依存しているため、標準ライブラリを使う方が一貫性があるからです。call_onceのセマンティクスは:何個のスレッドが同時にwrap_ibv_symbols()を呼び出しても、lambda は一度だけ実行され、残りのスレッドはブロックして待機し、その後すべてが同じinitResultを取得します。これは手書きのダブルチェックロッキング(DCLP)よりもはるかに安全です——DCLP には C++ メモリモデルにおける有名なリオーダーの落とし穴があります。
Step-by-Step:シンボル解決の完全なフロー
NCCL が初めて IB トランスポートを必要とするとき、wrap_ibv_symbols():
📎 src/misc/ibvwrap.cc:26-29
ncclResult_t wrap_ibv_symbols(void) {
std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
return initResult;
}buildIbvSymbolsはibvsymbols.cc(本章には含まれていません)で定義されており、その役割はdlopen("libibverbs.so")でライブラリを開き、各関数名に対してdlsymを呼び出してポインタを埋めることです。シンボルが見つからない場合、対応するフィールドは NULL のままです。
この「NULL を許容する」設計はラッパー層全体に貫かれています。CHECK_NOT_NULLマクロを見てください:
📎 src/misc/ibvwrap.cc:26-29
#define CHECK_NOT_NULL(container, internal_name) \
if (container.internal_name == NULL) { \
WARN("lib wrapper not initialized."); \
return ncclInternalError; \
}各ラッパー関数は呼び出し前に対応するシンボルが非 NULL かどうかをチェックします。これはつまり:古いバージョンの libibverbs に新しい関数が欠けている場合、NCCL はロード時にクラッシュせず、その関数が実際に使われるときに初めてエラーを報告します。これが段階的デグラデーションの鍵です。
設計思考:マクロラッパーの三重の責務
ibvwrap.ccには 7 つのマクロが定義されており、それらは単なる構文糖ではなく、三重の責務を担っています:
1. ヌルポインタ保護:CHECK_NOT_NULLは未初期化を傍受します
2. エラーコードの正規化:libibverbs の多様なエラー規約(-1 を返す、errno を返す、NULL ポインタを返す)を統一的にncclResult_t
3. に翻訳しますログ埋め込みWARN:失敗時に
が関数名と errno を出力しますIBV_PTR_CHECK_ERRNO最も複雑なマクロである
📎 src/misc/ibvwrap.cc:38-45
#define IBV_PTR_CHECK_ERRNO(container, internal_name, call, retval, error_retval, name) \
CHECK_NOT_NULL(container, internal_name); \
retval = container.call; \
if (retval == error_retval) { \
WARN("Call to " name " failed with error %s", strerror(errno)); \
return ncclSystemError; \
} \
return ncclSuccess;コピーretval展開後は 4 つのことを行います:シンボルが非 NULL かチェック、呼び出しを実行、戻り値をibv_pd*に書き込む(通常はポインタ引数を通じて返されるstrerror(errno)など)、エラー値と等しいか判定。注意すべきはibv_alloc_pd——libibverbs のポインタ返却型関数(errnoなど)は失敗時に NULL を返しerrnoを設定するので、ここで
を読むのが正しいのです。IBV_INT_CHECK一方、
📎 src/misc/ibvwrap.cc:84-91
#define IBV_INT_CHECK(container, internal_name, call, error_retval, name) \
CHECK_NOT_NULL(container, internal_name); \
int ret = container.call; \
if (ret == error_retval) { \
WARN("Call to " name " failed"); \
return ncclSystemError; \
} \
return ncclSuccess;コピーerrnoここではibv_fork_initを読みません。なぜならこの種の関数(
〔設計推論とアーキテクチャトレードオフ〕net_ib.ccこの「関数ごとに異なるマクロを使う」アプローチは煩雑に見えますが、必要不可欠です:libibverbs の API エラー規約は極めて不統一で、0/-1 を返すもの、errno 値を返すもの、ポインタを返すものがあります。無理に統一すると、かえってエラー情報が失われます。NCCL は「忠実に翻訳する」ことを選び、複雑さをラッパー層に留め、上位のncclSuccess。
は
を判定するだけで済みます
ibvcore.h13.2 ibvcore.h:ヘッダファイルに依存しない ABI 契約直感モデル:自前の辞書を持つ翻訳者は特異なファイルです——libibverbs の核心的な構造体、列挙型、定数を#include <infiniband/verbs.h>再定義しています
なしでこれらの型を使用する必要があるからです。infiniband/verbs.h〔設計推論とアーキテクチャトレードオフ〕dlopenこれは実際のエンジニアリング問題を解決します:
はディストリビューションやドライバのバージョンによって内容が異なります。NCCL がそれを直接インクルードすると、コンパイル時に特定のバージョンに束縛されます。しかし「最小必要サブセット」を自前で定義することで、NCCL はコンパイル時に IB ヘッダファイルを必要とせず、実行時にを通じて任意のバージョンのライブラリをロードできます。libibverbs-devこの層が欠けていると、災難です:がインストールされていないマシンでは NCCL をコンパイルできませんrdma-core。実際には実行時に
を通じてライブラリファイルが提供される可能性があるのにです。
主要構造体のメモリレイアウト
ibv_gidRDMA の理解に最も重要な構造体をいくつか選んで剖析します。
📎 src/include/ibvcore.h:58-64
union ibv_gid {
uint8_t raw[16];
struct {
uint64_t subnet_prefix;
uint64_t interface_id;
} global;
};コピーibvGetGidStrGID は InfiniBand の「IP アドレス」で、16 バイトです。16 バイト配列としても、2 つの 64 ビット整数としてもアクセスできます。RoCE(RDMA over Converged Ethernet)のシナリオでは、GID は実際には IPv6 アドレスです——これがinet_ntop(AF_INET6, ...)が
📎 src/include/ibvwrap.h:102-108
static inline const char* ibvGetGidStr(union ibv_gid* gid, char* gidStr, size_t strLen) {
static_assert(sizeof(union ibv_gid) == sizeof(struct in6_addr),
"the sizeof struct ibv_gid must be the size of struct in6_addr");
return inet_ntop(AF_INET6, gid->raw, gidStr, strLen);
}static_assertコピーibv_gidはコンパイル時にin6_addrとinet_ntopのサイズが一致することを保証し、これにより
ibv_mrがこの 16 バイトを正しく解釈できます。
📎 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;
};コピーaddrこれは GPUDirect RDMA の核心です。lengthは登録されたメモリの開始アドレス(ホストメモリ、または GPU メモリがホストにマップされたアドレス)で、lkeyは長さです。rkey(local key)とlkey(remote key)は、ネットワークカードがアクセス権限を検証するための「鍵」です——送信側は WQE にrkeyを含め、受信側は
〔設計推論とアーキテクチャトレードオフ〕addrなぜ登録が必要か?ネットワークカードが DMA を行うときは物理アドレスを使用しますが、lkey/rkeyは仮想アドレスです。登録プロセスにより、ドライバはこの仮想アドレスのページテーブルを「ピン留め」し、IOMMU マッピングを確立し、以降の参照用ハンドルとして
ibv_send_wrを返します。登録は高コスト(ページテーブル走査と IOMMU プログラミングを伴う)なので、NCCL は MR をキャッシュし、転送ごとの登録を避けます。
📎 src/include/ibvcore.h:704-738
struct ibv_send_wr {
uint64_t wr_id;
struct ibv_send_wr *next;
struct ibv_sge *sg_list;
int num_sge;
enum ibv_wr_opcode opcode;
int send_flags;
uint32_t imm_data;
union {
struct {
uint64_t remote_addr;
uint32_t rkey;
} rdma;
// ...
} wr;
};コピーwr_idこれは「ネットワークカードに何をしてほしいか」の記述です。sg_listはユーザー定義のタグ(完了時にそのまま返される)、opcodeは散列表(scatter-gather list)、wr.rdma.remote_addrとwr.rdma.rkey対向先のターゲットアドレスとアクセスキーを指定します。
ibv_sgeローカルメモリの一部を記述します:
📎 src/include/ibvcore.h:698-702
struct ibv_sge {
uint64_t addr;
uint32_t length;
uint32_t lkey;
};注意addrはuint64_tであり、ポインタではありません——WQE は NIC ハードウェアに読み取られるため、固定の 64 ビット形式でなければなりません。
インライン関数:シンボルテーブルをバイパスする高速パス
NCCL がインライン実装を選択する関数もあります。シンボルテーブルを経由しません。例えばibv_post_send:
📎 src/include/ibvcore.h:1099-1101
static inline int ibv_post_send(struct ibv_qp *qp, struct ibv_send_wr *wr, struct ibv_send_wr **bad_wr) {
return qp->context->ops.post_send(qp, wr, bad_wr);
}これは直接qp->context->ops.post_send関数ポインタを通じて呼び出されます。これは libibverbs の古典的な設計です:ibv_contextの中にops構造体があり、すべての操作関数ポインタを含み、具体的なドライバによって埋められます。
なぜpost_sendはopsを通り、シンボルテーブルを通らないのか?なぜならpost_sendはデータパス上のホット関数であり、送信のたびに呼び出されるからです。もしdlsymで解決されるグローバルシンボルテーブルを通ると、間接参照が一回増えます。一方qp->context->opsを通じれば、コンパイラはより良い最適化ができ、このポインタは QP 作成時に固定されます。対照的に、ibv_modify_qpは制御パス関数であり、呼び出し頻度が低いため、シンボルテーブルを通っても問題ありません。
NCCL のラッパーwrap_ibv_post_sendもインラインです:
📎 src/include/ibvwrap.h:77-85
static inline ncclResult_t wrap_ibv_post_send(struct ibv_qp* qp, struct ibv_send_wr* wr, struct ibv_send_wr** bad_wr) {
int ret = qp->context->ops.post_send(
qp, wr, bad_wr);
if (ret != IBV_SUCCESS) {
WARN("ibv_post_send() failed with error %s, Bad WR %p, First WR %p", strerror(ret), wr, *bad_wr);
return ncclSystemError;
}
return ncclSuccess;
}注意IBV_SUCCESSは 0 と定義されています:
📎 src/include/ibvwrap.h:23-25
typedef enum ibv_return_enum {
IBV_SUCCESS = 0,
} ibv_return_t;設計思考:ABI 互換性の「バージョン検出」
ibvcore.hの中に巧妙な ABI バージョン検出コードがあります:
📎 src/include/ibvcore.h:81
static void *__VERBS_ABI_IS_EXTENDED = ((uint8_t *)NULL) - 1;これは「マジックポインタ」です——値は(uint8_t*)0 - 1、つまり0xFFFFFFFFFFFFFFFFです。これはibv_context.abi_compatフィールドのマーカー値として使用されます:
📎 src/include/ibvcore.h:1072-1081
static inline struct verbs_context *verbs_get_ctx(struct ibv_context *ctx)
{
if (ctx->abi_compat != __VERBS_ABI_IS_EXTENDED)
return NULL;
return (struct verbs_context *)(((uintptr_t)ctx) -
offsetof(struct verbs_context,
context));
}もしabi_compatがこのマジック値と等しければ、基盤ライブラリが拡張 ABI をサポートしていることを示し、このときcontainer_ofテクニックを通じてibv_contextから逆算して外側のverbs_context。verbs_contextの最後のフィールドがibv_context:
📎 src/include/ibvcore.h:1068-1069
size_t sz; /* Must be immediately before struct ibv_context */
struct ibv_context context; /* Must be last field in the struct */これは C 言語で「継承」を実装する古典的な手法です:verbs_contextはibv_contextを「継承」し、基底クラスを末尾に置くことで、container_ofを使って基底クラスポインタから派生クラスポインタを逆算できます。szフィールドは構造体サイズを記録し、バージョン互換性に使用されます——新しいバージョンのライブラリは構造体を拡張でき、古いバージョンのコードはszをチェックしてあるフィールドが存在するか判断します。
verbs_get_ctx_opマクロはこのチェックをさらにカプセル化します:
📎 src/include/ibvcore.h:1083-1086
#define verbs_get_ctx_op(ctx, op) ({ \
struct verbs_context *__vctx = verbs_get_ctx(ctx); \
(!__vctx || (__vctx->sz < sizeof(*__vctx) - offsetof(struct verbs_context, op)) || \
!__vctx->op) ? NULL : __vctx; })これは三つのことをチェックします:拡張 ABI かどうか、構造体がそのフィールドを含むのに十分な大きさか、そのフィールドが非 NULL か。すべて満たされた場合のみ有効なポインタを返します。これがibv_query_port_exが安全に呼び出せる基礎です:
📎 src/include/ibvcore.h:1121-1132
static inline int ibv_query_port_ex(struct ibv_context *context,
uint8_t port_num,
struct ibv_port_attr *port_attr)
{
struct verbs_context *vctx = verbs_get_ctx_op(context, query_port);
if (vctx) {
return vctx->query_port(context, port_num, port_attr, sizeof(*port_attr));
}
return -1;
}もし基盤ライブラリが拡張query_portをサポートしていなければ、-1 を返し、呼び出し側のwrap_ibv_query_portは古い 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;
}注意memset(port_attr, 0, sizeof(*port_attr))——フォールバック前にゼロクリアします。古い API はactive_speed_exなどの新しいフィールドを埋めないため、ゼロクリアしないとスタック上のゴミ値を読んでしまいます。
13.3 QP ステートマシンと modify_qp のリトライ芸術
直感モデル:QP は「電話をかける」完全なプロセス
Queue Pair(QP)は RDMA 通信の基本単位であり、送信キュー(SQ)と受信キュー(RQ)を含みます。QP を確立することは電話をかけるようなものです:まずダイヤルし(RESET→INIT)、相手が応答するのを待ち(INIT→RTR)、双方が聞こえることを確認し(RTR→RTS)、それから通話できます。
もし QP ステートマシンがエラーになると、災難は:NIC が接続を確立できず、すべてのクロスマシン通信が失敗し、トレーニングタスクがスタックまたはクラッシュします。そして QP 状態遷移はまさに最も問題が起きやすい場所です——ネットワークジッタ、GID 変化、クロスレール接続エラーがすべてibv_modify_qpの失敗を引き起こします。
状態列挙と遷移
📎 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
};これは標準的な RDMA QP ステートマシンです。NCCL のibvQpStateNameは列挙を可読文字列に翻訳してログに使用します:
📎 src/misc/ibvwrap.cc:263-293
static void ibvQpStateName(enum ibv_qp_state state, char* msg, const size_t len) {
switch (state) {
case (IBV_QPS_RESET):
snprintf(msg, len, "RESET");
break;
case (IBV_QPS_INIT):
snprintf(msg, len, "INIT");
break;
// ...
}
}以下の状態図はソースコードの列挙と遷移セマンティクスに正確に対応しています:
stateDiagram-v2
[*] --> RESET : ibv_create_qp()
RESET --> INIT : modify_qp(IBV_QPS_INIT) [设置 pkey_index, port]
INIT --> RTR : modify_qp(IBV_QPS_RTR) [设置 ah_attr, dest_qp_num, rq_psn]
RTR --> RTS : modify_qp(IBV_QPS_RTS) [设置 sq_psn, timeout, retry_cnt]
RTS --> SQD : modify_qp(IBV_QPS_SQD) [SQ Drain]
SQD --> RTS : modify_qp(IBV_QPS_RTS)
RTS --> ERR : 硬件错误 / WC 错误
RTR --> ERR : 硬件错误
ERR --> RESET : modify_qp(IBV_QPS_RESET) [错误恢复]注意IBV_QPS_SQD(SQ Drained)とIBV_QPS_SQE(SQ Error)の二つの状態。SQD は優雅なシャットダウンに使用されます——送信キューを排出してから遷移します。SQE は送信キューにエラーがあることを示します。NCCL は正常パスではこれらの状態に自発的に入りませんが、エラー処理時にそれらを識別する必要があります。
Step-by-Step:modify_qp のリトライロジック
wrap_ibv_modify_qpは本章で最も複雑な関数であり、完全なリトライメカニズムを実装しています:
📎 src/misc/ibvwrap.cc:360-385
ncclResult_t wrap_ibv_modify_qp(struct ibv_qp* qp, struct ibv_qp_attr* attr, int attr_mask) {
char qpMsg[1024];
int ret = 0, attempts = 0;
int maxCnt = (int)ncclParamIbMQpRetryCnt() + 1; // number of attempts = number of retry + 1
int timeOut = (int)ncclParamIbMQpRetryTimeout();
CHECK_NOT_NULL(ibvSymbols, ibv_internal_modify_qp);
do {
if (attempts > 0) {
unsigned int sleepTime = timeOut * attempts;
ibvModifyQpLog(qp, attr->qp_state, attr, attr_mask, qpMsg, sizeof(qpMsg));
INFO(NCCL_NET, "Call to ibv_modify_qp failed with %d %s, %s, retrying %d/%d after %u msec of sleep", ret,
strerror(ret), qpMsg, attempts, maxCnt, sleepTime);
// sleep before retrying
std::this_thread::sleep_for(std::chrono::milliseconds(sleepTime));
}
ret = ibvSymbols.ibv_internal_modify_qp(qp, attr, attr_mask);
attempts++;
} while (IBV_MQP_RETRY_ERRNO_ALL(ret) && attempts < maxCnt);
if (ret != 0) {
ibvModifyQpLog(qp, attr->qp_state, attr, attr_mask, qpMsg, sizeof(qpMsg));
WARN("Call to ibv_modify_qp failed with %d %s, %s", ret, strerror(ret), qpMsg);
printIbModifyQpHint(ret);
return ncclSystemError;
}
return ncclSuccess;
}段階的に分解:
第一步:パラメータの読み取り。maxCnt = IbMQpRetryCnt() + 1、デフォルトで 34 回リトライするため、最大 35 回試行します。timeOutデフォルトは 100 ミリ秒。
第二步:リトライループに入る。最初のattempts == 0では sleep せず、直接呼び出します。その後失敗するたびに、sleepTime = timeOut * attempts——これは線形バックオフであり、1 回目のリトライは 100ms 待ち、2 回目は 200ms 待ち、34 回目は 3400ms 待ちます。
第三步:リトライするか判断。IBV_MQP_RETRY_ERRNO_ALL(ret)が続行するか決定します:
📎 src/misc/ibvwrap.cc:107-109
#define IBV_ERR_EQ(e, code) (e == code || e == (-code))
#define IBV_MQP_RETRY_ERRNO(e) (IBV_ERR_EQ(e, ETIMEDOUT))
#define IBV_MQP_RETRY_ERRNO_ALL(e) (ncclParamIbMQpRetryAll() ? (e != 0) : IBV_MQP_RETRY_ERRNO(e))デフォルトではETIMEDOUTのみリトライします。IBV_ERR_EQは正負の値を同時にマッチします。ドライバによってETIMEDOUTまたは-ETIMEDOUTを返す可能性があるためです。もしNCCL_IB_MQP_RETRY_ALL=1が設定されていれば、任意の非ゼロエラーに対してリトライします。
第四步:失敗時に診断情報を出力。ibvModifyQpLogはデバイス名、ポート番号、現在の状態、ターゲット状態、ローカル/リモート GID を収集します:
📎 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");
// ...
}注意QP_ATTRマクロの巧妙な設計:
📎 src/misc/ibvwrap.cc:295
#define QP_ATTR(attr, userAttr, userFlag, mask) ((userFlag & mask) ? (userAttr) : (attr))これは優先的にユーザーが渡した属性を使用し(もしattr_maskに対応するビットが設定されていれば)、そうでなければquery_qpで取得した現在の属性にフォールバックします。これによりquery_qpが失敗しても、ユーザーパラメータから部分的な情報を取得できます。
第五步:失敗時にヒントを提供。printIbModifyQpHintは一般的なエラーコードに対してトラブルシューティングの提案を提供します:
📎 src/misc/ibvwrap.cc:341-358
static void printIbModifyQpHint(int status) {
switch (status) {
case ETIMEDOUT:
INFO(NCCL_NET, "HINT: In many cases this error indicates that the NICs are not cross-rail connected.");
INFO(NCCL_NET, "HINT: To confirm, set NCCL_CROSS_NIC=0 to disable cross-rail communication ...");
return;
case EINVAL:
INFO(NCCL_NET, "HINT: In many cases this error indicates that an incorrect GID index is forced by "
"NCCL_IB_GID_INDEX, or that a NIC's GID changed mid-run.");
// ...
}
}このヒントは生産経験の結晶です。ETIMEDOUTの最も一般的な原因はクロスレール接続問題です——マルチレールネットワークで、rank A の NIC 0 が rank B の NIC 1 に接続しようとし、それらが同じレールにない場合、タイムアウトします。EINVALは通常 GID インデックスの設定ミス、または実行中に GID が変化した場合(例えば NIC のリセット)です。
並行制御とハードウェア相互作用
wrap_ibv_modify_qp自体にはロックがない——呼び出し側が同じ QP を複数スレッドで同時に変更しないことを保証する前提である。これは NCCL では成立する:QP の確立は初期化段階で単一スレッドによって行われる。
しかしリトライループ内のstd::this_thread::sleep_forは注目に値する。CPU を譲渡するが、ロックは一切解放しない(そもそもロックを保持していないため)。proxy スレッド内でこの関数を呼び出すと、sleep が proxy の進行をブロックする——QP 確立がスタックすると、通信全体が停滞する。これがデフォルトのリトライ回数が 34 回、合計約 60 秒である理由である——短いネットワークジッターをカバーするには十分だが、無限に待つことはない。
13.4 メモリ登録:GPUDirect RDMA の入口
直感的モデル:NIC に「入館カード」を発行する
NIC がメモリを直接読み書きするには、まずそのメモリを「認識」する必要がある。メモリ登録(ibv_reg_mr)は NIC に入館カードを発行するようなものである——そのメモリの物理アドレス範囲を伝え、lkey(ローカルキー)とrkey(リモートキー)を返す。その後 NIC が DMA を行う際、このキーでアクセスする。
メモリ登録が欠けている場合の災難は:NIC がいかなるメモリにもアクセスできず、RDMA が完全に機能しない。さらに隠れた問題は:host メモリを登録したのに GPU メモリにアクセスしようとすると、NIC が誤ったデータを読むか、保護エラーを引き起こす。
3 つの登録パス
NCCL は 3 つのメモリ登録関数をラップしており、それぞれ異なる使用シナリオに対応する:
パス 1:通常登録
📎 src/misc/ibvwrap.cc:198-201
ncclResult_t wrap_ibv_reg_mr(struct ibv_mr** ret, struct ibv_pd* pd, void* addr, size_t length, int access) {
IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_mr, ibv_internal_reg_mr(pd, addr, length, access), *ret, NULL,
"ibv_reg_mr");
}これは標準パスであり、addrは仮想アドレス、accessはアクセス権限フラグ(IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITEなど)。
パス 2:IOVA 指定登録
📎 src/misc/ibvwrap.cc:211-219
ncclResult_t wrap_ibv_reg_mr_iova2(struct ibv_mr** ret, struct ibv_pd* pd, void* addr, size_t length, uint64_t iova,
int access) {
if (ibvSymbols.ibv_internal_reg_mr_iova2 == NULL) {
return ncclInternalError;
}
if (ret == NULL) return ncclSuccess; // Assume dummy call
IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_mr_iova2, ibv_internal_reg_mr_iova2(pd, addr, length, iova, access),
*ret, NULL, "ibv_reg_mr_iova2");
}iova(I/O Virtual Address)は NIC が見るアドレスを指定できる。固定アドレスマッピングが必要なシナリオで有用である。注意:ret == NULLのときは直接成功を返す——これは「プローブ呼び出し」であり、関数の存在を確認するだけで、実際には登録しない。
パス 3:DMA-BUF 登録(GPUDirect RDMA の鍵)
📎 src/misc/ibvwrap.cc:222-227
ncclResult_t wrap_ibv_reg_dmabuf_mr(struct ibv_mr** ret, struct ibv_pd* pd, uint64_t offset, size_t length,
uint64_t iova, int fd, int access) {
IBV_PTR_CHECK_ERRNO(ibvSymbols, ibv_internal_reg_dmabuf_mr,
ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access), *ret, NULL,
"ibv_reg_dmabuf_mr");
}これが GPUDirect RDMA の核心である。fdは DMA-BUF ファイルディスクリプタ——GPU メモリの一部を表す。NCCL はcuMemGetHandleForAddressRangeのような CUDA API でこの fd を取得し、それをibv_reg_dmabuf_mrに渡す。NIC ドライバは DMA-BUF メカニズムを通じて GPU メモリを直接マッピングし、host メモリのコピーを経由しない。
DMA-BUF は Linux カーネルのバッファ共有フレームワークである。GPU ドライバ(NVIDIA の nvidia.ko など)がメモリを DMA-BUF としてエクスポートし、NIC ドライバ(mlx5 など)がそれをインポートして IOMMU マッピングを確立する。プロセス全体がカーネル内で完了し、ユーザー空間は fd を 1 つ渡すだけである。これが「NIC が GPU メモリを直接読み書きする」ための基盤メカニズムである。
直接登録 vs ラップ登録
2 つの「direct」バージョンがあることに注意:
📎 src/misc/ibvwrap.cc:203-209
struct ibv_mr* wrap_direct_ibv_reg_mr(struct ibv_pd* pd, void* addr, size_t length, int access) {
if (ibvSymbols.ibv_internal_reg_mr == NULL) {
WARN("lib wrapper not initialized.");
return NULL;
}
return ibvSymbols.ibv_internal_reg_mr(pd, addr, length, access);
}📎 src/misc/ibvwrap.cc:229-236
struct ibv_mr* wrap_direct_ibv_reg_dmabuf_mr(struct ibv_pd* pd, uint64_t offset, size_t length, uint64_t iova, int fd,
int access) {
if (ibvSymbols.ibv_internal_reg_dmabuf_mr == NULL) {
errno = EOPNOTSUPP; // ncclIbDmaBufSupport() requires this errno being set
return NULL;
}
return ibvSymbols.ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access);
}これらはibv_mr*ではなくncclResult_tを直接返し、WARN ログも出力しない。なぜか?
これら 2 つの関数は能力プローブ。ncclIbDmaBufSupport()がwrap_direct_ibv_reg_dmabuf_mrを呼び出して NIC が DMA-BUF をサポートするか探るために使われるためである。失敗した場合、「エラー」ではなく「非サポート」を判断するためにerrno == EOPNOTSUPPを取得することを期待する。ここで WARN を出力すると、DMA-BUF 非サポートのマシンでログが溢れる。そのため direct バージョンはエラー処理の責任を呼び出し側に委ねる。
アクセス権限フラグ
📎 src/include/ibvcore.h:365-372
enum ibv_access_flags {
IBV_ACCESS_LOCAL_WRITE = 1,
IBV_ACCESS_REMOTE_WRITE = (1<<1),
IBV_ACCESS_REMOTE_READ = (1<<2),
IBV_ACCESS_REMOTE_ATOMIC = (1<<3),
IBV_ACCESS_MW_BIND = (1<<4),
IBV_ACCESS_RELAXED_ORDERING = (1<<20),
};これらのフラグはビットマスクであり、組み合わせ可能である。LOCAL_WRITEはローカル書き込みを許可し(データ受信時に必要)、REMOTE_WRITEはリモート書き込みを許可し(RDMA WRITE のターゲットに必要)、REMOTE_READはリモート読み取りを許可する(RDMA READ のターゲットに必要)。
IBV_ACCESS_RELAXED_ORDERINGはパフォーマンス最適化フラグである——NIC がより緩いメモリ順序でアクセスすることを許可し、スループットを向上させる可能性があるが、アプリケーション層が正確性を保証する必要がある。
データフロー:GPU メモリから NIC までの完全なパス
以下の図はクロスホスト RDMA 書き込みのデータフローを示し、本章で扱う構造体をアンカーしている:
flowchart LR
subgraph GPU["GPU 显存"]
buf["ncclSendBuff<br/>(device ptr)"]
end
subgraph Host["Host 进程"]
dmabuf["DMA-BUF fd<br/>(cuMemGetHandleForAddressRange)"]
mr["ibv_mr<br/>{addr, lkey, rkey}"]
wr["ibv_send_wr<br/>{opcode=RDMA_WRITE,<br/>sg_list, wr.rdma.remote_addr, rkey}"]
end
subgraph NIC["网卡 mlx5"]
qp["ibv_qp<br/>(SQ + RQ)"]
wqe["WQE<br/>(硬件工作队列元素)"]
end
buf -->|导出| dmabuf
dmabuf -->|ibv_reg_dmabuf_mr| mr
mr -->|填充 sge.lkey| wr
wr -->|ibv_post_send| qp
qp -->|DMA 读取| wqe
wqe -->|PCIe P2P| buf
wqe -->|网络| remote["对端 GPU 显存<br/>(remote_addr + rkey)"]図中の各ノードはソースコード内の実際の型に対応する:ibv_mrは📎 src/include/ibvcore.h:402-410,ibv_send_wrから📎 src/include/ibvcore.h:704-738,ibv_qpは📎 src/include/ibvcore.h:787-802。
から
13.5 作業完了とエラー診断
直感的モデル:宅配の受領書post_sendRDMA は非同期である——poll_cqした後、結果はすぐにはわからない。NIC が操作を完了すると、Completion Queue(CQ)に Work Completion(WC)を置く。ちょうど宅配業者が受領書をあなたのポストに入れるように。あなたは能動的に
して取りに行く必要がある。WC 診断が欠けている場合の災難は:通信失敗時に「失敗した」ということだけわかり、「なぜ失敗したか」がわからない
。RDMA のエラーコードは 20 種類以上あり、それぞれ異なる根本原因に対応する。
📎 src/include/ibvcore.h:349-363
struct ibv_wc {
uint64_t wr_id;
enum ibv_wc_status status;
enum ibv_wc_opcode opcode;
uint32_t vendor_err;
uint32_t byte_len;
uint32_t imm_data; /* in network byte order */
uint32_t qp_num;
uint32_t src_qp;
int wc_flags;
uint16_t pkey_index;
uint16_t slid;
uint8_t sl;
uint8_t dlid_path_bits;
};wr_idコピーstatusは post 時に記入したタグ、opcodeは完了ステータス、byte_lenは操作タイプ、qp_numは実際の転送バイト数。src_qpと
はマルチ QP シナリオでどの QP が完了したかを識別するために使われる。
ibvWcStatusStrステータスコードの翻訳
📎 src/misc/ibvwrap.cc:415-464
const char* ibvWcStatusStr(enum ibv_wc_status status) {
switch (status) {
case IBV_WC_SUCCESS:
return "IBV_WC_SUCCESS";
case IBV_WC_LOC_LEN_ERR:
return "IBV_WC_LOC_LEN_ERR";
// ... 20 多个 case
default:
return "UNKNOWN_STATUS";
}
}コピー
| これらのステータスコードの意味: | ステータスコード | 意味 |
|---|---|---|
IBV_WC_SUCCESS | 一般的な根本原因 | — |
IBV_WC_LOC_LEN_ERR | 成功 | ローカル長エラー |
IBV_WC_LOC_ACCESS_ERR | SGE 長が MR 範囲を超過 | ローカルアクセスエラー |
IBV_WC_REM_ACCESS_ERR | lkey が無効または権限不足 | リモートアクセスエラー |
IBV_WC_RETRY_EXC_ERR | rkey が無効または対端の MR が登録解除済み | リトライ枯渇 |
IBV_WC_RNR_RETRY_EXC_ERR | ネットワーク不通または対端 QP が未準備 | 対向側が recv を post していない |
IBV_WC_RESP_TIMEOUT_ERR | 応答タイムアウト | 対向側が無応答 |
IBV_WC_RNR_RETRY_EXC_ERR(Receiver Not Ready)は本番環境で最もよくある問題の一つです。これは送信側がデータを送信したが、受信側が事前に十分な recv buffer を post していないことを意味します。NCCL では、これは通常コネクション確立段階で発生します——両者の QP 状態が同期しておらず、一方が既に送信を開始しているのに、もう一方がまだ受信準備できていないのです。
opcode の翻訳
ibvWcOpcodeStrとibvWrOpcodeStrはそれぞれ完了 opcode とリクエスト opcode を翻訳します:
📎 src/misc/ibvwrap.cc:467-488
const char* ibvWcOpcodeStr(enum ibv_wc_opcode opcode) {
switch (opcode) {
case IBV_WC_SEND:
return "IBV_WC_SEND";
case IBV_WC_RDMA_WRITE:
return "IBV_WC_RDMA_WRITE";
case IBV_WC_RDMA_READ:
return "IBV_WC_RDMA_READ";
// ...
}
}注意IBV_WC_RECVの値は1 << 7:
📎 src/include/ibvcore.h:329-342
enum ibv_wc_opcode {
IBV_WC_SEND,
IBV_WC_RDMA_WRITE,
IBV_WC_RDMA_READ,
IBV_WC_COMP_SWAP,
IBV_WC_FETCH_ADD,
IBV_WC_BIND_MW,
IBV_WC_RECV = 1 << 7,
IBV_WC_RECV_RDMA_WITH_IMM
};なぜIBV_WC_RECVは1 << 7であって、順序値ではないのか?受信完了と送信完了は二つの異なる種類の操作であり、上位ビットで区別することでコードがopcode & IBV_WC_RECVで「これが受信完了かどうか」を素早く判断できるからです。これは libibverbs の API 設計上の約束事です。
CQ のポーリング
wrap_ibv_poll_cqはインラインです:
📎 src/include/ibvwrap.h:60-69
static inline ncclResult_t wrap_ibv_poll_cq(struct ibv_cq* cq, int num_entries, struct ibv_wc* wc, int* num_done) {
int done = cq->context->ops.poll_cq(cq, num_entries,
wc);
if (done < 0) {
WARN("Call to ibv_poll_cq() returned %d", done);
return ncclSystemError;
}
*num_done = done;
return ncclSuccess;
}それはcq->context->ops.poll_cq経由で呼び出され、post_sendと同様にopsの高速パスを通ります。戻り値doneは今回ポーリングした WC の数で、0 は新しい完了がないこと、負の値はエラーを意味します。
poll_cqはビジーポーリング——ブロックせず、即座に戻ります。NCCL の proxy スレッドはループ内でこれを繰り返し呼び出し、完了イベントを取得するまで続けます。これが低遅延の鍵です:割り込み駆動と比べて、ビジーポーリングは割り込みコンテキストスイッチのオーバーヘッドを回避します。代償は CPU 使用率の高さですが、高性能計算のシナリオではこれは許容できます。
13.6 本番環境の落とし穴ガイド
落とし穴一:クロス rail 接続タイムアウト
現象:ibv_modify_qpがETIMEDOUTを返し、34 回リトライ後に失敗。
根本原因:マルチ rail ネットワークでは、各 GPU は通常特定の NIC にバインドされます。rank A の GPU 0 が NIC 0 にバインドされ、rank B の GPU 0 が NIC 1 にバインドされ、NIC 0 と NIC 1 が同じ rail にない場合(つまり異なるスイッチに接続されている場合)、QP 確立がタイムアウトします。
調査:ソースコードが既にヒントを与えています:
📎 src/misc/ibvwrap.cc:343-347
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;を設定すると同 rail 通信を強制できます。これで解決するなら、確かにクロス rail 問題です。NCCL_CROSS_NIC=0復旧チェーン
:NCCL のリトライ機構(34 回、線形バックオフ)はネットワークに十分な復旧時間を与えます。しかし根本原因がトポロジ設定ミスなら、リトライは無意味で、またはNCCL_IB_HCA設定を修正する必要があります。NCCL_CROSS_NIC落とし穴二:GID インデックスエラー
現象
が:ibv_modify_qpを返すEINVAL。
根本原因:NCCL_IB_GID_INDEXが存在しない GID インデックスを強制指定したか、実行中に NIC の GID が変化した(例えば RoCE 網卡が IP を再取得した)ためです。
調査:
📎 src/misc/ibvwrap.cc:341-358
case EINVAL:
INFO(NCCL_NET, "HINT: In many cases this error indicates an incorrect GID index is forced by "
"NCCL_IB_GID_INDEX, or that a NIC's GID changed mid-run.");
INFO(NCCL_NET, "HINT: To confirm, set NCCL_IB_GID_INDEX=-1 to enable automatic detection and check "
"'dmesg | grep -i gid' for GID changes ...");
return;を設定してNCCL_IB_GID_INDEX=-1自動検出を有効にします。同時にdmesgに GID 変化イベントがないか確認します。
落とし穴三:DMA-BUF 非対応による host コピーへのフォールバック
現象:GPUDirect RDMA が有効にならず、性能が期待を下回る。
根本原因:NIC ドライバまたはカーネルが DMA-BUF をサポートしておらず、wrap_direct_ibv_reg_dmabuf_mrが NULL を返しerrno = EOPNOTSUPP:
📎 src/misc/ibvwrap.cc:229-236
struct ibv_mr* wrap_direct_ibv_reg_dmabuf_mr(struct ibv_pd* pd, uint64_t offset, size_t length, uint64_t iova, int fd,
int access) {
if (ibvSymbols.ibv_internal_reg_dmabuf_mr == NULL) {
errno = EOPNOTSUPP; // ncclIbDmaBufSupport() requires this errno being set
return NULL;
}
return ibvSymbols.ibv_internal_reg_dmabuf_mr(pd, offset, length, iova, fd, access);
}コメントに注意:ncclIbDmaBufSupport()はこのerrnoに依存してサポートの有無を判断します。ここでEOPNOTSUPPを設定しないと、上位層は「エラー」と誤判定し「非対応」とは判断しません。
調査:カーネルバージョン(5.12+ が必要)、NIC ドライババージョン、およびnvidia-peermemモジュールがロードされているか確認します。本当に非対応の場合、NCCL は host メモリ経由にフォールバックし、性能は低下しますが機能は正常です。
落とし穴四:MR キャッシュとメモリリーク
メモリ登録は高コストな操作(IOMMU プログラミングを伴う)であり、NCCL はibv_mrをキャッシュします。しかしキャッシュ戦略が不適切だと二つの問題が生じます:一つはメモリリーク(MR がずっと解除されない)、もう一つはキャッシュ無効化(メモリが解放されたのに MR が古いアドレスを指したまま)です。
wrap_ibv_dereg_mrは解除のエントリポイントです:
📎 src/misc/ibvwrap.cc:238-241
ncclResult_t wrap_ibv_dereg_mr(
struct ibv_mr* mr) {
IBV_INT_CHECK_RET_ERRNO(ibvSymbols, ibv_internal_dereg_mr, ibv_internal_dereg_mr(mr), 0, "ibv_dereg_mr");
}本番環境で、学習タスクが通信ドメインを頻繁に作成/破棄し、MR が正しく解除されないと、IOMMU マッピングテーブルが膨張し、最終的にibv_reg_mr失敗(ENOMEMを返す)を引き起こします。調査方法は/sys/kernel/debug/iommu下のマッピング数を監視することです。
設計上の考察:なぜラッパー層はこれほど「厚い」のか
本章を振り返ると、ibvwrap.ccは 509 行、ibvcore.hは 1134 行あります。「単に libibverbs を呼ぶだけ」のラッパー層としては、この規模はかなり大きいです。なぜでしょうか?
三つの理由:
第一に、エラー処理の複雑さ。libibverbs の API エラー規約は極めて不統一で、NCCL は各規約ごとにマクロを書き、各関数で正しく使う必要があります。これは過剰設計ではなく、「忠実な翻訳」に必要なコストです。
第二に、ABI 互換性の負担。ibvcore.hはすべての構造体を再定義し、verbs_contextのバージョン検出も処理します。これはコンパイル時に IB ヘッダファイルに依存せず、実行時に任意のバージョンと互換にするためです。
第三に、診断情報の価値。ibvModifyQpLog、printIbModifyQpHint、ibvWcStatusStrこれらの関数は正常パスでは呼ばれませんが、障害調査時には計り知れない価値があります。NCCL は診断情報をラッパー層に「予め埋め込む」ことを選び、エラー発生時にその場で収集するのを避けています。
この「厚いラッパー」の代償はコード量の多さと保守コストの高さです。しかし利点は:上位のnet_ib.ccが統一されたncclResult_tインターフェースで書け、libibverbs の様々な癖を気にしなくてよいことです。これは典型的な「複雑性の隔離」設計です。
本章のまとめ
本章では、NCCL の InfiniBand トランスポートカプセル化層を深く掘り下げました。核心的なポイントは以下の通りです:
1. シンボルテーブルカプセル化:ncclIbvSymbolsを通じてdlopen + dlsym実行時に libibverbs をロードし、std::once_flagと組み合わせてスレッドセーフな初期化を保証します。これにより、NCCL は IB ドライバのないマシンでもロードできます。
2. ABI 契約:ibvcore.hlibibverbs の核心的な型を再定義し、__VERBS_ABI_IS_EXTENDEDマジックポインタとverbs_contextのcontainer_ofテクニックによってバージョン検出を実現します。
3. QP ステートマシン:wrap_ibv_modify_qp34 回の線形バックオフリトライを実装し、ETIMEDOUTとEINVALに対して診断ヒントを提供します。
4. GPUDirect RDMA:wrap_ibv_reg_dmabuf_mrDMA-BUF メカニズムを通じて、ネットワークカードが GPU メモリを直接マッピングできるようにし、wrap_direct_ibv_reg_dmabuf_mrは能力検出に使用されます。
5. エラー診断:ibvWcStatusStr、ibvWcOpcodeStr、ibvWrOpcodeStrハードウェアエラーコードを可読な文字列に変換することは、本番環境でのトラブルシューティングにおける重要なツールです。
本章の考察とセルフチェック
Q1: もしwrap_ibv_symbols内のstd::call_onceを通常のif (initResult == ncclSuccess) return initResult;ダブルチェックロックに置き換えた場合、どのような並行シナリオで問題が発生するでしょうか?
参考解析:📎 src/misc/ibvwrap.cc:26-29:
ncclResult_t wrap_ibv_symbols(void) {
std::call_once(initOnceFlag, []() { initResult = buildIbvSymbols(&ibvSymbols); });
return initResult;
}もし素朴なダブルチェックロックに置き換えた場合、問題はメモリリオーダリング。buildIbvSymbolsがibvSymbolsの各フィールドを埋め、その後initResultに書き込みます。メモリバリアがない場合、CPU またはコンパイラがinitResult = ncclSuccessを `
ここまでで、NCCL が net_ib を通じて libibverbs をプラグイン可能なトランスポート層としてカプセル化し、GPUDirect RDMA を利用してネットワークカードが GPU メモリに直接アクセスする仕組みを明らかにしました。このメカニズムは、マシン間通信のレイテンシと帯域幅のボトルネックを解決します。しかし、マシン内通信も同様に重要です——次章では対称メモリと NVLS に入り、NCCL が NVLink マルチキャストを利用してハードウェアアクセラレーションされた集合通信を実現する方法を見ていきます。その時、本章の RDMA メカニズムと NVLS が補完関係にあることがわかるでしょう:前者はマシン間を担当し、後者はマシン内を担当します。
第 14 章:第 14 章:対称メモリと NVLS:マルチキャスト加速と LSA デバイス側直接アドレッシング
第 14 章:対称メモリと NVLS:マルチキャスト加速と LSA デバイス側直接アドレッシング
前章では、マシン間の AllReduce を追い、データが GPU メモリからネットワークカードを経由して対向 GPU に到達する様子を見ました。そのパスが解決するのはマシン間の通信です。しかし、現代の AI クラスタでは、同一マシン内、さらには同一 NVLink ドメイン内の GPU 間通信量も同様に膨大です——データ並列トレーニングにおける勾配同期、テンソル並列における活性値交換のほとんどがマシン内で発生します。もしマシン内通信が依然として GPU→メモリ→ネットワークカード→対向ネットワークカード→メモリ→GPU というマシン間フローを通るなら、市内の宅配便をわざわざ航空便で送るようなもので、レイテンシが無駄に消費されます。本章で解き明かすのは、NCCL がマシン内通信のために用意した二つの強力なツール:対称メモリと NVLS です。前者は各 rank が同一の仮想アドレスで全 rank のバッファにアクセスできるようにし、後者は NVSwitch ハードウェアのマルチキャスト機能を利用してリダクションを行います。両者を組み合わせることで、小メッセージ集合通信のレイテンシをハードウェア限界に近づけることができます。
14.1 対称メモリ:「3 列目 5 番目の席」を誰の家でも同じ位置として指す
直感的モデル
クラスで宿題のノートを交換することを想像してください。従来の方法では、各自が自分のノートに番号を振り、「張三、私の 5 冊目を君に;李四、私の 8 冊目を君に」と叫びます——各自が「誰のノートがどこにあり、何冊目か」を覚えなければなりません。これが通常の通信です:アドレスは相対的で、私的なものであり、対向のデータにアクセスするには、まず対向のアドレスマッピングを知る必要があります。
対称メモリは別のアプローチを取ります:クラス全員で「3 列目 5 番目の席」という座標を約束し、それが誰の家でも同じ物理位置を指すようにします。すると張三が李四の 5 冊目を取るには、「李四の家の 3 列目 5 番目の席」と言うだけでよく、アドレス変換は一切不要です。これが対称メモリの核心です:各 rank のバッファが、全 rank のアドレス空間で同じ仮想アドレスにマッピングされる。
もし対称メモリがなければ、マシン内集合通信はどのような災難に直面するでしょうか? 各 rank が対向バッファにアクセスするたびに、「アドレス変換」——テーブル検索、オフセット計算、場合によってはマッピング関係を確認するためのプロセス間通信——を経なければなりません。小メッセージ(数 KB)では、この変換のオーバーヘッドがデータ自体の転送よりも大きくなる可能性があります。対称メモリはこのオーバーヘッドを完全に排除します。これこそが「小メッセージのレイテンシを大幅に削減する」根本的な理由です。
データ構造とメモリレイアウト
対称メモリの登録タイプはncclSymRegType_tによって記述され、ncclGetSymRegTypeは send/recv ウィンドウにNCCL_WIN_COLL_SYMMETRICフラグが付いているかどうかに基づいて、登録状態を四つに分類します。
📎 src/sym_kernels.cc:395-412
ncclResult_t ncclGetSymRegType(struct ncclDevrWindow* sendWin, struct ncclDevrWindow* recvWin,
ncclSymRegType_t* winRegType) {
bool isSendSymmReg = false;
bool isRecvSymmReg = false;
if (sendWin && (sendWin->winFlags & NCCL_WIN_COLL_SYMMETRIC)) isSendSymmReg = true;
if (recvWin && (recvWin->winFlags & NCCL_WIN_COLL_SYMMETRIC)) isRecvSymmReg = true;
// determine the registration type
if (!isSendSymmReg && !isRecvSymmReg) {
*winRegType = ncclSymSendNonregRecvNonreg;
} else if (isSendSymmReg && !isRecvSymmReg) {
*winRegType = ncclSymSendRegRecvNonreg;
} else if (!isSendSymmReg && isRecvSymmReg) {
*winRegType = ncclSymSendNonregRecvReg;
} else if (isSendSymmReg && is isRecvSymmReg) {
*winRegType = ncclSymSendRegRecvReg;
}
return ncclSuccess;
}これら四つの状態が、後続の kernel がどのパスを通るかを決定します:完全対称登録(SendRegRecvReg)は最速の LSA パスを通り、完全非登録(SendNonregRecvNonreg)は通常パスを通り、混合状態は特別な処理が必要です。winFlags内のNCCL_WIN_COLL_SYMMETRICビットが「このウィンドウが対称登録済みかどうか」のマークです。
対称メモリの初期化エントリポイントはncclSymkInitOnceであり、これが行う重要なことの一つは:現在の通信ドメインが LSA マルチキャストをサポートしているかどうかの判定です(hasLsaMultimem)。
📎 src/sym_kernels.cc:185-196
ncclResult_t ncclSymkInitOnce(struct ncclComm* comm) {
// ncclTeamLsa() below calls this internally but drops the error code so we do it here.
NCCLCHECK(ncclDevrInitOnce(comm));
struct ncclSymkState* symk = &comm->symkState;
if (!symk->initialized) {
symk->initialized = true;
struct ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
// Disable LSA multicast for cross-clique since NVLS isn't available across cliques
symk->hasLsaMultimem =
ncclNvlsSymmetricMultimemEnabled(comm) && ncclTeamLsa(comm).nRanks > 2 && !comm->p2pCrossClique;
reqs.lsaMultimem = symk->hasLsaMultimem;hasLsaMultimemの3つの条件はすべて揃わなければならない:NVLS対称マルチキャストが有効、LSAチームのrank数が2より大きい(2つのrankなら直接ポイントツーポイントの方が速く、マルチキャストは不要)、かつcliqueを跨がない(cliqueを跨ぐとNVSwitchマルチキャストが使用不可)。この判定が直接reqs.lsaMultimemをセットするかどうかを決め、ひいてはデバイス側コミュニケータのリソース割り当てに影響する。
シナリオ駆動のステップバイステップ・ウォークスルー
AllReduceを1回発起し、メッセージサイズ4KB、8つのrankが同一NVLinkドメイン内にあると仮定する。ncclSymkMaskがどのkernelが利用可能かを決定する。
📎 src/sym_kernels.cc:304-352
uint32_t ncclSymkMask(struct ncclComm* comm, ncclFunc_t coll, int /*ncclDevRedOp_t*/ red, ncclDataType_t ty,
size_t nElts, bool symAligned16B) {
uint32_t kmask = kernelMask_coll(coll);
bool hasSTMC = comm->symkState.hasLsaMultimem;
bool hasLDMC = false;
if (comm->symkState.hasLsaMultimem) {
switch (ty) {
case ncclInt32:
...
hasLDMC = red == ncclDevSum || red == ncclDevMinMax || red == ncclDevSumPostDiv;
break;
...
}
}
if (!hasSTMC) kmask &= ~kernelMask_STMC;
if (!hasLDMC) kmask &= ~kernelMask_LDMC;第一步:kernelMask_coll集合タイプ(AllReduce)に基づいて候補kernel集合kernelMask_ARを取り出す。第二步:hasLsaMultimemをチェックし、マルチキャストがサポートされていれば、さらにデータ型とリダクション操作がLDMC(Load-Multicast)をサポートするかを判定する。第三步:ビットマスクでサポートされない機能をクリア——kmask &= ~kernelMask_STMCSTMCをサポートしないkernelをすべて除外する。
次にサイズ制限:
📎 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;ここには2つのハード境界がある:LL系kernelは32ビット整数で要素数を追跡するため、バスバイト数が2GBを超えるとLL kernelが除外され、64GBを超えるとすべてのkernelが除外される(kmask = 0)。これは典型的な「ビット幅と引き換えに性能を得る」手法——32ビットインデックスは64ビットよりレジスタと命令を節約できるが、代償としてメッセージサイズの上限がある。
最後にTMAとGINの可用性チェック:
📎 src/sym_kernels.cc:344-350
if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
if (!symAligned16B) kmask &= ~kernelMask_Tma;
bool hasGin = ncclParamSymGinKernelsEnable() != 0;
if (!hasGin) kmask &= ~kernelMask_Gin;
bool needGin = ncclTeamLsa(comm).nRanks < comm->nRanks;
kmask &= needGin ? kernelMask_Gin : ~kernelMask_Gin;
return kmask;TMAはSMEM容量が基準を満たす(ncclSymkTmaAvailableがmaxSharedMemOptinをチェック)かつ16バイトアラインメントが必要。GINは「LSAチームのrank数が総rank数より小さい」場合にのみ必要——つまり、通信ドメインがLSA境界を越える(ネットワーク経由が必要)場合にのみGINが意味を持つ。通信ドメイン全体がLSA内にあれば、GIN kernelは除外される。
並行制御とハードウェア相互作用
対称メモリのアドレス解決は最終的にデバイス側に落ちる。ncclSymkMakeDevWorkhost側のタスク記述をデバイス側が読める作業項目に変換する。
📎 src/sym_kernels.cc:380-393
ncclResult_t ncclSymkMakeDevWork(struct ncclComm* comm, struct ncclTaskColl* task, struct ncclSymkDevWork* outDevWork) {
outDevWork->rootRank = task->root;
outDevWork->redOpArg = task->opDev.scalarArg;
outDevWork->nElts = task->count;
outDevWork->inputWin = task->sendWin ? task->sendWin->vidmem : nullptr;
outDevWork->inputOff =
task->sendWin ? (uint8_t*)task->sendbuff - (uint8_t*)task->sendWin->userPtr : (size_t)task->sendbuff;
outDevWork->outputWin = task->recvWin ? task->recvWin->vidmem : nullptr;
outDevWork->outputOff =
task->recvWin ? (uint8_t*)task->recvbuff - (uint8_t*)task->recvWin->userPtr : (size_t)task->recvbuff;
outDevWork->sChannelId = 0xffff;
outDevWork->nChannels = 0;
return ncclSuccess;
}注意すべきはinputOffの計算:sendWinが存在する場合(対称登録ウィンドウ)、オフセットはsendbuff - sendWin->userPtr——これはウィンドウ内オフセットであり、デバイス側はinputWin(ウィンドウベースアドレス)にinputOffを加えて実アドレスを算出できる。sendWinが存在しない場合、オフセットは直接sendbuffの絶対アドレスとなる。この設計により、デバイス側kernelは同一のロジックで登録済みバッファと未登録バッファを処理できる。
ncclSymkInitOnceではGIN関連のリソース要件も初期化される。inbox、outbox、accumulation buffer、rail signalを含む。
📎 src/sym_kernels.cc:208-251
struct ncclDevResourceRequirements ginInboxRailReq = {};
struct ncclDevResourceRequirements ginOutboxReq = {};
struct ncclDevResourceRequirements rsGinAccumReq = {};
struct ncclDevResourceRequirements railSignalReq = {};
if (ncclParamSymGinKernelsEnable() && ncclTeamLsa(comm).nRanks < comm->nRanks) {
int maxBlocks;
size_t bufSize;
getRequirements_gin(comm, &maxBlocks, &bufSize);
maxBlocks = std::max(maxBlocks, comm->config.minCTAs);
maxBlocks = std::min(maxBlocks, comm->config.maxCTAs);
if (ncclParamSymCTAs() >= 1) maxBlocks = ncclParamSymCTAs();
maxBlocks = std::min(maxBlocks, ncclSymkMaxBlocks);
symk->maxGinInboxBlocks = maxBlocks;
symk->kcomm.rsGinAccumBytesPerBlock = ncclSymkRsGinAccumBytesPerBlock();
rsGinAccumReq.bufferSize = (size_t)maxBlocks * symk->kcomm.rsGinAccumBytesPerBlock;
rsGinAccumReq.bufferAlign = 128;
rsGinAccumReq.outBufferHandle = &symk->kcomm.rsGinAccumBuf;
...
uint32_t railSignalCount = ncclTeamRail(comm).nRanks * ncclSymkMaxBlocks;
...
reqs.barrierCount = ncclSymkMaxBlocks;
reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAIL;
reqs.ginStrongSignalsRequired = true;
reqs.ginVaSignalsRequired = true;
}getRequirements_ginはチューニングモデルで必要なblock数とバッファサイズを算出し、その後[minCTAs, maxCTAs]区間にclampされる。rsGinAccumBytesPerBlockは各blockの累加バッファサイズで、128バイトにアラインされる——これはキャッシュラインサイズであり、偽共有を避けるため。
flowchart TD
start["ncclSymkMask(comm, coll, red, ty, nElts)"] --> coll{"集合类型?"}
coll -->|AllGather| mask_ag["kmask = kernelMask_AG"]
coll -->|AllReduce| mask_ar["kmask = kernelMask_AR"]
coll -->|ReduceScatter| mask_rs["kmask = kernelMask_RS"]
mask_ag --> check_stmc{"hasLsaMultimem?"}
mask_ar --> check_stmc
mask_rs --> check_stmc
check_stmc -->|否| clear_stmc["kmask &= ~kernelMask_STMC"]
check_stmc -->|是| check_ldmc{"数据类型+归约支持LDMC?"}
clear_stmc --> size_check
check_ldmc -->|否| clear_ldmc["kmask &= ~kernelMask_LDMC"]
check_ldmc -->|是| size_check
clear_ldmc --> size_check
size_check{"nBusBytes >= 2GB?"} -->|是| clear_ll["kmask &= ~kernelMask_LL"]
size_check -->|否| tma_check
clear_ll --> tma_check{"TMA可用且16B对齐?"}
tma_check -->|否| clear_tma["kmask &= ~kernelMask_Tma"]
tma_check -->|是| gin_check
clear_tma --> gin_check{"需要GIN? LSA rank < 总rank"}
gin_check -->|否| clear_gin["kmask &= ~kernelMask_Gin"]
gin_check -->|是| done
clear_gin --> done["返回 kmask"]この図はncclSymkMaskの決定チェーンを完全に描き出している:集合タイプから出発し、マルチキャストサポート、データ型、サイズ境界、TMA可用性、GIN要件の5つのフィルタを順に通過し、最終的にビットマスクを返す。各フィルタはkernelの一群を除外する可能性があり、これこそNCCLの「シナリオごとに最適なkernelを選ぶ」ことの表れである。
本番環境の落とし穴回避ガイド
落とし穴1:cliqueを跨ぐとマルチキャストが静かに無効化される。 hasLsaMultimemの3番目の条件は!comm->p2pCrossCliqueである。クラスタがMNNVL(Multi-Node NVLink)を構成していても、一部のrankがcliqueを跨ぐとマルチキャストが無効化され、性能は静かに通常パスへ退化する。調査時はncclNvlsSymmetricMultimemEnabledのログ出力を見る。
落とし穴2:16バイトアラインメントの暗黙の要件。 ncclSymkMaskではif (!symAligned16B) kmask &= ~kernelMask_Tma;——ユーザーバッファが16バイトアラインでない場合、TMA kernelが除外される。TMAはHopper/Blackwell上で最速のコピーエンジンであり、これを失うことは性能低下を意味する。本番環境では、ユーザーが渡すバッファはしばしばcudaMallocに由来し、自然にアラインされる。しかしカスタムアロケータやスライスに由来する場合は落とし穴にはまる可能性がある。
落とし穴3:2GB境界。LL kernelは32ビットインデックスを使用し、2GBバスバイト数を超えると除外される。大規模モデル訓練では、1回のAllReduceの勾配がこの値を超えることがあり、その場合NCCLは自動的にSTMCまたはSimpleプロトコルに切り替える。これはバグではないが、手動でLLプロトコルを指定するとncclInvalidArgument。
---
14.2 NVLS:NVSwitchハードウェアにリダクションを任せる
直感モデル
従来のAllReduceは「ソフトウェアリダクション」である:各GPUがデータを隣接GPUに送り、隣接GPUが加算して転送する——データはGPU間を行き来し、加算はSM上で実行される。これは8人が紙を回して合計を計算するようなもので、各人が読んで、加算して、また回す必要がある。
NVLSは発想を変えた:NVSwitchチップに内蔵されたマルチキャスト(multicast)とリダクション(reduction)機能。データをマルチキャストアドレスに書き込むと、NVSwitchが自動的にすべてのメンバーにブロードキャストし、ハードウェア内で加算を完了します。これは8人が同じホワイトボードに数字を書き、ホワイトボードが自動的に合計を表示するようなものです——GPUは一度書いて一度読むだけで、中間の転送と加算はすべてスイッチハードウェアが行います。
NVLSがなければ、ノード内AllReduceの帯域幅はGPU間のポイントツーポイントリンクによって制限され、SMは加算に大量のサイクルを費やす必要があります。NVLSはこの2つをハードウェアにオフロードし、SMは他の計算を行うことができます。
データ構造とメモリレイアウト
NVLSの核心はマルチキャストグループ(MC group)。ncclMcGroup構造体はマルチキャストグループの全状態を記述します。
📎 src/transport/multicast.cc:72-77
struct ncclMcGroup {
CUmemGenericAllocationHandle handle; // the MC object
char* base; // mapped MC VA base
size_t capacity; // total mapped VA size
int dev; // local device, for unbind
};4つのフィールド:handleはCUDAマルチキャストオブジェクトのハンドル、baseはマルチキャスト仮想アドレスのベースアドレス、capacityは総マッピングサイズ、devはローカルデバイス番号(バインド解除用)。ここにはロックがないことに注意——マルチキャストグループの作成と破棄は初期化/破棄フェーズで行われ、ホットパス上にはありません。
マルチキャストグループは複数のパーティション(partition)に分割され、各パーティションは不変のスライスです。ncclMcPartitionは1つのパーティションを記述します。
📎 src/transport/multicast.cc:162-170
// A partition is self-sufficient for binds: it carries the group's handle, device and
// bind granularity alongside its own extent.
for (int i = 0; i < nRequests; i++) {
if (outPartitions[i].size == 0) continue;
outPartitions[i].ptr = group->base + outPartitions[i].offset;
outPartitions[i].mcHandle = mcHandle;
outPartitions[i].minGranularity = minGran;
outPartitions[i].dev = comm->cudaDev;
}各パーティションは自身のoffset、size、ptrを保持し、所属グループのmcHandle、minGranularity、devも持ちます。この「自己完結型」の設計により、パーティションはグループ情報を再検索することなく独立してバインド関数に渡すことができます。
シナリオ駆動のステップバイステップウォークスルー
8つのrankがNVLSドメインを確立するとします。ncclMcGroupBuildPartitionsがマルチキャストグループの作成とパーティションの分割を担当します。
📎 src/transport/multicast.cc:79-121
ncclResult_t ncclMcGroupBuildPartitions(struct ncclComm* comm, const struct ncclMcRequest* requests, int nRequests,
struct ncclMcGroup** outGroup, struct ncclMcPartition* outPartitions) {
...
mcprop.numDevices = comm->localRanks;
mcprop.handleTypes = ncclCuMemHandleType;
mcprop.flags = 0;
mcprop.size = 0;
for (int i = 0; i < nRequests; i++) mcprop.size += requests[i].size;
CUCHECKGOTO(cuMulticastGetGranularity(&recGran, &mcprop, CU_MULTICAST_GRANULARITY_RECOMMENDED), ret, fail);
CUCHECKGOTO(cuMulticastGetGranularity(&minGran, &mcprop, CU_MULTICAST_GRANULARITY_MINIMUM), ret, fail);
// Bump-allocate an immutable slice per request. Offsets and sizes are rounded
// to the recommended granularity (a multiple of the MC minimum) so every slice
// boundary is a valid bind offset.
for (int i = 0; i < nRequests; i++) {
outPartitions[i] = {};
if (requests[i].size == 0) continue;
size_t align = requests[i].alignment > recGran ? requests[i].alignment : recGran;
ALIGN_SIZE(capacity, align);
size_t slice = requests[i].size;
ALIGN_SIZE(slice, recGran);
outPartitions[i].offset = capacity;
outPartitions[i].size = slice;
capacity += slice;
}ステップ1:すべてのリクエストのサイズを累積し、マルチキャストグループの総サイズを取得。ステップ2:CUDAの推奨粒度と最小粒度を照会——これはハードウェア制約であり、マルチキャストオブジェクトのアドレスとサイズは粒度の整数倍でなければなりません。ステップ3:bumpアロケーション——各リクエストに1ブロックを割り当て、オフセットとサイズを推奨粒度にアライン。ALIGN_SIZE(capacity, align)は各スライスの開始オフセットが有効なバインドオフセットであることを保証します。
次はrank間の作成とインポートです:
📎 src/transport/multicast.cc:125-146
if (comm->localRank == 0) {
NCCLCHECKGOTO(ncclMcCreate(comm, &mcprop, comm->localRank, comm->localRanks, &mcHandle, shareableHandle), ret,
fail);
mcCreated = 1;
NCCLCHECKGOTO(bootstrapIntraNodeBroadcast(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
0, shareableHandle, NVLS_HANDLE_SIZE),
ret, fail);
} else {
NCCLCHECKGOTO(bootstrapIntraNodeBroadcast(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
0, shareableHandle, NVLS_HANDLE_SIZE),
ret, fail);
NCCLCHECKGOTO(ncclMcImport(comm, shareableHandle, comm->localRankToRank[0], &mcHandle), ret, fail);
mcCreated = 1;
}
CUCHECKGOTO(cuMulticastAddDevice(mcHandle, comm->cudaDev), ret, fail);
// cuMemMap of an MC object blocks until every device has been added. This
// abort-aware barrier makes a peer failing before cuMulticastAddDevice trip the
// abort flag here instead of stranding survivors in the blocking cuMemMap.
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks,
comm->localRankToRank[0]),
ret, fail);localRank 0がマルチキャストオブジェクトを作成し、bootstrap経由でshareable handleをブロードキャスト;他のrankはhandleを受信してインポートします。cuMulticastAddDeviceはローカルデバイスをマルチキャストグループに追加します。あのbarrierに注意——コメントが明確に述べています:cuMemMapはすべてのデバイスが参加するまでブロックし、あるpeerがcuMulticastAddDeviceの前に失敗した場合、生存者はcuMemMapでスタックします。このbarrierにより、失敗はブロック前にabortフラグで捕捉されます。
最後にマッピングとアクセス権限の設定です:
📎 src/transport/multicast.cc:148-155
// Reserve and map the whole MC VA once; each consumer slice is a view into it.
CUCHECKGOTO(cuMemAddressReserve(&base, capacity, recGran, 0U, 0), ret, fail);
CUCHECKGOTO(cuMemMap(base, capacity, 0, mcHandle, 0), ret, fail);
mapped = 1;
desc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
desc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
desc.location.id = comm->cudaDev;
CUCHECKGOTO(cuMemSetAccess(base, capacity, &desc, 1), ret, fail);マルチキャストVA全体は一度だけ予約およびマッピングされ、各コンシューマスライスはこのVAのビューです。これは「一度マッピング、複数回スライス」の設計——各コンシューマが個別にマルチキャストオブジェクトを作成するよりもリソースを節約します。
並行制御とハードウェア相互作用
バインドはNVLSの最も重要な操作です。ncclMcPartitionBindMemUC(ユニキャスト)メモリハンドルをマルチキャストグループの特定のオフセットにバインドします。
📎 src/transport/multicast.cc:200-225
ncclResult_t ncclMcPartitionBindMem(const struct ncclMcPartition* partition, size_t offsetInPartition,
CUmemGenericAllocationHandle mem, size_t memOffset, size_t bindSize) {
// A bind overrunning its partition would corrupt the next consumer's partition; fail
// cleanly instead (possible when UC rounding exceeds the MC-rounded partition).
if (offsetInPartition + bindSize > partition->size) {
WARN("NVLS MC bind of size %zu at slice offset %zu exceeds slice size %zu (UC/MC granularity mismatch)", bindSize,
offsetInPartition, partition->size);
return ncclInternalError;
}
size_t mcOffset = partition->offset + offsetInPartition;
...
CUresult err = CUPFN(cuMulticastBindMem(partition->mcHandle, mcOffset, mem, memOffset, bindSize, 0 /*flags*/));
if (err != CUDA_SUCCESS) {
...
WARN("Failed to bind NVLink SHARP (NVLS) Multicast memory of size %zu at MC group %llx offset %zu : CUDA error %d "
"'%s'.\nThis is usually caused by a system or configuration error in the Fabric Manager or NVSwitches.\n"
"Disable NVLS (NCCL_NVLS_ENABLE=0) if you wish to avoid this error in the future.",
bindSize, partition->mcHandle, mcOffset, err, errStr);
return ncclUnhandledCudaError;
}
return ncclSuccess;
}第一の防御線は境界チェックです:offsetInPartition + bindSize > partition->sizeでエラーを報告します。コメントが理由を説明しています——UCメモリの粒度はMCパーティションより大きい可能性があり、UCがアライン後にMCパーティションの境界を超えると、次のコンシューマのパーティションを踏んでしまいます。これは典型的な「2つの粒度の不一致」の罠です。
cuMulticastBindMemはハードウェア呼び出しで、コメントには「blocks until all ranks have been added to the group」とあり——これはNVLSで最も問題が発生しやすい箇所です。Fabric Managerの設定ミスやNVSwitchファームウェアの問題がある場合、ここでハングするかエラーを返します。エラーメッセージには直接ユーザーにNCCL_NVLS_ENABLE=0を推奨しており、これは本番環境の標準的な脱出ハッチです。
また、ユーザーバッファ登録用の「バインド試行」バリアントもあります:
📎 src/transport/multicast.cc:237-268
ncclResult_t ncclMcPartitionTryBindAddr(const struct ncclMcPartition* partition, size_t offsetInPartition,
CUdeviceptr address, size_t bindSize, enum ncclMcBindStatus* outStatus) {
const char* errStr = NULL;
*outStatus = ncclMcBindStatusTransient;
if (offsetInPartition + bindSize > partition->size) {
...
return ncclInternalError;
}
size_t mcOffset = partition->offset + offsetInPartition;
CUresult err = CUPFN(cuMulticastBindAddr(partition->mcHandle, mcOffset, address, bindSize, 0 /*flags*/));
if (err == CUDA_SUCCESS) {
*outStatus = ncclMcBindStatusOk;
return ncclSuccess;
}
(void)pfn_cuGetErrorString(err, &errStr);
// Only an outright rejection of the input is a property of the buffer. Anything else,
// notably OUT_OF_MEMORY, may succeed later, so it must not be reported as permanent.
if (err == CUDA_ERROR_INVALID_VALUE || err == CUDA_ERROR_NOT_SUPPORTED || err == CUDA_ERROR_NOT_PERMITTED) {
*outStatus = ncclMcBindStatusNoSupport;
...
} else {
WARN("NVLS Multicast bind of size %zu at MC group %llx offset %zu dev %d failed transiently: CUDA error %d '%s'.\n"
"The buffer is left unregistered for this operation and will be retried; repeated occurrences indicate "
"sustained resource pressure.",
bindSize, partition->mcHandle, mcOffset, partition->dev, err, errStr);
}
return ncclSuccess;
}ここには巧妙なエラー分類があります:CUDA_ERROR_INVALID_VALUE、NOT_SUPPORTED、NOT_PERMITTEDはncclMcBindStatusNoSupportに分類されます——これは永続的失敗であり、このバッファ自体がマルチキャストバインドをサポートしていないことを示します。一方、他のエラー(特にOUT_OF_MEMORY)はncclMcBindStatusTransientに分類されます——これは一時的失敗であり、リトライ可能です。この区別は極めて重要です:OOMを永続的失敗として扱うと、成功し得た登録を誤って諦めてしまいます;パラメータエラーを一時的失敗として扱うと、無限にリトライしてしまいます。
本番環境の落とし穴回避ガイド
落とし穴1:Fabric Managerの設定ミスによるcuMulticastBindMemのハング。これはNVLSの最も古典的な本番障害です。エラーメッセージはFabric ManagerまたはNVSwitchを明確に指摘しています。トラブルシューティング手順:まずNCCL_NVLS_ENABLE=0で問題が消えることを確認し、次にFabric ManagerのログとNVSwitchファームウェアバージョンを確認します。
落とし穴2:UC/MC粒度の不一致。 ncclMcPartitionBindMemの境界チェックがこの問題を捕捉しますが、「UC/MC granularity mismatch」警告が表示された場合、あるリクエストのUCサイズがアライン後にMCパーティションを超えていることを示します。これは通常、リクエストサイズが粒度境界に近い場合に発生します。
落とし穴3:マルチキャストグループ作成失敗後のリソースリーク。 ncclMcGroupBuildPartitionsのfailパスはCUCALL(best-effort)を使用しており、CUCHECK:
📎 src/transport/multicast.cc:179-184
fail:
// Best-effort (CUCALL) so a failing cleanup op cannot skip releasing the MC handle.
if (mapped) CUCALL(cuMemUnmap(base, capacity));
if (base) CUCALL(cuMemAddressFree(base, capacity));
if (mcCreated) CUCALL(cuMemRelease(mcHandle));
return ret;コメントは理由を説明している:cleanup 操作自体が失敗しても、それを理由に MC ハンドルの解放をスキップしてはならない——MC スロットは希少なリソースであり、リークすると後続の作成が失敗する。これは「クリーンアップ経路はベストエフォートでなければならない」という典型的な設計である。
sequenceDiagram
participant R0 as "Rank 0 (localRank=0)"
participant R1 as "Rank 1..N-1"
participant BS as "bootstrapIntraNode"
participant CU as "CUDA Driver"
R0->>CU: "cuMulticastCreate(mcHandle, prop)"
CU-->>R0: "mcHandle"
R0->>BS: "bootstrapIntraNodeBroadcast(shareableHandle)"
BS-->>R1: "shareableHandle"
R1->>CU: "cuMemImportFromShareableHandle(mcHandle)"
CU-->>R1: "mcHandle"
R0->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
R1->>CU: "cuMulticastAddDevice(mcHandle, cudaDev)"
R0->>BS: "bootstrapIntraNodeBarrier()"
R1->>BS: "bootstrapIntraNodeBarrier()"
Note over R0,R1: "barrier 防止 cuMemMap 阻塞时 peer 失败"
R0->>CU: "cuMemAddressReserve(base, capacity)"
R0->>CU: "cuMemMap(base, capacity, mcHandle)"
R0->>CU: "cuMemSetAccess(base, capacity, desc)"
R0->>CU: "cuMulticastBindMem(mcHandle, mcOffset, ucHandle)"
CU-->>R0: "绑定完成,硬件多播就绪"このシーケンス図は、マルチキャストグループが作成からバインドまでの完全なフローを描いている。鍵となるのはあの barrier である——それは「peer 失敗」と「cuMemMap ブロッキング」を分離し、生存者がデッドロックするのを防ぐ。
---
14.3 対称メモリと NVLS の合体:LSA ポインタがデバイス側でどのように解決されるか
直感的モデル
対称メモリは「アドレス一致」問題を解決し、NVLS は「ハードウェアリダクション」問題を解決する。しかし両者が真に協調するには、もう一つ鍵となるメカニズムが必要である:デバイス側は、あるアドレスが対称であり、マルチキャスト経路を通れることをどのように知るのか?
答えは LSA(Load-Store Accessible)ポインタにある。LSA は「ロード・ストアアクセス可能」の略で、このポインタが指すメモリに GPU が通常の load/store 命令で直接アクセスできることを意味する——それが物理的にローカルにあろうとリモートにあろうと関係ない。アドレスがマルチキャストグループ内にあれば、load/store は NVSwitch ハードウェアにインターセプトされブロードキャストされる。
データ構造とメモリレイアウト
ncclSymkDevWorkはデバイス側のワークディスクリプタであり、対称メモリの鍵となる情報を運んでいる。
📎 src/sym_kernels.cc:380-393
ncclResult_t ncclSymkMakeDevWork(struct ncclComm* comm, struct ncclTaskColl* task, struct ncclSymkDevWork* outDevWork) {
outDevWork->rootRank = task->root;
outDevWork->redOpArg = task->opDev.scalarArg;
outDevWork->nElts = task->count;
outDevWork->inputWin = task->sendWin ? task->sendWin->vidmem : nullptr;
outDevWork->inputOff =
task->sendWin ? (uint8_t*)task->sendbuff - (uint8_t*)task->sendWin->userPtr : (size_t)task->sendbuff;
outDevWork->outputWin = task->recvWin ? task->recvWin->vidmem : nullptr;
outDevWork->outputOff =
task->recvWin ? (uint8_t*)task->recvbuff - (uint8_t*)task->recvWin->userPtr : (size_t)task->recvbuff;
outDevWork->sChannelId = 0xffff;
outDevWork->nChannels = 0;
return ncclSuccess;
}inputWinはウィンドウのデバイス側仮想アドレス(vidmem),inputOffはウィンドウ内のバッファのオフセットである。デバイス側 kernel はこれら二つの値を取得した後、inputWin + inputOffを計算して実際のアドレスを得る。このアドレスがマルチキャストグループ内にあれば、ハードウェアが自動的にブロードキャストを処理する。
ncclSymkInitOnceには LSA barrier と LLA2A(Low-Latency All-to-All)リソースも設定されている。
📎 src/sym_kernels.cc:197-206
reqs.lsaBarrierCount = ncclSymkMaxBlocks;
reqs.ginStrongSignalsRequired = false;
reqs.ginVaSignalsRequired = false;
struct ncclDevResourceRequirements lla2aReq;
ncclLLA2ACreateRequirement(ncclSymkMaxBlocks,
ncclLLA2ACalcSlots(ncclTeamLsa(comm).nRanks * ncclSymkMaxThreads, ncclSymkLLMaxEltSize),
&symk->kcomm.lsaLLA2A, &lla2aReq);
lla2aReq.next = reqs.resourceRequirementsList;
reqs.resourceRequirementsList = &lla2aReq;lsaBarrierCountはncclSymkMaxBlocksに設定される——各 block に一つの barrier スロット。LLA2A は低遅延 all-to-all の略で、LSA ドメイン内で高速なデータ交換を行うために用いられる。ncclLLA2ACalcSlotsは rank 数、スレッド数、最大要素サイズに基づいて必要なスロット数を算出する。
シナリオ駆動のステップバイステップウォークスルー
ある AllReduce がAllReduce_AGxLLMC_Rkernel(AllGather + LL + MC + Reduce)を使用すると仮定する。この kernel のワークフローは:
1. AllGather フェーズ:各 rank が自分のデータをマルチキャストグループに書き込み、NVSwitch ハードウェアがすべての rank にブロードキャストする。
2. Reduce フェーズ:各 rank がマルチキャストグループからすべての rank のデータを読み取り、ローカルでリダクションを行う。
ncclSymkMaskはこの kernel が利用可能かどうかをチェックする。kernelMask_LLはAllReduce_AGxLLMC_Rを含むが、それはhasLsaMultimemが真であることが前提である(そうでなければkernelMask_STMCがクリアされ、AllReduce_AGxLLMC_Rは STMC 集合に属する)。
待って、ここに細かい点がある:kernelMask_STMCはAllReduce_AGxLLMC_Rを含むか?ソースコードを見てみよう:
📎 src/sym_kernels.cc:17-21
constexpr uint32_t kernelMask_STMC =
1 << ncclSymkKernelId_AllGather_LLMC | 1 << ncclSymkKernelId_AllGather_STMC |
1 << ncclSymkKernelId_AllGather_TmaSTMC | 1 << ncclSymkKernelId_AllReduce_AGxLLMC_R |
1 << ncclSymkKernelId_AllReduce_RSxLDMC_AGxSTMC | 1 << ncclSymkKernelId_ReduceScatter_LDMC |
1 << ncclSymkKernelId_AllGather_RailRing_LsaSTMC;はい、AllReduce_AGxLLMC_RはkernelMask_STMCの中にある。したがってhasLsaMultimemが偽なら、この kernel は除外される。これが、対称メモリと NVLS が協調して動作しなければならない理由を説明している——マルチキャストがなければ、MC 系列の kernel はすべて利用不可となる。
デバイス側はncclSymkDevWorkを取得した後、inputWinとinputOffに基づいてアドレスを計算する。アドレスがマルチキャストグループ内にあれば、load/store 命令は NVSwitch にインターセプトされる。これが LSA ポインタの解決プロセスである:ソフトウェアによる変換は不要で、ハードウェアがアドレス範囲に基づいて自動的に判断する。
並行制御とハードウェア相互作用
NVLS の同期メカニズムはcredit(クレジット)。ncclNvlsSetupに依存しており、credit パーティションが初期化される。
📎 src/transport/nvls.cc:407-447
int nChannels = comm->nvlsChannels;
size_t creditSize = nChannels * 2 * memSize * nHeads;
int nvlsStepSize = comm->nvlsChunkSize;
NCCLCHECKGOTO(ncclCalloc(&comm->nvlsResources, 1), res, fail);
comm->nvlsResources->inited = false;
comm->nvlsResources->refCount = 1;
comm->nvlsResources->nChannels = nChannels;
comm->nvlsResources->nHeads = nHeads;
comm->nvlsResources->chunkSize = comm->nvlsChunkSize;
comm->nvlsResources->treeMaxChunkSize = comm->nvlsTreeMaxChunkSize;
resources = comm->nvlsResources;
for (int c = 0; c < nChannels; c++) {
NCCLCHECKGOTO(initNvlsChannel(comm, c, NULL, false), res, fail);
}
memset(&resources->accessDesc, 0, sizeof(resources->accessDesc));
resources->accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
resources->accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
resources->accessDesc.location.id = comm->cudaDev;
resources->dev = comm->cudaDev;
// Build the single shared MC group for this NVLS domain. The data slice is
// reserved here but bound later by ncclNvlsBufferSetup.
{
size_t buffSize = nvlsStepSize * NCCL_STEPS;
size_t dataSize = nChannels * 2 * buffSize * nHeads;
size_t ubSize = ncclNvlsUbSize(comm);
struct ncclMcRequest requests[3] = {{creditSize, 0}, {dataSize, 0}, {ubSize, 0}};
struct ncclMcPartition partitions[3];
NCCLCHECKGOTO(ncclMcGroupBuildPartitions(comm, requests, 3, &resources->mcGroup, partitions), res, fail);
resources->creditPartition = partitions[0];
resources->dataPartition = partitions[1];
if (ubSize) {
resources->ubPartition = partitions[2];
NCCLCHECKGOTO(ncclMcArenaInit(comm, &resources->ubArena, &resources->ubPartition), res, fail);
resources->ubEnabled = true;
}
NCCLCHECKGOTO(nvlsAllocBindUc(comm, &resources->creditPartition, creditSize, &resources->creditUc), res, fail);
}マルチキャストグループは三つのパーティションに分割される:creditPartition(クレジット)、dataPartition(データ)、ubPartition(ユーザーバッファ)。credit パーティションは同期に用いられる——各 channel は独立した head/tail ポインタを持ち、マルチキャストグループを通じて共有される。
credit の初期化は後続のループで行われる:
📎 src/transport/nvls.cc:456-491
for (int h = 0; h < nHeads; h++) {
int nvlsPeer = comm->nRanks + 1 + h;
for (int c = 0; c < nChannels; c++) {
struct ncclChannel* channel = comm->channels + c;
char* mem = NULL;
struct ncclChannelPeer* peer = channel->peers[nvlsPeer];
// Reduce UC -> MC
mem = (char*)resources->creditUc.ptr + (h * 2 * nChannels + c) * memSize;
peer->send[1].transportComm = &nvlsTransport.send;
peer->send[1].conn.buffs[NCCL_PROTO_SIMPLE] = NULL;
peer->send[1].conn.head = (uint64_t*)mem;
peer->send[1].conn.tail = (uint64_t*)(mem + memSize / 2);
peer->send[1].conn.stepSize = nvlsStepSize;
mem = (char*)resources->creditPartition.ptr + (h * 2 * nChannels + c) * memSize;
peer->recv[0].transportComm = &nvlsTransport.recv;
peer->recv[0].conn.buffs[NCCL_PROTO_SIMPLE] = NULL;
peer->recv[0].conn.head = (uint64_t*)mem;
peer->recv[0].conn.tail = (uint64_t*)(mem + memSize / 2);
peer->recv[0].conn.stepSize = nvlsStepSize;
peer->recv[0].conn.flags |= NCCL_NVLS_MIN_POLL;各 head と channel の組み合わせが独立した credit 領域を持つ。headとtailは 64 ビットポインタであり、memSizeは 64 バイト(size_t memSize = 64;)なので、head と tail がそれぞれ 32 バイトを占める——ちょうどキャッシュラインの半分である。NCCL_NVLS_MIN_POLLフラグは受信側に最小ポーリングモードを使わせ、CPU オーバーヘッドを削減する。
本番環境の落とし穴回避ガイド
落とし穴 1:credit パーティションの head/tail 競合。複数の channel が同じマルチキャストグループを共有するが、各 channel は独立した credit 領域を持つ。channel 数が不適切に設定されると(例えばnvlsCTAsを大きくしすぎると)、credit 領域が膨張し、貴重なマルチキャストアドレス空間を占有する。ncclNvlsChannelsは GPU アーキテクチャとノード数に基づいて channel 数を自動調整する:
📎 src/transport/nvls.cc:100-133
if (comm->config.nvlsCTAs != NCCL_CONFIG_UNDEF_INT) {
channels = comm->config.nvlsCTAs;
} else if (channels == 0 && comm->compCap >= 100) {
// Use a reduced number of channels for single node/MNNVL domain on Blackwell and above.
// comm->nNodes is not yet initialized at this point so we need to use local information.
bool multiNode = false;
if (comm->MNNVL) {
multiNode = (comm->clique.size < comm->nRanks);
} else {
int i;
for (i = 1; i < comm->nRanks; i++) {
if (comm->peerInfo[i].hostHash != comm->peerInfo[0].hostHash) break;
}
multiNode = (i < comm->nRanks);
}
if (multiNode) {
channels = RUBIN_AND_LATER(comm->compCap) ? /*RUBIN=*/64 : /*SM100=*/32;
} else {
channels = RUBIN_AND_LATER(comm->compCap) ? /*RUBIN=*/48 : /*SM100=*/24;
}
} else if (channels == 0) {
channels = /*SM90=*/16;
}注意comm->nNodesはこの段階ではまだ初期化されていないため、コードはpeerInfo[i].hostHashを手動で使ってマルチノードかどうかを判断している。これは初期化順序の典型的な罠である——まだ計算されていないフィールドに依存してはならない。
落とし穴 2:MNNVL は NVLS buffer 登録をサポートしない。 📎 src/transport/nvls.cc:516-517
// MNNVL does not support NVLS buffer registration
if (!comm->MNNVL && comm->nvlsResources->nvlsShmemHandle == NULL) {MNNVL(Multi-Node NVLink)環境では、ユーザーバッファ登録がスキップされる。あなたのクラスタが MNNVL で、UB 登録による性能向上に依存しているなら、登録が効いていないことに気づくだろう。これはハードウェアの制限であり、バグではない。
落とし穴3:共有リソースの参照カウント。 ncclNvlsSetup親子通信ドメイン間でNVLSリソースの共有をサポート:
📎 src/transport/nvls.cc:380-392
if (nvlsShare) {
/* reuse NVLS resources */
comm->nvlsChannels = std::min(comm->nvlsChannels, parent->nvlsResources->nChannels);
/* Inherit chunk sizes from the shared resource since we're reusing the parent's
* NVLS buffers, which were allocated and laid out based on these values. */
comm->nvlsChunkSize = parent->nvlsResources->chunkSize;
comm->nvlsTreeMaxChunkSize = parent->nvlsResources->treeMaxChunkSize;
for (int c = 0; c < comm->nvlsChannels; c++) {
NCCLCHECKGOTO(initNvlsChannel(comm, c, parent, true), res, fail);
}
comm->nvlsResources = parent->nvlsResources;
ncclAtomicRefCountIncrement(&parent->nvlsResources->refCount);
}子通信ドメインは親通信ドメインのリソースを再利用し、参照カウントが1増える。ncclNvlsFree内で参照カウントがゼロになるまで実際には解放されない。参照カウントの管理に誤りがあると、リソースの早期解放やリークが発生する。注意nvlsChunkSizeとnvlsTreeMaxChunkSizeは親通信ドメインの値を継承しなければならない——バッファはこれらの値に基づいてレイアウトされるため、変更するとアドレス計算が誤る。
flowchart LR
subgraph host["Host 侧"]
task["ncclTaskColl<br/>sendbuff/recvbuff"]
devwork["ncclSymkDevWork<br/>inputWin + inputOff"]
task -->|"ncclSymkMakeDevWork"| devwork
end
subgraph device["Device 侧"]
kernel["SymKernel<br/>load/store"]
lsa{"地址在多播组内?"}
devwork --> kernel
kernel --> lsa
end
subgraph hw["NVSwitch 硬件"]
mc["多播组<br/>MC group"]
reduce["硬件归约<br/>Reduction"]
lsa -->|"是"| mc
lsa -->|"否"| local["本地显存<br/>UC memory"]
mc --> reduce
reduce -->|"广播结果"| kernel
endこのデータフロー図は、host側のタスクからデバイス側の実行までの完全な経路を示している。重要な分岐はlsa{"地址在多播组内?"}——もし真なら、NVSwitchハードウェアマルチキャストとリダクションを通る。もし偽なら、ローカルVRAMを通る。この判定はハードウェアがアドレス範囲に基づいて自動的に行い、ソフトウェアの介入は不要である。
---
14.4 設計考察:なぜ対称メモリは小メッセージのレイテンシを低減できるのか
本章冒頭の核心的な問いに戻る:なぜ対称メモリは小メッセージのレイテンシを大幅に低減できるのか?
第一に、アドレス変換のオーバーヘッドを排除する。従来の通信では、各rankが対端のバッファにアクセスするたびにテーブル参照とオフセット計算が必要だった。対称メモリでは全rankが同一のアドレスセットを使うため、デバイス側カーネルは直接base + offsetを計算すればよい。小メッセージでは、この変換のオーバーヘッドが占める割合が非常に高い。
第二に、制御メッセージの往復を排除する。従来の通信では「あなたのどのバッファに書き込むか」といった制御情報を交換する必要があった。対称メモリではアドレスが事前に取り決められているため、実行時のネゴシエーションが不要である。
第三に、ハードウェアマルチキャストを可能にする。アドレスが対称である場合にのみ、NVSwitchは同一のアドレスセットでマルチキャストできる。各rankのアドレスが異なれば、ハードウェアはどこにブロードキャストすべきか知ることができない。
第四に、SMのリダクション負担を軽減する。NVLSは加算をNVSwitchにオフロードし、SMは一度の書き込みと一度の読み取りを発行するだけでよい。小メッセージでは、SMの命令オーバーヘッドがレイテンシの主要な要因である。
これら4つの要因が重なり、小メッセージのレイテンシを「マイクロ秒級」から「サブマイクロ秒級」へと引き下げる。
エンジニアリングの観点から見ると、対称メモリの設計はNCCLの核心的な哲学を体現している:複雑さを初期化段階に押しやり、ホットパスを可能な限りシンプルに保つ。アドレスネゴシエーション、マルチキャストグループの作成、creditの割り当てはすべて初期化時に行われ、実行時カーネルは最も単純なアドレス計算とload/storeのみを行う。この「初期化は重く、実行時は軽く」という設計は、高性能通信ライブラリの共通パターンである。
---
本章のまとめ
本章ではNCCLの機内通信の2大支柱を分解した:
1. 対称メモリ:ncclSymkInitOnceとncclSymkMaskを通じてアドレスが一致したバッファを確立し、各rankが同一のアドレスセットで全rankのデータにアクセスできるようにする。ncclSymkMakeDevWorkhost側のタスクをデバイス側のワークアイテムに変換し、inputWin + inputOffはアドレス解決の核心的な公式である。
2. NVLSマルチキャスト:ncclMcGroupBuildPartitionsでマルチキャストグループを作成し、ncclMcPartitionBindMemUCメモリをマルチキャストグループにバインドし、cuMulticastBindMemはハードウェア呼び出しである。マルチキャストグループはcredit、data、ubの3つのパーティションに分割され、それぞれ同期、データ転送、ユーザーバッファ登録に使用される。
3. LSAポインタ解決:デバイス側がアドレス範囲に基づいてマルチキャストパスを通るかどうかを自動判定し、ソフトウェア変換は不要である。NCCL_NVLS_MIN_POLLフラグはポーリングのオーバーヘッドを最適化する。
4. エラー処理:ncclMcPartitionTryBindAddrは永続的失敗と一時的失敗を区別し、ncclMcGroupBuildPartitionsのfailパスはCUCALLでリソースの解放を保証する。
本章の考察とセルフチェック
Q1: もしncclMcPartitionBindMem内の境界チェックif (offsetInPartition + bindSize > partition->size)を削除した場合、どのようなシナリオでメモリ境界違反が発生するか?なぜこのチェックを「UCとMCの粒度が同じ」で代替できないのか?
参考解説:📎 src/transport/multicast.cc:200-208:
第15章:第15章:RMAとGIN:リモートメモリアクセスとGPU直結通信の進化
第15章:RMAとGIN:リモートメモリアクセスとGPU直結通信の進化
前章では、対称メモリによって各rankが同一のアドレスセットで全rankのバッファにアクセスでき、NVLSがNVSwitchのマルチキャスト能力を活用してハードウェア加速リダクションを極限まで推し進めることを見た。しかし集合通信がすべてではない——アプリケーションがポイントツーポイントのリモートメモリ操作を必要としたり、GPUカーネルが直接ネットワークリクエストを発行したい場合には、RMAとGINの出番となる。RMAはput/getセマンティクスのリモートメモリアクセスを提供し、GINはGPUがhost proxyスレッドをバイパスして直接ネットワークと対話できるようにする。本章は「まずRMA、次にGIN」の順で、これら2つのメカニズムのデータ構造、スケジューリングロジック、並行制御、本番環境の落とし穴を段階的に分解する。
RMAのデュアルチャネルモデル:CEとProxyの役割分担
直感的モデル
多国籍宅配システムを想像してみよう。同一市内の宅配(LSA到達可能なrank)は地元の配送車で直接届けられるが、市をまたぐ宅配(LSA到達不可能なrank)は航空貨物代理店に引き渡さなければならない。NCCLのRMAはまさにこのモデルである。同じput操作が、対象rankがLSA(Load-Store Accessible)チーム内にあるかどうかによって、まったく異なる2つの実行パス、すなわちCE(Copy Engine、コピーエンジン)パスとProxy(プロキシスレッド)パスにルーティングされる。
この振り分けメカニズムがなければ、すべてのRMA操作がproxyスレッドを通ることになり、同一マシン内のputもhostスレッドを経由することになり、host-device間の往復遅延が無駄に1回増える。逆に、すべての操作がCEを通れば、マシン間操作はネットワークプラグインの非同期能力を活用できなくなる。
データ構造とメモリレイアウト
RMAの中核となるスケジューリング構造はncclRmaArgsであり、これは1つのplanにおけるRMAタスクの振り分け結果を記録する。主要なフィールドは以下の通り:
| フィールド | 意味 |
|---|---|
func | 操作タイプ(PutSignal / Signal / WaitSignal) |
nRmaTasks | 総タスク数 |
nRmaTasksProxy | proxyパスを通るタスク数 |
nRmaTasksCe | CEパスを通るタスク数 |
各plan内部には2つの侵入型キューを維持する:rmaTaskQueueCeおよびrmaTaskQueueProxyであり、それぞれ2つのパスのタスクを格納する。📎 src/rma/rma.cc:166-171
あるrankがLSA到達可能かどうかを判定するロジックは非常に直接的で、lsaRankList配列を走査して線形探索を行う。📎 src/rma/rma.cc:34-41この探索はタスクスケジューリング時に各peerに対して1回実行され、計算量はO(lsaSize)であり、典型的な小規模LSAチーム(通常2〜8個のrank)ではオーバーヘッドは無視できる。
ステップバイステップのスケジューリングフロー
アプリケーションが1回のRMA put操作を呼び出すと、タスクはplanner->rmaTaskQueues[ctx]。scheduleRmaTasksToPlanに入り、キューのタスクをplanに割り当てる役割を担う。📎 src/rma/rma.cc:141-296
第一步:最初の非空のcontextキューを見つける。NCCLは複数のRMA context(numRmaCtxで設定)をサポートし、各contextは独立したキューを持つ。📎 src/rma/rma.cc:148-155
第二步:最初のタスクを取り出し、操作タイプを判定する。WaitSignalであれば特殊な分割ロジックを通り、Put/Signalであればバッチマージロジックを通る。📎 src/rma/rma.cc:163-168
WaitSignalタスクの場合、スケジューラはpeersリストをLSA到達可能性に基づいて2つのグループ、CEグループとProxyグループに分割する必要がある。📎 src/rma/rma.cc:187-204分割後、それぞれ2つの新しいncclTaskRma構造を作成し、それぞれが対応するグループのpeers配列を保持する。📎 src/rma/rma.cc:207-246元のタスクは解放される。📎 src/rma/rma.cc:251
Put/Signalタスクの場合、ロジックはより複雑である。スケジューラはすべてのcontextのキューを走査し、連続するput/signalタスクをすべて同じplanに引き込み、WaitSignalに遭遇するまで停止する。📎 src/rma/rma.cc:279-295この設計の目的はコメントに明確に書かれている:1回のkernel launchで全contextのput/signalをカバーし、proxyは任意のブロッキング操作の前にすべての非同期リクエストを一括で発行でき、CEパスは全contextのコピーとシグナルをバッチで投入する。📎 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_loop並列実行とストリーム同期
スケジューリング完了後、ncclLaunchRmaはfuncフィールドに基づいてncclRmaPutまたはncclRmaWaitSignal。📎 src/rma/rma.cc:109-131
にディスパッチする。ncclRmaPutを例にとると、plan内にproxyとCEタスクが同時に存在する場合、2つのパスを並列実行する必要がある。NCCLの手法は:入力ストリーム上でeventを記録し、CEストリームにこのeventを待機させ、その後両方のストリームで同時に操作を起動し、最後にCEストリーム上で再度eventを記録し、入力ストリームにそれを待機させる。📎 src/rma/rma.cc:80-96このeventチェーンは次を保証する:CE操作は入力ストリームの依存関係が準備できる前に開始されず、入力ストリームの後続操作もCE完了前に開始されない。
proxyタスクのみ、またはCEタスクのみの場合は、入力ストリーム上で直接対応する操作を起動し、追加のストリーム同期は不要である。📎 src/rma/rma.cc:97-101
設計上の考察と本番環境の落とし穴
落とし穴1:LSA到達可能性判定の静的性。 isLsaAccessibleスケジューリング時にcomm->devrState.lsaRankListをクエリするが、このリストは通信ドメインの初期化後は変化しない。実行中にトポロジが変化した場合(例えばNVLink障害による降格)、LSAリストは自動更新されず、本来proxyを通るべき操作がCEパスを通り続け、回復不能なエラーを引き起こす可能性がある。
落とし穴2:バッチマージのFIFO保証。バッチマージロジックは連続するput/signalタスクのみを引き出し、WaitSignalに遭遇すると停止する。📎 src/rma/rma.cc:283これは各context内のFIFO順序を保証するが、contextをまたぐタスクは同じplanにマージされる可能性がある。アプリケーションがcontextをまたぐ操作順序に依存する場合、WaitSignalを明示的に使用してバリアを確立する必要がある。
落とし穴3:メモリリークパス。WaitSignal分岐において、npeersProxy == 0の場合、コードはpeersProxy、nsignalsProxy、signalIdxsProxyの3つの配列を解放する。📎 src/rma/rma.cc:239-244しかしnpeersCe == 0かつnpeersProxy > 0,peersCeなどの配列がncclMemoryStackAllocで割り当てられている場合、手動で解放する必要はない(スタック型アロケータが一括回収する)。📎 src/rma/rma.cc:176-178この非対称性は読者を混乱させやすいが、実際には正しい——スタックに割り当てられたメモリはcomm->memScopedによって統一的に管理される。
RMA Proxy コンテキスト:シグナル、キュー、ロックフリーリングバッファ
直感的モデル
Proxy コンテキストは「郵便局の仕分けセンター」のようなものだ:GPU は送信したい荷物(put リクエスト)を受信箱(リングバッファ)に入れ、proxy スレッドが受信箱から荷物を取り出し、宅配業者(ネットワークプラグイン)に渡し、宅配業者が配達後に受領書(シグナル)に捺印する。このプロセス全体を通じて、GPU と proxy スレッドはロックフリーデータ構造を介して通信し、高コストなロック競合を回避する。
データ構造とメモリレイアウト
ncclRmaProxyCtxは proxy コンテキストのホスト構造体であり、その主要なフィールドは以下の通り:
シグナル領域(signalsDev):GPU 上に割り当てられたメモリで、サイズはnRanks * numRmaSig * sizeof(uint64_t)。📎 src/rma/rma_proxy.cc:120-123各 rank はnumRmaSig個のシグナルスロットを持ち、その rank からのシグナルを受信するために使用される。このメモリはネットワークプラグインに登録される際にNCCL_NET_MR_FLAG_FORCE_SO(強制強順序)とNCCL_NET_MR_FLAG_SIGNAL_NEVER_RESET(シグナルは決してリセットされない)フラグが付与される。📎 src/rma/rma_proxy.cc:125-127強順序フラグは put と signal の間の順序関係を保証する——put が signal より先に発行された場合、ネットワークは put データが到達した後にのみ signal が書き込まれることを保証しなければならない。
シーケンス番号領域(opSeqs/readySeqs/doneSeqs):各 rank に1組、allocMemCPUAccessibleによって割り当てられ、GDR(GPU Direct RDMA)メモリまたは通常の host メモリの可能性がある。📎 src/rma/rma_proxy.cc:132-137これら3つのシーケンス番号はそれぞれ追跡する:コミット済みの操作番号、準備完了の操作番号、完了済みの操作番号。
ロックフリーリングバッファ(circularBuffers):サイズnRanks * queueSizeのポインタ配列で、各 rank に独立したリングキューがある。📎 src/rma/rma_proxy.cc:163-164付随するpis(Producer Index)とcis(Consumer Index)配列はそれぞれnRanks個の要素を持つ。📎 src/rma/rma_proxy.cc:165-166キューサイズは2の冪でなければならず、これによりインデックスのラップアラウンドはビット AND 演算& (queueSize - 1)でモジュロ演算を置き換えられる。📎 src/rma/rma_proxy.cc:156-160
InProgress キュー:各 peer に1つの侵入型リンクリストがあり、ネットワークプラグインにコミット済みだが未完了のディスクリプタを格納する。📎 src/rma/rma_proxy.cc:170-175これは単一消費者キューであり、proxy スレッドのみがアクセスするため、アトミック操作は不要。
Step-by-Step:コンテキスト作成から進捗推進まで
コンテキスト作成:ncclRmaProxyCreateContextまず RMA プラグインを介してネットワークコンテキストを作成する。📎 src/rma/rma_proxy.cc:229次にncclRmaProxyCtxAllocを呼び出してシグナル、シーケンス番号、リングバッファなどのリソースを割り当てる。📎 src/rma/rma_proxy.cc:231続いてncclRmaProxyCtxAllocGraphを呼び出してグラフキャプチャモードに必要なリソース——CPU アクセス可能なシグナル、flush バッファ、永続化キュー——を割り当てる。📎 src/rma/rma_proxy.cc:232
グラフキャプチャモードが存在するのは、CUDA Graph がすべての操作のリプレイを要求するためである。通常モードでは、シグナルは GPU メモリ上にあり、proxy は GDR を介して読み取る;グラフキャプチャモードでは、シグナルは CPU アクセス可能なメモリ上にあり、proxy が直接読み書きでき、GDR の不確実性を回避する。📎 src/rma/rma_proxy.cc:184-190
進捗スレッド:ncclRmaProxyProgressThreadは proxy のメインループである。📎 src/rma/rma_proxy.cc:354-389それはrmaProgress状態ワードに基づいて動作を決定する:
rmaProgress == 1:通常推進モード、すべての proxy コンテキストを走査してncclRmaProxyProgress。📎src/rma/rma_proxy.cc:361-372rmaProgress == 2を呼び出す📎src/rma/rma_proxy.cc:373-378rmaProgress == -1:一時停止モード、リソース回収に使用。スレッドは一時停止を確認した後、条件変数を待機する。📎src/rma/rma_proxy.cc:379-380rmaProgress == 0:終了シグナル、スレッドはリターンする。📎src/rma/rma_proxy.cc:381-382
:アイドル待機。ncclRmaProxyProgressもしasyncResultがエラーを返した場合、スレッドはエラーコードをrmaProgress = -2に書き込み、📎 src/rma/rma_proxy.cc:365-369を設定してから終了する。ncclCommGetAsyncErrorこのエラーコードはメインスレッドが後続の
呼び出しで読み取る。
並行制御とメモリオーダー
RMA proxy の並行モデルは「単一生産者-単一消費者」である:GPU カーネルが生産者、proxy スレッドが消費者。リングバッファの PI は GPU が更新し、CI は proxy が更新する。単一生産者単一消費者であるため、CAS 操作は不要で、正しいメモリオーダーのみが必要。NCCL_NET_MR_FLAG_FORCE_SOシグナル領域の強順序フラグ📎 src/rma/rma_proxy.cc:127が鍵である。
NCCL_NET_MR_FLAG_SIGNAL_NEVER_RESETこのフラグがなければ、ネットワークプラグインが put と signal の順序を並べ替え、受信側がデータ到達前にシグナルを認識し、ダーティデータを読み取る可能性がある。📎 src/rma/rma_proxy.cc:127フラグはネットワークプラグインに伝える:シグナルは一度書き込まれるとリセットされない。
これによりプラグインはシグナルの書き込みパスを最適化できる——毎回の書き込み前にゼロクリアする必要がない。
本番環境の落とし穴落とし穴1:キューサイズが2の冪でない。NCCL_RMA_PROXY_QUEUE_SIZEもしユーザーが📎 src/rma/rma_proxy.cc:156-159を介して2の冪でない値を設定した場合、コードはデフォルト値にフォールバックし、INFO ログを出力する。
このフォールバックはサイレント(INFO レベルのみ)であり、本番環境では見落とされやすい。ユーザーがバーストトラフィックを吸収するためにより大きなキューを期待していても、実際にはデフォルト値が使用され、バックプレッシャーが発生する可能性がある。 ncclRmaProxyRegMrSym落とし穴2:DMA-BUF 登録失敗のフォールバックチェーン。regMrSym。📎 src/rma/rma_proxy.cc:76-108CUDA メモリの登録には3層のフォールバックがある:まず DataDirect モードの DMA-BUF を試み、失敗したら非 DataDirect の DMA-BUF を試み、さらに失敗したら通常の📎 src/gin/gin_host_proxy.cc:429-430コメントで特に警告されている:ある MR が非 DataDirect パスに入った場合、他のすべての MR もそうしなければならず、混在使用は GIN の順序保証を破壊する。
この制約は RMA パスでは明示的にチェックされておらず、潜在的な危険性がある。落とし穴3:進捗スレッドのエラー伝播遅延。ncclRmaProxyProgressもしasyncResultがエラーを返した場合、スレッドは📎 src/rma/rma_proxy.cc:366-369ただし、メインスレッドは長時間実行されるカーネルを実行している可能性があり、すぐにはチェックしないasyncResult。この間、後続の RMA 操作はキューに入り続けるが処理されず、メインスレッドがエラーを検出するまで続く。これは非同期エラー伝播に固有の遅延であり、アプリケーションは定期的にncclCommGetAsyncErrorを呼び出してこのウィンドウを短縮する必要がある。
GIN アーキテクチャ:GPU が直接ネットワークリクエストを発行
直感的モデル
従来のモードでは、GPU がネットワークデータを送信するには「GPU → ホストメモリ → プロキシスレッド → ネットワークカード」の経路を経由する必要があった。GIN(GPU-Initiated Networking)の目標は、CPU がネットワークカードの MMIO レジスタに直接書き込むように、GPU がネットワークカードの送信キューに直接書き込めるようにすることである。これには、ネットワークカードが GPU 発行の doorbell 書き込みをサポートすること、および GPU とプロキシスレッド間の通信プロトコルが必要である。
データ構造とメモリレイアウト
GIN の核心的なデータ構造はginProxyHostGpuCtxであり、これは GPU-ホスト通信コンテキストを表す:
| フィールド | 型 | 意味 |
|---|---|---|
queues | ncclGinProxyGfd_t* | GFD キュー、サイズnRanks * queueSize |
pis | uint32_t* | プロデューサーインデックス(GPU が書き込み) |
cis | uint32_t* | コンシューマーインデックス(プロキシが書き込み) |
cisShadow | uint32_t* | CI のシャドウコピー(プロキシローカル) |
sis | uint32_t* | 既見インデックス(プロキシローカル) |
states | ginProxyGfdState* | 各 GFD スロットの状態 |
inlines | uint64_t* | インラインデータバッファ |
GFD(GIN Forwarding Descriptor)は GPU がプロキシに書き込むリクエスト記述子である。各 GFD は複数の qword で構成され、操作タイプ、ソースアドレス、宛先アドレス、サイズ、シグナル情報などを含む。📎 src/gin/gin_host_proxy.cc:158-163
queues配列のメモリ割り当てには重要な詳細がある:それはallocMemCPUAccessibleによって割り当てられるが、forceHost=trueパラメータが渡される。📎 src/gin/gin_host_proxy.cc:564これは、キュー自体がホストメモリにあり、GPU が PCIe 経由で書き込むことを意味する。一方、cis配列は GPU アクセス可能メモリ(おそらく GDR)に割り当てられる。プロキシが頻繁に更新する必要があるためである。📎 src/gin/gin_host_proxy.cc:565-566
cisShadowとsisはプロキシスレッドのローカルコピーであり、GPU メモリに存在する可能性があるcis。📎 src/gin/gin_host_proxy.cc:44-47を毎回読み取ることを避ける。cisShadowが前進した場合にのみ、cis。
Step-by-Step:GFD のポーリングと処理
ncclGinProxyProgressは GIN プロキシのメインループである。📎 src/gin/gin_host_proxy.cc:648-669
第一步:各コンテキストに対して、まずproxyGinPollCompletionsを呼び出して、送信済みリクエストの完了状態をチェックする。📎 src/gin/gin_host_proxy.cc:653
第二步:各ターゲットランクに対して、GFD をバッチポーリングする。pollBatchは一度に処理する GFD の最大数を制御する。📎 src/gin/gin_host_proxy.cc:654-655
第三步:proxyGinPollGfdキューの先頭に新しい GFD があるかチェックする。判断基準は GFD ヘッダのフラグビットが非ゼロかどうかである。📎 src/gin/gin_host_proxy.cc:176-182もしあれば、まず最初の qword(ヘッダ)をコピーし、残りの qword が準備完了するのを待つ。📎 src/gin/gin_host_proxy.cc:194-202コピー完了後、キュー内の GFD をゼロクリアして、重複処理を防ぐ。📎 src/gin/gin_host_proxy.cc:206-208
第四步:proxyGinProcessGfd操作タイプに応じて異なる処理パスにディスパッチする。📎 src/gin/gin_host_proxy.cc:246-340
flowchart TD
poll_start["proxyGinPollGfd(ctx, hostGpuCtx, targetRank)"]
check_avail{"isGfdAvailable?"}
no_gfd["返回 0, 跳出批量循环"]
copy_header["拷贝 GFD header qword"]
copy_rest["循环等待并拷贝其余 qword"]
reset_gfd["清零队列中的 GFD"]
set_state["设置 state->op, counterId, done=0"]
inc_sis["sis[targetRank]++"]
process["proxyGinProcessGfd(ctx, hostGpuCtx, targetRank, gfd, state, isLastInBatch)"]
check_va{"op & ncclGinProxyOpVASignal?"}
check_get{"op & ncclGinProxyOpGet?"}
check_flush{"op & ncclGinProxyOpFlush?"}
check_inline{"op & ncclGinProxyOpWithInline?"}
va_signal["rmaBackend->iputSignal(...)"]
get_op["rmaBackend->iget(...)"]
flush_op["rmaBackend->iflush(...)"]
inline_src["从 inlines 缓冲区取源地址"]
normal_src["从 GFD 取源地址"]
put_signal["rmaBackend->iputSignal(...)"]
put_only["rmaBackend->iput(...)"]
poll_start --> check_avail
check_avail -->|否| no_gfd
check_avail -->|是| copy_header
copy_header --> copy_rest
copy_rest --> reset_gfd
reset_gfd --> set_state
set_state --> inc_sis
inc_sis --> process
process --> check_va
check_va -->|是| va_signal
check_va -->|否| check_get
check_get -->|是| get_op
check_get -->|否| check_flush
check_flush -->|是| flush_op
check_flush -->|否| check_inline
check_inline -->|是| inline_src
check_inline -->|否| normal_src
inline_src --> put_signal
normal_src --> put_signal
put_signal --> put_onlyポーリングとカウンタ更新の完了
proxyGinPollCompletionsは送信済みリクエストの完了状態をチェックする責務を負う。📎 src/gin/gin_host_proxy.cc:113-156
各ターゲットランクに対して、cisShadowからsisまで、既見だが未消費のすべての GFD 状態を走査する。📎 src/gin/gin_host_proxy.cc:117状態が未完了の場合、rmaBackend->testを呼び出してチェックする。📎 src/gin/gin_host_proxy.cc:122完了しており、操作にカウンタフラグが付いている場合、カウンタ値を更新する。📎 src/gin/gin_host_proxy.cc:132-141
カウンタ更新はアトミックロードとアトミックストアを使用するが、コメントではアトミック加算が不要な理由が説明されている:GPU カーネルは未完了操作がある状態でカウンタをリセットできないため、競合は存在しない。📎 src/gin/gin_host_proxy.cc:133-135
CI の更新には「ホールを許容する」メカニズムがある:state->done && i == cisShadow[targetRank]の場合にのみ CI を進める。📎 src/gin/gin_host_proxy.cc:145-151これにより CI が単調増加することが保証され、一部の GFD が先に完了しても、未完了の GFD をスキップしない。
並行制御とメモリバリア
GIN プロキシの並行モデルは RMA プロキシよりも複雑である。複数のプロキシスレッドが存在するためである(GIN_PROXY_NTHREADSによって制御される)。📎 src/gin/gin_host.cc:90
ncclGinProgressでは、各スレッドが接続のグループを担当する:スレッド t は接続 t, t+proxyNthreads, t+2*proxyNthreads, ... を処理する。📎 src/gin/gin_host.cc:72この割り当て方式により、各接続が1つのスレッドのみによって処理されることが保証され、接続レベルの競合を回避する。
devComms リンクリストの変更には書き込みロックによる保護が必要である。ginProgressWriteLockまずwritePendingフラグを設定し、次に書き込みロックを取得する。📎 src/gin/gin_host.cc:43-47進捗スレッドは各ループの開始時にwritePendingをチェックし、真であれば CPU を譲る。📎 src/gin/gin_host.cc:63-66この設計により、進捗スレッドが読み取りロックを保持している間に書き込みロックによってブロックされることを回避する。
writePendingを使用するが、コメントではこのロジックが単一の書き手のみを仮定していることが指摘されている。std::atomic<bool>NCCL の使用シナリオでは、メインスレッドのみが devComms リンクリストを変更するため、この仮定は成立する。📎 src/gin/gin_host.cc:43-47本番環境の落とし穴
落とし穴1:GFD キューのメモリ位置。
は強制的にホストメモリに割り当てられる( queuesこれは、GPU が GFD に書き込むには PCIe バスを経由する必要があることを意味する。GFD 書き込み頻度が高い場合(小メッセージシナリオ)、PCIe 帯域幅がボトルネックになる可能性がある。対照的に、forceHost=true),📎 src/gin/gin_host_proxy.cc:564は GPU アクセス可能メモリに割り当てられる。プロキシが頻繁に更新する必要があるためである。cis落とし穴2:インラインデータの再構築。📎 src/gin/gin_host_proxy.cc:565-566
GFD にインラインデータが含まれる場合、プロキシは複数の qword からインライン値を再構築する必要がある。 当 GFD 带有内联数据时,proxy 需要从多个 qword 中重建内联值。📎 src/gin/gin_host_proxy.cc:298-305再構築ロジックは size に基づいてどの qword を読み取るかを決定する:size ≤ 4 の場合は下位 32 ビットのみ読み取り、size > 4 の場合は下位 64 ビットを読み取り、size > 6 の場合はさらに上位 16 ビットを読み取る。この分割ロジックは GPU 側の書き込みロジックと厳密に対応している必要があり、不一致があるとデータ破損を引き起こす。
落とし穴 3:マルチスレッドの進捗と接続の割り当て。異なる rank が異なるGIN_PROXY_NTHREADSを設定した場合、AllGather で最小値を取った後、一部のスレッドに接続が割り当てられない可能性がある。📎 src/gin/gin_host.cc:181-183コメントによると、これらのスレッドは stride ループ内で空回りし、正確性の問題は発生しないが、CPU リソースを浪費する。
GIN バックエンドの選択とバージョン互換性
直感的モデル
GIN は複数のバックエンドをサポートする:Proxy(RMA プラグインベースのソフトウェアシミュレーション)、GDAKI(GPU Direct Async Kernel Initiated)、GPI(GPU-Initiated)、EFA GDA(AWS EFA の GPU Direct Async)。これは同じ API に複数の実装があり得るようなもの——ソフトウェアシミュレーション版は互換性が最も高いが性能は普通、ハードウェアオフロード版は性能が最も高いが特定の NIC サポートが必要。
バックエンドバージョンマトリクス
各バックエンドにはバージョン互換性配列があり、インデックスはバックエンドバージョン番号、値はそのバージョンが要求する最低 NCCL バージョン。📎 src/gin/gin_host.cc:27-33
| バックエンド | バージョン 0 | バージョン 1 | バージョン 2 | バージョン 3 |
|---|---|---|---|---|
| Proxy | 0 | 2.30.3 | 2.30.5 | 2.32.0 |
| GDAKI | 0 | 2.30.3 | 2.30.5 | - |
| GPI | 0 | 2.30.5 | - | - |
| EFA GDA | 0 | 2.31.0 | 2.32.0 | - |
バージョン選択ロジック:バージョン配列を走査し、要求バージョンが現在のデバイスコードバージョンより高い最初のエントリを見つけ、その前のバージョンが利用可能バージョンとなる。📎 src/gin/gin_host.cc:300-304
バックエンド選択フロー
ncclGinDevCommSetupすべてのアクティブなバックエンドを走査し、各バックエンドで DevComm の作成を試みる。📎 src/gin/gin_host.cc:427-442選択条件には以下が含まれる:要求された GIN タイプが一致する(または未指定)、シグナル能力が要件を満たす。📎 src/gin/gin_host.cc:430-435
ncclGinValidateSignalRequest2 つの能力をチェックする:強シグナル(supportsStrongSignals)と VA シグナル(supportsVASignals)。📎 src/gin/gin_host.cc:230-243リクエストが強シグナルを要求しているがバックエンドがサポートしていない場合、そのバックエンドをスキップする。
接続確立と stride 計算
ncclGinConnectOnceGIN 接続を確立する。📎 src/gin/gin_host.cc:92-228
接続タイプが stride を決定する:FULL モードでは stride は 1(すべての rank に接続)、RAIL モードでは stride はcontiguousRanksPerHost(同じ rail の rank のみに接続)。📎 src/gin/gin_host.cc:139-145
において、ginDevCommSetupWithBackendでの stride の検証ロジックは非常に厳格である:
- 要求された stride は 0 であってはならない。📎
src/gin/gin_host.cc:318-323 - 要求された stride は rail team の stride より大きくてはならない。📎
src/gin/gin_host.cc:324-330 - 要求された stride は接続済み stride の倍数でなければならない。📎
src/gin/gin_host.cc:331-337
これらの制約の動機は:階層バリアが GIN を少なくとも RAIL 接続と仮定していること。📎 src/gin/gin_host.cc:325stride がこれらの条件を満たさない場合、一部の rank 間の通信パスが存在しない可能性がある。
本番環境の落とし穴
落とし穴 1:バックエンドバージョンの不一致。デバイスコードバージョンがバックエンドの要求する最低バージョンより低い場合、backendVersionはより低い値に留まる。📎 src/gin/gin_host.cc:301-303これにより一部の新機能が利用不可になる可能性がある(例えばシグナルが永久にリセットされない)が、エラーにはならない。しかし、デバイスコードバージョンがすべての既知バージョンより高い場合、backendVersionは最大値を取り、未定義動作を引き起こす可能性がある。
落とし穴 2:stride 検証の境界。もしrequestedStride % connectedStride != 0なら、作成は失敗する。📎 src/gin/gin_host.cc:331-337このチェックは connectedStride が 2 の冪であると仮定している(FULL モードでは 1、RAIL モードではcontiguousRanksPerHost)。もしcontiguousRanksPerHostが 2 の冪でない場合(例えば 3)、倍数チェックが正当な stride を拒否する可能性がある。
本章の考察とセルフチェック
Q1:scheduleRmaTasksToPlanの WaitSignal 分岐において、plan->rmaArgs->nRmaTasks = (npeersCe > 0 ? 1 : 0) + (npeersProxy > 0 ? 1 : 0)の行を削除し、直接 1 に設定した場合、どのようなシナリオで問題が発生するか?
参考解析:📎 src/rma/rma.cc:248。nRmaTasksが記録するのは実際にエンキューされたタスク数である。すべての peer が LSA 到達可能(npeersProxy == 0)の場合、実際には 1 つの CE タスクのみがエンキューされ、nRmaTasksは 1 であるべき。すべての peer が到達不可(npeersCe == 0)の場合、実際には 1 つの Proxy タスクのみがエンキューされ、nRmaTasksも 1 であるべき。しかし peer が混在分布の場合、両方のタスクがエンキューされ、nRmaTasksは 2 であるべき。
この行をplan->rmaArgs->nRmaTasks = 1に変更した場合、混在分布シナリオでは、nRmaTasksは実際のタスク数を過小評価する。後続のncclRmaWaitSignalでの判断plan->rmaArgs->nRmaTasksProxy > 0 && plan->rmaArgs->nRmaTasksCe > 0は依然として正しく動作する(nRmaTasksProxyとnRmaTasksCe),📎 src/rma/rma.cc:47を使用しているため)が、nRmaTasksに依存してリソース見積もりやログ統計を行うコードは誤った結果を得る。さらに深刻なのは、後続のコードがnRmaTasksを使用して配列を割り当てたりループ回数を計算したりする場合、バッファオーバーフローやタスクの欠落を引き起こす可能性がある。
Q2:proxyGinPollGfdにおいて、hostGpuCtx->sis[targetRank]++をproxyGinProcessGfd呼び出しの後に移動した場合、どのような並行シナリオで GFD が重複処理されるか?
参考解析:📎 src/gin/gin_host_proxy.cc:228。sisは「既見インデックス」であり、proxy が既に確認して処理を開始した GFD の数を表す。proxyGinPollGfdは GFD のコピー完了後すぐにインクリメントされ、sisその後 1 を返して成功を示す。呼び出し元のncclGinProxyProgressはループ内でproxyGinPollGfdを呼び出し、1 が返れば次の GFD の処理を続ける。📎 src/gin/gin_host_proxy.cc:648-669
もしsis++をproxyGinProcessGfdの後に移動した場合、proxyGinProcessGfdの実行中(ネットワークプラグインの非同期呼び出しを含む可能性がある)に、sisは依然として現在の GFD を指している。このとき GPU が同じスロットに新しい GFD を書き込んだ場合(キューはリング状であるため、pisが既にラップアラウンドしている可能性がある)、proxyGinPollGfdはこのスロットを再び認識するが、sisは進んでいないため、同じスロットを重複処理することになる。
さらに危険なのは、proxyGinPollGfdGFD をコピーした後、キュー内の GFD はクリアされます。📎 src/gin/gin_host_proxy.cc:206-208もしsisが進まない場合、次のポーリングでクリア後の GFD(flag が 0)が見え、isGfdAvailablefalse を返し、GFD が失われます。これにより GPU 側が永遠に処理されないリクエストを待ち続け、最終的にデッドロックします。
Q3:ncclRmaProxyProgressThreadにおいて、もしrmaProgress == 2分岐でrmaProxyState->cond.notify_one()の呼び出しを忘れた場合、どのようなシナリオでメインスレッドが永久にブロックされますか?
参考解析:📎 src/rma/rma_proxy.cc:373-378。rmaProgress == 2は「一時停止リクエスト」状態であり、リソース回収に使用されます。メインスレッドがrmaProgress = 2を設定した後、進捗スレッドが一時停止を確認するのを待ちます。進捗スレッドはcond.wait(lock)で待機しており、メインスレッドはcond.notify_one()を呼び出してそれを起こす必要があります。📎 src/rma/rma_proxy.cc:377
もし進捗スレッドがrmaProgress = 0を設定した後notify_one()を忘れた場合、メインスレッドは条件変数を永遠に待ち続けます。しかしさらに重要なのは、進捗スレッドがcond.wait(lock)で待機しているとき、メインスレッドはrmaProgress = 2を設定するためにまずロックを取得する必要があることです。もし進捗スレッドがwaitの前にロックを解放しなかった場合、メインスレッドはロックを取得できず、デッドロックが発生します。
正しい順序は:進捗スレッドがrmaProgress = 0を設定し、notify_one()を呼び出してメインスレッドを起こし、次にcond.wait(lock)を呼び出してロックを解放し待機します。メインスレッドが起こされた後ロックを取得し、rmaProgress = 2を設定し、notify_one()を呼び出して進捗スレッドを起こし、次に進捗スレッドの確認を待ちます。進捗スレッドが起こされた後、rmaProgress = 0を設定し、再度notify_one()を呼び出し、次にwaitを呼び出します。このハンドシェイクプロトコルにおいて、いずれかのステップでnotify_one()が欠けると永久ブロックが発生します。
RMA の put/get セマンティクスから GIN の GPU 発起ネットワーク通信まで、私たちは NCCL が汎用リモートメモリアクセスエンジンへと進化する重要な一歩を歩み終えました。しかし、どんなに精巧なメカニズムであっても、最終的にはプラグイン体系を通じて外部ネットワークバックエンド、チューニング戦略、パフォーマンスコレクタと接続する必要があります。次の章ではプラグインの世界に入り、NCCL がコアコードを変更することなく、net、tuner、profiler、env などの拡張を動的にロードする方法を見て、google-fastsocket と google-CoMMA を例にエコシステム拡張性の実装要点を明らかにします。
第 16 章:第 16 章:プラグインエコシステムと環境変数:net、tuner、profiler、env が NCCL の動作をどのように拡張するか
第 16 章:プラグインエコシステムと環境変数:net、tuner、profiler、env が NCCL の動作をどのように拡張するか
前の章では、NCCL が RMA と GIN を通じて通信能力を集合操作からポイントツーポイントのリモートアクセスに拡張し、さらには GPU が直接ネットワークリクエストを発起できるようにする方法を見ました。このような新ハードウェアと低遅延シナリオへの進化は、通信エンジンの柔軟性により高い要求を課します:新しいネットワーク、新しいチューニング戦略、新しい収集ツールを適応させるたびにコアコードを再コンパイルする必要があるなら、NCCL はエコシステムの変化に追いつくのが難しくなります。本章では src/plugin と plugins ディレクトリを分解し、一つの核心的な問いに答えます:NCCL はコアコードを再コンパイルすることなく、ネットワークバックエンド、チューニング戦略、パフォーマンスコレクタ、設定ソースをどのように置き換えるのか。
16.1 プラグインローダー:plugin_open.cc が .so をどのように使用可能なバックエンドに変えるか
直感的モデル
plugin_open.ccを NCCL の「採用エージェント」と想像してください:それは求人リスト(NET、GIN、RMA、TUNER、PROFILER、ENV)を持ち、各求人は候補ライブラリ名に対応します。NCCL が特定の求人の人材を必要とするとき、エージェントは固定順序で人材市場(動的リンカ)に行き人を探し、見つかれば契約を結び(dlopen)、見つからなければ「この人は存在しない」と記録し、最後にハンドルを返します。このエージェント層がなければ、NCCL はネットワークバックエンドをバイナリにハードコードするしかなく、どの NIC ベンダーも接続するには NCCL ソースコードを変更する必要があります——これこそプラグイン体系が撲滅しようとする災難です。
データ構造とメモリレイアウト
ローダーの全状態は六つの並列配列であり、インデックスはプラグインタイプ列挙です:
static char* libNames[NUM_LIBS]; // 已加载库的名字
char* ncclPluginLibPaths[NUM_LIBS]; // 库的绝对路径
static void* libHandles[NUM_LIBS]; // dlopen 返回的句柄
static const char* pluginNames[NUM_LIBS]; // 日志用的人类可读名
static const char* pluginPrefix[NUM_LIBS]; // 库名前缀
static const char* pluginFallback[NUM_LIBS]; // 找不到时的提示
static unsigned long subsys[NUM_LIBS]; // 日志子系统位掩码これら七つの配列の添字は厳密に整列する必要があり、pluginNames[type]、pluginPrefix[type]、subsys[type]は同じプラグインタイプを記述します。📎 src/plugin/plugin_open.cc:18-29はNUM_LIBS = 6を定義し、タイプ順序は{"NET", "GIN", "RMA", "TUNER", "PROFILER", "ENV"}、プレフィックスは{"libnccl-net", "libnccl-gin", "libnccl-rma", "libnccl-tuner", "libnccl-profiler", "libnccl-env"}。
ここで構造体配列ではなく並列配列を使用するのは、openPluginLibという単一関数が六種類のプラグインを同時にサービスできるようにするためです——タイプは単なる添字であり、ロジックは完全に再利用されます。代償は、新しいプラグインタイプを追加する際に六つの配列を同期的に変更する必要があり、コンパイラが変更漏れをチェックできないことです。
subsys配列はログの帰属を決定します:NET/GIN/RMA はすべてNCCL_INIT | NCCL_NETに属し、TUNER はNCCL_INIT | NCCL_TUNINGに属し、PROFILER はNCCL_INITのみに属し、ENV はNCCL_INIT | NCCL_ENV。📎 src/plugin/plugin_open.cc:26-29に属します。これによりNCCL_DEBUG_SUBSYS=NET時にネットワークプラグインのログのみが見え、チューニングログに埋もれることがありません。
Step-by-Step Walkthrough:一度のncclOpenNetPluginLib("mlx5")の完全な旅
ユーザーがNCCL_NET_PLUGIN=mlx5を設定し、NCCL 初期化時にncclOpenNetPluginLib("mlx5")を呼び出すと仮定します。それは直接openPluginLib(ncclPluginTypeNet, "mlx5")。📎 src/plugin/plugin_open.cc:132-134
に転送されます。第一步:候補ライブラリ名を構築。libName空でないsnprintf(libName_, MAX_STR_LEN, "%s", libName)が渡されたため、libName_分岐を通り、"mlx5"。📎 src/plugin/plugin_open.cc:85-89は.soになります。この時点ではまだ合法的なライブラリファイル名ではないことに注意——プレフィックスも
サフィックスもありません。 tryOpenLib("mlx5", ...)第二步:最初のオープン試行。📎 src/plugin/plugin_open.cc:91が呼び出されます。tryOpenLibに入った後、まずnameが空か長さゼロかをチェックし、次に特別な分岐があります:名前がSTATIC_PLUGINで始まる場合、nameをnullptr。📎 src/plugin/plugin_open.cc:37-39これは NCCL に静的リンクされたプラグイン用のセンチネルです——dlopen(nullptr)Linux 上ではメインプログラムのハンドルを返し、それによってdlsymがメインプログラムのシンボルテーブルからプラグインシンボルを見つけられるようにします。
次にncclOsDlopen(name)。📎 src/plugin/plugin_open.cc:41を呼び出します。"mlx5"はパスでも有効なライブラリ名でもないため、dlopenは失敗します。失敗後、コードはncclOsDlerror()のエラー文字列を取得し、精密な判定を行います:エラー文字列にnameと"No such file or directory"の両方が含まれている場合、*errをENOENT。📎 src/plugin/plugin_open.cc:42-55に設定します。この判定の意義は「ファイルが根本的に存在しない」と「ファイルは存在するがロードに失敗した」を区別することです——前者は単に候補名が間違っているだけなので、静かに次の候補名を試すべきです;後者は実際のエラーなので、ログを出力すべきです。
第三步:最初の失敗後の処理。に戻り、openPluginLib,libHandles[type]が空で、かつopenErr == ENOENTであるため、"mlx5"をeNoEntNameList。📎 src/plugin/plugin_open.cc:97-101に追加します。このリストは最終的に「Could not find: mlx5 libnccl-net-mlx5.so」というログに組み立てられます。
第四步:二回目の試行——プレフィックスを付ける。コードはlibNameがパスでなく(/を含まない)、ライブラリ名でもない(libで始まらず、.soで終わらない)かどうかをチェックします。📎 src/plugin/plugin_open.cc:105-107 "mlx5"条件を満たすため、"libnccl-net-mlx5.so"を組み立てて再度試行します。📎 src/plugin/plugin_open.cc:108今回はdlopenが成功し、libHandles[type]が代入され、libNames[type]がライブラリ名を記録し、ncclPluginLibPaths[type]を通じてgetLibPath絶対パスを取得し、関数はハンドルを返します。📎 src/plugin/plugin_open.cc:110-115
第五步:絶対パスを取得する。 getLibPathLinux 上でdlinfo(handle, RTLD_DI_LINKMAP, &lm)を使ってlink_mapを取り出し、さらにstrdup(lm->l_name)。📎 src/plugin/plugin_open.cc:65-69します。このパスは以降のすべてのログに現れ、ユーザーがどのファイルがロードされたかを一目で分かるようにします——本番環境で「なぜ間違ったプラグインがロードされたか」を調査する際、このログ行が第一現場です。
全体の決定フローは以下の通りです:
flowchart TD
start["openPluginLib(type, libName)"] --> build{"libName 非空?"}
build -->|是| use_name["libName_ = libName"]
build -->|否| use_prefix["libName_ = pluginPrefix[type] + .so"]
use_name --> try1["tryOpenLib(libName_)"]
use_prefix --> try1
try1 --> ok1{"handle 非空?"}
ok1 -->|是| success["记录 libNames/libPaths, 返回 handle"]
ok1 -->|否| enoent{"openErr == ENOENT?"}
enoent -->|是| append1["appendNameToList(eNoEntNameList)"]
enoent -->|否| log1["INFO 打印 dlopen 错误"]
append1 --> shape{"非路径且非库名?"}
log1 --> shape
shape -->|是| try2["tryOpenLib(prefix-libName.so)"]
shape -->|否| report["打印 Could not find 列表"]
try2 --> ok2{"handle 非空?"}
ok2 -->|是| success
ok2 -->|否| report
report --> retnull["返回 nullptr"]設計上の考察と本番環境での落とし穴
候補名の順序がすなわち優先度です。まずユーザーが指定した裸の名前を試し、次にプレフィックスを付けた名前を試します。これは、カレントディレクトリにたまたまmlx5という名前のファイルがある場合、それが優先的にロードされることを意味します——これは潜在的なセキュリティ面であり、本番環境ではLD_LIBRARY_PATHにプラグインと同名の実行ファイルを置くことを避けるべきです。
STATIC_PLUGINのセマンティクス。のとき、NCCL_NET_PLUGIN=STATIC_PLUGINは名前を空にし、tryOpenLibメインプログラムを開き、dlopen(nullptr)メインプログラムのシンボルテーブルからdlsymなどのシンボルを探します。ncclNet_v12これによりプラグインを NCCL バイナリに静的リンクでき、📎 src/plugin/plugin_open.cc:37-39のデプロイの手間を省けますが、代償として実行時の差し替え能力を失います。.so参照カウントとアンロード。
は ncclClosePluginLibのときのみ実際にlibHandles[type] == handleを行い、パスと名前をクリアします。dlcloseこの等値判定は、すでに差し替えられたハンドルを誤って閉じることを防ぎます。GIN と RMA プラグインは📎 src/plugin/plugin_open.cc:176-186を通じて NET ライブラリのハンドルを再利用し、その実現方法は同じライブラリ名を再度ncclGetGinPluginLib/ncclGetNetPluginLibして参照カウントを増やすことです。dlopenこれは📎 src/plugin/plugin_open.cc:156-164の参照カウントセマンティクスです——同じライブラリが二回開かれた場合、実際にアンロードするにはdlopenを二回行う必要があります。dlclose16.2 net.cc:ネットワークプラグインのステートマシンとライフサイクル
直感的モデル
はネットワークプラグインの「スケジューリングセンター」です。それはプラグインライブラリの配列を維持し、各ライブラリは独自の状態(未ロード、ロード失敗、ロード待ち、初期化待ち、有効化済み)を持ちます。新しい通信ドメイン(communicator)が誕生すると、スケジューリングセンターはすべての候補プラグインを走査し、一つずつ初期化を試み、最初に成功したものがその通信ドメインに「割り当て」られ、残りの外部プラグインはすべて無効化されます。このステートマシン層がなければ、NCCL は「プラグインはロードされたがデバイスが利用不可」「複数のプラグインが共存する場合どれを選ぶか」「通信ドメイン破棄時に安全にアンロードする方法」といった現実的な問題を処理できません。
net.ccデータ構造とメモリレイアウト
核心となる構造は
フィールドnetPluginLib_t:
| 型 | 意味 | プラグインライブラリ名 |
|---|---|---|
name | char[255] | dlopen ハンドル |
dlHandle | void* | ネットワーク関数テーブル |
ncclNet | ncclNet_t* | ネットワーク API バージョン番号 |
ncclNetVer | int | 集合通信オフロード関数テーブル |
ncclCollNet | ncclCollNet_t* | 列挙型 |
ncclNetPluginState | ネットワークプラグイン状態 | 列挙型 |
ncclCollNetPluginState | CollNet プラグイン状態 | 参照カウント |
ncclNetPluginRefCount | int | 物理/仮想デバイス数 |
netPhysDevs/netVirtDevs | int | CollNet デバイス数 |
collNetPhysDevs/collNetVirtDevs | int | がこれらのフィールドを定義しています。注意すべきは、 |
📎 src/plugin/net.cc:63-76とncclNetは別々の二つの関数テーブルであり、状態も別々の二つの列挙型であることです——一つのプラグインがネットワーク機能を提供しても CollNet オフロードを提供しない場合があります。ncclCollNet状態列挙型には五つの値があります:
(初期化失敗)、Disabled = -2(ロード失敗)、LoadFailed = -1(ロード待ち)、LoadReady = 0(ロード済み初期化待ち)、InitReady = 1(有効化済み)。Enabled = 2は負数で失敗状態を表し、「状態 >= InitReady」のような比較が自然に「少なくともロード済み」を表現できるようにします。📎 src/plugin/net.cc:54-60グローバル状態は三つの変数です:
はプラグイン総数を記録し、pluginCountはプラグイン配列であり、netPluginLibs[NCCL_NET_MAX_PLUGINS]は並行アクセスを保護し、netPluginMutexは初期化が一度だけ行われることを保証します。initPluginLibsOnceFlagStep-by-Step Walkthrough:ある📎 src/plugin/net.cc:78-81
の完全な旅ncclNetInit(comm)第一步:一回限りの初期化。
はプラグインリストが一度だけ構築されることを保証します。 std::call_once(initPluginLibsOnceFlag, initPluginLibsOnceFunc)は📎 src/plugin/net.cc:360 initPluginLibsOnceFunc環境変数を読み取り、設定されていなければデフォルトでNCCL_NET_PLUGINを追加し、その後二つの組み込みプラグイン"libnccl-net.so"とncclNetIbを登録します。環境変数の解析はncclNetSocket。📎 src/plugin/net.cc:288-340
でカンマ区切りし、複数のプラグイン名をサポートします。strtok_rには容量チェックがあります:外部プラグインの数は📎 src/plugin/net.cc:303-324を超えることはできず、超過分は無視されログに記録されます。NCCL_NET_MAX_PLUGINS - NCCL_NET_NUM_INTERNAL_PLUGINS組み込みプラグインは固定で 2 つ(IB と Socket)なので、外部プラグインは最大📎 src/plugin/net.cc:307-311個です。NCCL_NET_MAX_PLUGINS - 2第二步:ロックして走査。
は走査プロセス全体を保護します。 std::lock_guard<std::mutex> lock(netPluginMutex)各プラグインインデックスについて、まずそれが外部プラグインであり📎 src/plugin/net.cc:361状態にあるかどうかを判定し、そうであればLoadReadyを呼び出します。ncclNetPluginLoad。📎 src/plugin/net.cc:364-367
第三步:プラグインをロード。 ncclNetPluginLoadはncclOpenNetPluginLibを呼び出してハンドルを取得し、その後高バージョンから低バージョンへ順にgetNcclNet_v12からgetNcclNet_v6まで試行し、最初に非空を返したバージョンが採用されます。📎 src/plugin/net.cc:103-112バージョン配列ncclNetVersionと関数ポインタ配列getNcclNetは降順に並べられ、最新 API が優先的に使用されることを保証します。📎 src/plugin/net.cc:41-43
すべてのバージョンでncclNetが取得できない場合、そのライブラリは正当なネットワークプラグインではないことを示します。このときNCCL_NET_PLUGINが明示的に設定されているかチェックします:設定されている場合はATTNレベルで警告します(ユーザーが明確に要求したのに失敗したため);設定されていない場合はINFOレベル(単なるデフォルトの試行失敗)。📎 src/plugin/net.cc:115-125この区別は重要です——ユーザーが明示的に設定した失敗は必ず見せる必要があります。
第四步:プラグインを初期化する。に戻り、ncclNetInitに対して、状態が>= InitReadyかつ名前が一致するcomm->config.netNameのプラグインのncclNetPluginInit。📎 src/plugin/net.cc:369-372 ncclNetPluginInitを呼び出して二つのことを行う:プラグインのinit関数を呼び出して通信ドメインコンテキストを確立し、初回初期化時にdevicesを呼び出してデバイス数を検出する。📎 src/plugin/net.cc:186-236
注意initの呼び出し条件:pluginLib->ncclNetPluginState >= ncclNetPluginStateInitReady。📎 src/plugin/net.cc:190コメントには「新しい通信ドメインごとに init を呼び出して正しいコンテキストを設定する必要がある」と明記されている。📎 src/plugin/net.cc:189しかしデバイス検出は== InitReady時に一度だけ行われる。📎 src/plugin/net.cc:201この「init は毎回呼び出し、devices は一度だけ呼び出し」という区別はパフォーマンス最適化である——デバイス検出は遅い可能性があるが、コンテキストは通信ドメインごとに独立している必要がある。
第五步:割り当てと無効化。初期化成功後にncclNetPluginAssignToCommを呼び出し、これはプラグインのncclNetをcomm->ncclNetに割り当て、参照カウントをインクリメントし、comm->netPluginIndex。📎 src/plugin/net.cc:238-255を設定する。割り当て成功後すぐにncclNetPluginDisableOtherExternalを呼び出して他のすべての外部プラグインを無効化する。📎 src/plugin/net.cc:377-380
無効化ロジックには重要な判断がある:割り当てられたプラグインが外部プラグイン(pluginIndex >= pluginCount - NCCL_NET_NUM_INTERNAL_PLUGINS)である場合にのみ、他の外部プラグインを無効化する。📎 src/plugin/net.cc:257-259割り当てられたのが組み込み IB プラグインの場合、外部プラグインはそのまま維持される——これにより後続の通信ドメインに選択の余地が残される。
flowchart TD
init["ncclNetInit(comm)"] --> once["call_once(initPluginLibsOnceFunc)"]
once --> lock["lock(netPluginMutex)"]
lock --> loop{"遍历 pluginIndex"}
loop -->|外部且 LoadReady| load["ncclNetPluginLoad()"]
loop -->|状态 >= InitReady| namechk{"netName 匹配?"}
load --> namechk
namechk -->|否| loop
namechk -->|是| plugininit["ncclNetPluginInit()"]
plugininit --> enabled{"状态 == Enabled?"}
enabled -->|否| loop
enabled -->|是| assign["ncclNetPluginAssignToComm()"]
assign --> assigned{"isAssigned?"}
assigned -->|否| finalize["ncclNetPluginFinalize()"]
finalize --> loop
assigned -->|是| disable["ncclNetPluginDisableOtherExternal()"]
disable --> ok["返回 ncclSuccess"]
loop -->|遍历结束| fail["WARN 无可用插件, 返回 ncclInvalidUsage"]並行制御とハードウェア相互作用
netPluginMutexすべてのnetPluginLibsへの読み書きを保護する。ncclNetInit、ncclNetFinalizeすべてロックを取得する。📎 src/plugin/net.cc:361📎 src/plugin/net.cc:411-416しかしncclNetGetDevCountなどの関数のコメントには「ロックは不要、呼び出し元が既にncclTopoGetSystemのロック内にいるため」とある。📎 src/plugin/net.cc:418-429これは「ロックは上位層が保持する」という規約であり、ネストロックのオーバーヘッドを削減するが、代償として呼び出し元が規約を守る必要がある。
ncclGpuGdrSupportプラグインとハードウェアの直接的な相互作用を示す:2MB の GPU バッファを割り当て、プラグインのlisten/connect/acceptを通じてループバック接続を確立し、次にregMrを試みて GPU メモリを登録する。📎 src/plugin/net.cc:464-535登録が成功すれば、NIC が GPUDirect RDMA をサポートしていることを示す。この検出結果はgdrSupportMatrix[32]にキャッシュされ、CUDA デバイス番号でインデックスされる。📎 src/plugin/net.cc:478-480
注意gdrSupportMatrixはstaticのものであり、通信ドメイン間で共有される。📎 src/plugin/net.cc:478これは同一プロセス内の複数の通信ドメインが検出結果を再利用し、重複する高コストな検出を避けることを意味する。しかし配列サイズは 32 にハードコードされており、32 個を超える GPU を持つマシンでは範囲外アクセスが発生する——これは暗黙の上限仮定である。
本番環境の落とし穴ガイド
落とし穴一:プラグインのロードは成功したがデバイス数がゼロ。 ncclNetPluginInitチェックdevices(&ndev) != ncclSuccess || ndev <= 0で失敗分岐にジャンプする。📎 src/plugin/net.cc:202失敗後にfinalizeを呼び出して確立済みのコンテキストをクリーンアップし、デバイス数をNCCL_UNDEF_DEV_COUNTにリセットし、状態をDisabled。📎 src/plugin/net.cc:229-234に設定する。このクリーンアップを行わないと、後続の通信ドメインが「初期化済みだがデバイスなし」のプラグインを目にし、診断困難なエラーを引き起こす。
落とし穴二:initは成功したがdevicesが失敗。コードはinitCompletedフラグでinitの成功を追跡する。📎 src/plugin/net.cc:178-184📎 src/plugin/net.cc:198失敗分岐ではinitCompletedが真の場合にのみfinalize。📎 src/plugin/net.cc:230を呼び出す。これにより未初期化のコンテキストに対してfinalizeを呼び出すことを防ぐ——多くのプラグインのfinalizeは NULL ポインタをチェックしないため、誤って呼び出すとクラッシュする。
落とし穴三:通信ドメイン破棄時の参照カウント。 ncclNetPluginFinalizeまずプラグインのfinalizeを呼び出し、次に参照カウントをデクリメントし、最後に参照カウントがゼロになりかつ外部プラグインの場合にライブラリをアンロードする。📎 src/plugin/net.cc:342-355 ncclNetPluginUnloadチェックdlHandleが非 NULL かつ参照カウントがゼロの場合にのみ実際にdlclose。📎 src/plugin/net.cc:84-101アンロード後にフィールドをリセットするがnameは保持し、再ロード時に再利用できるようにする。📎 src/plugin/net.cc:84-101
16.3 tuner.cc と profiler.cc:戦略プラグインと観測プラグインの異なる契約
直感的モデル
Tuner プラグインは「ナビソフトのルート選好設定」のようなもの——車の運転方法は変えず、どの道を選ぶかだけを変える。Profiler プラグインは「ドライブレコーダー」のようなもの——運転には介入せず、何が起きたかを記録するだけである。両者の共通点はどちらも関数テーブルを通じて接続されることだが、違いは Tuner が「通信ドメインごとに一つのインスタンス」の軽量な戦略オブジェクトであるのに対し、Profiler は GPU が生成するイベントを非同期に消費するための独立したスレッドを必要とすることである。
tuner.cc:極めてシンプルなグローバルシングルトン
Tuner の状態は極めて単純:一つのミューテックス、一つの参照カウント、一つのライブラリハンドル、一つのシンボルポインタ、一つの状態変数。📎 src/plugin/tuner.cc:24-37プラグイン配列はなく、複数プラグインの共存もない——グローバルに一つの tuner のみ。
ncclTunerPluginLoadのロジックは「初回ロード、以降再利用」:状態がLoadSuccessの場合、直接シンボルをcomm->tunerに割り当てて参照カウントをインクリメントする。📎 src/plugin/tuner.cc:53-57そうでなければNCCL_TUNER_PLUGIN環境変数を読み取り、"none"の場合は直接失敗する。📎 src/plugin/tuner.cc:59-63
バージョン交渉は v6 から v2 に降順で、一つずつ試行する。📎 src/plugin/tuner.cc:75-87ここには v1 がないことに注意——tuner API は v2 から初めて安定した関数テーブル構造を持つ。
興味深い詳細:もしncclOpenTunerPluginLibが空を返した場合、コードはncclGetNetPluginLib(ncclPluginTypeTuner)。📎 src/plugin/tuner.cc:65-70を試みる。これは tuner が net プラグインライブラリにパッケージできることを意味する——これによりデプロイの複雑さが軽減され、一つの.soがネットワークとチューニング機能を同時に提供する。
profiler.cc:非同期イベント消費スレッド
Profiler は本章で最も複雑なプラグインである。なぜなら GPU が非同期に生成するイベントを処理する必要があるからである。核心構造はncclProfilerThread:
| フィールド | 型 | 役割 |
|---|---|---|
thread | std::thread | 消費スレッド |
mutex | std::mutex | キューを保護 |
cond | condition_variable | 新しい作業があると起床 |
condIterationInactive | condition_variable | イテレーション終了を待機 |
stop | int | 停止フラグ |
refCount | int | 通信ドメイン参照カウント |
cudaDev | int | バインドされた CUDA デバイス |
abortFlag | volatile uint32_t* | 中止フラグ |
iterationActive | bool | イテレーション中かどうか |
pending/pendingTail | 連結リスト | 保留中の作業 |
active/activeTail | 連結リスト | 処理中の作業 |
opStack/opPool | メモリプール | 作業オブジェクトの割り当て |
inflight/maxInflightSeen/maxInflight | size_t | バックプレッシャー観測 |
droppedOps | uint64_t | 割り当て失敗カウント |
📎 src/plugin/profiler.cc:38-69がこの構造を定義する。注意pendingとactiveは二つの独立した連結リストである:プロデューサはpendingに追加し、消費スレッドはロック内でpendingをactiveに連結し、その後ロック外でactive。📎 src/plugin/profiler.cc:56-59
iterationActiveを走査する。trueフラグは並行正確性の鍵である:消費スレッドはロック内でfalse通信ドメイン状態を破棄できる。📎 src/plugin/profiler.cc:52-55
Step-by-Step Walkthrough:1回の KernelCh イベントの生成と消費
第一步:ホスト側のエンキュー。カーネルプラン(kernel plan)が投入されると、ncclProfilerPostPlanWorkプラン内の集合タスクを走査し、各タスクで有効化されたncclProfileKernelChに対して、チャネル範囲ごとにprofilerPostWorkInternal。📎 src/plugin/profiler.cc:1315-1331
profilerPostWorkInternalを呼び出す。まずcomm->profiler.workCounter[channelId]をインクリメントし、次にprofilerEnqueueOp。📎 src/plugin/profiler.cc:1259-1266を呼び出す。コメントはこのインクリメントが「割り当てが失敗しても、呼び出しごとに必ず1回」でなければならないと強調しており、デバイスカーネルとの同期を保つ。📎 src/plugin/profiler.cc:1259-1266
第二步:ワークオブジェクトの割り当て。 profilerEnqueueOpロック内でメモリプールからncclProfilerWorkOpを割り当て、チャネル番号、ワークカウンタ、アクティブマスク、タスクイベントハンドル、通信ドメインコンテキストなどのフィールドを埋める。📎 src/plugin/profiler.cc:1199-1223割り当て失敗時はdroppedOpsをインクリメントしてログを記録するが、ロールバックしないworkCounter——これがデバイスとの同期を保つ鍵である。📎 src/plugin/profiler.cc:1202-1207
割り当て成功後はオブジェクトをpendingリンクリストの末尾に追加し、inflightをインクリメントし、maxInflightSeenを更新し、消費スレッドを起床させる。📎 src/plugin/profiler.cc:1225-1239
第三步:消費スレッドの待機。 ncclProfilerThreadFuncループでwaitForAction。📎 src/plugin/profiler.cc:1074-1077 waitForActionを呼び出し、ロック内で条件変数を待機する。pendingまたはactiveが非空になるか、停止/中止シグナルを受信するまで。📎 src/plugin/profiler.cc:1017-1031
起床後はappendWorkToActiveQueueを呼び出してpendingをactiveの末尾に連結し、iterationActive = trueを設定してNCCL_PROFILER_THREAD_PROGRESS。📎 src/plugin/profiler.cc:1017-1031
を返す。第四步:ワークの処理。 profilerProgressOpsロック外でactiveリンクリストを走査する。📎 src/plugin/profiler.cc:958-999各ワークオブジェクトについて、デバイスが起動タイムスタンプを書き込んだかどうかを確認する:wc <= op->workStarted[ch].data[slot].counter。📎 src/plugin/profiler.cc:972ここで<=ではなく==を使用していることに注意。デバイスはMAX_PROFILER_EVENTS_PER_CHANNEL個のスロットをラップアラウンドするため、ホストが遅れるとデバイスがそのスロットを上書きしている可能性がある。📎 src/plugin/profiler.cc:969-971
起動条件が満たされれば、ncclProfilerStartKernelChEventを呼び出してプラグインに通知する。📎 src/plugin/profiler.cc:973次に完了条件を確認し、満たされていればまずフェーズイベントを発火し、その後ncclProfilerStopKernelChEvent。📎 src/plugin/profiler.cc:978-985
を呼び出す。完了したワークオブジェクトはリンクリストから取り出され、recycledリストに収集される。📎 src/plugin/profiler.cc:987-991
第五步:回収と公開。 cleanupAndStopロック内でrecycledリストを回収し、新しいactiveTailを公開し、iterationActiveをクリアして待機者に通知する。📎 src/plugin/profiler.cc:1036-1050
sequenceDiagram
participant Host as 主机线程
participant PT as Profiler 线程
participant Plugin as Profiler 插件
participant Dev as GPU 内核
Host->>Host: profilerPostWorkInternal() 递增 workCounter
Host->>PT: profilerEnqueueOp() 追加到 pending
Host->>PT: cond.notify_one()
PT->>PT: waitForAction() 返回 PROGRESS
PT->>PT: appendWorkToActiveQueue() 拼接 pending 到 active
Dev->>Dev: 内核写入 workStarted/workCompleted 时间戳
PT->>PT: profilerProgressOps() 检查 wc <= counter
PT->>Plugin: startEvent(ncclProfileKernelCh)
PT->>Plugin: recordEventState(ncclProfilerKernelChStop)
PT->>Plugin: stopEvent()
PT->>PT: cleanupAndStop() 回收对象, 清除 iterationActive並行制御とバックプレッシャ
NCCL_PROFILER_DEFAULT_MAX_INFLIGHTはMAXCHANNELS * MAX_PROFILER_EVENTS_PER_CHANNEL * 4。📎 src/plugin/profiler.cc:32-32と定義される。これは「ソフト上限」であり——超過してもエンキューは阻止されず、ログが記録されるだけである。📎 src/plugin/profiler.cc:1233-1238コメントは、KernelCh イベントをその親タスクイベントとペアにするためにエンキューを維持すると説明している。📎 src/plugin/profiler.cc:32-32
ログは2の冪でトリガーされる:(pt->inflight & (pt->inflight - 1)) == 0。📎 src/plugin/profiler.cc:1233これにより inflight が 1、2、4、8... のときのみログが記録され、ログの氾濫を避ける。
消費スレッドのバックオフ戦略はupdateProgressIntervalにある:進展があれば即座にリトライし、進展がなければ1マイクロ秒から倍々に増やし、上限は10マイクロ秒。📎 src/plugin/profiler.cc:1054-1057この設計はレイテンシと CPU 使用率のバランスを取っている。
本番運用の落とし穴ガイド
落とし穴1:破棄時のワークリーク。 ncclProfilerThreadDestroyまずiterationActiveが偽になるのを待ち、次にprofilerPurgeByContextを呼び出して、その通信ドメインコンテキストを参照するすべての保留中ワークをクリアする。📎 src/plugin/profiler.cc:1162-1169このクリアを行わないと、プラグインコールバックが破棄済みのコンテキストポインタを受け取り、use-after-free を引き起こす。
落とし穴2:停止時のドレイン。停止シグナルを受信したがactiveが非空の場合、NCCL_PROFILER_THREAD_CLEANUP_AND_STOP,cleanupAndStopを返す。drainStuckのパラメータが真であり、残りのすべてのワークを直接回収する。📎 src/plugin/profiler.cc:1029📎 src/plugin/profiler.cc:1036-1050コメントによれば、これらのワークのカーネルは決して実行されないため、直接破棄する。📎 src/plugin/profiler.cc:1034-1035
落とし穴3:CUDA デバイスバインディング。消費スレッドの起動時にcudaSetDevice(pt->cudaDev)。📎 src/plugin/profiler.cc:1054-1057を呼び出す。コメントの説明:スレッド自体はホストの固定メモリのみを読み取るが、プラグインがコンテキスト依存のドライバ呼び出しを行う可能性があるため、防御的にバインディングする。📎 src/plugin/profiler.cc:1054-1057バインディング失敗時はログのみ記録し中止しない。スレッド自体は CUDA に依存しないためである。📎 src/plugin/profiler.cc:1065-1070
16.4 公式サンプル:google-fastsocket と google-CoMMA の実装ポイント
直感モデル
公式サンプルはプラグイン API の「リファレンス実装」である。google-fastsocketユーザー空間ネットワークスタックでカーネル TCP を置き換える方法を示す;google-CoMMA通信性能を収集する profiler プラグインの実装方法を示す。これらの存在は、プラグイン API が実際の要件を十分に表現できることを証明している。
google-fastsocket:ネットワークバックエンドの置き換え
FastSocket は Google がオープンソース化したユーザー空間ネットワークスタックであり、AF_FABRICアドレスファミリを通じてカーネル TCP/IP スタックをバイパスする。NCCL net プラグインとして、ncclNet_tのすべての関数を実装する必要がある:init、devices、getProperties、listen、connect、accept、regMr、isend、irecv、test、closeSendなど。
重要な実装ポイントはgetPropertiesが返すptrSupportである:FastSocket が GPUDirect RDMA をサポートする場合はNCCL_PTR_HOST|NCCL_PTR_CUDAに設定すべき;そうでなければNCCL_PTR_HOSTにしか設定できず、NCCL は送信前に GPU データをホストメモリにコピーする。📎 plugins/net/README.md:245-245
connectとacceptの「非ブロッキング」契約はプラグイン実装の核心的な難点である:これらは即座に戻り、sendComm/recvCommをNULLに設定し、NCCL が成功するまで繰り返し呼び出すようにしなければならない。📎 plugins/net/README.md:299-311これにはプラグイン内部で接続状態マシンを維持し、時間のかかるハンドシェイクをバックグラウンドに置くことが要求される。
google-CoMMA:profiler プラグインの実装
CoMMA(Collective Memory Monitoring Agent)は Google の通信性能コレクタである。profiler プラグインとして、ncclProfiler_t関数テーブルを実装する:init、finalize、startEvent、stopEvent、recordEventState。
initはncclProfilerEventMaskポインタを受け取り、プラグインはこのマスクに書き込むことでどのイベントを購読するかを選択する。📎 src/plugin/profiler.cc:341NCCL がサポートするイベントタイプには Group、Coll、P2p、ProxyOp、ProxyStep、ProxyCtrl、KernelCh、KernelPhase、NetPlugin などがある。📎 src/plugin/profiler.cc:285-307
startEventはイベントハンドルを返し、後続のstopEventとrecordEventStateがこのハンドルでイベントを関連付ける。📎 src/plugin/profiler.cc:392📎 src/plugin/profiler.cc:400-407プラグインはハンドルを使って自身の状態を保存し、イベントのペアリングと所要時間の統計を実装できる。
設計上の考察
なぜ net プラグインにはバージョン交渉があり、tuner/profiler にはないのか?net API はデバイス側のコード(ncclNetDeviceHandle)に関わるため、バージョンの不一致はカーネルクラッシュを引き起こします。一方、tuner/profiler は純粋にホスト側であるため、バージョンの不一致はせいぜい機能の欠落にとどまります。📎 src/plugin/net.cc:153-176は以下を示しています:ncclNetCheckDeviceVersionデバイスタイプとバージョンを確認する方法、不一致の場合はncclInternalError。
なぜ profiler には独立したスレッドが必要なのか?profiler コールバックはブロックする可能性があるためです(例えばファイル書き込みやネットワークリクエストの送信)。ホストスレッドで呼び出すと通信が遅くなります。📎 src/plugin/profiler.cc:950-952コメントには「プラグインコールバックはブロックする可能性があるため、ロックを保持したまま呼び出してはならない」と明記されています。
16.5 本番環境の落とし穴回避ガイドと障害復旧チェーン
落とし穴1:プラグインバージョンの不一致によるカーネルクラッシュ
ncclNetCheckDeviceVersion以下を確認します:props.netDeviceTypeおよびprops.netDeviceVersion。📎 src/plugin/net.cc:153-176プラグインが報告するNCCL_NET_DEVICE_UNPACKバージョンが NCCL コンパイル時のNCCL_NET_DEVICE_UNPACK_VERSIONと一致しない場合、ncclInternalErrorを返して警告を発します。📎 src/plugin/net.cc:153-176このチェックはncclNetPluginAssignToComm内で呼び出され、失敗した場合プラグインは通信ドメインに割り当てられません。📎 src/plugin/net.cc:241
復旧チェーン:バージョン不一致 →ncclNetCheckDeviceVersionがエラーを返す →ncclNetPluginAssignToCommがisAssigned = false → ncclNetInitを返して次のプラグインを試行 → 最終的に組み込み Socket プラグインにフォールバックする可能性があります。
落とし穴2:profiler スレッドが終了できない
profiler プラグインがstopEvent内でブロックすると、消費スレッドはprofilerProgressOps内でスタックし、iterationActiveが永遠に真となり、ncclProfilerThreadDestroyが永久に待機します。📎 src/plugin/profiler.cc:1166これは実際のデッドロックリスクです。
復旧チェーン:comm->abortFlagが設定される →waitForActionが中止を検出 →CLEANUP_AND_STOP → cleanupAndStopを返してキューを排出。📎 src/plugin/profiler.cc:1017-1031しかしスレッドがすでにプラグインコールバック内でスタックしている場合、中止フラグはそれを中断できません——これはプラグイン実装者の責任であり、コールバックにはタイムアウトが必要です。
落とし穴3:tuner プラグインの参照カウントリーク
ncclTunerPluginLoad成功時にtunerPluginRefCount。📎 src/plugin/tuner.cc:98 ncclTunerPluginUnloadをインクリメントし、comm->tunerPluginLoadedが真のときにデクリメントします。📎 src/plugin/tuner.cc:111-123ある通信ドメインが tuner をロードしたが、破棄時にtunerPluginLoadedが予期せずゼロクリアされた場合、参照カウントは永遠にゼロにならず、プラグインライブラリは永遠にアンロードされません。
本章の考察とセルフチェック
Q1: もしncclNetPluginLoad内の「高バージョンから低バージョンへ試行する」ループを「最高バージョンのみ試行する」に変更した場合、どのようなシナリオで本来利用可能なプラグインがロードできなくなりますか?
参考解析:以下を参照してください:📎 src/plugin/net.cc:108-112。ループはNCCL_NET_VERSION_COUNT個のバージョンを v12 から v6 まで走査し、最初に非 null を返したものが採用されます。v12 のみを試行した場合、v11 のみを実装した古いプラグインはロードに失敗します。
この設計は後方互換性のためです:NCCL コアが v12 をサポートするようにアップグレードされた後も、v11 のみを提供するプラグインをロードできます。プラグイン作者は複数バージョンのシンボルを提供することが推奨されています(📎 plugins/net/README.md:35-37参照)。これにより同じ.soが複数の NCCL バージョンに対応できます。
降格試行を削除すると、ユーザーが NCCL をアップグレードした後に古いプラグインが突然利用できなくなり、組み込み Socket プラグインにフォールバックするしかなくなり、性能が大幅に低下します。これこそがバージョン交渉が存在する意義です。
Q2:profilerProgressOps内で、もしwc <= op->workStarted[ch].data[slot].counterをwc == op->workStarted[ch].data[slot].counterに変更した場合、どのような高並行シナリオでイベントが永遠に発火しなくなりますか?
参考解析:以下を参照してください:📎 src/plugin/profiler.cc:969-972。コメントにはデバイスがMAX_PROFILER_EVENTS_PER_CHANNEL個のスロットをラップアラウンドすることが明記されています。ホストの消費速度がデバイスの生産速度に追いつかない場合、デバイスはすでにカウンタwc + Nでスロットwc % MAX_PROFILER_EVENTS_PER_CHANNEL。
を上書きしている可能性があります。このときop->workStarted[ch].data[slot].counterの値はwc + Nであり、op->workCounterはwcです。==で判定すると失敗し、イベントは永遠に発火せず、ワークオブジェクトは永遠にactiveリンクリストに留まり、inflightは増える一方で減らず、最終的にメモリプールを枯渇させます。
<=であればこの状況を正しく処理できます:デバイスが書き込んだカウンタが期待値以上であれば、イベントは準備完了と見なします。これは典型的な「プロデューサー-コンシューマーラップアラウンドバッファ」の正確性条件です。
Q3: もしncclProfilerThreadDestroy内でiterationActiveが偽になるのを待つループを削除した場合、どのようなタイミングで profiler プラグインが解放済みの通信ドメインコンテキストにアクセスしますか?
参考解析:以下を参照してください:📎 src/plugin/profiler.cc:1162-1166。コメントにはncclProfilerPluginFinalizeがncclProfilerThreadDestroyの返却後すぐに通信ドメインのprofilerContext。
を破棄することが説明されています。消費スレッドがprofilerProgressOps内でプラグインコールバックを呼び出すとき、渡されるのはop->profilerContext。📎 src/plugin/profiler.cc:938です。破棄スレッドがiterationActiveが偽になるのを待たずに返ると、ncclProfilerPluginFinalizeがコンテキストを解放し、消費スレッドがそのコンテキストを使ってプラグインを呼び出している可能性があります——use-after-free です。
iterationActiveのハンドシェイクプロトコルは:消費スレッドがロック内でtrueを設定した後ロックを解放してプラグインを呼び出し、破棄スレッドはロック内でそれがfalse。📎 src/plugin/profiler.cc:1028📎 src/plugin/profiler.cc:1054-1057に戻るのを待ちます。このプロトコルによりプラグインコールバック中はコンテキストが常に有効であることが保証されます。
待機を削除すると、破棄スレッドが消費スレッドのプラグインコールバック進入直後に返る可能性があり、プラグインがダングリングポインタを取得します。これは典型的な「ライフサイクルと並行アクセス」の競合状態です。
プラグイン体系により NCCL は閉鎖的から開放的へと移行しました:ネットワークバックエンド、チューニング戦略、性能コレクタ、設定ソースをコアコードを変更せずに置き換えられます。しかしプラグインは新たな故障面も導入します——バージョン不一致、ライフサイクル競合、参照カウントリーク。次章では RAS と診断サブシステムに入り、NCCL がどのように故障を検出し、進捗を監視し、長時間のトレーニングタスクで自己修復を実現するかを見ていきます。
プラグイン体系は NCCL のコア通信パスと交換可能なコンポーネントの間に明確な境界を引き、net、tuner、profiler、env の4種類のプラグインがそれぞれ登録と参照カウントの仕組みを通じて安全にランタイム動作に介入します。しかし拡張可能な通信エンジンは、コンポーネントを柔軟に交換できるだけでなく、長時間のトレーニングで安定して動作する必要があります——ネットワークカードや GPU に故障が発生したとき、NCCL はどのように検出し、監視し、復旧をトリガーするのか?次章では RAS と診断メカニズムに入り、本番環境での信頼性がどのように体系的に保証されるかを見ていきます。
第17章:第17章:RASメカニズムとフォールトトレランス:リンク障害検出、ハートビート、グレースフルデグラデーション
第17章:RASメカニズムとフォールトトレランス:リンク障害検出、ハートビート、グレースフルデグラデーション
前章では、プラグイン体系がどのようにコア通信パスと交換可能なコンポーネントの境界を明確にし、コアコードを変更せずにネットワークバックエンド、チューニング戦略、パフォーマンスコレクタを差し替えられるようにするかを確認した。しかし拡張性は本番運用の一側面に過ぎず、もう一つの同様に困難な問題がある:あるAllReduceが既に72時間実行されているとき、あるマシンのNICが静かに故障した場合、NCCLはなぜそれを発見し、隔離し、継続できるのか?RASサブシステムこそが、NCCLが「動く」から「本番運用可能」へと進む分水嶺であり、本章では障害検出、進捗監視、自己修復メカニズムの背後にある設計を分解する。
17.1 RAS総合制御:プロセスごとに1つのRASスレッドを持つグローバルコーディネータ
直感的モデル
RASをジョブ全体の「当直室」と想像してほしい。各NCCLプロセス(各rank)は初期化時に当直室を開設し、そこに専任スレッドが座っている。すべての通信ドメイン(communicator)の確立、破棄、診断リクエストはまず当直室に登録する必要があり、当直室同士は独立したRASネットワークを通じて「誰が生きているか、誰が死んだか」を相互に通報する。
もしこの当直室がなければ、NCCLは通信パス自体のタイムアウトによってのみ障害を感知できる——しかし通信パス上のタイムアウトは遅く、誤判定しやすい(一度のネットワークジッタがノード死亡と見なされる可能性がある)。RASは「障害感知」をデータプレーンから制御プレーンに分離し、独立した軽量ハートビートと診断チャネルで健全性状態を判定する。
データ構造とメモリレイアウト
RASのコア状態はras.ccのグローバル変数に散在しており、一つずつ分解する:
| 変数 | 型 | 役割 |
|---|---|---|
rasInitMutex | std::mutex | RASシングルトン初期化を保護 |
rasInitialized | bool | 初期化済みかどうか |
rasInitRefCount | int | 参照カウント、アクティブなcomm数に等しい |
rasNetListeningSocket | struct ncclSocket | RASネットワークリスニングソケット |
rasNotificationPipe[2] | ncclSocketPairDescriptor | ローカルスレッド → RASスレッドへの通知パイプ |
rasPfds | struct pollfd* | メインイベントループのpoll配列 |
ncclComms | struct ncclComm** | すべての通信ドメインポインタ配列 |
📎 src/ras/ras.cc:49-61これらのグローバル状態を定義する。注意すべきはrasInitRefCountがncclAtomicRefCountIncrementで📎 src/ras/ras.cc:129を増減し、rasInitializedが通常のboolと二重チェックロックで📎 src/ras/ras.cc:103-105を保護すること——これは典型的な「一度初期化、その後読み取り専用」パターンである。
ncclComms配列の割り当て戦略は注目に値する:オンデマンドで成長するのではなく、毎回RAS_INCREMENT * 8(すなわち32スロット)ずつ拡張する📎 src/ras/ras.cc:139-140。配列内にはnullptrの空洞(comm破棄時に空にする)が許容され、新しいcommは最初の空洞を再利用する📎 src/ras/ras.cc:135-137。
シナリオ駆動Walkthrough:comm初期化からRASスレッド起動まで
ステップ1:ncclRasCommInitが呼び出される。これは各comm初期化時に最初に呼び出されるRAS関数である📎 src/ras/ras.cc:101。まずrasInitializedをチェックし、未初期化ならクリティカルセクションに入る:
1. bootstrapネットワークインターフェースアドレスでrasNetListeningSocketを初期化し、ポートを0に設定してカーネルにランダム割り当てさせる📎 src/ras/ras.cc:108-109
2. そのソケットをリッスンする📎 src/ras/ras.cc:113
3. ローカル通知パイプを作成する📎 src/ras/ras.cc:118
4. 診断サブシステムを初期化する📎 src/ras/ras.cc:120
5.rasThreadMainスレッドを起動する📎 src/ras/ras.cc:121
6.atexit(rasTerminate)を登録し、プロセス終了時のクリーンアップを保証する📎 src/ras/ras.cc:126
ステップ2:commを登録する。初回初期化かどうかに関わらず、commポインタをncclComms配列に書き込み📎 src/ras/ras.cc:142、ncclCommsSortedをfalseに設定する📎 src/ras/ras.cc:143——配列順序が変わったため、以前のソートが無効になったからである。
ステップ3:ポートを書き戻す。関数は最後にrasNetListeningSocket.addr(カーネル割り当てポートを含む)をmyRank->addr 📎 src/ras/ras.cc:146にコピーし、呼び出し側がRASネットワークがどのポートでリッスンしているかを知れるようにする。
メインイベントループ:poll駆動の多重化
rasThreadMainはRASスレッドの心臓である📎 src/ras/ras.cc:633。まず3つの固定fdを登録する:通知パイプ、RASネットワークリスニングソケット、クライアントリスニングソケット📎 src/ras/ras.cc:641-652。その後無限ループに入る:
for (int64_t nextWakeup = 0;;) {
// 计算超时
timeoutMs = min(..., 1000);
nEvents = poll(rasPfds, nRasPfds, timeoutMs);
// 处理事件
for (pollIdx...) { ... }
// 处理各类超时
rasSocksHandleTimeouts(now, &nextWakeup);
rasConnsHandleTimeouts(now, &nextWakeup);
rasNetHandleTimeouts(now, &nextWakeup);
rasCollsHandleTimeouts(now, &nextWakeup);
}📎 src/ras/ras.cc:655-728このループを示す。注意すべきはtimeoutMsが1000ms以内にハード制限されていること📎 src/ras/ras.cc:664——たとえnextWakeupが遠くても、毎秒一度は目覚め、タイムアウトチェックの適時性を保証する。
イベントディスパッチロジックはfd値でルーティングする📎 src/ras/ras.cc:684-715:通知パイプならrasLocalHandleを呼ぶ;リスニングソケットならaccept;そうでなければrasSocketsHeadとrasClientsHeadリンクリストを走査して対応するsocketを処理する。
ローカル通知メカニズム:パイプ + 固定長構造
ローカルNCCLスレッドとRASスレッドはsocketpairで通信する。通知構造rasNotificationは固定長の📎 src/ras/ras.cc:35-46であり、static_assertでPIPE_BUF 📎 src/ras/ras.cc:47を超えないことを保証する——これは書き込みの原子性を確保するためである(POSIXはPIPE_BUF未満の書き込みが原子的であることを保証する)。
送信側rasLocalNotifyはrasNotificationMutexで複数ユーザースレッドの書き込みを直列化し📎 src/ras/ras.cc:224-237、その後すべて書き終わるまでループで書き込む📎 src/ras/ras.cc:224-237。受信側rasLocalHandleも同様に構造全体を読み切るまでループで読み📎 src/ras/ras.cc:247-256、EOFを読むとncclSystemError 📎 src/ras/ras.cc:251-253。
を返すRAS_ADD_RANKS3種類の通知タイプ:RAS_RUN_DIAG(新rank参加)、RAS_TERMINATE(診断実行)、📎 src/ras/ras.cc:28-32。
(終了)
メッセージ送受信:長さプレフィックス + 増分進捗📎 src/ras/ras_internal.h:110-117RASメッセージのワイヤフォーマットは「4バイト長 + メッセージ本体」であるrasConnSendMsg。送信時📎 src/ras/ras.cc:362-390はまず長さを送り、次にメッセージ本体を送るmeta->offset、rasMsgRecvで進捗を記録し、部分送信後の次回継続をサポートする。受信時📎 src/ras/ras.cc:393-412。
はまず長さを受信し、長さに応じてバッファを割り当て、次にメッセージ本体を受信するrasMsgAllocここに細部がある:rasMsgMetaが割り当てるのはmsg構造であり、offsetofフィールドは構造の末尾にあり、📎 src/ras/ras.cc:313-319でオフセットを計算する📎 src/ras/ras.cc:323-328。この「メタデータ前置」レイアウトにより、メッセージは送信進捗やキュー投入時刻などのローカル情報を、ワイヤフォーマットを占有せずに運ぶことができる。
設計上の考察
なぜ epoll ではなく poll を使うのか?poll の O(n) 複雑度は RAS シナリオでは許容できる——RAS の接続数はデータプレーンの接続数よりはるかに少なく、RAS スレッド自体が性能クリティカルパスではない。poll はクロスプラットフォーム性も高い(Windows 互換)。
なぜ通知に条件変数ではなくパイプを使うのか?パイプは poll ループにシームレスに統合でき、RAS スレッドが統一されたpollすべてのイベントソースを待機できる。条件変数を使う場合、poll を起床させる追加の仕組みが必要になる。
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 進捗監視:DMA で GPU カウンタをホストへ転送
直感モデル
進捗監視は車のダッシュボードの「エンジン回転計」のようなものだ。運転(通信)には関与しないが、GPU 内部の進捗カウンタを継続的にホストメモリへコピーし、ホストが「この通信ドメインが固まっていないか」を判断できるようにする。これがなければ、AllReduce がハングしたとき「プログラムが戻らない」ことしか分からず、GPU が計算中なのか、ネットワーク待ちなのか、完全にデッドロックしているのか分からない。
データ構造とメモリレイアウト
各 CUDA デバイスに対応するncclGpuProgressCounterMonitorワーカースレッド📎 src/ras/progress_monitor.cc:35-52:
| フィールド | 型 | 役割 |
|---|---|---|
cudaDev | int | バインドされた CUDA デバイス番号 |
thread | std::thread | ワーカースレッド |
mutex / cv | std::mutex / condition_variable | 可変状態の保護と起床 |
running / shouldStop | bool | スレッドライフサイクルフラグ |
copyInFlight | bool | DMA コピーが進行中かどうか |
copyStallWarned | bool | 今回のスタールについて既に警告済みかどうか |
copyStartNs | uint64_t | 今回のコピー開始時刻 |
sideStream | cudaStream_t | 専用ノンブロッキングストリーム |
copyDone | cudaEvent_t | コピー完了イベント |
warningMutex | std::mutex | 警告タイムスタンプの保護 |
lastStaleWarnNs / lastErrorWarnNs | uint64_t | レート制限タイムスタンプ |
destroyRefs | int | 破棄参照カウント |
registrations | 侵入型キュー | 本デバイスに登録された comm リスト |
📎 src/ras/progress_monitor.cc:59-62ロック順序を明確化:gpuProgressCounterMonitorsMuより先にncclGpuProgressCounterMonitor::mutex。これはデッドロックを避けるための重要な約束である。
グローバル配列gpuProgressCounterMonitors[kRasMaxCudaDevices]デバイス番号でインデックス📎 src/ras/progress_monitor.cc:59-62。
シナリオ駆動ウォークスルー:1 回のカウンタコピー
ステップ 1:登録。 ncclProgressCounterMonitorInitが呼び出される📎 src/ras/progress_monitor.cc:319。もしdeviceCountersBlockが空なら直接 return(その comm は監視に参加しない)📎 src/ras/progress_monitor.cc:323。そうでなければグローバルロック内でそのデバイスの worker を検索または作成📎 src/ras/progress_monitor.cc:328-335、そして comm をキューに投入registrations 📎 src/ras/progress_monitor.cc:339。
ステップ 2:ワーカースレッド起動。 createGpuProgressCounterMonitorが worker を作成し、cudaSetDeviceを設定、sideStream(cudaStreamNonBlocking)とcopyDoneイベント📎 src/ras/progress_monitor.cc:280-282を作成し、スレッド起動後最大 2000ms 待機してrunningが true になるのを確認📎 src/ras/progress_monitor.cc:287-303。
ステップ 3:ループコピー。 progressCounterMonitorLoopまずデバイスをバインドし、relaxed ストリームキャプチャモードを設定(アプリケーションの graph capture を妨げないため)📎 src/ras/progress_monitor.cc:97-121、その後メインループへ:
1. 待機pollIntervalMs(デフォルト 1000ms)📎 src/ras/progress_monitor.cc:132-136
2. 前回のコピーがまだ進行中なら、cudaEventQueryで📎 src/ras/progress_monitor.cc:140をチェック。もしcudaErrorNotReadyかつ stale 閾値(デフォルト 5000ms)を超えていれば、レート制限警告を発出📎 src/ras/progress_monitor.cc:141-154
3. 登録されたすべての comm を走査し、各々に対してcudaMemcpyAsyncを呼び出してdeviceCountersBlockをhostCountersBlock 📎 src/ras/progress_monitor.cc:170-185
へコピーcopyDone4. いずれかのコピーが成功したら、copyInFlight 📎 src/ras/progress_monitor.cc:194-202
イベントを記録し
を設定progressCounterMonitorShouldWarn並行制御とレート制限📎 src/ras/progress_monitor.cc:78-87警告レート制限はwarningMutexで実装warnIntervalNs:staleWarnSecの保護下で前回警告からの経過が📎 src/ras/progress_monitor.cc:27を超えているかチェックし、超えていれば更新して true を返す。デフォルトの
は 600 秒📎 src/ras/progress_monitor.cc:29、つまり同一種別の警告は最大 10 分に 1 回。📎 src/ras/progress_monitor.cc:30パラメータには下限クランプがある:poll 間隔は最小 50ms
、stale 閾値は最小 1000ms
ncclProgressCounterMonitorDestroy。これによりユーザー設定が過激になり CPU が空回りするのを防ぐ。📎 src/ras/progress_monitor.cc:352-354:
破棄:参照カウント + ストリーム同期registrationsの破棄ロジックは本章で最も精妙な並行設計の一つ📎 src/ras/progress_monitor.cc:368
1. グローバルロック + worker ロック内でdestroyRefs++から comm を削除haveDestroyRef 📎 src/ras/progress_monitor.cc:371-372
2. 削除に成功したら、shouldStop 📎 src/ras/progress_monitor.cc:373-376
を設定cudaStreamSynchronize(g->sideStream)3. 登録リストが空になったら、グローバル配列から取り外し📎 src/ras/progress_monitor.cc:393
を設定releaseGpuProgressCounterMonitorDestroyRef4. ロック解放後、📎 src/ras/progress_monitor.cc:219-246
5. 最後にdestroyRefs?で参照カウントをデクリメントし、ゼロかつキューが空になったらスレッドを join して削除cudaStreamSynchronize〔設計推論とアーキテクチャのトレードオフ〕
sequenceDiagram
participant App as 应用线程
participant Mon as 监控线程
participant GPU as CUDA 设备
App->>Mon: ncclProgressCounterMonitorInit(comm)
Mon->>Mon: 查找/创建 worker
Mon->>Mon: registrations 入队 comm
loop 每 pollIntervalMs
Mon->>GPU: cudaEventQuery(copyDone)
GPU-->>Mon: cudaErrorNotReady / cudaSuccess
Mon->>GPU: cudaMemcpyAsync(hostCounters, deviceCounters, D2H, sideStream)
Mon->>GPU: cudaEventRecord(copyDone, sideStream)
end
App->>Mon: ncclProgressCounterMonitorDestroy(comm)
Mon->>Mon: registrations 删除 comm, destroyRefs++
Mon->>GPU: cudaStreamSynchronize(sideStream)
GPU-->>Mon: 拷贝排空完成
Mon->>Mon: releaseGpuProgressCounterMonitorDestroyRef
Mon->>Mon: join 线程, delete workerが必要か
なぜならcudaSetDeviceはロック外で実行され、その間に別のスレッドが同じ worker を破棄している可能性があるから。参照カウントにより最後の破棄者だけが実際に join と delete を行うことが保証される。コピーcudaSetDevice本番環境の落とし穴回避shouldStop落とし穴 1:📎 src/ras/progress_monitor.cc:97-107の失敗により監視が静かに無効化。NCCL_RASスレッド起動時に
が失敗すると、worker はを設定してcudaThreadExchangeStreamCaptureMode(cudaStreamCaptureModeRelaxed)を終了する📎 src/ras/progress_monitor.cc:110-111が、それを登録した comm は依然として監視が動作中だと考えている。このときカウンタミラーは Init 段階で失敗が露呈するまで古いままとなる。調査時は
ログに "progress-counter mirrors will remain stale" があるか確認する。
落とし穴 2:graph capture の衝突。
監視スレッドが CUDA API を呼び出す際、アプリケーションが stream capture 中だとキャプチャグラフを汚染する。コードはnvidia-smiで回避しており
、これは必須の防御である。
17.3 診断フレームワーク:テーブル駆動のチェックディスパッチrasDiagnosticsChecks 📎 src/ras/diagnostics.cc:63-77直感モデルcollectLocal(ローカル収集)とsummarize(集約)。計11項目のチェック:GPU モデル、CUDA ドライババージョン、ECC、NVLink、NCCL 環境、RDMA トポロジ、IOMMU モード、ATS、XID/SXID、NVIDIA ドライババージョン、パス。
rasDiagnosticsGetCheck三重検証を行う:ID 範囲、テーブルエントリ ID の一致、コールバックの非 NULL📎 src/ras/diagnostics.cc:104-128。これは防御的プログラミングである——テーブルエントリが誤って変更され、NULL ポインタを呼び出すことを防ぐ。
シナリオ駆動ウォークスルー:1回の診断の完全なライフサイクル
ステップ1:ローカル payload を構築する。 rasDiagnosticsCollectLocalPeerPayloadまず peer ヘッダを書き込み📎 src/ras/diagnostics.cc:226-227、次にディスパッチテーブルを走査し、各項目に対してrasDiagnosticsAppendCheckPayload 📎 src/ras/diagnostics.cc:229-231。
rasDiagnosticsAppendCheckPayloadを呼び出すcollectLocalを呼び出してrasDiagnosticsLocalDataを取得し、ncclUniquePtrで records の所有権を引き継ぎ📎 src/ras/diagnostics.cc:191-192、メタデータを検証し📎 src/ras/diagnostics.cc:193、レコード数が 0 ならスキップし📎 src/ras/diagnostics.cc:194、そうでなければチェックヘッダ + レコードデータを書き込む📎 src/ras/diagnostics.cc:196-201。
ステップ2:集合通信を開始する。 rasDiagnosticsStartを構築しRAS_COLL_DIAGリクエスト📎 src/ras/diagnostics.cc:532-537をrasNetSendCollReqを通じて送信し📎 src/ras/diagnostics.cc:539、クライアント状態をRAS_CLIENT_DIAG_FINI 📎 src/ras/diagnostics.cc:541。
に設定する rasCollDiagMergeステップ3:レスポンスをマージする。📎 src/ras/diagnostics.cc:310-337各 peer の payload を集合バッファに追加する📎 src/ras/diagnostics.cc:320-324。ここでは大量のオーバーフローチェックが行われている:peer 数の上限📎 src/ras/diagnostics.cc:325-328。
、総サイズの上限 rasDiagnosticsSummarizePeerPayloadsステップ4:集約。📎 src/ras/diagnostics.cc:399:
- は2回スキャン📎
src/ras/diagnostics.cc:418-470 - 1回目:各 peer ヘッダとチェックヘッダを検証し、各チェック種別のレコード数とバイト数を累計する📎
src/ras/diagnostics.cc:472-476 - 各チェック種別のマージバッファを割り当てる📎
src/ras/diagnostics.cc:479-497 - 2回目:各 peer のレコードを対応するバッファにコピーする
summarize📎src/ras/diagnostics.cc:499-506
最後に各チェック種別に対して
を呼び出すrasDiagnosticsClientStateクライアント状態とキャンセル📎 src/ras/diagnostics.cc:242-245診断状態はrasClient->diagnosticsに存在しrasDiagnosticsCancelTarget、📎 src/ras/diagnostics.cc:286-293に紐づいている。📎 src/ras/diagnostics.cc:48-52。
はクライアント socket のクローズ時に reporter を noop に差し替え
設計上の考察〔設計推論とアーキテクチャのトレードオフ〕
なぜ2回スキャンするのか?recordStride? 📎 src/ras/diagnostics.cc:197payload が可変長であるため、1回目のスキャンでなければ各チェック種別に必要なバッファサイズを算出できないからである。1回スキャンでは、動的拡張(複数回の realloc)か、過大な事前確保のいずれかになる。2回スキャンは、1回の正確な確保で決定性を得る。rasDiagnosticsAccountCheckRecordsなぜチェックヘッダに📎 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"]チェックごとにレコード構造のサイズが異なるため、集約時にストライドを知らないと正しくコピーおよび検証できないからである。
は同一チェックの stride を一致させることを強制する
peers.ccコピー
17.4 ピア管理:ソート済み配列 + ハッシュ同期
直感的モデル
rasPeersが維持しているのは「クラス全員の名簿」である。各 RAS スレッドは完全に同一の名簿を保持し、各 NCCL プロセスのアドレス、PID、管理する GPU を記録する。新しいメンバーが加わったり、誰かが「消息不明」になったりすると、RAS ネットワークを通じて変更をブロードキャストする。名簿はハッシュ値をバージョン番号として使い、毎回の全量同期を避ける。📎src/ras/peers.cc:18-19データ構造とメモリレイアウトrasDeadPeers2つのコア配列:📎src/ras/peers.cc:37-38。
:既知の全 peer をアドレス順にソート 📎 src/ras/peers.cc:25-28。死亡した peer も含む。rasPeers:死亡した peer のアドレスを別途格納rasDeadPeersなぜ死んだ peer を別に格納するのか?rasPeersのコメントが明確に説明している:
rasPeerInfoは大規模下ではほぼ静的で非常に大きいが、📎 src/ras/ras_internal.h:110-117:
| は動的でずっと小さい。別々に格納することで、毎回の同期で巨大な | 配列を転送することを避ける。 | 構造 |
|---|---|---|
addr | ncclSocketAddress | フィールド |
pid | ncclPid_t | 型 |
cudaDevs | uint64_t | 説明 |
nvmlDevs | uint64_t | ネットワークアドレス(ソートキー) |
hostHash / pidHash | uint64_t | プロセス ID |
CUDA デバイスビットマスク(CUDA_VISIBLE_DEVICES の影響を受ける)rasPeersHashNVML デバイスビットマスク(影響を受けない)rasDeadPeersHashcomm から抽出し、commHash を減算して通信ドメイン非依存にする📎 src/ras/peers.cc:21📎 src/ras/peers.cc:37-38。
2つのハッシュ
と rasRanksConvertToPeersが同期の核心であるrasRankInitシナリオ駆動ウォークスルー:新しい rank の参加rasPeerInfo 📎 src/ras/peers.cc:104ステップ1:変換。📎 src/ras/peers.cc:114が📎 src/ras/peers.cc:127-130配列を📎 src/ras/peers.cc:134-139。
に変換する。まずアドレス + cudaDev でソートし rasPeersUpdate、空アドレスをスキップし📎 src/ras/peers.cc:197、同一アドレスの複数 GPU プロセスをマージする(ビットマスク OR)📎 src/ras/peers.cc:202-229ステップ2:ローカル配列を更新する。📎 src/ras/peers.cc:244-361は本章で最も複雑なマージアルゴリズムであるrankPeers。まず新しい配列サイズを計算し📎 src/ras/peers.cc:301-308、次に2つのソート済み配列をマージする📎 src/ras/peers.cc:393-402。重要な点:マージ過程で
を「差分」に作り変える——実際に新規追加された GPU ビットのみを保持し rasNetUpdatePeers、最後に寄与のないエントリを削除するrasNextLink。これによりブロードキャストするデータ量が最小になる。rasPrevLinkステップ3:伝播。📎 src/ras/peers.cc:430-450が📎 src/ras/peers.cc:443-444。
と rasConnSendPeersUpdateの2方向に沿って伝播し📎 src/ras/peers.cc:500-508、その後接続を再構築するpeersHashステップ4:更新を送信する。deadPeersHash 📎 src/ras/peers.cc:521-524まずハッシュをチェックし📎 src/ras/peers.cc:608-653。
:相手が現在のハッシュを既知ならスキップする。メッセージに
rasPeerDeclareDeadとrasDeadPeersを含め、受信側がマージ後もハッシュが一致しなければ📎 src/ras/peers.cc:793-812。rasMsgHandleBCDeadPeerを返送する📎 src/ras/ras.cc:578-591死んだ peer の宣言と伝播*pDone = trueがアドレスを
rasDeadPeersUpdateに追加し、ソート後にハッシュを再計算する📎 src/ras/peers.cc:838-893がブロードキャストされた死んだ peer メッセージを処理するmemmove:ローカルで未知なら接続を切断し死亡を宣言し、そうでなければmemcpy 📎 src/ras/peers.cc:855をマークする
再ブロードキャストを停止する。
rasLinkReinitConnsがマージソートで新旧の死んだ peer リストをマージする📎 src/ras/peers.cc:680。ここでは📎 src/ras/peers.cc:706-711ではなく
rasLinkCalculatePeerを使うことに注意。送信元と送信先が重複する可能性があるためである。📎 src/ras/peers.cc:743-785接続再構築:重複接続競合の回避📎 src/ras/peers.cc:743-785が peer 更新後にリンク接続を再構築する
。核心戦略:アドレスが小さい側から接続を開始し
、双方が同時に開始して重複することを避ける。 ncclSocketsCompareが次の peer インデックスを計算し、死んだ peer をスキップする📎 src/ras/peers.cc:960-990。fallback には追加の最適化もある:前の fallback と同一ノードの peer をスキップしmemcmp、ノード全体のダウン時に1つずつ待つことを避ける。📎 src/ras/peers.cc:957-959本番環境の落とし穴
落とし穴2:myPeerIdx無効化。配列が拡張されるとmyPeerIdxが変わる📎 src/ras/peers.cc:22-23。rasPeersUpdateマージ処理中に同期的に更新する📎 src/ras/peers.cc:312📎 src/ras/peers.cc:358、更新に失敗した場合は二分探索にフォールバックする📎 src/ras/peers.cc:374-388。
落とし穴3:ハッシュ衝突による同期漏れ。ハッシュは「同期が必要かどうか」の判断にのみ使用され、正確性には使用されない 。たとえハッシュ衝突で同期がスキップされても、後続のkeep-alive交換にはハッシュが含まれるため、最終的に収束する。
flowchart LR
subgraph 输入
ranks["rasRankInit[]"]
end
subgraph 转换
convert["rasRanksConvertToPeers: 排序+合并同地址"]
rankPeers["rasPeerInfo[] (rankPeers)"]
end
subgraph 合并
update["rasPeersUpdate: 归并到 rasPeers"]
diff["rankPeers 改造为差异"]
hash["重算 rasPeersHash"]
end
subgraph 传播
send["rasConnSendPeersUpdate: 带哈希"]
recv["rasMsgHandlePeersUpdate: 合并+回发"]
reinit["rasLinkReinitConns: 重建连接"]
end
ranks --> convert --> rankPeers --> update
update --> diff --> hash
hash --> send --> recv --> reinit17.5 設計思考:RASとメイン通信パスの境界
RASサブシステムの最も核心的な設計判断はデータプレーンとの完全な分離。RASスレッドは集合通信のデータ転送に一切関与せず、三つのことだけを行う:peerリストの維持、接続ヘルスの検出、診断の実行。この分離によりいくつかの利点が得られる:
1. 障害の分離:RASスレッドがクラッシュしても通信失敗には直結しない(ただし障害感知能力は失われる)
2. 性能への影響なし:RASのハートビートと同期トラフィックは独立したネットワークを経由し、データプレーンの帯域を消費しない
3. 可観測性:診断と監視を通信の進行と並行して実行できる
代償は状態の一貫性の課題である:RASが見るcomm状態はデータプレーンより遅延する可能性がある。ncclRasCommInitとncclRasCommFiniはncclCommsMutexを通じて📎 src/ras/ras.cc:77-77を保護するが、RASスレッドが読み取る際はスナップショットのみで、強い一貫性は保証しない。
もう一つの重要な設計はタイムアウトの階層化。ras_internal.hは一連のタイムアウト定数📎 src/ras/ras_internal.h:214-249を定義している:keep-alive間隔1秒、警告閾値5秒、エラー閾値20秒、peer死亡閾値60秒。この階層化により、システムは異なる深刻度に応じて異なるアクションを取れる——まず警告、次に予備接続を試み、最後に死亡を宣告する。
17.6 本章のまとめ
本章ではNCCL RASサブシステムの四つの核心モジュールを分解した:
ras.cc:シングルトンRASスレッド+pollイベントループ、パイプ経由でローカル通知を受信し、独立ネットワーク経由で他のrankとメッセージを交換するprogress_monitor.cc:デバイスごとに1つのワーカースレッド、DMAでGPU進捗カウンタをホストに転送、レート制限警告と参照カウントによる破棄を備えるdiagnostics.cc:テーブル駆動の検査ディスパッチフレームワーク、2パススキャンで各rankの診断payloadを集約するpeers.cc:ソート済み配列+ハッシュ同期によるpeerリスト管理、死んだpeerは別途格納して帯域を節約する
本章の思考とセルフチェック
Q1:rasLocalNotifyはrasNotificationMutexで直列化して書き込むが、rasLocalHandleの読み取り時には対応するロックがない。なぜこれが安全なのか?もしstatic_assert(sizeof(struct rasNotification) <= PIPE_BUF)を削除した場合、どのようなシナリオで問題が発生するか?
参考解析:安全性はPOSIXのパイプ書き込みの原子性保証に由来する——PIPE_BUF未満の書き込みは原子的である📎 src/ras/ras.cc:47。rasLocalNotifyのループ書き込み📎 src/ras/ras.cc:224-237は単一の書き込みで完了する場合、他の書き込みと交錯しない。rasLocalHandleのループ読み取り📎 src/ras/ras.cc:247-256は部分的なデータを読む可能性があるが、書き込みが原子的であるため、読み取るのは必ず完全なメッセージのプレフィックスであり、次回の読み取りで補完すればよい。
を削除すると、もしstatic_assertがrasNotificationを超える場合PIPE_BUF、書き込みが複数の非原子的な書き込みに分割される可能性がある。2つのスレッドが並行して書き込むと、それらのバイトが交錯し、RASスレッドが2回の通知が結合された不正なデータを読む可能性がある。msg.typeはスレッドAから来てmsg.addRanks.ranksはスレッドBから来る可能性があり、rasLocalHandleの未知の型分岐📎 src/ras/ras.cc:267-269またはさらに悪い場合は野ポインタのデリファレンスを引き起こす。
Q2:ncclProgressCounterMonitorDestroyはロックを解放した後にcudaStreamSynchronize 📎 src/ras/progress_monitor.cc:381-400を実行する。もし同期中に別のスレッドもDestroyを呼び出して同じcommを破棄した場合、何が起こるか?destroyRefsはどのように問題を防ぐか?
参考解析:destroyRefsはworkerが早期に削除されるのを防ぐ参照カウントである。最初のスレッドがcommを削除した後destroyRefs++ 📎 src/ras/progress_monitor.cc:371、この時点でhaveDestroyRef = true。2番目のスレッドが同じcommを削除しようとすると、ncclIntruQueueDeleteはnullptrを返し(既に削除済み)、haveDestroyRefはfalseのまま📎 src/ras/progress_monitor.cc:368、同期と解放を直接スキップする。
最初のスレッドがcudaStreamSynchronizeを完了した後releaseGpuProgressCounterMonitorDestroyRef 📎 src/ras/progress_monitor.cc:402を呼び出し、destroyRefsを0までデクリメントし、かつ登録キューが空であれば、初めてスレッドをjoinして📎 src/ras/progress_monitor.cc:225。
をdeleteするdestroyRefsもしdelete gがなければ、最初のスレッドが同期中に2番目のスレッドのreleaseGpuProgressCounterMonitorDestroyRefによってworkerを解放され、use-after-freeを引き起こす可能性がある。なお📎 src/ras/progress_monitor.cc:222-225はグローバルロック+workerロック内でregistrationsをデクリメントし、destroyRefs == 0が空であることと
Q3:rasDiagnosticsSummarizePeerPayloadsの原子性を保証する。checkHeader->payloadBytes != checkHeader->nRecords * checkHeader->recordStride 📎 src/ras/diagnostics.cc:451-454は最初のパススキャン時にrecordStride = 0を検証する。もし悪意のあるまたは破損したpeerがnRecords = 0を送信し、かつ
の場合、この検証は通過するか?その後何が起こるか?:recordStride <= 0参考解析📎 src/ras/diagnostics.cc:451は最初の条件でブロックされncclInternalError、recordStride = 0を返す。したがって
は通過しない。recordStride > 0しかしもしnRecords = 0かつpayloadBytes = 0の場合、rasDiagnosticsAccountCheckRecordsとなり、検証は通過する。nRecords == 0は📎 src/ras/diagnostics.cc:378に対して直接成功を返しcombined、recordsBytes == 0を更新しない。後続の割り当て時に📎 src/ras/diagnostics.cc:473はpayloadBytes > 0を割り当てず、コピー時に📎 src/ras/diagnostics.cc:490が偽でスキップするsummarize。最終的にrecords = nullptr, recordsBytes = 0は
を受け取り、各検査のsummarize実装は空の入力を処理する必要がある。nRecords > INT_MAX / recordStride真のリスクは📎 src/ras/diagnostics.cc:453の検査nRecords * recordStrideにある——これはnRecords = 2^31, recordStride = 2の整数オーバーフローが等価検証を回避するのを防ぐ。もしこの検査を削除すると、攻撃者はpayloadBytes = 0を構築でき、積がオーバーフローして0となり、rasDiagnosticsAccountCheckRecordsと等しくなり、検証を通過した後nRecordsは巨大な
を累積し、後続の割り当てやコピーで範囲外アクセスを引き起こす。
全章を貫く設計原則は、制御プレーンとデータプレーンの分離、状態のハッシュによるバージョン管理、タイムアウトの階層処理、並行処理における参照カウントによるライフサイクル保護である。これらの原則により、RAS は通信性能を損なうことなく障害検出と自己修復を実現できる。そして通信性能のもう一つの重要な支えであるメモリ管理もまた、精密なエンジニアリング上のトレードオフを必要とする。なぜ NCCL は通信前にメモリを登録する必要があるのか?登録キャッシュは性能にどう影響するのか?次章では allocator、登録キャッシュ、ユーザーバッファ登録を深掘りし、これらの疑問の答えを明らかにする。
第18章:第18章:メモリ割り当てとデバイスメモリ管理:allocator、登録キャッシュ、ユーザー登録メモリの最適化
第18章:メモリ割り当てとデバイスメモリ管理:allocator、登録キャッシュ、ユーザー登録メモリの最適化
前章では、RAS サブシステムが制御プレーン上でデータプレーンから独立して動作し、ハッシュでバージョン管理し、参照カウントでライフサイクルを保護する方法を見た。本章では NCCL の第三の柱であるメモリ管理に入る。通信性能の上限は、しばしばアルゴリズムそのものではなく、「データを NIC が直接読み書きできるかどうか」に依存する。NCCL はこのために三層のメカニズムを構築している。最下層ではncclSpaceとncclShadowPoolがアドレス空間とシャドウオブジェクトを管理し、中間層ではncclMemManagerが動的メモリのインポート・エクスポートとサスペンド・レジュームを追跡し、最上層ではncclCommRegisterがユーザーバッファをキャッシュに登録し、通信のたびにメモリを繰り返し pin することを避ける。本章ではこれら三つのメカニズムを層ごとに分解し、「なぜ NCCL は通信前にメモリを登録する必要があるのか」「登録キャッシュは性能にどう影響するのか」に答える。
18.1 ncclSpace:アドレス空間を満/空交互のセグメントに分割する
直感的モデル
0 から右に無限に伸びる駐車スペースの番号線を想像してほしい。いくつかのスペースには車が停まっており(割り当て済み)、いくつかは空いている(未割り当て)。ncclSpaceはこの番号線の「スペース状態記録帳」である——各スペースを記録するのではなく、「状態が反転する境界点」だけを記録する。これがなければ、NCCL が対称メモリの仮想アドレス区間を管理する際、各バイトにマークビットを維持する必要があり、メモリオーバーヘッドがアドレス空間に比例し、全く受け入れられない。
データ構造とメモリレイアウト
ncclSpaceの定義は極めて簡潔📎 src/include/allocator.h:20-24:
struct ncclSpace {
int count; // cuts[] 中有效元素个数
int capacity; // cuts[] 已分配容量
int64_t* cuts; // 升序排列的边界点数组
};核心的な洞察はソースコードのコメントに明確に書かれている📎 src/allocator.cc:151-153:cuts[]は非負整数軸を「満」と「空」が交互に現れるセグメントに分割し、分割点は昇順に並び、最後の分割点以降のセグメントは必ず空である(未割り当てフロンティア)。これから第iセグメントが満かどうかを判定する公式を導出できる:
isFull(i) = (i%2 != ncuts%2)この公式の意味は、セグメントの満/空状態が「セグメントインデックスの偶奇性」と「分割点総数の偶奇性」の両方で決まるということである。ncutsが偶数のとき、第0セグメント(cuts[0]の前)は空であり、ncutsが奇数のとき、第0セグメントは満である。この不変条件はモジュール全体を貫いている。
Step-by-Step Walkthrough:一回の割り当てが cuts[] をどう変えるか
シナリオ:初期ncclSpaceが空(count=0)、ncclSpaceTryAlloc(a, limit=1000, size=100, align=1, &outOffset)。
を呼び出す 📎 src/allocator.cc:209。i = a->count % 2ステップ1:最初の空セグメントを特定するcount=0、このときi=0、したがって
、第0セグメントからスキャン開始。 📎 src/allocator.cc:212-213。i==0ステップ2:セグメント境界を計算するlo=0;i==a->countのときhi=limit=1000のとき[0, 1000)。
。したがって空セグメントは 📎 src/allocator.cc:214-215。off = alignUp(0, 1) = 0,0 + 100 <= 1000ステップ3:アラインメントと容量チェック
が成立、割り当て成功。 📎 src/allocator.cc:217-223ステップ4:分割点を挿入するi==0。なぜならinsertSegment(a, 0, 0, 100)。insertSegment(先頭に挿入)なので、スローパスindex=0でlo=0, hi=100 📎 src/allocator.cc:172-174の位置に二つの分割点📎 src/allocator.cc:185-203を挿入し、その後「隣接重複値フィルタリング」📎 src/allocator.cc:182-184。
を実行する。フィルタリングロジックは非常に巧妙である:読み書き二つのカーソルでスキャンし、重複値に遭遇すると書き込みカーソルを戻し、ペアの重複値を削除する——ペアの重複は空セグメントが二つの満セグメントに挟まれていることを意味し、マージできるからである。ただし先頭のゼロは特殊ケースで、単独で削除できるcuts = [0, 100],count=2割り当て後isFull(0) = (0%2 != 2%2) = false。このとき[0,0)、第0セグメント([0,100)、空)は空;第1セグメント(
)は満。正しい。 📎 src/allocator.cc:239-267ステップ5:解放ncclSpaceFree(a, 0, 100)。cuts[count-1] <= offsetを呼び出す。まず📎 src/allocator.cc:231-237が成立するかチェック100 <= 0、すなわちi = 1 - count%2 = 1 - 0 = 1 📎 src/allocator.cc:246,cuts[1]=100 > 0が偽、続行。最初の満セグメントを特定i=1。lo = cuts[0] = 0,hi = cuts[1] = 100、したがってoffset < lo || hi < offset+size 📎 src/allocator.cc:252,0<0。チェック100<100偽、lo==offset偽、通過。なぜならoffset+size==hiかつoffset+size != hi、二つの高速パスはどちらも満たさない(一つ目はlo != offsetを要求、二つ目はinsertSegment(a, 1, 0, 100) 📎 src/allocator.cc:264を要求)、スローパスcuts = [0, 0, 100, 100]。挿入後[],count=0、フィルタリング後
。初期状態に戻る。insertSegmentこの「挿入後フィルタリング」の設計により、割り当て/解放時に複雑なセグメントマージロジックを行うことを避け、複雑さを
の一箇所に集中させている。
設計上の考察と本番での落とし穴なぜ size_t ではなく int64_t を使うのか?ncclSpaceなぜならCUdeviceptrが管理するのは「ポインタ」ではなく「オフセット」であり、オフセットは負になり得る(実際の使用ではならないが)、また CUDA の
幅と一致させる必要があるからである。符号付き型を使うことでデバッグ時に範囲外を発見しやすくなる。:ncclSpaceFree性能の罠📎 src/allocator.cc:245のコメントは「This could be binary search, but since allocate is linear there's no point」と直言しているcuts[]。これは割り当てと解放がどちらも O(n) スキャンであることを意味する。ある通信ドメインが大量の小さなセグメントを頻繁に割り当て・解放すると、
が膨張し、各操作が遅くなる。本番環境では登録済みバッファをできるだけ再利用し、登録/解除を繰り返さないようにすべきである。:alignUp(lo, align)アラインメントオーバーフローのリスクloはINT64_MAXがalignに近く、かつlimitが大きいときにオーバーフローする可能性がある。ソースコードには明示的なチェックがない。なぜなら
18.2 ncclShadowPool:デバイスオブジェクトとホストシャドウのペア管理
直感的モデル
GPU kernel はデバイス上で動作し、ホストメモリ内の C++ オブジェクト(例えばncclDevComm内のメタデータ)に直接アクセスできない。ncclShadowPoolは「翻訳者」のようなものである:各デバイス側オブジェクトにデバイスメモリのブロックを割り当て、同時にホスト側に対応する「シャドウ」メモリを割り当て、「デバイスアドレス → ホストアドレス」のマッピングテーブルを維持する。ホストがデバイスオブジェクトの設定を変更する必要がある場合、まずホストシャドウを変更し、次にデバイスにコピーする。これがなければ、kernel がメタデータを読むたびにcudaMemcpyを介してホストから取得する必要があり、遅延が許容できないほど高くなる。
データ構造とメモリレイアウト
2つのコア構造体📎 src/allocator.cc:272-277:
struct ncclShadowPage { // 最多 64 个对象的连续块
struct ncclShadowPage* next;
int objSize;
uint64_t freeMask; // 位图,1=空闲,0=已占用
void* devObjs;
};
struct ncclShadowObject {
struct ncclShadowObject* next;
void* devObj;
void* hostObj;
struct ncclShadowPage* page; // null 表示直接分配在 CUDA mempool
};ncclShadowPool自体📎 src/include/allocator.h:42-47:
struct ncclShadowPool {
int count, hbits; // 对象数、哈希位数
struct ncclShadowObject** table; // 哈希桶数组
cudaMemPool_t memPool; // 可选的 CUDA 内存池
struct ncclShadowPage* pages; // 页链表
};重要な設計ポイント:freeMaskは uint64_tであるため、1ページあたり最大64個のオブジェクトとなる。これは恣意的に選ばれたものではない——64ビットはちょうど1つのキャッシュラインの幅であり、popFirstOneBitは単一の__builtin_ctzll命令で最初の空きスロットを見つけることができ、ループが不要である。
ハッシュテーブルの成長戦略:ソースコードのコメント「Maintain 2:1 object:bucket ratio」📎 src/allocator.cc:368、つまりオブジェクト数がバケット数の2倍を超えると拡張する。初期hbits=4(16バケット)📎 src/allocator.cc:363、毎回倍増する。
Step-by-Step Walkthrough:1回の割り当てがページまたは直接接続を選択する方法
シナリオ:ncclShadowPoolAlloc(pool, size=1024, &devObj, &hostObj, stream)。
ステップ1:遅延初期化 📎 src/allocator.cc:347-366。もしhbits==0なら、まずデバイスがメモリプール📎 src/allocator.cc:352をサポートしているか確認し、サポートしていればcudaMemPool_tを作成し、maxSizeをパラメータSHADOW_MEMPOOL_MAX_SIZE(デフォルト1GB)に設定する。次に16バケットのハッシュテーブルを割り当てる。📎 src/allocator.cc:359ステップ2:拡張が必要か確認
。もし 📎 src/allocator.cc:369-386なら、倍のバケット配列を割り当て、古いテーブルを走査して再挿入し(count+1 > 2<<hbitsはhashInsertを使用してバケットインデックスncclHashPointerを計算)、古いテーブルを解放する。📎 src/allocator.cc:333-337ステップ3:ページパスか直接接続パスかを決定
。判定条件 📎 src/allocator.cc:390、つまり(64<<10)/size >= 3のときページパスを取る。size <= 21845に対して、ページパスを取る。size=1024,65536/1024=64 >= 3ステップ4:ページ内オブジェクトサイズを計算
。つまりページ内オブジェクトサイズは2のべき乗で128バイトの倍数にアラインされる。 📎 src/allocator.cc:391-392。shift = max(0, log2Down(1024)+1-4) = max(0, 10+1-4) = 7。pageObjSize = ((1024 + 127) >> 7) << 7 = 1024ステップ5:ページを検索または作成
。 📎 src/allocator.cc:393-415リンクリストを走査し、pool->pagesのページを探す。なければ新しいページを作成:objSize == pageObjSize(64スロットすべて空)pageSize = min(65536, 64*1024) = 65536,freeMask = uint64_t(-1) >> (64 - 65536/1024) = uint64_t(-1) >> 0 = 全 1。📎 src/allocator.cc:400またはcudaMallocFromPoolAsyncでデバイスメモリcudaMallocを割り当て、📎 src/allocator.cc:403-404をゼロクリアcudaMemsetAsyncステップ6:ページからスロットを取得📎 src/allocator.cc:405。
最初の空きビットを見つけ、 📎 src/allocator.cc:408-412。popFirstOneBit(&page->freeMask)。もしdevObj = page->devObjs + slot * pageObjSizeが0になったら(ページ満杯)、ページを空きリンクリストから削除freeMaskステップ7:ホストシャドウオブジェクトを割り当て📎 src/allocator.cc:411。
、ここで 📎 src/allocator.cc:423-428。malloc(sizeof(ncclShadowObject) + alignof(max_align_t)-1 + size)バイトを余分に割り当ててアライメントパディングに使用することに注意。alignof(max_align_t)-1、つまりオブジェクトヘッダの後を最大アライメント境界にアラインする。次にhostObj = alignUp((char*)(obj+1), alignof(max_align_t))をゼロクリア。memset(hostObj, 0, size)ステップ8:ハッシュテーブルに挿入しカウントを更新
並行制御とハードウェア相互作用 📎 src/allocator.cc:429-430。
自体
ncclShadowPoolにはロックがない。これはシングルスレッドコンテキストでのみ使用できるか、呼び出し側が相互排他を保証する必要があることを意味する。NCCLの実際の使用から見ると、主に通信ドメインの初期化段階で呼び出され、この時点ではシングルスレッドである。と
cudaMallocFromPoolAsyncは非同期操作であり、cudaFreeAsyncパラメータに依存して順序を保証するstreamはすべてのリソースを解放した後に呼び出され📎 src/allocator.cc:403,459。ncclShadowPoolDestruct、すべての非同期解放が完了してからメモリプールを破棄することを保証する。cudaStreamSynchronize(stream) 📎 src/allocator.cc:333-337本番環境の落とし穴回避ガイド
落とし穴1:ページ内オブジェクトサイズのアライメントによるメモリ浪費
は2のべき乗でアラインされ、もし。pageObjSize。各オブジェクトで24バイト浪費し、ページ内64オブジェクトで1536バイト浪費する。多数の小さなオブジェクトに対して、このオーバーヘッドは無視できない。size=1000,shift = log2Down(1000)+1-4 = 9+1-4 = 6,pageObjSize = ((1000+63)>>6)<<6 = 1024落とし穴2:
がオブジェクトを見つけられない場合の動作ncclShadowPoolFree。それは 📎 src/allocator.cc:442-445を返し警告を出力するが、ncclInternalErrorいかなるリソースも解放しない。呼び出し側が戻り値を無視すると、メモリリークが発生する。本番コードは戻り値を必ずチェックする必要がある。落とし穴3:
内のncclShadowPoolDestructのページが回収されるfreeMask==0。ここで 📎 src/allocator.cc:301-306を1に設定する(すべて1ではなく)ことに注意。これは最初のスロットのみを空きとしてマークすることを意味する。これは「満杯ページ」をfreeMaskリンクリストに戻すためだが、ページ内の他のスロットは依然として占有されている——実際にはこれらのオブジェクトはまもなく解放されるため、この操作は安全である。しかしデストラクタ処理中に並行アクセスがあると、不整合な状態を読み取る可能性がある。pool->pages18.3 ncclMemManager:動的メモリの参照カウントとサスペンド/レジューム
直感的モデル
トレーニングタスクは数日間実行される可能性があり、その間 GPU が他のタスクにプリエンプトされたり、チェックポイントが必要になることがある。
は「メモリ管理人」のようなものである:すべての動的割り当てメモリ(scratch/offload)を記録し、必要に応じて GPU メモリを「サスペンド」(物理ページをアンマップし、仮想アドレスを保持)し、データを CPU にバックアップし、復元時に物理ページを再割り当て、再マップし、データを復元する。これがなければ、タスクがプリエンプトされた後は最初からやり直すしかなく、数時間のトレーニング進捗を無駄にする。ncclMemManagerデータ構造とメモリレイアウト
のコアフィールド(初期化コードから推測)
ncclMemManagerフィールド📎 src/mem_manager.cc:32-60:
| 型 | 意味 | 動的メモリエントリリンクリストの先頭 |
|---|---|---|
entries | ncclDynMemEntry* | リンクリストの長さ |
numEntries | int | 0=アクティブ、1=サスペンド済み |
released | int | 参照カウント(複数の comm が共有可能) |
refCount | int | 永続メモリ総量(アトミック) |
totalPersist | size_t | scratch メモリ総量(アトミック) |
totalScratch | size_t | offload メモリ総量(アトミック) |
totalOffload | size_t | CPU バックアップメモリ総量 |
cpuBackupUsage | size_t | entries リンクリストを保護 |
lock | std::mutex | アトミックフラグ、破棄された mutex へのアクセスを防止 |
initialized | int | メモリレイアウトの重要な設計 |
は:lockであるが、std::mutexはncclMemManagerで割り当てられる(C スタイル)ため、placement new で明示的にncclCallocを構築し、デストラクタで明示的に📎 src/mem_manager.cc:39を呼び出す必要がある。これは C/C++ 混在プログラミングの古典的な落とし穴である。~mutex() 📎 src/mem_manager.cc:120アトミック変数とロックの役割分担
:統計フィールド(など)はアトミック操作で更新され、ロック不要;totalPersistリンクリストはentriesで保護される。これにより統計クエリ(lock)はロックなしでncclCommMemStatsを読み取ることができ、リンクリスト操作はロックを保持する必要がある。📎 src/mem_manager.cc:1117-1130,而链表操作必须持锁。
ステップバイステップのウォークスルー:サスペンドとレジュームの完全なフロー
サスペンドフロー ncclCommMemSuspend 📎 src/mem_manager.cc:418-540:
ステップ1:事前チェック 📎 src/mem_manager.cc:419-430。メモリマネージャが無効化されているか、comm が空か、すでにサスペンドされているかを確認する。
ステップ2:デバイス同期と barrier 📎 src/mem_manager.cc:440-441。cudaDeviceSynchronize()すべての GPU 操作が完了することを確認し、その後bootstrapBarrierすべての rank が同期することを確認する。barrier tag は0xBEEF。
ステップ3:第1回スキャン——peer がインポートしたすべてのバッファを unmap 📎 src/mem_manager.cc:444-465。各isImportedFromPeer && state==Activeのエントリに対して、cuMemUnmapを呼び出してマッピングを解除し📎 src/mem_manager.cc:451、handle を解放し📎 src/mem_manager.cc:456、状態をReleased。
に変更する。ステップ4:第2回スキャン——ローカルメモリを offload 📎 src/mem_manager.cc:468-526。peer インポートおよび解放済みのエントリをスキップする。ncclMemOffloadタイプに対して、まず CPU バックアップを割り当て📎 src/mem_manager.cc:484、その後cudaMemcpyで GPU から CPU にコピーする。📎 src/mem_manager.cc:492タイプに対しては、統計を累積するのみ。その後 shareable FD を閉じncclMemScratch、状態を📎 src/mem_manager.cc:508-513,cuMemUnmap 📎 src/mem_manager.cc:516,cuMemRelease 📎 src/mem_manager.cc:519に変更する。ステップ5:サスペンド済みとしてマークReleased。
レジュームフロー 📎 src/mem_manager.cc:528。
ステップ1:ローカルメモリを復元 ncclCommMemResume 📎 src/mem_manager.cc:550-942:
。各 📎 src/mem_manager.cc:577-668のエントリに対して、再度!isImportedFromPeer && state==Releasedを同じ仮想アドレスにマッピングしcuMemCreate 📎 src/mem_manager.cc:599,ncclCuMemMapAndSetAccess、peer アクセス権限を復元し📎 src/mem_manager.cc:602、offload タイプに対しては CPU バックアップからデータを復元し📎 src/mem_manager.cc:610-626、FABRIC handle を再エクスポートする📎 src/mem_manager.cc:632-643ステップ2:barrier 同期📎 src/mem_manager.cc:646-658。
。tag は依然として 📎 src/mem_manager.cc:671-679ステップ3:新しい handle 情報を交換0xBEEF。
。各 rank がブロードキャストする必要があるローカルバッファの数を集計し 📎 src/mem_manager.cc:688-816、📎 src/mem_manager.cc:689-696でカウントを交換しbootstrapAllGather、オフセットを計算し📎 src/mem_manager.cc:710、その後まず📎 src/mem_manager.cc:724-728してからbootstrapSend(コメントに明記「send first, then receive to avoid deadlock」bootstrapRecvステップ4:peer バッファを再インポート📎 src/mem_manager.cc:783)。
。各 📎 src/mem_manager.cc:822-911のエントリに対して、交換結果の中から一致する handle 情報を検索するisImportedFromPeer && state==Released。POSIX FD タイプは hostHash が同じかどうかを確認する必要があり📎 src/mem_manager.cc:829-835、その後 proxy 経由で FD を取得し📎 src/mem_manager.cc:853-859インポートする📎 src/mem_manager.cc:866,cuMemImportFromShareableHandle。FABRIC タイプは直接インポートする📎 src/mem_manager.cc:873。その後📎 src/mem_manager.cc:878再マッピングするncclCuMemMapAndSetAccessステップ5:最終 barrier📎 src/mem_manager.cc:893。
。tag は 📎 src/mem_manager.cc:916-928であり、前述の0xCAFEと区別する。0xBEEF並行制御とハードウェア連携
参照カウントによるライフサイクル保護
まず:ncclMemManagerDestroyをデクリメントし、まだ 0 より大きければ現在の comm のポインタのみをクリアしrefCount 📎 src/mem_manager.cc:76、リソースは解放しない。これにより複数の comm が同じメモリマネージャを共有できる(例えば split_share のシナリオ)。📎 src/mem_manager.cc:81アトミックな initialized フラグ
:すべての操作の前にをチェックし、破棄済みの mutex へのアクセスを防ぐ。破棄時にはCOMPILER_ATOMIC_LOAD(&manager->initialized, memory_order_acquire) 📎 src/mem_manager.cc:136,242,338,358で 0 をストアしmemory_order_release、以前の書き込み操作が他のスレッドから可視であることを保証する。📎 src/mem_manager.cc:87CUDA VMM API の使用
は CUDA 仮想メモリ管理 API であり、物理メモリと仮想アドレスの分離を可能にする。これがサスペンド/レジュームの基盤である——サスペンド時には物理ページを unmap するが仮想アドレスは保持し、レジューム時には同じ仮想アドレスに再マッピングするため、確立済みのすべてのポインタ関係を変更する必要がない。:cuMemCreate/cuMemMap/cuMemUnmap/cuMemRelease本番環境の落とし穴ガイド
落とし穴1:split_share 通信ドメインはサスペンドをサポートしない
。もし 📎 src/mem_manager.cc:1014-1018なら、直接refCount > 1を返す。複数の comm がメモリマネージャを共有している場合、1つの comm をサスペンドすると他の comm のメモリに影響を与えるため。ncclInvalidUsage落とし穴2:POSIX FD のクロスノード無効化
。POSIX ファイルディスクリプタは同一ノード内でのみ有効であり、クロスノードレジューム時にはスキップする必要がある。ソースコードでは 📎 src/mem_manager.cc:853-859比較で同一ノードかどうかを判定している。hostHash落とし穴3:offload データ復元失敗時にバックアップを保持
。もし 📎 src/mem_manager.cc:635で CPU から GPU への復元が失敗した場合、ソースコードは警告を出力しcudaMemcpyを保持し、解放しない。これは呼び出し元にリトライの機会を与えるためだが、リトライしなければ CPU メモリがリークする。cpuBackup落とし穴4:
における use-after-free リスクncclMemUntrackDynamic。ソースコードはロック保持状態でエントリを見つけ、必要な情報を保存し、エントリを解放し、その後ロック外で統計を更新する📎 src/mem_manager.cc:302。この順序は正しいが、もし📎 src/mem_manager.cc:311-327ポインタが呼び出し元のスタックメモリを指しており、呼び出し元がロック外で読み取る場合、infoのライフサイクルが関数全体をカバーすることを保証する必要がある。infoコピー
flowchart TD
start["ncclCommMemSuspend(comm)"] --> check{"manager->released?"}
check -->|"是"| err1["返回 ncclInvalidUsage"]
check -->|"否"| sync["cudaDeviceSynchronize()"]
sync --> barrier1["bootstrapBarrier(tag=0xBEEF)"]
barrier1 --> pass1["第一遍: 遍历 entries"]
pass1 --> cond1{"isImportedFromPeer && Active?"}
cond1 -->|"是"| unmap1["cuMemUnmap + cuMemRelease"]
cond1 -->|"否"| skip1["跳过"]
unmap1 --> pass2["第二遍: 遍历 entries"]
skip1 --> pass2
pass2 --> cond2{"memType == Offload?"}
cond2 -->|"是"| backup["ncclCudaHostCalloc + cudaMemcpy D2H"]
cond2 -->|"否"| scratch["累加 releasedScratch"]
backup --> unmap2["cuMemUnmap + cuMemRelease"]
scratch --> unmap2
unmap2 --> mark["manager->released = 1"]
mark --> done["返回 ncclSuccess"]
err1 --> done18.4 登録キャッシュ:ncclRegister が重複 pin を回避する方法
直感的モデル
ネットワークカードが GPU メモリを直接読み書きする(GPUDirect RDMA)には、まずこのメモリを「登録」する必要がある——ネットワークカードに「このアドレスに直接アクセスしてよい」と伝える。登録プロセスはページの pin、IOMMU マッピングの確立を伴い、オーバーヘッドが大きい(ミリ秒単位)。毎回の AllReduce で再登録すると、小メッセージ通信のレイテンシは登録オーバーヘッドに完全に埋もれてしまう。
はまさに「登録キャッシュ」である:登録済みのアドレス範囲を順序付き配列に記録し、次回同じまたは包含するバッファに遭遇した場合、直接再利用し、再登録しない。ncclRegisterデータ構造とメモリレイアウト
の核心は順序付き配列
ncclRegCacheであり、各要素はslotsの主要フィールド(使用状況から推測):ncclReg*。ncclRegフィールド
| 型 | 意味 | ページアラインされた開始アドレス |
|---|---|---|
begAddr | uintptr_t | ページアラインされた終了アドレス |
endAddr | uintptr_t | ローカル参照カウント |
localRefs | int | グラフ参照カウント |
graphRefs | int | 登録状態ビット(NET/NVLS/COLLNET/IPC) |
state | int | ネットワーク handle リンクリスト |
netHandleHead | ncclRegNetHandles* | 网络 handle 链表 |
ipcInfos | ncclIpcInfo** | IPC 情報配列 |
ページアラインメント:begAddr = (uintptr_t)data & -pageSize 📎 src/register/register.cc:31,endAddr = ((uintptr_t)data + size + pageSize - 1) & -pageSize 📎 src/register/register.cc:32。-pageSizeはpageSizeの2の補数であり、「pageSize の倍数に切り下げる」ことと等価です。この理由は、登録の最小粒度がページであり、たとえ1バイトだけ登録してもページ全体を登録する必要があるためです。
Step-by-Step Walkthrough:1回の登録がどのようにキャッシュにヒットするか
シナリオ:ncclCommRegister(comm, buff=0x7f0000001000, size=4096, &handle)。
ステップ1:パラメータチェックとページアラインメント 📎 src/register/register.cc:18-24。CommCheckcomm の有効性を検証します。仮にpageSize=4096,begAddr = 0x7f0000001000 & -4096 = 0x7f0000001000,endAddr = (0x7f0000001000 + 4096 + 4095) & -4096 = 0x7f0000002000。
ステップ2:システムメモリチェック 📎 src/register/register.cc:36-64。もしncclCuMemEnable()なら、アドレス範囲とメモリタイプを照会します。もしmemType == CU_MEMORYTYPE_HOSTなら、CPU メモリであることを示し、登録をスキップします📎 src/register/register.cc:58-61。そうでなければ Sysmem セグメントがあるか確認します📎 src/register/register.cc:50-55。
ステップ3:キャッシュを走査して挿入位置を探す 📎 src/register/register.cc:66-89。ループslotは 0 から開始:
- もし
slot == population(末尾に到達)またはbegAddr < slots[slot]->begAddr(現在のアドレスがキャッシュエントリより前)なら、新しいエントリを作成する必要があることを示します📎src/register/register.cc:67。 - もし
slots[slot]->begAddr <= begAddr && slots[slot]->endAddr >= endAddrなら、現在のバッファが既存のエントリに完全に含まれていることを示し、参照カウントを直接増やします📎src/register/register.cc:83-87。
ステップ4:新しいエントリを作成 📎 src/register/register.cc:68-82。キャッシュが満杯なら、拡張します(初期 32、その後倍々)📎 src/register/register.cc:70。memmoveをslot位置で空間を空けるために使用します📎 src/register/register.cc:73,ncclCalloc新しいエントリを割り当てます📎 src/register/register.cc:74、begAddr/endAddrを設定し、isGraphに基づいてgraphRefsまたはlocalRefsを 1 に設定します📎 src/register/register.cc:78-79,population++、handle を返します。
ステップ5:登録解除 📎 src/register/register.cc:172-195。commDeregisterまず handle に対応する slot を見つけます📎 src/register/register.cc:180、参照カウントを減らします📎 src/register/register.cc:185-186。まだ参照があれば、直接返します📎 src/register/register.cc:187。そうでなければregCleanupを呼び出してすべての下位登録をクリーンアップします📎 src/register/register.cc:188、エントリを解放し、memmoveで穴を埋めます📎 src/register/register.cc:190,population--。
設計上の考察と本番での落とし穴
なぜハッシュテーブルではなく整列配列を使うのか?登録クエリは「範囲包含」クエリであり、完全一致ではないためです。整列配列は二分探索をサポートし(ソースコードは線形スキャンを使用していますが)、メモリ局所性も良好です。ハッシュテーブルは「このアドレスがより大きな範囲に含まれているか」といったクエリを効率的に処理できません。
regCleanupの状態ビット設計 📎 src/register/register.cc:95-134。stateはビットマスクであり、各ビットが1つの登録タイプ(NET/NVLS/COLLNET/IPC)に対応します。クリーンアップ時にはビットごとにチェックし、完了した登録のみをクリーンアップします。この設計により、一部の登録が成功し一部が失敗する状況が可能になります——例えばネットワーク登録は成功したが IPC 登録が失敗した場合、クリーンアップ時にはネットワーク部分のみをクリーンアップします。
本番の罠:登録キャッシュはメモリ解放を感知しない。ユーザーがあるバッファを登録し、その後登録解除せずにcudaFreeした場合、キャッシュには依然としてこのエントリが残っています。次回の割り当てで同じアドレスが再利用される可能性があり、キャッシュヒットするが実際のメモリは無効になっているという事態が発生します。NCCL の規約では、登録と登録解除はペアで行う必要があり、登録期間中にメモリが解放されないことをユーザーが保証する責任があります。
ncclCommRegisterのスキップ条件 📎 src/register/register.cc:150-159。もしLocalRegister=0またはP2pUsesMemcpy=1なら、直接NULLhandle を返します。これは、特定の構成(例えば P2P が RDMA ではなく memcpy を使用する場合)では、登録が完全にスキップされることを意味します。呼び出し側は handle が NULL かどうかを確認する必要があります。
18.5 集合通信登録:coll_reg が異なるアルゴリズムに対して登録戦略をどのように選択するか
直感的モデル
異なる集合通信アルゴリズムは異なる転送パスを通ります:NVLS は NVLink SHARP、Ring は P2P またはネットワーク、Tree はツリートポロジです。各パスには異なる登録方法が必要です:NVLS は NVLS ハードウェアに登録する必要があり、ネットワークは NIC に登録する必要があり、IPC は対向 GPU に登録する必要があります。coll_reg.ccはまさに「登録戦略ルーター」です:アルゴリズム、プロトコル、バッファタイプに基づいて、どの登録関数を呼び出すかを決定します。これがなければ、各アルゴリズムが独自に登録ロジックを実装する必要があり、コードが重複しエラーが発生しやすくなります。
Step-by-Step Walkthrough:Ring アルゴリズムの登録判断
シナリオ:ncclRegisterCollBuffers(comm, info, outRegBufSend, outRegBufRecv, cleanupQueue, regNeedConnect)、ここでinfo->algorithm == NCCL_ALGO_RING,info->protocol == NCCL_PROTO_SIMPLE。
ステップ1:事前チェック 📎 src/register/coll_reg.cc:155-157。regBufType = NCCL_REGULAR_BUFFER,regNeedConnect = trueを設定します。もしLocalRegister=0かつ永続グラフ登録でなければ、直接終了します。
ステップ2:Ring ブランチに入る 📎 src/register/coll_reg.cc:338。recvRegRecord/sendRegRecordを NULL に初期化し、sendNetConns/sendNetHandles/recvNetConns/recvNetHandles/srecvNetHandles配列を割り当てます📎 src/register/coll_reg.cc:356-360。
ステップ3:既存の登録レコードを探す 📎 src/register/coll_reg.cc:351-355。ncclRegFindキャッシュ内で recv/send バッファを探します。recv が見つからず永続グラフ登録でなければ、終了します📎 src/register/coll_reg.cc:352。クロスノードかつ send が見つからず永続グラフ登録でなければ、終了します📎 src/register/coll_reg.cc:354。
ステップ4:すべての channel を走査して peer を収集 📎 src/register/coll_reg.cc:362-393。各 channel について、ring.prevとring.nextを確認します。接続フラグにNCCL_DIRECT_NICが含まれていれば、recvNetConns/sendNetConns 📎 src/register/coll_reg.cc:370-379に記録します。もしNCCL_P2P_READ | NCCL_P2P_WRITEが含まれていれば、peer をpeerRanks配列に追加します📎 src/register/coll_reg.cc:382-391。
ステップ5:IPC 登録 📎 src/register/coll_reg.cc:394-407。もしnPeers > 0 && comm->isAllDirectP2pなら、まずグラフ登録を試みます📎 src/register/coll_reg.cc:395-399、失敗したらローカル登録を試みます📎 src/register/coll_reg.cc:400-403。成功したら、regBufType = NCCL_IPC_REG_BUFFER 📎 src/register/coll_reg.cc:406。
を設定します 📎 src/register/coll_reg.cc:409-457ステップ6:ネットワーク登録!comm->useNetPXN && comm->useGdr && netDeviceType != UNPACK。📎 src/register/coll_reg.cc:415-418かつ AllReduce 以外の PreMulSum/SumPostDiv を確認します📎 src/register/coll_reg.cc:419-430。まずグラフ登録を試みます📎 src/register/coll_reg.cc:431-442、失敗したらローカル登録regBufType |= NCCL_NET_REG_BUFFER。成功したら、📎 src/register/coll_reg.cc:445-452。
を設定し、handle 配列を保存します 📎 src/register/coll_reg.cc:551-554ステップ7:チャネル数の調整
。IPC 登録のみでシングルノードかつチャネル数が 17-24 の間であれば、16 に下げます。これは IPC 登録後の帯域特性に合わせるためです。
設計上の考察と本番での落とし穴なぜ NVLS と Ring の登録順序は逆なのか?📎 src/register/coll_reg.cc:86-94NVLS ブランチはまずグラフ登録を試みてからローカル登録📎 src/register/coll_reg.cc:395-403、一方 Ring ブランチはまずローカルを試みてからグラフ
isMloPartBufRdmaCapable。これは NVLS のグラフ登録の方が成功しやすく(NVLS ハードウェアは永続バッファに対して最適化されている)、Ring のローカル登録の方が軽量であるためです。 📎 src/register/coll_reg.cc:14-37。コメントは「登録判断はグローバルでなければならず、communicator 全体の保証を使用する」ことを強調している📎 src/register/coll_reg.cc:20。これは、ある rank のバッファが RDMA をサポートしていても、通信ドメイン内にサポートしない rank が1つでもあれば、通信ドメイン全体で登録されないことを意味する。これは一部の rank が登録し、一部が登録しないことによる不整合を避けるためである。
本番環境の落とし穴:登録失敗時のサイレントデグラデーション。ncclRegisterCollBuffers登録失敗時にはエラーを報告せず、regBufTypeの対応するビットを設定しないだけである。これは通信が依然として動作するが、性能が低下することを意味する。本番環境で性能が期待に達しない場合、NCCL_REGログを確認して登録が成功したかどうかを確認すべきである。
flowchart LR
subgraph input["输入"]
task["ncclTaskColl<br/>algorithm=RING<br/>protocol=SIMPLE"]
end
subgraph ipc["IPC 注册路径"]
find["ncclRegFind<br/>查找缓存"]
collect["遍历 channel<br/>收集 peerRanks"]
ipcReg["ncclIpcLocalRegisterBuffer<br/>或 GraphRegister"]
end
subgraph net["网络注册路径"]
checkGdr{"useGdr &&<br/>!useNetPXN?"}
netReg["ncclNetLocalRegisterBuffer<br/>或 GraphRegister"]
end
subgraph output["输出"]
regType["info->regBufType<br/>NCCL_IPC_REG_BUFFER<br/>NCCL_NET_REG_BUFFER"]
handles["info->sendNetHandles<br/>info->recvNetHandles"]
end
task --> find
find --> collect
collect --> ipcReg
ipcReg --> regType
find --> checkGdr
checkGdr -->|"是"| netReg
checkGdr -->|"否"| regType
netReg --> regType
netReg --> handles上の図は Ring アルゴリズム下の2つの並行する登録パスを示している:IPC パスは同一ノードの P2P 接続を処理し、ネットワークパスはノード間の RDMA 接続を処理する。2つのパスは独立して実行され、最終的に両方ともinfo->regBufType。
18.6 本番環境の落とし穴回避と障害復旧チェーン
落とし穴1:登録キャッシュとメモリプールの相互作用
ncclMemAllocでメモリを割り当てる場合、内部的に CUDA VMM API📎 src/allocator.cc:38-94を使用する。この割り当て方法で作成された物理メモリにはgpuDirectRDMACapableフラグが付いており📎 src/allocator.cc:54、RDMA をネイティブにサポートすることを意味する。しかしncclMemFreeで解放する際、メモリマネージャが既に破棄されている場合、cudaFreeフォールバックパス📎 src/allocator.cc:130-132を通る。これにより VMM で割り当てられたメモリが誤ってcudaFreeで解放される可能性がある。本番環境ではncclMemAlloc/ncclMemFreeのペア使用を確保し、メモリマネージャ破棄後に解放しないようにしなければならない。
落とし穴2:サスペンド中の通信リクエスト
ncclCommMemSuspend実行中に新しい通信リクエストが到着した場合、どうなるか?ソースコードはサスペンド前にcudaDeviceSynchronize() 📎 src/mem_manager.cc:440を呼び出し、キューに入れられたすべての GPU 操作が完了することを保証する。しかし host 側の通信リクエストがキューに投入されている場合、明示的な保護はない。本番環境ではサスペンド前にすべての通信スレッドを停止するか、group セマンティクスを使用してサスペンド操作と他の操作が直列化されることを保証すべきである。
落とし穴3:FABRIC handle の互換性
ncclMemAllocCUDA 12.3+ では FABRIC handle📎 src/allocator.cc:60-71の使用を試みる。もしcuMemCreateがCUDA_ERROR_NOT_PERMITTEDまたはCUDA_ERROR_NOT_SUPPORTEDを返した場合、POSIX FD📎 src/allocator.cc:63-65にフォールバックする。しかし復元時に handle タイプが FABRIC であるがエクスポートに失敗した場合、直接エラーを報告し unmap📎 src/mem_manager.cc:649-655する。これは混合環境(一部の GPU が FABRIC をサポートし、一部がサポートしない)では、サスペンド/レジュームが失敗する可能性があることを意味する。
落とし穴4:参照カウントリーク
ncclRegisterキャッシュヒットするたびに参照カウント📎 src/register/register.cc:84-85が増加する。呼び出し元が N 回登録したが M 回しか登録解除しなかった場合(M < N)、参照カウントは永遠にゼロにならず、regCleanupは永遠に呼び出されず、基盤の登録リソースがリークする。本番コードでは厳密にペアにする必要があるncclCommRegister/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: 注册完成本章の考察とセルフチェック
Q1: もしncclSpaceFreeのif (a->count == 0 || a->cuts[a->count - 1] <= offset)チェック📎 src/allocator.cc:231-237を削除した場合、どのようなシナリオで範囲外アクセスが発生するか?
参考解析:このチェックには2つの役割がある。第一に、a->count == 0空配列アクセスcuts[-1]を防ぐ。第二に、a->cuts[a->count-1] <= offsetoffsetが割り当て済み範囲を超えるのを防ぐ。削除した場合、count == 0のとき、a->cuts[a->count - 1]はcuts[-1]を読み取り、これは未定義動作であり、ヒープメタデータを読んだりセグメンテーションフォルトを引き起こす可能性がある。さらに隠蔽的なのは、count > 0であっても、offsetが最後のカットポイントより大きい場合、後続のwhile (a->cuts[i] <= offset) i += 2ループ📎 src/allocator.cc:247がiを増加し続けて範囲外になるまで続く。なぜならcuts[]内にoffsetより大きい要素が存在しないからである。本番環境でのトリガーシナリオは:呼び出し元が一度も割り当てられていないオフセットを渡した場合(例えばバッファが外部で解放された後に再度 free を呼び出す)、またはncclSpaceが並行変更されて状態が不整合になった場合。修正方法はこのチェックを保持し、エラー返却時にoffsetとcountを出力して調査を容易にすることである。
Q2: ncclMemManagerDestroyにおいて、もしrefCountがデクリメント後も0より大きい場合、現在の comm のポインタのみをクリアしリソースは解放しない📎 src/mem_manager.cc:78-83。このとき別の comm がncclMemTrackを呼び出している場合、何が起こるか?
参考解析:ncclMemTrackまずmanager->initialized 📎 src/mem_manager.cc:136をチェックする。refCount > 0のときinitialized = 0は設定されないため、チェックは通過する。次にmanager->lockを取得しentriesリンクリスト📎 src/mem_manager.cc:188-192を変更する。これは安全である。なぜならrefCount > 0は少なくとも1つの comm が参照を保持していることを意味し、メモリマネージャは破棄されないからである。真のリスクは:最後の comm がncclMemManagerDestroyを呼び出すとき、refCountが0にデクリメントされ、initialized = 0 📎 src/mem_manager.cc:87を設定しすべてのリソースを解放する。このとき別のスレッドがncclMemTrack内で既にinitializedチェックを通過しているがまだロックを取得していない場合、解放済みのmanager->lockにアクセスし、use-after-free が発生する。ソースコードはmemory_order_acquire/releaseのペアリングでこの問題を緩和しているが、厳密にはまだ競合ウィンドウが存在する。本番環境ではメモリマネージャを破棄する前にすべての通信スレッドが停止していることを保証すべきである。
Q3:ncclCommMemResumeにおいて、POSIX FD タイプの peer バッファはノード間でスキップされる📎 src/mem_manager.cc:853-859。もしすべての peer バッファがスキップされた場合、restoredPeerCountは0であるが、manager->releasedは依然として0に設定される📎 src/mem_manager.cc:913。これによりどのような結果が生じるか?
参考解析:manager->released = 0はメモリマネージャが復元完了と見なすことを意味する。しかしスキップされた peer バッファがある場合、それらのstateは依然としてncclDynMemStateReleased,handleであり、依然として0である。後続の通信がこれらのバッファにアクセスすると、CUDA エラー(未マップの仮想アドレスへのアクセス)が発生する。さらに深刻なのは、ncclCommMemStatsがncclStatGpuMemSuspendedをクエリすると0(アクティブ)📎 src/mem_manager.cc:1130を返すが、実際には一部のメモリが復元されていないことである。この問題の根本原因は:ノード間 POSIX FD はそもそもインポートされるべきではない——サスペンド前に、これらのバッファは存在すべきでないentries中。正しい方法は、サスペンド時にクロスノードの POSIX FD エントリを回復不能としてマークするか、レジューム時にエラーを返して暗黙的にスキップしないことです。本番環境では、POSIX FD を使用しクロスノードである場合、FABRIC ハンドルに切り替えるか、サスペンド/レジュームが単一ノード内でのみ行われることを保証すべきです。
メモリ管理は NCCL パフォーマンスの見えない支柱です:ncclSpace極めて簡潔なカットポイント配列でアドレス空間を管理し、ncclShadowPool64 ビットビットマップとハッシュテーブルでデバイス/ホストオブジェクトのペアリングを管理し、ncclMemManager参照カウントと CUDA VMM API でサスペンド・レジュームを実現し、ncclRegister順序付き配列で登録結果をキャッシュして重複 pin を回避します。これら 4 層のメカニズムが共に「通信前にメモリを再登録する必要がない」という重要なパフォーマンス保証を支えています。次の章ではデバイス側コミュニケータと ABI 互換性に入り、devcommがこれらの host 側のメモリレイアウトを GPU kernel からアクセス可能な構造にどのようにマッピングするかを見ていきます。
上の図は登録のタイミングを示しています:キャッシュヒット時は参照カウントを増やすだけで、下位層の登録は呼び出しません。キャッシュミス時にのみ新しいエントリを作成し、下位層の登録をトリガーします。ここまでで、host 側のメモリ管理メカニズムは明確になりました。しかし通信は最終的に GPU 上で発生し、kernel は対向 rank のアドレスと接続状態に直接アクセスする必要があります。次の章ではデバイス側コミュニケータと ABI 互換性に入り、devcomm が host 側 ncclComm のメタデータをデバイス側からアクセス可能な構造にどのようにマッピングするか、そしてバージョン化された ABI が新旧 kernel とライブラリの互換性をどのように保証するかを見ていきます。
第 19 章:第 19 章:デバイス側通信ドメインと ABI 互換性:devcomm と kernel の通信契約
第 19 章:デバイス側通信ドメインと ABI 互換性:devcomm と kernel の通信契約
前の章では、host 側の ncclMemManager が参照カウントと CUDA VMM API で通信バッファのライフサイクルを管理しているのを見ました。しかし通信が実際に発生する場所は GPU kernel です——kernel 内のスレッドは知る必要があります:自分はどの rank か?対向 rank のバッファはどの仮想アドレスにあるか?接続は準備できているか?これらの情報は host 側の ncclComm 構造体にありますが、kernel は host ポインタを直接デリファレンスできません。もし NCCL が kernel に毎回パラメータ渡しやグローバルメモリクエリでこれらのメタデータを取得させると、毎回の通信で余分なレイテンシと帯域オーバーヘッドが発生します。さらに悪いことに、kernel コードが一度コンパイルされると、アクセスするフィールドのオフセットは固定されます——ライブラリのアップグレードで ncclComm のレイアウトが変わると、古い kernel は誤ったデータを読み取ってしまいます。これが devcomm が解決すべき核心的な問題です:host 側通信ドメインの重要なメタデータを、安定したバージョン化されたメモリレイアウトで、デバイス側からアクセス可能な構造にマッピングすることです。src/devcomm ディレクトリ下の devcomm_v22902.cc、devcomm_v22907.cc、devcomm_v23000.cc、devcomm_v23100.cc がこのバージョン化 ABI の具体的な実装です。各ファイルは 1 つの NCCL バージョン区間に対応し、その区間内の ncclDevComm の正確なメモリレイアウト、および新旧バージョン間のフィールドコピーロジックを定義しています。本章では順に分解していきます:デバイス側コミュニケータの核心データ構造はどのようなものか、バージョン化 ABI の登録とマッチングメカニズムはどのように動作するか、新旧バージョン間でどのようにフィールドレベルの変換を行うか、そしてこのメカニズムの本番環境における境界と落とし穴について。
一、デバイス側コミュニケータの核心構造:ncclDevComm のメモリレイアウト
直感的モデル
ncclDevCommを「工位カード」と想像してください:各 GPU kernel が起動するとき、1 枚のカードを受け取ります。そこには「あなたは 3 番 rank、全体で 8 rank、あなたの LSA グループには 4 rank、対向バッファのベースアドレスは 0x7f...」と書かれています。このカードは十分小さく(kernel パラメータに収まる)かつ、すべての重要情報を含む必要があります。もしこのカードが存在しなければ、kernel は host 側から繰り返しパラメータを渡すしかなく、毎回の通信で再組み立てが必要になります——レイテンシが高く、エラーが発生しやすくなります。
データ構造とメモリレイアウト
ncclDevComm_v23000を例にとると、その完全な定義は📎 src/devcomm/devcomm_v23000.cc:25-62:
struct ncclDevComm_v23000 {
unsigned int magic; // 偏移 0,魔数校验
unsigned int version; // 偏移 4,版本号
int rank, nRanks; // 偏移 8, 12
uint32_t nRanks_rcp32; // 偏移 16,nRanks 的倒数(定点数)
int lsaRank, lsaSize; // 偏移 20, 24
uint32_t lsaSize_rcp32; // 偏移 28
ncclDevCommWindowTable_t windowTable; // 偏移 32
ncclWindow_t resourceWindow; // 偏移 40
ncclResourceWindow_vidmem_v23000_t resourceWindow_inlined; // 偏移 48
ncclGinBarrierHandle_t hybridWorldGinBarrier; // 偏移 112
...
};📎 src/devcomm/devcomm_v23000.cc:64-93一連のstatic_assertで各フィールドのオフセットを固定しています。これは装飾ではありません——ABI 互換性のコンパイル時契約です。もしあるフィールドのオフセットがコンパイラのアライメント戦略の変化で移動すると、コンパイルが失敗し、実行時にデバッグ困難なメモリのずれが発生することはありません。
いくつかの重要なフィールドの設計動機:
nRanks_rcp32とlsaSize_rcp32:これはnRanksとlsaSizeの逆数であり、32ビット固定小数点数で表現される。カーネル内でrankからバッファオフセットへの除算を行う際、GPUの整数除算は非常に遅いため、逆数を掛けてシフトする方式で大幅に高速化できる。これは典型的な「空間と引き換えに時間を節約する」手法であり、4バイト多く保存することで、毎回の除算にかかる数十クロックサイクルを節約する。
resourceWindow_inlined:これはインラインのウィンドウ記述子であり、型はncclResourceWindow_vidmem_v23000_tである。注意📎 src/devcomm/devcomm_v23000.cc:11-18におけるその定義:
typedef struct ncclResourceWindow_vidmem_v23000 {
char reserved1[8];
char* lsaFlatBase;
char reserved2[8];
uint32_t stride4G;
uint32_t mcOffset4K;
char reserved3[32]; // NOTE: shrunk from 40 in 2.30u1 to reclaim 8 bytes
} ncclResourceWindow_vidmem_v23000_t;ここでのreserved1、reserved2、reserved3はパディングフィールドであり、プレースホルダーとして使用される。なぜパディングが必要か?それはncclDevComm_v23000のレイアウトが「基準バージョン」とオフセットを一致させなければならず、たとえ一部のフィールドが現在のバージョンで使われなくなっても、後続フィールドのオフセットを不変に保つためにプレースホルダーを保持する必要があるからである。📎 src/devcomm/devcomm_v23000.cc:11-18のコメントは明確に説明している:2.30u1はreserved3を40バイトから32バイトに縮小し、8バイトをhybridWorldGinBarrierのために空けた。これはレイアウト再配置である——パディング領域を縮小することで、全体サイズを変えずに新しいフィールドを詰め込んだ。
📎 src/devcomm/devcomm_v23000.cc:11-18のstatic_assertがさらに検証する:lsaFlatBase、stride4G、mcOffset4Kの3つのフィールドのオフセットは「現在のバージョン」のncclWindow_vidmemと一致しなければならず、構造体全体のサイズは64バイトである。これはresourceWindow_inlinedがv23000と現在のバージョンの間でバイナリ互換であることを意味する——直接memcpyできる。
バージョン化構造体のファミリー
比較ncclDevComm_v22902 📎 src/devcomm/devcomm_v22902.cc:38-62とncclDevComm_v22907 📎 src/devcomm/devcomm_v22907.cc:13-41により、フィールドの進化が見える:
| フィールド | v22902 | v22907 | v23000 |
|---|---|---|---|
magic/version | なし | なし | あり(オフセット0/4) |
ginContextCount | uint8_t | uint32_t | uint32_t |
ginNetDeviceTypes | [4] | [NCCL_GIN_MAX_CONNECTIONS] | [NCCL_GIN_MAX_CONNECTIONS] |
ginIsRailed | なし | bool | に分割ginConnectionsRailed + ginContextsRailed |
hybridWorldGinBarrier | なし | なし | あり(オフセット112) |
| 構造体サイズ | 200 | 224 | 240 |
この進化の道筋はNCCLのバージョン戦略を明らかにしている:必要な時にのみフィールドを追加し、できるだけパディング領域を活用する。v22902からv22907ではginSignalBase、ginCounterBase、ginContextBase、ginIsRailedなどのGIN関連フィールドが追加された;v22907からv23000ではmagic/version検証フィールドとhybridWorldGinBarrierが追加され、同時にginIsRailedが2つのより精密なフラグビットに分割された。
---
二、バージョン化ABIの登録とマッチング:ncclDevCommCompat構造
直感的モデル
バージョン化ABIを「翻訳プラグイン」のセットとして想像しよう:アプリケーションがNCCL 2.29.2でコンパイルされたが、実行時にリンクされるのは2.31.0のライブラリである場合、ライブラリは「2.29.2のカーネルがどのようなncclDevCommレイアウトを期待するか」を知り、現在のバージョンのncclDevCommを古いレイアウトに翻訳する必要がある。各バージョン区間は1つの翻訳プラグインに対応し、グローバルテーブルに登録される。
中核構造:ncclDevCommCompat
各devcomm_vXXXXX.ccファイルの末尾にncclDevCommCompat構造体が定義されている。v23000を例にすると📎 src/devcomm/devcomm_v23000.cc:192-199:
struct ncclDevCommCompat ncclDevCommCompat_v23000 = {
NCCL_VERSION(2, 30, 0), // minVersion
NCCL_VERSION(2, 30, 7), // maxVersion
nullptr, // commPropertiesFilter
ncclDevCommRequirementsFilter_v23000, // devCommRequirementsFilter
ncclDevCommCopyNewToOld_v23000, // devCommCopyNewToOld
ncclDevCommCopyOldToNew_v23000, // devCommCopyOldToNew
};6つのフィールドの意味:
1. minVersion / maxVersion:このプラグインが担当するバージョン区間。v23000は2.30.0から2.30.7をカバーする。
2. commPropertiesFilter:オプションのフィルターであり、ncclCommPropertiesにおいて旧バージョンに公開される機能フラグを調整するために使用される。v23000ではnullptrに設定され、フィルタリングが不要であることを示す。
3. devCommRequirementsFilter:アプリケーションが要求するデバイス側リソースが旧バージョンと互換性があるかどうかをチェックする。v23000の実装📎 src/devcomm/devcomm_v23000.cc:95-98は単にginTypeをcomm->sharedResからreqs。
4. devCommCopyNewToOldにコピーする:ncclDevComm:現在のバージョンの
5. devCommCopyOldToNewを旧バージョンのレイアウトにコピーする。
:旧バージョンのレイアウトを現在のバージョンにコピーし戻す。
バージョン区間の分割
| 4つのファイルのバージョン区間: | minVersion | maxVersion | ファイル |
|---|---|---|---|
devcomm_v22902.cc | 2.29.2 | 2.29.3 | 備考 |
devcomm_v22907.cc | 2.29.5 | 2.29.7 | 最も初期のバージョン化実装 |
devcomm_v23000.cc | 2.30.0 | 2.30.7 | GINフィールドを追加するが、GINの後方互換性は提供しない |
devcomm_v23100.cc | 2.31.0 | magic/version検証を追加 | 現在のバージョン |
📎 src/devcomm/devcomm_v23100.cc:10-17すべてのフィルターがnullptrであり、完全互換を示すnullptrのv23100プラグインはすべてのコールバックがncclDevCommであり、これは2.31.0以降、
〔設計上の推論とアーキテクチャのトレードオフ〕
v22902とv22907の間のバージョン区間に「隙間」があることに注意(2.29.4と2.29.6には対応するプラグインがない)。これはこれらのバージョンがリリースされなかったか、それらのレイアウトが隣接バージョンと完全に一致し再利用可能であるためかもしれない。
マッチングフローncclCommGetDeviceHandleアプリケーションが
または類似のAPIを呼び出すとき、NCCLは以下を行う必要がある:reqs->version)。
1. アプリケーションのコンパイル時に埋め込まれたNCCLバージョン番号を読み取る(ncclDevCommCompat2. グローバルな
テーブルでそのバージョンをカバーするプラグインを探す。devCommCopyNewToOld3. 見つかった場合、プラグインの
を呼び出して現在のレイアウトを旧レイアウトに変換する。
4. 見つからない場合、エラーを返すかデフォルトの動作を使用する。
flowchart TD
start["应用请求设备侧通信器"] --> read_ver["读取 reqs->version<br/>(应用编译时版本)"]
read_ver --> find_compat{"在 ncclDevCommCompat 表中<br/>查找覆盖该版本的插件?"}
find_compat -->|找到| check_filter["调用 devCommRequirementsFilter<br/>检查资源请求兼容性"]
find_compat -->|未找到| err_unsupported["返回 ncclInvalidUsage<br/>版本不兼容"]
check_filter --> filter_ok{"过滤器返回<br/>ncclSuccess?"}
filter_ok -->|是| copy_new_to_old["调用 devCommCopyNewToOld<br/>把当前布局转为旧布局"]
filter_ok -->|否| err_gin["返回 ncclInvalidUsage<br/>GIN 资源不兼容"]
copy_new_to_old --> done["返回旧布局 ncclDevComm"]
err_unsupported --> done_err["应用收到错误"]
err_gin --> done_err---
コピー
三、フィールドレベルの変換:新旧レイアウトの相互変換方法
直感的モデルncclDevCommバージョン変換は「翻訳」のようなもの:新バージョンのrankは現代中国語の文章であり、旧バージョンのレイアウトは漢文である。翻訳器はフィールドごとに対応させる必要がある——直接対応するフィールドもあれば(rank対ginConnectionStride > 1)、「意訳」が必要なフィールドもあり(ginConnectionsRailed = trueを
に翻訳)、旧バージョンに存在しないフィールドもある(直接破棄)。
NewToOld変換:現在のバージョンから旧バージョンへncclDevCommCopyNewToOld_v23000を例にすると📎 src/devcomm/devcomm_v23000.cc:114-152:
static ncclResult_t ncclDevCommCopyNewToOld_v23000(ncclComm_t comm, void* oldDevComm,
struct ncclDevComm const* newDevComm) {
struct ncclDevComm_v23000* old = (struct ncclDevComm_v23000*)oldDevComm;
memset(old, '\0', sizeof(*old)); // 先清零,防止未初始化字段泄露
old->magic = newDevComm->magic;
old->version = newDevComm->version;
old->rank = newDevComm->rank;
...
old->ginConnectionsRailed = (newDevComm->ginConnectionStride > 1);
old->ginStrongLegacySignals = newDevComm->ginStrongLegacySignals;
old->ginContextsRailed = (newDevComm->ginContextStride > 1);
...
}重要なステップ:
1. memsetゼロクリア 📎 src/devcomm/devcomm_v23000.cc:118:これは安全対策である——旧構造体には新バージョンに存在しないフィールドがある可能性があり、ゼロクリアにより未初期化メモリがデバイス側に漏れるのを防ぐ。
2. 直接フィールドコピー:rank、nRanks、lsaRankなどを直接代入。
3. インラインウィンドウ変換:呼び出しncclDevCommCopyResourceWindowNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:100-105、フィールドごとにコピーlsaFlatBase、stride4G、mcOffset4K。
4. セマンティック変換:ginConnectionsRailed = (newDevComm->ginConnectionStride > 1) 📎 src/devcomm/devcomm_v23000.cc:142。新バージョンではginConnectionStride(整数ステップ)でrailedかどうかを表し、旧バージョンではブール値を使用する。ステップが1より大きい場合、接続がrailedであることを示す。
5. 配列コピー:memcpyコピーginNetDeviceTypesとginHandles配列📎 src/devcomm/devcomm_v23000.cc:135-136。
OldToNew変換:旧バージョンから現在のバージョンへ
逆変換は📎 src/devcomm/devcomm_v23000.cc:154-190:
static ncclResult_t ncclDevCommCopyOldToNew_v23000(ncclComm_t comm, struct ncclDevComm* newDevComm,
void const* oldDevComm) {
struct ncclDevComm_v23000 const* old = (struct ncclDevComm_v23000 const*)oldDevComm;
newDevComm->magic = old->magic;
...
newDevComm->ginConnectionStride = old->ginConnectionsRailed ? old->lsaSize : 1;
newDevComm->ginContextStride = old->ginContextsRailed ? old->lsaSize : 1;
...
}注意📎 src/devcomm/devcomm_v23000.cc:180-181のセマンティック変換:旧バージョンでginConnectionsRailedが真であれば、新バージョンのginConnectionStrideはlsaSize;そうでなければ 1 に設定する。ここではlsaSizeをステップ幅として使用している。これは railed モードでは各 LSA グループ内の rank が 1 つの GIN 接続を共有しており、ステップ幅が LSA グループのサイズに等しいためである。
v22902 の特別処理
ncclDevCommCopyOldToNew_v22902 📎 src/devcomm/devcomm_v22902.cc:149-167には重要なコメントがある:
// Note: this callback will be used with v22907 as well because, prior to 2.30.0, ncclDevComm was unversioned,
// so v22902 and v22907 variants are indistinguishable.これは、2.30.0 より前ではncclDevCommにmagic/versionフィールドが存在しないため、ライブラリが古い構造体を v22902 なのか v22907 なのか区別できないことを意味する。したがって、v22907 のdevCommCopyOldToNewはnullptr 📎 src/devcomm/devcomm_v22907.cc:128に設定され、実際には v22902 のバージョンが使用される。両方とも GIN の後方互換性をサポートしていないため、GIN 関連フィールドの差異は正確性に影響しない。
リソースウィンドウのバージョン管理
ncclWindow_vidmem_v22902の定義はdevcomm_v22902.h内にあり(本章ではそのファイルの内容は提供されていない)が、📎 src/devcomm/devcomm_v22902.cc:141と📎 src/devcomm/devcomm_v22902.cc:164から、v22902 がncclDevCommCopyResourceWindow_v22902を使用してウィンドウ変換を行うことがわかる。この関数はdevcomm_v22902.hで宣言されており、具体的な実装は本章のソースコードには示されていない。
📎 src/devcomm/devcomm_v23000.cc:11-18のstatic_assertは、v23000 のウィンドウレイアウトが現在のバージョンと一致することを検証しているため、v23000 の変換関数はフィールドごとにそのままコピーできる。
---
四、能力フィルタリングとリソースチェック:古い kernel がサポートされていない機能にアクセスするのを防ぐ
直感的モデル
バージョン変換は単なる「フィールドの引っ越し」ではない——古いバージョンがアプリケーションの要求する機能をサポートしているかもチェックする必要がある。例えば、2.29.2 でコンパイルされた kernel が GIN リソースを要求しても、2.29.2 のncclDevCommレイアウトでは GIN フィールドが不完全であり、直接変換すると kernel がゴミデータを読んでしまう。そのため、変換前にこのような要求を遮断する「フィルター」が必要となる。
commPropertiesFilter:能力フラグのフィルタリング
ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77:
static ncclResult_t ncclCommPropertiesFilter_v22907(ncclComm_t comm, struct ncclCommProperties* props) {
// We don't provide backwards compatibility for GIN with 2.29.7. If a communicator needs it, we indicate that
// the Device API is not available.
props->deviceApiSupport = (props->deviceApiSupport && ncclTeamLsa(comm).nRanks == comm->nRanks);
props->ginType = NCCL_GIN_TYPE_NONE;
props->railedGinType = NCCL_GIN_TYPE_NONE;
return ncclSuccess;
}3 つの操作:
1. deviceApiSupportを降格:LSA グループの rank 数が総 rank 数と等しくない場合(つまりノード間通信が存在する場合)、デバイス API を無効化する。これは 2.29.7 の GIN がノード間通信をサポートしていないためである。
2. ginTypeを NONE に設定:アプリケーションに「このバージョンは GIN をサポートしていない」ことを明示的に伝える。
3. railedGinTypeを NONE に設定:同上。
ncclCommPropertiesFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:86-96も同様だが、1 つ細かい点が追加されている:
// 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-17v22902 の GIN 型列挙を定義している:
typedef enum : uint8_t {
NCCL_GIN_TYPE_NONE_v22902 = 0,
NCCL_GIN_TYPE_PROXY_v22902 = 2,
NCCL_GIN_TYPE_GDAKI_v22902 = 3,
} ncclGinType_t_v22902;これはuint8_t型であることに注意。一方、新しいバージョンではginTypeはintである。したがって v22902 のフィルターはpropsをncclCommProperties_v22902*に強制キャストしてから、uint8_t型のginType。📎 src/devcomm/devcomm_v22902.cc:35-36のstatic_assertに書き込む必要がある。ginTypeは
devCommRequirementsFilter:リソース要求のチェック
ncclDevCommRequirementsFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:79-98はアプリケーションが GIN リソースを要求したかどうかをチェックする:
static ncclResult_t ncclDevCommRequirementsFilter_v22907(ncclComm_t comm, ncclDevCommRequirements_t* reqs) {
bool requestedGinResources =
reqs->ginSignalCount > 0 || reqs->ginCounterCount > 0 || reqs->barrierCount > 0 || reqs->railGinBarrierCount > 0;
struct ncclDevResourceRequirements* node = reqs->resourceRequirementsList;
while (!requestedGinResources && node != nullptr) {
requestedGinResources = node->ginSignalCount > 0 || node->ginCounterCount > 0;
node = node->next;
}
if (requestedGinResources && (reqs->ginConnectionType != NCCL_GIN_CONNECTION_NONE || reqs->ginForceEnable)) {
// 打印警告并返回错误
return ncclInvalidUsage;
}
return ncclSuccess;
}ロジックは 2 ステップに分かれる:
1. トップレベルの要求をチェック:reqs->ginSignalCount、ginCounterCount、barrierCount、railGinBarrierCountのいずれかが 0 より大きければ、GIN リソースが要求されたことを示す。
2. リソース要求リンクリストを走査:トップレベルで要求がなければ、resourceRequirementsListリンクリストを走査し続け、各ノードのginSignalCountとginCounterCount。
をチェックする。ginConnectionTypeもし実際に GIN リソースが要求されており、かつNONEがginForceEnableでない、またはncclInvalidUsageが真であれば、
ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126を返して警告を出力し、アプリケーションの再コンパイルが必要であることを通知する。barrierCountはより複雑で、GIN チェックに加えて
// 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;〔設計上の推論とアーキテクチャのトレードオフ〕barrierCount2.29.4 より前では、barrierCountは LSA barrier のみを表し、GIN の要求を暗黙的に含まなかった。2.29.4 以降、barrierCountは GIN の要求を暗黙的に含む。古いバージョンとの互換性のため、フィルターはlsaBarrierCountをbarrierCountに変換し、railGinBarrierCount。
と
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---
以下のシーケンス図は、アプリケーション要求からバージョン変換までの完全なインタラクションを示している:
コピー
五、本番環境の落とし穴ガイドと障害復旧チェーン落とし穴 1:GIN リソース要求と古いバージョン kernel の衝突ncclGinPut)。
シナリオ:ncclDevCommRequirementsFilter_v22902 📎 src/devcomm/devcomm_v22902.cc:98-126:アプリケーションは NCCL 2.29.2 でコンパイルされたが、実行時に 2.31.0 のライブラリにリンクされた。アプリケーションは kernel 内で GIN 関連のデバイス側 API(ginForceEnable何が起こるかginSignalCount > 0がncclInvalidUsageまたは
The application was compiled with too old version of NCCL. It was compiled with NCCL version 2.29.2, but is
running with NCCL library version 2.31.0. Because of its use of GIN device kernels, it needs to be recompiled,
preferably with the same NCCL version that it will be running with.を返して警告を出力する:コピーncclDevComm_v22902根本原因ginContextCount、ginNetDeviceTypes、ginHandles:2.29.2 の
レイアウトでは、GIN フィールド(など)が 2.31.0 のレイアウトと互換性がない。強制的に変換すると、kernel が誤ったオフセットを読み、未定義動作を引き起こす。
正しい対処法
:アプリケーションは実行時ライブラリと同じ(または互換性のある)NCCL バージョンで再コンパイルしなければならない。再コンパイルできない場合は、kernel 内で GIN API を使用するのを避けるべきである。落とし穴 2:ノード間通信時にデバイス API がサイレントに無効化されるncclTeamLsa(comm).nRanks != comm->nRanks)。
シナリオ:ncclCommPropertiesFilter_v22907 📎 src/devcomm/devcomm_v22907.cc:69-77:アプリケーションは 2.29.7 でコンパイルされ、通信ドメインにノード間 rank が含まれる(props->deviceApiSupport何が起こるかfalseが
をに設定する。アプリケーションがこのフラグをチェックしていれば、デバイス API が利用不可であることがわかるが、チェックせずに直接デバイス側 API を呼び出すと未定義動作になる。
根本原因:2.29.7 の GIN はノード間通信をサポートしていない。LSA(Local SHARP Aggregation)グループ内の rank のみがデバイス側 API を使用できる。ncclCommProperties.deviceApiSupport正しい対処法false:アプリケーションは初期化後に
をチェックし、
であれば host 側 API にフォールバックすべきである。:ncclDevCommCopyNewToOld_v23000 📎 src/devcomm/devcomm_v23000.cc:118落とし穴 3:memset によるゼロクリアと未初期化フィールドの漏洩memset(old, '\0', sizeof(*old))。
シナリオがコピー前にginSignalBase、ginCounterBaseなぜ必要か
:古い構造体には新しいバージョンに存在しないフィールド(v22902 の:開発者が手動でバージョン変換を実装し、ゼロクリアを忘れた場合、kernel がランダムな値を読み取り、間欠的なエラーとして現れる可能性があります——再現とデバッグが困難です。
正しい方法:変換前に常にターゲット構造体全体をゼロクリアします。NCCL のすべてのCopyNewToOld実装はこのパターンに従っています📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。
落とし穴4:バージョン区間の隙間によるマッチング失敗
シナリオ:アプリケーションが NCCL 2.29.4 でコンパイルされています。バージョン区間テーブルを確認:
| ファイル | minVersion | maxVersion |
|---|---|---|
| v22902 | 2.29.2 | 2.29.3 |
| v22907 | 2.29.5 | 2.29.7 |
2.29.4 に対応するプラグインがありません。
何が起こるか: マッチングロジックが厳密に区間で検索する場合、2.29.4 はマッチングに失敗し、エラーを返します。しかし実際の実装では、「最近傍マッチ」戦略が存在する可能性があります——2.29.4 は v22902 または v22907 のプラグインにルーティングされるかもしれません。
正しい方法:アプリケーションはできるだけランタイムライブラリと同じメジャーバージョン番号を使用すべきです。バージョンを跨ぐ必要がある場合は、ターゲットバージョン区間に対応する互換プラグインがあるかテストすべきです。
障害回復チェーン
バージョン変換が失敗した場合、NCCL のエラー回復チェーン:
1. フィルターがエラーを返す:devCommRequirementsFilterが返すncclInvalidUsage。
2. 上位 API がエラーをキャッチ:ncclCommGetDeviceHandle戻り値をチェックし、非ncclSuccessの場合、devComm構造体を埋めません。
3. アプリケーションの処理:アプリケーションは戻り値をチェックし、失敗した場合は host 側 API にフォールバックするか通信を終了すべきです。
4. ログ記録:NCCL はWARNレベルのログを出力し、コンパイルバージョンとランタイムバージョンを含めて問題の特定を支援します。
現在 NCCL は「自動降格」メカニズムを提供していません——バージョン変換が失敗した場合、自動的に host 側 API にフォールバックしません。アプリケーションが自分でフォールバックロジックを実装する必要があります。
---
設計上の考察
なぜ「安定 ABI」ではなくバージョン化構造体を使うのか?
代替案は「決して変わらない」ncclDevCommレイアウトを設計し、すべての新フィールドを間接ポインタ経由でアクセスすることです。しかしこれには2つの問題があります:第一に間接アクセスはレイテンシを増加させ(kernel は追加のデリファレンスが必要)、第二にパディング領域を活用したレイアウト最適化ができません。NCCL がバージョン化構造体を選択したのは、「性能」と「互換性」の間のトレードオフです——各バージョン区間内の kernel は最適なレイアウトを得て、バージョンを跨ぐ場合は変換層を通じて互換性を保証します。
なぜ v22907 のdevCommCopyOldToNewは nullptr に設定されているのか?
📎 src/devcomm/devcomm_v22902.cc:153-155のコメントが理由を説明しています:2.30.0 以前はncclDevCommにバージョンフィールドがなかったため、v22902 と v22907 の旧レイアウトを区別できません。両方とも GIN 後方互換性をサポートしていないため、GIN フィールドの差異は正確性に影響せず、v22902 の変換関数を再利用しています。
なぜnRanks_rcp32は浮動小数点数ではなく固定小数点数を使うのか?
GPU の浮動小数点除算の精度は1/nRanksを正確に表現するのに不十分な場合があります。特にnRanksが2の冪でない場合。固定小数点数(32ビット整数で表される小数)は十分な精度を提供でき、整数乗算は浮動小数点乗算より高速です。
---
本章のまとめ
本章ではsrc/devcommディレクトリ下のバージョン化 ABI 実装を分解しました:
1. ncclDevCommのメモリレイアウト:各バージョンは正確なフィールドオフセットを持ち、static_assertでコンパイル時に検証されます。主要フィールドにはrank、nRanks、nRanks_rcp32、lsaRank、lsaSize、windowTable、resourceWindowなどがあります。
2. バージョン化 ABI の登録:各バージョン区間は1つのncclDevCommCompat構造体に対応し、minVersion、maxVersion、フィルター関数、変換関数を含みます。
3. フィールドレベルの変換:CopyNewToOldとCopyOldToNewはフィールドごとにコピーし、意味の変化を処理します(例:ginConnectionStride > 1をginConnectionsRailed = true)。
4. に変換):commPropertiesFilter能力フィルタリングdevCommRequirementsFilterは旧バージョンに公開する能力フラグを調整し、
5. はリソース要求が旧バージョンと互換性があるかチェックします。本番の落とし穴
:GIN リソース要求と旧バージョン kernel の衝突、クロスノード通信時のデバイス API の無効化、memset ゼロクリアの必要性、バージョン区間の隙間によるマッチング失敗。nccl_device次章ではデバイス側 API とカーネル融合に入り、
ヘッダーファイルがデバイス側関数をどのように組織するか、および kernel fusion が複数の集合通信操作を1つの kernel に統合して実行する方法を見ていきます。
本章の考察とセルフチェックncclDevCommCopyNewToOld_v23000Q1: もしmemset(old, '\0', sizeof(*old))の
を削除した場合、どのようなシナリオで kernel が誤ったデータを読み取るでしょうか?v22902 と v23000 のフィールド差異を踏まえて分析してください。:
ncclDevComm_v22902参考解析📎 src/devcomm/devcomm_v22902.cc:84の構造体サイズは200バイトncclDevComm_v23000、一方📎 src/devcomm/devcomm_v23000.cc:95-98は240バイトginSignalBase。v22902 にはginCounterBase(オフセット176)、ginContextBase(オフセット184)、
(オフセット204)などのフィールドがあり、これらは v23000 に存在しないか意味が異なります。memsetもしoldを削除した場合、v23000 から v22902 に変換する際、ginSignalBase、ginCounterBase構造体の中で v23000 に存在しないフィールド(例:
- )はスタック上のゴミ値を保持します。もし kernel がたまたまこれらのフィールドを読み取った場合(例えば旧 kernel の GIN コードパス)、ランダムな値を得て、以下を引き起こします:
- シグナルベースアドレスが誤り、GIN 操作が誤ったメモリ位置に書き込む。
- カウンタベースアドレスが誤り、カウンタのオーバーフローまたはアンダーフローを引き起こす。
memset極端な場合、不正なメモリアクセスを引き起こし、kernel がクラッシュする可能性があります。CopyNewToOldのゼロクリアは、明示的に代入されていないすべてのフィールドが0であることを保証し、これは安全なデフォルト値です。NCCL のすべての📎 src/devcomm/devcomm_v22902.cc:132 📎 src/devcomm/devcomm_v22907.cc:104 📎 src/devcomm/devcomm_v23000.cc:118。
実装はこのステップを含んでいますncclDevCommCompatプラグイン。NCCL がこの状況をどのように処理する可能性があるか、またアプリケーションがどのように回避すべきかを分析してください。
参考解析:
バージョン区間表:
- v22902:2.29.2 - 2.29.3
- v22907:2.29.5 - 2.29.7
- v23000:2.30.0 - 2.30.7
- v23100:2.31.0 - 現在
2.29.4 は v22902 と v22907 の間の隙間に該当します。考えられる処理方法:
1. 最近傍マッチ:NCCL は要求バージョン以下の最大区間、すなわち v22902 を選択する可能性があります。しかし v22902 のmaxVersionは 2.29.3 であり、厳密には 2.29.4 をカバーしていません。
2. エラーを返す:マッチングロジックが厳密に区間に従う場合、2.29.4 はマッチに失敗し、ncclInvalidUsage。
3. 上方マッチ:要求バージョン以上の最小区間、すなわち v22907 を選択します。しかし v22907 のminVersionは 2.29.5 であり、これも 2.29.4 をカバーしていません。
実際の実装では、NCCL には「フォールトトレランス」戦略がある可能性があります——正確なマッチが見つからない場合、隣接する区間のプラグインを使用しようとします。しかしこれは信頼できる保証ではありません。
アプリケーションの回避方法:
- ランタイムライブラリと同じメジャーバージョン番号(例:2.31.x)を使用する。
- バージョンを跨ぐ必要がある場合、対象バージョン区間に対応する互換プラグインがあるかテストする。
- 初期化後に
ncclCommProperties.deviceApiSupportを確認し、もしfalseであれば、host 側 API にフォールバックする。
Q3: ncclDevCommRequirementsFilter_v22902の中に次のようなロジックがあります:if (reqs->barrierCount) { reqs->lsaBarrierCount = std::max(reqs->lsaBarrierCount, reqs->barrierCount); reqs->barrierCount = 0; }。この変換が必要な理由、および変換しない場合に何が起こるかを説明してください。
参考解析:
📎 src/devcomm/devcomm_v22902.cc:117-121のコメントには次のように記されています:「Prior to 2.29.4, a non-zero barrierCount did not imply GIN, but it does since.」
2.29.4 より前では、barrierCountは LSA barrier の数のみを表し、GIN 要件を暗黙的に示すことはありませんでした。2.29.4 以降、barrierCountは GIN 要件を暗黙的に示します(つまり barrier を要求することは GIN リソースが必要であることを意味します)。
アプリケーションが 2.29.2 でコンパイルされた場合、barrierCount > 0を設定して LSA barrier 要件を表しているかもしれませんが、これが GIN 要件を暗黙的に示すことは認識していません。もし NCCL ライブラリ(2.31.0)が新しいセマンティクスに従って直接処理すると、アプリケーションが GIN リソースを要求したと見なし、その後ncclDevCommRequirementsFilter_v22902が GIN リクエストを検出してncclInvalidUsageを返します——これは誤検出です。
変換ロジックはbarrierCountをlsaBarrierCount(両者の最大値を取る)に変換し、barrierCountをクリアします。これにより:
lsaBarrierCountアプリケーションの barrier 要件が保持されます。barrierCount = 0GIN 要件の誤検出が回避されます。railGinBarrierCount = 0同様に、旧バージョンではこれも GIN 要件を暗黙的に示さないためです。
変換しない場合、アプリケーションが 2.29.2 でコンパイルされbarrierCount > 0を設定していると、誤って拒否され、デバイス API を使用できなくなります。
ここまでで、devcomm がバージョン化された ABI を通じて host 側通信ドメインの重要なメタデータをデバイス側に安全にマッピングし、kernel が host ポインタなしで rank、アドレス、接続状態を取得できる仕組みが明らかになりました。このメカニズムは kernel が通信ドメインにアクセスする基本的な問題を解決しましたが、デバイス側の能力はこれにとどまりません。ユーザーが自身の kernel 内で通信プリミティブを直接呼び出したり、通信と計算を同一 kernel に融合させたい場合には、より上位のデバイス側 API とカーネル融合技術が必要です。次の章では nccl_device ディレクトリと関連するサンプルを深掘りし、ncclBarrier、ncclLsaBarrier、ncclGinBarrier などのデバイス側 API がどのようにユーザー kernel の通信参加を可能にし、カーネル融合がどのように起動オーバーヘッドを削減するかを探求し、NCCL をライブラリからプログラミングモデルへと押し進めます。
第 20 章:第 20 章:デバイス側ネイティブ API とオペレータ融合:nccl_device と kernel fusion の実践
第 20 章:デバイス側ネイティブ API とオペレータ融合:nccl_device と kernel fusion の実践
前章では、devcommがhost側のncclCommのメタデータをどのようにバージョン管理しながらデバイス側にマッピングし、カーネルがrank、アドレス、接続状態を読み取れるようにするかを明らかにしました。しかし「メタデータを読める」ことと「通信を開始できる」ことは別問題です。メタデータだけでは、ユーザーカーネルはせいぜい自分でアドレスを計算し、自分でフラグを書き込む程度しかできません。rank間の同期やマシン間のシグナル伝達が必要になれば、やはりhost側でncclAllReduceなどの集合APIを呼び出す必要があり、そのような呼び出しのたびにカーネル起動とhost-device間の往復が発生します。本章で解き明かすsrc/nccl_deviceディレクトリこそ、NCCLが「呼び出されるライブラリ」から「プログラミング可能なモデル」へと進む鍵です。ここで提供されるのは新しい集合通信アルゴリズムではなく、デバイス側プリミティブのセットです。ユーザー自身のカーネル内部でncclBarrier、ncclLsaBarrier、ncclGinBarrierといった同期操作を呼び出せるようにし、「通信」と「計算」を同一カーネルに詰め込み、中間の起動オーバーヘッドを省きます。本章のソース資料は、このプリミティブ群のhost側における要件宣言(CreateRequirement)とチーム(Team)抽象に焦点を当てており、これがデバイス側APIの入口です。本章を理解する上での重要な前提:デバイス側APIの設計哲学は「host側でリソース要件を宣言し、device側でリソースを消費する」ことです。host側はbarrierを直接作成せず、NCCLに「nBarriers個のbarrierが必要で、チームにはteam.nRanks人のメンバーがいる」と伝え、NCCLはそれに基づいて必要なバッファ数とGINシグナル数を計算し、device側でこれらのリソースをインスタンス化します。この「宣言-消費」の分離が、デバイス側コードがhostポインタなしで動作できる根本的な理由です。
一、Team抽象:デバイス側APIの座標系
直感的モデル
多国籍企業の組織構造を想像してください。メールを送るには、まず「誰に送るか」を知る必要があります——全社(World)に送るのか、同じオフィスの同僚(LSA)に送るのか、それとも同じ事業ラインのクロスオフィスチーム(Rail)に送るのか。ncclTeam_tこれが「受信者範囲」の記述子です。Team抽象がなければ、各デバイス側APIが「自分はこの通信ドメインで何番目か、全部で何人いるか」を毎回再計算しなければならず、コードは重複しエラーが起きやすくなります。
データ構造とメモリレイアウト
ncclTeam_tはデバイス側APIの座標系であり、その3つのフィールドが定義するのは等差数列:
| フィールド | 意味 | 類推 |
|---|---|---|
nRanks | チーム内のメンバー総数 | グループに何人いるか |
rank | 現在のrankのチーム内番号 | グループ内での自分の番号 |
stride | チーム内の隣接メンバーのworldにおける歩長 | グループ内の隣り合う2人の学籍番号の差 |
strideは最も見落とされやすいが最も重要なフィールドです。Worldチームではstride = 1、すべてのrankが連続して並んでいるためです。しかしRailチームではstride = lsaSize、同じrail上のrankはworld内でlsaSize個ごとにしか現れないためです。
📎 src/nccl_device/core.cc:13-19はWorldチームの構築を示しています:直接comm->nRanksとcomm->rank,strideを1に固定します。これはncclDevrInitOnceを必要としない唯一のチームです。その情報はすべてhost側のcommにあるためです。
📎 src/nccl_device/core.cc:22-33はLSAチームです。L26のncclDevrInitOnce(comm)に注意——これはデバイス側リソース初期化の冪等な入口です。L23-25のコメントは非常に重要です:ここでは意図的にエラーを無視する。初期化に失敗した場合、返されるteamは「ゴミ値」ですが、次に本当にリソースを必要とするAPI呼び出しが再びncclDevrInitOnceをトリガーし、エラーを報告します。これは「遅延エラー報告」戦略であり、チーム照会のような軽量操作で重いエラーを投げるのを避けます。
シナリオ駆動Walkthrough:WorldからRailへの座標変換
8カードマシンを仮定し、lsaSize = 4(4カードごとに1つのLSAドメイン)、nRanks = 8。次にncclTeamRailがどのように構築されるかを見てみましょう:
📎 src/nccl_device/core.cc:70-79において、nRanks = 8 / 4 = 2,rank = comm->rank / 4,stride = 4。現在のrankが5なら、Railチーム内でのrank = 5 / 4 = 1,stride = 4、つまりRailチームのメンバーはworld内のrank 1とrank 5です。
次にncclTeamRankToWorldの換算公式を見てみましょう:
📎 src/nccl_device/core.cc:82-84のcomm->rank + (rank - team.rank) * team.strideは相対オフセット計算です:まず目標rankの現在rankに対するチーム内オフセット(rank - team.rank)を算出し、次に歩長strideを掛け、現在rankのworld番号を加えます。この公式はすべてのチームに通用します。なぜならstrideがすでにチームの配列規則をエンコードしているからです。
ncclTeamRankToLsaは異なります:
📎 src/nccl_device/core.cc:87-92はcomm->devrState.lsaSelf + (rank - team.rank) * team.strideを使います。ここでlsaSelfではなくcomm->rankを使っていることに注意——LSA番号はデバイス側リソース初期化後に初めてわかるもので、world rankと異なる可能性があるためです。
flowchart TD
start["用户调用 ncclTeamRail(comm)"] --> init{"ncclDevrInitOnce(comm)<br/>成功?"}
init -->|"否"| empty["返回 ncclTeam_t{}<br/>空团队"]
init -->|"是"| calc["计算 nRanks = comm->nRanks / lsaSize<br/>rank = comm->rank / lsaSize<br/>stride = lsaSize"]
calc --> ret["返回 ncclTeam_t"]
empty --> caller["调用方继续<br/>下一个 API 会报错"]
ret --> callerこの図は「遅延エラー報告」戦略の実行パスを明らかにしています:初期化失敗時は空のチームを返しますが、呼び出し元を中断しません。エラーは次に本当にリソースを必要とするAPI(ncclLsaBarrierCreateRequirementなど)で露呈します。
設計上の考察と落とし穴
なぜncclTeamWorldはncclDevrInitOnce?を呼び出さないのか?Worldチームの情報は完全にhost側から来るためですcommデバイス側リソースは一切不要です。無理に呼び出すと、純粋なhostクエリ操作がデバイス側の初期化に依存することになり、不要な失敗ポイントが増えます。
ハマりポイント:ncclTeamRankToLsa初期化失敗時に返す-1(📎 src/nccl_device/core.cc:87-92)、一方でncclTeamRankToWorldは決して失敗しません。呼び出し側がこれら2つの関数を混用し、戻り値をチェックしない場合、LSA初期化失敗時に-1を正当なrankとして使用し、範囲外アクセスを引き起こす可能性があります。本番コードではncclTeamRankToLsaの戻り値を失敗しうる操作として扱うべきです。
---
二、Barrier要件宣言:host側がデバイスリソースを「予約」する方法
直感的モデル
デバイス側APIのリソース割り当ては会議室の予約のようなものです:会議室に直接飛び込んで会議を始めることはできず、まず受付(host側のCreateRequirement)に申請を提出する必要があります——「会議を3回、各8名で開催したい」。受付はそれに基づいて必要な広さ(bufferSize)、必要な椅子の数(ginSignalCount)を計算し、会場番号(outBufferHandle)を渡します。この予約メカニズムがなければ、デバイス側kernelは自分のbarrierバッファがどこにあり、どれくらいの大きさかを知ることができず、安全に読み書きできません。
データ構造とメモリレイアウト
3つのbarrierのCreateRequirement関数は同じパターンを共有しています:要件構造体をゼロクリア → バッファサイズ/アラインメントを設定 → 出力ハンドルポインタを設定。ただし、リソースタイプは異なります:
| Barrierタイプ | リソースタイプ | サイズ計算式 | アラインメント |
|---|---|---|---|
| LSA Barrier | バッファ | (3*n + n*team.nRanks) * sizeof(uint32_t) | alignof(uint32_t) |
| CFT Barrier | バッファ | (3*n + n*team.nRanks) * NCCL_CFT_BARRIER_GRAN | NCCL_CFT_BARRIER_ALIGN |
| GIN Barrier | GINシグナル | n * team.nRanks個のシグナル | バッファは関与しない |
まずLSA Barrierのサイズ計算式を見てみましょう:
📎 src/nccl_device/lsa_barrier.cc:14-22の(3 * nBarriers + nBarriers * team.nRanks) * sizeof(uint32_t)は2つの部分に分解できます:
3 * nBarriers:各barrierには3つのuint32_tの制御フィールドが必要です([INFERENCE] 通常は「到達カウント」「ラウンド」「状態フラグ」)。nBarriers * team.nRanks:各barrierにはチーム内の各メンバー用に1つのuint32_tの到達スロットを確保する必要があります。
したがって、単一barrierの総サイズは3 + team.nRanks個のuint32_tです。この計算式はLSAとCFTで完全に一致していますが、CFTはNCCL_CFT_BARRIER_GRANを粒度単位として使用しています(より大きな境界にアラインするためと思われます)。
GIN Barrierはまったく異なります:
📎 src/nccl_device/gin_barrier.cc:14-20はバッファを割り当てず、ginSignalCount = nBarriers * team.nRanksを設定し、outGinSignalStartをハンドル内のsignal0にポイントします。これはGIN barrierがネットワークシグナルパスを通るため、共有メモリバッファは不要で、NICが認識できるシグナルスロットが必要だからです。
シナリオ駆動Walkthrough:1回のLSA Barrierの完全な予約
ユーザーが4カードのLSAチーム上に2つのbarrierを作成するとします:
1. 呼び出し ncclLsaBarrierCreateRequirement(team, 2, &handle, &req)。
2. ゼロクリア:memset(outReq, 0, sizeof(*outReq))(📎 src/nccl_device/lsa_barrier.cc:14-22)——未設定のフィールドが確定値であることを保証し、呼び出し側がスタック上のゴミを読むのを防ぎます。
3. barrier数を記録:outHandle->nBarriers = 2(📎 src/nccl_device/lsa_barrier.cc:14-22)。
4. バッファサイズを計算:(3*2 + 2*4) * 4 = (6 + 8) * 4 = 56バイト(📎 src/nccl_device/lsa_barrier.cc:14-22)。
5. アラインメントを設定:alignof(uint32_t) = 4(📎 src/nccl_device/lsa_barrier.cc:14-22)。
6. ハンドルポインタを書き戻し:outReq->outBufferHandle = &outHandle->bufHandle(📎 src/nccl_device/lsa_barrier.cc:14-22)——NCCLが実際にバッファを割り当てた後、アドレスをハンドルに書き戻せるようにします。
flowchart LR
subgraph host["host 侧声明阶段"]
req["ncclLsaBarrierCreateRequirement<br/>team, nBarriers=2"]
calc["bufferSize = (3*2 + 2*4)*4 = 56<br/>bufferAlign = 4"]
handle["outHandle->nBarriers = 2<br/>outReq->outBufferHandle = &handle->bufHandle"]
end
subgraph dev["device 侧消费阶段"]
buf["缓冲区 56 字节<br/>3 控制字段 + 4 到达槽位"]
bar["ncclLsaBarrier 实例"]
end
req --> calc --> handle
handle -.->|"NCCL 分配后回填"| buf
buf --> barこのデータフロー図は「宣言」と「消費」の分離を示しています:host側はサイズとポインタを計算するだけで、実際のバッファ割り当てとインスタンス化はNCCL内部で行われ、device側kernelが受け取るのはすでに埋められたハンドルです。
設計上の考察とハマりポイント
なぜmemsetでoutReq?全体をゼロクリアするのかncclDevResourceRequirements_tは複数フィールドの構造体であり、barrierタイプごとに一部のフィールドしか埋めないからです。ゼロクリアにより未使用フィールド(LSA barrierでは使わないginSignalCountなど)が0になり、NCCL内部はそれに基づいて「このリソースは不要」と判断します。ゼロクリアしないと、スタック上のランダムな値が「GINリソースが必要」と誤認され、前章で述べた誤検出問題を引き起こす可能性があります。
ハマりポイント:outReq->outBufferHandle = &outHandle->bufHandleはハンドル内部フィールドのアドレスをNCCLに渡しました。これはoutHandleがNCCLのバッファ割り当て完了まで有効でなければならない(スタックに回収されたり移動されたりしてはいけない)ことを意味します。ユーザーがoutHandleを早期に解放されるスコープに置くと、NCCLの書き戻し時にダングリングポインタに書き込むことになります。
CFT Barrierの粒度の違い:📎 src/nccl_device/cft_barrier.cc:13-21はNCCL_CFT_BARRIER_GRANとNCCL_CFT_BARRIER_ALIGNでLSAのsizeof(uint32_t)とalignof(uint32_t)を置き換えています。これはCFT(Cross-Fabric Teamまたは同様のクロスドメインチームと思われる)のbarrierがより大きなアラインメント粒度を必要とすることを示しており、マルチキャストメモリ領域を跨ぐため、ハードウェアがアドレスアラインメントに対してより厳格な要件を持つためと思われます。
---
三、3つのBarrierのセマンティックな役割分担:LSA、CFT、GINがそれぞれ何を担うか
直感的モデル
3つのbarrierは3つの異なる範囲の「集合ラッパ」のようなものです:
- LSA Barrier:同じオフィス内の同僚の集合、共有メモリを通り、最速。
- CFT Barrier:オフィスを跨ぐが同じ建物内の集合、マルチキャストメモリを通り、中速。
- GIN Barrier:都市を跨ぐ、あるいは国を跨ぐ集合、ネットワークシグナルを通り、最遅だがカバレッジは最も広い。
barrierタイプを間違えてもエラーにはなりませんが、巨大な性能損失をもたらします——GIN barrierで同じオフィスの同期を行うのは、隣の席のファイルを国際宅配便で送るようなものです。
データ構造とメモリレイアウトの比較
host側の要件宣言から見ると、3つのリソース要件はまったく異なります:
| 次元 | LSA Barrier | CFT Barrier | GIN Barrier |
|---|---|---|---|
必要commパラメータ | いいえ | いいえ | はい |
| バッファ | あり | あり | なし |
| GINシグナル | なし | なし | あり |
| サイズ単位 | uint32_t | NCCL_CFT_BARRIER_GRAN | シグナル数 |
| 出力ハンドルフィールド | bufHandle | bufHandle | signal0 |
GIN Barrierだけがcommパラメータを必要とすることに注意してください:
📎 src/nccl_device/gin_barrier.cc:14-20の関数シグネチャにはncclComm_t commが含まれますが、LSAとCFTのシグネチャにはncclTeam_t team。これは GIN シグナルが特定のネットワーク接続にバインドされる必要があり、ネットワーク接続情報がcommにあるためです。
シナリオ駆動 Walkthrough:GIN Barrier のシグナル割り当て
📎 src/nccl_device/gin_barrier.cc:14-20のロジックは LSA より単純ですが、セマンティクスはより微妙です:
1. クリア:memset(outReq, 0, sizeof(*outReq))(L16)。
2. シグナル数の設定:outReq->ginSignalCount = nBarriers * team.nRanks(L17)——各 barrier はチーム内の各メンバーにシグナルスロットを1つ割り当てる必要があります。
3. シグナル開始ポインタの書き戻し:outReq->outGinSignalStart = &outHandle->signal0(L18)——ここではbufferSizeが設定されていないことに注意してください。GIN barrier は共有メモリバッファを使用しないためです。
signal0この名前は、ハンドル内に連続したシグナルフィールドのグループがあることを示唆しています(signal0, signal1, ...),outGinSignalStartが最初のものを指し、NCCL はこれに基づいてnBarriers * team.nRanks個のシグナルの割り当てをどこから開始するかを知ります。
並行制御とハードウェア相互作用
3種類の barrier の並行制御メカニズムは完全に異なります:
- LSA Barrier:共有メモリベースのアトミック操作。
3 + team.nRanks個のuint32_tにおいて、到達スロットはアトミック加算またはアトミック書き込みで「到着した」ことをマークし、制御フィールドはアトミック読み取りで「全員が到着したか」をチェックします。これは純粋な GPU 内同期であり、ネットワークは関与しません。 - CFT Barrier:マルチキャストメモリ(multimem)ベース。[INFERENCE] マルチキャストメモリは1回の書き込み操作で複数の rank のビューを同時に更新できるため、CFT barrier はより少ない制御フィールドでより広範な同期を実現できる可能性があります。
- GIN Barrier:ネットワークシグナルベース。
ginSignalCount個のシグナルが NIC を通じて送信され、受信側はシグナルスロットをポーリングします。これはクロスマシンハードウェアが関与する唯一の barrier です。
sequenceDiagram
participant K as "用户 Kernel"
participant LSA as "LSA 共享内存"
participant CFT as "CFT 多播内存"
participant NIC as "网卡 GIN 信号"
K->>LSA: "原子写到达槽位"
LSA-->>K: "轮询所有槽位"
Note over K,LSA: LSA barrier 完成
K->>CFT: "多播写控制字段"
CFT-->>K: "读多播状态"
Note over K,CFT: CFT barrier 完成
K->>NIC: "发送 GIN 信号"
NIC-->>K: "轮询信号槽位"
Note over K,NIC: GIN barrier 完成このシーケンス図は、3種類の barrier のハードウェア相互作用階層を示しています:純粋な GPU 内同期から、マルチキャストメモリ、そして NIC シグナルへと、レイテンシは順に増加し、カバレッジ範囲も順に拡大します。
設計上の考察と落とし穴
なぜ LSA と CFT はcommパラメータを必要としないのか?それは、それらのリソース(共有メモリ、マルチキャストメモリ)が既にncclDevrInitOnce段階でチームにバインドされており、team自体がリソース位置情報を暗黙的に含んでいるためです。一方、GIN シグナルはネットワークリソースを動的に割り当てる必要があり、commを通じてネットワーク接続状態にアクセスする必要があります。
落とし穴:GIN Barrier のginSignalCountはnBarriers * team.nRanksです。チームが非常に大きく(例:1024 rank)、barrier が多い(例:100個)場合、シグナルの総数は102400に達します。NIC のシグナルスロットは限られたリソースであり、過剰な要求はncclDevrInitOnceの失敗を引き起こす可能性があります。本番コードは、大量の予備を一度に要求するのではなく、実際に必要な最小限の barrier 数に基づいて要求すべきです。
---
四、要件宣言からデバイス側消費まで:完全なライフサイクル
直感モデル
CreateRequirementは単なる「注文」であり、実際の「発送」と「受領」は NCCL 内部とデバイス側 kernel で発生します。ライフサイクル全体はオンラインショッピングのようです:あなたが注文(CreateRequirement)→ 商家が在庫準備(NCCL がリソース割り当て)→ 宅配便が配達(リソースが DevComm にバインド)→ あなたが受け取って使用(デバイス側 kernel が barrier を呼び出し)。
データ構造とメモリレイアウト:ハンドルのフィールド進化
を例にとると、ライフサイクル中に3つの段階を経ます:ncclLsaBarrierHandle_t段階
| その他のフィールド | nBarriers | bufHandle | CreateRequirement 後 |
|---|---|---|---|
| 設定済み | アドレスは書き戻し済みだが、内容は未割り当て | 未設定 | NCCL 割り当て後 |
| 設定済み | 実際のバッファを指す | 設定済み | デバイス側使用 |
| 読み取り専用 | 読み取り専用 | 読み取り専用 | が |
📎 src/nccl_device/lsa_barrier.cc:14-22を設定し、nBarriers,📎 src/nccl_device/lsa_barrier.cc:14-22がbufHandleのアドレスを書き戻します。これら2つの操作の間で、NCCL 内部がバッファの実際の割り当てを完了します。
シナリオ駆動 Walkthrough:1回の完全な barrier 使用
1. Host 側宣言:ユーザーがncclLsaBarrierCreateRequirement(team, 2, &handle, &req)を呼び出し、req.bufferSize = 56。
2. を取得Host 側提出req:ユーザーがncclDevCommCreateをhandle.bufHandle。
3. に渡し(前章の内容)、NCCL が56バイトのバッファを割り当て、アドレスをに書き込みますhandleデバイス側初期化bufHandle:ユーザー kernel 起動時に、DevComm から
4. を取り出し、でバッファを特定します。ncclLsaBarrier(handle, barrierIndex)デバイス側同期
5. :kernel がを呼び出し、バッファの対応するスロットに到達マークを書き込み、他のスロットをポーリングします。
flowchart TD
a["ncclLsaBarrierCreateRequirement<br/>算出 bufferSize=56"] --> b["ncclDevCommCreate<br/>分配 56 字节缓冲区"]
b --> c{"分配成功?"}
c -->|"否"| err["返回 ncclSystemError<br/>句柄无效"]
c -->|"是"| d["回填 handle.bufHandle<br/>指向实际缓冲区"]
d --> e["用户 kernel 启动<br/>从 DevComm 取 handle"]
e --> f["ncclLsaBarrier(handle, idx)<br/>写到达槽位 + 轮询"]
f --> g{"所有 rank 到达?"}
g -->|"否"| f
g -->|"是"| h["barrier 返回<br/>kernel 继续"]
err --> i["用户需检查返回值<br/>不可使用无效句柄"]:全 rank が到達すると、barrier が戻り、kernel が実行を継続します。ncclLsaBarrierCreateRequirementコピーncclSuccess(📎 src/nccl_device/lsa_barrier.cc:14-22この決定図は、宣言から使用までの完全なパスと、割り当て失敗時のエラーブランチを示しています。注意:
自体は常に
を返し)、実際の失敗は後続のリソース割り当て段階で発生します。並行制御とハードウェア相互作用デバイス側 barrier の並行制御の核心は
- アトミック操作 + メモリバリアです。LSA barrier を例にとると:
- 到達段階:各 rank はアトミック書き込み(またはアトミック加算)で自身の到達スロットを更新します。このステップは release セマンティクスを使用する必要があり、barrier 前のすべてのメモリ操作が他の rank に可視であることを保証します。
- ポーリング段階:各 rank はアトミック読み取り(または volatile 読み取り)ですべてのスロットをチェックします。このステップは acquire セマンティクスを使用する必要があり、「全員が到着した」ことを確認した後、他の人々が barrier 前に書き込んだデータを読み取れることを保証します。
3 * nBarriers個の制御フィールドは、まさにこのような「ラウンド」問題を処理するために使われる可能性が高い。1つのフィールドは現在のラウンドを記録し、1つのフィールドは到達カウントを記録し、1つのフィールドはリセットフラグとして機能する。これにより、複数のbarrierが同じスロット群を再利用してもラウンドが混同されない。
本番環境の落とし穴回避ガイド
落とし穴1:ハンドルのライフサイクル管理。outReq->outBufferHandle = &outHandle->bufHandleハンドル内部フィールドのアドレスをNCCLに渡している。もしユーザーがncclDevCommCreateが返る前にoutHandleを破棄すると、NCCLが書き戻す際に解放済みメモリへ書き込むことになる。正しい方法は、outHandleのライフサイクルを、それを生成した関数スコープではなくDevCommにバインドすることである。
落とし穴2:barrier数とチームサイズの積。bufferSize = (3*n + n*team.nRanks) * sizeof(uint32_t)において、n*team.nRanks項は大規模チームでサイズを支配する。1024ランク、100barrierでは100*1024*4 = 409600バイト、約400KBが必要となる。各ランクがこれだけを要求すると、VRAMプレッシャーは無視できない。総barrier数ではなく、実際に同時使用するbarrier数に応じて要求すべきである。
落とし穴3:GIN barrierのシグナル枯渇。GINシグナルはNICリソースであり、数には限りがある。複数のDevCommが同時に大量のGINシグナルを要求すると、NICスロットが枯渇する可能性がある。本番コードでは、DevComm作成失敗時にGINシグナル不足かどうかを確認し、nBarriersを減らすかLSA barrierへの切り替えを検討すべきである。
落とし穴4:初期化失敗の遅延露呈。ncclTeamLsaなどの関数はncclDevrInitOnce失敗時に空チーム(📎 src/nccl_device/core.cc:22-33)を返し、エラーを報告しない。ユーザーコードが後続APIの戻り値をチェックしない場合、空チーム上で操作を続行し、特定が困難なエラーを引き起こす可能性がある。デバイス側APIを初めて使用する際に、チームの有効性を明示的にチェックすることを推奨する(例:team.nRanks > 0)。
---
五、カーネル融合:なぜ通信と計算を1つのkernelに詰め込むのか
直感的モデル
従来のモードでは、1回の「AllReduce + 活性化関数」に2つのkernelが必要である。1つは通信、1つは計算を行う。2つのkernel間には暗黙的なグローバル同期がある——通信kernelが完全に終了しないと、計算kernelは開始できない。これはまるでリレー競走のようだ:第1走者が走り終えたらバトンを第2走者に渡さなければならず、受け渡しの瞬間は両者とも待っている。カーネル融合は、同じkernelで通信と計算の両方を実行させることで、走りながら靴を履き替えるように、受け渡しの待ち時間を省く。
データ構造とメモリレイアウト
カーネル融合の鍵は、通信プリミティブ(barrierなど)と計算ロジックが同じkernelのレジスタと共有メモリを共有することにある。これは以下を意味する:
- レジスタプレッシャー:通信プリミティブのアトミック操作とポーリングループがレジスタを消費し、計算ロジックのレジスタ予算を圧迫する。
- 共有メモリ競合:LSA barrierのバッファを共有メモリに置くと、計算ロジックの共有メモリ需要と競合する。
- Occupancyへの影響:融合kernelのoccupancyは通常、純粋な計算kernelより低い。通信プリミティブが追加リソースを必要とするためである。
デバイス側APIの設計(host側でリソースを宣言し、device側で消費する)は、まさにこれらのプレッシャーを緩和するためである:リソースはhost側で事前に割り当てられ、device側kernelは読み書きするだけでよく、動的確保が不要なため、レジスタ占有が減少する。
シナリオ駆動Walkthrough:融合kernelの実行フロー
ユーザーが「AllReduce + ReLU」の融合kernelを書くと仮定する:
1. Host側の準備:ncclLsaBarrierCreateRequirementを呼び出してbarrierを要求し、ncclDevCommCreateを呼び出してリソースを割り当てる。
2. Kernel起動:ユーザーkernelはDevCommとbarrierハンドルを引数として受け取る。
3. 通信フェーズ:kernel内でncclLsaBarrierを呼び出して全ランクを同期し、その後各ランクがデータを交換する(対称メモリを介した直接読み書き)。
4. 計算フェーズ:同期完了後、kernelはローカルデータに対して直接ReLUを実行し、追加のkernel起動は不要である。
5. 完了:kernelが終了し、host側は追加の通信kernelを待つ必要がない。
flowchart LR
subgraph old["传统模式:两个 kernel"]
k1["通信 kernel<br/>AllReduce"] --> sync["隐式全局同步<br/>kernel 边界"]
sync --> k2["计算 kernel<br/>ReLU"]
end
subgraph fused["融合模式:一个 kernel"]
f1["通信阶段<br/>ncclLsaBarrier + 数据交换"]
f1 --> f2["计算阶段<br/>ReLU"]
end
old -.->|"融合后省掉"| fusedこの比較図は融合の核心的メリットを示している:kernel境界での暗黙的なグローバル同期を省くことである。従来のモードでは、この同期のコストは2回のkernel起動のレイテンシにGPUパイプラインの排出を加えたものである。
設計上の考察と落とし穴
なぜデバイス側APIは「融合AllReduce」を直接提供しないのか?融合の具体的な形式はユーザーの計算ロジックに依存するためである。NCCLが提供するのはプリミティブ(barrier、シグナル、対称メモリアクセス)であり、完成品(融合AllReduce+ReLU)ではない。ユーザーはこれらのプリミティブを自分で組み合わせて、自身のニーズに合った融合kernelを実装する必要がある。これが「プログラミングモデル」と「ライブラリ」の本質的な違いである。
落とし穴ポイント:融合カーネルのデバッグ難易度は分離カーネルよりはるかに高い。barrier ロジックにバグがあると、カーネルがハング(デッドロック)する可能性があり、GPU カーネルのハングはホストプロセスのハングのように簡単には診断できない。融合カーネルにタイムアウト機構を追加するか、まず小規模なチームで barrier ロジックを検証することを推奨する。
落とし穴ポイント:融合カーネルの occupancy 低下により、計算性能の損失が通信節約の利益を上回る可能性がある。融合を決定する前に、通信レイテンシの低減だけを見るのではなく、融合前後のエンドツーエンド時間を測定すべきである。
本章の考察とセルフチェック
Q1:もしncclTeamLsaの L26 のncclDevrInitOnce呼び出しを削除し、直接comm->devrState.lsaSizeとlsaSelfを返した場合、どのようなシナリオでデバイス側カーネルが誤ったチーム情報を読み取るか?
参考解析:ncclDevrInitOnceはデバイス側リソース初期化の冪等エントリポイントである。これを削除すると、comm->devrState.lsaSizeとlsaSelfはまだ初期値(通常は 0 または未定義)のままである可能性がある。デバイス側 API を初めて使用するシナリオでは、ユーザーがncclTeamLsaを呼び出すとnRanks = 0の空チームを取得する。その後、ユーザーがチームの有効性をチェックせずに、このチームで直接ncclLsaBarrierCreateRequirementを呼び出すと、bufferSize = (3*n + n*0) * 4 = 12nバイトを計算する——実際に必要な量より少ない。なぜならn*team.nRanks項が 0 になるからである。これによりバッファオーバーフローが発生する:barrier ランタイムはteam.nRanks個の到達スロットに書き込もうとするが、バッファには3n個のuint32_tの空間しか割り当てられていない。さらに隠蔽的なのは、もしlsaSelfも 0 の場合、ncclTeamRankToLsaは誤った rank 番号を返し、barrier の到達スロットが誤った位置に書き込まれ、すべての rank が到達するのを永遠に待てなくなり、カーネルがハングする可能性がある。これはまさに L23-25 のコメントにある「ゴミ値を返し、次の API がエラーを報告する」戦略が防ごうとしている状況である——ただし、次の API が実際にエラーを報告し、誤ったサイズを黙って使用しないことが前提である。
Q2:ncclLsaBarrierCreateRequirementのサイズ公式は(3*nBarriers + nBarriers*team.nRanks) * sizeof(uint32_t)である。チームに 8 つの rank があり、ユーザーが 1 つの barrier を申請すると、バッファは 44 バイトである。barrier 実装における「3 つの制御フィールド」がそれぞれ「到達カウント」「ラウンド」「リセットフラグ」であると仮定して、推論せよ:8 つの rank が同時に到達したとき、もし「到達カウント」が非アトミックな++操作を使用した場合、何が起こるか?
参考解析:非アトミックな++は GPU 上では「読み取り-変更-書き込み」の 3 ステップであり、アトミック操作ではない。8 つの rank が同時にcount++を実行すると、複数の rank が同じ古い値(例えばすべて 0)を読み取り、その後すべてが 1 を書き戻す可能性がある。最終的にcountは 8 ではなく 1 しか増加せず、barrier は永遠に「まだ全員揃っていない」と認識し、すべての rank がポーリング段階で無限ループに陥る。これが、LSA barrier の到達スロットがアトミック操作(例えばatomicAdd)または各 rank が独自の独立したスロットに書き込む(nBarriers * team.nRanks項はまさに各 rank に独立したスロットを予約するためのもの)必要がある理由である。もし「各 rank が独自のスロットに書き込む」方式を採用すれば、アトミック加算は不要で、アトミック書き込み + メモリバリアのみが必要である。なぜなら各スロットには書き込み者が 1 人だけだからである。これはまた、サイズ公式にnBarriers * team.nRanks項がある理由も説明する——空間と引き換えにアトミック性を得て、複数書き込み者の競合を避けるのである。
Q3:ncclGinBarrierCreateRequirementはcommパラメータを必要とするがncclLsaBarrierCreateRequirementは必要としない。もし無理に LSA barrier にもcommパラメータを追加した場合(インターフェース統一のためと仮定)、どのような設計問題が生じるか?逆に、GIN barrier からcommパラメータを削除した場合、どのようなシナリオで失敗するか?
参考解析:LSA barrier にcommパラメータを追加する問題は、不必要な依存関係を導入することである。LSA barrier のリソース(共有メモリ)はすでにncclDevrInitOnce段階でチームにバインドされており、team自体がリソースの位置を暗黙的に示している。commを追加すると、純粋なチーム操作が通信ドメインの状態に依存し、失敗点が増える(例えばcommが無効な場合 LSA barrier も作成できない)。また「最小権限」の原則に違反する。逆に、GIN barrier からcommパラメータを削除すると失敗する。なぜなら GIN シグナルは特定のネットワーク接続にバインドされる必要があるからである。ncclGinBarrierCreateRequirementのginSignalCountは、どの NIC、どの QP(Queue Pair)にシグナルを送信するかを知る必要があり、これらの情報はcommのネットワーク伝送層の状態にある。commがなければ、NCCL はシグナルをどの NIC のスロットに割り当てるべきか決定できず、シグナルがターゲット rank に正しくルーティングされることも保証できない。これはデバイス側 API の設計原則を体現している:リソース需要の宣言は、それが本当に必要とするコンテキストにのみ依存する——LSA はチームトポロジのみを必要とし、GIN はネットワーク接続を必要とする。
---
デバイス側 API とカーネル融合は、NCCL を「呼び出すライブラリ」から「プログラミングするモデル」へと変えた。ncclTeam_tは座標系を提供し、CreateRequirementはリソース予約機構を提供し、3 種類の barrier は共有メモリからネットワークシグナルまでのすべての同期範囲をカバーする。しかし、リソースを宣言し、融合カーネルを書いたからといって、性能が良いとは限らない——barrier の数、チームのサイズ、融合の粒度、その一つ一つの選択がエンドツーエンド性能に影響する。次章では性能チューニングの実践に入り、チューニングパラメータがアルゴリズム選択にどう影響するか、そして実際のベンチマークでチューニング効果をどう検証するかを見ていく。
ここまでで、私たちはdevcommのメタデータマッピングからnccl_deviceのデバイス側プリミティブまでの全過程を歩み終え、NCCLが「hostで宣言し、deviceで消費する」モデルを通じて、ユーザーカーネルがbarrier系の同期操作を直接呼び出し、通信と計算を同一カーネルに融合させる仕組みを見てきた。しかしこれらのメカニズムを把握した後、より現実的な問題が自然に浮かび上がる。実際の訓練タスクで性能が基準に達しない場合、アルゴリズムの選択が不適切なのか、プロトコルが不一致なのか、それともチャネル数の設定が不合理なのかを、どう判断すればよいのか。次章では、前20章のメカニズムを一連の実行可能なチューニング方法論としてまとめ、性能レポート、コストモデル、環境変数を組み合わせて、現象から根本原因への調査パスを示す。
第21章:第21章:性能チューニング実践:tuningの実践、benchmarkツールとチューニング方法論
第21章:性能チューニング実践:tuningの実践、benchmarkツールとチューニング方法論
前章では、ユーザー定義カーネルがデバイス側APIとNCCL通信プリミティブを通じてどのように協調し、さらには通信と計算を同一カーネルに融合させるかを見てきた。これはNCCLをプログラミングモデルとして捉える可能性を開いたが、同時に現実的な問題ももたらす。通信性能が期待に達しないとき、どこから手を付けるべきか。NCCLは100以上のNCCL_PARAMを公開しているが、1回の集合通信がどの経路を通るかを実際に決めるのは、実はたった3つのノブだけである。アルゴリズム(Algo)、プロトコル(Proto)、チャネル数(nChannels)だ。本章では、前20章のメカニズムを実行可能な調査パスとしてつなげる。まず性能レポートを見て現象を特定し、次にコストモデルを読んでNCCL自身がどう選択するかを理解し、最後に環境変数とbenchmarkで仮説を検証する。
21.1 性能レポート:「正常」のベースラインをまず確立する
チューニングの第一歩はパラメータを変えることではなく、「正常」がどのようなものかを知ることだ。現在のシステムのピーク帯域幅がどれくらいかすら分からなければ、どんなパラメータ調整も当て推量にすぎない。
NCCL公式はdocs/perfの下で参考性能データを公開しており、その位置づけは非常に明確である。製品レベルの保証ではなく、期待値を揃えるための参照点だ。
📎 docs/perf/README.md:3-14
NCCL publishes reference performance data to:
1. Provide reference points that help users align performance expectations.
2. Help users validate their system setup.
3. Reduce repeated requests to the NCCL team for basic performance numbers.
These results are references, and NOT product-level guarantees that the same
performance is achievable on every system. Performance depends on a complex
combination of software versions, system configuration, hardware, and operating
conditions, including factors outside NCCL's control. A difference within 5% is
generally considered acceptable variance due to differences in the underlying
systems.ここには2つの重要な情報があり、初心者が見落としやすい。
第一に、5%以内の差異は正常な変動である。これは、公式より3%低い測定結果が出ても、慌ててパラメータを調整しないことを意味する。まず測定ノイズ、GPUクロックの揺らぎ、あるいは隣接タスクの干渉でないかを確認せよ。
第二に、公式はピーク帯域幅のみを公開し、レイテンシは公開しない。
📎 docs/perf/README.md:24-24
We publish peak bandwidth for a selection of commonly used platforms. We do not
currently publish latency because it is typically more sensitive to factors
outside NCCL's control.なぜレイテンシは公開されないのか。レイテンシはシステム状態に極めて敏感だからである。CPU周波数、PCIeリンク状態、NICファームウェアバージョン、さらにはBIOSの電源ポリシーまで影響する。帯域幅は大きなメッセージでは飽和に近づき、比較的安定している。レイテンシは小さなメッセージでは無数の微小な要素が積み重なって生じ、どの一环が揺らいでも増幅される。したがってチューニング時には、大きなメッセージは帯域幅を見て、小さなメッセージはレイテンシを見る、これが2つの異なる調査パスである。
📎 docs/perf/README.md:24-24
If your workload differs significantly from the published results, open an
issue in the [NCCL repository](https://github.com/NVIDIA/nccl/issues) or contact
NVIDIA Support. We will try our best to help.調査順序の第一条:まず標準benchmark(例えばnccl-testsのall_reduce_perf)を実行し、結果を公式レポートと比較する。差が5%以内なら、システム構成に問題はなく、性能ボトルネックはアプリケーション層(例えば通信頻度、メッセージ分割方式)にある。差が顕著なら、NCCLパラメータのチューニングに進む。
21.2 コストモデル:NCCL自身がアルゴリズムとプロトコルをどう選ぶか
パラメータを調整するには、まずNCCLのデフォルトがどう選ばれているかを理解する必要がある。その内部には「コストモデル」があり、本質的にはテーブル参照+公式計算である。与えられたメッセージサイズ、トポロジタイプ、rank数に対して、各「アルゴリズム×プロトコル」の組み合わせの所要時間を見積もり、最小のものを選ぶ。
直感的モデル
コストモデルをナビゲーションソフトのように想像しよう。出発点と終点(メッセージサイズ、トポロジ)を入力すると、内部で各ルート(アルゴリズム/プロトコルの組み合わせ)の時間を見積もり、最速のものを推薦する。ナビの見積もりは履歴データと道路等級に基づき、NCCLの見積もりはハードコードされたレイテンシ/帯域幅パラメータテーブルに基づく。
もしこのモデルがなければ、NCCLはすべてのシナリオで同じ固定アルゴリズムを使うしかない。小さなメッセージは起動オーバーヘッドが大きすぎて遅くなり、大きなメッセージは帯域幅の利用が不十分で遅くなり、システムは両極端で悪い性能を示すだろう。
データ構造:モデルテーブルとチューニングコンテキスト
コストモデルの中核はmodelMap配列であり、各要素が1つの「アルゴリズム/プロトコル/対称カーネル」の組み合わせに対応する。
📎 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
...各エントリには4つのフィールドがある。mod_init(初期化関数)、mod_sim(シミュレーション関数)、mod_final(クリーンアップ関数)、enabled(5つの関数それぞれの有効化フラグ)。enabled配列の順序は{Broadcast, Reduce, AllGather, ReduceScatter, AllReduce}である。この順序に注意せよ。後でコードを読むときに繰り返し使うことになる。
重要な観察:TreeはAllReduceでのみ有効化され({0,0,0,0,1})、Ringはすべての関数で有効化される({1,1,1,1,1})。これは、Treeアルゴリズムの利点がAllReduceの還元フェーズを並列化できる点にあるが、AllGather/ReduceScatterのような本質的にリングパイプラインである操作には、Ringの方が自然だからである。
モデルの具体的なパラメータはncclTunerConstants_tにあり、各トポロジーにおける基本レイテンシと帯域幅を含んでいる。
📎 src/tuning/cost_model.cc:142-152
static const ncclTunerConstants_t ncclTunerConstantsDefaults = {
// baseLatencies
{
{6.8, 14.0, 8.4}, // Tree
{6.6, 14.0, 8.4}, // Ring
{0, 0, 0}, // Collnet Direct
{0, 0, 0}, // Collnet Chain
{0, 0, 0}, // NVLS
{0, 0, 0}, // NVLS Tree
{8.0, 8.0, 8.0} // PAT
},各アルゴリズムには3つの基本レイテンシ値があり、LL / LL128 / Simple の3つのプロトコルに対応している。例えば Ring の{6.6, 14.0, 8.4}は、LLプロトコルの基本レイテンシが6.6マイクロ秒、LL128が14.0、Simpleが8.4であることを意味する。これらの数値はNVIDIAが実ハードウェア上で測定した経験値である。
ハードウェアレイテンシはトポロジータイプ(NVLink / PCI / NET)ごとに個別に与えられる。
📎 src/tuning/cost_model.cc:153-184
// hwLatencies
{
/* NVLINK */
{
{0.6, 1.25, 4.0}, // Tree (LL/LL128/Simple)
{0.6, 1.9, 3.4}, // Ring (LL/LL128/Simple)
...
},
/* PCI */
{
{1.0, 1.9, 4.0}, // Tree (LL/LL128/Simple)
{1.0, 2.5, 5.7}, // Ring (LL/LL128/Simple)
...
},
/* NET */
{
{5.0, 8.5, 14}, // Tree (LL/LL128/Simple)
{2.7, 4.0, 14.0}, // Ring (LL/LL128/Simple)
...
},
},比較すればトポロジーの違いが分かる。NVLink上のRing/Simpleのホップあたりレイテンシは3.4マイクロ秒、PCI上では5.7、NET上では14.0である。これがクロスホスト通信が遅い理由だ——1ホップごとに10マイクロ秒余分にかかる。
帯域幅パラメータはGPUアーキテクチャの世代ごとに与えられる。
📎 src/tuning/cost_model.cc:183-183
// llMaxBws
{
{39.0, 39.0, 20.4}, /* Volta-N1/Intel-N2/Intel-N4) */
{87.7, 22.5 /*avg of ring & tree*/, 19.0}, /* Ampere-N1/AMD-N2/AMD-N4) */
{141.0, 45.0 /*avg of ring & tree*/, 35.0}, /* Hopper-N1/AMD-N2/AMD-N4) */
{2 * 141.2, 2 * 45.0 /*avg of ring & tree*/, 2 * 35.0}, /* Blackwell-N1/AMD-N2/AMD-N4) */
},各行は1世代のアーキテクチャに対応し、3つの値はそれぞれシングルノード(N1)、デュアルノード(N2)、クアッドノード(N4)シナリオにおけるLLプロトコルの最大帯域幅である。Hopperはシングルノードで141 GB/s、Blackwellは倍の282 GB/s——これが新しいカードで同じアルゴリズムの性能がはるかに良くなる理由を説明している。
チューニングコンテキスト:per-commの状態
各通信ドメイン(communicator)はncclTuningContext_tを1つ保持し、この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];
};4つの重要なフィールド:
forced[NCCL_NUM_FUNCTIONS]:どの関数が環境変数によってアルゴリズム/プロトコルを強制指定されたかを示す。これがNCCL_ALGO/NCCL_PROTOが効果を発揮する着地点である。enabled[NCCL_TUNING_COUNT][NCCL_NUM_FUNCTIONS]:2次元ブールテーブルで、あるモデルがある関数に対して有効かどうかを示す。無効化されたモデルは選択に参加しない。generalLatencies/generalBandwidths:3次元配列で、「関数 × アルゴリズム × プロトコル」ごとに推定レイテンシと帯域幅を格納する。これがncclTuningInitが印刷する大きな表のソースである。threadThresholds/maxThreads:スレッド数に関連する閾値で、各blockが何スレッド使うかを決定する。
シナリオ駆動Walkthrough:1回のAllReduceのアルゴリズム選択
あなたがncclAllReduceを呼び出し、メッセージサイズ1MB、8カードシングルノードNVLinkだと仮定する。NCCL内部ではncclTuningInput_tを構築し、次にncclTuningCompute。
📎 src/tuning/tuning.cc:180-202
ncclResult_t ncclTuningCompute(struct ncclTuningInput_t* const input, struct ncclTuningResult_t* const result) {
ncclResult_t ret = ncclSuccess;
TRACE(NCCL_TUNING, ...);
struct ncclTuningResultList_t tunings;
tunings.head = nullptr;
struct ncclTuningResult_t bestTuning = NCCL_TUNING_RESULT_INIT;
// Set tuning to Ring/Simple for single rank case
if (input->comm->nRanks <= 1) {
bestTuning.algo = NCCL_ALGO_RING;
bestTuning.proto = NCCL_PROTO_SIMPLE;
...
} else {
NCCLCHECKGOTO(ncclTuningComputeAllTunings(input, &tunings), ret, exit);コピー
ステップ1:単一rankは直接Ring/Simpleを返し、一切計算しない。これはショートカット最適化である——単一カードには通信がないので、どのアルゴリズムを選んでも同じである。ncclTuningComputeAllTuningsステップ2:複数rankの場合は
📎 src/tuning/tuning.cc:128-149
ncclResult_t ncclTuningComputeAllTunings(struct ncclTuningInput_t* const input,
struct ncclTuningResultList_t* const tunings) {
ncclResult_t ret = ncclSuccess;
for (int i = 0; i < NCCL_TUNING_COUNT; i++) {
struct ncclTuningResult_t tuning = NCCL_TUNING_RESULT_INIT;
tuning.id = i;
tuning.valid = 1;
if (!(input->tuningMask & (1ULL << i))) {
tuning.valid = 0;
continue;
}
NCCLCHECK(ncclTuningExpandId(i, &tuning.algo, &tuning.proto, &tuning.symKernelId, &tuning.ceMethodId));
NCCLCHECKGOTO(ncclTuningComputeTuning(i, input, &tuning), ret, fail);
if (tuning.valid) NCCLCHECKGOTO(ncclTuningResultListPushFront(tunings, tuning), ret, fail);
}コピーtuningMaskここには巧妙な設計がある:NCCL_TUNING_MASK_GENERAL_KERNELS、NCCL_TUNING_MASK_SYM_KERNELS、NCCL_TUNING_MASK_CEは64ビットマスクで、各ビットが1つの候補の組み合わせに対応する。
📎 src/include/tuning.h:17-25
#define NCCL_TUNING_SYM_KERNEL_ID_OFFSET (NCCL_NUM_ALGORITHMS * NCCL_NUM_PROTOCOLS)
#define NCCL_TUNING_CE_METHOD_ID_OFFSET (NCCL_TUNING_SYM_KERNEL_ID_OFFSET + ncclSymkKernelId_Count)
#define NCCL_TUNING_COUNT (NCCL_TUNING_CE_METHOD_ID_OFFSET + ncclCeMethodId_Count)
#define NCCL_TUNING_MASK_GENERAL_KERNELS ((1ULL << NCCL_TUNING_SYM_KERNEL_ID_OFFSET) - 1ULL)
#define NCCL_TUNING_MASK_SYM_KERNELS \
((1ULL << NCCL_TUNING_CE_METHOD_ID_OFFSET) - 1ULL - NCCL_TUNING_MASK_GENERAL_KERNELS)
#define NCCL_TUNING_MASK_CE ((1ULL << NCCL_TUNING_COUNT) - (1ULL << NCCL_TUNING_CE_METHOD_ID_OFFSET))
#define NCCL_TUNING_MASK_ALL ((1ULL << NCCL_TUNING_COUNT) - 1ULL)コピーNCCL_NUM_ALGORITHMS × NCCL_NUM_PROTOCOLSマスクのレイアウトは、下位ncclSymkKernelId_Countビットが従来の「アルゴリズム×プロトコル」の組み合わせ、中間のncclTuningComputeビットが対称カーネル、上位ビットがCE(Copy Engine)メソッドである。配列ではなくビットマスクを使うのは、
内で「この候補が今回のチューニング範囲内かどうか」を高速に判定するためである。ncclTuningComputeTuningステップ3:各候補に対してncclTuningCostModelSimModel。
📎 src/tuning/cost_model.cc:470-497
ncclResult_t ncclTuningCostModelSimModel(int id, struct ncclTuningInput_t* const input,
struct ncclTuningResult_t* const result) {
struct ncclTuningModelEntry_t* model = nullptr;
ncclResult_t ret = ncclSuccess;
result->forced = input->comm->tuningContext.forced[input->func];
NCCLCHECKGOTO(getModelEntry(id, &model), ret, not_valid);
if (model == nullptr) {
ret = ncclInternalError;
goto not_valid;
}
if (input->comm->tuningContext.enabled[id][input->func] == 0) {
goto not_valid;
}
if (model->model != nullptr) {
NCCLCHECKGOTO(model->model(input, result), ret, not_valid);
if (result->timeUs <= 0.0) {
goto not_valid;
}
} else {
goto not_valid;
}
exit:
return ret;
not_valid:
result->timeUs = NCCL_TUNING_IGNORE;
result->valid = 0;
goto exit;
}に転送する。not_validコピーtimeUsNCCL_TUNING_IGNORE、validラベルの処理に注意:いずれかのステップが失敗すると(モデルが存在しない、無効化されている、シミュレーションが非正の時間を返す)、
を
📎 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;
}を0に設定する。この候補は以降の選択から除外される。selectionTimeUsステップ4:すべての有効な候補から所要時間が最小のものを選ぶ。timeUs。selectionTimeUsコピー
ここに細かい点がある:選択には
flowchart TD
start["ncclTuningCompute(input, result)"] --> check_ranks{"comm->nRanks <= 1?"}
check_ranks -->|是| single["bestTuning = Ring/Simple<br/>nChannels = 0"]
check_ranks -->|否| all["ncclTuningComputeAllTunings()"]
all --> loop{"遍历 i in NCCL_TUNING_COUNT"}
loop -->|mask 未命中| skip["tuning.valid = 0<br/>continue"]
loop -->|mask 命中| expand["ncclTuningExpandId(i, ...)"]
expand --> sim["ncclTuningComputeTuning()<br/>→ ncclTuningCostModelSimModel()"]
sim --> sim_check{"enabled[id][func] != 0<br/>且 model->model != nullptr?"}
sim_check -->|否| invalid["timeUs = NCCL_TUNING_IGNORE<br/>valid = 0"]
sim_check -->|是| push["ncclTuningResultListPushFront()"]
skip --> loop
invalid --> loop
push --> loop
loop -->|遍历结束| tuner_check{"comm->tuner != NULL?"}
tuner_check -->|是| plugin["tuner->getCollInfo()<br/>覆盖 generalTable"]
tuner_check -->|否| select["ncclTuningSelectBestTuning()"]
plugin --> select
select --> channels["ncclTuningGetChannels()"]
channels --> eff{"CTAPolicy & EFFICIENCY<br/>且 NCCL_ALGO/NCCL_PROTO 未设置?"}
eff -->|是| nvls["尝试 NVLS 覆盖<br/>ncclNvlsRegResourcesQuery()"]
eff -->|否| done["*result = bestTuning"]
nvls --> done
single --> doneにフォールバックする。
は「選択時間」であり、追加のペナルティ項(例えば特定のシナリオで一部のアルゴリズムに追加コストがかかる)を含む可能性がある。これによりコストモデルに「推定時間」と「選択時間」を分離する能力が与えられる。
フローチャートNCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNELコピーparseListこの図はエントリから最終結果までの決定パスを完全に描いており、単一rankショートカット、マスクフィルタリング、モデル無効化、tunerプラグインの介入、CTAPolicyの上書きなど、すべての分岐を含んでいる。enabled21.3 環境変数:性能に本当に影響する3つのノブ
コストモデルを理解すれば、環境変数がどのように介入するかが分かる。
parseListこれら3つの変数は
📎 src/tuning/cost_model.cc:14-32
// Parse a map of prefixes to a list of elements. The first prefix is
// optional and, if not present, the list of elements will be applied
// to all prefixes. Only the first list of elements can lack a
// prefix. Prefixes (if present) are followed by a colon. Lists of
// elements are comma delimited. Mappings of prefix to the lists of
// elements are semi-colon delimited.
//
// For example:
//
// NCCL_ALGO="ring,collnetdirect;allreduce:tree,collnetdirect;broadcast:ring"
// Enable ring and collnetdirect for all functions, then select tree
// and collnetdirect for allreduce and ring for broadcast.
//
// NCCL_PROTO="LL,Simple;allreduce:^LL"
// Enable LL and Simple for all functions, but everything except LL
// for allreduce.
//
// NCCL_PROTO="^LL128;allreduce:LL128"
// Enable everything but LL128, but only LL128 for allreduce.テーブルを直接変更し、ユーザーの意図に合わない候補をすべて無効化する。
1. 解析構文:NCCL_ALGO="ring,tree"がサポートする構文は、ほとんどの人が想像するより複雑である。
2. コピー:NCCL_ALGO="ring;allreduce:tree"3つの使い方:
3. グローバルリスト:NCCL_PROTO="^LL128"—— すべての関数でringとtreeのみを使う。
^関数プレフィックス別
📎 src/tuning/cost_model.cc:59-67
int unset, set;
if (elemList[0] == '^') {
unset = 1;
set = 0;
elemList++;
} else {
unset = 0;
set = 1;
}除外構文^—— LL128以外はすべて有効化。unset=1、set=0プレフィックスが鍵である——これは「unset」を意味し、デフォルトの全有効からあるオプションを除外する。unsetコピーset。
📎 src/tuning/cost_model.cc:69-96
bool foundPrefix = false;
for (int p = 0; p < nprefixes; p++) {
if (prefix && strcasecmp(prefix, prefixElems[p]) != 0) continue;
foundPrefix = true;
for (int e = 0; e < nelems; e++) list[p * nelems + e] = unset;
tokStr = strdup(elemList);
char* tmpStr;
char* elem = strtok_r(tokStr, ",", &tmpStr);
while (elem) {
int e;
for (e = 0; e < nelems; e++) {
if (strcasecmp(elem, elems[e]) == 0) {
list[p * nelems + e] = set;
forced[p] = 1;
break;
}
}
if (e == nelems) {
WARN("Unrecognized element token \"%s\" when parsing \"%s\"", elem, str);
ret = ncclInvalidUsage;
goto fail;
}
elem = strtok_r(NULL, ",", &tmpStr);
}に解析されると、forced[p] = 1。その後、マッチしたprefixに対して、まずリスト全体を
(全除外)で埋め、次に列挙された要素を
ncclTuningCostModelInitに設定する。
📎 src/tuning/cost_model.cc:363-384
for (int f = 0; f < NCCL_NUM_FUNCTIONS; f++) {
// Disable LL128 when 1) it is not supported on the platform, and 2) user did not explicitly request it.
// protoEnable[..] == 2 indicates that user did not set NCCL_PROTO=LL128 explicitly.
if (proto == NCCL_PROTO_LL128 && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] == 2 &&
!isLL128Enabled(comm->minCompCap, comm->maxCompCap, comm->graphs[algo].typeInter,
comm->graphs[algo].typeIntra, comm->nRanks, f, algo, comm->minDriverVersion)) {
comm->tuningContext.enabled[i][f] = 0;
}
// Check the user env vars only for functions that have a forced configuration and not already disabled.
if (comm->tuningContext.forced[f] == 0 || comm->tuningContext.enabled[i][f] == 0) continue;
comm->tuningContext.enabled[i][f] = 0;
TRACE(NCCL_TUNING, "a/p/s %s/%s/%s enabled %d/%d/%d", ...);
if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
(proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
(symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
comm->tuningContext.enabled[i][f] = 1;
}
}1. の行に注意——ユーザーが明示的にある要素を列挙した場合、対応する関数は「強制」とマークされる。このマークは後でコストモデルが自由に選択することを許可するかどうかの判定に使われる。強制と無効化の相互作用isLL128EnabledprotoEnable == 2には、ユーザーの強制と環境変数、プラットフォーム能力の相互作用を処理する重要なロジックがある。
2. コピーこのロジックの順序は重要である:forced[f] != 0まずLL128プラットフォーム能力を処理enabled[i][f] = 0)、その後ユーザーがこの組み合わせを許可しているかを確認し、許可されていれば再度有効化する。
protoEnableの値には三種類ある:0(ユーザーによる除外)、1(ユーザーによる有効化)、2(ユーザーが言及しておらず、デフォルトで有効)。この三状態設計により、「ユーザーの明示的な要求」と「プラットフォームのデフォルト」を区別できる。
環境変数読み取りのキャッシュ機構
すべてのNCCL_PARAMマクロは最終的にncclLoadParam。
📎 src/misc/param.cc:78-108
int64_t ncclLoadParam(char const* env, int64_t deftVal, int64_t uninitialized, int64_t* cache, int8_t* noCache) {
static std::mutex mutex;
std::lock_guard<std::mutex> lock(mutex);
// noCache is only load/stored within the mutex, no need for atomic
if (*noCache == /*uninitialized*/ -1) ncclGetCachePolicy(env, noCache);
if (COMPILER_ATOMIC_LOAD(cache, std::memory_order_relaxed) != uninitialized) {
return COMPILER_ATOMIC_LOAD(cache, std::memory_order_relaxed);
}
// Read the environment variable
const char* str = ncclGetEnv(env);
int64_t value = deftVal;
if (str && strlen(str) > 0) {
errno = 0;
char* end = nullptr;
value = strtoll(str, &end, 0);
// Preserve numeric-prefix parsing while rejecting non-numeric values.
if (errno || end == str) {
value = deftVal;
ATTN("Invalid value %s for %s, using default %lld.", str, env, (long long)deftVal);
} else {
INFO(NCCL_ENV, "%s set by environment to %lld.", env, (long long)value);
}
}
if (*noCache == /*cache*/ 0) COMPILER_ATOMIC_STORE(cache, value, std::memory_order_relaxed);
return value;
}このコードには注目すべき設計がいくつかある:
グローバルミューテックス:static std::mutex mutexが読み取りプロセス全体を保護する。これは、すべてのパラメータの初回読み取りが直列化されることを意味する。なぜロックを使い、ロックフリーにしないのか?パラメータ読み取りは初期化段階でのみ発生し、ホットパス上にはないため、ロックのオーバーヘッドは無視でき、正確性の方がより重要だからである。
二重チェック:まずアトミックにcacheを読み取り、すでに初期化済みならそのまま返す。これにより、パラメータ読み取りのたびにロックに入ることを避けられる——ロック自体は初期化後ほとんど競合しないが、アトミック読み取りの方が高速である。
キャッシュ戦略:noCacheフラグが、読み取った値をcacheに書き戻すかどうかを決定する。一部のパラメータ(動的な応答が必要なものなど)はキャッシュを無効化し、毎回環境変数を読み直すことがある。
エラー処理:strtoll解析失敗時はデフォルト値を使用し、ATTN警告を出力する。end == strの判定に注意——文字列の先頭が数字でなければ、endはstrと等しくなり、数字がまったく解析されなかったことを示す。
設定ファイルのサポート
環境変数は必ずしもシェルから設定する必要はなく、NCCL は設定ファイルからの読み取りをサポートしている。
📎 src/misc/param.cc:52-67
static void initEnvFunc() {
char confFilePath[1024];
const char* userFile = std::getenv("NCCL_CONF_FILE");
if (userFile && strlen(userFile) > 0) {
snprintf(confFilePath, sizeof(confFilePath), "%s", userFile);
setEnvFile(confFilePath);
} else {
const char* userDir = userHomeDir();
if (userDir) {
snprintf(confFilePath, sizeof(confFilePath), "%s/.nccl.conf", userDir);
setEnvFile(confFilePath);
}
}
snprintf(confFilePath, sizeof(confFilePath), "/etc/nccl.conf");
setEnvFile(confFilePath);
}読み込み順序:NCCL_CONF_FILEで指定されたファイル(設定されている場合)→~/.nccl.conf → /etc/nccl.conf。後に読み込まれたものが先に読み込まれたものを上書きする(setEnvFileがncclOsSetEnv)。
📎 src/misc/param.cc:69-72
void initEnv() {
static std::once_flag once;
std::call_once(once, initEnvFunc);
}std::call_onceコピーncclGetEnv。
は設定ファイルが一度だけ読み込まれることを保証する。複数のスレッドが同時に初めて
21.4 チャネル数:過小評価されている性能調整ノブ
アルゴリズムとプロトコルは「どう進むか」を決め、チャネル数は「いくつの道を開くか」を決める。多くの人はチューニング時に最初の二つだけに注目し、チャネル数を無視する——しかし大メッセージのシナリオでは、チャネル数が帯域幅利用率を決める鍵となることが多い。
ncclTuningComputeチャネル数はどこから来るのかncclTuningGetChannels最適なアルゴリズム/プロトコルを選出した後、
📎 src/tuning/tuning.cc:233-235
if (bestTuning.algo != NCCL_ALGO_UNDEF && bestTuning.proto != NCCL_PROTO_UNDEF) {
NCCLCHECKGOTO(ncclTuningGetChannels(input, &bestTuning), ret, exit);
}コピーncclTuningResult_tチャネル数の計算ロジックは本章のソース資料にはないが、
📎 src/include/tuning.h:42-55
struct ncclTuningResult_t {
int id;
int valid;
float timeUs;
float selectionTimeUs;
int algo;
int proto;
int symKernelId;
int ceMethodId;
int nChannels;
int maxChannels;
int nWarps;
int forced;
};nChannelsコピーmaxChannelsは最終的に使用されるチャネル数、nWarpsは上限である。
は各 block の warp 数である。
CTAPolicy によるチャネル数の上書きNCCL_CTA_POLICY_EFFICIENCY戦略を処理する特別なロジックがある。
📎 src/tuning/tuning.cc:236-257
// NCCL_CTA_POLICY_EFFICIENCY requires user (non-symmetric) buffer registration (currently unsupported with MNNVL).
// Run after GetChannels so bestTuning.nChannels is valid. Skip when a tuner plugin owns selection
// (same as pre-rearch). The NVLS-bit guard keeps this bias inside the candidate set: a per-call
// algSelection may have narrowed tuningMask, so EFFICIENCY must not resurrect NVLS when excluded.
if (input->comm->tuner == NULL && (input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY) &&
ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL && !input->comm->MNNVL &&
(input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)))) {
if (input->regBuff && (input->func == ncclFuncAllGather || input->func == ncclFuncReduceScatter)) {
if ((input->comm->nNodes > 1 && input->collNetSupport && input->nvlsSupport) ||
(input->comm->nNodes == 1 && input->nvlsSupport)) {
int recChannels;
NCCLCHECKGOTO(ncclNvlsRegResourcesQuery(input->comm, input->func, &recChannels), ret, exit);
if (recChannels <= bestTuning.nChannels) {
bestTuning.algo = NCCL_ALGO_NVLS;
bestTuning.proto = NCCL_PROTO_SIMPLE;
bestTuning.nChannels = recChannels;
bestTuning.maxChannels = recChannels;
bestTuning.nWarps = input->comm->tuningContext.maxThreads[bestTuning.algo][bestTuning.proto] / WARP_SIZE;
}
}
}
}このコードのガード条件は非常に密であり、一つずつ読み解く価値がある:
1. input->comm->tuner == NULL:tuner プラグインがない場合にのみこの部分を通る。プラグインが選択権を持つ場合、NCCL は介入しない。
2. input->CTAPolicy & NCCL_CTA_POLICY_EFFICIENCY:ユーザーが効率優先戦略を設定した。
3. ncclGetEnv("NCCL_ALGO") == NULL && ncclGetEnv("NCCL_PROTO") == NULL:ユーザーがアルゴリズム/プロトコルを強制していない。強制している場合はユーザーの選択を尊重する。
4. !input->comm->MNNVL:MNNVL シナリオではサポートされない。
5. input->tuningMask & (1ull << (NCCL_ALGO_NVLS * NCCL_NUM_PROTOCOLS + NCCL_PROTO_SIMPLE)):NVLS/Simple が候補集合内にある。このガードは排除された選択肢の「復活」を防ぐ。
条件を満たすと、NVLS 登録リソースがサポートできるチャネル数を照会し、現在の選択を超えなければ NVLS アルゴリズムに切り替える。
なぜ EFFICIENCY 戦略は NVLS を好むのか?NVLS(NVLink SHARP)はスイッチハードウェアを利用してリダクションを行うため、GPU の計算と通信のオーバーヘッドを削減でき、AllGather/ReduceScatter のような操作でより効率的だからである。しかしそのチャネル数はハードウェアリソースに制限されるため、ncclNvlsRegResourcesQueryで実際の利用可能量を照会する必要がある。
対称カーネルのフォールバックロジック
対称カーネル(symmetric kernel)は比較的新しい機能であり、利用できない場合は汎用カーネルにフォールバックする必要がある。
📎 src/tuning/tuning.cc:258-298
if ((bestTuning.symKernelId != ncclSymkKernelId_Count ||
(input->tuningMask & NCCL_TUNING_MASK_SYM_KERNELS && bestTuning.symKernelId == ncclSymkKernelId_Count)) &&
bestTuning.algo == NCCL_ALGO_UNDEF && bestTuning.proto == NCCL_PROTO_UNDEF) {
bool isLLKernel = (1 << bestTuning.symKernelId) & ncclSymkLLKernelMask();
bool isOneThreadMultiGpus = input->comm->intraRanks > 1 && !ncclParamSingleProcMemRegEnable();
bool needFallback = bestTuning.symKernelId != ncclSymkKernelId_Count ? false : true;
// General kernel tuning structs if fallback is needed
struct ncclTuningResult_t generalTuning = NCCL_TUNING_RESULT_INIT;
struct ncclTuningInput_t generalInput = *input;
generalInput.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;
// Fallback logic for symmetric LL kernels:
// - If both src and dst are registered, we don't fall back if a symmetric kernel is available.
// - Otherwise, we have to fall back to generl kernel if running the selected symmetric LL kernel is
// not possible (if the buffers are not registered and we manage multiple GPUs).
// - If the user forced a symmetric kernel via NCCL_SYM_KERNEL or requested preference for using
// symmetric kernels even without symmetric buffers via NCCL_SYM_NOWIN_ENABLE, we respect that.
// - Otherwise, we query the general cost model and if it selects a non-LL proto, we pick that.
if (bestTuning.symKernelId != ncclSymkKernelId_Count) {
if (input->winRegType == ncclSymSendRegRecvReg) {
needFallback = false;
} else if (isLLKernel) {
needFallback = isOneThreadMultiGpus && input->winRegType == ncclSymSendNonregRecvNonreg;
if (!needFallback && !result->forced) {
needFallback = !ncclParamSymNoWinEnable() && input->winRegType == ncclSymSendNonregRecvNonreg;
if (!needFallback) {
NOWARN(ncclTuningCompute(&generalInput, &generalTuning), NCCL_TUNING);
needFallback = (generalTuning.proto != NCCL_PROTO_LL);
}
}
}
}フォールバック決定木:
- 送信バッファと受信バッファの両方が登録されている場合(
ncclSymSendRegRecvReg)、フォールバックしない。 - LL カーネルで、単一スレッドが複数 GPU を管理し、バッファが未登録の場合、フォールバックする。
- ユーザーが
NCCL_SYM_NOWIN_ENABLEを設定しておらず、バッファが未登録の場合、フォールバックする。 - それ以外の場合、汎用コストモデルを照会し、それが非 LL プロトコルを選んだらフォールバックする。
このロジックの核心は:対称 LL カーネルはバッファ登録があってこそ利点を発揮できる。未登録の場合、LL カーネルの利点(低遅延)が余分なアドレス変換オーバーヘッドに相殺される可能性があるため、汎用カーネルにフォールバックする方が得策である。
利用可能な組み合わせがない場合のエラー処理
すべての候補が排除された場合、NCCL はエラーを報告し診断情報を出す。
📎 src/tuning/tuning.cc:308-329
if ((bestTuning.algo == NCCL_ALGO_UNDEF || bestTuning.proto == NCCL_PROTO_UNDEF) &&
bestTuning.symKernelId == ncclSymkKernelId_Count && bestTuning.ceMethodId == ncclCeMethodId_Count) {
char ncclAlgoEnvStr[1024] = "";
char ncclProtoEnvStr[1024] = "";
char ncclSymKernelIdEnvStr[1024] = "";
const char* symKernelIdEnv = ncclGetEnv("NCCL_SYM_KERNEL");
if (symKernelIdEnv) {
snprintf(ncclSymKernelIdEnvStr, 1023, " NCCL_SYM_KERNEL was set to %s.", symKernelIdEnv);
}
const char* algoEnv = ncclGetEnv("NCCL_ALGO");
if (algoEnv) {
snprintf(ncclAlgoEnvStr, 1023, " NCCL_ALGO was set to %s.", algoEnv);
}
const char* protoEnv = ncclGetEnv("NCCL_PROTO");
if (protoEnv) {
snprintf(ncclProtoEnvStr, 1023, " NCCL_PROTO was set to %s.", protoEnv);
}
WARN("No algorithm/protocol nor symKernelId available for function %s with datatype %s.%s%s%s",
ncclFuncToString(input->func), ncclDatatypeToString(input->datatype), ncclAlgoEnvStr, ncclProtoEnvStr,
ncclSymKernelIdEnvStr);
ret = (algoEnv || protoEnv || symKernelIdEnv) ? ncclInvalidUsage : ncclInternalError;
}エラーコードの選択には理由がある:ユーザーが環境変数を設定している場合(algoEnv || protoEnv || symKernelIdEnv)、ncclInvalidUsageを返す——これはユーザーの設定問題である;そうでなければncclInternalErrorを返す——これは NCCL 内部の問題である(すべての候補が予期せず排除された)。
21.5 本番環境の落とし穴回避ガイド
落とし穴一:環境変数のスペルミスによるサイレントフォールバック
parseListは認識できないトークンに遭遇するとncclInvalidUsageを返すが、NCCL_ALGO=RING(大文字)と書いても、strcasecmpは正しくマッチする。本当に危険なのはスペルミスである。例えばNCCL_ALGO=rnig。
📎 src/tuning/cost_model.cc:87-91
if (e == nelems) {
WARN("Unrecognized element token \"%s\" when parsing \"%s\"", elem, str);
ret = ncclInvalidUsage;
goto fail;
}ここでは WARN を出力しエラーを返す。しかしNCCL_DEBUG=WARNを有効にしていなければ、この警告が見えない可能性がある。推奨:チューニング時は常にNCCL_DEBUG=WARNまたはNCCL_DEBUG=INFOを設定し、設定解析の結果を確認できるようにする。
落とし穴二:NCCL_ALGO と NCCL_PROTO の相互作用
もしNCCL_ALGO=treeを設定してNCCL_PROTOを設定しなければ、NCCL は Tree アルゴリズムの下で最適なプロトコルを選ぶ。しかしNCCL_ALGO=treeとNCCL_PROTO=LLを同時に設定し、Tree/LL の組み合わせが一部の関数で無効化されている場合(例えば Tree は AllReduce でのみ有効)、「利用可能な組み合わせがない」エラーが発生する。
📎 src/tuning/cost_model.cc:379-383
if (((algo != NCCL_ALGO_UNDEF && algoEnable[f * NCCL_NUM_ALGORITHMS + algo] != 0) &&
(proto != NCCL_PROTO_UNDEF && protoEnable[f * NCCL_NUM_PROTOCOLS + proto] != 0)) ||
(symKernelId != ncclSymkKernelId_Count && symKernelIdEnable[f * ncclSymkKernelId_Count + symKernelId] != 0)) {
comm->tuningContext.enabled[i][f] = 1;
}アルゴリズムとプロトコルが同時に許可されている場合にのみ、組み合わせが有効になる。これは AND ロジックであり、OR ではない。
落とし穴三:LL128 のプラットフォーム制限
LL128 はすべてのプラットフォームでサポートされているわけではない。isLL128Enabled計算能力、ドライババージョン、接続タイプを確認しました。
📎 src/tuning/cost_model.cc:119-139
static int isLL128Enabled(int minCompCap, int maxCompCap, int interType, int intraType, int nRanks, int func, int algo,
int minDriverVersion) {
int ret = 1;
if (ncclParamLl128C2c() && minCompCap >= 90 && (!RUBIN_AND_LATER(minCompCap) || minDriverVersion >= 13030)) {
// Rubin, Blackwell, and Hopper: Enable LL128 for all P2C and PXN if CUDA supports it.
ret &= (interType <= PATH_PXN);
} else {
// Enable LL128 only up to PXB. Don't enable LL128 over PxN because PxN can encapsulate PxB or P2C links.
ret &= (interType <= PATH_PXB);
if (!ncclParamLl128C2c() && minCompCap >= 90)
INFO(
NCCL_GRAPH | NCCL_TUNING,
"Disabling LL128 over all PxN connections (PXB and C2C). This ensures that no C2C link will be used by LL128.");
}
ret &= (intraType <= PATH_NVB);
// Enable LL128 for interoperability between GPUs with different compcap (Hopper and above)
ret &= (minCompCap == maxCompCap || minCompCap >= 90);
ret &= !(minCompCap < 70 || (minCompCap == 90 && CUDART_VERSION == 11080 && func == ncclFuncAllReduce &&
algo == NCCL_ALGO_RING && nRanks == 2));
return ret;
}いくつかの重要な制限:
minCompCap < 70:Volta 以前の GPU は LL128 をサポートしていません。intraType <= PATH_NVB:ノード内接続は NVLink レベルでなければなりません。- Hopper + CUDA 11.8 + AllReduce + Ring + 2 ranks:これは既知のバグシナリオであり、明示的に除外されています。
推奨:プラットフォームが LL128 をサポートしていない場合、NCCL_PROTO=LL128を強制的に設定しないでください。そうしないとエラーが発生します。NCCL に自動選択させてください。
落とし穴4:チャネル数と VRAM
チャネル数が多いほど、必要なバッファが大きくなります。VRAM が逼迫しているシナリオでは、チャネルが多すぎると OOM が発生する可能性があります。
📎 src/tuning/tuning.cc:246-253
int recChannels;
NCCLCHECKGOTO(ncclNvlsRegResourcesQuery(input->comm, input->func, &recChannels), ret, exit);
if (recChannels <= bestTuning.nChannels) {
bestTuning.algo = NCCL_ALGO_NVLS;
bestTuning.proto = NCCL_PROTO_SIMPLE;
bestTuning.nChannels = recChannels;
bestTuning.maxChannels = recChannels;
bestTuning.nWarps = input->comm->tuningContext.maxThreads[bestTuning.algo][bestTuning.proto] / WARP_SIZE;
}NVLS のチャネル数はncclNvlsRegResourcesQueryがハードウェアリソースを照会して決定するものであり、自由に設定できるものではありません。ハードウェアリソースが不足している場合、チャネル数は制限されます。
21.6 チューニング意思決定フロー
これまでの内容をまとめて、実行可能なトラブルシューティングフローを導き出します。
flowchart TD
start["性能不达标"] --> baseline["跑 nccl-tests 对比官方报告"]
baseline --> diff{"差距 > 5%?"}
diff -->|否| app["检查应用层:<br/>通信频率、消息切分"]
diff -->|是| debug["设置 NCCL_DEBUG=INFO<br/>查看算法/协议选择"]
debug --> check_algo{"选择的算法合理?"}
check_algo -->|否| force_algo["尝试 NCCL_ALGO 强制<br/>对比不同算法"]
check_algo -->|是| check_proto{"协议合理?"}
check_proto -->|否| force_proto["尝试 NCCL_PROTO 强制<br/>小消息 LL,大消息 Simple"]
check_proto -->|是| check_chan{"通道数合理?"}
check_chan -->|否| tune_chan["调整 NCCL_NCHANNELS<br/>或检查显存限制"]
check_chan -->|是| check_topo["检查拓扑:<br/>NCCL_TOPO_DUMP 确认链路"]
force_algo --> verify["重新 benchmark 验证"]
force_proto --> verify
tune_chan --> verify
check_topo --> verify
verify --> improved{"性能提升?"}
improved -->|是| done["固化配置"]
improved -->|否| escalate["提交 issue 或联系支持"]このフローの核心的な考え方は:まず特定し、次にパラメータを調整し、最後に検証するです。いきなり環境変数をでたらめに設定しないでください。
本章のまとめ
本章では、NCCL のチューニングパスを4つのレベルに分解しました:
1. ベースライン:公式のパフォーマンスレポートで期待値を確立し、5% 以内は正常な変動であり、大きなメッセージは帯域幅、小さなメッセージはレイテンシを見ます。
2. コストモデル:NCCL は内部的にmodelMapテーブル + レイテンシ/帯域幅パラメータで各組み合わせの所要時間を見積もり、最小のものを選びます。このモデルを理解することがチューニングの前提です。
3. 環境変数:NCCL_ALGO、NCCL_PROTO、NCCL_SYM_KERNELはparseListで解析された後にenabledテーブルを変更し、特定の組み合わせを強制または除外します。構文はグローバル、関数別、除外の3つのモードをサポートしています。
4. チャネル数:はncclTuningGetChannelsによって計算され、ハードウェアリソースと CTAPolicy の影響を受けます。
本章の考察とセルフチェック
Q1: もしncclTuningComputeの単一 rank ショートカットロジック(input->comm->nRanks <= 1分岐)を削除したら、何が起こるでしょうか?どのようなシナリオで問題が発生するでしょうか?
参考解説:
単一 rank ショートカットは📎 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);
...この分岐を削除すると、単一 rank シナリオはncclTuningComputeAllTuningsに入り、すべての候補の組み合わせを走査します。問題は:
1. パフォーマンスの無駄:単一 rank には通信がないため、すべてのアルゴリズムの所要時間見積もりは純粋なオーバーヘッドであり、どれを選んでも同じです。すべての候補を走査するのは純粋な無駄です。
2. 結果が選べない可能性:一部のアルゴリズムは単一 rank ではモデルによって無効と判定される可能性があり(例えば Ring は環を形成するために少なくとも2つの rank が必要)、tuningsリストが空になり、ncclTuningSelectBestTuningがFLT_MAXの初期値を返し、最終的にbestTuning.algoは依然としてNCCL_ALGO_UNDEF。
3. エラーパスをトリガー:もしbestTuning.algo == NCCL_ALGO_UNDEFなら、📎 src/tuning/tuning.cc:308-329のエラー処理に入り、"No algorithm/protocol available" 警告を出力し、ncclInternalError。
を返します。したがって、このショートカットは単なる最適化ではなく、正確性の保証です——単一 rank シナリオには確定的なデフォルト値が必須です。
Q2: parseListにおけるforced[p] = 1この行のコード(📎 src/tuning/cost_model.cc:83)の役割は何ですか?もしそれを削除したら、NCCL_ALGO=ringの動作はどう変わりますか?
参考解説:
forced[p] = 1は📎 src/tuning/cost_model.cc:80-85:
for (e = 0; e < nelems; e++) {
if (strcasecmp(elem, elems[e]) == 0) {
list[p * nelems + e] = set;
forced[p] = 1;
break;
}
}forced配列はncclTuningContext_tで定義されています([
重要なノブは3つだけです:アルゴリズム、プロトコル、チャネル数。他のパラメータはほとんどが補助的な診断や特定シナリオの最適化です。このチューニングパスを習得すれば、ほとんどのシナリオで NCCL をハードウェアに近い性能で動作させることができます。しかし性能以外に、本番環境にはさらに厄介な問題のカテゴリがあります:一見正常なコードが、特定の条件下でハングしたりエラーになったりする可能性です。次の章では、NCCL の本番環境における典型的な落とし穴事例——デッドロック、タイムアウト、バージョン不一致、よくある誤用——をまとめ、NCCL 内部がこれらの問題をどのように検出・報告するかを見ていきます。
第22章:第22章:本番トラブルシューティングと落とし穴:よくあるデッドロック、タイムアウト、バージョン不一致と調査方案
第22章:本番トラブルシューティングと落とし穴:よくあるデッドロック、タイムアウト、バージョン不一致と調査方案
前章では性能チューニングの調査順序と重要なノブを整理しましたが、本番環境における NCCL の障害は、性能が基準に達しないことではなく、プログラムが直接ハングしたりクラッシュしたりすることがしばしばです。これらの障害の根本原因は通常、ある関数の書き間違いではなく、呼び出し順序、ライフサイクル、またはバージョン契約が破壊されたことです。本章では、最も典型的な4つの落とし穴に焦点を当てます:group セマンティクスの誤用によるデッドロック、パラメータ検証の欠如によるサイレントエラー、ABI バージョン不一致、そしてタイムアウトとリトライの境界です。src/group.cc、src/misc/argcheck.cc、src/include/checks.h、contrib/nccl_ep/nccl_ep.cc の4つの手がかりに沿って、NCCL 内部がエラー発生前にどのようにそれを阻止しているかを明らかにします。
Group セマンティクスの誤用:「GroupEnd を1つ書き忘れる」となぜハングするのか
直感モデル:Group は「ショッピングカート」であり、「加速スイッチ」ではない
をncclGroupStart() / ncclGroupEnd()オンラインショッピングのカートのように想像してください:複数の商品(複数の通信呼び出し)をカートに入れ、最後に一度に精算します(ncclGroupEnd)。入れるだけで精算しないと、カートは永遠に宙に浮いたままです——NCCL 内部が維持するncclGroupDepthカウンタがゼロに戻らず、後続のすべての通信呼び出しが「まだ注文を溜めている」と思い込み、永遠に実際に kernel を発行しないため、プロセス全体がハングします。
これは本番環境で最もよく見られるデッドロック形態である:コードが何らかの例外分岐でreturnしてしまい、ncclGroupEndをスキップし、そしてncclGroupDepthはthread_localであるため、関数の戻りによって自動的にクリーンアップされない。
データ構造:thread_local の group 状態
NCCL は group 状態をすべてスレッドローカルストレージに置いている。これがデッドロックを理解する鍵である。
📎 src/group.cc:34-34
thread_local int ncclGroupDepth = 0; // depth of ncclGroupStart nesting
thread_local ncclResult_t ncclGroupError = ncclSuccess;
thread_local struct ncclComm* ncclGroupCommHead[ncclGroupTaskTypeNum] = {nullptr};
thread_local struct ncclComm* ncclGroupCommPreconnectHead = nullptr;
thread_local struct ncclIntruQueue<struct ncclAsyncJob, &ncclAsyncJob::next> ncclAsyncJobs;
thread_local int ncclGroupBlocking = -1; /* default mode */フィールドごとの解説:
ncclGroupDepth:ネスト深度。ncclGroupStartがインクリメントされ、ncclGroupEndがデクリメントされ、0 まで減って初めて実際に送信がトリガーされる。ネスト対応は設計上の利便性だが、「End を1つ漏らす」と深度が永遠に1のまま止まることも意味する。ncclGroupError:このスレッドに蓄積された group エラー。一度呼び出しが失敗すると、以降のncclGroupEndは直接失敗パスを通る。ncclGroupCommHead[]:タスク種別(collective / rawTask / mgmtTask / symRegister)ごとにグループ化された通信ドメインのリンクリスト先頭。ncclAsyncJobs:実行待ちの非同期タスクキュー(例:preconnect、symmetric register)。ncclGroupBlocking:-1は「まだどの通信ドメインにも遭遇していない」ことを表し、0は非ブロッキング、1はブロッキングを表す。このフィールドは後述の「ブロッキングと非ブロッキングの混用」検出の中核である。
thread_localをグローバル変数ではなく
を使う動機は直接的である:NCCL は複数スレッドがそれぞれ独立した group コンテキストを持ち、互いに干渉しないことを許容する。代償は——スレッド終了時にこれらの状態が自動クリーンアップされず、スレッドが group の途中で終了すると状態がリークすることである。
Step-by-Step:1回の GroupEnd の完全な検証チェーンncclGroupEnd()シナリオを代入:アプリケーションがncclGroupDepthを呼び出し、このとき
は1である。
📎 src/group.cc:1048-1052
if (ncclGroupDepth == 0) {
WARN("ncclGroupEnd: not in a group call.");
ret = ncclInvalidUsage;
goto exit;
}コピーncclGroupStartユーザーがncclGroupEndを呼ばずに直接ncclInvalidUsageした場合、ここで "not in a group call" を出力して
を返す。これは最も親切なエラーである——即座にエラーを報告し、ハングしない。
📎 src/group.cc:1061-1063
if ((--ncclGroupDepth) > 0) goto exit;
if ((ret = ncclGroupError) != ncclSuccess) goto fail;コピーEnd多層にネストしている場合、内側の
は深度をデクリメントして戻るだけで、送信をトリガーしない。最外層のみが続行する。同時に蓄積エラーをチェックする。
📎 src/group.cc:1095-1101
if (hasCommHead || !ncclIntruQueueEmpty(&groupJob->asyncJobs) || ncclGroupCommPreconnectHead != nullptr) {
/* make sure ncclGroupBlocking has been set. */
if (ncclGroupBlocking != 0 && ncclGroupBlocking != 1) {
WARN("Invalid group blocking state %d", ncclGroupBlocking);
ret = ncclInternalError;
goto fail;
}ncclGroupBlockingコピー{0, 1}は-1の間でなければならない。もしそれがまだ
なら、group 内に通信ドメインも非同期タスクもなく、論理的にここに来るべきではない。
📎 src/group.cc:1102-1134
if (ncclGroupBlocking == 0) {
/* nonblocking group */
if (!ncclIntruQueueEmpty(&groupJob->asyncJobs)) {
ncclAsyncJob* job = ncclIntruQueueHead(&groupJob->asyncJobs);
do {
NCCLCHECKGOTO(ncclCommSetAsyncError(job->comm, ncclInProgress), ret, fail);
if (job->comm->groupJob == NULL) {
job->comm->groupJob = groupJob;
groupJob->groupRefCount++;
}
job = job->next;
} while (job);
}
...
groupJob->base.func = groupLaunchNonBlocking;
STDTHREADCREATE_GOTO(groupJob->base.thread, ncclAsyncJobMain, ret, fail, &groupJob->base);
groupJob->nonBlockingInit = true;
ret = ncclInProgress;
}コピーgroupRefCount++ret = ncclInProgressとncclGroupEndに注意:非ブロッキングモードでは、ncclInProgressは即座にncclCommGetAsyncErrorを返し、実際の送信はバックグラウンドスレッドで実行される。呼び出し側は後でncclGroupJobCompleteでポーリングするか、
で待機しなければならない。
阻塞与非阻塞混用:为什么被禁止ncclAsyncLaunch
📎 src/group.cc:55-64
/* check if there are blocking and nonblocking comms at the same time in group. */
if (comm->destroyFlag) {
ncclGroupBlocking = 1;
} else if (ncclGroupBlocking == -1) {
/* first met communicator */
ncclGroupBlocking = comm->config.blocking;
} else if (ncclGroupBlocking != comm->config.blocking) {
WARN("Blocking and nonblocking communicators are not allowed in the same group.");
ret = ncclInvalidArgument;
}〔設計上の推論とアーキテクチャのトレードオフ〕ncclGroupEndなぜ混用が禁止されているのか?ブロッキング通信ドメインの送信セマンティクスは「呼び出しが戻ったとき kernel は既にコミット済み」であり、非ブロッキングは「呼び出しが戻ったときタスクはキューに入ったが未コミット」である。両者が同じ group 内にあると、
は統一的な戻りセマンティクスを与えられない——待つのか待たないのか?NCCL は直接拒否することを選び、問題を API 境界に露出させる。
本番の落とし穴:3つの実シナリオシナリオ1:例外分岐で GroupEnd を漏らす。ncclGroupStartコードがncclGroupEndとreturn,ncclGroupDepthの間で例外を投げるか早期にncclGroupEndし、1のまま止まる。以降のすべての通信呼び出しが「溜め込み」状態に入り、永遠に送信されない。調査方法:ncclGroupDepthの前にgdbを出力するか、
でその thread_local 変数を観察する。シナリオ2:スレッドをまたいで同じ comm を使用する。thread_localgroup 状態はncclGroupStartであるため、スレッド A がncclAllReduceを呼び出した後、スレッド B が
を呼んでも A の group には入らない。A と B が同じ comm を操作すると、「一部の呼び出しが group 内、一部が group 外」という錯乱が生じる。NCCL はこの状況を検出しない。なぜなら1つの comm は任意の時点で1つのスレッドのみに操作されると仮定しているからである。シナリオ3:CUDA graph capture と group の相互作用。doLaunches
📎 src/group.cc:448-455
if (capturingYes && capturingNo) {
// We have entered barriers but are aborting without leaving them. Thus
// these comms are permanently trashed. We need a good mechanism for
// tracking and reporting that.
WARN("Either none or all communicators in a ncclGroup() can be CUDA graph captured.");
result = ncclInvalidUsage;
goto failure;
}コピー
flowchart TD
start["ncclGroupEnd()"] --> depth_check{"ncclGroupDepth == 0?"}
depth_check -->|是| err_usage["WARN not in a group call<br/>return ncclInvalidUsage"]
depth_check -->|否| dec["--ncclGroupDepth"]
dec --> nested{"depth > 0?"}
nested -->|是| exit_ok["goto exit 返回"]
nested -->|否| err_check{"ncclGroupError == success?"}
err_check -->|否| fail_clean["groupCleanup 清理所有 comm 与 asyncJobs"]
err_check -->|是| blocking_check{"ncclGroupBlocking in {0,1}?"}
blocking_check -->|否| err_internal["WARN Invalid group blocking state<br/>return ncclInternalError"]
blocking_check -->|是| mode_split{"ncclGroupBlocking == 0?"}
mode_split -->|是 非阻塞| async_launch["STDTHREADCREATE groupLaunchNonBlocking<br/>ret = ncclInProgress"]
mode_split -->|否 阻塞| sync_launch["groupLaunch 同步下发<br/>delete groupJob"]
async_launch --> reset["groupLocalResetJobState"]
sync_launch --> reset
reset --> exit_ok
fail_clean --> resetコピー
パラメータ検証とサイレントエラー:ArgCheck が「正常に見える」呼び出しをどうブロックするか
直感モデル:ArgCheck は「空港の保安検査」
パラメータ検証は空港の保安検査のようなものである:より速く飛ばせる責任はないが、「荷物に見えて実は危険物」なものをブロックできる。これがなければ、デバイスを間違えたポインタが GPU kernel にゴミデータを読ませるか、さらに悪いことに——他人の VRAM を静かに破壊する。
データ構造:検証モードとグローバルチェックキューcomm->checkMode:
📎 src/misc/argcheck.cc:227-251
if (info->comm->checkMode != ncclCheckModeDefault) {
if ((info->coll == ncclFuncSend || info->coll == ncclFuncRecv)) {
if (info->count > 0) NCCLCHECK(CudaPtrCheck(info->recvbuff, info->comm, "buff", info->opName));
} else if (info->coll == ncclFuncPutSignal || info->coll == ncclFuncSignal || info->coll == ncclFuncWaitSignal) {
// One-sided RMA ops specify the remote destination via peerWin, not sendbuff/recvbuff,
// so the standard CUDA pointer checks do not apply here.
INFO(NCCL_COLL, "%s : skipping sendbuff/recvbuff pointer check (one-sided RMA uses peerWin)", info->opName);
} else {
// Check CUDA device pointers
if (info->coll != ncclFuncBroadcast || info->comm->rank == info->root) {
NCCLCHECK(CudaPtrCheck(info->sendbuff, info->comm, "sendbuff", info->opName));
}
if (info->coll != ncclFuncReduce || info->comm->rank == info->root) {
NCCLCHECK(CudaPtrCheck(info->recvbuff, info->comm, "recvbuff", info->opName));
}
}
if (info->comm->checkMode == ncclCheckModeDebugGlobal) {
struct ncclArgsInfo* argsInfo;
NCCLCHECK(ncclCalloc(&argsInfo, 1));
argsInfo->info = *info;
argsInfo->next = NULL;
ncclIntruQueueEnqueue(&info->comm->argsInfoQueue, argsInfo);
}
}コピー
ncclCheckModeDefault3つのモード:- :最も安価なチェック(root 範囲、datatype 範囲、op 範囲)のみを行い、CUDA API に触れない。
CudaPtrCheck非デフォルトモード:cudaPointerGetAttributesを呼び出し、これが実際に ncclCheckModeDebugGlobalを呼び出すため、パフォーマンスオーバーヘッドがある。ncclInfo:ローカルチェックに加えて、argsInfoQueue、group が終了するまで待ってから、ランク間のグローバル整合性チェックを行います。
この設計は性能と正確性のトレードオフです:cudaPointerGetAttributesは同期 CUDA 呼び出しであり、ホットパス上で毎回の通信ごとに呼び出すと小メッセージが著しく遅くなります。そのためデフォルトモードでは「ゼロコスト」のチェックのみを行い、高コストなポインタ検証はデバッグモードに委ねています。
Step-by-Step:CudaPtrCheck の三層防御
シナリオを当てはめる:ユーザーがsendbuffを渡し、NCCL がデバッグモードでそれを検証します。
第一層、ポインタが有効か:
📎 src/misc/argcheck.cc:12-18
ncclResult_t CudaPtrCheck(const void* pointer, struct ncclComm* comm, const char* ptrname, const char* opname) {
cudaPointerAttributes attr;
cudaError_t err = cudaPointerGetAttributes(&attr, pointer);
if (err != cudaSuccess || attr.devicePointer == NULL) {
WARN("%s : %s %p is not a valid pointer", opname, ptrname, pointer);
return ncclInvalidArgument;
}cudaPointerGetAttributesは無効なポインタに対してエラーを返すか、devicePointerが NULL になります。これにより「ホストのスタックアドレスを渡した」あるいは「解放済みのポインタを渡した」ケースを防ぎます。
第二層、デバイスが一致するか:
📎 src/misc/argcheck.cc:19-26
#if CUDART_VERSION >= 10000
if (attr.type == cudaMemoryTypeDevice && attr.device != comm->cudaDev) {
#else
if (attr.memoryType == cudaMemoryTypeDevice && attr.device != comm->cudaDev) {
#endif
WARN("%s : %s allocated on device %d mismatchs with NCCL device %d", opname, ptrname, attr.device, comm->cudaDev);
return ncclInvalidArgument;
}これが最も見落としやすい落とし穴です:ポインタは有効な GPU ポインタだが、別の GPU に属しているケースです。マルチ GPU マシンでは、ユーザーがcudaSetDeviceを忘れると、簡単に間違って渡してしまいます。NCCL はここで明確に拒否します。
第三層、通信ドメインオブジェクトの完全性:
📎 src/misc/argcheck.cc:38-45
ncclResult_t CommCheck(struct ncclComm* comm, const char* opname, const char* ptrname) {
NCCLCHECK(PtrCheck(comm, opname, ptrname));
if (comm->startMagic != NCCL_MAGIC || comm->endMagic != NCCL_MAGIC) {
WARN("Error: corrupted comm object detected");
return ncclInvalidArgument;
}
return ncclSuccess;
}startMagic / endMagicはncclComm構造体の先頭と末尾に置かれたセンチネル値です。ユーザーが野ポインタを渡した場合、あるいは comm が既に解放されている場合、magic が一致しません。これは「メモリ破壊検出」の古典的な手法です——2つのセンチネルで構造体を挟み、あらゆる範囲外書き込みがいずれかを破壊する可能性があります。
グローバル整合性チェック:registrationCheck のランク間検証
これは NCCL で最も「重い」検証であり、ncclCheckModeDebugGlobalの場合にのみトリガーされます。これがチェックするのは——すべてのランクの対称メモリ登録状態が一致しているかどうかです。
📎 src/misc/argcheck.cc:95-111
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, bufInfo, sizeof(struct symBufInfo) * 2), ret, fail);
cmpBufInfo[0] = bufInfo[0];
cmpBufInfo[1] = bufInfo[1];
for (int r = 1; r < comm->nRanks; r++) {
int infoIdx = r * 2;
if (cmpBufInfo[0].isSymRegistered != bufInfo[infoIdx].isSymRegistered ||
cmpBufInfo[1].isSymRegistered != bufInfo[infoIdx + 1].isSymRegistered) {
if (comm->rank == 0) {
WARN("Coll %s size %ld symmetric registration check failed on rank %d: sendReg %d recvReg %d mismatch with "
"rank 0 sendReg %d recvReg %d",
info->opName, size, r, bufInfo[infoIdx].isSymRegistered, bufInfo[infoIdx + 1].isSymRegistered,
cmpBufInfo[0].isSymRegistered, cmpBufInfo[1].isSymRegistered);
}
ret = ncclInvalidArgument;
goto fail;
}これは bootstrap のallGatherを通じて各ランクの(isSymRegistered, bigOffset, userOffset)を収集し、ランクごとに比較します。もしランク 0 の send buffer が対称メモリを登録しており、ランク 3 が登録していない場合、ここでエラーが報告されます。
なぜこのチェックが重要なのでしょうか?対称メモリ(symmetric memory)は、すべてのランクが同じ仮想アドレスセットでバッファにアクセスすることを要求します。もしあるランクのバッファが登録されていない場合、kernel 内で計算されるアドレスが誤りとなり、ゴミを読んだり範囲外アクセスを引き起こします。この種のエラーは実行時に「結果が時々正しくない」という形で現れ、極めてデバッグが困難です。NCCL は API 境界で一度の allGather のコストを払ってこれを防いでいます。
本番環境での落とし穴
落とし穴1:デフォルトモードではポインタエラーが報告されない。ユーザーがデバッグモードを有効にしていない場合、誤ったデバイスのポインタを渡しても、NCCL はArgsCheck段階でエラーを報告せず、kernel 実行時まで発見されません——その時点では既に他のランクの VRAM を破壊している可能性があります。開発段階ではNCCL_DEBUG=WARNにcheckModeを加えてデバッグすることを推奨します。
落とし穴2:ncclCheckModeDebugGlobalの allGather オーバーヘッド。通信ごとに bootstrap allGather を行うため、小メッセージ高頻度のシナリオではボトルネックになります。このモードはデバッグにのみ適しており、本番環境には投入できません。
落とし穴3:userRedOp のライフサイクル。この部分を見てください:
📎 src/misc/argcheck.cc:220-225
int opIx = int(ncclUserRedOpMangle(info->comm, info->op)) - int(ncclNumOps);
if (ncclNumOps <= info->op &&
(info->comm->userRedOpCapacity <= opIx || info->comm->userRedOps[opIx].freeNext != -1)) {
WARN("%s : reduction operation %d unknown to this communicator", info->opName, info->op);
return ncclInvalidArgument;
}ユーザー定義の reduction op は comm に登録されます。もしユーザーが「以前登録されたが既に解放された」op を渡した場合、freeNext != -1はそれが回収済みであることを検出します。これは「ダングリング op ハンドル」を防ぐチェックです。
エラー伝播マクロ:NCCLCHECK ファミリーが「エラーを落とさない」ことをどう保証するか
直感的モデル:エラー伝播マクロは「バトン」
NCCL のエラー処理は一連のマクロによるリレーに依存しています:下位関数がncclResult_tを返し、上位がNCCLCHECKでチェックし、成功でなければ即座にリターンします。これはリレー競走のようなものです——バトン(エラーコード)は最後まで受け渡されなければならず、どこかで落ちるとチェーン全体が途切れます。
データ構造:マクロファミリーの全体像
📎 src/include/checks.h:148-166
#define NCCLCHECK(call) \
do { \
ncclResult_t RES = call; \
if (RES != ncclSuccess && RES != ncclInProgress) { \
/* Print the back trace*/ \
if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
return RES; \
} \
} while (0)
#define NCCLCHECKGOTO(call, RES, label) \
do { \
RES = call; \
if (RES != ncclSuccess && RES != ncclInProgress) { \
/* Print the back trace*/ \
if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
goto label; \
} \
} while (0)重要な詳細:ncclInProgressは「エラーではない」と見なされます。これは非ブロッキング通信の核心です——ncclGroupEndがncclInProgressを返すのは「タスクは投入されたが、まだ完了していない」ことを意味し、呼び出し側はエラーとして扱うのではなくポーリングを続けるべきです。
NCCLCHECKは直接return,NCCLCHECKGOTOにジャンプしてlabelへ進みます。後者はリソースのクリーンアップが必要なシナリオで使用されます。
クリーンアップパス:NCCLCHECKIGNORE は最初のエラーを保持
📎 src/include/checks.h:168-177
// Report failure but continue - useful for cleanup paths where we want to
// attempt all cleanup steps. Preserves the first error in RES.
#define NCCLCHECKIGNORE(call, RES) \
do { \
ncclResult_t TMPRES = call; \
if (TMPRES != ncclSuccess && TMPRES != ncclInProgress) { \
if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", TMPRES); \
if (RES == ncclSuccess) RES = TMPRES; \
} \
} while (0)コメントが明確に述べている通り:クリーンアップパスでは「すべてのクリーンアップステップを試みる」必要があり、最初のエラーで中断されてはなりません。ただしエラーコードは最初のものを保持します——最初のエラーが通常、最も診断価値の高い根本原因だからです。
待機と中止:NCCLWAIT の abortFlag チェック
📎 src/include/checks.h:196-205
#define NCCLWAIT(call, cond, abortFlagPtr) \
do { \
uint32_t* tmpAbortFlag = (abortFlagPtr); \
ncclResult_t RES = call; \
if (RES != ncclSuccess && RES != ncclInProgress) { \
if (ncclDebugNoWarn == 0) INFO_LOC(NCCL_ALL, "-> %d", RES); \
return ncclInternalError; \
} \
if (COMPILER_ATOMIC_LOAD(tmpAbortFlag, std::memory_order_acquire)) NEQCHECK(*tmpAbortFlag, 0); \
} while (!(cond))これはポーリング待機のテンプレートです:各ループでcall(進捗を推進)を呼び、cond(満たされたか)をチェックし、同時にabortFlag(中止されたか)をチェックします。abortFlagはmemory_order_acquireロードを使用し、他のスレッドが書き込んだ中止シグナルを確実に認識します。
この設計は古典的な問題を解決します:あるランクがエラーになったとき、他のランクがまだそのデータを待ち続けている可能性があるという問題です。abortFlagはランク間で中止シグナルを伝播するメカニズムです——一度設定されると、すべての待機ループが終了します。
スレッド作成とメモリ割り当ての安全マクロ
📎 src/include/checks.h:237-256
#define STDTHREADCREATE_IMPL(var, func, error_action, ...) \
do { \
try { \
(var) = std::thread(func, __VA_ARGS__); \
} catch (const std::exception& e) { \
WARN("Thread creation failed: %s", e.what()); \
error_action; \
} \
} while (0)
#define STDTHREADCREATE(var, func, ...) STDTHREADCREATE_IMPL(var, func, return ncclSystemError, __VA_ARGS__)
#define STDTHREADCREATE_GOTO(var, func, RES, label, ...) \
STDTHREADCREATE_IMPL( \
var, func, \
do { \
RES = ncclSystemError; \
goto label; \
} while (0), \
__VA_ARGS__)std::threadの構築失敗は例外をスローします(例えばスレッド数が上限を超えた場合)。このマクロは例外をncclSystemErrorに変換し、例外が C API 境界を貫通するのを防ぎます。
📎 src/include/checks.h:258-275
#define NEW_NOTHROW(var, x) \
do { \
(var) = new (std::nothrow) x{}; \
if (!(var)) { \
WARN("Allocation failed"); \
return ncclSystemError; \
} \
} while (0)new (std::nothrow)は割り当て失敗時に例外をスローするのではなく nullptr を返します。これは C API 境界における C++ コードの標準的な手法です。
本番環境での落とし穴
落とし穴1:ncclInProgressが誤って成功と見なされる。一部のユーザーコードはif (ret == ncclSuccess)で成功を判定しますが、非ブロッキングモードでは返されるのはncclInProgressです。正しい方法はif (ret == ncclSuccess || ret == ncclInProgress)とするか、ncclCommGetAsyncErrorでクエリすることです。
落とし穴2:NCCLCHECKデストラクタで使う。デストラクタで使うとNCCLCHECK、エラーは直接return、後続のクリーンアップをスキップする。使うべきはNCCLCHECKIGNORE。
ABI バージョンの不一致:nccl_ep の size-based 設計
直感モデル:ABI は「コンセントの規格」
ABI(アプリケーションバイナリインターフェース)は電源コンセントの規格のようなものだ。ライブラリと呼び出し側で「構造体がどんな形か」の理解が一致しないと、米国規格のプラグを欧州規格のコンセントに挿すようなもの——軽ければ動かない、重ければ焼損する。contrib/nccl_epは巧妙な設計を採用している。境界を越える各構造体はsizeフィールドで始まる。
データ構造:size + magic の二重検証
📎 contrib/nccl_ep/nccl_ep.cc:70-76
// Size-based ABI versioning: every cross-boundary struct starts with a `size`
// field set by the caller to sizeof(struct). The library checks that against
// its own known size; any mismatch means caller and library are from different
// releases. Strict equality for now — see nccl_ep.h for the planned future
// relaxation (all-zero-trailing-bytes escape hatch).
// Immediately after `size` there is a `magic` field pre-filled by NCCL_EP_*_INIT
// to catch unininitialized structures.設計の要点:
sizeフィールドは呼び出し側がsizeof(struct)を設定し、ライブラリはそれが自身の認識する size と等しいか検査する。magicフィールドはNCCL_EP_*_INITマクロで事前に設定され、「未初期化」の構造体を捕捉するために使う。- 現在は厳密等価だが、将来は「末尾が全てゼロなら size がより小さくても許容する」緩和モードをサポートする予定。
Step-by-Step:EP_REQUIRE_STRUCT の検証フロー
📎 contrib/nccl_ep/nccl_ep.cc:77-80
#define EP_REQUIRE_STRUCT(ptr) \
do { \
assert( \
(ptr) != nullptr && (ptr)->size == sizeof(*(ptr)) && \このマクロはncclEpDispatch、ncclEpCombineなどのエントリポイントで呼び出される:
📎 contrib/nccl_ep/nccl_ep.cc:2827-2830
EP_REQUIRE_STRUCT(inputs);
EP_REQUIRE_STRUCT(outputs);
EP_OPTIONAL_LAYOUT_INFO(layout_info);
EP_OPTIONAL_STRUCT(config);inputsとoutputsは必須パラメータで、EP_REQUIRE_STRUCT;layout_infoとconfigはオプションパラメータで、EP_OPTIONAL_*。
バージョン安全なフィールド読み取り:layoutInfoRecvTopkIdxKind
これが最も精妙な部分——「呼び出し側の構造体がより小さい可能性がある」状況でフィールドを安全に読み取る方法。
📎 contrib/nccl_ep/nccl_ep.cc:139-144
// Safe field reader for ncclEpLayoutInfo_t::recv_topk_idx_kind. Returns AUTO
// when the caller's struct (size) does not cover the field, preserving the
// pre-flag default.
static inline ncclEpExpertIdKind_t layoutInfoRecvTopkIdxKind(const ncclEpLayoutInfo_t* lip) {
if (lip == nullptr) return NCCL_EP_EXPERT_ID_AUTO;
constexpr size_t field_end = offsetof(ncclEpLayoutInfo_t, recv_topk_idx_kind) + sizeof(ncclEpExpertIdKind_t);
if (lip->size < field_end) return NCCL_EP_EXPERT_ID_AUTO;
return lip->recv_topk_idx_kind;
}ロジックは:呼び出し側のsizeが「そのフィールドが終わるオフセット」より小さければ、呼び出し側は旧バージョンの構造体を使っており、このフィールドは存在しないのでデフォルト値AUTOを返す。そうでなければ通常通り読み取る。
これは ABI 互換の標準手法だ:新フィールドは構造体の末尾にのみ追加でき、読み取り時にsizeでフィールドの存在を判定する。これにより旧呼び出し側が旧構造体を使っても、新ライブラリは正しく処理できる。
バージョン番号チェック:ハード拒否ではなくソフト警告
📎 contrib/nccl_ep/nccl_ep.cc:1393-1400
if (in_config->version != NCCL_EP_API_VERSION) {
fprintf(
stderr,
"NCCL EP WARN: ncclEpGroupConfig_t.version=%u, library API_VERSION=%u; "
"behavior may differ across versions.\n",
in_config->version,
(unsigned)NCCL_EP_API_VERSION);
}ここはWARNではなくreturn errorであることに注意。バージョン番号の不一致は単なる警告だ。なぜならsizeチェックが既にメモリレイアウトの安全性を保証しているからだ。バージョン番号はむしろ「動作が異なる可能性がある」というヒントである。
本番の落とし穴
落とし穴1:INIT マクロでの初期化を忘れる。ユーザーが手動でmemset構造体を 0 にすると、magicは 0 になり、EP_REQUIRE_STRUCTは失敗する。必ずNCCL_EP_*_INITマクロを使わなければならない。
落とし穴2:バージョンをまたいだ動的ライブラリの混用。アプリケーションがリンクしているのが新版のlibnccl_ep.soで、ヘッダファイルが旧版の場合、sizeof(struct)が不一致になり、EP_REQUIRE_STRUCTが即座にエラーを報告する。これは設計意図——静かなエラーよりも高速な失敗が優れている。
落とし穴3:EP_OPTIONAL_LAYOUT_INFOの範囲チェック。この部分を見てほしい:
📎 contrib/nccl_ep/nccl_ep.cc:114-123
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_infosize を[min, sizeof]の範囲で許容する。これはEP_REQUIRE_STRUCTの厳密等価よりも緩い。理由はlayout_infoがオプションパラメータであり、歴史的にフィールドの増減があったからだ。
flowchart TD
entry["ncclEpDispatch(inputs, outputs, layout_info, config)"] --> req_inputs{"EP_REQUIRE_STRUCT(inputs)<br/>size == sizeof?"}
req_inputs -->|否| err_size["assert 失败 / 返回错误"]
req_inputs -->|是| req_outputs{"EP_REQUIRE_STRUCT(outputs)"}
req_outputs -->|否| err_size
req_outputs -->|是| opt_layout{"layout_info != nullptr?"}
opt_layout -->|否| skip_layout["跳过 layout 校验"]
opt_layout -->|是| range_check{"size in [min, sizeof]?"}
range_check -->|否| err_range["fprintf size out of range<br/>return ncclInvalidArgument"]
range_check -->|是| magic_check{"magic == NCCL_EP_MAGIC?"}
magic_check -->|否| err_magic["fprintf magic mismatch<br/>return ncclInvalidArgument"]
magic_check -->|是| read_field["layoutInfoRecvTopkIdxKind<br/>size < field_end ? AUTO : 实际值"]
skip_layout --> read_field
read_field --> proceed["继续执行 dispatch 逻辑"]タイムアウト、リトライ、中止:NCCLWAIT から nccl_ep の timeout_cycles へ
直感モデル:タイムアウトは「ヒューズ」
分散通信では、1つの rank がスタックすると全ての rank がデッドウェイトする。タイムアウト機構はヒューズのようなものだ:通常は動作せず、電流異常が起きると溶断し、システム全体の焼損を防ぐ。
データ構造:abortFlag と timeout_cycles
NCCL コアはabortFlagで中止シグナルを伝播する。ncclAsyncLaunch内の伝播を見てほしい:
📎 src/group.cc:49-52
job->abortFlag = comm->abortFlag;
job->abortFlagDev = comm->abortFlagDev;
job->childAbortFlag = comm->childAbortFlag;
job->childAbortFlagDev = comm->childAbortFlagDev;各 job は comm の abortFlag ポインタを保持する。group がエラーを検出すると:
📎 src/group.cc:118-126
if (!job->destroyFlag &&
(COMPILER_ATOMIC_LOAD(groupAbortFlag, std::memory_order_acquire) || errorJobAbortFlag == true)) {
COMPILER_ATOMIC_STORE(job->abortFlag, uint32_t(1), std::memory_order_release);
COMPILER_ATOMIC_STORE(job->abortFlagDev, uint32_t(1), std::memory_order_release);
if (job->childAbortFlag) {
COMPILER_ATOMIC_STORE(job->childAbortFlag, uint32_t(1), std::memory_order_release);
COMPILER_ATOMIC_STORE(job->childAbortFlagDev, uint32_t(1), std::memory_order_release);
}
}またはgroupAbortFlagが真になると、全ての job の abortFlag が 1 に設定される。errorJobAbortFlagは以前の書き込みが他のスレッドから可視であることを保証する。memory_order_releasenccl_ep のタイムアウト設計:GPU クロックサイクル
はより精密なタイムアウト——GPU クロックサイクル単位——を採用している。
nccl_epコピー
📎 contrib/nccl_ep/nccl_ep.cc:1558-1591
// Resolve timeout_cycles: env var > config field > compile-time default
{
int dev;
int clock_khz_int;
CUDA_CHECK(cudaGetDevice(&dev));
CUDA_CHECK(cudaDeviceGetAttribute(&clock_khz_int, cudaDevAttrClockRate, dev));
uint64_t clock_khz = static_cast<uint64_t>(clock_khz_int);
uint64_t resolved = NUM_TIMEOUT_CYCLES;
const char* source = "compile-time default";
const uint64_t env_ms = static_cast<uint64_t>(ep_group->env.timeout_ms.value.ul);
// Only a positive timeout overrides the default.
const bool have_env_ms = ep_group->env.timeout_ms.is_set && env_ms > 0;
if (have_env_ms) {
resolved = clock_khz * 1000ULL * env_ms / 1000ULL;
source = "NCCL_EP_TIMEOUT_MS env var";
...
} else if (ep_group->config.timeout_ns != 0) {
resolved = clock_khz * 1000ULL * (ep_group->config.timeout_ns / 1000000ULL) / 1000ULL;
source = "config.timeout_ns";
}
ep_group->timeout_cycles = resolved;> 設定フィールドNCCL_EP_TIMEOUT_MS> コンパイル時デフォルト値。変換式はtimeout_ns、つまりミリ秒をクロックサイクルに変換する。clock_khz * 1000 * ms / 1000〔設計推論とアーキテクチャのトレードオフ〕
レジスタを読むしかないからだ。クロックサイクルでタイムアウト判定を行えば、kernel 内で直接比較でき、host の介入が不要になる。clock64()非同期エラーフラグ:host-pinned メモリ
コピー
📎 contrib/nccl_ep/nccl_ep.cc:1767-1778
// Allocate mask buffer and async error flag for active-mask support
if (ep_group->config.enable_mask && ep_group->config.algorithm == NCCL_EP_ALGO_LOW_LATENCY) {
size_t mask_bytes = ep_group->nRanks * sizeof(int);
CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&ep_group->mask_buffer), mask_bytes));
// Initialize all ranks as active (1 = active, 0 = masked/failed)
std::vector<int> all_active(ep_group->nRanks, 1);
CUDA_CHECK(
cudaMemcpyAsync(ep_group->mask_buffer, all_active.data(), mask_bytes, cudaMemcpyHostToDevice, stream));
CUDA_CHECK(
cudaHostAlloc(reinterpret_cast<void**>(&ep_group->async_error_flag), sizeof(int), cudaHostAllocMapped));
*ep_group->async_error_flag = 0;
}async_error_flagで確保される。これは host-pinned かつデバイスアドレス空間にマップされたメモリだ。GPU kernel が書き込み、host が読み取ることができ、明示的なコピーが不要である。cudaHostAllocMapped非同期エラーの読み取り:アトミックロード
コピー
📎 contrib/nccl_ep/nccl_ep.cc:4312-4321
ncclResult_t ncclEpGetAsyncError(ncclEpGroup_t ep_group, int* error_out) {
EP_HOST_ASSERT(ep_group != nullptr);
if (!ep_group->config.enable_mask) {
return ncclInvalidUsage;
}
EP_HOST_ASSERT(ep_group->async_error_flag != nullptr && "ncclEpGetAsyncError: enable_mask must be true");
EP_HOST_ASSERT(error_out != nullptr);
*error_out = __atomic_load_n(ep_group->async_error_flag, __ATOMIC_ACQUIRE);
return ncclSuccess;
}に__atomic_load_nを加えて使い、キャッシュされた古い値ではなく、GPU が書き込んだ最新の値を読むことを保証する。__ATOMIC_ACQUIRE本番の落とし穴
落とし穴1:タイムアウト設定が短すぎて誤報を引き起こす。
もしを小さく設定しすぎると、正常なネットワークジッタがタイムアウトと誤判定される。実際のネットワーク RTT に基づいて設定することを推奨し、通常は10秒以上とする。NCCL_EP_TIMEOUT_MS落とし穴2:abortFlag 設定後にクリアされない。
abortFlag が 1 に設定されると、comm は「中止」状態に入る。ユーザーがこの comm を引き続き使いたい場合、まず abortFlag をクリアしなければならない。NCCL のがこのクリアを行う。ncclCommAbort落とし穴3:
の前提条件。ncclEpMaskCleanこの部分を見てほしい:コピー
📎 contrib/nccl_ep/nccl_ep.cc:4262-4266
EP_HOST_ASSERT(ep_group->config.algorithm == NCCL_EP_ALGO_LOW_LATENCY);
EP_HOST_ASSERT(
ep_group->rdma_buffer != nullptr &&
"ncclEpMaskClean: rdma_buffer not yet allocated; create at least one LL handle first");
EP_HOST_ASSERT(ep_group->sync_buffer != nullptr && ep_group->sync_window != nullptr);ncclEpMaskCleanはrdma_bufferが確保済みであることを要求する。ユーザーが group を作成したがまだ LL handle を何も作成していない場合、rdma_bufferは nullptr になる(LL は遅延確保のため)。ここで assert が失敗する。
本章のまとめ
本章では4種類の本番の落とし穴を繋いだ:
1. Group セマンティクスの誤用:ncclGroupDepthは thread_local であり、省略するとncclGroupEnd恒久的なハングを引き起こす;ブロッキング通信ドメインとノンブロッキング通信ドメインは混用できない;CUDA graph capture はオール・オア・ナッシングでなければならない。
2. パラメータ検証:ArgsCheckモード別に検証し、デフォルトモードではゼロコストのチェックのみを行う;CudaPtrCheck三層の防御線で無効なポインタ、誤ったデバイス、破損した comm を遮断する;registrationCheckクロスランクの対称メモリ整合性チェックを行う。
3. エラー伝播:NCCLCHECKファミリーはエラーを失わないことを保証する;ncclInProgressエラーではない;NCCLCHECKIGNOREクリーンアップパスで最初のエラーを保持するために使用される;NCCLWAITポーリング中に abortFlag をチェックする。
4. ABI バージョン:nccl_epsize-based 設計を採用し、各境界を越える構造体はsizeで始まり、magicと組み合わせて未初期化を捕捉する;新しいフィールドは末尾にのみ追加でき、読み取り時にsizeで存在するかどうかを判断する。
5. タイムアウトと中止:コアはabortFlagで中止を伝播する;nccl_epGPU クロックサイクルでタイムアウトを実装し、async_error_flaghost-pinned メモリで GPU→host の非同期通知を実現する。
本章の考察とセルフチェック
Q1: もしncclGroupEndInternalのif ((--ncclGroupDepth) > 0) goto exit;(📎 src/group.cc:1061)をif (ncclGroupDepth > 0) goto exit;(デクリメントしない)に変更したら、何が起こるか?ネストされた group のシナリオではどのような結果になるか?
参考解説:
元のコードは--ncclGroupDepthまずデクリメントしてから判定する。デクリメントしないように変更すると:
if (ncclGroupDepth > 0) goto exit; // 错误版本すると毎回ncclGroupEndで深度が減少しなくなる。ユーザーが次のように書いたと仮定する:
ncclGroupStart(); // depth = 1
ncclGroupStart(); // depth = 2
ncclAllReduce(...);
ncclGroupEnd(); // 原版: depth = 1, 返回; 错误版: depth = 2, 返回
ncclGroupEnd(); // 原版: depth = 0, 触发下发; 错误版: depth = 2, 返回誤ったバージョンでは、2回目のncclGroupEnd時にncclGroupDepthは依然として 2 であり、> 0が成立し、直接goto exitとなり、永遠に発行がトリガーされない。すべての通信呼び出しが「溜め込み」状態のままとなり、プロセスがハングする。
さらに隠蔽的なのは:ncclGroupDepthは thread_local であり、関数が戻ってもリセットされない。たとえ後続のコードが group API を呼び出さなくても、このスレッド上のすべての通信が無効になる。
この変更はさらにncclGroupStartのペアリングセマンティクスを破壊する——ncclGroupStartはインクリメント、ncclGroupEndはデクリメントしないため、深度は増える一方で最終的にオーバーフローする(ただし int のオーバーフローには20億回の呼び出しが必要で、実際にはより論理的なハングが起こる可能性が高い)。
Q2: CudaPtrCheckのattr.type == cudaMemoryTypeDevice && attr.device != comm->cudaDev(📎 src/misc/argcheck.cc:20)というチェックで、もしattr.type == cudaMemoryTypeDeviceという条件を削除したら、どのような問題が起こるか?どのようなシナリオで誤検出されるか?
参考回答:
cudaPointerAttributes.typeには3つの可能な値がある:cudaMemoryTypeDevice(デバイスメモリ)、cudaMemoryTypeHost(ホストメモリ)、cudaMemoryTypeManaged(ユニファイドメモリ)。
もしattr.type == cudaMemoryTypeDevice条件を削除すると、次のようになる:
if (attr.device != comm->cudaDev) { // 错误版本すると host メモリや managed メモリに対して、attr.deviceは -1 または 0 の可能性があり、comm->cudaDevと一致せず、「デバイス不一致」と誤検出される。
具体的なシナリオ:ユーザーがcudaMallocManagedで割り当てられたポインタを渡す。managed メモリのattr.deviceは通常割り当て時のデバイスだが、メモリが他のデバイスに移行された場合、attr.deviceが変化する可能性がある。より一般的なのは host メモリ(例えばcudaHostAllocで割り当てられた pinned メモリ)で、attr.deviceは -1 となり、どのcudaDevとも等しくなく、誤検出される。
NCCL は host メモリを通信バッファとして許可している(cudaMemcpyを介して中継)、そのため「デバイスメモリだがデバイスが正しくない」と「非デバイスメモリ」を区別する必要がある。前者はエラーであり、後者は合法である。
Q3: layoutInfoRecvTopkIdxKind(📎 contrib/nccl_ep/nccl_ep.cc:139-144)でlip->size < field_endを使用してフィールドの存在を判断する。もし新バージョンが構造体の途中にフィールドを挿入した場合(末尾ではなく)、この判断はどのように破綻するか?なぜ ABI 設計では新しいフィールドを末尾にのみ追加できると規定されているのか?
参考解説:
元の構造体が次のようであると仮定する:
struct ncclEpLayoutInfo_t {
unsigned int size;
unsigned int magic;
ncclEpExpertIdKind_t recv_topk_idx_kind; // offset = 8
};field_end = offsetof(recv_topk_idx_kind) + sizeof(...) = 8 + 4 = 12。
もし新バージョンがmagicとrecv_topk_idx_kindの間にフィールドを挿入すると:
struct ncclEpLayoutInfo_t {
unsigned int size;
unsigned int magic;
unsigned int new_field; // 新插入
ncclEpExpertIdKind_t recv_topk_idx_kind; // offset 变成 12
};このときfield_end = 12 + 4 = 16。旧呼び出し側のsizeは 12(旧構造体サイズ)であり、12 < 16が成立し、関数はAUTOを返す——しかし旧呼び出し側には実際にはrecv_topk_idx_kindフィールドがあり、単にオフセットが異なるだけである。これにより旧呼び出し側が設定したrecv_topk_idx_kindが無視される。
さらに悪いことに、もし旧呼び出し側が旧オフセット(8)でrecv_topk_idx_kindを書き込み、新ライブラリが新オフセット(12)で読み取ると、new_fieldの値を読み取り、完全に混乱する。
したがって ABI 設計の鉄則は:新しいフィールドは構造体の末尾にのみ追加できる。こうすれば旧呼び出し側のsizeは新フィールドのfield_endより小さく、関数は正しくデフォルト値を返す;新呼び出し側のsizeは新フィールドをカバーし、正常に読み取れる。途中にフィールドを挿入すると、offsetofに基づくすべてのバージョン判断が破壊される。
本章では本番環境における4種類の典型的な落とし穴とその内部防御メカニズムを分析した。これらの境界条件は、NCCL の安定した動作がコア実装だけでなく、周辺エコシステムの適応と拡張にも依存していることを思い起こさせる。次章ではエコシステムと拡張に移り、nccl4py、nccl4rust、nccl_ep、nccl_ubx といった周辺プロジェクトがどのように NCCL の能力をより広範なユーザーに届けているかを見ていく。
第23章:第23章:エコシステム拡張:nccl4py、nccl4rust、nccl_ep、nccl_ubx などの周辺プロジェクト
第23章:エコシステム拡張:nccl4py、nccl4rust、nccl_ep、nccl_ubx などの周辺プロジェクト
前章では、本番環境における NCCL の典型的な障害を調査しました。group セマンティクスの誤用、rank 数の不一致、stream の相互作用、ABI バージョンの競合、ネットワークタイムアウトです。これらの問題の多くは C ABI を直接使用する場面で発生しますが、現代の大規模モデル学習フレームワークは通常 C ABI を直接呼び出さず、Python や Rust などの言語バインディング、あるいは MoE や超帯域幅通信などのシナリオ向けの拡張プロジェクトを通じて NCCL の機能を再利用します。これらの周辺プロジェクトは bindings/ と contrib/ ディレクトリに配置され、実験的・コミュニティメンテナンスという位置づけであり、コアライブラリのリリース品質保証を継承していません。本章では nccl4py、nccl4rust、nccl_ep、nccl_ubx、nccl_checkpoint を一つずつ分析し、これらが言語バインディング、デバイス API 拡張、シンボルインターセプトという三つの経路を通じて、コアの外側にどのように豊かなエコシステムを構築しているかを見ていきます。
nccl4py:Cython バインディングと名前空間パッケージ設計
直感的モデル:C ABI を Python が理解できる言葉に翻訳する
NCCL コアを C 言語しか話せない外交官、Python 学習スクリプトを Python しか話せないインターンだと想像してください。nccl4py はその翻訳者です——外交官の言葉(NCCL の動作)は変えず、「ncclAllReduce(sendbuff, recvbuff, count, ...)」を「nccl.all_reduce(tensor)」に翻訳するだけです。この翻訳層がなければ、すべての Python フレームワークが自前で ctypes バインディングを書く必要があり、重複作業でエラーも起きやすくなります。
階層構造:Cython 低レベル層 + Python 高レベル層
nccl4py の設計は二層構造です:低レベル層は Cython バインディング(nccl/bindings/cynccl.pxd)、高レベル層は Python API(nccl.core)です。README にはこの階層が明確に記載されています📎 bindings/nccl4py/README.md:4-4:
nccl4py provides low-level Cython bindings and a high-level Python API
Cython バインディングは.pxdファイル形式で wheel に同梱されて配布され、他の Cython 拡張が直接cimport 📎 bindings/nccl4py/README.md:39-43:
from nccl.bindings cimport cyncclなぜ Python 層だけでなく Cython 層も公開するのか?それは、一部のフレームワーク(DeepSpeed や Megatron など)のコアループが Cython 内にあり、毎回の呼び出しで Python インタプリタを経由するとオーバーヘッドが大きすぎるからです。直接cimport cyncclすることで、Cython 拡張が C に近いゼロオーバーヘッドで NCCL 関数を呼び出せます。これは「階層的公開」の典型的な設計です——高レベル層は一般ユーザーに、低レベル層は性能重視のシナリオに。
名前空間パッケージ:複数のディストリビューションがncclプレフィックスを共有
これが nccl4py の最も巧妙な設計です。ncclは PEP 420 の暗黙的名前空間パッケージです📎 bindings/nccl4py/README.md:50-51:
ncclis a PEP 420 implicit namespace package. nccl4py providesnccl.bindingsandnccl.core; other NCCL extension distributions can provide additionalnccl.*subpackages.
従来の Python パッケージでは、nccl/__init__.pyがnccl名前空間全体を「所有」します。nccl4py と nccl_ep の Python バインディングがどちらもnccl.xxxを提供しようとすると競合します——先にインストールした方が勝ちます。PEP 420 名前空間パッケージはこの問題を解決します:__init__.pyがなくても、複数のディストリビューションがそれぞれnccl/ディレクトリにサブパッケージを配置でき、Python のインポートシステムがそれらをマージします。したがって nccl4py はnccl.bindingsとnccl.coreを提供し、nccl_ep はnccl.epを提供し、両者は共存できます📎 contrib/nccl_ep/README.md:80-82。
この設計はエコシステム拡張にとって極めて重要です:将来、第三者がnccl.monitoring、nccl.profilingを追加したい場合、nccl4py のコードを変更する必要がありません。
CUDA バージョン選択:extra メカニズム
インストール時にnccl4py[cu12]またはnccl4py[cu13]で CUDA のメジャーバージョンを選択します📎 bindings/nccl4py/README.md:13-17。README にはその理由が説明されています:extras は対応する NCCL runtime と CUDA Python 依存関係をインストールします📎 bindings/nccl4py/README.md:19。公開済みの wheel にはCUDA_HOMEやローカル CUDA Toolkit は不要ですが、ソースからのビルドには📎 bindings/nccl4py/README.md:20-21。
〔設計推論とアーキテクチャのトレードオフ〕
これは Python エコシステムが CUDA バージョンの断片化に対処する標準的な手法です。CUDA 12 と 13 の ABI は互換性がなく、一つの wheel で両方をカバーできません。extra を使って pip がユーザー環境に応じて正しいバイナリ依存関係を選択することで、実行時に初めてバージョン不一致に気づく事態を避けられます。
本番環境の落とし穴回避__init__.py落とし穴1:名前空間パッケージとの競合。nccl/サードパーティパッケージが__init__.pyの下にnccl.coreを配置すると、PEP 420 名前空間パッケージのメカニズムが破壊され、python -c "import nccl; print(nccl.__path__)"のインポートが失敗します。調査方法:AttributeError、もしncclが報告されたら
が名前空間パッケージでないことを示します。 cynccl.pxd落とし穴2:Cython ABI バージョンのドリフト。📎 bindings/nccl4py/README.md:32-32は実験的 API.pxdであり、NCCL アップグレード時にcimport cyncclが変わる可能性があります。
に依存する Cython 拡張は nccl4py のバージョンと厳密に一致する必要があり、そうでなければコンパイル時のシンボル解決が失敗します。
nccl4rust:RAII 所有権とデバイス側の境界
直感的モデル:コンパイラにライフサイクル管理を任せるncclCommInitRankC 言語では、あなたはncclCommDestroyで communicator を取得し、使用後は必ず
nccl4rust の核心的価値は、この所有権セマンティクスを NCCL の C ABI に適用することにある。
階層構造:5つの crate がそれぞれ役割を担う
README の Layout テーブルには5つの crate が列挙されている📎 contrib/nccl4rust/README.md:20-28:
| Path | Purpose |
|---|---|
crates/nccl-sys | bindgen が生成する生の host ABI |
crates/nccl | Rust スタイルの host ラッパー + RAII 所有権 |
crates/nccl-device-sys | no_stdCUDA-Oxide デバイス宣言 |
crates/nccl-device | 型付きDevComm、Team、Windowラッパー |
shim/ | 純粋な C-ABI シム、公開ヘッダーのみ使用 |
この分割は意図的なものだ。README はその動機を説明している📎 contrib/nccl4rust/README.md:30-32:host アプリケーションはncclのみを使用でき、Rust GPU コンパイラは不要;CUDA-Oxide カーネルはnccl-deviceを使用;生の ABI が必要なコンシューマは-syscrate を選択できる。この「オンデマンド階層化」により、異なるユーザーは自分に必要なコンパイルコストだけを支払う。
重要な設計:デバイスコミュニケータを値ではなくポインタで渡す
これは nccl4rust で最も学ぶ価値のある設計判断だ。README の Host/device ownership boundary のセクション📎 contrib/nccl4rust/README.md:211-219:
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.
なぜ Rust 構造体で C 構造体をミラーしないのか?なぜならncclDevComm_tはバージョン化されているため——NCCL のバージョンによってフィールドが異なる可能性がある。もしカーネルパラメータを値で Rust ミラーとして渡すと、カーネル ABI が特定の NCCL バージョンの構造体レイアウトに束縛される。NCCL が構造体をアップグレードすると、コンパイル済みのすべてのカーネルを再コンパイルする必要がある。ポインタで渡せばアドレスを1つ渡すだけで、カーネルはポインタ経由でアクセスし、レイアウト変更が ABI に影響しない。これは前章で述べたncclEpLayoutInfo_tの size-based ABI と同じ考え方だ——バージョン差異をポインタの背後に隔離する。
安全境界:どれが unsafe か
README の Current API contracts のセクションには6つの契約が列挙されている📎 contrib/nccl4rust/README.md:230-249、そのうち重要なものは:
- 生の
-syscrate は C ABI をミラーするだけで、所有権やライフタイムの検証を追加しない📎contrib/nccl4rust/README.md:232-233 - 現在の集合通信およびポイントツーポイントのラッパーは生のデバイスポインタを受け入れ、
unsafe📎contrib/nccl4rust/README.md:42-45 - として宣言されている。ポインタ変換メソッドは生のデバイスポインタを返し、オフセット境界、アラインメント、peer メンバーシップ、エイリアス、ウィンドウライフタイムを検証できない📎
contrib/nccl4rust/README.md:242-244
これが NCCL を Rust でバインディングする根本的な困難だ:NCCL の多くの API 契約は「バッファは CUDA stream が完了するまで有効でなければならない」だが、Rust の型システムは「stream 完了」という非同期イベントを表現できない。したがってこれらのメソッドはunsafeにしかできず、責任を呼び出し側に返す。README も改善の方向性を指摘している📎 contrib/nccl4rust/README.md:44-45:stream-aware なバッファ抽象により、これらの要件を安全な API にエンコードできる。これは将来の課題だ。
デバイス側:CUDA-Oxide と LTOIR シム
デバイス側の核心的な課題は:NCCL のデバイス API は C++ テンプレートであり、Rust デバイスコード(CUDA-Oxide)は C ABI を必要とする。解決策は C++ シム📎 contrib/nccl4rust/README.md:26:
shim/— CUDA C++ C-ABI shim built exclusively from publicnccl.handnccl_device.h
シムは LTOIR(LLVM 中間表現)にコンパイルされ、Rust PTX と一緒にリンクされて cubin になる📎 contrib/nccl4rust/README.md:165-167。README はビルドフローを説明している📎 contrib/nccl4rust/README.md:158-163:
make device \
NCCL_INCLUDE_DIR="$NCCL_INCLUDE_DIR" \
CUDA_HOME="$CUDA_HOME" \
ARCH=90LTOIR は NVIDIA のリンク時最適化中間フォーマットだ。LTOIR を直接 cubin にコンパイルするのではなく使用するのは、シムと Rust カーネルがリンク時にクロスランゲージ最適化——例えばシム関数を Rust カーネルにインライン化——を行うためだ。これは「C++ テンプレート + Rust カーネル」の混合プログラミングの鍵となる技術だ。
本番環境での落とし穴回避
落とし穴1:NCCL バージョンは正確に一致する必要がある。README は明確に要求しているMatching NCCL 2.31 headers and runtime 📎 contrib/nccl4rust/README.md:80-81、プロトタイプが初期の NCCL デバイス API バージョンで異なるフィールドを直接初期化するため。ヘッダーファイルとlibnccl.soのバージョンが一致しないと、デバイスコミュニケータのフィールドがずれる。
落とし穴2:CUDA graph とデバイスコミュニケータ。デバイスコミュニケータは host メモリ内のバージョン化された構造体で、デバイスにコピーされた後カーネルがポインタ経由でアクセスする。CUDA graph キャプチャ時にデバイスポインタをカーネルパラメータに焼き込むと、その後コミュニケータを再作成すると graph 内のポインタが無効になる。これは nccl_ep の RDMA buffer 再割り当て問題と同源だ。
落とし穴3:安全な初期化は生の group と混用できない。README は警告している📎 contrib/nccl4rust/README.md:238-239:安全な初期化と出力を生成する管理呼び出しは、生のnccl-sysgroup 状態と混用できない。なぜならラッパー層は生の group 状態を観察できないからだ。混用するとラッパー層のポーリングロジックと生の group セマンティクスが衝突する。
nccl_ep:エキスパート並列の dispatch/combine プリミティブ
直感的モデル:MoE の「仕分けセンター」
MoE(Mixture of Experts)モデルでは、各トークンをtop-k個のエキスパートにルーティングする必要がある。エキスパートは異なるGPU上に分散しているため、トークンはGPU間を転送される必要がある——これがdispatchである。エキスパートの計算後、結果は元のトークンが存在するGPUに送り返される——これがcombineである。nccl_epは、この「仕分けセンター」の通信エンジンである。
これがなければ、各MoEフレームワークが独自にdispatch/combineの通信ロジックを実装しなければならず、重複が多く最適化が困難になる。nccl_epはこれをNCCLエコシステムにおける標準プリミティブとした。
2つのアルゴリズム:LLとHT
READMEでは2つのアルゴリズムが説明されている📎 contrib/nccl_ep/README.md:36-40:
- Low-Latency (LL):小バッチ、レイテンシ敏感(LLM推論)。直接的なポイントツーポイントall-to-all通信を使用する。
- High-Throughput (HT):大バッチの学習および推論プリフィル。階層型通信を使用——ノード内はNVLinkで集約、ノード間はRDMA。Hopperのwarp-specializedパイプラインとTMAを活用する。
これら2つのアルゴリズムの分岐は、MoE推論と学習の異なるボトルネックを反映している。推論時はバッチが小さく、レイテンシが主要な問題であるため、LLは直接的なポイントツーポイントで集約オーバーヘッドを回避する。学習時はバッチが大きく、帯域幅が主要な問題であるため、HTは階層型集約でノード間トラフィックを削減する。これは典型的な「ワークロード特性に応じたアルゴリズム選択」の設計である。
中核データ構造:ncclEpGroupConfig_t
これはEPの設定構造体であり、フィールドが多い📎 contrib/nccl_ep/README.md:339-362。主要フィールド:
sizeとversion:ABIバージョンチェック。前章で述べたsize-based ABIと同源📎contrib/nccl_ep/README.md:340-341algorithm:HTまたはLL📎contrib/nccl_ep/README.md:342max_dispatch_tokens_per_rank:単一rankが最大でdispatchするトークン数📎contrib/nccl_ep/README.md:344rdma_buffer_size:LLモードのRDMAバッファサイズ📎contrib/nccl_ep/README.md:356-356alloc:カスタムデバイスメモリアロケータ📎contrib/nccl_ep/README.md:359
rdma_buffer_sizeのNCCL_EP_AUTOセマンティクスは深掘りする価値がある。READMEは📎 contrib/nccl_ep/README.md:396-406を次のように説明している:AUTOモードではバッファはncclEpCreateGroup時に割り当てられず、最初のncclEpInitHandle時に実際の(layout, num_topk)に応じて割り当てられる。後続のhandleがより大きなバッファを必要とする場合、集団的に再割り当てが行われる。この「遅延割り当て」設計は、ユーザーがバッファサイズを推測することを避けるが、3つの制約を導入する📎 contrib/nccl_ep/README.md:396-406:
1. すべてのrankが同じ(layout, num_topk)で同期呼び出しを行う必要があるncclEpInitHandle
2. 再割り当ては古いバッファの内容を破棄し、send_onlyに一時保存されたデータは失われる
3. CUDA graphキャプチャはRDMAベースアドレスポインタを焼き込むため、再割り当て後は再キャプチャが必要
これは本章で最も重要な本番環境の落とし穴の一つである。遅延割り当ては使いやすさと引き換えに、「いつ再割り当てするか」の複雑さをユーザーに転嫁している。
テンソル記述子:静的と動的の2形態
ncclEpTensor_tは軽量な値型である📎 contrib/nccl_ep/README.md:310-332。READMEは2つの使用法を示している:
静的記述子(スタック上、NCCL_EP_TENSOR_INIT_INLINE)📎 contrib/nccl_ep/README.md:806-809:
ncclEpTensor_t expert_counters = { NCCL_EP_TENSOR_INIT_INLINE,
.ndim = 1, .datatype = ncclInt32,
.data = expert_counters_data,
.sizes = expert_counters_dims };動的記述子(ヒープ上、ncclEpTensorAlloc)📎 contrib/nccl_ep/README.md:793-798:
ncclEpTensor_t* topk_idx = nullptr;
{
size_t dims[2] = { num_tokens, top_k };
ncclEpTensorAlloc(&topk_idx, 2, ncclInt64, dims, /*config=*/NULL);
cudaMalloc(&topk_idx->data, num_tokens * top_k * sizeof(int64_t));
}2形態の違いはsizes配列の所有権にある。静的記述子のsizesは呼び出し側が所有するスタック配列であり、記述子より長く生存する必要がある📎 contrib/nccl_ep/README.md:325-326。動的記述子のsizesはライブラリが所有するヒープコピーであり、ncclEpTensorDestroyによって解放される📎 contrib/nccl_ep/README.md:514-514。公開構造体はncclEpTensor_t*ポインタを保持するため、2形態を同じ呼び出し内で混在させることができる📎 contrib/nccl_ep/README.md:514-514。この設計により、単純なシナリオではヒープ割り当てがゼロになり、複雑なシナリオではライブラリ管理の利便性が得られる。
実行モード:同期と段階的
READMEのExecution Modesの節は📎 contrib/nccl_ep/README.md:701-7412つのモードを説明している:
同期モード(デフォルト):データ受信待ちの時間を含め、操作全体を通じてGPUリソースを占有する📎 contrib/nccl_ep/README.md:705-709。
段階的モード(LLのみ):操作をsendとreceiveの2段階に分割する📎 contrib/nccl_ep/README.md:718-726。send_only = 1で開始し、データ転送が開始されるとGPUリソースを解放し、アプリケーションはそのリソースで計算を行い、最後にncclEpCompleteで完了する📎 contrib/nccl_ep/README.md:728-741。
sequenceDiagram
participant App as 应用线程
participant EP as ncclEpDispatch
participant GPU as GPU 内核
participant Net as RDMA 网卡
App->>EP: ncclEpDispatch(send_only=1)
EP->>GPU: 启动发送内核
GPU->>Net: GIN put/signal 发起传输
EP-->>App: 立即返回,释放 SM
Note over App: 应用用释放的 SM 做计算
App->>EP: ncclEpComplete()
EP->>GPU: 启动接收内核
GPU->>Net: 等待数据到达
Net-->>GPU: 数据写入
GPU-->>EP: 完成
EP-->>App: 返回,数据就绪このシーケンス図は段階的モードの中核的価値を示している:send_onlyは開始後すぐに戻り、SMリソースが計算に解放され、アプリケーションが他の作業を終えた後にncclEpCompleteを呼んで受信完了を待つ。これは「計算-通信オーバーラップ」の古典的なパターンである。
本番環境の落とし穴回避
落とし穴1:ncclEpInitHandleの条件的集団性。AUTOモードでは、ncclEpInitHandleは条件的集団呼び出しである📎 contrib/nccl_ep/README.md:396-406。あるrankがlayoutの違いにより再割り当てをトリガーした場合、他のrankも同期して参加する必要がある。同期しないとデッドロックやデータ破損が発生する。
落とし穴2:CUDA graphキャプチャ期間中のncclEpInitHandle。READMEは明確に警告している📎 contrib/nccl_ep/README.md:396-406:AUTOモードではcudaStreamBeginCaptureとcudaStreamEndCaptureの間でncclEpInitHandleを呼び出してはならない。再割り当てはRDMAベースアドレスを変更するが、graphキャプチャはすでに古いポインタを焼き込んでいるためである。
落とし穴3:guardオーバーヘッド。READMEは言及している📎 contrib/nccl_ep/README.md:299-303:EPはデフォルトで内部通信バッファにguardを追加し、隣接するdispatch/combine呼び出しが互いのデータを破壊するのを防ぐ。上級ユーザーが連続操作が競合しないことをすでに保証している場合、NCCL_EP_DISABLE_GUARD=1で無効化してオーバーヘッドを回収できる。しかし誤って無効化するとデータのサイレント破損を引き起こす。
nccl_ubx:融合集合通信と対称アロケータ
直感的モデル:「引っ越し前後の梱包・開梱」も引っ越し業者に任せる
通常の集合通信はデータを運ぶだけです。しかし実際のモデルでは、AllReduce の前に残差加算を行い、後に RMSNorm を行うことがよくあります。これらの操作を別々に行うと、データは VRAM 内を何度も往復することになります。nccl_ubx のアプローチは、残差加算、RMSNorm、mxfp8 量子化をすべて集合通信カーネルに融合することです📎 contrib/nccl_ubx/README.md:6-9。まるで引越し業者が箱を運ぶだけでなく、梱包と開梱も手伝い、一度で完了するようなものです。
ハードウェア前提:NVLink マルチキャストが必須
README は SM 9.0+(Hopper/Blackwell)を明確に要求しており、MC カーネルパスには NVLink マルチキャストハードウェアが必要です📎 contrib/nccl_ubx/README.md:24-24。SM 8.0(A100)はサポートされません。Ampere には NVLink マルチキャストハードウェアがなく、multimem.*インライン PTX が arch 8.0 向けにアセンブルできないためです📎 contrib/nccl_ubx/README.md:24-24。
これが ubx が「実験的」である理由を説明しています——Hopper で初めて導入された NVLink マルチキャスト機能に依存しているのです。multimem.*この命令により、1 つの GPU が 1 命令で複数の GPU の対称アドレスにデータを書き込むことができ、これがハードウェアアクセラレーションによる集合通信の基盤です。このハードウェアがなければ、ubx の中核最適化は成立しません。
対称アロケータ:PyTorch テンソルを NCCL ウィンドウに変える
ubx の中核はカスタム対称アロケータです📎 contrib/nccl_ubx/README.md:11-14:
A central piece of the design is a custom symmetric allocator that provides zero-copy collective input/output buffers while remaining easy to plug into existing PyTorch code: tensors are ordinary torch.Tensor instances backed by an NCCL-managed symmetric window.
これが ubx の最も巧妙な点です。NCCL の対称メモリは、すべての rank が同じ仮想アドレスセットでバッファにアクセスすることを要求します(第 14 章で説明)。しかし PyTorch ユーザーはtorch.Tensorを使うことに慣れています。ubx はtorch.Tensorの基盤ストレージを直接 NCCL 対称ウィンドウにすることで、ユーザーコードを変更せずに集合通信をゼロコピーで行えます——入出力バッファが対称メモリそのものであり、余分なコピーが不要です。
集合通信のバリアントと自動選択
README の Available collectives テーブル📎 contrib/nccl_ubx/README.md:90-90:
| 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 | — |
3 つのバリアントの違い:mcは NVLink マルチキャストハードウェアを使用し、ucは通常のユニキャストを使用し、lamportは低レイテンシアルゴリズムです。自動選択は 0.25 MB で分岐——小メッセージは Lamport 低レイテンシ、大メッセージは MC/UC 高帯域幅。この閾値は NCCL コアのチューニングロジックに似ていますが、ubx では固定閾値に簡略化されています。
融合操作:residual + RMSNorm
README に記載📎 contrib/nccl_ubx/README.md:103-103:
SymmAllocator.allreduce_mc()andallreduce_lamport()accept optionalgamma/residual_inparameters to fuse residual addition + RMSNorm into the same kernel.
これが ubx の中核的な売りです。従来のフローは:AllReduce → 残差加算 → RMSNorm で、3 回の VRAM 読み書きでした。融合後は 1 回のカーネルで完了し、VRAM 帯域幅を 2/3 節約できます。帯域幅制限のある大規模モデル訓練にとって、これは確実な高速化です。
MoE トークンディスパッチ + mxfp8 量子化
README に記載a2av_token_bf16_mxfp8 📎 contrib/nccl_ubx/README.md:103-103:
a single GPU kernel that routes bf16 tokens to remote ranks while quantizing them to mxfp8 (E8M0 scale per 32 elements) on the fly.
このカーネルは「ルーティング + 量子化」を融合します。bf16 は 16 ビット、mxfp8 は 8 ビットで、量子化後はデータ量が半減し、ノード間転送の帯域幅要件も半減します。転送前に量子化する方が転送後に量子化するより優れています——節約されるのはネットワーク帯域幅であり、VRAM 帯域幅ではないからです。これは MoE 推論の鍵となる最適化です。
本番環境での落とし穴回避
落とし穴 1:TORCH_CUDA_ARCH_LISTにはaサフィックスが必須です。README は強調しています📎 contrib/nccl_ubx/README.md:47-56:aサフィックスを使用して完全なmultimem.*命令セットへのアクセスを確保します。一部のアクセラレーション専用バリアントは通常の9.0/10.0では利用できず、将来のカーネルがこれらのバリアントを使用すると、静かに性能が低下するかアセンブルに失敗します。
落とし穴 2:UBX_BUILD_TIMEOUTのランタイムオーバーヘッド。README は説明しています📎 contrib/nccl_ubx/README.md:47-56:1 に設定するとカーネル側で spinloop タイムアウトがコンパイルされ、ランタイムオーバーヘッドが増加します(余分なclock64()チェックとタイムアウト時のprintf)。ハングの調査時のみ有効にしてください。
落とし穴 3:NCCL_NVLS_ENABLE=0の降格。README はこの環境変数を挙げています📎 contrib/nccl_ubx/README.md:202:0 に設定すると NVLink マルチキャストなしで実行できます。ただし MC カーネルパスが無効になり、UC/Lamport バリアントのみとなり、性能が大幅に低下します。
nccl_checkpoint:LD_PRELOAD インターセプトと状態リプレイ
直感的モデル:通信ドメインのスナップショットを撮る
訓練タスクが数時間実行された後、突然別のマシンに移行する必要がある、あるいは復元のために状態を保存する必要があるとします。通常のチェックポイントはモデルの重みとオプティマイザ状態のみを保存しますが、NCCL 通信ドメインの状態(rank 番号、接続、バッファ)は直接シリアライズできません。nccl_checkpoint のアプローチは:すべての NCCL 呼び出しをインターセプトし、初期化ステップを記録し、復元時にこれらのステップをリプレイすることです📎 contrib/nccl_checkpoint/README.md:3-7。
まるで家具の組み立て手順をすべて録画し、引越し後にその録画に従って再組み立てするようなもので、組み立て済みの家具を丸ごと運ぼうとするのではありません。
中核メカニズム:LD_PRELOAD シンボルインターセプト
README の Design セクション📎 contrib/nccl_checkpoint/README.md:17-20:
The application is launched with LD_PRELOAD=/path/to/libnccl-checkpoint-shim.so in the environment. This allows the library to intercept all calls to NCCL functions to capture all resource initialization steps.
LD_PRELOADは Linux 動的リンカのメカニズムです:アプリケーションが通常の共有ライブラリをロードする前に、指定された.soを先にロードします。この.so内で NCCL と同名のシンボル(例えばncclCommInitRank)が定義されている場合、動的リンカは.so内のバージョンを優先的に使用します。これにより shim がすべての NCCL 呼び出しをインターセプトし、パラメータを記録し、復元時にリプレイできます。
チェックポイントフロー
README の Python サンプル📎 contrib/nccl_checkpoint/README.md:44-58完全なフローを示しています:
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()フローは4ステップに分かれます:
1. checkpoint_prepare():すべてのcommunicatorを破棄し、CUDA CheckpointとCRIUがプロセス状態を安全にダンプできるようにする📎 contrib/nccl_checkpoint/README.md:25-27
2. cuCheckpointProcessLock/Checkpoint:CUDAドライバがプロセスをロックしてチェックポイントを実行
3. CRIU dump:外部ツールがプロセスのメモリとファイルディスクリプタをディスクにダンプ
4. cuCheckpointProcessRestore/Unlock + checkpoint_restore():プロセスを復元し、NCCL設定をリプレイ📎 contrib/nccl_checkpoint/README.md:29-31
Redis KVS:マシン間ランデブー
READMEでRedisが必要な理由を説明📎 contrib/nccl_checkpoint/README.md:33-38:
Because it is useful to restore on different hardware, IP addresses may have changed. There is no convenient way to directly inform the NCCL Checkpoint library of all peer addresses during the restore process, so the library depends on a temporary Redis Key-Value store to be made available.
復元時にマシンが変わり、IPが変わる可能性があります。NCCL通信ドメインの再構築には、すべてのpeerの新しいアドレスを知る必要があります。しかしshimはこれらのアドレスを直接知ることができないため、Redis KVSをランデブーに使用します——すべてのプロセスが新しいアドレスをKVSに書き込み、KVSから他のプロセスのアドレスを読み取ります。これは引っ越し後に大家が公共の掲示板で新しい住所を交換するようなものです。
READMEではRedisは復元のブートストラップ段階でのみ必要と説明📎 contrib/nccl_checkpoint/README.md:221-221,checkpoint_restore()が返った後は停止できます。
制限:3つの非サポート
READMEのLimitationsセクション📎 contrib/nccl_checkpoint/README.md:119-129に3つの制限が記載されています:
1. ncclWinGetUserPtr()が返すポインタは復元後に無効📎 contrib/nccl_checkpoint/README.md:125-126
2. CUDA graphキャプチャ非サポート📎 contrib/nccl_checkpoint/README.md:136-136
3. デバイスAPI非サポート——ncclDevCommオブジェクトとデバイス可視のncclWindow_t値は復元できません📎 contrib/nccl_checkpoint/README.md:136-136
3番目の制限が最も深刻です。デバイスAPIはNCCLの新しい方向性(第19章で説明したDevComm)ですが、checkpointはサポートしていません。つまりデバイスAPIを使用するアプリケーション(nccl_ep、nccl_ubxなど)はcheckpointで復元できません。これはエコシステムの断片化の現れです——新機能は速く進むが、信頼性ツールが追いついていません。
本番環境での落とし穴回避
落とし穴1:NCCL_CHECKPOINT_KVS_PATHはチェックポイント前に設定し、復元時には変更できません。READMEの警告📎 contrib/nccl_checkpoint/README.md:221-221:この環境変数はチェックポイント準備段階では使用されませんが、チェックポイントにキャプチャされ、復元時に簡単に変更できません。そのためチェックポイント前に設定しておく必要があり、復元環境でRedisアドレスが一致している必要があります。
落とし穴2:NCCL_CHECKPOINT_KVS_TIMEOUTはshimのRedisランデブーのみをカバーします。READMEの説明📎 contrib/nccl_checkpoint/README.md:221-221:デフォルトは300秒。communicatorのリプレイがNCCL転送確立段階に入ると、基盤のNCCL転送呼び出しは独自の動作を使用し、転送固有の診断が必要になる場合があります。つまり、タイムアウトはRedis段階のみを保護し、転送確立段階のハングはNCCL_DEBUGで調査する必要があります。
落とし穴3:NCCLバージョンが一致する必要があります。READMEではNCCL 2.31.0以降を要求📎 contrib/nccl_checkpoint/README.md:158、またNCCL_SRCパス内のNCCLバージョンがランタイムNCCLライブラリバージョンと正確に一致することを推奨📎 contrib/nccl_checkpoint/README.md:156-158。バージョンの不一致はリプレイ時の構造体レイアウトのずれを引き起こします。
設計思考:エコシステム拡張の3つのモード
これら5つのプロジェクトを振り返ると、NCCLエコシステム拡張の3つのモードを归纳できます:
モード1:言語バインディング(nccl4py、nccl4rust)。核心的な課題は所有権とライフサイクルです。CのABIには所有権セマンティクスがなく、バインディング層が自分で補う必要があります。nccl4pyはCythonで階層化し、nccl4rustはRAII +unsafe境界を使用します。共通点は:バージョン差異をポインタの背後に隔離する——nccl4rustはポインタでDevCommを渡し、nccl4pyは名前空間パッケージでバージョンを隔離します。
モード2:デバイスAPI拡張(nccl_ep、nccl_ubx)。核心的な課題はABIバージョン管理とリソースライフサイクルです。nccl_epはsize-based ABI(前章で詳述)、nccl_ubxは対称アロケータを使用します。共通点は:遅延割り当て + 集団再割り当て——nccl_epのRDMA bufferとnccl_ubxの対称プールはどちらもオンデマンド割り当てですが、再割り当てにはすべてのrankの同期が必要です。
モード3:シンボルインターセプト(nccl_checkpoint)。核心的な課題は状態キャプチャとリプレイです。LD_PRELOADで全てのNCCL呼び出しをインターセプトし、初期化ステップを記録し、復元時にリプレイします。このモードはNCCLコアを変更しませんが、既存アプリケーションに透過的にチェックポイント機能を追加できます。
3つのモードの共通制約はNCCLバージョン互換性です。すべてのプロジェクトが正確に一致するNCCLバージョンを要求します。NCCLのABIが進化しているためです。これはNCCLエコシステムの根本的な緊張を反映しています:コアは急速に反復するが、周辺プロジェクトは安定性を必要とします。size-based ABI、ポインタ渡し、名前空間パッケージはすべてこの緊張を緩和する技術的手段です。
flowchart TD
start["用户想扩展 NCCL"] --> q1{"扩展什么?"}
q1 -->|"语言互操作"| lang["语言绑定"]
q1 -->|"新通信模式"| dev["设备 API 扩展"]
q1 -->|"可靠性"| ckpt["符号拦截"]
lang --> q2{"性能敏感?"}
q2 -->|"是"| cython["Cython 底层 + Python 高层<br/>nccl4py"]
q2 -->|"否"| raii["RAII 包装<br/>nccl4rust"]
dev --> q3{"需要 MoE?"}
q3 -->|"是"| ep["dispatch/combine<br/>nccl_ep"]
q3 -->|"否"| ubx["融合集合通信<br/>nccl_ubx"]
ckpt --> preload["LD_PRELOAD 拦截<br/>nccl_checkpoint"]
cython --> abi{"ABI 版本管理"}
raii --> abi
ep --> abi
ubx --> abi
preload --> abi
abi -->|"指针传递"| safe["版本差异隔离"]
abi -->|"size-based"| safe
abi -->|"命名空间包"| safeこの意思決定図はNCCL拡張の選択パスを示しています。どの道を選んでも、最終的にはABIバージョン管理という核心的な問題に直面し、3つの技術的手段(ポインタ渡し、size-based ABI、名前空間パッケージ)はすべてバージョン差異を安定したインターフェースの背後に隔離します。
本章のまとめ
本章ではNCCLエコシステムの5つの周辺プロジェクトを分析しました:
- nccl4pyCython レイヤリング + PEP 420 名前空間パッケージで、Python エコシステムをゼロコンフリクトで拡張可能にする
nccl.*サブパッケージ。 - nccl4rustRAII 所有権 + ポインタ渡しのデバイスコミュニケータで、バージョン管理された C 構造体レイアウトをカーネル ABI の外に隔離する。
- nccl_epLL/HT デュアルアルゴリズム + 遅延 RDMA バッファ割り当てで、MoE に dispatch/combine プリミティブを提供するが、条件付き集合呼び出しと CUDA graph 無効化の制約を導入する。
- nccl_ubx対称アロケータ + カーネル融合で、残差加算、RMSNorm、mxfp8 量子化を集合通信カーネルに折り込むが、Hopper+ の NVLink マルチキャストハードウェアに依存する。
- nccl_checkpoint用
LD_PRELOADシンボルインターセプト + Redis rendezvous で、クロスマシン通信ドメインのチェックポイントを実現するが、デバイス API と CUDA graph はサポートしない。
本章の考察とセルフチェック
Q1: nccl_ep のrdma_buffer_size = NCCL_EP_AUTOモードで、rank 0 が先にncclEpInitHandleを呼び出してバッファ再割り当てをトリガーし、rank 1 は layout が異なるため再割り当てをトリガーしなかった場合、何が起こるか?📎 contrib/nccl_ep/README.md:396-406の制約を踏まえて分析せよ。
参考解説:README に明記されている📎 contrib/nccl_ep/README.md:396-406:All ranks must call ncclEpInitHandle in lockstep with the same (layout, num_topk)。AUTO モードではncclEpInitHandleは条件付き集合呼び出しである——再割り当てがトリガーされるかどうかは、その handle の(layout, num_topk)が現在のバッファより大きな空間を必要とするかどうかに依存する。
rank 0 の layout がより大きなバッファを必要とし再割り当てをトリガーする一方、rank 1 の layout がそれを必要としない場合、rank 0 は「deregister window → free → ncclMemAlloc → register」という集合操作を実行する📎 contrib/nccl_ep/README.md:396-406が、rank 1 は実行しない。これにより 2 つの問題が生じる:
1. 集合操作の不一致:NCCL の window deregister/register は集合操作であり、すべての rank の参加が必要である。rank 0 が一方的に実行すると、rank 1 は後続の通信で古いウィンドウハンドルを参照する一方、rank 0 はすでに新しいウィンドウに切り替わっているため、通信失敗やデータ破損が発生する。
2. ベースアドレスの不一致:再割り当て後、rank 0 の RDMA ベースアドレスは変わるが、rank 1 は変わらない。README には「recorded layout offsets on every live handle are pure offsets relative to the group's rdma_buffer and resolve correctly against the new base」📎 contrib/nccl_ep/README.md:396-406とあるが、これはすべての rank が再割り当てする前提でのみ成立する。rank 1 のベースアドレスは変わらず、rank 0 は変わったため、クロス rank のアドレス解決がずれる。
正しい方法は:すべての rank が同じ(layout, num_topk)で同期的にncclEpInitHandleを呼び出し、再割り当ての決定が一致することを保証する。保証できない場合は、明示的なrdma_buffer_size > 0モードを使用し、ncclEpCreateGroup時に十分大きなバッファを一度に割り当て、実行時の再割り当てを避ける📎 contrib/nccl_ep/README.md:396-406。
Q2: nccl4rust はなぜ値渡しではなくポインタでncclDevComm_tをデバイスカーネルに渡すのか?値渡しに変更した場合、NCCL が構造体レイアウトをアップグレードした後に何が起こるか?📎 contrib/nccl4rust/README.md:211-219を踏まえて分析せよ。
参考解説:README に明記されている📎 contrib/nccl4rust/README.md:217-219:Kernels construct nccl_device::DevComm from a pointer to that device copy. Using a pointer rather than a by-value Rust mirror keeps the versioned C struct layout out of the kernel argument ABI.
ncclDevComm_tはバージョン管理された公開構造体であり、NCCL バージョンによってフィールドが異なる可能性がある。値渡しの場合:
1. カーネル ABI が構造体レイアウトにバインドされる:カーネル引数を値渡しすると、コンパイラは構造体全体のバイト配置をカーネルの呼び出し規約に焼き込む。NCCL が構造体をアップグレード(フィールド追加、フィールド順序変更、アラインメント変更)した後も、コンパイル済みカーネルは古いレイアウトで引数を解釈し続けるため、フィールドがずれる。
2. すべてのカーネルを再コンパイルする必要がある:NCCL をアップグレードするたびに、デバイスコミュニケータを使用するすべてのカーネルを再コンパイルしなければならない。多数のマシンにデプロイされたトレーニングタスクにとって、これは巨大な運用負担となる。
3. バージョン間の非互換:host 側が新しい NCCL でコミュニケータを作成し、デバイス側カーネルが古い NCCL でコンパイルされている場合、値渡しではカーネルが誤ったフィールドを読み取る。
ポインタ渡しなら 8 バイトのアドレスを 1 つ渡すだけで、カーネルはポインタ経由で構造体にアクセスする。NCCL が構造体レイアウトをアップグレードしたとき、host 側が新バージョンでコミュニケータを作成しデバイスにコピーすれば、カーネルがポインタ経由でアクセスするのは新しいレイアウトである。カーネル自体は再コンパイル不要である。なぜならその引数は単なるアドレスだからだ。これによりバージョン差異をポインタの背後に隔離できる——ポインタは安定しており、ポインタが指す内容は変わりうる。
これは nccl_ep の size-based ABI と同じ設計哲学である:間接層によって、変わりやすいバージョン詳細を安定したインターフェースの背後に隔離する。
Q3: nccl_checkpoint はLD_PRELOADで NCCL 呼び出しをインターセプトするが、アプリケーションが nccl4py と nccl_checkpoint の両方にリンクしている場合、nccl4py の Cython バインディングは直接libnccl.soのシンボルを呼び出すため、LD_PRELOADはインターセプトできるか?シンボル解決順序を分析せよ。
参考解説:これはシンボル解決順序に依存する。LD_PRELOADのメカニズムは:動的リンカがアプリケーションの正常な依存関係にある共有ライブラリをロードする前に、まずLD_PRELOADで指定された.soをロードする。アプリケーション(またはそれが依存するライブラリ)がシンボルを参照すると、動的リンカは「先にロードしたものが先に解決される」順序で検索する——LD_PRELOADの.soがlibnccl.so。
より優先される。したがって理論上、nccl4py の Cython バインディングがncclCommInitRankを呼び出すとき、動的リンカはまずlibnccl-checkpoint-shim.so内の同名シンボルを見つけ、インターセプトが成功する。
しかし、いくつかのエッジケースがある:
1. 直接dlopen + dlsym:nccl4py がdlopen("libnccl.so")を使ってからdlsymで関数ポインタを取得する場合、LD_PRELOADはインターセプトできない。なぜならdlsymは指定された.so内で直接シンボルを探し、グローバルシンボルテーブルを経由しないからである。README では C アプリケーションがdlsymを使ってncclCheckpointPrepare 📎 contrib/nccl_checkpoint/README.md:109-109を解決すると言及されているが、それはチェックポイント自身のシンボルを解決するものであり、NCCL シンボルではない。
2. シンボルバインディングのタイミング:nccl4py がLD_PRELOADが有効になる前に NCCL シンボルをバインドした場合(例えば__attribute__((constructor))内で)、インターセプトが失敗する可能性がある。しかし通常、LD_PRELOADはプロセス起動時に有効になり、どのユーザーコードよりも早い。
3. RTLD_DEEPBIND:nccl4py がdlopenを使う際にRTLD_DEEPBINDを指定すると、シンボル検索はlibnccl.so内部で優先的に解決され、LD_PRELOADをバイパスする。これはよくある落とし穴である。
4. 静的リンク:nccl4py が NCCL を静的リンクしている場合、LD_PRELOADは完全に無効である。シンボルはすでにコンパイル時に解決されているからである。
したがって結論は:通常の動的リンクのシナリオではLD_PRELOADは nccl4py の呼び出しをインターセプトできるが、nccl4py がdlopen + RTLD_DEEPBINDや静的リンクを使用している場合、インターセプトは失敗する。本番環境ではLD_DEBUG=bindingsを使ってシンボルバインディングを検証し、NCCL 呼び出しが shim によってインターセプトされていることを確認すべきである。
次章では、アーキテクチャの進化と将来の方向性に目を向け、NCCL が集合通信ライブラリからプログラマブル通信エンジンへとどのように進化するかを見ていく。
これらの周辺プロジェクトは、言語バインディング、デバイス API 拡張、シンボルインターセプトを通じて、NCCL のコア機能をさまざまなシナリオで再利用する方法を示している。そしてすべてのプロジェクトに共通する核心的な制約は NCCL ABI バージョンの互換性である——size-based ABI、ポインタ渡し、名前空間パッケージは、いずれもバージョン差異を安定したインターフェースの背後に隔離する技術的手段である。これらの手段を理解することが、これらの周辺プロジェクトを安全に使用する前提となる。これらの拡張プロジェクトがコアの境界を絶えず試す一方で、NCCL 自身も静かに進化している:固定された集合操作からプログラマブル通信エンジンへ、host proxy から GPU 直発へ、登録バッファから対称メモリへ。次章では、ソースコードに残された進化の痕跡に基づき、これらの変化が上位フレームワークの通信方式をどのように再形成するかを探る。
第 24 章:第 24 章:アーキテクチャの進化と将来の方向性:静的通信からプログラマブル通信へ
第 24 章:アーキテクチャの進化と将来の方向性:静的通信からプログラマブル通信へ
前章では、コミュニティが NCCL コアを中心にどのように周辺エコシステムを構築しているかを見た:Python バインディング、Rust バインディング、エキスパート並列通信、超帯域幅プリミティブ、通信チェックポイント。これらのプロジェクトはすべて NCCL の安定した API を再利用しているが、その要求は従来の集合通信の範疇を超えている——エキスパート並列は細粒度のポイントツーポイント送受信を必要とし、チェックポイントは通信状態の一時停止/再開を必要とし、超帯域幅プリミティブは標準の集合操作をバイパスして直接ネットワークを操作する必要がある。これらの要求は同じ問題を指し示している:NCCL の固定された集合操作モデルは、より柔軟な通信ニーズによって押し広げられつつある。本章ではもはや単一のモジュールを見るのではなく、ソースコードにすでに現れている進化の痕跡から、NCCL がどこへ向かおうとしているかを議論する。具体的には、絡み合う三つの進化の力が分析される:通信プリミティブが固定集合からプログラマブルへ——src/rma/rma.cc の RMA タスクスケジューリングにより、上位層は AllReduce を呼び出すだけでなく、Put/Signal/WaitSignal プリミティブを組み合わせることができる;ネットワーク起動が host proxy から GPU 直発へ——src/gin/gin_host.cc の GIN バックエンド管理により、GPU kernel が直接ネットワークカードを駆動する;メモリモデルが登録バッファから対称メモリへ——src/sym_kernels.cc の対称メモリ kernel 選択により、すべての rank が同一の仮想アドレスセットで互いのバッファにアクセスする。これら三つの力は孤立しておらず、同じインフラストラクチャを共有している:src/nccl_device/core.cc の team 抽象と src/devcomm/devcomm_v23100.cc のバージョン化された DevComm。それらがどのように噛み合っているかを理解すれば、NCCL が「集合通信ライブラリ」から「プログラマブル通信エンジン」への進化する論理を理解できる。
一、プログラマブル通信プリミティブ:RMA が「固定レシピ」を「ビュッフェ」に変える方法
直感的モデル
従来の NCCL の集合通信は固定セットメニューのようなものだ。AllReduce を注文すれば、キッチンは AllReduce の手順通りに調理を完了する。しかし、エキスパート並列(MoE)のシナリオでは、各トークンを異なるエキスパートに送信する必要があり、その送信パターンはコンパイル時には全く分からない——これはビュッフェのようなもので、何を取るか、どれだけ取るか、いつ取るかを自分で決めなければならない。
RMA とは、NCCL が上位層に提供する「ビュッフェ台」である:Put(データを相手側メモリに書き込む)、Signal(相手側に通知する)、WaitSignal(相手側の信号を待つ)。上位フレームワークはこれら3つのプリミティブを自由に組み合わせて、任意の通信パターンを実現できる。
RMA がなければ、MoE の all-to-all は複数回の小規模な集合操作でしか模擬できず、毎回完全なカーネル起動と同期フローを経る必要があり、遅延が許容できないほど高くなる。
データ構造とメモリレイアウト
RMA の核心的なデータ構造はncclTaskRma(タスク記述)とncclRmaArgs(計画パラメータ)である。まずncclRmaArgsのフィールドを見てみよう。これはscheduleRmaTasksToPlanで初期化される。
📎 src/rma/rma.cc:166-171
plan->isRma = true;
plan->rmaArgs = ncclMemoryStackAlloc<struct ncclRmaArgs>(&comm->memScoped);
plan->rmaArgs->func = firstTask->func;
plan->rmaArgs->nRmaTasks = 0;
plan->rmaArgs->nRmaTasksProxy = 0;
plan->rmaArgs->nRmaTasksCe = 0;ここでの重要なフィールドはnRmaTasksProxyとnRmaTasksCeである。これらは RMA タスクを2つの実行パスに分ける:
- CE パス(Copy Engine、コピーエンジン):対象 rank が LSA(Local Symmetric Access、ローカル対称アクセス)範囲内にあり、GPU のコピーエンジンで直接完了でき、ネットワークは不要である。
- Proxy パス:対象 rank が LSA 範囲内にない場合、host proxy スレッドがネットワークを駆動する必要がある。
この二分法の設計動機は非常に直接的である:LSA 範囲内の通信は NVLink または PCIe を通り、帯域幅が高く遅延が低いため、CE 非同期コピーが最も効率的である。マシン間通信は必ずネットワークカードを通るため、proxy スレッドが駆動するしかない。2種類のタスクを分けてスケジューリングすることで、CE と proxy を直列に待つのではなく並列に実行できる。
ncclTaskRma自体はpeers、nsignals、signalIdxsという3つの配列ポインタを含み、それぞれ対象 rank、信号数、信号インデックスを記録する。WaitSignal タスクでは、1つのタスクが複数の peer を待つことができる。Put/Signal タスクでは、1つのタスクは1つの peer のみを対象とする。
Step-by-Step Walkthrough:ある WaitSignal のスケジューリング
具体的なシナリオを代入しよう:rank 0 がncclWaitSignalを呼び出し、rank 1 と rank 3 の信号を待つ。rank 1 は LSA 範囲内にあり、rank 3 はそうでないと仮定する。
第一步:最初の非空コンテキストキューを見つける。
📎 src/rma/rma.cc:148-158
int ctx = -1;
for (int i = 0; i < comm->config.numRmaCtx; i++) {
if (!ncclIntruQueueEmpty(&planner->rmaTaskQueues[i])) {
ctx = i;
break;
}
}
if (ctx == -1) return ncclSuccess;RMA タスクは context ごとにキューに分けられ、各 context は独立した RMA チャネルである。ここではタスクがある最初の context を見つけ、そのキューを取り出す。
第二步:最初のタスクを取り出し、タイプを判定する。
📎 src/rma/rma.cc:163-168
struct ncclTaskRma* firstTask = ncclIntruQueueDequeue(ctxQueue);
plan->isRma = true;
plan->rmaArgs = ncclMemoryStackAlloc<struct ncclRmaArgs>(&comm->memScoped);
plan->rmaArgs->func = firstTask->func;firstTask->funcがncclFuncWaitSignalであり、WaitSignal 分岐に入る。
第三步:LSA 到達可能性に従って peer を分割する。
📎 src/rma/rma.cc:187-204
for (int i = 0; i < firstTask->npeers; i++) {
int peerRank = firstTask->peers[i];
bool lsaAccessible = isLsaAccessible(comm, peerRank);
if (lsaAccessible) {
peersCe[npeersCe] = peerRank;
nsignalsCe[npeersCe] = firstTask->nsignals[i];
signalIdxsCe[npeersCe] = firstTask->signalIdxs[i];
npeersCe++;
} else {
peersProxy[npeersProxy] = peerRank;
nsignalsProxy[npeersProxy] = firstTask->nsignals[i];
signalIdxsProxy[npeersProxy] = firstTask->signalIdxs[i];
npeersProxy++;
}
}isLsaAccessibleがcomm->devrState.lsaRankListを走査し、peer が LSA チーム内にあるか判定する。rank 1 は LSA 内にあり、CE リストに入る。rank 3 はそうでなく、Proxy リストに入る。
第四步:CE と Proxy それぞれに新しいタスクを作成する。
📎 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;
}元の1つの WaitSignal タスクが2つに分割される:CE タスクは rank 1 を待ち、Proxy タスクは rank 3 を待つ。2つのタスクは並列に実行できる——CE パスは GPU 上で待ち、Proxy パスは host スレッド上で待つ。
第五步:元のタスクを解放する。
📎 src/rma/rma.cc:249-251
planner->nTasksRma -= 1;
ncclMemoryPoolFree(&comm->memPool_ncclTaskRma, firstTask);元のタスクはすでに2つの新しいタスクに分割されており、メモリプールに解放される。
並行制御とハードウェア相互作用
RMA の並列実行はncclRmaWaitSignalに現れる。
📎 src/rma/rma.cc:43-74
if (plan->rmaArgs->nRmaTasksProxy > 0 && plan->rmaArgs->nRmaTasksCe > 0) {
cudaStream_t ceStream = comm->rmaState.rmaCeState.ceStream;
cudaEvent_t ceEvent = comm->rmaState.rmaCeState.ceEvent;
CUDACHECKGOTO(cudaEventRecord(ceEvent, stream), ret, fail);
CUDACHECKGOTO(cudaStreamWaitEvent(ceStream, ceEvent, 0), ret, fail);
NCCLCHECKGOTO(ncclRmaProxyWaitLaunch(comm, plan, stream), ret, fail);
NCCLCHECKGOTO(ncclRmaCeWaitLaunch(comm, plan, ceStream), ret, fail);
CUDACHECKGOTO(cudaEventRecord(ceEvent, ceStream), ret, fail);
CUDACHECKGOTO(cudaStreamWaitEvent(stream, ceEvent, 0), ret, fail);
}このコードは CUDA event でストリーム間同期を行う:まず入力ストリームで event を記録し、CE ストリームにこの event を待たせ、次に2つのストリームでそれぞれ proxy と CE タスクを起動し、最後に入力ストリームに CE ストリームの event を待たせる。これにより2つのパスが並行して進むが、外部からは1つの同期操作として見える。
ここでの設計トレードオフは:並列実行は遅延を低減できるが、追加の event 記録とストリーム同期のオーバーヘッドを導入する。小さいメッセージでは、このオーバーヘッドが並列の利益を上回る可能性がある。大きいメッセージでは、並列の利益が顕著である。NCCL はここで適応的な判断を行わず、一律に並列パスを通る——RMA の典型的なシナリオは大きいメッセージの細粒度通信だからである。
本番環境の落とし穴回避ガイド
落とし穴 1:LSA 到達可能性の判定ミスによりタスクが誤ったパスを通る。 isLsaAccessibleがlsaRankListを走査し、もしlsaSizeが 0 の場合(例えば単一 rank の通信ドメイン)、すべての peer が到達不可能と判定され、すべて Proxy パスを通る。これは小規模テストでは露見しないが、大規模デプロイでは性能が急落する。調査方法はscheduleRmaTasksToPlanの INFO ログでnRmaTasksProxyとnRmaTasksCeの比率を見ることである。
落とし穴 2:WaitSignal タスク分割後の peer 配列のライフサイクル。CE パスのpeersCeはncclMemoryStackAllocで割り当てられ、ライフサイクルはcomm->memScopedに従う。Proxy パスのpeersProxyはncclCallocで割り当てられ、タスク実行完了後に手動でfree。もし Proxy タスクの作成に失敗した場合、failブランチはこれらの配列を解放します。
📎 src/rma/rma.cc:302-308
exit:
return ret;
fail:
free(peersProxy);
free(nsignalsProxy);
free(signalIdxsProxy);
goto exit;落とし穴 3:Put/Signal タスクのクロス context バッチ処理。Put/Signal ブランチでは、NCCL はすべての context の put/signal タスクを同じ plan にまとめますが、WaitSignal に遭遇すると停止します。
📎 src/rma/rma.cc:279-295
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);
...
}
}この設計の意図は、1 回の kernel 起動ですべての context の put/signal をカバーし、起動オーバーヘッドを削減することです。ただし、各 context のキューは最初の WaitSignal までしか消費せず、per-context FIFO 順序を保証します。上位層が同じ context 内で put と waitSignal を交互に呼び出すと、バッチ効果は大幅に低下します——これは RMA 使用時に注意が必要なパターンです。
---
二、GPU 直送ネットワーク:GIN が kernel を host proxy から迂回させる仕組み
直感的モデル
従来の NCCL のネットワーク通信は手紙を送るようなものです:GPU kernel がデータをバッファに置き、host proxy スレッドがデータを NIC に渡し、NIC が送信します。GIN は GPU kernel が直接相手のメールボックスに手紙を投函するようなものです——kernel が NIC の送信キューに直接書き込み、NIC が GPU メモリを直接読み取ります。
GIN がなければ、ネットワーク通信のたびに host メモリを経由するため、レイテンシは少なくとも PCIe の往復 1 回分増加します。MoE のような細粒度通信では、このレイテンシは致命的です。
データ構造とメモリレイアウト
GIN の核心状態はncclGinStateであり、複数のバックエンド(backend)と複数の DevComm を管理します。まずバックエンドバージョン互換テーブルを見てみましょう。
📎 src/gin/gin_host.cc:27-33
const int proxyBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 3), NCCL_VERSION(2, 30, 5), NCCL_VERSION(2, 32, 0)};
const int gdakiBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 3), NCCL_VERSION(2, 30, 5)};
const int gpiBackendMinVersions[] = {0, NCCL_VERSION(2, 30, 5)};
constexpr int efaGdaBackendMinVersions[] = {0, NCCL_VERSION(2, 31, 0), NCCL_VERSION(2, 32, 0)};これらの配列のインデックスはバックエンドバージョン番号で、値は互換性のある最低 NCCL バージョンです。例えばproxyBackendMinVersions[3]はバックエンドバージョン 3 に対応し、NCCL 2.32.0 以上を要求します。この設計により、NCCL はコンパイル時にバインドするのではなく、実行時にデバイスコードバージョンに応じて適切なバックエンドバージョンを選択できます。
このバージョン互換テーブルの設計動機は、GIN バックエンド(NIC ドライバ、ファームウェア)と NCCL ライブラリのバージョン進化のペースが異なることです。バージョン要件をハードコードすると、どちらか一方のアップグレードで非互換が発生します。配列でバージョンマッピングを行うことで、実行時に動的に選択でき、古いバックエンドとの後方互換性を保てます。
ncclGinStateDevCommは各 DevComm の GIN 状態であり、contextCount、backendIndex、ginCtx[]、devHandles[]などのフィールドを含みます。これはリンクリストとしてginState->devCommsに接続されます。
Step-by-Step Walkthrough:1 回の GIN 接続確立
シナリオを想定しましょう:rank 0 が通信ドメインを初期化し、GIN 接続を確立する必要があります。
第一步:GIN が有効かつサポートされているか確認。
📎 src/gin/gin_host.cc:96-107
if (ginState->connected) return ncclSuccess;
if (ncclParamGinEnable() == 0) {
WARN("GIN is disabled.");
return ncclInternalError;
}
if (!ginState->supported) {
WARN("GIN not supported.");
return ncclInvalidUsage;
}ncclParamGinEnable()は環境変数NCCL_GIN_ENABLEを読み取り、デフォルトは 1 です。ユーザーが明示的に無効化した場合、直接エラーを返します。
第二步:対称メモリのサポートを確認。
📎 src/gin/gin_host.cc:111-114
if (!comm->symmetricSupport) {
WARN("Communicator does not support symmetric memory!");
return ncclInternalError;
}GIN は対称メモリに依存します——GPU kernel が相手側バッファの仮想アドレスを知る必要があり、対称メモリでのみアドレスの一致が保証されるからです。
第三步:ローカル GIN デバイスリストを取得。
📎 src/gin/gin_host.cc:116-122
int nLocalGinDevs;
int localGinDevs[NCCL_TOPO_MAX_NODES];
NCCLCHECK(ncclTopoGetLocalGinDevs(comm, localGinDevs, &nLocalGinDevs));
if (nLocalGinDevs > NCCL_GIN_MAX_CONNECTIONS) {
ATTN("Found %d local devices, but GIN supports at most %d connections. Using the first %d connections.",
nLocalGinDevs, NCCL_GIN_MAX_CONNECTIONS, NCCL_GIN_MAX_CONNECTIONS);
}ncclTopoGetLocalGinDevsはトポロジグラフから GIN をサポートするすべての NIC を見つけます。もしNCCL_GIN_MAX_CONNECTIONSを超える場合、先頭のいくつかのみを取り、警告を出力します。
第四步:GIN チームを計算。
📎 src/gin/gin_host.cc:138-149
ginTeam = ncclTeamWorld(comm);
if (ginState->ginConnectionType != NCCL_GIN_CONNECTION_FULL) {
ginTeam = {
.nRanks = comm->nRanks / comm->contiguousRanksPerHost,
.rank = comm->rank / comm->contiguousRanksPerHost,
.stride = comm->contiguousRanksPerHost,
};
}
for (int r = 0; r < ginTeam.nRanks; r++) {
int worldRank = ncclTeamRankToWorld(comm, ginTeam, r);
handles[r] = allHandles + worldRank * NCCL_NET_HANDLE_MAXSIZE;
}接続タイプが FULL の場合、GIN チームはワールド全体のチームです;そうでなければ各 host の最初の rank のみ接続します(rail 接続)。ncclTeamRankToWorldはチーム内の rank をワールド rank に変換します。
第五步:バックエンドごとに接続を確立。
📎 src/gin/gin_host.cc:151-202
for (int backendIdx = 0; backendIdx < ginState->numActiveBackends; backendIdx++) {
backend = &ginState->backends[backendIdx];
NCCLCHECKGOTO(backend->ncclGin->devices(&ndev), ret, fail);
...
for (int commIdx = 0; commIdx < backend->ginCommCount; commIdx++) {
NCCLCHECKGOTO(backend->ncclGin->listen(...), ret, fail);
NCCLCHECKGOTO(backend->ncclGin->getProperties(...), ret, fail);
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, allHandles, NCCL_NET_HANDLE_MAXSIZE), ret, fail);
NCCLCHECKGOTO(backend->ncclGin->connect(...), ret, fail);
NCCLCHECKGOTO(backend->ncclGin->closeListen(...), ret, fail);
}
}各バックエンドはまずdevicesを呼び出してデバイス数を取得し、その後各接続に対して listen→getProperties→allGather→connect→closeListen のフローを実行します。bootstrapAllGatherはすべての rank 間で handle を交換し、各 rank が相手側の接続情報を知ることができるようにします。
並行制御とハードウェア相互作用
GIN の進捗スレッドは核心的な並行メカニズムです。
📎 src/gin/gin_host.cc:56-87
void* ncclGinProgress(struct ncclGinState* ginState, int threadIdx) {
if (ncclOsCpuCount(ginState->cpuAffinity)) {
ncclOsSetAffinity(ginState->cpuAffinity);
}
while (1) {
if (ginState->proxyThreadStopSignal.load()) return NULL;
if (ginState->writePending.load()) {
std::this_thread::yield();
continue;
}
{
std::shared_lock<std::shared_timed_mutex> rlock(ginState->devCommRwMutex);
struct ncclGinStateDevComm* dc = ginState->devComms;
while (dc) {
struct ncclGinBackendState* backend = &ginState->backends[dc->backendIndex];
for (int commIdx = threadIdx; commIdx < backend->ginCommCount; commIdx += ginState->proxyNthreads) {
if (dc->devHandles[commIdx]->needsProxyProgress) {
ncclResult_t ret = backend->ncclGin->ginProgress(dc->ginCtx[commIdx]);
if (ret != ncclSuccess) {
COMPILER_ATOMIC_STORE(&ginState->asyncResult, ret, std::memory_order_release);
return NULL;
}
}
}
dc = dc->next;
}
}
std::this_thread::yield();
}
}ここにはいくつかの重要な設計があります:
1. CPU アフィニティ:ncclOsSetAffinityは進捗スレッドを指定された CPU コアにバインドし、スレッドマイグレーションによるキャッシュ無効化を回避します。
2. 書き込みロックバックオフ:writePendingはアトミックフラグで、メインスレッドがdevCommsリンクリストを変更する際に先にセットし、進捗スレッドがそれを見ると自発的に yield し、ロック競合を回避します。
3. 読み書きロック:devCommRwMutexはshared_timed_mutexであり、進捗スレッドは読み取りロックを保持してリンクリストを走査し、メインスレッドは書き込みロックを保持してリンクリストを変更します。
4. スレッド分担:スレッド t は接続 t, t+proxyNthreads, t+2*proxyNthreads, ... を担当し、stride ループで負荷分散を実現します。
📎 src/gin/gin_host.cc:43-47
static void ginProgressWriteLock(struct ncclGinState* ginState) {
ginState->writePending.store(true);
ginState->devCommRwMutex.lock();
}
static void ginProgressWriteUnlock(struct ncclGinState* ginState) {
ginState->devCommRwMutex.unlock();
ginState->writePending.store(false);
}この書き込みロックの実装は書き手が 1 つ(メインスレッド)のみであることを前提としているため、追加のミューテックスは不要です。writePendingは先にセットしてからロックを取得し、進捗スレッドがロック取得前に書き込み意図を確認して自発的に退避できるようにします。
本番環境の落とし穴回避ガイド
落とし穴 1:GIN 接続数の不一致による AllGather デッドロック。各 rank のginCommCountは異なる可能性があり(ローカル NIC 数に依存)、NCCL はbootstrapAllGatherで全 rank の最小値を取ります。
📎 src/gin/gin_host.cc:176-180
ginCommCountHandles[comm->rank] = backend->ginCommCount;
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, ginCommCountHandles, sizeof(int)), ret, fail);
for (int r = 0; r < comm->nRanks; r++) {
backend->ginCommCount = std::min(backend->ginCommCount, ginCommCountHandles[r]);
}ある rank の NIC 数が他の rank より少ない場合、すべての rank が最小値に揃えられます。これにより接続の対称性は保証されますが、NIC リソースが無駄になります。
落とし穴 2:proxyNthreads が ginCommCount を超えるとスレッドが空回りする。ユーザーが設定した場合NCCL_GIN_PROXY_NTHREADSより大きいginCommCount、余分なスレッドは stride ループ内で空回りします。
📎 src/gin/gin_host.cc:181-183
// After cross-rank min, proxyNthreads may exceed ginCommCount if ranks disagree
// on NCCL_GIN_PROXY_NTHREADS (atypical — env vars are normally uniform across a job).
// Extra threads simply idle in the stride loop; no correctness issue.これは正確性の問題ではありませんが、CPU リソースを無駄にします。調査方法はNCCL_GIN_PROXY_NTHREADSが実際の NIC 数より大きいかどうかを確認することです。
落とし穴 3:DevComm 解放時の競合状態。 ncclGinDevCommFreeまず DevComm をリンクリストから取り外し、その後 context を破棄します。
📎 src/gin/gin_host.cc:464-475
ginProgressWriteLock(ginState);
if (prevDc) prevDc->next = dc->next;
else ginState->devComms = dc->next;
ginProgressWriteUnlock(ginState);
struct ncclGinBackendState* backend = &ginState->backends[dc->backendIndex];
for (int commIdx = 0; commIdx < backend->ginCommCount; commIdx++) {
NCCLCHECK(backend->ncclGin->destroyContext(dc->ginCtx[commIdx]));
}取り外した後、進捗スレッドはこの DevComm を認識できなくなるため、context の破棄は安全です。ただし、破棄中に in-flight のネットワーク操作があると未定義動作を引き起こす可能性があります。これは GIN を使用する際に確保すべき事項です:DevComm を解放する前に、すべての操作が完了していることを確認する必要があります。
---
三、対称メモリ kernel:「登録バッファ」から「統一アドレス空間」へ
直感的モデル
従来の NCCL のバッファは「登録制」です:各 rank が自身のバッファを登録し、通信時に handle を介してアドレスを交換します。対称メモリは「統一アドレス空間」です:すべての rank が同じ仮想アドレスを約束し、rank 0 のアドレス A と rank 1 のアドレス A はそれぞれの物理メモリを指しますが、コード内では同じアドレスでアクセスできます。
これは、皆が「3 列 5 番」と約束すれば、各人の家で同じ位置を指し、物を探すときに「君の家の 3 列 5 番はどこ?」と先に聞く必要がないのと同じです。
対称メモリがなければ、各 kernel はまず相手のアドレスを解析する必要があり、命令オーバーヘッドとレジスタプレッシャーが増加します。
データ構造とメモリレイアウト
対称メモリ kernel の核心は kernel mask です——現在の通信ドメインでどの kernel が利用可能かをマークするビットマップです。
📎 src/sym_kernels.cc:17-63
constexpr uint32_t kernelMask_STMC =
1 << ncclSymkKernelId_AllGather_LLMC | 1 << ncclSymkKernelId_AllGather_STMC |
...
constexpr uint32_t kernelMask_LDMC = ...;
constexpr uint32_t kernelMask_LL = ...;
constexpr uint32_t kernelMask_AG = ...;
constexpr uint32_t kernelMask_AR = ...;
constexpr uint32_t kernelMask_RS = ...;
constexpr uint32_t kernelMask_LSA = ...;
constexpr uint32_t kernelMask_Gin = ...;
constexpr uint32_t kernelMask_Tma = ...;各 mask は 32 ビット整数で、i 番目のビットが 1 なら kernel i が利用可能です。これらの mask は異なる次元でグループ化されます:
- プロトコル別:STMC(Simple TMA Multimem Copy)、LDMC(Low-latency Direct Multimem Copy)、LL(Low Latency)
- 操作別:AG(AllGather)、AR(AllReduce)、RS(ReduceScatter)
- ハードウェア別:LSA(Local Symmetric Access)、Gin(GPU-Initiated Networking)、Tma(Tensor Memory Accelerator)
このビットマップ設計の利点は、ビット演算で利用可能な kernel を高速にフィルタリングできることです。例えばkmask &= ~kernelMask_STMCの一行で全ての STMC kernel を無効化でき、リストを走査する必要がありません。
Step-by-Step Walkthrough:kernel mask 計算の一例
シナリオを代入します:rank 0 が AllReduce を実行、データ型は float16、メッセージサイズ 1MB、通信ドメインは 8 つの rank、全て NVLink 接続。
第一步:操作に対応する基本 mask を取得。
📎 src/sym_kernels.cc:304-306
uint32_t kmask = kernelMask_coll(coll);kernelMask_coll(ncclFuncAllReduce)が返すkernelMask_AR、5 つの AllReduce kernel を含みます。
第二步:STMC と LDMC の可用性を確認。
📎 src/sym_kernels.cc:308-334
bool hasSTMC = comm->symkState.hasLsaMultimem;
bool hasLDMC = false;
if (comm->symkState.hasLsaMultimem) {
switch (ty) {
case ncclFloat16:
case ncclBfloat16:
hasLDMC = red == ncclDevSum || red == ncclDevMinMax || red == ncclDevSumPostDiv;
break;
...
}
}
if (!hasSTMC) kmask &= ~kernelMask_STMC;
if (!hasLDMC) kmask &= ~kernelMask_LDMC;hasLsaMultimemはncclSymkInitOnceで計算され、NVLS 対称マルチキャストが利用可能で LSA チームが 2 つの rank より大きいことを要求します。float16 は LDMC をサポートするため、hasLsaMultimemが真なら LDMC kernel は保持されます。
第三步:メッセージサイズ制限を確認。
📎 src/sym_kernels.cc:336-342
size_t nBytes = alignUp(nElts * ncclTypeSize(ty), NCCL_SYM_KERNEL_CELL_SIZE);
size_t nBusBytes = (coll == ncclFuncAllReduce ? 1 : comm->nRanks) * nBytes;
if (nBusBytes >= (size_t(2) << 30)) kmask &= ~kernelMask_LL;
if (nBusBytes >= 32 * (size_t(2) << 30)) kmask = 0;LL kernel は 32 ビット整数で要素数を追跡するため、バスバイト数が 2GB を超えると無効化されます。64GB を超えると全ての kernel が無効化されます(32 ビット整数オーバーフロー)。
第四步:TMA 可用性を確認。
📎 src/sym_kernels.cc:344-345
if (!ncclSymkTmaAvailable(comm)) kmask &= ~kernelMask_Tma;
if (!symAligned16B) kmask &= ~kernelMask_Tma;TMA は SMEM 容量と計算能力 10.0+、およびバッファの 16 バイトアライメントが必要です。
第五步:GIN 要件を確認。
📎 src/sym_kernels.cc:347-350
bool hasGin = ncclParamSymGinKernelsEnable() != 0;
if (!hasGin) kmask &= ~kernelMask_Gin;
bool needGin = ncclTeamLsa(comm).nRanks < comm->nRanks;
kmask &= needGin ? kernelMask_Gin : ~kernelMask_Gin;LSA チームが全ての rank をカバーする場合、GIN は不要です;そうでなければ GIN kernel のみを保持します。
並行制御とハードウェア相互作用
対称メモリ kernel の初期化は DevComm 作成とリソース割り当てを含みます。
📎 src/sym_kernels.cc:185-264
ncclResult_t ncclSymkInitOnce(struct ncclComm* comm) {
NCCLCHECK(ncclDevrInitOnce(comm));
struct ncclSymkState* symk = &comm->symkState;
if (!symk->initialized) {
symk->initialized = true;
struct ncclDevCommRequirements reqs = NCCL_DEV_COMM_REQUIREMENTS_INITIALIZER;
symk->hasLsaMultimem = ncclNvlsSymmetricMultimemEnabled(comm) && ncclTeamLsa(comm).nRanks > 2 && !comm->p2pCrossClique;
reqs.lsaMultimem = symk->hasLsaMultimem;
reqs.lsaBarrierCount = ncclSymkMaxBlocks;
...
NCCLCHECK(ncclDevrCommCreateInternal(comm, &reqs, &symk->kcomm.devComm, /*isInternal=*/true, /*deviceCodeVersion=*/NCCL_VERSION_CODE));
}
return ncclSuccess;
}ここでの鍵はncclDevrCommCreateInternalで、これは LSA マルチキャスト、GIN inbox/outbox、シグナルなどのリソースを含む内部 DevComm を作成します。reqs.ginConnectionType = NCCL_GIN_CONNECTION_RAILは GIN が rail 接続モードを使用することを指定します。
📎 src/sym_kernels.cc:257-261
symk->kcomm.workStarted = comm->profiler.symWorkStarted;
symk->kcomm.workCompleted = comm->profiler.symWorkCompleted;
symk->kcomm.workPhases = comm->profiler.symWorkPhases;対称メモリ kernel は独立した profiler バッファを使用し、通常の kernel の workCounter と交錯するのを避けます。
本番環境の落とし穴ガイド
落とし穴 1:TMA kernel の SMEM 要件。TMA は各 warp に約 8KB の SMEM スクラッチが必要で、16 warp なら 128KB です。
📎 src/sym_kernels.cc:135-142
bool ncclSymkTmaAvailable(struct ncclComm* comm) {
if (comm->maxSharedMemOptin < ncclTmaShmemScratchWarpSize() * 16) {
return false;
}
return comm->minCompCap >= 100 && ncclParamSymTmaEnable();
}GPU の SMEM 容量が不足している場合(例:MIG インスタンス)、TMA kernel は無効化されます。調査方法はmaxSharedMemOptinがncclTmaShmemScratchWarpSize() * 16。
より小さいかどうかを確認することです。落とし穴 2:GIN chunk size の境界。
📎 src/sym_kernels.cc:148-153
static constexpr size_t ncclSymkRsGinDefaultChunkBytes = 128 << 10;
static constexpr size_t ncclSymkRsGinMinChunkBytes = 128;
static constexpr size_t ncclSymkRsGinMaxChunkBytes = size_t(1) << 30;
size_t ncclSymkRsGinChunkBytes() {
int64_t param = ncclParamSymRsGinChunkSize();
size_t chunkBytes = param > 0 ? (size_t)param : ncclSymkRsGinDefaultChunkBytes;
chunkBytes = std::max(ncclSymkRsGinMinChunkBytes, std::min(chunkBytes, ncclSymkRsGinMaxChunkBytes));
return pow2Down(chunkBytes);
}コピーNCCL_SYM_RS_GIN_CHUNK_SIZEユーザーが設定した
落とし穴3:対称メモリ登録タイプの不一致。 ncclGetSymRegTypesendWin と recvWin のNCCL_WIN_COLL_SYMMETRICフラグに基づいて登録タイプを判断する。
📎 src/sym_kernels.cc:395-412
if (!isSendSymmReg && !isRecvSymmReg) {
*winRegType = ncclSymSendNonregRecvNonreg;
} else if (isSendSymmReg && !isRecvSymmReg) {
*winRegType = ncclSymSendRegRecvNonreg;
} else if (!isSendSymmReg && isRecvSymmReg) {
*winRegType = ncclSymSendNonregRecvReg;
} else if (isSendSymmReg && isRecvSymmReg) {
*winRegType = ncclSymSendRegRecvReg;
}send と recv の登録タイプが一致しない場合、kernel は異なるコードパスを通る必要がある。これはパフォーマンスに影響するが、エラーにはならない。
---
四、Team 抽象とバージョン化 DevComm:進化の基盤インフラ
直感的モデル
Team 抽象は「グループ分け」のようなものだ:ワールドチームはクラス全体、LSA チームは隣の席、Rail チームは同じ列の席。異なる通信パターンには異なるグループ分けの視点が必要だ。
バージョン化 DevComm は「翻訳者」のようなものだ:異なるバージョンのデバイスコードは異なる「方言」を話し、DevComm 互換レイヤーが翻訳を担当し、新旧のコードが互いを理解できるようにする。
Team 抽象がなければ、各 kernel が自分で rank マッピングを計算しなければならない。バージョン化 DevComm がなければ、ABI の変更がすべてのデバイスコードの再コンパイルを引き起こす。
データ構造とメモリレイアウト
Team はシンプルな三つ組である:nRanks、rank、stride。
📎 src/nccl_device/core.cc:13-19
ncclTeam_t ncclTeamWorld(ncclComm_t comm) {
ncclTeam_t ans;
ans.nRanks = comm->nRanks;
ans.rank = comm->rank;
ans.stride = 1;
return ans;
}ワールドチームの stride は 1 である。なぜならすべての rank が連続して配置されているからだ。
📎 src/nccl_device/core.cc:70-79
ncclTeam_t ncclTeamRail(ncclComm_t comm) {
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};
ncclTeam_t ans;
ans.nRanks = comm->nRanks / comm->devrState.lsaSize;
ans.rank = comm->rank / comm->devrState.lsaSize;
ans.stride = comm->devrState.lsaSize;
return ans;
}Rail チームの stride はlsaSizeである。なぜなら各 rail 上の rank は LSA チームのサイズ分だけ間隔が空いているからだ。
バージョン化 DevComm の核心はncclDevCommCompat構造体である。
📎 src/devcomm/devcomm_v23100.cc:10-17
struct ncclDevCommCompat ncclDevCommCompat_v23100 = {
NCCL_VERSION(2, 31, 0), // minVersion
NCCL_VERSION_CODE, // maxVersion
nullptr, // commPropertiesFilter
nullptr, // devCommRequirementsFilter
nullptr, // devCommCopyNewToOld
nullptr, // devCommCopyOldToNew
};この構造体はバージョン 2.31.0 の互換性ルールを定義している。minVersionとmaxVersionが適用バージョン範囲を定義し、後ろの4つの関数ポインタが属性フィルタリングと構造変換ロジックを定義する。すべてが nullptr の場合、このバージョンには特別な互換性要件がないことを示す。
Step-by-Step Walkthrough:1回の Team 変換
あるシナリオを想定しよう:rank 5 が 8 rank の通信ドメインにあり、LSA チームサイズが 4 である。rank 5 の Rail チームにおける rank を計算する。
第一步:DevR 状態を初期化する。
📎 src/nccl_device/core.cc:70-79
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};ncclDevrInitOnceLSA チーム、CFT チームなどの派生情報を計算する。失敗した場合、空のチームを返す。
第二步:Rail チームのパラメータを計算する。
📎 src/nccl_device/core.cc:70-79
ncclTeam_t ans;
ans.nRanks = comm->nRanks / comm->devrState.lsaSize; // 8 / 4 = 2
ans.rank = comm->rank / comm->devrState.lsaSize; // 5 / 4 = 1
ans.stride = comm->devrState.lsaSize; // 4rank 5 の Rail チームにおける rank は 1 で、チームには 2 つの rank があり、stride は 4 である。
第三步:ワールド rank に変換し戻す。
📎 src/nccl_device/core.cc:82-84
int ncclTeamRankToWorld(ncclComm_t comm, ncclTeam_t team, int rank) {
return comm->rank + (rank - team.rank) * team.stride;
}Rail rank 0 をワールド rank に変換する場合:5 + (0 - 1) * 4 = 1。検証:rank 1 と rank 5 は同じ rail 上にある(間隔 4)。
並行制御とハードウェア相互作用
Team 抽象自体はステートレスであり、並行制御は不要である。しかしncclDevrInitOnceは遅延ロードであり、最初の呼び出し時にすべての派生情報を計算する。
📎 src/nccl_device/core.cc:22-33
ncclTeam_t ncclTeamLsa(ncclComm_t comm) {
if (ncclSuccess != ncclDevrInitOnce(comm)) return ncclTeam_t{};
ncclTeam_t ans;
ans.nRanks = comm->devrState.lsaSize;
ans.rank = comm->devrState.lsaSelf;
ans.stride = 1;
return ans;
}コメントには「Ignoring errors since if it fails ncclDevrInitOnce will try again」とある——初期化に失敗した場合、空のチームを返し、次回の呼び出しで再試行する。
本番環境の落とし穴ガイド
落とし穴1:Team 変換の stride 仮定。 ncclTeamRankToWorldはチーム内の rank が等差数列であると仮定している。
📎 src/nccl_device/core.cc:82-84
int ncclTeamRankToWorld(ncclComm_t comm, ncclTeam_t team, int rank) {
return comm->rank + (rank - team.rank) * team.stride;
}チームが等差数列でない場合(例えばカスタムの任意のグループ分け)、この関数は誤った計算をする。NCCL は現在、規則的なチームのみをサポートしている。
落とし穴2:バージョン化 DevComm のヌルポインタ。 ncclDevCommCompat_v23100のすべての関数ポインタは nullptr であり、特別な互換性ロジックがないことを示す。将来のバージョンで変換が必要な場合、これらの関数を実装しなければならず、そうでなければ新旧のコードが相互運用できない。
落とし穴3:CFT チームの階層モード。 ncclTeamCftは3つのモードをサポートする:FLAT、HIER_MULTIMEM、HIER_LSA。
📎 src/nccl_device/core.cc:36-55
if (mode == NCCL_CFT_TEAM_FLAT) return flatTeam;
int innerSize;
if (mode == NCCL_CFT_TEAM_HIER_MULTIMEM) {
innerSize = comm->devrState.cftMcSize;
} else if (mode == NCCL_CFT_TEAM_HIER_LSA) {
innerSize = comm->devrState.lsaSize;
} else {
return ncclTeam_t{};
}
return ncclTeamOuterFactor(flatTeam, innerSize);無効なモードを渡した場合、空のチームを返す。CFT チームを使用する際はモードが正しいことを確認する必要がある。
---
設計上の考察
なぜ NCCL は RMA、GIN、対称メモリの3つの進化パスを同時にサポートするのか?
これら3つのパスは異なるレベルの問題を解決している:
- RMA「通信パターンが固定」という問題を解決する——上位層がプリミティブを組み合わせて、任意の通信パターンを実現できるようにする。
- GIN「ネットワーク遅延が高い」という問題を解決する——GPU が直接ネットワークカードを駆動し、host proxy をバイパスする。
- 対称メモリ「アドレス解決のオーバーヘッド」という問題を解決する——kernel が統一アドレスで直接ピアメモリにアクセスできるようにする。
これらは代替関係ではなく、補完関係にある。RMA は GIN を下位伝送として使用でき、GIN は対称メモリに依存してアドレス一貫性を提供する。三者が共同で「プログラマブル通信エンジン」の基盤インフラを構成している。
バージョン化 DevComm の設計哲学とは何か?
バージョン化 DevComm の核心思想は「ABI 安定、API 進化」である。デバイスコード(kernel)はコンパイル後にバイナリに埋め込まれ、NCCL ライブラリのアップグレードに伴って再コンパイルできない。したがって NCCL は古いデバイスコードが新しいライブラリ上で動作することを保証しなければならない。ncclDevCommCompat構造体が互換レイヤーの入口である:新しいライブラリはデバイスコードのバージョンに基づいて適切な互換ルールを選択し、必要に応じて構造変換を行う。
---
本章のまとめ
本章では、ソースコード中の進化の痕跡から出発し、NCCL が集合通信ライブラリからプログラマブル通信エンジンへと向かう3つの力
1. RMA(src/rma/rma.cc):Put/Signal/WaitSignal プリミティブの組み合わせにより、上位層があらゆる通信パターンを実装できるようにする。中核設計は、LSA 到達可能性に基づいてタスクを CE と Proxy の二経路に分割し並列実行することである。
2. GIN(src/gin/gin_host.cc):GPU から直接ネットワークへ送信し、host proxy を迂回する。中核設計はマルチバックエンド管理、バージョン互換表、進捗スレッドプールである。
3. 対称メモリ kernel(src/sym_kernels.cc):統一アドレス空間により、アドレス解決のオーバーヘッドを排除する。中核設計は kernel mask ビットマップと TMA/GIN ハードウェアアクセラレーションである。
4. Team 抽象化とバージョン化 DevComm(src/nccl_device/core.cc、src/devcomm/devcomm_v23100.cc):進化のための基盤を提供する。Team はグループ視点を提供し、バージョン化 DevComm は ABI 互換性を提供する。
これらの変化が上位フレームワークに与える影響は深遠である:PyTorch の ProcessGroup は RMA プリミティブを直接呼び出してカスタム通信パターンを実装できる;Megatron のエキスパート並列は GIN を利用して all-to-all の遅延を低減できる;対称メモリは kernel コードをより簡潔にする。
本章の考察とセルフチェック
Q1:もしscheduleRmaTasksToPlanにおける WaitSignal 分岐の LSA 到達可能性判定を削除し、すべての peer が Proxy 経路を通ると、どのような結果になるか?どのようなシナリオで性能災害が引き起こされるか?
参考解説:
LSA 到達可能性判定は📎 src/rma/rma.cc:187-204にあり、peer を CE と Proxy の二組に分ける。もしこの判定を削除すると、すべての peer が Proxy 経路を通り、nRmaTasksCeは常に 0 となる。
結果は:CE 経路が全く使用されず、すべての WaitSignal が host proxy スレッドを介してネットワークをポーリングする。LSA 範囲内の peer(同一マシンの NVLink 相互接続)では、本来 GPU コピーエンジンで非同期に待機できるものが、host スレッドのポーリングになり、遅延がマイクロ秒級からミリ秒級に上昇する。
性能災害シナリオ:MoE 訓練では、各 token が複数のエキスパートの信号を待つ必要がある。もしすべての信号が Proxy を通ると、host スレッドがボトルネックとなり、GPU は多くの時間を host のポーリング待ちに費やす。8 基の GPU がすべて NVLink で接続されたマシンでは、この劣化は特に顕著である——本来すべての通信が CE を通れたものが、すべて host に集中する。
調査方法:scheduleRmaTasksToPlanの INFO ログを見て、もしnRmaTasksCeが常に 0 でnRmaTasksProxyが大きい場合、LSA 判定に問題があることを示す。
Q2:ncclGinProgressにおけるwritePendingフラグとdevCommRwMutex読み書きロックの連携で、もしwritePendingチェックを削除し、読み書きロックのみを残すと、どのような問題が生じるか?
参考解説:
writePendingチェックは📎 src/gin/gin_host.cc:63-66にあり、メインスレッドが書き込もうとするときに進捗スレッドが自発的に yield するようにする。もしこのチェックを削除すると、進捗スレッドは直接読みロックを取得しようとする。
問題は:std::shared_timed_mutexの読みロックは共有であり、複数の進捗スレッドが同時に保持できる。メインスレッドが書きロックを取得するには、すべての読みロックが解放されるのを待たなければならない。高負荷時には、進捗スレッドが頻繁に読みロックを取得し、メインスレッドが長時間書きロックを取得できず、ncclGinDevCommSetupやncclGinDevCommFreeがブロックされる可能性がある。
さらに深刻なのは:もしメインスレッドがginProgressWriteLockで先にwritePendingをセットしてからロックを取得し、進捗スレッドがwritePendingをチェックしない場合、進捗スレッドはメインスレッドのセット後も読みロックを取得し続け、メインスレッドの待ち時間が予測不能になる。
writePendingの役割は「ソフト通知」である:進捗スレッドに「これから書くので、少し譲ってくれ」と伝える。これは単にロックの公平性に依存するよりも効率的である。なぜなら進捗スレッドはロックでブロックするのではなく、自発的に yield できるからである。
Q3:ncclSymkMaskにおいて、もしnBusBytes >= 32 * (size_t(2) << 30)時にすべての kernel を無効化(kmask = 0)すると、このときncclSymkAvailableは false を返し、NCCL はどの経路にフォールバックするか?このフォールバック経路にはどのような性能影響があるか?
参考解説:
kmask = 0は📎 src/sym_kernels.cc:342にあり、このときncclSymkAvailableは false を返す(📎 src/sym_kernels.cc:354-361)。
フォールバック経路は:NCCL は従来の集合通信 kernel(非対称メモリ kernel)を使用する。これらの kernel は登録バッファ方式で対向メモリにアクセスし、まずアドレスを解決する必要があり、命令オーバーヘッドが大きい。
性能影響:超大メッセージ(64GB バスバイト超)では、従来 kernel のアドレス解決オーバーヘッドの割合は非常に小さい。データ転送自体が支配的だからである。しかし境界ケース(ちょうど 64GB を超える場合)では、従来 kernel は対称メモリ kernel より 10-20% 遅くなる可能性がある。
この制限の根本原因は:対称メモリ kernel は 32 ビット整数で unrolled loop chunk を追跡し、各 chunk は少なくとも 32 バイトであるため、最大アドレス可能範囲は 32 * 2^31 = 64GB となる。この範囲を超えると整数オーバーフローが発生する。
実際の運用では、単一の集合通信が 64GB を超えるシナリオは稀である(通常は勾配蓄積後の all-reduce)が、不可能ではない。このようなシナリオに遭遇した場合、分割通信や従来 kernel の使用を検討できる。
---
章末の橋渡し
本章では、NCCL が「固定集合操作」から「プログラマブル通信エンジン」へと進化していることを見た:RMA はプリミティブの組み合わせを提供し、GIN は GPU 直接送信を提供し、対称メモリは統一アドレス空間を提供し、Team とバージョン化 DevComm は基盤を提供する。
これらの進化は孤立したものではなく、共通の目標を指し示している:上位フレームワークがより低い遅延と高い柔軟性でカスタム通信パターンを実装できるようにする。PyTorchやMegatronのようなフレームワークにとって、これはNCCLの上に直接MoE all-to-all、パイプライン並列、エキスパート並列などの複雑な通信パターンを構築でき、NCCLを迂回して独自にネットワーク層を実装する必要がないことを意味する。
次の章は本書の最終章である。我々は一度のAllReduceの完全な経路をもう一度辿る——ncclAllReduce呼び出しから始まり、タスクのエンキュー、アルゴリズム選択、kernel起動、proxy推進、ネットワーク転送を経て、結果が返るまで。この振り返りでは、前の24章の知識点を繋ぎ合わせ、完全な認知マップを形成する。
ここまでで、我々はNCCLが固定集合操作からプログラマブル通信エンジンへと進化する三条の主線を明らかにした:RMAプリミティブの組み合わせ、GPU直送ネットワーク、対称メモリモデル、そしてそれらを支えるteam抽象とバージョン化されたDevCommである。これらのメカニズムは共により柔軟でハードウェア能力に近い通信の未来を指し示している。しかし、アーキテクチャがどのように進化しようとも、一度のAllReduceの完全な経路は常にNCCLを理解する基盤である。次の章では新しいコードを導入せず、第3章から第10章のエンドツーエンドフローを再度通しで解説する——ncclAllReduce呼び出しから、通信ドメインの確立、トポロジ探索、アルゴリズム選定、タスクエンキュー、kernel起動、デバイス側プリミティブ実行、結果の書き戻しまで。あなたは各章に散らばったメカニズムを再び完全なメンタルモデルに組み立て、そして「問題に遭遇したらどの章を調べるべきか」の索引を得るだろう。
第25章:第25章:全景振り返りと考察:あるAllReduceの究極の旅と設計の精髓
第25章:全景振り返りと考察:あるAllReduceの究極の旅と設計の精髓
前の章では、ソースコード中の進化の痕跡に基づき、NCCLが固定集合操作からプログラマブルへ、host proxyからGPU直送へ、登録バッファから対称メモリへと向かうアーキテクチャの趨勢を展望した。今こそ、これらの趨勢を具体的な実行フローに戻して検証する時である。この章では新しいコードを一切導入せず、第3章から第10章のエンドツーエンド経路を再び繋ぎ合わせる——ncclAllReduceの一行の呼び出しから始まり、結果がビデオメモリに書き戻されるまで。読み終えた後、あなたは明確に答えられるはずだ:一度のAllReduceは一体どの関数を経るのか?各関数はどのファイルのどの行にあるのか?問題に遭遇したらどの章を開くべきか?
一、初期化:通信ドメインはどのように「成長」するのか
直感モデル
通信ドメインを「グループチャット」と想像しよう。あなたがncclCommInitRankを呼ぶのは「グループチャットへの参加申請」であり、NCCLはこの時にグループメンバー名簿(peerInfo)、誰と誰がどの線で繋がるか(トポロジ図)、各線に何本のパイプラインを開くか(channel)を全て確定しなければならない。もしこのステップで間違えれば、以降の全ての通信が間違っている——グループチャットに誰かが入れられていないように、あなたが送ったメッセージは永遠に一人に届かない。
データ構造とメモリレイアウト
通信ドメインの核心構造はncclCommであり、その初期化は二段階に分かれる:commAllocが「骨組みの割り当て」を担当し、initTransportsRankが「血肉の充填」を担当する。
commAllocの中で最も注目すべきは共有リソース参照カウントの設計である。子通信ドメイン(split/shrinkで生成)が親通信ドメインのリソースを再利用する時、コピーするのではなく、同じncclSharedResourcesを共有し参照カウントをインクリメントする:
📎 src/init.cc:533-555
if (parent == NULL || !parent->shareResources) {
struct ncclSharedResources* sharedRes;
NEW_NOTHROW(sharedRes, ncclSharedResources);
sharedRes->owner = comm;
...
comm->sharedRes = sharedRes;
sharedRes->refCount = 1;
NCCLCHECK(ncclNetInit(comm));
NCCLCHECK(ncclRmaInit(comm));
NCCLCHECK(ncclGinInit(comm));
} else {
comm->sharedRes = parent->sharedRes;
ncclAtomicRefCountIncrement(&parent->sharedRes->refCount);
NCCLCHECK(ncclNetInitFromParent(comm, parent));
NCCLCHECK(ncclRmaInitFromParent(comm, parent));
}このコードの意図は明確である:ネットワークプラグイン、RMA、GINといった「重いリソース」は一度だけ初期化され、子通信ドメインは直接借用する。refCountはアトミック操作でインクリメントし、マルチスレッド下で重複解放が起きないことを保証する。
もう一つの重要な点はcommAlloc内のチャネルの初期化である。全てのチャネルはまず「未初期化」(id = -1)とマークされ、後続のsetupChannelが実際に内容を埋める:
📎 src/init.cc:607-608
// Mark channels as non initialized.
for (int c = 0; c < MAXCHANNELS; c++) comm->channels[c].id = -1;この-1はセンチネル値である。もしコードが誤って未初期化のチャネルを使った場合、id == -1が即座に問題を露呈し、ランダムなメモリを読むことはない。
Step-by-Step:ncclCommInitRankからinitTransportsRankまで
ユーザーがncclCommInitRankを呼んだ後、実際の実行フローはこうである:
1. ncclCommInitRankまずncclInitEnvを呼び環境プラグインをロードし、次にncclGroupStartInternalを呼びgroupセマンティクスに入る(これは「一度のgroupで複数の通信ドメインを初期化する」をサポートするためである)。
2. 続いてncclCommInitRankDevを呼び、これはパラメータ検証、comm構造の割り当て、configの解析を行い、そして実際の初期化作業を非同期jobに投げる:
📎 src/init.cc:2923-2929
if (ncclParamEnqueueRearchEnable()) {
NCCLCHECKGOTO(ncclMgmtTaskEnqueue((struct ncclAsyncJob*)job, ncclCommInitRankFunc, ncclCommInitJobFree, comm), res, fail);
} else {
NCCLCHECKGOTO(ncclAsyncLaunch((struct ncclAsyncJob*)job, ncclCommInitRankFunc, NULL, ncclCommInitJobFree, comm), res, fail);
}ここでのncclParamEnqueueRearchEnable()分岐に注意——これはNCCLが進行中の「enqueueリファクタリング」の痕跡である。デフォルトではncclAsyncLaunchを通り、リファクタリングを有効にするとncclMgmtTaskEnqueueを通る。両方のパスは最終的にncclCommInitRankFunc。
3. ncclCommInitRankFuncを呼ぶ。 は初期化のメイン関数である。まずデバイスを設定し、GPU属性を調べ、kernelを初期化する:
📎 src/init.cc:2119-2127
timers[TIMER_INIT_TOTAL] = clockNano();
CUDACHECKGOTO(cudaSetDevice(cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&maxSharedMem, cudaDevAttrMaxSharedMemoryPerBlockOptin, cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&archMajor, cudaDevAttrComputeCapabilityMajor, cudaDev), res, fail);
CUDACHECKGOTO(cudaDeviceGetAttribute(&archMinor, cudaDevAttrComputeCapabilityMinor, cudaDev), res, fail);
cudaArch = 100 * archMajor + 10 * archMinor;
timers[TIMER_INIT_KERNELS] = clockNano();
NCCLCHECKGOTO(ncclInitKernelsForDevice(cudaArch, maxSharedMem, &maxLocalSizeBytes), res, fail);cudaArch = 100 * archMajor + 10 * archMinorこの
4. 次に、通常の初期化か split/shrink/grow かに応じて、異なる bootstrap パスを通ります:
📎 src/init.cc:2136-2191
if (job->parent && !job->isGrow) {
// SPLIT/SHRINK: use bootstrapSplit
...
NCCLCHECKGOTO(bootstrapSplit(comm->commHash, comm, job->parent, job->color, job->key, parentRanks), res, fail);
} else {
// GROW or NORMAL INIT: use bootstrapInit
...
NCCLCHECKGOTO(bootstrapInit(job->nId, (struct ncclBootstrapHandle*)job->commId, comm, job->parent), res, fail);
}5. 最後に呼び出しますinitTransportsRank、これは初期化全体で最も重い関数です(約800行)。内部で2回の AllGather を行います:
- AllGather1:交換します
ncclPeerInfo(各 rank のデバイス情報、host hash、pid hash、GPU UUID など):
📎 src/init.cc:1236-1239
NCCLCHECKGOTO(ncclCalloc(&comm->peerInfo, nranks + 1), ret, fail); // Extra rank to represent CollNet root
NCCLCHECKGOTO(fillInfo(comm, comm->peerInfo + rank, comm->commHash), ret, fail);
NCCLCHECKGOTO(bootstrapAllGather(comm->bootstrap, comm->peerInfo, sizeof(struct ncclPeerInfo)), ret, fail);
COMPILER_ATOMIC_STORE(&comm->peerInfoValid, true, std::memory_order_release);注意nranks + 1この割り当て——余分な1つの位置は CollNet root 用です。peerInfoValidrelease セマンティクスで格納し、他のスレッドがこのフラグを見たときに peerInfo の内容が既に可視であることを保証します。
- AllGather3:トポロジ計算結果(各 rank が計算した ring/tree 構造、帯域幅、チャネル数など)を交換し、全 rank の最小値で整合させます:
📎 src/init.cc:1687-1703
for (int i = 0; i < nranks; i++) {
allTopoRanks[i] = &allGather3Data[i].topoRanks;
// Make sure we align all ranks so that the tuning is consistent across ranks
for (int a = 0; a < NCCL_NUM_ALGORITHMS; a++) {
graphs[a]->nChannels = std::min(allGather3Data[i].graphInfo[a].nChannels, graphs[a]->nChannels);
graphs[a]->sameChannels = std::min(allGather3Data[i].graphInfo[a].sameChannels, graphs[a]->sameChannels);
graphs[a]->bwIntra = std::min(allGather3Data[i].graphInfo[a].bwIntra, graphs[a]->bwIntra);
graphs[a]->bwInter = std::min(allGather3Data[i].graphInfo[a].bwInter, graphs[a]->bwInter);
graphs[a]->typeIntra = std::max(allGather3Data[i].graphInfo[a].typeIntra, graphs[a]->typeIntra);
graphs[a]->typeInter = std::max(allGather3Data[i].graphInfo[a].typeInter, graphs[a]->typeInter);
graphs[a]->crossNic = std::max(allGather3Data[i].graphInfo[a].crossNic, graphs[a]->crossNic);
}
...
}帯域幅は min、タイプは max を取ります。これは「木桶の原理」です:通信ドメイン全体の性能は最も遅い rank によって決まります。整合させないと、異なる rank が異なるアルゴリズム選択を計算し、通信デッドロックを引き起こす可能性があります。
初期化フローチャート
flowchart TD
api["ncclCommInitRank()"] --> env["ncclInitEnv()"]
env --> grp["ncclGroupStartInternal()"]
grp --> dev["ncclCommInitRankDev()"]
dev --> alloc["ncclCalloc(comm) + parseCommConfig()"]
alloc --> launch{"ncclParamEnqueueRearchEnable()?"}
launch -->|是| mgmt["ncclMgmtTaskEnqueue(ncclCommInitRankFunc)"]
launch -->|否| async["ncclAsyncLaunch(ncclCommInitRankFunc)"]
mgmt --> func["ncclCommInitRankFunc()"]
async --> func
func --> kernels["ncclInitKernelsForDevice(cudaArch)"]
kernels --> branch{"job->parent && !job->isGrow?"}
branch -->|是 split/shrink| split["bootstrapSplit()"]
branch -->|否 grow/normal| init["bootstrapInit()"]
split --> transports["initTransportsRank()"]
init --> transports
transports --> ag1["bootstrapAllGather(peerInfo)"]
ag1 --> topo["ncclTopoGetSystem() + ncclTopoComputePaths()"]
topo --> graphs["ncclTopoCompute(ringGraph/treeGraph/nvlsGraph)"]
graphs --> ag3["bootstrapAllGather(allGather3Data)"]
ag3 --> align["min/max 对齐所有 rank 的图参数"]
align --> connect["setupChannel() + ncclTransportRingConnect()"]
connect --> devcomm["devCommSetup()"]
devcomm --> done["initState = ncclSuccess"]設計上の考察と落とし穴
なぜ初期化は非同期でなければならないのか?マルチ rank の初期化にはプロセス間同期(bootstrap)が必要であり、同期的に実行すると呼び出しスレッドをブロックするためです。非同期化により、ユーザーはグループ内で複数の通信ドメインを同時に初期化し、並行して進めることができます。
落とし穴:initTransportsRankの末尾に intra-node barrier があります:
📎 src/init.cc:1968-1971
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);この barrier は、同一マシンの全 rank がリソース割り当てを完了してから続行することを保証します。ある rank がdevCommSetupでスタックしている場合(例えばメモリ不足)、他の rank はここで永遠に待ちます。本番環境で「初期化がハングする」に遭遇したら、まずある rank のdevCommSetupが失敗していないかを確認してください。
二、タスクのエンキュー:API 呼び出しから内部タスクオブジェクトまで
直感的モデル
ユーザーがncclAllReduceを呼び出すのは、レストランで料理を注文するようなものです。ncclEnqueueCheckはウェイターであり、あなたの注文をキッチンが理解できる「作業伝票」(ncclTaskColl)に翻訳し、comm->plannerという「注文プール」に入れます。この層がなければ、NCCL は複数の呼び出しを1回の kernel 起動に統合できません——毎回の注文で個別に火をつけるのは、極めて非効率です。
データ構造とメモリレイアウト
タスクエンキューの核心はncclKernelPlannerであり、これはcomm->plannerにぶら下がっています。主要なフィールドは以下の通りです:
collSorter:トラフィックサイズでソートされた集合通信タスクキューcollTaskQueue:最終的にソートされたタスクキューpeers[]:各 peer の send/recv キュー(P2P 用)wipPlan:構築中の kernel plan
タスクオブジェクトncclTaskCollの主要フィールドはcollTaskAppendで埋められます:
📎 src/enqueue/enqueue.cc:2800-2847
struct ncclTaskColl* t = ncclMemoryPoolAlloc<struct ncclTaskColl>(&comm->memPool_ncclTaskColl, &comm->memPermanent);
t->func = info->coll;
t->sendbuff = info->sendbuff;
t->recvbuff = info->recvbuff;
t->count = info->count;
t->root = info->root;
t->datatype = info->datatype;
size_t elementSize = ncclTypeSize(t->datatype);
if (t->func == ncclFuncAllGather || t->func == ncclFuncBroadcast) {
t->count *= elementSize;
t->datatype = ncclInt8;
elementSize = 1;
}
t->trafficBytes = t->count * elementSize * ncclFuncTrafficPerByte(t->func, comm->nRanks);
...
t->aggIsolate = ncclCollConfigNeedAggIsolate(&info->collConfig) || info->collConfig.CTAPolicy != comm->config.CTAPolicy;
NCCL_CONFIG_SET(t, minCTAs, ncclParamMinCTAs(), info->collConfig.minCTAs, comm->config.minCTAs, 1, MAXCHANNELS);
NCCL_CONFIG_SET(t, maxCTAs, ncclParamMaxCTAs(), (std::min(info->collConfig.maxCTAs, comm->config.maxCTAs)), comm->config.maxCTAs, 1, MAXCHANNELS);
...
planner->nTasksColl += 1;
ncclTaskCollSorterInsert(&planner->collSorter, t, t->trafficBytes);いくつかの詳細に注意:
1. AllGather/Broadcast の特別処理:count に要素サイズを掛け、datatype をncclInt8に変更します。これはこれらの操作のセマンティクスが「バイトを運ぶ」ことであり、元の型を気にする必要がないためです。
2. trafficBytesの計算:ncclFuncTrafficPerByteは各バイトが何回転送される必要があるかを返します。AllReduce は 2(reduce + broadcast)、AllGather は nRanks を返します:
📎 src/enqueue/enqueue.cc:123-134
static inline int ncclFuncTrafficPerByte(ncclFunc_t func, int nRanks) {
switch (func) {
case ncclFuncAllReduce:
return 2;
case ncclFuncAllGather:
return nRanks;
case ncclFuncReduceScatter:
return nRanks;
default:
return 1;
}
}3. NCCL_CONFIG_SETマクロ:これは「env > per-call > comm」の3段階設定解決です。環境変数が最優先、次に単一呼び出しの config、最後に通信ドメインレベルのデフォルト値です。
Step-by-Step:ncclAllReduce のエンキューパス
1. ncclEnqueueCheckまず通信ドメインの検証と group への進入を行います:
📎 src/enqueue/enqueue.cc:3478-3495
ncclResult_t ncclEnqueueCheck(struct ncclInfo* info) {
ncclResult_t ret = CommCheck(info->comm, info->opName, "comm");
if (ret != ncclSuccess) return ncclGroupErrCheck(ret);
if (info->comm->revokedFlag) {
WARN("%s: communicator was revoked", info->opName);
return ncclGroupErrCheck(ncclInvalidUsage);
}
...
NCCLCHECK(ncclGroupStartInternal());
ret = ncclSuccess;
int devOld = -1;
NCCLCHECKGOTO(ncclCommEnsureReady(info->comm), ret, fail);2. 次にtaskAppendを呼び出し、操作タイプに応じてディスパッチします:
📎 src/enqueue/enqueue.cc:3337-3348
static ncclResult_t taskAppend(struct ncclComm* comm, struct ncclInfo* info) {
ncclFunc_t collAPI = info->coll;
bool hasLaunchCompletionEvent = ncclInfoHasLaunchCompletionEvent(info);
if (ncclParamEnqueueRearchEnable()) {
NCCLCHECK(rawTaskAppend(comm, info));
} else if (info->coll == ncclFuncSend || info->coll == ncclFuncRecv) {
NCCLCHECK(p2pTaskAppend(comm, info, info->coll, collAPI, (void*)info->recvbuff, info->count, info->datatype, info->root, true));
} else if (info->coll == ncclFuncPutSignal || info->coll == ncclFuncSignal || info->coll == ncclFuncWaitSignal) {
NCCLCHECK(rmaTaskAppend(comm, info));
} else {
...
}
}AllReduce の場合、最後のelseブランチを通り、最終的にcollTaskAppend。
3. collTaskAppendを呼び出してタスクをcollSorterに挿入し、trafficBytesでソートします。ソートの目的は、スケジューラが大きなタスクを優先的に処理し、小さなタスクがチャネルリソースを断片化するのを避けるためです。
タスクエンキューのデータフロー
flowchart LR
api["ncclAllReduce()"] --> info["ncclInfo 填充"]
info --> enq["ncclEnqueueCheck()"]
enq --> check["CommCheck + ncclCommEnsureReady()"]
check --> append["taskAppend()"]
append --> coll["collTaskAppend()"]
coll --> task["ncclTaskColl 分配"]
task --> sorter["ncclTaskCollSorterInsert(collSorter)"]
sorter --> prepare["ncclPrepareTasks()"]
prepare --> algo["ncclGetAlgoInfo() 选算法"]
algo --> schedule["scheduleCollTasksToPlan()"]
schedule --> plan["ncclKernelPlan"]設計上の考察と落とし穴
なぜncclMemoryPoolAllocではなくmalloc?を使うのか? タスクオブジェクトはライフサイクルが短く、頻繁に割り当てられるためです。メモリプールは毎回のmalloc/freeのシステムコールオーバーヘッドを回避します。注意ncclMemoryPoolAllocの第2引数は&comm->memPermanentです——これはタスクオブジェクトが通信ドメインの破棄時に一括解放され、各タスクが個別に解放されないことを意味します。
落とし穴:ncclPrepareTasksに「集約」ロジックがあり、サイズが近い(4倍以内)タスクをマージします:
📎 src/enqueue/enqueue.cc:506-512
// We aggregate operations that are within 4X size of each other.
while (aggEnd != nullptr && aggEnd->trafficBytes < 4 * aggBeg->trafficBytes && !aggBeg->aggIsolate && !aggEnd->aggIsolate) {
agg.count += aggEnd->count;
agg.trafficBytes += aggEnd->trafficBytes;
aggEnd = aggEnd->next;
}この集約はアルゴリズム選択をより安定させるためです——各小さなタスクが個別にアルゴリズムを選ぶと、多数の異なるアルゴリズムが選ばれ、kernel が断片化する可能性があります。しかしaggIsolateフラグは集約を阻止し、「個別にスケジュールする必要がある」タスク(per-call config 付きなど)に使用されます。
三、アルゴリズム選型:コストモデルがどのように最適解を選ぶか
直感的モデル
アルゴリズム選型はナビゲーションソフトがルートを選ぶようなものです。NCCL の「コストモデル」(tuning モジュール)は、各アルゴリズム/プロトコル組み合わせが与えられたメッセージサイズとトポロジでかかる時間を見積もり、最速のものを選びます。コストモデルがなければ、NCCL は1つのアルゴリズムを固定で書くしかなく、小さなメッセージでは帯域幅を浪費し、大きなメッセージでは遅延を浪費します。
データ構造とメモリレイアウト
アルゴリズム選型の入口はncclGetAlgoInfo:
📎 src/enqueue/enqueue.cc:2159-2185
ncclResult_t ncclGetAlgoInfo(struct ncclComm* comm, struct ncclTaskColl* info, int collNetSupport, int nvlsSupport,
int numPipeOps, ncclSimInfo_t* simInfo) {
size_t elementSize = ncclTypeSize(info->datatype);
size_t nBytes = elementSize * ncclFuncMaxSendRecvCount(info->func, comm->nRanks, info->count);
info->algorithm = NCCL_ALGO_UNDEF;
info->protocol = NCCL_PROTO_UNDEF;
struct ncclTuningInput_t input;
input.comm = comm;
input.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;
uint64_t effAlgMask = comm->tuningContext.forced[info->func] ? 0 : info->algMask;
if (effAlgMask != 0) {
input.tuningMask = effAlgMask & NCCL_TUNING_MASK_GENERAL_KERNELS;
}
input.CTAPolicy = info->CTAPolicy;
input.func = info->func;
input.redOp = info->opHost;
input.devRedOp = info->opDev.op;
input.datatype = info->datatype;
input.nBytes = nBytes;
input.numPipeOps = numPipeOps;
input.collNetSupport = collNetSupport;
input.nvlsSupport = nvlsSupport;
input.count = info->count;
NCCLCHECK(ncclGetRegBuff(comm, info, &input.regBuff));
...
}注意effAlgMaskのロジック:環境変数がアルゴリズムを強制指定した場合(comm->tuningContext.forced[info->func]が非ゼロ)、ユーザーのalgMaskを無視し、環境変数のものを使います。これは「env > per-call」優先度の体現です。
次にncclTuningComputeを呼び出して最適な結果を得ます:
📎 src/enqueue/enqueue.cc:2213-2224
} else {
NCCLCHECK(ncclTuningCompute(&input, &bestTuning));
}
INFO(NCCL_TUNING, "Best tuning, algorithm, %s, protocol, %s", ncclAlgoToString(bestTuning.algo), ncclProtoToString(bestTuning.proto));
info->algorithm = bestTuning.algo;
info->protocol = bestTuning.proto;
info->nWarps = bestTuning.nWarps;
if (simInfo) simInfo->estimatedTime = bestTuning.timeUs;
TRACE(NCCL_COLL, "%ld Bytes -> Algo %d proto %d time %f", nBytes, info->algorithm, info->protocol, bestTuning.timeUs);
info->nMaxChannels = bestTuning.maxChannels == 0 ? info->nMaxChannels : bestTuning.maxChannels;Step-by-Step:1回のAllReduceにおけるアルゴリズム選択
8カード・シングルノード、メッセージサイズ1MB、AllReduceを仮定:
1. nBytes = 1MB,numPipeOpsは現在のplan内に既にあるタスク数。
2. collNetSupportとnvlsSupportはncclGetCollNetSupportとncclNvlsTransportEnabledによって決まる。
3. ncclTuningCompute利用可能なすべての (algo, proto) の組み合わせを走査し、コストモデルで時間を見積もる。
4. 1MB・シングルノードのシナリオでは、通常 NVLS または Tree+LL128 が勝つ。
5. 結果を書き戻すinfo->algorithm、info->protocol、info->nWarps。
アルゴリズム選択の決定図
flowchart TD
start["ncclGetAlgoInfo()"] --> nbytes["计算 nBytes = elementSize * count"]
nbytes --> forced{"comm->tuningContext.forced[func]?"}
forced -->|是| envMask["effAlgMask = 0, 用环境变量强制"]
forced -->|否| userMask{"info->algMask != 0?"}
userMask -->|是| useUser["tuningMask = algMask"]
userMask -->|否| full["tuningMask = GENERAL_KERNELS"]
envMask --> compute["ncclTuningCompute(input, bestTuning)"]
useUser --> compute
full --> compute
compute --> result{"bestTuning.algo == UNDEF?"}
result -->|是| fallback["重算全量菜单"]
fallback --> force{"forceAlgSelection?"}
force -->|是| err["返回 ncclInvalidArgument"]
force -->|否| auto["回退到自动选择"]
result -->|否| assign["info->algorithm = bestTuning.algo"]
auto --> assign
assign --> done["返回 ncclSuccess"]設計上の考察と落とし穴
なぜアルゴリズム選択は「rank間で揃える」必要があるのか?なぜなら、異なるrankが異なるアルゴリズムを選ぶと通信パターンが一致せず、デッドロックするからである。そこでinitTransportsRankでは min/max ですべてのグラフパラメータを揃え、各rankのコストモデル入力が一致することを保証する。
落とし穴:ncclGetAlgoInfoには「再計算」ロジックがある——ユーザーがalgMaskを指定したがどのアルゴリズムもマッチしない場合、まず黙って全量メニューを再計算し、その後ハードエラーかソフトフォールバックかを判定する:
📎 src/enqueue/enqueue.cc:2192-2208
NOWARN(ncclTuningCompute(&input, &bestTuning), NCCL_TUNING);
if (bestTuning.algo == NCCL_ALGO_UNDEF) {
input.tuningMask = NCCL_TUNING_MASK_GENERAL_KERNELS;
bestTuning = NCCL_TUNING_RESULT_INIT;
bestTuning.maxChannels = 0;
NCCLCHECK(ncclTuningCompute(&input, &bestTuning));
if (info->forceAlgSelection) {
WARN("algSelection: no algorithm in the selected set is available for %s", ncclFuncToString(info->func));
return ncclInvalidArgument;
}
INFO(NCCL_TUNING, "algSelection: selected set unavailable for %s; falling back to automatic selection", ncclFuncToString(info->func));
}NOWARNマクロで一時的に警告を抑制する。なぜなら「アルゴリズムがマッチしない」は正常な場合もあるからである(ユーザーが選んだ集合が確かに利用不可など)。forceAlgSelectionが真のときのみエラーを報告する。
四、タスクスケジューリングとkernel planの構築
直感モデル
タスクスケジューリングは、たくさんの注文をいくつかの生産ラインに割り当てるようなものだ。scheduleCollTasksToPlanは各タスクがいくつのチャネルを使い、各チャネルがどれだけのデータを処理するかを決定し、最終的にncclKernelPlanを生成する——これがGPUに渡す「作業指示書」である。
データ構造とメモリレイアウト
ncclKernelPlanの主要フィールド:
channelMask:このplanが使うチャネル(ビットマップ)workBytes:すべてのwork構造の総バイト数nWorkBatches:work batch数kernelArgs:kernel起動パラメータworkStorageType:workデータをどこに格納するか(args/fifo/persistent)
finishPlanは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;3つのストレージタイプのトレードオフ:
- Args:最速だが、kernelパラメータサイズに制限がある(通常4KB)
- Fifo:リングバッファ、中程度のサイズに適する
- Persistent:独立したデバイスメモリ割り当て、CUDA Graphシナリオに適する
Step-by-Step:scheduleCollTasksToPlanのチャネル割り当て
1. まずこのplanにいくつのタスクを収められるかを見積もる:
📎 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. 次にトラフィックに応じてチャネルをタスクに割り当てる。非CollNetタスクでは、「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);このコードはデータを「低/中/高」の3段に切る:countLo、countMid、countHi。低段と高段は境界チャネル、中段は中間チャネルである。このように分割するのは、各チャネルが処理するデータ量をできるだけ均等にするためである。
3. 最後にcalcCollChunkingを呼び、各チャネルのchunkサイズを計算する:
📎 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;
...
}スケジューリングフロー図
flowchart TD
prep["ncclPrepareTasks()"] --> sort["collSorter 按 trafficBytes 排序"]
sort --> agg["按 (fn,op,ty) 聚合任务"]
agg --> algo["ncclGetAlgoInfo() 选算法"]
algo --> bins["按 isCollnet/isNvls 分箱"]
bins --> sched["scheduleCollTasksToPlan()"]
sched --> budget{"ncclTestBudget()?"}
budget -->|否| full["plan_full: 停止添加"]
budget -->|是| kind{"task->isCollnet?"}
kind -->|是| collnet["calcCollChunking + 全通道分配"]
kind -->|否| cells["cell 切分: countLo/Mid/Hi"]
collnet --> batch["ncclAddWorkBatchToPlan()"]
cells --> batch
batch --> proxy["ncclAddProxyOpIfNeeded()"]
proxy --> finish["finishPlan()"]
finish --> storage{"workBytes 能放进 args?"}
storage -->|是| args["ncclDevWorkStorageTypeArgs"]
storage -->|否| fifo["ncclDevWorkStorageTypeFifo"]設計上の考察と落とし穴
なぜCollNetタスクは別扱いなのか?なぜならCollNetはネットワークスイッチでリダクションを行うため、チャネル割り当てロジックが通常のring/treeと全く異なるからである。CollNetタスクは利用可能なすべてのチャネルを直接占有するが、通常タスクはトラフィックに応じて分割する必要がある。
落とし穴:ncclTestBudgetの見積もりは粗い式nBatches = divUp(nPlanColls, 4)を使っている——4つの集合操作ごとに1つのbatchが生成されると仮定している。この見積もりは不正確かもしれないので、後で正確なチェックがある:
📎 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;
}正確なチェックが失敗した場合、直接リターンし(エラーを報告しない)、上位層に新しいplanを開かせる。
五、Kernel起動とデバイス側実行
直感モデル
Kernel起動は、作業指示書を工場に渡すようなものだ。ncclLaunchKernelはncclKernelPlanをCUDA kernel起動パラメータに変換し、その後cuLaunchKernelExを呼ぶ。デバイス側kernelは作業指示書を受け取ると、アルゴリズムに従ってデータ転送を実行する。
データ構造とメモリレイアウト
ncclLaunchKernelの主要ステップ:
📎 src/enqueue/enqueue.cc:1886-1909
ncclResult_t ncclLaunchKernel(struct ncclComm* comm, struct ncclKernelPlan* plan) {
ncclResult_t ret = ncclSuccess;
struct ncclKernelPlanner* planner = &comm->planner;
int nChannels = countOneBits(plan->channelMask);
void* sym = plan->kernelFn;
dim3 grid = {(unsigned)nChannels, 1, 1};
dim3 block = {(unsigned)plan->threadPerBlock, 1, 1};
int smem = plan->isSymColl ? plan->kernelDynSmem : ncclShmemDynamicSize(comm->cudaArch);
cudaStream_t launchStream = planner->streams->stream;
...
void* extra[] = {CU_LAUNCH_PARAM_BUFFER_POINTER, plan->kernelArgs, CU_LAUNCH_PARAM_BUFFER_SIZE, &plan->kernelArgsSize, CU_LAUNCH_PARAM_END};
...
CUfunction fn;
CUDACHECKGOTO(cudaGetFuncBySymbol(&fn, sym), ret, do_return);注意grid.x = nChannels——各チャネルに1つのblock。block.x = plan->threadPerBlock——各blockのスレッド数はタスクによって決まる。
Step-by-Step:planからkernel起動まで
1. まずuploadWorkを呼び、workデータを目標位置(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. 次にCUDA launch属性を構築する。sm90+ではcluster次元を設定する:
📎 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. 最後にcuLaunchKernelEx:
📎 src/enqueue/enqueue.cc:1992
CUCHECKGOTO(cuLaunchKernelEx(&launchConfig, fn, nullptr, extra), ret, do_return);デバイス側:runRingの実行
デバイス側kernelは作業指示書を受け取ると、アルゴリズムに応じて対応するRunWorkColl特化を呼ぶ。Ring AllReduceを例に:
📎 src/device/all_reduce.h:14-83
template <typename T, typename RedOp, typename Proto>
__device__ __forceinline__ void runRing(int tid, int nthreads, struct ncclDevWorkColl* work) {
ncclRing* ring = &ncclShmem.channel.ring;
int ringIx = ring->index;
const int nranks = ncclShmem.comm.nRanks;
ssize_t gridOffset;
ssize_t channelCount;
ssize_t chunkCount;
ncclCollCbdPart(work, ncclShmem.channelId, Proto::Id, sizeof(T), (ssize_t*)nullptr, &gridOffset, &channelCount, &chunkCount);
const ssize_t loopCount = nranks * chunkCount;
...
Primitives<T, RedOp, FanSymmetric<1>, 1, Proto, 0> prims(tid, nthreads, &ring->prev, &ring->next, work->sendbuff, work->recvbuff, work->redOpArg, 0, 0, 0, work);
for (ssize_t elemOffset = 0; elemOffset < channelCount; elemOffset += loopCount) {
ssize_t remCount = channelCount - elemOffset;
ssize_t chunkOffset;
if (remCount < loopCount) chunkCount = alignUp(divUp(remCount, nranks), 16 / sizeof(T));
auto modRanks = [&] __device__(int r) -> int { return r - (r >= nranks ? nranks : 0); };
// step 0: push data to next GPU
chunk = modRanks(ringIx + nranks - 1);
chunkOffset = chunk * chunkCount;
offset = gridOffset + elemOffset + chunkOffset;
nelem = (int)min(chunkCount, remCount - chunkOffset);
prims.directSend(offset, offset, nelem);
// k-2 steps: reduce and copy to next GPU
for (int j = 2; j < nranks; ++j) {
chunk = modRanks(ringIx + nranks - j);
chunkOffset = chunk * chunkCount;
offset = gridOffset + elemOffset + chunkOffset;
nelem = (int)min(chunkCount, remCount - chunkOffset);
prims.directRecvReduceDirectSend(offset, offset, nelem);
}
// step k-1: reduce this buffer and data, which will produce the final result
chunk = ringIx + 0;
chunkOffset = chunk * chunkCount;
offset = gridOffset + elemOffset + chunkOffset;
nelem = (int)min(chunkCount, remCount - chunkOffset);
prims.directRecvReduceCopyDirectSend(offset, offset, nelem, /*postOp=*/true);
// k-2 steps: copy to next GPU
for (int j = 1; j < nranks - 1; ++j) {
chunk = modRanks(ringIx + nranks - j);
chunkOffset = chunk * chunkCount;
offset = gridOffset + elemOffset + chunkOffset;
nelem = (int)min(chunkCount, remCount - chunkOffset);
prims.directRecvCopyDirectSend(offset, offset, nelem);
}
// Make final copy from buffer to dest.
chunk = modRanks(ringIx + 1);
chunkOffset = chunk * chunkCount;
offset = gridOffset + elemOffset + chunkOffset;
nelem = (int)min(chunkCount, remCount - chunkOffset);
prims.directRecv(offset, nelem);
}
}Ring AllReduceの古典的な2段階:
- Reduce-Scatter段階(最初のnranks-1ステップ):各rankは自分のデータを次に送り、同時に前のrankのデータを受信してリダクションする。
- AllGather段階(後半のnranks-1ステップ):リダクション済みの結果をリングに沿って伝播する。
modRanksこのlambdaはリングインデックスのラップアラウンドを処理する:r >= nranksのときnranksを減算する。
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() 释放资源設計上の考察と落とし穴
なぜcuLaunchKernelExではなくcudaLaunchKernel?を使うのか? なぜならlaunch属性(cluster次元、mem sync domain、launch completion event)を設定する必要があるからである。これらの属性はCUDA 12.0+でのみサポートされる。
落とし穴:uploadWorkここでは persistent モードの処理が非常に複雑です——GPU メモリの割り当て、データのコピー、イベントの記録を行い、さらに CUDA Graph キャプチャモードでも正しく動作する必要があります:
📎 src/enqueue/enqueue.cc:1445-1478
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);
NCCLCHECKGOTO(ncclStrongStreamAcquire(ncclCudaGraphNone(comm->config.graphUsageMode), &comm->sharedRes->deviceStream, /*concurrent=*/false, &deviceStream), result, fail);
if (comm->memPool) {
CUDACHECKGOTO(cudaMallocAsync(&fifoBufDev, workBytes, comm->memPool, deviceStream), result, fail);
} else {
CUDACHECKGOTO(cudaMalloc(&fifoBufDev, workBytes), result, fail);
}
plan->workBufPersistent = fifoBufDev;
plan->kernelArgs->workBuf = fifoBufDev;
CUDACHECKGOTO(cudaMemcpyAsync(fifoBufDev, fifoBufHost, workBytes, cudaMemcpyDefault, deviceStream), result, fail);
cudaEvent_t memcpyDone;
CUDACHECKGOTO(cudaEventCreateWithFlags(&memcpyDone, cudaEventDisableTiming), result, fail);
CUDACHECKGOTO(cudaEventRecord(memcpyDone, deviceStream), result, fail);cudaThreadExchangeStreamCaptureModeはキャプチャモード中に一時的に relaxed モードへ切り替え、GPU メモリの割り当てを許可するためです。コピー完了後にイベントを記録し、後続でncclCommPollEventCallbacksにより回収します。
六、本番環境の落とし穴ガイド
落とし穴 1:初期化でハングする
現象:ncclCommInitRankがスタックして返らない。
調査:NCCL_DEBUG=INFOのログを見て、最後に出力された rank を特定します。すべての rank が "Init START" を出力したが "Init COMPLETE" がない場合、initTransportsRankでスタックしていることを示します。
よくある原因:
- いずれかの rank の
devCommSetupが失敗(GPU メモリ不足、CUDA エラー) - bootstrap ネットワークが不通(ファイアウォール、ポート競合)
- rank 間で NCCL バージョンが不一致
ソースコード根拠:initTransportsRankの末尾にある intra-node barrier は、すべてのローカル rank を待機します:
📎 src/init.cc:1968-1971
/* Local intra-node barrier */
NCCLCHECKGOTO(bootstrapIntraNodeBarrier(comm->bootstrap, comm->localRankToRank, comm->localRank, comm->localRanks, comm->localRankToRank[0]), ret, fail);落とし穴 2:work FIFO のオーバーフロー
現象:kernel 起動後にハングする、またはncclInternalError。
原因:waitWorkFifoAvailableが FIFO 空間を待っているが、消費側(kernel)が進まない。
📎 src/enqueue/enqueue.cc:1333-1349
static ncclResult_t waitWorkFifoAvailable(struct ncclComm* comm, uint32_t desiredProduced) {
bool hasRoom = (desiredProduced - comm->workFifoConsumed) <= comm->workFifoBytes;
if (!hasRoom) {
while (true) {
// Check abort flag to break deadlock when abort is signaled
if (COMPILER_ATOMIC_LOAD(comm->abortFlag, std::memory_order_acquire)) {
return ncclInternalError;
}
NCCLCHECK(ncclCommPollEventCallbacks(comm, /*waitSome=*/true));
hasRoom = (desiredProduced - comm->workFifoConsumed) <= comm->workFifoBytes;
if (hasRoom) break;
std::this_thread::yield();
}
}
return ncclSuccess;
}abort flag のチェックに注意——これが唯一の脱出経路です。abort も設定されていない場合、無限ループになります。
回避策:NCCL_WORK_FIFO_BYTESを大きくする、または 1 回の group 内の操作数を減らす。
落とし穴 3:CUDA Graph キャプチャの失敗
現象:CUDA Graph キャプチャ中に NCCL を呼び出すと、"operation not permitted" が報告される。
原因:キャプチャモードでは一部の CUDA 操作(cudaMallocなど)を実行できません。NCCL はcudaThreadExchangeStreamCaptureModeで一時的にモードを切り替えますが、すべての操作を回避できるわけではありません。
ソースコード根拠:uploadWorkの persistent 分岐:
📎 src/enqueue/enqueue.cc:1445
CUDACHECKGOTO(cudaThreadExchangeStreamCaptureMode(&mode), result, fail);回避策:NCCL_GRAPH_MIXING_SUPPORT=1で graph 混合モードを有効にする、または work buffer を事前に割り当てる。
本章のまとめ
この章では、1 回の AllReduce の完全な経路をもう一度たどりました:
1. 初期化:ncclCommInitRank → ncclCommInitRankFunc → initTransportsRank、通信ドメインの確立、トポロジの探索、グラフパラメータの整合。
2. タスクのエンキュー:ncclEnqueueCheck → taskAppend → collTaskAppend、API 呼び出しをncclTaskColl。
3. アルゴリズム選定:ncclGetAlgoInfo → ncclTuningCompute、コストモデルで最適な (algo, proto) を選択。
4. タスクスケジューリング:ncclPrepareTasks → scheduleCollTasksToPlan → finishPlan、タスクをチャネルに割り当て、ncclKernelPlan。
5. Kernel 起動:ncclLaunchKernel → cuLaunchKernelEx、plan を CUDA 起動パラメータに変換。
6. デバイス側の実行:runRing / runTreeUpDown / runNvls、アルゴリズムに従ってデータ転送を実行。
本章の考察とセルフチェック
Q1: もしinitTransportsRank内の AllGather3 以降の min/max 整合ロジック(L1690-L1698)を削除した場合、どのようなシナリオで通信デッドロックが発生するか?その理由は?
参考解説:このロジックは、すべての rank が各アルゴリズムのnChannels、bwIntra、bwInterなどのパラメータで一致することを保証します。削除すると、各 rank は自身のローカルトポロジで計算した結果を使用します。異種クラスタを考えてみましょう:rank 0 は 8 カード NVLink マシン上、rank 8 は 4 カード PCIe マシン上にあります。rank 0 は ring に 8 本のチャネルがあると計算し、rank 8 は 4 本と計算します。それらが Ring AllReduce を実行すると、rank 0 は rank 8 が 8 本のチャネルでデータを送るのを待ちますが、rank
ここまでで、1 回の AllReduce の完全な経路の振り返りを完了しました。初期化、トポロジ探索、アルゴリズム選択、タスクエンキュー、kernel 起動から、デバイス側の実行とネットワーク転送まで、各段階は前の章の詳細な分析に対応しています。この経路図は NCCL を理解するための骨格であるだけでなく、問題を調査するための索引でもあります:初期化失敗は第 3、4 章、アルゴリズム選択ミスは第 5 章、タスクエンキューのエラーは第 6、7 章、kernel 起動失敗は第 8 章、デバイス側のハングは第 9、10 章、ネットワーク問題は第 12、13 章を参照してください。NCCL がプログラマブル通信、GPU ダイレクト発信、対称メモリへと進化するにつれて、この経路はさらに延伸していきます——そしてあなたはすでにそれを追跡する方法を身につけています。
どんなに複雑なプロジェクトも、実は一冊の良い本があれば読解できます
本書は AiReadCode が公式オープンソースリポジトリをスキャンして全自動で編纂したもので、実際の Commit 行番号が永久にアンカーされています。