# 接入量化后端 `DiffSynth-Studio` 的量化框架由 `diffsynth.core.quant` 提供,内置了 bitsandbytes、torchao、comfy-kitchen 等后端(见[模型量化](../Pipeline_Usage/Quantization.md))。如果你有自研的量化算法或想接入新的量化库,只需实现一个 `QuantBackend`,框架的在线量化、预量化 checkpoint 保存/加载、混合量化、显存管理、量化 + LoRA 训练都能直接复用。 本文以一个玩具后端 **INT9**(9bit 对称权重量化,真实按 9bit 打包存储,每个输出通道一个 fp32 scale)为例,走完接入的全过程。INT9 在硬件上并不存在,这里只是为了让示例代码足够短、又能覆盖所有需要实现的接口。完整的接口签名与约定见 [`diffsynth.core.quant` API 文档](../API_Reference/core/quant.md#扩展接口自定义后端)。 ## 框架结构 量化框架分成三层: - **`QuantizeConfig`**:面向用户的配置与入口,负责在模型中遍历、匹配并替换 `nn.Linear`。你不需要改动它。 - **`QuantBackend`**:适配层,只处理**单个** `nn.Linear`:怎么量化、怎么造空壳、怎么反量化、怎么读写 state dict。这是你要实现的部分。 - **量化 Linear**:实际承载量化权重并在 `forward` 中完成反量化 + 矩阵乘的模块。 量化 Linear 必须满足四条契约: - **(a)** 是 `nn.Linear` 的替代品,`forward(x)` 内部完成反量化 + 矩阵乘;且必须是 `torch.nn.Linear` 的子类,否则 LoRA 注入与显存管理无法识别它。 - **(b)** `.to(...)` 只搬设备,不改打包权重与量化状态的 dtype。显存管理会对模型做 dtype 转换,若打包权重被转成 bf16,量化状态就损坏了。 - **(c)** `state_dict()` 与 `load_state_dict(assign=True)` 可以往返,必要时通过 `flatten_state_dict` / `unflatten_state_dict` 转换。 - **(d)**(仅训练需要)`forward` 对输入可微,梯度能穿过冻结的量化层到达 LoRA 分支。 ## 第一步:编写量化 Linear INT9 的存储布局需要一点设计:9bit 没有对应的原生 dtype,如果直接把它塞进 int16 张量,每个权重仍然占 16bit,和 bf16 一样大,量化就白做了。因此这里把每个权重拆成两部分存放——低 8 位放进 uint8 的 `weight`,第 9 位(最高位)单独构成一个位平面,8 个权重打包进 1 个字节存进 `weight_msb`,再加上每个输出通道一个 fp32 的 `weight_scale`。这样每个权重实际占用 9bit,是 bf16 的 56%。 另外注意两个细节: - 删掉 `nn.Linear` 原有的 `weight` 参数,改为注册同名 buffer,这样 checkpoint 的键名依然是 `层名.weight`,磁盘 offload 与混合量化的键归属判断才能正常工作。 - 通过重写 `_apply` 守护打包张量的 dtype,即契约 (b)。`.to()` / `.half()` / `.float()` 等所有转换都会走到 `_apply`,把会改变 dtype 的转换降级为纯搬设备即可。 ```python from dataclasses import dataclass, field import torch import torch.nn.functional as F from diffsynth.core.quant import BackendConfig, QuantBackend, register_quant_backend, register_quant_method def pack_msb(bits): """把 0/1 位平面按 8 个权重 1 字节打包,每个权重只占 1bit。""" flat = bits.reshape(-1) padding = (-flat.numel()) % 8 if padding: flat = torch.cat([flat, flat.new_zeros(padding)]) groups = flat.view(-1, 8) packed = torch.zeros(groups.shape[0], dtype=torch.uint8, device=flat.device) for index in range(8): packed |= groups[:, index] << index return packed def unpack_msb(packed, numel): bits = torch.stack([(packed >> index) & 1 for index in range(8)], dim=1) return bits.reshape(-1)[:numel] class Int9Linear(torch.nn.Linear): """int9 权重:低 8 位存在 uint8 的 `weight` 中,第 9 位打包进 `weight_msb`, 每个输出通道一个 fp32 scale。每个权重占 9bit,是 bf16 的 56%。""" dtype_guarded_tensor_names = ("weight", "weight_msb", "weight_scale") def __init__(self, in_features, out_features, bias, compute_dtype): with torch.device("meta"): super().__init__(in_features, out_features, bias=bias, dtype=compute_dtype) del self.weight self.register_buffer("weight", torch.empty(out_features, in_features, dtype=torch.uint8, device="meta")) self.register_buffer("weight_msb", torch.empty((in_features * out_features + 7) // 8, dtype=torch.uint8, device="meta")) self.register_buffer("weight_scale", torch.empty(out_features, dtype=torch.float32, device="meta")) if self.bias is not None: self.bias.requires_grad_(False) def _apply(self, fn, recurse=True): protected = {id(tensor) for name in self.dtype_guarded_tensor_names if (tensor := getattr(self, name, None)) is not None} def guard(tensor): converted = fn(tensor) if id(tensor) in protected and converted.dtype != tensor.dtype: return tensor.to(device=converted.device) return converted return super()._apply(guard, recurse) def dequantize_weight(self, dtype): msb = unpack_msb(self.weight_msb, self.weight.numel()).view_as(self.weight) codes = self.weight.to(torch.int16) | (msb.to(torch.int16) << 8) return ((codes - 256).float() * self.weight_scale.unsqueeze(1)).to(dtype) def forward(self, x): bias = self.bias.to(x.dtype) if self.bias is not None else None return F.linear(x, self.dequantize_weight(x.dtype), bias) ``` `forward` 中的反量化用的是常规张量运算,梯度可以经 `F.linear` 传回输入 `x`,因此契约 (d) 自动满足。这里的解包是用 PyTorch 算子逐位拼出来的,只为示例简洁;真实后端通常把解包与矩阵乘融合进一个 kernel,避免每次 forward 都物化一份 fp 权重。 `dequantize_weight` 中有一个容易踩的坑:整数码的还原必须在 fp32 中做。bf16 只有 8bit 有效精度,超过 256 的整数无法精确表示,如果直接把码值转成 bf16 再乘 scale,第 9 位就被舍入掉了,精度收益会白白丢失(实测误差从 int8 的 2.25 倍优势退化到 1.15 倍)。凡是码值位宽超过计算精度的有效位数的量化格式,都要注意这一点。 ## 第二步:编写后端 后端的每个方法只处理一层 `nn.Linear`: - `capabilities()`:声明能力,四个开关默认全为 `False`。`is_serializable=True` 才允许保存量化权重,`is_differentiable=True` 才允许量化 + LoRA 训练。 - `quantized_linear_classes()`:声明本后端产出的 Linear 类,`is_quantized_linear` 默认用它做 `isinstance` 判断。 - `create_quantized_linear()`:在线量化,把 fp 的 `nn.Linear` 变成量化 Linear。`compute_device` 是量化计算所在设备,`model_device` 是量化完成后存放的设备,两者配合可以逐层流式量化,显存里一次只放一层。 - `create_quantized_linear_shell()`:造一个空壳,用于加载预量化 checkpoint 以及磁盘 offload。空壳会在每次 offload 时重建,所以要建在 `meta` 设备上,保持廉价。 - `dequantize_to_linear()`:反量化回普通 `nn.Linear`,供 `mode="dequant_once"` 使用。 - `flatten_state_dict` / `unflatten_state_dict`:state dict 与扁平张量之间的转换。INT9 的 state dict 本身就是普通张量,直接用基类实现即可,无需重写;只有像 bitsandbytes、torchao 那样含复合张量(张量子类、嵌套量化状态)的后端才需要重写。 未实现的方法会由基类抛出带说明的异常,因此只支持部分能力的后端只实现自己需要的即可。`self.config` 是框架注入的后端配置实例,即下一步要写的 `Int9WeightOnlyConfig`。 ```python @register_quant_backend("toy_int9") class Int9QuantBackend(QuantBackend): project_url = "https://example.com/toy-int9" def capabilities(self): return {**super().capabilities(), "is_serializable": True, "is_differentiable": True} def quantized_linear_classes(self): return (Int9Linear,) def create_quantized_linear(self, linear, compute_device=None, model_device=None): weight = linear.weight.data if compute_device is not None: weight = weight.to(device=compute_device) amax = weight.abs().amax(dim=1) if self.config.per_channel else weight.abs().amax().expand(weight.shape[0]) scale = (amax.float() / 255).clamp(min=1e-8) codes = (weight.float() / scale.unsqueeze(1)).round().clamp(-256, 255).to(torch.int16) + 256 quant_linear = Int9Linear(linear.in_features, linear.out_features, bias=linear.bias is not None, compute_dtype=weight.dtype) quant_linear.weight = (codes & 0xFF).to(torch.uint8) quant_linear.weight_msb = pack_msb((codes >> 8).to(torch.uint8)) quant_linear.weight_scale = scale if linear.bias is not None: quant_linear.bias = torch.nn.Parameter(linear.bias.data.to(device=scale.device), requires_grad=False) return quant_linear if model_device is None else quant_linear.to(device=model_device) def create_quantized_linear_shell(self, linear, compute_dtype): return Int9Linear(linear.in_features, linear.out_features, bias=linear.bias is not None, compute_dtype=compute_dtype) def dequantize_to_linear(self, module, compute_dtype, compute_device=None, model_device=None): if compute_device is not None: module = module.to(device=compute_device) fp_weight = module.dequantize_weight(compute_dtype) linear = torch.nn.Linear(module.in_features, module.out_features, bias=module.bias is not None, device="meta") linear.weight = torch.nn.Parameter(fp_weight, requires_grad=False) if module.bias is not None: linear.bias = torch.nn.Parameter(module.bias.data.to(dtype=compute_dtype, device=fp_weight.device), requires_grad=False) return linear if model_device is None else linear.to(device=model_device) ``` ## 第三步:编写后端配置 后端配置继承 `BackendConfig`:用户可调的参数写成普通 dataclass 字段,由方法固定、不允许用户覆盖的值用 `field(init=False, default=...)` 声明。`describe_quant_method` 会分别展示这两类参数,`from_kwargs` 则会在用户传入未知的 `backend_config_kwargs` 时报错。 ```python @dataclass class Int9WeightOnlyConfig(BackendConfig): per_channel: bool = True # 用户可调:per-channel 还是 per-tensor bits: int = field(init=False, default=9) # 方法固定,不可覆盖 ``` ## 第四步:注册量化方法 一个后端可以注册多个方法,通过配置中被固定的字段区分(例如 bitsandbytes 后端用 `quant_type` 区分 nf4 与 fp4)。方法名建议遵循 `<后端>_<格式>_w<权重位宽>a<激活位宽>` 的命名约定: ```python register_quant_method("toy_int9_w9a16", "toy_int9", Int9WeightOnlyConfig.from_kwargs, label="9bit, int9, weight-only (toy)") ``` 注册后端和方法有两种方式: **方式一:写在自己的代码里(推荐,即插即用)**。把上面的代码放在任意模块中,只要在构造 `QuantizeConfig` 之前 import 过这个模块,方法就已经注册进 `QUANT_METHODS`,可以像内置方法一样使用,无需改动框架代码: ```python import my_project.toy_int9 # 触发 register_quant_backend / register_quant_method from diffsynth.core.quant import QuantizeConfig quantize = QuantizeConfig(method="toy_int9_w9a16", backend_config_kwargs={"per_channel": True}) ``` **方式二:作为内置后端(永久生效)**。把后端文件放到 `diffsynth/core/quant/backends/` 下,并在 `diffsynth/core/quant/backends/__init__.py` 的 `_LAZY_BACKENDS` 中登记,框架就会在需要时按需 import,用户无需手动 import: ```python _LAZY_BACKENDS = { "bitsandbytes": ".bitsandbytes", "torchao": ".torchao", "comfy_kitchen": ".comfy_kitchen", "toy_int9": ".toy_int9", } ``` 如果你的量化算法或量化库有通用价值,欢迎按方式二提 PR 给我们,让更多用户直接用上。需要第三方依赖的后端请在 `validate_environment()` 中检查依赖并给出安装提示,在 `project_url` 中填写上游项目地址。 ## 第五步:自检 框架提供了两个自检工具,建议在接入后立刻跑一遍。`check_backend_contract` 会检查后端是否声明了 Linear 类、两个工厂方法是否返回声明的类、所有类是否都是 `nn.Linear` 的子类,以及后端实际写出的 checkpoint 键是否都落在层名之下(漏掉一个 scale 会让磁盘 offload 静默加载出损坏的层)。不支持的工厂方法会被跳过,不计为失败。 ```python from diffsynth.core.quant import QUANT_BACKENDS, QUANT_METHODS, check_backend_contract, check_differentiable, describe_quant_method describe_quant_method("toy_int9_w9a16") spec = QUANT_METHODS["toy_int9_w9a16"] check_backend_contract(QUANT_BACKENDS[spec.backend](spec.config_factory({})), compute_device="cpu") ``` 输出如下,`describe_quant_method` 同时验证了用户可调参数与固定参数的划分是否符合预期: ``` method: toy_int9_w9a16 backend: toy_int9 detail: 9bit, int9, weight-only (toy) backend config: my_project.toy_int9.Int9WeightOnlyConfig backend_config_kwargs (user-tunable): per_channel = True pinned by method (not overridable): bits = 9 check_backend_contract (toy_int9): [PASS] quantized_linear_classes() is non-empty: ['Int9Linear'] [PASS] Int9Linear subclasses torch.nn.Linear [PASS] a plain nn.Linear is not reported as quantized [PASS] create_quantized_linear_shell() returns a declared class, got Int9Linear [PASS] the shell is recognized before load_state_dict (disk offload routing) [PASS] create_quantized_linear() returns a declared class, got Int9Linear [PASS] every stored key lives under the layer name; uncovered: [] => OK ``` 接着在一个小模型上验证数值误差、真实的显存收益、契约 (b) 的 dtype 守护、以及契约 (d) 的可微性: ```python import torch from diffsynth.core.quant import QuantizeConfig, check_differentiable class ToyModel(torch.nn.Module): def __init__(self): super().__init__() self.fc1 = torch.nn.Linear(256, 512) self.fc2 = torch.nn.Linear(512, 256, bias=False) def forward(self, x): return self.fc2(torch.nn.functional.silu(self.fc1(x))) def footprint(model): return sum(t.numel() * t.element_size() for t in list(model.parameters()) + list(model.buffers())) torch.manual_seed(0) model = ToyModel().to(torch.bfloat16) x = torch.randn(4, 256, dtype=torch.bfloat16) reference = model(x) fp_bytes = footprint(model) QuantizeConfig(method="toy_int9_w9a16").quantize_model(model, compute_device="cpu") print("relative error:", ((model(x) - reference).norm() / reference.norm()).item()) print(f"footprint: {fp_bytes} -> {footprint(model)} bytes ({footprint(model) / fp_bytes:.3f} of bf16)") model.to(torch.float32) # 契约 (b):打包权重的 dtype 不应改变 print(model.fc1.weight.dtype, model.fc1.weight_msb.dtype, model.fc1.weight_scale.dtype, model.fc1.bias.dtype) check_differentiable(model.fc1) # 契约 (d) ``` ``` 2 nn.Linear layers quantized (method: toy_int9_w9a16). relative error: 0.004150390625 footprint: 525312 -> 299008 bytes (0.569 of bf16) torch.uint8 torch.uint8 torch.float32 torch.float32 check_differentiable (Int9Linear): OK -- gradients pass through the module to its input ``` 实测占用是 bf16 的 0.569,略高于 9/16 = 0.5625,差值来自 fp32 的 scale 和未量化的 bias。如果这个比例接近 1,说明打包格式没有真正压缩权重,需要回到第一步检查存储布局。 ### 在真实模型上推理:Z-Image 小模型验证通过后,就可以直接在真实模型上用了——自定义后端和内置方法的用法完全一致,只要在构造 `QuantizeConfig` 之前 import 过注册后端的模块,把它传给 `ModelConfig(quantize=...)` 即可: ```python import torch import my_project.toy_int9 # 注册 toy_int9 后端与 toy_int9_w9a16 方法 from diffsynth.core.quant import QuantizeConfig from diffsynth.pipelines.z_image import ModelConfig, ZImagePipeline pipe = ZImagePipeline.from_pretrained( torch_dtype=torch.bfloat16, device="cuda", model_configs=[ ModelConfig( model_id="Tongyi-MAI/Z-Image-Turbo", origin_file_pattern="transformer/*.safetensors", quantize=QuantizeConfig(method="toy_int9_w9a16"), ), ModelConfig(model_id="Tongyi-MAI/Z-Image-Turbo", origin_file_pattern="text_encoder/*.safetensors"), ModelConfig(model_id="Tongyi-MAI/Z-Image-Turbo", origin_file_pattern="vae/diffusion_pytorch_model.safetensors"), ], tokenizer_config=ModelConfig(model_id="Tongyi-MAI/Z-Image-Turbo", origin_file_pattern="tokenizer/"), ) dit_bytes = sum(t.numel() * t.element_size() for t in list(pipe.dit.parameters()) + list(pipe.dit.buffers())) print(f"dit weights: {dit_bytes / 1024 ** 3:.3f} GiB") prompt = "A delicate portrait of an underwater girl, blue dress flowing, hair gently drifting, light and shadow clear, surrounded by bubbles, serene expression, exquisite details, dreamlike and beautiful." image = pipe(prompt=prompt, seed=42, rand_device="cuda") image.save("z_image_toy_int9.jpg") ``` 实测 Z-Image Turbo 的 DiT 权重占用(8 步 Turbo 出图正常,画质与 bf16 无明显差异): | | DiT 权重 | | --- | --- | | bf16 | 11.464 GiB | | `toy_int9_w9a16` | 6.456 GiB(0.563×) | 需要注意峰值显存与权重占用不是一回事:这个 toy 的 forward 每次都会临时物化一份 fp 权重,所以峰值的节省会小于权重的节省。在一个 48 个 Linear 的合成模型上(权重全部常驻 GPU)实测: | | 权重 | forward 峰值 | | --- | --- | --- | | bf16 | 1.500 GiB | 1.527 GiB | | `toy_int9_w9a16` | 0.845 GiB(0.563×) | 1.036 GiB(0.678×) | 这份临时权重只与**最大的那一层**有关,不随层数增长,所以模型越深、收益越接近权重的比例;真实后端把解包与矩阵乘融进一个 kernel 后就不需要它了。想进一步压低峰值,可以叠加[显存管理](../Pipeline_Usage/VRAM_management.md)按层搬运权重(把 `vram_config` 传给上面的每个 `ModelConfig`,实测峰值可降到 2.1 GiB)。 ### 精度对比:int9 vs int8 多出来的 1 bit 是否真的换来了精度?把同一个权重用**完全相同**的 per-channel 对称量化方案分别做 8bit 与 9bit,对比反量化后的权重误差与层输出误差即可。这也是给新后端做精度回归的通用做法:控制其他变量,只改位宽。 ```python import torch from my_project.toy_int9 import Int9QuantBackend, Int9WeightOnlyConfig def quantize_int8(linear): """同样的 per-channel 对称方案,只少 1 bit:码值范围 [-128, 127]。""" weight = linear.weight.data scale = (weight.abs().amax(dim=1).float() / 127).clamp(min=1e-8) codes = (weight.float() / scale.unsqueeze(1)).round().clamp(-128, 127) return (codes * scale.unsqueeze(1)).to(weight.dtype) def relative_error(reference, value): return ((value.float() - reference.float()).norm() / reference.float().norm()).item() torch.manual_seed(0) backend = Int9QuantBackend(Int9WeightOnlyConfig()) linear = torch.nn.Linear(2048, 2048, bias=False).to(torch.bfloat16) fp_weight = linear.weight.data.clone() int9_weight = backend.create_quantized_linear(linear).dequantize_weight(torch.bfloat16) int8_weight = quantize_int8(linear) error8, error9 = relative_error(fp_weight, int8_weight), relative_error(fp_weight, int9_weight) print(f"weight error: int8 {error8:.6f} | int9 {error9:.6f} ({error8 / error9:.2f}x lower)") x = torch.randn(64, 2048, dtype=torch.bfloat16) reference = torch.nn.functional.linear(x, fp_weight) out8 = relative_error(reference, torch.nn.functional.linear(x, int8_weight)) out9 = relative_error(reference, torch.nn.functional.linear(x, int9_weight)) print(f"output error: int8 {out8:.6f} | int9 {out9:.6f} ({out8 / out9:.2f}x lower)") ``` ``` weight error: int8 0.004353 | int9 0.001937 (2.25x lower) output error: int8 0.004947 | int9 0.002816 (1.76x lower) ``` 结论符合预期:码值范围从 255 级扩到 511 级,量化步长减半,权重误差随之降到约 1/2(实测 2.25 倍,均匀量化下误差与步长成正比)。端到端的层输出收益略小(1.76 倍),因为激活值本身是 bf16,矩阵乘自带的舍入噪声会占掉一部分收益——这也提示:位宽收益要放到实际计算精度下评估,而不是只看权重误差。 最后验证契约 (c):保存量化权重,再用空壳加载回来,两者的输出应完全一致。 ```python from safetensors.torch import load_file, save_file save_config = QuantizeConfig(method="toy_int9_w9a16") tensors, metadata = save_config.flatten_state_dict(model.state_dict()) save_file(tensors, "toy_int9.safetensors", metadata=metadata) loaded = ToyModel().to(torch.bfloat16) load_config = QuantizeConfig(method="toy_int9_w9a16", load_prequantized=True) load_config.prepare_for_prequantized_load(loaded, compute_dtype=torch.bfloat16) loaded.load_state_dict(load_config.unflatten_state_dict(load_file("toy_int9.safetensors"), metadata), assign=True) print("reload match:", torch.equal(loaded(x.float()), model(x.float()))) ``` ``` reload match: True ``` ### 与 Disk Offload 组合验证 [显存管理](../Pipeline_Usage/VRAM_management.md)中的 Disk Offload 对量化后端的要求最严格:模型常驻内存中只保留 `meta` 空壳,每次 forward 时才按层把张量从磁盘流式读回来,用完即丢。它依赖两件事: - 只支持**预量化 checkpoint**,因此必须 `load_prequantized=True`,并先经 `prepare_for_prequantized_load` 把目标层换成空壳。 - 某一层需要哪些张量,是用层的点分名做前缀扫描从 checkpoint 键里找出来的,然后以 `load_state_dict(assign=True)` 严格加载。因此后端只要满足「所有张量都在 `层名.` 之下」这一条(无论是 `层名.weight_scale` 这样的平级张量,还是 bnb 那样的嵌套量化状态),就能被正确切分;键少了或多了会直接报错,而不会静默加载出错误的层。 ```python import torch from safetensors.torch import save_file from diffsynth.core.loader.model import load_metadata_from_safetensors from diffsynth.core.quant import QuantizeConfig from diffsynth.core.vram.disk_map import DiskMap from diffsynth.core.vram.layers import AutoWrappedLinear, enable_vram_management_recursively resident = ToyModel().to(torch.bfloat16) x = torch.randn(2, 256, dtype=torch.bfloat16, device="cuda") save_config = QuantizeConfig(method="toy_int9_w9a16") save_config.quantize_model(resident, compute_device="cuda") resident = resident.to("cuda") reference = resident(x) tensors, metadata = save_config.flatten_state_dict(resident.state_dict()) save_file({key: value.cpu() for key, value in tensors.items()}, "toy_int9.safetensors", metadata=metadata) fresh = ToyModel().to(torch.bfloat16) load_config = QuantizeConfig(method="toy_int9_w9a16", load_prequantized=True) load_config.prepare_for_prequantized_load(fresh, compute_dtype=torch.bfloat16) enable_vram_management_recursively( fresh, module_map={torch.nn.Linear: AutoWrappedLinear}, vram_config={ "offload_dtype": "disk", "offload_device": "disk", "onload_dtype": "disk", "onload_device": "disk", "preparing_dtype": torch.bfloat16, "preparing_device": "cuda", "computation_dtype": torch.bfloat16, "computation_device": "cuda", }, disk_map=DiskMap(["toy_int9.safetensors"], "cuda", torch_dtype=None), quantize=load_config, metadata=load_metadata_from_safetensors("toy_int9.safetensors"), ) for name, module in fresh.named_modules(): if getattr(module, "disk_offload", False): print(f"{name}: {module._disk_required_keys()}") resident_bytes = sum(t.numel() * t.element_size() for t in list(resident.parameters()) + list(resident.buffers())) offloaded_bytes = sum(t.numel() * t.element_size() for t in list(fresh.parameters()) + list(fresh.buffers()) if not t.is_meta) print(f"resident {resident_bytes} bytes -> in memory after disk offload {offloaded_bytes} bytes") print("output matches:", torch.equal(fresh(x), reference), "| repeatable:", torch.equal(fresh(x), reference)) ``` 在前面那个 `ToyModel` 上实测(`DiskMap` 的 `torch_dtype=None` 很关键,它保证打包张量不会在读取时被转换精度): ``` 2 nn.Linear layers replaced for loading the pre-quantized checkpoint (method: toy_int9_w9a16). fc1: ['fc1.bias', 'fc1.weight', 'fc1.weight_msb', 'fc1.weight_scale'] fc2: ['fc2.weight', 'fc2.weight_msb', 'fc2.weight_scale'] resident 299008 bytes -> in memory after disk offload 0 bytes output matches: True | repeatable: True ``` 每层的 `weight` / `weight_msb` / `weight_scale` / `bias` 都被正确归到该层名下,常驻占用降到 0 字节(全部是 `meta` 空壳),输出与常驻量化模型逐位相同,且多次 forward 结果稳定——说明空壳的反复重建与流式加载没有副作用。 在真实模型上,则可以用[模型量化](../Pipeline_Usage/Quantization.md)中的通用流程做端到端验证:把 `QuantizeConfig(method="toy_int9_w9a16")` 传给 `ModelConfig(quantize=...)` 做在线量化推理,用 `save_quantized_model` 保存量化权重并注册 hash 后加载,以及在量化模型上注入 LoRA 训练。 ## 接入检查清单 - 打包格式真的减小了权重体积:量化前后实测占用之比应接近理论位宽比,而不是接近 1。 - 精度收益经过验证:与少 1 bit 的同方案对比,误差确实下降;否则说明反量化路径中丢失了精度。 - 量化 Linear 是 `torch.nn.Linear` 的子类,`state_dict` 的键都在层名之下。 - `_apply` 守护了所有打包张量与量化状态的 dtype。 - `capabilities()` 与实际能力一致:声明 `is_serializable` 就要保证 state dict 能往返,声明 `is_differentiable` 就要能通过 `check_differentiable`。 - `create_quantized_linear` 尊重 `compute_device` / `model_device`,以支持逐层流式量化。 - 能与 Disk Offload 组合:空壳建在 `meta` 上且重建代价低,所有张量都在层名之下,且 `unflatten_state_dict` 能接受「单层子字典 + 整文件 metadata」的调用方式。 - 依赖第三方库时,`validate_environment()` 给出明确的安装提示,`project_url` 指向上游项目。 - `check_backend_contract` 全部通过。