SGLang · 技术汇报 · 2026-08

PP × HiCache L3
社区问题与修复全景

流水线并行(Pipeline Parallelism)遇上三层 KV 缓存(L1 显存 / L2 主机内存 / L3 外部存储)后, 为何崩溃、社区如何修复、目前进展如何。沿途覆盖 L1/L2/L3 四条数据通路、请求全生命周期、 abort 链路与 chunked prefill × PP 的动态分块。组合场景(TP / DPA / CP / EP × PP、PD × Mooncake、EAGLE)见姊妹 deck。

读完能带走 ①

一条可复用的判据:什么样的本地状态绝对不能直接进入调度决策——以及怎么在 code review 时一眼认出它。

读完能带走 ②

四条对齐原语(P1–P4)及其适用边界:数值取 MIN、控制走 leader、通信平面隔离、异常路径也守恒。

读完能带走 ③

一份可操作结论:PP+L2 / PP+L3 各自是否可用于生产、崩溃后如何从报错反推根因(第 11 页诊断路径)。

L2 + PP 已修复合入 L3 + PP 主修复 #27010 未合并 总纲 issue #22607

/ 空格 下一页 · 上一页 · Home 回封面 — 素材来源:sgl-project/sglang 社区 issue/PR + 本仓库源码(含 file:line 引用)

本页引用 — PR / issue:#22607 · #27010(行号基于 2026-08 基线,链接指向 main)

结论先行 · 01

三句话讲完整个故事

根因:一条被打破的隐含契约

PP 的每个 rank 各跑一个独立 Scheduler + 独立 radix tree,代码隐含假设「所有 rank 的状态确定性一致」。 HiCache 的异步 DMA ack、异步 L3 prefetch、以及 per-rank 的物理内存成败打破了这条契约 → 各 rank 的树状态发散(diverge)

修复范式:两种对齐原语

凡是「本地状态影响调度决策」的点,一律改为 ① 对称 all_reduce(MIN)(最慢者获胜),或 ② PP0 单点决策 + pp_sync 链式广播。 且失败 / abort 路径也必须产出与成功路径等量的协议消息同步点全景 → 第 7 页;MIN 投票逐帧 → 第 16 页。

现状(2026-08)

L2+PP 系列修复已合入(#27285 / #28916 / #29258 / #31869…)。 L3+PP 主修复 #27010 仍是 open 状态。 maintainer 于 2026-08-05 在 #30158 明确表态:“PP + L3 is not supported yet.”

先约定三个贯穿全篇的词(本片的分析工具,非官方术语): per-rank 量(代码注释的用词:a per-rank … count / can diverge across PP rankshiradix_cache.py:254)= 取值取决于现实世界快慢的量——网速、磁盘、DMA 完成时刻、线程抢 GIL、内存分配成败;同一逻辑步在不同 rank 上读到的值必然不同,且不可复现。 逻辑量 = 由确定性代码路径决定的量——第几个调度步、第 k 张 ack、请求序列里的第几个请求;全员必然一致。 协议量 = per-rank 量经过一次显式跨 rank 对齐(all_reduce / leader 广播)后得到的值——全员一致,可安全进入决策。 本篇所述缺陷的共同根因是「per-rank 量直接进入决策」,修复方向统一为「先将其转换为协议量」。

本页引用 — PR / issue:#27010 · #27285 · #28916 · #29258 · #30158 · #31869(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 02

HiCache 三层架构:L1 显存 · L2 主机内存 · L3 外部存储

L1 · GPU 显存 RadixCache / TreeNode device radix 树 · radix_cache.py Device KV pool(HBM) token_to_kv_pool L2 · 主机 pinned 内存 host radix 节点(host_value) HiRadixCache 扩展 · hiradix_cache.py HostKVCache pool memory_pool_host.py(MHA/MLA/Mamba) L3 · 外部存储(HiCacheStorage) mooncake · hf3fs · nixl · eic · aibrix · simm · file … hicache_storage.py + storage/backend_factory.py,按 page 存取,跨实例共享 编排与搬运 HiRadixCache 树 + 策略(调度线程) HiCacheController DMA / IO 引擎 + 4 后台线程 写策略 write_through / write_through_selective / write_back L1 逐出→L2 承接, L2 逐出→L3 承接, 前缀越冷放得越远, 命中越近读得越快 write_backup(D2H DMA) load_back(H2D 逐层流水) write_backup_storage(batch set) prefetch(batch get,异步线程)
图 1 · 三层缓存与所有权。左列自上而下是 L1→L2→L3 的存储层级;右列是唯一的「搬运方」:树与策略在 HiRadixCache(调度线程),实际 DMA/IO 在 HiCacheController(含 4 条后台线程)。四条彩色箭头即四条数据通路,颜色 = 数据的来源层。

入口选择:registry.py:101enable_hierarchical_cache=True 时用 HiRadixCache(HybridModel 走 UnifiedRadixCache);L3 后端由 StorageBackendFactorybackend_factory.py:16)按 --hicache-storage-backend 创建。

本页引用 — 源码:backend_factory.py · registry.py(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 03

四条数据通路:两条下沉备份 + 两条上浮取回

先记住一个统一套路:四条通路全是「异步提交 + ack 回收」——调度线程只往队列里提交任务,真正的拷贝由后台 stream / 线程完成,完成后往 ack 队列丢一张「完成凭证」(即代码里的 ackack_write_queue 的一个条目 = finish_event + 覆盖的节点列表);调度线程从不等待 IO,只在每个调度步开头统一 drain 这些 ackcheck_hicache_events;本片后文说的「ack 回收」均指此事)。方向上两两成对:L1→L2L2→L3 是冷数据下沉(备份),L2→L1L3→L2 是热数据上浮(取回)。

L1 → L2 备份(write-through)

触发:前缀在 L1 的命中数攒够阈值(_inc_hit_count)——是热点才值得花 D2H 带宽备份,将来被 GPU 逐出后还能从 L2 快速捞回。

链路write_backupcontroller.writewrite_queuestart_writing 在独立 write_stream 上整批 D2H DMA(专用流,不占计算流、不影响正在跑的 forward)→ HiCacheAckack_write_queue → 每步 writing_checkfinish_event.query() 非阻塞问「拷完没」,完成才收尾(发 KV event、标记 backuped、触发 L2→L3、解锁 device 副本)。

# Backed-up nodes must form a
# contiguous prefix from root
# (no gaps).
if not write_back and (
    node.parent != self.root_node
    and not node.parent.backuped
):
    return 0   # 父未备份 → 本节点跳过

hiradix_cache.py:858——为什么必须从根连续、不能有洞:树节点的完整前缀 = 根到它的整条路径。父节点没备份而子节点备份了,恢复时拿到的是「断头的中间段」——缺前缀的 KV 无法使用(attention 需要完整前缀)。所以父亲没备份,儿子先跳过,下次再说。

L2 → L1 回载(load_back)

触发:组 batch 时 match_prefix 发现前缀在树上有记录、但 device 副本已被逐出——数据只剩 L2 那份,要用必须先搬回 GPU。

边载边算:KV 按层组织、前向也逐层算,所以不必等全部拷完——init_load_backhiradix_cache.py:1358)收集 evicted 祖先 → controller.loadload_stream 逐层 load_to_device_per_layer,每完成第 i 层就推进 LayerDoneCounter,计算流算第 i 层 attention 前 wait 到 i 即可。H2D 与前几层计算流水线重叠,回载延迟几乎被藏掉。

  • 全有或全无:不足 load_back_threshold(10 token)不搬——搬运固定开销比重算还贵;超 mem_quota 放弃——不为回载把别人的热数据逐出去;
  • 失败先 evict() 腾 device 空间再重试一次,仍不行就重算。

L2 → L3 备份(backup)

触发:卡片 ① 那条链的自然延续——L1→L2 的 write ack 收尾时顺手触发 write_backup_storage。注意介质换了:D2H 是 GPU DMA 走 stream,写 L3 是网络/磁盘 IO,改由后台 Python 线程承担。

链路StorageOperationbackup_queuebackup_threadSTORAGE_BATCH_SIZE 分批 page_setack_backup_queue。期间 node.protect_host() 把 host 页钉住——写到一半源页被逐出,落到存储的就是垃圾。MLA 模型只有 tp_rank 0 写 L3:latent KV 在各 rank 完全相同,不去重会写 N 份一样的数据。

L3 → L2 预取(prefetch,唯一可撤销)

触发时机最特别:请求刚入 waiting queue 就发起scheduler.py:2427 _prefetch_kvcache),不等被调度——用排队的「死时间」掩盖 L3 的网络延迟,轮到组 batch 时数据最好已在 L2。

链路prefetch_from_storage 分配 host 内存、登记 ongoing_prefetch[rid] → 后台线程先 batch_exists 问存储「这个前缀你存了多长」,命中不足阈值直接 revoke(连下载都不开始);足够才分批 page_get每批产一个 PrefetchAck(completed_tokens)。线程流水线见下页。

它与前三条的本质区别:预取是投机——请求可能被 abort、命中可能不足、排队时间可能不够,所以全程支持中途放弃。而这些每批一个的 ack,正是第 16 页 MIN 投票的「选票」、第 20 页 abort 后「空转也要发够」的那个东西。

write_back 模式是 write-through 的反面:平时命中再多也不备份,L1 逐出时才被迫 write_backup(write_back=True)阻塞等 DMA 完成再逐出(hiradix_cache.py:1129)——省掉平时的备份带宽,代价是逐出路径变慢。check_hicache_eventshiradix_cache.py:1459)是本页最重要的一处:四条通路的 ack 回收全集中在每个调度步开头这一次轮询——每步收几张账取决于本地 IO 完成了多少,而这在 PP 各 rank 上天然不同。这里就是第 9–10 页发散源 ① 与 #27285 修复的案发现场。

本页引用 — PR / issue:#27285 | 源码:hiradix_cache.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 04

搬运引擎 HiCacheController:两类引擎、4 条后台线程

一个 Controller 管全部三层搬运,但分两类引擎L1↔L2GPU 的 DMA 引擎——write_stream / load_stream 两条专用 CUDA stream(cache_controller.py:290),发射后由硬件异步拷贝,不需要任何 Python 线程,调度线程只负责发射 + query() ack 回收;L2↔L3 是网络/磁盘 IO,调用会阻塞,所以配了 4 条后台 Python 线程(下图 ①–④,仅在配置了 storage backend 时启动,cache_controller.py:373-382)。所以「4 条线程」全是 L3 的事,别在 L1↔L2 那边找线程。
Scheduler 线程(每个调度步) check_hicache_events() → writing_check / loading_check / drain_storage_control_queues 预取链(L3 → L2) prefetch_queue ① prefetch_thread batch_exists + MIN 汇聚 不足阈值 → revoke prefetch_buffer ② prefetch_io_aux 分批 page_get 下载 每批产 1 个 ack prefetch_sync_queue PrefetchAck ③ prefetch_sync_thread completed_tokens 跨 TP/PP all_reduce(MIN)(gloo) ack_prefetch_queue → 调度线程消费 prefetch_revoke_queue 命中不足 / 撤销 备份链(L2 → L3) backup_queue ④ backup_thread _page_backup 分批 page_set ack_backup_queue 只能本地排水 L1↔L2 DMA:无线程,硬件干活 write_queue + ack_write_queue load_queue + ack_load_queue 后台线程(storage_stop_event 统一停止) 队列 调度线程轮询收口(check_hicache_events)
图 2 · 预取链占三条线程(①②③)、备份链一条(④)——4 条后台线程全部属于 L2↔L3。关键点:PrefetchAck 不直接回调度线程,而是先经 ③ prefetch_sync_thread 做跨 TP/PP 的 all_reduce(MIN)——这正是 #27010 为 PP 兼容而引入的设计(详见第 15 页)。实线 = 数据/消息流动,虚线 = 调度线程每步的轮询收口。(cache_controller.py:365 起)

预取全流程(L3 → L2,逐步走一遍)

  • 步 1 · 提交(调度线程):请求入 waiting queue 时 prefetch_from_storage——分配 host 内存、登记 ongoing_prefetch[rid]、任务丢进 prefetch_queue立即返回
  • 步 2 · 探询(① prefetch_thread):batch_exists 问 L3「这个前缀你存了多长」,命中长度跨 rank MIN 汇聚;不足阈值 → 丢 prefetch_revoke_queue,整个预取撤销,到此为止
  • 步 3 · 下载(② prefetch_io_aux):任务经 prefetch_buffer 移交,分批 page_get 把 KV 页下载进 host;每下完一批prefetch_sync_queue 丢一个 PrefetchAck(completed_tokens=本地累计)
  • 步 4 · 投票(③ prefetch_sync_thread):把第 k 批 ack 与其他 rank 的第 k 批做 all_reduce(MIN)(gloo),共识进度写入 ack_prefetch_queue
  • 步 5 · ack 回收 / 结算(调度线程):每步 check_hicache_events 消费共识进度与 revoke;请求出队组批时终止预取,用最后一个共识值当 host 命中长度

步 3–5 的逐帧图解就是第 16 页;abort 打进来时步 3 不得提前退出(第 20 页)。

备份全流程(L2 → L3,只有三步)

  • 步 1 · 提交(调度线程):L1→L2 的 write ack 收尾时触发 write_backup_storage——StorageOperationbackup_queue,host 页 protect_host() 钉住防逐出。
  • 步 2 · 写出(④ backup_thread):按 STORAGE_BATCH_SIZE 分批 page_set 写进 L3 后端,完成凭证入 ack_backup_queue
  • 步 3 · ack 回收(调度线程):解除 protect、节点标记已入 L3。

备份链比预取链简单得多的原因:写 L3 是纯本地副作用,结果不影响任何调度决策,所以不需要跨 rank 投票——ack_backup_queue「只能本地排水」说的就是这个。预取则相反:它的结果(命中多长)直接进组批决策,必须走共识。

本页引用 — PR / issue:#27010 | 源码:cache_controller.py(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 05

PP 事件循环:每个 rank 一个独立 Scheduler

PP rank 0 唯一从 ZMQ 收请求 get_new_batch_prefill 独立 Scheduler + radix 树 PP rank 1 独立 Scheduler + radix 树 PP rank 2 独立 Scheduler + radix 树 PP rank N−1(last) 产出 logits / 采样输出 采样结果沿输出环回传 独立 Scheduler + radix 树 recv_reqs 逐跳 relay(异步) proxy tensors(hidden states,逐 microbatch) 输出环链路(last → first):采样结果回 rank0 出口,沿途各 rank 提交状态
图 3 · PP 拓扑:一条环。请求沿链下发、hidden states 逐 microbatch 前传、结果由 last rank 沿输出环回传。没有任何全局 barrier——各 stage 天然错开一个 microbatch 的相位(这也是后面「不能用 collective 修复」的原因)。(scheduler_pp_mixin.py:257 event_loop_pp)
# event_loop_pp 主循环(scheduler_pp_mixin.py:257,简化)
while True:
    for mb_id in range(self.pp_loop_size):
        next_mb_id = (mb_id + 1) % self.pp_loop_size

        # ① 收请求:rank0 从 ZMQ,其余 rank 收上游 relay
        recv_reqs = self.recv_requests()
        self.process_input_requests(recv_reqs)

        # ② 请求转发给下一个 stage(异步 send)
        if not self.pp_group.is_last_rank:
            self._pp_send_pyobj_to_next_stage(recv_reqs)

        # ③ 独立组批(要害:每 rank 各自决策)
        self.mbs[mb_id] = self.get_next_batch_to_run()

        # ④ 收上游 hidden states(非首 rank,同步 recv)
        proxy = self._pp_recv_proxy_tensors()

        # ⑤ 发射本 stage 前向(GPU 上异步执行)
        result = self._pp_launch_batch(mb_id, proxy)

        # ⑥ 输出环(三合一):等上一轮转发落地;把上一轮收到的
        #    self.pp_outputs 转发给下游(last rank 注入自己的采样结果)
        #    再从上游收本轮到站的那批结果
        outputs = self._pp_commit_send_output_work(self.pp_outputs)

        # ⑦ 给刚到站的那批做状态提交(与 ⑤ 的 GPU 计算重叠)
        self._pp_process_batch_result(
            self.mbs[next_mb_id], outputs)

        # ⑧ hidden states 发给下游(异步 send)
        if not self.pp_group.is_last_rank:
            self._pp_send_dict_to_next_stage(result.proxy_tensors)

        # ⑨ 暂存本轮收到的结果,下一轮 ⑥ 转发给下游(存一拍再转发)
        self.pp_outputs = outputs

各阶段在做什么

  • ①② 请求流:只有 rank0 有对外的 ZMQ socket,其余 rank 的「收请求」收的是上游 relay 来的同一序列——保证全员看到相同的请求流(第 7 页同步点 ①)。
  • ③ 独立组批:每个 rank 各自跑 match_prefix、各自分配内存、各自组 batch,互相不通信——正确性全靠「输入相同 + 决策函数确定 ⇒ 结果相同」这条契约。
  • ④⑤⑧ hidden 接力:非首 rank 拿上游算完的 hidden states 当本层输入(同步 recv,等不到就停),本 stage 算完异步发给下游——这是 PP 的主数据流
  • ⑥⑦⑨ 输出环与状态提交:采样只发生在 last rank;结果的传播和请求 relay 一样是存一拍再转发——每个 rank 本轮 ⑥ 收到、⑦ 提交(追加 token 进本 rank 的 Req、更新 radix 树、判定 finish)、⑨ 暂存,下一轮 ⑥ 才转发给下家,每跳晚一拍、与相位差同构。last rank 是环的起点也是终点:它注入自己的采样结果,等环绕一圈收回来才做自己的状态提交——全员状态提交时序同构。

关于 ①–⑨ 这套编号:代码 docstring 的 Unified Schedule 列的是 8 个动作,并注明「顺序可被重排以减少通信停顿与气泡」(scheduler_pp_mixin.py:257)。本片为便于跨页对照,按实际执行顺序把「⑨ 暂存」单列、编号到 ⑨——编号是讲解用的归纳,不是代码里的固定阶段划分;真正固定的只有三处阻塞 recv(①④⑥)构成的偏序。

「交还 tokenizer」是什么意思:五组件环路(第 21 页)里,Scheduler 对外的 ZMQ socket 只在 rank0 上真正创建ipc_channels.py:36),而采样发生在 last rank——所以生成的 token 必须沿输出环链路送回 rank0,由 rank0 推给 Detokenizer → TokenizerManager → SSE 流式返回客户端。输出环因此承担两个职责:rank0 是全 pipeline 唯一的对外出口,同时沿途每个 rank 借同一份结果完成本地状态提交(阶段 ⑦)。
⑥ 内部其实是三个动作(以 rank1 第 i 轮为例,按执行顺序): (a) commit 回收——阻塞等「第 i−1 轮发给 rank2 的那次 send」真正被收走。为什么需要:(b) 是 isend,发射即返回、不等对方收;不在下轮开头 commit 回收,未落地的发送会无限堆积(#28916 那个 12 GB/h 泄漏的同款模式),而 commit 回收本身就是天然的反向限速——下游收得慢,本 rank 就被卡住减速。 (b) send 转发——把 ⑨ 暂存的「第 i−1 轮从 rank0 收到的结果」异步发给 rank2(last rank 在此注入自己新采样的结果)。 (c) recv 接收——阻塞等 rank0 送来本轮的结果,交给 ⑦ 提交、⑨ 暂存。 收发都只经手「隔一轮」的货——这就是逐跳延迟一拍的 relay每跳恰好慢一拍的机械原因。send 排在 recv 前还有个讲究:若全员先 recv,环上人人等上家、直接死锁;先 send 后 recv(个别后端配奇偶交错)保证环永远转得动。
要害在 ③:一旦某 rank 的 radix 树多/少一个节点,prefix_indices 长度就不同 → extend_num_tokens 不同 → 阶段 ④ 收到的 proxy tensor 形状对不上——crash 或假死。Part 2 的所有问题都从这里长出来。

本页引用 — PR / issue:#28916 | 源码:ipc_channels.py · scheduler_pp_mixin.py(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 06

microbatch 流水 × PP4:两次完整迭代

把上一页的循环体各步放进 4 个 rank 的流水里跑两轮——看清谁在等谁、数据往哪流、相位差怎么形成。

收请求 转发 组批 等上游 前向 输出环 提交 发下游 暂存 iter i:发射 mb0,⑦ 提交 mb1(三轮前发射的旧批) iter i+1:发射 mb1,提交 mb2 PP0 PP1 PP2 PP3 requests ②→① hidden ⑧→④ 输出 ⑥→⑥ ⑨ 暂存 → 下一轮 ⑥ 转发(send 的内容来自上一轮) 环路闭合:PP3 新采样结果注入环,逐跳绕一圈;PP0 = 唯一 ZMQ 出口 = 阻塞同步点(recv 等不到就停——相位差由此自然形成,无需 barrier) ③ 内含 check_hicache_events:pp_sync 共识(#27285)/ 读 MIN 共识(#27010) 请求 relay ②→① hidden ⑧→④ 输出环 ⑥→⑥ ⑨→下一轮⑥(rank 内暂存转发)
图 4 · PP4 的 microbatch 流水:两次完整迭代。横向按迭代对齐、并非严格时间比例——跨 rank 的先后由三处阻塞 recv 保证(① 收 relay、④ 收 hidden、⑥ 收输出环),相位差由此自然形成,全程没有任何 barrier。两次迭代的动作完全相同,只是槽位轮转:iter i 发射 mb0、⑦ 提交槽位 mb1 里三轮前发射的旧批;iter i+1 发射 mb1、提交 mb2——同一时刻 4 个 microbatch 在飞。对齐的全部是「决策的输入」(请求序列、共识计数、采样结果),执行时刻从不对齐;一旦某个决策读了未对齐的本地状态,契约即破(第 9 页)。

缓存层动作在循环体各步中的落点(格子底部色条:L1 琥珀 / L2 青绿 / L3 靛蓝): 请求一入队就发起 L3→L2 预取——用排队时间掩盖下载(第 4 页卡片 ④);全部缓存决策的收口——check_hicache_eventsL1L2 DMA 账(pp_sync 共识)、match_prefix 查 L1 树 + host 树、发起 L2→L1 回载与 L1→L2 备份、L3 预取结算取 MIN 共识值; 前向期间 L2→L1 逐层 H2D「边载边算」; 状态提交把完成请求的 KV 插入 L1 radix 树(命中攒够再触发 write-through 下沉)。L2L3 的实际搬运始终在后台线程(第 5 页 ①–④),与循环体各步并行、只在 ③ 被读到共识结果。这四步各自改了 KV cache 的什么,下一页逐项拆开。

步骤PP0(首)PP1 / PP2(中间)PP3(末)
① 收请求 ★从 ZMQ 排空积压(唯一入口)阻塞收上游 relay 的同一列表同中间 rank
② 转发请求异步发给 PP1异步发给下一跳(原样转发)不转发(链到头)
③ 独立组批三者相同:check_hicache_events(drain + pp_sync 共识 #27285 + 读 prefetch 的 MIN 共识 #27010)→ match_prefix → 组批。互不通信,全靠「决策输入一致」
④ 收 hidden ★无上游,跳过阻塞等上一 stage 的 proxy tensor同中间 rank
⑤ 前向算首段层算中段层算末段层 + 采样 next_token_ids
⑥ 输出环 send+recv ★
_pp_commit_send_output_work
转发 ⑨ 暂存的结果给 PP1;收 PP3 绕回的注入转发暂存的;收上一跳的注入自己新采样的结果给 PP0;收 PP2 转来的旧结果
⑦ 提交
_pp_process_batch_result
状态提交 + stream_output 推给 Detokenizer(唯一出口)状态提交(追加 token / 判 finish / 入本 rank 的树)状态提交——用绕回来的结果,不用本地刚采样的
⑧ 发 hidden异步发给下一跳同左不发(没有下游)
⑨ 暂存三者相同:self.pp_outputs = outputs——本轮 ⑥ 收到的结果存一拍,下一轮 ⑥ 转发给下家(逐跳延迟一拍的 relay)

★ = 阻塞同步点(等不到就停)。与循环体各步并行的还有后台 gloo 平面(第 5 页线程 ①–④):PrefetchAck 逐批投票 all_reduce(MIN),只阻塞后台线程;③ 组批读到的永远是已出炉的共识值(逐帧见第 16 页)。

本页引用 — PR / issue:#27010 · #27285(行号基于 2026-08 基线,链接指向 main)

Part 1 · 背景机制 · 07

①③⑤⑦ 四步各自怎么改 KV cache

沿一个请求把 microbatch 流水里真正碰缓存的四步拆开:每步改哪个存储对象、改的是「账」还是「字节」。分工一句话——① 在 L2 投机预热,③ 改元数据,⑤ 写字节,⑦ 移交所有权。

① 收请求投机预热 L2 ③ 组批全改元数据 ⑤ 前向字节落地 ⑦ 提交所有权所有权移交 L1 radix 树 元数据:树形 / 标记 match_prefix 部分命中→分裂节点 收 ack:标 backuped / loading 毕 evict:砍 lock_ref==0 子树 insert:committed 段挂上树 _inc_hit_count 攒备份阈值 → 达标即提交 write_backup L1 KV pool KV 字节(HBM slot) alloc:extend N 行 / decode 1 行 load_back 占 slot(字节 ⑤ 才到) 不够先 evict 腾出空间,再不够 OOM set_kv_buffer:新 K/V 逐层落 slot load_stream:H2D 逐层落回载段 (LayerDoneCounter 边载边算) free:与树重复的段还 allocator free:spec 超额段 [committed, allocated) req_to_token · 锁 每请求映射 / lock_ref 占 req slot,写 token→KV 映射 inc_lock_ref:前缀路径锁 protected dec_lock_ref / chunk 间锁移交 finish:归还 req slot L2 host 池 + 树 pinned 内存 prefetch:alloc host 页(暂不挂树) 后台 page_get 把 L3 字节写进来 revoke / abort 可整段退回 结算(MIN 共识):已到段挂 host 树 超出共识的尾段 host 页退回 write_stream D2H:备份字节落 host (读 L1 前缀,不改 L1)
图 4b · 四步 × 四个存储对象——谁改账、谁写字节。彩色格 = 有 KV 字节被写入(琥珀 = 写进 L1 显存,青绿 = 写进 L2 主机内存),灰格 = 只改元数据。一眼看出分工:字节只在 ① 的后台下载与 ⑤ 的三条流里移动;③ 和 ⑦ 全是元数据操作——③ 决定「谁占哪个 slot、谁被逐出」,⑦ 决定「谁归树所有」。正因为 ③⑦ 只是账,它们才必须全 rank 逐字一致(第 9 页)。

收请求:只碰 L2 的「池账」,树上无痕

process_input_requests 构造 Req 入 waiting_queue,本身不碰任何缓存;真正动手的是入队瞬间的 _prefetch_kvcachescheduler.py:2427):从 HostKVCache 池 alloc 一段 host 页、登记 ongoing_prefetch[rid],随即返回。

排队期间,后台线程(第 5 页 ①②)分批 page_get 把 L3 字节持续写进这段页——但页尚未挂上 host 树,别的请求看不见;revoke / abort 可整段退回(第 20 页)。L1 在本步一个字节、一个节点都不动。

组批:全部元数据修改的收口,固定顺序三小步

  • ack 回收与状态提交check_hicache_eventshiradix_cache.py:1459):write ack → 节点标 backuped(host_value 生效,顺手触发 L2→L3);load ack → 清 loading 标记;L3 预取按 MIN 共识值结算——已到前缀挂上 host 树、尾段 host 页退回。
  • 匹配 + 回载match_prefix 部分命中会分裂 TreeNode——查询也改树形;命中段若 device 副本已被逐出,init_load_backhiradix_cache.py:1358)alloc device slot 并提交逐层 H2D。
  • 分配 + 上锁alloc_for_extend):req_to_token_pool 占 slot、alloc_token_slots 给新 token 留位——不够先 evict()(L1 唯一的删除入口,LRU 剪除 lock_ref==0 子树,common.py:278);inc_lock_ref 把前缀整条路径锁成 protected。decode 侧 prepare_for_decode 每请求 +1 slot、kv_committed_len += 1
  • chunk 尺寸(PP + --enable-dynamic-chunking 时):续 chunk 的 token 数也在本步定——用全员同构的延迟模型解出,决策输入是启动时广播的画像数据、不是各 rank 自测的耗时(第 18 页)。

三小步的走查示意见本页下方图 4c。

前向:唯一大规模写 KV 字节的地方,三条流并发

  • 计算流:每层 attention 算出新 K/V,当场 set_kv_buffer 写进 ③ 分好的 out_cache_loc——extend 一次写 N 行、decode 写 1 行(memory_pool.py:1052)。
  • load 流load_to_device_per_layer 逐层 H2D 落回载字节,拷完第 i 层推进 LayerDoneCounter,计算流等到 i 才算第 i 层——「边载边算」(第 4 页卡片 ②)。
  • write 流:此前提交的 L1→L2 备份在 write_stream 上做 D2H——只读 device、写 host,不占计算流。

三条流并发碰同一个 pool 而不打架,靠的是 slot 所有权在 ③ 已划清:计算流只写本批的 out_cache_loc,load 流只写重新占位的 slot,write 流只读被锁住的前缀。

状态提交:字节零拷贝,改的是所有权

  • finishcache_finished_reqradix_cache.py:442):insert 把 committed 段挂上 L1 树;树里已有的重复段立刻 free 还给 allocator(去重);dec_lock_ref 解锁前缀;归还 req_to_token slot;spec 超额段一并释放(第 22 页双指针)。
  • chunk 间歇cache_unfinished_req:已算部分入树、映射重指向树上 slot、锁移交更深节点(第 22 页)。
  • 下沉起点:insert 途中 _inc_hit_count 攒够阈值 → write_backup 提交 D2H——⑦ 是 L1→L2→L3 整条下沉链的第一推动(第 4 页卡片 ①)。

效果:这段 KV 从「请求私有」(protected)所有权移交为「树上公共」(evictable)——从此可被共享,也可被牺牲(三类计量见第 23 页)。

3.1 ack 回收与状态提交:先把全员元数据推到同一快照 ack_write_queue 本地 query():2 张已完成 PrefetchAck 本地已下载 3 页 共识裁剪 pp_sync / MIN write:共识 = 1 → 只落 1 张,A 标 backuped ✓ (另 1 张留队,下轮再收) prefetch:共识 = 2 页 → C 段挂 host 树 2 页 (本地多下的第 3 页退回 root A B C E E:lock_ref=0 的旁支(3.3 的牺牲者) 3.2 匹配 + 回载:查树、分裂、占位——不搬字节 请求 R 的 prompt(12 页)对照 3.1 之后的树: A · 4 页 B · 4 页 C₁ · 2 新 · 2 页 A:还在显存 | B、C₁:只剩 host 副本 | C 部分命中 → 分裂 C₁|C₂(查询也改树形) init_load_back 给 B、C₁ 占 device slot 提交逐层 H2D—— 字节 ⑤ 才到(边载边算) 本步输出 prefix = A+B+C₁ (10 页,B/C₁ 在途) extend = 2 页 预算先行:total < available + evictable 才准入;回载超 mem_quota / 分配失败 → 沿 parent 回退,弃载改算 3.3 分配 + 上锁:把决策物化成锁与 slot inc_lock_ref:root→A→B→C₁ 全路径 +1 → protected A B C₁ evict 从此不可触碰这条链 E ✗ evict 旁支 E 被剪除 → slot 还 allocator req_to_token[R]: A B C₁ ▢▢ = out_cache_loc(新 2 页)→ ⑤ 写字节 本步输出:extend_lens、slot 编号、锁状态——全 rank 相同,④ 的 proxy tensor 行数由此对齐 三步在每个 rank 独立执行;因为所有输入——共识计数(3.1)、请求流与树快照(3.2)、确定性分配(3.3)——都已对齐,每个 rank 画出来的这三格一模一样。
图 4c · 组批三小步走一遍(示意:请求 R,prompt 12 页;页数为虚构)。3.1 本地读数(2 张 write ack、3 页预取)一律先过共识裁剪(1 张、2 页)才允许落账——多出的部分留队或退回,快的 rank 不得提前推进;3.2 拿对齐后的树做匹配:A 还在显存、B+C₁ 只剩 host 副本,部分命中把 C 分裂成 C₁|C₂,再为回载占位(字节 ⑤ 才到);3.3 把整条前缀路径锁成 protected、砍掉无锁旁支 E 腾出空间,写好 req_to_token 映射并拿到 out_cache_loc。三格的每一步都只依赖已对齐的输入——这就是「独立组批却全员一致」的机械原理。
PP 视角收束:四步在每个 rank 各自发生——每 rank 一份 pool 分片、一棵私有树。⑤ 写的字节本就 per-rank 不同(各 rank 存各自层的 K/V),天生无需对齐;要命的是 ①③⑦ 的元数据必须逐步同构——③ 的分裂 / 逐出 / 分配、⑦ 的插入,任何一步因本地 IO 快慢读了不同输入,prefix_indices 长度就分家、④ 的 proxy tensor 形状就对不上。这正是下一页开始的整个故事。

本页引用 — 源码:common.py · hiradix_cache.py · memory_pool.py · radix_cache.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)

Part 2 · 问题 · 08

核心矛盾:异步缓存 × 确定性契约

收请求 转发 组批 ④–⑨ iter i · slot mb0 R 到达入队,① 发起预取;③ 组的是别的请求 iter i+1 · slot mb1 R 仍在排队;③ 依旧没轮到 R iter i+2 · slot mb0 复用 R 出队 → 组进 slot mb0 PP0 PP0 的 L3→L2 预取下载(从 iter i 的 ① 发起) t₁ 完成 → 插树(896 tok) t_d:组批时下载条已到头 ✓ → hit = 896 R 经 relay 下发(同一序列) PP1 PP1 的同一个预取(更慢,仍在下载) t₂ 才完成 t_d:组批时切在下载条中间 ✗ → hit = 0 同一逻辑步:都在第 i+2 步 把 R 出队、组进 slot mb0 R 在 slot mb0 里的 extend token 数:PP0 视角 = len(R) − 896,PP1 视角 = len(R) − 0 ④ 传给下游的是 hidden states(每个 extend token 一行)→ 行数与 PP1 自己的预期不符 → shape mismatch(第 11 页) slot 对应关系:mb_id = iter % pp_loop_size。本图 pp_size = 2 → 槽位 0 / 1 交替复用(第 7 页的 PP4 则是 0–3 轮转)。 ★ = 阻塞同步点;④–⑨ = 流水其余步骤——故障在 ③ 形成,在 ④ 暴露。每轮 ③ 都在组批,只是前两轮组的是别的请求。
图 5 · 把故障放回流水(两 rank × 三次迭代 × slot)。R 在 iter i 到达:PP0 从 ZMQ 收到(①)、经 ② relay 给 PP1,两 rank 各自在 ① 发起同一个 L3→L2 预取(每条泳道下方的进度条)。iter i 与 i+1 的 ③ 照常在组批,只是组的是别的请求——R 一直排队。iter i+2 两个 rank 在同一逻辑步把 R 出队、组进复用的 slot mb0;此刻 t_d 落在 PP0 下载条结束之后(✓ hit=896)、却切在 PP1 下载条中间(✗ hit=0)。于是 R 的 extend token 数两边不同,而 ④ 下发的 hidden states 每个 extend token 占一行——行数对不上,shape mismatch。修复只能「把决策对齐」(第 16 页 MIN 投票),不能「把 IO 对齐」。
预答一个必然被问的问题——层数相同,凭什么 PP1 更慢?先注意一个抵消:PP1 起跑晚一个相位 φ(relay 晚一跳),但它的 t_d 也晚同一个 φ——若两边下载耗时相同,竞态结果完全一样、不发散。所以发散的必要条件是耗时方差:① 各 rank 拉的是自己的层分片,走独立的 NIC/连接、落在不同存储节点——网络拥塞、重传、存储端冷热(内存命中 vs 下盘)全部独立抖动;② prefetch_io_aux 是 Python 线程,与调度线程抢 GIL,rank0 还多干 ZMQ 杂务;③ 下载按 STORAGE_BATCH_SIZE 分批,毫秒级延迟差跨过批边界就被放大成一整批的进度差;④ host 分配快慢/成败 per-rank(#28429);⑤ 且「层数相同」常不成立——#29258 实测各 stage 每 token 字节数差 5.26%,下载量本就不等。t_d 是判定时刻:毫秒方差 × 每小时数千请求 × 数小时 uptime,总会有请求落进 [t₁, t₂] 窗口——这就是「运行数小时后才崩溃、缓存越大崩溃越频繁」的来源(第 11 页)。
L1 only ── 确定性成立 ✓ 两 rank 的输入相同:同一请求序列 同步 match / insert 调度线程内确定性执行,各 rank 步调相同 ——没有任何异步状态源 = rank0 的树 rank1 的树 步步同构——天然正确,无需任何同步 L1 + L2 ── 开始发散 ✗ 两 rank 的输入相同:同一请求序列 同步 match / insert 仍然确定性 异步 DMA ack(D2H / H2D) 完成时刻 per-rank 不同 → 每步 ack 消费数不同 rank0 的树 rank1 少一个节点 host 树形状漂移(节点有无 / 数目不同) L1 + L2 + L3 ── 发散更多 ✗✗ 两 rank 的输入相同:同一请求序列 同步 match / insert + 异步 DMA ack 前两栏的问题全部保留 异步存储查询 / 下载进度 / 分配成败 全部 per-rank → 连 anchor hash chain 都不同 rank0 的树 缺节点+数据不同 形状与内容都漂移(从 L3 取回不同数据)
图 5b · #22607 的分层论证:异步状态源逐层累加,确定性逐层崩塌。L1-only 时,决策的唯一状态来源是调度线程内的同步执行——输入相同就步步同构,根本不需要同步原语。加 L2 后,「异步 DMA 完成时刻」混进决策,每步 ack 消费数 per-rank 不同,host 树形状开始漂移(起初社区以为 L2 安全,后被 stepinto 推翻)。再加 L3,存储查询、下载进度、host 分配成败全是 per-rank 的,连 anchor hash chain 都算不一致——从存储取回的内容也发散。左栏对比色(/节点)表示同一位置存了不同数据。

用 microbatch 流水说同一件事:③ 与 ⑦ 是仅有的改树点

循环体里,树只在 ③ 组批(match / insert / 逐出 / drain ack)和 ⑦ 提交cache_finished_req 入树)被修改。⑦ 的输入永远来自 ⑥ 输出环的对齐结果——三种配置下都安全;三层的差别全在 ③ 读到了什么:

配置③ 的输入到失效的传导路径后果
L1 only 只有对齐通道:①② 送来的请求流 + ⑦ 留下的树(源头是 ⑥ 输出环) 无——③ 是「对齐输入 → 确定性输出」的纯函数 树步步同构 ✓
+ L2 混入第一路 per-rank 输入:本步就绪的 ack 数writing_check / loading_check drain,取决于 PCIe/DMA 进度) 两跳间接:ack 消费数不同 → backuped 集合不同 → 逐出选出不同牺牲者 → device 树不同 → 下一次 ③ 的 match 不同 → ④ proxy tensor 行数不符。潜伏深,社区最初误判安全(第 10 页六步链) 形状漂移 ✗
+ L3 再混入第二路:预取的本地下载进度 / 存储命中 / host 分配成败(网络级方差,比 PCIe 大几个量级) 一跳直达:结算直接把本地进度写成 host_hit_length当步 ③ 即偏离 → ④ shape mismatch(图 5)。另有 anchor hash chain 分叉 → 从存储取回不同内容 形状 + 内容漂移 ✗✗

这也解释了修复形态:#27285 / #27010 都不去对齐 IO,而是把 ③ 读到的per-rank 输入换成协议输入——ack 回收数由 PP0 裁决(pp_sync)、命中长度由全员投票(MIN)——③ 重新变回纯函数。

同一判据的代码版:修复 = 在「per-rank 读数」与「改状态」之间插入一次协议变换

L1 无需前后对比——match_prefix 全程纯内存树遍历,代码里没有任何 query() / 队列 / 时间戳参与决策,所以它没有「修复前」。L2 / L3 各看一处关键代码:

L2 · writing_check 的前后(#27285 系)

# 修复前(upstream pre-#27285 的形态)
while ack_write_queue:
    ack = ack_write_queue[0]
    if not ack.finish_event.query():  # ① 问本地进度:DMA 拷到哪了
        break
    ack_write_queue.pop(0)         # ② 答案直接决定 drain 数
    _finish_write_through_ack(ack) # ③ 消费 ack 改树 → 分歧
# 修复后(hiradix_cache.py:1006)
finish_count = 0                      # 先只数 ack,不消费
for _, ev, _ in ack_write_queue:
    if not ev.query(): break          # 本地照样问
    finish_count += 1
finish_count = self._sync_hicache_ack_finish_count(
    finish_count)
                                      # ← 新增:TP-MIN + PP 节拍
while finish_count > 0:               # 按协议数精确消费
    ack_write_queue.pop(0); ...

query() 那行还在——物理进度无法对齐也不必对齐;插入的是高亮那一道协议变换。两个细节:PP 方向只传节拍、不沿用 PP0 计数(各 stage DMA 队列深度不同,沿用 → 形态 B pop from empty list);同步必须无条件执行(条件跳过 → NCCL 序错位死锁,#26923)。

L3 · 预取结算的前后(#27010 系)

# 修复前(pre-#27010):结算直接读本地进度
completed = operation.completed_tokens   # 纯本地读数
matched = min(host_hit_len, completed)   # 一跳进命中长度
                                         # → 896 vs 0(图 5)
# 修复后(hiradix_cache.py:1543)——两道协议
# 道1·进度值:每批 ack 先过 prefetch_sync_thread 的
#   all_reduce(MIN) 才推进 completed_tokens(第 16 页)
should_terminate = False
if self.pp_rank == 0:                 # 道2·只有 PP0 有权判断
    should_terminate = op.is_terminated() or \
        self.can_terminate_prefetch(op)
self._all_reduce(t, ReduceOp.MAX)     # 裁决广播
if t.item() != 1: return False
terminate_prefetch(op)                # 全员同刻终止
handle_prefetch_result(op)            # 用共识进度结算

completed_tokens 连名字都没变,但它的取值来源(本地累计 → MIN 共识)和读取时机(各自随时读 → PP0 裁决、全员同刻)都被协议接管——L3 的 per-rank 读数本来一跳直达命中长度,所以要包两道。

本页引用 — PR / issue:#22607 · #26923 · #27010 · #27285 · #28429 · #29258 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)

Part 2 · 问题 · 09

四类发散源(umbrella issue #22607 的归纳)

先把 L1+L2 的完整因果链走一遍(不需要 L3 即可复现崩溃): ⑴ 备份异步——write_backup 的 D2H 由 GPU DMA 引擎在 write_stream 上执行,调度线程只能 query() 问进度; ⑵ 每步 ack 消费数是 per-rank 量——writing_check 数「本步几个 ack 就绪」(1 个 ack = 一批 D2H 拷贝的完成凭证),答案取决于 PCIe 争用/数据量/流抢占,per-rank 抖动,第 N 步 PP0 drain 3 个、PP1 drain 1 个; ⑶ 消费 ack 改树——node.backuped 置位、解锁副本,两边「已备份集合」开始不同; ⑷ 关键跳变:write-through 下 L1 逐出只许逐已备份节点,同一节点 PP0 眼里可逐、PP1 眼里不可逐 → 选了不同牺牲者 → device 树本身不同; ⑸ 下个请求 match_prefix 命中长度不同 → extend 数不同 → hidden states 行数不同 → 与图 5 相同的失效路径; ⑹ 级联放大(下方卡片 ④)。#27285 修的不是「账何时完成」(物理上无法对齐),而是「何时落账」——把落账从 per-rank 量变成协议量(PP0 裁决)。

异步完成时序

D2H/H2D DMA 与 L3 下载各 rank 独立完成。谁先完成谁先改树 → 同一时刻 host_hit_length 896 vs 0(上页图)。L2 场景即 writing_check / loading_check 每轮处理的完成事件数不同,偏差随时间累积(stepinto, 2026-06-04)。

Anchor / hash chain 发散

L3 查询的 hash chain 从 host 树的 anchor 节点起算。PP0 已插入上次预取的节点而 PP1 没有 → 对同一请求算出完全不同的 hash chain,从存储取回不同的数据——不止形状错,内容也错。

LRU 时钟(time.monotonic)

last_access_time = time.monotonic() 在各 rank 有微秒差 → 选出不同的逐出牺牲者。生产案例(PP2+NSA+PD):PP0 插入节点后立刻因内存压力逐出,PP1 晚一拍 replay 时无压力、节点保留 → matched_host 0 vs 17664 → crash。长 uptime 下 float 精度还会退化(#30158)。

级联放大

host 树只要差一个节点,后续所有逐出选择、write-through 判定、prefetch anchor 都基于错的状态继续决策 → 差异持续叠加放大#22607 原文措辞),直到某次 batch 形状对不上而崩溃。因此症状常表现为「运行数小时后才崩溃」「缓存越大越频繁」。

再叠加一个横向维度(issue #28429):per-rank 物理内存成败——write_backup 只在 host 分配成功的 rank 入队(失败 rank 静默 return 0),load_back 只在 device 分配成功的 rank 生效。同一逻辑决策在不同 rank 落地成不同动作,TP-only 也会因此死锁(不限于 PP)。

本页引用 — PR / issue:#22607 · #27285 · #28429 · #30158(行号基于 2026-08 基线,链接指向 main)

Part 2 · 问题 · 10

崩溃形态:从报错反推发散

形态 A · shape mismatch

RuntimeError: shape '[3013, -1, 128]'
is invalid for input of size 8192000

KV 读取长度与 batch 形状不匹配——上游 stage 与下游 stage 对「本 batch 有多少 extend token」判断不一致。复现:Qwen3-32B --pp-size 2 --enable-hierarchical-cache + 1000 prompts 压测必现(#27285)。

形态 B · 队列错位

IndexError: pop from empty list

下游 rank 被要求消费「rank0 认为存在」的完成事件,但本地 DMA 队列更短——各 PP stage 有独立的 DMA 队列深度,沿用 rank0 的计数将导致弹空(hiradix_cache.py:247 docstring 直接点名此 bug)。

形态 C · NCCL 假死

WorkNCCL Timeout(ms=600000)
watchdog caught collective ...

某 rank 的调度线程阻塞在永远等不到的队列排水上,不再发 p2p recv → 上游的 SEND 挂 600s 后被 watchdog 终止。极易被误判为网络故障

现场特征(来自真实工单)

  • 空载亦崩溃#30158,GLM-5.2 FP8,PP2+TP4+mooncake):空闲循环也在做事件同步与逐出。
  • --hicache-size 越大崩溃越频繁:80 GB 约一天一次,170 GB 一夜 4–5 次——逐出压力与发散频率正相关(发散源 ③)。
  • 只有 PP0 泄漏内存#28902):恒定 ~12 GB/h,被 OOM-kill 6 次,PP1 稳定,空闲也漏——指向 rank0 独有的发送路径(第 14 页揭晓)。

诊断路径:从现象反推根因(线上排障顺序)

步 1先分流:死了还是卡了

有 Python traceback → crash(形态 A/B,走步 2);无 traceback、GPU 利用率归零、约 600 s 后 watchdog 报 NCCL timeout → hang(形态 C,走步 3)。两条路的根因不同,别混着查。

步 2crash:看异常类型定位维度

shape ... is invalid → 各 rank 对本 batch extend token 数不一致(③ 组批读了未对齐的输入);pop from empty listack 计数错位(沿用了其他 rank 的 drain 数)。前者查 extend_num_tokens,后者查各 rank 队列深度。

步 3hang:先排除「网络故障」误判

本 bug 的特征是一个 rank 卡在调度线程、其余 rank 卡在等它的 p2p。用 py-spy dump 逐 rank 查看调用栈:若某 rank 停在 check_hicache_events / _all_reduce / 队列 get,而其他 rank 停在 NCCL recv——是本 bug,不是网络。(此为建议手段,非社区文档)

步 4用开关二分,确认是发散

不配 storage backend 仍崩溃 → L2 维发散(#27285 系列,确认版本已含修复);--disable-radix-cache 后不再崩溃 → 确认由缓存引入;调小 --hicache-size 崩溃频率随之下降 → 逐出压力相关(发散源 ③)。

为什么值得先花三分钟做这个分流:这族 bug 的三种表象分别指向不同的失效维度——形态 A 是 PP 维的慢性发散(可能已运行数小时后才触发),形态 B 是计数协议错位,形态 C 是 collective 序列错位(TP 维往往立即死)。三者的修复面各不相同(第 12 页修复地图),而形态 C 若被误判为网络抖动,会导致把时间花在换网卡、调 NCCL 超时上——真正的原因是某个 rank 永远等不到的队列排水。

本页引用 — PR / issue:#27285 · #28902 · #30158 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 11

修复地图:umbrella #22607 的推进路线

社区策略(2026-05-13 周会决议):L3 引入后 PP 打再多补丁仍易异常、且 L3 宕机时 PP 无 fallback——所以先保证 L2+PP 绝对正确,再攻 L3。下表按这条主线排序。
PR状态对齐的是什么一句话机制
#27285merged 06-06L2 事件消费pp_sync:PP0 决定本轮消费几个 DMA 完成事件,链式广播,全员消费同样数量
#28916merged 06-23pp_sync 自身#27285 引入的 PP0 内存泄漏:给 work_list 设硬上界、下游拖住上游
#29258merged 06-26L2 容量各 stage 层数不同 → host 容量差 5.26% → 对 host token 容量 ReduceOp.MIN
#31869merged 07-29PD 队列 + abortbootstrap/prealloc 以共识结果为唯一依据,消除「共识后二次 poll」的 abort 竞态窗口
#31443merged 07-21DSA/hybrid 前缀sidecar pool 命中页取「前导连续成功段」而非求和,防止空洞被当安全前缀
#29106merged 06-27SWA / layer 映射DeepSeek-V4 PP 下 SWA 分配与 layer 映射修正
#27010openL3 全链路 ★主修复:prefetch_sync_thread + 等量 ack 协议 + pp_sync 扩展到预取控制队列
#31425open内存分配决策write/load 拆 reserve→MIN 共识→commit/abort 两阶段事务,全体成或全体废
#22759openLRU 逐出逻辑 batch-step 时钟替换 time.monotonic(),根治发散源 ③
#25148open前缀长度pp_prefix_len_cap:bootstrap 时各 stage 前缀能力取 min 作为匹配上限
#33473open性能write/load 完成数合并为一次 pp_sync,32K 输入 prefill 吞吐 +37%(494K→677K tok/s)

另有已合入的外围修复:#20460(CP sync)、#25887#26923writing_check 的 all_reduce 改为无条件执行,修 NCCL 死锁)、#27366(HiMamba sidecar 预取 hang)等。

本页引用 — PR / issue:#20460 · #22607 · #22759 · #25148 · #25887 · #26923 · #27010 · #27285(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 12 · merged

#27285 · pp_sync:PP0 决策,链式广播

背景 · 症状与复现

Qwen3-32B --pp-size 2 --enable-hierarchical-cache + bench_serving --num-prompts 1000 压测稳定复现RuntimeError: shape '[3013, -1, 128]' is invalid(第 11 页形态 A)。只开 L2、不配任何 storage backend 也崩溃——证明发散不需要 L3 参与。

背景 · 根因定位

stepinto(2026-06-04,#22607):「pp0 和 pp1 的 writing_check / loading_check 处理的完成事件数不同,随时间越来越发散」——即第 10 页那条六步链:ack 回收数是 per-rank 量 → backuped 集合分叉 → 逐出选不同牺牲者 → 树漂移 → shape mismatch。

背景 · 立项

2026-05-13 周会决议:先保证 L2+PP 绝对正确,再攻 L3(L3 宕机时 PP 无 fallback)。于是从 L3 大 patch #27010 中把 L2 部分拆出为本 PR。作者 stepinto,2026-06-06 合入;改动横跨 hiradix_cache / unified_radix_cache / cache_controller / hybrid_cache,并新增 e2e KL 测试。

收请求 转发 组批 等上游 前向 输出环 提交 发下游 暂存 本 PR 只动 的第一件事:check_hicache_events 里的 ack 消费(drain); ④ 的 shape mismatch、⑤ 的错算都只是它的下游症状 修复前:每 rank 按自己的 per-rank 读数直接 drain ack PP0 query() → 3 个 ack 就绪 消费 3 个 · 改树 PP1 query() → 1 个 ack 就绪 消费 1 个 · 改树 backuped 集合分叉 → 逐出选不同牺牲者 → 树漂移 → ④ shape mismatch 修复后:读数先过协议——全员 drain 同一个数量的 ack query() 数就绪 ack 本地照读,不改状态 TP 组 MIN 最慢者获胜 PP0 决策者 isend PP1 recv 覆写 isend PP2 recv→isend 全员 drain 同 N 个 ack 点对点异步链,Work 挂 work_list、无 barrier——相位差保住 效果:ack 消费数从「per-rank 量」变成「协议量」,③ 恢复『对齐输入 → 确定性输出』的纯函数——④ 的形状校验自然通过,树同构 ✓(e2e KL 测试护航)
图 6 · #27285 在 microbatch 流水里的落点:只动 ③ 的 ack drain 入口。1 个 ack = ack_write_queue 的一个条目 = 一批 D2H 拷贝的完成凭证(finish_event + 覆盖的节点列表);drain 一个 ack = pop 出队并对其节点调 _finish_write_through_ack(标记 backuped、解锁、触发 L2→L3)——每 drain 一个就改一次树,所以数量必须全员一致。上:修复点定位——check_hicache_events 是 ③ 组批做的第一件事,④⑤ 的崩溃只是它的下游症状。中:修复前各 rank 按自己的 query() 读数直接 drain。下:修复后读数先过 TP 组 MIN,再由 pp_sync 链(recv 覆写 → isend 转发)统一——为什么不用 PP collective?各 stage 天然错相位,collective 强制对齐即死锁,链式 p2p 是唯一保相位的传播方式。后续演化(ack 计数改「本地 TP-MIN + 节拍」)见页底警示框。
解决思路——三条设计判断:不对齐 IO,只对齐「落账」:DMA 何时完成物理上不可控也不必控;「本步确认几张账」可以协议化。query() 保留,读数先过协议再改状态(第 9 页代码对比)。 ② 控制决策用 leader 裁决,不用全员投票:「消费几个事件」要的是一个统一的数,PP0 作为请求入口天然是 leader;数值上仍在 PP0 的 TP 组内取 MIN(TP 内最慢者获胜),PP 方向只做覆写下传。 ③ 传播用链式 p2p,不用 collective:各 stage 天然错相位,collective 强制对齐 = 气泡或死锁;isend 异步、不设 barrier,流水线保持满载。
# hiradix_cache.py(#27285 引入,行号为当前仓库)
def _all_reduce(self, data, tp_reduce_op):
    if self.pp_rank == 0:                # 只有 PP0 真 reduce
        self._all_reduce_attn_groups(data, tp_reduce_op)
    self._pp_sync(data)                  # 沿链下传

def _pp_sync(self, data):
    if self.pp_rank > 0:
        dist.recv(data, ..., tag=2)      # 覆写本地值
    if self.pp_rank + 1 < self.pp_size:
        w = dist.isend(data.clone(), ...)
        self.work_list.append(w)         # 异步,不 barrier
# writing_check / loading_check 改造后
finish_count = 数出本地已完成的 finish_event
self._all_reduce(finish_count, ReduceOp.MIN)
for _ in range(finish_count.item()):
    ack = ack_write_queue.pop(0)   # 全员精确消费
    ...                            # 同样数量 → 树变异序列一致

# 注意:ack 完成数用「本地 TP-MIN + PP 只传节拍」的变体
# (_sync_hicache_ack_finish_count)——各 stage DMA 队列深度
# 本就不同,沿用 PP0 计数会 IndexError: pop from empty list
落地与后续——同步原语自己也成了新的 bug 面:本 PR 合入后陆续暴露三件事。 ⑴ #28916_pp_sync 每轮 isend(data.clone()) 无上界、无人限速,下游落后时 clone 无界堆积 → PP0 独有 ~12 GB/h 泄漏(下一页); ⑵ #26923:这个同步必须无条件执行——按「本地有没有在途写」条件跳过会让各 rank 的集合通信序错位 → NCCL 死锁; ⑶ ack 计数变体:各 stage 的 DMA 队列深度本就不同,沿用 PP0 计数会弹空队列——后来改成「本地 TP-MIN + PP 只传节拍」(右侧代码注释、hiradix_cache.py:1006 现行形态)。 一个 PR 修掉一类发散,也教了社区一课:修复引入的每条通信路径,都要再按同样的标准审一遍

本页引用 — PR / issue:#22607 · #26923 · #27010 · #27285 · #28916 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 13 · merged

#28916 · 修 pp_sync 自己引入的泄漏:给 work_list 设硬上界

背景 · 症状特征(#28902

GLM-5.2 PD 分离、2 个 PP prefill 节点:只有 PP0 泄漏、恒定 ~12 GB/h、空闲也漏,被 OOM-kill 6 次;PP1 稳定。这组特征排除了常规怀疑对象——流量相关的泄漏会随负载波动,KV/缓存泄漏会全员一起漏。只有 rank0 + 恒速 + 无关流量 ⇒ 指向一条「每个调度步执行一次、且 rank0 独有」的代码路径。

背景 · 根因(上一页埋的雷)

_pp_sync 每轮 isend(data.clone()):clone 是 isend 快照所需;PP2 部署里只有 PP0 发送(PP1 是 last),且 isend 无人节流——PP1 的 recv 是阻塞的、天然被上游限速,PP0 想发多快发多快;空闲也发,因为同步必须无条件执行#26923)。旧回收是轮末非阻塞 reap——乐观 GC,发射速率 > 完成速率时永远追不上

背景 · 立项

恒定速率的算术解释:每轮固定的 clone + Work 句柄开销 × PP0 与 PP1 的循环速率差 = 常数斜率,与流量无关。作者 whybeyoung,2026-06-23 合入;同样的修复镜像到 UnifiedRadixCache。流水线定位:与 #27285 同址——③ 组批第一行的 check_hicache_events

诱发原因:速率差 ×(每轮固定开销)×(无界队列) PP0 · 发射侧(无人限速) 每格 = 一次 isend(clone) 每个调度步无条件发一次——空闲轮询也在发(#26923 要求无条件同步) PP1 · 消费侧(天然被限速) 每格 = 一次 recv 每轮被 GPU 前向 / 阻塞 recv 拖慢——频率远低于 PP0 的空转轮询 两者之差落进通道(work_list 里未被收走的 send + clone): 堆积量随时间线性增长 → 恒速 ~12 GB/h 时间 泄漏斜率 = 每轮固定开销 ×(发射频率 − 消费频率)→ 恒定直线:与流量无关、空闲也漏、只有发射侧的 PP0 漏 修复前:轮末非阻塞 reap(乐观回收) _reap_completed_async_work() 只回收「已完成」的 send——发射快过完成时永远追不上 work_list ↑ ∞ 回收是「尽力而为」,全程没有任何一处强制等待 修复后:轮首阻塞 drain(设硬上界) _drain_async_work() ← check_hicache_events 开头 wait() 等上一轮全部 isend 被收走才继续 → 恒 ≤ 一轮 下游消费慢 → PP0 被 wait() 钉在轮首 → 自动降速 = 反向限速:下游拖住上游,无需任何显式流控协议
图 7 · 诱发原因与修复对比。上:泄漏的三要素——PP0 每个调度步无条件发射(空闲轮询也在发,#26923 所致)、PP1 被 GPU/阻塞 recv 天然限速、二者之间的队列无界;堆积量因此随时间线性增长,斜率与流量无关——这解释了上述三条特征(恒速、空闲也漏、只有 PP0)。下:修复前的轮末非阻塞 reap 只回收已完成的 send、追不上发射;修复后把回收点挪到下一轮开头并阻塞等待,work_list 恒 ≤ 一轮,下游落后时自动拖住上游(反向限速)。
解决思路——问题不是「回收不勤快」,而是「生产者无界」:所以不去把 reap 做得更频繁,而是给发射加硬边界。两个判断: ① 挪位置比改逻辑更重要——drain 放在下一轮开头而非本轮结尾,isend 有整整一轮时间在后台完成,正常情况下轮首 wait() 的阻塞 ≈ 0,异步性没有牺牲; ② 卡住恰恰是想要的——下游消费慢 → PP0 被 wait() 钉在轮首 → 自动降速到下游的节奏。work_list 从此恒 ≤ 一轮的量,且不需要任何显式流控协议:阻塞语义本身就是限速器——下游拖住上游(反向限速)
# hiradix_cache.py:205(#28916 后的形态)
def _drain_async_work(self):
    for work in self.work_list:
        work.wait()              # 阻塞等上一轮所有 isend 落地
    self.work_list.clear()

def check_hicache_events(self):
    self._drain_async_work()     # ← 从「轮末 reap」改到「轮首 drain」
    self.writing_check(); self.loading_check(); ...
模式复用与方法论:「本轮异步发射、下轮开头阻塞 commit 回收」这个习语此后成了标配——第 6 页 ⑥ 输出环的动作 (a) commit 回收(_pp_commit_comm_work)用的是一模一样的手法。方法论上,本页是第 13 页那句话的实例:审计一条新通信路径,不只要问「它对不对」,还要问「它的在飞量有没有上界、谁来拖住它减速」——#27285 修好了确定性,却留下了一条无界的异步发送路径。

本页引用 — PR / issue:#26923 · #27285 · #28902 · #28916 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 14 · open · L3 主修复

#27010 · L3 全链路:三条通信通道 + 等量 ack 协议

读懂本页的钥匙是先看修复前的崩溃过程:L3 预取会产出两个进决策的数字——命中长度(存了多长)与下载进度(下到哪了)——两个都是 per-rank 量,却被直接读进了「必须全员一致」的 ③ 组批。

背景 · 症状(#30158 等)

GLM-5.2 FP8,PP2+TP4+mooncake L3:空载亦崩溃--hicache-size 越大崩溃越频繁(80 GB 约一天 1 次,170 GB 一夜 4–5 次);三种崩溃签名(第 11 页)轮番出现。官方口径(stepinto,2026-08-05):“PP + L3 is not supported yet.”

背景 · 根因为什么比 L2 难

两个数字里,命中长度一跳直达组批决策(第 9 页判据表);更棘手的是它们产生在三条后台线程里而非调度线程——#27285 那种「调度线程收口」管不到后台线程,读数流到 ③ 之前还有一段本地值未经对齐即被使用的窗口。#22607 的四类发散源(第 10 页)有三类挂在这条链上。

背景 · 立项

umbrella #22607 的终点站:周会决议先拆出 L2(#27285),剩余 L3 部分即本 PR(作者 stepinto,仍 open)。改动横跨 cache_controller / hiradix_cache / unified_radix_cache / mooncake_store 等六个模块、新建两个通信组。验证结果见本页下方「三问」。

修复前:③ 直接读本地 per-rank 读数 PP0 已下 896 / 1024 PP1 已下 300 / 1024 t_d:R 出队组批(同一逻辑步) ③ hit=896 → extend 128 行 ③ hit=300 → extend 724 行 ④ 128≠724 shape 不符 ✗ 两边跑的是同一份代码——分家仅仅因为 ③ 读了不同的本地值(第 9 页图 5 的同款现场) 另一类失效(形态 C):某 rank 分配失败 return 0 / 空队列 early-return 跳过一次 all_reduce → 与他人的 collective 错位 → 立即挂死(#28429 / #30760) 修复后:③ 只准读共识值(IO 照旧各快各的) PP0 本地仍是 896 PP1 本地仍是 300 all_reduce(MIN) 共识 hit = 300 ③ hit=300 → 724 行 ③ hit=300 → 724 行 ④ 行数处处一致 ✓ —— 代价:PP0 已下载的 596 token 命中被放弃(MIN = 最慢者获胜,用缓存收益买一致性)
图 8 · 修复前后:同一场景,只换了 ③ 读的数字。上半:PP0 网快已下 896、PP1 只下 300,③ 各读本地进度 → 同一请求两边 extend 行数不同 → ④ 的 proxy tensor「每 extend token 一行」对不上,崩溃;跳过 collective 的变体则立即挂死。下半:下载进度分毫未动,只是 ③ 之前多了一道 all_reduce(MIN)——全员按最慢者的 300 决策,行数处处一致。IO 从未被对齐,被对齐的只是决策输入
一句话读懂 #27010:把「每个 rank 读自己的本地 per-rank 读数」全部改成「先投票、用共识值」,且数据在哪条线程产生,投票就在哪条线程做。为此它新建两条投票通道(PG1 / PG2)+ 沿用一条控制通道(pp_sync),再用「等量 ack」保证投票永不错位——下图与三张卡片逐条拆开。
PP0(× TP ranks) PP1(× TP ranks) prefetch_thread 探询线程(第 5 页 ①) prefetch_thread 探询线程 PG1 · gloo · all_reduce(MIN) hit:1000 vs 800 → 全员 800(查询对齐) prefetch_sync_thread 投票线程(第 5 页 ③,本 PR 新增) prefetch_sync_thread 投票线程(新增) PG2 · gloo · all_reduce(MIN) 第 k 批进度:896 vs 300 → 300(第 16 页逐帧) Scheduler 线程 结算 / drain(③ 组批内) Scheduler 线程 recv 覆写(服从 PP0) pp_sync · PP0 裁决 → 链式覆写 terminate 取 MAX / drain:TP 内 MIN → PP0 下发 铁律 ①(P3 通信平面隔离):每条线程独立通信组——同一个 gloo communicator 被两个线程并发 all_reduce 会损坏 / 死锁, 所以 PG1、PG2 必须是两个组;后台走 gloo(CPU),与调度线程的 CUDA 集合通信彻底隔离。 铁律 ②(P4 协议守恒):三条通道全靠「第 k 票配对第 k 票」——等量 ack 协议要求失败 / 被 abort 的操作也发出 同样数量的 ack(右下代码、第 20 页软取消)。
图 8b · 三条互相隔离的通信通道——协议跟着数据下沉到线程层。哪个线程产生数据,哪个线程自己投票:数值类共识(命中长度 PG1、下载进度 PG2)用对称 MIN(最慢者获胜);控制类(何时终止、drain 多少)沿用 pp_sync 的 leader 裁决。L2 修复(#27285)只需在 ③ 里管 DMA ack,L3 必须把协议下沉到线程——这是两个 PR 结构不同的根本原因。三通道 + 等量 ack,合起来把第 9 页判据表里 L3 那行「一跳直达」的per-rank 输入全部换成协议输入。

通道 1PG1 · 命中长度投 MIN

探询线程各自问 L3「这个前缀你存了多长」。各 rank 拉的是自己的 KV 分片——走不同网卡、落不同存储节点,答案天然不同,查询失败还可能是 0。修复后:本地答案先在专用 gloo 组all_reduce(MIN),全员再拿共识值决定「下不下、下多长」。

为什么取 MIN:共识长度以内的数据每个 rank 都真实拿得到——MIN 是唯一全员兑现得起的值。

通道 2PG2 · 下载进度逐批投 MIN

下载线程每完成一批,产一个 PrefetchAck(completed_tokens=本地累计)。本 PR 新增一条投票线程 prefetch_sync_thread,只做一件事:拿本 rank 的第 k 个 ack 与其他 rank 的第 k 个做 all_reduce(MIN),把「全员都至少下到了多少」写进调度线程读的队列。

③ 组批读到的永远是共识进度——PP0 本地 896、PP1 本地 300,两边读到的都是 300;本地进度从不直接进决策(逐帧动画:第 16 页)。

通道 3pp_sync · 控制决策 leader 裁决

「何时终止预取」「本步 drain 几条消息」是控制类决策,不适合对称投票:终止用 all_reduce(MAX)——有一个 rank 说该停就全员停;drain 数量先 TP/CP 内 MIN、再取 PP0 的值沿 PP 链下发,其余 rank 服从。

数值类 MIN、控制类 leader——第 2 页的两种对齐原语在此并用。

三个常见疑问:① 为什么不等全员下载完再用?——那预取退化成同步 IO,「用排队时间掩盖下载」的意义全没了(第 4 页卡片 ④);② 为什么不像 #27285 那样集中在 ③ 修?——读数产生在后台线程,等流到 ③ 中间存在本地值未经对齐即被使用的窗口(如探询失败直接 revoke 的分支),协议必须跟着数据下沉到产生它的线程;③ 怎么证明修对了?——SWE-bench verified × DeepSeek-V4-Flash:基线 TP8+L3 解出 356/500,本 PR PP4+TP2+L3 解出 353/500,差异均为容器超时,正确性等价。
TP × PP 的两级同步,以及这个组合为什么会触发本页问题: TP 组内是硬锁步——同一 batch 同一步、每层都有 collective,契约是「集合通信序列逐条一致」,作为交换组内可以放心用 collective;PP 之间相位错开、只许 p2p。所以同步原语都是两级复合:先 TP 组内对称 all_reduce(MIN),再 _pp_sync 链式跨 stage。三条触发链: ① 物理状态在 TP 维也 per-rank——KV 是切片的,每个 TP rank 有自己的 host pool / DMA / L3 分片下载 / 分配成败,就绪时刻在两个维度同时发散(TP 内先做 MIN 不是仪式,是真有方差要吸收); ② TP 的失效更快、更彻底——条件分支让部分 rank 跳过 all_reduce(空队列 early-return、分配失败 return 0#28429),TP0 站在投票里等、TP1-3 已进下一轮 broadcast_pyobj,两个不同 collective 对到一起 → 立即挂死(第 11 页形态 C;#30760 是不需要 PP 的 TP-only 实证;#26923「同步必须无条件」即为此);PP 维发散则表现为稍后的形态 A; ③ 线程 × 通信组并发陷阱——L3 把通信需求带进后台线程,同一 gloo communicator 被两线程并发 all_reduce 会损坏,复用调度线程的 NCCL 组会与前向 per-layer collective 交错——铁律 ① 的两个独立组由此逼出。
# cache_controller.py —— 协议守恒的关键(原注释)
# When an error is occurred, we should keep looping
# and produce the same number of PrefetchAck as other
# ranks do, because prefetch_sync_thread ... perform
# reduce on the results.  This is so tricky.
if all_success and operation.is_terminated():
    all_success = False   # 终止≠退出:继续空转产 ack

等量 ack 协议:本地失败 / 被 abort 的 operation 不得提前 break,必须继续循环产出与其他 rank 等量的 PrefetchAck(内容标记失败)——因为对端的 all_reduce(MIN) 在按次数配对,少发一个就全体错位。异常路径也被纳入确定性协议

现状与遗留:本 PR 仍 open 未合并;伴生的 #31425(分配两阶段事务)、#22759(逻辑时钟)、#25148(前缀 cap)同样未合——官方口径因此仍是「PP + L3 not supported」(第 25 页落地建议)。合并阻力很直观:改动横跨六个模块、新建两个通信组——正是第 13 页那句「每条新通信路径都要再按同样标准审一遍」的最大考验。

本页引用 — PR / issue:#22607 · #22759 · #25148 · #26923 · #27010 · #27285 · #28429 · #30158(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 15 · #27010 机制详解

all_reduce(MIN) 逐帧看:本地进度是选票,不是决策

与 microbatch 流水的关系:① 入队发起预取 → 下载 / 投票在后台线程(第 5 页 ②③),与 ①–⑨ 并行 → ③ 组批结算。 ① 入队:发起预取 PP0 本地下载 后台 IO 线程 · 只产选票(快) 共识序列 后台投票线程产出 ③ 组批只读最后值 PP1 本地下载 后台 IO 线程 · 只产选票(慢) 票₁ ack=160 票₂ ack=320 票₃ ack=400·完 票₁ ack=80 票₂ ack=240 票₃ ack=300·完 共识₁ = 80 min(160, 80) 共识₂ = 240 min(320, 240) 共识₃ = 300 min(400, 300) 先到 → 等票₁ 等 PP1 的票₃ 后到 → 触发共识₁ 结算 = ③ 组批(R 出队) 全员 match 300 = 最后一个共识值 PP0 多下的 100 tok 弃用 时间 → 先到的票:拐角虚线 = 就地等待(快慢差在此被吸收) 后到的票:到达即触发第 k 轮共识 另一种情形:若 PP1 更快、本地到 500 —— 共识₃ = min(400, 500) = 400,轮到 PP1 下载作废。MIN 不挑快慢,只取全员都兑现得起的下界。
图 9 · 选票 vs 决策(逐帧)。上下两条泳道只产「选票」:每下完一批就往中间投一票(ack = 本地累计 token)。第 k 个共识值在第 k 票全员到齐那一刻产出——先到的票沿拐角虚线就地等待,快慢差在等待中被吸收,从不泄漏进决策。红线是结算时刻:调度只读共识泳道的最后一个值(300),全员天然同值,PP0 多下载的 100 token 直接弃用。等量 ack 保证第 k 票总能配对——谁少发一票,投票就永远停在第 k 轮。全部批次走完后由 cache_controller.py:1078 补发 completed_req 终票。流水定位:两条竖线是本图与 microbatch 流水仅有的接触点——灰虚线 = ① 入队时发起预取,红虚线 = ③ 组批时结算;中间的下载与投票全程在后台线程,与 ①–⑨ 并行推进、互不阻塞。

直觉版(错):各用各的

「rank0 在 t 时刻用本地 400 去 match,rank1 在 t+1 用本地 300 去 match」——这正是修复前的 bug:两个 rank 拿不同的 host_hit_length 组同一个 batch → shape mismatch(第 9 页)。本地进度从不直接进决策。

协议版(对):先投票,后决策

每批下载完成 = 投一票(_page_transfercache_controller.py:1026);第 k 个共识 = min(全员第 k 票),票不齐不出值;结算时读最后一个共识值,全员天然同值。各 rank 的快慢差被「等票」这个动作吸收,不泄漏进调度决策。

三个推论:① MIN 是全员都兑现得起的唯一安全值(下界——共识长度内的数据在每个 rank 都真实存在);② 集体决策退化到最慢 rank——用缓存收益买一致性;③ 配对靠计数,任何 rank 少发一票 = 全员卡死在该轮投票——这就是「协议守恒」被立为铁律的原因(P4,第 25 页)。

本页引用 — PR / issue:#27010 | 源码:cache_controller.py(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 16

把「每一种本地状态」逐个对齐

#31425 · 内存分配 → 两阶段事务 open

# write_backup 两阶段事务
resv = tensor(1 if reservation else 0)
self._all_reduce(resv, ReduceOp.MIN)
if resv.item() == 0:  # 任一 rank 没抢到
    abort_write(reservation)
    return 0          # 全体放弃备份

根治 #28429:入队决策不再取决于本 rank 分配成败。load_back 对称改造(全体加载或全体回滚),并禁止多 rank 下逐 rank 的「evict 后重试」。

#29258 · 容量对齐 merged

PP 各 stage 层数不同 → 每 token 字节数不同 → 固定 --hicache-size(GB)换算出的 host token 容量各 stage 差 5.26%(GLM-5.2 实测 8023168 vs 7621952)→ 压力下驻留集发散。修复:sync_fixed_hicache_size() 对容量做 ReduceOp.MIN

#22759 · 逐出对齐:逻辑时钟 open

PP>1 时用逻辑 batch-step 计数器替换 time.monotonic() 作为 last_access_time——步进一致则全员选出同一个 LRU 牺牲者,根治发散源 ③。Workaround:SGLANG_ENABLE_UNIFIED_RADIX_TREE=1(UnifiedRadixTree 已用逻辑时钟)。

#25148 / #31443 / #33473 · 前缀·空洞·性能

  • #25148 openReq.pp_prefix_len_cap = 各 stage min(prefix + host_hit),防止后段 stage 承诺前段给不出的前缀。
  • #31443 merged:sidecar 命中页取「前导连续成功段」(rs.index(False))而非求和——中间空洞不能算进安全前缀。
  • #33473 open:write/load 完成数合并为一次 pp_sync,32K 输入场景 prefill 吞吐 494K→677K tok/s(+37%)——同步本身已成开销。

本页引用 — PR / issue:#22759 · #25148 · #28429 · #29258 · #31425 · #31443 · #33473(行号基于 2026-08 基线,链接指向 main)

Part 3 · 社区修复 · 17 · #15372 系列

chunked prefill × PP:动态分块(--enable-dynamic-chunking)

chunked prefill 与 PP 相性天生不好:同样 token 数的 chunk,history 越长 attention 越贵,而 PP 的 microbatch 流水在 ★ 同步点锁步——最慢的 chunk 拖住整环。最新修复给 PP 配了专属方案:把「等 token 数」换成「等耗时」,且延迟这个 per-rank 量只在启动阶段进入系统一次。

读图:条宽 = 实际耗时(不是 token 数);条内 = 本片 token 数 + 要扫的前缀长度。示例 base = 8k。 等 token 数(固定 chunk)——为什么越算越慢 8k tok 扫前缀 0 8k tok 扫前缀 8k 8k tok 扫前缀 16k 8k tok 扫前缀 24k T 1.35 T 1.7 T 2.1 T ← 最慢,整环等它 每个新 token 都要对全部前缀做 attention → 同样 8k token 越来越贵;红段 = 超出 T 的溢出——流水在 ④⑥ ★ 锁步,这段时间全环陪等(第 7 页) 等耗时(动态 chunk)——固定耗时,反解 token 数 8k tok 扫前缀 0 6.6k tok 扫前缀 8k 5.6k tok 扫前缀 14.6k 4.9k tok 扫前缀 20.2k token 递减, 每片 ≈ T,无溢出 前缀照样越来越长——但每片装的新 token 变少,两者相抵,耗时拉平(token 数为示意值) ① 启动:PP0 实测拟合 128 个递减 chunk 逐个前向计时 f(l) = a·l² + b·l + c(累计耗时曲线) ② 定目标 T T = f(base) − f(0) = 第一片(无前缀)的耗时 ③ 运行时逐片解方程 f(L+x) − f(L) = T → 本片装 x 个 token L = 当前已算前缀长度 数值防线:下限 base/4 · 对齐 max(page, 64) · 平滑 0.75 · 上限 context − L − 100 · 画像失败 → 回退固定 chunk
图 9b · 为什么要动态分块,以及它怎么算。上排:固定每片 8k token,但每个新 token 都要对全部前缀做 attention——前缀 0→24k,同样的 8k token 耗时 T→2.1T;红色溢出段就是 PP 全环陪等的气泡(TP-only 无人等你,所以这是 PP 专属问题)。下排:反过来固定每片耗时 = T,用启动时拟合的耗时曲线反解每片该装多少 token(8k→4.9k 递减)。底部三步:实测拟合 → 定 T → 逐片解方程——per-rank 量只在 ① 进入系统一次,之后全是纯函数(左卡展开)。

per-rank 量只进入系统一次:画像 → 广播 → 纯函数

  • PP0 单点画像scheduler_pp_mixin.py:1776):启动时构造 128 个 token 数递减的 dummy 请求逐个前向实测延迟;DPA 下还须填 global_num_tokens(本 dp 组 = 长度、其余 0)发起 idle batch 参与 MLP sync(:1831#17339)——呼应组合场景 deck DPA 页的「必须发起 idle batch」。
  • 广播原始样本:先 attn_tp 组内 broadcast,再 pp_group.broadcast_object_list(src=0):1930)——发的是 (seq_lens, latencies) 原始数据,不是各 rank 自己测。
  • 全员同拟合:每个 rank 用同一份数据跑同一个确定性 lstsq:2654;丢弃首样本防无 warmup 偏差 #17198,样本 < 8 或 a ≤ 0 拒绝拟合)。此后 predict(history_len) 是纯函数,history_len 本受请求流契约保护——全网格解出同一个 chunk size,运行时零通信
  • 失败安全:画像抛异常 → 告警并退回固定 chunk(scheduler.py:1058);预测无解返 None → 用 base。

#27285 / #27010 同款范式(第 13 / 16 页):不对齐 IO、只对齐决策输入——延迟这个 per-rank 量在启动时被采样一次,随即转为协议数据。

落点与修复沉淀

  • 只作用于续 chunk:组批时仅当 chunked_req 非空才预测(scheduler.py:3130)——首 chunk 用 base,后续按 history 递减;prefill buffer 按 1.25×base 预留探测余量(server_args.py:5368)。
  • 修复沉淀#15372 采样 32→128 点、对齐下限 page→max(page, 64);#16140 chunk 下限 base/4——防长 history 下解出微小 chunk、固定开销反超收益;#17198 拟合丢首样本;#17339 DPA 画像参与 collective。平滑系数 SGLANG_DYNAMIC_CHUNKING_SMOOTH_FACTOR=0.75 防尺寸骤降。
  • centralized 方案再进一步:本地 plan-driven 分支里,chunk 尺寸随 extend_lenschunked_rid 编入 PPPrefillPlan 由 PP0 统一下发,其余 rank build_batch_from_plan 镜像 chunked_req 指针(scheduler_pp_mixin.py:143/794)——从「全员同构地各自算」升级为「单点算、协议发」。

chunk 与缓存的交界不变:每片结束仍走 cache_unfinished_req 入树 + 锁移交(第 8 / 22 页),动态的只是「下一片切多长」。

长 prompt 的折中(为什么有 base/4 下限):token 单价随位置线性上涨(f′(l) = 2a·l + b),纯解方程时 x ≈ T/(2aL) 按 1/L 收缩——超长 history 下 chunk 会掉到几百 token,片数暴涨,每片的固定开销(恰是 T 刻意排除的常数 c:调度一步、kernel 发射、PP 边界一次 P2P)摊不薄,吞吐反而下降。两道闸在数学上殊途同归:平滑 0.75 的极限值 = 0.25·base;硬下限 = base/4(#16140)——超长 prompt 下 chunk 钉在 base/4,「等耗时」不再严格成立、气泡部分回归,但有界。三选一:固定 chunk = 气泡无界 ❌;纯解方程 = 片数与固定开销无界 ❌;解方程 + base/4 下限 = 气泡有界 + 片数有界 ✓。
一句话:动态分块示范了在 PP 上引入per-rank 输入的「正确姿势」——采样单点化、数据协议化、决策纯函数化。HiCache 的教训(Part 2–3)与它是同一问题的反例与正解。

本页引用 — PR / issue:#15372 · #16140 · #17198 · #17339 · #27010 · #27285 | 源码:scheduler.py · scheduler_pp_mixin.py · server_args.py(行号基于 2026-08 基线,链接指向 main)

Part 4 · Abort 专题 · 18

请求 abort 全流程:一个请求可能死在五个地方

客户端断连(sleep 2s 后触发) / abort API / 超时 / 优先级抢占 TokenizerManager.abort_request 空 rid + 非 abort_all 会被拒(防误伤全部) Scheduler.abort_request scheduler.py:4182 · 按位置分派 AbortReq chunked 中 不立即拆 batch, 记 _pending_ chunked_abort, 下个调度步开头 统一处理 waiting queue 方法① 直接 pop 弹出 + 通知 tokenizer 若开 L3: release_aborted_ request(rid) → grammar queue 方法② set_finish_ with_abort 标记完成态,仍走 一次廉价 prefill 后自然清理 PD 各队列 prefill: bootstrap / inflight / deferred- release;decode: prealloc / transfer / retracted + sender.abort running 中 方法③ to_finish 标记后跑完当轮 decode 再走既有 清理路径(释放 KV、通知下游) PP 特有:必须扫所有 microbatch [*running_mbs, *mbs] vs 单批 [running_batch] release_aborted_request:终止在途 L3 prefetch(详见下页) 调用点:abort / 等待超时 / 优先级抢占 / chunked abort / bootstrap 失败 …(共 6 处) 超时 abort 的 rank 一致性:超时 rid 集合只在 entry rank 用单一时钟决定,再广播——否则跨 deadline 的请求在部分 rank 被 abort, waiting/running 分歧 → 同一 forward_ct 的 batch 组成分歧 → NCCL shape mismatch → 永久死锁(scheduler.py:1531 注释)
图 10 · abort 的五个落点与三种手法。手法随请求位置变化:排队中 = 直接弹出(方法①);语法编译中 = 标记完成(方法②);运行中 = to_finish 软标记、跑完当轮再清(方法③)。PP 下唯一的显式分支:inflight 集合要扫全部 microbatch(scheduler.py:4285)。「PD 各队列」的六个停靠点逐一拆解见下表。
停靠点请求此刻在干嘛abort 当场做什么资源谁来收
bootstrap_queueprefill与 decode 握手(注册 room、建 RDMA 会话),一个 token 未算sender.abort()——只把传输会话标 Failed每步队列轮询 poll_and_all_reduce(结果先过共识再用)看到 Failed → 释放 KV / metadata buffer、摘队、回执 tokenizer;Failed 状态沿传输通道传播给对端
inflight_queueprefillprefill 已算完,KV 正经 RDMA 发往 decode 途中同上 sender.abort()
deferred_release_queueprefillKV 已发完,资源被 abort-gated 闸门押着(centralized 方案)同上 sender.abort(),release 待共识放行
prealloc_queuedecode为将到的 KV 预分配槽位、等对端开始传kv_receiver.abort()同上:共识轮询到 Failed → 释放预分配 KV、摘队
transfer_queuedecodeKV 正在接收、落位kv_receiver.abort()
retracted_queuedecodedecode OOM 被撤下,KV 暂存 CPU(kv_cache_cpu)等重进唯一当场清的del kv_cache_cpu + 立即回 AbortReq当场——已不在任何传输/计算中,无交接风险

手法与「running 中」的 to_finish 同源:标记 Failed(会话置失败态)→ 资源由每步的共识轮询回收——跨实例靠 Failed 沿传输通道传播到对端,跨 rank 靠 poll 结果先 all_reduce 再用,谁也不提前推进(scheduler.py:4259 起;共识轮询 disaggregation/utils.py:133,五态定义 disaggregation/base/conn.py:76)。另注:方法① 弹出 waiting queue 时 PD 也有补充动作——decode 端的排队请求已带着预分配 KV,弹出要 release_kv_cache;prefill 端要释放 metadata buffer、握手中的还要补一个 sender.abort()

本页引用 — 源码:scheduler.py(行号基于 2026-08 基线,链接指向 main)

Part 4 · Abort 专题 · 19

abort × L3 prefetch:软取消 + 协议守恒

Scheduler 线程 abort 到达 → release_aborted_request ① mark_terminate:只置标志位 ② 解 host 锚点、删 ongoing_prefetch[rid] ③ 立即释放已下载段 →(释放点 A) 收尾(最终 ack 到达) rid 已不在 ongoing_prefetch → 不发布结果,只释放尾部 free(host[completed:])(释放点 B) IO 线程(prefetch_io_aux) ack₁ ack₂ 标志位(无跨 rank 同步) _page_transfer 批边界发现终止 all_success = False,但不 break—— 继续循环,每批照发 ack ack₃∅ ack₄∅ 空转 ack:与其他 rank 等量,保第 k 票配对 最终 ack·completed_req 该请求的 host 页(host_indices) 已下载 [0, completed) 尾部(在途 / 未下载) 释放点 A:abort 当下直接扔(已作废) 释放点 B:最终 ack 到达时 同一段 host 内存、两个释放点:头部 abort 当下就还;尾部必须等 IO 线程最终 ack——在途的下载还在往这些页里落字节,提前释放会让重新分配者的数据被写坏。 时间 →
图 11 · abort 是「软取消」——三条带对齐看。上带:调度线程只做「置标志 + 元数据更新 + 释放头段」后随即返回;中带:IO 线程在批边界才发现终止,且不得提前退出——虚线格 ack₃∅ / ack₄∅ 就是空转产出的选票,数量与其他 rank 严格等量(否则第 16 页的第 k 票配对全体错位);下带:同一段 host 内存两个释放点——头段(释放点 A)abort 当下回收,尾段(释放点 B)必须等最终 ack,因为在途下载还在写。(hiradix_cache.py:2139 / cache_controller.py:965,1032)
两个容易漏掉的细节:① 「终止」分两种,只有 abort 这种是本地的——abort 的 mark_terminate 只是本地标志;预取的正常结算性终止(请求活着、要出队组批了)仍走共识:pp0 按停止策略判断(best_effort / timeout——超时阈值随页数线性放宽 / wait_complete),再 all_reduce(MAX) 广播「有一个 rank 说停就全员停」(hiradix_cache.py:1562);centralized plan 变体(:1587)保证哪怕 rid 已查无此人,每次检查也恰好发一枚同步 token——协议守恒连「查无此人」的分支都覆盖。② 六个入口、一套软取消——收敛到 release_aborted_request 的调用点共 6 处:客户端 abort、等待超时、优先级抢占、chunked abort(第 19 页)、PD bootstrap 失败等。任何让排队中请求非正常死亡的路径都必须走同一套软取消,漏掉一条 = 一个新的挂死源。
# hiradix_cache.py:2139
def release_aborted_request(self, rid):
    if rid not in self.ongoing_prefetch: return
    node, key, host_indices, op = self.ongoing_prefetch[rid]
    completed, _ = controller.terminate_prefetch(op)  # 软
    node.release_host()
    del self.ongoing_prefetch[rid]
    controller.append_host_mem_release(
        host_indices[:completed])   # 已下载段:已作废

abort 竞态修复 #31869(issue #30476,PD+PP prefill 崩溃):bootstrap/prealloc 明明已算出 PP 共识,队列消费者却再 poll 一次本地状态——abort 恰好落在「共识后、消费前」窗口时各 stage 应用不同结果。修复 poll_and_all_reduce_pp:共识直接映射为 poll 结果,不再二次 poll,failure 优先;顺带每次消费省一轮集合通信(45–70µs → <6µs)。

遗留张力release_aborted_request 本身不做跨 rank 同步、直接改 ongoing_prefetch;而控制队列的 agreed 排水恰恰依赖「ongoing_prefetch 键集跨 rank 一致」。abort 风暴下这是仍需盯防的窗口。

本页引用 — PR / issue:#30476 · #31869 | 源码:cache_controller.py · hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)

Part 5 · 请求全景 · 20

请求处理全流程:五个组件、一条环

① HTTP POST /generate 客户端 HTTP / SSE ⑥ SSE 吐字 TokenizerManager 主进程 · http_server tokenize → 分配 rid rid_to_state 等回包 回流唤醒 asyncio event ② token ids + 采样参数 PUSH scheduler_input DPController 仅 dp_size>1 存在(独立进程, 也是全网格的父进程) 4 种分发策略(详见下方) dp = 1 时此框不存在,② 直达 Scheduler ③ 按 dp 策略分发 PUSH worker_ports[dp] Scheduler 网格 × dp × pp × tp 个进程 rank0 挂 socket 其余 rank 不挂 socket TP broadcast + PP relay → 全员同一请求序列(组合场景 deck) 各持 radix 树 + KV 分片 ④ 采样出的 token id PUSH detokenizer_ipc Detokenizer 独立进程 token id → 文本 增量 detokenize 旁路:send_to_tokenizer(AbortReq 确认等,不经 Detokenizer) ⑤ 增量文本回流 · PUSH tokenizer_ipc(BatchStrOutput) ◆ socket 只在 rank-zero(pp_rank==0 且 attn_tp_rank==0)真正创建,其余 rank 靠 TP broadcast_pyobj 收到同样的请求序列(ipc_channels.py:36) ◆ 开 --enable-dp-attention 后,所有 ipc:// UNIX socket 换成 TCP,端口从 dist_init_port 确定性派生(server_args.py:7502)
图 12 · 请求走一条环——按 ①–⑥ 读。① 客户端 POST /generate;② TokenizerManager 切词、分配 rid,把 token ids 推给调度侧;③ dp>1 时经 DPController 分发(默认 round_robin,PD-prefill 用 follow_bootstrap_room = room % dp_size,保证 decode 端能反解出 prefill 的 dp rank);④ Scheduler 采样出 token id 推给 Detokenizer;⑤ 增量文本沿底部回流 TokenizerManager,唤醒 rid_to_state 里等待的 asyncio event;⑥ SSE 流式吐给客户端。两个要点:整条环只有 TokenizerManager 一个对外出入口;Scheduler 是 dp×pp×tp 网格但 ZMQ socket 只挂 rank0——其余 rank 靠 TP 广播 / PP relay 拿到同一请求序列,这正是组合场景 deck「TP × PP」页请求平面的入口。

DPController 细拆:不只是个转发器

  • 身份:独立进程 sglang::data_parallel_controller,同时是整个 dp × pp × tp Scheduler 网格的父进程——所有 Scheduler 都由它按 pp_rank × tp_rank 双重循环 fork 出来(data_parallel_controller.py:481)。多节点时只有 node_rank 0 的 controller 跑分发 event_loop,其余节点的只做拉起与守护。
  • 消息三分法:259):生成 / 嵌入请求 → 按策略挑一个 dp 组;Profile / Block 类 → 发全体 worker;其余控制消息(AbortReq 等)→ 只发每个 tp 组的 rank0,组内复制交给既有广播通道(组合场景 deck)。发错的后果各不相同:请求发两组 = 重复计算重复回包;全局事漏一个 = 状态不齐;控制事发全员 = 与组内广播重复送达、消息序列分叉
  • 负载从共享内存来:「多忙」只有 Scheduler 自己知道,跨进程怎么拿?问一圈(请求-响应)= 每次分发一个往返、阻塞分发热路径;让 Scheduler 推送 = 新增一条消息流、有堆积;写 shm 快照 = 写方无锁零开销、读方随取——负载天然容忍几毫秒陈旧,不值得付一次通信(DPBudget:91,按时间戳去重)。滞后的代价用两招补:每分发一个请求即本地投机 +1 + 真实快照最多 20ms 刷一次:220)——否则突发下每次分发都采信滞后快照,整批集中落到同一个 dp 组。
  • 外部路由优先req.routed_dp_rank 已被 router 指定则直通(:599),本地策略仅作为回退——PD 部署下选组权在 router,不在 controller。
分发策略规则典型场景
round_robin(默认)轮转计数器,自动跳过 inactive worker通用;worker 可被 ActiveRanksOutput 动态摘除
follow_bootstrap_roomroom % dp_size;断言请求必须经 router 进入PD-prefill 默认——decode 端用同一算式反解出 prefill 的 dp 组(组合场景 deck)
total_requests挑 running+waiting 最少的组请求数主导的负载
total_tokens挑总 token 最少的组(平手比请求数)长上下文混布——按 token 计费更准

流程细化:② 里 TokenizerManager 不做任何负载决策——只 tokenize、分配 rid、在 rid_to_state 登记,批量输入(BatchTokenizedGenerate)也是整批转给 controller 逐个走同一分发函数;③ 的分发粒度是整个 dp 组——选中的是该组的入口 rank0,组内 TP 广播 / PP relay 与单实例完全相同(组合场景 deck);被选中的组从此独占这个请求——dp 组之间不迁移、不重试,请求的一生都留在 ③ 选定的那个组里。(策略实现:data_parallel_controller.py:606-646

本页引用 — 源码:data_parallel_controller.py · ipc_channels.py · server_args.py(行号基于 2026-08 基线,链接指向 main)

Part 5 · 请求全景 · 21

请求生命周期:Req 状态机与 KV 计量

grammar_queue(可选) 在 Scheduler 内 · 编译完才放行 waiting_queue 排队中(calc_priority) 持有:无 KV、无锁 后台:L3 预取代为推进 (第 16 页) prefill · extend 逐片算 prompt 持有:已算片 KV + 路径锁 (protected,不可逐出) ⓑ ×N 圈 running_batch decode 每轮 +1 token 持有:全 KV + 锁, kv_committed_len 逐轮 +1 finished KV 所有权移交给树 (转公共), 锁清零、还 slot ⓔ retract(唯一回退边) PD 变体入口:PREFILL 先进 bootstrap_queue(与 decode 握手)、DECODE 先进 prealloc_queue(等 KV 传输) → 汇入 waiting_queue 走同一状态机;abort 可打在任意阶段(落点与 PD 六停靠点:第 19 页) ⓐ 出队组批:match_prefix 拿前缀 → add_one_req 过预算 → alloc + inc_lock_ref 上锁——即第 8 页的 ③;从这一刻起请求开始持有 KV 与锁 ⓑ chunk 自环(长 prompt 转 N 圈):每片算完 cache_unfinished_req——已算 KV 挂上树、锁移交到更深节点,回 ③ 等下一片(片间可被 abort,第 19 页) ⓒ 最后一片算完 → 转入 running_batch,开始 decode ⓓ update_finish_state 判停(abort > 长度 > grammar > stop)→ cache_finished_req:committed 段入树、去重 free、解锁、还 slot——即第 8 页的 ⑦ ⓔ decode OOM 且 evict 后仍不够 → 优先撤下「已生成最多」的:不入树、立刻还 KV 清锁、回队重跑;全部撤下仍不够 → FINISH_ABORT(OOM)
图 13 · 一个请求的一生——框 = 状态与手里的资源,边 ⓐ–ⓔ = 转移动作(对照图例)。看「持有」一行就看懂了状态机:waiting 不持有 KV 与锁(只有后台预取代为推进);ⓐ 出队起持有 KV 与路径锁;decode 逐 token 增持;finished 把 KV 的所有权移交给树、锁清零。ⓑ 自环每圈都做一次「入树 + 锁移交」,所以 chunk 间被 abort 也有账可清;ⓔ retract 是唯一回退边——手里的一切原样退回(不入树、立刻还 KV),回队从头再来。整张状态机(含 grammar_queue)都住在 Scheduler 进程里——即第 21 页环路的 Scheduler 框内部:带语法约束的请求由 Scheduler 收下后先进 grammar_manager 的编译队列(scheduler.py:2412),编译在 Scheduler 的后台执行器完成;TokenizerManager 只切词,不碰语法。(schedule_batch.py:1425 update_finish_state / :2494 retract_decode)
# KV 计量双指针(spec 也复用这套)schedule_batch.py
kv_allocated_len   # 已分配(含 spec 预留的超额)
kv_committed_len   # 已确认有效(被接受的 token)
pop_committed_kv_cache()      # finish 时只入树这部分
pop_overallocated_kv_cache()  # 释放 [committed, allocated) 超额段
# 结束判定优先级 update_finish_state(:1425)
to_finish(外部 abort)
→ len(output_ids) ≥ max_new_tokens
→ grammar terminated → vocab 越界
→ stop token / stop string 取 min(位置)
  # spec 一次接受多 token 时防 stop-string 泄漏

本页引用 — 源码:schedule_batch.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)

Part 5 · 请求全景 · 22

每个 rank 的 radix cache:三类计量 + 延迟逐出

同一个 KV 池(HBM slot 总数恒定),每个 slot 永远处于四种归属之一——三类计量 + 一个不被统计的灰段: 空闲 available allocator 手里,随取 计量:available_size() 请求私有 在跑请求刚写的 KV,不在树上 三类计量不统计这段 树上 · 被锁 protected lock_ref > 0,在跑请求引用中 计量:protected_size() 树上 · 无锁 evictable 缓存着,但随时可牺牲 计量:evictable_size() ③ alloc:给新 token 占位 retract / abort 原样退回;⑦ 去重时树里已有的重复段 free ⑦ insert 入树 (带锁移交) ⑦ dec_lock_ref:finish 解锁 / chunk 锁移交 ③ inc_lock_ref:前缀命中复用,重新上锁 evict:alloc 缺口才砍, LRU 从叶子级联上溯 准入预算 = available + evictable 「可逐出的也算可用」——rem_total_tokens(schedule_policy.py:525) evict 推迟到 alloc 时刻,准入时不做 alloc 缺口 → evict_from_tree_cache → 仍不够才 OOM(common.py:278)
图 14 · 一个 KV slot 的一生是一个圈(顺时针主环 + 两条虚线捷径)。主环:空闲 →(③ alloc)→ 请求私有 →(⑦ insert 入树,带锁移交)→ protected →(解锁)→ evictable →(evict)→ 回到空闲。两条捷径都跳过一整轮:evictable 被新请求的前缀命中,直接重新上锁回 protected(缓存命中的本质);私有段 retract / abort 时原样退回空闲——以及 ⑦ 去重发现树里已有同样内容时,重复的私有段直接 free。调度器眼中的「可用内存」= 绿 + 青两段之和,evict 是唯一把内存从缓存手里拿回来的边、且拖到 alloc 真缺口才发生——这是 scheduler 与 KV 池之间的核心契约。锁是路径锁:inc/dec 沿节点一路走到 root,保证在用前缀整条链不可逐出。(radix_cache.py:597/612/568)
# cache_finished_req(radix_cache.py:442)请求结束
insert(token_ids[:kv_committed])   # 整段入树
free(kv[cache_protected_len : prefix_len])
     # ↑ 树里已有的重复段 → 立刻还给 allocator
dec_lock_ref(req.last_node)        # 解锁前缀

# cache_unfinished_req(:493)chunk 间歇
insert(fill_ids); free(重复段)
re-match_prefix → req_to_token 重指向树里的 slot
dec_lock_ref(旧 last_node); inc_lock_ref(新 last_node)
     # ↑ 锁移交:越算越深,锁也越走越深

每 rank 一棵树,无共享。TP/PP 的每个 scheduler 进程各自持有 self.tree_cache,彼此从不通信——同构完全靠「相同的请求序列 × 相同的确定性决策」维持。这正是 Part 2 里 HiCache 打破的东西。

防线:retract 决策必须 _tp_all_agree(本地 evict 副作用每 rank 都跑,但决策取全局共识,scheduler.py:3384);periodic sched_consistency_check 校验 batch digest(:3538)。

RadixKey.extra_key 提供命名空间隔离(LoRA id / cache version):token 相同但 extra_key 不同永不共享节点。EAGLE 下 key 转 bigram 视图(见组合场景 deck)。

本页引用 — 源码:common.py · radix_cache.py · schedule_policy.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)

Part 6 · 收束 · 23

收束 · sglang 的 chunk × PP 方案

chunked prefill 与 PP 的矛盾:attention O(n²),同样 token 数的 chunk 耗时随 history 递增,而 microbatch 流水在 ★ 同步点锁步——最慢的 chunk 拖住整环;chunk 尺寸决策若读本地状态,又会落入 Part 2 的发散。sglang 的答案是三件套。

支柱 1等耗时:动态分块

把「等 token 数」换成「等耗时」:PP0 启动时实测 128 个递减 chunk 拟合 f(l)=al²+bl+c,运行时对续 chunk 解 f(L+x)−f(L)=T 反推 token 数——每片耗时恒定,流水线无凸块(--enable-dynamic-chunking,仅 pp>1,scheduler.py:1058)。

数值防线:下限 base/4 · 对齐 max(page, 64) · 平滑 0.75 · 上限 context−L−100。

支柱 2一致性:per-rank 量只进入系统一次 + 单点决策

延迟画像只在 PP0 采样,broadcast_object_list 广播原始样本,全员跑同一个确定性 lstsq → predict(history_len) 是纯函数、运行时零通信。本地 centralized 方案更进一步:PP0 组批,chunk 尺寸随 extend_lens / chunked_rid 编入 PPPrefillPlan 下发,下游 build_batch_from_plan 镜像 chunked_req(scheduler_pp_mixin.py:143/794)——从「全员同构地各自算」升级为「单点算、协议发」。

支柱 3状态提交与异常不变式

chunk 间隙照走 cache_unfinished_req:已算 KV 入树、锁移交更深节点(第 22 页 ⓑ);chunked 半途 abort 不当场拆——记 _pending_chunked_abort,下个调度步开头这个全员必经的位置统一清(scheduler.py:2689);PP 下 abort 先记 intent、按固定节奏经 plan 生效,不允许任何 rank 单方面跳过 collective。

修复动作规避的失效
#15372画像 32→128 点;对齐下限 page → max(page, 64)拟合欠采样;微小错位 chunk
#16140chunk 下限 = base/4长 history 下解出微小 chunk,固定开销反超收益
#17198拟合丢弃首个样本无 warmup 首跑偏慢 → 系数有偏
#17339DPA 下画像 forward 填 global_num_tokens 参与 MLP syncMLP sync 缺席 → 全员挂死
centralized plan(本地)chunk 决策由 PP0 算好、随 plan 协议下发消除「每 rank 独立决策」这个发散源本身
一句话:chunk × PP 方案示范了在 PP 上引入per-rank 输入的「正确姿势」——采样单点化、数据协议化、决策纯函数化。HiCache(Part 2–3)与它是同一问题的反例与正解;机制逐帧图解见第 18 页。

本页引用 — PR / issue:#15372 · #16140 · #17198 · #17339 | 源码:scheduler.py · scheduler_pp_mixin.py(行号基于 2026-08 基线,链接指向 main)

Part 6 · 收束 · 24

收束 · PP × HiCache:一句话与四条铁律

全篇可以压成一句话:PP 的正确性契约是「输入相同 + 决策确定 ⇒ 全员同构」(第 6 页);HiCache 把两路 per-rank IO 读数——L2 的 DMA ack、L3 的预取进度——接进 ③ 组批,打破契约;修复的全部内容 = 把每一个per-rank 输入换成协议输入。IO 从未被对齐,被对齐的只是决策所用的数字(第 15 页图 8)。

问题回放

每个 rank 一个独立 Scheduler、一棵私有 radix 树、一份 KV 分片,互不通信(第 23 页)。四类发散源(第 10 页):ack 回收数量、L3 命中长度、host 分配成败、LRU 时钟——全是 per-rank 量。三种崩溃形态(第 11 页):shape mismatch(PP 维慢性发散)、pop empty、NCCL 600s 挂死(TP 维 collective 错位,立即失败)。运行数小时后才崩溃、缓存越大崩溃越频繁——因为毫秒级 IO 方差 × 海量请求总会命中竞态窗口。

修复范式:四条铁律

  • P1 对称 MIN:进度 / 容量 / 命中长度类数值,跨 rank all_reduce(MIN)——最慢者获胜,宁可全员按最差情况执行(#27010 PG1/PG2、#29258#31425)。
  • P2 Leader + pp_sync:「做不做 / 做几个」类决策由 PP0 裁决、点对点链式下发;绝不用 PP collective——各 stage 必须错相位,强制对齐即死锁(#27285)。
  • P3 通信平面隔离:后台线程走 gloo(CPU),每条线程独立通信组——同一 communicator 被两线程并发使用会损坏(#27010 单独建 PG1/PG2 的原因)。
  • P4 协议守恒:失败 / 终止 / abort 也不得少发协议消息——等量 ack、每 rid 恰好一枚 sync token、共识后不二次 poll(#31869、第 20 页软取消)。任何「提前 return」都是错位的种子。

可以用

PP + L2#27285 / #28916 / #29258 / #26923 已合入,e2e KL 测试护航(test_pp_with_hicache.py);chunk × PP 动态分块(第 18 页)同样可用。

先别上生产

PP + L3:官方口径「not supported yet」(#30158);主修复 #27010#31425#22759#25148 均未合并。

一定要上 PP + L3

  • 跟进 #27010 分支自行验证;
  • SGLANG_ENABLE_UNIFIED_RADIX_TREE=1 缓解 LRU 时钟发散;
  • 调小 --hicache-size;监控三种崩溃签名并配 crash 转储。
两条路线之争:社区路线 = 给每个决策点逐一加对齐(P1–P4 的活用,修一处防一处);本地分支 refactor/pp-centralized-conductor-design 走得更彻底——centralized conductor:admission、prefetch 判定、PD 状态迁移全部收敛到 rank0 产出 PPPrefillPlan,其余 rank 纯 replay,从根上消除「每 rank 独立决策」这个发散源本身。chunk × PP 的动态分块与 plan 化(第 18 / 24 页)正是这一哲学在调度侧的成功范例。
如果你在做别的系统:这四条不是 HiCache 专属。任何满足「多副本各自决策(SPMD)+ 资源异步就绪」的系统都面临同一问题——MoE 的 expert 容量与 all-to-all、KV offload 与 PD 传输、投机解码的接受长度、甚至分布式限流器。判据可以直接搬:把每个决策点的输入列出来,标出哪些取决于现实世界的快慢(per-rank 量,见第 2 页术语约定),这份清单就是你的发散源清单;然后逐个决定它该取 MIN、走 leader,还是干脆把决策收敛到单点。真正的难点不在于修复单个决策点,而在于证明清单是完整的——这也是 #27010 至今未合的原因。

来源:github.com/sgl-project/sglang — issues #22607 · #30158 · #28902 · #30476 · #28429;PRs #27285 · #28916 · #27010 · #31425 · #31869 · #29258 · #22759 · #25148 · #31443 · #33473 · #15372 · #16140 · #17198 · #17339。源码引用基于本仓库 python/sglang/srt