AI 基建
0%
第五部分 · 推断与服务 · 第 32 章

显存与调度

作者Changkun Ou
阅读时长约 12 分钟

第 31 章 已确立调度器最重要的安全规则:执行词元计划之前,先为它预留所需状态。本章把这条规则一直落实到物理 KV 缓存块。分配器必须核算不断增长的序列、共享前缀、请求取消和内存压力;调度器则必须选择能够装进这些块的任务,同时兼顾延迟、公平性和进度。

多套系统奠定了主要技术。Orca 在 2022 年提出迭代级调度 (Yu et al. 2022)。vLLM 的 PagedAttention 在 2023 年把分块分配用于 KV 状态 (Kwon et al. 2023)。SGLang 在 2024 年用 RadixAttention 组织可复用前缀 (Zheng et al. 2024)。Sarathi-Serve 在同机调度器内限制每轮预填充量,DistServe 则把预填充与解码放进不同的资源池 (Agrawal et al. 2024; Zhong et al. 2024)。Mooncake 后来又把放置问题扩展成由存储支撑、覆盖整个集群的 KV 层次结构 (Qin et al. 2025)。这些机制可以组合使用,并不是一套必须按历史顺序采用的步骤。

从保留词元到物理块

把每个保留词元对应的逻辑 KV 载荷记为

κ=2Lnkvdheadbkv.\kappa = 2L\,n_{\mathrm{kv}}\,d_{\mathrm{head}}\,b_{\mathrm{kv}}.

其中,LL 是 Transformer 层数,nkvn_{\mathrm{kv}} 是每层的键值头数,dheadd_{\mathrm{head}} 是一个头的维度,bkvb_{\mathrm{kv}} 是每个缓存元素的字节数,因子二代表键和值。κ\kappa 表示切分、对齐、元数据或分配器开销之前,每个词元需要的逻辑字节数。

假设一个分配块能够保存 BB 个词元位置的状态。对于请求 iiTiT_i 是需要保留的提示词和已生成词元数,qiq_i 表示逻辑块数,MiallocM_i^{\mathrm{alloc}} 表示已分配的 KV 字节容量,wiw_i 表示最后一个块中尚未使用的词元位置数。那么

qi=TiB,Mialloc=qiBκ,wi=qiBTi,0wi<B.\begin{gathered} q_i=\left\lceil\frac{T_i}{B}\right\rceil,\\ M_i^{\mathrm{alloc}}=q_i B\kappa,\\ w_i=q_iB-T_i, \qquad 0\le w_i<B. \end{gathered}

这个上界准确说明了固定块能保证什么:末块浪费少于每条非共享序列的一个块。分页并不会消除块表、对齐、引用计数、预留容量,也不会消除进程中其他位置的闲置内存。

下面三类浪费不能混为一谈:

浪费 原因 固定块带来的改变
过度预留 按请求声明的最大长度分配容量,但请求从未增长到该长度 随序列增长逐块分配
外部碎片 大小不一的连续内存段留下空洞,较大的新请求无法利用 任何空闲固定块都能满足下一次块申请
末块浪费 最后一个块没有填满 将浪费限制在少于 BB 个词元位置

下面的可运行示例进行精确的离散记账。四个请求分别保留 [37, 81, 130, 211] 个词元,并且都声明最大长度为 256。示例报告不同块大小下分配的词元槽位与末块浪费。它不会把块表条目虚构成运行时成本,因为这一侧的取舍必须通过内核测量才能确定。

from math import ceil

lengths = [37, 81, 130, 211]
declared_max = 256

print("block  entries  allocated  tail-waste")
for block_tokens in [1, 8, 16, 32, 64]:
    entries = sum(ceil(length / block_tokens) for length in lengths)
    allocated = entries * block_tokens
    waste = allocated - sum(lengths)
    print(f"{block_tokens:>5}  {entries:>7}  {allocated:>9}  {waste:>10}")

contiguous = len(lengths) * declared_max
print("contiguous max-reservation slots:", contiguous)

块越大,块表条目越少,但末块浪费可能越多。这两个数字都不是完整的性能模型。部署时应根据序列长度分布、缓存布局、内核实现和实测延迟选择 BB

块表消除连续内存要求

PagedAttention 分页注意力(PagedAttention) 把请求的逻辑 KV 序列存进大小固定、物理位置无需相邻的块 (Kwon et al. 2023)。设 P\mathcal P 为物理块标识符的集合,请求 ii 的块表可以写成如下映射:

πi:{0,,qi1}P.\pi_i: \{0,\ldots,q_i-1\}\longrightarrow\mathcal P.

这里,逻辑块索引 jj 映射到物理块 πi(j)\pi_i(j)。例如,即使第 2、7、9 号块散落在内存池中,一个占用三个块的请求仍可以使用下面这张块表:

逻辑块 jj 0 1 2
物理块 πi(j)\pi_i(j) 7 2 9

注意力内核会通过这层间接映射读取状态。这个设计借鉴了虚拟内存,却并不是硬件页表:映射由服务运行时和注意力内核显式管理,也不一定涉及按需缺页、地址转换后备缓冲器(TLB)或操作系统换页。

图 32.1. 四个合成请求共享一个含 32 个块的内存池。连续分配模式为每个请求预留八个块;分页模式则随请求增长分配块。每个彩色单元格表示一个已分配块,图中有意省略了块内的部分占用情况。虚线单元格表示已预留但尚未使用的块。

在 vLLM 论文评估的模型、负载及 FasterTransformer 和 Orca 基线下,完整的 vLLM 系统在延迟相近时实现了二至四倍吞吐量 (Kwon et al. 2023)。这个结果包含块管理器、调度器、内核和共享机制,不能把它视为分块分配本身能够带来、且与硬件无关的固定倍数。

分配是词元计划的一部分

连续批处理(continuous batching) 允许活跃请求集合在不同迭代之间变化,但每轮迭代不必推进所有常驻请求。一个计划可以包含解码步骤或预填充分块;某个请求也可能因为优先级、容量或公平性策略而不执行任何任务。如果计划为请求 ii 安排 si0s_i\ge0 个新位置,那么它需要新增的块数为

Δqi=Ti+siBTiB.\Delta q_i = \left\lceil\frac{T_i+s_i}{B}\right\rceil - \left\lceil\frac{T_i}{B}\right\rceil.

这里,TiT_i 是请求当前保留的长度,sis_i 是本轮计划新增的位置数,Δqi\Delta q_i 是块表需要增加的条目数。假设完成允许的缓存逐出后,还有 qfreeq_{\mathrm{free}} 个可用块,那么内存可行性要求

iSΔqiqfree,\sum_{i\in\mathcal S}\Delta q_i \le q_{\mathrm{free}},

其中,S\mathcal S 是计划选中的请求集合。内存可行性与调度器的词元预算或计算预算是两项独立约束。计划即使装得进内存,也可能无法满足当前的延迟约定。

分配器需要遵循“预留、执行、提交”协议。启动模型计算之前,它必须以原子方式预留全部新块;执行成功后,再提交映射与词元状态;取消或失败会回滚尚未提交的预留。一个有用的守恒检查是

qfree+qreserved+qcommitted=qcapacity.q_{\mathrm{free}} +q_{\mathrm{reserved}} +q_{\mathrm{committed}} =q_{\mathrm{capacity}}.

这四项分别是空闲块、为计划中的迭代预留的块、被活跃或缓存状态引用的已提交块,以及内存池总容量。任何仍被引用的块都不得重新分配,每次状态转换都必须保持等式成立。

flowchart TD
    A[构建词元计划] --> B[计算所需新增块数]
    B --> C{可回收容量是否足够?}
    C -->|否| D[执行已配置的内存压力策略]
    D --> M[推迟或终止本次计划]
    C -->|是| E[以原子方式预留块]
    E --> F[执行模型计算]
    F --> G{执行是否成功?}
    G -->|否| H[回滚预留]
    G -->|是| I[提交映射与 KV 状态]
    I --> J{请求或缓存条目是否释放?}
    J -->|否| N[保留仍被引用的已提交块]
    J -->|是| K[减少块引用计数]
    K --> L[把零引用块放回空闲池]
图 32.2. 一个块从空闲池进入预留状态,再被提交。执行失败会回滚预留;请求完成、取消、逐出或抢占时先减少引用,引用数降为零的块才会回到空闲池。

共享前缀会改变块的所有权

当两个请求对应的 KV 状态完全相同时,它们可以共享同一个物理前缀块。可见文本相同还不够。缓存身份通常至少包括词元 ID、模型权重或版本、启用的适配器、位置处理方式、注意力与缓存格式,以及任何会影响前缀的多模态状态。即使数学上可以共享,租户与隔离策略也可能禁止这样做。

BiP\mathcal B_i\subseteq\mathcal P 为请求 ii 引用的物理块集合,R\mathcal R 为常驻请求集合。正在使用的物理块数为

qlive=iRBi,qliveqcapacity,q_{\mathrm{live}} = \left|\bigcup_{i\in\mathcal R}\mathcal B_i\right|, \qquad q_{\mathrm{live}}\le q_{\mathrm{capacity}},

这里,并集让共享块只计算一次。每个物理块 pp 都有一个引用计数 rpr_p,记录所有活跃请求和保留缓存的所有者。只有相关引用全部移除且 rp=0r_p=0 后,这个块才能回收。

前缀缓存(prefix caching) 会建立一份索引,把兼容的词元前缀映射到这些块。SGLang 的 RadixAttention 用基数树保存前缀,查找可复用的最长前缀,跟踪活跃节点的引用,并在内存压力下逐出可复用的叶节点状态 (Zheng et al. 2024)。完整的共享块必须保持不可变;出现分叉的后缀会获得不同的块。若实现允许共享一个仍可写入的未满块,就必须采用写时复制或等价规则。

前缀缓存带来四个运维问题:

  1. 身份: 哪些模型、适配器、位置、缓存格式、模态和租户字段共同组成缓存命名空间?
  2. 所有权: 哪些活跃请求和索引中保留的条目正在引用每个块?
  3. 价值: 一次命中能省下多少预填充词元和多少时间?
  4. 逐出: 活跃任务需要空间时,应移除哪个未固定的前缀?

只报告请求级命中率,可能掩盖大部分实际价值。还应报告匹配的前缀词元数或字节数,因为命中八个词元与命中八千个词元,节省的计算并不相同。

内存压力需要明确策略

动态分配能比按最大长度预留接纳更多任务,却不能保证每条已接纳序列都能增长到声明的上限。这里的内存压力,是指空闲池无法满足 iΔqi\sum_i\Delta q_i,系统必须在几种不同操作之间作出选择:

操作 影响的状态 恢复执行时的成本
逐出可复用的前缀状态 没有活跃所有者的缓存块 以后未命中时重新计算前缀
推迟接纳 等待中的请求 增加排队延迟,不丢失模型计算
抢占并重新计算 活跃请求 丢弃 KV 状态,之后重新执行模型计算
卸载后再载入 活跃或缓存块 传输、存储排队与同步
明确拒绝或报错 视约定而定,影响等待中或活跃的请求 请求失败,必须反映在服务指标中

这些操作不能互换。逐出一个没有活跃所有者的缓存条目,不会改变任何正在运行的请求;抢占活跃任务则会。卸载能够保留状态,却会新增一条数据路径,而这条路径可能比重新计算更慢。优先级可以保护交互流量,也可能让较早到达的批处理请求长期得不到执行,因此选择被抢占的受害请求时,还必须考虑等待时间或公平性约束,而不能只看内存评分。

采取压力策略后是否重试,本身就是策略的一部分。这不是允许调度器无限重试。如果无法回收容量,调度器必须推迟、拒绝或明确报告失败。不可见的重试循环会把已知的分配失败变成无休止的排队。

分块预填充共享每轮预算

长预填充可能占用一整轮迭代,使活跃解码请求的词元间隔变长。Sarathi-Serve 把预填充分块,再把这些块与解码任务合并执行 (Agrawal et al. 2024)。设 D\mathcal D 为选中的解码请求,did_i 为各请求计划执行的解码位置数,Pf\mathcal P_f 为选中的预填充请求,cjc_j 为各自的分块大小,KK 为经过性能分析得到的每轮词元预算。一个简化约束是

iDdi+jPfcjK.\sum_{i\in\mathcal D}d_i + \sum_{j\in\mathcal P_f}c_j \le K.

在这个式子中,如果没有采用多词元解码,did_i 通常为一。词元数只是执行时间的近似指标:上下文长度、内核、阶段组合、并行布局和硬件,都会改变每个词元对应的工作量。

分块只能限制一轮计划纳入多少预填充工作,并不能消除预填充与解码之间的全部干扰。块太小会增加内核启动、重复调度,还可能降低内核效率。只保护解码的策略可能让新预填充长期得不到执行,进而恶化首词元时间。因此,调度器除了目标批大小,还必须测量各类队列的等待时间和延迟。

只有数据路径划算时才移动 KV 状态

预填充与解码分离会为两个阶段分配不同的设备池。这样可以消除同机内核争用,也能解除部分资源和并行选择之间的绑定;但所需 KV 状态到达解码池之前,请求无法开始解码。设 τfixed\tau_{\mathrm{fixed}} 为启动与同步延迟,SS 为传输的 KV 载荷字节数,β\beta 为这条路径的实测带宽。单独计算传输时,有

τtransferτfixed+Sβ.\tau_{\mathrm{transfer}} \ge \tau_{\mathrm{fixed}}+\frac{S}{\beta}.

式中使用实测带宽,而不是链路标称带宽。端到端延迟还包括源端和目的端的队列、争用、重试和背压。传输可以与其他任务重叠,但使用这些状态的一方仍需要清晰的就绪与所有权协议。

DistServe 在选定的 TTFT 和 TPOT 达标率目标下,分别优化预填充与解码资源 (Zhong et al. 2024)。Mooncake 把同一项状态移动问题扩展到 GPU 内存、CPU 动态随机存取存储器(DRAM)、SSD 和网络链路 (Qin et al. 2025)。这些系统展示的是具体设计和实测负载,并不能证明阶段分离或分层存储普遍更优。

缓存感知的路由还必须同时考虑局部性和负载。设 QjQ_j 为工作节点 jj 的预测排队延迟,PiP_i 为提示词长度,HijH_{ij} 为兼容的缓存前缀长度,Cprefill(u;j)C_{\mathrm{prefill}}(u;j) 为在该节点计算 uu 个未缓存词元的预测时间,XijX_{ij} 为传输与路由开销。一种示意性的完成时间估计为

C^ij=Qj+Cprefill(PiHij;j)+Xij.\widehat C_{ij} = Q_j +C_{\mathrm{prefill}}(P_i-H_{ij};j) +X_{ij}.

这个估计式只是起点。容量、公平性、故障域、预测误差和租户隔离,都可能推翻选择最低估计值的决定。

争议所在

没有一种分配或放置策略能在所有负载下胜出。小块能更严格地限制末块浪费,却会产生更多块表条目;保留前缀只有在它被逐出之前出现兼容复用才有价值。存储路径较慢时,重新计算可能优于卸载;算力紧张时,卸载也可能更划算。同机部署免去 KV 传输,阶段分离则可以隔离两个阶段的队列,并独立调整资源池规模。交叉点取决于提示词和输出长度分布、输入负载、SLO、并行方式、拓扑和故障策略。

同时验证分配器与调度器

正确性测试不能只检查请求能否成功完成,还应覆盖以下情况:

  1. 在预留与提交之间取消一个请求,确认所有已预留块都回到内存池。
  2. 让多个请求共享前缀并按不同顺序结束,证明共享块只会在最后一个引用消失之后回收。
  3. 注入执行与传输故障,确认已提交映射始终有效,未提交映射全部回滚。
  4. 耗尽内存池,确认配置的逐出、抢占、推迟、拒绝和公平性规则被明确执行,而不是落入重试循环。
  5. 改变模型版本、适配器、位置处理、模态或租户命名空间,确认不兼容的前缀无法命中缓存。

在负载下,应报告四组指标:

  • 内存池状态: 空闲块、预留块、已提交块和被引用的唯一物理块数;末块浪费;分配失败;峰值占用水位。
  • 复用与回收: 匹配的前缀词元数、命中率与逐出率、复用距离、重新计算的词元数、卸载字节数和重新载入延迟。
  • 调度: 按请求类别统计的队列等待时间、预填充分块大小、解码批组成、抢占、拒绝和公平性结果。
  • 服务结果: TTFT、词元间隔、TPOT、有效吞吐量、接纳率、KV 传输字节数与延迟、加速器时间和成本。

第 31 章 给出了资源匹配的负载评估方法。本章新增的要求是守恒:核对从接纳到释放期间的每个已调度词元和每个物理块。

下层约束

第 8 章 通过层数、KV 头数、头维度和缓存精度,决定每个词元对应的逻辑 KV 字节数。本章决定如何分配、共享、回收和移动这些状态。第 33 章 会改变一次模型执行能够接受多少词元,从而改变词元计划和未来的块需求。第 34 章 可以减少缓存字节数,也可能改变内核实际能达到的性能。这些下层条件中任何一项发生变化,都必须重新测量内存策略。

收益与边界

分块分配为调度器提供了精确的可行性检验,引用计数保证前缀共享安全。内存压力策略明确哪些任务可以丢弃、移动、延迟或拒绝;分块预算控制同机部署中两个阶段的干扰;传输模型则把状态与计算分离的代价显式呈现出来。

这些机制可以独立使用,也经常组合使用。分配器负责维护内存与所有权不变量;调度器则要依据负载的延迟、公平性、接纳率和成本约定,在可安全执行的计划中作出选择。混淆两者的职责,会让系统看似很快,却在第一次请求取消、缓存未命中、流量突发或块池耗尽时失效。

延伸阅读

  • Yu et al., “Orca: A Distributed Serving System for Transformer-Based Generative Models” (用于自回归模型服务的迭代级调度), 2022. usenix.org
    Orca 引入迭代级调度,让生成模型服务器在每个解码步骤后重组批次,而不必等待静态批次全部结束。
  • Kwon et al., “Efficient Memory Management for Large Language Model Serving with PagedAttention” (vLLM 中基于分块的 KV 缓存分配), 2023. arXiv:2309.06180
    vLLM 通过分块分配与共享 KV 缓存来减少碎片,并提高服务系统可同时驻留的序列数量。
  • Zheng et al., “SGLang: Efficient Execution of Structured Language Model Programs” (用基数树管理可复用的词元前缀), 2024. proceedings.neurips.cc
    SGLang 使用压缩有限状态机与跳跃式前向处理,减少结构化输出中确定性片段的顺序解码工作。
  • Agrawal et al., “Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve” (分块预填充与面向停顿的批处理), 2024. arXiv:2403.02310
    Sarathi-Serve 将长预填充拆成多个块并与解码共同调度,以限制生成停顿并保留批处理机会。
  • Zhong et al., “DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving” (在联合延迟目标下分离部署预填充与解码), 2024. arXiv:2401.09670
    DistServe 将预填充与解码放到独立 GPU 池,并用在指定 TTFT 与 TPOT 达标率下可持续的到达率定义 goodput。
  • Qin et al., “Mooncake: Trading More Storage for Less Computation—A KVCache-centric Architecture for Serving LLM Chatbot,” 2025. usenix.org
    Mooncake 在分布式缓存层次中管理 KV 状态,以存储与传输容量换取更少的重复预填充计算。

评论

登录后评论