说说从一次推理请求出发:模型、显存、网络与服务系统如何共同决定性能(学习笔记)

这两天一直在研究这个话题,踩了几个坑,把遇到的东西整理成文,供有需要的朋友参考。

文章目录


1 -> 引言

同一个大模型,在个人电脑上可能每秒只生成几个 Token,在服务器上可以支撑大量并发;同一套服务在空载时响应很快,用户一多却开始长时间排队。把这些差异全部归因于“GPU 不够快”或“模型太大”,通常找不到真正的瓶颈。

大语言模型推理是一条完整链路。请求要经过网络、排队和批处理,模型先读取全部输入并建立 KV Cache,再逐个生成输出 Token。模型权重占用显存,KV Cache 随上下文和并发增长,多张 GPU 之间还要同步中间结果。用户感受到的延迟,是这些环节共同作用的结果。

搞懂这条链路的意义,不是要求每个人都去设计芯片,而是学会把“推理很慢”拆成可以测量的问题:首字慢在哪里,后续生成为什么慢,并发增加后吞吐是否仍然上升,显存究竟被权重还是缓存占满。

2 -> 一次请求包含两个性质不同的计算阶段

请求到达服务后,通常先进入调度队列。服务端会根据显存、批次 Token 上限和调度策略,把一个或多个请求组合起来。模型真正执行时,可以把过程分成 Prefill 和 Decode 两个阶段。

Prefill 会并行处理全部输入 Token,计算每一层注意力需要的 Key 和 Value,并把它们保存到 KV Cache。输入越长,需要处理的 Token 越多,首个输出通常就越晚出现。Prefill 能形成较大的矩阵运算,通常更容易利用 GPU 计算单元。

Decode 每次只生成一个新 Token。新 Token 会查询之前已经保存的 KV Cache,并经过模型各层得到下一个 Token 的概率。这个过程具有自回归依赖,前一个 Token 没生成,后一个 Token 就不能开始。在较小批次下,Decode 经常需要反复读取大量权重和缓存,因而更容易受到显存带宽限制。

“Prefill 偏计算密集、Decode 偏内存带宽密集”是常见近似,不是对所有模型和批次都成立的铁律。批次、量化方式、模型结构和硬件都会改变算术强度。但这个近似足以解释一个常见现象:增加计算峰值可能明显改善长 Prompt 的首字时间,却不一定同比提高单用户的逐 Token 生成速度。

FlashAttention 论文进一步说明,注意力性能不仅取决于浮点运算量,还取决于 GPU 高带宽内存与片上 SRAM 之间的数据读写。它通过分块计算减少内存访问,在保持精确注意力结果的情况下改善运行时间和内存占用。这也是为什么只算是芯片的理论算力并不足以预测真实推理速度。

3 -> 先统一指标,再讨论快慢

推理系统最常用的指标包含 TTFT、TPOT、端到端延迟和系统吞吐。不同工具对边界的定义可能略有差异,算是结果前必须确认口径。NVIDIA 的推理基准指标文档给出了这些指标的一套明确说明。

指标含义核心受什么影响用户感受TTFT从提交请求到收到首个有效 Token排队、网络、输入长度、Prefill等多久开始见到回答TPOT / ITL首个 Token 之后,相邻输出 Token 的平均间隔Decode、显存带宽、KV Cache、并行通信回答生成是否流畅E2E Latency从请求开始到完整回答结束TTFT、输出长度、TPOT整个任务花多久TPS系统单位时间输出的 Token 总数批处理、并发、硬件利用率服务总体处理能力RPS系统每秒完成的请求数请求长度分布、TPS、排队策略服务能承受多少请求

若输出 Token 数为 N,并且 N > 1,常见的 TPOT 计算方式是:

TPOT = (end_time - first_token_time) / (N - 1)

系统 TPS 则可以用测试窗口内输出 Token 总数除以从第一个请求开始到最后一个请求结束的时间。必须注意,TPS 高不代表单个用户体验一定好。提高并发和批次可以增加系统总吞吐,却可能让每个请求排队更久,导致 TTFT 和 TPOT 上升。工程上要寻找满足延迟目标时的最高吞吐,而不是单独追求一个最大数字。

4 -> 显存先装权重,剩余空间才属于 KV Cache

部署模型前,首先要做容量预算。最粗略的权重显存可以这样估算:

权重容量 ≈ 参数量 × 每个参数的字节数

一个 70B 参数模型以 BF16 或 FP16 保存,理论权重约为 70 × 10^9 × 2 字节,也就是约 140 GB。采用 8 位或 4 位量化后,理论权重可以下降到约 70 GB 或 35 GB,但实际运行还要考虑量化元数据、临时缓冲区、框架开销和显存碎片,不能把理论值直接当成部署上限。

KV Cache 的大小取决于模型层数、KV 头数量、每个头的维度、上下文 Token 数和数据精度。对常见 Transformer,可以用下面的近似式:

KV Cache 字节数
≈ 2 × 层数 × KV头数 × 头维度 × Token数 × 每元素字节数

最前面的 2 分别代表 Key 和 Value。采用 GQA 或 MQA 的模型,其 KV 头数可能显著少于查询头数,这样一来缓存需求也会下降。

下面的 Python 函数可以快速估算单个请求和多并发下的 KV Cache 理论容量:

def kv_cache_gib(
    layers: int,
    kv_heads: int,
    head_dim: int,
    tokens: int,
    bytes_per_element: int = 2,
    concurrency: int = 1,
) -> float:
    bytes_per_request = (
        2
        * layers
        * kv_heads
        * head_dim
        * tokens
        * bytes_per_element
    )
    total_bytes = bytes_per_request * concurrency
    return total_bytes / (1024**3)

single = kv_cache_gib(
    layers=32,
    kv_heads=8,
    head_dim=128,
    tokens=8192,
)
concurrent = kv_cache_gib(
    layers=32,
    kv_heads=8,
    head_dim=128,
    tokens=8192,
    concurrency=16,
)

print(f"single request: {single:.2f} GiB")
print(f"16 concurrent requests: {concurrent:.2f} GiB")

这组参数下,每个 Token 的 KV 数据约为 128 KiB,8,192 Token 的单请求缓存约为 1 GiB,16 个同长度并发请求约需 16 GiB。实际服务还会受到缓存块大小、序列长度差异、前缀共享和达成开销影响,但这个数量级已经能说明为什么长上下文与高并发很容易吃掉权重之外的显存。

PagedAttention 论文借鉴操作系统分页思想,把每个请求的 KV Cache 划分为块,减少连续内存预留造成的碎片,并支持缓存块共享。它解决的核心是缓存管理效率,不会让 KV 数据本身凭空消失。若上下文和并发都持续增长,容量预算仍然不可省略。

5 -> 每种优化手段解决的问题不同

推理优化不能从“最流行的技术”开始,而应从已经测到的瓶颈开始。

技术核心解决什么可能的代价或边界量化降低权重容量和内存带宽压力可能影响精度,部分硬件未必有高效内核FlashAttention减少注意力计算的内存读写需要模型、精度与硬件内核支持PagedAttention降低 KV Cache 碎片,支持动态分配仍受总显存和上下文长度限制连续批处理请求结束后立即补入新请求,提高利用率批次过大可能损害交互延迟前缀缓存复用重复系统提示或公共前缀的 KV前缀必须一致,缓存需要淘汰策略推测解码用较小模型提议多个 Token,由大模型验证接受率低时收益有限,还增加系统复杂度张量并行将单个模型权重拆到多张 GPU每层都可能发生通信,互联性能很重要Prefill/Decode 分离分别调度两种性质不同的负载KV 传输、路由和容量规划更复杂

连续批处理会在每一步 Decode 后重新组织批次,已完成请求离开,新请求立即加入,不必等待整个静态批次结束。Hugging Face 的连续批处理文档也把调度策略、最大批次 Token、分页缓存和前缀缓存列为相互关联的配置。提高批次上限通常有利于吞吐,但如果大量长 Prompt 与短交互请求混在一起,用户可见延迟可能变差。

张量并行则解决单张 GPU 放不下模型的问题。权重被切分后,各 GPU 分别计算局部结果,再通过高速互联同步。GPU 数量增加并不保证线性加速;如果通信时间抵消了计算收益,更多设备反而可能提高成本却没有改善延迟。

6 -> 用一份日志计算 TTFT、TPOT 和系统吞吐

优化之前,至少要保存请求开始时间、首个 Token 时间、结束时间、输入 Token 数和输出 Token 数。假设基准工具导出了下面的 CSV:

request_id,start_ts,first_token_ts,end_ts,input_tokens,output_tokens
r1,0.00,0.42,3.10,1024,80
r2,0.15,0.71,4.36,4096,96
r3,0.40,0.88,2.92,512,64

下面的标准库脚本可以计算每个请求的指标,以及测试窗口的 p50、p95 和总 TPS:

```
from __future__ import annotations

import csv
import math
from pathlib import Path

def percentile(values: list[float], q: float) -> float:
if not values:
raise ValueError("values must not be empty")
ordered = sorted(values)
position = (len(ordered) - 1) q
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return ordered[lower]
weight = position - lower
return ordered[lower]
(1 - weight) + ordered[upper] * weight

rows: list[dict[str, float | int | str]] = []
with Path("requests.csv").open(encoding="utf-8", newline="") as file:
for raw in csv.DictReader(file):
output_tokens = int(raw["output_tokens"])
start = float(raw["start_ts"])
first = float(raw["first_token_ts"])
end = float(raw["end_ts"])

if not start


暂时整理到这里。以上都是个人理解,可能有疏漏,欢迎指正。

评论 (0)

暂无评论