Measuring Performance of Transformer Inference
TL;DR · AI 摘要
Transformer推理性能需通过延迟、吞吐量等指标衡量,需关注首token时间、内存使用及多GPU优化。
核心要点
- 首token时间(TTFT)和每输出token时间(TPOT)是衡量用户感知延迟的关键指标。
- 使用CUDA事件和NumPy百分位数分析可准确评估GPU性能和尾部延迟。
- 多GPU和多机器部署需考虑负载均衡与通信开销,以优化吞吐量和成本。
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Transformer推理性能测量
- 核心指标
- TTFT/TPOT
- 吞吐量
- 尾部延迟
- 测量方法
- CUDA事件
- 内存分析
- 并发基准测试
- 优化维度
- 多GPU
- 成本模型
金句 / Highlights
值得收藏与分享的关键句。
TTFT和TPOT分别反映预填充和解码阶段的性能瓶颈,需独立监控。
尾部延迟(p99)比平均值更能揭示极端请求对系统的影响。
NumPy的percentile函数可快速计算高分位数延迟指标。
测量Transformer推理性能 - MachineLearningMastery.com
测量Transformer推理性能
By
Adrian Tam
on
2026年8月4日
in
0
分享
文章
当你优化大语言模型(LLM)的推理性能时,需要知道如何衡量它。没有测量,很容易让模型变得更复杂却并不更快,或者在提高吞吐量的同时让用户感知到的延迟变得更差。
LLM服务有多种性能指标。用户关心的是看到第一个token需要多长时间,以及后续答案流的生成速度。操作人员关心的是硬件能处理多少请求、使用的内存以及每个生成token的成本。研究人员可能关心优化是否改变了模型的输出质量。
在本章中,你将学习:
- 延迟和吞吐量指标
- 首个token时间和每个输出token时间
- 测量CPU和GPU推理
- 使用CUDA事件
- 多请求基准测试
- 多GPU和多机器的考量
让我们开始吧。
测量Transformer推理性能的照片由Tomas Anton Escobar拍摄。部分权利保留。
概述
本章分为八个部分,它们是:
- LLM推理指标
- 单个请求的测量
- 预热和同步
- 使用CUDA事件测量GPU工作
- 内存使用测量
- 并发请求测量
- 多GPU和多机器
- 每个token的成本
LLM推理指标
最常见的推理指标包括:
- 延迟(Latency):请求从开始到结束所需的时间。
- 首次输出token时间(Time to first token, TTFT):用户等待第一个输出token出现的时间。
- 每个输出token时间(Time per output token, TPOT):第一个token之后生成的token之间的平均时间间隔。
- 吞吐量(Throughput):每秒处理的token数或请求数。
- 内存使用(Memory usage):使用的CPU内存或GPU内存数量。
- 利用率(Utilization):基准测试期间加速器的繁忙程度。
- 每个token的成本(Cost per token):硬件或服务成本除以处理的token数量。
对于LLM来说,单个延迟数值通常不足以全面衡量性能。考虑以下两个请求:
- 请求A:2000个提示token和20个输出token
- 请求B:20个提示token和2000个输出token
请求A对预填充阶段造成压力,请求B对解码阶段造成压力。它们可能有相同的总token数量,但性能特征不同。这就是为什么应该分别记录提示token和输出token的数量。
尾部延迟也很重要。如果大多数请求在一秒钟内完成,但少数请求需要十秒钟,用户会注意到。除了平均值或中位数外,还应报告高百分位延迟,如p90、p95和p99。高百分位数能更好地描述最坏情况。你可以使用NumPy轻松从值列表中找到这些百分位数:
import numpy as np def summarize(values): values = np.asarray(values, dtype=np.float64) return { "mean": values.mean(), "median": np.percentile(values, 50), "p90": np.percentile(values, 90), "p95": np.percentile(values, 95), "p99": np.percentile(values, 99), }
这些数值看似简单,但能避免一个常见错误:在优化平均值的同时让最坏情况变得更慢。
测量单个请求
最简单的测量方法使用 time.perf_counter() 。这是 Python 中内置的高精度壁时计时器,适合测量经过的时间。相比 time.time() ,它的精度更高。
以下示例分别测量了 Hugging Face 因果语言模型的预填充和解码阶段:
import time import torch from transformers import AutoModelForCausalLM, AutoTokenizer def load_model(model_name="sshleifer/tiny-gpt2", device="cpu"): tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained(model_name).to(device) model.eval() return tokenizer, model @torch.no_grad() def measure_one_request(model, tokenizer, prompt, max_new_tokens=50, device="cpu"): input_ids = tokenizer(prompt, return_tensors="pt").input_ids.to(device) start = time.perf_counter() outputs = model(input_ids, use_cache=True) prefill_end = time.perf_counter() past_key_values = outputs.past_key_values next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) generated = [next_token] decode_times = [] for _ in range(max_new_tokens - 1): step_start = time.perf_counter() outputs = model( next_token, past_key_values=past_key_values, use_cache=True, ) # 注意:此处可能需要 torch.cuda.synchronize() step_end = time.perf_counter() decode_times.append(step_end - step_start) past_key_values = outputs.past_key_values next_token = outputs.logits[:, -1, :].argmax(dim=-1, keepdim=True) generated.append(next_token) if tokenizer.eos_token_id is not None: if next_token.item() == tokenizer.eos_token_id: break end = time.perf_counter() output_ids = torch.cat([input_ids] + generated, dim=1) return { "text": tokenizer.decode(output_ids[0], skip_special_tokens=True), "prompt_tokens": input_ids.size(1), "output_tokens": len(generated), "prefill_seconds": prefill_end - start, "decode_seconds": sum(decode_times), "total_seconds": end - start, "ttft_seconds": prefill_end - start, "seconds_per_output_token": ( sum(decode_times) / max(1, len(decode_times)) ), }
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
51
52
53
54
55
56
57
58
59
60
time
torch
from
transformers
AutoModelForCausalLM
AutoTokenizer
load_model
model_name
"sshleifer/tiny-gpt2"
device
"cpu"
tokenizer
from_pretrained
model
to
eval
@
no_grad
measure_one_request
prompt
max_new_tokens
input_ids
return_tensors
"pt"
start
perf_counter
outputs
use_cache
True
prefill_end
past_key_values
next_token
logits
[
-
]
argmax
dim
keepdim
generated
decode_times
for
_
range
step_start
注意:此处可能需要 torch.cuda.synchronize()
step_end
append
if
eos_token_id
is
not
None
item
==
break
end
output_ids
cat
+
"text"
decode
skip_special_tokens
"prompt_tokens"
size
"output_tokens"
len
"prefill_seconds"
"decode_seconds"
sum
"total_seconds"
"ttft_seconds"
"seconds_per_output_token"
/
max
该函数没有使用模型的 generate() 方法。这是有意为之。目的是将预填充和解码阶段暴露出来,以便分别进行测量。输出的 output_tokens 数量包含预填充和解码阶段生成的所有 token。seconds_per_output_token 表示解码阶段每个输出 token 的平均耗时。
需要注意两个细节:
- use_cache=True 要求模型返回 KV 缓存。
- 在解码阶段,模型只接收 next_token ,而不是完整的序列。
这是与第一章相同的理念,但使用了库模型。
预热与同步
在测量性能时,请注意某些一次性成本不应主导结果。在 Python 中,模块的导入可能很慢,但后续导入相同模块是即时的。同样,由于数据结构的初始化或缓存预热,某些代码的首次执行可能比后续执行更慢。您希望测量稳定状态的工作负载,而不是这些设置开销。
因此,基准测试应包含预热阶段。由于各种原因,前几次迭代可能较慢。不要测量总时间并除以迭代次数,而应测量每次迭代的时间并分析稳定状态的结果。例如,如果您使用模型生成多个标记,很可能会将生成过程放入循环中。按如下方式测量每次迭代,然后忽略前几次结果:
def iterations(model, tokenizer, prompt, device, steps=100, warmup=10): results = [] for _ in range(steps): result = measure_one_request( model, tokenizer, prompt, max_new_tokens=8, device=device, ) results.append(result) steady = results[warmup:] return summarize([item["total_seconds"] for item in steady])
iterations
steps
100
warmup
results
result
steady
如果您使用 GPU 运行 LLM 推理,首次运行时还需要初始化内核。不幸的是,许多 GPU 操作是异步的。也就是说,当您在 GPU 上启动一个操作时,Python 可能会立即继续执行代码,而 GPU 仍在工作。因此,用简单的方法测量时间会是错误的。相反,您应该使用 torch.cuda.synchronize() 等待 GPU 完成操作后再停止计时器:
def sync_if_needed(device): if device.startswith("cuda"): torch.cuda.synchronize() start = time.perf_counter() outputs = model(input_ids, use_cache=True) sync_if_needed(device) elapsed = time.perf_counter() - start
sync_if_needed
startswith
"cuda"
cuda
synchronize
elapsed
这提供了包含实际 GPU 工作的墙钟时间测量。为了在 GPU 上获得准确的预填充和每个标记解码时间,请在 measure_one_request() 中每次调用 timed model(...) 后调用 sync_if_needed(device),而不仅仅是在请求结束时调用一次。
使用 CUDA 事件测量 GPU 工作
CUDA 事件测量 GPU 执行内核所花费的时间,而不是端到端用户延迟。这段时间不包括任何 Python 开销。下面是一个使用 CUDA 事件测量时间的示例:
def cuda_event_time(fn): start = torch.cuda.Event(enable_timing=True) end = torch.cuda.Event(enable_timing=True) start.record() result = fn() end.record() torch.cuda.synchronize() milliseconds = start.elapsed_time(end) return result, milliseconds / 1000.0
cuda_event_time
fn
Event
enable_timing
record
milliseconds
elapsed_time
1000.0
您可以使用它来测量一次前向传递:
with torch.no_grad(): outputs, seconds = cuda_event_time( lambda: model(input_ids, use_cache=True) ) print(f"GPU forward time: {seconds:.6f} seconds")
with
seconds
lambda
f
"GPU forward time: {seconds:.6f} seconds"
CUDA 事件计时和墙钟计时回答了不同的问题:
- 墙钟计时测量应用程序实际体验到的时间。
- CUDA 事件计时测量 GPU 工作所花费的时间。
对于推理服务,挂钟时间通常是主要指标,因为用户会体验到排队、分词、调度、网络开销和流式传输。当优化内核或比较模型执行路径时,CUDA事件非常有用。
要进行更深入的GPU性能分析,可以使用PyTorch Profiler、Nsight Systems、Nsight Compute或基于CUPTI的监控工具。这些工具可以报告内核时间线、内存复制、GPU利用率和操作符级分解。它们比计时器更复杂,但当简单基准测试显示模型运行缓慢且需要了解具体原因时是必要的。
测量内存使用
内存是另一个需要测量的维度,因为它限制的不是单个用户的性能,而是系统能同时服务的用户数量。通常GPU内存是瓶颈。在PyTorch中,可以像下面这样报告已分配和已保留内存:
def gpu_memory_summary(device="cuda"): torch.cuda.synchronize() return { "allocated_gb": torch.cuda.memory_allocated(device) / 1e9, "reserved_gb": torch.cuda.memory_reserved(device) / 1e9, "max_allocated_gb": torch.cuda.max_memory_allocated(device) / 1e9, }
gpu_memory_summary
"allocated_gb"
memory_allocated
1e9
"reserved_gb"
memory_reserved
"max_allocated_gb"
max_memory_allocated
已分配内存是张量使用的内存。已保留内存是PyTorch缓存分配器持有的内存。最大已分配内存通常是容量规划最有用的数值。
已分配和已保留内存是实时快照,但最大已分配内存是随时间变化的峰值。为了准确测量,应在基准测试前重置峰值统计:
torch.cuda.reset_peak_memory_stats() result = measure_one_request(model, tokenizer, prompt, device="cuda") memory = gpu_memory_summary("cuda") print(memory)
reset_peak_memory_stats
memory
内存测量应与token数量结合进行。较长提示或生成更多token的运行会自然使用更多KV缓存内存。
测量并发请求数
要创建运行语言模型的服务器,应通过每秒能处理的请求数来评估系统性能。吞吐量取决于单个请求的处理速度以及能同时处理的请求数,但并发性在存在竞争时不会线性扩展。
生产系统需要处理多个用户,调度器可能会将他们的工作合并处理。以下简单基准测试使用Python线程同时运行多个请求。此实现未包含持续批处理。它仅测量当多个调用者同时使用模型包装器时的表现。
from concurrent.futures import ThreadPoolExecutor, as_completed
def run_prompt(model, tokenizer, prompt, device):
start = time.perf_counter()
result = measure_one_request(
model, tokenizer, prompt, max_new_tokens=32, device=device,
)
end = time.perf_counter()
result["wall_seconds"] = end - start
return result
def benchmark_concurrent(model, tokenizer, prompts, device="cpu", workers=4):
results = []
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = [
pool.submit(run_prompt, model, tokenizer, prompt, device)
for prompt in prompts
]
for future in as_completed(futures):
results.append(future.result())
end = time.perf_counter()
total_output_tokens = sum(item["output_tokens"] for item in results)
return {
"requests": len(results),
"total_seconds": end - start,
"output_tokens": total_output_tokens,
"output_tokens_per_second": total_output_tokens / (end - start),
"latency_summary": summarize([item["wall_seconds"] for item in results]),
}并发
未来任务
线程池执行器
as_completed
run_prompt
"wall_seconds"
benchmark_concurrent
prompts
workers
max_workers
pool
submit
future
total_output_tokens
"requests"
"output_tokens_per_second"
"latency_summary"
此基准测试仅用于说明目的,不能替代真实的服务基准测试。它没有模拟HTTP开销、流式传输、请求队列、批处理、取消操作或缓存淘汰。但它是在单请求基准测试之后的一个有用步骤,可以并行运行模型并观察每个请求的延迟。Python的GIL(全局解释器锁)通常不是主要关注点,因为重型模型执行通常卸载到编译代码中。在没有同步的情况下,从多个线程并发使用相同模型或张量是不安全的,因此在CUDA上共享模型时需要使用锁或单个工作线程。
在对真实服务器进行基准测试时,至少需要记录以下内容:
- 并发用户数
- 提示词令牌分布
- 输出令牌分布
- 请求速率
- 首令牌时间(TTFT)分位数
- 令牌间延迟分位数
- 每秒总令牌数
- 错误率和超时率
分布情况非常重要。所有提示词都恰好为128个令牌且所有输出都恰好为128个令牌的基准测试虽然易于比较,但可能无法代表您的应用场景。
多GPU和多机器
可以通过多种不同方式使用多个GPU进行推理。不同的方法会显著影响系统性能。
最简单的方法是复制。您在每个GPU上加载一个模型副本,并将不同请求路由到不同副本。这会提高吞吐量且易于理解,但每个GPU必须有足够的内存来容纳完整模型及其KV缓存。
另一种方法是将一个模型拆分到多个GPU上。张量并行将权重矩阵分布在不同设备上。流水线并行将不同层放置在不同设备上。上下文并行对序列工作进行分区。专家并行用于专家混合模型。这些技术可以运行更大的模型,但会引入通信开销并可能增加延迟。
多台机器增加了另一层复杂性。系统可能需要在多台机器上使用大量副本以应对高请求量。它也可能将单个大型模型拆分到多台机器上,但这种方式更困难,因为网络通信速度比单台机器内部的通信要慢。对于低延迟服务,在一次前向传递中跨越机器边界应被视为昂贵的操作。
在评估具有多块GPU或多台机器系统的性能时,会增加通信和同步开销的新维度。在选择多GPU或多机器架构之前,请回答以下问题:
- 您是服务一个大型模型还是多个小型模型?
- 您是受模型权重内存限制还是KV缓存内存限制?
- 您需要更低的延迟、更高的吞吐量,还是两者都需要?
- 请求是否独立,还是共享长提示前缀?
- 一块GPU能否容纳模型,还是必须对模型进行分区?
这些问题很重要,因为最佳设计取决于瓶颈所在。增加GPU并不会自动使单个请求更快。它可能通过复制提高吞吐量,也可能通过分区使更大模型成为可能。基准测试应显示您实际获得的效果。
每个Token的成本
成本是衡量性能的指标。使用更昂贵硬件的更快系统可能并不适合特定应用场景。
一个简单的成本估算公式如下:
每个输出Token的成本 = 每秒硬件成本 / 每秒输出Token数
如果GPU实例每小时费用为3美元,且服务每秒生成1000个输出Token:
每秒硬件成本 = 3.00 / 3600 = 0.000833 每个输出Token的成本 = 0.000833 / 1000 = 0.000000833
仅从硬件角度看,每个输出Token的成本不到百万分之一美元。实际计算还可能包括闲置容量、存储、网络、工程时间、编排开销和失败请求等因素。
成本应与质量进行对比。量化、更小的模型和路由可以降低成本,但可能改变模型行为。高效的推理系统不仅仅是最快的系统,而是在满足质量和可靠性要求的前提下,以最低的实际成本实现的系统。
进一步阅读
以下是一些可能对您有用的资源:
- 维基百科上的利特尔定律。这是关联平均并发量、到达率和响应时间的有用排队论结果。在推理请求率、延迟和飞行中推理请求数量时,这是一个有帮助的思维模型。
- NVIDIA NIM LLMs基准测试中的指标。该页面定义了常见的LLM推理指标,如首Token生成时间、端到端延迟、Token间延迟、每秒Token数和每秒请求数。
- MLPerf Inference,由MLCommons提供。这是一个广泛使用的基准测试套件,用于衡量不同部署场景下的推理性能。它不仅限于LLM,但为可重复的基准测试和报告提供了有用的规范。
- PyTorch文档中的torch.profiler。这是PyTorch的主要性能分析接口,用于收集CPU和加速器活动、操作符时间、内存信息、张量形状以及后续可检查的跟踪信息。
- NVIDIA Nsight Systems 用户指南,由 NVIDIA 编写。当挂钟计时器无法满足需求,需要记录 CUDA API 调用、GPU 内核、内存拷贝、CPU 工作和同步操作的时间线时,Nsight Systems 非常有用。
- 《驯服巨兽:高效大语言模型推理服务综述》,Zhen 等人著。该综述从更宏观的视角探讨了大语言模型推理服务,涵盖请求调度、模型部署、存储管理、资源解耦、负载均衡和集群级服务等议题。
总结
在本章中,你学习了如何衡量大语言模型推理性能。你了解到为何需要将预填充和解码阶段分开测量,掌握了如何使用挂钟计时器和 CUDA 事件,如何记录内存使用情况,以及如何报告延迟百分位数。你还了解到多 GPU 场景可能意味着通过复制提升吞吐量或通过分片支持更大模型,基准测试应明确区分这两种情况。
在本书的下一章节,你将开始学习如何提升单个模型的推理速度,首先从浮点精度优化入手。
更多相关内容
- 使用 Transformer 模型:从训练到推理
- 大语言模型推理中的静态批处理、动态批处理与连续批处理
- 同时服务多位用户:连续批处理如何实现
- 大语言模型推理缓存完整指南
- 构建推理缓存以降低成本
- 借助 LoRA 实现快速且经济的微调大模型推理
/.entry /think