vLLM 與 PagedAttention:高吞吐推理的關鍵

從 KV Cache 的記憶體浪費到 PagedAttention 與連續批次,拆解 vLLM 如何成為現代 LLM 部署的標準方案。

vLLM 與 PagedAttention:高吞吐推理的關鍵

上一篇我們談了量化,理解了如何用更少的位元來降低記憶體需求、加速推理、降低成本。

但量化只解決了「模型權重太大」的問題,還有一個更棘手的瓶頸:KV Cache 的記憶體管理

傳統的推理框架,KV Cache 的記憶體利用率只有 20% 到 40%,大量記憶體被浪費在碎片和預留上。
這導致批次大小受限,吞吐無法提升,成本居高不下。

這一篇,我們要談一個改變 LLM 推理格局的技術:vLLM 與 PagedAttention
它們讓 KV Cache 的記憶體利用率接近 100%,吞吐提升數倍,成為現代 LLM 部署的標準方案。

PagedAttention + Continuous Batching = High Throughput

一、先理解問題: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

Internal Fragmentation + External Fragmentation + No Sharing

二、PagedAttention 的核心思想

靈感來自作業系統

PagedAttention 的靈感來自作業系統的虛擬記憶體管理

在作業系統中:

  1. 記憶體被分成固定大小的頁(Page)
  2. 程式看到的是連續的虛擬記憶體,但實際的實體記憶體可以分散。
  3. 作業系統負責將虛擬頁映射到實體頁。

PagedAttention 把同樣的概念應用到 KV Cache:

  1. KV Cache 被分成固定大小的塊(Block)
  2. 每個塊可以儲存固定數量 token 的 KV(如 16 個)。
  3. 邏輯上連續的 KV Cache,實際可以分散在不同的實體塊中。
  4. 用一個**塊表(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 共享(共同的對話歷史)

Logical Blocks → Block Table → Physical Blocks

三、PagedAttention 的運作流程

步驟一:初始化

當一個新請求進來時:

  1. 分配一個空的塊表。
  2. 不預先分配所有塊,而是按需分配

步驟二:預填充

處理輸入序列時:

  1. 將輸入 token 的 KV 寫入塊中。
  2. 每填滿一個塊,就分配一個新的塊。
  3. 更新塊表。

步驟三:解碼

生成新 token 時:

  1. 計算新 token 的 KV。
  2. 檢查當前塊是否還有空間。
  3. 如果有,寫入當前塊。
  4. 如果沒有,分配新塊,更新塊表。
  5. 用塊表查找所有先前的 KV,計算注意力。

步驟四:完成

請求完成後:

  1. 釋放所有塊。
  2. 這些塊可以被其他請求使用。

與傳統方式的對比

面向傳統方式PagedAttention
記憶體分配預先分配最大長度按需分配塊
記憶體利用率20-40%90%+
內部碎片嚴重最多一個塊
外部碎片
前綴共享不支援支援
批次大小受限大幅提升

四、連續批次(Continuous Batching)

PagedAttention 解決了記憶體問題,但要充分發揮它的威力,還需要連續批次

靜態批次的問題

傳統的批次處理是靜態批次

步驟 1:收集 8 個請求
步驟 2:一起處理這 8 個請求
步驟 3:等待所有請求完成
步驟 4:收集下一批 8 個請求

問題:

  1. 短請求要等長請求:如果一個請求生成 10 個 token,另一個生成 500 個,短的要等長的。
  2. GPU 閒置:等待期間 GPU 沒有充分利用。
  3. 延遲增加:新請求要等當前批次完成才能開始。

連續批次的運作

連續批次讓請求完成就移出,新請求隨時加入

時間軸:
t=0: 請求 A、B、C、D 開始
t=1: 請求 E 加入,請求 A 完成
t=2: 請求 F 加入,請求 B 完成
t=3: 請求 G 加入,請求 C 完成
...

GPU 幾乎不會閒置,因為總是有請求在處理。

連續批次 + PagedAttention

PagedAttention 讓連續批次成為可能:

  1. 塊可以動態分配:新請求進來時,分配新的塊。
  2. 塊可以獨立釋放:請求完成時,釋放它的塊。
  3. 不需要連續記憶體:塊可以分散在記憶體各處。
傳統批次:
[請求 A][請求 B][請求 C][請求 D]
全部完成才能開始下一批

連續批次 + PagedAttention:
[請求 A][請求 B][請求 C][請求 D]
[請求 E][請求 F]  ← 隨時加入
[請求 G]          ← 隨時加入

Static Batching vs Continuous Batching

五、vLLM 的架構

vLLM 是第一個實作 PagedAttention 的推理框架,也是目前最廣泛使用的。

核心組件

┌────────────────────────────────────────────┐
│                  vLLM                      │
│                                            │
│  ┌─────────────┐  ┌─────────────┐          │
│  │  Scheduler  │  │  Block      │          │
│  │  調度器      │  │  Manager    │          │
│  └─────────────┘  │  塊管理器    │          │
│                   └─────────────┘          │
│                                            │
│  ┌─────────────────────────────────────┐   │
│  │         Worker (GPU)                │   │
│  │  ┌───────────┐  ┌───────────┐       │   │
│  │  │ Attention │  │ KV Cache  │       │   │
│  │  │ Kernel    │  │ (Paged)   │       │   │
│  │  └───────────┘  └───────────┘       │   │
│  └─────────────────────────────────────┘   │
└────────────────────────────────────────────┘
組件職責
Scheduler決定哪些請求進入批次
Block Manager管理塊的分配與釋放
Attention Kernel用塊表計算注意力
Worker在 GPU 上執行計算

工作流程

  1. 請求進來:Scheduler 接收請求,排入佇列。
  2. 分配塊:Block Manager 為請求分配初始塊。
  3. 預填充:Worker 處理輸入,寫入 KV 到塊中。
  4. 解碼:逐步生成 token,按需分配新塊。
  5. 完成:釋放塊,通知 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

以一組固定請求為例:

指標HuggingFacevLLM提升
吞吐(tokens/s)50025005x
記憶體利用率30%95%3x
最大批次8648x
每請求成本$0.001$0.00025x

vLLM vs TGI(Text Generation Inference)

指標TGIvLLM差異
吞吐2000 tokens/s2500 tokens/svLLM 略高
前綴共享不支援支援vLLM 優勢
量化支援有限GPTQ/AWQvLLM 更全面
生態系HuggingFace開源社群各有優勢

不同批次大小的吞吐

批次大小吞吐(tokens/s)延遲(ms/token)
110010
860012
32160018
64220025
128250040

可以看到:

  • 吞吐隨批次增加而提升,但有邊際效益遞減。
  • 延遲隨批次增加而增加,但幅度較小。

Throughput ↑ vs Latency ↑

八、進階功能

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,你就掌握了高吞吐推理的關鍵。
接下來,我們要談另一個重要的優化:批次處理與連續批次。


下一篇預告

《批次處理與連續批次:提升吞吐的關鍵》

我們會深入批次處理的原理、靜態批次與連續批次的差異、如何設計高效的調度策略、如何平衡吞吐與延遲,並用具體的程式碼展示如何實作一個連續批次系統。