微调 2-Bit Qwen3 模型

QLoRA 是一种广泛采用的量化大型语言模型(LLMs)微调方法。该方法不更新完整模型,而是冻结基础模型的权重,并训练一个轻量级适配器——即插入自注意力和 MLP 层等关键组件中的少量额外参数。这种方案能以最小的内存和计算开销实现高效微调。

该技术最常与 bitsandbytes 的 4 位量化方案配合使用,实践证明其能产生稳定且精度尚可的结果。bitsandbytes 并非 QLoRA 的最佳选择:相比当前最先进的量化方法,它在精度和效率方面都存在不足。

得益于优化的 CUDA 内核,现代替代方案不仅能提供更高的精度,还能实现更快的微调。这些新技术还支持更低比特位的量化,包括 2 比特和 3 比特格式。尽管如此,低比特位模型的微调仍具挑战性。这类模型往往存在显著的精度下降问题,导致难以可靠训练。不过,仅微调适配器而非整个模型,可以作为一种针对性"修复"手段,同时提升模型在特定任务上的表现。

本文将探讨低比特位模型适配器微调的主要挑战。在极端压缩级别下,模型的初始精度可能低至无法恢复,或训练过程变得不稳定——即使微小的学习率也可能引发梯度爆炸。采用正确的适配器初始化方法(例如 EoRA)有助于缓解这些问题,既能加速收敛,又能提升最终性能。

我们将逐步演示如何在单块 24GB 显存的 RTX 4090 显卡上,使用 Transformers 和 TRL 为 2 比特 Qwen3-14B 模型微调 LoRA 适配器。

2-Bit LLMs 的 QLoRA 微调基础

选择合适模型:性能降级,但未失效

成功的 QLoRA 微调需要坚实基础。虽然低比特量化(尤其是 2 比特)会显著降低模型性能,但不应使模型完全失效。换言之,量化后的模型仍需具备生成连贯文本的能力。

小型 LLMs 在激进量化下特别容易崩溃,因此谨慎选择模型至关重要。将 Qwen3 模型精确量化为 4 位和 2 位精度,在保持基准可用性的同时实现高效微调。

我们发现 Qwen3 对 2 位量化表现出较强的鲁棒性,仅在需要良好指令跟随能力的任务上出现有限但显著的性能下降。

例如,在 IFEval 基准测试中,2 位量化的 Qwen3-14B 模型比原始未量化版本得分低了约 16 分。虽然下降幅度显著,但模型并未失效,仍能生成连贯的输出并保持稳定表现。事实上,即便是 2 位量化形态的 Qwen3-14B,其性能仍优于 Llama 3.1 8B 和 Qwen3-1.7B 等若干更大的 16 位模型。

本文将重点介绍如何微调这款 2 比特的 Qwen3-14B 模型。

这款 2 比特量化的 Qwen3-14B 模型仅占用 7.36GB 内存空间。这意味着 24GB 显存的 GPU 就足以在其基础上进行适配器微调,即便是 16GB 显存的显卡也能轻松胜任这项任务。

2 比特 QLoRA:原理虽简,暗藏玄机

bitsandbytes 量化与 QLoRA 微调常被搭配使用绝非偶然——它们源自同一篇研究论文,该论文提出了针对 LLMs 的低比特训练统一实践框架。不过若能避开几个关键陷阱,将新型量化技术与 QLoRA 结合使用,还能释放更优异的性能表现。

它们的实现版本同期发布,大多数教程和文档历来都将二者相提并论。尽管 QLoRA 原始研究距今已有两年,这种惯例仍广泛存在。

然而,区分二者至关重要:

QLoRA 是一种通过训练轻量级适配器来微调任何量化模型的方法。

bitsandbytes NF4 只是执行量化的一种方法

你不仅限于使用 bitsandbytes。QLoRA 可以应用于通过 GPTQ、AWQ、HQQ 甚至 GGUF 格式量化的模型。实际上,HQQ+本质上就是直接应用于 HQQ 量化模型的 QLoRA 变体。

借助 Transformers 和 TRL 等框架,你通常可以直接沿用现有的 QLoRA 微调代码,只需替换为 GPTQ 量化模型即可。整个过程应该能无缝运行。

本文中,我们使用的是通过 AutoRound 以 GPTQ 格式量化的 2 比特 Qwen3-14B 模型。得益于针对 GPTQ 高度优化的 CUDA 内核,这种配置不仅能实现比 bitsandbytes 更快的微调速度,还因 AutoRound 更先进的量化算法而具有更高的精度。

尽管如此,2-bit QLoRA 技术也面临一系列影响微调效率的挑战。

关键陷阱一:较小的分组规模

高质量的 2 比特量化模型通常比 4 比特版本采用更小的分组尺寸,普遍为 32 而非 128。这既影响内存布局也改变量化噪声的分布方式,在配置微调流程时必须予以考虑。

采用分组大小为 32 的量化模型在前向传播时需要解量化的组数是 128 分组大小模型的四倍。虽然这不会使微调或推理速度恰好降低四倍,但确实会导致明显的性能下降,尤其是当 CUDA 内核未针对小分组尺寸进行充分优化时。具体影响会因后端实现效率而有显著差异,特别是针对 32 分组大小的优化程度。

关键陷阱二:训练不稳定性

另一个主要挑战是由于极端量化导致的训练不稳定性。尽管 QLoRA 仅微调适配器层(保持基础模型权重冻结),但量化模型精度的下降仍可能导致训练动态不稳定。

在我的实验中,我经常遇到梯度爆炸问题。训练初期进展顺利,但在预热阶段结束后不久,梯度范数和训练损失会迅速飙升。为缓解这一问题,我建议:

对梯度范数进行裁剪

使用非常小的学习率,特别是对于较大模型或激进量化级别的情况

使用 EoRA 初始化实现更优 QLoRA

EoRA 这种免训练方法——它通过在量化模型顶部校准适配器来恢复或提升模型精度。

对于 2 比特的 Qwen3-14B 模型,EoRA 方法将准确率差距从 16 分降低至 11 分,这对无需训练的方法而言是显著提升。

由于 EoRA 生成的是标准适配器,我们可以将其作为 QLoRA 微调的初始化起点。操作非常简单,只需在量化模型基础上加载 EoRA 适配器并标记为可训练状态。借助 TRL 工具库,这一工作流程已获得完整支持。

通过 EoRA 初始化,模型有望凭借更优的起点实现更快收敛。虽然理论上无法保证,但与从头训练相比,该方法可能获得更高的最终准确率。

环境要求与代码实现

您可以通过 Transformers 和 TRL 框架对 GPTQ 格式模型实施 QLoRA 微调。由于我们专门处理 GPTQ 格式的模型,您还需安装 optimum 库及相应的 gptq 软件包(根据具体实现方案,可能包含 AutoGPTQ 或 GPTQModel 组件)。

此外,我们安装 bitsandbytes 以启用 8 位 AdamW 优化器,这有助于减少训练期间的内存使用量。

pip install --upgrade trl transformers datasets optimum gptqmodel bitsandbytes

如前一节所述,这段代码相当标准。它非常接近使用 bitsandbytes 进行 QLoRA 微调的典型设置,仅针对 GPTQ 模型做了细微调整。

以下是使用与不使用 EoRA 初始化微调 2 位 Qwen3-14B 模型时的学习曲线对比:

 Key Observations

✅ 训练稳定性强:一切运行流畅,学习曲线如预期般下降。

⚡ EoRA 提供先发优势:采用 EoRA 初始化的模型起始损失值更低,适配器在早期阶段学习速度更快。

📉 学习动态保持一致:尽管起点更优,EoRA 并未改变整体学习轨迹。两种配置的损失曲线形态保持相似。

结论

QLoRA 通过在学习过程中添加少量低秩适配器来微调量化模型。这种方法能大幅降低 GPU 显存占用(通常减少 4-6 倍),让你用单张高端显卡就能训练 LLMs,且基本不会影响下游任务质量。

何时该选用 QLoRA?

当你需要完整模型精度但显存有限,或想并行运行多个实验时就可以使用。它在监督微调(SFT)和指令调优场景中表现尤为出色。

使用 EoRA 初始化的影响

EoRA 适配器是优质的训练起点。由于 EoRA 无需训练,实际获取 EoRA 适配器的成本非常低廉。

若训练过程出现不稳定怎么办

降低学习率:例如降至 1×10⁻⁷;大多数情况下,仅此一项就能平滑损失曲线。

仍然出现尖峰?将 SFTConfig 中的 max_grad_norm 设置为极小值(如 0.05)。在该阈值进行梯度裁剪几乎总能抑制梯度爆炸并恢复收敛性。

按顺序调整这两个参数,QLoRA+EoRA 组合就能在各类任务中保持内存高效且稳如磐石。

import torch, os, multiprocessing
from datasets import load_dataset
from peft import LoraConfig, prepare_model_for_kbit_training, PeftModel
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    BitsAndBytesConfig,
    set_seed
)
from trl import SFTTrainer, SFTConfig
set_seed(1234)


def fine_tune(model_name, batch_size=1, gradient_accumulation_steps=32, EoRA=False):

    tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)

    ds_train = load_dataset("allenai/tulu-3-sft-mixture", split="train[:150000]")
    def process(row):
        row["text"] = tokenizer.apply_chat_template(row["messages"], tokenize=False, add_generation_prompt=False, enable_thinking=False) 
        return row
    
    ds_train = ds_train.map(
        process,
        num_proc= multiprocessing.cpu_count(),
        load_from_cache_file=False,
    )

    ds_train = ds_train.remove_columns(["messages"])
    
    model = AutoModelForCausalLM.from_pretrained(
            model_name, device_map={"": 0}, torch_dtype=torch.float16, trust_remote_code=True, attn_implementation="flash_attention_2"
    )
    model = prepare_model_for_kbit_training(model, gradient_checkpointing_kwargs={'use_reentrant':True})
    if EoRA:
      model = PeftModel.from_pretrained(model, "kaitchup/Qwen3-14B-autoround-2bit-gptq-EoRA-r32", is_trainable=True)
      peft_config = None
    else:
      peft_config = LoraConfig(
            lora_alpha=32,
            lora_dropout=0.0,
            r=32,
            bias="none",
            task_type="CAUSAL_LM",
            target_modules= ['k_proj', 'q_proj', 'v_proj', 'o_proj', "gate_proj", "down_proj", "up_proj"],
      )
    
    name = model_name.split("/")[-1]
    
    output_dir = "./LoRA"+name+"_eora/"
    
    training_arguments = SFTConfig(
          output_dir=output_dir,
          optim="paged_adamw_8bit",
          per_device_train_batch_size=batch_size,
          gradient_accumulation_steps=gradient_accumulation_steps,
          log_level="debug",
          save_steps=100,
          logging_steps=25,
          learning_rate=1e-5,
          bf16 = True,
          max_steps=1000,
          warmup_ratio=0.03,
          lr_scheduler_type="linear",
          dataset_text_field="text",
          max_seq_length=4096,
          report_to="none",
    )
    
    trainer = SFTTrainer(
          model=model,
          train_dataset=ds_train,
          peft_config=peft_config,
          processing_class=tokenizer,
          args=training_arguments,
    )
    
    #--code by Unsloth: https://colab.research.google.com/drive/1Ys44kVvmeZtnICzWz0xgpRnrIOjZAuxp?usp=sharing#scrollTo=pCqnaKmlO1U9
    
    gpu_stats = torch.cuda.get_device_properties(0)
    start_gpu_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)
    max_memory = round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3)
    print(f"GPU = {gpu_stats.name}. Max memory = {max_memory} GB.")
    print(f"{start_gpu_memory} GB of memory reserved.")
    
    trainer_ = trainer.train()
    
    
    used_memory = round(torch.cuda.max_memory_reserved() / 1024 / 1024 / 1024, 3)
    used_memory_for_trainer= round(used_memory - start_gpu_memory, 3)
    used_percentage = round(used_memory         /max_memory*100, 3)
    trainer_percentage = round(used_memory_for_trainer/max_memory*100, 3)
    print(f"{trainer_.metrics['train_runtime']} seconds used for training.")
    print(f"{round(trainer_.metrics['train_runtime']/60, 2)} minutes used for training.")
    print(f"Peak reserved memory = {used_memory} GB.")
    print(f"Peak reserved memory for training = {used_memory_for_trainer} GB.")
    print(f"Peak reserved memory % of max memory = {used_percentage} %.")
    print(f"Peak reserved memory for training % of max memory = {trainer_percentage} %.")
    print("-----")
    #---- 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

runner000001

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值