AI 推理基准测试自动化——用 CI 管道拦截每一次性能回退

AI 推理基准测试自动化——用 CI 管道拦截每一次性能回退

一、推理延迟的无声恶化:为什么没有 CI 的 benchmark 形同虚设

大模型推理服务的性能指标——TTFT(首 Token 延迟)、TPOT(每 Token 延迟)、吞吐量——并非一成不变。框架升级(vLLM 从 0.4 升到 0.5)、模型格式转换(FP16 切换 GPTQ-INT4)、CUDA 版本变更,甚至 PyTorch 中某个算子实现的"优化",都可能在看似无关的变更中引入 10-20% 的性能回退。

手动运行 benchmark 的最大问题是"频率不足"。通常只有在重大版本发布前才会跑一次性能测试,而中间几十次提交引入的微小退化会累积成一场无声的性能灾难。一次真实的案例是:某推理服务在 3 周内调整了 beam search 的重排序逻辑,TTFT 逐次增加了 5%→8%→11%,因为每次变更的增量太小,Code Review 时无法察觉。直到线上 P99 延迟告警触发,团队才意识到问题的严重性。

要解决这个问题,不是"多跑几次 benchmark",而是将 benchmark 嵌入 CI 管道,让每一次 PR 合并都伴随着一张性能变化报告。

flowchart TD A[开发者提交 PR] --> B[CI 触发] B --> C[编译与单元测试] C --> D{基础测试通过?} D -->|否| Z[PR 标记失败] D -->|是| E[启动推理 Benchmark] E --> E1[预热阶段: 1000 请求] E --> E2[稳定测试: 5000 请求 @ 并发 32] E1 --> F[采集性能指标] E2 --> F F --> F1[TTFT: P50/P95/P99] F --> F2[TPOT: P50/P95/P99] F --> F3[吞吐量: tokens/sec] F --> F4[GPU 显存占用峰值] F1 --> G{与 main 分支基准值对比} F2 --> G F3 --> G F4 --> G G -->|退化 > 5%| H[PR 自动评论 + Slack 告警] G -->|退化 ≤ 5%| I[通过, 更新基线值] H --> J[性能审查人员手动评估] I --> K[PR 自动合并]

二、Benchmark 管道的架构设计:从数据采集到阈值判定

2.1 测试负载的构造

Benchmark 的有效性取决于测试负载是否能代表线上真实流量。使用均匀分布的 prompt 长度会导致与生产环境完全不匹配的结论——因为生产流量中长文本(>2000 tokens prompt)占比更高,而长文本的 TTFT 通常呈超线性增长:

# 构造符合生产分布的测试负载 # 线上调研显示:40% 短文本(<500 tokens)、35% 中文本(500-2000)、25% 长文本(>2000) import random def generate_test_prompts(n: int, tokenizer) -> list[str]: """ 按照生产流量分布构造 benchmark 测试集 短文本: 100-500 tokens, 权重 0.40 中文本: 500-2000 tokens, 权重 0.35 长文本: 2000-4000 tokens, 权重 0.25 """ prompts = [] for _ in range(n): rand = random.random() if rand < 0.40: # 短文本:模拟对话轮次较少的场景 target_len = random.randint(100, 500) elif rand < 0.75: # 中文本:模拟代码生成或文章摘要场景 target_len = random.randint(500, 2000) else: # 长文本:模拟 RAG 上下文或文档问答场景 target_len = random.randint(2000, 4000) # 用重复的 marker token 占位,达到目标长度 prompt = "benchmark " * (target_len // 10) prompts.append(prompt) return prompts

2.2 预热阶段的重要性

GPU 在冷启动时 kernel 需要编译(JIT),显存分配未稳定。不经过预热直接测试,TTFT 可能比稳态高出 30-50%。预热阶段的关键参数是预热请求数和预热时长:

# 预热配置:发送足够多的请求使 GPU kernel 缓存达到稳态 WARMUP_REQUESTS = 1000 # 预热请求数 WARMUP_DURATION_SEC = 120 # 预热至少持续 2 分钟

2.3 核心指标与采集代码

使用 vLLM 的 metrics 端点和异步请求编排:

import asyncio import aiohttp import time import statistics from dataclasses import dataclass @dataclass class BenchmarkResult: """Benchmark 采集的核心指标""" ttft_p50: float # 首 Token 延迟中位数 ttft_p95: float # 首 Token 延迟 P95 ttft_p99: float # 首 Token 延迟 P99 tpot_mean: float # 每 Token 生成延迟均值 throughput: float # 总吞吐量 tokens/sec gpu_memory_peak: int # GPU 显存峰值 MB async def run_benchmark(api_url: str, prompts: list[str], concurrency: int = 32) -> BenchmarkResult: """ 异步并发发送推理请求,采集延迟与吞吐指标 使用 asyncio.Semaphore 控制并发数,避免压垮服务端 """ semaphore = asyncio.Semaphore(concurrency) ttfts = [] # 首 Token 延迟列表 tpots = [] # 每 Token 延迟列表 total_tokens = 0 start_time = time.monotonic() async def send_one(prompt: str): async with semaphore: req_start = time.monotonic() async with aiohttp.ClientSession() as session: async with session.post( f"{api_url}/v1/completions", json={"prompt": prompt, "max_tokens": 256, "stream": True} ) as resp: first_token = True tokens_received = 0 # 流式接收 token,记录首 Token 时间和总 tokens 数 async for line in resp.content: if first_token: ttft = time.monotonic() - req_start ttfts.append(ttft) first_token = False tokens_received += 1 tpots.append((time.monotonic() - req_start) / max(tokens_received, 1)) return tokens_received tasks = [send_one(p) for p in prompts] results = await asyncio.gather(*tasks) total_tokens = sum(results) elapsed = time.monotonic() - start_time return BenchmarkResult( ttft_p50=statistics.median(ttfts), ttft_p95=sorted(ttfts)[int(len(ttfts) * 0.95)], ttft_p99=sorted(ttfts)[int(len(ttfts) * 0.99)], tpot_mean=statistics.mean(tpots), throughput=total_tokens / elapsed, gpu_memory_peak=0, # 通过 nvidia-smi 异步采集 )

三、阈值告警与退化分析

3.1 退化阈值的设定

退化的判定需要区分"噪声"和"真正的回归"。在 GPU 环境中,同一 benchmark 连续运行 10 次的 TTFT P99 标准差通常在 2-3% 范围内。因此将告警阈值设定为 5% 是合理的——超过 5% 的退化大概率不是随机波动而是代码变更导致的:

# 退化检测逻辑 def check_regression(current: BenchmarkResult, baseline: BenchmarkResult, threshold: float = 0.05): """对比当前结果与 baseline,检测超过阈值的退化""" regressions = [] # 检查 TTFT 各分位数 if current.ttft_p99 > baseline.ttft_p99 * (1 + threshold): degradation = (current.ttft_p99 - baseline.ttft_p99) / baseline.ttft_p99 * 100 regressions.append(f"TTFT P99 退化 {degradation:.1f}%(当前: {current.ttft_p99:.0f}ms vs 基线: {baseline.ttft_p99:.0f}ms)") if current.throughput < baseline.throughput * (1 - threshold): degradation = (baseline.throughput - current.throughput) / baseline.throughput * 100 regressions.append(f"吞吐量下降 {degradation:.1f}%") return regressions

3.2 当退化被检测到后

退化不等于 bug,也可能是预期内的——比如引入了更复杂的采样策略来提升生成质量。关键在于强制关联"代码变更"与"性能指标变化",让性能成为 Code Review 的维度之一。

在 CI 中实现自动化的性能报告评论,将本次 PR 的性能变化以表格形式贴在 PR 讨论区,要求 Reviewer 对超过 5% 的退化给出"Acceptable(可接受)"或"Needs Fix(需修复)"的判断。

四、CI 集成中的工程挑战

在实际 CI 环境中运行 AI 推理 benchmark 面临两个独特的工程挑战。

第一个是 GPU 资源的隔离性。CI Runner 通常使用 Docker 容器,而容器内访问 GPU 需要 NVIDIA Container Runtime 和 CUDA 版本匹配。推荐使用带 GPU 标签的专用 Runner,避免与其他 CPU 密集型 CI 任务共用同一节点:

# .gitlab-ci.yml 中的 benchmark Job 配置 inference-benchmark: stage: test tags: - gpu # 使用 GPU 专用 Runner - a100-40gb # 指定 GPU 型号 image: nvidia/cuda:12.1-runtime-ubuntu22.04 script: - pip install vllm aiohttp - python scripts/benchmark.py --output benchmark_results.json - python scripts/compare_baseline.py benchmark_results.json artifacts: paths: - benchmark_results.json expire_in: 30 days

第二个挑战是 benchmark 的执行时间。一次完整的 benchmark(含预热和 5000 条测试用例)在 A100 上约需 15-20 分钟。如果以 PR 粒度执行会严重阻塞 CI 流水线。优化方案是按分支区分测试深度:feature 分支执行快速筛查(1000 条请求、无预热、约 3 分钟),release 分支执行完整测试。

五、总结

将 AI 推理性能测试嵌入 CI 管道的核心价值在于:将性能回归的检测窗口从"发布前"压缩到"每次 PR 合并"。这意味着性能退化在引入的当天就能被捕获,而非在数周后的线上环境暴露。

落地路径分为三步:第一,构建能复现生产流量分布的 benchmark 负载集,确保测试的覆盖度有效。第二,在 CI 中部署 GPU Runner,将 benchmark 作为 PR 检查项之一,设定 5% 退化阈值作为告警线。第三,建立"性能审查"流程,要求 Reviewer 对超过阈值的退化给出明确判断。

这种自动化不是银弹——它不能替代人工对退化的根因分析,但它确保了退化不会被无声地合并到主分支。