Prhub

#34981 [model-loader] Split weight loading from postprocessing

原始 PR 作者 windscope 合并时间 2026-09-15 08:10 文件变更 2 提交数 3 评论 2 代码增减 +127 / -0

执行摘要

拆分权重加载与后处理两阶段,保持组合接口不变

PR body 指出原实现把 checkpoint 加载与模块后处理耦合在同一个方法中:Plugins that inspect installed weights need a method boundary between those phases。即插件若要在权重加载完成后、量化后处理(quant_method.process_weights_after_loading)之前检查已安装的权重,缺少可插入的方法边界。分支名 pre-weight-processing-hook 与首提交 Add a weight-loaded model loader hook 表明这是后续权重处理 hook 机制的铺垫。

值得快速阅读,不必精读。关注两点:一是用显式类名调用的静态拆分如何在保持三参数兼容接口的同时打开阶段边界;二是测试如何用事件序列把“调用顺序”这种隐式契约变成可回归的显式断言。若正在做模型加载插件或扩展点设计,可把该 PR 作为最小可用范例。

讨论亮点

该 PR 未收到任何 reviewer 评论,也没有 review 线程;comments 中仅有作者 windscope 两次发出 /tag-and-rerun-ci 触发 CI 重跑,最终由 merrymercy 直接合入。唯一可观察的“讨论”来自提交历史:f31c7d5 先加 hook 雏形,a9568cc 才完成拆分并补齐测试,体现出先探路、再补契约验证的演进方式。

实现拆解

  1. 拆分组合方法(python/sglang/srt/model_loader/loader.py):load_weights_and_postprocess 从承载全部逻辑改为薄封装,依次调用 DefaultModelLoader.load_weights_only(model, weights, target_device) 与 DefaultModelLoader.postprocess_weights(model, target_device)。原三参数接口保持不变,load_model 与 deferred startup 提交路径的调用点无需改动。
  2. load_weights_only 承担加载阶段:包含原有全部加载逻辑——CUDA 峰值内存观测、mxfp8 的 weight_scale 到 _inv 名字改写、NVFP4 与 ModelOpt FP4 在线量化的 temp_set_env 环境变量作用域及 torch.cuda.synchronize()、empty_cache()。
  3. postprocess_weights 承担后处理阶段:遍历 model.named_modules(),对带 quant_method 的模块在 device_loading_context 内调用 process_weights_after_loading,完整保留 CPU offloading 场景下参数临时搬移的语义。
  4. 测试配套(新增 test/registered/unit/model_loader/test_default_model_loader.py,120 行):第一个用例用事件序列断言先 load 后 postprocess 的顺序;第二个用例覆盖 load_model(直接权重)与 commit_model_weights(deferred startup)两条启动路径,并通过 CustomModelLoader 验证三参数覆写仍然生效,同时锁定 _get_weights_iterator 的 startup_prefetch_started 与 startup_prefetch_active 参数传递。
文件 模块 状态 重要度
python/sglang/srt/model_loader/loader.py 模型加载 modified 5.8
test/registered/unit/model_loader/test_default_model_loader.py 模型加载 added 6.88

关键符号

load_weights_only postprocess_weights load_weights_and_postprocess

关键源码片段

python/sglang/srt/model_loader/loader.py data-contract

核心源码变更:把 load_weights_and_postprocess 拆分为 load_weights_only 与 postprocess_weights 两个静态方法,组合方法变为薄封装。这是插件阶段边界得以存在的基础,也是本 PR 唯一的源码改动。

@staticmethod
def load_weights_and_postprocess(model, weights, target_device):
    # 组合入口:对外保持三参数接口不变,内部按序执行两个阶段。
    # 插件需要阶段边界时,可直接分别调用两个静态方法。
    DefaultModelLoader.load_weights_only(model, weights, target_device)
    DefaultModelLoader.postprocess_weights(model, target_device)
​
@staticmethod
def load_weights_only(model, weights, target_device):
    # 阶段一:仅把 checkpoint 权重写入 model,不做任何后处理。
    # 保留原有内存观测日志:记录加载前的峰值与可用显存。
    if is_cuda_alike():
        peak_memory = torch.cuda.max_memory_allocated()
        logger.debug(
            "Peak GPU memory before loading weights: %s GiB",
            f"{peak_memory / GIB_BYTES:.3f}",
        )
        memory_start = get_available_gpu_memory(
            target_device.type, gpu_id=torch.cuda.current_device()
        )
​
    quant_config = getattr(model, "quant_config", None)
    is_nvfp4_online = getattr(quant_config, "is_nvfp4_online", False)
    is_modelopt_fp4_online = (
        quant_config is not None
        and quant_config.get_name() == "modelopt_fp4"
        and not quant_config.is_checkpoint_nvfp4_serialized
    )
    is_mxfp8 = quant_config is not None and quant_config.get_name() == "mxfp8"
    if is_mxfp8:
        # mxfp8 的 weight_scale 在加载前改写为 <name>_inv 形式。
        weights = (
            (
                f"{name}_inv" if name.endswith(".weight_scale") else name,
                loaded_weight,
            )
            for name, loaded_weight in weights
        )
​
    if is_nvfp4_online or is_modelopt_fp4_online:
        # 在线 FP4 量化仅在加载期生效:临时收紧 FlashInfer 环境变量,
        # 结束后自动恢复,避免影响后续 serving 阶段的数学精度行为。
        with temp_set_env(
            FLASHINFER_DISABLE_FP4_QUANT_FAST_MATH="1",
            FLASHINFER_NVFP4_4OVER6="1",
            FLASHINFER_NVFP4_4OVER6_E4M3_USE_256="0",
            FLASHINFER_NVFP4_4OVER6_ERR_MODE="MSE",
            FLASHINFER_NVFP4_4OVER6_ERR_USE_FAST_MATH="1",
        ):
            model.load_weights(weights)
        if target_device.type == "cuda":
            torch.cuda.synchronize()
            torch.cuda.empty_cache()
    else:
        model.load_weights(weights)
​
    # 与开头配对的显存增量日志,供在线量化内存节省测试使用。
    if is_cuda_alike():
        memory_end = get_available_gpu_memory(
            target_device.type, gpu_id=torch.cuda.current_device()
        )
        logger.debug(
            "Memory increase during load_weights: %s GiB",
            f"{memory_start - memory_end:.3f}",
        )
​
@staticmethod
def postprocess_weights(model, target_device):
    # 阶段二:遍历所有子模块,触发量化方法的权重后处理
    #(重打包、量化等)。CPU offloading 场景下先临时把参数搬上
    # 目标设备处理,再随 device_loading_context 退出搬回。
    for _, module in model.named_modules():
        quant_method = getattr(module, "quant_method", None)
        if quant_method is not None:
            with device_loading_context(module, target_device):
                quant_method.process_weights_after_loading(module)
test/registered/unit/model_loader/test_default_model_loader.py test-coverage

新增 120 行 CPU 单测,是本次改动的主要工作量。用事件序列把调用顺序变成可断言契约,并覆盖正常加载、deferred startup、三参数自定义覆写三个关键场景,防止未来重构破坏阶段边界。

class TestDefaultModelLoader(CustomTestCase):
    def test_load_weights_only_precedes_postprocessing(self):
        # 用事件序列把“先 load 后 postprocess”的调用顺序变成可断言的契约。
        events = []
        model = Mock()
        model.quant_config = None
        module = Mock()
        module.quant_method.process_weights_after_loading.side_effect = (
            lambda _: events.append("postprocess")
        )
        model.load_weights.side_effect = lambda _: events.append("load")
        model.named_modules.return_value = [("layer", module)]
​
        # 在 CPU 环境下绕过 CUDA 分支与设备上下文,聚焦顺序本身。
        with (
            patch.object(loader_mod, "is_cuda_alike", return_value=False),
            patch.object(
                loader_mod,
                "device_loading_context",
                side_effect=lambda *_: nullcontext(),
            ),
        ):
            DefaultModelLoader.load_weights_and_postprocess(
                model,
                iter(()),
                torch.device("cpu"),
            )
​
        self.assertEqual(events, ["load", "postprocess"])

评论区精华

无实质 review 讨论,作者两次触发 CI 重跑 other

PR 没有收到 reviewer 评论;comments 中仅有作者 windscope 发出的两次 /tag-and-rerun-ci 指令,用于重新触发 CI。

结论:无设计争议,合并者 merrymercy 直接合入。 · 已解决

风险与影响

  1. 组合方法的静态绑定语义:load_weights_and_postprocess 通过类名显式调用 DefaultModelLoader.load_weights_only 与 postprocess_weights,在静态方法下不会走子类覆写分派;若未来子类只覆写单一阶段方法并复用组合方法,覆写不会生效。当前仓库内所有子类都覆写组合方法本身,因此无实际回归,但这是新契约的隐性约束。
  2. 精度测试缺失:PR 声明未运行模型精度测试,仅以 CPU 单测验证;虽然逻辑为纯搬移、风险很低,但 NVFP4、ModelOpt FP4、mxfp8 等量化路径缺少端到端验证。
  3. 无性能与并发风险:改动只在模型加载期,不涉及推理执行路径。

对最终用户无行为影响,接口与所有调用路径完全保留。对模型加载插件开发者而言是新的扩展点:可在加载与后处理之间插入权重检查。对团队而言,这是模型加载器插件化方向的第一个契约变更,后续 hook 机制将基于这两个阶段方法扩展。影响范围集中在 python/sglang/srt/model_loader/loader.py 及其子类,普通 serving 流程无感知。

新增公开静态方法契约 组合方法不走子类覆写分派 未运行精度测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论