Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

计算硬件与分布式训练

工坊助手终于能跑起来了。你把模型换大一点,希望它少犯些错,程序却在第一步训练时退出:内存不够。把批次减小以后能跑了,一轮又要等很久。再加一张显卡,会不会立刻快一倍?

这几个问题看起来都和“机器不够强”有关,实际原因却不一样。模型可能装不下,也可能一直在等数据搬过来,还可能花了不少时间和另一台设备交换结果。

这一章沿着一次训练往里看:数字存在哪里,计算怎样分配,哪些东西可以少存,哪些工作可以分给别人。我们会运行两个真实的 CPU 进程,共同完成一次参数更新;你不需要先买 GPU,才能理解分布式训练最关键的正确性问题。

1. 存得下、搬得快、算得快,是三件事

CPU 擅长组织程序、处理分支和完成各种通用工作。GPU 则适合让大量计算单元同时处理相似的运算。矩阵乘法正好有很多规则重复的乘加,因此神经网络常能从 GPU 获益。不过,一个很小的矩阵也可能还没算多久,就已经把时间花在任务启动和数据传输上了。

先分清三个量:

你想知道什么常见量在训练中意味着什么
能装多少东西内存或显存容量,单位 byte权重、梯度、中间结果能否同时放下
每秒能搬多少东西带宽,单位 byte/s计算单元能多快拿到所需数字
每秒能做多少运算算力,单位 FLOP/s在指定精度和运算条件下的计算速率

FLOP 表示一次浮点运算,FLOP/s 才是速率。第二十四章算过模型需要多少次矩阵运算;那相当于知道要做多少工作,还没有回答机器每秒能完成多少。

许多 GPU 有自己的显存。数据从主机内存传到显存、从显存读到计算单元附近,走的是不同通路;多块 GPU 之间又有设备互连。某些设备采用统一内存,也仍然受到容量、带宽和访问方式的限制。不能因为它们都叫“传数据”,就把这些带宽当成同一个数。

一条很有用的性能上限

假设一次操作需要做 FF 次浮点运算,并通过我们正在分析的那条内存通路搬运 MM 字节。设备的计算上限是 CC FLOP/s,带宽上限是 BB byte/s,那么耗时至少要满足:

t≥max⁡(FC,MB) t\geq\max\left(\frac{F}{C},\frac{M}{B}\right)

即使搬运和计算能完美重叠,也不能比其中较慢的一项更快。真实程序还会有调度、同步和其他开销,所以这只是一个乐观下限。

把 F/MF/M 记作计算强度 II,单位是 FLOP/byte。它描述“平均每搬来一个字节,做了多少计算”。于是性能上限可以写成:

Ft≤min⁡(C,BI) \frac{F}{t}\leq\min(C,BI)

这就是 Roofline 模型的基本想法:算力给出一条水平的屋顶,带宽给出另一条随数据复用程度上升的斜线。这里的 MM 应按所选内存层级的实际流量理解,不能直接拿模型文件大小替代。原始技术报告介绍了这一分析方法。

图 1:这是一台假想设备的上限图,使用十进制的 T,即 101210^{12},不是硬件跑分。横、纵轴都采用对数刻度。

如果 I=10I=10 FLOP/byte,带宽只能支持 20 TFLOP/s。把计算上限从 100 提高到 200,当前操作仍然拿不到更多数据。反过来,若 I=100I=100,带宽可以支持 200 TFLOP/s,此时 100 的计算上限才是主要限制。

这也解释了为什么批处理和数据复用重要:同一组权重搬来一次,若能服务更多输入,就可能提高计算强度。但批次变大也会多占内存,还可能让排队的人等得更久。下一章会继续算这笔账。

2. 显存里不只装着模型权重

假设一个模型有十亿个参数,每个参数用 2 字节保存,那么权重本身约占 2 GB。看到这里,先别急着认为“一张略大于 2 GB 的卡就能训练它”。

训练还要存梯度、优化器状态,以及反向传播需要的中间结果。以参数、梯度都用 FP32,Adam 的两份状态也用 FP32 为例,每个参数对应的这些张量共需:

4+4+4+4=16 byte 4+4+4+4=16\ \text{byte}

十亿参数就对应约 16 GB。这个例子尚未包含激活、注意力中间张量、临时工作区、通信缓冲区和框架分配器保留的空间。换成混合精度或其他优化器,账目也会改变;有些方案还会保留额外的高精度权重副本。

我们在第二十七章统计过 LoRA 的状态张量。那是明确对象的字节数,并不等于整个进程的峰值内存。实际排查时也应保留这个区别:

  • 张量大小回答“这些数字本身有多大”。
  • 分配器统计回答“框架当前分配或保留了多少设备内存”。
  • 进程内存和设备监控还可能包含别的运行开销。

若权重和优化器状态已占满空间,减小批次帮助有限;若主要是激活太大,减小微批次或少保存中间结果,往往更直接。

3. 混合精度:把有限的位数用在合适的地方

想象你在纸上计算,只允许写有限位数。很小的数可能被舍入成零,很大的数可能根本写不下。浮点格式也有类似取舍。

格式每个数的字节数指数位尾数的小数位
FP324823
FP162510
BF16287

三者还各有一位符号位。指数影响可表示的数量级,尾数影响同一数量级里能分得多细。BF16 与 FP32 有相同数量的指数位,但有效精度低得多;“都能存很大或很小的数”不等于“存得同样准确”。

本章代码把三个 FP32 数转换后再显示:

原来的数转为 FP16 后转为 BF16 后
1.001约 1.000981
70000inf70144
10−810^{-8}0约 1.00117×10−81.00117\times10^{-8}

所以,降低精度并不是无损压缩。它可能减少存储和搬运,也可能让硬件使用更快的计算路径,但要重新检查数值稳定性和训练结果。

混合精度让不同运算使用合适的精度,而不是把整个模型简单地转换成半精度。PyTorch 的 autocast 会根据设备和运算选择计算类型。本例在 CPU 上使用 BF16 自动混合精度:线性层输出是 BF16,原有参数和累积梯度仍是 FP32,损失在 FP32 中计算,反向传播放在 autocast 上下文之外。AMP 官方文档说明了这些接口的使用方式。

FP16 训练常配合梯度缩放:先放大损失,让过小的梯度不那么容易下溢,再在更新前把梯度缩回去;遇到非有限值时,缩放器可以跳过更新并调整比例。它不能修复所有溢出或训练问题。BF16 的范围更宽,常见用法不需要这一步,但仍要检查损失和梯度是否正常。

本章的小实验验证数据类型与有限梯度,没有据此宣布 BF16 在你的 CPU 上一定更快。

4. 分几次算,与少存一点,是两种办法

梯度累积:等收齐几份意见,再更新一次

第二十三章已经实现过梯度累积:把一大批样本拆成几份,每份分别前向、反向,梯度先累加起来,最后只更新一次参数。

若每个进程每次处理 bb 条样本,累积 AA 次,一共有 RR 个数据并行进程,而且每份样本数相同,那么一次更新对应:

Bglobal=bAR B_{\text{global}}=bAR

例如每次 8 条、累积 4 次、2 个进程,就是 64 条。这里说的是一次更新使用的样本数量,不能由此推出它一定与任意模型的一次整批计算完全相同。依赖批次统计的层、随机过程,以及不恰当的损失归一化都可能改变结果。

尤其是语言模型:每条文本的有效词元数可能不同。你想优化“所有有效词元的平均损失”,就要按有效词元总数加权,而不是平均几个长度不同的局部均值。优化器更新、梯度裁剪和学习率调度,也要围绕约定的一次完整更新安排。

累积减少了一次前向所需的激活空间,没有减少完整模型的权重和优化器状态,也没有凭空省掉这些样本需要的运算。

激活检查点:先记入口,回头再重算

反向传播需要前向过程中的一些中间结果。最直接的办法是全部保存;激活检查点则只保留部分边界,在反向需要时重新计算相应片段。这个“检查点”是计算图里的重算边界,与第二十三章保存到磁盘的训练恢复文件用途不同。

本例把四个小残差模块串起来。每个模块先把宽度从 64 扩到 256,经过 GELU,再缩回 64。我们比较正常保存和逐块使用检查点:

图 2:同一组输入和参数,双精度 CPU 实测。上图统计前向为反向保存的非参数底层存储,按共享存储去重;下图统计进入模块前向函数的次数。

输出、输入梯度和参数梯度在设定误差内一致,保存的非参数存储从 303104 字节降到 40960 字节,模块前向进入次数则从 4 增到 8。省下空间的代价,是反向时又走进了这些模块。

这里没有测进程峰值内存,也没有测 GPU 显存。次数增加一倍更不表示总运行时间恰好翻倍:非重入检查点可以在所需中间量重建完毕后提前停止,函数“进入一次”不一定意味着整段都重新执行了一遍。

实现显式使用 use_reentrant=False。重算必须与原计算相符;若模块依赖会变化的外部状态,或在重算时使用不同的随机数,就要格外仔细。PyTorch 默认处理部分随机数状态以支持 Dropout 等场景,设备变化等情况仍有边界,见检查点文档。

5. 数据并行:模型复制,数据分工

现在请两位同事一起训练同一个模型。每人保留一份相同参数,分别处理自己的数据,算出梯度,再交换意见,让两边做相同的更新。这就是数据并行的基本过程。

PyTorch 的 DistributedDataParallel,简称 DDP,在反向传播期间同步梯度。各进程从一致的参数出发,使用一致的梯度和优化器规则,更新后便能保持一致。进程编号常叫 rank,进程总数叫 world_size。DDP 的设计说明解释了这一过程。

普通 DDP 在每个进程都保留完整模型,通常也各有梯度和优化器状态。因此,“两张卡各有一半数据”不意味着“一份模型的内存自动被分成两半”。

两个人做了不同数量的题,怎样平均

这次用最熟悉的直线模型,训练数据是:

x=1,2,3,4,5,y=2x+1 x=1,2,3,4,5,\qquad y=2x+1

初始 w=b=0w=b=0,损失是五条数据的平均平方误差,学习率为 0.01。进程 0 处理前两条,进程 1 处理后三条。

如果两边各求一个平均损失,再把两个梯度平均,就会给前两条数据和后三条数据各分一半权重。可我们希望每一条数据同样重要,正确的权重应该是 2/52/5 和 3/53/5。

用 SrS_r 表示第 rr 个进程的损失之和,NN 表示所有进程的样本总数。目标是:

L=1N∑r=1RSr L=\frac{1}{N}\sum_{r=1}^{R}S_r

本例默认 DDP 会平均各进程梯度。因此,每个进程用于反向的局部标量应设成:

L~r=RNSr \widetilde L_r=\frac{R}{N}S_r

平均以后:

1R∑r=1R∇L~r=1N∑r=1R∇Sr=∇L \frac{1}{R}\sum_{r=1}^{R}\nabla\widetilde L_r =\frac{1}{N}\sum_{r=1}^{R}\nabla S_r =\nabla L

这里假定普通同步 DDP、固定进程数,没有改变通信归约行为的自定义钩子。all_reduce 先把局部样本数求和,得到全局 NN;随后 DDP 同步参数梯度。全局均值与局部均值的区别也见官方接口说明。

手算这一步,ww 的梯度是 −50-50,bb 的梯度是 −14-14,所以更新后为 [0.5,0.14][0.5,0.14]。我们的两个真实进程得到同样结果;错误地平均局部均值,则得到约 [0.438333,0.13][0.438333,0.13]。程序能结束、两边参数也能相同,仍然可能优化错了目标。

有梯度累积时,可以用 DDP 的 no_sync 避免每个微批次都通信,把同步留到最后;前向也要处于对应上下文内。这样做时,仍需按完整累积窗口的有效数量归一化。本章的进程实验只执行一次更新,没有把累积和通信重叠的加速效果混进来。

6. 一份模型都放不下时,拆什么

数据并行适合分摊数据计算。如果单个模型已经放不下,还可以拆模型内部的工作。

图 3:三种分工方式可以组合。图中张量并行画的是按列切分的一种情况;不同切法的通信方式并不相同。

张量并行:把一层里的大矩阵拆开

回到第一章的矩阵乘法。把权重按列分为两份:

W=[W1 W2] W=[W_1\ W_2]

那么:

XW=[XW1 XW2] XW=[XW_1\ XW_2]

两台设备可以各算一份,再按列拼接。若改为沿共同维度切分,令 X=[X1 X2]X=[X_1\ X_2],并把 W1,W2W_1,W_2 上下叠成 WW,则:

XW=X1W1+X2W2 XW=X_1W_1+X_2W_2

这次需要把两个局部乘积相加,不能拼接。后续层如何划分,还会影响结果是否必须立刻收齐。Megatron-LM给出了 Transformer 层内并行的一种系统设计。

本章脚本在一个 CPU 进程中核对这两条代数关系。它证明切分后的数值能拼回原乘积,没有测量多设备张量并行的性能。

流水线并行:让不同层接力

还可以把第 1~4 层放在设备一,第 5~8 层放在设备二。前向时传递激活,反向时传回相应梯度。

只送一个微批次进去时,设备二要等设备一完成前半段;设备一做完又可能闲着。连续送入多个微批次后,设备二处理第一份的同时,设备一可以处理第二份。开头填充、结尾排空,以及各段耗时不均,仍会留下空闲时间,常被叫作流水线“气泡”。GPipe讨论了用微批次组织这种训练。

流水线的调度要照顾前向、反向和参数版本。仅仅把模型切成几段,不会自动得到高利用率。本章讲分工原理,没有实现完整的流水线调度器。

还有一种冗余,可以不必每人都保存

模型复制以后,多份优化器状态常常一模一样。ZeRO 按阶段逐步切分优化器状态、梯度和参数;计算需要时,再进行相应通信。可以先把它理解为“共同维护状态的不同部分”,而不是每个人都长期持有完整副本。ZeRO 原论文给出了各阶段的内存与通信分析。

分片会减少某些常驻状态,但运行时还有临时收集、激活和缓冲区,不能简单把峰值内存除以设备数量。分片、张量并行和流水线并行都涉及交换数据;网络速度与模型形状,会影响哪种组合划算。

7. 在普通电脑上跑一次真实分工

沿用本书固定的 Python 3.12 与 PyTorch 2.14.1 环境,在仓库根目录运行:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/hardware_training.py

也可以下载脚本,在已经安装依赖的环境中执行 python hardware_training.py。实验只用 CPU,不下载数据或模型;需要当前 PyTorch 支持 Gloo 通信后端。请保存成文件运行,两个子进程需要重新导入模块。

脚本先启动两个进程,再依次检查激活重算、矩阵切分和混合精度。进程通信发生在本机,不需要多机集群。在 macOS 和 Linux 上,示例默认选择本机回环接口,避免自动选择网卡时连到不合适的网络;如果你已设置 GLOO_SOCKET_IFNAME,则保留该设置。这个选择只适用于本章的单机实验,多机运行需要可互通的网络接口,见 PyTorch 的网卡配置说明。

展开完整实现
"""第 36 章:两个真实 CPU 进程同步梯度,并检查激活重算与数值精度。"""

from copy import deepcopy
from datetime import timedelta
import json
import os
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
import torch
from torch import nn
import torch.distributed as dist
import torch.multiprocessing as mp
from torch.nn.parallel import DistributedDataParallel
from torch.utils.checkpoint import checkpoint


def regression_data():
    x = torch.arange(1, 6, dtype=torch.float64).reshape(-1, 1)
    return x, 2*x+1


def zero_model():
    model = nn.Linear(1, 1).double()
    nn.init.zeros_(model.weight)
    nn.init.zeros_(model.bias)
    return model


def full_batch_update():
    x, y = regression_data()
    model = zero_model()
    optimizer = torch.optim.SGD(model.parameters(), lr=.01)
    nn.functional.mse_loss(model(x), y).backward()
    optimizer.step()
    return torch.cat([p.detach().flatten() for p in model.parameters()])


def worker(rank, rendezvous, output_dir):
    # 本例的两个进程都在同一台电脑;默认走回环接口,保留显式网卡设置。
    if sys.platform in ("darwin", "linux"):
        os.environ.setdefault("GLOO_SOCKET_IFNAME", "lo0" if sys.platform == "darwin" else "lo")
    # Gloo 的启动日志留在各进程日志文件;若进程出错,spawn 仍会抛出异常。
    with open(Path(output_dir)/f"rank-{rank}.log", "w") as log:
        os.dup2(log.fileno(), 1)
        os.dup2(log.fileno(), 2)
        torch.set_num_threads(1)
        dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=2,
                                timeout=timedelta(seconds=30))
        try:
            x, y = regression_data()
            part = slice(0, 2) if rank == 0 else slice(2, 5)
            local_x, local_y = x[part], y[part]
            total = torch.tensor(float(len(local_x)), dtype=torch.float64)
            dist.all_reduce(total, op=dist.ReduceOp.SUM)
            results = []
            for correct in [True, False]:
                model = DistributedDataParallel(zero_model())
                optimizer = torch.optim.SGD(model.parameters(), lr=.01)
                errors = (model(local_x)-local_y).square()
                # 默认 DDP 平均各 rank 的梯度;因此局部和乘 world_size/全局数量。
                loss = errors.sum()*dist.get_world_size()/total if correct else errors.mean()
                loss.backward()
                optimizer.step()
                values = torch.cat([p.detach().flatten() for p in model.parameters()])
                results.append(values.tolist())
            (Path(output_dir)/f"rank-{rank}.json").write_text(json.dumps(results))
        finally:
            dist.destroy_process_group()


def distributed_demo():
    if not dist.is_available() or not dist.is_gloo_available():
        raise RuntimeError("本实验需要启用 Gloo 的 PyTorch,不以本地求平均代替进程通信")
    with TemporaryDirectory() as directory:
        rendezvous = (Path(directory)/"rendezvous").as_uri()
        mp.spawn(worker, args=(rendezvous, directory), nprocs=2, join=True)
        rows = [json.loads((Path(directory)/f"rank-{rank}.json").read_text())
                for rank in range(2)]
    first, second = [torch.tensor(row, dtype=torch.float64) for row in rows]
    assert torch.equal(first, second), "两个进程更新后参数不同"
    reference = full_batch_update()
    assert torch.allclose(first[0], reference, atol=1e-12, rtol=0)
    assert not torch.allclose(first[1], reference, atol=1e-12, rtol=0)
    return reference, first


class CountedBlock(nn.Module):
    def __init__(self, width=64):
        super().__init__()
        self.layers = nn.Sequential(nn.Linear(width, 4*width), nn.GELU(),
                                    nn.Linear(4*width, width))
        self.calls = 0

    def forward(self, x):
        self.calls += 1
        return x+self.layers(x)


def checkpoint_run(model, x, enabled):
    model = deepcopy(model)
    x = x.clone().requires_grad_()
    parameter_storages = {p.untyped_storage().data_ptr() for p in model.parameters()}
    saved = {}
    def pack(tensor):
        storage = tensor.untyped_storage()
        pointer = storage.data_ptr()
        if storage.nbytes() and pointer not in parameter_storages:
            saved[pointer] = storage.nbytes()
        return tensor
    def unpack(tensor):
        return tensor
    # 只观察前向为反向保存的非参数 storage;不把它叫进程峰值或显存峰值。
    with torch.autograd.graph.saved_tensors_hooks(pack, unpack):
        output = x
        for block in model:
            output = checkpoint(block, output, use_reentrant=False) if enabled else block(output)
        loss = output.square().mean()
    saved_bytes = sum(saved.values())
    loss.backward()
    gradients = torch.cat([p.grad.flatten() for p in model.parameters()])
    return dict(bytes=saved_bytes, calls=sum(b.calls for b in model),
                output=output.detach(), input_gradient=x.grad, gradients=gradients)


def checkpoint_demo():
    torch.manual_seed(2026)
    model = nn.ModuleList([CountedBlock() for _ in range(4)]).double()
    x = torch.randn(16, 64, dtype=torch.float64)
    regular = checkpoint_run(model, x, False)
    recomputed = checkpoint_run(model, x, True)
    for field in ["output", "input_gradient", "gradients"]:
        assert torch.allclose(regular[field], recomputed[field], atol=1e-12, rtol=1e-12)
    assert recomputed["bytes"] < regular["bytes"]
    assert recomputed["calls"] > regular["calls"]
    return regular, recomputed


def tensor_parallel_algebra():
    torch.manual_seed(2027)
    x = torch.randn(3, 4, dtype=torch.float64)
    w = torch.randn(4, 6, dtype=torch.float64)
    column_split = torch.cat([x@part for part in w.split(3, dim=1)], dim=1)
    row_split = sum(a@b for a, b in zip(x.split(2, dim=1), w.split(2, dim=0)))
    return (torch.allclose(x@w, column_split, atol=1e-12),
            torch.allclose(x@w, row_split, atol=1e-12))


def precision_demo():
    value = torch.tensor([1.001, 70000., 1e-8], dtype=torch.float32)
    representations = {name: value.to(dtype).float().tolist() for name, dtype in
                       [("FP32", torch.float32), ("FP16", torch.float16),
                        ("BF16", torch.bfloat16)]}
    torch.manual_seed(2028)
    model = nn.Linear(8, 4)
    x = torch.randn(2, 8)
    with torch.autocast(device_type="cpu", dtype=torch.bfloat16):
        output = model(x)
        loss = output.float().square().mean()
    loss.backward()
    assert all(torch.isfinite(p.grad).all() for p in model.parameters())
    return representations, str(output.dtype), str(model.weight.dtype), str(model.weight.grad.dtype)


def main():
    torch.set_num_threads(1)
    reference, distributed = distributed_demo()
    print("两个真实 CPU/Gloo 进程;本地样本数分别为 2、3。")
    print("整批更新 [w, b]:", [round(v, 6) for v in reference.tolist()])
    print("按全局数量加权:", [round(v, 6) for v in distributed[0].tolist()])
    print("错误地平均本地均值:", [round(v, 6) for v in distributed[1].tolist()])
    print("两个进程参数一致;正确加权与整批更新一致。")
    regular, recomputed = checkpoint_demo()
    print("前向保存的非参数 storage 字节:", regular["bytes"], "→", recomputed["bytes"])
    print("模块前向进入次数:", regular["calls"], "→", recomputed["calls"])
    print("检查点前后输出、输入梯度和参数梯度一致。")
    print("列切分拼接、行切分求和均与原乘积一致:", all(tensor_parallel_algebra()))
    values, output, weight, gradient = precision_demo()
    for name, row in values.items():
        print(name, [format(v, ".6g") for v in row])
    print("CPU autocast 输出 / 参数 / 梯度:", output, weight, gradient)


if __name__ == "__main__":
    main()

运行输出:

两个真实 CPU/Gloo 进程;本地样本数分别为 2、3。
整批更新 [w, b]: [0.5, 0.14]
按全局数量加权: [0.5, 0.14]
错误地平均本地均值: [0.438333, 0.13]
两个进程参数一致;正确加权与整批更新一致。
前向保存的非参数 storage 字节: 303104 → 40960
模块前向进入次数: 4 → 8
检查点前后输出、输入梯度和参数梯度一致。
列切分拼接、行切分求和均与原乘积一致: True
FP32 ['1.001', '70000', '1e-08']
FP16 ['1.00098', 'inf', '0']
BF16 ['1', '70144', '1.00117e-08']
CPU autocast 输出 / 参数 / 梯度: torch.bfloat16 torch.float32 torch.float32

这些结果各自回答一个具体问题:全局损失算对了吗,少存激活后梯度还对吗,矩阵切开后怎样合并,降低精度会丢掉什么?它们没有证明两块 GPU 会快一倍,也没有证明这里的训练方法已适合某个真实大模型。

8. 速度不理想时,从哪一段找原因

先用同样输入检查输出、损失和梯度,再讨论速度。若优化前后任务都变了,计时数字就难以解释。

随后把一轮训练拆开观察:数据读取是否让设备等着,前向和反向各花多久,通信是否拖住最后的更新,峰值内存出现在什么位置。性能分析器可以帮助定位,但采集本身也有开销,应另做正常运行计时。

对异步设备,主机函数返回不一定代表设备计算完成。计时需要设备同步或恰当的设备事件;否则量到的可能只是任务提交时间。还要固定形状、精度和批次,预热后重复测量,记录环境。我们这一章的实验证据来自 CPU,不把纸上的 FLOPs 或字节数直接换算成未测过的 GPU 秒数。

若多设备变慢,检查是否每次只做了一点计算就大量通信,或某个进程拿到了更长的文本,让其他进程总在等它。优化的起点是找到时间去了哪里,再决定减少搬运、增加复用,还是调整分工。

9. 自己算一遍,再做一个小改动

  1. 图 1 的假想设备上,计算强度为 5 FLOP/byte 时,性能上限是多少?只把计算上限翻倍,是否改变这个答案?
  2. 十亿参数的模型按本章 FP32 参数、梯度和 Adam 两份状态估算,共多少 GB?为什么这仍不是峰值内存?
  3. 每个数据并行进程一次处理 4 条样本,累积 3 次,共 2 个进程,一次更新对应多少条?若每条文本长度不同,还要统计什么?
  4. 两个进程各有 2 条和 3 条样本,局部平均损失分别为 10、20。全局平均损失是多少?把两个均值直接平均会得到多少?
  5. 本章将局部损失之和乘 R/NR/N。如果通信实现求和而不除以 RR,局部标量应怎样调整?
  6. 检查点让模块进入次数从 4 变为 8,为什么不能据此说训练慢了一倍?统计的 40960 字节又为什么不是进程峰值?
  7. 把矩阵乘法沿共同维度切开后,两个结果应相加还是拼接?用脚本的 tensor_parallel_algebra 改一组形状验证。
  8. 训练在第一步前向就内存不足,普通 DDP 是否一定能解决?分别考虑权重占用和激活占用。
  9. 将 precision_demo 中的 70000 改成 60000,观察 FP16 表示。再把数据类型都改成 FP32,是否就保证训练不会出现 inf?
展开参考答案与实验提示
  1. 带宽给出 2×5=102\times5=10 TFLOP/s,上限是 10。算力从 100 变为 200,仍被带宽限制。
  2. 约 16 GB,采用十进制 GB;还没计入激活、临时工作区、通信与框架开销。
  3. 4×3×2=244\times3\times2=24 条。若目标是每个有效词元等权,还要统计整个更新窗口的有效词元总数,并按这个数量归一化。
  4. (2×10+3×20)/5=16(2\times10+3\times20)/5=16,直接平均得到 15。两种算法给每条样本的权重不同。
  5. 用 Sr/NS_r/N,让各进程梯度求和后成为全局均值梯度。实现改变时,要从目标损失重新推导,不能沿用缩放因子。
  6. 次数没有描述每次实际重算了多少,也没包含原本的反向与更新耗时。字节数只观察前向为反向保存的非参数存储,不覆盖整个进程。
  7. 相加。每个局部乘积只贡献同一个输出位置的部分求和项;按输出列切分的情形才是拼接。
  8. 不一定。普通 DDP 每个进程仍有完整模型。分摊样本可能减少各进程的激活,但若权重或优化器状态已经放不下,需要考虑分片、模型并行等办法。
  9. 60000 在这里可由 FP16 有限表示。FP32 的范围也有限,异常输入、失控的数值或过大的更新仍可能导致非有限值。

下一章大模型推理过程与加速会把视角移到生成:已经训练好的模型,为什么第一个词元与后面的词元,等起来不太一样?