09 > 计算受限还是访存受限

每个核函数都被两件事之一拖住,要么是 GPU 算数学有多快,要么是它搬数据有多快。这一课教你用算术强度这一个数字和一张叫屋顶线的简单图表来判断是哪一个。所有例子都用这些课里跑程序的那块 NVIDIA L40S。

每个核函数都有两道上限

一个核函数要跑完,需要两样东西,一是核心把数学算完,二是数据在显存和核心之间搬完。

两者都要花时间,而且在 GPU 上是同时进行的。一些线程束在等数据时,另一些在计算(见第 08 课)。所以核函数的耗时大约等于两者中较慢的那个。

  • 计算时间 = FLOPs / 峰值 FLOPS
  • 数据时间 = 搬运字节数 / 显存带宽

哪个更大,就由哪个决定耗时。数据那部分更大,核函数就是访存受限。数学那部分更大,就是计算受限。

数 FLOPs

一个 FLOP 就是对浮点数做一次加、减、乘或除。FLOPS 是速度,表示每秒能做多少次。

第 06 课讲过峰值从哪来,就是核心数 × 时钟频率 × 2,因为一次 FMA 算 2 个 FLOP。L40S 是 18,176 个 FP32 核心 × 2.52 GHz × 2 ≈ 91.6 TFLOPS,正是 NVIDIA 规格表上的 FP32 数字。

[!NOTE] 小写 s 的 FLOPs 是工作量,大写 S 的 FLOPS 是速度。一个核函数做 1,000 个 FLOP,在 91.6 TFLOPS 的 GPU 上,光是数学至少也要 1,000 / 91.6 万亿秒。

要数一个核函数的 FLOPs,先数一个线程做多少数学,再乘以线程数。c[i] = a[i] + b[i] 每个元素 1 个 FLOP。y[i] = a * x[i] + y[i] 每个元素 2 个 FLOP,一次乘、一次加。

数字节

搬运字节数指的是在 GPU 显存和芯片之间来回走的字节。一个 float 是 4 字节。线程从显存读的每个值、写回的每个值都要算上。

对 c[i] = a[i] + b[i],每个线程读 a[i] 和 b[i],写 c[i],所以是 3 个 float × 4 字节 = 每个元素 12 字节。

这一侧的速度上限是第 06 课讲的显存带宽。L40S 有 48 GB 的 GDDR6 显存,带宽 864 GB/s。

算术强度

把两个数放在一起,就得到这一课最有用的数字。

算术强度 = FLOPs / 搬运字节数

单位是 FLOP/字节,表示核函数每搬进一个字节做多少数学。它只取决于核函数,跟 GPU 无关。算术强度低的核函数大部分时间在等数据。算术强度高的核函数会把每个字节重复使用很多次。

向量加法每 12 字节只有 1 个 FLOP,也就是 1 / 12 ≈ 0.083 FLOP/字节。非常低。

脊点

GPU 也有一个自己的数字可以拿来比较,就是峰值 FLOPS 除以显存带宽。

脊点 = 峰值 FLOPS / 显存带宽

L40S 是 91,600 GFLOPS / 864 GB/s ≈ 106 FLOP/字节。要让 FP32 核心一直忙着,核函数每读一个字节大约要做 106 个 FLOP。少于这个数,核心就要等显存。

[!TIP] 快速判断的方法是拿核函数的算术强度和 GPU 的脊点比。低于脊点是访存受限,高于脊点是计算受限。

不同 GPU 的脊点差别很大。

GPU FP32 峰值 带宽 脊点
L40S 91.6 TFLOPS 864 GB/s ≈ 106 FLOP/字节
H100 SXM 67 TFLOPS 3,350 GB/s ≈ 20 FLOP/字节
RTX 5090 104.8 TFLOPS 1,792 GB/s ≈ 58 FLOP/字节

H100 的带宽相对它的 FP32 算力多得多,所以相当轻的核函数也能碰到它的 FP32 屋顶。L40S 的 FP32 算力很强,但用的是 GDDR6 显存,所以很多核函数先撞上的是显存上限。

屋顶线模型

屋顶线模型把这些画成一张图。x 轴是算术强度,y 轴是可达 GFLOPS。两条轴都是对数刻度,每走一格就是乘一个倍数。

可达 GFLOPS = min(峰值 GFLOPS, 算术强度 × 带宽)

图上有两条线,在脊点相交。左边是一条斜线,即带宽 × 强度,也就是显存屋顶。右边是一条平线,即峰值,也就是计算屋顶。一个核函数就是屋顶下的一个点,永远不可能超过屋顶。

选一块 GPU 和一个核函数。点落在斜线上是访存受限,落在平线上是计算受限。

算一算向量加法

在 L40S 上做向量加法,每个数组 1 亿个 float(N = 100,000,000)。

  • 每个元素 1 个 FLOP,共 100,000,000 个 FLOP。
  • 每个元素 12 字节,共 1,200,000,000 字节 = 1.2 GB。
  • 计算时间是 100,000,000 / 91.6 万亿 ≈ 0.0011 ms(约 1.1 µs)。
  • 数据时间是 1.2 GB / 864 GB/s ≈ 1.39 ms。

数据部分大约长 1,270 倍。屋顶线给出同样的答案,0.083 × 864 ≈ 72 GFLOPS 可达,不到 91,600 GFLOPS 峰值的 0.1%。向量加法在任何 GPU 上都严重访存受限。它就是 CUDA 实践部分里向量加法那一课的程序(CUDA 第 08 课)。

算一算 SAXPY 和点积

SAXPY 计算 y[i] = a * x[i] + y[i]。每个元素做 2 个 FLOP(一次 FMA),搬 12 字节,即读 x[i] 和 y[i],写 y[i]。算术强度是 2 / 12 ≈ 0.167 FLOP/字节,在 L40S 上可达 0.167 × 864 ≈ 144 GFLOPS。

点积在两个数组上计算 s += x[i] * y[i]。每个元素做 2 个 FLOP,读 8 字节,每个元素不写回任何东西。算术强度是 2 / 8 = 0.25 FLOP/字节,在 L40S 上可达 0.25 × 864 = 216 GFLOPS。

两者都比向量加法好一点,但仍远低于 106 的脊点。它们是访存受限的。数学算得再快,它们的耗时也完全不会变。

算一算矩阵乘法

两个 N × N 矩阵的矩阵乘法 C = A × B 就不一样了。C 的每个元素是 N 个乘积之和,要做 N 次乘法和 N 次加法。

  • FLOPs 是 N × N 个元素 × 2N = 2N³。
  • 假设每个矩阵只过一次显存,读 A 和 B,写 C,字节数共 3 × N² 个 float × 4 字节 = 12N²。
  • 算术强度是 2N³ / 12N² = N / 6。

强度随 N 增长,因为每个读进来的数会被用 N 次。下面取 N = 4,096。

  • FLOPs 是 2 × 4,096³ = 137,438,953,472(约 1,374 亿)。
  • 字节数是 12 × 4,096² = 201,326,592(约 201 MB)。
  • 算术强度是 4,096 / 6 ≈ 683 FLOP/字节。

683 远高于 106,所以在 L40S 上这个矩阵乘法是计算受限的。计算时间是 1,374 亿 / 91.6 万亿 ≈ 1.5 ms,数据时间是 201 MB / 864 GB/s ≈ 0.23 ms。N = 256 时强度只有 256 / 6 ≈ 43,在 L40S 上访存受限(脊点 106),在 H100 上却是计算受限(脊点 20)。

[!WARNING] N / 6 是最好的情况,即每个矩阵只过一次显存。朴素的核函数会一遍又一遍地读同样的行和列,搬的字节多得多,实际强度就被拉低了。从片上存储(比如共享内存和缓存,见第 07 课)重复使用数据,才能让真实的核函数接近 N / 6。

结果意味着什么

答案告诉你力气该花在哪里。

  • 访存受限时,优化数据搬运。每个字节只读一次,以合并访问的方式按大块、对齐地读,把要重复使用的数据放进共享内存或寄存器,用更小的数字格式让搬的字节更少,并把核函数融合起来,免得中间结果写出去又读回来。
  • 计算受限时,优化数学。用 Tensor Core,在精度允许时用更便宜的格式,去掉不必要的计算。

优化错了方向等于白做。让向量加法的数学快一倍,它的耗时仍然约是 1.39 ms,因为数学从来不是瓶颈。

Tensor Core 带来更高的屋顶

上面的屋顶都是普通核心上的 FP32。Tensor Core 为矩阵运算提供高得多的计算屋顶。L40S 的 Tensor Core 在不用稀疏性时 FP16 可达 362 TFLOPS,约为 FP32 峰值的 4 倍。带宽还是 864 GB/s,脊点就升到约 362,000 / 864 ≈ 419 FLOP/字节。

更高的屋顶只帮得上计算受限的核函数。无论屋顶多高,向量加法都停在 72 GFLOPS。第 10 课会讲数字格式和 Tensor Core。

这对 CUDA 意味着什么

调优一个核函数之前,先数它的 FLOPs 和字节。一行算术就能告诉你该改显存访问还是改数学,以及这个核函数离这块 GPU 的极限还有多远。

你最先写的大多数简单核函数,比如向量加法、缩放、复制和归约,都是访存受限的。对它们来说,目标是跑满显存带宽,而不是 TFLOPS。一个向量加法如果能以接近 864 GB/s 的速度搬完 1.2 GB,就已经是很好的核函数了,哪怕它只用了不到 0.1% 的峰值 FLOPS。

术语表

  • GPU(Graphics Processing Unit,图形处理器):这些课讲的处理器,由许多并行工作的核心组成。
  • 核函数(kernel):在 GPU 上运行的函数,每个线程运行一份。
  • 线程束(warp):一起执行的 32 个线程。一些线程束等数据时,另一些在计算。
  • 访存受限(memory bound):耗时由搬数据而不是数学决定的核函数,它落在屋顶线的斜线上。
  • 计算受限(compute bound):耗时由数学而不是搬数据决定的核函数,它落在屋顶线的平线上。
  • FLOP(floating-point operation,浮点运算):对浮点数做一次加、减、乘或除。小写 s 的 FLOPs 是它的数量。
  • FLOPS(floating-point operations per second,每秒浮点运算次数):速度,GFLOPS 是每秒十亿次,TFLOPS 是每秒万亿次。
  • FMA(fused multiply-add,融合乘加):一条计算 a × b + c 的指令,算 2 个 FLOP。
  • FP32(32-bit floating point,32 位浮点):GPU 计算的标准数字格式,一个 float 是 4 字节。
  • 峰值 FLOPS(peak FLOPS):核心数 × 时钟频率 × 2,L40S 的 FP32 是 91.6 TFLOPS。
  • 搬运字节数(bytes moved):在 GPU 显存和芯片之间来回走的字节,每次读和每次写都算。
  • 显存带宽(memory bandwidth):显存每秒能送出的字节数,L40S 是 864 GB/s。
  • GB/s(gigabytes per second,吉字节每秒):每秒搬运的十亿字节数,是显存带宽的单位。
  • GDDR6(Graphics Double Data Rate 6):L40S 用的显存类型,比 H100 这类数据中心 GPU 用的 HBM(High Bandwidth Memory,高带宽内存)慢。
  • 算术强度(arithmetic intensity):FLOPs 除以搬运字节数,单位 FLOP/字节。它取决于核函数,与 GPU 无关。
  • 脊点(ridge point):峰值 FLOPS 除以显存带宽,L40S 约 106 FLOP/字节,H100 SXM 约 20。
  • 屋顶线模型(roofline model):以算术强度为横轴、可达 FLOPS 为纵轴的双对数图,有一条倾斜的显存屋顶和一条水平的计算屋顶。
  • 可达 GFLOPS(attainable GFLOPS):核函数最多能达到的速度,min(峰值, 强度 × 带宽)。
  • 向量加法(vector add):c[i] = a[i] + b[i],每 12 字节 1 个 FLOP。
  • SAXPY(Single-precision A times X Plus Y,单精度 a 乘 x 加 y):y[i] = a * x[i] + y[i],每 12 字节 2 个 FLOP。
  • 点积(dot product):两个数组上 x[i] * y[i] 的和,每 8 字节 2 个 FLOP。
  • 矩阵乘法(matrix multiply):C = A × B,N × N 的 FP32 矩阵要做 2N³ 个 FLOP,至少搬 12N² 字节,所以强度是 N / 6。
  • 共享内存(shared memory):同一线程块的线程共用的快速片上存储,核函数可以重复使用其中的数据,不用再读一次。
  • 合并访问(coalesced access):相邻线程读相邻地址,显存用少数几次大传输就能满足它们。
  • 归约(reduction):把许多值合成一个值,比如求和或求最大值。
  • Tensor Core:专为矩阵运算设计的单元,屋顶高得多,L40S 在不用稀疏性时 FP16 达 362 TFLOPS。
  • FP16(16-bit floating point,16 位浮点):占 2 字节的数字格式,Tensor Core 跑它比 FP32 核心跑 FP32 快得多。
  • 稀疏性(sparsity):Tensor Core 按固定模式跳过零值的功能。规格表常给出带稀疏性的数字,是稠密数字的两倍。
  • CUDA(Compute Unified Device Architecture,统一计算设备架构):NVIDIA 用来编写在其 GPU 上运行的程序的平台。