跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 17 章 端到端 I:用 HuggingFace 资产从零训练一个 7B 模型

/code/17-e2e-training-7b/

06-train-loop/train_step.py7037 字节
"""一个「没有魔法」的预训练循环:数据 → 模型 → 混合精度 → 梯度累积 → 保存/恢复。

它是第 17 章 §6 的落地版,刻意写成一个文件,方便逐行对照 nanoGPT / litgpt /
torchtitan 的结构(见 references.md R4–R6)。训练循环本身**与 TileLang 无关**:
TileLang 的职责只是把其中某几个算子换掉(§7),其余部分照旧。

三种跑法,从「本机 CPU 就能验证」到「真 7B」:

1. 冒烟模式(默认,纯 CPU,几秒到几十秒):
       python3 train_step.py --steps 100
   用一份极小的 Qwen2 结构 + 内置语料,验证「一步能跑通」与「loss 在下降」。
2. 小规模真实训练(单卡):
       python3 train_step.py --from-hf Qwen/Qwen2.5-0.5B --dataset roneneldan/TinyStories \
           --tokenizer Qwen/Qwen2.5-0.5B --steps 2000 --bf16 --device cuda
3. 7B 继续训练(多卡,超出单机;见正文 §10 的降级路径)。

验证级别:冒烟模式在写作机器(macOS/CPU)上跑过,属于「CPU 逻辑验证」;
`--from-hf` 与 7B 两条路径只做过静态核对,未运行。
"""

from __future__ import annotations

import argparse
import math
import sys
from pathlib import Path

import torch
from transformers import Qwen2Config, Qwen2ForCausalLM

# 复用本章 02 示例的数据管线(流式读取 + 去重 + packing)。
sys.path.append(str(Path(__file__).resolve().parent.parent / "02-data-pipeline"))
from pack_tokens import (  # noqa: E402
    build_tokenizer,
    dedup,
    hf_streaming_docs,
    offline_docs,
    pack,
    shuffled,
)


def tiny_config() -> Qwen2Config:
    """和 Qwen2.5 同族但小到能在 CPU 上跑的结构,用来验证整条链路。"""

    return Qwen2Config(
        vocab_size=256,
        hidden_size=64,
        intermediate_size=128,
        num_hidden_layers=2,
        num_attention_heads=4,
        num_key_value_heads=2,   # GQA:KV 头少于 Q 头,和 7B 的比例同向
        max_position_embeddings=128,
        tie_word_embeddings=True,
    )


def build_model(args) -> Qwen2ForCausalLM:
    if args.from_hf:
        model = Qwen2ForCausalLM.from_pretrained(args.from_hf, torch_dtype=torch.bfloat16)
    else:
        model = Qwen2ForCausalLM(tiny_config())
    return model.to(args.device)


def iteration_batches(args, encode, eos_id):
    """产出 (input_ids, labels):labels 就是 input_ids 右移一位。"""

    if args.dataset:
        raw = hf_streaming_docs(args.dataset, args.config, args.take)
    else:
        raw = offline_docs(args.docs)
    docs = shuffled(dedup(raw), args.shuffle_buffer, args.seed)
    blocks = pack(encode, docs, eos_id, args.block_size)
    pending: list[list[int]] = []
    for block in blocks:
        pending.append(block)
        if len(pending) == args.micro_batch:
            ids = torch.tensor(pending, dtype=torch.long, device=args.device)
            yield ids, ids.clone()
            pending = []


def estimate_loss(model, batches, iters: int) -> float:
    """在 no_grad 下平均若干批的 loss。

    注意:这里拿到的仍是训练语料(冒烟模式是为了证明接口能用)。真实训练必须留出
    一份**不相交**的验证集,否则这个数只是训练 loss 的另一种写法。
    """
    model.eval()
    total, n = 0.0, 0
    with torch.no_grad():
        for i, (ids, labels) in enumerate(batches):
            if i >= iters:
                break
            logits = model(ids).logits
            loss = torch.nn.functional.cross_entropy(
                logits[:, :-1, :].reshape(-1, logits.shape[-1]),
                labels[:, 1:].reshape(-1),
            )
            total += loss.item()
            n += 1
    model.train()
    return total / max(n, 1)


def save_ckpt(path: Path, model, optimizer, step: int) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    torch.save({"model": model.state_dict(), "optim": optimizer.state_dict(), "step": step}, path)


def main() -> None:
    ap = argparse.ArgumentParser(description="最小可读的预训练循环")
    ap.add_argument("--from-hf", default=None, help="从 HF 权重初始化(继续训练路线)")
    ap.add_argument("--tokenizer", default=None, help="HF tokenizer;留空用字节 fallback")
    ap.add_argument("--dataset", default=None, help="HF 数据集;留空用内置语料")
    ap.add_argument("--config", default=None)
    ap.add_argument("--take", type=int, default=2000)
    ap.add_argument("--docs", type=int, default=4000)
    ap.add_argument("--block-size", type=int, default=64)
    ap.add_argument("--micro-batch", type=int, default=4)
    ap.add_argument("--grad-accum", type=int, default=2, help="梯度累积步数")
    ap.add_argument("--steps", type=int, default=100)
    ap.add_argument("--lr", type=float, default=3e-4)
    ap.add_argument("--shuffle-buffer", type=int, default=64)
    ap.add_argument("--seed", type=int, default=1234)
    ap.add_argument("--device", default="cpu")
    ap.add_argument("--bf16", action="store_true", help="混合精度(需要支持的设备)")
    ap.add_argument("--ckpt", default="", help="checkpoint 路径,用于保存与恢复")
    ap.add_argument("--eval-every", type=int, default=50)
    args = ap.parse_args()

    torch.manual_seed(args.seed)
    model = build_model(args)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"模型参数量:{n_params / 1e6:.2f}M  device={args.device} bf16={args.bf16}")

    encode, eos_id = build_tokenizer(args.tokenizer)
    optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95),
                                  weight_decay=0.1)

    start_step = 0
    ckpt = Path(args.ckpt) if args.ckpt else None
    if ckpt is not None and ckpt.exists():
        state = torch.load(ckpt, map_location=args.device)
        model.load_state_dict(state["model"])
        optimizer.load_state_dict(state["optim"])
        start_step = int(state["step"])
        print(f"从 checkpoint 恢复:{ckpt} @ step {start_step}")

    use_amp = args.bf16 and args.device != "cpu"
    amp_ctx = torch.autocast(device_type=args.device, dtype=torch.bfloat16) if use_amp else None
    scaler = None  # bf16 与 fp32 都不需要 GradScaler;只有 fp16 才需要

    model.train()
    optimizer.zero_grad(set_to_none=True)
    batch_iter = iteration_batches(args, encode, eos_id)
    losses: list[float] = []

    for step in range(start_step, args.steps):
        accum_loss = 0.0
        for _ in range(args.grad_accum):
            ids, labels = next(batch_iter)
            with (amp_ctx if amp_ctx is not None else torch.enable_grad()):
                logits = model(ids).logits
                loss = torch.nn.functional.cross_entropy(
                    logits[:, :-1, :].reshape(-1, logits.shape[-1]),
                    labels[:, 1:].reshape(-1),
                )
            (loss / args.grad_accum).backward()
            accum_loss += loss.item() / args.grad_accum

        # 梯度裁剪放在 step 之前,且在所有累积梯度都凑齐之后。
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
        losses.append(accum_loss)

        if (step + 1) % 10 == 0 or step == start_step:
            print(f"step {step + 1:5d}  loss {accum_loss:.4f}  "
                  f"ppl {math.exp(min(accum_loss, 20)):.2f}")

    if len(losses) >= 20:
        first = sum(losses[:10]) / 10
        last = sum(losses[-10:]) / 10
        print(f"前 10 步平均 loss {first:.4f} → 后 10 步平均 loss {last:.4f} "
              f"({'下降' if last < first else '未下降'})")

    # 评测接口演示:换一个 seed / 数据源即得到验证集口径(真实训练务必用留出数据)。
    eval_loss = estimate_loss(model, iteration_batches(args, encode, eos_id), iters=8)
    print(f"eval loss(演示口径){eval_loss:.4f}")

    if ckpt is not None:
        save_ckpt(ckpt, model, optimizer, args.steps)
        print(f"已保存 checkpoint:{ckpt}")


if __name__ == "__main__":
    main()

← 浏览本章全部代码