QLoRA微调原理详解

为什么会有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 为什么要关注量化误差?

在神经网络中

  1. 单个权重的误差可能很小

weight_error = 0.07

  1. 但在前向传播时误差会累积

layer1_error = weight_error * input_value

layer2_error = layer1_error * next_weight

...

  1. 累积的误差可能导致:
  • 模型性能下降 - 训练不稳定 - 预测结果偏差

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. 有助于训练稳定性

这种设计的优势:

  1. 保持统计特性:随机舍入帮助保持权重分布
  2. 避免累积误差:减少系统性偏差
  3. 训练稳定性:更好的梯度流动
  4. 显存效率:在极致压缩的同时保持模型性能

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的核心创新在于:

  1. 非线性量化点分布
  2. 针对神经网络权重的正态分布特性优化
  3. 在相同位宽(4-bit)下获得更好的量化效果
  4. 特别适合大语言模型的权重分布特征

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",
        "特点": "动态调度"
    }

简单来说:

  1. 传统优化器就像是把所有书都摊在桌子上
  2. 分页优化器就像是:
    • 书架上放着所有的书(CPU显存)
    • 桌子上只放当前在看的那本书(GPU显存)
    • 看完一本就放回书架,再拿下一本
    • 这样桌子(GPU显存)就不会被占满
相关推荐
阿杰学AI40 分钟前
AI核心知识31——大语言模型之Multimodal Understanding(简洁且通俗易懂版)
人工智能·ai·语言模型·自然语言处理·aigc·embedding·多模态理解
子午40 分钟前
【交通标志识别系统】Python+TensorFlow+Django+人工智能+深度学习+卷积神经网络算法
人工智能·python·深度学习
励志成为糕手40 分钟前
动手学CNN:图像处理的卷积神经网络实践指南
图像处理·人工智能·深度学习·计算机视觉·cnn
chatexcel43 分钟前
ChatExcel AI 表格功能详解:多模态数据自动抓取与智能结构化生成的实战效率提升
人工智能
木头左1 小时前
门控注意力单元与LSTM细胞状态更新的协同机制
人工智能·rnn·lstm
xhyyvr1 小时前
VR 超凡赛车:沉浸式动感驾驶,解锁交通安全普法新体验
人工智能·vr
大千AI助手1 小时前
马哈拉诺比斯距离:理解数据间的“真实”距离
人工智能·深度学习·机器学习·距离度量·大千ai助手·马氏距离·马哈拉诺比斯距离
玖日大大1 小时前
基于 Hugging Face Transformers 搭建情感分析模型:从原理到实战
人工智能·学习
老蒋新思维2 小时前
创客匠人峰会复盘:AI 时代知识变现,从流量思维到共识驱动的系统重构
大数据·人工智能·tcp/ip·重构·创始人ip·创客匠人·知识变现