大语言模型的性能问题,不能等到部署完成、压测脚本跑完才暴露。很多时候,项目还停留在选型阶段,就需要回答“7B 模型在 A100 上生成一个 token 大概要多久”“8 万 token 上下文会占多少显存”“训练 1 万亿 token 需要多少张卡”。这些问题可以凭经验猜测,也可以回到模型参数、数据类型、序列长度和硬件规格这些最基础的数值,用几条公式推算出延迟、吞吐和显存占用的合理区间。这就是从第一性原理出发的 LLM 性能建模:不依赖完整集群,不做繁琐压测,先从最底层的数量级判断开始。
本文按这条主线展开:先建立估算模型,解释 Prefill 和 Decode 为什么是两种不同瓶颈;再以 7B 模型为例做一次完整计算;随后扩展到训练场景,说明 FLOPs、显存和通信怎么估算;最后给出实测校准方法和工程中常见的估算误区。读完以后,你可以对着任意一个模型配置和 GPU 规格,在 10 分钟内给出第一版性能估算,并知道该在哪个环节用真实测量修正它。
1. 从第一性原理建模,先想清楚要算哪些数
1.1 四个核心问题:时延、吞吐、显存、成本
性能建模不是算一个“性能分数”,而是要回答四个相互关联的问题。
| 要回答的问题 | 常用指标 | 第一性原理输入 |
|---|---|---|
| 第一个 token 要等多久 | TTFT(Time To First Token) | Prefill 阶段计算量、batch 大小、GPU 算力 |
| 输出一个 token 要多久 | TPOT(Time Per Output Token) | Decode 阶段每 token 计算量与权重搬运字节数 |
| 每秒能处理多少请求 | 吞吐量 | batch 大小、Prefill/Decode token 比例、调度方式 |
| 最长能支持多少上下文 | 显存峰值 | 权重字节数、KV Cache、激活值、框架预留 |
这四个问题不是孤立的。显存不足会让 batch 变小,batch 变小会让吞吐下降;上下文变长会让 KV Cache 膨胀,进而压缩 batch 空间。建模的价值在于,把所有变量放在同一个公式体系里,改动一个输入,就能看到它对时延、吞吐和显存的连锁影响。
1.2 为什么需要建模而不是直接压测
压测当然必要,但在很多场景下,压测来得太晚,或者成本太高。
首先是选型阶段。模型还没部署,机器还没租好,团队需要先判断“7B 模型用一张卡能不能跑”“量化到 INT4 后够不够快”。这时没有环境可压测,只能靠估算。
其次是定位问题。压测只能告诉你“服务慢”,建模能告诉你是慢在哪个环节。如果实测 decode 单 token 延迟接近权重搬运的耗时,瓶颈在显存带宽;如果远高于估算值,问题可能在注意力计算、Python 开销、服务框架调度或并发排队上。
再次是框架隔离。压测结果受 vLLM、TGI、SGLang 等框架的预分配、连续批处理、显存碎片影响很大。第一性原理模型算的是“模型本身的下限”,先有下限,才能判断框架有没有把性能发挥出来。
注意,建模不能替代压测。估算给的是数量级和上下界,最终上线前还是要用真实请求做验证。合理做法是“建模给先验,实测做校准,再更新模型”。
1.3 Prefill 与 Decode 的瓶颈完全不同
理解 LLM 推理性能,第一步是分清两个阶段。
Prefill 阶段处理整个输入 prompt。它像一次大的矩阵乘法,输入 token 越多,计算量越大。这个阶段可以充分利用 GPU 的并行算力,通常属于计算密集(compute-bound),表现为高 GPU 利用率、单次耗时随输入长度增长。
Decode 阶段逐 token 生成。每生成一个 token,都需要把模型所有权重读一遍。对大多数 GPU 来说,读取 14GB 权重的耗时,远大于在 14GB 权重上做矩阵乘法的耗时。因此 Decode 阶段通常属于访存密集(memory-bound),表现为 GPU 利用率不高,延迟主要受显存带宽限制。
这解释了为什么“一个 token 要多久”和“第一个 token 要多久”必须分开估算。把两者混在一起,用同一套利用率去算,结果会偏差一个数量级。
2. 三个基础量:FLOPs、内存字节数和算术强度
2.1 计算量:Transformer 前向推理的 2NT 近似
对于 decoder-only 的 Transformer,业界常用的经验公式是:处理 T 个 token 的前向推理,计算量约为 2NT。
其中 N 是模型参数量,T 是处理的 token 数。这个近似把多头注意力、MLP、LayerNorm 等所有计算全部折算进去。对 1B 以上的模型,它的误差通常在可接受范围内;但对非常小的模型和极长序列,注意力分数计算 O(T²d) 这一项会变大,需要单独补上。
按这个公式,一次 Prefill 输入 T 个 token,计算量约为 2NT。Decode 阶段每生成一个 token,计算量约为 2N。前者是整段输入一起算,后者是每 token 单独算,这是两者时延差距的计算层原因。
2.2 内存量:权重、KV Cache 与激活值
显存占用主要来自四部分:权重、KV Cache、激活值和框架开销。其中权重和 KV Cache 可以用公式直接估算。
权重内存等于参数量乘以每参数字节数,取决于数据类型。
| 数据类型 | 每参数字节数 | 常见场景 |
|---|---|---|
| FP32 | 4 | 训练主权重、优化器状态 |
| FP16 | 2 | 推理权重、部分训练 |
| BF16 | 2 | 训练主流、推理权重 |
| FP8 | 1 | 新一代 GPU 上的推理与训练加速 |
| INT8 | 1 | 推理量化 |
| INT4 | 0.5 | 推理量化,显存压力显著下降 |
例如 7B 模型,FP16 权重是 14GB,INT4 量化后约 3.5GB。数据类型直接改变权重搬运量,也就直接改变 Decode 阶段的理论时延,这是后续估算的核心参数。
KV Cache 的估算公式是:
KV Cache 字节数 = 2 × 层数 × KV 头数 × 每头维度 × 序列长度 × 每元素字节数对于多头注意力,KV 头数 × 每头维度等于隐藏维度 d_model,公式可以简化为每 token 约 2 × 层数 × d_model × 字节数。以 32 层、d_model 4096、FP16 为例,每个 token 的 KV Cache 约 0.5MB。2048 token 的输入需要约 1GB,32768 token 需要约 16GB。这还只是一个请求,batch 为 8 时直接翻 8 倍。
激活值在 Prefill 阶段占比很高,具体取决于 batch、序列长度、层数、隐藏维度和是否使用激活重计算。它不像 KV Cache 那样能用简单公式一算到底,通常用显存统计工具实测确认。
2.3 算术强度:用 Roofline 思路判断瓶颈
算出 FLOPs 和字节数之后,需要判断瓶颈落在算力还是带宽上。这时用算术强度(Arithmetic Intensity):每搬运 1 字节数据,能完成多少次浮点运算。
算术强度 = FLOPs / 内存字节数Prefill 阶段处理 T 个 token,计算量是 2NT,权重和 KV Cache 的写入量约为 N × 字节数加 KV 字节数。当 T 较大时,算术强度随 T 增长,通常远高于 GPU 的“平衡点”,属于计算密集。
Decode 阶段每 token 计算量是 2N,需要搬运的权重字节数是 N × 字节数。算术强度约等于 2 除以字节数。FP16 时约为 1,远低于平衡点,属于访存密集。
这个判断很重要:Decode 阶段无论 GPU 算力多高,只要显存带宽不变,延迟就基本固定在“权重字节数 / 有效带宽”附近。给 7B 模型换更快的 GPU,如果带宽提升不大,Decode 速度也不会明显提升。
3. 手算一次 7B 模型的推理性能
3.1 确定输入参数
下面用一个典型配置做完整计算。所有数字都是公开规格和常见假设,实际项目中需要替换成自己的硬件型号。
| 参数 | 取值 | 说明 |
|---|---|---|
| 模型参数量 N | 7e9 | 7B 级别 |
| 数据类型 | FP16 | 每参数 2 字节 |
| GPU | A100 80G | BF16/FP16 峰值算力约 312 TFLOPS,HBM 带宽约 2 TB/s |
| 计算利用率 | 50% | 大矩阵乘法达不到理论峰值 |
| 带宽利用率 | 80% | 顺序读权重时相对容易接近峰值 |
| Prefill 输入长度 | 2048 | 单请求 |
这些假设代表“合理的乐观估计”。实际运行时,计算利用率可能是 30% 到 60%,带宽利用率可能是 50% 到 85%,所以算出来的应该是区间而不是精确值。
3.2 先算 Decode,再算 Prefill
先算 Decode,因为它决定了大模型交互的“手感”。
权重字节数:
7e9 × 2 = 14 GB计算时间:
2 × 7e9 = 14e9 FLOPs 14e9 / (312e12 × 0.5) ≈ 0.09 ms带宽时间:
14e9 / (2e12 × 0.8) ≈ 8.75 ms取两者最大值,Decode 单 token 延迟约 8.75ms,折算约 114 token/s。可见计算时间不到带宽时间的 1/100,瓶颈完全在显存带宽。
再算 Prefill。输入 2048 token 的总计算量:
2 × 7e9 × 2048 = 2.87e13 FLOPs按 156 TFLOPS 有效算力:
2.87e13 / 1.56e14 ≈ 0.18 s也就是说,TTFT 大约在 0.2 秒量级,不含网络传输和调度排队。如果把它平均到每个 token,大约是 0.09ms,看起来很快,但用户感知的是整段输入处理完之后才开始输出,所以 Prefill 300ms 和 Decode 10ms 必须分开看。
KV Cache 显存:
2 × 32 × 4096 × 2 = 0.5 MB/token 2048 token → 约 1 GB 32768 token → 约 16 GB权重 14GB 加上 16GB KV Cache,再加上激活值和 CUDA 上下文,80GB 显存勉强够单请求跑 32K 上下文。这也是为什么长上下文场景必须考虑 KV Cache 量化或 GQA 结构。
3.3 把估算过程写成可复用脚本
手算只能验证一次,实际项目中会把公式写成函数,方便批量对比不同模型、不同 GPU、不同精度。
def estimate_llm_inference( n_params: float, # 模型参数量,例如 7e9 seq_prefill: int, # Prefill 阶段输入 token 数 n_layers: int, d_model: int, kv_bytes_per_elem: int = 2, # KV Cache 每元素字节数,FP16 为 2 weight_bytes_per_param: float = 2.0, # 权重每参数字节数 gpu_fp16_flops: float = 312e12, # GPU 峰值算力 gpu_bandwidth_bps: float = 2e12, # 显存带宽 compute_util: float = 0.5, # 计算利用率 bandwidth_util: float = 0.8, # 带宽利用率 ): flops_prefill = 2.0 * n_params * seq_prefill flops_per_token_decode = 2.0 * n_params weight_bytes = n_params * weight_bytes_per_param prefill_time_s = flops_prefill / (gpu_fp16_flops * compute_util) decode_compute_s = flops_per_token_decode / (gpu_fp16_flops * compute_util) decode_memory_s = weight_bytes / (gpu_bandwidth_bps * bandwidth_util) decode_time_s = max(decode_compute_s, decode_memory_s) kv_per_token = 2.0 * n_layers * d_model * kv_bytes_per_elem kv_bytes = kv_per_token * seq_prefill return { "prefill_time_s": prefill_time_s, "ttft_s": prefill_time_s, "decode_ms_per_token": decode_time_s * 1000, "decode_tokens_per_s": 1.0 / decode_time_s, "kv_cache_gb": kv_bytes / 1e9, } result = estimate_llm_inference( n_params=7e9, seq_prefill=2048, n_layers=32, d_model=4096, ) for k, v in result.items(): print(f"{k}: {v:.3f}" if isinstance(v, float) else f"{k}: {v}")运行后得到一组估算值:
prefill_time_s: 0.184 ttft_s: 0.184 decode_ms_per_token: 8.750 decode_tokens_per_s: 114.286 kv_cache_gb: 1.074脚本的核心逻辑就是“计算时间和带宽时间取最大值”。这个脚本没有考虑注意力计算的额外耗时、连续批处理对带宽的复用、以及多请求并发时的调度开销,所以它给出的更适合作为下限参考。
4. 从推理扩展到训练:FLOPs、显存与通信
4.1 训练总计算量约为 6NT
推理前向传播约 2NT,训练还要算反向传播。反向传播的计算量约为前向传播的两倍,因此训练一个 epoch 的总计算量约为:
总的训练 FLOPs ≈ 6 × N × T这里的 T 是所有训练样本的 token 总数。以 7B 模型训练 1 万亿 token 为例:
6 × 7e9 × 1e12 = 4.2e22 FLOPs假设在 8 张 A100 上训练,每张卡有效算力约 156 TFLOPS:
4.2e22 / (8 × 1.56e14) ≈ 3.37e7 秒 ≈ 390 天这个结果说明,7B 模型在 8 张 A100 上训练 1 万亿 token,需要一年以上。如果把 GPU 数量提升到 64 张,约 49 天。这个数量级判断足以在项目立项阶段排除不合理的算力方案。
4.2 训练显存:模型状态、梯度和激活
训练显存比推理复杂得多。除了权重,还要保存梯度和优化器状态。混合精度 Adam 训练时,每个参数大约需要:
| 项 | 每参数字节数 |
|---|---|
| FP16 权重 | 2 |
| FP16 梯度 | 2 |
| FP32 主权重 | 4 |
| Adam 一阶动量 m | 4 |
| Adam 二阶动量 v | 4 |
| 合计 | 16 |
7B 模型的模型状态约 112GB,远超过单张 A100 的 80GB。这就是为什么训练 7B 模型必须做分布式并行或 ZeRO 分片,而不像推理那样一张卡放权重就够了。
激活值在训练时同样很大,尤其在大 batch 和长序列场景。激活重计算(activation checkpointing)用额外一次前向传播换回显存,是一种典型的“用算力换空间”取舍,训练脚本里通常会开启。
4.3 分布式训练中通信和分片策略的影响
模型状态放不下时,需要用数据并行加 ZeRO 分片。不同策略的显存节省和通信成本差异明显。
| 方案 | 每卡保存的模型状态 | 每步通信量 |
|---|---|---|
| DDP(数据并行) | 每卡完整副本 | 一次全量梯度 AllReduce |
| ZeRO-1 | 优化器状态分片 | 梯度 ReduceScatter + 参数 AllGather |
| ZeRO-2 | 优化器状态 + 梯度分片 | 同上,通信略增 |
| ZeRO-3 | 参数、梯度、优化器全部分片 | 每层前向/反向额外 AllGather,通信量最高 |
DDP 的通信量约为权重字节数的两倍。7B 模型 FP16 梯度约 14GB,一次 AllReduce 实际传输约 28GB。在 NVLink 带宽约 50GB/s 量级的集群上,这是几十毫秒到几百毫秒量级的开销;小模型加多卡时,通信甚至可能超过计算时间。
从第一性原理估算训练性能,不能只看 GPU 算力,必须把“模型状态放不放得下”和“通信要多久”一起算进去。如果模型状态超过单卡显存,先决定分片策略,再算每步耗时。
5. 用实测校准估算,而不是停留在纸面
5.1 实测需要采集哪些指标
建模的价值在于可修正。拿到估算值之后,需要跑一组小规模测量,把估算和实际对齐。
最需要采集的指标是:
| 指标 | 采集方式 | 用途 |
|---|---|---|
| Prefill 耗时 | 单次 forward 计时 | 校准计算利用率 |
| Decode 单 token 耗时 | 逐 token 生成计时 | 校准带宽利用率 |
| 显存峰值 | nvidia-smi 或 torch.cuda.max_memory_allocated | 校验 KV Cache 和激活估算 |
| GPU 利用率 | 采样工具 | 判断是否像预期那样 Prefill 高、Decode 低 |
采样 GPU 利用率可以用命令行工具:
nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv -l 1想要看每个 kernel 的耗时分布,可以在 CUDA 环境下使用 nsys 和 ncu。nsys profile看整体时间线,ncu --set full看每个 kernel 的算力、带宽和利用率。这些工具输出的实际瓶颈,往往能直接验证估算时假设的利用率是否有偏差。
5.2 一个最小测量脚本
在没有服务框架的情况下,先用 Transformers 跑一个最小测量脚本,把 Prefill 和 Decode 分开计时。
import time import torch from transformers import AutoModelForCausalLM, AutoTokenizer device = "cuda" model_name = "your-org/your-7b-model" # 替换成实际模型 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float16 ).to(device).eval() prompt = "The capital of France is " * 200 inputs = tokenizer(prompt, return_tensors="pt").to(device) # warmup,先触发 CUDA kernel 和显存分配 with torch.no_grad(): model(**inputs, use_cache=True) torch.cuda.synchronize() # Prefill 计时 start = time.perf_counter() with torch.no_grad(): out = model(**inputs, use_cache=True) torch.cuda.synchronize() prefill_s = time.perf_counter() - start # Decode 计时:逐 token 生成 input_ids = out.logits.argmax(dim=-1)[:, -1:] start = time.perf_counter() with torch.no_grad(): for _ in range(128): out = model(input_ids, use_cache=True) input_ids = out.logits.argmax(dim=-1)[:, -1:] torch.cuda.synchronize() decode_s = time.perf_counter() - start print(f"prefill: {prefill_s:.3f}s for {inputs['input_ids'].shape[1]} tokens") print(f"decode: {decode_s / 128 * 1000:.3f} ms/token")脚本的关键是 warmup 和torch.cuda.synchronize()。没有 warmup,第一次 forward 会包含 CUDA context 初始化和显存分配,测出的时间偏大;没有 synchronize,测到的是 GPU 异步执行之前的时间,几乎总是偏小。
5.3 估算值和实测值对不上时从哪里找原因
实测结果与估算不一致很常见。校准的思路不是直接改公式,而是反推“哪个假设错了”。
估算明显快于实测,优先检查以下方向:
- 计算利用率没有达到 50%,小 batch 时大矩阵乘法无法喂饱 GPU。
- Decode 阶段不是单纯的权重搬运,还要读取 KV Cache,序列越长,KV 读取开销越大。
- LayerNorm、RMSNorm、RoPE、注意力 softmax 这些非矩阵乘算子占用大量 launch 时间。
- Python 和 PyTorch 调度开销在短序列上占比很高。
- 显存带宽实际利用率低于 80%。
实测快于估算,可能原因:
- 权重被量化成了更低精度,实际搬运字节数小于估算。
- 使用了 GQA,KV Cache 显著缩小,Decode 阶段带宽压力降低。
- batch 大于 1,多个请求共享权重复用,摊薄了权重搬运成本。
校准的最终结果,是把公式里的利用率参数修正成“这台机器、这个模型、这个 batch 下的实测值”。以后换模型规模、换 GPU 时,再拿同一套校准后的参数去估算,准确度会明显提升。
6. 五个常见的性能估算误区
6.1 误区对照表
下面是工程实践里最容易出现的五类估算错误。每一条都可以对照自己的估算过程检查一遍。
| 误区 | 典型现象 | 根因 | 正确处理 | | --- | --- | --- |