跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

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

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

01-plan-ledger/train_plan.py4801 字节
#!/usr/bin/env python3
"""训练一个 7B 模型要多少显存、多少算力:一台机器上的「账本」估算。

这个脚本只回答两个在动手之前必须先算清楚的问题:

1. **显存账本**:权重、梯度、优化器状态、以及它们被分片(ZeRO/FSDP)之后,
   每张卡各背多少 GiB。
2. **算力账本**:训练 `D` 个 token 需要多少 FLOPs,在给定有效吞吐下要多少
   硬件小时。

它是第 17 章 §2「可行性核算」的可执行版本。所有公式都在正文里推导过,
脚本只做算术,没有任何对本机硬件的测量断言。

运行(纯 Python,不需要 GPU,不需要 torch):

    python3 train_plan.py
    python3 train_plan.py --tokens 10e9 --eff-tflops 120

依据的资料来源见本章 `references.md`:Qwen2.5-7B 的结构参数来自模型卡,
FineWeb-Edu 的 token 规模来自数据集卡,roofline 方法来自第 2 章。
"""

from __future__ import annotations

import argparse
from dataclasses import dataclass


# 一个 bf16 数占 2 字节,一个 fp32 数占 4 字节。
BYTES_BF16 = 2
BYTES_FP32 = 4
# GiB = 1024^3 字节。显存账单用 GiB;厂商标称的「80GB」是 10^9 字节,
# 二者相差约 7%,估算时按更大的那个(GiB)保守对待。
BYTES_PER_GIB = float(1024**3)


@dataclass(frozen=True)
class ModelSpec:
    """一个模型在账本里需要的结构参数。

    只有 `params` 是显存账本真正需要的;其余字段用于读者对照第 5 章
    「结构参数如何决定算子选择」。
    """

    name: str
    params: float          # 总参数量(含 embedding)
    non_embedding: float   # 去掉 embedding 的输出层与词表参数
    layers: int
    hidden: int
    q_heads: int
    kv_heads: int
    intermediate: int
    vocab: int
    context: int


# Qwen2.5-7B 的公开结构参数,逐项来自模型卡(见 references.md R2)。
QWEN25_7B = ModelSpec(
    name="Qwen2.5-7B",
    params=7.61e9,
    non_embedding=6.53e9,
    layers=28,
    hidden=3584,
    q_heads=28,
    kv_heads=4,
    intermediate=18944,
    vocab=152064,
    context=131072,
)


@dataclass(frozen=True)
class TrainState:
    """优化器与精度的选择,决定每个参数要存几份。"""

    weight_dtype_bytes: int = BYTES_BF16   # bf16 混合精度:前向/反向用 bf16 权重
    grad_dtype_bytes: int = BYTES_BF16     # 梯度也存 bf16
    adam_dtype_bytes: int = BYTES_FP32     # Adam 的 m、v 用 fp32
    fp32_master: bool = True               # 是否额外保留 fp32 主权重


def memory_bytes(params: float, state: TrainState) -> dict[str, float]:
    """返回训练态下每个参数项占用的字节数。"""

    weights = params * state.weight_dtype_bytes
    grads = params * state.grad_dtype_bytes
    adam_m = params * state.adam_dtype_bytes
    adam_v = params * state.adam_dtype_bytes
    master = params * BYTES_FP32 if state.fp32_master else 0.0
    total = weights + grads + adam_m + adam_v + master
    return {
        "权重 (bf16)": weights,
        "梯度 (bf16)": grads,
        "Adam m (fp32)": adam_m,
        "Adam v (fp32)": adam_v,
        "fp32 主权重": master,
        "合计": total,
    }


def to_gib(num_bytes: float) -> float:
    return num_bytes / BYTES_PER_GIB


def training_flops(params: float, tokens: float) -> float:
    """C ≈ 6 · N · D。

    推导(正文 §2.3):前向每个参数每个 token 约 2 FLOPs(一次乘一次加),
    反向约为前向的两倍(对权重和输入各算一次梯度),合计每参数每 token
    约 6 FLOPs。这是一个忽略 attention 里序列长度二次项的常用估算,
    仅在「token 数远大于 6·序列长度」时准确——短序列、长上下文时要乘修正系数。
    """

    return 6.0 * params * tokens


def hardware_hours(flops: float, eff_tflops: float, n_accelerators: int) -> float:
    """给定「每张卡的有效吞吐」(TFLOP/s,已含 MFU 折扣),返回卡·小时。"""

    if eff_tflops <= 0 or n_accelerators <= 0:
        raise ValueError("eff_tflops 与 n_accelerators 必须为正")
    per_second = eff_tflops * 1e12 * n_accelerators
    seconds = flops / per_second
    return seconds / 3600.0 * n_accelerators


def print_memory_ledger(spec: ModelSpec, state: TrainState, dp_sizes: tuple[int, ...]) -> float:
    print(f"== 显存账本:{spec.name}({spec.params / 1e9:.2f}B 参数)==")
    ledger = memory_bytes(spec.params, state)
    for name, value in ledger.items():
        print(f"  {name:<16} {to_gib(value):8.2f} GiB")
    total = ledger["合计"]
    shard = "  ".join(
        f"{dp} 卡→{to_gib(total) / dp:6.2f} GiB" for dp in dp_sizes if dp > 0
    )
    if shard:
        print(f"  按 ZeRO-3/FSDP 均摊(优化器/权重/梯度):{shard}")
    print("  (不含激活值、通信缓冲、CUDA context 与显存碎片;见正文 §2.2)")
    print()
    return total


def main() -> None:
    parser = argparse.ArgumentParser(description="7B 训练显存/算力账本")
    parser.add_argument("--tokens", type=float, default=10e9, help="训练 token 数")
    parser.add_argument("--eff-tflops", type=float, default=120.0,
                        help="每张卡的有效吞吐(TFLOP/s,含 MFU 折扣;务必用你机器实测值替换)")
    parser.add_argument("--dp-size", type=int, default=8, help="数据并行的卡数,用于均摊账本")
    args = parser.parse_args()

    state = TrainState()
    dp_sizes = tuple(sorted({1, 8, 16, args.dp_size}))
    print_memory_ledger(QWEN25_7B, state, dp_sizes=dp_sizes)

    print(f"== 算力账本:在 {args.tokens / 1e9:.1f}B token 上训练 ==")
    flops = training_flops(QWEN25_7B.params, args.tokens)
    print(f"  C ≈ 6·N·D = {flops:.3e} FLOPs")
    for cards in (1, 8, 64):
        hours = hardware_hours(flops, args.eff_tflops, cards)
        wall = hours / cards
        print(f"  {cards:>3} 卡 × {args.eff_tflops:>5.0f} TFLOP/s 有效吞吐 → "
              f"{hours:10.1f} 卡·小时,墙钟 {wall:8.2f} 小时")
    print()
    print("提示:`--eff-tflops` 必须替换成你自己机器上实测到的有效吞吐")
    print("(第 12 章讲怎么测),否则上面的时间是假设而不是结论。")


if __name__ == "__main__":
    main()

← 浏览本章全部代码