@TeachTheMachine:衡量Transformer推理性能
摘要
一份关于衡量Transformer推理性能的实用教程,涵盖延迟、TTFT、吞吐量、内存使用等指标,以及针对LLM的基准测试技术。
查看缓存全文
缓存时间: 2026/08/06 22:43
测量 Transformer 推理性能
https://t.co/AVpe9MHZyT
测量 Transformer 推理性能 - MachineLearningMastery.com
来源:https://machinelearningmastery.com/measuring-performance-of-transformer-inference/ 当你优化 LLM 的推理性能时,你需要知道如何测量它。没有测量,很容易让模型变得更复杂却没有变得更快,或者提高了吞吐量却让用户感知的延迟变得更差。
一个 LLM 服务有几种性能指标。用户关心的是看到第一个 token 需要多长时间,以及回答的其余部分以多快的速度流式返回。运维人员关心的是硬件能够处理多少个请求,使用了多少内存,以及每个生成的 token 成本是多少。研究人员可能关心优化是否会改变模型的输出质量。
在本章中,你将了解:
- 延迟和吞吐量指标
- 首个 token 时间和每个输出 token 的时间
- 测量 CPU 和 GPU 推理
- 使用 CUDA 事件
- 对多个请求进行基准测试
- 考虑多 GPU 和多机器
让我们开始吧。
测量 Transformer 推理性能 照片由Tomas Anton Escobar (https://unsplash.com/photos/white-and-red-train-beside-building-at-daytime-PHyF2mCMei0) 拍摄。保留部分权利。
概述
本章分为八个部分;它们是:
- LLM 推理的指标
- 测量单个请求
- 预热和同步
- 使用 CUDA 事件测量 GPU 工作
- 测量内存使用
- 测量并发请求
- 多 GPU 和多机器
- 每个 token 的成本
LLM 推理的指标
最常见的推理指标是:
- **延迟:**一个请求从开始到完成所需的时间。
- **首个 token 时间(TTFT):**用户在第一个输出 token 出现之前等待的时间。
- **每个输出 token 的时间(TPOT):**在第一个 token 之后,生成 token 之间的平均时间。
- **吞吐量:**每秒处理的 token 或请求数量。
- **内存使用:**使用了多少 CPU 内存或 GPU 内存。
- **利用率:**在基准测试期间加速器的繁忙程度。
- **每个 token 的成本:**硬件或服务成本除以处理的 token 数量。
对于 LLM 来说,单一的延迟数字通常是不够的。考虑两个请求:
- 请求 A:2,000 个提示 token 和 20 个输出 token
- 请求 B:20 个提示 token 和 2,000 个输出 token
请求 A 对 prefill(预填充)阶段压力大。请求 B 对 decode(解码)阶段压力大。它们的 token 总数可能相同,但它们的性能特征不同。这就是为什么你应该分别记录提示 token 和输出 token。
尾部延迟也很重要。如果大多数请求在一秒内完成,但少数请求需要十秒,用户会注意到。除了均值或中位数之外,还应该报告高百分位延迟,如 p90、p95 和 p99。高百分位数能更好地描述最坏情况。你可以使用 NumPy 轻松地从值列表中找到这些百分位数:
importnumpyasnp
defsummarize(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 因果语言模型的 prefill 和 decode:
1
2
3
4
5
6
7
8
9
10
11
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
50
51
52
53
54
55
56
57
58
59
60
importtime
importtorch
fromtransformersimportAutoModelForCausalLM,AutoTokenizer
defload_model(model_name=“sshleifer/tiny-gpt2”,device=“cpu”):
tokenizer=AutoTokenizer.from_pretrained(model_name)
model=AutoModelForCausalLM.from_pretrained(model_name).to(device)
model.eval()
returntokenizer,model
@torch.no_grad()
defmeasure_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_inrange(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)
iftokenizer.eos_token_idisnotNone:
ifnext_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))
),
}
这个函数不使用模型的generate\(\)方法。这是有意为之的。目标是暴露 prefill 和 decode 阶段,以便可以分别测量它们。output\_tokens的数量包括 prefill 和 decode 阶段生成的 token。seconds\_per\_output\_token是 decode 阶段每个输出 token 的平均时间。
有两个细节需要注意:
use\_cache=True要求模型返回 KV 缓存。- 在 decode 阶段,模型只接收
next\_token,而不是整个序列。
这与第 1 章的思路相同,但使用的是库模型。
预热和同步
在测量性能时,请注意一些一次性成本不应主导结果。在 Python 中,模块的import可能很慢,但随后对同一模块的import是即时的。类似地,某些代码的首次执行可能由于数据结构初始化或缓存预热而比后续执行慢。你想要测量的是稳态工作,而不是那些设置开销。
因此,基准测试应该包含预热。前几次迭代可能会因为各种原因而变慢。你不应该测量总时间然后除以迭代次数,而应该测量每次迭代的时间并分析稳态迭代。例如,如果你使用模型生成多个 token,你可能会将生成过程放在一个循环中。按如下方式测量每次迭代,然后忽略前几次结果:
defiterations(model,tokenizer,prompt,device,steps=100,warmup=10):
results=[]
for_inrange(steps):
result=measure_one_request(
model,
tokenizer,
prompt,
max_new_tokens=8,
device=device,
)
results.append(result)
steady=results[warmup:]
returnsummarize([item[“total_seconds”]foriteminsteady])
如果你使用 GPU 来运行 LLM 推理,你还需要在首次运行内核时对其进行初始化。不幸的是,许多 GPU 操作是异步的。也就是说,当你在 GPU 上启动一个操作时,Python 可能会立即继续执行你的代码,而 GPU 仍在工作。因此,用朴素的方法来测量时间是不正确的。相反,你应该使用torch\.cuda\.synchronize\(\)来等待 GPU 完成操作后再停止计时器:
defsync_if_needed(device):
ifdevice.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
这给出了一个包含实际 GPU 工作的挂钟时间测量值。为了在 GPU 上获得准确的 prefill 和逐 token decode 计时,请在measure\_one\_request\(\)中每次计时的model\(\.\.\.\)调用之后调用sync\_if\_needed\(device\),而不仅仅是在请求结束时调用一次。
使用 CUDA 事件测量 GPU 工作
CUDA 事件测量的是 GPU 执行内核所花费的时间,而不是端到端的用户延迟。这个时间不包括任何 Python 开销。下面是一个如何使用 CUDA 事件来测量时间的示例:
defcuda_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)
returnresult,milliseconds/1000.0
你可以用它来测量一次前向传播:
withtorch.no_grad():
outputs,seconds=cuda_event_time(
lambda:model(input_ids,use_cache=True)
)
print(f“GPU forward time: {seconds:.6f} seconds“)
CUDA 事件计时和挂钟计时回答的是不同的问题:
- 挂钟计时测量的是应用程序所体验到的内容。
- CUDA 事件计时测量的是 GPU 工作花费了多长时间。
对于推理服务来说,挂钟计时通常是主要指标,因为用户会体验到队列、tokenization、调度、网络开销和流式传输。CUDA 事件在优化内核或比较模型执行路径时非常有用。
要进行更深入的 GPU 性能分析,请使用 PyTorch Profiler、Nsight Systems、Nsight Compute 或基于 CUPTI 的监控等工具。这些工具可以报告内核时间线、内存拷贝、GPU 利用率和算子级分解。它们比计时器更复杂,但当简单基准测试显示模型很慢而你需要知道原因时,它们是必要的。
测量内存使用
内存是需要测量的另一个维度,因为它限制的不是单个用户的速度,而是你的系统可以服务多少用户。通常 GPU 内存是瓶颈。在 PyTorch 中,你可以像下面这样报告已分配和已预留的内存:
defgpu_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,
}
已分配值(allocated)是张量使用的内存。已预留值(reserved)是 PyTorch 缓存分配器持有的内存。最大已分配值(max allocated)通常是容量规划中最有用的数字。
已分配和已预留内存是实时快照,但最大已分配值是随时间变化的峰值。为了准确测量,你应该在基准测试之前重置峰值统计:
torch.cuda.reset_peak_memory_stats()
result=measure_one_request(model,tokenizer,prompt,device=“cuda”)
memory=gpu_memory_summary(“cuda”)
print(memory)
内存应该与 token 一起测量。提示更长或生成 token 更多的运行自然会使用更多的 KV 缓存内存。
测量并发请求
要创建一个运行语言模型的服务器,可以通过系统每秒能处理多少请求来评估它。吞吐量既取决于你完成一个请求的速度,也取决于你能并发运行多少个请求,尽管在争用条件下并发并不会线性扩展。
生产系统应该处理多个用户,而调度器可能会将他们的工作批处理在一起。下面这个简单的基准测试使用 Python 线程并发运行几个请求。这并没有实现连续批处理。它只是衡量当多个调用者同时使用同一个模型包装器时,它的行为如何。
1
2
3
4
5
6
7
8
9
10
11
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
fromconcurrent.futuresimportThreadPoolExecutor,as_completed
defrun_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
returnresult
defbenchmark_concurrent(model,tokenizer,prompts,device=“cpu”,workers=4):
results=[]
start=time.perf_counter()
withThreadPoolExecutor(max_workers=workers)aspool:
futures=[
pool.submit(run_prompt,model,tokenizer,prompt,device)
forpromptinprompts
]
forfutureinas_completed(futures):
results.append(future.result())
end=time.perf_counter()
total_output_tokens=sum(item[“output_tokens”]foriteminresults)
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”]foriteminresults]),
}
这个基准测试仅用于说明。它不能替代真实的服务基准测试。它没有模拟 HTTP 开销、流式传输、请求队列、批处理、取消或缓存驱逐。但它是单请求基准测试之后有用的下一步,你可以并行运行模型并观察每个请求的延迟。Python GIL(全局解释器锁)通常不是这里的主要问题,因为重量级的模型执行通常会卸载到编译代码中。在没有同步的情况下,从多个线程并发使用同一个模型或张量是不安全的,因此在 CUDA 上共享一个模型时,只能使用锁或单个工作线程。
在对真实服务器进行基准测试时,至少记录:
- 并发用户数
- 提示 token 分布
- 输出 token 分布
- 请求速率
- TTFT(首个 token 时间)百分位数
- token 间延迟百分位数
- 每秒总 token 数
- 错误率和超时率
分布很重要。一个所有提示都恰好是 128 个 token、所有输出都恰好是 128 个 token 的基准测试很容易比较,但它可能不能代表你的应用程序。
多 GPU 和多机器
多 GPU 可以通过几种不同的方式用于推理。不同的方法会极大地改变你系统的性能。
最简单的方法是复制。你在每个 GPU 上加载一份模型副本,并将不同的请求路由到不同的副本。这提高了吞吐量,而且易于推理,但每个 GPU 必须有足够的内存来容纳完整的模型及其 KV 缓存。
另一种方法是将一个模型拆分到多个 GPU 上。张量并行将权重矩阵拆分到多个设备上。流水线并行将不同的层放在不同的设备上。上下文并行对序列工作进行分区。专家并行用于混合专家模型。这些技术允许运行更大的模型,但它们会引入通信开销,并可能增加延迟。
多机器又增加了一层复杂性。一个系统可能跨机器使用许多副本来处理高请求量。它也可能将单个大型模型拆分到多台机器上,但这更困难,因为网络通信比单台机器内的通信慢。对于低延迟服务,在一次前向传播中跨越机器边界应被视为代价高昂的操作。
测量多 GPU 或多机器系统的性能增加了通信和同步开销这一新维度。在选择多 GPU 或多机器设计之前,请回答这些问题:
- 你是在服务一个大型模型还是许多较小的模型?
- 你是
相似文章
@TeachTheMachine: 使用Transformer模型:从训练到推理
本教程介绍如何使用Transformer模型,从训练到推理,重点讲解自回归生成、prefill(预填充)与decode(解码)阶段,以及用于高效推理的键值缓存。
@ickma2311: 高效AI 第12讲:Transformer 与 LLM 本讲不仅介绍 LLM 的工作原理,还深入讲解其底层构建模块……
一门高效AI课程的第12讲笔记,涵盖 Transformer 与 LLM 基础知识,包括多头注意力机制、位置编码、KV 缓存,以及模型架构与推理效率之间的关联。内容阐释了 Transformer 中的设计选择如何影响内存占用、延迟表现和硬件效率。
您的LLM推理基准测试在误导您
本文解释了为何LLM推理的合成基准测试可能具有误导性,因为生产流量具有突发性和可变性,并建议使用真实工作负载进行测试以选择正确的推理框架。
Transformer 数学探索器 [P]
这个交互式工具通过数据流图可视化 Transformer 模型的数学基础,涵盖了从 GPT-2 到 Qwen 3.6 的架构以及各种注意力机制。
降低LLM延迟
用于降低大语言模型延迟、提高推理速度的技术和方法。