为什么会有QLoRA?
1.背景需求
- 随着大语言模型(LLM)的规模不断扩大,微调这些模型需要大量的计算资源和显存。
- 传统的微调方法在处理超大模型时,显存需求和计算成本都非常高。
2.现有方法的局限性
LoRA:通过低秩分解减少了可训练参数,但仍需加载完整的模型权重到GPU显存中。
核心问题:LoRA虽然减少了可训练参数,但原始模型权重仍需完整加载到GPU,对于超大模型来说,光是加载就需要太多显存。
1.QLoRA微调方式对比

1.1 Full Finetuning (完全微调)
- Base Model: 16-bit基础模型
- 没有使用适配器(No Adapters)
- Optimizer State: 需要存储所有参数的32-bit优化器状态
- 特点:
- 需要更新所有模型参数
- 显存消耗最大
- 蓝色箭头表示参数更新流
- 绿色箭头表示梯度流
1.2 LoRA (低秩适配器)
- Base Model: 16-bit基础模型
- Adapters: 添加16-bit低秩适配器
- 特点:
- 基础模型参数保持冻结
- 只训练适配器参数
- 每个层都有独立的适配器
- 蓝色箭头表示参数更新流
- 绿色箭头表示梯度流
1.3QLoRA (量化版LoRA)
- Base Model: 4-bit量化基础模型
- Adapters: 添加低秩适配器
- 创新点:
- 基础模型被量化到4-bit
- 使用分页优化器(CPU存储优化器状态)
- 粉色箭头表示优化器状态在CPU-GPU间的分页流动
- 蓝色箭头表示参数更新流
- 绿色箭头表示梯度流
主要区别:

1.4 QLoRA与LoRA的关系与对比
- LoRA:主要通过低秩分解减少参数量,适用于中小型模型的高效微调。
- QLoRA:结合了LoRA的低秩分解和4-bit量化技术,特别适合在消费级硬件上微调超大模型。
可以理解为:QLoRA = LoRA + 量化技术
-
LoRA 负责减少可训练参数
-
量化负责压缩原始模型权重
2. QLoRA的优缺点
2.1 QLoRA的好处
提高显存效率:
- 通过4-bit量化,QLoRA显著降低了显存占用。
- 可以在单个48GB GPU上微调65B参数模型,甚至在24GB消费级GPU上微调33B参数模型。
- 减少了对高端硬件的依赖,使得更多研究者和开发者能够参与大模型的微调。
- 量化后的模型推理速度更快,适合大规模部署。
同样的65B模型:FP16格式:130GB 4-bit量化:32.5GB
保持模型性能:
- 创新的NF4量化格式
- 几乎不损失模型效果
降低硬件门槛:
- 可在单张消费级显卡上微调大模型
- 让更多研究者能参与大模型开发
2.2 QLoRA的缺点
精度损失:
- 量化可能导致一定的精度损失,尽管QLoRA通过NF4技术尽量减少了这种影响。
复杂性增加:
- 需要额外的量化和反量化步骤,增加了实现的复杂性。
训练稳定性:
- 需要仔细调节超参数以确保训练稳定性。
3. 什么是量化
3.1 Q代表什么
- Q 代表Quantization,即量化。QLoRA的核心创新在于结合量化技术来优化显存和计算效率。
量化是将高精度的数值(如浮点数)转换为低精度的数值(如整数)的过程,以减少存储和计算的需求。它可以显著降低模型的显存占用和计算复杂度,同时在某些情况下还能加速推理过程。
注意:只能从高精度量化到低精度;FP32 -> FP16 -> 8-bit -> 4-bit,不能反向操作。
3.2 精度怎么理解
3.2.1 FP32 (32位浮点数)
结构: [1位符号位][8位指数位][23位尾数位]
表示范围:
- 最小值:±1.175494351 × 10^-38
- 最大值:±3.402823466 × 10^38
- 精度:约7位十进制数字
示例: 3.14159265359 -> 0x40490FDB
符号位:0 -> 正数,1 -> 负数
指数位:
FP32中指数位占8位,范围是0-255
使用偏置表示法(Bias=127):实际指数 = 指数位的值 - 127(偏置值)
例如:
指数位值 = 128(二进制10000000),实际指数 = 128 - 127 = 1
这样可以表示负指数:指数位值 = 126,实际指数 = 126 - 127 = -1
尾数位:
FP32中尾数位占23位
实际值前面默认加1.
二进制: 10010010000111111011011
实际值: 1.10010010000111111011011
= 1 + 2^(-1) + 2^(-4) + 2^(-5) + ...
≈ 1.5707963...
最终计算值:浮点数值 = (-1)^符号位 × 2^实际指数 × 尾数
0x40490FDB的含义
0x40490FDB 是十六进制表示:
0x表示十六进制
4049 0FDB 是实际的值
转换为二进制:
0x4 0 4 9 0 F D B
0100 0000 0100 1001 0000 1111 1101 1011
按IEEE 754标准解析这32位:
[0|10000000|10010010000111111011011]
↑ ↑ ↑
| | |
符号位 指数位 尾数位
(1位) (8位) (23位)
详细计算过程
1. 符号位(1位):
0 -> 正数
2. 指数位(8位):
10000000 -> 128(十进制)
实际指数 = 128 - 127(偏置) = 1
3. 尾数位(23位):
10010010000111111011011
1.10010010000111111011011(二进制)
= 1.5707963...
4. 最终值计算:
值 = (-1)^符号位 × 2^实际指数 × 尾数
= 1 × 2^1 × 1.5707963...
= 3.14159265359...
3.2.2 BF16 (Brain Floating Point)
结构: [1位符号位][8位指数位][7位尾数位]
特点:
- 保持与FP32相同的指数范围
- 牺牲一些精度换取更小的存储空间
- 常用于深度学习训练
示例: 3.14159265359 -> 0x4049 精度约为2-3位十进制数字
3.2.3 FP16 (16位浮点数)
结构: [1位符号位][5位指数位][10位尾数位]
表示范围:
- 最小值:±6.10352 × 10^-5
- 最大值:±65504
- 精度:约3-4位十进制数字
示例: 3.14159265359 -> 0x4248
3.2.4 INT8 (8位整数)
结构: 8位整数值
表示范围: 有符号:[-128, 127] 无符号:[0, 255]
量化示例: 原始值: 3.14159265359 量化后: 25 (假设映射到0-255范围)
需要额外存储:
- scale (缩放因子)
- zero_point (零点偏移)
3.2.5 INT4 (4位整数)
结构: 4位整数值
表示范围: 有符号:[-8, 7] 无符号:[0, 15]
QLoRA的NF4格式: 特殊的非线性量化区间: [-1, -0.7, -0.3, -0.1, 0, 0.1, 0.3, 0.7, 1]
精度对比示例
原始值: 3.14159265359
FP32: 3.14159265359 # 完整精度
BF16: 3.141 # 3位精度
FP16: 3.1416 # 4位精度
INT8: 3.14 # 2位精度
INT4: 3.0 # 1位精度
# 存储同一个数所需的位数
FP32: 32位 -> 0x40490FDB
BF16: 16位 -> 0x4049
FP16: 16位 -> 0x4248
INT8: 8位 -> 25
INT4: 4位 -> 7
3.3 量化误差与舍入方式
3.3.1 什么是量化误差?
假设我们有一个FP16的权重值
原始值 = 0.37
使用4-bit量化,我们只能用16个数字表示所有可能的值
可用的量化值 = [-1.0, -0.7, -0.3, -0.1, 0, 0.1, 0.3, 0.7, 1.0]
0.37需要被映射到最近的可用值
量化后 = 0.3
量化误差
误差 = 原始值 - 量化后的值
= 0.37 - 0.3
= 0.07 # 这就是量化误差
3.3.2 为什么要关注量化误差?
在神经网络中
- 单个权重的误差可能很小
weight_error = 0.07
- 但在前向传播时误差会累积
layer1_error = weight_error * input_value
layer2_error = layer1_error * next_weight
...
- 累积的误差可能导致:
- 模型性能下降 - 训练不稳定 - 预测结果偏差
3.4 QLoRA如何处理舍入
# 传统的舍入方式(最近邻舍入)
def nearest_rounding(value, quantized_values):
return 找到最近的量化值
# 例如:
value = 0.37
nearest = 0.3 # 因为0.3是最近的可用值
# QLoRA使用随机舍入
def stochastic_rounding(value, lower, upper):
distance_to_lower = value - lower
distance_to_upper = upper - value
# 根据距离决定舍入概率
prob_upper = distance_to_lower / (upper - lower)
prob_lower = 1 - prob_upper
# 随机选择
return random.choices(
[lower, upper],
weights=[prob_lower, prob_upper]
)[0]
# 例如:
value = 0.37
lower = 0.3
upper = 0.7
# 可能舍入到0.3或0.7,概率基于距离
3.5 为什么QLoRA选择这种舍入方式?
# 考虑以下情况
原始权重序列 = [0.37, 0.34, 0.36, 0.35]
# 使用最近邻舍入
传统结果 = [0.3, 0.3, 0.3, 0.3] # 所有值都被舍入到0.3
问题: 完全丢失了原始分布信息
# 使用随机舍入
QLoRA结果可能是 = [0.3, 0.3, 0.7, 0.3]
优势:
1. 在统计意义上保持了原始分布
2. 避免了系统性偏差
3. 有助于训练稳定性
这种设计的优势:
- 保持统计特性:随机舍入帮助保持权重分布
- 避免累积误差:减少系统性偏差
- 训练稳定性:更好的梯度流动
- 显存效率:在极致压缩的同时保持模型性能
4. QLoRA概念
4.1 NF4量化
NF4的基本概念
# 传统4-bit整数量化(INT4)
INT4_POINTS = [-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7]
# 特点:线性均匀分布
# QLoRA的NF4量化
NF4_POINTS = [
-1.0, -0.7, -0.3, -0.1, # 负值区间
0, # 零点
0.1, 0.3, 0.7, 1.0 # 正值区间
]
# 特点:非线性分布,针对正态分布优化
1. 在常见值(接近0)的地方放更多的量化点
2. 在罕见值(远离0)的地方放较少的量化点
为什么需要NF4?
# 神经网络权重通常呈现正态分布
"""
大多数权重集中在0附近
| *** |
| ******* |
| *********** |
| ****************** |
-1 ------0------ +1
"""
# 传统INT4的问题
"""
1. 线性间隔不适合正态分布
2. 对中心区域(接近0)的精度不够
3. 对两端的大值表示过多
"""
NF4的设计原理
class NF4Quantization:
def __init__(self):
# 1. 非均匀量化点设计
self.quant_points = {
"near_zero": [-0.1, 0, 0.1], # 密集采样
"middle": [-0.3, 0.3], # 中等间隔
"far": [-1.0, -0.7, 0.7, 1.0] # 大间隔
}
def quantize(self, weight):
# 2. 基于距离的量化
if abs(weight) < 0.15:
# 小值使用精细量化
return find_nearest(weight, self.quant_points["near_zero"])
elif abs(weight) < 0.5:
# 中等值使用中等精度
return find_nearest(weight, self.quant_points["middle"])
else:
# 大值使用粗略量化
return find_nearest(weight, self.quant_points["far"])
NF4与传统量化的对比
# 假设有以下权重
weights = [0.05, 0.2,0.28 , 0.8, -0.03, -0.6, -0.9]
# INT4量化(线性)
def int4_quantize(weights):
# 均匀分布的量化点
quantized = [
0.0, # 0.05 -> 0
0.25, # 0.2 -> 0.25
0.25, # 0.28 -> 0.25
0.75, # 0.8 -> 0.75
0.0, # -0.03 -> 0
-0.5, # -0.6 -> -0.5
-1.0 # -0.9 -> -1.0
]
return quantized
# NF4量化(非线性)
def nf4_quantize(weights):
# 非均匀分布的量化点
quantized = [
0.1, # 0.05 -> 0.1 (更精确)
0.3, # 0.2 -> 0.3
0.3, # 0.28 -> 0.3
0.7, # 0.8 -> 0.7
0.0, # -0.03 -> 0 (更精确)
-0.7, # -0.6 -> -0.7
-1.0 # -0.9 -> -1.0
]
return quantized
NF4的优势
"精度优化": {
"零点附近": "更密集的量化点",
"大值区域": "更合理的分布"
},
"显存效率": {
"位宽": "仍然是4-bit",
"表示范围": "优化后的[-1,1]区间"
},
"训练稳定性": {
"梯度传播": "更好的数值稳定性",
"舍入误差": "更小的累积误差"
}
NF4的核心创新在于:
- 非线性量化点分布
- 针对神经网络权重的正态分布特性优化
- 在相同位宽(4-bit)下获得更好的量化效果
- 特别适合大语言模型的权重分布特征
4.2 双量化技术
首先理解单次量化
# 假设我们有一组原始权重
original_weights = [2.5, -1.8, 0.4, -0.2, 0.1]
# 单次量化过程
def single_quantization():
# 1. 计算量化参数
scale = max(abs(original_weights)) # = 2.5
# 2. 归一化
normalized = [
2.5/2.5, # = 1.0
-1.8/2.5, # = -0.72
0.4/2.5, # = 0.16
-0.2/2.5, # = -0.08
0.1/2.5 # = 0.04
]
# 3. 使用NF4量化
quantized = [
1.0, # 1.0 -> 1.0
-0.7, # -0.72 -> -0.7
0.1, # 0.16 -> 0.1
-0.1, # -0.08 -> -0.1
0.0 # 0.04 -> 0.0
]
# 需要保存:
# - 量化后的权重(4-bit)
# - scale值(FP16格式,16-bit)
双量化的问题由来
# 问题:scale值占用空间太大
def memory_analysis():
# 对于每个权重:
weight_memory = "4-bit" # 量化后的权重
scale_memory = "16-bit" # 量化参数(scale)
# 显存占用比例
print("scale占用显存是权重的4倍!")
# 例如:
# 权重:[1.0, -0.7, 0.1, -0.1, 0.0] -> 4-bit每个数
# scale:2.5 -> 16-bit
双量化的解决方案
def double_quantization():
# 第一次量化:对权重进行4-bit量化
# 1. 计算scale
scale = 2.5
# 2. 量化权重
quantized_weights = [1.0, -0.7, 0.1, -0.1, 0.0] # 4-bit
# 第二次量化:对scale进行8-bit量化
# 3. 量化scale值
quantized_scale = quantize_to_8bit(2.5)
# 最终存储:
# - 量化后的权重(4-bit)
# - 量化后的scale(8-bit,不是原来的16-bit)
具体例子
def full_double_quantization_example():
# 原始权重
weights = [2.5, -1.8, 0.4]
# 1. 计算scale
scale = max(abs(weights)) # = 2.5
# 2. 第一次量化(权重)
normalized = [w/scale for w in weights]
# normalized = [1.0, -0.72, 0.16]
# 使用NF4量化点
quantized_weights = [1.0, -0.7, 0.1] # 4-bit存储
# 3. 第二次量化(scale)
# 直接线性量化到8-bit
quantized_scale = int(scale * 255 / 256) # 8-bit存储
两种量化方式的区别
量化方式不同
"第一次量化(NF4)": {
"方式": "映射到预定义量化点",
"特点": "非线性分布的量化点",
"位数": "4-bit",
"目的": "保持权重分布特征"
"原因1": "权重分布需要特殊处理",
"原因2": "模型性能对权重精度敏感"
},
"第二次量化(线性)": {
"方式": "简单线性映射",
"特点": "均匀分布的量化点",
"位数": "8-bit",
"目的": "简单压缩存储空间",
"原因1": "scale是单个数值,不需要特殊分布",
"原因2": "8-bit精度对scale已经足够",
"原因3": "实现简单,计算快速"
}
占用空间不同
# 单次量化
single_quant = {
"权重": "1000个 × 4-bit = 4000 bits",
"scale": "1个 × 16-bit = 16 bits",
"总计": "4016 bits"
}
# 双量化
double_quant = {
"权重": "1000个 × 4-bit = 4000 bits",
"scale": "1个 × 8-bit = 8 bits",
"总计": "4008 bits"
}
4.3 分页优化器
传统方式
1. 模型参数在GPU
2. 优化器状态也在GPU
问题:
- Adam优化器需要存储两倍于参数量的状态
- 对于大模型来说,GPU显存不够用
# 例如:
model_params = "1GB参数"
adam_states = "2GB优化器状态" # 需要常驻GPU
total_gpu_memory = "3GB" # 总共占用
分页优化器的基本思想
"""
核心思想:
1. 优化器状态主要存在CPU显存中
2. 只把当前需要用的部分调到GPU
3. 用完就调回CPU,释放GPU显存
"""
# 例如:处理一个神经网络层
# 1. 把这层的优化器状态从CPU调到GPU
# 2. 更新这层的参数
# 3. 把更新后的状态调回CPU
# GPU显存被释放
具体例子
# 假设我们有一个大模型
model = {
"layer1": "1GB参数",
"layer2": "1GB参数",
"layer3": "1GB参数"
}
# 传统优化器
"""所有优化器状态都在GPU"""
gpu_memory = {
"layer1_state": "2GB",
"layer2_state": "2GB",
"layer3_state": "2GB"
}
# 总共需要6GB GPU显存
# 分页优化器
# 处理layer1
gpu_memory = {
"layer1_state": "2GB" # 只加载当前层的状态
}
# 处理完释放
# 处理layer2
gpu_memory = {
"layer2_state": "2GB" # 替换成第二层的状态
}
# 处理完释放
# 处理layer3
gpu_memory = {
"layer3_state": "2GB" # 替换成第三层的状态
}
# 处理完释放
实际效果
# 显存使用对比
"传统优化器": {
"模型参数": "1GB",
"优化器状态": "2GB",
"总GPU显存": "3GB",
"特点": "常驻显存"
}
"分页优化器": {
"模型参数": "1GB",
"优化器状态": "0.1GB", # 只保留当前处理的部分
"总GPU显存": "1.1GB",
"特点": "动态调度"
}
简单来说:
- 传统优化器就像是把所有书都摊在桌子上
- 分页优化器就像是:
- 书架上放着所有的书(CPU显存)
- 桌子上只放当前在看的那本书(GPU显存)
- 看完一本就放回书架,再拿下一本
- 这样桌子(GPU显存)就不会被占满