bitsandbytes :为什么它能“加载即量化”,NF4、双重量化与 QLoRA 到底在做什么

GPTQ、AWQ 往往要先准备校准数据、执行量化、保存新权重;GGUF 通常还涉及格式转换与特定运行时。bitsandbytes 走的是另一条路:在 Transformers 加载模型时替换线性层并完成低比特权重转换,几行配置就能把模型以 8bit 或 4bit 形式放进设备内存。它不一定是推理最快的方案,也不是所有场景下精度最优的方案,但它把“量化”从一项专门工程,变成了普通开发者可以随手打开的模型加载选项。

这篇文章从零建立直觉,再逐层拆开 LLM.int8()、FP4、NF4、双重量化、计算精度和 QLoRA。读完后,你不仅会写配置,更应该能回答这些问题:

  • bitsandbytes 到底在什么时候量化?
  • 它为什么不需要校准数据?
  • 8bit 为什么还要保留一条 FP16 计算支路?
  • NF4 为什么比“均匀切成 16 格”更适合神经网络权重?
  • 双重量化到底量化了什么,能省多少?
  • “4bit 存、16bit 算”准确到什么程度?
  • 为什么 4bit 模型不一定比 FP16 更快?
  • QLoRA 为什么能反向传播,却又不更新 4bit 基座权重?
  • bitsandbytes、GPTQ、AWQ、GGUF 应该怎样选?

阅读前先修正四个很常见、但不够准确的说法

在正式开始前,先把几个传播很广的简化说法校正一下。它们用来建立第一印象没问题,但写成教程时最好说完整。

第一,bitsandbytes 常见用法是“加载时量化”;保存能力要按位宽与版本区分

最常见的工作流确实是:从 Hugging Face 的 FP16/BF16 权重开始,from_pretrained() 加载时进行 8bit 或 4bit 转换。因此第一次使用时,你通常仍要下载原始高精度检查点。

“bitsandbytes 只省显存、绝对不能保存低比特权重”也不够准确。当前 Transformers 文档已经明确给出 8bit 模型保存、推送与重载的路径;4bit 检查点在近年的工具链中也存在保存与重载路径,但兼容性更依赖具体的 Transformers、bitsandbytes、模型架构、序列化格式和加载方式,不能脱离版本一概而论。更稳妥的表述是:

  • 从原始模型按需加载时,本地缓存里往往仍保留原始高精度权重;
  • 8bit 检查点已有较明确的官方保存与重载工作流;
  • 4bit 检查点应在目标版本和目标架构上验证保存、冷启动重载与跨机器迁移;
  • 即使能够保存,它的运行时依赖和跨生态可移植性,仍与 GGUF、AWQ、GPTQ 这类面向部署的预量化产物不同。[1]

第二,NF4 不是 BitsAndBytesConfig 的默认值

当前配置类中,bnb_4bit_quant_type 的默认值是 "fp4",不是 "nf4"。如果你想使用 NF4,应该明确写出:

bnb_4bit_quant_type="nf4"

官方文档尤其推荐在 4bit 基座模型训练,也就是 QLoRA 场景中使用 NF4。[1]

第三,“4bit 存、16bit 算”是常见配置,不是无条件事实

bnb_4bit_compute_dtype 默认通常是 FP32。只有当你显式把它设为 torch.bfloat16torch.float16 时,才是常说的“4bit 存、16bit 算”。而且实现通常不会把整层权重永久展开成一份完整 BF16 副本;更常见的是在内核中按块读取、反量化并参与矩阵乘法。

第四,bitsandbytes 已不再是“只支持 Linux + NVIDIA CUDA”的代名词

截至本文修订时,官方文档列出了 NVIDIA GPU、CPU、Intel XPU、Intel Gaudi 等支持,并对 AMD ROCm、Apple Silicon 等平台持续推进或提供实验性支持。本文的代码仍以 NVIDIA CUDA 为主,因为这条路径最成熟、资料最多,但“CPU 基本用不了”“Windows 完全不支持”已经是旧印象。[2]

有了这四个修正,下面再建立完整框架。


一、先看它到底省心在哪

用 bitsandbytes 加载一个 4bit 模型,核心代码可以短到这样:

import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-1.5B-Instruct",
    quantization_config=quant_config,
    device_map="auto",
    dtype=torch.bfloat16,
)

你没有单独写量化脚本,没有准备校准语料,没有逐层搜索量化参数,也没有先导出一种新格式。对于支持的 Transformers 模型,框架会把适合量化的线性层替换为 bitsandbytes 的低比特线性层,并在加载权重时完成转换。

这就是 bitsandbytes 最重要的产品价值:它把量化包装成了模型加载策略。

对初学者而言,这意味着你不必先成为量化专家,才能验证“这张显卡能否跑得动这个模型”;对研究和微调而言,这意味着你可以快速切换 FP16、8bit、FP4、NF4,而不用为每种实验维护一套离线产物;对 QLoRA 而言,这意味着 4bit 冻结基座可以直接接入 PEFT 和训练框架。

不过,配置简单不代表内部简单。bitsandbytes 的 8bit 与 4bit 其实是两套不同思路:

  • 8bit 主要对应 LLM.int8():向量级缩放加离群特征的混合精度分解;
  • 4bit 主要对应 分块权重量化:每个小块独立缩放,再用 FP4 或 NF4 码本表示权重;
  • QLoRA 则在 4bit 冻结基座之上训练 LoRA,并结合 NF4、双重量化和分页优化器降低训练内存。

下面先从最容易被忽略的一件事开始:显存到底花到哪里去了。


二、先学会算显存账:量化主要压缩的是哪一部分

很多人第一次接触量化,会把“模型参数位宽”直接等同于“程序总显存”。例如:

  • FP16 是 16bit;
  • 4bit 是 FP16 的四分之一;
  • 所以 4bit 模型总显存也应该只剩四分之一。

这个推理只对“被量化权重的理论裸存储量”近似成立,对整个推理进程并不成立。

2.1 只算权重时,估算很简单

设模型有 (N) 个参数,忽略所有额外信息:

  • FP32 权重约占 (4N) 字节;
  • FP16/BF16 权重约占 (2N) 字节;
  • INT8 权重约占 (N) 字节;
  • 4bit 权重约占 (0.5N) 字节。

以 7B 参数模型为例,使用十进制 GB 粗算:

权重形式 理论裸权重大小
FP32 约 28 GB
FP16/BF16 约 14 GB
8bit 约 7 GB
4bit 约 3.5 GB

这张表非常适合做第一轮容量判断,但不能拿来承诺实际显存。

2.2 真正运行时,还要加上这些开销

一个模型进程通常还包含:

  1. 没有被量化的模块。 LayerNorm、部分 embedding、输出头、偏置或不兼容量化的层,可能仍以 FP16、BF16 或 FP32 保存。
  2. 量化元数据。 每个块需要 scale、量化状态、码本信息;4bit 还需要把两个 4bit 编码打包进一个字节。
  3. CUDA 上下文与缓存分配器。 即使没有大张量,CUDA 运行时本身也会占用显存;PyTorch 还会保留已申请但暂未使用的显存块。
  4. 临时工作区。 矩阵乘法、反量化、注意力实现和采样过程可能申请临时 buffer。
  5. 激活。 推理时当前层的中间结果仍要存在;训练时还要为反向传播保留更多激活。
  6. KV Cache。 自回归生成会缓存每一层过去 token 的 Key 和 Value。上下文越长、批量越大,KV Cache 越可观。
  7. 训练专属内存。 梯度、LoRA 参数、优化器状态、损失计算和梯度检查点重算都会占内存。

因此,实际常见现象不是“4bit 总显存精确变成 FP16 的 25%”,而是:权重部分接近四分之一,总进程显存下降到原来的三分之一、二分之一或其他比例。 模型越大、上下文越短,权重压缩带来的总收益通常越明显;模型越小、上下文越长,固定开销和 KV Cache 占比越高。

2.3 量化权重不等于量化 KV Cache

这是长上下文场景里最重要的认知之一。

bitsandbytes 的 load_in_4bit=True 主要处理模型权重,默认并不会顺手把 KV Cache 也变成 4bit。于是你可能看到:

  • 模型刚加载时只占 5 GB;
  • 输入几万 token 后显存不断上升;
  • 最后仍然 OOM。

这不是量化失效,而是你压缩了“模型静态资产”,却没有压缩“随序列长度增长的运行状态”。所以判断一张卡能否跑某个任务,至少要同时看:模型参数量、权重位宽、上下文长度、批量大小、KV Cache dtype 和注意力实现。


三、“加载即量化”到底发生了什么

把 bitsandbytes 简称为“在线量化”很方便,但最好知道这只是教学上的归纳,不是严谨到不能打破的分类。

3.1 GPTQ、AWQ 的典型路径:先量化,再部署

GPTQ 与 AWQ 都属于典型的后训练量化路线。常见流程是:

高精度模型
  ↓
准备代表性校准数据
  ↓
逐层统计或优化量化参数
  ↓
生成打包后的低比特权重
  ↓
保存为新的量化检查点
  ↓
部署时直接加载低比特产物

GPTQ 使用近似二阶信息逐步量化权重并补偿误差;AWQ 借助激活统计识别重要通道,再通过等价缩放降低显著权重的量化损失。两者都愿意在部署前多做一些工作,以换取更精细的量化决策和更适配推理内核的权重布局。[5][6]

3.2 bitsandbytes 的常见路径:加载时替换模块并转换权重

bitsandbytes 与 Transformers 集成后的常见流程更像:

读取模型结构
  ↓
把可量化的 torch.nn.Linear 替换为 Linear8bitLt / Linear4bit
  ↓
从检查点流式读取高精度权重
  ↓
按目标方案量化、打包并分发到设备
  ↓
得到可直接推理或接入 PEFT 的模型对象

这里有两个细节值得强调。

第一,框架通常会借助 Accelerate、meta device 和分片加载,避免先在 GPU 中完整放下一份 FP16 模型,再复制出低比特模型。否则“为了量化 14 GB 的模型,先需要 14 GB 显存”就失去意义了。

第二,所谓“加载时量化”不表示模型每次前向都重新量化。权重加载并转成 4bit/8bit 后,会以低比特形式驻留;前向过程中做的是读取低比特权重、按需反量化并完成矩阵运算,而不是每次从原始 FP16 文件重新生成低比特权重。

3.3 为什么它不需要校准数据

GPTQ、AWQ 的量化决策会参考一批代表性输入:

  • GPTQ 需要从层输入估计与误差相关的二阶信息;
  • AWQ 需要收集激活统计来判断哪些通道更重要。

bitsandbytes 的基础 4bit 权重量化主要依据当前权重块自身的数值范围与固定码本;LLM.int8() 的离群处理则在运行时根据隐藏状态阈值进行混合精度分解。它不需要先拿一批语料跑完整校准流程,因此通用性和启动速度都更好。

代价也很直观:它没有利用你的目标任务分布去精修每一层的量化参数。于是它的优势是低门槛和可训练性,而不是保证在每个模型、每个硬件、每个吞吐目标上都胜过离线量化。

3.4 在线与离线不是“高级”和“低级”的区别

不要把两条路线理解成:

  • 在线量化简单,所以技术含量低;
  • 离线量化复杂,所以一定更好。

它们优化的是不同目标。

bitsandbytes 优化的是:

  • 任意兼容模型快速试验;
  • 不准备校准集;
  • 与 PyTorch、Transformers、PEFT 直接衔接;
  • 让低比特冻结基座参与训练。

GPTQ/AWQ 优化的是:

  • 预先付出一次量化成本;
  • 产出可反复部署的低比特检查点;
  • 尽量降低量化误差;
  • 配合面向推理的打包格式和专用内核。

真正的工程选型从来不是“谁更先进”,而是“谁更匹配你的瓶颈”。


四、8bit 原理:LLM.int8() 为什么不是普通 INT8

很多教程把 bitsandbytes 8bit 简化成“把 FP16 权重除以 scale,四舍五入到 INT8”。如果只是这样,LLM.int8() 就不会成为一篇单独的论文。

它解决的核心问题是:大模型隐藏状态中会出现少量、系统性的极端特征维度。 这些特征很少,却可能对注意力和预测结果非常重要。若用一个粗糙的统一尺度把它们和普通值一起压到 INT8,极端值会占满动态范围,普通值就只能挤在很少的整数刻度上。

4.1 先理解最朴素的 absmax 量化

给定一组浮点数 (x),对称 INT8 的编码范围通常取 ([-127,127])。定义:

[
s = \frac{127}{\max |x|}
]

量化:

[
q = \operatorname{round}(s x)
]

反量化:

[
\hat{x} = \frac{q}{s}
]

问题在于,假设绝大多数值在 ([-1,1]),但其中一个值是 20,那么 scale 必须照顾 20。结果是大量普通值只落在零附近很少几个整数上,量化误差明显变大。

4.2 第一层改进:vector-wise quantization

矩阵乘法可以看作很多行向量与列向量的内积。与其整张矩阵只用一个 scale,不如:

  • 对输入矩阵的每一行使用独立缩放;
  • 对权重矩阵的每一列使用独立缩放;
  • INT8 乘法后,再用行 scale 与列 scale 的外积恢复尺度。

这叫向量级量化。它比整张张量共用一个 scale 精细得多,因为局部极值只影响对应行或列。

4.3 第二层改进:把离群特征维度拆出去

仅有向量级缩放还不够。LLM.int8() 论文发现,在足够大的 Transformer 中,极端值不是随机散落,而是集中在少数隐藏特征维度中。论文提出混合精度分解:

设输入隐藏状态为 (X),权重为 (W),原始计算是:

[
Y = XW
]

把特征维度分成普通集合 (R) 与离群集合 (O):

[
Y = X_R W_R + X_O W_O
]

其中:

  • (X_R W_R) 使用向量级 INT8 路径;
  • (X_O W_O) 使用 FP16 高精度路径;
  • 最后把两部分结果相加。

论文报告,超过 99.9% 的值仍可在 8bit 路径中计算,而关键离群特征保留 16bit,从而在其评测模型上保持接近高精度基线的表现。[3]

4.4 llm_int8_threshold 判断的是隐藏状态,不是“权重大不大”

这一点非常容易讲错。

llm_int8_threshold=6.0 不是在加载时扫描权重,把绝对值大于 6 的权重留在 FP16;它对应的是前向过程中隐藏状态离群值的阈值。超过阈值的特征会触发高精度处理。

默认 6.0 通常是合理起点。不要因为看到“阈值”就机械调小或调大:

  • 不同模型的隐藏状态分布不同;
  • 阈值会同时影响质量、混合精度路径比例和速度;
  • 某些实现对特殊值还有优化或快捷路径;
  • 最终应通过任务指标与吞吐共同验证。

4.5 LLM.int8() 不是纯粹的 W8A8

部署资料里常用 W8A8 表示权重和激活都以 8bit 参与计算。但 LLM.int8() 更准确的描述是:

  • 大部分权重与特征走 8bit 矩阵乘法;
  • 离群特征走 16bit;
  • INT8 乘积通常以更高位整数累加,再恢复到浮点输出;
  • 整个算子是混合精度的。

因此,它的目标不是“所有数都必须是 INT8”,而是“把绝大多数工作放到低比特路径,同时保护少数高敏感特征”。这是它比朴素 INT8 更稳的关键。

4.6 8bit 什么时候值得用

8bit 很适合这些场景:

  • 你希望尽量接近 FP16/BF16 的模型表现;
  • 显存只差大约一倍,4bit 没必要那么激进;
  • 模型或任务对 4bit 误差比较敏感;
  • 你想先用最保守的低比特方案验证流程;
  • 需要使用 8bit CPU offload 等机制装下更大模型。

它不一定适合:

  • 显存非常紧张,必须把权重压到接近 4bit;
  • 你追求某个专用推理引擎上的极致吞吐;
  • 模型很小,量化内核和混合精度分解的固定开销占比太高。

五、4bit 的基本结构:不是把每个权重粗暴塞进 0~15

bitsandbytes 4bit 的核心是分块量化 + 4bit 码本 + 按块缩放

5.1 为什么必须分块

假设一整层有几百万个权重,只用一个全局最大值做 scale。只要其中有少数极端权重,整层的量化刻度就会被拉宽,普通权重的分辨率下降。

于是把权重展平并切成小块,每个块独立计算绝对最大值。bitsandbytes 的 4bit functional API 常见默认 block size 是 64,也就是每 64 个权重共享一个缩放常数。[8]

块越小:

  • 每个 scale 负责的数更相近,量化通常更精确;
  • 但 scale 数量更多,元数据开销更大。

块越大:

  • 元数据更少;
  • 但局部离群值影响范围更大。

这正是后面双重量化要解决的矛盾:为了精度把块切小,却因此存了很多 scale。

5.2 一个 4bit 块如何编码

对某个权重块 (B={w_i}),先计算:

[
a = \max_i |w_i|
]

再归一化:

[
u_i = \frac{w_i}{a}
]

此时 (u_i) 大致位于 ([-1,1])。然后准备一个只有 16 个值的码本:

[
C = {c_0,c_1,\ldots,c_{15}}
]

每个权重只保存最接近码本值的索引:

[
q_i = \arg\min_j |u_i-c_j|
]

因为索引只有 0~15,所以 4bit 就够。反量化时:

[
\hat{w_i}=a\cdot c_{q_i}
]

真正保存的是:

  • 每个权重的 4bit 索引;
  • 每个块的 scale,也就是上面的 (a);
  • 量化类型和其他状态。

两个 4bit 索引可以打包进一个 uint8,因此 bnb_4bit_quant_storage=torch.uint8 并不意味着权重变成了 8bit 精度;uint8 只是装两个半字节的容器。

5.3 4bit 最大的问题不是范围,而是只有 16 个代表值

16 个值非常少。关键问题就变成:这 16 个点放在哪里?

  • 如果均匀分布在 ([-1,1]),实现简单,但可能浪费刻度;
  • 如果采用 4bit 浮点码本,能表达不同数量级,但码点布局未必最适合神经网络权重;
  • 如果根据权重常见分布设计非均匀码本,就可能把有限刻度用在“最常出现”的区域。

这就是 NF4 的出发点。


六、NF4:它不是“更神奇的 4bit”,而是更匹配分布的 16 个码点

NF4 全称 NormalFloat 4。它来自 QLoRA 工作,设计目标是为近似零均值正态分布的预训练权重提供更合适的 4bit 表示。[4]

6.1 为什么均匀量化会浪费刻度

想象一组接近正态分布的权重:

  • 大量权重密集在 0 附近;
  • 越靠近两端,权重越少;
  • 真正接近最大绝对值的元素只占很小比例。

若把 16 个码点均匀铺在 ([-1,1]),0 附近相邻码点的间距和边缘一样大。但数据在 0 附近最密集,恰恰最需要更细分辨率;两端数据少,却占用了同样密度的刻度。

NF4 的思路是:按照标准正态分布的分位点构造非均匀码本,让每个量化区间在理想分布下承担近似相等的概率质量。 结果就是:

  • 0 附近码点更密;
  • 两端码点更疏;
  • 同样只有 16 个值,却更符合权重的常见分布。

6.2 “按正态分布排刻度”更严谨的说法

常见比喻是“正态分布哪里数据多,哪里刻度就密”。这个直觉没错,但正式一点可以这样描述:

  1. 从标准正态分布 (N(0,1)) 的分位函数中取得一组代表点;
  2. 把码本归一化到 ([-1,1]);
  3. 为了让零值可以无误差表示,对正负部分的构造做非对称处理,并保留精确的 0;
  4. 每个实际权重块先按 absmax 归一化,再映射到这组固定码点。

所以 NF4 不是每个块都重新统计 16 个分位点,也不是对整个模型做昂贵的分布拟合。码本本身是固定的,块内只需要缩放和最近码点映射。

6.3 NF4 为什么适合 QLoRA

QLoRA 训练时,4bit 基座被冻结。LoRA 需要在这个近似基座上学习任务增量。如果基座量化误差过大,LoRA 一部分容量就会被迫用来“补量化误差”,而不是学习任务。

QLoRA 论文在多种模型与任务上比较了 4bit 格式,报告 NF4 的量化精度优于所比较的 FP4 和 INT4 形式;论文的消融也显示,NF4 + 双重量化可以在其设置中恢复接近 16bit LoRA 的表现。[4]

因此,对 QLoRA 来说,NF4 不是单纯为了“推理输出更好看”,而是为了给适配器提供一个误差更小的冻结基座。

6.4 NF4 是否永远比 FP4 好

不应把论文结论扩大成无条件定律。

NF4 的优势依赖于一个关键先验:被量化权重块接近零均值正态分布。现实模型中:

  • 不同层分布不同;
  • 某些权重经过特殊训练、裁剪或合并后不再近似正态;
  • 不同实现、块大小和任务会改变最终差距;
  • 纯推理中的感知差异可能比微调场景小。

稳妥做法是:

  • QLoRA 默认优先 NF4;
  • 纯推理也可优先尝试 NF4;
  • 但把 FP4 作为对照,通过困惑度或任务指标验证,而不是只比较一条聊天回答。

6.5 一个必须避免的混淆:FP4 不等于均匀 INT4

bitsandbytes 的 "fp4" 是 4bit 浮点式码本,不是简单的 16 个均匀整数刻度。因此更准确的对比是:

  • FP4:采用 4bit 浮点风格的离散值布局;
  • NF4:采用面向正态分布分位点设计的离散值布局;
  • INT4:通常指整数码点,还要结合对称/非对称、group size、zero point 等具体定义。

不要把所有“4bit”当成同一种格式。位数只告诉你有多少编码空间,不告诉你码点如何安排、scale 如何分组、内核如何计算。


七、双重量化:量化的不是权重第二遍,而是第一遍产生的 scale

“双重量化”这个名字很容易让人误解成:

权重先量化成 8bit,再量化成 4bit

实际不是。权重仍然只做一次 4bit 编码;第二次被量化的是每个权重块对应的量化常数

7.1 为什么 scale 也会变成一笔账

假设每 64 个权重为一块,每块需要一个 FP32 scale。

  • 一个 scale 占 32bit;
  • 由 64 个权重共同承担;
  • 平均到每个权重,就是:

[
\frac{32}{64}=0.5\text{ bit/parameter}
]

也就是说,权重编码本身是 4bit,但只算第一层 scale,平均有效开销已经接近 4.5bit/参数,还没加其他状态。

7.2 第二次量化如何降低这笔开销

QLoRA 的双重量化把第一层 scale 再按块量化。例如论文中的设计:

  • 第一层权重块大小为 64;
  • 第一层 scale 用 8bit 保存;
  • 每 256 个第一层 scale 再共享一个 FP32 二级 scale。

于是平均每个参数承担的 scale 开销变成:

[
\frac{8}{64}+\frac{32}{64\times256}
=0.127\text{ bit/parameter}
]

相较于原来的 0.5bit,约节省:

[
0.5-0.127=0.373\text{ bit/parameter}
]

论文把它概括为平均节省约 0.37bit/参数;对 65B 模型,大约是 3GB 量级。[4]

7.3 0.37bit 看起来很小,为什么仍然重要

对单个参数,0.37bit 几乎可以忽略;乘上几十亿参数后就不是小数目。

粗略估算:

  • 7B:(7\times10^9\times0.373/8),约 0.33GB;
  • 13B:约 0.61GB;
  • 33B:约 1.54GB;
  • 65B:约 3.03GB。

在显存富余的服务器上,这可能只是锦上添花;在 16GB、24GB、48GB 这种“差几百 MB 就装不下”的边界场景里,它可能决定实验能不能启动。

7.4 双重量化真的完全没有代价吗

官方文档和 QLoRA 论文都把它描述为额外节省内存且没有观察到明显性能退化。[1][4] 工程上更稳妥的表述是:

  • 它通常对质量影响极小;
  • 会增加一点量化状态和反量化逻辑复杂度;
  • 是否有可测速度差异取决于内核、硬件和工作负载;
  • 对极端敏感任务,仍应做对照评测。

因此,QLoRA 和显存紧张的 4bit 实验通常建议打开:

bnb_4bit_use_double_quant=True

但它不是魔法,也不应替代实际基准测试。


八、计算精度:把“存储位宽”和“计算位宽”彻底分开

理解 bitsandbytes 最重要的能力之一,是同时区分四种 dtype 概念。

8.1 量化类型:NF4 或 FP4

bnb_4bit_quant_type 控制。它决定 4bit 编码的码本布局。

bnb_4bit_quant_type="nf4"

这回答的是:“一个 4bit 编码 0~15 分别代表什么值?”

8.2 量化存储容器:通常是 uint8

bnb_4bit_quant_storage 控制。默认通常为 torch.uint8。两个 4bit 值打包在一个字节里。

这回答的是:“这些 4bit 编码放在哪种 PyTorch 张量容器中?”

不要看到权重张量底层是 uint8,就误以为它是 8bit 权重量化。

8.3 计算 dtype:FP32、BF16 或 FP16

bnb_4bit_compute_dtype 控制。当前配置默认通常是 FP32;为了速度与内存,常改为 BF16 或 FP16。[1]

bnb_4bit_compute_dtype=torch.bfloat16

这回答的是:“低比特权重参与矩阵运算时,反量化结果与算术采用什么浮点精度?”

8.4 非量化模块 dtype

from_pretrained() 中的 dtype,旧版接口常见 torch_dtype,会影响没有被 4bit 替换的模块,例如 LayerNorm、embedding 或输出头。

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=quant_config,
    dtype=torch.bfloat16,
)

这回答的是:“模型中仍保持浮点的部分用什么类型?”

8.5 为什么 BF16 常被推荐

BF16 与 FP32 一样有 8 位指数,因此动态范围大;它的尾数精度低于 FP16,但比 FP16 更不容易因为数值范围过窄而溢出。对支持 BF16 的现代加速器,BF16 往往兼顾:

  • 16bit 存储与带宽;
  • 比 FP16 更宽的数值范围;
  • 比默认 FP32 更快的计算。

但“推荐 BF16”有前提:硬件和 PyTorch 后端真的支持。CUDA 环境可以先检查:

import torch

print(torch.cuda.is_bf16_supported())

若不支持,可用 FP16;若模型数值不稳定或后端对 FP16 支持不理想,再考虑 FP32。

8.6 “反量化后再算”不等于完整生成一份 BF16 权重副本

为了建立直觉,常说:

4bit 权重 → 反量化成 BF16 → 矩阵乘法

这很有用,但容易让人误以为每次前向都要在显存里额外放下一整层 BF16 权重。

高效实现通常会把反量化与矩阵乘法融合:按 tile 或 block 读取打包权重,把所需部分在寄存器、共享内存或临时缓冲中恢复,再立即参与运算。完整高精度权重不会长期驻留。否则 4bit 的显存优势会被一份完整 BF16 副本抵消。

所以更准确的说法是:

权重以 4bit 形式长期存储;计算内核按需把局部权重恢复到指定计算精度,并完成乘加与累积。

8.7 为什么 4bit 不一定更快

从理论上看,4bit 权重更小,显存带宽压力更低,理应更快。但真实速度取决于两股力量:

可能加速的部分:

  • 从显存读取的权重字节更少;
  • 自回归 decode 常常受显存带宽限制;
  • 更大的模型可以留在单卡,减少跨卡通信或 CPU offload。

可能拖慢的部分:

  • 解包和反量化需要额外指令;
  • 4bit 内核未必充分利用你的 GPU;
  • 小模型、小 batch 时固定开销占比高;
  • 某些硬件对 FP16/BF16 Tensor Core 极其优化,原生半精度已经很快;
  • prompt prefill 与单 token decode 的瓶颈不同;
  • device_map="auto" 若触发 CPU offload,PCIe 传输可能压倒量化收益。

结论不是“4bit 会变慢”,也不是“4bit 一定加速”,而是:bitsandbytes 首先是内存工具,速度必须在目标硬件和目标负载上实测。


九、BitsAndBytesConfig 参数逐个拆解

下面把常用参数分成“入门必会”和“进阶按需”。

9.1 load_in_8bit

BitsAndBytesConfig(load_in_8bit=True)

启用 LLM.int8(),把支持的线性层替换为 8bit 线性层。适合希望接近半精度质量、同时把权重显存大致减半的场景。

9.2 load_in_4bit

BitsAndBytesConfig(load_in_4bit=True)

启用 4bit 线性层。与 load_in_8bit 互斥,不要同时设为 True

9.3 bnb_4bit_quant_type

bnb_4bit_quant_type="nf4"  # 或 "fp4"

决定 4bit 码本。

  • "fp4":配置默认值;
  • "nf4":面向近似正态权重设计,QLoRA 推荐显式使用。

9.4 bnb_4bit_compute_dtype

bnb_4bit_compute_dtype=torch.bfloat16

决定 4bit 线性层内部计算精度。

  • 默认 FP32:兼容与稳定性较好,但更慢;
  • BF16:硬件支持时通常是优先选择;
  • FP16:更普遍,但动态范围更窄。

9.5 bnb_4bit_use_double_quant

bnb_4bit_use_double_quant=True

把第一层量化的 scale 再压缩,进一步节省显存。QLoRA 和显存边界场景通常建议开启。

9.6 bnb_4bit_quant_storage

bnb_4bit_quant_storage=torch.uint8

控制打包后权重的存储张量 dtype。普通单卡推理一般保持默认即可。它在 FSDP-QLoRA 等分布式场景中更重要,因为通信与分片系统可能要求量化存储 dtype 与其他参数 dtype 协调。

9.7 llm_int8_threshold

llm_int8_threshold=6.0

LLM.int8() 隐藏状态离群阈值。默认 6.0 是常见起点。除非有明确的速度或质量评测,不建议随手改。

9.8 llm_int8_skip_modules

llm_int8_skip_modules=["lm_head"]

明确指定不转成 8bit 的模块。某些多头结构或数值敏感输出层量化后可能不稳定,可以保留高精度。

9.9 llm_int8_enable_fp32_cpu_offload

llm_int8_enable_fp32_cpu_offload=True

允许把部分模块放到 CPU,以装下超出单卡显存的模型。要注意:放在 CPU 的权重通常以 FP32 保存,不是在 CPU 上做 INT8 计算。因此它是容量补救方案,不是免费扩容;速度可能明显受 PCIe 和 CPU 限制。

9.10 llm_int8_has_fp16_weight

这个参数主要面向需要保留 16bit 主权重的特殊训练场景,使前后向不必反复在主权重表示之间转换。普通 8bit 推理通常保持默认。

9.11 device_map 不是量化参数,但往往决定你是否真的省心

常见写法:

device_map="auto"

它让 Accelerate 根据可用设备分配模块。对推理很方便,但要注意:

  • 如果单卡装不下,可能自动把部分模块放到 CPU;
  • 这会让显存下降,却也可能大幅降低速度;
  • 做严格单卡基准时,应使用明确的设备映射,例如 device_map={"": 0}
  • 官方文档提醒,device_map="auto" 主要用于推理,训练应交给 Accelerate、Trainer、FSDP 等训练编排机制。[1]

9.12 一套更严谨的 4bit 推荐配置

import torch
from transformers import BitsAndBytesConfig

if not torch.cuda.is_available():
    raise RuntimeError("本示例以 CUDA 为目标,请改用与你后端匹配的设备配置。")

compute_dtype = (
    torch.bfloat16
    if torch.cuda.is_bf16_supported()
    else torch.float16
)

quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=compute_dtype,
    bnb_4bit_use_double_quant=True,
)

它不是对所有任务的数学最优解,但作为 QLoRA 和显存优先实验的起点,比“不写任何 4bit 参数”更可靠。


十、上手实测:从最小代码到可复现基准

10.1 安装

本文以较新的 PyTorch、Transformers、Accelerate 和 bitsandbytes 为例:

pip install -U torch transformers accelerate bitsandbytes

做 QLoRA 再安装:

pip install -U peft datasets trl

当前官方安装文档已经覆盖 NVIDIA GPU、CPU、Intel XPU、Intel Gaudi 等后端;不同平台的最低版本和 wheel 支持变化较快,遇到安装问题应以你所用 bitsandbytes 版本的安装矩阵为准。[2]

10.2 先检查运行环境

import torch
import transformers
import bitsandbytes as bnb

print("PyTorch:", torch.__version__)
print("Transformers:", transformers.__version__)
print("bitsandbytes:", bnb.__version__)
print("CUDA available:", torch.cuda.is_available())
print("PyTorch CUDA:", torch.version.cuda)

if torch.cuda.is_available():
    print("GPU:", torch.cuda.get_device_name(0))
    print("BF16 supported:", torch.cuda.is_bf16_supported())

如果出现 no kernel image available、CUDA 运行库不匹配等错误,优先排查:

  • PyTorch 自带的 CUDA runtime 版本;
  • 驱动是否支持;
  • 系统是否同时暴露多个冲突的 CUDA 路径;
  • GPU compute capability 是否满足所用功能;
  • bitsandbytes wheel 是否覆盖当前平台。

10.3 8bit 最小示例

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_id = "Qwen/Qwen2.5-1.5B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_id)

model_8bit = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=BitsAndBytesConfig(load_in_8bit=True),
    device_map="auto",
    dtype="auto",
)

print(f"模型占用估算: {model_8bit.get_memory_footprint() / 1024**2:.1f} MiB")

10.4 NF4 + 双重量化最小示例

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

model_id = "Qwen/Qwen2.5-1.5B-Instruct"

compute_dtype = (
    torch.bfloat16
    if torch.cuda.is_available() and torch.cuda.is_bf16_supported()
    else torch.float16
)

quant_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=compute_dtype,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(model_id)
model_4bit = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=quant_config,
    device_map="auto",
    dtype=compute_dtype,
)

print(f"模型占用估算: {model_4bit.get_memory_footprint() / 1024**2:.1f} MiB")

若你的 Transformers 版本尚未接受 dtype,可把它替换为 torch_dtype。这属于版本接口差异,不是量化逻辑差异。

10.5 统一生成函数

import torch


def generate_once(model, tokenizer, prompt: str) -> str:
    messages = [{"role": "user", "content": prompt}]
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )

    inputs = tokenizer(text, return_tensors="pt")

    # 单卡模型通常可用 model.device;若模型被 device_map 分片,
    # 输入应放到输入 embedding 所在设备。
    input_device = model.get_input_embeddings().weight.device
    inputs = {k: v.to(input_device) for k, v in inputs.items()}

    with torch.inference_mode():
        output = model.generate(
            **inputs,
            max_new_tokens=96,
            do_sample=False,
            use_cache=True,
        )

    new_tokens = output[0, inputs["input_ids"].shape[1]:]
    return tokenizer.decode(new_tokens, skip_special_tokens=True)


print(generate_once(
    model_4bit,
    tokenizer,
    "请用一个生活类比解释大模型量化,并指出类比不准确的地方。",
))

10.6 如何确认模型真的用了 4bit 层

不要只看 model.dtype。一个 4bit 模型的 model.dtype 可能显示 BF16、FP16 或 FP32,因为它反映的往往是部分浮点参数,而不是每个量化权重的实际编码。

可以检查模块类型:

import bitsandbytes as bnb

count = 0
for name, module in model_4bit.named_modules():
    if isinstance(module, bnb.nn.Linear4bit):
        count += 1
        if count <= 5:
            print(name, type(module).__name__)

print("Linear4bit 层数量:", count)

也可以查看量化配置:

print(model_4bit.quantization_config)

以及模型内存估算:

print(model_4bit.get_memory_footprint())

10.7 为什么不建议在同一 Python 进程里连续测五种模式

很多示例会:

加载 FP16 → del model → empty_cache → 加载 8bit → 再加载 4bit

这对快速演示可以,但严格比较容易被这些因素污染:

  • Python 引用未完全释放;
  • PyTorch 缓存分配器保留显存块;
  • CUDA 内核与工作区已被初始化;
  • 文件系统缓存使后面的加载更快;
  • tokenizer、生成输出或 hook 仍持有对象;
  • memory_allocated 不包含所有驱动层开销。

更可靠的方法是每种模式启动独立进程。下面给出一份可直接使用的基准脚本。


十一、一份更可靠的显存与速度基准脚本

将下面内容保存为 bnb_bench.py

from __future__ import annotations

import argparse
import json
import time
from typing import Any

import torch
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
)


def to_mib(value: int) -> float:
    return value / 1024**2


def choose_compute_dtype(name: str) -> torch.dtype:
    if name == "fp32":
        return torch.float32
    if name == "fp16":
        return torch.float16
    if name == "bf16":
        if not torch.cuda.is_bf16_supported():
            raise RuntimeError("当前 CUDA 设备不支持 BF16。")
        return torch.bfloat16
    if name == "auto":
        return (
            torch.bfloat16
            if torch.cuda.is_bf16_supported()
            else torch.float16
        )
    raise ValueError(f"未知 compute dtype: {name}")


def build_quant_config(
    mode: str,
    compute_dtype: torch.dtype,
) -> BitsAndBytesConfig | None:
    if mode == "fp16":
        return None
    if mode == "int8":
        return BitsAndBytesConfig(load_in_8bit=True)
    if mode == "fp4":
        return BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="fp4",
            bnb_4bit_compute_dtype=compute_dtype,
            bnb_4bit_use_double_quant=False,
        )
    if mode == "nf4":
        return BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=compute_dtype,
            bnb_4bit_use_double_quant=False,
        )
    if mode == "nf4-dq":
        return BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_compute_dtype=compute_dtype,
            bnb_4bit_use_double_quant=True,
        )
    raise ValueError(f"未知模式: {mode}")


def load_model_compat(
    model_id: str,
    kwargs: dict[str, Any],
):
    """兼容新旧 Transformers 中 dtype / torch_dtype 的参数名。"""
    try:
        return AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
    except TypeError as exc:
        if "dtype" not in kwargs:
            raise
        fallback = dict(kwargs)
        fallback["torch_dtype"] = fallback.pop("dtype")
        try:
            return AutoModelForCausalLM.from_pretrained(model_id, **fallback)
        except Exception:
            raise exc


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--model",
        default="Qwen/Qwen2.5-1.5B-Instruct",
    )
    parser.add_argument(
        "--mode",
        choices=["fp16", "int8", "fp4", "nf4", "nf4-dq"],
        required=True,
    )
    parser.add_argument(
        "--compute-dtype",
        choices=["auto", "fp16", "bf16", "fp32"],
        default="auto",
    )
    parser.add_argument("--device", type=int, default=0)
    parser.add_argument("--max-new-tokens", type=int, default=128)
    args = parser.parse_args()

    if not torch.cuda.is_available():
        raise RuntimeError("这份基准脚本要求 CUDA。")

    torch.cuda.set_device(args.device)
    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats(args.device)

    compute_dtype = choose_compute_dtype(args.compute_dtype)
    quant_config = build_quant_config(args.mode, compute_dtype)

    tokenizer = AutoTokenizer.from_pretrained(args.model)

    load_kwargs: dict[str, Any] = {
        # 明确放在单卡,避免 auto 悄悄 CPU offload,污染比较。
        "device_map": {"": args.device},
        "low_cpu_mem_usage": True,
    }

    if quant_config is None:
        load_kwargs["dtype"] = torch.float16
    else:
        load_kwargs["quantization_config"] = quant_config
        # 控制未量化模块的 dtype。
        load_kwargs["dtype"] = compute_dtype

    start = time.perf_counter()
    model = load_model_compat(args.model, load_kwargs)
    model.eval()
    torch.cuda.synchronize(args.device)
    load_seconds = time.perf_counter() - start

    allocated_after_load = torch.cuda.memory_allocated(args.device)
    reserved_after_load = torch.cuda.memory_reserved(args.device)
    peak_during_load = torch.cuda.max_memory_allocated(args.device)
    model_footprint = model.get_memory_footprint()

    prompt = "请用两段话解释模型量化,并说明它为什么不一定加速。"
    messages = [{"role": "user", "content": prompt}]
    rendered = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
    )
    inputs = tokenizer(rendered, return_tensors="pt")
    input_device = model.get_input_embeddings().weight.device
    inputs = {k: v.to(input_device) for k, v in inputs.items()}

    # 预热一次,减少首次内核编译与初始化干扰。
    with torch.inference_mode():
        _ = model.generate(
            **inputs,
            max_new_tokens=8,
            do_sample=False,
            use_cache=True,
        )
    torch.cuda.synchronize(args.device)

    torch.cuda.reset_peak_memory_stats(args.device)
    start = time.perf_counter()
    with torch.inference_mode():
        output = model.generate(
            **inputs,
            max_new_tokens=args.max_new_tokens,
            do_sample=False,
            use_cache=True,
        )
    torch.cuda.synchronize(args.device)
    generation_seconds = time.perf_counter() - start

    new_token_count = int(
        output.shape[1] - inputs["input_ids"].shape[1]
    )
    tokens_per_second = new_token_count / generation_seconds
    peak_during_generation = torch.cuda.max_memory_allocated(args.device)

    response_tokens = output[0, inputs["input_ids"].shape[1]:]
    response = tokenizer.decode(response_tokens, skip_special_tokens=True)

    result = {
        "model": args.model,
        "mode": args.mode,
        "compute_dtype": str(compute_dtype),
        "load_seconds": round(load_seconds, 3),
        "model_footprint_mib": round(to_mib(model_footprint), 1),
        "allocated_after_load_mib": round(to_mib(allocated_after_load), 1),
        "reserved_after_load_mib": round(to_mib(reserved_after_load), 1),
        "peak_during_load_mib": round(to_mib(peak_during_load), 1),
        "peak_during_generation_mib": round(
            to_mib(peak_during_generation), 1
        ),
        "new_tokens": new_token_count,
        "generation_seconds": round(generation_seconds, 3),
        "tokens_per_second": round(tokens_per_second, 2),
    }

    print(json.dumps(result, ensure_ascii=False, indent=2))
    print("\n回答预览:")
    print(response[:300])


if __name__ == "__main__":
    main()

分别用独立进程运行:

for mode in fp16 int8 fp4 nf4 nf4-dq; do
  python bnb_bench.py --mode "$mode"
done

11.1 看哪些数字

这份脚本同时输出:

  • model_footprint_mib:模型参数与 buffer 的框架估算;
  • allocated_after_load_mib:PyTorch 当前仍在使用的 CUDA 显存;
  • reserved_after_load_mib:PyTorch 向 CUDA 申请并保留的显存;
  • peak_during_load_mib:加载阶段峰值;
  • peak_during_generation_mib:生成阶段峰值;
  • tokens_per_second:本次固定生成设置下的粗略吞吐。

不要只截一项就下结论。比如:

  • model_footprint 适合看权重压缩;
  • peak_during_generation 更接近“实际任务会不会 OOM”;
  • reserved 高不等于内存泄漏,可能只是缓存分配器行为;
  • nvidia-smi 看到的进程显存还会包含框架外与驱动层开销。

11.2 不要把示例数字当成标准答案

同一模型的结果会受到以下因素影响:

  • GPU 架构;
  • bitsandbytes、PyTorch、Transformers 版本;
  • CUDA runtime 与驱动;
  • 注意力实现;
  • 是否启用 FlashAttention;
  • prompt 长度与生成长度;
  • 是否 CPU offload;
  • 模型是否 tied embeddings;
  • dtype 和量化跳过模块;
  • batch size。

因此文章里最负责任的写法不是“1.5B 模型一定占 1010MB”,而是:

在给定软件栈、硬件、输入长度和设备映射下,本次测得某个数;其他环境应以本地结果为准。

11.3 一条回答不能证明量化质量

聊天输出“看起来通顺”只能证明模型没有完全坏掉,不能证明量化几乎无损。更可靠的比较至少包含一种:

  • 固定验证集上的 perplexity;
  • 与任务相关的准确率、F1、Pass@k 等;
  • lm-evaluation-harness 上的多个标准任务;
  • 足够规模的人工盲评;
  • 对关键业务样本的回归集;
  • 多随机种子、多 prompt 模板测试。

还要固定:tokenizer、chat template、生成参数、上下文长度、评测脚本与随机种子。量化误差可能只在长文本、数学、代码、低资源语言或少数边缘样本中暴露。


十二、QLoRA:bitsandbytes 最重要的训练场景

bitsandbytes 被大量开发者认识,并不只是因为“推理时省显存”,更因为它成为了 QLoRA 的关键基础设施。

12.1 先理解普通全量微调为什么贵

全量微调不仅要保存模型权重,还要保存:

  • 每个可训练参数的梯度;
  • Adam 一阶动量;
  • Adam 二阶动量;
  • 某些混合精度方案中的 FP32 master weights;
  • 反向传播所需激活;
  • 临时计算 buffer。

因此,一个 FP16 权重本身约 14GB 的 7B 模型,全量训练远不止 14GB。模型越大,参数相关状态线性增长;序列越长,激活相关内存也快速增长。

12.2 LoRA 做了什么

对一个线性层:

[
Y=XW
]

LoRA 不直接更新大矩阵 (W),而是加入一个低秩增量:

[
Y=XW+sXAB
]

其中:

  • (A\in\mathbb{R}^{d_{in}\times r});
  • (B\in\mathbb{R}^{r\times d_{out}});
  • rank (r) 远小于原矩阵维度;
  • (s) 常由 lora_alpha / r 决定。

训练时冻结 (W),只更新小矩阵 (A) 与 (B)。这样梯度和优化器状态只为少量适配器参数保存。

12.3 QLoRA 比 LoRA 多了哪一步

普通 LoRA 的基座权重通常仍以 FP16/BF16 保存。QLoRA 则把冻结基座以 4bit 保存:

高精度预训练模型
  ↓ 加载时 4bit 量化
NF4 冻结基座
  +
BF16/FP16 LoRA 适配器
  ↓
只更新 LoRA

这样同时减少:

  • 基座权重显存;
  • 需要训练的参数数量;
  • 适配器的梯度与优化器状态。

QLoRA 原论文的三项核心内存技术是:

  1. NF4;
  2. 双重量化;
  3. 分页优化器,用统一内存在内存峰值时把优化器页在 CPU 与 GPU 之间迁移。[4]

12.4 “梯度穿过 4bit 权重”是什么意思

这是 QLoRA 最容易被误解的地方。

前向:

[
Y=X\hat{W}_{4bit}+sXAB
]

其中 (\hat{W}_{4bit}) 表示从 4bit 存储按计算 dtype 恢复出的近似权重。

反向时,需要计算损失对输入和 LoRA 参数的梯度,因此计算图会经过基座矩阵乘法。但基座参数被设置为:

requires_grad = False

所以:

  • 会利用基座参与前向和反向链式法则;
  • 不会为基座保存可更新梯度;
  • 不会让优化器修改 4bit 权重;
  • 真正更新的是 LoRA 参数。

“通过量化模型反向传播”不等于“直接训练量化权重”。当前 Transformers 文档也明确说明,4bit/8bit 训练支持的是额外参数,而非把所有量化权重当作普通可训练参数。[1]

12.5 prepare_model_for_kbit_training() 做什么

PEFT 提供:

from peft import prepare_model_for_kbit_training

model = prepare_model_for_kbit_training(model)

这个函数不是装饰性调用。它会执行一组为 k-bit 训练准备的操作,包括冻结基座参数、把部分非量化参数提升到更稳定的精度、处理梯度检查点和输入梯度等。官方 PEFT 源码文档明确指出,它会冻结所有基座层。[7]

这也解释了为什么调用后看到部分层变成 FP32,不必立刻怀疑“4bit 失效”:

  • 大线性权重仍是 4bit;
  • LayerNorm、输出相关模块等可能为了稳定性保留或提升为高精度;
  • 总内存不是纯粹的 4bit 裸权重大小。

12.6 一套清晰的 QLoRA 模型准备代码

import torch
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training,
)
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

model_id = "Qwen/Qwen2.5-1.5B-Instruct"

compute_dtype = (
    torch.bfloat16
    if torch.cuda.is_available() and torch.cuda.is_bf16_supported()
    else torch.float16
)

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=compute_dtype,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    dtype=compute_dtype,
)

# 训练时通常关闭 KV Cache,避免与梯度检查点冲突并节省内存。
model.config.use_cache = False

model = prepare_model_for_kbit_training(
    model,
    use_gradient_checkpointing=True,
)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    # QLoRA 风格通常尽量覆盖 Transformer 中的线性层。
    target_modules="all-linear",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

12.7 为什么 target_modules="all-linear" 经常优于只放 q_proj、v_proj

早期 LoRA 配方常只给注意力中的 query、value 投影加适配器,参数更少。但 QLoRA 论文发现,为了匹配更强的 16bit 基线,在所有 Transformer 线性层上放 LoRA 很关键。[4]

这不表示任何任务都必须 all-linear

  • 适配器越多,训练参数与计算会增加;
  • 不同架构模块命名不同;
  • 输出头是否包含在内要看 PEFT 规则与任务;
  • 小数据集可能需要更强正则。

但对于“我要尽量复现 QLoRA 的高保真思路”,all-linear 比只写 q_projv_proj 更接近原始设计。

12.8 分页优化器和 8bit 优化器不是一回事

bitsandbytes 还提供 8bit 优化器,例如 Adam8bit。它压缩的是优化器状态,不是模型权重。

几个概念应分开:

  • load_in_4bit=True:压缩基座权重;
  • LoRA:减少可训练参数;
  • paged_adamw_*:利用分页机制应对内存峰值;
  • adamw_8bit:压缩优化器状态;
  • gradient checkpointing:少存激活,反向时重算。

它们可以组合,但每项解决不同内存来源。由于 LoRA 参数本来就少,8bit 优化器对适配器状态的绝对节省可能不如基座 4bit 那么夸张;分页机制在长序列、峰值明显时更有价值。

12.9 QLoRA 不是“任何显卡都能微调任何模型”

量化基座解决了大头,但训练仍受这些因素影响:

  • 序列长度;
  • micro batch size;
  • 梯度累积;
  • LoRA 覆盖层数与 rank;
  • 激活检查点;
  • attention kernel;
  • 数据 packing;
  • 优化器;
  • 多模态模型额外编码器;
  • GPU 是否支持目标 compute dtype。

因此,“一张消费级显卡能微调 7B”只能作为经验级描述。更专业的说法应包含显卡容量、序列长度、batch、rank、是否 checkpointing 等完整条件。

12.10 训练后保存了什么

常见 PEFT 保存:

model.save_pretrained("my-qlora-adapter")

通常保存的是:

  • LoRA adapter 权重;
  • PEFT 配置;
  • 可能的额外可训练模块。

它不是完整基座。部署时需要:

  1. 加载与训练时对应的基座模型;
  2. 使用兼容的量化配置;
  3. 再加载 adapter。

如果想合并成独立模型,通常要考虑:

  • 是否先在高精度基座中 merge;
  • merge 后是否重新量化;
  • 当前 PEFT、Transformers、量化后端是否支持直接合并;
  • 合并和再量化是否引入额外误差。

不要把“LoRA 可以 merge”理解成“任何 4bit 模型都能无损原地合并”。生产部署前应单独验证。


十三、bitsandbytes、GPTQ、AWQ、GGUF 横向对比

先给一张更不容易误导的表。

维度 bitsandbytes GPTQ AWQ GGUF / llama.cpp 生态
常见量化时机 通常在加载时按需量化;部分低比特检查点可保存并重载,兼容性需按版本验证 部署前离线量化 部署前离线量化 转换或量化后生成 GGUF 文件
是否需要校准数据 通常不需要 通常需要 需要激活统计 取决于量化类型;经典流程可不需要,部分高级方案会用重要性矩阵
核心思路 LLM.int8 混合精度;4bit 分块 FP4/NF4 近似二阶误差补偿的逐层权重量化 激活感知的显著通道保护与缩放 文件格式 + 多种 CPU/GPU 量化与内核实现
上手门槛 很低 中等 中等 使用预制文件很低,自行转换有一定门槛
训练/微调 QLoRA 主流路径,PEFT 集成成熟 主要面向推理,部分 PEFT 流程可用 主要面向推理,部分生态支持适配器 主要面向本地推理,不是主流训练格式
推理速度 依硬件与内核,不能保证最快 专用内核下常有优势 专用内核下常有优势 CPU 与 CPU/GPU 混合推理成熟
磁盘产物 默认加载路径常保留高精度缓存;8bit 保存路径较明确,4bit 重载兼容性需按版本实测 紧凑预量化检查点 紧凑预量化检查点 单文件或分片 GGUF,便于本地分发
生态重心 PyTorch、Transformers、PEFT GPU 推理与服务 GPU/边缘推理与服务 llama.cpp 及本地应用
最典型用途 快速实验、显存受限推理、QLoRA 重复部署、追求 GPU 推理效率 重复部署、追求质量与硬件友好 CPU、本地桌面、跨平台离线运行

13.1 质量不能简单排成 AWQ > GPTQ > NF4

不同量化方案的误差排名受这些因素影响:

  • 模型架构与规模;
  • 校准数据是否匹配;
  • group size;
  • 是否量化输出头;
  • 权重位宽和激活位宽;
  • 任务类型;
  • 推理内核是否严格对应量化格式;
  • 评测指标。

GPTQ 与 AWQ 通过校准获得更有针对性的量化参数,往往在部署精度上有优势;NF4 利用权重分布先验,不需数据,且非常适合 QLoRA。不能脱离模型和任务给出永久总排名。

13.2 速度也不能简单写成“AWQ/GPTQ 一定更快”

专用部署格式经常有更积极的权重打包、kernel fusion 和硬件适配,所以在对应引擎里可能明显快于 bitsandbytes。但速度受:

  • GPU 架构;
  • batch size;
  • prompt prefill 与 token decode 比例;
  • 并发;
  • 内核版本;
  • 是否跨卡;
  • 是否 CPU offload;
  • attention kernel;
  • 模型尺寸。

最稳妥的选型流程是:先根据生态缩小候选,再在目标机器上测吞吐、首 token 延迟、单 token 延迟和显存。

13.3 “省磁盘”也要区分缓存与发布产物

如果你这样用 bitsandbytes:

from_pretrained("原始模型", quantization_config=...)

Hugging Face 缓存里通常有原始模型文件,所以本地总磁盘并没有自动减少。

在工具链明确支持且已完成重载验证的前提下,把量化模型保存成独立检查点并清理不再需要的原始缓存,磁盘占用可能下降。但此时还应考虑:

  • 目标环境能否加载这种 bitsandbytes 序列化格式;
  • 是否依赖特定 Transformers/bitsandbytes 版本;
  • 服务框架是否对该格式有高性能路径;
  • 是否需要跨语言、跨平台运行。

GGUF、AWQ、GPTQ 的优势往往不只是“文件小”,而是整个部署生态围绕预量化格式建立。


十四、怎样选:用问题而不是口号做决策

场景一:我要做 LoRA/SFT 微调

优先考虑:bitsandbytes NF4 + 双重量化 + PEFT。

原因不是它必然拥有最高推理吞吐,而是它把 4bit 冻结基座、反向传播和 adapter 训练连成了成熟工作流。

场景二:我只想快速试一个 Hugging Face 模型能否放进显卡

优先考虑 bitsandbytes:

  1. 先 8bit,观察质量与显存;
  2. 不够再上 NF4 4bit;
  3. 显存仍不够,再用 device_map="auto"、CPU offload 或更小模型。

场景三:我要长期部署同一个模型,追求吞吐和稳定延迟

不要直接把实验期的 bitsandbytes 配置搬到生产。应对比:

  • AWQ;
  • GPTQ;
  • 目标服务框架支持的其他低比特格式;
  • FP8、INT8 或厂商原生路径;
  • bitsandbytes 作为基线。

生产选型更关注并发吞吐、P99 延迟、内核稳定性、监控和版本锁定。

场景四:我要在 CPU 或普通笔记本上本地跑

当前 bitsandbytes 已有 CPU 等后端支持,但若目标是成熟的 CPU 本地推理、量化文件分发和桌面工具兼容,GGUF/llama.cpp 生态通常仍更顺手。这里比较的是生态成熟度和内核路线,不是“bitsandbytes 在 CPU 上绝对不能运行”。

场景五:我最在意磁盘和模型分发

优先考虑已有的预量化产物或把目标格式显式保存。不要只打开 load_in_4bit 就以为下载目录会自动缩小。

场景六:我最在意质量

先不要从位宽猜质量。做三件事:

  1. 建立 FP16/BF16 基线;
  2. 至少对比 8bit、NF4 4bit 和一个校准式 4bit 方案;
  3. 用业务回归集而不是聊天观感做决定。

场景七:我最在意长上下文

除了权重量化,还要评估:

  • KV Cache dtype;
  • GQA/MQA 架构;
  • batch 与并发;
  • sliding window;
  • attention kernel;
  • context length 对吞吐的影响。

只压缩权重,可能仍无法解决长上下文 OOM。


十五、常见误区与避坑

误区 1:bitsandbytes 每次前向都会重新量化权重

不对。常见的是加载时完成权重转换;前向时按需反量化并计算。重新启动并从原始高精度检查点加载时,才可能再次执行加载量化。

误区 2:使用 bitsandbytes 后,磁盘模型一定不变小

不够准确。若只从原始模型按需加载,本地缓存仍有高精度文件;当前 8bit 保存与重载已有明确文档路径,而 4bit 还要按具体版本、架构和序列化方式验证。是否省磁盘,最终取决于你的保存、缓存、兼容性和分发工作流。

误区 3:bnb_4bit_quant_type 默认就是 NF4

错误。默认通常是 FP4。要用 NF4,应显式配置。

误区 4:4bit 模型的所有参数都是 4bit

不对。常被替换的是线性层权重;LayerNorm、embedding、输出头、偏置或跳过模块可能保持高精度。

误区 5:model.dtype 显示 BF16,说明模型没有 4bit

不对。model.dtype 不能完整代表混合量化模型。应检查 Linear4bit 模块、量化配置和内存占用。

误区 6:4bit 就是把 FP16 显存精确除以四

只对理想裸权重近似成立。scale、非量化模块、KV Cache、激活、CUDA 上下文和临时 buffer 都不会按同样比例下降。

误区 7:双重量化把权重又量化了一次

不对。它主要量化第一层量化产生的 scale/统计量。

误区 8:双重量化能把 4bit 再变成 2bit

不对。权重编码仍是 4bit;它只降低元数据的平均位开销。

误区 9:compute_dtype 不写也会自动用 BF16

不对。默认通常是 FP32。要使用 BF16/FP16 应显式指定,并确认硬件支持。

误区 10:BF16 在任何显卡上都比 FP16 好

不对。BF16 需要硬件与后端支持;不支持时可能报错、回退或性能不理想。推荐值必须带硬件前提。

误区 11:4bit 推理一定比 FP16 快四倍

不对。权重字节数缩小不等于总计算量缩小四倍。反量化、内核效率、batch、模型大小和瓶颈类型都会影响速度。

误区 12:量化后还 OOM,说明 bitsandbytes 没生效

不一定。可能是 KV Cache、长序列激活、生成临时 buffer、CPU/GPU 设备映射或训练状态导致。

误区 13:QLoRA 会直接更新 4bit 基座权重

不对。标准 QLoRA 冻结基座,只训练 LoRA 等额外参数。梯度经过基座计算图,不代表基座被优化器更新。

误区 14:LoRA 参数很少,所以训练内存几乎只剩 adapter

不对。冻结基座仍要驻留,前向和反向仍有激活;长序列时激活可能非常可观。

误区 15:device_map="auto" 永远是最佳设置

不对。它适合快速推理,但可能把模块放到 CPU,造成速度骤降;训练也不应无脑依赖它。

误区 16:一条 prompt 回答正常,就说明量化无损

不对。生成具有冗余和偶然性,少数样本看不出细微退化。至少要有固定回归集或标准评测。

误区 17:bitsandbytes 只能用于 NVIDIA CUDA

这是旧信息。当前官方文档已列出多个后端;但不同功能、平台和版本的成熟度仍有差异,不能反过来理解成“所有后端完全等价”。

误区 18:8bit optimizer 和 8bit 模型是一回事

不对。前者压缩优化器状态,后者压缩模型线性层权重并改变矩阵乘法路径。两者可独立使用。

误区 19:调用 dequantize() 能恢复原始权重

不对。量化已经丢失信息。dequantize() 只是把低比特近似值展开到高精度 dtype,不会找回量化前被舍弃的小数。

误区 20:torch.cuda.empty_cache() 会释放所有模型显存

不对。它只释放缓存分配器中当前没有活跃张量引用的块。只要 Python 对象仍引用张量,显存就不会被释放。


十六、常见报错与排查路径

16.1 No kernel image is available for execution on the device

常见原因:

  • GPU 架构不在 wheel 编译目标中;
  • CUDA wheel 与设备能力不匹配;
  • 安装了错误平台的包;
  • 使用了过旧 GPU 执行要求更高的功能。

排查:

  1. 查看 GPU 型号与 compute capability;
  2. 查看 torch.version.cuda
  3. 对照 bitsandbytes 当前版本安装矩阵;
  4. 更新 wheel 或按官方说明源码编译。

16.2 CUDA 版本看起来对,但仍提示找不到库

系统可能同时存在:

  • PyTorch wheel 自带 CUDA runtime;
  • /usr/local/cuda-*
  • Conda 环境里的 cudart
  • LD_LIBRARY_PATH 中的旧库。

要区分:

  • nvidia-smi 显示的是驱动可支持的 CUDA 上限;
  • torch.version.cuda 是 PyTorch 构建所用 CUDA 版本;
  • nvcc --version 是本机 Toolkit 编译器版本。

三者不必完全相同,但库解析冲突会导致 bitsandbytes 加载错误。

16.3 BF16 报错或输出异常

先改成:

bnb_4bit_compute_dtype=torch.float16

若仍不稳定,再尝试 FP32,确认问题是否来自精度。还要检查模型原生 dtype、GPU 支持和 attention kernel。

16.4 模型加载后速度极慢

检查 hf_device_map

print(model.hf_device_map)

若大量层在 cpudisk,瓶颈很可能是 offload。显存节省不等于高性能。

16.5 输入和模型不在同一设备

单卡可这样处理:

inputs = tokenizer(text, return_tensors="pt").to(model.device)

分片模型更稳妥的做法是把输入放到 embedding 所在设备:

input_device = model.get_input_embeddings().weight.device
inputs = {k: v.to(input_device) for k, v in inputs.items()}

16.6 加载没 OOM,生成时 OOM

优先缩减:

  • 输入长度;
  • max_new_tokens
  • batch size;
  • beam 数;
  • 并发请求数。

然后检查 KV Cache 和注意力实现。权重量化只解决一部分。

16.7 QLoRA 第一个 backward 就 OOM

优先调整:

  1. 把 micro batch 降到 1;
  2. 用 gradient accumulation 保持有效 batch;
  3. 开 gradient checkpointing;
  4. 缩短序列;
  5. 开数据 packing,减少 padding 浪费;
  6. 降低 LoRA rank;
  7. 检查是否意外让基座参数 requires_grad=True
  8. 检查是否启用了 use_cache=True
  9. 选择分页优化器;
  10. 确认没有同时保留多份模型。

16.8 训练时没有可训练参数

检查调用顺序:

加载量化模型
→ prepare_model_for_kbit_training
→ get_peft_model
→ print_trainable_parameters

如果在注入 LoRA 后又执行某个冻结函数,可能把 adapter 也冻结。

16.9 训练完成但保存目录很小

这通常是正常的:你保存的是 adapter,不是完整基座。部署时仍需原始基座和 PEFT 配置。

16.10 保存量化模型后无法在另一台机器加载

检查:

  • Transformers 与 bitsandbytes 版本;
  • 目标后端是否支持相同量化类型;
  • quantization_config.json 是否完整;
  • 权重是否使用兼容的 safetensors 序列化;
  • 自定义模型代码是否需要 trust_remote_code=True
  • 模型架构是否在目标版本中受支持。

量化检查点比普通 FP16 检查点更依赖运行时契约,生产环境应锁定版本并做冷启动测试。


十七、进阶理解:几个经常被忽略的工程问题

17.1 小模型可能看不到理想压缩比

对于 1B 甚至更小模型:

  • CUDA 上下文占比高;
  • embedding 和输出头占比可能较高;
  • 量化元数据相对显眼;
  • kernel 启动与反量化开销更难摊薄。

因此 4bit 可能只让总显存下降 50%~70%,而不是 75%。模型越大,权重主导越明显,理论压缩比越容易体现。

17.2 tied embeddings 会影响显存统计

一些模型输入 embedding 与输出 lm_head 共享权重。不同加载、量化、保存路径对共享参数的处理可能影响:

  • 是否重复计数;
  • 某一层是否保持高精度;
  • get_memory_footprint() 与参数数目换算是否完全一致。

看到估算与理论不符时,不要先假设库出错,先检查权重共享与未量化模块。

17.3 多 GPU 下,量化不自动解决通信瓶颈

把模型分到多卡后:

  • 权重更小,单卡容量压力下降;
  • 但层间传输、张量并行 all-reduce 或 pipeline bubble 仍存在;
  • 不同卡上的量化内核支持可能不同;
  • 自动 device map 通常优化“装得下”,不一定优化“跑得快”。

生产多卡服务应使用明确的并行策略和服务框架,而不是把 device_map="auto" 当成最终方案。

17.4 量化误差和采样随机性不是一回事

即使固定 prompt,使用 do_sample=True 时,两次 FP16 输出也可能不同。比较量化模型时:

  • 先用 greedy 或固定随机种子;
  • 记录 logits、困惑度或任务分数;
  • 不要把采样差异误判为量化退化。

但即使 greedy,低比特误差也可能在某一步改变 top-1 token,随后整条生成路径分叉。这是自回归模型的放大效应,不表示每一步误差都很大。

17.5 量化的“质量”不只是一项指标

至少要区分:

  • 语言模型 perplexity;
  • 指令跟随;
  • 事实问答;
  • 数学与代码;
  • 长上下文检索;
  • 多语言;
  • 安全与拒答行为;
  • 校准与置信度。

一个方案在平均困惑度上接近基线,不保证业务边缘行为完全一致。

17.6 QLoRA 的优势不代表最终部署也必须用 bitsandbytes

很常见的生命周期是:

训练:bitsandbytes NF4 + QLoRA
  ↓
得到 LoRA adapter
  ↓
在高精度基座上合并或保留 adapter
  ↓
按生产后端重新量化为 AWQ / GPTQ / GGUF / 其他格式

训练格式与部署格式可以不同。bitsandbytes 的核心价值在训练阶段,最终服务可以选择更适合吞吐和平台的格式。

17.7 “无校准”不等于“无数据依赖”

量化过程不需要校准集,但你仍然需要数据来:

  • 判断质量是否可接受;
  • 选择 8bit 还是 4bit;
  • 调整 LoRA;
  • 构建回归测试;
  • 发现特定领域退化。

不需要校准数据,只是减少量化前置步骤,不是免除评测责任。


十八、FAQ

Q1:新手第一次应该先试 8bit 还是 4bit?

显存允许时先试 8bit,建立接近半精度的保守基线;显存紧张或要做 QLoRA,直接试 NF4 4bit。最终以任务指标和显存为准。

Q2:纯推理也推荐 NF4 吗?

可以把 NF4 作为优先候选,尤其当模型权重分布接近其设计假设时。但纯推理中的差距可能比 QLoRA 训练场景小,最好和 FP4 做实测。

Q3:双重量化应该永远打开吗?

显存紧张、QLoRA、大模型场景通常值得打开。若你在做极端低延迟基准或对任何数值变化都敏感,可保留一个关闭版本作对照。

Q4:4bit 会不会明显伤害中文能力?

不能只按语言判断。影响取决于模型、量化格式、任务和评测。中文、代码、数学、长文本都应放进回归集,而不是引用其他模型的结论。

Q5:量化 tokenizer 有意义吗?

没有。tokenizer 主要是词表、规则和少量 CPU 数据结构,不是显存大头。模型量化处理的是神经网络权重和计算。

Q6:能不能把 4bit 模型全部参数都训练起来?

标准 bitsandbytes + QLoRA 流程不这样做。官方支持的主流方式是训练额外参数。要更新低比特基座,需要专门的量化感知训练或其他算法,不能把普通 Linear4bit 当作 FP16 参数直接全量优化。

Q7:QLoRA adapter 能否加载到 FP16 基座上?

通常 adapter 针对同一个基座模型结构,可以加载到高精度基座上,但它训练时看到的是量化近似权重,最终表现可能与 4bit 基座略有差异。无论是否 merge,都应重新评测。

Q8:为什么 4bit 模型加载时间有时比 FP16 更长?

因为除了磁盘读取,还要替换模块、量化、打包和建立量化状态。在工具链能够直接重载对应低比特检查点的组合中,重复转换成本可能降低;是否成立要以实际版本的冷启动测试为准。

Q9:为什么 8bit 只省了 35%,没有省一半?

因为总显存还包括高精度模块、CUDA 上下文、KV Cache、临时 buffer 等。8bit 主要把可量化权重从约 2 字节/参数降到约 1 字节/参数。

Q10:为什么 NF4 + 双重量化和 NF4 显存几乎一样?

模型较小时,双重量化节省的几百 MB 以内差异可能被固定开销、统计口径和 allocator 粒度掩盖。对几十 B 模型,差异更明显。

Q11:为什么 memory_allocated()nvidia-smi 小?

前者主要统计 PyTorch 当前管理的活跃分配;后者看到整个进程的设备内存,包括 CUDA context、库工作区及其他分配。

Q12:为什么 memory_reserved()memory_allocated() 大很多?

PyTorch 缓存分配器会保留已申请显存以便后续复用,减少频繁向驱动申请的成本。这不等于都有活跃张量。

Q13:量化后可以用 FlashAttention 吗?

权重量化与注意力实现是不同层面,很多模型可以组合使用,但兼容性取决于模型、dtype、GPU 和软件版本。要分别验证。

Q14:bitsandbytes 能替代 vLLM、TensorRT-LLM 之类的服务框架吗?

不能直接类比。bitsandbytes 提供低比特层、算子和优化器;服务框架还负责批处理、KV Cache 管理、调度、并行和网络接口。它们可能集成,也可能使用其他量化内核。

Q15:怎样判断是否该从 bitsandbytes 换成 AWQ/GPTQ?

当模型和任务已经稳定、会被大量重复调用,并且吞吐、延迟或部署文件成为主要瓶颈时,就值得做离线量化与生产基准。实验阶段则通常没必要太早增加产物管理成本。


十九、最后用一张心智模型收束全文

可以把 bitsandbytes 理解成三层能力。

第一层:低比特存储

  • 8bit 把大部分线性权重压到 1 字节量级;
  • 4bit 用两个半字节编码塞进一个 uint8
  • scale、码本、量化状态负责把离散编码还原成近似浮点权重。

第二层:可用的低比特计算

  • LLM.int8() 用向量级量化保护普通特征;
  • 把关键离群特征单独送入 16bit 路径;
  • 4bit 内核按块反量化并完成矩阵运算;
  • compute dtype 决定局部恢复和计算采用 FP32、BF16 还是 FP16。

第三层:与训练生态结合

  • Transformers 在加载时替换模块;
  • Accelerate 负责大模型分发;
  • PEFT 冻结基座并插入 LoRA;
  • NF4 减少量化误差;
  • 双重量化压缩 scale;
  • 分页优化器和梯度检查点继续削减训练峰值。

把这三层连起来,就能理解它为什么“看起来只加几行配置”,却同时涉及量化格式、CUDA 内核、模型加载和参数高效微调。


二十、小结:真正应该记住的十句话

  1. bitsandbytes 的最大优势不是绝对最快,而是把量化变成加载配置。
  2. 它通常不需要校准数据,因此适合快速实验和广泛模型兼容。
  3. LLM.int8() 不是朴素 INT8,而是向量级量化加离群特征的 16bit 支路。
  4. 4bit 的核心是分块缩放与 16 值码本,不同 4bit 格式并不等价。
  5. NF4 用正态分布分位点设计码本,尤其适合 QLoRA 的冻结预训练权重。
  6. NF4 不是配置默认值,想用必须显式写 bnb_4bit_quant_type="nf4"
  7. 双重量化压缩的是 scale,不是把权重从 4bit 再压一遍。
  8. “4bit 存、16bit 算”需要显式设置 compute dtype;默认通常是 FP32。
  9. QLoRA 让梯度穿过量化基座,但只更新 LoRA 等额外参数。
  10. 实验和微调优先考虑 bitsandbytes;生产推理、CPU 本地运行或特定吞吐目标,应与 AWQ、GPTQ、GGUF 等方案实测比较。

如果只记一套 QLoRA 起步配置,可以记住:

BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,  # 硬件支持时
    bnb_4bit_use_double_quant=True,
)

但比“黄金配置”更重要的是知道它每一项在解决什么问题:

  • load_in_4bit 解决基座权重容量;
  • nf4 解决 16 个码点如何更合理地表示常见权重;
  • compute_dtype 解决运算速度和数值范围;
  • double_quant 解决大量 scale 的元数据开销。

理解到这里,你就不再是“照抄四行参数”,而是真正掌握了 bitsandbytes 的工作方式、适用边界和工程代价。


参考资料

[1] Hugging Face Transformers 文档:Bitsandbytes quantization;BitsAndBytesConfig API。

[2] Hugging Face bitsandbytes 文档:Installation Guide;Hardware Compatibility。

[3] Tim Dettmers, Mike Lewis, Younes Belkada, Luke Zettlemoyer. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS 2022, arXiv:2208.07339。

[4] Tim Dettmers, Artidoro Pagnoni, Ari Holtzman, Luke Zettlemoyer. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314。

[5] Elias Frantar, Saleh Ashkboos, Torsten Hoefler, Dan Alistarh. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. ICLR 2023, arXiv:2210.17323。

[6] Ji Lin et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. MLSys 2024, arXiv:2306.00978。

[7] Hugging Face PEFT 文档与源码:Quantization developer guide;prepare_model_for_kbit_training()

[8] bitsandbytes Functional API:quantize_4bit()dequantize_4bit(),默认 block size 与量化状态说明。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐