服务与批处理


文档摘要

服务与批处理 把一个 LLM 服务给成千上万的并发用户,远不止是加载模型再跑推理这么简单。本文件涵盖预填充-解码的拆分、连续批处理(continuous batching)、PagedAttention 和 vLLM、调度策略、分离式服务、多模型与 LoRA 服务,以及那些真正重要的指标 单个 LLM 推理请求很简单:喂入 token,生成输出 token。但要把 LLM 以低延迟、高吞吐服务给一万个并发用户,就是个系统工程问题了。最朴素的办法(一次处理一个请求)会浪费 90% 以上的 GPU 算力。聪明的批处理和调度能在不加硬件的前提下把吞吐提升 10-50 倍。

服务与批处理

把一个 LLM 服务给成千上万的并发用户,远不止是加载模型再跑推理这么简单。本文件涵盖预填充-解码的拆分、连续批处理(continuous batching)、PagedAttention 和 vLLM、调度策略、分离式服务、多模型与 LoRA 服务,以及那些真正重要的指标

  • 单个 LLM 推理请求很简单:喂入 token,生成输出 token。但要把 LLM 以低延迟、高吞吐服务给一万个并发用户,就是个系统工程问题了。最朴素的办法(一次处理一个请求)会浪费 90% 以上的 GPU 算力。聪明的批处理和调度能在不加硬件的前提下把吞吐提升 10-50 倍。

预填充 vs 解码:两个截然不同的阶段

  • LLM 推理有两个计算特性根本不同的阶段:

  • 预填充(prefill)(prompt 处理):一次性处理所有输入 token。这是一次大矩阵乘:O(\text{prompt\_length} \times d_{\text{model}}^2)。prompt 可以并行处理(所有 token 都已知)。预填充是**计算受限(compute-bound)**的:GPU 的 ALU 是瓶颈。

  • 解码(decode)(token 生成):自回归地一次生成一个 token。每个新 token 都要通过 KV-cache attend 到所有历史 token。解码是**访存带宽受限(memory-bandwidth-bound)**的:GPU 大部分时间在从显存搬模型权重和 KV-cache,而不是在算。每个解码步只产出一个 token,却要搬动整个模型(70B 模型 FP16 约 140 GB)。

  • 几点含义:

预填充(prefill) 解码(decode)
处理的 token 一次性全部(并行) 一次一个(串行)
瓶颈 计算(FLOPS) 显存带宽
算术强度 很低
GPU 利用率 高(50-80%) 无批处理时很低(1-10%)
延迟指标 首 token 延迟(TTFT) 每输出 token 耗时(TPOT)
  • TTFT 影响用户体验(响应开始流式返回要等多久)。TPOT 决定主观生成速度。用户能容忍较高的 TTFT(1-5 秒),但期望较快的 TPOT(对话类应用每 token 30-100 ms)。

静态批处理(朴素)

  • 最简单的批处理:攒 B 个请求,把它们填充到同样长度,当成一个 batch 处理。

  • 问题 1:请求的 prompt 长度不同,生成的输出 token 数也不同。短请求早早结束,却得等 batch 里最长的那个请求结束,才能开始下一个 batch。GPU 在为唯一剩下的长请求生成时空转。

  • 问题 2:填充(padding)浪费算力。最长 prompt 2000 token、最短 50 token,batch 就得填充到 2000。GPU 要为短请求处理 1950 个 padding token——纯浪费。

静态批处理在等最长请求时空耗 GPU 槽位;连续批处理立即填满空出来的槽位

连续批处理

  • 连续批处理(continuous batching)(也叫迭代级批处理)以单个解码步为粒度运作(而不是整个请求),从而同时解决上面两个问题。

  • 每个解码步:

    1. 所有在途请求并行生成一个 token(作为一个 batch)。
    2. 结束的请求(生成了 EOS token)立即从 batch 里移除
    3. 队列里的新请求立即插入到空出来的槽位。
  • batch 大小每一步都在动态变化。GPU 永远不会因等拖后腿的请求而空转,也没有浪费的 padding(每个请求只用自己需要的槽位)。

  • 效果:连续批处理通常比静态批处理提升 2-10 倍吞吐,且不改变模型质量、也不显著增加延迟。

PagedAttention 和 vLLM

  • KV-cache 带来了显存管理的噩梦。每个请求都有一个随生成 token 增长的 KV-cache。不同请求处于不同阶段(缓存大小不同)。为每个请求分配连续显存会浪费空间(必须按可能的最大长度预分配,哪怕请求只生成几个 token)。

PagedAttention 把虚拟的 KV-cache 页映射到不连续的物理 GPU 显存,消除碎片化并按需分配

  • PagedAttention(Kwon 等,2023)把操作系统的虚拟内存概念(第 13 章)用到 KV-cache 上。缓存被切成固定大小的页(pages)(一组 token 位置的块)。页按需分配,在物理 GPU 显存里可以不连续。

  • 好处:

    • 无碎片化:页大小统一,请求之间没有浪费显存的"洞"。
    • 惰性分配:只有真正生成 token 时才分配显存,而不是预先按最大长度分配。
    • 写时复制(copy-on-write):共享公共前缀(如系统 prompt)的请求共享同样的 KV-cache 页。只有当请求分叉时才复制页。
  • vLLM 就是围绕 PagedAttention 构建的推理引擎。它通过几乎消除 KV-cache 显存浪费,实现了比静态分配服务(如不带分页注意力的 HuggingFace text-generation-inference)高 2-4 倍的吞吐。

调度策略

  • 当多个请求在排队、而 GPU 只能处理有限 batch 时,**调度(scheduling)**决定服务哪些请求:

  • 先来先服务(FCFS):按到达顺序处理请求。简单但不公平:一个提交 1 万 token 生成的用户会阻塞后面所有人。

  • 最短作业优先(SJF):先处理会最快完成的请求。最小化平均延迟,但惩罚长请求(可能饿死)。实际中输出长度估计未知,所以 SJF 用启发式(prompt 长度、用户历史)。

  • 抢占(preemption):如果高优先级请求到来,暂停一个低优先级的在途请求(把它的 KV-cache 换出到 CPU 显存或 SSD),服务完高优先级请求,再恢复被暂停的。vLLM 支持这个。

  • 基于优先级:给用户或请求类型分配优先级。实时交互查询比批处理任务优先级高。配合抢占,能保证高优先级流量的延迟 SLO。

  • token 预算:限制活跃 batch 里的总 token 数。防止少数长请求独占 GPU 显存、饿死新请求。

分离式服务

  • 预填充和解码的计算画像恰好相反。在同一张 GPU 上跑两者,意味着 GPU 在计算受限(预填充)和访存带宽受限(解码)之间来回切换,哪种资源都用不满。

  • **分离式服务(disaggregated serving)**把两者分开:

    • 预填充节点:为计算优化的 GPU(高 FLOPS,显存可能少些)。处理所有进来的 prompt。
    • 解码节点:为访存带宽优化的 GPU(大 KV-cache 容量、高显存带宽)。处理所有 token 生成。
  • 预填充节点算出初始 KV-cache,发给解码节点(通过 NVLink 或网络)。解码节点用收到的缓存生成 token。

  • 这就是 Mooncake(Moonshot AI)的架构,目前也有不少 LLM 服务团队在探索。好处:每种 GPU 类型都匹配它的工作负载特征,整体利用率更高。

多模型与 LoRA 服务

  • 在生产环境中,你常常要同时服务多个模型(不同档位用不同大小、不同任务用不同微调变体)。

  • 模型多路复用(model multiplexing):在同一张 GPU 上加载多个模型,把请求路由到合适的模型。GPU 显存共享:一张 40 GB 的 GPU 可以同时装一个 13B 模型(26 GB)和一个 7B 模型(14 GB)。

  • LoRA 服务:与其部署各自独立的微调模型,不如部署一个基座模型配多个 LoRA 适配器(LoRA adapters)(第 6 章)。每个适配器增加 <1% 的参数。推理时把请求路由到合适的适配器。

  • S-LoRA(Sheng 等,2023):从一个基座模型服务上千个 LoRA 适配器。适配器存在 CPU 上,按需分页进 GPU 显存。基座模型的 KV-cache 和权重是共享的;每个请求只有小的 LoRA 矩阵不同。

  • Punica(Chen 等,2023):用一个自定义 CUDA kernel,在同一 batch 里给不同请求套用不同的 LoRA 矩阵,从而跨不同 LoRA 适配器做批处理。避免了每个请求切换适配器的开销。

约束生成与引导生成

  • 很多应用需要 LLM 输出特定格式:合法的 JSON、SQL 查询、某种语言的代码,或符合某个 schema 的响应。**约束生成(constrained generation)**保证输出符合某个文法或 schema。

  • 文法约束解码(grammar-constrained decoding):在每个解码步,屏蔽掉会违反文法的 token。如果当前输出是 {"name": "Alice", "age": 且文法要求下一个是整数,就屏蔽掉除数字外的所有 token。LLM 的概率分布在合法 token 上重新归一化。

  • Outlines(Willard & Louf,2023):把 JSON schema 或正则表达式编译成有限状态机(FSM)。每个解码步,FSM 决定哪些 token 是合法的后续。非法 token 概率置 0。这保证 100% 的 schema 合规,零重试。

  • SGLang 原生集成了约束生成:你用 Python 描述输出结构,引擎高效地处理 token 屏蔽和缓存。配合 RadixAttention(前缀缓存),结构化输出能复用缓存的前缀。

  • 为什么重要:没有约束生成,你得自由生成再解析输出,失败就重试。复杂 JSON schema 的重试率常常有 10-30%,浪费算力。约束生成彻底消除重试。

请求路由

  • 不是每个查询都需要最大的模型。**请求路由(request routing)**根据估计的难度把查询导向不同模型:

  • 级联(cascading):先试小模型。如果小模型的置信度低于阈值(例如 top token 的 softmax 概率 < 0.8),升级到大模型。简单查询(80% 以上的流量)由小模型便宜地服务;只有难查询才用贵的模型。

  • 学习型路由:训练一个轻量分类器(或用小模型的困惑度)预测查询需要哪个模型档位。把"2+2 等于几"路由给 3B 模型,把"解释量子纠缠的数学基础"路由给 70B 模型。

  • 效果:如果 80% 的查询能由成本低 10 倍的模型处理,每查询平均成本就降约 70%。这是多模型部署中投入产出比最高的成本优化之一。

  • 端云混合路由Cactus(开源项目,仓库位于 github.com/cactus-compute/cactus)在设备层实现请求路由。它通过自定义的 ARM SIMD kernel 在设备端(手机、笔记本、可穿戴)运行小模型,并在本地模型置信度低或查询超出设备能力时自动路由到云端模型。应用对两条路径都用 OpenAI 兼容 API——路由是透明的。这是基础设施级的级联:第一档免费(端侧),第二档要钱(云 API)。对于大多数查询都很简单的应用(助手问答、自动补全、转录),端侧处理能以零边际成本覆盖 70-90% 的流量。

推理指标

  • 合适的指标取决于用例:
指标 衡量什么 目标(对话) 目标(批处理)
TTFT 首 token 延迟 <1 s 不那么重要
TPOT 每输出 token 耗时 <100 ms 不那么重要
吞吐 每秒 token 数(总量) 不那么重要 最大化
p99 延迟 最差的 1% 请求 <5 s <30 s
每 token 成本 美元/百万 token 最小化 最小化
SLO 达标率 满足延迟目标的请求比例 >99% >95%
  • TTFT vs TPOT 权衡:激进的批处理提升吞吐(每秒总 token 更多)但会增加 TPOT(每个 token 耗时更长,因为 GPU 要处理更多请求)。调度策略必须在吞吐(收入)和延迟(用户体验)之间平衡。

  • 每 token 成本是生产的终极指标。它综合了硬件成本(GPU 租金)、吞吐(每秒 token)和利用率。一个跑在 50% GPU 利用率的系统,每 token 成本是 100% 利用率系统的 2 倍。这就是为什么批处理、调度和 PagedAttention 这么重要——它们提高利用率。

编程练习(使用 CoLab 或 notebook)

  1. 模拟连续批处理 vs 静态批处理,测量吞吐差异。
import random import time def simulate_static_batching(requests, batch_size=8): """按固定 batch 处理请求。等所有请求都结束。""" total_tokens = 0 total_time = 0 for i in range(0, len(requests), batch_size): batch = requests[i:i + batch_size] max_len = max(r['output_len'] for r in batch) # batch 里所有请求都按最长的算时间 batch_time = max_len * 0.01 # 每 token 10ms total_time += batch_time total_tokens += sum(r['output_len'] for r in batch) return total_tokens / total_time # 每秒 token def simulate_continuous_batching(requests, max_batch=8): """用连续批处理。结束的移除,新的加入。""" total_tokens = 0 total_time = 0 active = [] queue = list(requests) while active or queue: # 填满 batch while len(active) < max_batch and queue: active.append({'remaining': queue.pop(0)['output_len']}) if not active: break # 一次解码步:所有活跃请求各生成 1 个 token for req in active: req['remaining'] -= 1 total_tokens += len(active) total_time += 0.01 # 每步 10ms # 移除已完成的请求 active = [r for r in active if r['remaining'] > 0] return total_tokens / total_time # 生成输出长度各异的请求 random.seed(42) requests = [{'output_len': random.randint(10, 500)} for _ in range(100)] static_tps = simulate_static_batching(requests) continuous_tps = simulate_continuous_batching(requests) print(f"Static batching: {static_tps:.0f} tokens/s") print(f"Continuous batching: {continuous_tps:.0f} tokens/s") print(f"Speedup: {continuous_tps / static_tps:.1f}x")
  1. 计算 PagedAttention 带来的 KV-cache 显存节省。对比预分配(最坏情况)vs 分页(实际用量)。
def paged_vs_preallocated(n_requests, max_seq_len, avg_seq_len, page_size, kv_per_token_bytes): """对比显存占用:预分配 vs 分页 KV-cache。""" # 预分配:每个请求都拿到 max_seq_len 个槽位 preallocated_gb = n_requests * max_seq_len * kv_per_token_bytes / 1e9 # 分页:只按实际用量分配(按页粒度) import math avg_pages = math.ceil(avg_seq_len / page_size) paged_gb = n_requests * avg_pages * page_size * kv_per_token_bytes / 1e9 waste_preallocated = (max_seq_len - avg_seq_len) / max_seq_len waste_paged = (avg_pages * page_size - avg_seq_len) / (avg_pages * page_size) print(f"Requests: {n_requests}, Max seq: {max_seq_len}, Avg seq: {avg_seq_len}") print(f" Preallocated: {preallocated_gb:.1f} GB (waste: {waste_preallocated:.0%})") print(f" Paged: {paged_gb:.1f} GB (waste: {waste_paged:.0%})") print(f" Savings: {preallocated_gb - paged_gb:.1f} GB ({preallocated_gb/paged_gb:.1f}x)") print() # Llama-70B:每层每 token 约 1.3 KB,80 层 = 每 token 总计约 100 KB kv_bytes = 100_000 # 场景 1:短请求、最大长度大 paged_vs_preallocated(256, max_seq_len=4096, avg_seq_len=256, page_size=16, kv_per_token_bytes=kv_bytes) # 场景 2:长度不一 paged_vs_preallocated(256, max_seq_len=8192, avg_seq_len=1024, page_size=16, kv_per_token_bytes=kv_bytes) # 场景 3:长上下文 paged_vs_preallocated(64, max_seq_len=131072, avg_seq_len=16000, page_size=16, kv_per_token_bytes=kv_bytes)

发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U