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 页诊断路径)。
→ / 空格 下一页 · ← 上一页 · Home 回封面 — 素材来源:sgl-project/sglang 社区 issue/PR + 本仓库源码(含 file:line 引用)
本页引用 — PR / issue:#22607 · #27010(行号基于 2026-08 基线,链接指向 main)
三句话讲完整个故事
根因:一条被打破的隐含契约
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 页。
hiradix_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)
HiCache 三层架构:L1 显存 · L2 主机内存 · L3 外部存储
HiRadixCache(调度线程),实际 DMA/IO 在 HiCacheController(含 4 条后台线程)。四条彩色箭头即四条数据通路,颜色 = 数据的来源层。入口选择:registry.py:101 中 enable_hierarchical_cache=True 时用 HiRadixCache(HybridModel 走 UnifiedRadixCache);L3 后端由 StorageBackendFactory(backend_factory.py:16)按 --hicache-storage-backend 创建。
本页引用 — 源码:backend_factory.py · registry.py(行号基于 2026-08 基线,链接指向 main)
四条数据通路:两条下沉备份 + 两条上浮取回
ack_write_queue 的一个条目 = finish_event + 覆盖的节点列表);调度线程从不等待 IO,只在每个调度步开头统一 drain 这些 ack(check_hicache_events;本片后文说的「ack 回收」均指此事)。方向上两两成对:L1→L2 与 L2→L3 是冷数据下沉(备份),L2→L1 与 L3→L2 是热数据上浮(取回)。
L1 → L2 备份(write-through)
触发:前缀在 L1 的命中数攒够阈值(_inc_hit_count)——是热点才值得花 D2H 带宽备份,将来被 GPU 逐出后还能从 L2 快速捞回。
链路:write_backup → controller.write 入 write_queue → start_writing 在独立 write_stream 上整批 D2H DMA(专用流,不占计算流、不影响正在跑的 forward)→ HiCacheAck 入 ack_write_queue → 每步 writing_check 用 finish_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_back(hiradix_cache.py:1358)收集 evicted 祖先 → controller.load → load_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 线程承担。
链路:StorageOperation 入 backup_queue → backup_thread 按 STORAGE_BATCH_SIZE 分批 page_set → ack_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_events(hiradix_cache.py:1459)是本页最重要的一处:四条通路的 ack 回收全集中在每个调度步开头这一次轮询——每步收几张账取决于本地 IO 完成了多少,而这在 PP 各 rank 上天然不同。这里就是第 9–10 页发散源 ① 与 #27285 修复的案发现场。
本页引用 — PR / issue:#27285 | 源码:hiradix_cache.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)
搬运引擎 HiCacheController:两类引擎、4 条后台线程
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 那边找线程。
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——StorageOperation入backup_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)
PP 事件循环:每个 rank 一个独立 Scheduler
# 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(①④⑥)构成的偏序。
ipc_channels.py:36),而采样发生在 last rank——所以生成的 token 必须沿输出环链路送回 rank0,由 rank0 推给 Detokenizer → TokenizerManager → SSE 流式返回客户端。输出环因此承担两个职责:rank0 是全 pipeline 唯一的对外出口,同时沿途每个 rank 借同一份结果完成本地状态提交(阶段 ⑦)。prefix_indices 长度就不同 → extend_num_tokens 不同 → 阶段 ④ 收到的 proxy tensor 形状对不上——crash 或假死。Part 2 的所有问题都从这里长出来。
本页引用 — PR / issue:#28916 | 源码:ipc_channels.py · scheduler_pp_mixin.py(行号基于 2026-08 基线,链接指向 main)
microbatch 流水 × PP4:两次完整迭代
把上一页的循环体各步放进 4 个 rank 的流水里跑两轮——看清谁在等谁、数据往哪流、相位差怎么形成。
缓存层动作在循环体各步中的落点(格子底部色条:L1 琥珀 / L2 青绿 / L3 靛蓝):① 请求一入队就发起 L3→L2 预取——用排队时间掩盖下载(第 4 页卡片 ④);③ 是全部缓存决策的收口——check_hicache_events 收 L1↔L2 DMA 账(pp_sync 共识)、match_prefix 查 L1 树 + host 树、发起 L2→L1 回载与 L1→L2 备份、L3 预取结算取 MIN 共识值;⑤ 前向期间 L2→L1 逐层 H2D「边载边算」;⑦ 状态提交把完成请求的 KV 插入 L1 radix 树(命中攒够再触发 write-through 下沉)。L2↔L3 的实际搬运始终在后台线程(第 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)
①③⑤⑦ 四步各自怎么改 KV cache
沿一个请求把 microbatch 流水里真正碰缓存的四步拆开:每步改哪个存储对象、改的是「账」还是「字节」。分工一句话——① 在 L2 投机预热,③ 改元数据,⑤ 写字节,⑦ 移交所有权。
①收请求:只碰 L2 的「池账」,树上无痕
process_input_requests 构造 Req 入 waiting_queue,本身不碰任何缓存;真正动手的是入队瞬间的 _prefetch_kvcache(scheduler.py:2427):从 HostKVCache 池 alloc 一段 host 页、登记 ongoing_prefetch[rid],随即返回。
排队期间,后台线程(第 5 页 ①②)分批 page_get 把 L3 字节持续写进这段页——但页尚未挂上 host 树,别的请求看不见;revoke / abort 可整段退回(第 20 页)。L1 在本步一个字节、一个节点都不动。
③组批:全部元数据修改的收口,固定顺序三小步
- ack 回收与状态提交(
check_hicache_events,hiradix_cache.py:1459):write ack → 节点标backuped(host_value 生效,顺手触发 L2→L3);load ack → 清 loading 标记;L3 预取按 MIN 共识值结算——已到前缀挂上 host 树、尾段 host 页退回。 - 匹配 + 回载:
match_prefix部分命中会分裂 TreeNode——查询也改树形;命中段若 device 副本已被逐出,init_load_back(hiradix_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 流只读被锁住的前缀。
⑦状态提交:字节零拷贝,改的是所有权
- finish →
cache_finished_req(radix_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 页)。
prefix_indices 长度就分家、④ 的 proxy tensor 形状就对不上。这正是下一页开始的整个故事。
本页引用 — 源码:common.py · hiradix_cache.py · memory_pool.py · radix_cache.py · scheduler.py(行号基于 2026-08 基线,链接指向 main)
核心矛盾:异步缓存 × 确定性契约
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 页)。
用 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)
四类发散源(umbrella issue #22607 的归纳)
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 形状对不上而崩溃。因此症状常表现为「运行数小时后才崩溃」「缓存越大越频繁」。
write_backup 只在 host 分配成功的 rank 入队(失败 rank 静默 return 0),load_back 只在 device 分配成功的 rank 生效。同一逻辑决策在不同 rank 落地成不同动作,TP-only 也会因此死锁(不限于 PP)。
本页引用 — PR / issue:#22607 · #27285 · #28429 · #30158(行号基于 2026-08 基线,链接指向 main)
崩溃形态:从报错反推发散
形态 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 list → ack 计数错位(沿用了其他 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 崩溃频率随之下降 → 逐出压力相关(发散源 ③)。
本页引用 — PR / issue:#27285 · #28902 · #30158 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)
修复地图:umbrella #22607 的推进路线
| PR | 状态 | 对齐的是什么 | 一句话机制 |
|---|---|---|---|
#27285 | merged 06-06 | L2 事件消费 | pp_sync:PP0 决定本轮消费几个 DMA 完成事件,链式广播,全员消费同样数量 |
#28916 | merged 06-23 | pp_sync 自身 | 修 #27285 引入的 PP0 内存泄漏:给 work_list 设硬上界、下游拖住上游 |
#29258 | merged 06-26 | L2 容量 | 各 stage 层数不同 → host 容量差 5.26% → 对 host token 容量 ReduceOp.MIN |
#31869 | merged 07-29 | PD 队列 + abort | bootstrap/prealloc 以共识结果为唯一依据,消除「共识后二次 poll」的 abort 竞态窗口 |
#31443 | merged 07-21 | DSA/hybrid 前缀 | sidecar pool 命中页取「前导连续成功段」而非求和,防止空洞被当安全前缀 |
#29106 | merged 06-27 | SWA / layer 映射 | DeepSeek-V4 PP 下 SWA 分配与 layer 映射修正 |
#27010 | open | L3 全链路 ★ | 主修复:prefetch_sync_thread + 等量 ack 协议 + pp_sync 扩展到预取控制队列 |
#31425 | open | 内存分配决策 | write/load 拆 reserve→MIN 共识→commit/abort 两阶段事务,全体成或全体废 |
#22759 | open | LRU 逐出 | 逻辑 batch-step 时钟替换 time.monotonic(),根治发散源 ③ |
#25148 | open | 前缀长度 | pp_prefix_len_cap:bootstrap 时各 stage 前缀能力取 min 作为匹配上限 |
#33473 | open | 性能 | write/load 完成数合并为一次 pp_sync,32K 输入 prefill 吞吐 +37%(494K→677K tok/s) |
另有已合入的外围修复:#20460(CP sync)、#25887、#26923(writing_check 的 all_reduce 改为无条件执行,修 NCCL 死锁)、#27366(HiMamba sidecar 预取 hang)等。
本页引用 — PR / issue:#20460 · #22607 · #22759 · #25148 · #25887 · #26923 · #27010 · #27285(行号基于 2026-08 基线,链接指向 main)
#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 测试。
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 + 节拍」)见页底警示框。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
_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)
#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。
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(); ...
_pp_commit_comm_work)用的是一模一样的手法。方法论上,本页是第 13 页那句话的实例:审计一条新通信路径,不只要问「它对不对」,还要问「它的在飞量有没有上界、谁来拖住它减速」——#27285 修好了确定性,却留下了一条无界的异步发送路径。
本页引用 — PR / issue:#26923 · #27285 · #28902 · #28916 | 源码:hiradix_cache.py(行号基于 2026-08 基线,链接指向 main)
#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.”
all_reduce(MIN)——全员按最慢者的 300 决策,行数处处一致。IO 从未被对齐,被对齐的只是决策输入。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 页的两种对齐原语在此并用。
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)
all_reduce(MIN) 逐帧看:本地进度是选票,不是决策
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_transfer,cache_controller.py:1026);第 k 个共识 = min(全员第 k 票),票不齐不出值;结算时读最后一个共识值,全员天然同值。各 rank 的快慢差被「等票」这个动作吸收,不泄漏进调度决策。
本页引用 — PR / issue:#27010 | 源码:cache_controller.py(行号基于 2026-08 基线,链接指向 main)
把「每一种本地状态」逐个对齐
#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 已用逻辑时钟)。
本页引用 — PR / issue:#22759 · #25148 · #28429 · #29258 · #31425 · #31443 · #33473(行号基于 2026-08 基线,链接指向 main)
chunked prefill × PP:动态分块(--enable-dynamic-chunking)
chunked prefill 与 PP 相性天生不好:同样 token 数的 chunk,history 越长 attention 越贵,而 PP 的 microbatch 流水在 ★ 同步点锁步——最慢的 chunk 拖住整环。最新修复给 PP 配了专属方案:把「等 token 数」换成「等耗时」,且延迟这个 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_lens、chunked_rid编入PPPrefillPlan由 PP0 统一下发,其余 rankbuild_batch_from_plan镜像 chunked_req 指针(scheduler_pp_mixin.py:143/794)——从「全员同构地各自算」升级为「单点算、协议发」。
chunk 与缓存的交界不变:每片结束仍走 cache_unfinished_req 入树 + 锁移交(第 8 / 22 页),动态的只是「下一片切多长」。
本页引用 — PR / issue:#15372 · #16140 · #17198 · #17339 · #27010 · #27285 | 源码:scheduler.py · scheduler_pp_mixin.py · server_args.py(行号基于 2026-08 基线,链接指向 main)
请求 abort 全流程:一个请求可能死在五个地方
to_finish 软标记、跑完当轮再清(方法③)。PP 下唯一的显式分支:inflight 集合要扫全部 microbatch(scheduler.py:4285)。「PD 各队列」的六个停靠点逐一拆解见下表。| 停靠点 | 端 | 请求此刻在干嘛 | abort 当场做什么 | 资源谁来收 |
|---|---|---|---|---|
bootstrap_queue | prefill | 与 decode 握手(注册 room、建 RDMA 会话),一个 token 未算 | sender.abort()——只把传输会话标 Failed | 每步队列轮询 poll_and_all_reduce(结果先过共识再用)看到 Failed → 释放 KV / metadata buffer、摘队、回执 tokenizer;Failed 状态沿传输通道传播给对端 |
inflight_queue | prefill | prefill 已算完,KV 正经 RDMA 发往 decode 途中 | 同上 sender.abort() | |
deferred_release_queue | prefill | KV 已发完,资源被 abort-gated 闸门押着(centralized 方案) | 同上 sender.abort(),release 待共识放行 | |
prealloc_queue | decode | 为将到的 KV 预分配槽位、等对端开始传 | kv_receiver.abort() | 同上:共识轮询到 Failed → 释放预分配 KV、摘队 |
transfer_queue | decode | KV 正在接收、落位 | kv_receiver.abort() | |
retracted_queue | decode | decode 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)
abort × L3 prefetch:软取消 + 协议守恒
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)
请求处理全流程:五个组件、一条环
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_room | room % 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)
请求生命周期:Req 状态机与 KV 计量
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)
每个 rank 的 radix cache:三类计量 + 延迟逐出
# 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)
收束 · 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 |
| #16140 | chunk 下限 = base/4 | 长 history 下解出微小 chunk,固定开销反超收益 |
| #17198 | 拟合丢弃首个样本 | 无 warmup 首跑偏慢 → 系数有偏 |
| #17339 | DPA 下画像 forward 填 global_num_tokens 参与 MLP sync | MLP sync 缺席 → 全员挂死 |
| centralized plan(本地) | chunk 决策由 PP0 算好、随 plan 协议下发 | 消除「每 rank 独立决策」这个发散源本身 |
本页引用 — PR / issue:#15372 · #16140 · #17198 · #17339 | 源码:scheduler.py · scheduler_pp_mixin.py(行号基于 2026-08 基线,链接指向 main)
收束 · 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
- 跟进 #27010 分支自行验证;
SGLANG_ENABLE_UNIFIED_RADIX_TREE=1缓解 LRU 时钟发散;- 调小
--hicache-size;监控三种崩溃签名并配 crash 转储。
refactor/pp-centralized-conductor-design 走得更彻底——centralized conductor:admission、prefetch 判定、PD 状态迁移全部收敛到 rank0 产出 PPPrefillPlan,其余 rank 纯 replay,从根上消除「每 rank 独立决策」这个发散源本身。chunk × PP 的动态分块与 plan 化(第 18 / 24 页)正是这一哲学在调度侧的成功范例。
来源: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。