日期:2026-10-27 目标硬件:RTX 3050 Laptop 4 GB / SM86 / Windows 定位:把社区量化 checkpoint 转换成 DeepInfer-SM86 原生 W4A16 分层格式 本文:实现说明 + 走过的弯路 + 当前结果与边界。不是 GPU 性能榜单。
1. 先说结论
这次让 DeepInfer-SM86 能够导入三种社区量化格式:
GPTQ
AWQ
GGUF
入口是一个统一转换器:
社区量化 checkpoint
│
▼
解包 / 反量化 / 张量映射
│
▼
按层重打包为 SM86 symmetric int4
│
▼
DeepInfer-SM86 layered W4A16 checkpoint
│
▼
现有 W4A16 runtime / paged scheduler 直接加载
最终支持的能力:
| 格式 | 能力 |
|---|---|
| GPTQ | 2/4/8-bit、group quant、qzeros、g_idx / desc_act |
| AWQ | AutoAWQ GEMM 4-bit packed 布局 |
| GGUF | 单文件,F32/F16/BF16 和 gguf 包支持反量化的 ggml 类型 |
| 输出 | layered W4A16,group size 可配置,默认 128 |
需要明确一句:
这是离线转换兼容,不是原格式零拷贝直接执行。
社区格式不能直接喂给当前的 SM86 GEMV kernel。强行“直接兼容”只会得到两种结果:要么逐 token 在线反量化,速度很差;要么把错误布局当成正确权重,生成看起来能跑、实际 logits 错误的模型。最终选择了更稳的路线:先转换成 native W4A16。
2. 为什么不做原格式直跑
当前 SM86 kernel 的输入约定是:
packed: [out_features, in_features / 2] uint8
scales: [out_features, in_features / group_size] fp16
GPTQ、AWQ、GGUF 的存储布局都不是这个形状:
- GPTQ 的
qweight沿输入维打包; - AWQ 的
qweight沿输出维打包,而且还有一个 8-value reorder; - GGUF 是容器格式,内部 tensor 可能是 Q4_0、Q4_K、Q6_K 等不同 block;
- GGUF 的 tensor 命名是 llama.cpp 风格,不是 HF 命名;
- GGUF 常见 fused QKV / gate-up,需要拆开;
- GGUF 文件通常不包含 HF tokenizer 目录。
如果做在线直跑,意味着:
- 每个 linear 都要保留原格式的 packed tensor;
- 每个 token 都要按原格式解包;
- 每个线性层都要做一次 scale/zero 反量化;
- attention、cache、scheduler 仍按现在的 W4A16 形状工作;
- GGUF block 反量化尤其不适合逐 token 做。
在 4 GB 显存和当前 kernel 结构下,这会让“兼容”变成“能跑但极慢”,不如一次性离线转换。
3. 新增的代码结构
deepinfer/quantization/
formats.py 格式检测、bits/group_size/布局校验
dequant.py GPTQ/AWQ int32 解包与反量化
pack.py W4A16 symmetric int4 重打包
sources.py HF safetensors 源读取
gguf_source.py GGUF metadata/tensor 映射与反量化
convert.py 统一转换器
scripts/
import_quantized_checkpoint.py
tests/quantization/
test_dequant.py
test_convert_hf.py
test_convert_gguf.py
统一接口大致是:
convert_checkpoint(
source_path,
output_path,
group_size=128,
tokenizer=None,
overwrite=False,
source_format=None,
)
输出目录结构:
config.json
generation_config.json
manifest.json
embed.safetensors
norm.safetensors
lm_head.safetensors # tied embedding 时不存在
layer_00.safetensors
layer_01.safetensors
...
每个 layer 文件包含:
self_attn.q_proj.packed / scales / bias?
self_attn.k_proj.packed / scales / bias?
self_attn.v_proj.packed / scales / bias?
self_attn.o_proj.packed / scales
mlp.gate_proj.packed / scales
mlp.up_proj.packed / scales
mlp.down_proj.packed / scales
input_layernorm.weight
post_attention_layernorm.weight
pre_feedforward_layernorm.weight? # Gemma 2
post_feedforward_layernorm.weight? # Gemma 2
4. GPTQ 是怎么处理的
GPTQ 常见布局:
qweight: [in_features / (32 / bits), out_features]
qzeros: [num_groups, out_features / (32 / bits)]
scales: [num_groups, out_features]
每一步 int32 中打包多个低位整数。反量化的关键是两件事。
4.1 解包方向
GPTQ 的 qweight 沿输入维打包,所以需要把每个 int32 的多个 shift 结果还原到输入特征维,再 reshape 成:
[in_features, out_features]
最后转置成目标 W4A16 需要的:
[out_features, in_features]
4.2 qzeros 的 +1
GPTQ 的 qzeros 里存的是 zero - 1,反量化时要:
real_zero = unpack(qzeros) + 1
这就是第一次合成 GPTQ 测试时余弦只有约 0.96、误差明显偏大的原因。最初以为是 packing 方向错了,后来发现是零点少加了一。
4.3 desc_act / g_idx
GPTQ 的 desc_act=true 时,输入特征到 scale group 的映射不一定是:
g_idx[i] = i // group_size
而是 checkpoint 里保存的 g_idx。实现中:
- 有
g_idx就按它取scales[g_idx]和zeros[g_idx]; - 没有
g_idx才回退到默认 group 映射; - 如果配置声称
desc_act=true但 checkpoint 没有g_idx,直接报错,而不是猜。
这条严格检查是有必要的。Activation-order checkpoint 如果被当成普通 group checkpoint,模型通常还是能输出文本,但精度会悄悄变差,很难排查。
5. AWQ 是怎么处理的
AWQ GEMM 的布局:
qweight: [in_features, out_features / 8]
qzeros: [num_groups, out_features / 8]
scales: [num_groups, out_features]
解包后还需要做 AutoAWQ 的重新排序:
AWQ_REVERSE_ORDER = [0, 4, 1, 5, 2, 6, 3, 7]
正确的处理顺序是:
- 把 qweight 的每个 int32 按 4-bit shift 解成 8 个值;
- reshape 到
[in_features, out_features]; - 对每 8 个输出值按 reverse order 重排;
- 对 qzeros 做同样的重排;
- 按 group 做
(value - zero) * scale; - 转置到
[out_features, in_features]。
AWQ 最容易错的地方不是 shift,而是这个输出维 reorder。直接按 GPTQ 的方向解包,结果不会报 shape 错误,但权重会错。
另外,AWQ 有 GEMM / GEMV / ExLlama 等不同 packing 约定。本次只实现 AutoAWQ GEMM。格式检测会检查 version,遇到 gemv / exllama 直接拒绝,避免生成错误 checkpoint。
6. GGUF 是怎么处理的
GGUF 和 GPTQ/AWQ 不是一个层级的问题:
- GPTQ/AWQ 是 packed int32 布局;
- GGUF 是文件容器,内部可以是 F16、Q4_0、Q4_K、Q6_K 等很多类型;
- GGUF 还带 architecture、hparams、tokenizer metadata 和 llama.cpp tensor 命名。
本次依赖纯 Python 的 gguf 包负责:
- 解析 GGUF 文件;
- 读取 metadata 和 tensor info;
- 按 ggml block 类型反量化。
导入器负责:
- architecture 映射;
- hparams -> HF config;
- llama.cpp tensor 名 -> 目标模型权重名;
- fused QKV / gate-up 拆分;
- 最终按 W4A16 重新打包。
6.1 GGUF tensor 名映射
典型映射:
token_embd.weight -> model.embed_tokens.weight
output_norm.weight -> model.norm.weight
output.weight -> lm_head.weight
blk.N.attn_norm -> model.layers.N.input_layernorm
blk.N.attn_q/k/v -> self_attn.q/k/v_proj
blk.N.attn_output -> self_attn.o_proj
blk.N.ffn_norm -> model.layers.N.post_attention_layernorm
blk.N.ffn_gate/up/down -> mlp.gate/up/down_proj
Gemma 2 则额外映射:
blk.N.post_attention_norm -> post_attention_layernorm
blk.N.ffn_norm -> pre_feedforward_layernorm
blk.N.post_ffw_norm -> post_feedforward_layernorm
6.2 GGUF architecture 和 hparams
从 metadata 生成目标 config.json:
general.architecture;- block_count;
- embedding_length;
- feed_forward_length;
- attention.head_count / head_count_kv;
- key_length / value_length;
- layer_norm_rms_epsilon;
- rope.freq_base;
- context_length;
- vocab_size;
- tokenizer 的 bos/eos/pad id。
Mistral 有一个容易踩的坑:很多 GGUF 使用 general.architecture=llama,只通过 attention.sliding_window 表达 SWA。导入器看到这个组合会把模型类型提升为 Mistral,保留 sliding-window 语义。
6.3 Fused tensor
GGUF 可能存成:
blk.N.attn_qkv.weight
blk.N.ffn_gate_up.weight
导入器会按配置里的 head 数和中间维度拆分,分别写入目标 q/k/v 和 gate/up。
6.4 Tokenizer
GGUF 不像 HF 目录那样天然带 tokenizer_config.json 和 tokenizer.json。因此:
- 只做权重转换时,
--tokenizer可省略; - 要接 OpenAI gateway 时,建议显式提供:
--tokenizer F:\models\Qwen2.5-3B-Instruct
导入器会复制 tokenizer 文件,并在存在 GGUF chat template 时补齐 tokenizer_config.json。
7. 输出格式和运行时修正
7.1 Symmetric int4 重打包
转换器把源 dense 权重按目标 kernel 要求重新量化:
scale = max(abs(group)) / 7
q = clamp(round(weight / scale), -8, 7)
两个 signed int4 装进一个 uint8,scales 保存为 FP16。
7.2 runtime 不再硬编码 group_size
以前 QwenSm86Runtime 固定用 128 加载 W4A16。现在会读取:
manifest.json -> group_size
这样转换器使用 --group-size 64/32 时,runtime 也能正确加载并分配 scale 张量。
7.3 CPU checkpoint 加载修正
测试转换后 checkpoint 时发现:
Qwen2W4A16ForCausalLM.from_checkpoint(..., device="cpu")
仍然会无条件调用 _bind_cupy(),在 CPU 上直接报错。
修复为只有 packed tensor 在 CUDA 上时才绑定 CuPy。这个修正让转换器的 CPU 端到端测试成为可能,也避免 CPU 环境执行无关的 CUDA bridge 初始化。
7.4 防止输出覆盖源
--overwrite 是危险参数。转换器会检查:
output == source 或者 output 是 source 的祖先目录
如果是,直接拒绝。否则一次错误的重定向可能把源 checkpoint 删掉。
8. 结果
8.1 自动化测试
完整 CPU 回归:
26 passed, 1 skipped
ruff: All checks passed
其中量化格式模块:
8 passed
覆盖:
- GPTQ desc_act / g_idx 反量化;
- AWQ GEMM reverse-order 反量化;
- W4A16 pack roundtrip;
- GPTQ -> SM86 W4A16 端到端;
- AWQ -> SM86 W4A16 端到端;
- GGUF Q4_0 -> SM86 W4A16 端到端;
- GGUF Q4_K block 反量化;
- 转换后 checkpoint 重新加载、logits 有限,并与 FP reference 做 cosine 对齐。
8.2 Tiny Qwen2 端到端观测
用一层的 tiny Qwen2 做合成量化 checkpoint,再转换、加载、比较 logits:
| 源格式 | logits cosine(tiny 模型,非生产指标) |
|---|---|
| GPTQ 合成 | 约 0.9966 |
| AWQ 合成 | 约 0.9959 |
| GGUF Q4_0 合成 | 约 0.9863 |
这些数字只说明解包、重排和转换链路方向正确,不代表真实 3B/7B 模型的质量。
8.3 没有做的结果
必须明确:
- 没有在真实 Qwen2.5-3B/7B 社区 checkpoint 上跑完整转换;
- 没有在 RTX 3050 上测 GPTQ/AWQ/GGUF 转换后的吞吐;
- 没有测真实模型量化前后的 perplexity / 任务质量;
- 没有 GPU 显存峰值数据。
当前环境只有 CPU 版 PyTorch,所以这次只给正确性结果,不给性能结论。
9. 走过的弯路
下面这些是这次真正踩过的坑,不是事后总结出来的漂亮经验。
9.1 一开始想过“直接支持社区格式运行”
最初的直觉是:既然已经有 W4A16 GEMV,能不能把 GPTQ/AWQ 的 qweight 直接塞给 kernel?
答案是不行。qweight 的 packing 方向、group 顺序、qzeros 语义和 GGUF block 布局都不一样。在线逐 token 解包会带来额外开销,且很容易把布局错误藏进 “模型好像还能说话”的结果里。
最后走了“离线转换到 native 格式”的保守路线。它的代价是转换时间和临时磁盘, 但换来两个好处:
- 执行路径只有一套 W4A16 kernel;
- 转换结果可以被 safetensors/manifest 检查,不依赖运行时 luck。
9.2 GPTQ 的 qzeros 少加了 1
第一次写 GPTQ 合成测试时,反量化权重的平均误差明显偏大,cosine 只有约 0.96。检查 packing 方向和 scale 后才发现问题在零点:
qzeros 里存的是 zero - 1
反量化时要用 unpack + 1
这个问题最阴险的地方是:如果直接拿真实 checkpoint 跑,模型可能仍然输出一些 token,但数值已经偏了。合成 roundtrip 测试在这里救了一次。
9.3 GPTQ 的 desc_act 不能靠猜
一开始只按 group_size 做:
g_idx[i] = i // group_size
这对普通 group quant 没问题,但 desc_act=true 时 checkpoint 会提供 g_idx。
如果忽略它,scale/zero 会被应用到错误的输入行。
最终规则是:
- 有
g_idx就严格使用; - 配置声称 desc_act 但 checkpoint 没有 g_idx 就报错;
- 不自动假设“可能是顺序的”。
宁可拒绝,也不悄悄生成精度错误的权重。
9.4 AWQ 的 reverse order 比 shift 更容易错
AWQ 的 int32 unpack 本身不复杂,但输出维有一个顺序重排:
[0, 4, 1, 5, 2, 6, 3, 7]
最初按直觉直接 reshape,shape 完全正确,数值却不对。只看 shape 是发现不了的, 必须用合成权重的 roundtrip cosine 才能暴露。
9.5 AWQ 还有 GEMV / ExLlama 等不同布局
即使都叫 AWQ,不同 backend 的 qweight packing 也可能不同。本次只支持
AutoAWQ GEMM。检测到 version=gemv 或 version=exllama 时直接拒绝。
这是故意做得“不宽容”的:错误的反量化公式比明确拒绝更危险。
9.6 GGUF 的维度顺序与普通张量相反
GGUF/ggml 的 tensor info 里保存的维度顺序和 PyTorch 逻辑 shape 相反。第一次 构造测试 GGUF 时,把原始 shape 当成 raw shape 传给 writer,直接触发了:
Quantized tensor bytes per row is not a multiple of ... type size
后来确认 writer/reader 会做 byte-shape <-> logical-shape 转换,反量化后的线性
权重已经是目标需要的 [out, in],不应再额外转置。
9.7 想用 gguf 包 quantize Q4_K 来造测试,结果它只支持 dequantize
为了覆盖常见的 Q4_K,第一版测试想用 gguf.quants.quantize 合成一个完整
Q4_K checkpoint。结果直接报:
NotImplementedError
这个包可以反量化 Q4_K,但没有实现 Q4_K 的 quantize。
最后的测试策略改成:
- Q4_0 做完整 GGUF -> W4A16 端到端;
- Q4_K 手工构造一个 144-byte block,验证我们的读取和反量化路径。
这个弯路提醒:测试夹具本身也可能“不具备生产能力”。
9.8 GGUF 没有天然 HF tokenizer
考虑过从 GGUF 的 tokenizer metadata 自动生成 tokenizer.json,但 GGUF 可能使用
BPE、SentencePiece 等不同 tokenizer 后端,自动还原 HF tokenizer 的 special
tokens、chat template 和 added tokens 很容易出错。
这次没有伪造一个不完整的 tokenizer。权重转换可以独立完成;如果需要接网关,
提供 --tokenizer 指向一个 HF tokenizer 目录。
9.9 lm_head 很想一起量化,但 runtime 还不支持
从显存角度,7B 的 dense FP16 lm_head 很贵。最初考虑过在转换时直接把 lm_head 也打成 W4A16。
但当前 runtime 的 lm_head 是 dense nn.Linear,如果只在转换器里写一个 packed
head,加载器根本不会使用它。那会得到一个“文件里有 head packed 张量,但推理
没用上”的假兼容。
因此本次明确:
- tied embedding:不写 head;
- 非 tied:写 FP16 dense head;
- 大 head 打印提示;
- 把量化 head 留到 runtime 支持 W4/W3 head 之后再实现。
9.10 runtime 的 group_size 硬编码
转换测试中用 group_size=32 加载时发现了问题:runtime 一直固定传
group_size=128,不读输出 manifest。
修复后 runtime 从 manifest 读取 group_size。这个问题说明“转换器支持”不仅是转换 器自己的事,加载端也必须消费同样的元数据。
9.11 CPU 加载仍无条件绑定 CuPy
转换后 checkpoint 用 CPU 加载时又遇到一个隐藏假设:
layer.self_attn.fused_qkv._bind_cupy()
这里默认模型一定在 CUDA 上。测试环境没有 CUDA,于是直接报错。
最后改成只在 packed tensor 实际位于 CUDA 时绑定。这个改动也让转换器的 CPU 端到端回归可以真正执行。
9.12 --overwrite 可能误删源目录
输出目录如果被错误地设成源目录或源目录的祖先,--overwrite 的清理逻辑可能先
删掉源 checkpoint,再去读它。
虽然这是命令行使用错误,但工具应该保护用户。现在转换器会在任何清理前检查:
source.is_relative_to(output) -> 拒绝
这个检查看起来很小,但比事后恢复模型目录便宜得多。
10. 仍未完成
| 能力 | 状态 |
|---|---|
| GPTQ 2/4/8-bit -> W4A16 | ✅ |
| GPTQ desc_act / g_idx | ✅ |
| AWQ GEMM 4-bit -> W4A16 | ✅ |
| GGUF Q4_0 完整转换 | ✅ |
| GGUF Q4_K block 反量化 | ✅ |
| 原格式零拷贝直跑 | ❌ |
| AWQ GEMV / ExLlama layout | ❌ |
per-channel group_size=-1 |
❌ |
| GGUF split shards | ❌ |
| 量化 embedding / 量化 norm | ❌ |
| 量化 lm_head 原生执行 | ❌ |
| MoE / MLA | ❌ |
| 真实 3B/7B 质量与吞吐数据 | 未测 |
尤其不要把 tiny-model cosine 当成生产精度结论。它只能证明“这个格式的解包和 重排方向是对的”。
11. 如何复现
安装依赖:
pip install -r requirements-sm86.txt
运行全部 CPU 回归:
python -m pytest -q tests
只跑量化模块:
python -m pytest -q tests\quantization
GPTQ:
python scripts\import_quantized_checkpoint.py `
--source F:\models\Qwen2.5-3B-GPTQ-Int4 `
--output D:\Qwen3B-GPTQ-W4A16-SM86 `
--format auto --group-size 128
AWQ:
python scripts\import_quantized_checkpoint.py `
--source F:\models\Qwen2.5-3B-AWQ `
--output D:\Qwen3B-AWQ-W4A16-SM86 `
--format awq --group-size 128
GGUF:
python scripts\import_quantized_checkpoint.py `
--source F:\models\qwen2.5-3b-q4_k_m.gguf `
--tokenizer F:\models\Qwen2.5-3B-Instruct `
--output D:\Qwen3B-GGUF-W4A16-SM86 `
--format gguf --group-size 128
转换完成后可以继续使用现有 W4A16 gateway:
python apps\server.py `
--checkpoint D:\Qwen3B-GGUF-W4A16-SM86 `
--backend w4a16 `
--scheduler --scheduler-backend paged `
--port 18001
12. 最后的判断
这次改造的价值不是“支持了多少个缩写”,而是把量化格式问题从主执行路径里隔离 出来了:
格式识别 / 解包 / 反量化
↓
统一 W4A16 转换
↓
稳定执行路径
GPTQ 的 qzeros、AWQ 的 reorder、GGUF 的 block 布局和 tensor 命名,都是很容易 被“能跑通”表象掩盖的问题。最后选择以合成 roundtrip + CPU 端到端 + 明确拒绝 未知布局作为底线。
一句话总结:
现在 DeepInfer-SM86 可以吃下 GPTQ、AWQ 和多种 GGUF,但它是通过一个严格的 离线转换器吃下,而不是假装这些格式和 native W4A16 kernel 是同一种东西。
- 实现提交:
183e92b - 兼容说明:
docs/QUANTIZATION_FORMAT_COMPATIBILITY.md - 测试:
tests/quantization/ - CLI:
scripts/import_quantized_checkpoint.py
评论
0还没有评论,来说点什么吧。
发表评论