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("-----")
#----