跳到主要内容

L4.1 推理服务器与运行时

三维坐标 layer: L4(推理 Serving)level: Seniorpillar: 训推框架

本文是 L4 推理层的开篇。目标不是罗列「有哪些推理框架」,而是讲透一台推理服务器从「显存管理」到「请求调度」的两大内核机制——为什么 vLLM 能把吞吐拉高一个数量级,以及 TensorRT-LLM / Triton 各自占据运行时栈的哪一格。

学习目标

  • 前置知识:读过 L0.1(理解一条推理请求 prefill→decode 的完整流转)与 L0.2(知道 decode 阶段是 memory-bound、瓶颈在显存带宽而非算力);学过 L2.5 FlashAttention(理解 attention kernel 的 IO 感知优化,本文 KV Cache 正是建立在它之上)。会写基础 Python / 用过 OpenAI 风格 API 即可,无需读过任何 Serving 框架源码。
  • 学完产出:① 能讲清传统「连续显存预分配」为何让 KV Cache 利用率长期低于 40%,并区分内部碎片与外部碎片;② 能画出 PagedAttention「逻辑块 → 块表 → 离散物理块」的三层映射,说清它如何把碎片压到 4% 以下并支撑前缀复用;③ 能用「请求级 vs 迭代级调度」一句话讲透 Continuous Batching 相对 Static Batching 翻数倍的根因,并说明它与 PagedAttention 为何是共生关系;④ 能用 Prefill/Decode 阶段抢占解释「长 prompt 涌入 → 短请求 P99 尖刺」,并给出 Chunked Prefill 等不加卡的缓解手段;⑤ 能为给定业务在 vLLM 与 TensorRT-LLM 之间做选型,并亲手用 vLLM 起服务、压测出吞吐先升后饱和、P99 在显存打满后陡增的曲线。
  • 阅读姿势:盯住一条主线——「推理服务器的所有内核设计,都是为了让 GPU 的每一个计算步(step)都坐满有效请求、且每一份显存都用在存活的 KV 上」。无论是 PagedAttention 消灭碎片、Continuous Batching 填满座位,还是 Chunked Prefill 抹平抢占,本质都在解决同一个问题:decode 是 memory-bound 的,GPU 算力没打满、显存却先被碎片和长请求吃光——把这堵「显存与调度墙」推开,吞吐才能翻番。

背景与现状

推理服务器(Inference Server)运行时(Runtime) 是两个常被混为一谈、实则分工明确的概念。运行时负责「单次前向计算怎么跑得最快」——把模型图编译成针对特定硬件的 kernel(如 TensorRT 引擎、ONNX Runtime EP);推理服务器则负责「成百上千个并发请求怎么调度得最省」——KV Cache 怎么分配、batch 怎么拼、请求怎么排队。一句话:运行时管「算得快」,服务器管「服务得多」

从产业视角看,LLM Serving 的演进可以概括为三个阶段:

  • 静态批处理时代(2022 前):沿用 CV/NLP 的 Static Batching,一个 batch 里所有请求必须等最长的那条生成完才能返回,GPU 利用率常年低于 30%
  • PagedAttention 拐点(2023):vLLM 提出 PagedAttention,把操作系统的虚拟内存分页思想搬进 KV Cache,显存碎片从 60%+ 压到 4% 以下,吞吐相对 HuggingFace transformers 提升 14–24 倍。
  • 运行时深度融合(2023 至今)TensorRT-LLM 把层融合 + FP8 + In-flight Batching 做到极致,Triton Inference Server 成为多框架统一编排底座,Serving 与编译运行时的边界越来越模糊。

业界信号vllm-project/vllm 已成为开源 LLM Serving 的事实标准,几乎所有云厂商的 LLM 推理 API 后端都能看到 vLLM 或 TensorRT-LLM 的身影——这说明「调度算法 + 显存管理」才是推理成本的胜负手,而非简单堆卡。

原理与架构

理解现代推理服务器,只需抓住两条主线:KV Cache 怎么存(PagedAttention)请求怎么调度(Continuous Batching)。这两者正是 vLLM 的灵魂。

2.1 为什么 KV Cache 是显存杀手

自回归生成时,每个已生成 token 的 Key / Value 向量都要缓存下来,供后续 token 做 attention。一个序列的 KV Cache 显存随长度线性增长,且长度事先未知(生成到 EOS 才停)。

传统做法:为每个请求预留一段连续显存(按 max_seq_len 预分配)。这带来两类致命浪费:

  • 内部碎片(Internal Fragmentation):按最大长度预留,但实际只生成几十个 token,大量预留显存空转。
  • 外部碎片(External Fragmentation):请求长度参差不齐,连续块释放后留下大小不一的空洞,新请求塞不进去。

实测下来,传统方案真正用于存 KV 的显存常常不足 40%,其余全是碎片。

2.2 PagedAttention:把 OS 虚拟内存搬进 GPU

PagedAttention 的核心洞察:KV Cache 不必物理连续。它借鉴 操作系统虚拟内存分页——把 KV Cache 切成固定大小的 物理块(Block,如 16 个 token),逻辑上连续的序列可以映射到物理上离散的块,通过一张 Block Table(块表,等价于页表) 维护「逻辑块 → 物理块」的映射。

这张图的关键:逻辑块 0/1/2 连续,但映射到的物理块 7/2/9 完全离散。带来三个工业级收益:

  • 几乎零碎片:按需分配块,用多少分多少,显存利用率冲到 96%+。
  • 写时复制(Copy-on-Write)共享:并行采样 / Beam Search 多个候选共享同一段 prompt 的物理块,只在分叉处复制——前缀复用(Prefix Caching) 也建立在此之上。
  • 动态增长无需搬迁:序列变长只需追加新块、更新块表,不必整体迁移连续显存。

2.3 Continuous Batching:迭代级调度

光有省显存还不够,怎么把多请求拼成 batch 喂给 GPU 决定吞吐上限。传统 Static Batching请求级(request-level) 调度——整批一起进、一起出,短请求被长请求拖死。

Continuous Batching(连续批处理,又称 In-flight Batching) 改为 迭代级(iteration-level) 调度:每生成一个 token 就重新组一次 batch——已完成的请求立刻离场、释放块,等待队列里的新请求立刻补位。

核心在于:Continuous Batching 让 GPU 几乎不空转——空出的「座位」立刻被新请求填满,这是吞吐相对 Static Batching 翻数倍的根因。它与 PagedAttention 是共生关系:没有分页式的快速块分配 / 释放,迭代级的请求进出会被显存搬迁成本拖垮。

2.4 Chunked Prefill:抹平 Prefill 与 Decode 的抢占

请求分两阶段:Prefill(处理完整 prompt,计算密集)Decode(逐 token 生成,访存密集)。长 prompt 的 Prefill 会长时间独占 GPU,阻塞正在 Decode 的其他请求,造成 P99 延迟尖刺。

Chunked Prefill(分块预填充) 把长 prompt 切成小块,和 Decode 请求混合进同一个 batch 调度。收益:Prefill 不再「一口气吃完」,Decode 请求得以穿插推进——TTFT(首 token 延迟)与 ITL(token 间延迟)更平滑,整体 SLO 更稳。

2.5 运行时栈:TensorRT-LLM / TGI / ONNX Runtime / Triton 定位

组件层级定位核心机制适用场景
vLLM推理服务器PagedAttention + Continuous Batching高吞吐 LLM Serving,开源首选
TensorRT-LLM编译运行时静态图构建 + 层/张量融合 + FP8 + In-flight BatchingNVIDIA 卡上追求极致延迟/吞吐
TGI推理服务器Continuous Batching + 张量并行,HF 生态HF 模型快速上线
ONNX Runtime通用运行时图优化 + 算子融合 + 多 EP(CPU/CUDA/TensorRT)跨硬件、跨框架统一推理
Triton Inference ServerServing 编排底座多框架后端 + 动态批处理 + 模型并发实例统一编排多模型/多框架,生产网关

TensorRT-LLM 的内核是 静态图(Static Graph)编译:离线把模型图固化、做 层张量融合(Layer & Tensor Fusion,如 Conv+BN+ReLU 合一、attention QKV 投影合并),并在 运行时根据 batch/shape 自动选择最优 Kernel(Kernel Auto-Tuning)。代价是「图静态化」——动态 shape 支持弱于 vLLM,灵活性换性能。

Triton Inference Server 则是更上层的编排底座:本身不实现 attention,而是统一挂载 TensorRT / ONNX / PyTorch / vLLM 等多种后端,提供动态批处理、模型并发、版本管理与统一 gRPC/HTTP 入口——它是生产环境里的「推理网关」。

动手实践:极简代码实操

实验目标:用 vLLM 起一个 OpenAI 兼容服务,写一个并发脚本压测 throughput 与 P99,并观察启动日志里的 KV Cache 块数与 gpu_memory_utilization——亲手建立「调度参数 → 显存占用 → 吞吐」的第一手感知。产出物:一份并发压测的吞吐/延迟数据 + KV Cache 占用截图。

3.1 环境准备(NVIDIA GPU 路径)

# 推荐 Python 3.11;用 uv 或 venv 隔离
python3 -m venv .venv && source .venv/bin/activate
pip install vllm # 自动拉取匹配 CUDA 的 wheel
pip install openai aiohttp # 压测客户端依赖

3.2 启动 OpenAI 兼容服务

# 起一个小模型,限制显存占比便于观察 KV Cache 分配
# 6GB 卡建议 gpu_memory_utilization=0.70、max_model_len=1024,避免 OOM
vllm serve Qwen/Qwen2.5-0.5B-Instruct \
--gpu-memory-utilization 0.70 \
--max-model-len 1024 \
--port 8000

启动日志重点看这两行(KV Cache 观测点):

# GPU blocks: 12000, CPU blocks: 4096 ← 分页式 KV Cache 的物理块总数
# Maximum concurrency for 2048 tokens per request: 93.75x ← 可并发的序列数估算

调小 --gpu-memory-utilization(如 0.5)再重启,会看到 GPU blocks 数明显下降——这直观印证「KV Cache 容量 ∝ 可分配的物理块数 ∝ 并发上限」。

3.3 并发压测脚本

import asyncio, time
from openai import AsyncOpenAI

client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
N, CONCURRENCY = 200, 50 # 总请求数 / 并发数
PROMPT = "用三句话解释什么是分页式 KV Cache。"

async def one_req(sem, latencies):
async with sem:
t0 = time.perf_counter()
await client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": PROMPT}],
max_tokens=128,
)
latencies.append(time.perf_counter() - t0)

async def main():
sem = asyncio.Semaphore(CONCURRENCY)
latencies = []
t_start = time.perf_counter()
await asyncio.gather(*[one_req(sem, latencies) for _ in range(N)])
wall = time.perf_counter() - t_start

latencies.sort()
p50 = latencies[int(0.50 * N)]
p99 = latencies[int(0.99 * N) - 1]
print(f"[throughput] {N / wall:.1f} req/s (wall={wall:.1f}s)")
print(f"[latency] P50={p50*1000:.0f}ms P99={p99*1000:.0f}ms")

asyncio.run(main())
python bench_vllm_client.py
# 本机实测(RTX 3060 6GB,N=40 CONCURRENCY=8):
# [throughput] 6.6 req/s (wall=6.1s)
# [latency] P50=871ms P99=1867ms

一键复现:bash run_vllm_lab.sh(需 .venv-sys + Python 3.12,见 ai-infra-labs 仓库)。6GB 显存下请先用 python -c "import torch; torch.cuda.empty_cache()" 释放其他进程占用的显存。

实验观察练习:把 CONCURRENCY 从 10 → 50 → 100 逐档拉高,记录 throughput 与 P99 的变化曲线——你会看到吞吐先升后饱和、P99 在显存打满后陡增,这正是 Continuous Batching 把 GPU 座位填满后触达 KV Cache 上限的信号。

3.4 无 GPU 替代方案(Mac / CPU)

vLLM 的核心收益依赖 GPU 显存分页,无 N 卡时有三条平替路径:

# 方案 A(推荐,最简):Ollama 本地跑小模型,验证 OpenAI 兼容 API
# Ollama 内部同样实现了 Continuous Batching,可观察 num_ctx 对显存/内存的影响
ollama run qwen2.5:0.5b
# 其 OpenAI 兼容端点:http://localhost:11434/v1 —— 上面的 bench.py 改 base_url 即可复用

# 方案 B:vLLM CPU 后端(吞吐低,仅用于理解调度逻辑,不用于性能结论)
# 需源码编译 CPU 版,参考官方 "Installation -> CPU" 文档

# 方案 C:Google Colab 免费 T4 GPU
# !pip install vllm 后在 Notebook 内起服务,与 3.2 路径完全一致

目的一致:无论 GPU 还是 Ollama/Colab,核心是建立「并发数 ↑ → batch 变大 → 吞吐 ↑ 直到 KV Cache 打满 → P99 陡增」的体感,这是理解 L4 调度的起点。

踩坑预警 (Gotchas)

  • 首请求极慢别误判:vLLM 启动后第一次请求包含 CUDA Graph 捕获与权重预热,测吞吐务必先发几个 warmup 请求,否则 P99 被首请求污染。
  • gpu_memory_utilization 不是越高越好:设到 0.95+ 容易在长上下文请求涌入时 OOM 崩服务;生产留 10–15% 余量给激活与碎片。
  • --max-model-len 设太大吃光块:它决定单请求最多占多少 KV 块,设过大会让 GPU blocks 被少数长请求霸占,并发上限骤降——日志里的 Maximum concurrency 会直接掉下来。
  • 压测客户端成瓶颈:单进程 Python 客户端在高并发下自身受 GIL/事件循环限制,吞吐打不满时先确认瓶颈在服务端还是客户端(用多进程或 wrk/vegeta 交叉验证)。
  • Ollama 与 vLLM 数字不可直接比:两者调度与量化策略不同,平替路径只验证趋势,不要拿来做绝对性能对比。

深入思考

下面三题每题先给题干,再用 <details> 折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。

思考题 1:显存预算如何决定最大并发

一台 80GB A100 跑 7B 模型(权重 FP16 约 14GB),你要把剩余显存切给 KV Cache。结合 2.1 节的碎片成因2.2 节 PagedAttention 的块机制,推导「block_sizemax_model_lengpu_memory_utilization」三者如何共同决定最大并发序列数?若线上长短请求混杂,你会如何取舍这三个参数来兼顾吞吐与 P99?

展开参考答案(含显存预算分配流向图 + 算一遍)

结论:可分配的 KV 物理块总数 = (总显存 × gpu_memory_utilization − 权重 − 激活预留) / 每块字节数,而单请求最多吃 max_model_len / block_size 个块;二者相除就是「理想满载」的最大并发序列数。长短混杂时,调大 max_model_len 会让少数长请求霸占块池、并发上限骤降,所以要在「容得下最长请求」与「保住高并发」之间压一个折中值。

用具体数字算一遍(7B 模型,数量级估算):

  1. 单 token 的 KV 字节数2(K 和 V)× 层数 × 每层 KV 头数 × head_dim × 2 字节(FP16)。以 7B(约 32 层、KV 维度合计 4096)估算,单 token KV ≈ 2 × 32 × 4096 × 2 ≈ 512 KB
  2. 每块字节数block_size = 16 时,一块 = 16 × 512 KB ≈ 8 MB
  3. 块池总数:50GB / 8MB ≈ 6400 块(与 3.2 节日志里 GPU blocks 量级一致)。
  4. 单请求占块max_model_len = 20482048 / 16 = 128 块/请求
  5. 理想最大并发:6400 / 128 ≈ 50 个序列。若把 max_model_len 调到 8192,单请求占 512 块,并发上限直接掉到 12 个——这就是日志里 Maximum concurrencymax_model_len 反向变化的根因(呼应 2.2 节「按需分配块」)。

长短混杂的取舍:① max_model_len 设成「业务 P99 长度」而非「理论最长」,避免被极少数超长请求拉低全局并发;② gpu_memory_utilization 生产留 10–15% 余量给激活与碎片波动(设到 0.95+ 易在长请求涌入时 OOM);③ block_size 偏小(如 16)碎片更少但块表开销略大,一般用默认即可。本质是在「单请求显存上限」与「并发座位数」之间找平衡——与思考题 3 的抢占问题同源。

思考题 2:vLLM 还是 TensorRT-LLM

同一个 7B 模型,业务 A 要「极低延迟、固定 batch、长期稳定服务」,业务 B 要「高并发、变长输入、快速迭代上线」。你会分别选 TensorRT-LLM 还是 vLLM?请从「静态图编译 vs 动态调度」「层融合收益 vs 灵活性损失」两个维度论证(呼应 2.5 节运行时栈定位)。

展开参考答案(含选型决策树图 + 对比表)

结论:业务 A 选 TensorRT-LLM——固定 batch、稳定服务正好吃满静态图编译 + 层融合的延迟红利;业务 B 选 vLLM——变长输入、快速迭代需要动态 shape 与开箱即用的 Continuous Batching,承受不起每次改配置都要重新 build engine 的灵活性损失。

两个维度的论证:

维度TensorRT-LLM(业务 A)vLLM(业务 B)
静态图编译 vs 动态调度离线把图固化成 engine,运行时按固定 batch/shape 选最优 kernel——稳定负载下延迟最低、抖动最小运行时动态组 batch、动态追加 KV 块,变长输入零成本接入,无需重新 build
层融合收益 vs 灵活性损失层张量融合(QKV 投影合并、attention kernel 融合)+ FP8 把单次前向压到极致;代价是改模型/改配置要重编译,动态 shape 支持弱灵活性优先:换模型、改 max_model_len、加 LoRA 都是改参数即可;代价是单请求极限延迟略逊于固化的静态图

为什么这样分:业务 A 的「固定 batch + 长期稳定」让「离线编译一次、长期复用」的成本被充分摊薄,层融合与 FP8 带来的每 token 延迟下降是纯赚;业务 B 的「变长输入 + 快速迭代」里,每次重新 build engine 的工程成本会吃掉性能收益,而 vLLM 的 PagedAttention + Continuous Batching 恰好把高并发吞吐做成了开箱即用。两者并非对立——生产里常用 Triton(2.5 节的编排底座)把 TensorRT-LLM 后端挂载进来,再叠加动态批处理,让同一网关兼顾两类业务。

思考题 3:长 prompt 涌入引发的 P99 尖刺

线上服务在涌入几条 32K 长 prompt 后,所有短请求的 P99 突然飙升。请用 2.4 节的 Prefill/Decode 阶段抢占Chunked Prefill 机制解释根因,并给出不加卡的两种缓解手段及各自代价。

展开参考答案(含 Prefill 抢占时间线图 + 缓解对比)

结论:长 prompt 的 Prefill 是一次性吃完整段 prompt 的计算密集型操作,它会长时间独占 GPU 的这一个 step,把正在 Decode 的短请求全部憋住;Chunked Prefill 把长 Prefill 切成小块、与 Decode 混排进同一 batch,让短请求得以穿插推进,从而抹平 P99 尖刺。

根因拆解(呼应 2.3 节迭代级调度与 2.4 节阶段抢占):

  • Prefill 计算密集、一次性大:32K prompt 的 Prefill 要算 32K 个 token 的完整 attention,浮点量是单次 Decode(只算 1 个新 token)的成千上万倍。在迭代级调度里,这个巨大的 Prefill 占满了某个 step,调度器在这一 step 内无法给短请求的 Decode 留位置
  • 短请求被「饿死」:Decode 是 memory-bound、单 step 很快,但只要被长 Prefill 卡住几个 step,累积的等待就让 P99(最慢的 1%)直接飙升。

两种不加卡的缓解手段及代价:

手段做法代价
开启 Chunked Prefill把长 Prefill 切成固定大小的块,每个 step 只塞一块,与短请求 Decode 混排进同一 batch长请求自身的 TTFT 略微变长(被切片穿插),且需调 max_num_batched_tokens 平衡块大小
限制单请求长度 / 排队隔离给超长 prompt 设独立队列或更小的并发配额,避免它们与短请求争抢同一批 step超长请求排队时间变长、吞吐下降;本质是把延迟从「短请求」转移给「长请求」

算一笔账:设 32K Prefill 在不切块时独占约 800ms 的一个大 step,期间所有短请求 Decode 全停;开启 Chunked Prefill 后切成 16 块、每块约 50ms,短请求得以在每两块之间各推进 1 个 token——短请求的 ITL(token 间延迟)从「最坏 800ms 一跳」摊平到「约 50ms 一跳」,P99 尖刺被显著削平,代价是长请求 TTFT 从 800ms 拉长到约 800ms + 穿插开销。这正是 2.4 节「用 TTFT 的小让步换 ITL 的大平滑」的工程取舍。

延伸阅读

专题深入:vLLM 动手实验本章已覆盖;TensorRT-LLM build 链路、ONNX 导出与 ORT 推理L4.4 TensorRT-LLM 与 ONNX Runtime

1. 核心 Paper

  • Efficient Memory Management for Large Language Model Serving with PagedAttention(vLLM,SOSP 2023)— 必读,PagedAttention 的原始论文,KV Cache 分页思想的源头。
  • Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI 2022)— Continuous Batching(iteration-level scheduling)的奠基工作。
  • FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness(2022)— 理解 attention kernel 的访存优化,是运行时层的基石。

2. 相关高 Star 仓库与源码必读路径

  • vllm-project/vllm — 入口看 vllm/core/block_manager.py(PagedAttention 块管理)、vllm/core/scheduler.py(Continuous Batching 调度核心)。
  • NVIDIA/TensorRT-LLM — 看 examples/ 下各模型的 engine build 流程,理解静态图构建与层融合。
  • huggingface/text-generation-inferencetriton-inference-server/server — 对照 TGI 与 Triton 的后端编排设计。

3. 优质博客 / 视频

  • vLLM 官方博客「vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention」— 一图看懂分页收益。
  • NVIDIA 技术博客「Mastering LLM Techniques: Inference Optimization」与 TensorRT-LLM Best Practices。
  • Anyscale「Continuous batching in LLM inference」— 用图解透 iteration-level 调度的吞吐收益。

下一篇L4.2 推理加速机制:把本文「调度与显存」放大到「单次前向算得更快」——讲清量化、投机解码、KV Cache 压缩等加速机制的物理本质。