2026-05-15AILLM推理优化GPU工程实践

LLM 推理工程极限:Continuous Batching 与 GPU 利用率优化

大语言模型推理和传统深度学习推理有个本质区别:**输入输出长度不固定**。一个请求可能只输出 20 个 token,另一个请求要输出 2000 个 token。如果简单按请求排队,等短请求的人被长请求堵死,GPU 利用率惨不忍睹。

biluo·10392 words

背景:为什么 LLM 推理这么难优化

大语言模型推理和传统深度学习推理有个本质区别:输入输出长度不固定。一个请求可能只输出 20 个 token,另一个请求要输出 2000 个 token。如果简单按请求排队,等短请求的人被长请求堵死,GPU 利用率惨不忍睹。

2023 年之前,主流做法是静态分桶(Static Batching):把请求按长度分组,每批凑满固定长度再处理。效果差强人意。2024 年,Orca 论文引入了迭代级调度(Iteration-Level Scheduling),后来的 vLLM 把它实现为 Continuous Batching,GPU 利用率直接上一个台阶。

今天我们来深度拆解这套机制,配合 PyTorch 代码示例,搞清楚它为什么有效。

1. 经典 Static Batching 的瓶颈

先看静态分批的问题。假设我们有 4 个请求,长度各不相同:

`

Request A: 512 input + 64 output

Request B: 512 input + 128 output

Request C: 1024 input + 32 output

Request D: 1024 input + 256 output

`

静态分批要把它们凑成一批处理,必须 padding 到最大长度:

`

Batch: 1024 (max) input + 256 (max) output = 1280 tokens per iteration

`

每个请求实际有用的 token 比例很低,大部分计算是无效的 padding。更糟糕的是,Request A 在 64 步后就可以返回了,但它必须等 Batch 里最慢的 D 跑完 256 步。这是 死等问题(Head-of-Line Blocking)

`

Timeline (iteration steps):

Step 1-64: [A B C D] 四路并行

Step 65-128: [ B C D ] A 结束但占着位置

Step 129-256:[ C D ] B 结束但占着位置

Step 257-320:[ D ] 只有 D 在跑

`

GPU 利用率曲线就是一个波峰然后漫长的低谷。

2. Continuous Batching:迭代级调度

Continuous Batching 的核心思想:不再等待整个批次完成才加入新请求,而是在每个 generation step(每个 token 生成)结束后,检查是否有请求已完成,如果有就立即移出,同时把新请求加进来。

这是迭代级调度,不是请求级调度。调度发生在每个生成步,而不是每个请求结束时。

2.1 调度循环

`python

import torch

from typing import List, Dict

class ContinuousBatchingScheduler:

"""

迭代级调度器:每个 forward pass 后决定谁能进谁要出

"""

def __init__(self, max_batch_size: int, device: str = "cuda"):

self.max_batch_size = max_batch_size

self.device = device

# running 表示正在生成的请求

self.running: List[GenerationRequest] = []

def step(self,

requests: List[GenerationRequest],

logits: torch.Tensor) -> List[int]:

"""

每个生成步的调度决策

返回: 已完成请求的 indices,要从批次中移除

"""

finished = []

for i, req in enumerate(self.running):

# 计算这个请求在此步的采样

next_token = self.sample(logits[i], req.temperature)

req.generated_tokens.append(next_token)

# 检查是否结束

if self.is_finished(req, next_token):

finished.append(i)

# 核心操作:移除已完成的,加入等待的

# 这行是 Continuous Batching 的关键所在

self._evict_and_fill(finished, requests)

return finished

def _evict_and_fill(self,

finished_indices: List[int],

pending_requests: List[GenerationRequest]):

"""移除完成的,填入新请求"""

# 从后往前删,避免 index 错位

for i in sorted(finished_indices, reverse=True):

self.running.pop(i)

# 填入新请求直到满批

while (len(self.running) < self.max_batch_size

and pending_requests):

self.running.append(pending_requests.pop(0))

`

2.2 GPU 时间线对比

Continuous Batching 让 GPU 保持高利用率:

`

Static Batching:

[======ABCD======] [==EFGH==]

busy idle gap busy

Continuous Batching:

[==A==] [==E==]

[==B==] [==AB==] [==ABC==] [==FG==] ...

[==C==] [==CD==] [==BCD==] [==GH==]

[==D==] [==D===] [==D====]

`

从四路并行逐渐变成一路,然后立刻有新请求填进来。没有长空闲。

3. PagedAttention:vLLM 的内存革命

Continuous Batching 解决了调度问题,但还有一个瓶颈:KV Cache 内存管理

Attention 计算需要存储 Key 和 Value 向量。对于一个 4096 token 上下文、70B 参数的模型,单个请求的 KV Cache 就能达到:

`

hidden_size = 8192 # 70B 模型

num_heads = 64

head_dim = 128

kv_cache_per_token = 2 * num_heads * head_dim * 2bytes(fp16)

= 2 * 64 * 128 * 2 = 32KB per token

4096 tokens * 32KB = 128MB per request

`

如果同时跑 100 个请求,仅 KV Cache 就要 12.8GB,加上模型权重 140GB... GPU 显存根本装不下。

vLLM 提出的 PagedAttention 灵感来自操作系统的分页管理:不再一次性分配一大块连续显存给 KV Cache,而是按 block 分页,按需分配。

3.1 Block 管理逻辑

`python

from dataclasses import dataclass

from typing import Dict, List

@dataclass

class KVCacheBlock:

"""KV Cache 物理块,类似 OS 的内存页"""

block_id: int

num_slots: int = 16 # 每个 block 16 个 token slot

num_free_slots: int = 16

# 物理显存指针

k_ptr: int = 0

v_ptr: int = 0

class KVCacheManager:

"""

类 OS 页表的 KV Cache 管理器

逻辑块 -> 物理块的映射,允许非连续存储

"""

def __init__(self, total_blocks: int, block_slots: int):

self.total_blocks = total_blocks

self.block_slots = block_slots

# 逻辑块到物理块的映射表

self.block_tables: Dict[int, List[int]] = {}

# 物理块分配状态

self.physical_blocks: List[KVCacheBlock] = [

KVCacheBlock(block_id=i, num_slots=block_slots)

for i in range(total_blocks)

]

self.free_blocks = set(range(total_blocks))

def allocate(self, num_tokens: int) -> List[int]:

"""为新请求分配物理块,返回块 ID 列表"""

num_blocks_needed = (num_tokens + self.block_slots - 1) // self.block_slots

allocated = []

for _ in range(num_blocks_needed):

if not self.free_blocks:

raise RuntimeError("KV Cache 内存耗尽,需要 evict 策略")

block_id = self.free_blocks.pop()

allocated.append(block_id)

return allocated

def free(self, block_list: List[int]):

"""释放物理块"""

for b in block_list:

self.free_blocks.add(b)

class GenerationRequest:

"""请求对应的逻辑块序列"""

def __init__(self, request_id: int, prompt_tokens: List[int]):

self.request_id = request_id

self.prompt_tokens = prompt_tokens

self.generated_tokens: List[int] = []

self.block_ids: List[int] = [] # 逻辑块序列 -> 物理块

self.num_generated: int = 0

`

3.2 PagedAttention Kernel

`python

# 简化的 PagedAttention 计算逻辑

# 实际实现需要用 CUDA C++ 或 Triton's custom kernel

def paged_attention(

query: torch.Tensor, # [batch, num_heads, seq_len, head_dim]

key_cache: torch.Tensor, # [num_blocks, num_heads, block_size, head_dim]

block_tables: List[List[int]], # 每请求的物理块列表

seq_lens: List[int]

) -> torch.Tensor:

"""

query: 当前 step 的查询向量

key_cache: 按 block 存储的 KV Cache

block_tables: 逻辑位置 -> 物理块的映射

"""

output = torch.zeros_like(query)

for batch_idx in range(query.shape[0]):

seq_len = seq_lens[batch_idx]

blocks = block_tables[batch_idx]

block_size = key_cache.shape[2]

# 把逻辑序列映射到物理块

num_blocks = len(blocks)

key_states = torch.zeros(

seq_len, query.shape[2], query.shape[3],

device=query.device, dtype=query.dtype

)

block_offset = 0

for phys_block_id in blocks:

# 从物理块读取数据(可以非连续)

phys_block = key_cache[phys_block_id]

copy_len = min(block_size, seq_len - block_offset)

key_states[block_offset:block_offset+copy_len] = phys_block[:copy_len]

block_offset += copy_len

# 标准 attention 计算

attn_weights = torch.matmul(query[batch_idx], key_states.transpose(-2, -1))

attn_weights = attn_weights / (query.shape[3] ** 0.5)

attn_weights = torch.softmax(attn_weights, dim=-1)

output[batch_idx] = torch.matmul(attn_weights, key_states)

return output

`

关键洞察:物理块可以非连续分配,但逻辑上是连续的。这解决了传统方案"一次性分配大连续显存"的浪费问题。

vLLM 的实测数据:比 HuggingFace TF 提升2-3 倍吞吐量,比 Text Generation Inference (TGI) 提升 1.5-2 倍

4. Prefill 阶段与 Decode 阶段的差异优化

LLM 推理分两个阶段:

  • **Prefill**:处理输入 prompt,一次性计算 attention(是并行计算,适合大 batch)
  • **Decode**:逐 token 生成(是自回归的,每步只处理一个 token,attention 计算量小但调度开销大)

两个阶段的特性差异巨大,混合在一起处理会互相影响。生产系统通常分离 Prefill 和 Decode 节点,或者使用 disaggregated prefill/decode 架构。

`python

class DisaggScheduler:

"""

分离式预填充/解码调度器

Prefill 节点处理新请求的 prompt

Decode 节点处理自回归生成

"""

def __init__(self, prefill_nodes: int, decode_nodes: int):

self.prefill_servers = prefill_nodes

self.decode_servers = decode_nodes

self.prefill_queue: List[GenerationRequest] = []

self.decode_queue: List[GenerationRequest] = []

def add_request(self, req: GenerationRequest):

# 新请求先进 Prefill 处理 prompt

self.prefill_queue.append(req)

def step(self):

# Prefill 完成后转 Decode 队列

finished_prefill = self._drain_prefill()

self.decode_queue.extend(finished_prefill)

# Decode 节点处理生成

self._process_decode()

def _drain_prefill(self) -> List[GenerationRequest]:

# Prefill 是一次性计算,可以大 batch

batch = self.prefill_queue[:self.prefill_batch_size]

# ... 调用 Prefill 节点

return [r for r in batch if r.is_prefill_done()]

def _process_decode(self):

# Decode 逐 token,需要 Continuous Batching

# ... Continuous Batching 调度逻辑

`

5. 实战调优:让 GPU 利用率从 40% 到 85%

光有算法不够,还要知道怎么调参。以下是我们在 8x H100 集群上的调优经验:

5.1 Batch Size 设置

`

公式:max_batch_size ≈ GPU显存 / (model_params * 2 + kv_cache_per_token * max_seq_len)

`

对于 70B FP16 模型(140GB),8x H100 80GB:

  • 权重:140GB
  • 激活值 + Attention 输出:约 20GB
  • KV Cache 可用:640 - 160 = 480GB
  • 每请求 4096 上下文 KV Cache:4096 * 32KB = 128MB
  • max_batch_size ≈ 480GB / 128MB ≈ 3600 并发请求(理论值)

实际因为调度开销和碎片化,建议设置 256-512

5.2 CUDA Graph 优化

每步执行 kernel 调用有开销。可以用 CUDA Graph 捕获一系列操作,一次性提交:

`python

# 使用 torch.cuda.CUDAGraph 减少 kernel 启动开销

graph = torch.cuda.CUDAGraph()

# 第一次运行 - 捕获

with torch.cuda.graph(graph):

static_input = torch.empty_like(dynamic_input)

static_output = model.forward(static_input)

# 后续每次只需 replay,避免 kernel 调度开销

dynamic_input.copy_(real_input)

graph.replay()

output.copy_(static_output)

`

实测可减少 10-15% 的调度 overhead

5.3 量化配合

INT8 量化可以在精度损失可接受的情况下大幅减少显存占用:

`python

# 使用 BitsAndBytes 进行 INT8 量化

from transformers import AutoModelForCausalLM, BitsAndBytesConfig

bnb_config = BitsAndBytesConfig(

load_in_8bit=True,

llm_int8_threshold=6.0, # outlier 阈值

llm_int8_has_fp16_weight=False

)

model = AutoModelForCausalLM.from_pretrained(

"meta-llama/Llama-3-70b",

quantization_config=bnb_config,

device_map="auto"

)

`

70B FP16 → 70B INT8,显存从 140GB 降到约 70GB,可以跑更大的 batch size。

6. 总结:推理优化的几个层次

层次 技术 效果
调度层 Continuous Batching 吞吐量 2-3x
内存层 PagedAttention 并发数 5-10x
计算层 CUDA Graph 延迟 -15%
精度层 INT8/FP8 量化 显存 -50%
架构层 Prefill/Decode 分离 吞吐翻倍

GPU 利用率从 40% 到 85%,不是靠某一个 trick,而是这一套组合拳打出来的。每一层都有独立的paper和开源实现可以深入研究。

核心教训:LLM 推理不是"把模型跑起来"这么简单,它是一个系统工程问题。调度、内存、计算、架构四个层次缺一不可,你的瓶颈在哪一层,决定了你该优先优化什么。

---

*调优环境:8x NVIDIA H100 80GB, PyTorch 2.4, vLLM 0.6, Llama-3-70B*