土法炼钢兴趣小组的算法知识备份

LLM 推理服务的真实瓶颈:不是 token/s,而是调度和尾延迟

目录

“这个模型 200 token/s” —— 但你的用户等了 3 秒才看到第一个字。

每次看到有人用 token/s 来评估 LLM 推理服务的性能,我都想问一句:那是谁的 token/s? 是空载单请求跑出来的?还是 100 个并发下的 P99?

200 token/s 的模型,在 50 并发下可能 P99 TTFT(Time to First Token)高达 8 秒。 你的用户盯着空白屏幕 8 秒,然后关掉了页面。

真正的瓶颈从来不是”模型能多快吐 token”。 而是:调度器怎么分配 GPU 资源、KV-Cache 怎么管理显存、尾部请求为什么被饿死。

这篇文章从推理的两个阶段讲起,一路拆到 vLLM 的 PagedAttention、continuous batching、尾延迟的来源,最后给出工程实践建议。如果你在做 LLM 推理服务,或者在选型推理框架,这篇应该能帮到你。

一、LLM 推理的两个阶段

LLM 推理不是一个均匀的过程。它有两个性质完全不同的阶段,理解这个区分是理解所有后续优化的前提。

Prefill(提示词处理)

用户发送一段 prompt 过来,模型需要先把整个 prompt “读一遍”——计算每一层的 attention 和 FFN,生成所有 token 位置的 KV-Cache。

Prefill 的特点:

一个 2048 token 的 prompt 在 A100 上的 prefill 时间,对于 13B 模型大约是 200-500ms。

Decode(逐 token 生成)

Prefill 结束后,模型进入 decode 阶段:每次只生成一个 token,然后把它追加到序列末尾,更新 KV-Cache,再用新的 KV-Cache 生成下一个 token。

Decode 的特点:

这就是为什么 decode 阶段的瓶颈是显存带宽而不是算力

一道简单的算术题

假设一个 13B 模型(FP16,约 26GB 权重),A100 的显存带宽是 2TB/s:

# Decode 阶段每生成一个 token 需要读取全部模型权重
model_size_bytes = 13e9 * 2       # 13B params × 2 bytes (FP16) = 26GB
memory_bandwidth = 2e12            # A100: 2 TB/s

# 理论最大 token 生成速度(单请求)
max_tokens_per_sec = memory_bandwidth / model_size_bytes
print(f"理论上限: {max_tokens_per_sec:.0f} token/s")   # ≈ 77 token/s

# 实际还要读 KV-Cache,所以更低
# 这就是为什么单请求 decode 速度很难超过 60-80 token/s

注意看:A100 有 312 TFLOPS 的 FP16 算力,但 decode 阶段根本用不满。每个 token 的计算量太小,GPU 一直在”等数据”。

TTFT vs TPS

指标 含义 影响因素 用户感知
TTFT 首 token 延迟 prefill 时间 + 排队时间 用户等待第一个字出现
TPS 生成速度 decode 速度 × batch 大小 文字流出的速度
P99 TTFT 尾部延迟 调度策略 + 负载 最惨的 1% 用户体验

大多数 benchmark 只报 TPS,但用户体验主要取决于 TTFT 和 P99 延迟

二、KV-Cache:显存杀手

为什么需要 KV-Cache

Transformer 的自注意力机制在生成第 \(t\) 个 token 时,需要和之前所有 \(t-1\) 个 token 做 attention 计算。如果每次都重新算所有历史 token 的 key 和 value,计算量是 \(O(t^2)\),完全不可接受。

所以我们把每一层的 key 和 value 向量缓存起来,这就是 KV-Cache。decode 阶段每生成一个新 token,只需要计算这一个 token 的 query,然后和缓存的所有 key 做 attention。

KV-Cache 到底有多大

计算公式:

\[\text{KV-Cache Size} = 2 \times L \times H \times d \times s \times \text{dtype\_size}\]

其中: - \(2\):key 和 value 各一份 - \(L\):Transformer 层数 - \(H\):注意力头数 - \(d\):每个头的维度 - \(s\):序列长度 - \(\text{dtype\_size}\):数据类型大小(FP16 = 2 bytes)

def calc_kv_cache_size(
    num_layers: int,
    num_heads: int,
    head_dim: int,
    seq_len: int,
    dtype_bytes: int = 2,   # FP16
) -> dict:
    """计算单个请求的 KV-Cache 大小"""
    # 每个 token 的 KV 大小
    per_token = 2 * num_layers * num_heads * head_dim * dtype_bytes

    # 整个序列
    total_bytes = per_token * seq_len
    total_mb = total_bytes / (1024 ** 2)
    total_gb = total_bytes / (1024 ** 3)

    return {
        "per_token_bytes": per_token,
        "total_bytes": total_bytes,
        "total_mb": round(total_mb, 2),
        "total_gb": round(total_gb, 4),
    }


# LLaMA-2 13B 参数
result = calc_kv_cache_size(
    num_layers=40,
    num_heads=40,
    head_dim=128,
    seq_len=4096,
)
print(f"单请求 KV-Cache: {result['total_mb']} MB")
# → 单请求 KV-Cache: 2560.0 MB ≈ 2.5 GB

# 如果你想同时服务 10 个请求?
print(f"10 个并发: {result['total_gb'] * 10:.1f} GB")
# → 10 个并发: 25.0 GB
# A100 80GB 显存,模型本身占 26GB,剩下 54GB
# 最多同时服务 ~21 个请求(理论值,实际更少)

2.5 GB 一个请求。这就是为什么大模型推理的并发数上不去。显存不够不是因为模型太大,而是 KV-Cache 太大

KV-Cache 碎片化

传统做法是给每个请求预分配最大序列长度(比如 4096)的连续显存。问题是:

  1. 内部碎片:大多数请求用不满 4096 tokens,剩下的空间浪费了
  2. 外部碎片:请求结束释放显存后,留下大小不一的空洞
  3. 利用率低:实际显存利用率可能只有 20-40%

这跟内存分配器面临的碎片化问题一模一样。如果你读过我写的 内存分配器的竞技场,应该很熟悉这个故事——连续分配看起来简单,但在动态负载下碎片会杀死你。

三、PagedAttention 和 vLLM

传统方法的困境

在 vLLM 出现之前,LLM 推理服务(比如 HuggingFace TGI 早期版本)通常这样管理 KV-Cache:

# 传统方式:预分配连续显存
# 问题:max_seq_len=4096,但平均只用 500 tokens
class NaiveKVCache:
    def __init__(self, max_batch_size, max_seq_len, num_layers, num_heads, head_dim):
        # 一次性分配所有显存,不管实际用多少
        self.k_cache = torch.zeros(
            max_batch_size, num_layers, max_seq_len, num_heads, head_dim,
            dtype=torch.float16, device="cuda"
        )
        self.v_cache = torch.zeros(
            max_batch_size, num_layers, max_seq_len, num_heads, head_dim,
            dtype=torch.float16, device="cuda"
        )
        # max_batch_size=16, max_seq_len=4096, 13B 模型
        # → 预分配 16 × 2.5GB = 40GB
        # 但如果平均只用 500 tokens → 实际利用率 12.2%

PagedAttention:虚拟内存的思路

vLLM 的核心创新 PagedAttention 直接借鉴了操作系统的虚拟内存管理:

# PagedAttention 的核心概念(简化示意)
class PagedKVCacheManager:
    """类似操作系统的页式内存管理"""

    def __init__(self, num_gpu_blocks: int, block_size: int = 16):
        self.block_size = block_size       # 每个 block 存 16 个 token 的 KV
        self.num_gpu_blocks = num_gpu_blocks
        self.free_blocks: list[int] = list(range(num_gpu_blocks))
        self.block_tables: dict[int, list[int]] = {}   # request_id → [block_ids]

    def allocate(self, request_id: int) -> int:
        """为请求分配一个新的物理 block"""
        if not self.free_blocks:
            raise RuntimeError("GPU blocks exhausted — need preemption")
        block_id = self.free_blocks.pop()
        if request_id not in self.block_tables:
            self.block_tables[request_id] = []
        self.block_tables[request_id].append(block_id)
        return block_id

    def free(self, request_id: int):
        """请求完成,归还所有 block"""
        blocks = self.block_tables.pop(request_id, [])
        self.free_blocks.extend(blocks)

    def get_physical_blocks(self, request_id: int) -> list[int]:
        """获取请求的物理 block 列表(供 PagedAttention kernel 使用)"""
        return self.block_tables.get(request_id, [])

    def num_free_blocks(self) -> int:
        return len(self.free_blocks)


# 示例:一个 block 存 16 tokens 的 KV
# 请求 A 用了 100 tokens → 需要 ceil(100/16) = 7 个 block
# 请求 B 用了 30 tokens  → 需要 ceil(30/16)  = 2 个 block
# 不需要预分配 max_seq_len,按需增长

vLLM 架构全景

vLLM 的架构由三个核心组件构成(参见下方架构图):

vLLM 架构图

1. Scheduler(调度器)

2. PagedAttention Kernel(GPU 内核)

3. CacheEngine(缓存引擎)

从 vLLM 源码看 Scheduler

光看架构图还不够,我们扒一下 vLLM 的调度器源码,看看它每一步到底在做什么。以下是基于 vLLM v0.4.x 的核心逻辑简化——真实代码更复杂,但骨架就是这样。

# vLLM Scheduler 核心逻辑(简化伪代码)
# 参考:vllm/core/scheduler.py

class Scheduler:
    def __init__(self, scheduler_config, cache_config):
        # 三个队列——这是调度器的全部状态
        self.waiting: deque[SequenceGroup] = deque()   # 新来的请求,还没开始 prefill
        self.running: deque[SequenceGroup] = deque()   # 正在 GPU 上跑的请求
        self.swapped: deque[SequenceGroup] = deque()   # KV-Cache 被换到 CPU 的请求

    def schedule(self) -> SchedulerOutputs:
        """每个 iteration 调用一次,决定本轮 batch 的组成"""
        budget = SchedulingBudget(
            token_budget=self.scheduler_config.max_num_batched_tokens,
            max_num_seqs=self.scheduler_config.max_num_seqs,
        )

        # 第一步:处理 running 队列(已在 GPU 上的请求)
        # 如果显存紧张,需要 preempt 一些请求腾出空间
        running_scheduled, preempted = self._schedule_running(budget)

        # 被 preempt 的请求根据策略处理:
        for seq_group in preempted:
            if self.preemption_mode == "recompute":
                # 丢弃 KV-Cache,回到 waiting 队列重新 prefill
                self._free_blocks(seq_group)
                self.waiting.appendleft(seq_group)  # 放到队首,优先调度
            elif self.preemption_mode == "swap":
                # KV-Cache 从 GPU 搬到 CPU,进入 swapped 队列
                self._swap_out(seq_group)
                self.swapped.append(seq_group)

        # 第二步:处理 swapped 队列(尝试把之前换出的请求换回来)
        # 只有 running 的请求都安排好了,才考虑 swap in
        swapped_in = self._schedule_swapped(budget)

        # 第三步:处理 waiting 队列(新请求的 prefill)
        # 只有前两步都搞定了,剩余 budget 才给新请求
        prefills = self._schedule_prefills(budget)

        return SchedulerOutputs(
            scheduled_seq_groups=running_scheduled + swapped_in + prefills,
            blocks_to_swap_in={...},    # CPU → GPU 的 block 映射
            blocks_to_swap_out={...},   # GPU → CPU 的 block 映射
            blocks_to_copy={...},       # CoW 触发的 block 复制
        )

    def _schedule_running(self, budget) -> tuple[list, list]:
        """检查 running 队列里的请求是否都能分配到新的 block"""
        scheduled, preempted = [], []
        for seq_group in self.running:
            if self._can_allocate_one_block(seq_group):
                budget.consume(num_tokens=1, num_seqs=1)  # decode 每步只需 1 token
                scheduled.append(seq_group)
            else:
                # 显存不够了——从队尾开始 preempt(LIFO,最新的请求先被抢占)
                preempted.append(seq_group)
        return scheduled, preempted

几个关键设计决策:

  1. 三级优先级:running > swapped > waiting。正在跑的请求永远优先——因为它们已经消耗了 GPU 算力做 prefill,preempt 它们的沉没成本最高。
  2. Recompute vs Swap 的选择:短序列用 recompute(重新 prefill 很快),长序列用 swap(重新算太贵)。vLLM 默认策略是根据序列长度自动选择。
  3. Budget 控制max_num_batched_tokensmax_num_seqs 同时限制——前者控制 GPU 计算量,后者控制显存占用。两个任意一个超了就停止调度。
  4. LIFO preemption:最后加入的请求最先被抢占。直觉是:新请求生成的 token 少,丢弃/swap 的代价最小。

显存利用率的飞跃

# 对比:传统方法 vs PagedAttention 的显存利用率
def compare_memory_efficiency(
    gpu_memory_gb: float,
    model_size_gb: float,
    kv_per_token_bytes: int,
    avg_seq_len: int,
    max_seq_len: int,
    block_size: int = 16,
):
    available = (gpu_memory_gb - model_size_gb) * (1024 ** 3)  # bytes

    # 传统方法:预分配 max_seq_len
    naive_per_request = kv_per_token_bytes * max_seq_len
    naive_max_batch = int(available / naive_per_request)
    naive_actual_usage = (avg_seq_len / max_seq_len)   # 内部碎片

    # PagedAttention:按需分配,block 粒度
    import math
    paged_per_request = kv_per_token_bytes * math.ceil(avg_seq_len / block_size) * block_size
    paged_max_batch = int(available / paged_per_request)
    paged_actual_usage = avg_seq_len / (math.ceil(avg_seq_len / block_size) * block_size)

    return {
        "naive_max_batch": naive_max_batch,
        "naive_utilization": f"{naive_actual_usage:.1%}",
        "paged_max_batch": paged_max_batch,
        "paged_utilization": f"{paged_actual_usage:.1%}",
        "throughput_improvement": f"{paged_max_batch / naive_max_batch:.1f}x",
    }


result = compare_memory_efficiency(
    gpu_memory_gb=80,            # A100 80GB
    model_size_gb=26,            # 13B FP16
    kv_per_token_bytes=655360,   # 2 × 40 × 40 × 128 × 2 bytes
    avg_seq_len=500,
    max_seq_len=4096,
    block_size=16,
)
print(result)
# naive_max_batch: 14
# naive_utilization: 12.2%
# paged_max_batch: 112
# paged_utilization: 97.7%
# throughput_improvement: 8.0x

vLLM 基本使用

from vllm import LLM, SamplingParams

# 初始化模型——vLLM 自动设置 PagedAttention 和 KV-Cache 管理
llm = LLM(
    model="meta-llama/Llama-2-13b-chat-hf",
    tensor_parallel_size=1,          # 单卡
    gpu_memory_utilization=0.90,     # 使用 90% 显存给 KV-Cache
    max_model_len=4096,
    block_size=16,                   # KV-Cache block 大小
    swap_space=4,                    # CPU swap 空间 (GB)
    enforce_eager=False,             # 使用 CUDA graph 加速
)

sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

# 批量推理——vLLM 内部使用 continuous batching
prompts = [
    "解释什么是 PagedAttention",
    "用 Python 写一个快速排序",
    "Transformer 的自注意力机制是如何工作的",
]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    prompt = output.prompt
    generated_text = output.outputs[0].text
    print(f"Prompt: {prompt[:40]}...")
    print(f"Output: {generated_text[:100]}...")
    print(f"Tokens generated: {len(output.outputs[0].token_ids)}")
    print("---")
# 在线服务模式(OpenAI 兼容 API)
# 启动命令:
# python -m vllm.entrypoints.openai.api_server \
#     --model meta-llama/Llama-2-13b-chat-hf \
#     --gpu-memory-utilization 0.9 \
#     --max-model-len 4096

import openai

client = openai.OpenAI(
    base_url="http://localhost:8000/v1",
    api_key="not-needed",
)

# Streaming 模式——用户可以看到 token 逐个出现
stream = client.chat.completions.create(
    model="meta-llama/Llama-2-13b-chat-hf",
    messages=[{"role": "user", "content": "什么是 KV-Cache?"}],
    stream=True,
    max_tokens=256,
)

for chunk in stream:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

四、Continuous Batching

静态 Batching 的问题

传统的静态 batching 像餐厅的翻台制:一桌人全部吃完才能安排下一桌。

# 静态 batching 的问题示意
import time

class StaticBatchScheduler:
    """所有请求必须等最慢的那个完成"""

    def __init__(self, max_batch_size: int):
        self.max_batch_size = max_batch_size

    def process_batch(self, requests: list[dict]) -> list[dict]:
        batch = requests[:self.max_batch_size]
        max_output_len = max(r["expected_output_len"] for r in batch)

        results = []
        # 问题:短请求生成完了也要等长请求
        for step in range(max_output_len):
            for req in batch:
                if step < req["expected_output_len"]:
                    # 还在生成
                    req.setdefault("tokens", []).append(f"tok_{step}")
                else:
                    # 已经结束了,但 GPU 资源仍被占用!
                    pass    # ← 浪费

        return batch


# 例子:4 个请求,输出长度差异很大
requests = [
    {"id": "A", "expected_output_len": 10},    # 很快就完了
    {"id": "B", "expected_output_len": 200},   # 要生成很长
    {"id": "C", "expected_output_len": 15},    # 很快
    {"id": "D", "expected_output_len": 180},   # 也很长
]
# 请求 A 和 C 在第 15 步就完成了
# 但要等到第 200 步(B 完成)才能处理新请求
# GPU 利用率: (10+200+15+180) / (200×4) = 50.6%

Iteration-Level Scheduling

Continuous batching(也叫 iteration-level scheduling)的核心思想来自 Orca 论文(Yu et al., 2022):

不在 request 级别组 batch,而是在 每个 decode step 重新调度。

class ContinuousBatchScheduler:
    """每个 decode iteration 重新调度"""

    def __init__(self, max_batch_size: int, max_blocks: int):
        self.max_batch_size = max_batch_size
        self.max_blocks = max_blocks
        self.waiting: list[dict] = []     # 等待执行的请求
        self.running: list[dict] = []     # 正在执行的请求

    def add_request(self, request: dict):
        self.waiting.append(request)

    def schedule_step(self) -> list[dict]:
        """每个 iteration 调用一次"""
        # 1. 移除已完成的请求,释放资源
        still_running = []
        for req in self.running:
            if req["generated_tokens"] >= req["max_tokens"]:
                self._free_blocks(req)
                req["status"] = "completed"
            elif req.get("eos_generated"):
                self._free_blocks(req)
                req["status"] = "completed"
            else:
                still_running.append(req)
        self.running = still_running

        # 2. 空出来的位置可以立即给新请求
        while (
            self.waiting
            and len(self.running) < self.max_batch_size
            and self._has_free_blocks()
        ):
            new_req = self.waiting.pop(0)
            new_req["status"] = "running"
            new_req["generated_tokens"] = 0
            self._allocate_blocks(new_req)
            self.running.append(new_req)

        return self.running

    def _has_free_blocks(self) -> bool:
        used = sum(r.get("num_blocks", 1) for r in self.running)
        return used < self.max_blocks

    def _allocate_blocks(self, request: dict):
        request["num_blocks"] = 1    # 初始分配

    def _free_blocks(self, request: dict):
        request["num_blocks"] = 0


# 关键区别:
# 静态 batch:[A, B, C, D] 一起跑,A 完了也要等 B
# Continuous batch:A 完了 → 立刻把 E 塞进来
# GPU 始终在做有用的工作

Preemption 策略

当显存不够容纳所有 running 请求的 KV-Cache 时,调度器需要做 preemption:

策略 做法 优点 缺点
Swap 把 KV-Cache 搬到 CPU 内存 不丢失计算结果 PCIe 传输慢
Recompute 丢弃 KV-Cache,之后重新 prefill 不占 CPU 内存 浪费 GPU 算力
Drop 直接丢弃请求 最简单 用户体验最差

vLLM 默认使用 swap 策略,显存紧张时将低优先级请求的 KV-Cache block 异步搬到 CPU:

# vLLM 的 preemption 逻辑(简化)
def handle_preemption(scheduler, running_requests, new_request):
    """显存不足时的处理"""
    # 按优先级排序:已生成更多 token 的请求优先级更低
    # (因为重新计算的代价更高,所以实际策略更复杂)
    candidates = sorted(running_requests, key=lambda r: r["generated_tokens"])

    freed_blocks = 0
    preempted = []

    for req in candidates:
        if freed_blocks >= new_request["required_blocks"]:
            break
        # Swap KV-Cache to CPU
        freed_blocks += req["num_blocks"]
        req["status"] = "swapped"
        preempted.append(req)

    return preempted

吞吐量 vs 延迟的权衡

Continuous batching 的一个微妙问题:batch 越大,吞吐量越高,但单个请求的 decode 延迟也越高。

# 吞吐量-延迟权衡的直觉
def throughput_latency_tradeoff(
    model_size_gb: float,
    memory_bandwidth_tb: float,
    batch_sizes: list[int],
):
    """展示 batch size 对吞吐量和延迟的影响"""
    model_bytes = model_size_gb * 1e9
    bw = memory_bandwidth_tb * 1e12

    results = []
    for bs in batch_sizes:
        # 每个 decode step 需要读取一次模型权重
        # batch 内所有请求共享这次读取
        time_per_step = model_bytes / bw                 # 秒
        throughput = bs / time_per_step                  # tokens/s (所有请求)
        per_request_latency = time_per_step * 1000       # ms (每个 token)

        results.append({
            "batch_size": bs,
            "throughput_tps": round(throughput, 1),
            "per_token_latency_ms": round(per_request_latency, 2),
        })

    return results


# A100 + 13B FP16
results = throughput_latency_tradeoff(
    model_size_gb=26,
    memory_bandwidth_tb=2.0,
    batch_sizes=[1, 4, 8, 16, 32, 64],
)
for r in results:
    print(f"batch={r['batch_size']:3d}  "
          f"throughput={r['throughput_tps']:8.1f} tok/s  "
          f"per_token={r['per_token_latency_ms']:.2f} ms")

# batch=  1  throughput=    76.9 tok/s  per_token=13.00 ms
# batch=  4  throughput=   307.7 tok/s  per_token=13.00 ms
# batch=  8  throughput=   615.4 tok/s  per_token=13.00 ms
# batch= 16  throughput=  1230.8 tok/s  per_token=13.00 ms
# batch= 32  throughput=  2461.5 tok/s  per_token=13.00 ms
# batch= 64  throughput=  4923.1 tok/s  per_token=13.00 ms
# ↑ 理想情况下 batch 越大吞吐越高,per-token 延迟不变
# 但实际上大 batch 会增加 KV-Cache 读取量和调度开销

理论上看起来很美——batch 越大,吞吐线性增长。但实际上: - 大 batch 意味着 KV-Cache 占更多显存,每个 attention 要读更大的 KV - Prefill 和 decode 混合调度时,长 prefill 会阻塞所有 decode 请求 - 排队的请求 TTFT 在增长

Orca 论文的核心洞察:与其优化单请求速度,不如优化整个系统的调度效率。让 GPU 每个 cycle 都在做有价值的计算。

五、尾延迟的来源

你的 P50 延迟可能很好看。但 P99 才是真正的杀手——那 1% 最惨的用户,可能等了 10 秒才看到第一个字。

排队延迟

最常见也最容易被忽视的延迟来源。当所有 GPU block 都被占满时,新请求只能排队等待。

import numpy as np

def simulate_queue_delay(
    arrival_rate: float,        # 请求/秒
    avg_prefill_time: float,    # 秒
    avg_decode_time: float,     # 秒
    max_concurrent: int,        # 最大并发数
    num_requests: int = 10000,
    seed: int = 42,
):
    """模拟请求排队延迟(简化 M/G/c 队列)"""
    rng = np.random.default_rng(seed)

    # 生成请求到达时间(泊松过程)
    inter_arrivals = rng.exponential(1.0 / arrival_rate, num_requests)
    arrival_times = np.cumsum(inter_arrivals)

    # 每个请求的服务时间
    prefill_times = rng.exponential(avg_prefill_time, num_requests)
    output_lens = rng.geometric(1.0 / 100, num_requests)  # 平均 100 tokens
    decode_times = output_lens * avg_decode_time

    total_service = prefill_times + decode_times

    # 模拟 c 个服务器
    server_free_at = np.zeros(max_concurrent)
    ttft_list = []

    for i in range(num_requests):
        # 找最早空闲的服务器
        earliest_server = np.argmin(server_free_at)
        start_time = max(arrival_times[i], server_free_at[earliest_server])
        queue_delay = start_time - arrival_times[i]
        ttft = queue_delay + prefill_times[i]
        ttft_list.append(ttft)
        server_free_at[earliest_server] = start_time + total_service[i]

    ttft_arr = np.array(ttft_list)
    return {
        "P50_TTFT": f"{np.percentile(ttft_arr, 50)*1000:.0f} ms",
        "P95_TTFT": f"{np.percentile(ttft_arr, 95)*1000:.0f} ms",
        "P99_TTFT": f"{np.percentile(ttft_arr, 99)*1000:.0f} ms",
        "max_TTFT": f"{np.max(ttft_arr)*1000:.0f} ms",
    }


# 场景:20 req/s,最多 16 并发
result = simulate_queue_delay(
    arrival_rate=20,
    avg_prefill_time=0.3,
    avg_decode_time=0.013,
    max_concurrent=16,
)
print(result)
# 典型输出:
# P50_TTFT: 320 ms      ← 看起来不错
# P95_TTFT: 2800 ms     ← 开始不妙了
# P99_TTFT: 5200 ms     ← 5 秒!
# max_TTFT: 12000 ms    ← 有人等了 12 秒

Preemption 导致的重计算

当请求被 preempt 后恢复执行,如果使用 recompute 策略,需要重新做一次 prefill。对于长 prompt 的请求,这可能意味着额外的数百毫秒延迟。

更坏的情况:一个请求可能被 preempt 多次。每次恢复都要重新计算已有的 KV-Cache,形成”饥饿”现象。

KV-Cache Eviction 和 Recomputation

当系统决定驱逐某个请求的 KV-Cache 时:

# KV-Cache eviction 的代价
def eviction_cost(
    prompt_len: int,
    generated_len: int,
    prefill_speed_tokens_per_sec: int = 10000,
    decode_speed_tokens_per_sec: int = 60,
):
    """被驱逐的请求恢复执行需要多长时间"""
    # 需要重新 prefill 整个上下文(prompt + 已生成的 tokens)
    total_context = prompt_len + generated_len
    recompute_time = total_context / prefill_speed_tokens_per_sec

    return {
        "recompute_context_tokens": total_context,
        "recompute_time_ms": round(recompute_time * 1000, 1),
        "wasted_decode_time_ms": round(generated_len / decode_speed_tokens_per_sec * 1000, 1),
    }


# 一个已经生成了 200 tokens 的请求被驱逐
cost = eviction_cost(prompt_len=1024, generated_len=200)
print(cost)
# recompute_context_tokens: 1224
# recompute_time_ms: 122.4 ms       ← 重新 prefill 的时间
# wasted_decode_time_ms: 3333.3 ms  ← 之前 decode 的工作白费了

模型加载和热启动

在弹性伸缩场景下,冷启动是另一个尾延迟的来源:

实际延迟分析

综合以上因素,一个生产环境的延迟分布通常长这样:

延迟来源 P50 影响 P95 影响 P99 影响
Prefill 计算 200ms 500ms 800ms
排队等待 ~0ms 1500ms 4000ms
Preemption 恢复 ~0ms ~0ms 2000ms
KV-Cache swap ~0ms 100ms 500ms
网络/框架开销 10ms 20ms 50ms
总计 TTFT ~210ms ~2100ms ~7350ms

P99 是 P50 的 35 倍。这就是为什么只看平均延迟会让你产生错误的安全感。

这和序列化选型的道理一样——benchmark 数字好看不代表上了生产环境还好看。如果你读过 序列化的真实代价,你知道”快 10 倍”的 benchmark 到了真实负载下可能完全不成立。尾延迟也是:只有压测到 P99 才能看到系统的真实瓶颈。

六、优化工程实践

知道了瓶颈在哪,来看看工程上怎么优化。

Speculative Decoding

核心思想:用一个小模型(draft model)快速”猜”接下来几个 token,然后让大模型一次性验证。

# ⚠️ 概念示意代码,非可直接运行。实际实现参见 vLLM 的 spec_decode 模块
def speculative_decode_step(
    draft_model,
    target_model,
    input_ids,
    num_speculative_tokens: int = 5,
):
    """Speculative decoding 的一个 step(概念示意)"""
    # 1. Draft model 快速生成 K 个候选 token
    draft_tokens = []
    draft_probs = []
    current = input_ids

    for _ in range(num_speculative_tokens):
        logits = draft_model(current)
        prob = softmax(logits[:, -1, :])
        token = sample(prob)
        draft_tokens.append(token)
        draft_probs.append(prob)
        current = concat(current, token)

    # 2. Target model 一次前向传播验证所有候选
    # 关键:验证 K 个 token 只需要 1 次前向传播(类似 prefill)
    all_candidates = concat(input_ids, stack(draft_tokens))
    target_logits = target_model(all_candidates)

    # 3. 逐个检查 draft token 是否被 target 接受
    accepted_tokens = []
    for i, (draft_tok, draft_prob) in enumerate(zip(draft_tokens, draft_probs)):
        target_prob = softmax(target_logits[:, len(input_ids) + i, :])
        # 改进的拒绝采样:保证输出分布和 target model 完全一致
        acceptance_rate = min(1, target_prob[draft_tok] / draft_prob[draft_tok])
        if random() < acceptance_rate:
            accepted_tokens.append(draft_tok)
        else:
            # 从修正分布中采样一个 token
            corrected = max(0, target_prob - draft_prob)
            accepted_tokens.append(sample(normalize(corrected)))
            break   # 后续的 draft token 全部丢弃

    # 平均每步接受 3-4 个 token → 有效速度提升 2-3x
    return accepted_tokens

Speculative decoding 的好处是输出分布和原始大模型完全一致——不是近似,是数学上精确相同。代价是需要加载两个模型。

Prefix Caching

很多场景下,不同请求有相同的系统提示(system prompt)。Prefix caching 让这些请求共享同一份 KV-Cache:

class PrefixCacheManager:
    """共享相同前缀的 KV-Cache"""

    def __init__(self):
        # hash(token_ids) → physical_block_ids
        self.prefix_cache: dict[int, list[int]] = {}

    def get_or_compute_prefix(self, token_ids: list[int], block_size: int = 16):
        """检查前缀是否已缓存"""
        # 按 block 粒度计算 hash
        num_blocks = len(token_ids) // block_size
        cached_blocks = []

        for i in range(num_blocks):
            block_tokens = tuple(token_ids[i * block_size : (i + 1) * block_size])
            block_hash = hash(block_tokens)

            if block_hash in self.prefix_cache:
                cached_blocks.append(self.prefix_cache[block_hash])
            else:
                break   # 之后的 block 需要重新计算

        # 返回:已缓存的 block 数 + 需要从第几个 token 开始 prefill
        resume_from = len(cached_blocks) * block_size
        return {
            "cached_blocks": len(cached_blocks),
            "total_blocks": num_blocks,
            "cache_hit_rate": len(cached_blocks) / max(num_blocks, 1),
            "resume_token_pos": resume_from,
            "saved_prefill_tokens": resume_from,
        }


# 示例:1000 token 的系统提示 + 不同的用户输入
prefix_mgr = PrefixCacheManager()

# 假设系统提示已经缓存
system_prompt_tokens = list(range(1000))   # 模拟 1000 token 的系统提示

# 第一个请求:冷启动,没有缓存
# 第二个请求开始:共享系统提示的 KV-Cache
# 节省:1000 / 10000 ≈ 100ms 的 prefill 时间

Chunked Prefill

长 prompt 的 prefill 会阻塞整个 GPU,导致正在 decode 的请求全部停顿。Chunked prefill 把长 prompt 分成小块,和 decode 请求交替执行:

class ChunkedPrefillScheduler:
    """把长 prefill 分块,和 decode 请求交替执行"""

    def __init__(self, chunk_size: int = 512, max_batch_tokens: int = 2048):
        self.chunk_size = chunk_size
        self.max_batch_tokens = max_batch_tokens

    def schedule_iteration(self, prefill_requests, decode_requests):
        """一个 iteration 的调度"""
        batch = []
        token_budget = self.max_batch_tokens

        # 优先安排 decode 请求(每个只需 1 token)
        for req in decode_requests:
            if token_budget >= 1:
                batch.append({"request": req, "type": "decode", "tokens": 1})
                token_budget -= 1

        # 剩余预算分给 prefill 请求(按 chunk 分配)
        for req in prefill_requests:
            remaining = req["prompt_len"] - req.get("prefilled_tokens", 0)
            chunk = min(remaining, self.chunk_size, token_budget)
            if chunk > 0:
                batch.append({
                    "request": req,
                    "type": "prefill_chunk",
                    "tokens": chunk,
                    "start_pos": req.get("prefilled_tokens", 0),
                })
                req["prefilled_tokens"] = req.get("prefilled_tokens", 0) + chunk
                token_budget -= chunk

        return batch


# 效果:
# 没有 chunked prefill:4096 token prompt → 阻塞 GPU 400ms
# 有 chunked prefill:分 8 个 chunk (512 token),每个 chunk 间插入 decode step
# decode 请求的延迟从 400ms 抖动降低到 50ms 抖动

多机推理的通信开销

当模型大到单卡放不下(比如 70B+ 模型),需要做张量并行(tensor parallelism)或者流水线并行(pipeline parallelism)。通信开销变成新的瓶颈:

# 张量并行的通信开销估算
def tensor_parallel_overhead(
    hidden_size: int,
    num_gpus: int,
    interconnect_bandwidth_gb: float,   # 单向,GB/s
    dtype_bytes: int = 2,
):
    """每个 decode step 的 all-reduce 通信开销"""
    # 每个 Transformer 层需要 2 次 all-reduce(attention 后和 FFN 后)
    # all-reduce 数据量 = 2 × (num_gpus - 1) / num_gpus × hidden_size × dtype
    data_per_allreduce = hidden_size * dtype_bytes           # bytes
    # Ring all-reduce: 2 × (n-1)/n × data_size
    allreduce_bytes = 2 * (num_gpus - 1) / num_gpus * data_per_allreduce

    time_per_allreduce = allreduce_bytes / (interconnect_bandwidth_gb * 1e9)

    return {
        "allreduce_data_bytes": int(allreduce_bytes),
        "time_per_allreduce_us": round(time_per_allreduce * 1e6, 2),
        "note": "每层 2 次 all-reduce,70B (80层) → 每 step 160 次",
        "total_per_step_ms": round(time_per_allreduce * 160 * 1000, 2),
    }


# LLaMA-2 70B,4 卡 A100(NVLink 600GB/s)
overhead = tensor_parallel_overhead(
    hidden_size=8192,
    num_gpus=4,
    interconnect_bandwidth_gb=600,   # NVLink 4.0
)
print(overhead)
# 每个 decode step 额外通信开销约 0.6-1.0 ms
# 对比 decode step 本身约 5-8 ms → 通信占 10-15%

选型指南

框架 核心优势 适用场景 注意事项
vLLM PagedAttention,显存效率最优 通用在线服务 自定义模型支持有限
TensorRT-LLM NVIDIA 深度优化,性能天花板高 需要极致性能 配置复杂,只支持 NVIDIA
SGLang RadixAttention,prefix caching 最优 多轮对话、agent 相对较新
DeepSpeed-FastGen SplitFuse,chunk prefill 原生支持 长上下文场景 社区较小

选型建议:

  1. 如果你刚起步:用 vLLM。生态最成熟,文档最全,默认配置就够用
  2. 如果你追求极致吞吐:上 TensorRT-LLM,但准备好花时间在配置和 debug 上
  3. 如果你做多轮对话 / agent:SGLang 的 RadixAttention 在 prefix 复用场景下表现最好
  4. 所有场景:一定要做压力测试,看 P99 不要只看 P50
# 快速对比不同框架性能的脚本框架
import subprocess
import json
import time
import statistics

def benchmark_inference_server(
    base_url: str,
    prompts: list[str],
    num_concurrent: int = 16,
    max_tokens: int = 128,
) -> dict:
    """对推理服务做简单的延迟测试"""
    import aiohttp
    import asyncio

    ttfts = []
    total_times = []

    async def send_request(session, prompt):
        start = time.monotonic()
        first_token_time = None

        payload = {
            "model": "default",
            "messages": [{"role": "user", "content": prompt}],
            "max_tokens": max_tokens,
            "stream": True,
        }

        async with session.post(
            f"{base_url}/v1/chat/completions",
            json=payload,
        ) as resp:
            async for line in resp.content:
                if first_token_time is None and b"content" in line:
                    first_token_time = time.monotonic()

        end = time.monotonic()
        ttft = (first_token_time - start) if first_token_time else (end - start)
        return {"ttft": ttft, "total": end - start}

    async def run_benchmark():
        async with aiohttp.ClientSession() as session:
            tasks = [send_request(session, p) for p in prompts[:num_concurrent]]
            results = await asyncio.gather(*tasks, return_exceptions=True)
            return [r for r in results if isinstance(r, dict)]

    results = asyncio.run(run_benchmark())
    ttfts = [r["ttft"] for r in results]

    return {
        "num_requests": len(results),
        "ttft_p50_ms": round(statistics.median(ttfts) * 1000, 1),
        "ttft_p99_ms": round(sorted(ttfts)[int(len(ttfts) * 0.99)] * 1000, 1),
        "ttft_max_ms": round(max(ttfts) * 1000, 1),
    }


# 用法:
# results = benchmark_inference_server(
#     base_url="http://localhost:8000",
#     prompts=["请解释量子计算"] * 100,
#     num_concurrent=32,
# )
# print(json.dumps(results, indent=2))

P99 延迟优化检查清单

跑通了不代表能上线。下面这张清单是我们在生产环境中反复调过的 10 个关键参数和策略——每一个都可能让你的 P99 差出一个数量级:

# 优化项 怎么调 为什么重要
1 max_num_seqs 从默认 256 开始,压测观察 P99 拐点,通常 64-128 是甜区 并发请求太多 → 每个请求分到的 KV-Cache block 少 → preemption 频发
2 swap_space_gb 设为 GPU 显存的 25-50%(如 80GB 卡设 20-40) 太小 → swap 空间不够,被迫 recompute;太大 → 浪费 CPU 内存
3 gpu_memory_utilization 生产环境建议 0.85-0.90,别贪 0.95 留 10-15% 余量给突发的长序列,避免 OOM 导致整个服务重启
4 enable_chunked_prefill 长上下文场景必须开启 一个 32K token 的 prefill 会阻塞整个 batch 的 decode,chunk 化后分多步完成
5 max_num_batched_tokens 根据 GPU 算力设置,A100 建议 4096-8192 太大 → prefill 延迟高;太小 → 吞吐上不去。配合 chunked prefill 效果最好
6 Tensor Parallel vs Pipeline Parallel 单机多卡用 TP(NVLink 延迟低);跨机用 PP(减少通信轮次) 选错并行策略,通信开销可能占到 decode 延迟的 30%+
7 量化方法选择 精度敏感用 FP8/INT8;显存极限用 AWQ-INT4 量化不只是省显存——INT4 的 dequantize 开销在小 batch 下可能抵消收益
8 CUDA Graph 预热 启动时用典型 batch size 做 warmup(enforce_eager=False 首次推理触发 JIT 编译 → 前几个请求的延迟飙高。预热消除这个冷启动
9 请求超时配置 设置合理的 generation timeout(如 max_tokens × 期望 TPS 的 3 倍) 无超时 → 一个超长生成请求独占资源,拖慢所有人
10 负载均衡策略 用 least-connections 而不是 round-robin;感知队列深度更佳 round-robin 不感知请求复杂度,一个长 prompt 就能把一个实例打满

💡 经验法则:先用默认参数跑基线,然后每次只改一个参数,用固定的 prompt 集做压测,看 P50/P99/吞吐量三个指标。参数之间有交互效应——比如开了 chunked prefill 之后 max_num_batched_tokens 的最优值会变。


延伸阅读:

参考资料:

  1. Kwon, W. et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP ’23.
  2. Yu, G. et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI ’22.
  3. Leviathan, Y. et al. (2023). Fast Inference from Transformers via Speculative Decoding. ICML ’23.
  4. Zheng, L. et al. (2023). Efficiently Programming Large Language Models using SGLang. arXiv:2312.07104.
  5. vLLM 官方文档 – 配置参数和最佳实践
  6. TensorRT-LLM 文档 – NVIDIA 官方推理优化框架

By .