执行摘要
- 一句话:KV 释放、复用断言与存在性检查统一走 KV record
- 推荐动作:值得精读。重点看
memory_pool.py 的 alloc / free 断言设计与 scheduler_pp_mixin.py 的统一释放路径。本 PR 是“断言应基于记录状态而非派生字段”的样板:holds_kv 与 kv_allocated_len 的语义可以直接复用到后续 unified-memory 改造中。若团队正在做 KV ownership 或 radix cache 相关重构,建议合入后密切观察 dllm / disagg / pp 路径是否有新断言触发。
功能与动机
关联 Issue #37094 先把 req_pool_idx 从 Req / SessionSlot 移入 ReqKvInfo,让记录自己回答存在性(is_held 与 is_released 配对)。本 PR body 指出三类“绕过记录契约”的存量写法:PP profiling 循环手写释放(raw slot slice free + pool mamba free + row free),“跳过 kv_len_to_handle 和 per-cache finished paths”;行复用断言从 scheduler 字段(inflight_middle_chunks、kv_committed_len)推断,导致“已释放的行能以 stale state 通过断言”;13 处存在性检查直接拼写 req_pool_idx is not None。目标是让 release、row-reuse 断言与 presence 检查统一读 KV record,停止绕过契约。
实现拆解
-
统一 PP profiling 释放路径:python/sglang/srt/managers/scheduler_pp_mixin.py 的 profile_and_init_predictor 中,原来手写“取 kv_indices → token_to_kv_pool_allocator.free → free_mamba_cache → pool.free → mark_kv_released”五步释放,改为一行 release_kv_cache(req, self.tree_cache, is_insert=False)。等价性由作者在 PR body 中论证:这些 profiling 请求满足 cache_protected_len == 0、kv_committed_len == kv_allocated_len == extend_range.end、last_node is None,因此 radix / chunk / mamba 三种 cache 的释放范围一致,且 mamba 所有权改由 cache contract 决定而不是总走 pool。
-
收紧行复用断言:python/sglang/srt/mem_cache/memory_pool.py 的 ReqToTokenPool.alloc / free 与 python/sglang/srt/disaggregation/decode.py 的 DecodeReqToTokenPool.alloc / free 中:复用行判定从 req_pool_idx is not None 改为 holds_kv;断言从 inflight_middle_chunks > 0 or kv_committed_len > 0 改为 kv_allocated_len > 0(复用行必须实际携带已分配 KV)。同时删除长期放宽的“only one chunked request”断言及其注释残留——单 batch 单 chunked 请求自 #20476 起由 batch composition 强制,此处不再重复校验。
-
presence 检查统一到 holds_kv:共 13 处 req_pool_idx is not None / is None 改为 holds_kv / not holds_kv,涉及 sparse_coordinator.py(on_request_begin / on_request_end)、dsv4_req_to_token_pool.py(set_c128_prefix_pages / alloc 的 fresh 判定)、allocation.py(alloc_for_extend 的 DLLM reuse_kv)、scheduler.py(process_pending_chunked_abort)、pool_stats_observer.py(active_pool_idxs)、radix_cache_cpp.py(cache_finished_req / cache_unfinished_req 的 assert)等。哨兵域检查(decode offload manager 中 req_pool_idx is None or == -1)保留 raw field read,不改为 holds_kv。
-
修复 benchmark 与测试夹具:python/sglang/benchmark/one_batch.py 的 correctness-test 路径从 req.req_pool_idx 改为 req.kv.req_pool_idx(字段在 #37094 已迁移);测试侧 test_decode_retraction_backup.py、test_dllm_fdfo_kv_reuse.py、test_minicpm_sparse_cache.py 把触及 holds_kv 的 SimpleNamespace fake 升级为真实 ReqKvInfo。
-
测试与 CI 配套:本 PR 无新增针对性单测,系统性回归依赖既有用例 + CI。作者在 Issue 评论中多次 /rerun-test 了 test_pp_single_node.py、test_disaggregation_basic.py、test_dllm_batching_fdfo.py、test_openai_completion_rust.py,均通过。
关键文件:
python/sglang/srt/mem_cache/memory_pool.py(模块 内存池;类别 source;类型 core-logic;符号 ReqToTokenPool.alloc, ReqToTokenPool.free): 核心变更:ReqToTokenPool 的 alloc / free 断言与存在性判断从原始字段转向 holds_kv + kv_allocated_len,是全 PR 语义核心。
python/sglang/srt/managers/scheduler_pp_mixin.py(模块 调度器;类别 source;类型 dependency-wiring;符号 profile_and_init_predictor): PP dynamic-chunk profiling 的手写三步释放改为统一走 release_kv_cache,消除绕行路径。
python/sglang/srt/disaggregation/decode.py(模块 解码池;类别 source;类型 core-logic;符号 DecodeReqToTokenPool.alloc, DecodeReqToTokenPool.free): DecodeReqToTokenPool 同步收紧复用断言,并删除长期放宽的 single-chunked assert 及其注释残留。
python/sglang/srt/mem_cache/radix_cache_cpp.py(模块 缓存树;类别 source;类型 core-logic;符号 cache_finished_req, cache_unfinished_req): cache_finished_req / cache_unfinished_req 的 assert 改用 holds_kv,保持缓存树侧与记录语义一致。
python/sglang/srt/mem_cache/allocation.py(模块 内存分配;类别 source;类型 core-logic;符号 alloc_for_extend): DLLM 场景的 reuse_kv 存在性判断改用 holds_kv。
python/sglang/srt/mem_cache/sparsity/core/sparse_coordinator.py(模块 稀疏缓存;类别 source;类型 core-logic;符号 on_request_begin, on_request_end): 稀疏缓存的请求注册与清理以 holds_kv 为准,避免在无 KV 时误操作。
python/sglang/srt/hardware_backend/npu/dsv4/dsv4_req_to_token_pool.py(模块 NPU 适配;类别 source;类型 core-logic;符号 set_c128_prefix_pages, alloc): DSV4 C128 前缀页安装与 fresh 行判定改用 holds_kv,避免绕过记录。
python/sglang/srt/managers/scheduler.py(模块 调度器;类别 source;类型 core-logic;符号 process_pending_chunked_abort): process_pending_chunked_abort 中判断请求是否仍持有 KV 改用 holds_kv。
python/sglang/benchmark/one_batch.py(模块 基准工具;类别 source;类型 core-logic;符号 prepare_extend_inputs_for_correctness_test): 修复字段迁移后遗留的 stale read,正确性测试路径改读 req.kv.req_pool_idx。
python/sglang/srt/managers/scheduler_components/pool_stats_observer.py(模块 观测器;类别 source;类型 core-logic;符号 active_pool_idxs): 活跃行统计从 raw 字段改为 holds_kv,保证统计口径与 KV record 一致。
test/registered/unit/mem_cache/test_decode_retraction_backup.py(模块 单测;类别 test;类型 test-coverage): 触及 holds_kv 的测试 fake 从 SimpleNamespace 升级为真实 ReqKvInfo。
test/registered/unit/mem_cache/test_dllm_fdfo_kv_reuse.py(模块 单测;类别 test;类型 test-coverage): DLLM fdfo KV 复用测试同步到 record 语义。
test/registered/unit/layers/test_minicpm_sparse_cache.py(模块 单测;类别 test;类型 test-coverage): 稀疏缓存测试夹具对齐 holds_kv 语义。
关键符号:ReqToTokenPool.alloc, ReqToTokenPool.free, DecodeReqToTokenPool.alloc, DecodeReqToTokenPool.free, profile_and_init_predictor, SparseCoordinator.on_request_begin, SparseCoordinator.on_request_end, DSV4ReqToTokenTablesMixin.set_c128_prefix_pages, alloc_for_extend, Scheduler.process_pending_chunked_abort, PoolStatsObserver.active_pool_idxs, RadixCacheCPP.cache_finished_req, RadixCacheCPP.cache_unfinished_req, prepare_extend_inputs_for_correctness_test
关键源码片段
python/sglang/srt/mem_cache/memory_pool.py
核心变更:ReqToTokenPool 的 alloc / free 断言与存在性判断从原始字段转向 holds_kv + kv_allocated_len,是全 PR 语义核心。
def alloc(self, reqs: list[Req]) -> Optional[List[int]]:
# 复用行判定:直接读 KV record 的 holds_kv(即 req_pool_idx is not None),
# 而不是从 scheduler 字段推断 chunked continuation。
# holds_kv 与 req_pool_idx is not None 完全等价,
# 但语义上把“是否持有 KV”的答案收敛到记录自身。
reusing = [i for i, r in enumerate(reqs) if r.kv.holds_kv]
# 关键收紧:复用行必须携带已分配的 KV。
# 旧检查用 kv_committed_len > 0 推断,但 kv_committed_len 在 release
# 时不会清零,已释放的行仍可能带着旧值通过断言;
# kv_allocated_len 才是“当前持有多少 KV”的 memory-layer 事实。
assert all(
reqs[i].kv.kv_allocated_len > 0 for i in reusing
), "a reused row must carry allocated KV"
# 只为真正的新行分配槽位,已持有行的 req_pool_idx 保持不变
select_index = self.alloc_rows(len(reqs) - len(reusing))
if select_index is None:
return None
offset = 0
for r in reqs:
if not r.kv.holds_kv:
r.kv.req_pool_idx = select_index[offset]
offset += 1
return [r.kv.req_pool_idx for r in reqs]
def free(self, req: Req):
# free 同样以记录为准:只有真正持有行的请求才能释放
assert req.kv.holds_kv, "request must have req_pool_idx"
self.free_rows([req.kv.req_pool_idx])
req.kv.req_pool_idx = None
python/sglang/srt/managers/scheduler_pp_mixin.py
PP dynamic-chunk profiling 的手写三步释放改为统一走 release_kv_cache,消除绕行路径。
# 释放 KV 与 Mamba cache:统一走 release_kv_cache,而不是手写三步释放。
# 手写路径绕过了 kv_len_to_handle 与 per-cache finished 逻辑,
# 且 mamba 所有权总是走 pool,不符合 cache contract。
# 在这些 profiling 请求上(cache_protected_len == 0、
# kv_committed_len == kv_allocated_len == extend_range.end、last_node is None),
# 三种 cache(radix / chunk / mamba)的释放范围完全相同,
# 因此这里的等价替换是安全的。
if req.kv.holds_kv:
release_kv_cache(req, self.tree_cache, is_insert=False)
评论区精华
本 PR 无 review 评论(review_comments_count 为 0),以下要点提炼自 PR body 的设计论证:
风险与影响
- 风险:
- 分配断言收紧可能暴露违规路径:
memory_pool.py 与 decode.py 的 alloc 现在要求复用行 kv_allocated_len > 0。由于 kv_committed_len 在 release 时不清零,旧逻辑下已释放行可携带 stale state 通过检查;若存在“释放后同一调度轮内复用同一 Req”的合法路径(如 DLLM fdfo 场景),新断言会直接触发。CI 已重跑 dllm / pp / disaggregation 相关用例,但边界场景仍需留意。
- PP 释放等价性依赖前置条件:
scheduler_pp_mixin.py 统一改走 release_kv_cache 的正确性建立在 4 个条件上;未来若 PP profiling 引入 radix 命中或 cache 保护(last_node is not None),释放范围将不再等价,需要重新论证。
- 删除 single-chunked assert 依赖外部约束:该保护被移除的前提是 #20476 已在 batch composition 强制执行单 chunked 请求,若该约束后续被放松,此处的防御会缺失。
- presence 与 sentinel 语义边界:13 处统一改用
holds_kv 后,任何新增代码若混淆“记录存在性”与“-1 哨兵”,可能在 decode offload 路径上误判。作者有意保留哨兵域 raw read,但边界仍靠约定维持。
- 回归覆盖依赖 CI:三处测试均为 fixture 更新,缺乏针对新断言的定向单测,系统性风险主要靠现有集成测试兜底。
- 影响:对用户无可见功能变化。影响面集中在内部 KV 生命周期语义:调度器(scheduler.py、scheduler_pp_mixin.py、pool_stats_observer.py)、内存池(memory_pool.py)、disaggregation 解码池(decode.py)、radix 缓存树(radix_cache_cpp.py)、NPU DSV4 适配、稀疏缓存协调器以及 benchmark 工具。对团队的价值是确立 ReqKvInfo(KV record)作为分配、释放、存在性判定的唯一入口,消除多处绕过记录契约的派生读取,为后续 unified-memory 与 KV ownership 演进降低语义漂移风险;同时更严格的断言有助于在开发期尽早暴露 KV 生命周期 bug。
- 风险标记:分配断言收紧可能暴露违规路径, PP 释放等价性依赖前置条件, presence 语义迁移面广, 依赖 #20476 batch 层约束, 测试以 CI 覆盖为主
关联脉络
- PR #37094 [mem_cache] Move
req_pool_idx into ReqKvInfo: 直接前置 PR:把 req_pool_idx 移入 ReqKvInfo 并定义 is_held / is_released 谓词,本 PR 的所有改动都建立在该记录之上。
参与讨论