1. 为什么我盯上了 AMD ROCm 云跑 Gemma4 情绪 LoRA
先说结论:这次实验的起点其实很朴素——我手头有一批用户评论数据,想做一个情绪分类的小模型,三分类(正向、中性、负向),原本用 CPU 跑推理勉强能撑,但一上微调就直接趴窝。后来看到 AMD ROCm 云上能按小时租到 MI 系列加速卡,价格比同档位的主流方案友好不少,就动了心思:能不能在 ROCm 上把 Gemma4 的 LoRA 微调完整跑通?
Gemma4 是 Google 放出的开源权重模型系列,相比前代在指令跟随和多语言上更稳,参数量适中,做 LoRA 微调时显存压力可控。LoRA(Low-Rank Adaptation)的核心思路是在原权重旁边挂一对低秩矩阵 A 和 B,训练时冻结主干,只更新这两个小矩阵。打个比方,主干模型像一本已经印好的厚词典,LoRA 就是贴在书页边上的便利贴,改起来快、撕下来也不伤原书。对情绪分类这种任务,LoRA 的参数量通常只占全量的百分之零点几,训练成本能压到很低。
但 ROCm 这条路的坑,和 CUDA 生态完全不是一个量级。我前后折腾了大概三天,中间重装过两次环境,最后把准确率从基线 0.594 拉到 0.734,涨了 14 个百分点。这个数字不算惊艳,但对一个只有几千条样本、单卡跑几十分钟的小任务来说,我认为是能交差的。下面我把整套流程、参数选择、踩过的坑和排查思路全部摊开讲,适合两类人看:一是想在非主流加速卡上做大模型微调的,二是想拿 Gemma4 做垂直任务但不想烧太多算力的。
需要提前说明的是,本文所有操作基于公开的 ROCm 软件栈和 Hugging Face 生态,不涉及任何特定云厂商的私有接口,你换一家支持 ROCm 的云环境,流程基本一致。
2. 整体方案设计与技术选型拆解
2.1 为什么是 LoRA 而不是全参微调或 Prompt 调优
情绪分类这个任务,标签空间很小,语义边界相对清晰,属于典型的“判别式”下游任务。全参微调当然效果上限更高,但代价是显存和时间的双重爆炸。以 Gemma4 这个量级为例,全参微调至少要准备数倍于推理的显存,而且很容易在小数据集上过拟合。Prompt 调优(比如软提示)虽然省资源,但收敛慢、对提示初始化敏感,调起来像玄学。
LoRA 恰好卡在中间:它通过低秩分解注入可训练参数,既保留了主干的通用语义能力,又能让模型“偏向”你的任务分布。我选 LoRA 还有一个现实原因——ROCm 上很多自定义算子的支持不如 CUDA 完善,全参微调会触发更多底层 kernel,出问题的概率成倍上升。LoRA 只训练少量线性层,算子路径简单,踩坑面小很多。
具体到配置,我锁定的是r=16、alpha=32、dropout=0.05,目标模块选q_proj和v_proj。为什么是这两个?注意力机制里,Query 决定“我要找什么”,Value 决定“我找到的内容是什么”,这两个投影对语义判别最敏感。K 和 O 投影我也试过加进去,但收益不明显,反而让可训练参数翻倍,训练时间拉长。r=16是个经验值,秩太低(比如 4)欠拟合,太高(比如 64)在小数据上容易过拟合,16 在两者之间比较平衡。alpha设为2r是常见做法,相当于给 LoRA 分支的输出乘一个缩放系数,让初始阶段对主干的扰动不至于太猛。
2.2 ROCm 云环境相比传统方案的真实差异
很多人对 ROCm 的印象还停留在“能跑但难用”。我这次的实际体感是:基础训练链路已经相当成熟,PyTorch 对 ROCm 的支持在近几个版本进步明显,torch.cuda命名空间下的 API 基本能直接映射过去。但差异集中在三个地方。
第一是版本耦合极强。ROCm 驱动版本、PyTorch 编译版本、hipBLAS 库版本三者必须严格对齐,错一个就可能在反向传播时报奇怪的段错误。我第二次重装就是因为 PyTorch 装成了 CPU 版,训练时device显示正常但实际没走加速卡,速度慢到怀疑人生。
第二是显存管理策略不同。ROCm 的显存分配器在碎片回收上比 CUDA 保守,长时间训练容易出现“明明还有余量却 OOM”的情况。解决办法是显式设置PYTORCH_HIP_ALLOC_CONF环境变量,把分配策略调成更激进的后端。
第三是生态工具链。像bitsandbytes这类量化库在 ROCm 上的支持时好时坏,我这次干脆没用 4bit 量化,直接用 fp16 跑,显存占用高一点但稳定。选型上我的原则很明确:能用原生 PyTorch + transformers 解决的,绝不引入额外依赖,每多一个库就多一层版本风险。
2.3 数据与评估口径的确定
数据集是我自己整理的,约 4800 条中文短评论,按 8:1:1 切分训练、验证、测试。标签三分类,分布大致均衡,没有做重采样。评估指标用准确率(accuracy)和宏平均 F1,后者能防止某一类被忽略。基线 0.594 是直接用 Gemma4 原权重做零样本推理得到的——我写了一个固定模板,让模型输出“正向/中性/负向”,取第一个有效词。这个基线其实偏低,说明原模型对中文口语化情绪表达的判别能力有限,正好给了微调发挥空间。
这里有个细节值得说:我没有用train_test_split随机切,而是按时间切。因为评论数据有时间漂移,随机切会让训练集和测试集共享同一时段的语言习惯,评估结果虚高。按时间切虽然让任务更难,但更接近真实上线场景。
3. 核心细节解析与实操要点
3.1 环境搭建:版本对齐是生死线
ROCm 环境最怕的就是“看起来装好了”。我的建议是装完立刻跑一段自检脚本,确认三件事:加速卡是否被识别、PyTorch 是否编译了 HIP 支持、矩阵运算是否真的落在卡上。
import torch print(torch.__version__) print(torch.cuda.is_available()) # ROCm 下这个返回 True 才算对 print(torch.cuda.get_device_name(0)) a = torch.randn(2048, 2048, device='cuda') b = torch.randn(2048, 2048, device='cuda') c = a @ b print(c.sum().item())如果is_available()返回 False,八成是装成了 CPU 版 PyTorch。这时候不要急着重装,先pip show torch看版本号里有没有+rocm后缀。没有的话,去 PyTorch 官网选对应 ROCm 版本的安装命令重装。我踩的第一个坑就在这里:第一次装完能 import,但训练时 loss 不下降,排查半天才发现梯度根本没回传,因为模型在 CPU 上。
提示:ROCm 环境下不要盲目
pip install torch,一定要用官方给出的带版本号的安装命令,否则默认拉的是 CPU 轮子。
3.2 模型加载与 LoRA 注入的关键参数
加载 Gemma4 时,torch_dtype我设成float16,device_map设成{"": 0}强制全部放卡 0。这里有个容易忽略的点:Gemma 系列默认的pad_token可能和eos_token相同,做 batch 训练时如果不显式设置 padding 侧,会导致 loss 计算把 padding 也算进去。我的做法是手动指定tokenizer.pad_token = tokenizer.eos_token,并在DataCollator里设padding="longest"。
LoRA 注入用peft库,配置如下:
from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "v_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()打印出来可训练参数大约占总量的 0.3% 左右。这个比例很健康,既够学任务特征,又不会把主干带偏。bias="none"是刻意为之,训练 bias 在小数据上收益低还容易过拟合。
3.3 训练超参的选择逻辑
超参这块我没有用自动搜索,而是基于经验加小范围试错。学习率用2e-4,这是 LoRA 微调的常见起点,比全参微调高一个量级,因为可训练参数少,需要更大的步长才能有效更新。batch_size设 8,配合梯度累积 4 步,等效 batch 32。为什么要累积?因为 ROCm 显存碎片问题,单次大 batch 容易触发 OOM,累积能在不增加峰值显存的前提下稳定梯度。
训练轮数设 3,warmup_ratio=0.1,lr_scheduler_type="cosine"。3 轮是我试出来的:1 轮欠拟合,验证集准确率还在爬;5 轮开始过拟合,训练 loss 继续降但验证 loss 反弹。3 轮刚好在拐点附近。max_length设 256,覆盖了 99% 的样本长度,超出的截断。
| 超参 | 取值 | 选择理由 |
|---|---|---|
| 学习率 | 2e-4 | LoRA 常用起点,参数少需大步长 |
| batch_size | 8 | 显存与稳定性的平衡点 |
| 梯度累积 | 4 | 规避 ROCm 显存碎片导致的 OOM |
| 训练轮数 | 3 | 验证 loss 拐点,防过拟合 |
| max_length | 256 | 覆盖 99% 样本,控制显存 |
| warmup_ratio | 0.1 | 防止初期梯度震荡 |
3.4 评估流程的严谨性
评估不能只看训练 loss。我每轮结束都在验证集上跑一次生成式推理,用固定 prompt 模板,解析输出并和标签比对。这里有个坑:生成式评估比分类头评估慢得多,因为要逐条 decode。我的优化是把batch_size在评估时调大(推理不存梯度,显存压力小),并开torch.no_grad()。另外,解析输出时要做鲁棒处理,比如模型可能输出“正向。”带标点,或者“我认为是正向”,要用关键词匹配而不是精确相等。
4. 实操过程与核心环节实现
4.1 数据预处理与 tokenize 的实操细节
数据格式是{"text": "...", "label": 0/1/2}。我把标签映射成自然语言描述拼进 prompt,比如“请判断以下评论的情绪:{text}\n情绪:”,标签对应“正向/中性/负向”。这样做的原因是 Gemma4 是指令模型,用生成式目标训练比纯分类头更契合它的预训练分布。
tokenize 时用truncation=True、padding=False,padding 交给 DataCollator 动态处理。为什么要动态?因为如果预处理阶段就 pad 到 256,短样本会浪费大量显存和计算。动态 padding 让每个 batch 只 pad 到该 batch 最长样本的长度,实测能省 20% 左右的时间。
def tokenize_fn(example): prompt = f"请判断以下评论的情绪:{example['text']}\n情绪:" label_text = id2label[example['label']] full = prompt + label_text + tokenizer.eos_token tokenized = tokenizer(full, truncation=True, max_length=256) tokenized["labels"] = tokenized["input_ids"].copy() return tokenized注意labels直接复制input_ids,因为这是因果语言模型,预测下一个 token。但这样会把 prompt 部分的 loss 也算进去,严格来说应该 mask 掉 prompt 部分。我试过 mask,效果提升不明显,反而增加代码复杂度,就保留了全序列 loss。如果你数据量更小,建议加上 mask,能让模型更聚焦在标签生成上。
4.2 训练循环与 ROCm 特有的稳定性处理
训练用transformers.Trainer,但做了几处针对 ROCm 的调整。第一,关掉fp16自动混合精度,改用bf16。ROCm 对 bf16 的支持比 fp16 更稳,数值溢出风险小。第二,设置dataloader_num_workers=2,太多 worker 在 ROCm 上偶发死锁。第三,加gradient_checkpointing=True,用时间换显存,让更大的模型或更长的序列能跑起来。
training_args = TrainingArguments( output_dir="./gemma4-lora-sentiment", per_device_train_batch_size=8, gradient_accumulation_steps=4, learning_rate=2e-4, num_train_epochs=3, bf16=True, logging_steps=20, eval_strategy="epoch", save_strategy="epoch", warmup_ratio=0.1, lr_scheduler_type="cosine", gradient_checkpointing=True, dataloader_num_workers=2, report_to="none" )训练过程中我盯着loss曲线和显存占用。第一次跑的时候,loss 在前 100 步几乎不动,我一度以为学习率太低。后来发现是warmup阶段正常现象,warmup 结束后 loss 才开始明显下降。所以看到初期 loss 平,先别急着调参,等 warmup 走完再说。
4.3 准确率从 0.594 到 0.734 的关键动作
基线 0.594 是零样本推理的结果。微调后测试集准确率 0.734,宏 F1 从 0.571 提到 0.712。提升主要来自两类样本:一是带反讽的口语表达,比如“这服务真是绝了”,零样本容易判成正向,微调后能识别出负向;二是短文本,比如“还行”,零样本倾向中性,微调后能结合上下文判正向。
我认为提升的核心不在 LoRA 本身,而在数据质量和 prompt 设计。我做了两件事:一是清洗掉标注不一致的样本,大概删了 200 条;二是把 prompt 模板固定下来,训练和推理用完全一致的格式。很多人微调后效果不涨,就是因为训练用一套模板、推理用另一套,模型学到的条件分布对不上。
| 阶段 | 准确率 | 宏 F1 |
|---|---|---|
| 零样本基线 | 0.594 | 0.571 |
| 微调 1 轮 | 0.681 | 0.653 |
| 微调 2 轮 | 0.719 | 0.698 |
| 微调 3 轮 | 0.734 | 0.712 |
4.4 模型保存与推理部署
训练完用model.save_pretrained()只存 LoRA 适配器,大小几十 MB,非常轻量。推理时先加载原模型,再用PeftModel.from_pretrained()挂上适配器。这里有个细节:保存时要把 tokenizer 一起存,否则推理时 prompt 模板对不上。
from peft import PeftModel base = AutoModelForCausalLM.from_pretrained("google/gemma-4", torch_dtype=torch.float16, device_map={"": 0}) model = PeftModel.from_pretrained(base, "./gemma4-lora-sentiment") model.eval() def predict(text): prompt = f"请判断以下评论的情绪:{text}\n情绪:" inputs = tokenizer(prompt, return_tensors="pt").to("cuda") with torch.no_grad(): out = model.generate(**inputs, max_new_tokens=4, do_sample=False) result = tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True) for label in ["正向", "中性", "负向"]: if label in result: return label return "中性"do_sample=False保证输出确定性,max_new_tokens=4因为标签最多两个字加一个结束符,够用。
5. 四个坑的完整复盘与排查技巧
5.1 坑一:PyTorch 装成 CPU 版,训练静默降速
这是最隐蔽的坑。表现是训练能跑、loss 也降,但速度慢到离谱,一个 epoch 要几小时。我一开始以为是数据量大,后来用nvidia-smi的 ROCm 对应工具rocm-smi一看,加速卡利用率几乎为零。根因是pip install torch默认拉 CPU 轮子。
排查方法:python -c "import torch; print(torch.version.hip)",如果输出None,就是 CPU 版。解决就是卸载重装带+rocm的版本。这个坑我建议你在环境搭好后第一时间验证,别等训练跑起来才发现。
5.2 坑二:显存碎片导致间歇性 OOM
ROCm 的显存分配器在长时间训练后容易碎片化,表现为前几个 batch 正常,跑到中途突然 OOM,而且报错信息里显示的可用显存明明还够。解决办法是设置环境变量:
export PYTORCH_HIP_ALLOC_CONF=expandable_segments:True这个配置让分配器用可扩展段管理显存,减少碎片。我加上之后,同样的 batch 配置再没 OOM 过。另外,梯度累积步数不要设太大,累积越多,中间激活占用的显存越难及时释放。
5.3 坑三:tokenizer 的 pad_token 缺失导致 loss 异常
Gemma 的 tokenizer 默认可能没有独立的pad_token,如果不设置,DataCollator 会报错或者用eos_token填充但不在 attention mask 里屏蔽。表现是 loss 数值异常大或者不收敛。解决就一行:
tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "right"padding_side设 right 是因为因果语言模型从左侧开始预测,右侧 padding 不会干扰标签对齐。如果设成 left,生成式评估时可能出问题。
5.4 坑四:评估时 prompt 模板与训练不一致
这个坑最容易被忽略,但影响最大。我第一版评估脚本图省事,直接用了更短的 prompt,结果准确率只有 0.65,比预期低一大截。排查后发现是模板不一致,模型在训练时见的是“请判断以下评论的情绪:xxx\n情绪:”,评估时变成了“情绪分类:xxx”,条件分布对不上。
| 问题现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 训练极慢 | PyTorch 为 CPU 版 | 检查torch.version.hip | 重装 ROCm 版 |
| 中途 OOM | 显存碎片 | 观察 OOM 时报错显存 | 设 expandable_segments |
| loss 不收敛 | pad_token 缺失 | 检查 tokenizer 配置 | 设 pad_token 和 padding_side |
| 评估偏低 | prompt 模板不一致 | 对比训练与评估模板 | 统一模板 |
提示:微调任务里,模板一致性比超参调优更重要。模板变了,等于换了个任务,模型自然懵。
5.5 独家避坑心得
除了上面四个,还有几个小经验。第一,训练前先用 100 条样本跑一个 mini epoch,确认整条链路通,再上全量数据,能省大量试错时间。第二,ROCm 环境下尽量别用accelerate的自动 device_map,手动指定更可控。第三,保存 checkpoint 时用save_total_limit=2,避免磁盘被塞满,ROCm 云实例的磁盘通常不大。第四,如果要做多卡,先确认torch.distributed的 backend 在 ROCm 上是nccl还是rccl,用错了直接 hang 住。
6. 关于这套方案还能怎么扩展
跑通之后我又试了几个方向。一是把 LoRA 的target_modules扩展到k_proj和o_proj,准确率只涨了 0.008,但训练时间多了 40%,性价比不高,不推荐。二是换更小的r=8,准确率掉到 0.71,说明 16 这个秩对这个任务是有必要的。三是把 Gemma4 换成同量级的其他开源模型做对比,发现 Gemma4 在中文情绪任务上的零样本基线本身就偏高,微调后的天花板也更高,选它是对的。
如果你数据量更大,比如几万条,我建议把r提到 32,训练轮数加到 4 到 5 轮,同时把lora_dropout提到 0.1 防过拟合。如果数据量只有几百条,那就把r降到 8,轮数减到 2,重点放在数据清洗上,别指望模型从噪声里学出花来。
最后分享一个我实际用下来很稳的小技巧:训练结束后,别急着删中间 checkpoint,把每一轮的适配器都留着,在验证集上分别评估,选验证 F1 最高的那个,而不是默认用最后一轮。我这次最后一轮是 0.734,但第二轮其实在验证集上 F1 更高,只是测试集上略低,这种差异在小数据上很常见,多留一手总没错。