Skip to content

07-12 下午:性能分析技术基础(Profiling)

最后更新于·约 8217 字

性能工具给出的是迹象,不是结论。一个热点1的高 cache miss、低 IPC2 或长时间等待,必须回到循环访问、线程分工和数据位置解释;优化后的同一组测量再决定假设是否成立。

性能测量

性能数据必须有问题定义。要比较两个版本,至少固定输入规模、数据类型、编译器和优化选项、线程数、CPU 绑核、NUMA 策略、是否包含初始化/I/O。首次运行可能受动态链接、页面分配、缓存预热影响,因此应多次测量,报告中位数/分布,而不是只挑一次最好结果。

运行时间的构成

单周期 CPU 里,每个指令都是固定的一周期,性能很好算:指令数 × 时钟周期。现代处理器不讲这套,但理解性能仍是在回答同一类问题。一个程序消耗的(处理器)时间大致可以拆成:

$$ \text{时间} \approx \text{指令数}\times \text{CPI3}\times \text{时钟周期长}

$$

其中 CPI(cycles per instruction)是平均每一条指令花几个时钟周期——理想流水线接近 1,但 cache miss、分支预测错误、依赖等待都会让它远大于 1。反过来看 IPC(instructions per cycle)= \(1/\text{CPI}\),表示每个时钟完成几条指令。

所以当你看到 perf statIPC = 0.65,意思是每个时钟平均只完成 0.65 条指令——处理器有大量周期在等待(多半是等内存或等依赖),而不是在算。等内存往往表现为 cache-misses 高、stalled cycles 高。读这些数字的顺序是:先看总时间,再拆出指令数与 CPI,再用 cache/分支事件定位等待具体等在哪。

平均值掩盖尾部延迟

并行作业结束要等最慢的 rank,服务请求也常由最慢的一次决定。除平均耗时外,保留中位数、最小/最大值或分位数;一次异常慢是系统噪声、负载不均还是同步阻塞,要从 profiler 的时间线 trace 里看各 rank/线程的等待出现在哪个阶段才能判断。

最基本的度量是 wall-clock time,即用户实际上等待了多久。C++ 可用 std::chrono::steady_clock,C 可用 clock_gettime。计时区间应只包住要比较的核心工作;把随机初始化、打印和文件读取混在一起,无法判断优化改到了哪里。

auto begin = std::chrono::steady_clock::now();
kernel(a, b, c, n);
auto end = std::chrono::steady_clock::now();
double seconds = std::chrono::duration<double>(end - begin).count();

对矩阵乘法,常把工作量写作约 \(2N^3\) FLOP4(一次乘加计为两次浮点操作),于是:

\[ \text{GFLOP/s}=\frac{2N^3}{\text{seconds}\times10^9} \]

这个数方便和机器能力比较,但前提是算法确实做了这些运算、结果没有被编译器优化掉,并且数值结果通过校验。

测性能前要分清两组单位。

  • 延迟sms、cycles,回答一次要多等多长时间;
  • 吞吐op/sFLOP/sB/s,回答单位时间能做多少。

延迟由依赖链和串行等待决定,吞吐受并行宽度和资源上限决定。这里有一句很贴切的玩笑——“永远不要低估一辆装满磁带、在高速公路上狂奔的面包车的带宽”(Andrew Tanenbaum)🚐💨。它想说的是,一次运送大量数据时,总吞吐可以非常可观,但逐次往返的延迟依然糟糕。现实中 AWS Snowball、阿里云闪电立方这类“卡车搬迁数据”的服务,正是把这个思路用到了极致。

so,高带宽不等于低延迟。小消息同步受延迟支配,大块搬运受带宽限制。

思考题

为什么计时边界不清会导致“优化后更快”的假象?举例说明哪些时间可能混进来?

答案

如果计时包含初始化、文件读取、内存分配、进程启动或 CUDA 初始化,而优化只影响计算内核,测到的变化可能来自无关部分。反过来,若计时结束前没有等待异步设备或子进程,也会漏掉真实耗时。应把准备阶段、计算阶段和收尾阶段分开,明确每个测量对象。

性能上限与 Roofline

CPU 的计算峰值由核心数、频率、每周期可发射的向量 FMA 数量和向量宽度决定;内存系统则有单个 socket、全部 socket 和不同访问模式下的带宽上限。用 microbenchmark 测这些上限,比直接引用产品宣传页更可信。

程序实际性能受两条上界限制:计算峰值和内存带宽。定义运算强度

$$ I=\frac{\text{FLOPs5}}{\text{DRAM bytes transferred}}

$$

Roofline6 近似给出:

\[ P\le\min(P_{\mathrm{peak}}, B_{\mathrm{memory}}\times I) \]

若点落在带宽斜线附近,继续增加算术单元或手写 FMA 的收益有限;应减少 DRAM 流量、提高缓存复用。若点接近水平计算屋顶,才说明算术吞吐和指令调度值得重点看。

Roofline 还提醒我们区分第三种状态:程序既没有饱和带宽,也离计算屋顶很远,可能是并发度不足、依赖链太长或延迟没有被隐藏。Roofline 的作用是给下一步实验提出可检验的假设,而不是给程序贴上内存型/计算型的静态标签。

Roofline 的三种典型位置:带宽受限、计算受限,以及并发度不足导致的延迟受限。

图:斜线是带宽上限,水平线是计算峰值;落在两条屋顶下方很远的点还受并发度、依赖或控制流限制。

几个实际 kernel 可以放回同一把尺子上:向量加、点积和 stencil 靠近带宽斜线,优化后的 GEMM7 靠近计算屋顶。图中每个点都是测量值,Roofline 的用途正是让这个版本快了变成可检验的判断:它是少搬了数据、提升了运算强度,还是只是离机器上限还很远。

课件将 daxpy、dot、stencil 和 GEMM 的实测点放到同一张 Roofline 图上。

图:同一台机器上,不同算子的性能上限由不同资源决定。

以逐元素 c[i] = a[i] + b[i] 为例,float 数据每个元素至少读入 8 字节、写出 4 字节,只做一次加法,运算强度约为 \(1/12\) FLOP/byte。即使 CPU 有很高的 FMA 峰值,这个循环也会很快碰到内存带宽屋顶。矩阵乘法则不同,一个读入 cache 的 A 或 B 元素会参与许多次乘加,随着 tile 增大,DRAM 上看到的字节数增长得远慢于 FLOP,点才可能向右移动。

这也解释了同一段代码换更强 CPU 为何不一定快。如果新 CPU 的计算峰值翻倍、内存带宽几乎不变,带宽受限的点仍停在原来的斜线上;若优化把连续数组改成多次扫描,FLOP 数不变,字节数却增加,点会向左走。Roofline 不是在给程序贴标签,它要求每一次优化说清楚减少了什么搬运,或增加了哪一块数据的复用。

思考题

一个 kernel 每次执行 1000 次浮点运算,读写字节共 400 B。若机器峰值是 50 GFLOPS、带宽是 25 GB/s,理论上限大约是多少?

答案

算术强度是 1000 / 400 = 2.5 FLOP/B。机器的平衡点是 50 / 25 = 2 FLOP/B,该 kernel 强度低于平衡点,属于带宽受限。理论上限约为 25 GFLOPS × 2.5 = 62.5 GFLOPS,但不会超过硬件峰值,因此以 50 GFLOPS 为上限。实际值还会因访存效率、并行度和其他开销更低。

CPU 利用率与热点

性能分析(profiling)8把时间、指令和硬件事件对应到具体代码。CPU 利用率低可能是 I/O、网络、锁、页面缺失或负载不均;CPU 利用率高也可能只是忙于做无用工作。需要同时看热点、调用栈和硬件计数器。

Linux 上可先用:

perf stat ./matmul 4096
perf record -g ./matmul 4096
perf report

perf stat 可看到 cycles、instructions、IPC、cache misses 等总体计数;perf record 采样调用栈,定位时间主要在哪个函数/哪行循环。Intel 平台的 VTune、LIKWID/perfctr、AMD 的工具也能提供更细粒度的事件和时间线。工具的读数是证据,不是自动结论:高 cache miss 要结合数据规模和时间占比解释。

perf stat 中的 IPC 是 instructions / cycles。IPC 很低时,处理器可能在等内存,也可能在等长依赖、频繁分支或锁;IPC 很高也不代表程序快,简单的带宽扫描同样可以很高效地发射指令却受 DRAM 限制。cache miss 的绝对次数也要除以访问次数和运行时间看。一个处理几十 GB 数据的程序有很多 LLC miss 很正常;小工作集却频繁 miss,才更值得回到访问模式排查。

朴素 GEMM 的 perf stat 给出一个具体的数字:IPC 约 0.65,而那颗核每周期能发射 6 条指令,0.65/6 远不到一半——执行单元多数周期在等待,等的是内存把 B 送过来。解释 IPC 时要结合核的发射宽度,否则 0.65 本身说明不了什么;看到核在等待,再进一步查它在等什么。

现代核是乱序、超标量的结构:指令从前端取指、解码后进入重排序缓冲和调度器,硬件打破程序顺序、把能并行执行的指令分派给不同的执行端口,再从内存子系统取回数据。指令顺序和实际执行顺序不一致,正是 IPC 能超过 1、又可能在某个环节空等的原因。

课件给出 Intel 现代乱序核的微架构框图:前端取指解码、ROB/重命名、调度器与多种执行端口,以及配套的内存子系统。

图:核硬件按端口宽度并行执行指令;IPC 低于发射宽度,说明调度器与执行端口之间有空闲周期。

perf record -g 的火焰图或调用树回答的是时间落在哪条调用路径。它不等于那一行指令一定最慢:一个函数自身占时高,可能是在算,也可能是它里面的 load stall、锁或系统调用没有在符号层细分。先用采样找到区域,再针对该区域取硬件计数或读汇编,范围会小很多。

工具按问题分工。

命令 回答的问题 典型输出
perf stat 整段运行时硬件做了多少工作 cycles、instructions、cache 事件、运行时间
perf record -g 时间主要落在哪条调用路径 带调用栈的采样数据
perf report / annotate 热点对应哪个函数或哪段指令 符号、源码或汇编占比
perf top 正在运行的系统此刻忙在哪里 实时热点列表

先用轻量的 perf 缩小范围,再在需要缓存、NUMA、流水线 slot 等细节时进入 VTune 一类工具,读数通常更容易解释。

一次 perf stat 输出同时给出 cycles、instructions、cache 事件和 IPC;低 IPC 说明处理器大量时间在等待,但仍需结合热点判断等待什么。

图:计数器先描述整段运行。只有把它与采样到的热点和源码访问模式对应,才能区分内存、分支、依赖或锁等待。

课件给出 perf 工具族的分工:perf stat 问硬件在做什么、perf record/report 找时间花在哪、perf top 看实时热点。

图:先 stat 看整体计数器,再 record+report 采样定位热点源码,最后 top 看实时分布——这是基本流程。

思考题

profiler 显示某函数占 80% CPU 时间。把它优化 2 倍,程序最多能加速多少?为什么“热点百分比”不等于同样的整体收益?

答案

设原程序时间为 1,该函数占 0.8。优化 2 倍后这部分降到 0.4,其余仍为 0.2,总时间 0.6,整体加速约 1 / 0.6 ≈ 1.67。百分比说明时间在哪里,整体收益还要看这段能优化多少,以及优化后是否暴露新的瓶颈。

朴素矩阵乘法

对行主序 C/C++ 数组,最直观的三重循环可能是:

for (int i = 0; i < n; ++i)
  for (int j = 0; j < n; ++j)
    for (int k = 0; k < n; ++k)
      c[i][j] += a[i][k] * b[k][j];

a[i][k] 沿行连续,但 b[k][j] 固定 j、变动 k,是在按列跳跃访问 B。B 很大时,每一步可能碰到新的 cache line;数据从 DRAM 反复搬运,算术单元在等内存。

先换循环次序,常可让内层连续扫描 B 的行:

for (int i = 0; i < n; ++i)
  for (int k = 0; k < n; ++k) {
    double aik = a[i][k];
    for (int j = 0; j < n; ++j)
      c[i][j] += aik * b[k][j];
  }

这一步未改变数学算法,却改变了访存模式。它往往比一开始写 SIMD 指令带来更大的收益。

三重循环总共只有六种次序,按最内层到底在流式读什么可以分成三类,实测性能相差一个数量级。

内层循环 内层实际流式读 次序 GFLOP/s
k B 的列(跳跃) ijk / jik 0.54 / 0.50
i A 的列 + C 的列 jki / kji 0.26 / 0.26
j 都不跳跃(B、C 按行) ikj / kij 4.00 / 3.98

同一个算法、同样的 -O2,哪种循环次序决定了能不能让内层访问连续。三层循环的排列一共有 \(3!=6\) 种,真正值得用的是让内层连续扫 B 的 ikj/kij

这背后是 64 字节 cache line 的物理含义。一次内存读取以 64 B(8 个 double)为单位搬入 cache:ikj 让内层沿 B 的行连续走,取到一条 line 就能服务 8 次迭代的乘加;反过来若内层扫 B 的列,每次迭代只用到 line 里 8 字节、其余 56 字节被丢弃。行 walk 与列 walk 的差别,就是一条 line 服务 8 次和每条 line 只服务一次的区别。 课件画出 Mem moves in 64-byte lines:行 walk 取一条 line 服务 8 次迭代,列 walk(跨 8KB 步长)每取一条 line 只用其中一个 double。

图:一次内存访问搬入 64B(8 个 double);行方向能被整条 line 覆盖,列方向则每条 line 大多浪费。

改动前后可以用 perf stat 直接对照。naive(ijk)ikj(都是 -O2)指令数几乎相同(约 75 亿条),cycles 却从约 117 亿降到约 14 亿,IPC 从 0.65 升到 5.37,cache-references 也从约 9.7 亿降到约 1.4 亿。同样的指令、少得多的等待,就是访问模式改变带来的。

思考题

矩阵乘法的三层循环顺序没有改变浮点乘加次数,为什么速度可能差很多?

答案

不同顺序对 A、B、C 的访问步长不同。连续访问更容易命中 cache,跳跃访问会放大内存事务。矩阵乘的算术强度较高,但只有数据复用落在寄存器和 cache 中时,计算单元才可能持续有操作数。

Cache blocking

即使循环顺序改善,整个矩阵仍可能远大于 cache。分块(blocking)把矩阵切成 BS×BS 的 tile:一次只处理几个能放进 cache 的小块,在块内反复使用 A、B、C 数据。

for (int ii = 0; ii < n; ii += BS)
  for (int kk = 0; kk < n; kk += BS)
    for (int jj = 0; jj < n; jj += BS)
      for (int i = ii; i < std::min(ii + BS, n); ++i)
        for (int k = kk; k < std::min(kk + BS, n); ++k) {
          double aik = a[i][k];
          for (int j = jj; j < std::min(jj + BS, n); ++j)
            c[i][j] += aik * b[k][j];
        }

块大小不是凭空定一个常数。它要考虑 L1/L2/L3 容量、元素大小、线程数和关联度;不同层甚至需要不同块大小。先用少量候选尺寸测量,再决定是否值得进一步微调。成熟 GEMM 库会做多级 blocking、packing 和寄存器分块,因此生产代码通常应直接使用 BLAS。

课件展示 blocking 的思想:把 A、B、C 切成 BS×BS 的 tile,让三个子块在块内计算期间一起驻留 cache,而非让整个矩阵流式通过。

图:分块让三个子块(A、B、C 的 tile)在一轮 k 循环内驻留 cache,最大限度复用已搬入的数据。

分块尺寸可以按各层 cache 的容量推,约束是三块数据(A、B、C 的子块)同时驻留。L1d(约 48 KB)决定 micro-panel 的大小,让 nr×BS 的 B 小块留在 L1;L2(约 2 MB)要求 3·BS²·8 ≤ L2,一个 C 大块、一块 A、一块 B 共约 3 个 BS×BS 的双精度 tile;L3(约 45 MB/socket)容纳 packed B 的面板。按这个推算,BS≈288 量级。

blocking 换来的主要是更少的 DRAM 流量,而不是更少的指令。不阻塞时,B 的每个元素会被提取 N 次;分块后,B 的一个 line 在离开 cache 前会被整块工作消费完,B 的搬运量下降约 N/BS 倍。真正的代价看 B 矩阵能否塞进 L3,可以先用算术估算:B 是 \(N\times N\)、双精度,占 \(8N^2\) 字节;若 \(8N^2\) 小于 L3 容量,blocking 收益有限,若超过则明显。

课件对比有无 blocking 时 cache 中的驻留:不阻塞时 B 的行在被再次使用前已被逐出,阻塞后 B 的子块能驻留到整块算完。

图:区别在于数据在再次被使用前是否已被逐出 cache;分块让工作子块驻留到被完整消费。

N B 矩阵(\(8N^2\)) 是否进 L3 ikj blocked
2048 33 MB 能装下 3.98 3.71
3072 75 MB 溢出 2.65 3.72
4096 134 MB 严重溢出 2.28 3.57

课件用 8N² 与 45 MB L3 判定 blocking 何时有用:B 装得进 L3 时 ikj 与 blocked 差距不大,溢出后 ikj 陡降、blocked 维持。

图:一张表说明分块是否值得可由 \(8N^2\) 与 L3 容量直接预判,不必等跑完再猜。

B 未溢出 L3 时,blocking 反而略慢(2048 时为 3.71 对 3.98);B 一超过 L3,ikj 因反复搬运 B 而降到 2.65、2.28,blocked 靠块内复用维持在 3.7 左右。分块是否值得取决于工作集能否放进对应层级,\(8N^2\) 这个数在运行前就能判断。

packing 是把原矩阵的一块复制到按微内核访问顺序连续的缓冲区。它增加了一次复制,却能让后续内层循环连续 load,避免跨大步长和复杂边界判断。矩阵足够大、同一 packed 块被反复使用时,复制成本会被摊薄;很小的矩阵或只用一次的块,则可能得不偿失。许多高性能库把这层细节藏起来,理解它能帮助阅读 profiler:看到额外的 copy 函数,不应立刻把它当作无用开销。

一条完整的优化轨迹也能说明先后次序:仅仅改循环顺序已得到约 7.6x,cache blocking9 再得到约 1.6x;自动向量化10、手写微内核和多核并行是在此基础上继续提高。访问布局要先于指令级微调,因为布局决定数据是否能被 cache 和带宽系统有效供给。

为什么先改布局是有因果关系的顺序

一条 AVX-512 指令能在一个周期做很多 FMA,但再宽的计算单元也要先把数据从 DRAM 一路搬到寄存器。若程序本身在每个内层迭代都跨大步长访问,访存路径先被浪费掉,给再多的算术 lane 也没有用。先改循环次序/分块,是让已搬上来的数据被尽量复用;只有这一步之后,自动向量化和手写微内核才有足够多的局部数据可算。顺序由瓶颈决定,不能把它当成固定教条。

思考题

为什么优化矩阵乘法时常先做数据布局或分块,再看向量化指令?顺序颠倒会有什么问题?

答案

向量化只能提高每条指令处理的数据量,若数据仍频繁从低层存储重复搬运,计算单元还是等数据。分块和布局先提高局部性和复用,让寄存器与 cache 能持续供给操作数,之后向量和 FMA 才有机会发挥作用。若先手工写复杂指令,代码可读性下降,后续改布局也更难。

自动向量化与查看生成代码

当最内层循环连续、迭代独立、指针别名清楚时,编译器可把多个 j 合并成 SIMD 操作。对 Clang:

clang++ -O3 -march=native -Rpass=loop-vectorize matmul.cpp

关闭优化或向量化,再比较生成汇编和运行时间,可以看到优化是否真的发生。不要只因报告写 vectorized 就宣布成功:如果总程序仍被 DRAM 限制,向量化后的速度提升会受带宽约束。

四个构建组合(N=1024)能分开布局与编译器各自的贡献:

版本 访问模式 编译器设置 GFLOP/s
naive (ijk) 跨大步长 -O3 开自动向量化 0.52
ikj 流式连续 -O1 关自动向量化 3.66
ikj 流式连续 -O3 开自动向量化 7.16
ikj + OpenMP(48T) 流式连续 -O3 160

布局让 0.52 → 3.66,编译器自动向量化再让 3.66 → 7.16,多核并行最后拉到 160。这里布局带来的约 7× 比编译器带来的约 2× 大得多,所以优先级通常是布布局 → 并行 → 自动向量化 → 手写 intrinsic。

自动向量化也有天花板。从端口依赖推算:内层每步要做 load B、load C、FMA、store C,若内存端口每周期约 3 个、FMA 有 2 个 port,则 FMA:mem 比例约为 0.67,一个周期做不满 1 条 FMA;即使换成更宽的向量,内存端口不够也加不出并行度。把一段 C 留在寄存器里,让每个字节喂给更多 FMA,才是把这个比例拉高、逼近单核屋顶的办法。

进一步的寄存器分块会让多个结果累加器长期留在寄存器中,减少 load/store;同时要匹配 load 单元、store 单元和 FMA 单元的吞吐。寄存器过多会 spill 到内存,反而变慢。这里已经接近微内核优化,通常由 BLAS 库完成。

AVX-512 微内核

微内核不是简单地把内层 j 循环换成一条 AVX 指令。示例让一组 ZMM 寄存器保存一个 \(4\times16\) 的 C tile;每轮从 packed B 读两个向量,把 A 的若干标量广播后做 FMA。C 在寄存器里跨多次 K 维迭代累加,最后才写回内存,减少了反复读写 C 的成本。

这解释了为什么手写 SIMD 需要理解 tile、packing 和寄存器预算,也解释了为什么简单场景优先调用 BLAS。微内核优化只应在 profiler 已证明 GEMM 是主要热点、库接口又无法满足布局或融合需求时才进入。

课件的 AVX-512 微内核:4×16 的 C tile 驻留在 8 个 ZMM 累加器中。

图:C 的小块留在寄存器中沿 K 维累加,packed B 连续加载,A 的标量广播到向量 lane;最后统一写回。

多线程与内存流量

OpenMP 并行矩阵乘法时,若不同线程负责不同的 C tile,每个线程主要写自己的结果,正确性和缓存行为都较好:

#pragma omp parallel for schedule(static)
for (int ii = 0; ii < n; ii += BS) {
    // 一个线程处理若干行块/结果 tile
}

若按 K 维切分,每线程可独立读取 A、B 的部分,但多个线程会累加同一个 C tile,需要 reduction、锁或额外缓冲区;同步与额外写回会抵消收益。如何切分不能只看计算量是否均分,还要看哪些数据被共享、是否会争带宽、是否能复用共享 L3。

线程数增加到某一点后,内存带宽先饱和,之后更多线程只是在竞争。此时 GFLOP/s 不再随线程数增长是正常现象,不是 OpenMP 写错。

多核扩展的实测能看出明显的边际:同一微内核,1 线程约 54 GFLOP/s、12 线程约 592、24 线程约 969、48 线程约 1729,再到 96 线程反而降到约 707——更多线程不一定更快,超出一颗 socket 的合理并发后只增加调度和带宽争用。

线程多了还会暴露负载不均衡与同步等待。一个例子是把 3136 行切成 49 个行条块分给 48 个线程,总量看似整除不了,结果 47 个线程约 710 ms 就做完,剩下一个线程要同时做两块(约 1173 ms),其余线程在 barrier 上空等,整个机器约 39% 的时间闲置。把切分粒度细到按行之后,所有线程都约 710 ms 完成,闲置降到约 13%,墙钟时间从 1173 ms 降到 819 ms。这类问题用 wall-clock 看不出来——perf/VTune 看到的是时间片里大段 Inactive Sync Wait。有的实况里 32 线程的程序光是自旋等待就占了大半 CPU 时间,所以并行程序的 profiler 读数经常显示利用率并不像墙钟时间那么理想。

NUMA 的测量与绑定

双路服务器的每个 socket 邻近自己的 DRAM。若线程运行在 socket 0、数据页却首次触碰于 socket 1,就会发生 remote access。性能分析中可分别测试本地分配、本地访问和远端访问,观察延迟/带宽差异。

常见策略是让每个线程初始化自己将处理的数组块;并在必要时将线程和内存绑定到同一 NUMA 节点。可以用:

numactl --hardware
numactl --cpunodebind=0 --membind=0 ./program
numastat -p PID

绑定不应变成默认迷信。若任务本身需要跨 socket 共享数据,或调度环境已做了合理绑定,手工绑定未必更快;仍需测量。

实测把 24 个线程固定在 socket 0 后,访问本地内存约为 42.7 GFLOP/s、平均加载延迟约 230 cycles;把内存强制分配在远端节点后,性能约为 31.9 GFLOP/s、延迟约 335 cycles。数值随机器改变,但本地性带来的方向性差异正是 NUMA 绑定11要验证的东西。

同一程序固定 CPU 后,本地与远端 DRAM 的测量结果差异。

图:固定线程位置后只改变页面位置,远端加载延迟上升、吞吐下降;具体数值只代表课件所测机器。

NUMA 要分清三个位置

测 NUMA 时要区分线程在哪个 socket、页面在哪个 socket、以及线程实际访问哪块页面。只把进程绑到 node 0、却由 node 1 的线程先初始化数组,仍可能得到远端页面。可先让每个线程并行初始化自己后续处理的块,再保持相同的线程划分进入主循环;改变一个条件后重跑,才能把差异归因于数据位置而不是随机波动。

思考题

多线程矩阵初始化由主线程完成,计算时各线程访问对应区域。为什么显式绑定后性能可能仍不理想?

答案

绑定只约束线程在哪里运行,不自动迁移已经分配的数据。如果页面在初始化时都落在主线程所在节点,其他节点的线程仍会远端访问。应按未来的访问者执行 first touch,或显式设置内存策略;还要检查绑定是否与线程调度、库线程和 I/O 冲突。

计时边界与统计量

测量一个 kernel 时,计时器应夹住同一份工作:必要的预热之后开始计时,结束时确保 CPU 线程或 GPU stream 已完成,再读取时钟。把随机数据生成、首次页分配、JIT 编译或文件加载有选择地计入,会改变问题本身;报告必须写清边界。CPU 可用单调时钟,MPI 程序通常取所有 rank 中的最大耗时,因为作业完成由最慢 rank 决定;GPU 异步发射时应使用 CUDA event 或显式同步,不能只量 host 提交 kernel 的微小时间。

单次运行容易受频率调节、后台负载、首次 cache cold start 和系统噪声影响。对相同输入运行多次,报告中位数或稳定区间这些统计量12,同时保存最小/最大值和线程绑定信息,才能判断一个 3% 的改动是否真实。优化中若校验被移出计时区,仍应在每次候选实现后运行;少做了计算得到的加速不是性能结果。

思考题

同一个程序跑三次,时间分别是 9.8s、10.1s、12.4s,只报告最快值合理吗?

答案

一般不合理。应说明计时边界、是否预热、输入是否一致,并报告均值、标准差或分位数。只挑最快值会掩盖系统噪声、缓存冷热和首次分配等影响,重复实验的意义正是估计波动范围。

从事件到循环

perf stat 的 IPC 是 retired instructions / cycles,不是 CPU 利用率。IPC 低可能来自 cache miss、分支错误、数据依赖或前端取指受限;IPC 高也可能只是在高效执行不重要的初始化。cache-misses 需要和访问次数、cache line 大小、运行时间一起解释。把计数器与采样 profiler 的热点函数、源码行和汇编对应,才能提出可证伪的假设:例如热点在 B[k*n+j] 且 LLC miss 高,才有理由尝试转置或 packing;改完再测同一事件集和总时间。

性能上限也可由实际字节流量反查。若一个 SAXPY 每元素读 x、读写 y,理想情况下约搬运 12 字节并做 2 FLOP,运算强度约为 1/6 FLOP/byte;在 200 GB/s 的有效带宽下,即使峰值算力是数 TFLOPS,Roofline 上限也只有约 33 GFLOPS。这个估算的价值在于排除不可能:当实测已接近该数,不应期待把 FMA 再调快十倍。

perf/VTune 用于热点和硬件事件;火焰图把调用栈占比可视化;trace/timeline 用于观察线程、MPI、GPU 和 I/O 的等待关系;微基准用于测机器上限;程序内计时用于标记算法阶段。一次优化只改一个假设,并保存版本、命令、输入、结果和正确性校验。

性能优化的顺序可以概括为:先确认热点和上限;先改善算法与访问模式;再做 cache blocking;再启用编译器优化和向量化;再并行、绑核;最后才写针对某一指令集的微内核。这个顺序并非教条,但能避免把时间花在不影响总运行时间的细节上。

课件将一次 GEMM 优化的每一步放回同一张 Roofline 图:循环顺序、分块、自动向量化、微内核、多核并行和库调用各自解决不同瓶颈。

图:优化轨迹同时向右提高数据复用、向上提高吞吐;每一步应由前一版本的瓶颈证据驱动。

可复查的性能实验

一个可信的 benchmark 至少有两条线。第一条是正确性线:在小尺寸上与朴素实现或高精度库结果比较,浮点计算要使用合理的绝对/相对误差阈值;优化重排了加法顺序时,末位差异未必是错误,但系统性的数量级偏差一定要先查。第二条是性能线:固定问题规模、编译选项、线程绑核和输入,预热后重复多次,再记录时间分布、吞吐和相应的 profiler 证据。

尤其要分清一个 kernel 的快和程序整体的快。例如 MoE 的专家 GEMM 通过 packing 后可能更快,但 packing、路由索引、结果 scatter 和线程同步也要计入端到端时间;CUDA kernel 的 event 时间也不能代替包含 host-device 传输的服务延迟。局部微基准用于解释某个假设,端到端计时用于判断这项修改是否值得保留,两者缺一不可。

端到端评测会把初始化、主程序、MPI、CUDA runtime 和必要同步放在同一计时范围内。CPU 侧可用 perf 观察完整 baseline,再分析热点 rank 和线程活动;GPU 侧的主机 profiler 只能显示调用栈与等待,具体 kernel 还要交给 Nsight Systems、Nsight Compute。单独一段循环或一个 kernel 的加速比不能替代完整正确性和端到端时间。

优化记录中保留命令、机器信息、git 版本、输入尺寸和正确性结果并不繁琐。性能会受 CPU 频率、后台负载、NUMA 放置和库版本影响;缺少这些条件,后来即使看到一组漂亮的 GFLOP/s,也无法知道它与哪一个版本可比。课上 Measure, locate, hypothesize, change one thing 的顺序,最终是为了让每次改动都能被证据支持或推翻。


  1. 热点。运行时间或资源消耗占比异常高的代码区域。 

  2. instructions per cycle,每周期平均完成指令数。 

  3. cycles per instruction,每条指令平均占用周期数。 

  4. floating point operation,一次浮点运算。 

  5. floating point operations per second,每秒浮点运算次数。 

  6. Roofline 模型。结合算力上限和内存带宽上限估计性能。 

  7. general matrix multiply,通用矩阵乘法。 

  8. profiling,性能分析。测量程序运行特征,找出时间和资源消耗来源。 

  9. 缓存分块。把循环访问的数据切成能留在缓存中的小块。 

  10. 向量化。把多个元素的操作映射为 SIMD 指令。 

  11. NUMA 绑定。把线程和内存固定在同一 NUMA 节点附近,减少远端访问。 

  12. statistics,统计量。从多次测量中汇总出的数值,例如中位数、均值、最小值和最大值。 

有用的话请给我个 star => Stars 本站总浏览