# code

QwenImage2.1本地量化部署

📅 2026-09-23 ✍️ Layla

1. 量化

最近在尝试把新鲜出炉的QwenIamge2.1-7B部署在本地的破烂电脑上,记录一下部署的过程。

首先,先来看看我的笔记本的配置:24GB内存(wsl 20GB) + 4060 8GB显存

再来看看QwenIamge2.1-7B模型的参数在BF16下的磁盘占用:

组件 原始格式 磁盘大小
processor 非模型权重 15.88 MB
scheduler 配置数据 485 B
text_encoder BF16 17.53 GB
transformer BF16 14.23 GB
vae FP32 1.35 GB
合计   33.13 GB

显然,这33GB塞不进显存,甚至内存都不够。那就先量化试试看。我采用的是4bit-nf4量化,关于nf4量化的原理可以参考我之前的博客。

量化之后的磁盘空间占用估算:

组件 NF4 权重 量化元数据 未量化部分 估算总大小
processor 0 0 15.88 MB 15.88 MB
scheduler 0 0 485 B 485 B
text_encoder 3.759 GB 0.470 GB 2.499 GB 6.73 GB
transformer 3.558 GB 0.445 GB 0.000 GB 4.00 GB
vae 0 0 1.351 GB 1.35 GB
合计 7.32 GB 0.91 GB 3.87 GB 12.10 GB

量化元数据是指nf4一级量化产生的quant_state,例如:

  • absmax:每 64 个权重保存一个 FP32 缩放值,是元数据的主要部分。

  • quant_map/code:16 个 NF4 数值对应的码本。

  • shape、blocksize、dtype 等恢复权重所需的信息。

由于没有开启二级量化,量化元数据以FP32格式存储,不过占用空间也不大就是了。当前方案只量化了text_encoder和transformer这两块比较大的组件,vae只有1.35GB,量化的收益不大。

量化完的模型大约12GB,可以放进内存了,但是还是塞不进8GB显存。这时候就需要用到cpu offload了。

2. cpu offload

为了更好地理解cpu offload,我们先来看看整个模型的推理过程。以生成一张1024x1024的图片为例:

image-20260923190512046

text_encoder,transformer,vae就是整个模型的三个组件。推理的过程中,某一个组件只有在相应的流程中才用得上。比如去噪时,只有transformer模块再运算,其他两个都在闲着。于是,我们可以在某个模块需要用到的时候再将它搬进显存。例如,在encoder阶段,只有text_encoder驻留显存;去噪阶段,text_encoder置换回内存,transformer搬进显存;解码时,再换成vae进入显存。这样就完成了对整个模型的肢解。

当然,这也是有代价的。模型权重不停地在内存与显存之间搬运,时间开销大大增加。

3. 推理框架

现在,我们来看看推理脚本,使用的是diffusers框架:

import torch
from pathlib import Path
from diffusers import QwenImage21Pipeline
from diffusers.quantizers import PipelineQuantizationConfig

BASE_DIR = Path(__file__).parent
MODEL_DIR = BASE_DIR / "Qwen-Image-2.1"

# 量化配置
quantization_config = PipelineQuantizationConfig(
    quant_backend="bitsandbytes_4bit",
    quant_kwargs={
        "load_in_4bit": True,
        "bnb_4bit_quant_type": "nf4",
        "bnb_4bit_compute_dtype": torch.bfloat16,
    },
    # 需要量化的模块
    components_to_quantize=["transformer", "text_encoder"],
)

# 搬运权重到显存+量化
pipe = QwenImage21Pipeline.from_pretrained(MODEL_DIR, quantization_config=quantization_config)
# 挂hook,搬回CPU
pipe.enable_model_cpu_offload()

image = pipe(
    prompt='',
    width=1024,
    height=1024,
    num_inference_steps=20,
    generator=torch.Generator("cpu").manual_seed(42),
).images[0]

output_path = BASE_DIR / "output.png"
image.save(output_path)
print(f"saved -> {output_path}")

神奇的cpu offload就是在这一行:

pipe.enable_model_cpu_offload()

那么,它是如何生效的呢?模型是怎么知道什么时候该上transformer,什么时候该上vae呢?我们进去看看源码。

def enable_model_cpu_offload()中核心的逻辑就是这一段:

for model_str in self.model_cpu_offload_seq.split("->"):
    model = all_model_components.pop(model_str, None)

    if not isinstance(model, torch.nn.Module):
        continue

    # This is because the model would already be placed on a CUDA device.
    _, _, is_loaded_in_8bit_bnb = _check_bnb_status(model)
    if is_loaded_in_8bit_bnb and (
        is_transformers_version("<", "4.58.0") or is_bitsandbytes_version("<", "0.48.0")
    ):
        logger.info(
            f"Skipping the hook placement for the {model.__class__.__name__} as it is loaded in `bitsandbytes` 8bit."
        )
        continue

    _, hook = cpu_offload_with_hook(model, device, prev_module_hook=hook)
    self._all_hooks.append(hook)

这段代码的作用是:按模型执行顺序建立一条 CPU offload hook 链。

for model_str in self.model_cpu_offload_seq.split("->"):

model_cpu_offload_seq 对当前 Qwen Pipeline 是:

text_encoder -> transformer -> vae
  • all_model_components.pop(model_str, None):从所有组件中取出当前模型。使用 pop 是为了在后面处理不在主执行链中的模型,例如 ControlNet。

  • cpu_offload_with_hook(model, device, prev_module_hook=hook):给当前模型安装 hook,并把上一个模型的 hook传进去。这样当前模型开始执行前,会先卸载上一个模型。

  • _, hook = cpu_offload_with_hook(model, device, prev_module_hook=hook):保存当前 hook,作为下一个模型的 prev_module_hook。

最终形成:

text_encoder hook
        ↑
transformer hook
        ↑
	vae hook

实际执行时:

调用 transformer.forward()
  → 先 offload text_encoder
  → 再加载 transformer 到 GPU

调用 vae.forward()
  → 先 offload transformer
  → 再加载 vae 到 GPU

运行一下,看看终端打印的log:

Loading checkpoint shards: 100%|███████████████████████████████████████████████| 2/2 [00:16<00:00,  8.24s/it]
Loading weights: 100%|█████████████████████████████████████████████████████| 750/750 [00:53<00:00, 14.08it/s]
Loading pipeline components...: 100%|██████████████████████████████████████████| 5/5 [01:13<00:00, 14.78s/it]
100%|████████████████████████████████████████████████████████████████████████| 20/20 [01:01<00:00,  3.10s/it]
saved -> /xxx/output.png

这几个进度条都在干啥:

Loading pipeline components...
  ├─ text_encoder
  │    └─ Loading weights: 750/750
  ├─ transformer
  │    └─ Loading checkpoint shards: 2/2
  └─ vae
       └─ 加载单个 safetensors 文件

最后结果:

b472e7e2ff947d9557e0ec3c34c755ca

# 研究讨论 / Discussions