AI Infra
Day 9memory-bound25 min read

Day 9 · 把 TTFT 和 TPOT 分开测,再和 W1 的理论下限对账

一次 generate 的耗时里藏着两个性格完全不同的数:首 token 时间和后续每 token 时间。今天把它们拆开,各自算出理论下限,然后用一个比值回答 W1 那张表能不能信。

今天要解决的问题

Day 7 在 Colab 的 T4 上把 TinyLlama-1.1B 跑通了,Day 8 把计时改对了:前后都 torch.cuda.synchronize(),warmup 三次,取中位数。现在手里有一个数:生成 64 个 token 大约要多少毫秒。

这个数没法直接和 W1 的理论对账。因为一次 generate 里其实发生了两件性格完全相反的事:先把整段 prompt 一口气算完(prefill),再一个一个往外吐 token(decode)。Day 5 算过,这两件事一个卡算力、一个卡带宽,落在 roofline 的两边。把它们的时间加在一起除以 token 数,得到的是一个谁也不代表的平均值。

所以今天做三件事:

  1. 把总耗时拆成两个数:TTFT(首 token 时间)和 TPOT(后续每 token 时间)。
  2. 给这两个数各算一个理论下限。TPOT 的下限就是 Day 5 那个除法,换成 T4 和 TinyLlama 的数重算一遍。
  3. 算一个比值:实测 TPOT ÷ 理论下限。这个比值是整周真正的产出,也是后面一年所有优化工作的基线。

和 Day 7、8 一样,今天的实测数字要等自己跑出来填进表里。文中出现的「预期」都会给算式或依据,不会替真实数据说话。

一次 generate 里到底发生了什么

先把时间线画出来。输入一段 200 个 token 的 prompt,让模型生成 64 个新 token:

时间 → prefill 200 个 token 一起算 … decode,一步一个 token TTFT TPOT 总耗时 = TTFT + (N − 1) × TPOT,N 是生成的 token 数
一次 generate 的时间线。左边那一大块是 prefill:200 个 prompt token 一起过一遍模型,产出第一个新 token。右边每个小块是一步 decode:只算一个 token,但每步都要把全部权重搬一遍。

TTFT,time to first token,是从发出请求到第一个新 token 出来的时间。它几乎等于 prefill 的时间:整段 prompt 过一遍 22 层,算出最后一个位置的 logits,采样出第一个 token。

TPOT,time per output token,是第一个 token 之后每多生成一个 token 的平均时间。它就是一步 decode 的时间:拿上一个 token 过一遍 22 层,查 KV cache 里存好的前文,产出下一个 token。

两者的关系:

总耗时 = TTFT + (N − 1) × TPOT

N 是生成的 token 数。N = 64 时,TPOT 那一项是 63 步,TTFT 只有一次。所以生成越长,总耗时越被 TPOT 主导;prompt 越长,TTFT 越占比重。一个只看总耗时的 benchmark,换个 prompt 长度或换个 N,数字就没法比。这是要把它们拆开的第一个理由。

第二个理由是 Day 5 讲过的:它们卡在不同的地方。prefill 卡算力,decode 卡带宽。优化手段完全不同,混在一起就不知道该往哪边用力。

先算 TPOT 的理论下限

这一步是 Day 5 的除法,换成 T4 和 TinyLlama 的数字重算一遍。

TinyLlama-1.1B 的权重是 1.1B 个数字,fp16 每个 2 字节:

1.1e9 × 2 bytes = 2.2e9 bytes ≈ 2.2 GB

T4 的显存带宽标称 320 GB/s。每步 decode 至少要把权重从头到尾搬一遍:

2.2 GB ÷ 320 GB/s ≈ 0.0069 s ≈ 6.9 ms

倒过来,每秒最多 1 ÷ 0.0069 ≈ 145 个 token。

这是 batch 1 时一步 decode 的物理下限,任何软件优化都不能突破,因为它是搬运时间不是计算时间。对比 Day 5 算的 A100 跑 7B:13.5 GB ÷ 2039 GB/s ≈ 6.6 ms。两个数很接近不是巧合:TinyLlama 比 7B 小 6 倍,T4 的带宽比 A100 慢 6.4 倍,正好抵消。这也说明「小模型在便宜卡上」和「大模型在贵卡上」的 decode 速度可以差不多,决定速度的是「权重字节 ÷ 带宽」这一个比值,不是模型多大或卡多贵。

那算力这边呢?一步 decode 的运算量是 2 × 参数量 = 2 × 1.1e9 ≈ 2.2 GFLOP。T4 的 fp16 tensor core 峰值 65 TFLOP/s:

2.2e9 ÷ 65e12 ≈ 0.034 ms

搬要 6.9 ms,算只要 0.034 ms,差两百倍。算力那 0.034 ms 完全藏在搬运时间里,可以当零。TPOT 的理论下限就是 6.9 ms,只由带宽和权重字节数决定。

KV cache 那份搬运要不要算

decode 每步除了搬权重,还要读 KV cache:前面所有 token 的 k 和 v。这份要不要算进下限?

TinyLlama 用了 GQA,Day 4 讲过:k、v 的头数不是 32 而是 4,每个头 64 维,所以 k、v 向量各是 4 × 64 = 256 长,不是 d = 2048。每 token 每层存 k 和 v 两份:

2 × 256 × 2 bytes = 1 KB

22 层就是 22 KB,严格算是 22.5 KB。prompt 200 个 token 加生成到第 64 个,KV cache 总共约 264 × 22.5 KB ≈ 6 MB。和 2.2 GB 的权重比是千分之三,不影响下限。

什么时候它会变得重要?序列长到 KV cache 和权重一个量级的时候。2.2 GB ÷ 22.5 KB ≈ 98,000 个 token。TinyLlama 的上下文窗口只有 2048,远远到不了。所以在这个实验里,TPOT 的下限就是搬权重的时间,KV cache 可以忽略。这也解释了一个容易想歪的地方:GQA 把 KV cache 压小了 8 倍(从 32 个头到 4 个头),但对 batch 1 短序列的 TPOT 几乎没有影响,因为 TPOT 的大头一直是权重。GQA 省的是显存,它的收益在 batch 开大、序列拉长的时候才兑现,那是 M2 的事。

再算 TTFT 的理论下限

prefill 是把 200 个 token 一起算。运算量是 2 × 参数量 × token 数:

2 × 1.1e9 × 200 = 4.4e11 FLOP = 440 GFLOP

T4 峰值 65 TFLOP/s,全速要:

440e9 ÷ 65e12 ≈ 6.8 ms

同时权重也要搬一遍,6.9 ms。这两件事在 GPU 里是重叠的,不是串行相加,所以下限取两者中较大的那个,约 7 ms。

有意思的地方来了。算术强度 = FLOP ÷ 字节。prefill 200 个 token,每搬一个 fp16 参数(2 字节)要做 2 × 200 = 400 次运算,强度 = 200 FLOP/byte。T4 的 ridge point 是 65e12 ÷ 320e9 ≈ 203。200 个 token 的 prefill 正好压在 T4 的 ridge point 上,算力和带宽同时忙满。prompt 再长一点就 compute-bound,再短一点就 memory-bound。

这和 Day 5 说的「prefill 天然 compute-bound」不矛盾。那句话的前提是 prompt 有几千个 token。prompt 只有几十个 token 时,prefill 和 decode 一样卡带宽。所以判断哪一侧不能死记结论,要算强度和 ridge point 比。

ridge ≈ 203 decode batch 1 强度 1,可达 0.32 TFLOP/s prefill 200 tok 强度 200,正好压线 prefill 2000 tok 强度 2000,封顶 65 1 10 203 10⁴ 算术强度 FLOP/byte(对数) 65 T 0.3 T 可达算力 FLOP/s(对数)
T4 上 TinyLlama 的三个点。decode batch 1 强度 1,在斜线最底部;200 token 的 prefill 强度 200,正好在 ridge point 203 上;2000 token 的 prefill 强度 2000,稳稳在屋顶。同一个模型,prompt 长度不同,落点就不同。

TTFT 的下限还要加一样东西:采样。prefill 算完最后一个位置的 logits(32000 个数),要做 softmax 和采样或 argmax,再把 token 拷回 CPU。这些是零头,微秒级,但它们在 TTFT 里和在每步 TPOT 里都出现一次。

所以 200 token prompt 的 TTFT 理论下限约 7 ms,和 TPOT 的 6.9 ms 差不多。实测 TTFT 会明显大于 TPOT,原因不在下限,在实际达到的效率:prefill 是几个大矩阵乘,能吃到 T4 算力的一部分;但 T4 上 fp16 矩阵乘的实际效率大概只有峰值的六七成(W3 会亲手测),加上 attention 在 200 个 token 上要算 200 × 200 的相似度矩阵,在 T4 上没有 FlashAttention 可用,走的是普通 softmax 路径,这一段效率更低。所以 TTFT 实测落在 15 到 40 ms 都算正常,不要拿 7 ms 去要求它。

怎么把两个数分开测

三种方法,精度递增,代码量也递增。

方法一:两次 generate 相减

最简单。先只生成 1 个 token,那次的耗时就是 TTFT;再生成 N 个 token,总耗时减去 TTFT 除以 N − 1 就是 TPOT。

# Colab,接着 Day 8 的 model / tokenizer / inputs 往下写
import torch, statistics

def timed(fn, repeat=10):
    """warmup 3 次后测 repeat 次,返回中位数毫秒。"""
    for _ in range(3):
        fn()
    torch.cuda.synchronize()
    times = []
    for _ in range(repeat):
        start = torch.cuda.Event(enable_timing=True)
        end = torch.cuda.Event(enable_timing=True)
        start.record()
        fn()
        end.record()
        torch.cuda.synchronize()
        times.append(start.elapsed_time(end))
    return statistics.median(times)

gen = lambda n: model.generate(**inputs, max_new_tokens=n, do_sample=False,
                               pad_token_id=tokenizer.eos_token_id)

N = 64
ttft_ms  = timed(lambda: gen(1))
total_ms = timed(lambda: gen(N))
tpot_ms  = (total_ms - ttft_ms) / (N - 1)

print(f"prompt tokens : {inputs['input_ids'].shape[1]}")
print(f"TTFT          : {ttft_ms:.1f} ms")
print(f"TPOT          : {tpot_ms:.2f} ms  ({1000/tpot_ms:.0f} tok/s)")

两点说明。do_sample=False 是贪心解码,去掉采样的随机性,保证每次生成的 token 一样,时间才可比。max_new_tokens=1 那次其实包含了 generate 自身的准备工作(建 cache、处理 stopping criteria),这部分会被算进 TTFT,让它稍微偏大,但每步 decode 也有类似的框架开销,所以 TPOT 这边算出来是公平的。

方法二:streamer 给每个 token 打时间戳

transformers 提供 TextIteratorStreamer,generate 在另一个线程里跑,主线程每收到一个 token 就记一次时间。这样能看到每一步的时间,不只是平均值。

from transformers import TextIteratorStreamer
from threading import Thread
import time

streamer = TextIteratorStreamer(tokenizer, skip_prompt=True)
kwargs = dict(**inputs, max_new_tokens=64, do_sample=False,
              streamer=streamer, pad_token_id=tokenizer.eos_token_id)

torch.cuda.synchronize()
t0 = time.perf_counter()
Thread(target=model.generate, kwargs=kwargs).start()

stamps = []
for _ in streamer:                 # 每来一个 token 就迭代一次
    stamps.append(time.perf_counter() - t0)

ttft = stamps[0] * 1000
steps = [(b - a) * 1000 for a, b in zip(stamps, stamps[1:])]
print(f"TTFT {ttft:.1f} ms, TPOT median {statistics.median(steps):.2f} ms")
print("前 8 步:", [f"{s:.1f}" for s in steps[:8]])

注意这里用的是 time.perf_counter,不是 CUDA event。因为 streamer 收到 token 的时刻就是 token 已经拷回 CPU 的时刻,这本身就是一个同步点,不需要额外 sync。它测到的是「用户能看到这个 token」的时间,比方法一多算了 token 解码成文字的开销,更接近真实服务的体验。但它只能看每步的总时间,分不清 GPU 花了多少、CPU 花了多少,那是 Day 10 profiler 的活。

streamer 的好处是能看到时间序列。前几步会不会偏慢?中间有没有突然一步特别长(Colab 上偶尔有,CPU 被别的东西抢了)?这些平均值里看不出来。

方法三:手写 decode 循环,每步单独计时

绕开 generate,自己写循环:prefill 一次,然后每步用上一步的 token 和 KV cache 算下一个。这是最精确的测法,因为每步的边界都在自己手里,也是 Day 10 抓 profiler 要用的形式。

@torch.no_grad()
def manual_decode(model, input_ids, n_new=64):
    start = torch.cuda.Event(enable_timing=True); end = torch.cuda.Event(enable_timing=True)

    # prefill:整段 prompt 一起算
    start.record()
    out = model(input_ids=input_ids, use_cache=True)
    next_tok = out.logits[:, -1].argmax(-1, keepdim=True)
    end.record(); torch.cuda.synchronize()
    ttft = start.elapsed_time(end)

    past = out.past_key_values
    step_ms = []
    for _ in range(n_new - 1):
        start.record()
        out = model(input_ids=next_tok, past_key_values=past, use_cache=True)
        next_tok = out.logits[:, -1].argmax(-1, keepdim=True)
        past = out.past_key_values
        end.record(); torch.cuda.synchronize()
        step_ms.append(start.elapsed_time(end))
    return ttft, step_ms

for _ in range(3):                       # warmup
    manual_decode(model, inputs["input_ids"], 8)

ttft, steps = manual_decode(model, inputs["input_ids"], 64)
print(f"TTFT {ttft:.1f} ms")
print(f"TPOT median {statistics.median(steps):.2f} ms, "
      f"min {min(steps):.2f}, max {max(steps):.2f}")

这个版本每步都 sync 一次,sync 本身有几十微秒的开销,会让 TPOT 略偏大。但它换来的是每一步的干净数据,而且和 generate 内部做的事几乎一样(generate 本质上就是这个循环加上采样和停止条件)。三种方法测出来的 TPOT 应该在同一个范围内,差个 10% 到 20% 正常;差一倍就要回头查是不是哪一个漏了 sync 或者没 warmup。

整周真正的产出:一个比值

三个数都有了之后,算这个:

比值 = 实测 TPOT ÷ 理论下限(6.9 ms)

W2 路线图说过,这一周真正的产出不是「学会用 profiler」,是这个比值。它回答的问题是:W1 那张纸上算的表,能不能拿来预测真实机器

比值落在不同区间,说的是不同的事:

比值意思下一步
1.0–1.5几乎打到带宽上限。HF eager 模式在 T4 上不太可能做到,如果测出来是这个数,先怀疑计时漏了 sync复查 Day 8 的三条
1.5–3memory-bound 的判断成立,GPU 大部分时间确实在搬权重,剩下的是 kernel 效率和少量框架开销W1 的表可以信,后面拿它做估算
3–6有一块明显的时间不在搬权重上。1B 模型在 T4 上每步只有 6.9 ms 的 GPU 活,但 HF 的 Python 循环每步要发几十到上百个 kernel,CPU 端的开销开始和 GPU 时间一个量级Day 11 在 timeline 上找 gap
> 6overhead 主导。GPU 大部分时间在等 CPU 发指令,这时候瓶颈已经不是带宽,Day 1 三分法里的第三种缩小 gap 的两条路,Day 11 讲

对 TinyLlama 这种小模型,比值偏大是正常的,甚至是预期的。原因是模型越小,每步 GPU 的活越少,但每步要发的 kernel 数量不变(22 层,每层十几个 kernel),固定的 CPU 开销占比就越高。同样的 HF 代码换成 7B 模型在 A100 上跑,每步 GPU 活是 6.6 ms 但 kernel 数量差不多,比值反而会更接近 1.5 到 3。比值大不一定是卡慢,可能是活太少。这是今天最容易得出错误结论的地方。

写下我在跑之前的预期,以后对照:Colab 的 CPU 不快,HF eager 每步 decode 大概会在 15 到 30 ms 之间,比值在 2 到 4。这个预期的依据是每步几十个 kernel、每个 launch 5 到 10 微秒的量级,再加 Python 层的逻辑,总 CPU 开销大约 5 到 15 ms,和 6.9 ms 的 GPU 时间相加。如果实测比这个还大,先查 Day 8 的三条(sync、warmup、中位数),再去 Day 11。

一步 decode 的时间从哪来(T4 · TinyLlama-1.1B · batch 1) 算力需要 0.034 ms(2.2 GFLOP ÷ 65 TFLOP/s) 搬权重需要 6.9 ms(2.2 GB ÷ 320 GB/s)= 理论下限 预期实测 15–30 ms,比值 2–4 搬权重 kernel 效率损失 CPU 发 kernel、Python 逻辑(gap) 第三行是跑之前写下的预期,不是实测。实测填进下面的记录表。
一步 decode 的时间账。算力那一条细到几乎看不见,搬权重那一条是物理下限,预期实测比下限多出来的部分主要是 CPU 端开销。比值就是第三条和第二条的长度比。

记录表

跑完填这张表。三种方法都测,互相校验。

备注
卡型号(nvidia-smi)Colab 可能换卡,每次记
模型 / dtypeTinyLlama-1.1B / fp16
prompt token 数inputs['input_ids'].shape[1]
生成 token 数 N64
TTFT,方法一ms两次 generate 相减
TTFT,方法三ms手写循环
TPOT 中位数,方法一ms
TPOT 中位数,方法二msstreamer
TPOT 中位数,方法三ms手写循环
TPOT min / max,方法三ms看抖动
理论下限6.9 ms2.2 GB ÷ 320 GB/s
比值 = TPOT ÷ 6.9整周的产出
tok/s = 1000 ÷ TPOT对比上限 145
TTFT ÷ TPOT预期 2 到 5 倍

最后一行是个副产品。TTFT 和 TPOT 的理论下限差不多都是 7 ms,实测 TTFT 却会明显大于 TPOT。这个倍数说的是「prefill 那几个大矩阵乘和 attention 在 T4 上的效率有多低」,W3 会专门测。

顺手算三个变化

有了公式,几个常见问题不用跑就能回答。

prompt 从 200 变到 2000,哪个数变? TPOT 几乎不变:权重还是 2.2 GB,KV cache 从 4.5 MB 涨到 45 MB,仍然是权重的 2%。TTFT 大约涨 10 倍:运算量线性涨到 4.4 TFLOP,68 ms 起,而且 attention 的 n² 项从 200² 涨到 2000²,涨 100 倍,这部分在 T4 上没有 FlashAttention 兜底,实测涨幅可能超过 10 倍。

换成 Qwen2.5-0.5B,TPOT 下限是多少? 0.5B × 2 bytes ≈ 1 GB,1 ÷ 320 ≈ 3.1 ms。模型减半,下限减半。但 kernel 数量差不多(24 层),CPU 开销不变,所以实测 TPOT 不会减半,比值会更大。这是「模型越小比值越大」的一个现成检验,有空可以跑一下。

同一个 TinyLlama 搬到 A100 上呢? 2.2 GB ÷ 2039 GB/s ≈ 1.1 ms。下限缩 6 倍,但如果 CPU 开销还是 10 ms 量级,实测 TPOT 大概只从 20 ms 降到 12 ms,比值飙到 10 以上。换更快的卡对 overhead-bound 的程序几乎没用,这是 Day 1 三分法的直接推论,Day 11 会在 timeline 上看到它。

batch 从 1 变到 8,两个数各怎么动

W3 的 Day 16 要把 batch 从 1 扫到 128,今天先用公式预演一下 batch 8,知道该期待什么形状。

把同一段 200 token 的 prompt 复制 8 份一起送进去。decode 每步搬的权重还是 2.2 GB,只搬一次,8 个序列共用;算力从 2.2 GFLOP 变成 17.6 GFLOP,在 T4 上要 0.27 ms,仍然远小于 6.9 ms 的搬运时间。KV cache 读取涨 8 倍,到 48 MB,仍是权重的 2%。所以 TPOT 的下限几乎不变,还是 6.9 ms,但这一步产出了 8 个 token,吞吐上限从 145 变成 1160 tok/s。这就是 Day 5 讲的「加 batch 几乎不加时间」在 T4 上的具体数字。

TTFT 就不一样了。prefill 的运算量是 2 × 1.1e9 × 200 × 8 = 3.5 TFLOP,T4 全速要 54 ms,比 batch 1 的 6.8 ms 多 8 倍。prefill 本来就压在 ridge point 上,batch 一开,强度变成 1600,彻底进入 compute-bound,时间随 batch 线性涨。所以 batch 让 TPOT 免费、让 TTFT 按倍数付钱。推理服务里「首字慢、后面快」的体感,一半来自这里。

顺手一算就知道 batch 加到多大 TPOT 才开始涨:算的时间追上搬的时间,即 0.034 ms × batch = 6.9 ms,batch ≈ 203。正好是 T4 的 ridge point,和 Day 5 在 A100 上算出 153 是同一件事。但 Day 16 实测时转折点大概率会早于 203,原因有三个,那天再拆:显存装不下那么大的 batch、kernel 在小 batch 下效率不足、CPU 开销在 GPU 时间变长后占比变化。今天只要记住预期形状:TPOT 在 batch 1 到几十之间几乎是平的,TTFT 从第一步就线性涨

代码上,batch 8 只是把 inputs 换成 8 份一样的 prompt:

prompts = [prompt] * 8
inputs8 = tokenizer(prompts, return_tensors="pt", padding=True).to("cuda")
ttft8, steps8 = manual_decode(model, inputs8["input_ids"], 64)
print(f"batch 8: TTFT {ttft8:.1f} ms, TPOT {statistics.median(steps8):.2f} ms, "
      f"吞吐 {8 * 1000 / statistics.median(steps8):.0f} tok/s")

同一段 prompt 复制 8 份是为了让 8 个序列长度一致,不用处理 padding 和 attention mask 的差异。真实服务里请求长短不一,那正是 M2 的 continuous batching 要解决的事,今天不碰。

名词解释

名词意思
TTFTtime to first token,从请求发出到第一个新 token 出来的时间,约等于 prefill 时间加一次采样
TPOTtime per output token,首 token 之后平均每多生成一个 token 的时间,等于一步 decode 的时间
ITLinter-token latency,和 TPOT 同义,有的 benchmark 用这个词
端到端延迟从请求到最后一个 token,= TTFT + (N − 1) × TPOT
吞吐每秒生成的 token 数,batch 1 时 = 1000 ÷ TPOT(ms)
prefill把整段 prompt 一次过完模型,产出第一个 token 和整段的 KV cache
decode每步只算一个新 token,查 KV cache 拿前文
贪心解码do_sample=False,每步取概率最大的 token,结果确定,时间可比
streamertransformers 里 generate 每产出一个 token 就往外推的机制,TextIteratorStreamer 是可迭代的版本
past_key_valuesHF 里 KV cache 的名字,手写 decode 循环时每步传进去再接回来
CUDA eventGPU 时间线上的标记,record() 打点,elapsed_time() 算两点之间的 GPU 时间
GQAgrouped-query attention,多个 q 头共享一组 k、v 头。TinyLlama 是 32 个 q 头共享 4 个 KV 头
ridge point卡的算力 ÷ 带宽。T4 是 65e12 ÷ 320e9 ≈ 203 FLOP/byte

常见误区

拿总耗时除以 token 数当 TPOT。这样算出来的数包含了 TTFT 那一大块,N 越小被拉得越高。N = 8 时 TTFT 占比可能有一半,算出来的「平均每 token」比真 TPOT 大 50%,换个 N 数字就变。TTFT 和 TPOT 必须分开报。

看到比值是 3 就说「T4 带宽只用了三分之一」。比值大的原因可能是 GPU 在等 CPU,不是带宽没用满。带宽利用率要看 GPU 真正在跑 kernel 的那段时间里搬了多少字节,而不是整步的墙钟时间。Day 10 的 profiler 能把这两段拆开。

以为 GQA 会让 decode 明显变快。batch 1、序列 2048 以内,KV cache 是权重的 2% 以下,把它压小 8 倍对 TPOT 的影响不到 2%。GQA 的收益是显存,是让 batch 能开更大,间接提高吞吐,不是直接降低单步延迟。

把 prefill 一律当 compute-bound。prompt 短的时候不是。T4 上 200 个 token 正好压在 ridge point 上,几十个 token 的 prompt 和 decode 一样卡带宽。要不要算强度,取决于 prompt 长度乘 2 和 ridge point 谁大。

streamer 测出的 TPOT 和 CUDA event 测出的差 20% 就慌。streamer 多算了 token 解码成文字、线程切换、Python 迭代的时间,它测的是用户体感。两种方法量的东西不完全一样,差 10% 到 20% 是正常的;差一倍才是有一边测错了。

忘了 Colab 会换卡。今天分到 T4,明天可能是别的。所有理论下限都按 320 GB/s 算,如果卡换了,下限要重算,不然比值毫无意义。每次跑之前 nvidia-smi 先记卡型号。

参考资料

文章

文档与代码

视频

SambaNova · Prefill vs Decode。几分钟讲清两个阶段为什么一个吃算力一个吃带宽,配今天第一张图看。
Faradawn Yang · AI Optimization Lecture 01 - Prefill vs Decode。逐段讲上面那篇 NVIDIA 博客,适合读完文章再听一遍。

自测

合上笔记做。

  1. TinyLlama-1.1B fp16 在 T4 上,batch 1 的 TPOT 理论下限是多少?写出算式。为什么算力那部分可以忽略?
答案

权重 1.1e9 × 2 bytes = 2.2 GB,除以带宽 320 GB/s ≈ 6.9 ms,约 145 tok/s。算力需要 2 × 1.1e9 = 2.2 GFLOP,除以 65 TFLOP/s ≈ 0.034 ms,是搬运时间的两百分之一,完全藏在搬运时间里。

  1. 为什么 TTFT 和 TPOT 必须分开测?说出两个理由。
答案

一,它们卡在不同地方:prefill 卡算力,decode 卡带宽,合在一起的平均值谁也不代表,不知道该往哪边优化。二,总耗时 = TTFT + (N − 1) × TPOT,换 prompt 长度或换 N 数字就变,分开报才能跨实验比较。

  1. 200 个 token 的 prompt 在 T4 上做 prefill,算术强度是多少?落在 roofline 哪一侧?
答案

每个参数搬 2 字节,做 2 × 200 = 400 次运算,强度 200 FLOP/byte。T4 的 ridge point 是 65e12 ÷ 320e9 ≈ 203,200 正好压在线上,算力和带宽同时忙满。prompt 更长就 compute-bound,更短就 memory-bound。

  1. 实测 TPOT 是理论下限的 4 倍,能不能说「带宽只用了 25%」?为什么?
答案

不能。比值大可能是 GPU 在等 CPU 发 kernel(overhead),不是搬运本身慢。带宽利用率要看 GPU 真正跑 kernel 那段时间里搬了多少字节。小模型每步 GPU 活少但 kernel 数量不减,CPU 开销占比天然高,比值大不一定是卡慢。要拆开 GPU 时间和 CPU 时间得用 profiler。

  1. TinyLlama 用 GQA 把 KV cache 压小了 8 倍,这对 batch 1、序列 2048 以内的 TPOT 有多大影响?
答案

几乎没有。每 token KV cache 22.5 KB,2048 个 token 才 46 MB,是 2.2 GB 权重的 2%。压小 8 倍省下的不到 2%。GQA 的收益是显存,让 batch 能开更大、序列能更长,收益在 M2 的 batching 实验里才兑现。

  1. 同一段代码、同一个 TinyLlama,从 T4 换到 A100,TPOT 下限变多少?实测大概会怎样?
答案

下限从 6.9 ms 变成 2.2 GB ÷ 2039 GB/s ≈ 1.1 ms,缩 6 倍。但如果每步 CPU 开销仍是 10 ms 量级,实测只会从大约 20 ms 降到 12 ms 左右,比值从 3 变成 10 以上。换快卡对 overhead-bound 的程序帮助很小,这是三分法的直接推论。

明天预告

Day 10 用 torch.profiler 抓一次 generate,导出 chrome trace,在 Perfetto 里打开。目标是把今天那个比值拆开:一步 decode 的 20 多毫秒里,GPU 真正在跑 kernel 的有多少毫秒,是哪几个 kernel,各自对应模型的哪一部分。验收标准是列出 top 5 耗时 kernel,并说出每个是在算注意力、算 FFN、还是在做 layernorm。

评论

  1. 加载中…

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

← 365 天课程目录