Prhub

#35873 [diffusion] Fail closed for unsupported quantized component checkpoints

原始 PR 作者 mickqian 合并时间 2026-08-22 09:33 文件变更 12 提交数 3 评论 1 代码增减 +297 / -61

执行摘要

原生组件加载器对不支持的量化检查点 fail-closed 拒绝

PR body 明确说明动机:需要“一个共享 admission boundary 给只物化 plain state dicts 的原生 auxiliary loaders”,并在“模型构造或 tensor 读取之前”检测 top-level、嵌套 text 和 compression 量化元数据。背景是 --component-paths.<component> 让组件路径路由变得通用,但并非每个 loader 都能物化每种量化格式;此前原生 loader 遇到量化 checkpoint 时会尝试把量化权重当作普通 state dict 加载,导致静默错误或难以诊断的启动失败。文档也强调 path routing does not imply that every loader can materialize every quantization format,因此必须以 metadata 驱动、按 loader 能力 fail-closed,而不是用全局 --quantization 覆盖来表达组件量化支持。

建议精读。值得关注的设计决策包括:一是复用 SRT 的 resolve_checkpoint_quant_spec 而不是在 multimodal_gen 内自建量化元数据解析,避免双份解析逻辑漂移;二是用继承 PlainStateDictComponentLoader 的方式收敛 6 个 loader,让准入边界成为模板方法而非复制粘贴;三是 upsampler 的“先读显式配置做检查、再读权重”顺序,把拒绝点前移到 IO 之前;四是测试用 resolve_model_cls.assert_not_called()safetensors_load_file.assert_not_called() 这类副作用断言,精确验证 fail-closed 发生在哪个阶段,是值得复用的测试模式。

讨论亮点

该 PR 没有任何人工 review 评论或讨论线程,唯一的 issue 评论来自 mintlify[bot] 的文档预览部署通知。因此无法提炼评审交锋,只能从 3 个 commit 的顺序推断设计演进:先落地拒绝逻辑(6ab35339),再补充文档澄清量化支持范围(770021e),最后把基类命名从实现导向改为机制导向(0568410 “name plain state-dict loaders by mechanism”),体现“先保证行为正确、再固化命名与抽象”的节奏。PR body 还声明了既有生产代码仅 +67/-43,刻意控制改动面,说明作者有意把准入边界做成低侵入的基类收敛而不是逐 loader 复制检查。

实现拆解

  1. 新增共享准入基类:在 component_loader.py 中新增 PlainStateDictComponentLoader,其静态方法 ensure_plain_state_dict_checkpoint 复用 sglang.srt.model_loader.checkpoint_quantization.resolve_checkpoint_quant_spec 解析 config;解析抛 TypeError/ValueError 时包装为 ComponentCheckpointUnsupportedErrorquant_spec is None 时放行,否则拒绝并在错误信息中带上组件名、来源字段 quant_spec.sourcequant_method。同时新增 load_component_config 统一“读配置 + 准入检查”入口。

  2. 五个 loader 机械切换到新基类adapter_loader.pybridge_loader.pydiffusion_decoder_loader.pysound_tokenizer_loader.pyvocoder_loader.py 全部从继承 ComponentLoader 改为继承 PlainStateDictComponentLoader,并把 get_diffusers_component_config(...) 替换为 self.load_component_config(...),删除各自的重复 import。这样准入检查自动发生在 ModelRegistry.resolve_model_cls 构造模型之前。

  3. Upsampler 特殊重构upsampler_loader.py 的配置可能来自 safetensors 元数据、同目录 config.json 或 HF URL,原 _load_config 同时承担“读显式配置”和“从 state dict 推断”。本 PR 拆出 _load_explicit_config(只读显式配置,返回原始 dict 或 None,不再提前 _normalize_config),在 safetensors_load_file 读取权重之前先执行显式配置的准入检查;无显式配置时才走 _infer_config_from_state_dict

  4. 测试覆盖:新增 test/unit/test_component_quantization_admission.py(+146 行),覆盖普通 checkpoint 放行、三种量化元数据布局 fail-closed、不可解析元数据 fail-closed、6 个 loader 的继承关系,并用 mock 断言 resolve_model_clssafetensors_load_file 均未被调用,验证拒绝发生在模型构造和权重读取之前。

  5. 文档配套quantization.mdx 新增 “Quantized Component Repositories” 三路径行为矩阵(SGLang 量化实现 / 委托 Transformers 或 Diffusers / 原生 plain-state loader fail-closed),cli.mdx 强调路径选择与量化支持分离、--quantization 只作用于 transformer loader,compatibility_matrix.mdxindex.mdx 同步微调。

文件 模块 状态 重要度
python/sglang/multimodal_gen/runtime/loader/component_loaders/component_loader.py 准入守卫 modified 7.74
python/sglang/multimodal_gen/runtime/loader/component_loaders/upsampler_loader.py 组件加载器 modified 7.42
python/sglang/multimodal_gen/runtime/loader/component_loaders/adapter_loader.py 组件加载器 modified 6.28
python/sglang/multimodal_gen/test/unit/test_component_quantization_admission.py 准入测试 added 7.58
docs/docs/sglang-diffusion/quantization.mdx 量化文档 modified 4.31

关键符号

ensure_plain_state_dict_checkpoint load_component_config _load_explicit_config UpsamplerLoader.load_customized AdapterLoader.load_customized BridgeLoader.load_customized DiffusionDecoderLoader.load_customized SoundTokenizerLoader.load_customized VocoderLoader.load_customized

关键源码片段

python/sglang/multimodal_gen/runtime/loader/component_loaders/component_loader.py core-logic

新增 PlainStateDictComponentLoader 基类与 ensure_plain_state_dict_checkpoint/load_component_config,是整个准入边界的核心;通过复用 SRT resolve_checkpoint_quant_spec 统一量化元数据判定,其余 6 个 loader 都依赖这里。

class PlainStateDictComponentLoader(ComponentLoader):
    """原生 plain-state 组件加载器的共享基类。    这类加载器只能物化未量化的原始 state dict(通过 load_state_dict 加载),
    因此必须在模型构造、权重读取之前拒绝携带量化元数据的 checkpoint。
    子类只需继承本类并通过 load_component_config 读取配置,即可自动获得
    该准入边界,无需各自复制检查逻辑。
    """
​
    @staticmethod
    def ensure_plain_state_dict_checkpoint(config: object, component_name: str) -> None:
        try:
            # 复用 SRT 侧统一的 checkpoint 量化解析器,避免在 multimodal_gen
            # 内部维护第二套 quantization_config 解析逻辑,保持判定口径一致。
            quant_spec = resolve_checkpoint_quant_spec(config)
        except (TypeError, ValueError) as error:
            # 元数据无法解析(如 quantization_config 不是 dict)也按
            # fail-closed 处理:无法证明未量化就拒绝,防止静默加载错误。
            raise ComponentCheckpointUnsupportedError(
                f"Cannot parse checkpoint quantization metadata for "
                f"{component_name!r}: {error}"
            ) from error
​
        if quant_spec is None:
            # 未声明任何量化元数据,属于普通 checkpoint,放行。
            return
​
        # 声明了量化方法但当前 materializer 无法恢复,直接拒绝。
        method = quant_spec.declared_method or "unspecified"
        raise ComponentCheckpointUnsupportedError(
            f"{component_name!r} checkpoint declares quantization metadata in "
            f"{quant_spec.source} (quant_method={method!r}), which its current "
            "plain state-dict materializer cannot restore."
        )
​
    def load_component_config(
        self, component_model_path: str, component_name: str
    ) -> dict[str, Any]:
        # 统一读取入口:先取 diffusers 组件 config,随即做量化准入检查,
        # 保证任何子类在拿到 config 之后、构造模型之前就被拦截。
        config = get_diffusers_component_config(component_path=component_model_path)
        self.ensure_plain_state_dict_checkpoint(config, component_name)
        return config
python/sglang/multimodal_gen/runtime/loader/component_loaders/upsampler_loader.py core-logic

唯一需要重构配置读取流程的 loader:因为 upsampler 配置可能藏在 safetensors 元数据或兄弟 config.json 中,必须把显式配置读取与 state dict 形状推断拆开,才能把准入检查前置到权重读取之前。

def _load_explicit_config(
    safetensors_path: str,
    original_path: str,
) -> dict | None:
    """读取显式 upsampler 配置,按以下优先级回退:    1. safetensors 元数据中的 config 键(LTX-2 原始仓库格式)
    2. 同目录 config.json(diffusers 格式)
    3. 通过 URL 从 Hugging Face 下载 config.json    与旧版 _load_config 不同,这里不再立即执行 _normalize_config,
    而是返回原始 dict,以便准入检查能看到 quantization_config 等
    元数据字段;配置规范化推迟到检查通过之后。
    """
    with safetensors.safe_open(safetensors_path, framework="pt") as f:
        meta = f.metadata()
        if meta and "config" in meta:
            logger.info("Using config from safetensors metadata")
            return json.loads(meta["config"])
​
    config_json_path = os.path.join(os.path.dirname(safetensors_path), "config.json")
    if os.path.isfile(config_json_path):
        with open(config_json_path) as fp:
            logger.info("Using config from sibling config.json")
            return json.load(fp)
​
    # URL 场景:从 Hugging Face 下载同目录的 config.json。
    hf = _parse_hf_url(original_path)
    if hf:
        repo_id, revision, filename = hf
        config_filename = os.path.dirname(filename) + "/config.json"
        try:
            local = _download_hf_file(repo_id, config_filename, revision)
            with open(local) as fp:
                logger.info("Using config from HF config.json")
                return json.load(fp)
        except Exception:
            pass
​
    # 没有显式配置时返回 None,由调用方从 state dict 形状推断。
    return None
​
​
class UpsamplerLoader(PlainStateDictComponentLoader):
    component_names = ["spatial_upsampler"]
    expected_library = "diffusers"
​
    def load_customized(
        self,
        component_model_path: str,
        server_args: ServerArgs,
        component_name: str,
    ):
        safetensors_path = _find_safetensors_file(component_model_path)
        # 先读显式配置并执行量化准入检查,确认未声明量化元数据后
        # 才读取权重张量,避免为不支持的 checkpoint 付出 IO 与显存成本。
        raw_config = _load_explicit_config(safetensors_path, component_model_path)
        if raw_config is not None:
            self.ensure_plain_state_dict_checkpoint(raw_config, component_name)
​
        state_dict = safetensors_load_file(safetensors_path)
        if raw_config is None:
            logger.info("No explicit config found, inferring from state dict")
            config = _infer_config_from_state_dict(state_dict)
        else:
            config = _normalize_config(raw_config)
​
        logger.info("Loading LatentUpsampler with config: %s", config)
        # 后续 meta 设备构造与权重装载逻辑保持不变。
python/sglang/multimodal_gen/test/unit/test_component_quantization_admission.py test-coverage

新增 146 行焦点测试,覆盖三类量化元数据布局的 fail-closed、不可解析元数据的 fail-closed、6 个 loader 的继承关系,以及通过副作用断言证明拒绝发生在模型构造和权重读取之前。

def test_all_quantization_metadata_layouts_fail_closed(self):
    # 覆盖三类量化元数据布局:顶层 quantization_config、嵌套在
    # text_config 内的量化配置、以及 compression_config(压缩张量)。
    # 三者都必须触发 fail-closed 拒绝,且错误信息带来源字段与 quant_method。
    configs = {
        "quantization_config": {
            "quantization_config": {"quant_method": "bitsandbytes"}
        },
        "text_config.quantization_config": {
            "text_config": {"quantization_config": {"quant_method": "fp8"}}
        },
        "compression_config": {
            "compression_config": {"quant_method": "compressed-tensors"}
        },
    }
    for source, config in configs.items():
        with (
            self.subTest(source=source),
            self.assertRaisesRegex(
                ComponentCheckpointUnsupportedError,
                rf"{re.escape(source)}.*quant_method=.*cannot restore",
            ),
        ):
            _TestLoader.ensure_plain_state_dict_checkpoint(config, "test_component")
​
    # 元数据本身不可解析(quantization_config 为字符串)也必须失败。
    with self.assertRaisesRegex(
        ComponentCheckpointUnsupportedError,
        "Cannot parse checkpoint quantization metadata",
    ):
        _TestLoader.ensure_plain_state_dict_checkpoint(
            {"quantization_config": "invalid"}, "test_component"
        )
​
​
def test_upsampler_rejects_quantization_before_loading_weights(self):
    config = {
        "_class_name": "LatentUpsampler",
        "quantization_config": {"quant_method": "bitsandbytes"},
    }
    # mock 掉权重读取入口,断言拒绝发生在 safetensors_load_file 之前
    # 而不是之后,验证准入边界真正位于 IO 之前。
    with (
        patch(
            "sglang.multimodal_gen.runtime.loader.component_loaders."
            "upsampler_loader._find_safetensors_file",
            return_value="/model/spatial_upsampler/model.safetensors",
        ),
        patch(
            "sglang.multimodal_gen.runtime.loader.component_loaders."
            "upsampler_loader._load_explicit_config",
            return_value=config,
        ),
        patch(
            "sglang.multimodal_gen.runtime.loader.component_loaders."
            "upsampler_loader.safetensors_load_file"
        ) as load_weights,
        self.assertRaises(ComponentCheckpointUnsupportedError),
    ):
        UpsamplerLoader().load_customized(
            "/model/spatial_upsampler", None, "spatial_upsampler"
        )
​
    load_weights.assert_not_called()

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

一是行为变更风险:此前某些携带量化元数据的组件仓库可能启动成功(即使后续行为错误),升级后将直接在加载阶段被拒绝,属于有意为之的 fail-closed,但对存量配置是破坏性变化。二是误拒绝风险ensure_plain_state_dict_checkpoint 完全依赖 SRT 的 resolve_checkpoint_quant_spec 判定;若 config 中残留 quantization_config 字段而权重实际未量化(例如从量化仓库复制 config 后替换权重),会被误拒。三是跨模块契约耦合multimodal_gen 直接 import sglang.srt.model_loader.checkpoint_quantization,SRT 侧解析行为变化(如识别新 method、放宽容错)会直接传导到本准入边界。四是upsampler 重构回归风险_load_explicit_config 返回原始 dict,_normalize_config 推迟到检查之后执行;若元数据中的 config 不是合法 dict,resolve_checkpoint_quant_spec 会抛 TypeError 并被包装成“无法解析”错误,信息可能不够精确。此外,所有新接入的原生 loader 若忘了继承 PlainStateDictComponentLoader,就会绕过守卫,存在遗漏面。

影响范围集中在 multimodal_gen 组件加载子系统,覆盖 connectors、duration_head、dual_tower_bridge、diffusion_decoder、sound_tokenizer、spatial_upsampler、vocoder 七类原生辅助组件的加载路径;库管理的 Transformers/Diffusers 组件(如标准 VAE、PE 模型等)不受影响。对用户而言,加载不支持的量化的组件仓库时,错误从不确定的加载期失败变为启动早期的 ComponentCheckpointUnsupportedError,错误信息包含组件名、检测来源和 quant_method,可操作性显著提升。对团队而言,确立了一条架构红线:路径路由与量化物化能力分离,后续新增原生 loader 必须继承同一基类,文档矩阵则为用户提供了组件级量化支持速查表。整体影响为中等偏正面,无运行时性能影响。

核心加载路径变更 行为变更 fail-closed 拒绝 跨模块依赖 SRT 解析器 潜在误拒绝风险

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论