Prhub

#35158 feat(unified-memory): byte-budget sizing, feasibility floor, and a conservation verifier

原始 PR 作者 caihuali95 合并时间 2026-09-01 06:09 文件变更 9 提交数 7 评论 0 代码增减 +658 / -21

执行摘要

统一内存按字节预算定容,新增守恒校验与 bs=1 可行性检查

PR body 明确列出三个 gap:

1) buffer 由重求和 ratio-derived token counts 决定而非真正 profiling 得到的 byte budget,SWA split 先按 per-token cell size floor 预算、再对每侧 token 数做 page-align,重求和会重建出比实际 profiling 少的容量——每侧最多差约一个 page 的 token,这部分内存从未被分配;
2) under-sized pool 在运行时表现为 retract livelock 而非启动失败,单个最坏情况请求单独运行都装不下时,retract loop 无物可退、服务器挂起;
3) unified pool 的字节记账没有守恒检查,watermark spans、holes、pending compaction、frontier ordering 的漂移会让计数器准入到并不空闲的内存——静默损坏而非崩溃。PR 同时声明:所有新增 hook 的默认值精确复现当前行为,静态池不受影响。

值得精读。核心设计决策包括:allocator-owned hook + 默认逐位兼容、诊断只读不 raise 的可用性取舍、_reserved_floor_bytes 单一事实来源、方向无关的 chain 排序、flooring 而非 rounding 的 4096 B 对齐。对想理解 SGLang 统一内存池演进方向(N 子池、字节准入)的工程师,这是很好的中间态样例,建议结合 #35154 与 #35177 一起阅读。

讨论亮点

本 PR 的 comments 与 review_comments 均为 0,评审交锋体现在合并者 ch-wan 随后的 3 个直达提交中:

  • 移除 bs=1 floor 的 full-context 项(fix commit):初版 floor 收取 model_context_len * full_entry_bytes,理由是“单个请求装不下就是 retract livelock”。ch-wan 指出 TpModelWorker.get_worker_info 已把 max_req_len clamp 到池容量,长于池的请求在准入时就被拒绝,不是 livelock;保留该项会让单 GPU 上的长上下文模型健康配置在 boot 时被拒。

  • 修正 call site 注释(docs commit):删掉 full-context term 后,调用点注释仍在论证被删 term 成立的理由。提交说明的原话是“You change the line, you own its comment”。

  • 压缩冗余注释:byte-budget 与 bs=1 floor 的四段注释被压缩到事实所需长度,4096 B 对齐的 provenance(.view() 为 KV dtype)得到保留。

此外,测试文件记录了一次已被修复的回归:_reserved_floor_bytes 手抄公式曾对 mamba entry 乘以 page_size,产生约 33 GiB 幻影需求,导致 Falcon-H1 在 page_size=256 时启动失败(GPU eval_434/436)。PR 以单一来源函数 + 对拍测试锁定该行为。

实现拆解

  1. 容量判定下沉到分配器allocator/base.pyBaseTokenToKVPoolAllocator 上新增 evict_to_free_tokenscheck_decode_capacityverify_byte_accounting 三个默认 hook,各自精确复现历史行为(token 驱逐、available_size() >= num_tokens、空列表),使 schedule_batch.py::check_decode_mem 直接委托、scheduler.py::on_idle 漏检查经既有 leak-report 通道上报,调度器侧零功能分支。

  2. 字节守恒验证器multi_ended_allocator.py 新增 _byte_accounting_violations(frontier 越界 + lazy 模式 watermark span == live + holes + pending)、_chain_byte_accounting_violations(chain 内 frontier 全序,检测共享 buffer 中 band 重叠)、_end_pair_chain(按 grow direction 排序,参数顺序无关);mamba 与 SWA 两个 composite 各自实现 verify_byte_accounting。违规只报告不 raise,避免 idle 路径因诊断升级造成可用性事件。

  3. 从 profiled budget 直接定容MemoryPoolConfig 新增 unified_total_bytes 数据契约字段;kv_cache_configurator.py::config_from_budget 在 unified 路径把预算 floor-align 到 4096 B 后写入(factory 会 .view() 整个 uint8 buffer 为 KV dtype,flooring 保证不 overcommit),经 _PoolSizes 传入 mamba/SWA factory;init_unified_swa_pools 直接以预算为 buffer 大小,init_unified_mamba_pools 在预算上叠加 state pool 字节;不设预算或 token-capped 重推导时字段保持 None,走旧 token 数求和逐位兼容,draft worker 显式传 None

  4. bs=1 可行性下限:新 _reserved_floor_bytes 把 slot-0 sink 预留公式收敛为单一事实来源(mamba entry 不乘 page_size),UnifiedKVPool.__init__ 与两个 factory 的 floor 共用;_check_bs1_feasibility_floor 在构造前对 floor_terms 求和,不足则 raise 带分项明细的 RuntimeError。Mamba floor 只收 3 个 state slot + sink,SWA floor 只收 min(model_context_len, sliding_window_size + page_size) 的窗口 + sink——full-attention 侧不收,因为 TpModelWorker.get_worker_info 已把 max_req_len clamp 到 pool,过长请求在准入时被拒而非 livelock。

  5. 测试配套:新增 test_unified_byte_budget_sizing.py(预算精确认可、fallback 保真、rounding 场景、floor 单一来源、bs=1 边界与上下文超长回归)和 test_unified_byte_accounting.py(健康生命周期每步守恒、live/hole/watermark 单点漂移检测、chain 重叠与方向无关排序),均注册到 base-a-test-cpu 套件。

文件 模块 状态 重要度
python/sglang/srt/mem_cache/multi_ended_allocator.py 字节分配器 modified 8.34
python/sglang/srt/mem_cache/unified_memory_pool.py 统一内存池 modified 8.0
test/registered/unit/mem_cache/test_unified_byte_budget_sizing.py 预算测试 added 8.05
test/registered/unit/mem_cache/test_unified_byte_accounting.py 守恒测试 added 7.84
python/sglang/srt/mem_cache/allocator/base.py 分配器基类 modified 7.45
python/sglang/srt/mem_cache/kv_cache_configurator.py 缓存配置器 modified 6.41
python/sglang/srt/managers/scheduler.py 调度器 modified 6.01
python/sglang/srt/model_executor/pool_configurator.py 池配置器 modified 5.72
python/sglang/srt/managers/schedule_batch.py 批调度 modified 5.68

关键符号

evict_to_free_tokens check_decode_capacity verify_byte_accounting _byte_accounting_violations _chain_byte_accounting_violations _end_pair_chain _reserved_floor_bytes _check_bs1_feasibility_floor

关键源码片段

python/sglang/srt/mem_cache/multi_ended_allocator.py core-logic

字节守恒验证器的核心实现:per-pool 守恒、chain frontier 全序、方向无关配对,并挂到 mamba 与 SWA 两个 composite 上。

def _byte_accounting_violations(self) -> List[str]:
    """Per-sub-pool 字节守恒:frontier 必须落在共享 buffer 内;
    lazy 模式下 watermark span 必须等于 live + holes + pending。
    这是 idle 期诊断,纯 host 算术,不触碰 GPU 数据。"""
    out: List[str] = []
    total = self.unified_buffer.total_bytes
    lo_b, hi_b = self._byte_low_frontier(), self._byte_high_frontier()
    # grow-up 侧 high 是最后分配页之后的字节,grow-down 侧 low 是
    # 最低存活页之下的字节;任何一侧越界都说明 band 跑出了 buffer。
    if not (0 <= lo_b <= hi_b <= total):
        out.append(
            f"[{self.sub_pool_name}] frontier out of bounds: "
            f"low={lo_b}, high={hi_b}, total={total}"
        )
    if self.lazy_compaction:
        # lazy 端:watermark 区间内含 live + holes + pending(eager
        # 无洞与待压实,span 天然等于 live)。任一计数器漂移都会现形。
        holes = int(self._free_phys_pages.numel())
        pending = len(self._pending_reuse_pages_cpu)
        wm_span = self._allocated_pages()
        if wm_span != self.live_page_count + holes + pending:
            out.append(
                f"[{self.sub_pool_name}] span {wm_span} != live "
                f"{self.live_page_count} + holes {holes} + pending {pending}"
            )
    return out
​
​
def _chain_byte_accounting_violations(
    chain: List[MultiEndedAllocator],
) -> List[str]:
    """按低地址→高地址排列的一串 band allocator 的守恒检查:每个成员各自
    守恒之外,还要求后一个成员的 low frontier 越过前一个成员的 high
    frontier,否则两个 band 在同一 byte buffer 里重叠。今天的 chain 是
    2-pool end pair;N-pool track 会在中间插入 float middles。"""
    out: List[str] = []
    for a in chain:
        out.extend(a._byte_accounting_violations())
    frontier = 0
    for a in chain:
        lo_b, hi_b = a._byte_low_frontier(), a._byte_high_frontier()
        if lo_b < frontier:
            out.append(
                f"[chain] {a.sub_pool_name} low frontier {lo_b} overlaps the "
                f"previous pool's high frontier {frontier}"
            )
        frontier = max(frontier, hi_b)
    return out
​
​
def _end_pair_chain(
    a: MultiEndedAllocator, b: MultiEndedAllocator
) -> List[MultiEndedAllocator]:
    """把 end pair 按 grow direction 排成低→高顺序:工厂与单测夹具对 pair
    的朝向不同,chain 检查必须对参数顺序不敏感(grow-up 在前、grow-down
    在后)。"""
    return sorted((a, b), key=lambda x: x.grow_direction != "up")
python/sglang/srt/mem_cache/unified_memory_pool.py core-logic

profiled budget 直接定容、bs=1 可行性 floor 与 `_reserved_floor_bytes` 单一事实来源都在此落地,是行为变更的主战场。

def _reserved_floor_bytes(sub_pool_specs: List[SubPoolSpec], page_size: int) -> int:
    """buffer 底部为 slot-0 哑写预留的 padding sink 字节数。    slot-0 哑写会落在这里;每个 sub-pool 的第一个可分配 slot 选在它之后。
    page-aware 子池的 slot-0 写会触达整个 page-0 envelope(最多
    page_size * entry_bytes),而 mamba 子池是 page_size=1,其 entry 只收一次——
    若按 page 乘 mamba entry,会在 pool 里虚留 page_size * ~100 MB 从不会被
    触碰的缓冲(GPU eval_434/436 Falcon-H1 启动回归即源于此)。    `UnifiedKVPool` 预留的正是这个值,factory 的 bs=1 floor 也收这个值——
    单一事实来源,避免公式再次漂移。"""
    return max(
        [max(s.entry_bytes() for s in sub_pool_specs)]
        + [
            page_size * s.entry_bytes()
            for s in sub_pool_specs
            if not isinstance(s, MambaSubPoolSpec) # mamba 是 page_size=1
        ]
    )
​
​
def _check_bs1_feasibility_floor(
    *, total_bytes: int, floor_terms: List[Tuple[str, int]], factory: str
) -> None:
    """bs=1 可行性下限——retract 循环的终端保证。    调度器会把请求 retract 到只剩最后一个;若最坏情况请求单独运行都放不进
    buffer,配置过小就会变成运行时 retract livelock(挂起而非报错)。因此
    在任何 pool 构建之前,启动期就要带明细地 fail loud。total_bytes >= floor
    时直接通过(>= 是契约,恰好相等不算失败)。"""
    floor = sum(b for _, b in floor_terms)
    if total_bytes >= floor:
        return
    detail = " + ".join(f"{name}={b}" for name, b in floor_terms)
    raise RuntimeError(
        f"[unified-memory-pool] {factory}: byte budget {total_bytes} cannot fit "
        f"ONE worst-case request (bs=1 floor {floor} = {detail}). A pool this "
        f"size retract-livelocks at runtime. Raise --mem-fraction-static, lower "
        f"the model context length, or reduce reserved memory."
    )
python/sglang/srt/mem_cache/allocator/base.py core-logic

allocator-owned 容量 hooks 的基类默认实现,调度器零功能分支的关键。

# -- scheduler-facing capacity hooks --
# 调度器无条件调用这些 hook(调度器侧零功能分支);默认实现精确复现历史
# token 行为,unified composite 用字节口径逻辑覆盖它们。def evict_to_free_tokens(self, tree_cache, num_tokens: int) -> None:
    """让前缀缓存驱逐未锁条目,直到此 allocator 能满足 num_tokens
    (或没有可驱逐项)。默认走共享的 token 数驱逐;joint-byte composite
    覆盖(驱逐一个多生命周期树节点会在多个 side 同时释放字节)。"""
    from sglang.srt.mem_cache.common import evict_from_tree_cache
​
    evict_from_tree_cache(tree_cache, num_tokens)def check_decode_capacity(self, *, num_tokens: int, tree_cache) -> bool:
    """下一个 decode 步的 num_tokens 分配是否放得下(先驱逐可回收缓存)。
    retract 循环收敛到同一个检查,所以 allocator 侧的不足会优雅 retract,
    而不是触发 fail-loud alloc 错误。默认复现历史
    `ScheduleBatch.check_decode_mem` 的正文;unified composite 用字节门
    + 自己的逐拍预留覆盖。"""
    self.evict_to_free_tokens(tree_cache, num_tokens)
    return self.available_size() >= num_tokensdef verify_byte_accounting(self) -> list:
    """idle 期守恒诊断:重算本 allocator 的字节/slot 记账,返回可读的
    违规串(空 == 健康)。默认:静态池没有字节模型。"""
    return []

评论区精华

bs=1 floor 是否应计入 full-context token 正确性

初版 floor 收取 `model_context_len * full_entry_bytes`,理由是“单个请求装不下就是 retract livelock”;合并者 ch-wan 指出 `TpModelWorker.get_worker_info` 已把 `max_req_len` clamp 到池容量,长于池的请求在准入时被拒而非 livelock,且该 term 会让单 GPU 长上下文模型的健康配置启动失败。

结论:移除该 term;floor 只收 mamba state slots / SWA window + slot-0 sink,并同步修正 call site 注释。 · 已解决

sink 预留公式的单一事实来源 正确性

`_reserved_floor_bytes` 之前是手抄公式,把 page_size 乘到 mamba entry 上,产生约 33 GiB 幻影需求导致 Falcon-H1 在 page_size=256 时拒绝启动;PR 提炼为单一函数并让 `UnifiedKVPool` 与 factory floor 共用。

结论:单一来源 + 对拍测试(test_floor_sink_equals_what_the_pool_reserves)锁定。 · 已解决

守恒诊断报告 vs 升级异常 设计

verifier 运行在 idle 路径,直接 raise 会把诊断变成可用性事件;PR 采用只报告、走现有 leak-report 通道,严格升级由 env 控制且默认关。

结论:只报告不 raise 是生产姿势,严格模式留给验证场景。 · 已解决

解码容量 gate 的归属 设计

check_decode_mem 从 schedule_batch.py 内联逻辑改为委托 allocator 的 check_decode_capacity;admission-budget 提取被有意推迟到第一个真实 consumer(unified byte budget)出现时,避免无 consumer 的死代码。

结论:allocator-owned hooks,默认逐位复现历史行为;调度器零功能分支。 · 已解决

风险与影响

  1. 启动期新检查的误判风险_check_bs1_feasibility_floor 计算错误会拒绝健康配置。历史上已有手抄公式把 mamba entry 按 page_size 乘的教训(约 33 GiB 幻影需求),本 PR 通过 _reserved_floor_bytes 单一来源与对拍测试缓解,但未来新增 sub-pool spec 类型时仍需同步维护该函数。

  2. 内存足迹变化:直接以预算定容后,SWA 与 Mamba 模型的 unified buffer 各增加约 12.42 MB / 11.35 MB,Qwen3.5-9B 增加 0.36 MB,Kimi-Linear 增加 4.5 KB。这是利用率提升,但相同配置下显存占用略增,与 #35154 对比测量需留意基线漂移。

  3. 容量判定语义迁移check_decode_memschedule_batch.py 内联逻辑改为委托 allocator。静态池默认行为逐位复现,但 unified composite 未来的字节口径覆盖版本一旦算错,会表现为准入过宽(静默损坏)或过紧(吞吐下降)。当前 PR 未提供 unified 的 decode byte gate 覆盖(留给下一代字节准入账本),该风险是“已预留、未启用”。

  4. 诊断非阻断verify_byte_accounting 只报告不 raise,计数漂移不会被主动 fail——这是可用性取舍,实际依赖 leak-report 通道被运维监控;严格升级由 env 控制且默认关。

  5. 兼容性:未设预算、draft worker、token-capped 路径全部回退旧行为;4096 B flooring 最多损失 4095 B 预算,不会 overcommit,静态池完全不受影响。

  • 用户/运维:配置过小的 unified pool 从“运行期服务器挂起”变为“启动时带分项明细报错”,可诊断性显著提升;统一内存池实际可用容量比旧 re-sum 多出每侧约一页 token。
  • 系统:unified-memory 功能线(#35154 之后的第二相位)补上容量闭环:预算 → 定容 → 可行性 → 运行时守恒校验。
  • 团队:确立了 allocator-owned capacity hook 模式,后续字节准入账本可以直接在 allocator 层落地;调度器不再感知池类型差异。
  • 范围:静态内存池路径完全不受影响;只影响开启 --enable-unified-memory 的 hybrid Mamba / SWA 模型。
核心分配路径变更 启动期新增 fail-loud 检查 内存足迹增加约 12 MB/ 侧 诊断默认只报告不中断 跨调度 / 配置 / 内存池模块改动

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论