vLLM 與 PagedAttention:高吞吐推理的關鍵
從 KV Cache 的記憶體浪費到 PagedAttention 與連續批次,拆解 vLLM 如何成為現代 LLM 部署的標準方案。
vLLM 與 PagedAttention:高吞吐推理的關鍵
上一篇我們談了量化,理解了如何用更少的位元來降低記憶體需求、加速推理、降低成本。
但量化只解決了「模型權重太大」的問題,還有一個更棘手的瓶頸:KV Cache 的記憶體管理。
傳統的推理框架,KV Cache 的記憶體利用率只有 20% 到 40%,大量記憶體被浪費在碎片和預留上。
這導致批次大小受限,吞吐無法提升,成本居高不下。
這一篇,我們要談一個改變 LLM 推理格局的技術:vLLM 與 PagedAttention。
它們讓 KV Cache 的記憶體利用率接近 100%,吞吐提升數倍,成為現代 LLM 部署的標準方案。
一、先理解問題:KV Cache 的記憶體浪費
傳統推理框架的做法
在傳統的推理框架(如 HuggingFace Transformers 的預設實作)中,KV Cache 是這樣管理的:
為每個請求預先分配一塊連續的記憶體
大小 = 最大序列長度 × 每 token 的 KV 大小
例如,如果最大序列長度是 2048,每個請求就預先分配 2048 個 token 的空間。
問題一:內部碎片
假設一個請求實際只生成了 100 個 token,但預先分配了 2048 個。
浪費了 1948 個 token 的空間(95% 浪費)。
[已使用 100][閒置 1948]
問題二:外部碎片
不同請求的 KV Cache 大小不同,造成記憶體中出現不連續的空洞。
[請求 A: 500][空洞][請求 B: 800][空洞][請求 C: 300]
這些空洞無法被其他請求使用,因為需要連續的記憶體。
問題三:無法共享
如果多個請求有相同的前綴(如相同的系統提示),每個請求都要各自儲存一份 KV Cache。
請求 A:[系統提示的 KV][問題 A 的 KV]
請求 B:[系統提示的 KV][問題 B 的 KV]
請求 C:[系統提示的 KV][問題 C 的 KV]
系統提示的 KV Cache 被重複儲存了三次。
記憶體浪費的後果
| 問題 | 後果 |
|---|---|
| 內部碎片 | 記憶體利用率低(20-40%) |
| 外部碎片 | 無法分配大塊記憶體 |
| 無法共享 | 重複儲存相同內容 |
| 批次受限 | 無法同時處理更多請求 |
| 吞吐下降 | 每秒處理的請求數減少 |
| 成本上升 | 需要更多 GPU |
二、PagedAttention 的核心思想
靈感來自作業系統
PagedAttention 的靈感來自作業系統的虛擬記憶體管理。
在作業系統中:
- 記憶體被分成固定大小的頁(Page)。
- 程式看到的是連續的虛擬記憶體,但實際的實體記憶體可以分散。
- 作業系統負責將虛擬頁映射到實體頁。
PagedAttention 把同樣的概念應用到 KV Cache:
- KV Cache 被分成固定大小的塊(Block)。
- 每個塊可以儲存固定數量 token 的 KV(如 16 個)。
- 邏輯上連續的 KV Cache,實際可以分散在不同的實體塊中。
- 用一個**塊表(Block Table)**來記錄映射關係。
具體運作方式
傳統方式:
請求 A 的 KV Cache 需要連續的記憶體
[token 1][token 2][token 3]...[token 2048]
PagedAttention:
請求 A 的 KV Cache 分成多個塊
塊 1: [token 1-16]
塊 2: [token 17-32]
塊 3: [token 33-48]
...
這些塊可以分散在記憶體各處:
[塊 1][塊 5][塊 2][塊 8][塊 3]...
塊表記錄了邏輯塊到實體塊的映射:
請求 A 的塊表:
邏輯塊 0 → 實體塊 5
邏輯塊 1 → 實體塊 12
邏輯塊 2 → 實體塊 3
...
解決三大問題
內部碎片:
最後一個塊可能沒填滿,但浪費最多只有一個塊的大小(如 16 個 token)。
相比傳統方式的 95% 浪費,PagedAttention 的浪費不到 4%。
外部碎片:
所有塊大小相同,不會產生外部碎片。
任何空閒的塊都可以被任何請求使用。
無法共享:
相同的前綴可以共享相同的塊。
多個請求的塊表指向同一組實體塊。
請求 A 的塊表:[塊 1][塊 2][塊 3][塊 4]
請求 B 的塊表:[塊 1][塊 2][塊 3][塊 5]
請求 C 的塊表:[塊 1][塊 2][塊 6][塊 7]
塊 1、塊 2 被三個請求共享(系統提示)
塊 3 被 A 和 B 共享(共同的對話歷史)
三、PagedAttention 的運作流程
步驟一:初始化
當一個新請求進來時:
- 分配一個空的塊表。
- 不預先分配所有塊,而是按需分配。
步驟二:預填充
處理輸入序列時:
- 將輸入 token 的 KV 寫入塊中。
- 每填滿一個塊,就分配一個新的塊。
- 更新塊表。
步驟三:解碼
生成新 token 時:
- 計算新 token 的 KV。
- 檢查當前塊是否還有空間。
- 如果有,寫入當前塊。
- 如果沒有,分配新塊,更新塊表。
- 用塊表查找所有先前的 KV,計算注意力。
步驟四:完成
請求完成後:
- 釋放所有塊。
- 這些塊可以被其他請求使用。
與傳統方式的對比
| 面向 | 傳統方式 | PagedAttention |
|---|---|---|
| 記憶體分配 | 預先分配最大長度 | 按需分配塊 |
| 記憶體利用率 | 20-40% | 90%+ |
| 內部碎片 | 嚴重 | 最多一個塊 |
| 外部碎片 | 有 | 無 |
| 前綴共享 | 不支援 | 支援 |
| 批次大小 | 受限 | 大幅提升 |
四、連續批次(Continuous Batching)
PagedAttention 解決了記憶體問題,但要充分發揮它的威力,還需要連續批次。
靜態批次的問題
傳統的批次處理是靜態批次:
步驟 1:收集 8 個請求
步驟 2:一起處理這 8 個請求
步驟 3:等待所有請求完成
步驟 4:收集下一批 8 個請求
問題:
- 短請求要等長請求:如果一個請求生成 10 個 token,另一個生成 500 個,短的要等長的。
- GPU 閒置:等待期間 GPU 沒有充分利用。
- 延遲增加:新請求要等當前批次完成才能開始。
連續批次的運作
連續批次讓請求完成就移出,新請求隨時加入:
時間軸:
t=0: 請求 A、B、C、D 開始
t=1: 請求 E 加入,請求 A 完成
t=2: 請求 F 加入,請求 B 完成
t=3: 請求 G 加入,請求 C 完成
...
GPU 幾乎不會閒置,因為總是有請求在處理。
連續批次 + PagedAttention
PagedAttention 讓連續批次成為可能:
- 塊可以動態分配:新請求進來時,分配新的塊。
- 塊可以獨立釋放:請求完成時,釋放它的塊。
- 不需要連續記憶體:塊可以分散在記憶體各處。
傳統批次:
[請求 A][請求 B][請求 C][請求 D]
全部完成才能開始下一批
連續批次 + PagedAttention:
[請求 A][請求 B][請求 C][請求 D]
[請求 E][請求 F] ← 隨時加入
[請求 G] ← 隨時加入
五、vLLM 的架構
vLLM 是第一個實作 PagedAttention 的推理框架,也是目前最廣泛使用的。
核心組件
┌────────────────────────────────────────────┐
│ vLLM │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Scheduler │ │ Block │ │
│ │ 調度器 │ │ Manager │ │
│ └─────────────┘ │ 塊管理器 │ │
│ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Worker (GPU) │ │
│ │ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Attention │ │ KV Cache │ │ │
│ │ │ Kernel │ │ (Paged) │ │ │
│ │ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────┘ │
└────────────────────────────────────────────┘
| 組件 | 職責 |
|---|---|
| Scheduler | 決定哪些請求進入批次 |
| Block Manager | 管理塊的分配與釋放 |
| Attention Kernel | 用塊表計算注意力 |
| Worker | 在 GPU 上執行計算 |
工作流程
- 請求進來:Scheduler 接收請求,排入佇列。
- 分配塊:Block Manager 為請求分配初始塊。
- 預填充:Worker 處理輸入,寫入 KV 到塊中。
- 解碼:逐步生成 token,按需分配新塊。
- 完成:釋放塊,通知 Scheduler。
前綴共享
vLLM 支援前綴共享(Prefix Sharing):
from vllm import LLM, SamplingParams
llm = LLM(model="meta-llama/Llama-2-7b-hf")
# 多個請求共享相同的系統提示
system_prompt = "你是一個樂於助人的助理。"
prompts = [
system_prompt + "問題 A",
system_prompt + "問題 B",
system_prompt + "問題 C",
]
# vLLM 自動共享系統提示的 KV Cache
outputs = llm.generate(prompts, SamplingParams(temperature=0.7))
系統提示的 KV Cache 只計算一次,三個請求共享。
六、實作:用 vLLM 部署高吞吐服務
安裝
pip install vllm
基本使用
from vllm import LLM, SamplingParams
# 1. 載入模型
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
tensor_parallel_size=1, # GPU 數量
gpu_memory_utilization=0.9, # GPU 記憶體使用率
max_num_seqs=256, # 最大批次大小
)
# 2. 設定採樣參數
sampling_params = SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=200,
)
# 3. 批次推理
prompts = [
"今天天氣真好,適合",
"請解釋什麼是機器學習",
"寫一首關於春天的詩",
]
outputs = llm.generate(prompts, sampling_params)
for output in outputs:
print(f"Prompt: {output.prompt}")
print(f"Output: {output.outputs[0].text}")
print("---")
串流輸出
from vllm import LLM, SamplingParams
llm = LLM(model="meta-llama/Llama-2-7b-hf")
sampling_params = SamplingParams(temperature=0.7, max_tokens=200)
# 串流輸出
for output in llm.generate(
["今天天氣真好,適合"],
sampling_params,
use_tqdm=False,
):
for token in output.outputs[0].token_ids:
print(tokenizer.decode([token]), end="", flush=True)
啟動 OpenAI 相容的 API 服務
vLLM 內建 OpenAI 相容的 API 伺服器:
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-2-7b-hf \
--port 8000 \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.9 \
--max-num-seqs 256
然後就可以用 OpenAI 的客戶端來呼叫:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="dummy",
)
response = client.chat.completions.create(
model="meta-llama/Llama-2-7b-hf",
messages=[
{"role": "user", "content": "今天天氣真好,適合"},
],
max_tokens=200,
)
print(response.choices[0].message.content)
量化模型
vLLM 支援 GPTQ 和 AWQ 量化:
from vllm import LLM
# AWQ 量化
llm = LLM(
model="TheBloke/Llama-2-7B-AWQ",
quantization="awq",
)
# GPTQ 量化
llm = LLM(
model="TheBloke/Llama-2-7B-GPTQ",
quantization="gptq",
)
多 GPU 部署
from vllm import LLM
# 用 4 張 GPU 部署 70B 模型
llm = LLM(
model="meta-llama/Llama-2-70b-hf",
tensor_parallel_size=4, # 張量平行
gpu_memory_utilization=0.9,
)
七、效能對比
vLLM vs HuggingFace Transformers
以一組固定請求為例:
| 指標 | HuggingFace | vLLM | 提升 |
|---|---|---|---|
| 吞吐(tokens/s) | 500 | 2500 | 5x |
| 記憶體利用率 | 30% | 95% | 3x |
| 最大批次 | 8 | 64 | 8x |
| 每請求成本 | $0.001 | $0.0002 | 5x |
vLLM vs TGI(Text Generation Inference)
| 指標 | TGI | vLLM | 差異 |
|---|---|---|---|
| 吞吐 | 2000 tokens/s | 2500 tokens/s | vLLM 略高 |
| 前綴共享 | 不支援 | 支援 | vLLM 優勢 |
| 量化支援 | 有限 | GPTQ/AWQ | vLLM 更全面 |
| 生態系 | HuggingFace | 開源社群 | 各有優勢 |
不同批次大小的吞吐
| 批次大小 | 吞吐(tokens/s) | 延遲(ms/token) |
|---|---|---|
| 1 | 100 | 10 |
| 8 | 600 | 12 |
| 32 | 1600 | 18 |
| 64 | 2200 | 25 |
| 128 | 2500 | 40 |
可以看到:
- 吞吐隨批次增加而提升,但有邊際效益遞減。
- 延遲隨批次增加而增加,但幅度較小。
八、進階功能
1. 張量平行(Tensor Parallelism)
把模型切分到多張 GPU 上:
llm = LLM(
model="meta-llama/Llama-2-70b-hf",
tensor_parallel_size=4, # 4 張 GPU
)
每張 GPU 負責模型的一部分,透過 NVLink 或 PCIe 通訊。
2. 流水線平行(Pipeline Parallelism)
把模型的不同層放到不同的 GPU 上:
llm = LLM(
model="meta-llama/Llama-2-70b-hf",
tensor_parallel_size=2,
pipeline_parallel_size=2, # 總共 4 張 GPU
)
3. 投機解碼(Speculative Decoding)
用小模型快速生成候選 token,大模型驗證:
llm = LLM(
model="meta-llama/Llama-2-70b-hf",
speculative_model="meta-llama/Llama-2-7b-hf", # 草稿模型
num_speculative_tokens=5,
)
4. LoRA 支援
vLLM 支援動態載入 LoRA 適配器:
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
enable_lora=True,
)
# 載入 LoRA
llm.llm_engine.add_lora(
lora_name="my-lora",
lora_path="/path/to/lora",
)
5. 前綴快取
自動快取相同前綴的 KV:
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
enable_prefix_caching=True,
)
九、最佳實踐
1. 調整 GPU 記憶體使用率
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
gpu_memory_utilization=0.9, # 預設 0.9
)
太低:浪費記憶體。太高:可能 OOM。
2. 設定合理的批次大小
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
max_num_seqs=256, # 最大同時處理的請求數
)
太小:吞吐低。太大:延遲高。
3. 使用前綴快取
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
enable_prefix_caching=True,
)
如果多個請求有相同的前綴,這能大幅節省計算。
4. 選擇合適的量化
# 需要省記憶體時
llm = LLM(
model="TheBloke/Llama-2-7B-AWQ",
quantization="awq",
)
5. 監控關鍵指標
def monitor_vllm(llm):
"""監控 vLLM 的指標"""
stats = llm.llm_engine.statistics
return {
"running_requests": stats.num_running,
"pending_requests": stats.num_pending,
"gpu_cache_usage": stats.gpu_cache_usage,
"throughput": stats.throughput,
}
6. 處理 OOM
llm = LLM(
model="meta-llama/Llama-2-7b-hf",
gpu_memory_utilization=0.85, # 降低使用率
max_num_seqs=128, # 降低批次
max_model_len=2048, # 降低最大長度
)
7. 用 Docker 部署
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
RUN pip install vllm
EXPOSE 8000
CMD ["python", "-m", "vllm.entrypoints.openai.api_server", \
"--model", "meta-llama/Llama-2-7b-hf", \
"--port", "8000"]
8. 結合 Kubernetes 自動擴縮
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm
spec:
replicas: 2
template:
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
args:
- "--model"
- "meta-llama/Llama-2-7b-hf"
resources:
limits:
nvidia.com/gpu: 1
十、常見的陷阱
1. 忽略 GPU 記憶體限制
# 不好的例子:用預設值,可能 OOM
llm = LLM(model="meta-llama/Llama-2-70b-hf")
# 好的例子:根據 GPU 記憶體調整
llm = LLM(
model="meta-llama/Llama-2-70b-hf",
tensor_parallel_size=4,
gpu_memory_utilization=0.9,
)
2. 批次太大
# 不好的例子:批次 1024,延遲很高
llm = LLM(model="...", max_num_seqs=1024)
# 好的例子:根據需求調整
llm = LLM(model="...", max_num_seqs=128)
3. 沒有用前綴快取
# 不好的例子:多個請求有相同前綴,重複計算
# 好的例子:啟用前綴快取
llm = LLM(model="...", enable_prefix_caching=True)
4. 忽略量化
# 不好的例子:用 FP16 跑 70B,需要 8 張 GPU
# 好的例子:用量化,只需 2-4 張
llm = LLM(model="...", quantization="awq")
5. 沒有監控
# 不好的例子:部署後就不管了
# 好的例子:持續監控吞吐、延遲、GPU 使用率
6. 用錯的平行策略
# 不好的例子:單張 GPU 跑 70B
# 好的例子:用張量平行
llm = LLM(model="...", tensor_parallel_size=4)
7. 忽略版本相容性
# 不好的例子:用不相容的版本
pip install vllm==0.1.0 transformers==4.40.0
# 好的例子:確認版本相容
pip install vllm transformers
8. 沒有測試
# 不好的例子:直接上生產
# 好的例子:先在小規模測試
llm = LLM(model="...", max_num_seqs=8) # 先小批次測試
十一、總結:從碎片到共享,從批次到連續
讓我們回顧這一篇的核心:
- KV Cache 的記憶體浪費:內部碎片、外部碎片、無法共享,利用率只有 20-40%。
- PagedAttention 的核心思想:把 KV Cache 分成固定大小的塊,用塊表管理。
- PagedAttention 的運作:按需分配、動態釋放、前綴共享。
- 連續批次:請求完成就移出,新請求隨時加入,GPU 不閒置。
- vLLM 的架構:Scheduler、Block Manager、Attention Kernel、Worker。
- 實作:用 vLLM 部署高吞吐服務、串流輸出、OpenAI 相容 API、量化、多 GPU。
- 效能對比:比 HuggingFace 快 5 倍,記憶體利用率從 30% 提升到 95%。
- 進階功能:張量平行、流水線平行、投機解碼、LoRA、前綴快取。
- 最佳實踐:調整 GPU 記憶體使用率、設定合理批次、用前綴快取、選對量化、監控、處理 OOM、用 Docker、結合 K8s。
- 常見陷阱:忽略記憶體限制、批次太大、沒有前綴快取、忽略量化、沒有監控、用錯平行策略、版本不相容、沒有測試。
vLLM 與 PagedAttention 是 LLM 部署的里程碑。
它們把 KV Cache 的記憶體利用率從 20-40% 提升到 90%+,把吞吐提升數倍,把成本降低數倍。
沒有它們,大規模 LLM 服務的成本會高得無法承受。
理解了 vLLM 與 PagedAttention,你就掌握了高吞吐推理的關鍵。
接下來,我們要談另一個重要的優化:批次處理與連續批次。
下一篇預告
《批次處理與連續批次:提升吞吐的關鍵》
我們會深入批次處理的原理、靜態批次與連續批次的差異、如何設計高效的調度策略、如何平衡吞吐與延遲,並用具體的程式碼展示如何實作一個連續批次系統。