从 dtype 看 QLoRA 会更清晰

本文是QLoRA 4bit训练全链路 dtype 通俗拆解(基座大权重 + LoRA小权重分开讲)

全程分三段:硬盘原始文件 → 加载进显存训练(前向/反向计算)→ 训练结束写回硬盘

先统一配置(标准QLoRA参数,行业通用):

python 复制代码
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",       # 存储4bit NF4
    bnb_4bit_compute_dtype=torch.bfloat16, # 计算统一BF16
    bnb_4bit_use_double_quant=True   # 双重量化,scale存INT8
)

两个核心对象:

  1. 基座大模型权重W:几十亿参数,全程冻结不更新;
  2. LoRA适配器A/B小矩阵:几万~几百万参数,唯一可训练、有梯度的部分。

一、基座大模型权重 W:完整 dtype 流转(硬盘 ↔ 显存)

阶段1:硬盘上的原始基座文件(未加载前)

网上下载的大模型(Llama/Qwen/Mistral)原始权重:

  • dtype:BF16(也有FP16版本,现在主流BF16)
  • 存储:safetensors/bin,每个权重数字占2字节16位浮点数
  • 大小示例:7B BF16 ≈13GB硬盘文件

阶段2:加载进显存(硬盘 → VRAM,量化转换发生在这里)

代码执行from_pretrained(..., quantization_config)时,底层bitsandbytes做两步转换:

  1. 硬盘读取BF16权重到CPU内存;
  2. 按64个权重分一组,量化压缩成 NF4 4bit
  3. 打包存入GPU显存。
显存里基座权重的真实存储结构(混合dtype)
  1. 主体权重数值:NF4 4bit(塞进uint8字节里,1个字节存2个4bit数字);
  2. 每组缩放系数scale(用来反量化还原浮点数):双重量化开启 → INT8;不开则FP32;
  3. 这部分基座永久冻结,不会产生梯度,全程不修改

显存占用直观对比7B:

原生BF16 LoRA:13GB;QLoRA 4bit NF4:3~4GB。

阶段3:训练每一步前向/反向计算(显存内实时类型转换,最关键)

显存里存的是4bit压缩格式,但GPU不能直接拿4bit做矩阵乘法,每轮计算临时反量化

  1. 取当前层NF4 4bit权重 + INT8 scale;
  2. 实时反量化,还原成配置指定的 BF16bnb_4bit_compute_dtype);
  3. 用BF16做矩阵乘、激活、损失计算;
  4. 计算完成后,立刻丢弃这份临时BF16副本,显存里依旧只保留4bit压缩版基座。

重点:

基座全程没有BF16常驻显存,只有计算瞬间临时生成、用完即删;基座永远不会更新,不存在梯度溢出风险。

阶段4:训练结束保存模型(显存 → 硬盘,两种保存方式)

方式A:只保存LoRA适配器(99%微调场景用,不存基座)

执行model.save_pretrained("./lora_output")

  • 基座4bit权重完全不保存,硬盘不会生成基座文件;
  • 只存LoRA小矩阵,和基座无关。
方式B:合并LoRA,导出完整基座模型(部署前才做merge_and_unload()

合并流程dtype变化:

  1. 读取显存4bit基座,完整反量化还原为FP32全精度临时权重;
  2. 把训练好的BF16 LoRA增量叠加到FP32基座上;
  3. 你可以手动转dtype再存:
    • merged_model.to(torch.bfloat16).save_pretrained() → 硬盘得到完整BF16权重文件;
    • merged_model.to(torch.float16).save_pretrained() → 硬盘得到完整FP16推理权重;
  4. 合并后硬盘文件是标准16bit浮点,不再带4bit量化信息。

关键点:不执行merge,就永远不会把基座写到硬盘;QLoRA训练过程不会修改原始基座硬盘文件。

二、LoRA适配器小权重 A、B:完整 dtype 流转(独立一套流程)

LoRA是附加小矩阵,和基座完全隔离,全程不做4bit量化

阶段1:硬盘(训练前无LoRA文件,随机初始化)

加载完4bit基座后,代码随机新建A、B矩阵,直接分配到GPU显存。

阶段2:显存常驻 dtype(训练全程不变)

  • A、B 存储 dtype:BF16(和compute dtype统一);
  • 梯度、优化器(AdamW)状态:全部BF16高精度;
  • 只有这部分参数会被反向传播更新,所以必须高精度防止微调效果崩盘。

显存占用极小:7B模型r=8时,LoRA仅几十~两百MB,完全不占显存。

阶段3:前向/反向计算

LoRA本身就是BF16常驻显存,不需要反量化;

计算时直接和基座临时还原出的BF16权重相加运算,全程统一BF16,无类型转换开销。

阶段4:训练结束保存LoRA到硬盘

model.save_pretrained()输出adapter文件夹:

  • safetensors里A/B权重 dtype:BF16
  • 文件体积很小(几十MB),不包含任何基座信息;
  • 后续推理时,必须搭配原始完整基座文件(BF16/FP16/4bit都可)一起加载使用。

三、一条完整时间线汇总(分开基座/ LoRA)

基座大权重W

  1. 硬盘原始:BF16
  2. 加载进显存:量化压缩为 NF4 4bit + INT8 scale(常驻显存)
  3. 每轮计算:临时反量化为BF16运算,算完删除临时BF16
  4. 训练结束不merge:不写基座到硬盘。

LoRA小权重A/B

  1. 训练初始化:显存直接创建BF16矩阵
  2. 全程训练:显存常驻BF16,梯度、优化器都是BF16
  3. 训练保存:硬盘写入BF16格式LoRA适配器文件。

四、为什么训练 BF16,推理就变成了 FP16 ?

  1. QLoRA训练不会改动原始基座硬盘文件,原始基座永远是BF16;
  2. 训练产出只有BF16格式LoRA小文件;
  3. 部署时两条路切换精度,不需要"自动变身",全是人工可控转换:
    • 方案1(不合并):加载原始BF16基座 + BF16 LoRA,代码model.half()全局转FP16推理,显存实时转换,不改动硬盘原文件;
    • 方案2(合并导出):merge完整BF16模型后,转FP16另存一份独立硬盘文件,以后推理直接加载FP16。

五、大白话类比

  • 基座好比一本厚书:硬盘原版是清晰16开彩印(BF16);装进显卡前压缩成微型缩印卡片(4bit NF4)存显存;每次要看某一页(计算),临时放大成清晰16开(BF16),看完扔掉放大图;想永久存一本适配推理的薄版,就完整还原彩印再转成省墨的16开(FP16)保存。
  • LoRA好比一张修正贴纸:全程高清彩印(BF16)贴在缩印书上;训练只修改贴纸,最后只保存这张小贴纸到硬盘。