Prhub

#43529 [Migration] Migrate bitsandbytes support to OOT plugin

原始 PR 作者 Isotr0py 合并时间 2026-08-09 10:27 文件变更 31 提交数 26 评论 29 代码增减 +187 / -1895

执行摘要

bitsandbytes 支持迁移至 OOT 插件,核心删除约 1900 行

关联 RFC Issue #39583 指出 bitsandbytes 与 GGUF 两个量化后端在 vLLM 中使用率极低(约 0.5% 与 0.1%),却给共享权重加载路径(linear.py、fused_moe/layer.py、vocab_parallel_embedding.py 等)引入了约 95 行分支,且未迁移到 weight_loader_v2,严重阻碍了核心加载基础设施的维护和重构。本 PR 作为该 RFC 的第一阶段,优先将 bitsandbytes 迁移为 OOT 插件,以还原 linear.py 的可读性并 unblock 后续清理。

值得精读,尤其是 linear.py 中 bnb 分支到通用 shard_indexer hook 的重构思路,以及删除 800+ 行专有 loader 后如何保持测试覆盖。关注点包括:adjust_shard_indexes 的通用性设计、插件注册机制、以及未落实的性能 guard 是否会在后续修复。适合对量化插件化和权重加载架构感兴趣的工程师。

讨论亮点

核心讨论集中在三处:

  • 性能回退风险(gemini-code-assist):在 linear.py 的 MergedColumnParallelLinear.weight_loaderQKVParallelLinear.weight_loader 中,orig_offsets / orig_qkv_offsets 字典在 shard 循环内对每个参数无条件构造,即使未使用自定义量化插件也会产生额外开销。建议用 getattr(param, "shard_indexer", None) is not None 保护。从合并后的代码看,adjust_shard_indexes 被无条件调用,该性能顾虑并未在本次 PR 中完全落实。

  • ParamMapping 死代码(hmellor):建议删除 model_loader/utils.py 中已无引用的 ParamMapping。Isotr0py 回应称插件侧仍在使用,约定在后续 PR 中迁移至插件仓库。

  • ROCm CI 覆盖(AndreasKaratzas):要求将新增的 BitsAndBytes Plugin 测试组同步到 test-amd.yaml,确保 ROCm 上 bitsandbytes 0.49.2 弃用后功能正常。Isotr0py 表示已在计划中,将放入后续 PR 处理。

实现拆解

实现分四步完成:

  1. 删除内置 bitsandbytes 实现:整体移除 vllm/model_executor/layers/quantization/bitsandbytes.py(614 行,含 BitsAndBytesConfigBitsAndBytesLinearMethodis_layer_skipped_bnb 等)和 vllm/model_executor/model_loader/bitsandbytes_loader.py(834 行,含 BitsAndBytesModelLoader 及其预量化/在线量化迭代器),并清理 model_loader/__init__.pyquantization/__init__.py 中的注册与导入。

  2. 泛化共享路径的 shard 逻辑:在 vllm/model_executor/layers/linear.py 中删除 adjust_bitsandbytes_4bit_shardColumnParallelLinearMergedColumnParallelLinearQKVParallelLinearRowParallelLinear 等 weight_loader 内所有 use_bitsandbytes_4bit 分支,改为调用通用的 adjust_shard_indexes(param, shard_offsets, shard_id, ...),插件可通过设置 param.shard_indexer 注入自定义 shard 偏移计算。同时删除 fused_moe/routed_experts.py 中的 BNB MoE 特例分支。

  3. 清理配置与加载契约:从 vllm/config/model.py 移除 _verify_bnb_config(8-bit 强制 eager 的 fallback),从 vllm/engine/arg_utils.py 移除 quantization==bitsandbytes 时强制 load_format 的隐式改写,删除 model_loader/utils.py 中仅被 BNB loader 使用的 ParamMapping 数据结构(迁移至插件侧),并同步调整 vllm/config/load.pyvllm/platforms/rocm.pyopenpangu.py 等零星引用。

  4. 测试与 CI 配套迁移:将原 tests/models/quantization/test_bitsandbytes.py 迁移为 tests/plugins_tests/bitsandbytes/test_bitsandbytes.py,测试内改为 from vllm_bnb_plugin import bitsandbytes_loader as bnb,移除了 is_quant_method_supported 的 skip 条件;新增 tests/plugins_tests/bitsandbytes/test_transformers.py 覆盖 transformers 后端量化一致性;在 .buildkite/test_areas/plugins.yaml 增加 BitsAndBytes Plugin 的 CI 步骤(软失败),并删除 tests/kernels/moe/test_moe_weight_loading_padded.py 中针对 BNB shape mismatch 的测试。文档和 example 同步更新插件安装方式。

文件 模块 状态 重要度
vllm/model_executor/layers/quantization/bitsandbytes.py 量化层 removed 9.36
vllm/model_executor/model_loader/bitsandbytes_loader.py 模型加载 removed 9.36
vllm/model_executor/layers/linear.py 线性层 modified 7.91
vllm/model_executor/model_loader/utils.py 模型加载 modified 7.91
tests/plugins_tests/bitsandbytes/test_bitsandbytes.py 插件测试 renamed 7.76
vllm/config/model.py 配置层 modified 7.15

关键符号

_get_quantized_weights_iterator _quantized_4bit_generator adjust_shard_indexes adjust_bitsandbytes_4bit_shard _verify_bnb_config validate_generated_texts

关键源码片段

vllm/model_executor/layers/quantization/bitsandbytes.py deletion

内置 BNB 量化配置与 LinearMethod/MoEMethod 的完整实现被整体删除(614 行),是迁移的核心对象。

# vllm/model_executor/layers/quantization/bitsandbytes.py ( 已删除 )
# 以下是迁移前 BNB 4bit/8bit 权重创建的核心逻辑,
# 展示了量化权重如何以 packed uint8 形式注册到 layer。class BitsAndBytesLinearMethod(LinearMethodBase):
    def create_weights(
        self,
        layer: torch.nn.Module,
        input_size_per_partition: int,
        output_partition_sizes: list[int],
        input_size: int,
        output_size: int,
        params_dtype: torch.dtype,
        **extra_weight_attrs,
    ):
        from bitsandbytes.nn import Int8Params
​
        def create_qweight_for_8bit():
            # 8bit 权重使用 bitsandbytes 的 Int8Params,
            # 通过 has_fp16_weights 控制是否保留 fp16 副本
            qweight = Int8Params(
                data=torch.empty(
                    sum(output_partition_sizes),
                    input_size_per_partition,
                    dtype=torch.int8,
                ),
                has_fp16_weights=self.quant_config.llm_int8_has_fp16_weight,
                requires_grad=False,
            )
            set_weight_attrs(qweight, {
                "input_dim": 0,
                "output_dim": 0,
                "pack_factor": 1,
                "use_bitsandbytes_8bit": True,
                "generation": 0,
            })
            return qweight
​
        def create_qweight_for_4bit():
            # 4bit 权重按 quant_ratio 打包为 uint8 张量,
            # 并打上 use_bitsandbytes_4bit 标记供 weight_loader 识别
            quant_ratio = calculate_quant_ratio(params_dtype)
            total_size = input_size_per_partition * sum(output_partition_sizes)
            if total_size % quant_ratio != 0:
                raise ValueError(
                    "The input size is not aligned with the quantized weight shape."
                )
            qweight = BitsAndBytesWeightParameter(
                torch.empty(total_size // quant_ratio, 1, dtype=torch.uint8),
                requires_grad=False,
            )
            set_weight_attrs(qweight, {
                "input_dim": 0,
                "output_dim": 0,
                "pack_factor": quant_ratio,
                "use_bitsandbytes_4bit": True,
            })
            return qweight
​
        if self.quant_config.load_in_8bit:
            qweight = create_qweight_for_8bit()
        else:
            qweight = create_qweight_for_4bit()
        layer.register_parameter("weight", qweight)
        set_weight_attrs(qweight, extra_weight_attrs)
vllm/model_executor/model_loader/bitsandbytes_loader.py deletion

独立的 BNB 模型加载器(834 行)被整体移除,其职责由 vllm-bnb-plugin 中的对应 loader 承接。

# vllm/model_executor/model_loader/bitsandbytes_loader.py ( 已删除 )
# 以下是预量化 4bit 权重的加载流程核心:
# 第一遍收集 quant_state 到 CPU,第二遍按需组装 QuantState。def _quantized_4bit_generator(self, hf_weights_files, use_safetensors, quant_state_dict):
    from bitsandbytes.functional import QuantState
​
    # 先遍历一遍所有权重,把 quant_state 相关的张量收集到临时字典
    weight_iterator = self._hf_weight_iter(hf_weights_files, use_safetensors)
    temp_state_dict = {}
    for org_weight_name, mapped_weight_name, weight_tensor in weight_iterator:
        if not self._is_4bit_weight_name(mapped_weight_name):
            continue
        if "quant_state.bitsandbytes" in mapped_weight_name:
            # bitsandbytes 库要求 quant_state 在 CPU 上
            temp_state_dict[mapped_weight_name] = weight_tensor.cpu().data
        else:
            temp_state_dict[mapped_weight_name] = weight_tensor
​
    def _parse_quant_state(param_name: str, temp_state_dict: dict) -> QuantState:
        quant_state = {}
        for k in temp_state_dict:
            if param_name + "." in k:
                quant_state[k] = temp_state_dict[k]
        return QuantState.from_dict(quant_state, device=current_platform.device_type)
​
    # 第二遍只产出真正的权重张量,并把 quant_state 挂到权重上
    for org_weight_name, mapped_weight_name, weight_tensor in self._hf_weight_iter(
        hf_weights_files, use_safetensors
    ):
        if self._is_4bit_weight_name(mapped_weight_name):
            continue
        if mapped_weight_name in temp_state_dict:
            set_weight_attrs(weight_tensor, {"quant_state": _parse_quant_state(mapped_weight_name, temp_state_dict)})
        yield org_weight_name, weight_tensor
vllm/model_executor/layers/linear.py data-contract

共享权重加载路径的核心文件,删除约 113 行 BNB 分支并引入泛化的 adjust_shard_indexes hook,是本次重构收益最大的文件。

# vllm/model_executor/layers/linear.py ( 迁移后 )
# MergedColumnParallelLinear 的 shard 循环:
# 不再直接判断 bnb,而是通过 param.shard_indexer 委托给量化插件。def weight_loader(self, param, loaded_weight, loaded_shard_id=None):
    self.validate_shard_id(loaded_shard_id)
    param_data = param.data
    output_dim = getattr(param, "output_dim", None)
​
    if loaded_shard_id is None or isinstance(loaded_shard_id, tuple):
        # 权重已在磁盘上融合(例如 mlp 的 gate_up_proj)
        if output_dim is None:
            param_data.copy_(loaded_weight)
            return
​
        output_sizes = self.output_sizes
        current_shard_offset = 0
        shard_offsets: list[tuple[int, int, int]] = []
        for i, output_size in enumerate(output_sizes):
            shard_offsets.append((i, current_shard_offset, output_size))
            current_shard_offset += output_size
​
        packed_dim = getattr(param, "packed_dim", None)
        for shard_id, shard_offset, shard_size in shard_offsets:
            if packed_dim == output_dim:
                # 按 pack_factor 换算 packed 空间的切分偏移
                shard_size //= param.packed_factor
                shard_offset //= param.packed_factor
​
            # 通用量化插件 hook:BNB 等后端通过 shard_indexer
            # 将偏移从逻辑空间映射到量化 packed 空间
            shard_indexer = getattr(param, "shard_indexer", None)
            if shard_indexer is not None:
                index = list(itertools.accumulate([0] + self.output_sizes))
                orig_offsets = {str(i): (index[i], size) for i, size in enumerate(self.output_sizes)}
                orig_offsets["total"] = (self.output_size, 0)
                shard_size, shard_offset = adjust_shard_indexes(
                    param, orig_offsets, str(shard_id), shard_size, shard_offset
                )
​
            loaded_weight_shard = loaded_weight.narrow(output_dim, shard_offset, shard_size)
            self.weight_loader(param, loaded_weight_shard, shard_id)
        return
​
    assert loaded_shard_id < len(self.output_sizes)
    # 单 shard 路径的逻辑与上面类似,省略细节
    ...

评论区精华

linear.py 中 shard offsets 计算缺乏 guard 导致性能开销 性能

gemini-code-assist 指出 MergedColumnParallelLinear 和 QKVParallelLinear 的 weight_loader 在 shard 循环内无条件构造 orig_offsets / orig_qkv_offsets 字典,对未使用自定义量化插件的模型也会有额外开销,建议用 getattr(param, "shard_indexer", None) 保护。

结论:合并后代码中 adjust_shard_indexes 仍被无条件调用,性能 guard 未落实;可能遗留为后续优化点,存在加载性能轻微回退的风险。 · 待处理

ParamMapping 是否为死代码 refactor

hmellor 发现 model_loader/utils.py 中 ParamMapping 在核心代码已无引用,建议删除。Isotr0py 回应插件侧仍在使用,约定后续迁移。

结论:确认 ParamMapping 在当前 PR 中保留,待后续 PR 移至插件仓库;当前从 model_loader/utils.py 中删除并保留在内存模块供插件导入(实际迁移到插件侧)。 · 已解决

ROCm CI 是否覆盖 BitsAndBytes Plugin 测试 测试

AndreasKaratzas 要求将 plugins.yaml 中新增的 BitsAndBytes Plugin 测试组同步到 test-amd.yaml,以确保 ROCm 上 bitsandbytes 0.49.2 弃用后功能正常。

结论:Isotr0py 表示将在后续 PR 中处理,本次只加入软失败 CI 步骤,ROCm 覆盖暂缺。 · 待处理

风险与影响

主要风险集中在三方面:

  1. 破坏性迁移:移除内置 bitsandbytes 支持后,未安装 vllm-bnb-plugin 的用户直接使用 --quantization bitsandbytes 会报未知量化方法错误。虽然文档更新了安装指引,但迁移窗口期可能影响存量用户。

  2. 性能回归隐患:linear.py 的 weight_loader 是模型加载热路径,gemini 指出的无条件 itertools.accumulate 与 dict 构造在每参数循环中执行,对非量化模型引入不必要的计算开销;尤其在 QKVParallelLinear 中构造 5 个 key 的偏移字典,可能拖慢大模型加载时间。

  3. 契约外露风险adjust_shard_indexesshard_indexer 成为新的公共 hook,如果插件侧实现与核心预期不一致(如 shard_offsets 结构变化),可能导致加载错误。此外 ParamMapping 仍被插件引用,核心删除后插件需要自行维护,存在版本兼容风险。

影响范围较大:核心权重加载路径(linear.py、fused_moe、model_loader)净减约 1500+ 行,显著提升可读性和可重构性;对使用 bitsandbytes 的用户是破坏性变更,需额外安装插件包;对 CI 增加软失败插件测试组(暂未覆盖 ROCm);对 GGUF 的后续迁移提供了可复用的 OOT 插件模式。团队内部需要同步维护 vllm-bnb-plugin 仓库,并在后续版本中跟进 ParamMapping 迁移和 ROCm CI。

核心路径变更 破坏性迁移 性能回退风险 后续跟进项

关联 Issue

#39583 [RFC]: Migrate bitsandbytes and GGUF quantization support to OOT plugin

完整报告

参与讨论