AI Infra
Day 15memory-bound28 min read

Day 15 · 用实测值画自己的 roofline,算实测 ridge point

把 Day 13 测出的带宽、Day 14 测出的算力换掉标称值,用 matplotlib 画出自己这张卡的 roofline,再把 Day 9 的 TPOT 换算成图上的一个点。屋顶从此是自己测出来的,不是宣传页上抄来的。

今天要解决的问题

Day 5 画过一张 roofline,横轴算术强度、纵轴可达算力,斜线是带宽、横线是算力,交点叫 ridge point。那张图上的两条线用的是 A100 的标称值:312 TFLOP/s 和 2039 GB/s。Day 13 和 Day 14 做的事就是戳破标称值:copy 大张量测出来的带宽到不了 320 GB/s,大方阵 matmul 也打不满 65 TFLOP/s。

所以今天要把那张图重画一遍,用的是自己这张卡实测出来的两个数。做完要能回答三件事:

  1. 我这张 T4 的实测 roofline 长什么样,实测 ridge point 是多少,和标称算出来的 203 差多少。
  2. Day 9 测出来的 TPOT,换算成图上的一个点是在哪。这个点离斜线多远,离屋顶多远,两段距离分别代表什么。
  3. 以后拿到任何一段代码或一个 kernel,怎么算出它的算术强度,把它标到这张图上。

这一篇的产出是一个 Python 脚本和一张图。脚本的所有输入都是参数,换卡、换模型、换实测值,改几个数重跑就行。

数字口径先说死。卡是 Colab 免费给的 T4:标称 fp16 tensor core 65 TFLOP/s,标称带宽 320 GB/s,标称 ridge point 65e12 ÷ 320e9 ≈ 203 FLOP/byte。模型是 TinyLlama-1.1B,fp16 权重约 2.2 GB。Day 13 和 Day 14 的实测值我还没填进去,下面用到实测值的地方一律写「预期区间」和「示例」,等真实数字出来再替换。

为什么屋顶要用实测值

标称值不是假的,是「在最理想条件下能达到的上限」。带宽的标称是内存控制器的理论吞吐,算力的标称是所有 tensor core 每个时钟都在做满载乘加、时钟锁在 boost 频率。真实运行时有四件事把它拉下来:

  • 时钟频率。T4 是 70 W 功耗墙的卡,大 matmul 跑满几秒钟就会降频,实测算力跟着降。
  • 数据搬运和计算不能完全重叠。矩阵乘的每个 tile 要先从显存搬进 shared memory 再算,首尾总有空档。
  • tile 利用率。矩阵尺寸不是 tile 大小的整数倍时,边上的 tile 只有一部分在干活。
  • 带宽的测法本身。copy 一个张量要读一遍写一遍,两次都走 HBM,控制器在读写之间切换有代价。

Day 13 和 Day 14 已经给了预期区间:实测带宽通常是标称的 75% 到 90%,实测 matmul 算力通常是 70% 到 90%。这两个折扣不一样,所以 ridge point 也会变。举个示例:如果实测带宽是 270 GB/s(84%),实测算力是 50 TFLOP/s(77%),那实测 ridge point 就是 50e12 ÷ 270e9 ≈ 185,不是 203。

差 10% 到 20% 看着不多,但 roofline 的用法是拿它做判断:一个 workload 的算术强度落在 ridge 左边还是右边,决定了下一步是去减字节还是去减 FLOP。算术强度在 180 到 200 之间的 workload,用标称屋顶判是 memory-bound,用实测屋顶判是 compute-bound,两个方向的优化手段完全不同。边界附近的判断,只能用实测的线。

还有一个更实际的理由。以后每做一次优化,都要回答「离屋顶还有多远」。屋顶是标称值的话,永远有一段追不上的距离,那段距离里有多少是自己的问题、多少是卡本身到不了,分不清。屋顶换成实测值,剩下的距离全是自己的。

一张 roofline 需要的四个数

画这张图只需要四个数,两个是卡的,两个是 workload 的。

卡的两个数,Day 13 和 Day 14 已经测出来了:

标称(T4)实测(Day 13 / Day 14 填)预期区间
显存带宽 B320 GB/s______ GB/s240–290 GB/s(75%–90%)
fp16 峰值算力 P65 TFLOP/s______ TFLOP/s45–58 TFLOP/s(70%–90%)
ridge point P ÷ B203 FLOP/byte______ FLOP/byte170–200

workload 的两个数,是「这段代码做了多少 FLOP」和「这段代码搬了多少字节」。两个数相除就是算术强度,FLOP 除以时间就是实际达到的算力。这两个数不是测出来的,是算出来的,测出来的只有时间。

所以画一个点的流程是:算 FLOP,算字节,测时间,然后

算术强度 AI   = FLOP ÷ 字节            单位 FLOP/byte,决定横坐标
实际算力      = FLOP ÷ 时间            单位 FLOP/s,决定纵坐标
可达算力      = min(P, B × AI)         这个 AI 下屋顶的高度

第三行就是 Day 5 写过的那个 min,只是 P 和 B 换成了实测值。

Day 13 实测带宽 B Day 14 实测算力 P Day 9 实测 TPOT 斜线 y = B × AI memory 屋顶 横线 y = P compute 屋顶 AI = FLOP ÷ 字节 算力 = FLOP ÷ TPOT roofline 图 两条线 + 若干点 ridge = P ÷ B
W3 前两天测的是卡,W2 测的是 workload。今天把三个来源拼成一张图:卡决定屋顶的形状,workload 决定点落在哪。

算术强度怎么算:三个例子

算术强度是横坐标,算错了点就标错了位置。用三个以后会反复遇到的 workload 把算法走一遍。

**例一,decode 一步,batch 1。**Day 5 算过。每个参数搬 2 字节(fp16),做 2 次运算(一乘一加)。

FLOP  = 2 × N            N 是参数量
字节  = 2 × N            权重每个 2 字节,全部读一遍
AI    = 2N ÷ 2N = 1 FLOP/byte

N 约掉了,所以 TinyLlama 是 1,Llama-2-7B 也是 1。严格说 decode 一步还要读 KV cache,TinyLlama 每个 token 22.5 KB,上下文 500 个 token 时是 11 MB,跟 2.2 GB 的权重比可以忽略,AI 还是约等于 1。

**例二,方阵 matmul,N × N 乘 N × N,fp16。**Day 14 测算力用的就是它。

FLOP  = 2 × N × N × N = 2N³          每个输出元素 N 次乘加
字节  = 读 A + 读 B + 写 C = 3 × N² × 2 = 6N²
AI    = 2N³ ÷ 6N² = N ÷ 3

N = 4096 时 AI ≈ 1365,是 ridge point 203 的六七倍,稳稳落在横线上,compute-bound。这就是为什么 Day 14 用大方阵测算力:AI 够高,测到的时间全是算的时间,搬运藏在里面。N = 256 时 AI 只有 85,低于 203,这时 matmul 是 memory-bound 的,测出来的「算力」其实是带宽换算出来的,数字会很难看。Day 14 说矩阵太小打不满,根子在这里。

**例三,逐元素加法,y = x + 1,fp16。**Day 13 测带宽用的。

FLOP  = n                 每个元素加一次
字节  = 读 x + 写 y = 2n × 2 = 4n
AI    = n ÷ 4n = 0.25 FLOP/byte

如果是 z = x + y,读两个写一个,字节是 6n,AI 是 1/6 ≈ 0.17。不管哪个写法,AI 都远低于 1,这类 op 永远在斜线上,时间完全由字节数决定。这也是为什么 Day 13 用它测带宽:它没有任何算力成分,测到的时间就是搬运时间。

三个例子放一张表:

workloadFLOP字节AI(FLOP/byte)在 T4 上落在哪
decode 一步,batch 12N2N≈ 1斜线,memory-bound
decode 一步,batch b2Nb2N + KV≈ b(KV 小时)b < 203 在斜线上
matmul 4096³ fp162 × 4096³ ≈ 137 GFLOP6 × 4096² × … ≈ 100 MB≈ 1365横线,compute-bound
matmul 256³ fp1633.5 MFLOP393 KB≈ 85斜线,memory-bound
y = x + 1n4n0.25斜线最左端

第二行是 Day 16 要扫的东西:batch 加大,权重还是只搬一遍,FLOP 翻 b 倍,AI 约等于 b。理论上 b 到 203 才碰屋顶,明天会看到实际曲线比这个早弯,而且弯的原因不是 FLOP。

把 Day 9 的 TPOT 变成图上的一个点

现在把 W2 的实测接进来。Day 9 测了 TinyLlama 在 T4 上 batch 1 的 TPOT,理论下限是 2.2 GB ÷ 320 GB/s ≈ 6.9 ms。实测值我用一个示例数代替:假设 TPOT 是 15 ms,是理论下限的 2.2 倍,落在 Day 9 说的「1.5 到 3 倍」区间里。

这一步的 FLOP 和字节:

FLOP  = 2 × 1.1e9 = 2.2 GFLOP
字节  = 2.2 GB(权重)+ 约 0.01 GB(KV cache)≈ 2.2 GB
AI    = 2.2e9 ÷ 2.2e9 ≈ 1 FLOP/byte

横坐标是 1。纵坐标是实际达到的算力:

实际算力 = 2.2e9 FLOP ÷ 0.015 s ≈ 147 GFLOP/s = 0.147 TFLOP/s

而 AI = 1 时屋顶的高度,用标称带宽是 320e9 × 1 = 320 GFLOP/s,用实测带宽(示例 270 GB/s)是 270 GFLOP/s。所以这个点在斜线下方,实际算力是实测屋顶的 147 ÷ 270 ≈ 54%。

54% 这个数怎么读:它说的是「在 AI = 1 这个位置,带宽只用出了一半多」。剩下的 46% 去哪了?Day 11 看过 timeline 上的 gap,decode 一步由几十个小 kernel 组成,kernel 之间 GPU 在等 CPU 发下一个,这段时间既没搬也没算。另一部分是 kernel 自身的效率,比如 attention kernel 在 batch 1、序列短的时候 tile 填不满。所以点到斜线的垂直距离,就是 overhead 加 kernel 效率损失。这两样都不是「算术强度不够」的问题,靠加 batch 解决不了,得靠减 launch 次数(CUDA graph、fusion)或者换更好的 kernel。

再把 Day 14 的 matmul 也标上去:N = 4096,AI ≈ 1365,实际算力就是 Day 14 测出来的数(示例 50 TFLOP/s),点会落在横线上或紧贴横线下面。它和 decode 那个点一左一右,一个在斜线下面一个在横线上,把「同一张卡、两种 workload、两种命运」画在了同一张图上。

0.11101001k10k 1e101e111e121e131e14 算术强度 FLOP/byte(对数) FLOP/s 实测 ridge ≈185 标称 ridge 203 decode b=1,AI≈1 0.15 TFLOP/s,屋顶的 54% matmul 4096³ AI≈1365 x+y,AI≈0.17 memory-bound compute-bound
示意图,坐标按示例值(实测带宽 270 GB/s、实测算力 50 TFLOP/s)摆放。虚线是标称屋顶,实线是实测屋顶,两者之间的缝就是卡本身到不了的部分。decode 的点悬在斜线下面,那段空隙是 overhead 和 kernel 效率,不是带宽不够。

完整代码

下面的脚本在 Colab 或本机都能跑,不需要 GPU,它只画图。所有数字都在最上面的参数区,实测值出来后改那几行就行。

# roofline.py —— 用实测值画自己的 roofline
# 在 Colab 里直接跑,或本机 pip install matplotlib numpy 后跑
import numpy as np
import matplotlib.pyplot as plt

# ---------- 参数区:只改这里 ----------
CARD = "T4"
NOMINAL_BW    = 320e9    # byte/s,标称带宽
NOMINAL_FLOPS = 65e12    # FLOP/s,标称 fp16 tensor core 算力
MEASURED_BW    = 270e9   # ← 换成 Day 13 实测值(示例 270 GB/s)
MEASURED_FLOPS = 50e12   # ← 换成 Day 14 实测值(示例 50 TFLOP/s)

# 要标的点:(名字, FLOP, 字节, 耗时秒)。时间是实测,FLOP 和字节是算出来的
POINTS = [
    # decode 一步 batch 1,TinyLlama fp16。TPOT 用 Day 9 的实测值替换 0.015
    ("decode b=1", 2 * 1.1e9, 2.2e9, 0.015),
    # matmul 4096³ fp16。耗时用 Day 14 实测值替换(示例:137 GFLOP ÷ 50 TFLOP/s)
    ("matmul 4096", 2 * 4096**3, 6 * 4096**2, 2 * 4096**3 / 50e12),
    # y = x + 1,1 亿个 fp16。耗时用 Day 13 实测值替换(示例:400 MB ÷ 270 GB/s)
    ("x + 1", 1e8, 4e8, 4e8 / 270e9),
]
# ----------------------------------------

def attainable(ai, bw, peak):
    """算术强度 ai 下的可达算力:两条线取低的那条。"""
    return np.minimum(peak, bw * ai)

ai = np.logspace(-1.5, 4.5, 400)               # 横轴 0.03 → 30000 FLOP/byte
ridge_nominal  = NOMINAL_FLOPS / NOMINAL_BW
ridge_measured = MEASURED_FLOPS / MEASURED_BW

fig, ax = plt.subplots(figsize=(8, 5.5))
ax.loglog(ai, attainable(ai, NOMINAL_BW, NOMINAL_FLOPS),
          "--", color="0.55", lw=1.5,
          label=f"标称屋顶  ridge={ridge_nominal:.0f}")
ax.loglog(ai, attainable(ai, MEASURED_BW, MEASURED_FLOPS),
          "-", color="#2b53d8", lw=2.5,
          label=f"实测屋顶  ridge={ridge_measured:.0f}")
ax.axvline(ridge_measured, color="0.75", lw=1, ls=":")

for name, flop, byte, sec in POINTS:
    x = flop / byte                 # 算术强度
    y = flop / sec                  # 实际达到的算力
    roof = attainable(x, MEASURED_BW, MEASURED_FLOPS)
    pct = 100 * y / roof
    ax.plot(x, y, "o", ms=8, color="#b26a12" if x > ridge_measured else "#2b53d8")
    ax.annotate(f"{name}\nAI={x:.2g}  {y/1e12:.3g} TFLOP/s\n屋顶的 {pct:.0f}%",
                (x, y), textcoords="offset points", xytext=(8, -34), fontsize=8)

ax.set_xlabel("算术强度  FLOP / byte")
ax.set_ylabel("可达算力  FLOP / s")
ax.set_title(f"{CARD} roofline:标称 vs 实测")
ax.set_ylim(1e10, 2e14)
ax.grid(True, which="both", alpha=0.25)
ax.legend(loc="lower right")
plt.tight_layout()
plt.savefig(f"roofline_{CARD}.png", dpi=160)
print(f"标称 ridge = {ridge_nominal:.1f}   实测 ridge = {ridge_measured:.1f}")
for name, flop, byte, sec in POINTS:
    x, y = flop / byte, flop / sec
    print(f"{name:>12}  AI={x:8.2f}  达到 {y/1e12:6.3f} TFLOP/s  "
          f"= 实测屋顶的 {100*y/attainable(x, MEASURED_BW, MEASURED_FLOPS):5.1f}%")

几处值得说的地方:

np.logspace 是因为两根轴都是对数刻度。算术强度从 0.1 跨到 10000,算力从 1e10 跨到 1e14,线性轴上画不下,而且 roofline 的斜线在对数轴上才是直线。

attainable 用的是 np.minimum 而不是 Python 的 min,因为传进去的 ai 是一整个数组。这个函数和 Day 5 那个 attainable(ai) 是同一个东西,只是 P 和 B 变成了参数。

点的颜色按它落在 ridge 左边还是右边分,左边靛蓝右边琥珀,和这个站点的颜色约定一致:靛蓝是 memory-bound,琥珀是 compute-bound。

POINTS 里每个点写四个数,时间是唯一实测的那个。FLOP 和字节要自己算,算法就是上一节的三个例子。以后加新的点,先把这两个数算对再填。

ridge_measured 那条竖虚线是整张图的分界线。它左边所有点的优化方向是「减字节或提强度」,右边所有点的方向是「提算力利用率」。

读图:一个点的三段距离

图画出来之后,每个点都有三段距离可以读。用 decode batch 1 那个点说。

**第一段,点到斜线的垂直距离。**点在 AI = 1,斜线在这里的高度是 B × 1 = 实测带宽。点比斜线低,说明连实测带宽都没用满。这段距离的成因只有两类:overhead(GPU 在等 CPU,Day 11 在 timeline 上看到的 gap)和 kernel 效率(kernel 在跑但没跑满带宽)。这两类都和算术强度无关,加 batch 不会让这段距离消失。缩小它的办法是 CUDA graph、torch.compile、更好的 kernel,这些是 M5 之后的事,现在只需要知道这段距离存在、有多大。

**第二段,沿斜线往右到 ridge point 的距离。**从 AI = 1 到 AI ≈ 185,差两个数量级。这是「算术强度不够」的部分,靠 batching 拉。斜线上每往右一格,可达算力就高一格,Day 5 说的「加 batch 几乎不加时间」就是沿这条线往上爬。

**第三段,横线的高度,也就是屋顶本身。**这是卡的物理极限,想再高只能换卡、换精度(fp8)、或者用 sparse。M1 到 M8 都不碰这一段。

ridge point decode b=1 ① 点→斜线 overhead + kernel 效率 加 batch 治不了 ② 沿斜线→ridge 算术强度不够 batching 把点往右推 ③ 屋顶 卡的极限 换卡/换精度 算术强度 → 算力 ↑
三段距离对应三类手段。以后看到任何一个优化技巧,先问它缩的是哪一段:CUDA graph 和 fusion 缩①,batching 缩②,换 H100 抬③。

这三段距离就是整条路线的地图。M2 的 batching 和 KV cache 在缩第二段,M2 W7 的量化是在把点往右挪(字节变少,同样 FLOP 下 AI 变高),M5 的 Triton kernel 和 fusion 在缩第一段。每学一个新东西,回到这张图上找它缩的是哪一段,找不到就说明还没理解它。

把 Day 10 的 top 5 kernel 也标上去

上面标的三个点都是「整段代码」级别的。roofline 更常见的用法是标单个 kernel,Day 10 从 profiler 里抓出来的那几个最耗时的 kernel,每一个都能算出自己的 AI。算法一样:FLOP 除以字节。TinyLlama 的形状已知(d = 2048,4 个 KV 头 × 64 = 256,FFN 中间 5632,词表 32000),decode batch 1 时每层各 kernel 的账是:

kernel(对应部件)FLOP字节(权重为主)AI字节 ÷ 270 GB/s和 launch 开销比
q_proj,2048 × 20488.4 M8.4 MB≈ 131 µs搬运主导
k_proj / v_proj,2048 × 2561.0 M1.0 MB≈ 13.9 µs和 launch 同量级
gate_proj / up_proj,2048 × 563223 M23 MB≈ 185 µs搬运主导
down_proj,5632 × 204823 M23 MB≈ 185 µs搬运主导
attention(上下文 500)≈ 2 M≈ 0.5 MB(读 K、V)≈ 41.9 µslaunch 主导
RMSNorm,2048 维≈ 8 K12 KB≈ 0.70.04 µs完全是 launch
SiLU × up,5632 维≈ 17 K34 KB≈ 0.50.13 µs完全是 launch
lm_head,2048 × 32000(每步一次)131 M131 MB≈ 1485 µs搬运主导

几个读法。

所有线性层的 AI 都是 1,和整段 decode 一样,因为它们就是「一个向量过一个矩阵」,矩阵每个元素搬 2 字节做 2 FLOP。它们的时间下限由字节决定,gate、up、down 三个 FFN 矩阵加起来 69 MB,占一层 101 MB 权重的三分之二,所以 Day 10 的 top 5 里 FFN 的 gemm 一定排在前面。

RMSNorm 和 SiLU 这类逐元素 kernel,字节只有几十 KB,按带宽算 0.04 到 0.13 微秒就该结束,但一次 kernel launch 本身要 5 到 10 微秒。也就是说这些 kernel 的时间里 99% 是 launch 开销,它们根本不在 roofline 上,是 Day 1 三分法里的第三类 overhead-bound。把它们的 AI 算出来标到图上没有意义,点会远远低于斜线,低的原因不是带宽没用好,是 kernel 太小了。这类 kernel 唯一的治法是不单独 launch,和前后的 kernel 合并,这就是 fusion,M5 的主题。

把一层的 kernel 数一遍:两次 RMSNorm、q/k/v/o 四个 gemm、rotary 几个小 op、一次 attention、两次残差加、gate/up/down 三个 gemm、一次 SiLU、一次乘。大约 15 到 25 个 kernel,22 层就是 330 到 550 次 launch,加上 lm_head 和采样。按每次 5 到 10 微秒算,一步 decode 光 launch 就要 2 到 5 毫秒。Day 9 示例里 TPOT 15 ms 减去搬权重的 6.9 ms 剩 8 ms,这 8 ms 里有一半左右可以用 launch 开销解释,剩下的是 Python 层的 generate 逻辑和 kernel 自身效率。这是第一次把「点到斜线的距离」拆成了有数字的几块。

所以 Day 10 的 top 5 表可以加两列变成 roofline 表:

# 把 profiler 的 kernel 表变成 roofline 上的点
# flop 和 byte 按上面的形状手算,cuda_time 从 key_averages() 里读
kernels = [
    # (名字, FLOP, 字节, profiler 里的 CUDA 时间 s)
    ("gate/up gemm", 23e6, 23e6, None),   # ← 填 Day 10 的实测
    ("down gemm",    23e6, 23e6, None),
    ("q_proj gemm",  8.4e6, 8.4e6, None),
    ("lm_head gemm", 131e6, 131e6, None),
    ("rmsnorm",      8e3, 12e3, None),
]
for name, flop, byte, sec in kernels:
    if sec is None:
        continue
    ai = flop / byte
    achieved = flop / sec
    roof = min(MEASURED_FLOPS, MEASURED_BW * ai)
    floor = byte / MEASURED_BW           # 只按带宽算的时间下限
    print(f"{name:>14} AI={ai:5.2f} 屋顶 {100*achieved/roof:5.1f}%  "
          f"实测 {sec*1e6:7.1f} µs  带宽下限 {floor*1e6:7.2f} µs")

最后一列「实测 vs 带宽下限」比屋顶百分比更直观:gemm 类应该在下限的 1.2 到 2 倍,超过 3 倍说明 kernel 有问题;RMSNorm 类实测会是下限的一百倍以上,那不是问题,是 launch 开销,把它记下来等 M5 处理。

TinyLlama 一层 decode,每个 kernel 搬的字节(权重为主),条长 ∝ 字节 gate_proj23 MB · 85 µs up_proj23 MB · 85 µs down_proj23 MB · 85 µs q_proj8.4 MB · 31 µs o_proj8.4 MB · 31 µs k/v_proj1 MB · 4 µs ≈ 一次 launch attention0.5 MB · 2 µs,launch 主导 rmsnorm ×212 KB · 0.04 µs,时间 100% 是 launch silu·mul, add几十 KB,同上 → M5 fusion 的对象
时间按实测带宽 270 GB/s 的示例值换算。三个 FFN 矩阵占一层字节的三分之二;右下角那几个 kernel 字节可以忽略,但每个都要付一次 5 到 10 µs 的 launch,22 层攒起来就是 Day 11 看到的 gap。

标称线和实测线之间的缝

图上虚线和实线之间有一条窄缝,这条缝是卡本身到不了的部分。Day 17 会专门讲为什么到不了,今天只记一件事:做判断永远用实线。

用示例数字说明这条缝有多大影响。标称 ridge 203,实测 ridge 185,差 9%。如果 Day 16 扫 batch 时发现曲线在 batch 180 左右开始压平,用标称线看是「还没到 ridge 就弯了,一定有别的瓶颈」,用实测线看是「正好碰到屋顶,符合预期」。同一条曲线两种解读,只有实测线的解读是对的。

反过来,在 AI 远低于 ridge 的区域(比如 decode batch 1 的 AI = 1),两条线差的只是带宽那 15% 的折扣,判断不受影响,memory-bound 就是 memory-bound。所以实测值最要紧的地方是 ridge 附近,离得远的地方用标称估一估也够。这也是为什么 Day 5 用标称值算出来的那张表到今天依然成立:它说的全是离 ridge 两个数量级的事。

记录表

实测值出来后填这张表,填完把参数区改掉重跑脚本。

标称实测实测 ÷ 标称来源
带宽 B(GB/s)320____________ %Day 13
fp16 算力 P(TFLOP/s)65____________ %Day 14
ridge point(FLOP/byte)203____________ %P ÷ B
decode b=1 TPOT(ms)6.9(下限)____________ 倍Day 9
decode b=1 实际算力(GFLOP/s)320(斜线)______屋顶的 ______ %2.2e9 ÷ TPOT
matmul 4096 实际算力(TFLOP/s)65______屋顶的 ______ %Day 14

最后一列「屋顶的百分比」用实测屋顶算,不用标称。decode 那行的预期在 40% 到 70% 之间:TPOT 是下限的 1.5 到 3 倍,倒过来就是屋顶的 33% 到 67%,再加上实测带宽本来就比标称低,比值会往上飘一点。

名词解释

名词意思
算术强度(AI)arithmetic intensity,一段代码做的 FLOP 除以它搬的字节,单位 FLOP/byte。roofline 的横坐标
可达算力attainable performance,给定 AI 下屋顶的高度,= min(P, B × AI)
实际算力achieved performance,FLOP 除以实测时间,点的纵坐标
ridge pointP ÷ B,斜线和横线的交点。T4 标称 203,实测预期 170 到 200
标称值厂商规格表上的峰值,理想条件下的上限。带宽是控制器理论吞吐,算力是全部 tensor core 满载
实测屋顶用 Day 13、Day 14 测出来的 B 和 P 画的两条线
overheadGPU 空转等 CPU 发 kernel 的时间,Day 11 timeline 上的 gap。表现为点在斜线下方
kernel 效率一个 kernel 在跑的时候实际用了带宽或算力的几成。也表现为点在斜线或横线下方
tilematmul 被切成的小块,每块搬进 shared memory 算完再写回。矩阵边缘的 tile 填不满就浪费
log-log 轴两根轴都取对数。roofline 的斜线只在这种轴上是直线
np.logspace(a, b, n)生成从 10^a 到 10^b 的 n 个对数均匀点,画对数轴曲线用
np.minimum逐元素取小,和 Python 内建 min 不同,能作用在整个数组上

常见误区

**用标称值判断 ridge 附近的 workload。**AI 在 170 到 210 之间的东西,标称线说 memory-bound,实测线可能说 compute-bound。这一带的判断只能用实测线。离 ridge 远的地方无所谓。

**把点到斜线的距离当成「算术强度不够」。**AI 已经确定了横坐标,点比斜线低是因为 overhead 和 kernel 效率,不是因为 AI 低。加 batch 是往右挪,不是往上抬。这两个方向的手段完全不同,搞混了会用 batching 去治 overhead,白忙。

算字节时漏了写。y = x + 1 读 2n 字节写 2n 字节,共 4n,不是 2n。matmul 的输出 C 也要写回。漏了写,字节少算一半,AI 高估一倍,点往右偏一格。

**算 FLOP 时忘了乘 2。**一次乘加是 2 FLOP。matmul 是 2MNK 不是 MNK,decode 是 2N 不是 N。这个 2 在算 AI 时不会约掉(字节里没有它),漏了 AI 就低一半。

**拿 fp32 的标称算力画 fp16 的屋顶。**T4 fp32 是 8.1 TFLOP/s,fp16 tensor core 是 65。屋顶的高度差 8 倍,ridge 差 8 倍。Day 14 说过,画图前先确认测的是哪种精度。

**把「屋顶的 54%」当成坏消息。**batch 1 的 decode 能到实测带宽的一半以上,对 HF transformers 的默认 generate 来说是正常水平。这个百分比的用处是当基线:以后每次优化回来看它有没有变大。

参考资料

文章

文档或代码

视频

Faradawn Yang · LLM Inference Lecture: Roofline Analysis for GPU (arithmetic intensity, compute and memory bound)。对着 LLM 推理讲算术强度和两种 bound,和今天算 decode 那个点的过程是同一件事,适合画完图再看一遍对答案。
Empiric · The Roofline Model: Why Your GPU Never Hits Peak TFLOPS。标题就是今天的主题:标称峰值为什么永远达不到,以及 roofline 怎么把「达不到」拆成几段。

自测

合上笔记做。

  1. 画一张 roofline 需要哪四个数?哪几个是测出来的,哪几个是算出来的?
答案

卡的两个数:带宽 B、峰值算力 P,这两个是实测的(Day 13、Day 14)。workload 的两个数:FLOP 和字节,这两个是算出来的,不是测的。workload 唯一实测的是时间,用来算纵坐标(FLOP ÷ 时间)。

  1. 方阵 matmul N × N 的算术强度是多少?N = 4096 和 N = 256 分别落在 T4 roofline 的哪一侧?
答案

AI = 2N³ ÷ 6N² = N ÷ 3。N = 4096 时约 1365,远高于 ridge 203,在横线上,compute-bound。N = 256 时约 85,低于 203,在斜线上,memory-bound。所以测算力必须用大矩阵。

  1. decode batch 1 的点为什么在斜线下方而不是斜线上?这段距离用什么手段缩小?
答案

因为连实测带宽都没用满:一部分时间 GPU 在等 CPU 发 kernel(overhead,timeline 上的 gap),一部分时间 kernel 在跑但没跑满带宽(kernel 效率)。这两样和算术强度无关,加 batch 治不了。要靠 CUDA graph、torch.compile 这类减 launch 次数的手段,或者更好的 kernel。

  1. 标称 ridge 203、实测 ridge 185,这个差别在什么情况下会改变判断,什么情况下不会?
答案

AI 落在两个 ridge 之间(185 到 203)的 workload,标称线判 memory-bound、实测线判 compute-bound,判断会翻。AI 离 ridge 很远的(比如 decode batch 1 的 AI = 1,或 matmul 4096 的 1365),两条线给的结论一样,不受影响。所以实测值最要紧的地方是 ridge 附近。

  1. TPOT 是 15 ms、模型 1.1B 参数,这个点的横纵坐标各是多少?它是实测屋顶(带宽 270 GB/s)的百分之几?
答案

横坐标 AI = 2 × 1.1e9 ÷ 2.2e9 ≈ 1。纵坐标 = 2.2e9 ÷ 0.015 ≈ 147 GFLOP/s。AI = 1 处屋顶高度 = 270e9 × 1 = 270 GFLOP/s,所以是 147 ÷ 270 ≈ 54%。

  1. 量化(fp16 → int4)在这张图上是把点往哪个方向挪?为什么?
答案

往右挪。量化不改 FLOP(还是 2N),但权重字节从 2N 变成 0.5N,AI 从 1 变成 4。同样的斜线上,AI 高四倍可达算力就高四倍,也就是 decode 一步的时间缩到四分之一。它挪的是第二段距离(算术强度),不是第一段。

明天预告

Day 16 把 batch 从 1 扫到 128:1、4、16、64、128 五个点,每个测 decode 每步时间,算吞吐,标到今天这张图上。W1 预测 batch 到 ridge 附近才碰屋顶,明天会看到实际曲线比那早弯,而且弯的原因不是 FLOP 追上了带宽,是 KV cache 的字节随 batch 一起涨,算术强度不再等于 batch。这是对 W1 那个预测的检验,也是第一次在自己的数据上看到「KV cache 是 batching 收益的天花板」。

评论

  1. 加载中…

只保存昵称和内容。会显示你的大致地区(国家 / 省),不保存 IP 地址。

← 365 天课程目录