跳转到内容

推理性能分析:从延迟指标到 GPU 瓶颈

推理性能要从一次请求的时间线出发:先区分排队、输入处理和生成,再比较成功吞吐、尾延迟与显存。优化应有相同负载的基线,并能指出哪段工作减少了。 GPU 利用率或总 token/s 只能提供线索,无法单独解释用户等待和失败。

下面先用固定示例数据手算指标,再用一个线性层推导计算与读写的理论下界,最后给出 vLLM 压测和 PyTorch Profiler 的完整步骤。指标与容量代码可在 CPU 上运行;硬件峰值是假设值,GPU 脚本和压测步骤尚无这里的设备实测结果。

客户端发出请求网关与队列输入处理与 Prefill第一枚输出后续 Decode 输出完成

设客户端发出请求的时间为 t₀,第一枚输出 token 为 t₁,最后一枚为 tₙ。客户端口径的定义是:

TTFT = t₁ − t₀
E2E = tₙ − t₀ (本例以最后 token 为结束)
第 i 个 ITL = tᵢ − tᵢ₋₁
TPOT = (tₙ − t₁) / (n−1) (n 大于 1 时)

真实服务的 E2E 可能以响应完成事件为结束,因此还包含最后 token 后的协议收尾。只有一枚输出的请求没有“后续 token 平均间隔”,不能把 TPOT 自动记成零混进统计。

第一段 SSE 数据可能是角色、元数据或心跳,网络块也不等于 token。计时前要明确能否观察真正的 token 时间;若只能记录首段非空内容,应标明这个口径。服务器记录的排队与 Prefill 时间,也不包含所有客户端网络等待。vLLM 指标定义

以下是固定示例事件,单位 ms,统一观察窗口为 0 到 500 ms。ok 表示该例中请求成功;第四条在窗口末端超时。

请求发出时间输出 token 时间状态TTFTE2ETPOT
A0[100,140,180,220]ok10022040
B10[160]ok150150无定义
C20[200,300,400]ok180380100
D30无timeout无成功样本无成功样本无

A 的 TPOT 是 (220−100)/(4−1)=40,而不是 220/4=55;后一个数把首输出前的等待也摊进去了。

TTFT 的成功样本为 [100,150,180]。本例采用排序后线性插值:索引=(N−1)×p,p 在 0 到 1 之间。P95 的索引为 2×0.95=1.9,所以 150+0.9×(180−150)=177 ms。只有三条成功样本,这个数用于演示计算,不能代表生产尾延迟;其他工具的分位数算法也可能不同。

请求 TPOT 的样本是 [40,100],P50 为 70 ms。把所有 token 间隔放在一起则是 [40,40,40,100,100],P50 为 40 ms。前者每条请求权重相同,后者输出间隔更多的请求权重更大;两种聚合不能混用。

统一观察窗口是 0.5 秒,因此:

错误率 = 1/4 = 25%
成功请求吞吐 = 3/0.5 = 6 请求/秒
成功请求的输出吞吐 = (4+1+3)/0.5 = 16 token/秒
假设延迟目标:TTFT ≤ 150 ms 且 E2E ≤ 300 ms
符合目标的请求只有 A、B
Goodput = 2/0.5 = 4 请求/秒

失败仍进入错误率分母,不进入成功延迟样本。报表应同时显示两者,否则服务大量失败时,少数成功请求的延迟可能看起来很好。上面的输出吞吐只计成功请求;引擎总生成计数、计费 token 和成功输出数量可能不同。

下载指标与 Roofline 代码,执行 python3 metrics.py。输出包括 TTFT P50/P95 150/177,请求 TPOT 70/97,错误率 0.25,Goodput 4。这些值来自表中的固定输入。

完整源码:分位数、请求与 token 两种聚合、Goodput 和 Roofline
metrics.py
"""Fixed teaching traces and a roofline estimate. These are not measured GPU results."""
import json
import math
def percentile(values, p):
if not values or not 0 <= p <= 100:
raise ValueError("Need samples and a percentile in [0,100]")
ordered = sorted(values)
position = (len(ordered) - 1) * p / 100
low, high = math.floor(position), math.ceil(position)
return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
def report(records, window_ms, ttft_limit=150, e2e_limit=300):
if window_ms <= 0 or not records:
raise ValueError("Need records and a positive observation window")
successful = [r for r in records if r["status"] == "ok"]
if not successful:
raise ValueError("This teaching report requires a successful request")
ttft, e2e, tpot, gaps, good = [], [], [], [], 0
for r in successful:
times = r["tokens"]
if not times or times != sorted(times) or times[0] < r["arrival"]:
raise ValueError("Invalid token timeline")
first, final = times[0] - r["arrival"], times[-1] - r["arrival"]
ttft.append(first)
e2e.append(final)
if len(times) > 1:
tpot.append((times[-1] - times[0]) / (len(times) - 1))
gaps.extend(b - a for a, b in zip(times, times[1:]))
good += first <= ttft_limit and final <= e2e_limit
seconds = window_ms / 1000
stats = lambda values: {"p50": percentile(values, 50), "p95": percentile(values, 95)} if values else None
return {"ttft_ms": stats(ttft), "e2e_ms": stats(e2e), "request_tpot_ms": stats(tpot),
"token_itl_ms": stats(gaps), "error_fraction": 1 - len(successful) / len(records),
"success_requests_per_s": len(successful) / seconds,
"output_tokens_per_s": sum(len(r["tokens"]) for r in successful) / seconds,
"goodput_requests_per_s": good / seconds}
def linear_roofline(rows, width=4096, out=16384, element_bytes=2):
flops = 2 * rows * width * out
# Optimistic single-read lower bound: input, weight, output. No attention or launch overhead.
traffic = element_bytes * (rows * width + width * out + rows * out)
compute_seconds = flops / 100e12 # Hypothetical 100 TFLOP/s
memory_seconds = traffic / 1e12 # Hypothetical 1 TB/s
return {"rows": rows, "FLOPs": flops, "bytes": traffic,
"FLOPs_per_byte": flops / traffic,
"lower_bound_ms": 1000 * max(compute_seconds, memory_seconds)}
def main():
records = [
{"arrival": 0, "status": "ok", "tokens": [100, 140, 180, 220]},
{"arrival": 10, "status": "ok", "tokens": [160]},
{"arrival": 20, "status": "ok", "tokens": [200, 300, 400]},
{"arrival": 30, "status": "timeout", "tokens": []},
]
result = report(records, 500)
assert result["ttft_ms"] == {"p50": 150, "p95": 177}
assert result["request_tpot_ms"] == {"p50": 70, "p95": 97}
assert result["error_fraction"] == 0.25 and result["goodput_requests_per_s"] == 4
print(json.dumps({"kind": "fixed teaching data and hypothetical hardware",
"metrics": result, "roofline": [linear_roofline(1), linear_roofline(128)]}, indent=2))
if __name__ == "__main__":
main()

用计算量和数据量提出瓶颈假设

Section titled “用计算量和数据量提出瓶颈假设”

考虑线性层 X[M,K] × W[K,N] = Y[M,N]。把一次乘和一次加分别计作一个 FLOP,计算量约为 F=2MKN。若输入、权重只读一次,输出只写一次,每元素 b 字节,乐观的数据量下界为 Q=b(MK+KN+MN)。

算术强度 I=F/Q,单位 FLOP/byte。假设硬件峰值为 100 TFLOP/s、显存带宽为 1 TB/s,则计算与带宽的分界为 100 FLOP/byte。理论下界是:

耗时 ≥ max(F / 计算峰值,Q / 显存带宽)

以 K=4096,N=16384,FP16 为例:

本轮行数 MFLOPs数据量下界算术强度假设硬件下的耗时下界
1134,217,728134,258,688 byte约 1 FLOP/byte约 0.1343 ms,带宽下界更大
12817,179,869,184139,460,608 byte约 123.19 FLOP/byte约 0.1718 ms,计算下界更大

从 1 行变到 128 行,权重数量没有增加,每个读入的权重参与更多输入行的计算,所以算术强度上升。这解释了增大有效批量为何可能提高小批量 Decode 的设备效率,但批量等待又可能增加请求延迟。

这是单个线性层、假设硬件和理想单次读取条件下的下界,不是测量结果。实际还包括 Attention 读取 KV、其他层、缓存命中、重复搬运、同步与 kernel 启动;硬件的有效峰值也取决于精度和矩阵形状。GPU 利用率不能直接替代这些判断。Nsight Compute Roofline 分析

在已有兼容的 Linux GPU 环境运行实验。先记录 vllm --version、GPU、驱动、模型 revision、dtype、启动参数和是否启用前缀缓存。参数说明按 2026-10-12 官方文档核对;执行前再检查已安装版本的 help。

在引擎启动并完成真实生成检查后,测一个固定长度的完成接口负载:

Terminal window
vllm bench serve \
--backend vllm \
--base-url http://127.0.0.1:8000 \
--endpoint /v1/completions \
--model Qwen/Qwen2.5-1.5B-Instruct \
--dataset-name random \
--random-input-len 256 \
--random-output-len 128 \
--random-range-ratio 0 \
--ignore-eos \
--seed 7 \
--num-prompts 100 \
--num-warmups 5 \
--request-rate 2 \
--percentile-metrics ttft,tpot,itl,e2el \
--metric-percentiles 50,95,99 \
--save-result --save-detailed \
--result-filename short-rate2.json

random-range-ratio=0 固定所请求的长度;ignore-eos 避免模型提前停止,用于控制系统工作量。这组随机输入不评估回答质量,强制忽略 EOS 的结果也不能当成实际产品生成行为。保存结果后仍要核对实际输出长度和错误。vLLM Benchmark CLI

先固定输入输出长度,分别用 1、2、4 请求/秒重复运行,改好结果文件名。再只把输入改为 2048,最后回到同一负载比较一个引擎参数。100 条请求是起始实验规模,研究尾延迟时需增加样本并观察多次重复的一致性。

固定到达速率与固定并发回答不同问题。到达速率控制请求何时开始发出;并发上限可能在客户端继续排队,使实际到达服务器的速率下降。报告必须保留实际速率、客户端队列、超时与未完成请求,避免服务越慢、客户端发得越少,反而看起来越稳定。

用 CUDA Events 和 Profiler 看真实执行

Section titled “用 CUDA Events 和 Profiler 看真实执行”

CUDA 通常异步提交:CPU 发起一个算子后可能先返回,GPU 还在工作。直接用 CPU 时钟包住 x @ weight,可能主要测到提交时间。下面的 GPU 脚本先预热,再用 CUDA Events 覆盖重复执行,最后等待设备完成。

CUDA streamCPU 程序CUDA streamCPU 程序记录 start event提交多个矩阵乘法记录 end eventsynchronize 等待完成完成,读取 events 的时间差

下载GPU 实验代码,在已有 CUDA-compatible PyTorch 的环境执行:

Terminal window
python3 profile_gpu.py --batch 8 --sequence 128 --width 1024 \
--iterations 50 --output-dir gpu-traces

它分别执行 M=B×S=1024 的 Prefill 型线性层,和 M=B=8 的 Decode 型线性层。两者的权重相同。这只是用于比较形状的线性层实验,不包括真实模型的 Attention、KV cache、调度与网络。

脚本输出 GPU 型号、PyTorch/CUDA 版本、配置、Events 平均耗时与张量显存峰值,并产生 prefill_linear.json、decode_linear.json 两份 Chrome Trace。没有 CUDA 时会明确停止,不会静默改用 CPU 后继续输出“GPU 结果”。

完整源码:预热、设备计时、显存与 Chrome Trace
profile_gpu.py
"""GPU-only experiment: use an existing CUDA-compatible PyTorch environment."""
import argparse
import json
from pathlib import Path
def main():
import torch
from torch.profiler import ProfilerActivity, profile, record_function
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--batch", type=int, default=8)
parser.add_argument("--sequence", type=int, default=128)
parser.add_argument("--width", type=int, default=1024)
parser.add_argument("--iterations", type=int, default=50)
parser.add_argument("--output-dir", default="gpu-traces")
args = parser.parse_args()
if min(args.batch, args.sequence, args.width, args.iterations) < 1:
parser.error("Dimensions and iteration count must be positive")
if not torch.cuda.is_available():
raise SystemExit("CUDA GPU unavailable. No CPU fallback: this experiment measures CUDA.")
output = Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
torch.manual_seed(7)
weight = torch.randn(args.width, 4 * args.width, device="cuda", dtype=torch.float16)
results = []
with torch.inference_mode():
for label, rows in [("prefill_linear", args.batch * args.sequence), ("decode_linear", args.batch)]:
x = torch.randn(rows, args.width, device="cuda", dtype=torch.float16)
for _ in range(10):
y = x @ weight
torch.cuda.synchronize()
torch.cuda.reset_peak_memory_stats()
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record()
for _ in range(args.iterations):
y = x @ weight
end.record()
torch.cuda.synchronize()
elapsed = start.elapsed_time(end) / args.iterations
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True, profile_memory=True) as prof:
for _ in range(5):
with record_function(label):
y = x @ weight
torch.cuda.synchronize()
prof.export_chrome_trace(str(output / f"{label}.json"))
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=8))
results.append({"case": label, "rows": rows, "mean_event_ms": elapsed,
"peak_tensor_bytes": torch.cuda.max_memory_allocated()})
del x, y
print(json.dumps({"torch": torch.__version__, "cuda": torch.version.cuda,
"gpu": torch.cuda.get_device_name(), "config": vars(args),
"results": results}, indent=2))
if __name__ == "__main__":
main()

这里没有填入 GPU 耗时表。运行后的结果才可用于比较;Events 测量与开启 Profiler 的采样分开,后者会增加观测开销。PyTorch Profiler:设备时间、内存与 Trace

用 Chrome Trace 格式的查看器加载两份 JSON,先找 prefill_linear 或 decode_linear 标记,再对应 CPU 操作、CUDA runtime 调用与 GPU kernel。不要把嵌套的父操作总时间全部相加,它们可能重复包含同一段工作;重叠执行的 kernel 时长之和也不等于墙钟时间。

观察假设下一项检查
CPU 有很长的输入处理,GPU 中间有空档输入准备没有及时供给设备分词、序列化、数据搬运和线程占用
很多短 kernel,中间有空档提交或同步开销可能显著CPU runtime、同步点、批量大小
大部分设备时间在 GEMM线性计算是主要部分矩阵形状、精度、有效批量与带宽/计算指标
长上下文后 Attention 时间增加KV 访问和 Attention 工作量增加实际上下文长度、缓存命中、Attention backend
GPU 稳定执行,但客户端 TTFT 很大排队或上游等待可能占主导服务器队列和跨服务请求时间线

Profiler 帮助定位“时间在哪”;要进一步区分某个 kernel 的计算利用率、显存吞吐和访存行为,可对少量代表性 kernel 使用 Nsight Compute。其观测可能改变执行方式,不能直接拿 profiling 运行的总耗时替代正常服务基线。

peak_tensor_bytes 是 PyTorch 张量分配口径,不包含进程的全部设备内存;模型服务预留的缓存池也需要单独观察。性能报告应保留失败率、质量、冷/热缓存和实际输出长度,再判断优化是否改善了目标场景。

返回学习路线或继续核对批处理与 token 预算。