QwenImage2.1本地量化部署
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的图片为例:

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 文件
最后结果:
