当一块24G显卡遇上470亿参数模型:QLoRA微调的思考
假如我们想微调一个7B模型来做客服助手而手头的机器是一块RTX 4090要如何做呢首先考虑下这个这个模型有多大——470亿参数。按FP16格式存光权重就要94GB进行全参数微调模型还没加载完显存就爆了。微调模型的三座大山罗列一下微调模型过程中可能面临的困境显存限制。微调一个7B参数的模型比如Llama-2-7B全参数微调需要约100GB显存RTX 3090/4090这种24GB的消费级显卡不可能扛住。成本过高。ml.p4d.24xlarge实例每小时32美元一次完整的微调可能要10-20小时。意味着你每犯一个错都要付出几百美元的代价。对于个人开发者或小团队来说不现实。技术难度。传统微调要更新所有参数梯度计算量巨大。而且容易过拟合出现灾难性遗忘——模型学新知识的同时把旧知识忘了。微调完的模型体积庞大部署也是个大问题。有了这个背景之后我们来看看LoRA和QLoRA。LoRA——解决参数爆炸LoRA的全称是Low-Rank Adaptation核心思想可以用一句话概括不要动那个巨大的权重矩阵只学一个低秩的增量。假设你有一个线性层权重矩阵W的维度是4096×4096。参数量是4096×409616,777,216个参数。反向传播时需要计算所有这些参数的梯度。Float32格式下约64MB但加上梯度、优化器状态实际占用可能超过200MB。这还只是一个层。一个7B模型可能有几十个这样的层。LoRA做了一个大胆的假设权重更新矩阵ΔW的内在秩很低。什么是秩通俗地说秩代表一个矩阵中真正独立的信息量。一个m×n的矩阵秩最大为min(m, n)。LoRA认为微调时的权重更新不需要那么高的秩。具体做法是ΔW B × A。其中B是d×r的矩阵A是r×k的矩阵r远小于min(d, k)。关键洞察在于只有A和B会接收梯度更新原始的W被冻结。让我用一个4×4的小矩阵来演示假设我们要微调一个线性层原始权重W是一个4×4的单位矩阵| 1.0 | 0 | 0 | 0 | | 0 | 1.0 | 0 | 0 | | 0 | 0 | 1.0 | 0 | | 0 | 0 | 0 | 1.0 |我们选择秩r2。LoRA矩阵B是4×2A是2×4B: A: | 1 0 | | 0.1 | 0 | 0 | 0 | | 0 1 | | 0 | 0.2 | 0 | 0 | | -1 0 | | 2 0 |权重更新ΔW B × A的结果是| 0.1 | 0 | 0 | 0 | | 0 | 0.2 | 0 | 0 | |-0.1 | 0 | 0 | 0 | | 0.2 | 0 | 0 | 0 |最终的有效权重W_eff W ΔW| 1.1 | 0 | 0 | 0 | | 0 | 1.2 | 0 | 0 | | 0 |-0.1 | 1.0 | 0 | | 0 | 0.2 | 0 | 1.0 |我们只训练了B和A这两个小矩阵就能实现对原始权重的微调。为什么能省这么多以LLaMA 7B为例假设总权重参数是64.8亿线性层LoRA秩r8目标层数量100层。全参数微调需要更新64.8亿个参数。而LoRA微调每层新增参数是r×(dk)8×2×409665,536总新增参数是65,536×100655万。这意味着只需要训练原始参数的千分之一。如何选择秩的大小秩r是LoRA最重要的超参数它控制着可训练参数数量、模型的表达能力和训练速度。对于风格迁移、语气调整这类相对简单的任务r8通常就足够了。如果是领域知识注入可能需要r16-32。而对于复杂推理能力增强这种高难度任务才需要考虑r64或更高。但要注意r越大越容易过拟合所以建议从小开始逐步增加找到那个最佳点。以下代码是具体的 LoRA 配置实践这里有几个关键参数值得细说。lora_alpha是缩放因子控制LoRA更新对模型的影响强度。实际权重更新公式是W_eff W (α/r) × B × A。更高的α表示LoRA更新的影响力更强。通常设置α2×r或αr。比如α16, r64意味着实际缩放为16/640.25。target_modules决定在哪些层注入LoRA。对于Transformer架构注意力层有q_proj、k_proj、v_proj、o_proj前馈网络有gate_proj、up_proj、down_proj。经验表明只在注意力层q_proj、v_proj注入LoRA已经能获得不错的效果。frompeftimportLoraConfig,get_peft_model,TaskTypefromtransformersimportAutoModelForCausalLM# 加载基础模型modelAutoModelForCausalLM.from_pretrained(mistralai/Mixtral-8x7B-v0.1,torch_dtypetorch.float16,device_mapauto)# 配置LoRAlora_configLoraConfig(r64,# 秩lora_alpha16,# 缩放系数target_modules[q_proj,v_proj,k_proj,o_proj],lora_dropout0.1,# Dropout防止过拟合biasnone,# 不训练偏置task_typeTaskType.CAUSAL_LM# 任务类型)# 应用LoRAmodelget_peft_model(model,lora_config)model.print_trainable_parameters()量化——压缩模型LoRA解决了参数太多难以训练的问题但还有另一个问题模型本身太大连加载都困难。回到Mixtral 8x7B。参数量约470亿FP16格式存储需要47×10^9×2 bytes94GB。加上梯度、优化器状态可能需要300GB显存。即使有A100 80GB也加载不了这个模型。量化的核心思想很简单用更少的位数来表示每个权重。格式每参数字节数7B模型大小47B模型大小FP324 bytes28 GB188 GBFP162 bytes14 GB94 GBINT81 byte7 GB47 GBINT40.5 byte3.5 GB23.5 GB量化如何工作直接把Float32截断为Int4会丢失大量信息量化的关键是缩放因子。假设原始权重范围是[-3.1415926, 2.7182818, -0.577215, 1.414213]。量化过程是这样的找到最大绝对值3.1415926计算缩放因子scale 3.1415926 / 7 0.4488假设INT4范围是[-7, 7]量化-3.1415926 / 0.4488 ≈ -7 → INT4: -72.7182818 / 0.4488 ≈ 6 → INT4: 6-0.577215 / 0.4488 ≈ -1 → INT4: -11.414213 / 0.4488 ≈ 3 → INT4: 3存储时量化值是[-7, 6, -1, 3]每个0.5 byte缩放因子是0.4488Float32。反量化时-7 × 0.4488 -3.1416 ≈ -3.1415926。误差在可接受范围内。更进一步压缩普通的INT4量化有个问题它假设权重均匀分布在[-max, max]区间。但实际上神经网络权重通常服从正态分布——大部分值在0附近极少数值很大。NF4NormalFloat 4专门为这种分布设计在0附近有更高的精度因为大部分权重在这里在极端值附近精度较低反正很少。NF4比普通INT4量化精度高约10-20%。此外QLoRA中缩放因子本身也占不少空间。每个缩放因子是Float324字节如果每128个权重共享一个缩放因子47B模型的缩放因子约需47×10^9/128×41.47GB。双重量化的思路是对缩放因子也进行量化。常规量化的流程是权重(FP16) → 量化权重(INT4) 缩放因子(FP32)。双重量化的流程是权重(FP16) → 量化权重(INT4) → 量化后的缩放因子(INT8) → 元缩放因子(FP32)。这个FP32的元缩放因子是唯一的浮点参数。重建过程分两步1. 先用元缩放因子反量化得到缩放因子 2. 再用缩放因子反量化得到可用权重。效果是能再节省10-20%内存。QLoRA——当LoRA遇见量化理解了LoRA和量化QLoRA就很简单了。核心思路是用INT4存储原始权重来节省显存训练时临时反量化为BFloat16来保证精度再用LoRA训练增量参数来减少计算量。模型加载阶段将INT4的模型加载还原为BF16精度前向传播阶段使用BF16精度计算激活值为什么计算时要用BFloat16 既然存储用INT4为什么不直接用INT4计算INT4精度太低梯度会变得极其不稳定。NT4只能表示2^416个不同的值梯度通常是很小的浮点数如0.0001量化误差会累积导致训练崩溃。BFloat16是个完美的折中与Float32相同的指数范围不会溢出精度足够支持梯度计算并且内存占用只有Float32的一半。完整的QLoRA配置代码配置量化其实就四个参数。importtorchfromtransformersimportAutoModelForCausalLM,AutoTokenizer,BitsAndBytesConfigfrompeftimportLoraConfig,get_peft_model,prepare_model_for_kbit_training# 步骤1配置4-bit量化bnb_configBitsAndBytesConfig(load_in_4bitTrue,# 启用4-bit加载bnb_4bit_use_double_quantTrue,# 启用双重量化bnb_4bit_quant_typenf4,# 使用NF4量化格式bnb_4bit_compute_dtypetorch.bfloat16# 计算精度)load_in_4bitTrue表示模型权重以4-bit整数格式加载到显存。相比FP16显存占用减少75%。7B模型从14GB降到3.5GB。bnb_4bit_quant_typenf4使用NF4格式。神经网络权重通常服从正态分布大部分值在0附近NF4在0附近有更高的量化精度实际效果比普通INT4好10-20%。bnb_4bit_use_double_quantTrue启用双重量化。对于47B模型常规量化缩放因子占用约1.47GB双重量化后能额外节省约0.7GB。虽然看起来不多但对于显存紧张的场景每一点节省都很重要。bnb_4bit_compute_dtypetorch.bfloat16决定计算时的精度。关键区别是BF16的指数位和FP32相同8位数值范围一样大几乎不会溢出。FP16的指数位只有5位大梯度值可能溢出导致训练崩溃。iftorch.cuda.is_bf16_supported():compute_dtypetorch.bfloat16# 推荐else:compute_dtypetorch.float16# 兼容老显卡显存节省效果对比以7B模型为例配置存储显存计算显存总显存无量化FP1614GB梯度优化器~30GB4-bit量化3.5GB临时BFloat16~8-10GB4-bit 双重量化~3GB临时BFloat16~7-9GB完整的工作流程如下# 步骤2加载量化后的模型modelAutoModelForCausalLM.from_pretrained(mistralai/Mixtral-8x7B-v0.1,quantization_configbnb_config,device_mapauto,use_cacheFalse# 使用梯度检查点时需要禁用)# 步骤3准备模型以接受k-bit训练modelprepare_model_for_kbit_training(model)# 步骤4配置LoRAlora_configLoraConfig(r64,lora_alpha16,target_modules[q_proj,k_proj,v_proj,o_proj],lora_dropout0.1,biasnone,task_typeCAUSAL_LM)# 步骤5应用LoRAmodelget_peft_model(model,lora_config)# 步骤6启用梯度检查点进一步节省显存model.gradient_checkpointing_enable()# 打印可训练参数model.print_trainable_parameters()数据处理——让模型学到正确的知识有了模型架构接下来需要处理训练数据。这部分经常被忽视但数据质量直接决定微调效果。首先大语言模型不直接处理文本它处理的是token ID序列。fromtransformersimportAutoTokenizer tokenizerAutoTokenizer.from_pretrained(mistralai/Mixtral-8x7B-v0.1)textThe cat chased the mousetokenstokenizer.encode(text)# 输出可能是[1437, 4821, 9384, 1437, 12893]# ↑ ↑ ↑ ↑ ↑# The cat chased the mousetoken序列和字词在词表中相互映射词表通过子词分词的方式编写。例如模型使用BPEByte Pair Encoding算法把单词拆分成更小的单元。比如unhappiness可能变成[“un”, “happiness”]或[“un”, “happi”, “ness”]。BPE的好处是未登录词OOV问题基本解决模型能理解词根和词缀的语义。“unhappy”、“undo”、unlock共享un前缀的语义。此外注意力掩码Attention Mask用于处理不同长度的序列。当批处理时短序列需要padding序列1: [1, 2, 3, 4, 0, 0, 0] ← padding with 0s 序列2: [5, 6, 7, 8, 9, 10, 11] 注意力掩码: 序列1: [1, 1, 1, 1, 0, 0, 0] ← 0表示padding模型忽略 序列2: [1, 1, 1, 1, 1, 1, 1] ← 全是真实token分块策略处理长文本微调数据集如Databricks Dolly 15k中的样本长度不一简单问答100-200 tokens复杂推理1000 tokens。如果采用padding策略所有样本padding到最大长度会浪费严重或者截断会丢失信息。QLoRA采用的分块策略是这样的defchunk_dataset(examples,chunk_size2048): 将所有文本连接然后切分成固定长度的块 # 拼接所有文本concatenated{k:sum(examples[k],[])forkinexamples.keys()}total_lengthlen(concatenated[list(examples.keys())[0]])# 舍弃最后不完整的块total_length(total_length//chunk_size)*chunk_size# 分块result{k:[t[i:ichunk_size]foriinrange(0,total_length,chunk_size)]fork,tinconcatenated.items()}# 创建remainder列存储不完整的部分remainder{input_ids:[],attention_mask:[],token_type_ids:[]}returnresult,remainder这种设计的好处是显而易见的。首先每个块都是完整的2048 tokens没有padding浪费。其次长文本被拆分成多个块信息不会丢失。更重要的是模型能看到更多不同的文本组合这实际上增加了数据多样性。当然这种策略也有潜在问题一个完整的instruction-response对可能被切分到两个块。但通常来说对decoder-only模型影响不大因为模型的目标是预测下一个token即使只看到部分context预测下一个合理token的任务依然有效某种程度上类似于数据增强。训练配置超参数选择如下gradient_accumulation_steps是梯度累积步数。实际batch size per_device_train_batch_size × num_gpus × gradient_accumulation_steps。当显存不足以容纳大batch时用这个参数模拟大batch训练。paged_adamw_8bit是8-bit优化器。传统Adam优化器需要存储一阶矩估计m和二阶矩估计v都与参数量相同。8-bit优化器把这些状态量化为INT8节省75%优化器显存。gradient_checkpointing是梯度检查点。训练深度网络时需要存储所有中间激活用于反向传播计算梯度。梯度检查点的策略是前向传播时只保存部分关键节点的激活反向传播时需要时重新计算。代价是训练时间增加10-20%收益是显存占用减少50-70%。fromtransformersimportTrainingArguments training_argsTrainingArguments(output_dir./output,# 核心超参数num_train_epochs3,per_device_train_batch_size2,# 每个GPU的batch sizegradient_accumulation_steps4,# 梯度累积等效batch_size8learning_rate2e-4,# 精度设置bf16True,# 使用BFloat16fp16False,# 不使用FP16# 优化设置optimpaged_adamw_8bit,# 8-bit Adam优化器节省显存weight_decay0.01,lr_scheduler_typecosine,warmup_ratio0.03,# 日志设置logging_steps10,save_strategyno,# 不保存checkpoint节省空间# 其他gradient_checkpointingTrue,# 启用梯度检查点)梯度检查点以计算换显存训练深度神经网络时前向传播会产生大量中间激活值activation这些激活值必须在反向传播时用于计算梯度。对于一个几十层的Transformer模型这些中间激活占用的显存可能比模型权重本身还大。以LLaMA 7B为例假设序列长度为2048batch size为1模型权重约14GBFP16中间激活可能达到20-40GB。这就是为什么即使模型能加载进去训练时还是会OOM。梯度检查点的核心思想是正常训练 输入 → Layer1 → 保存激活1 → Layer2 → 保存激活2 → ... → Layer32 → 保存激活32 → 输出 反向传播时直接使用保存的激活 → 计算梯度 梯度检查点 输入 → Layer1 → [丢弃] → Layer2 → [保存检查点] → Layer3 → [丢弃] → ... → 输出 反向传播时需要激活 → 从最近的检查点重新计算 → 得到激活 → 计算梯度假设一个32层的Transformer我们每4层保存一个检查点方式保存的激活数量显存占用正常训练32层全部保存100%梯度检查点8个检查点~25%当反向传播需要第15层的激活时找到最近的检查点第12层从第12层重新前向计算到第15层得到第15层的激活继续反向传播。虽然需要重新计算但只需重新计算检查点之间的部分层现代GPU计算速度很快重新计算的开销相对可控。显存节省带来的收益远大于时间开销。注意启用梯度检查点时需要禁用模型的use_cache。因为use_cache用于推理时的KV cache与梯度检查点不兼容。modelAutoModelForCausalLM.from_pretrained(mistralai/Mixtral-8x7B-v0.1,use_cacheFalse# 启用梯度检查点时必须禁用)训练脚本实现deftrain():# 解析参数argsparse_args()# 设置随机种子set_seed(args.seed)# 加载数据集datasetload_from_disk(args.dataset_path)# 配置量化bnb_configBitsAndBytesConfig(load_in_4bitTrue,bnb_4bit_use_double_quantTrue,bnb_4bit_quant_typenf4,bnb_4bit_compute_dtypetorch.bfloat16)# 加载模型modelAutoModelForCausalLM.from_pretrained(args.model_id,quantization_configbnb_config,device_mapauto,use_cacheFalseifargs.gradient_checkpointingelseTrue)# 准备k-bit训练modelprepare_model_for_kbit_training(model)# 配置LoRApeft_configLoraConfig(r64,lora_alpha16,target_modulesfind_all_linear_names(model),lora_dropout0.1,biasnone,task_typeCAUSAL_LM)# 应用LoRAmodelget_peft_model(model,peft_config)# 启用梯度检查点ifargs.gradient_checkpointing:model.gradient_checkpointing_enable()# 混合精度训练对LoRA层使用BFloat16ifargs.bf16:forname,moduleinmodel.named_modules():ifisinstance(module,LoraLayer):modulemodule.to(torch.bfloat16)ifnorminname:modulemodule.to(torch.float32)# LayerNorm保持FP32iflm_headinnameorembed_tokensinname:ifhasattr(module,weight):ifmodule.weight.dtypetorch.float32:modulemodule.to(torch.bfloat16)# 训练参数training_argsTrainingArguments(output_dirargs.output_dir,num_train_epochsargs.epochs,per_device_train_batch_sizeargs.per_device_train_batch_size,learning_rateargs.lr,bf16args.bf16,gradient_checkpointingargs.gradient_checkpointing,logging_dirf{args.output_dir}/logs,logging_steps10,save_strategyno)# 创建TrainertrainerTrainer(modelmodel,argstraining_args,train_datasetdataset,data_collatordefault_data_collator)# 开始训练trainer.train()# 保存模型model.save_pretrained(args.output_dir)模型部署——从训练到生产训练完成后需要部署模型供实际使用SageMaker Endpoint部署脚本如下fromsagemaker.huggingfaceimportHuggingFaceModel# 推理配置hub_config{HF_MODEL_ID:/opt/ml/model,# 模型路径SAGEMAKER_NUM_GPUS:json.dumps(8),# GPU数量MAX_INPUT_LENGTH:json.dumps(24000),MAX_TOTAL_TOKENS:json.dumps(32000),MAX_BATCH_PREFILL_TOKENS:json.dumps(32000),MAX_BATCH_TOTAL_TOKENS:json.dumps(512000),}# 创建模型modelHuggingFaceModel(model_dataestimator.model_data,# S3上的模型路径rolerole,image_urillm_image_uri,# 推理容器镜像envhub_config)# 部署端点predictormodel.deploy(endpoint_namemixtral-finetuned,initial_instance_count1,instance_typeml.g5.48xlarge,container_startup_health_check_timeout600)这几个参数决定了推理的行为。MAX_INPUT_LENGTH控制输入文本的最大token数问答场景1024通常就够了长文档处理可以设置更大。MAX_TOTAL_TOKENS是生成的最大token数输入输出决定了模型能生成多长的回复设置过大会占用更多显存。MAX_BATCH_PREFILL_TOKENS是预填充阶段token数。预填充是模型处理所有输入token、生成KV cache的阶段这个参数决定一次能处理多少个并发请求的输入。更高值意味着更高吞吐量但需要更多显存。MAX_BATCH_TOTAL_TOKENS控制同时能生成多少token用来平衡吞吐量和显存使用。API层的请求流程如下Lambda函数将请求转发到 sagemaker Endpoint并返回结果给用户。importjsonimportboto3 runtimeboto3.client(runtime.sagemaker)deflambda_handler(event,context):# 解析请求bodyjson.loads(event[body])promptbody[prompt]# 调用SageMakerresponseruntime.invoke_endpoint(EndpointNamemixtral-finetuned,ContentTypeapplication/json,Bodyjson.dumps({inputs:prompt,parameters:{max_new_tokens:1024,temperature:0.7,top_p:0.9,do_sample:True}}))resultjson.loads(response[Body].read())return{statusCode:200,body:json.dumps({response:result[0][generated_text]})}结语QLoRA不是什么魔法它只是把几个聪明的想法组合在一起LoRA让你只训练千分之一的参数量化让你把模型塞进消费级显卡梯度检查点让你用时间换空间。每一个技术点单独看都不复杂但组合起来就产生了一种奇妙的化学反应。最后是几点实战心得数据质量永远大于数据量。1000条高质量数据比10000条低质量数据有用得多。花时间清洗、去重、格式化这比调参更重要。从小模型开始实验。别一上来就搞70B先用7B摸清楚套路。固定其他参数只调一个这样你才能知道什么在起作用。用CloudWatch监控GPU使用率定期在验证集上评估模型质量。训练时看着loss曲线下降是最治愈的事情。技术在快速发展但核心原理是相通的。希望本文能帮助你理解QLoRA背后的思想而不仅仅是会用代码。

相关新闻

最新新闻

日新闻

周新闻

月新闻