3. 性能基准测试

聊到 Batch 推理优化,有个问题我经常被问到:「你怎么知道你的优化有效?」

嗯,这就要靠基准测试了。没有基准,你所有的调优都是盲人摸象。我在项目中见过太多团队,花了两周调参数,结果上线后发现吞吐量反而下降了——就是因为没有一套靠谱的测试流程。

这一章,我们就来聊聊怎么设计一个能打、能复现、能说服人的基准测试。

3.1 基准测试的核心设计原则

说白了,基准测试就三个目标:可复现、可对比、可解释。

  • 可复现:你今天的测试结果,明天还能跑出来。环境、数据、模型版本都得锁死。
  • 可对比:不同 Batch 大小、不同精度、不同硬件,你得能公平地比。
  • 可解释:性能好了或差了,你得知道为什么。不能只给个数字。

我个人习惯:每次测试前,先跑一个「基线」——通常是 Batch Size=1、FP32、不做任何优化。然后所有优化方案都跟这个基线比。这样你心里就有底了。

3.2 关键性能指标(KPI)

你想想看,做推理优化,我们到底在优化什么?三个维度:吞吐量、延迟、显存。这三者经常互相打架。

3.2.1 吞吐量(Throughput)

单位时间内能处理多少个请求。通常用 requests/s 或 tokens/s 表示。

  • 对于 LLM,我更关注 输出 tokens/s,因为生成阶段才是瓶颈。
  • 对于 CV 模型,关注 images/s。

我曾经在一个项目中,把 Batch Size 从 1 调到 32,吞吐量提升了 8 倍。但代价是延迟暴涨——这个后面会讲。

3.2.2 延迟(Latency)

单个请求从发起到返回的时间。注意,这里要区分 P50、P95、P99。

  • P50:一半请求比这个快,适合看整体表现。
  • P99:99% 的请求比这个快,适合看尾延迟。在线服务尤其关注这个。

我建议你至少记录 P50 和 P99。因为有时候平均延迟好看,但 P99 可能已经炸了。

3.2.3 显存(Memory)

GPU 显存是硬约束。Batch 越大,显存占用越高。一旦 OOM,一切归零。

  • 记录 峰值显存(peak memory)和 稳态显存。
  • 注意:显存占用不是线性的。模型权重、KV Cache、中间激活,各有各的规律。

避坑指南:我曾经在测试时只看了吞吐量,没注意显存。结果上线后,用户并发一上来,直接 OOM 崩溃。从那以后,我每次测试都会把显存监控加上。

3.3 测试工具与脚本编写

工具方面,我推荐几个常用的:

  • PyTorch Profiler:自带,适合看算子级性能。
  • NVIDIA Nsight Systems:系统级 profiling,能看到 GPU 利用率、数据搬运等。
  • 自定义脚本:最灵活,适合做批量测试。

下面是我自己写的一个基准测试脚本模板,你可以直接拿去改:

import torch
import time
import numpy as np
from transformers import AutoModelForCausalLM, AutoTokenizer

model_name = "meta-llama/Llama-2-7b-hf"
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16).cuda()
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 测试参数
batch_sizes = [1, 4, 8, 16, 32]
input_length = 128
output_length = 64
num_warmup = 10
num_runs = 50

results = []

for bs in batch_sizes:
    # 构造输入
    inputs = tokenizer(["Hello, how are you?"] * bs, return_tensors="pt", padding=True, truncation=True, max_length=input_length)
    inputs = {k: v.cuda() for k, v in inputs.items()}
    
    # Warmup
    for _ in range(num_warmup):
        with torch.no_grad():
            outputs = model.generate(**inputs, max_new_tokens=output_length)
    
    # 正式测试
    torch.cuda.synchronize()
    start_time = time.time()
    latencies = []
    
    for _ in range(num_runs):
        torch.cuda.synchronize()
        t0 = time.time()
        with torch.no_grad():
            outputs = model.generate(**inputs, max_new_tokens=output_length)
        torch.cuda.synchronize()
        t1 = time.time()
        latencies.append(t1 - t0)
    
    # 统计
    latencies = np.array(latencies)
    throughput = bs * output_length / latencies.mean()  # tokens/s
    p50 = np.percentile(latencies, 50)
    p99 = np.percentile(latencies, 99)
    
    # 显存
    memory = torch.cuda.max_memory_allocated() / 1024**3  # GB
    
    results.append({
        "batch_size": bs,
        "throughput_tokens_per_s": round(throughput, 2),
        "latency_p50_ms": round(p50 * 1000, 2),
        "latency_p99_ms": round(p99 * 1000, 2),
        "peak_memory_gb": round(memory, 2)
    })
    
    torch.cuda.reset_peak_memory_stats()

# 打印结果
import pandas as pd
df = pd.DataFrame(results)
print(df.to_string(index=False))

注意:上面的脚本假设你用的是 HuggingFace 的 generate 函数。如果你用 vLLM 或 TensorRT-LLM,接口会不同,但测试逻辑是一样的。

3.4 知识体系图:基准测试的核心逻辑

下面这张图,是我自己总结的基准测试流程。你照着这个走,基本不会漏掉什么。

基准测试核心流程 1. 定义测试目标 2. 选择关键指标 3. 搭建测试环境 4. 编写测试脚本 5. 运行 & 分析结果 关键指标 • 吞吐量 (tokens/s) • 延迟 P50 / P99 • 峰值显存 • GPU 利用率 推荐工具 • PyTorch Profiler • Nsight Systems • 自定义 Python 脚本 • TensorBoard

3.5 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 不要只测一次:GPU 频率、系统负载都会波动。至少跑 50 次,取统计值。
  • Warmup 不能省:第一次推理通常很慢(CUDA 初始化、缓存未命中)。我一般 warmup 10-20 次。
  • 注意输入长度:LLM 的延迟跟输入+输出长度强相关。测试时一定要固定长度,或者记录长度分布。
  • 显存要清零:每次测试后,记得 torch.cuda.reset_peak_memory_stats(),否则峰值会累加。

我的小习惯:每次测试完,我会把结果存成一个 CSV 文件,带上时间戳和 Git commit hash。这样以后回看,还能知道当时跑的是哪个版本的代码。

好了,基准测试这块就聊这么多。你拿着上面的脚本和流程,应该能跑出一份靠谱的测试报告了。记住:没有基准,就没有优化。