AI 工程基础体系 · 第 27/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

AI 模型服务:批处理、连续批次、KV Cache、量化和 GPU 容量

先建立一个统一模型:一次请求如何消耗 GPU

生成式 AI 模型服务通常同时处理三类资源:

  1. 模型权重:加载一次后驻留在 GPU 或分布在多个设备上。
  2. 中间激活:执行某一层计算时产生,通常随计算阶段变化。
  3. 请求状态:尤其是 Transformer 的 KV Cache,它随并发请求数、输入长度和已生成长度增长。

对于一次自回归生成请求,输入通常称为 prompt,模型先执行一次 prefill,处理完整输入;之后反复执行 decode,每次生成一个或少量新 token。

设:

  • PP:输入 prompt 的 token 数;
  • GG:最终生成 token 数;
  • S=P+GS=P+G:某一时刻的上下文长度;
  • LL:Transformer 层数;
  • HH:隐藏维度;
  • hkvh_{kv}:KV head 数;
  • d=H/hqd=H/h_q:每个 attention head 的维度,其中 hqh_q 是 query head 数;
  • bb:KV Cache 每个元素占用的字节数。

在使用 GQA 或 MQA 时,hkvh_{kv} 小于 hqh_q,KV Cache 按 hkvh_{kv} 计算,而不是按 query head 数计算。

对单个请求,KV Cache 的近似显存占用为:

MKV=2×L×S×hkv×d×bM_{KV} = 2 \times L \times S \times h_{kv} \times d \times b

其中前面的 2 分别代表 Key 和 Value。这个公式忽略了页表、对齐、临时缓冲区和框架元数据,但足以解释容量变化。

prefill 的注意力计算需要同时处理大量 prompt token,通常具有较高的计算并行度;decode 每次只新增一个 token,却必须读取该请求历史上下文的 KV,因此经常更受显存带宽和 KV Cache 读取影响。两者都叫“推理”,但资源行为并不相同。

flowchart LR
    A[请求进入] --> B[排队与准入]
    B --> C[Prefill:处理完整 prompt]
    C --> D[写入 KV Cache]
    D --> E[Decode:逐 token 生成]
    E --> F{是否结束}
    F -- 否 --> D
    F -- 是 --> G[释放或缓存 KV]

关键路径是:prompt 只在 prefill 阶段批量计算一次;之后每个 decode step 都需要读取已有 KV,并把当前 token 的 K/V 追加到缓存。因此,增大上下文长度会同时增加 KV 容量和 decode 阶段的读取成本。


批处理到底批什么

离线批处理:先收集,再统一执行

最简单的批处理是把多个样本组成一个 batch:

outputs = model(input_ids_batch)

如果输入长度不同,就需要 padding 到同一个长度。设一个 batch 中的序列长度为:

[10,100,1000][10, 100, 1000]

若直接 padding,实际计算长度接近:

3×1000=30003 \times 1000=3000

但真实 token 数只有:

10+100+1000=111010+100+1000=1110

这部分多出来的计算称为 padding 浪费。对于训练,padding 通常可以接受或通过 bucketing 减少;对于在线生成服务,padding 还会造成不必要的显存和延迟。

离线批处理适合:

  • 文档嵌入;
  • 批量分类;
  • 离线摘要;
  • 有固定输入集合和宽松延迟要求的任务。

它的主要优点是 GPU 利用率高、调度简单;缺点是必须等待一批请求形成,且短请求可能被长请求拖慢。

动态批处理:在一个时间窗口内合并请求

在线服务通常不会无限等待,而是在很短的 batching window 内收集请求。调度器可能执行以下流程:

  1. 请求 A 到达,进入队列;
  2. 等待最多几毫秒;
  3. 请求 B、C 在窗口内到达;
  4. 将 A、B、C 组成一个 batch;
  5. 对 batch 执行一次模型计算;
  6. 请求结果分别返回。

动态批处理改善了 GPU 利用率,但引入了排队延迟。对用户可见的首 token 延迟可以近似分解为:

TTFT=Tqueue+Tprefill+TschedulerTTFT = T_{queue} + T_{prefill} + T_{scheduler}

其中:

  • TqueueT_{queue}:等待进入 batch 的时间;
  • TprefillT_{prefill}:处理 prompt 的时间;
  • TschedulerT_{scheduler}:调度、拷贝和框架开销。

batch window 增大,通常提高合并率,却增加 TqueueT_{queue}。因此“batch 越大越快”只对吞吐量成立,不一定对单请求延迟成立。

连续批次:在每个生成迭代重新调度

连续批次(continuous batching,也常被称为 iteration-level batching)不是把请求从开始到结束固定放在同一个 batch 中,而是在每一个 decode 迭代重新决定 batch 内容。

假设三个请求的生成长度分别为:

  • A:生成 2 个 token;
  • B:生成 5 个 token;
  • C:生成 3 个 token。

固定批处理可能必须等待最长的 B:

迭代 固定 batch 中的请求
1 A、B、C
2 A、B、C
3 A、B、C,但 A、C 可能已经完成
4 B
5 B

如果 A 完成后仍占据 batch 槽位,GPU 会做无效计算,或者需要 padding。连续批次会在每轮移除已完成请求,并加入新请求:

迭代 活跃请求
1 A、B、C
2 A、B、C
3 B、D
4 B、D、E
5 B、E

这里的“连续”指请求可以在迭代边界加入或退出,而不是一个 batch 必须从头到尾固定。

连续批次的调度状态至少包括:

  • 请求 ID;
  • 输入 token 和已生成 token;
  • 当前长度 SS
  • KV Cache 的物理位置;
  • 停止条件;
  • 优先级或 deadline;
  • 是否处于 prefill、decode、暂停或完成状态。

一个实际调度器需要在每一轮同时考虑:

  1. 哪些请求已经完成;
  2. 哪些请求可以继续 decode;
  3. 新请求的 prompt 是否要执行 prefill;
  4. 剩余 GPU 显存能容纳多少新 KV;
  5. prefill 是否会阻塞已有请求的 decode;
  6. 是否需要限制单轮新增 token 数。

如果一次性对超长 prompt 做完整 prefill,它可能占满计算资源,使已有请求长时间拿不到下一个 token。这就是在线服务中常见的 prefill 阻塞 decode。一些推理引擎会使用 chunked prefill,把长 prompt 分成多个块,在 prefill 和 decode 之间交错调度。

连续批次不等于动态 padding

动态批处理主要解决“什么时候把请求合并”;连续批次还要解决“请求在不同生成长度下如何逐轮加入和退出”。

此外,生成请求每一轮的有效序列长度不同,普通二维张量通常需要 padding。推理引擎会使用长度元数据、压紧后的 token 表示或分页式 KV 管理来减少无效计算。因而:

  • 动态批处理可能仍使用固定 batch;
  • 连续批次可能仍有少量 padding;
  • 是否高效取决于调度器、attention kernel 和 KV 内存布局。

Prefill 和 Decode 的计算差异

Transformer 的 self-attention 对长度为 SS 的序列,注意力矩阵的理论复杂度约为:

O(S2)O(S^2)

但在 prefill 中,所有 prompt token 可以并行计算;在 decode 中,每次只增加一个 token。对于第 tt 个生成 token,模型需要计算当前 token 的 Query,并与长度约为 P+tP+t 的历史 Key 做注意力:

QtK1:tTQ_tK_{1:t}^{T}

因此 decode 每一步新增的注意力工作大致随历史长度线性增长,而不是重新计算整个 prompt 的 Query、Key、Value。

KV Cache 的核心作用是保存历史 token 的 Key 和 Value,使后续 decode 不必重新计算它们。如果没有 KV Cache,每生成一个 token,都需要重新对完整上下文执行大量层计算;有 KV Cache 后,历史 K/V 被复用,只计算新 token 对应的部分。

这也解释了一个容易混淆的事实:

KV Cache 减少重复计算,但不免费。它把部分计算成本转换成了显存容量和显存带宽成本。

在短 prompt、小并发下,计算可能是瓶颈;在长上下文、高并发 decode 下,KV Cache 读取和 GPU 显存容量可能成为瓶颈。


KV Cache 的结构与生命周期

每层保存什么

对每一层 attention,模型会产生:

  • KK:Key;
  • VV:Value。

典型逻辑形状可以表示为:

[batch, kv_heads, sequence_length, head_dim]

不同框架可能采用:

[sequence_length, batch, kv_heads, head_dim]

或把多个请求的 token 组织成压紧的连续缓冲区,但语义仍然是:每个请求、每一层、每个历史位置都有一组 K 和一组 V。

请求开始时,KV Cache 通常为空。prefill 完成后,它包含整个 prompt 的 K/V;每次 decode 后,追加新 token 的 K/V;请求结束、取消或超时时,相关空间被释放或归还缓存池。

用一个完整算例计算 KV Cache

假设模型有:

  • L=32L=32 层;
  • hkv=8h_{kv}=8 个 KV head;
  • d=128d=128
  • KV 使用 FP16,每个元素 b=2b=2 字节;
  • 上下文长度 S=4096S=4096

则单 token 的 KV 占用为:

2×32×8×128×2=131072 bytes2 \times 32 \times 8 \times 128 \times 2 = 131072\text{ bytes}

即:

  • 128 KiB/token;
  • 4096 token 约为 512 MiB;
  • 8 个同时占满 4096 token 的请求约为 4 GiB。

如果错误地按 32 个 query head 计算,会得到 4 倍结果。对于 GQA 模型,KV Cache 使用的是 KV head 数,不是 query head 数。

如果该模型没有 GQA,而是 hkv=32h_{kv}=32,则单请求占用变为:

2×32×32×128×2×4096=2 GiB2 \times 32 \times 32 \times 128 \times 2 \times 4096 = 2\text{ GiB}

这说明架构中的 GQA/MQA 不只是影响计算,也直接影响长上下文并发容量。

Prefix Cache 不是普通 KV Cache

如果多个请求具有完全相同的前缀,例如所有请求都包含相同的系统提示词,服务可以复用该前缀的 KV。这通常称为 prefix caching 或 prompt caching。

只有满足以下条件时,复用才在语义上成立:

  • token 序列完全相同,而不是“文本看起来相似”;
  • 使用同一个模型权重和 tokenizer;
  • 位置编码、RoPE 配置、attention 配置相同;
  • 不存在依赖请求状态的特殊处理。

共享 KV 时,常见做法是引用计数或 copy-on-write。多个请求可以只读共享相同前缀;某个请求继续生成时,新 token 写入自己的后续区域。

prefix cache 的命中可以降低 TTFT,但不会自动消除 decode 阶段的 KV 读取,也不应把“缓存命中”误认为“模型推理完全没有成本”。


连续批次中的 KV Cache 管理

为什么简单的连续大数组会浪费显存

假设最大上下文长度配置为 8192,服务同时运行 16 个请求。如果直接为每个请求预留完整空间,那么即使多数请求只使用 500 token,也会按照 8192 token 分配,造成严重浪费。

另一种问题是显存碎片:请求以不同长度结束,释放出的区域可能无法拼成足够大的连续块。此时 nvidia-smi 看起来还有空闲显存,但一次大分配仍然失败。

分页式 KV 管理

分页式 KV 管理将 KV Cache 切成固定大小的 block,例如每个 block 保存若干 token。调度器维护:

逻辑 token 区间 -> 物理 KV block

请求增长时,只申请足够的新 block;请求结束时,block 返回空闲池。这样可以降低内部碎片,并使请求不必占用连续物理显存。

这类机制是推理引擎常见实现,vLLM 的 PagedAttention 属于这一方向。需要区分:

  • 逻辑连续:一个请求的 token 顺序连续;
  • 物理连续:GPU 内存地址连续。

分页式管理只要求逻辑映射正确,不要求物理地址连续。attention kernel 需要根据 block table 找到对应的 K/V。

分页不会改变单 token 的理论 KV 字节数,也不会让 KV Cache“消失”;它主要提高空间利用率和调度灵活性。

一个请求的状态转换

stateDiagram-v2
    [*] --> Queued
    Queued --> Prefilling: 获得调度额度
    Prefilling --> Decoding: prompt 完成且 KV 已写入
    Decoding --> Decoding: 生成普通 token
    Decoding --> Finished: EOS/长度/停止串
    Decoding --> Cancelled: 客户端取消或超时
    Queued --> Rejected: 容量或策略拒绝
    Prefilling --> Failed: OOM/Kernel/输入错误
    Decoding --> Failed: OOM/设备错误
    Finished --> [*]
    Cancelled --> [*]
    Rejected --> [*]
    Failed --> [*]

每个终止路径都必须释放或回收 KV block。若取消请求只停止 HTTP 流而没有释放模型侧状态,服务会出现“请求数下降但显存不下降”的泄漏表现。


GPU 容量:不能只看模型参数量

权重容量

最粗略的权重大小估算是:

MWN×bWM_W \approx N \times b_W

其中:

  • NN:参数数量;
  • bWb_W:每个权重的字节数。

例如 7B 参数模型:

  • FP16:约 7×109×2=147\times10^9\times2=14 GB;
  • INT8:约 7 GB;
  • 4-bit:约 3.5 GB。

这里的 GB 是十进制近似;GPU 工具常以 GiB 展示。实际占用还包括:

  • 量化 scale 和 zero-point;
  • 分组量化元数据;
  • 对齐;
  • embedding、lm_head 等未量化或特殊处理的张量;
  • 框架 workspace;
  • CUDA allocator 保留块;
  • 多 GPU 通信缓冲区。

因此不能把“4 bit”直接等价为“FP16 显存的四分之一”。

激活和临时工作区

训练时,激活、梯度和优化器状态往往远大于推理时的临时激活。推理通常可以通过 inference_mode 或类似机制减少 autograd 状态,但仍需要:

  • attention 和 GEMM 的 workspace;
  • logits;
  • sampling 临时张量;
  • 通信缓冲;
  • kernel 特定的临时内存。

PyTorch 中,torch.no_grad()torch.inference_mode() 可避免建立训练所需的 autograd 图;它们不等于“所有中间张量都不占内存”,也不等于自动启用推理引擎的 KV 管理。相关语义应以当前 PyTorch 版本文档为准。

KV Cache 容量

总显存可以粗略写为:

MGPU=MW+MKV+Mactivation+Mworkspace+Mruntime+MfragmentationM_{GPU} = M_W + M_{KV} + M_{activation} + M_{workspace} + M_{runtime} + M_{fragmentation}

可用于 KV 的容量是:

MKV,available=MGPU,totalMWMreservedMactivationMworkspaceM_{KV,available} = M_{GPU,total} - M_W - M_{reserved} - M_{activation} - M_{workspace}

最大并发不是简单的:

GPU\显单请求显存\frac{GPU\显存}{单请求显存}

而应按每个请求的实际 token 数计算:

i(2LhkvdbSi)MKV,available\sum_i \left( 2Lh_{kv}dbS_i \right) \leq M_{KV,available}

其中 SiS_i 是第 ii 个请求当前已占用的上下文长度。

一个容量估算脚本

下面的脚本不加载模型,只计算理论 KV Cache 和粗略容量,适合验证模型配置是否算错:

# kv_capacity.py
from dataclasses import dataclass

@dataclass
class KVConfig:
    layers: int
    kv_heads: int
    head_dim: int
    kv_bytes: int       # FP16/BF16=2, FP8/INT8通常按1估算
    gpu_gib: float
    weights_gib: float
    reserved_gib: float

def kv_bytes_per_token(cfg: KVConfig) -> int:
    # 2: K 和 V
    return 2 * cfg.layers * cfg.kv_heads * cfg.head_dim * cfg.kv_bytes

def gib(n_bytes: float) -> float:
    return n_bytes / (1024 ** 3)

def main():
    cfg = KVConfig(
        layers=32,
        kv_heads=8,
        head_dim=128,
        kv_bytes=2,
        gpu_gib=24,
        weights_gib=13.04,
        reserved_gib=2.0,
    )

    per_token = kv_bytes_per_token(cfg)
    available = cfg.gpu_gib - cfg.weights_gib - cfg.reserved_gib
    max_tokens = int(available * (1024 ** 3) // per_token)

    context = 4096
    per_request = per_token * context
    max_requests = int(available * (1024 ** 3) // per_request)

    print(f"KV bytes/token: {per_token:,}")
    print(f"KV KiB/token: {per_token / 1024:.1f}")
    print(f"KV per request at {context} tokens: {gib(per_request):.3f} GiB")
    print(f"rough available KV memory: {available:.2f} GiB")
    print(f"rough total KV tokens: {max_tokens:,}")
    print(f"rough requests at {context} tokens: {max_requests}")

if __name__ == "__main__":
    main()

在给定参数下,理论输出接近:

KV bytes/token: 131,072
KV KiB/token: 128.0
KV per request at 4096 tokens: 0.500 GiB
rough available KV memory: 8.96 GiB
rough total KV tokens: 73,400
rough requests at 4096 tokens: 17

这个结果不是可直接用于生产的并发上限。脚本把 24 GiB GPU、13.04 GiB 权重和 2 GiB 预留空间作为输入,但没有精确计算 workspace、碎片、临时激活和框架实现开销。生产服务应进一步留出安全余量,并通过压测确认 OOM 前的稳定水位。


量化:减少什么,不能减少什么

量化是用更低位宽的数值表示权重、激活或缓存中的张量。它不是单一技术,而是至少包含三个不同对象:

  1. 权重量化:减少模型权重容量;
  2. 激活量化:降低矩阵计算中的激活带宽或计算成本;
  3. KV Cache 量化:降低每个请求的上下文状态容量。

三者的误差来源和工程收益不同,不能统称为“模型变成 4 bit”。

对称和非对称量化

以一组浮点数 xx 为例,最简单的对称量化可写为:

q=round(x/s)q=\operatorname{round}(x/s)

反量化为:

x^=q×s\hat{x}=q\times s

其中 ss 是 scale,qq 是低位整数。若要限制在 qminq_{\min}qmaxq_{\max}

s=maxxqmaxs=\frac{\max |x|}{q_{\max}}

非对称量化还会引入 zero-point zz

q=round(x/s)+zq=\operatorname{round}(x/s)+z

实际量化通常按 tensor、channel 或 group 设置 scale。分组越小,通常越能适应局部数值范围,但 scale 元数据和 kernel 复杂度也会上升。

量化误差可以直观表示为:

e=xx^e=x-\hat{x}

误差并不只由 bit 数决定,还受到以下因素影响:

  • 权重分布是否存在离群值;
  • scale 的粒度;
  • 激活是否动态变化;
  • 模型层和任务;
  • 校准数据是否代表真实输入;
  • 推理 kernel 是否原生支持该格式。

权重量化和运行时 dtype

常见组合包括:

  • W16A16:权重和激活都用 FP16/BF16;
  • W8A16:权重低比特存储,计算时可能反量化到更高精度;
  • W4A16:常见的权重低比特部署形式;
  • W8A8:权重和激活都以 8 bit 路径计算。

W4A16 的主要直接收益是降低权重驻留容量和权重读取带宽,但不意味着所有算子都以 4 bit 原生执行。某些实现会在 kernel 内部进行反量化;如果 GPU、算子或 batch 形态不适合,可能出现:

  • 显存下降但延迟没有下降;
  • 单请求更快,但大 batch 吞吐不变;
  • 某些层仍使用 FP16;
  • 量化 kernel 的启动和转换开销抵消收益。

因此选择量化格式时,必须同时验证:

  1. 权重是否能装入目标 GPU;
  2. 推理引擎是否有对应 kernel;
  3. prefill 和 decode 是否都受益;
  4. 质量指标是否满足要求;
  5. 量化后的服务是否仍支持目标上下文长度和并发。

KV Cache 量化是另一条质量边界

若 KV 从 FP16 改为 8 bit,理论存储字节数大致减半;改为 4 bit,理论上可进一步下降。但 KV 是注意力直接读取的历史状态,量化误差会随着上下文使用而影响后续 token。

权重 4 bit 不代表 KV 也自动是 4 bit。常见部署可能是:

权重:4 bit
激活:FP16/BF16
KV Cache:FP16/BF16

也可能是:

权重:4 bit
激活:FP16/BF16
KV Cache:FP8/INT8

这两种方案的容量、质量和 kernel 要求不同。KV 量化尤其需要用真实任务评测长上下文、检索内容位置、代码生成和多轮对话,而不能只观察短文本困惑度。

量化的完整验证方法

量化部署至少应比较四组结果:

维度 原始模型 量化模型
任务质量 基线 是否下降
TTFT 基线 prefill 是否改善
decode 速度 基线 每秒 token 是否改善
GPU 容量 基线 权重和 KV 各减少多少

如果只比较模型文件大小,无法证明服务性能改善;如果只比较平均输出速度,也可能掩盖长上下文质量下降和尾延迟恶化。


批处理、KV Cache 和量化之间的耦合

batch 增大时,KV 线性增长

若每个请求上下文长度相同,KV Cache 总量近似为:

MKV,total=B×MKV,requestM_{KV,total}=B\times M_{KV,request}

其中 BB 是活跃请求数。对于长度不等的请求,应使用各请求长度求和,而不是简单用最大长度乘 batch。

因此,增大 batch 可能:

  • 提高矩阵计算利用率;
  • 提高吞吐;
  • 增加 KV 占用;
  • 增加调度和排队延迟;
  • 增加 OOM 风险。

权重量化可能释放空间给 KV

假设 24 GiB GPU:

  • FP16 权重约 13.04 GiB;
  • 4 bit 权重约 3.5 GiB 加量化元数据;
  • 其余预留 2 GiB。

权重量化可能释放约 9 GiB 空间。若每个请求的 KV 是 0.5 GiB,理论上可以多容纳约 18 个满长度请求。但这不表示吞吐必然提高 18 倍,因为 decode 还受显存带宽、kernel 效率和调度开销限制。

长 prompt 不一定适合无限合并

Prefill 可以利用大矩阵并行,但多个超长 prompt 同时进入会造成:

  • 短请求 TTFT 增大;
  • 临时激活峰值上升;
  • KV 分配瞬间增加;
  • decode 请求被推迟。

因此连续批次通常需要同时限制:

  • 活跃请求数;
  • 活跃 KV token 总数;
  • 单次 prefill token 数;
  • 单轮 decode token 数;
  • 单租户或单优先级配额。

调度策略:吞吐、TTFT 和尾延迟如何冲突

生成服务至少要区分三个指标:

  • 吞吐量:单位时间生成或处理的 token 数;
  • TTFT:从请求到达到返回首个 token 的时间;
  • ITL(inter-token latency):相邻输出 token 之间的延迟。

一个策略可能提高吞吐,却恶化 TTFT。例如把很多新请求合并到一个巨大 prefill batch 中,GPU 利用率很高,但已有请求无法及时 decode。

常见调度思路包括:

FIFO

先到先服务,容易实现,也容易解释。缺点是一个超长 prompt 可能阻塞大量短请求,形成 head-of-line blocking。

长度分桶

按 prompt 长度或预计生成长度分桶,减少 padding 和长度差异。缺点是需要等待同桶请求,并且长度预测可能不准。

Decode 优先

优先保证已有请求继续生成,以降低 ITL。缺点是新请求 TTFT 可能变高。

Prefill 与 decode 配额

每轮给 prefill 和 decode 分别设置 token 预算。例如一轮最多处理 2048 个新 prompt token,同时为现有 decode 请求各生成一个 token。这样可以避免长 prompt 无限占用调度器。

公平性和限流

连续批次会让“请求数”不再是唯一负载指标。一个 100 token 请求和一个 100,000 token 请求都算一个请求,但它们的 KV 和计算成本完全不同。配额应考虑:

  • prompt token 数;
  • 最大上下文;
  • 预计输出 token;
  • 当前 KV token;
  • 租户预算;
  • 优先级和 deadline。

运行时内存与 PyTorch 诊断

PyTorch 的 CUDA allocator 可能保留已经向 CUDA 申请的显存,因此 nvidia-smitorch.cuda.memory_allocated()torch.cuda.memory_reserved() 的含义不同:

  • memory_allocated():当前 Tensor 实际占用;
  • memory_reserved():PyTorch allocator 保留的显存;
  • nvidia-smi:进程从驱动侧占用的显存,通常还包括上下文和其他运行时开销。

可以用以下代码观察一轮推理前后的差异:

import torch

def report(label):
    torch.cuda.synchronize()
    print(
        label,
        f"allocated={torch.cuda.memory_allocated() / 2**30:.2f} GiB, "
        f"reserved={torch.cuda.memory_reserved() / 2**30:.2f} GiB, "
        f"max_allocated={torch.cuda.max_memory_allocated() / 2**30:.2f} GiB"
    )

assert torch.cuda.is_available(), "需要 CUDA GPU"
device = torch.device("cuda")

torch.cuda.reset_peak_memory_stats()
report("before")

# 使用小张量模拟一次显存分配;真实服务中这里应替换为模型推理
x = torch.empty((4096, 4096), dtype=torch.float16, device=device)
report("after allocation")

del x
torch.cuda.empty_cache()
report("after release")

这里的 empty_cache() 只会把未被活动 Tensor 使用的缓存块归还给 CUDA allocator/驱动可再利用区域,不能释放仍被引用的 Tensor,也不能修复 KV 生命周期泄漏。生产诊断应同时记录:

  • 活跃请求数;
  • 活跃 KV token 数;
  • 每个请求的上下文长度;
  • prefill 和 decode 阶段;
  • allocated/reserved/peak memory;
  • OOM 发生前的 batch 和 token 预算。

典型失败表现与诊断路径

启动时 OOM

表现:模型尚未接收请求,加载权重时就失败。

可能原因

  • 权重 dtype 不符合预期;
  • 量化模型加载时仍保留了高精度副本;
  • 多 GPU 分片不均;
  • CUDA context 或其他进程已占用显存。

诊断

  1. 检查模型文件格式和实际加载 dtype;
  2. 使用 nvidia-smi 查看其他进程;
  3. 记录每个 rank 的显存;
  4. 在加载阶段分段报告 allocated/reserved;
  5. 验证是否发生了 CPU 到 GPU 的临时双份拷贝。

首个请求成功,第二批请求 OOM

表现:单请求正常,多并发或长上下文失败。

可能原因:KV Cache 随请求和 token 长度增长,且容量估算只计算了权重。

诊断

  • 比较单请求和多请求的 max_memory_allocated()
  • 计算活跃 KV token 数;
  • 查看请求是否都按最大上下文预留;
  • 检查 block 是否在取消后归还;
  • 检查 prefill 峰值是否超过 decode 稳态。

显存看似充足但仍无法分配

表现:监控显示有空闲显存,但大块分配失败。

可能原因

  • 显存碎片;
  • allocator 保留块与实际可用块不匹配;
  • kernel workspace 需要瞬时大块连续空间;
  • 多 GPU 中某一张卡成为瓶颈。

处理

  • 使用分页式 KV 管理;
  • 降低单轮 prefill token 上限;
  • 降低最大上下文或并发;
  • 让服务在压力测试中观察峰值,而不是只看稳态平均;
  • 不要把频繁 empty_cache() 当作根治方案,它可能增加分配开销和延迟。

吞吐提高但用户感觉更慢

表现:GPU 利用率和 tokens/s 上升,但 TTFT、P95/P99 延迟恶化。

原因:调度器偏向吞吐,batch window 或 prefill batch 过大,排队和 head-of-line blocking 增加。

验证

  • 将 TTFT 拆成排队、prefill、调度耗时;
  • 分别统计 prompt token/s 和 decode token/s;
  • 查看长 prompt 到达时短请求的延迟;
  • 比较 FIFO、decode 优先和 token-budget 策略。

量化后速度没有改善

表现:模型显存下降,但 TTFT 或 decode 速度基本不变,甚至变慢。

原因

  • 量化只降低了存储,没有使用高效量化 kernel;
  • decode 阶段瓶颈在 KV 读取而不是权重读取;
  • 反量化开销抵消收益;
  • batch 太小,无法发挥 kernel 优势;
  • GPU 架构不适合该量化格式。

此时应分别测试 prefill、decode、不同 batch、不同上下文长度,并确认实际执行的算子和 dtype,而不是仅查看量化文件名。


生产容量模型应该如何写

不要只配置:

max_batch_size = 32

更有意义的限制通常是:

max_active_requests
max_total_kv_tokens
max_context_length
max_new_tokens
max_prefill_tokens_per_iteration
max_queue_time

其中 max_total_kv_tokensmax_batch_size 更接近真实显存压力。例如:

  • 32 个请求,每个 512 token;
  • 8 个请求,每个 8192 token;

两者请求数不同,但 KV token 总量分别为 16384 和 65536,后者显著更容易 OOM。

一个简化的准入判断可以写成:

Tcurrent+rnewPr+预留输出 tokenTKV,maxT_{current} + \sum_{r\in new} P_r + \text{预留输出 token} \leq T_{KV,max}

其中 TcurrentT_{current} 是当前所有请求已占用的 KV token 数,PrP_r 是新请求 prompt token 数。实际系统还需考虑 block 粒度:如果一个 block 保存 KK 个 token,那么每个请求的分配量通常是:

KSKK\left\lceil\frac{S}{K}\right\rceil

这会产生内部碎片,因此理论 token 上限还要乘以实现相关的保守系数。

服务端应在容量不足时选择明确的故障路径:

  1. 排队但设置最大等待时间;
  2. 拒绝新请求并返回可重试错误;
  3. 降低允许的最大输出长度;
  4. 对低优先级请求降级到 CPU、较小模型或异步队列;
  5. 取消超时请求并确认 KV 已回收。

静默截断上下文、静默降低质量或无限排队,会使成本和用户体验变得不可预测。


与常见推理引擎的关系

Ollama、llama.cpp、vLLM 等工具在模型格式、量化格式、KV 管理、批处理策略和 API 语义上并不完全相同。

可以用以下维度比较,而不能只比较“能否启动模型”:

维度 需要确认的问题
模型格式 是否支持目标权重格式和 tokenizer
权重量化 量化发生在哪些层,是否有对应 kernel
KV Cache dtype、分页、prefix cache、最大 token 管理
批处理 是否支持动态或连续批次
调度 是否区分 prefill 和 decode
多 GPU 张量并行、流水线并行和通信开销
观测 是否能看到 TTFT、ITL、队列、token 和显存
兼容性 采样参数、停止串、流式输出和取消语义

“支持 4 bit”只说明某种量化权重可以被加载,不保证:

  • KV 也使用 4 bit;
  • 所有层都量化;
  • 所有 GPU 都有高效 kernel;
  • 连续批次和 prefix cache 同时可用;
  • 长上下文下质量和稳定性满足要求。

具体参数名称和可用特性随引擎版本、模型架构及 GPU 架构变化,部署前应以对应版本文档和实际启动日志为准。PyTorch 的 CUDA、推理模式和内存统计语义可参考其官方文档与教程,例如 PyTorch DocumentationPyTorch Tutorials;这些文档不能替代具体推理引擎对 paged KV、量化 kernel 或连续批次的实现说明。


成本治理:token 才是更接近真实负载的单位

GPU 成本不应只按请求数统计。对生成服务,至少应记录:

  • prompt token 数;
  • output token 数;
  • 当前上下文 token 数;
  • prefill GPU 时间;
  • decode GPU 时间;
  • TTFT;
  • ITL;
  • 排队时间;
  • prefix cache 命中率;
  • 峰值和平均 KV token 数;
  • 量化版本和模型版本。

相同的 1000 个请求可能有完全不同的成本:

  • 1000 个短分类请求,几乎没有持续 KV;
  • 1000 个长上下文生成请求,需要大量 KV 并发驻留;
  • 1000 个共享长系统提示词的请求,可能受益于 prefix cache;
  • 1000 个低命中率、长输出请求,会长时间占据 decode batch。

因此预算和限流最好使用 token 或 GPU 时间维度,而不是单纯使用 request count。一个租户可以被限制每分钟输入 token、输出 token 和最大并发 KV token;超出预算时,再根据优先级执行排队、拒绝、截断或降级。


最终判断框架

选择批处理方式时,先判断请求形态:

  • 离线、输入集合固定:静态批处理通常足够;
  • 在线、请求短且到达集中:动态批处理可以降低等待;
  • 在线生成、请求长度和完成时间差异大:连续批次更适合;
  • 长 prompt 与实时 decode 混合:需要区分 prefill/decode,并限制 prefill 预算;
  • 高并发长上下文:KV Cache 管理通常比单纯增大 batch 更关键。

选择量化方式时,分别回答:

  1. 要减少的是权重、激活还是 KV?
  2. 目标 GPU 是否有匹配 kernel?
  3. 质量下降是否经过真实任务评测?
  4. 节省的显存是否确实转化为更高并发或更低成本?
  5. 量化后的 decode 是否仍受 KV 带宽限制?

估算 GPU 容量时,至少计算:

总显存=权重+KV+激活+workspace+运行时与碎片\text{总显存} = \text{权重} + \text{KV} + \text{激活} + \text{workspace} + \text{运行时与碎片}

其中最容易被遗漏、却会随并发和上下文持续增长的部分是 KV Cache。批处理决定 GPU 同时服务多少工作,连续批次决定这些工作如何进出,量化决定部分数据每个元素占多少空间,而 GPU 容量决定调度器最终能否兑现这些计划。只有把四者放在同一个 token、显存和延迟模型中,吞吐、TTFT、稳定性与成本之间的取舍才是可计算、可验证的。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。