Home NCCL 专家课程 09:Benchmark 方法学、同步边界与噪声证据
Post
Cancel

NCCL 专家课程 09:Benchmark 方法学、同步边界与噪声证据

“跑十次取平均”为什么不够

同一台 4×V100 机器、同一个 1 MiB AllReduce,本章实际测到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-I 0:       50.385 us
-I 1:       54.125 us

-z 0:       60.730 us
-z 1:       77.315 us
-z 2:       77.905 us
-z 3:       88.210 us

-n 1:       85.975 us, CV 8.62%
-n 100:     53.570 us, CV 0.19%

same-core CPU noise:
median        59.935 us
P95          584.271 us
CV           143.94%

这些数字都来自正确完成的 AllReduce,#wrong 全部为 0。差异不是“某一条结果肯定错了”,而是参数改变了计时边界、摊销方式和干扰条件。

因此一个 NCCL benchmark 结论必须同时回答:

  1. 计时从哪里开始,到哪里结束?
  2. host 只 enqueue,还是每轮等待 device completion?
  3. warmup 是否真的覆盖了首次连接和时钟爬升?
  4. 单个报表 time 平均了多少次操作?
  5. CUDA event instrumentation 是否扰动被测对象?
  6. 取 rank0、平均 rank,还是 critical rank?
  7. correctness 是否执行,是否在 timed region 内?
  8. cycle 间 CV 和单 cycle 内 P99 是否都稳定?
  9. CPU affinity、GPU clock、温度和后台负载是否受控?
  10. 参数顺序是否与机器热身过程混在一起?

本章从 nccl-tests 源码建立计时状态机,再用 46 次独立进程、920 行测量和 9,976 条 NVML telemetry 验证。

证据边界

固定版本:

1
2
3
4
5
6
7
nccl-tests:
2.19.5
5bcd45d2ee8365e37895e118c9d11b0ebc15aa69

NCCL:
2.22.3
178b6b759074597777ce13438efb0e0ba625e429

正式运行:

1
2
3
4
5
ch09_benchmark/20260710T150350Z
4 x Tesla V100-SXM2-32GB
GPU 均位于 NUMA0
CPU0 属于 NUMA0
CPU1 属于 NUMA1

实验附件:

原始 NVML 采样有 9,976 行,保留在本机用于审计;博客只发布按配置聚合后的 telemetry,避免低价值附件膨胀。

三层时间不能混用

一次 nccl-tests 进程包含三层时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
process lifetime
  |
  +-- communicator init / buffer allocation
  +-- size-level warmup
  +-- out-of-place BenchTime
  |     +-- untimed priming collective
  |     +-- measured loop
  |     +-- completion
  |     +-- separate correctness
  +-- in-place BenchTime
  |     +-- untimed priming collective
  |     +-- measured loop
  |     +-- completion
  |     +-- separate correctness
  +-- teardown

报表的 time 不是 process wall time,也不包括 communicator 初始化。它通常是 BenchTime measured loop 与最终 completion 的归一化时间。

训练端到端 step time 又是另一层:

1
2
forward + backward + collective wait/overlap
+ optimizer + input pipeline + framework scheduling

所以 nccl-tests 正常只能证明独立 collective 路径正常,不能自动证明训练没有通信瓶颈。

flowchart LR
  START["process start"] --> INIT["communicator init<br/>buffer allocation"]
  INIT --> WARMUP["size-level warmup"]
  WARMUP --> PRIME["untimed priming collective"]
  PRIME --> TIMER_START["start timing"]
  TIMER_START --> LOOP["measured loop<br/>iters × agg_iters"]
  LOOP --> COMPLETE["completion / synchronization"]
  COMPLETE --> TIMER_STOP["stop + normalize time"]
  TIMER_STOP --> CHECK["separate correctness"]
  CHECK --> TEARDOWN["next mode/size or teardown"]

只有中间被计时边界包住的 loop 与其完成语义能够进入报表时间;初始化、warmup、独立 correctness 和进程 teardown 不属于同一个统计量。修改 blocking、event 或 completion 位置,本质上是在改变这个窗口。

源码一:默认参数是什么

src/common.cu 原始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Command line parameter defaults
int nThreads = 1;
int nGpus = 1;
size_t minBytes = 32*1024*1024;
size_t maxBytes = 32*1024*1024;
size_t stepBytes = 1*1024*1024;
size_t stepFactor = 1;
int datacheck = 1;
int warmup_iters = 1;
int iters = 20;
int agg_iters = 1;
static int run_cycles = 1;
int blocking_coll = 0;
int per_iter_timing = 0;
static int report_cputime = 0;

// 0=RANK0, 1=AVG, 2=MIN, 3=MAX
static int average = 1;

这些默认值意味着裸跑一条命令时:

参数默认含义
-w1每个 warmup size 提交一次
-n20一个报表 time 平均 20 次 outer iteration
-m1每个 outer iteration 一次 collective
-N1整个 size sweep 只打印一个 cycle
-z0循环内异步 enqueue,末尾统一完成
-I0不插入 per-iteration CUDA event
-C0报告包含完成的 deltaSec
-a1多进程时报告 process 平均时间
-c1额外做一次 correctness

如果基线不显式记录这些参数,后续两个“相同命令”可能测的是不同语义。

源码二:显式 warmup

TimeTest 原始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Warm-up for all sizes (using a stepfactor of 2)
for (size_t size = args->minbytes;
     size <= args->maxbytes;
     size = size * 2) {
  setupArgs(size, type, args);
  for (int iter = 0; iter < warmup_iters; iter++) {
    TESTCHECK(startColl(args, type, op, root, 0, iter));
  }
  TESTCHECK(completeColl(args));
}

// Benchmark
long repeat = run_cycles;
do {
  for (size_t size = args->minbytes; size <= args->maxbytes; ...) {
    setupArgs(size, type, args);
    writeBenchmarkLinePreamble(...);
    TESTCHECK(BenchTime(args, type, op, root, 0));
    TESTCHECK(BenchTime(args, type, op, root, 1));
    writeBenchmarkLineTerminator(iters, "");
  }
} while (--repeat);

带注释解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// warmup sweep 固定按 *2 遍历,不使用 benchmark 的 -f 步长。
for (size = min; size <= max; size *= 2) {
  // -w 决定每个 warmup size 提交几次。
  repeat(warmup_iters) {
    startColl(...);
  }

  // z0 下此前只是 enqueue,这里统一 stream sync。
  completeColl(...);
}

// -N 决定整个正式 size sweep 重复并打印多少次。
repeat(run_cycles) {
  for (benchmark sizes) {
    BenchTime(out_of_place);
    BenchTime(in_place);
  }
}

这里有两个工程细节:

  1. -w 是每个 size 的 warmup iteration,不是整个程序只 warmup 一次;
  2. -N 的多个 cycle 共享同一个 communicator 和 buffer,不是多个独立进程。

所以 cycle CV 衡量同一进程内重复 sweep 的稳定性,不能覆盖进程启动、communicator 初始化和容器冷启动差异。

源码三:w0 也不是零预热

BenchTime 在 measured loop 前还有一条不计时的 collective:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
testResult_t BenchTime(...) {
  size_t count = args->nbytes / wordSize(type);
  if (datacheck) {
    TESTCHECK(args->collTest->initData(
        args, type, op, root, 99, in_place));
  }

  // Sync
  TESTCHECK(startColl(args, type, op, root, in_place, 0));
  TESTCHECK(completeColl(args));

  Barrier(args);

  // 此后才创建 timer 并进入 performance loop。
  ...
}

逐行含义:

1
2
3
4
5
6
7
8
9
-w 0:
  跳过 TimeTest 的 size-level warmup

但每个 BenchTime 仍执行:
  1 次 untimed startColl
  1 次 completeColl
  1 次 Barrier

而 out-of-place 与 in-place 各执行一个 BenchTime

因此本章的 warmup 问题是:

在已有 BenchTime priming collective 的前提下,额外的 -w 0/1/5/20 对稳定性还有多大边际作用?

它不是首次调用 latency 实验。要测真正的 communicator cold-start,必须自己写 probe,把 init、首个 collective 和后续 collective 分开打点。

源码四:z0 的计时窗口

默认 -z 0 下,performance loop:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int record = per_iter_timing;
if (record) TESTCHECK(initEvents(args, iters));

timer tim;
for (int iter = 0; iter < iters; iter++) {
  if (record)
    TESTCHECK(recordEvents(args, iters, iter));

  for (int aiter = 0; aiter < agg_iters; aiter++) {
    TESTCHECK(startColl(
        args, type, op, root, in_place,
        iter*agg_iters+aiter));
  }
}

if (record)
  TESTCHECK(recordEvents(args, iters, iters));

TESTCHECK(completeColl(args));

double deltaSec =
    tim.elapsed() / (iters*agg_iters);

startColl 的尾部:

1
2
3
4
5
6
if (blocking_coll) {
  // Complete op before returning
  TESTCHECK(testStreamSynchronize(
      args->nGpus, args->streams, args->comms));
}
if (blocking_coll == 1) Barrier(args);

completeColl

1
2
3
4
5
6
7
8
testResult_t completeColl(struct threadArgs* args) {
  if (blocking_coll && agg_iters <= 1)
    return testSuccess;

  TESTCHECK(testStreamSynchronize(
      args->nGpus, args->streams, args->comms));
  return testSuccess;
}

所以 z0 的真实结构:

1
2
3
4
5
6
7
8
timer start
  enqueue iteration 0
  enqueue iteration 1
  ...
  enqueue iteration n-1
  completeColl -> stream synchronize
timer read
divide by n

它测的是稳态批量提交加最终完成的平均值。前后 iteration 可以在 NCCL pipeline 中连续流动,固定 host/timer 开销也被 n 次摊销。

这通常适合吞吐基线,但不等于应用每次 collective 后立即等待的 latency。

源码五:四种 blocking 模式

CLI 帮助直接给出:

1
2
3
4
-z 0: default asynchronous batch
-z 1: barrier after each inner iteration
-z 2: no barrier
-z 3: wait and barrier after each outer iteration

结合 startCollBenchTime,更精确的状态机:

modestartColl 内 stream syncstartColl 内 Barrierouter loop 额外 syncouter loop Barrier报告时间
z0批量 enqueue + 末尾完成
z1每次完成 + barrier
z2每次完成,无 barrier
z3collOnlySec,barrier 前截止

z3 原始分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (int iter = 0; iter < iters; iter++) {
  double t0 = tim.elapsed();
  ...
  TESTCHECK(startColl(...));

  if (blocking_coll == 3) {
    TESTCHECK(testStreamSynchronize(...));
    if (pairedEvents)
      TESTCHECK(recordEvents(args, iters, iter, 1));
    collOnlySec += tim.elapsed() - t0;
    Barrier(args);
  }
}

double deltaSec =
    (blocking_coll == 3 ? collOnlySec : tim.elapsed())
    / (iters*agg_iters);

z3 的 collOnlySec 截止在 Barrier 前,所以报表总时间试图排除 barrier。但 startColl 已因 blocking_coll != 0 做过一次 stream sync,后面的第二次 sync 通常只确认没有剩余工作。

不同 z 模式不是单纯“同一个 kernel 的四种测速方式”,而是不同同步工作负载。比较它们时,应解释同步策略,不应把差异归因于 Ring/Tree 算法变化。

源码六:-C 可能把完成排除

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
double cputimeSec =
    blocking_coll == 3 ? collOnlySec : tim.elapsed();
cputimeSec /= iters*agg_iters;

TESTCHECK(completeColl(args));

double deltaSec =
    (blocking_coll == 3 ? collOnlySec : tim.elapsed())
    / (iters*agg_iters);

double timeUsec =
    (report_cputime ? cputimeSec : deltaSec) * 1.0E6;

z0 下:

1
2
3
4
5
6
7
cputimeSec:
  在 completeColl 之前读取
  更接近 host enqueue 平均时间

deltaSec:
  在 completeColl 之后读取
  包含 device/communication completion

如果使用 -C 1,可能得到非常小的“CPU time”和看似异常高的带宽。它回答的是 host submission cost,不是 collective 完成 latency。

本章固定 -C 0。要专门分析 enqueue cost,应同时报告 host enqueue、CUDA event 和同步完成三个时间,而不是只切换一列的名字。

源码七:per-iteration event 会扰动

-I 1 为每张 GPU 创建 event:

1
2
3
4
5
6
7
8
int eventCount =
    blocking_coll == 3 ? eventIters*2 : eventIters + 1;

for (int i = 0; i < args->nGpus; i++) {
  for (int j = 0; j < eventCount; j++) {
    cudaEventCreate(&args->events[i*eventCount + j]);
  }
}

普通模式共享相邻 iteration 的边界 event:

1
event0 -> iter0 -> event1 -> iter1 -> event2

z3 使用每轮独立的 start/stop pair:

1
2
start0 -> iter0 -> stop0 -> host barrier
start1 -> iter1 -> stop1 -> host barrier

每轮时间先取 GPU 间最大值:

1
2
3
4
5
6
7
8
for (int iter = 0; iter < iters; iter++) {
  double maxSec = 0;
  for (int i = 0; i < args->nGpus; i++) {
    double sec = args->ms[i * iters + iter] * 1e-3;
    if (sec > maxSec) maxSec = sec;
  }
  iterTimes[iter] = maxSec;
}

多进程时又对同一 iteration 取 process max:

1
2
3
4
5
6
for (int i = 0; i < nIters; i++) {
  double maxSec = allProcessTimes[i];
  for (int p = 1; p < nProcs; p++)
    maxSec = max(maxSec, allProcessTimes[p*nIters+i]);
  iterTimes[i] = maxSec;
}

这是合理的 critical-rank 口径,但 event 自身也会进入 stream:

  • 创建和销毁 event 增加 host 工作;
  • 每轮 cudaEventRecord 增加 stream command;
  • 小消息原本只有几十微秒,instrumentation 相对开销不可忽略;
  • z1 的相邻 event 区间会跨过 host barrier 引起的 stream idle。

所以 -I 1 不是“免费地多打印四列”。本章专门测量它相对 -I 0 的扰动。

源码八:correctness 在 timed region 外

计时和带宽计算完成后,代码才重新初始化数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double algBw, busBw;
args->collTest->getBw(
    count, wordSize(type), deltaSec,
    &algBw, &busBw, nranks);

Barrier(args);

int64_t wrongElts = 0;
for (int c = 0; c < datacheck; c++) {
  TESTCHECK(args->collTest->initData(...));
  TESTCHECK(startColl(...));
  TESTCHECK(completeColl(args));
  TESTCHECK(CheckData(
      args, type, op, root, in_place, &wrongElts));
  Allreduce(args, &wrongElts, /*sum*/4);
}

这说明 -c 1

  • 增加进程总运行时间;
  • 不进入当前报表的 deltaSec
  • 对 out-of-place 和 in-place 分别验证;
  • 将所有 thread/process 的错误数求和。

性能数字没有 #wrong=0 只是未验证的吞吐,不是合格基线。

average 参数与 critical rank

源码定义:

1
2
3
4
5
6
// 0=RANK0, 1=AVG, 2=MIN, 3=MAX
static int average = 1;

...

Allreduce(args, &deltaSec, average);

多进程 benchmark 中:

  • -a 0:rank0/process0 口径;
  • -a 1:process 平均值;
  • -a 2:最快 process;
  • -a 3:最慢 process,即 critical process。

训练 step 通常被最慢 rank 决定,所以回归基线更适合同时保存 AVG 和 MAX,或至少明确使用 MAX。

本章一个进程管理四张 GPU,process 聚合只有一行,因此 -a 3 不会改变主报表 time;per-iteration event 仍会在四张 GPU 上取 max。

cycle CV 与 iteration CV

本章区分两个统计量。

Cycle CV

1
2
3
4
5
6
同一配置:
  nccl-tests -N 10
  每个 cycle 打印一个平均 time

cycle CV:
  这十个平均 time 的变异系数

它回答“这条基线重复打印时是否稳定”。

Iteration CV

1
2
3
4
5
6
7
同一个 cycle:
  -n 20
  -I 1
  获取 20 个 CUDA event interval

iteration CV:
  单 cycle 内 20 次 critical-GPU interval 的变异

它回答“单轮内部是否有尾延迟”。

两者可能同时出现:

1
2
cycle median 很稳定
但每个 cycle 内都有相似比例的长尾

不能用一个低 cycle CV 推导“没有 iteration tail”,也不能用一个高 iteration CV 推导“cycle 基线一定漂移”。

为什么用 median、P95 和 CV

对 cycle 时间序列 (t_1,…,t_k):

\[CV = population_stddev(t) / mean(t) * 100%\]

本课程预先固定:

1
2
3
4
5
6
7
CV <= 5%:
  允许在同一实验边界内给出精确相对差异

CV > 5%:
  保存数据
  报告不稳定
  拒绝精确优化百分比

median 抵抗少数极端值;P95 描述尾部;CV 将波动相对均值归一化。三者不能互相替代。

只有 10 个样本时,P95 仍很粗糙。本章的 P95 用于发现数量级长尾,不将它当成生产 SLO 的精密估计。

实验假设

H1:额外 warmup 对大消息稳态 median 影响很小

因为 BenchTime 自带 untimed priming collective,预测 64 MiB 在 w0/1/5/20 间差异小;w0 的首 cycle 和时钟采样可能更差。

H2:iteration 太少会放大固定开销

预测 n1 比 n20/n100 更慢、更不稳定,小消息最明显。

H3:per-iteration event 不是零开销

预测 -I 1 对 1 MiB 的相对扰动高于 64 MiB。

H4:blocking 改变 benchmark 语义

预测 z1/z2/z3 比 z0 慢,差异在 1 MiB 更显著。

H5:纯 GPU P2P 大消息对 host NUMA 不敏感

预测 CPU0/NUMA0 与 CPU1/NUMA1 对 64 MiB 的差异较小;单个 CPU core 的身份仍是混杂变量。

H6:同核 CPU 争用主要制造长尾

预测 benchmark 和 burner 共享 CPU0 时 P95/CV 大幅增加;burner 位于远端 CPU1 时影响较小。

为什么首轮实验被拒绝

首轮试运行只为每个 warmup 值启动一个进程,顺序是:

1
w0 -> w20 -> w1 -> w5

得到 1 MiB median:

1
2
3
4
w0:  62.075 us
w20: 60.000 us
w1:  61.245 us
w5:  54.010 us

但 NVML 同时显示首个 w0 进程的 active SM clock 曾低至 135 MHz,后续多数配置达到 1530 MHz。参数值与执行位置一一对应,不能区分:

1
2
3
warmup effect
vs
GPU clock/process-order drift

因此首轮结果不进入正式结论。

这个失败本身是方法学证据:控制了参数,不代表控制了执行顺序。

正式实验的平衡顺序

warmup 使用 4×4 Latin-square:

1
2
3
4
replicate 1: w0,  w1,  w5,  w20
replicate 2: w1,  w5,  w20, w0
replicate 3: w5,  w20, w0,  w1
replicate 4: w20, w0,  w1,  w5

每个 warmup 值跨四个执行位置,得到每个 size 40 个 cycle。

iteration 使用镜像顺序:

1
2
replicate 1: n1, n5,  n20, n100
replicate 2: n100, n20, n5, n1

blocking:

1
2
replicate 1: z0, z1, z2, z3
replicate 2: z3, z2, z1, z0

instrumentation:

1
I0, I1, I1, I0

affinity:

1
2
CPU0/NUMA0, CPU1/NUMA1,
CPU1/NUMA1, CPU0/NUMA0

noise:

1
2
idle, same-core, remote-NUMA,
remote-NUMA, same-core, idle

这不能消除所有环境漂移,但不再让一个参数值永远占据同一个位置。

正式矩阵

固定参数:

变量
collectiveAllReduce SUM
world size4 GPU,一个进程
datatypefloat
sizes1 MiB、64 MiB
cycles每个进程 10
correctness1 次,out/in-place 都检查
process aggregation-a 3
report CPU time-C 0
CPU affinity除 affinity 组外固定 CPU0
NVML sampling每 100 ms,四张 GPU

变量矩阵:

matrixvariantsprocess runscycles per variant/size
warmup0、1、5、201640
iterations1、5、20、100820
blocking0、1、2、3820
instrumentationI0、I1420
affinityCPU0、CPU1420
noiseidle、same core、remote NUMA620

总计:

1
2
3
4
46 independent nccl-tests processes
920 out-of-place report rows
920 corresponding in-place checks
9,976 NVML telemetry rows

实验驱动关键代码

配置结构明确保存每个变量:

1
2
3
4
5
6
7
8
9
10
11
@dataclass(frozen=True)
class RunSpec:
    matrix: str
    variant: str
    replicate: int
    warmup: int = 5
    iterations: int = 20
    blocking: int = 0
    per_iter: int = 1
    cpu: int = 0
    noise_cpu: int | None = None

每个进程命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
command = [
    "taskset", "-c", str(spec.cpu),
    str(binary),
    "-b", "1M",
    "-e", "64M",
    "-f", "64",
    "-g", "4",
    "-w", str(spec.warmup),
    "-n", str(spec.iterations),
    "-N", "10",
    "-c", "1",
    "-I", str(spec.per_iter),
    "-z", str(spec.blocking),
    "-d", "float",
    "-o", "sum",
    "-a", "3",
    "-C", "0",
]

同核噪声:

1
2
3
4
subprocess.Popen([
    "taskset", "-c", str(noise_cpu),
    "sh", "-c", "while :; do :; done",
])

driver 在 finally 中 terminate/kill burner,避免失败后残留后台负载。

正确性与完整性结果

1
2
3
4
5
6
7
config process runs:         46
expected report rows:        920
actual report rows:          920
out-of-place #wrong:         all zero
in-place #wrong:             all zero
process failures:            zero
out-of-bounds values:        zero

所有性能结论都建立在逐行 correctness 通过之后。

Warmup 结果

64 MiB

warmupsamplesmedian usP95 usCVvs w5first-cycle penalty
040855.135869.8970.81%+0.16%+1.29%
140854.220869.1980.79%+0.05%+1.61%
540853.780868.6920.75%baseline+0.85%
2040853.955864.6680.47%+0.02%+0.46%

四组 CV 都低于 1%,median 最大差异只有 0.16%。H1 在本机 64 MiB、当前 BenchTime priming 语义下成立。

不能外推为“所有 benchmark 都不需要 warmup”。这里的 w0 仍有 untimed priming,且 communicator 已完成初始化。

1 MiB

warmupmedian usP95 usCVvs w5first-cycle penalty
056.15563.1726.75%+3.65%+15.96%
154.19562.1146.17%+0.03%+7.10%
554.18062.3276.46%baseline+0.26%
2054.06561.9695.32%-0.21%+5.60%

四组 CV 全部超过 5%。因此:

  • 可以说 w0 的 first-cycle 暖机现象更明显;
  • 可以说额外 warmup 没有消除全部小消息波动;
  • 不能发布“w20 比 w5 快 0.21%”这样的精确结论。

Iteration 数结果

1 MiB

iterationsmedian usP95 usCVvs n20
185.97596.0398.62%+59.11%
561.18065.2085.85%+13.22%
2054.03562.0205.55%baseline
10053.57053.7170.19%-0.86%

n1/n5/n20 的 CV 都超阈值,精确倍率不作为稳定优化结论;但“样本太少会显著放大固定开销和波动”的方向性证据很强。n100 才把本次 1 MiB cycle CV 压到 0.19%。

64 MiB

iterationsmedian usP95 usCVvs n20
1888.720901.0731.06%+4.07%
5863.110870.5320.78%+1.07%
20853.985866.1610.54%baseline
100852.740853.2710.05%-0.15%

所有 64 MiB 组都稳定。n1 仍比 n20 慢 4.07%,说明即使大消息也会受到单次 timer/event/submission 边界影响;n20 与 n100 已非常接近。

工程建议:

1
2
3
4
5
6
7
8
小消息:
  优先 n >= 100,再看稳定性

大消息:
  n >= 20 通常可作为起点

任何消息区间:
  最终由 cycle CV 决定,不背固定 n

Instrumentation 扰动

sizeI0 medianI1 medianI0 CVI1 CVI1 overhead
1 MiB50.385 us54.125 us0.55%0.50%+7.42%
64 MiB850.330 us853.855 us0.08%0.08%+0.41%

两组都稳定,H3 得到定量验证。

-I 1 的用途是获得 min/max/P99/CV,不是生成无扰动的主性能基线。推荐双轨:

1
2
3
4
5
6
7
8
baseline run:
  -I 0
  得到低扰动 median/bandwidth

diagnostic run:
  -I 1
  得到 iteration tail
  同时记录 instrumentation overhead

Blocking 结果

1 MiB

modemedian usP95 usCVvs z0结论
z060.73062.4615.22%baselineCV 超阈值
z177.31579.4901.58%+27.31%每轮 sync+barrier
z277.90579.0521.18%+28.28%每轮 sync
z388.210100.7785.87%+45.25%CV 超阈值

z1 和 z2 稳定且接近,说明本机单进程下 Barrier 的额外代价有限,主要差异来自“每轮完成”而非末尾批量完成。

z0/z3 CV 超 5%,对应精确百分比只作为观测,不发布为稳定倍率。

64 MiB

modemedian usP95 usCVvs z0
z0862.645867.8170.59%baseline
z1874.620879.1070.26%+1.39%
z2874.545878.2910.18%+1.38%
z3885.650898.8532.49%+2.67%

大消息中 payload 时间占主导,同步边界的相对代价降到 1.38%-2.67%。

H4 成立,但正确表述是:

blocking 模式测量不同的完成/同步工作负载。

不是:

blocking 让 NCCL kernel 本身慢了固定百分比。

CPU affinity 结果

sizeCPU0/NUMA0CPU1/NUMA1deltaCV0/CV1
1 MiB61.725 us54.355 us-11.94%1.10% / 4.15%
64 MiB864.620 us854.025 us-1.23%0.32% / 0.37%

64 MiB 的差异较小,符合纯 GPU P2P payload 不经过 CPU DRAM 的预期。

1 MiB 的 CPU1 反而更快。不能据此声称“远端 NUMA 更优”,因为实验同时改变了:

1
2
3
4
5
NUMA node
CPU core identity
该 core 的系统进程竞争
CPU frequency/scheduler history
NCCL helper thread placement

严格 NUMA 实验需要在每个 NUMA 选择多个等价物理 core,检查频率与 IRQ,再分层比较。本章的结论是“单核绑核能控制调度,但一对 core 不能单独证明 NUMA 因果”。

这是比强行解释 -11.94% 更重要的工程结论。

后台负载结果

sizevariantmedian usP95 usCVmedian vs idle
1 MiBidle54.21059.1314.49%baseline
1 MiBremote NUMA53.99055.0452.85%-0.41%
1 MiBsame core59.935584.271143.94%+10.56%
64 MiBidle853.685859.8950.36%baseline
64 MiBremote NUMA853.290854.1620.07%-0.05%
64 MiBsame core865.6101157.34311.34%+1.40%

远端 CPU1 burner 对 median 几乎没有影响,两组 CV 仍低。

同核争用的重点不是 median:

1
2
3
4
5
6
7
8
9
1 MiB:
  median +10.56%
  P95 接近 idle 的 9.9 倍
  CV 143.94%

64 MiB:
  median +1.40%
  P95 +34.6%
  CV 11.34%

因为 CV 超阈值,不能把 +10.56%+1.40% 当作稳定 slowdown。可以下的结论是:

同核 CPU 争用制造了数量级明确的长尾和不可用基线,而远端单核负载没有复现该现象。

这验证了 H6。

GPU telemetry

46 个配置的 active samples 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
P-state:
  全部最差仍为 P0

active median SM clock:
  绝大多数配置为 1530 MHz

observed active minimum:
  首个 w0 replicate 曾到 135 MHz
  若干启动阶段为 1290-1387 MHz

temperature:
  最大 46 C

power:
  最大约 136 W

没有温度接近 V100 thermal limit 的证据,也没有 P-state 降级;但启动阶段 clock ramp 确实存在,这正是必须平衡执行顺序的原因。

NVML 100 ms 采样无法捕获每个几十微秒 collective 的瞬时时钟,只能作为进程阶段的环境证据,不能代替 CUPTI 或硬件性能计数器。

如何设计一份可信基线

固定身份

1
2
3
4
GPU model / UUID
driver / CUDA / NCCL / nccl-tests commit
container image digest
topology / NUMA / link state

固定 workload

1
2
3
4
5
6
collective
world size
actual bytes
datatype / reduction / root
in-place or out-of-place
algorithm/protocol constraints if any

固定计时

1
2
3
4
5
6
7
warmup count
iterations / agg_iters
cycles
blocking mode
report_cputime
average mode
per-iteration instrumentation

固定系统状态

1
2
3
4
5
CPU affinity
NUMA policy
GPU clocks / P-state / temperature
background CPU/GPU/network load
persistence mode

固定统计决策

1
2
3
4
5
6
7
8
median
P95/P99
cycle CV
iteration CV
correctness threshold
regression threshold
minimum sample count
unstable-result rejection rule

推荐的双阶段命令

低扰动主基线:

1
2
3
4
5
6
7
8
9
10
11
taskset -c 0 ./build/all_reduce_perf \
  -b 1M -e 256M -f 2 \
  -g 4 \
  -w 5 \
  -n 100 \
  -N 10 \
  -c 1 \
  -I 0 \
  -z 0 \
  -C 0 \
  -a 3

尾延迟诊断:

1
2
3
4
5
6
7
8
9
10
11
taskset -c 0 ./build/all_reduce_perf \
  -b 1M -e 256M -f 2 \
  -g 4 \
  -w 5 \
  -n 100 \
  -N 10 \
  -c 1 \
  -I 1 \
  -z 0 \
  -C 0 \
  -a 3

两者要分开保存,不能把 -I 1 结果悄悄替换低扰动基线。

回归判定示例

假设历史 64 MiB median 为 850 us,新版本为 875 us:

\[regression = (875 / 850 - 1) * 100% = 2.94%\]

不能马上判定失败。先检查:

  1. 新旧两组 CV 是否都低于 5%;
  2. P95 是否也同方向变化;
  3. correctness 是否都通过;
  4. 实际 bytes、-z/-I/-n 是否相同;
  5. GPU clock/P-state 是否同一分布;
  6. topology、transport、algorithm/protocol 是否变化;
  7. 重复顺序是否平衡;
  8. 差异是否超过预先设定阈值和历史自然波动。

回归系统应保存原始 cycle,而不是只保存 median;否则无法重算置信区间或识别单个异常值。

常见错误

错误一:w0 等于真正冷启动

反证:BenchTime 自带 untimed priming collective。

错误二:n1 最接近单次 latency,所以最准确

反证:本机 1 MiB n1 CV 8.62%,且 median 比 n20 高 59.11%;它暴露固定边界,但不是稳定稳态基线。

错误三:I1 只增加报表列

反证:1 MiB 稳定实验中 I1 带来 7.42% 扰动。

错误四:z0/z1 只是同一结果的两种打印方式

反证:z1 在每轮 startColl 内 stream sync 和 Barrier,工作负载已经改变。

错误五:correctness 会污染报表 time

反证:CheckData 位于 deltaSecgetBw 之后;它增加进程 wall time,不进入该报表 time。

错误六:median 看起来合理就可以忽略 CV

反证:same-core 64 MiB median 只慢 1.40%,但 P95 达 1157 us、CV 11.34%,基线不可用。

错误七:一个 NUMA0 core 和一个 NUMA1 core 足以证明 NUMA

反证:core identity、频率、IRQ 和系统竞争同时变化。应跨多个 core 重复。

错误八:单机 nccl-tests 正常就能证明 DDP 正常

反证:训练还包含 bucket、stream overlap、autograd ready order、compute contention 和 watchdog。

验收题

题一

-w 0 是否把首个 collective 放进正式计时?

不是。BenchTime 仍先执行一条 untimed startColl + completeColl

题二

z0 的 time 是 enqueue time 吗?

默认 -C 0 不是。它包含所有 enqueue 和末尾 completeColl,再除以 iteration 数。-C 1 才更接近 host submission time。

题三

为什么 z1 的 per-iteration event 可能包含 barrier 影响?

普通 event 共享相邻 iteration 边界;下一 event 要等 host 从 Barrier 返回后才能提交,因此 event 区间可能包含 stream idle。

题四

一组 median 提升 3%,CV 为 12%,能否发布“优化 3%”?

不能。先增加样本、消除噪声或报告不稳定;当前只能说观察到 3% median 差异。

题五

为什么本章保留 I0 和 I1 两套 run?

I0 提供低扰动主基线;I1 提供 iteration tail,且其自身扰动必须量化。

题六

为什么多进程更关心 MAX process?

collective 和同步训练 step 由最慢 rank 决定,平均值会掩盖 straggler。

本章结论

  1. nccl-tests 报表 time 只覆盖 BenchTime 的特定计时窗口,不是进程 wall time或训练 step time。
  2. -w 0 仍有每个 BenchTime 的 untimed priming collective,不能用于真实首调用 latency。
  3. 正式实验使用 Latin-square 和镜像顺序,避免参数值与 GPU 热身位置一一对应。
  4. 46 次独立进程产生 920 行测量,out-of-place/in-place correctness 全部通过。
  5. 64 MiB 的 w0/1/5/20 median 最大差异只有 0.16%,CV 均低于 1%。
  6. 1 MiB warmup 四组 CV 都超过 5%,拒绝精确 warmup 百分比。
  7. 64 MiB n1 比 n20 慢 4.07%;n100 与 n20 只差 -0.15%。
  8. 1 MiB n1/n5/n20 不稳定,n100 将 CV 降到 0.19%。
  9. -I 1 对 1 MiB 和 64 MiB 分别产生 7.42% 和 0.41% 的稳定扰动。
  10. z1/z2 每轮完成使 1 MiB 稳定增加约 27%-28%;这反映同步语义,不等于 kernel 变慢。
  11. 64 MiB 的 z1/z2/z3 相对 z0 增加 1.38%-2.67%。
  12. 同核 CPU 争用把 1 MiB P95 推到 584.271 us、CV 推到 143.94%;精确 median slowdown 被拒绝。
  13. 远端 CPU1 burner 对 64 MiB median 影响 -0.05%、CV 0.07%,没有复现同核长尾。
  14. active telemetry 以 P0/1530 MHz 为主,温度最高 46 C;首轮启动仍观察到 clock ramp。
  15. 单个 NUMA0/NUMA1 core 对照不足以证明 NUMA 因果,必须跨多个 core 分层重复。

下一章将建立 T = alpha + n/beta 的分段延迟-带宽模型:从 1 byte 到大消息密集扫描,用残差和真实 algorithm/protocol 切换判断模型在哪些区间成立。

This post is licensed under CC BY 4.0 by the author.

NCCL 专家课程 08:从 nccl-tests 源码推导 algbw 与 busbw

NCCL 专家课程 10:延迟-带宽分段模型、残差与 Tuner 预测