Files
maths-cs-ai-compendium-zh/chapter 17: AI inference/03. serving and batching.md
T
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
2026-05-03 10:23:20 +08:00

237 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 服务与批处理
*向数千并发用户提供LLM服务需要的不只是加载模型和运行推理。本文涵盖预填充-解码分离、连续批处理、PagedAttention和vLLM、调度策略、分离式服务、多模型和LoRA服务,以及关键指标*
- 单个LLM推理请求很简单:输入token,生成输出token。但要向10,000个并发用户提供低延迟、高吞吐量的LLM服务,这是一个系统工程问题。朴素方法(一次处理一个请求)浪费了90%以上的GPU容量。智能批处理和调度可以在不增加硬件的情况下将吞吐量提高10-50倍。
## 预填充 vs 解码:两个截然不同的阶段
- LLM推理有两个不同的阶段,具有根本不同的计算特征:
- **预填充**(提示处理):同时处理所有输入token。这是一个单次大规模矩阵乘法:$O(\text{prompt\_length} \times d_{\text{model}}^2)$。提示可以并行处理(所有token都已知)。预填充是**计算受限**的:GPU的ALU是瓶颈。
- **解码**(token生成):自回归地一次生成一个token。每个新token需要通过KV缓存关注所有先前的token。解码是**内存带宽受限**的:GPU大部分时间花在从内存加载模型权重和KV缓存上,而不是计算。每个解码步骤只产生一个token,但必须加载整个模型(70B FP16模型约140 GB)。
- 含义:
| | 预填充 | 解码 |
|--|---------|--------|
| 处理的token | 一次性全部(并行) | 一次一个(顺序) |
| 瓶颈 | 计算(FLOPS) | 内存带宽 |
| 算术强度 | 高 | 非常低 |
| GPU利用率 | 高(50-80% | 低(1-10%),无批处理时 |
| 延迟指标 | **首token时间(TTFT** | **每输出token时间(TPOT** |
- TTFT影响用户体验(多久直到响应开始流式传输)。TPOT决定感知的生成速度。用户可以容忍较高的TTFT(1-5秒),但期望快速的TPOT(对话应用每token 30-100毫秒)。
## 静态批处理(朴素方法)
- 最简单的批处理:收集$B$个请求,填充到相同长度,作为单个批次处理。
- **问题1**:请求有不同的提示长度,并生成不同数量的输出token。短请求提前完成,但必须等待批次中最长的请求完成后才能开始下一个批次。GPU在为剩余的一个长请求生成token时处于空闲状态。
- **问题2**:填充浪费计算。如果最长提示是2000个token,最短是50个,批次被填充到2000。GPU为短请求处理了1950个填充token——纯属浪费。
![静态批处理在等待最长请求时浪费GPU槽位;连续批处理立即填充释放的槽位](../images/static_vs_continuous_batching.svg)
## 连续批处理
- **连续批处理**(也称为迭代级批处理)通过在单个解码步骤的粒度上操作来解决这两个问题,而不是整个请求。
- 在每个解码步骤:
1. 所有进行中的请求并行生成一个token(作为一个批次)。
2. 完成的请求(生成EOS token)立即从批次中**移除**。
3. 队列中的新请求立即**插入**到释放的槽位中。
- 批次大小每步动态变化。GPU从不等候落后者,也没有浪费的填充(每个请求只使用它需要的槽位)。
- **影响**:连续批处理通常比静态批处理提高吞吐量2-10倍,模型质量不变且延迟无明显增加。
## PagedAttention和vLLM
- KV缓存造成了一个内存管理噩梦。每个请求都有一个随着每个生成的token而增长的KV缓存。不同请求处于不同阶段(不同缓存大小)。为每个请求分配连续内存浪费空间(必须为最大可能长度分配,即使请求只生成几个token)。
![PagedAttention将虚拟KV缓存页映射到非连续的物理GPU内存,消除碎片并实现按需分配](../images/paged_attention.svg)
- **PagedAttention**Kwon等人,2023)将操作系统虚拟内存的概念(第13章)应用于KV缓存。缓存被划分为固定大小的**页**(token位置的块)。页按需分配,在物理GPU内存中可以是非连续的。
- 优势:
- **无碎片**:页大小统一,因此请求之间没有浪费内存的"空洞"。
- **惰性分配**:仅在token实际生成时分配内存,而不是预分配最大长度。
- **写时复制**:共享共同前缀(例如系统提示)的请求共享相同的KV缓存页。仅当请求分叉时才复制页。
- **vLLM**是基于PagedAttention构建的推理引擎。通过几乎消除KV缓存内存浪费,它实现了比静态分配服务(如没有分页注意力的HuggingFace text-generation-inference)高2-4倍的吞吐量。
## 调度策略
- 当多个请求在等待且GPU只能处理有限批次时,**调度**决定服务哪些请求:
- **先来先服务(FCFS)**:按到达顺序处理请求。简单但不公平:一个提交10K-token生成的用户会阻塞所有后面的用户。
- **最短作业优先(SJF)**:处理最先完成的请求。最小化平均延迟,但惩罚长时间运行的请求(它们可能被饿死)。在实践中,估计输出长度未知,因此SJF使用启发式方法(提示长度、用户历史)。
- **抢占**:如果高优先级请求到达,暂停低优先级的进行中请求(将其KV缓存交换到CPU内存或SSD),服务高优先级请求,然后恢复暂停的请求。vLLM支持此功能。
- **基于优先级**:为用户或请求类型分配优先级。实时交互查询比批处理作业获得更高优先级。结合抢占,这确保高优先级流量的延迟SLO。
- **Token预算**:限制活跃批次中的总token数。这防止少量长请求独占GPU内存并饿死新请求。
## 分离式服务
- 预填充和解码具有相反的计算特征。在同一GPU上运行两者意味着GPU在计算受限(预填充)和内存带宽受限(解码)之间交替,从未充分利用任一资源。
- **分离式服务**将它们分开:
- **预填充节点**:为计算优化的GPU(高FLOPS,可能内存较少)。处理所有传入提示。
- **解码节点**:为内存带宽优化的GPU(大KV缓存容量,高内存带宽)。处理所有token生成。
- 预填充节点计算初始KV缓存并通过NVLink或网络将其发送到解码节点。解码节点使用接收到的缓存生成token。
- 这是**Mooncake**(月之暗面)的架构,并正在被多个LLM服务团队探索。好处:每个GPU类型与其工作负载特征匹配,提高整体利用率。
## 多模型和LoRA服务
- 在生产中,你通常服务多个模型(不同层级的模型大小不同,不同任务的微调变体不同)。
- **模型复用**:在同一GPU上加载多个模型,将请求路由到相应模型。GPU内存共享:一个40 GB GPU可能同时持有一个13B模型(26 GB)和一个7B模型(14 GB)。
- **LoRA服务**:不是部署单独的微调模型,而是部署一个基础模型并带有多个**LoRA适配器**(第6章)。每个适配器增加<1%的参数。请求在推理时路由到相应的适配器。
- **S-LoRA**Sheng等人,2023):从一个基础模型服务数千个LoRA适配器。适配器存储在CPU上,按需分页到GPU内存。基础模型的KV缓存和权重被共享;只有小的LoRA矩阵因请求而异。
- **Punica**Chen等人,2023):通过使用自定义CUDA内核在同一批次中为不同请求应用不同的LoRA矩阵,跨不同LoRA适配器对请求进行批处理。这避免了每个请求切换适配器的开销。
## 受限和引导生成
- 许多应用需要LLM以特定格式产生输出:有效的JSON、SQL查询、特定语言的代码或遵循模式的响应。**受限生成**保证输出符合语法或模式。
- **语法受限解码**:在每个解码步骤,屏蔽会违反语法的token。如果到目前为止的输出是`{"name": "Alice", "age":`且语法要求接下来是整数,则屏蔽除数字外的所有token。LLM的概率分布在有效token上重新归一化。
- **Outlines**Willard & Louf2023):将JSON模式或正则表达式编译成有限状态机(FSM)。在每个解码步骤,FSM确定哪些token是有效的后续。无效token获得概率0。这保证了100%的模式合规,零重试。
- **SGLang**原生集成受限生成:你用Python指定输出结构,引擎高效处理token掩码和缓存。这与RadixAttention(前缀缓存)结合,使得结构化输出重用缓存的公共前缀。
- **为什么重要**:没有受限生成,你自由生成然后解析输出,失败时重试。对于复杂JSON模式,重试率通常为10-30%,浪费计算。受限生成完全消除了重试。
## 请求路由
- 并非每个查询都需要最大的模型。**请求路由**根据估计的难度将查询定向到不同的模型:
- **级联**:先尝试小模型。如果小模型的置信度低于阈值(例如,top token的softmax概率<0.8),则升级到更大的模型。简单查询(80%+的流量)由小模型廉价服务;只有困难查询使用昂贵模型。
- **学习型路由**:训练一个轻量级分类器(或使用小模型的困惑度)来预测查询需要哪个模型层级。将"2+2等于多少?"路由到3B模型,将"解释量子纠缠的数学基础"路由到70B模型。
- **影响**:如果80%的查询可以由成本低10倍的模型处理,平均每查询成本下降约70%。这是多模型部署中影响最大的成本优化之一。
- **设备端+云混合路由****Cactus**[github.com/cactus-compute/cactus](https://github.com/cactus-compute/cactus))在设备级别实现请求路由。它通过自定义ARM SIMD内核在设备端(手机、笔记本电脑、可穿戴设备)运行小模型,并在本地模型置信度低或查询超出设备能力时自动路由到云端模型。应用为两条路径使用OpenAI兼容API——路由是透明的。这是在基础设施级别的级联:第一层是免费的(设备端),第二层花钱(云API)。对于大多数查询简单的应用(助手问答、自动补全、转录),设备端处理覆盖70-90%的流量,边际成本为零。
## 推理指标
- 正确的指标取决于用例:
| 指标 | 测量内容 | 目标(对话式) | 目标(批处理) |
|--------|-----------------|------------------------|-----------------|
| **TTFT** | 首token时间 | <1 s | 不太重要 |
| **TPOT** | 每输出token时间 | <100 ms | 不太重要 |
| **吞吐量** | token/秒(总计) | 不太重要 | 最大化 |
| **p99延迟** | 最差的1%请求 | <5 s | <30 s |
| **每token成本** | $/100万token | 最小化 | 最小化 |
| **SLO合规率** | 满足延迟目标的请求百分比 | >99% | >95% |
- **TTFT vs TPOT权衡**:激进的批处理增加吞吐量(总token数/秒更多),但增加TPOT(每个token耗时更长,因为GPU处理更多请求)。调度策略必须平衡吞吐量(收入)与延迟(用户体验)。
- **每token成本**是生产的最终指标。它结合了硬件成本(GPU租金)、吞吐量(token/秒)和利用率。运行在50% GPU利用率的系统比100%利用率的系统每token成本高2倍。这就是批处理、调度和PagedAttention如此重要的原因——它们提高了利用率。
## 编程任务(使用CoLab或notebook
1. 模拟连续vs静态批处理并测量吞吐量差异。
```python
import random
import time
def simulate_static_batching(requests, batch_size=8):
"""在固定批次中处理请求。等待所有完成。"""
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_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:
# 填充批次
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_tps:.0f} tokens/s")
print(f"连续批处理: {continuous_tps:.0f} tokens/s")
print(f"加速比: {continuous_tps / static_tps:.1f}x")
```
2. 计算PagedAttention的KV缓存内存节省。比较预分配(最坏情况)vs分页(实际使用)。
```python
def paged_vs_preallocated(n_requests, max_seq_len, avg_seq_len, page_size, kv_per_token_bytes):
"""比较内存使用:预分配vs分页KV缓存。"""
# 预分配:每个请求获得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"请求数: {n_requests}, 最大序列: {max_seq_len}, 平均序列: {avg_seq_len}")
print(f" 预分配: {preallocated_gb:.1f} GB (浪费: {waste_preallocated:.0%})")
print(f" 分页: {paged_gb:.1f} GB (浪费: {waste_paged:.0%})")
print(f" 节省: {preallocated_gb - paged_gb:.1f} GB ({preallocated_gb/paged_gb:.1f}x)")
print()
# Llama-70B:每层每token约1.3 KB80层 = 每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)
```