Prhub

#34053 [Fix] Account resident weight memory in KV sizing

原始 PR 作者 galletas1712 合并时间 2026-08-27 17:43 文件变更 9 提交数 4 评论 3 代码增减 +72 / -0

执行摘要

修正权重缓存驻留内存导致 KV 过度分配、启动 OOM

PR body 明确指出:IPC weight-cache daemon 在引擎启动前就让权重常驻显存,ModelRunner 在权重已占用显存之后才采样 pre-load 空闲内存基线;不校正该基线时,KV sizing 只基于剩余空闲内存预留 headroom,会过度分配 KV cache。实测中两个客户端依次连接同一 daemon 时,无修复版本两次都在 decode CUDA graph 捕获阶段 CUDA OOM,而 daemon 仍存活。校正必须在分布式 MIN 之前按 rank 本地完成,因为各 rank 的权重驻留量不同,MIN 之后再补偿会算错;同时 target KV sizing 发生在 draft 加载之后,目标、多 runner 与未来 speculative draft 权重必须先聚合再参与 sizing。

值得精读。重点学习两个设计决策:其一,rank-local 校正必须先于分布式 MIN,因为不同 rank 的权重驻留量不同,MIN 之后再补偿会算错;其二,用“loader 字段默认 0 + no-op 校正”替代显式 daemon 模式判断,使普通启动零开销、未来任何预驻留权重的 loader 都能复用同一钩子。此外,review 中“去掉额外本地快照与第二次 MIN,直接加回已有基线”的演进展示了如何收敛过度防御的实现。若团队后续推广此机制,建议补充 daemon 集成测试与 KV sizing 单测。

讨论亮点

review 核心讨论如下:

  • alexnails(COMMENTED)提出两点:一是该校正只在 weight daemon 启用时需要;二是 reduce_min_gpu_memory 及其结果 local_prep_model_load 或许应放进 global_ctx 或并入 available_gpu_memory。作者 galletas1712 回应:account_preloaded_weights 在 loader 上报 0 字节时是 no-op,普通启动不会多做一次规约;保留为 loader 字段默认 0 而非显式 --weight-cache-mode 判断,是为了让任何“映射已驻留权重”的 loader 都能复用同一钩子。同时作者承认原实现过度防御——daemon 只破坏 pre_model_load_memory 的首次测量,因此删掉了额外的本地快照和第二次 MIN,改为直接把字节加回已有 MIN 后的基线上(对应 commit 86b751c)。

  • liusy58(APPROVED)在 code review 中建议避免 getattr 访问 loader 字段,作者确认修复,最终提交 e7ac84d 改为 BaseModelLoader 直接声明字段、ModelRunner 直接访问。

  • 其余审核状态:liusy58 最终 APPROVED,PR 由 ch-wan 合并。

实现拆解

实现拆解如下:

  1. daemon 侧测量与协议返回python/sglang/srt/weight_cache/daemon.pyDaemonState.load() 在加载权重前先 current_platform.empty_cache() 并记录 torch.cuda.memory_reserved(self.gpu_id),加载完成并 synchronize() 后再 empty_cache(),以 max(0, 加载后 reserved - 加载前 reserved) 作为进程内 PyTorch allocator 的净增长,存入 DaemonState.preloaded_weights_bytes_handle_connection 在响应中随 entries 一并返回该字段。这样客户端无需自行猜测权重占用。

  2. loader 契约与客户端读取python/sglang/srt/model_loader/loader.pyBaseModelLoader 新增默认字段 preloaded_weights_bytes = 0python/sglang/srt/weight_cache/ipc_loader.pyIpcModelLoader.load_model() 每次开始时把该字段重置为 0,从 cache_data.get("preloaded_weights_bytes", 0) 读取(旧 daemon 缺失该字段时按 0 处理,保持兼容),并显式校验返回值必须是非 bool、非负 int,非法时抛 RuntimeError 而不是静默接受脏元数据;成功映射权重后写入 self.preloaded_weights_bytes

  3. rank-local 校正python/sglang/srt/model_executor/model_runner.py 新增 preloaded_weights_bytes property(从 loader 取值并校验非负 int,非法抛 ValueError)与 account_preloaded_weights() 方法(字节数换算成 GiB 后加回 pre_model_load_memory,0 字节时直接 no-op)。这保证校正发生在分布式 MIN 之前的本地 rank,且初始 sizing 与 capture 后 resizing 共用同一个校正后的基线。

  4. 跨 worker 聚合python/sglang/srt/managers/tp_worker.pyTpModelWorkerpython/sglang/srt/speculative/base_spec_worker.py 分别新增 preloaded_weights_bytes property,汇总 model_runner_listdraft_runners 的字节数;python/sglang/srt/managers/scheduler.pyinit_target_memory_pool() 在 KV 池分配前把 target 与 draft 的预驻留字节相加后调用 account_preloaded_weights()。代码注释明确承认“daemon + speculative draft”组合仍被显式拒绝(draft daemon 支持未实现),但核算路径已为其就绪。

  5. 配套改动python/sglang/srt/weight_cache/transport.py 把该字段接入可插拔 transport 协议(merge main 时适配了新的 transport 结构);test/registered/unit/model_loader/test_weight_cache_protocol.py 补充了字段相关的协议断言。整体为纯增量改动(+72/-0),普通非 daemon 启动路径因 loader 上报 0 字节而完全不受影响。

文件 模块 状态 重要度
python/sglang/srt/model_executor/model_runner.py 内存核算 modified 7.22
python/sglang/srt/managers/scheduler.py 调度器 modified 5.84
python/sglang/srt/weight_cache/daemon.py 权重缓存 modified 5.71
python/sglang/srt/weight_cache/ipc_loader.py 权重缓存 modified 6.23
python/sglang/srt/speculative/base_spec_worker.py 投机解码 modified 5.74
python/sglang/srt/managers/tp_worker.py TP 执行 modified 5.53
python/sglang/srt/model_loader/loader.py 模型加载 modified 4.88
python/sglang/srt/weight_cache/transport.py 权重缓存 modified 4.67
test/registered/unit/model_loader/test_weight_cache_protocol.py 协议测试 modified 3.63

关键符号

ModelRunner.preloaded_weights_bytes ModelRunner.account_preloaded_weights Scheduler.init_target_memory_pool TpModelWorker.preloaded_weights_bytes BaseSpecWorker.preloaded_weights_bytes IpcModelLoader.load_model DaemonState.load

关键源码片段

python/sglang/srt/model_executor/model_runner.py data-contract

核心核算入口:新增 preloaded_weights_bytes property(校验 loader 上报值)与 account_preloaded_weights 方法(把驻留字节加回 pre_model_load_memory),确保校正发生在分布式 MIN 之前的本地 rank,且初始 sizing 与 resizing 共用同一基线。

class ModelRunner:
    # ...(类头与初始化省略)
​
    @property
    def preloaded_weights_bytes(self) -> int:
        # 统一从 loader 读取预驻留权重字节数并校验合法性:
        # 拒绝 bool、非 int、负数,避免脏元数据污染 KV 容量计算
        value = self.loader.preloaded_weights_bytes
        if isinstance(value, bool) or not isinstance(value, int) or value < 0:
            raise ValueError(
                "ModelLoader.preloaded_weights_bytes must be a non-negative int, "
                f"got {value!r}"
            )
        return value
​
    def account_preloaded_weights(self, preloaded_weights_bytes: int) -> None:
        # dist-init 采样的 pre_model_load_memory 发生在 daemon 已驻留权重之后,
        # 导致 KV slack(B * (1 - mem_fraction_static))偏小、KV 分配偏大;
        # 把驻留字节数加回已经过分布式 MIN 的基线上即可恢复正确 slack。
        # loader 上报 0 时直接跳过,普通启动不会多付出一次规约开销。
        if preloaded_weights_bytes == 0:
            return
        self.pre_model_load_memory += preloaded_weights_bytes / (1 << 30)
​
    def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = None):
        """只分配 KV cache 内存池(不初始化后端与 CUDA graph)。"""
        if memory_pool_config is not None:
            self.memory_pool_config = memory_pool_config
​
        self.init_kv_cache_configurator()
        # 初始 sizing 与 graph capture 后的 resizing 均使用校正后的基线
        result = self.kv_cache_configurator.configure(
            pre_model_load_memory=self.pre_model_load_memory
        )
        self.max_total_num_tokens = result.max_total_num_tokens
        self.max_running_requests = result.max_running_requests
        self.req_to_token_pool = result.req_to_token_pool
        self.token_to_kv_pool = result.token_to_kv_pool
        self.token_to_kv_pool_allocator = result.token_to_kv_pool_allocator
        self.memory_pool_config = result.memory_pool_config
        if self.is_hybrid_swa:
            self.full_max_total_num_tokens = result.full_max_total_num_tokens
            self.swa_max_total_num_tokens = result.swa_max_total_num_tokens
        # 保留引用,防止共享 byte buffer 被 GC 回收
        self._unified_memory_pool = result.unified_memory_pool
​
        self._init_post_memory_pool_components()
python/sglang/srt/managers/scheduler.py core-logic

聚合校正的调用点:init_target_memory_pool 在 KV 池分配前把 target 与 draft worker 的预驻留字节相加并调用 account_preloaded_weights,是修复生效的关键编排。

def init_target_memory_pool(self):
    # 已分配过 KV 池则直接返回,避免重复校正导致基线被加倍
    if (
        self.tp_worker.model_runner.token_to_kv_pool is not None
        and self.tp_worker.model_runner.token_to_kv_pool_allocator is not None
    ):
        return
​
    # 聚合 target(含 multi-runner)与 speculative draft worker 的预驻留权重字节数;
    # 必须在分布式 MIN 之前完成 rank-local 校正:
    # 各 rank 驻留量不同,MIN 之后再补偿会算错
    preloaded_weights_bytes = self.tp_worker.preloaded_weights_bytes
    if self.draft_worker is not None:
        preloaded_weights_bytes += self.draft_worker.preloaded_weights_bytes
​
    # 立即在 KV 池分配前把字节加回 pre_model_load_memory,
    # 使初始 sizing 与 capture 后 resizing 使用同一校正值
    self.tp_worker.model_runner.account_preloaded_weights(preloaded_weights_bytes)
    self.tp_worker.alloc_memory_pool()
python/sglang/srt/weight_cache/daemon.py core-logic

数据来源:DaemonState 测量进程内 PyTorch allocator 在权重加载前后的净增长,并通过 IPC 响应的 preloaded_weights_bytes 字段返回给客户端。

def load(self):
    # ... 前置配置与 fingerprint 计算省略 ...
​
    # 先清空缓存再采样,保证 memory_reserved 增量只反映权重加载本身,
    # 避免 caching allocator 的残留分配污染测量
    current_platform.empty_cache()
    memory_before_load = torch.cuda.memory_reserved(self.gpu_id)
​
    # ... 实际加载权重并做 quant 预处理 ...
​
    # 同步后再清一次缓存,随后计算进程内 allocator 的净增长;
    # max(0, ...) 防止异常路径下出现负值
    current_platform.synchronize()
    current_platform.empty_cache()
    self.preloaded_weights_bytes = max(
        0, torch.cuda.memory_reserved(self.gpu_id) - memory_before_load
    )
​
    # 把全部权重导出为 IPC handle 供客户端零拷贝映射
    self._export_state()

评论区精华

校正范围与实现位置:仅 daemon 需要?local_prep_model_load 归属? 设计

alexnails 提出两点:一是该校正只在 weight daemon 启用时需要;二是 reduce_min_gpu_memory 及其结果 local_prep_model_load 或许应放进 global_ctx 或并入 available_gpu_memory。galletas1712 回应:account_preloaded_weights 在 loader 上报 0 字节时是 no-op,普通启动不会多做一次规约;保留为 loader 字段默认 0 而非显式 --weight-cache-mode 判断,是为了让任何映射已驻留权重的 loader 都能复用同一钩子;并承认原实现过度防御——daemon 只破坏 pre_model_load_memory 的首次测量,因此删掉了额外本地快照与第二次 MIN,直接加回已有 MIN 后的基线。

结论:采用“loader 字段默认 0 + no-op 校正”的通用契约;去掉额外快照与第二次 MIN,收敛为对现有基线的增量补偿。 · 已解决

避免 getattr 访问 loader 字段 style

liusy58 在 code review 中指出“We had better try to avoid using getattr”;galletas1712 回复“Thanks for the catch - fixed.”,最终提交 e7ac84d 改为 BaseModelLoader 直接声明字段后由 ModelRunner 直接访问。

结论:改为在 BaseModelLoader 上显式声明 preloaded_weights_bytes 字段,ModelRunner 直接访问,消除了 getattr 兜底。 · 已解决

风险与影响

技术风险如下:

  • 测量依赖 allocator 行为daemon.pytorch.cuda.memory_reserved 增量估算权重驻留量,虽在测量前后各做一次 empty_cache() 降低缓存分配噪声,但显存碎片化可能让增量略大于真实权重;若 daemon 与客户端 PyTorch 版本或 allocator 行为差异较大,测量值可能偏离实际驻留量。
  • 新增 loader 数据契约ModelRunner.preloaded_weights_bytes 对 loader 上报值做硬校验,任何第三方自定义 loader 若未实现该字段或返回非法值,会在启动时抛 ValueError——这是有意的契约强制,但属于新增的失败路径,需确保仓库内所有 loader 已实现默认 0 字段。
  • 旧 daemon 兼容ipc_loader.py 对缺失字段按 0 处理,行为降级为“不校正”,不会崩溃;但旧 daemon 下缺陷仍存在,属预期渐进式兼容。
  • 双计数风险scheduler.init_target_memory_pool() 有已分配判断避免重复校正;若未来放开 draft daemon 组合而实现不完整,聚合路径可能产生错误基线,当前通过显式拒绝规避。
  • 测试覆盖偏薄:仅协议测试 +2 行断言,缺少 daemon 集成测试或 KV sizing 单测来锁定校正逻辑,回归主要依赖作者的人工 A/B 验证。

对用户:使用 weight-cache daemon 且模型权重大于一半显存的场景(如 80 GB 卡跑 Qwen2.5-32B BF16)从确定性启动 OOM 变为可正常启动;修复后 KV token 数从 63,434 降为 38,430,说明 KV 容量核算更保守且正确。对系统:启动路径新增两次进程内 allocator 读取和一次已有 world-group 规约,稳态推理路径完全不变;普通(非 daemon)启动因 loader 上报 0 字节而零额外开销。对团队:引入了通用的 preloaded_weights_bytes loader 契约,为未来任何“预驻留权重”的加载器(含 speculative draft daemon)提供了统一的核算入口,但也要求后续 loader 实现遵守该契约。

权重缓存场景专用修复 依赖 allocator 内存测量 新增 loader 数据契约 测试覆盖较薄

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论