背景
我有一个中文网络小说数据集(~15K 本小说,2.2M 章节,77 亿字),经过 ETL 清洗后转换成了 ShareGPT 格式的 SFT 训练数据。目标是微调一个大语言模型来续写小说章节 — 给定一段情节,模型能续写出风格一致、情节连贯的长文本。
选择了 Qwen3.5-35B-A3B — 这是一个 MoE(Mixture of Experts)架构的模型,256 个 experts,虽然总参数量 35B,但每次推理只激活约 3B 参数,推理效率很高。
之前的训练跑出来的模型有一个严重的问题:过早输出 EOS token,生成几句话就停了,完全没法用。这次的目标是彻底解决这个问题。
不过在到达最终方案之前,我在前期 debug 上花了大量时间和钱。整个过程并不像这篇文章看起来那么顺利。
硬件与成本
在 Vast.ai 上租了一台双 NVIDIA H200 的机器:
| 规格 | 配置 |
|---|---|
| GPU | 2× NVIDIA H200 140GB HBM3 |
| 内存 | 2TB DDR5 |
| vCPUs | 56 |
| 磁盘 | 250GB |
| 价格 | $4.71/小时 |
前期 Debug:从 OOM 到跑通
在最终的训练配置定型之前,经历了一段痛苦的 debug 过程。
第一次尝试:device_map=“balanced” — 单卡瓶颈
最初的 train.py 用的是 device_map="balanced"(pipeline parallelism),让 Hugging Face 自动把模型分到两张卡上。但这需要 hack accelerate 的 AcceleratorState,手动清理分布式环境变量(WORLD_SIZE、RANK、LOCAL_RANK),否则 accelerate 会检测到分布式环境然后和 device_map 冲突:
# 旧代码(已删除)— 丑陋的 monkey-patchfrom accelerate import AcceleratorStateAcceleratorState._shared_state = {} # 强制重置for var in ["WORLD_SIZE", "RANK", "LOCAL_RANK", "MASTER_ADDR", "MASTER_PORT"]: os.environ.pop(var, None)
model, tokenizer = FastLanguageModel.from_pretrained( model_name=args.model, device_map="balanced", # pipeline parallelism ...)这个方案跑起来了,但训练时只有一张卡在算,另一张卡在等 — pipeline parallelism 的先天缺陷。GPU 利用率始终上不去。
第二次尝试:FSDP — 两张卡都 OOM
改成 FSDP 后,第一次启动直接 OOM:
torch.OutOfMemoryError: CUDA out of memory.Tried to allocate 16.00 MiB. GPU 0 has a total capacity of 139.83 GiB,of which 6.00 MiB is free.Process 5378: 70.27 GiB in use.Process 5379: 69.50 GiB in use.原因:FSDP 的 sharding 发生在模型加载之后。两个 rank 都先把完整的 70GB 模型加载到 GPU 0 上(因为默认 device_map 行为),然后才开始 shard。两个 70GB = 140GB,超过了单卡 139GB 的容量。
即使设置了 fsdp_cpu_ram_efficient_loading: true,Unsloth 的自定义加载路径也绕过了这个机制。
修复:per-rank device_map
解决方案是让每个 rank 只加载到自己对应的 GPU 上:
local_rank = int(os.environ.get("LOCAL_RANK", 0))model, tokenizer = FastLanguageModel.from_pretrained( model_name=args.model, device_map={"": local_rank}, # rank 0 → GPU 0, rank 1 → GPU 1 ...)这样两个进程各自加载到不同的 GPU,不再争抢同一块显存。FSDP 在此基础上再做 shard,把权重、梯度、优化器状态分片到两张卡上。
第三次尝试:FSDP wrapping 崩溃
加载不 OOM 了,但在 LoRA patching + FSDP wrapping 阶段又崩了(rank 1 exit code 1)。这次是因为 FSDP 的 dtype 一致性要求:模型里混了 fp32 和 bf16 的参数,FSDP 不接受。
# 修复:强制所有 fp32 参数转 bf16for name, param in model.named_parameters(): if param.dtype == torch.float32: log.info("Casting %s from fp32 → bf16", name) param.data = param.data.to(torch.bfloat16)终于跑起来了
经过这三轮 debug,FSDP 终于跑通了。前期 debug 花费约 $26。
完整训练代码
FSDP 配置
用 Hugging Face Accelerate 的 FSDP 做分布式训练。关键配置:
compute_environment: LOCAL_MACHINEdistributed_type: FSDPfsdp_config: fsdp_sharding_strategy: FULL_SHARD fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP fsdp_transformer_layer_cls_to_wrap: Qwen3_5MoeDecoderLayer fsdp_state_dict_type: FULL_STATE_DICT fsdp_offload_params: false fsdp_backward_prefetch: BACKWARD_PRE fsdp_forward_prefetch: true fsdp_use_orig_params: true fsdp_sync_module_states: false fsdp_cpu_ram_efficient_loading: falsemixed_precision: bf16num_processes: 2几个值得注意的点:
FULL_SHARD:权重、梯度、优化器状态全部分片到两张卡上,最大化显存利用fsdp_transformer_layer_cls_to_wrap: Qwen3_5MoeDecoderLayer:按 decoder layer 粒度做 wrap,这对 MoE 模型很重要fsdp_forward_prefetch: true:在前向传播时预取下一层的参数,减少通信等待fsdp_use_orig_params: true:LoRA 需要这个选项,否则 FSDP 会破坏 PEFT 的参数结构
启动脚本
#!/usr/bin/env bashset -euo pipefail
cd /workspace/training
accelerate launch \ --config_file fsdp_config.yaml \ train.py \ --max-seq-length 32768 \ --lora-r 32 \ --epochs 1 \ --lr 2e-4 \ --batch-size 2 \ --grad-accum 4 \ --max-samples 1000 \ "$@" \ 2>&1 | tee train_fsdp.log训练脚本
完整的 train.py,分四个部分讲解。
1. 模型加载与 LoRA 配置
def load_model(args): local_rank = int(os.environ.get("LOCAL_RANK", 0)) model, tokenizer = FastLanguageModel.from_pretrained( model_name=args.model, # "unsloth/Qwen3.5-35B-A3B" max_seq_length=args.max_seq_length, # 32768 load_in_4bit=False, # MoE + BnB 4bit 不兼容,必须用 bf16 dtype=torch.bfloat16, device_map={"": local_rank}, # FSDP 需要每个 rank 只加载到自己的 GPU )
# FSDP 要求所有参数 dtype 一致,把 fp32 的参数强制转 bf16 for name, param in model.named_parameters(): if param.dtype == torch.float32: param.data = param.data.to(torch.bfloat16)
# Qwen3.5 是 VLM,tokenizer 可能是 Processor 包装的 tokenizer = get_chat_template(tokenizer, chat_template="qwen-2.5") text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
model = FastLanguageModel.get_peft_model( model, r=32, lora_alpha=32, target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], lora_dropout=0, bias="none", use_gradient_checkpointing="unsloth", random_state=3407, ) return model, tokenizer, text_tokenizerTIPUnsloth 会自动检测到 MoE 模型,并额外给
mlp.experts.gate_up_proj和mlp.experts.down_proj加上 LoRA。最终可训练参数 931M / 18.5B(5.04%)。
2. Cut Cross Entropy — 省 30GB 显存的关键
35B 模型的 vocab projection(lm_head)会生成一个巨大的 logits tensor:(batch_size × seq_length × vocab_size)。在 batch=2、seq=32768、vocab=152064 的情况下,这个 tensor 占约 30GB 显存。
解决方案:用 cut_cross_entropy 库,直接在 hidden states 和 lm_head weight 上计算 loss,跳过 logits 的显式分配。
def _patch_model_with_cce(model): """替换 lm_head 为 identity,用 CCE 直接算 loss""" import types from transformers.modeling_outputs import CausalLMOutputWithPast
# 层层剥开 PEFT 包装,找到真正的 CausalLM 模型 lm_model = model while hasattr(lm_model, "model"): lm_model = lm_model.model if not hasattr(lm_model, "lm_head"): lm_model = model.base_model.model if hasattr(lm_model, "language_model"): lm_model = lm_model.language_model
# 保存 lm_head 权重,替换为 identity lm_head_weight = lm_model.lm_head.weight # (vocab_size, hidden_dim)
class IdentityLMHead(torch.nn.Module): def __init__(self, weight): super().__init__() self.weight = weight def forward(self, hidden_states): return hidden_states # 直接返回 hidden states,不做 vocab projection
lm_model.lm_head = IdentityLMHead(lm_head_weight)
# Monkey-patch forward orig_forward = lm_model.forward.__func__
def cce_forward(self, *args, **kwargs): labels = kwargs.pop("labels", None) kwargs["labels"] = None # 不让原始 forward 计算 loss
outputs = orig_forward(self, *args, **kwargs)
if labels is not None: hidden_states = outputs.logits # 其实是 identity 返回的 hidden states
if hidden_states.dtype == torch.float32: hidden_states = hidden_states.to(torch.bfloat16) lm_weight = self.lm_head.weight if lm_weight.dtype == torch.float32: lm_weight = lm_weight.to(torch.bfloat16)
# 直接从 hidden × weight 计算 CE loss,不分配 logits loss = linear_cross_entropy( hidden_states, lm_weight, labels, ignore_index=-100, shift=True, reduction="mean", )
return CausalLMOutputWithPast( loss=loss, logits=None, # 没有 logits — 省了 ~30GB past_key_values=outputs.past_key_values, hidden_states=outputs.hidden_states, attentions=outputs.attentions, ) return outputs
lm_model.forward = types.MethodType(cce_forward, lm_model)原理图:
标准流程: hidden_states → lm_head(Linear) → logits [30GB tensor] → CrossEntropy → loss
CCE 流程: hidden_states → identity (pass through) → linear_cross_entropy(hidden, weight, labels) → loss ↑ 无需分配 logits,直接在 hidden 上算3. 数据集处理
数据集是 ShareGPT 格式(messages 字段,包含 user/assistant 对话)。处理流程:
def prepare_dataset(args, tokenizer, text_tokenizer): dataset = load_dataset(args.dataset, split="train")
# 过滤:必须有 user 和 assistant 角色 def has_user_role(example): msgs = json.loads(example["messages"]) if isinstance(example["messages"], str) else example["messages"] roles = {m["role"] for m in msgs} return "user" in roles and "assistant" in roles
dataset = dataset.filter(has_user_role, num_proc=4)
# 子采样 if args.max_samples > 0 and len(dataset) > args.max_samples: dataset = dataset.shuffle(seed=42).select(range(args.max_samples))
# 用 chat template 格式化 def format_sharegpt(example): messages = json.loads(example["messages"]) if isinstance(example["messages"], str) else example["messages"] text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False) return {"text": text}
dataset = dataset.map(format_sharegpt, num_proc=4, remove_columns=dataset.column_names)
# 手动 tokenize — 绕过 SFTTrainer 的 eos_token 验证 bug def tokenize(examples): all_ids, all_masks = [], [] for text in examples["text"]: enc = text_tokenizer(text, truncation=True, max_length=args.max_seq_length) all_ids.append(enc["input_ids"]) all_masks.append(enc["attention_mask"]) return {"input_ids": all_ids, "attention_mask": all_masks, "labels": all_ids}
dataset = dataset.map(tokenize, batched=True, remove_columns=["text"]) return datasetWARNING这里手动 tokenize 而不是让 SFTTrainer 做,是因为 Unsloth 的 Qwen3.5 tokenizer 有一个 eos_token 验证的 bug,SFTTrainer 会因此报错。手动 tokenize 后把
input_ids同时赋给labels,绕过了这个问题。
4. 训练配置与 DataCollator
def train(args, model, tokenizer, dataset): training_args = SFTConfig( output_dir=args.output_dir, per_device_train_batch_size=args.batch_size, # 2 gradient_accumulation_steps=args.grad_accum, # 4 warmup_ratio=0.03, num_train_epochs=args.epochs, # 1 learning_rate=args.lr, # 2e-4 lr_scheduler_type="cosine", bf16=True, logging_steps=1, save_steps=200, save_total_limit=3, optim="adamw_torch", weight_decay=0.01, max_seq_length=args.max_seq_length, # 32768 packing=False, report_to="wandb", dataloader_num_workers=2, )
# 关键:batch_size > 1 时必须有 DataCollator 来 padding 变长序列 text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer) data_collator = DataCollatorForSeq2Seq( tokenizer=text_tokenizer, padding=True, pad_to_multiple_of=8, )
trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset, data_collator=data_collator, args=training_args, ) trainer.train()踩坑记录
第一坑:batch-size=1,GPU 只跑了一半
第一次跑用的 batch-size=1 + 100 个样本测试,WandB 的 System 监控显示:
- GPU Utilization:40-80% 之间波动
- GPU Memory Allocated:~60%
- GPU Power Usage:200-600W 之间震荡(满载应该稳定在 700W 附近)
原因很简单:batch 太小,每个 micro-step 算得快但 gradient accumulation 之间有空闲。而且 LoRA 只更新 5% 的参数,计算密度本身就低于全量训练。
改成 batch-size=2 后立即改善:
| 指标 | batch-size=1 | batch-size=2 |
|---|---|---|
| GPU Utilization | 40-80% | 85-100% |
| GPU Memory | ~60% | ~84% |
| GPU Power | 200-600W | 400-700W |
第二坑:batch-size > 1 导致 DataLoader 崩溃
改成 batch-size=2 后,训练在第一个 step 直接报错:
ValueError: Unable to create tensor, you should probably activate truncationand/or padding with 'padding=True' 'truncation=True' to have batched tensorswith the same length. Perhaps your features (`labels` in this case) haveexcessive nesting (inputs type `list` where type `int` is expected).完整 traceback 指向 transformers/data/data_collator.py:
File "transformers/tokenization_utils_base.py", line 731, in convert_to_tensors tensor = as_tensor(value)ValueError: expected sequence of length 7495 at dim 1 (got 29105)原因:batch-size=1 时每个 batch 只有一个样本,不需要 padding。但 batch-size=2 时,两个样本的 token 长度不同(比如 7495 和 29105),default collator 试图直接把它们拼成 tensor,当然失败了。
修复:加一个 DataCollatorForSeq2Seq,它会自动把同一 batch 内的序列 padding 到最长的那个:
from transformers import DataCollatorForSeq2Seq
data_collator = DataCollatorForSeq2Seq( tokenizer=text_tokenizer, padding=True, pad_to_multiple_of=8, # 对齐到 8 的倍数,有利于 GPU 计算效率)第三坑:250GB 磁盘空间不够
训练完成后要做量化,流程是:LoRA adapter → merge 成完整模型 → 转 GGUF → 量化。merge 那一步需要把完整的 35B 模型写到磁盘上(~70GB bf16 safetensors),但磁盘已经满了:
$ df -h /workspaceFilesystem Size Used Avail Use% Mounted onoverlay 250G 250G 56K 100% /
$ du -sh /workspace/training/*/35G qwen35-35b-novel-lora/ # LoRA adapter + checkpoints43G qwen35-35b-novel-merged/ # 不完整的 merge(写到一半空间满了)
$ du -sh /root/.cache/huggingface/163G /root/.cache/huggingface/ # 基础模型缓存清理 checkpoints 只腾出 64GB,还是不够 merge 的 70GB。
解决方案:用 /dev/shm(容器自带的 tmpfs 内存盘)。这台机器有 2TB 内存,/dev/shm 有 251GB 可用:
$ df -h /dev/shmFilesystem Size Used Avail Use% Mounted onshm 251G 0 251G 0% /dev/shmWARNING最初试过
mount -t tmpfs挂载自定义 ramdisk,但容器内没有 mount 权限:mount: /mnt/ramdisk: permission denied.
/dev/shm是默认可用的,不需要额外权限。
第四坑:Unsloth API 参数陷阱
做 inference 时加载模型:
# ❌ 报错:Can only load in 4bit or 8bit or 16bit, not a combination!FastModel.from_pretrained(model_name=path, load_in_16bit=True)
# ✅ 必须同时显式禁用 4bit(Unsloth 默认 load_in_4bit=True)FastModel.from_pretrained(model_name=path, load_in_4bit=False, load_in_16bit=True)查看 Unsloth 的函数签名才发现,load_in_4bit 默认是 True:
def from_pretrained( model_name='...', max_seq_length=2048, dtype=None, load_in_4bit=True, # ← 默认开启 4bit load_in_8bit=False, load_in_16bit=False, # ← 想用 16bit 必须同时关 4bit ...)另一个坑:apply_chat_template 在 Qwen3.5 的 tokenizer(实际上是 Processor)上返回的是 BatchEncoding 而不是 raw tensor,直接调用 .shape 会报 AttributeError:
# ❌inputs = tokenizer.apply_chat_template(messages, return_tensors="pt").to(device)log.info("Input tokens: %d", inputs.shape[-1]) # AttributeError
# ✅encoded = tokenizer.apply_chat_template(messages, return_tensors="pt")if hasattr(encoded, "input_ids"): input_ids = encoded["input_ids"].to(model.device)else: input_ids = encoded.to(model.device)训练日志
训练指标总览

四张图分别是:
- Training Loss:从 22.8 快速下降到 ~20,最后一个 step 降到 10.2(最后一个 batch 可能样本数不足)
- Learning Rate:cosine schedule,warmup 后从 2e-4 逐步衰减到 0
- GPU Utilization:两张卡大部分时间在 80-100%,偶尔掉到 0% 是 gradient accumulation 之间的同步间隙
- GPU Memory Allocated:稳定在 ~85%,说明 batch-size=2 + 32K seq length 是 H200 的甜点
详细训练 log
完整的训练 log(1000 samples, 63 steps, 1 epoch):
\\ /| Num examples = 1,000 | Num Epochs = 1 | Total steps = 63O^O/ \_/ \ Batch size per device = 2 | Gradient accumulation steps = 4\ / Data Parallel GPUs = 2 | Total batch size (2 x 4 x 2) = 16 "-____-" Trainable parameters = 931,102,720 of 18,484,693,688 (5.04% trained)Loss 曲线:
{'loss': '22.81', 'grad_norm': '5.311', 'learning_rate': '0', 'epoch': '0.016'}{'loss': '23.58', 'grad_norm': '5.302', 'learning_rate': '0.0001', 'epoch': '0.032'}{'loss': '21.62', 'grad_norm': '2.429', 'learning_rate': '0.0002', 'epoch': '0.048'}{'loss': '20.47', 'grad_norm': '1.292', 'learning_rate': '0.0002', 'epoch': '0.064'}{'loss': '20.62', 'grad_norm': '0.474', 'learning_rate': '0.0002', 'epoch': '0.08'}{'loss': '20.14', 'grad_norm': '0.318', 'learning_rate': '0.0002', 'epoch': '0.096'}{'loss': '21.06', 'grad_norm': '0.226', 'learning_rate': '0.0002', 'epoch': '0.112'}{'loss': '19.44', 'grad_norm': '0.233', 'learning_rate': '0.0002', 'epoch': '0.128'}...{'loss': '19.14', 'grad_norm': '0.145', 'learning_rate': '0.0001', 'epoch': '0.528'}...{'loss': '19.26', 'grad_norm': '0.141', 'learning_rate': '2.8e-05', 'epoch': '0.784'}...{'loss': '20.94', 'grad_norm': '4.931', 'learning_rate': '6.4e-06', 'epoch': '0.912'}{'loss': '20.06', 'grad_norm': '0.151', 'learning_rate': '4.7e-06', 'epoch': '0.928'}{'loss': '19.82', 'grad_norm': '0.162', 'learning_rate': '3.3e-06', 'epoch': '0.944'}几个观察:
- Loss 从 22.81 快速下降到 ~20,然后在 19-21 之间波动稳定
- grad_norm 在 step 1 之后迅速从 5.3 降到 0.1-0.2,说明模型很快适应了数据分布
- step 59 有一个 grad_norm 异常飙升到 4.93(可能碰到了一个特别长或特别怪的样本),但 loss 没有跳,说明模型足够稳定
- warmup 阶段(前 2 steps,lr 从 0 升到 2e-4)loss 先升后降,正常现象
- 每 step 约 14 分钟(823-923 秒),总耗时约 14.5 小时
Inference 验证
训练完成后,用两个 prompt 测试模型是否还会过早 EOS。
Prompt 1:都市修仙
System: 你是一位优秀的网络小说作家,擅长根据已有情节续写出精彩的故事章节。请保持人物性格一致、情节连贯,文风自然流畅。 User: 写一个关于都市修仙的故事开头,要求细节丰富,人物对话自然。
模型输出(节选):
林默没有回头,他知道自己现在的速度根本跑不过对方。他在巷子里狂奔,脑子里飞速运转。
刚才那个电话,那个纸条,还有那个神秘的男人……这一切都不是巧合。
他摸了摸口袋,那里除了手机,还有一样东西。是他从老纺织厂宿舍捡到的,一张皱巴巴的符纸。刚才为了扔外卖,他把符纸随手塞进了兜里。此刻,那符纸正隐隐发烫。
“看来,今晚是没法睡了。”
林默嘴角勾起一抹苦笑,脚下发力,朝着巷子深处的围墙跃去。
雨越下越大,冲刷着这座城市的污垢,却洗不掉即将降临的阴霾。
而属于林默的都市修仙之路,就在这雨夜,悄然拉开了帷幕。
Prompt 2:宗门大比
User: 写一个玄幻世界的宗门大比场景,重点描写主角以弱胜强的战斗过程。
模型输出(节选):
“不管是谁,想从我身上拿走什么,都得付出代价。”
他站起身,整理了一下有些破损的衣袍,迈步走向自己的住处。阳光洒在他的身上,拉出一道长长的影子,看起来依旧瘦削,但在那影子深处,却仿佛有一柄利剑,正悄然出鞘。
此时,演武场的高台之上。
赵长老望着林辰消失的方向,手指轻轻敲击着扶手,喃喃自语:“无尘步配合残锋劲……这可不是普通弟子能想到的。看来,我们苍梧山,又出了一条’潜龙’啊。”
他身旁的另一位长老忽然开口:“长老,要不要派人去查查他的来历?”
赵长老摇了摇头,眼中闪过一丝精光:“不必。真正的猎手,从不轻易出手。既然他选择了隐藏,我们就陪他玩玩。不过……若是他真有什么不该有的心思,这苍梧山的规矩,也不是摆设。”
两个输出都没有过早 EOS,生成了完整的长篇内容,人物、对话、情节都很到位。问题解决。
量化与分发
训练完成后,模型自动上传 LoRA adapter 到 HuggingFace。接下来做 GGUF 量化,方便在本地用 llama.cpp 或 Ollama 跑。
编译 llama.cpp
Vast.ai 容器没有预装 llama.cpp,需要从源码编译(带 CUDA 加速):
cd /tmpgit clone --depth 1 https://github.com/ggml-org/llama.cpp.gitcd llama.cppcmake -B build -DGGML_CUDA=ONcmake --build build --config Release -j8量化脚本
完整的 quantize.sh,利用 /dev/shm 内存盘做中间存储:
#!/usr/bin/env bashset -euo pipefail
cd /workspace/training
ADAPTER_DIR="./qwen35-35b-novel-lora"RAMDISK="/dev/shm"MERGED_DIR="${RAMDISK}/qwen35-35b-novel-merged"GGUF_DIR="./qwen35-35b-novel-gguf"LLAMA_CPP="/tmp/llama.cpp"HF_REPO="Steven10429/qwen35-35b-novel-gguf"
# Step 1: Merge LoRA → 完整模型(写到内存盘)python3 -c "import os; os.environ['UNSLOTH_COMPILE_DISABLE'] = '1'from unsloth import FastLanguageModelimport torchmodel, tokenizer = FastLanguageModel.from_pretrained( model_name='${ADAPTER_DIR}', max_seq_length=32768, load_in_4bit=False, dtype=torch.bfloat16,)model.save_pretrained_merged('${MERGED_DIR}', tokenizer, save_method='merged_16bit')"
# Step 2: 转 GGUF bf16(仍在内存盘)python3 "${LLAMA_CPP}/convert_hf_to_gguf.py" \ "${MERGED_DIR}" \ --outfile "${RAMDISK}/qwen35-35b-novel-bf16.gguf" \ --outtype bf16
# 清理 merged safetensors,释放内存盘空间rm -rf "${MERGED_DIR}"
# Step 3 & 4: 量化(输出到磁盘)mkdir -p "${GGUF_DIR}""${LLAMA_CPP}/build/bin/llama-quantize" \ "${RAMDISK}/qwen35-35b-novel-bf16.gguf" \ "${GGUF_DIR}/qwen35-35b-novel-Q8_0.gguf" Q8_0
"${LLAMA_CPP}/build/bin/llama-quantize" \ "${RAMDISK}/qwen35-35b-novel-bf16.gguf" \ "${GGUF_DIR}/qwen35-35b-novel-Q4_K_M.gguf" Q4_K_M
# 清理 bf16 GGUFrm -f "${RAMDISK}/qwen35-35b-novel-bf16.gguf"
# Step 5: 上传 HuggingFacepython3 -c "from huggingface_hub import HfApiapi = HfApi()import osapi.create_repo(repo_id='${HF_REPO}', repo_type='model', private=True, exist_ok=True)for f in sorted(os.listdir('${GGUF_DIR}')): if f.endswith('.gguf'): path = os.path.join('${GGUF_DIR}', f) size_gb = os.path.getsize(path) / 1e9 print(f'Uploading {f} ({size_gb:.1f} GB)...') api.upload_file(path_or_fileobj=path, path_in_repo=f, repo_id='${HF_REPO}', repo_type='model')"数据流:
LoRA adapter (7.5GB, 磁盘) → merge → 完整模型 safetensors (~70GB, /dev/shm 内存盘) → convert_hf_to_gguf → bf16.gguf (~70GB, /dev/shm 内存盘) → llama-quantize → Q8_0.gguf (35GB, 磁盘) → llama-quantize → Q4_K_M.gguf (20GB, 磁盘) → upload → HuggingFace量化结果
$ ls -lh ./qwen35-35b-novel-gguf/*.gguf-rw-r--r-- 1 root root 20G qwen35-35b-novel-Q4_K_M.gguf-rw-r--r-- 1 root root 35G qwen35-35b-novel-Q8_0.gguf量化 log 示例(Q8_0):
[ 74/ 733] blk.3.ffn_up_exps.weight - [2048, 512, 256, 1], type = bf16, converting to q8_0 .. size = 512.00 MiB -> 272.00 MiBMoE 的 expert 权重占了大头,每层 256 个 expert 的 gate/up/down projection 各 512MB(bf16),量化到 Q8_0 后变 272MB。
最终产出
| 产物 | 大小 | HuggingFace |
|---|---|---|
| LoRA adapter | 7.5GB | Steven10429/qwen35-35b-novel-lora |
| GGUF Q8_0 | 35GB | Steven10429/qwen35-35b-novel-gguf |
| GGUF Q4_K_M | 20GB | Steven10429/qwen35-35b-novel-gguf |
经验总结
-
先看 GPU 监控再调参数。batch-size=1 时看起来在跑,但 GPU 只用了一半。WandB 的 System 面板(GPU Utilization、Memory Allocated、Power Usage)是最直观的判断依据。
-
MoE 模型的 LoRA 微调,batch-size=2 是 H200 上的甜点。再大可能 OOM(尤其是遇到两个都接近 32K 的样本时),再小浪费算力。
-
长文本生成任务必须保持大 max_seq_length。如果训练时序列长度太短,模型学不到”继续写下去”的行为模式,会过早 EOS。32768 是这次成功的关键。
-
Vast.ai 的磁盘永远不够用。250GB 听起来很多,但基础模型缓存 + adapter + checkpoints 就占满了。
/dev/shm是救命稻草 — H200 机器通常有 2TB 内存,/dev/shm默认可用且不需要 mount 权限。 -
Cut Cross Entropy 是大模型 SFT 的必备技巧。对于 vocab_size > 100K 的模型,logits tensor 的显存占用是灾难性的。CCE 可以把这部分开销从 30GB 降到接近 0。
-
总花费约 $120。明细如下:
| 阶段 | 花费 | 说明 |
|---|---|---|
| 前期 debug | ~$26 | H100 OOM → 换 H200、pipeline parallelism → FSDP、FSDP OOM → per-rank loading |
| 训练 | ~$68 | 14.5h × $4.71/hr,1000 samples,63 steps |
| 数据转换 + 量化导出 | ~$14 | merge LoRA → GGUF bf16 → Q8_0 + Q4_K_M |
| 其他(小规模测试等) | ~$12 | 100 samples 测试跑、inference 验证 |
Debug 的钱几乎和训练本身一样多 — 这在大模型微调中很常见,做好心理准备。