本节课介绍了如何利用资源,包括内存(GB)和计算(FLOPs),以及模型中的张量,参数,优化器和混合精度训练等内容

Memory accounting(内存成本)

tensors_memory

张量基本都是以浮点数形式存储

float32(FP32,多用于存储优化器状态和参数):32位(4字节),1位符号,8位指数(动态范围),23位尾数(数值精度)

memory_usage = 元素个数 * 元素大小

float16(FP16):16位(2字节),1位符号,5位指数,10位尾数,内存减半,动态范围小

bfloat16(brain,BF16,多用于计算):16位,1位符号,8位指数,7位字节,动态范围和fp32一样,但是精度稍低

float8(FP8):用的比较少,H100显卡支持

根据流水线具体环节决定精度,称为混合精度训练,比如自注意力用FP32保证准确率,涉及矩阵乘法的简单前向传播可以用BF16

对于需要长时间积累的东西,希望的精度越高(FP32),BF16可以看作是更具有临时性的东西,基本上就是取出参数转为BF16然后推进模型运行

Compute accounting(计算成本)

tensors_on_gpus

如果要估计计算和数据移动开销:时刻注意tensor的位置(torch.cuda.is_available())

# 将 CPU 数据移到 GPU 
y = x.to("cuda:0")
assert y.device == torch.device("cuda", 0)

# 在 GPU 上生成数据
z = torch.zeros(32, 32, device="cuda:0")

tensor_operations

tensor_storage

tensor在pytorch中其实就是一些指向已分配内存的指针,假如有一个4 x 4矩阵,在内存中看起来像是一个长数组,张量本身拥有的是元数据(stride[0],stride[1]),元数据指定了如何在数组中找到特定地址

stride[0]:移动到下一行元素,需要跳过的元素,这里是4

stride[1]:移动到下一列元素,需要跳过的元素,这里是1

(行r,列c)计算index = r * stride[0] + c * stride[1]

tensor_slicing

对张量slice、view、transpose,其实只是创建了一个视图,而不是复制,改动视图同时会改变原张量的值

需要注意的一点是,有些视图(view)是连续的,就像只是平滑地遍历存储中的这个数组一样(连续要求各维度步幅从左到右严格递减,比如(3,1)连续,(1,3)非连续,访问同一行的步幅需要为1)),transpose会破坏张量连续性,而要创建视图则张量必须连续

 y = x.transpose(1, 0).contiguous().view(2, 3)

在这种情况下,x和y不具有相同的存储空间,因为使它连续的操作(contiguous)会创建副本(copy)(reshape同样是创建副本)

tensor_matmul

输入张量的前两位一般为 batch(批次)、sequence(序列长度,即token数量),进行乘法时,遍历 x 前 2 维与 y 相乘

x = torch.ones(4, 8, 16, 32)
w = torch.ones(32, 2)
y = x @ w
assert y.size() == torch.Size([4, 8, 16, 2])

tensor_einops(张量操作库)

为什么引入einops(Einstein Operations):传统 pytorch 的维度混乱

我们只需要命名所有的维度,而不是本质上只依赖于索引

x = torch.ones(2, 2, 3)  # batch, sequence, hidden
y = torch.ones(2, 2, 3)
z = x @ y.transpose(-2, -1)  # 容易搞混 -2, -1 是什么

jaxtyping_basics

使用这个库,我们可以为各个维度添加语义标注

# 旧方式
x = torch.ones(2, 2, 1, 3)  # 注释: batch seq heads hidden

# 新方式
x: Float[torch.Tensor, "batch seq heads hidden"] = torch.ones(2, 2, 1, 3)

einops_einsum

利用这个方式我们只需要关注输入和输出,并给出标注,而不关注过程

输出中未命名的维度(如 hidden)会被求和,任何被命名的维度都会被遍历

"..." 表示广播任意数量的维度(在矩阵乘法中不会变化的维度)

x: Float[torch.Tensor, "batch seq1 hidden"] = torch.ones(2, 3, 4)  
y: Float[torch.Tensor, "batch seq2 hidden"] = torch.ones(2, 3, 4)  

# 旧方式
z = x @ y.transpose(-2, -1)  # batch, sequence, sequence  

# 新方式
z = einsum(x, y, "batch seq1 hidden, batch seq2 hidden -> batch seq1 seq2") 

# 另一种写法
z = einsum(x, y, "... seq1 hidden, ... seq2 hidden -> ... seq1 seq2")  

    einops_reduce

    聚合指定的维度(如 hidden),且支持 sum、mean、max、min 等操作

    # 旧方式
    y = x.mean(dim=-1)
    
    # Einops 方式
    y = reduce(x, "... hidden -> ...", "sum")

    einops_rearrange

    拆分某个展平的维度为多个维度,应用于多头注意力等场景

    # 这里的 8 维向量是一个 flattened representation, 表示为 total_hidden = heads * hidden1
    x: Float[torch.Tensor, "batch seq total_hidden"] = torch.ones(2, 3, 8)
    
    # 拆分维度
    x = rearrange(x, "... (heads hidden1) -> ... heads hidden1", heads=2) # (2, 3, 2, 4)
    
    # 变换后合并
    x = rearrange(x, "... heads hidden2 -> ... (heads hidden2)")

    tensor_operations_flops

    张量操作的计算成本

    FLOPs:浮点操作次数(衡量完成的计算量),矩阵乘法在计算中占主导地位

    FLOP/s:每秒浮点运算次数(衡量硬件速度),取决于硬件和数据类型

    Linear model 前向传播的矩阵乘法中((B,D)@(D,K)):FLOPs = 2 * B * D * K(包括乘法,加法),B 是数据点数量,D 是输入维度,K 是输出维度,D x K 是这个矩阵的参数数量

    因此前向传播需要的 FLOPs = 2 * (data points) * (parameters)

    MFU(Model FLOPs utilization)模型浮点数运算利用率:实际每秒浮点运算次数 / 承诺的每秒浮点运算次数,通常 MFU >= 0.5 是好的

    gradients_flops

    还有一部分浮点运算次数来自计算梯度

    还是在 Linear model 中,经过反向传播需要的 FLOPs = 4 * (data points) * (parameters)

    所以总的运算量为:6 * (data points) * (parameters)

    Models

    Parameter initialization

    输入 -> 输出之间的数值增长的量级本质上与隐藏维度(hidden)的平方根成正比

    当模型很大时,将会爆炸(数值发散),并且训练方差不稳定,所以需要一种方式进行初始化,这种方式对隐藏层具有“不变性”

    Xavier 初始化:让输出值在某个值附近保持稳定(正态分布)

    # 除以 sqrt(input_dim) 进行缩放
    w = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))
    
    # 现在 output 的量级是常数
    output = x @ w  

    但是正态分布的尾部是无界的,对它进行截断到 [-3,-3] 的范围,这样就不会得到异常大的值

    custom_model

    这部分设计了一个简单的线性模型,一共三层参数(D * D,D * D,D),最后的 D 为 head / final layer,模型参数为 D^2 + D,并记得将模型移动 GPU 上

    然后生成了一些随机数据输入模型,经过前向传播就是依次通过各层,最后应用头部层

    note_about_randomness

    设定随机种子(seed)是复现模型训练的必要手段,比如有时候需要重现某个 bug

    训练模型中的很多地方都具有随机性(参数初始化,随机失活(dropout),数据排序)

    建议总是传递一个固定的随机种子,可以尽可能重现模型

    # Torch
    seed = 0
    torch.manual_seed(seed)
    
    # NumPy
    import numpy as np
    np.random.seed(seed)
    
    # Python
    import random
    random.seed(seed)

    data_loading

    文本经过分词器得到的数据是一个整数序列,转化为 numpy 数值会很方便

    我们一般不希望一次性加载全部数据到内存中,使用 memmap 函数可以假装加载,本质上提供了一个映射到文件的变量,它会按需加载文件

    orig_data = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=np.int32)
    
    # 将 NumPy 数组保存为二进制文件
    orig_data.tofile("data.npy")
    
    # 使用 memmap 函数假装加载
    data = np.memmap("data.npy", dtype=np.int32)
    
    assert np.array_equal(data, orig_data)

    还可以利用数据加载器 DataLoader,从每个 batch 中获取数据

    B = 2  # Batch size(批次大小)
    L = 4  # Sequence length(序列长度)
    
    x = get_batch(data, batch_size=B, sequence_length=L, device=get_device())

    Optimizer(优化器)

    先定义几个通用符号

    • \theta_t:第 t 步的模型参数(想要优化的对象)
    • g_t:第 t 步的梯度(gradient)
    • \eta:学习率(Learning Rate)
    • \varepsilon:防止分母为 0 而加的一个极小值(通常为 1e-8)

    SGD

    梯度下降算法:直接沿着梯度反方向走

    特点:没有记忆,每次更新独立(无状态)

    • \theta_{t+1} = \theta_t - \eta \cdot g_t
    class SGD(torch.optim.Optimizer):
        def __init__(self, params, lr=0.01):
            super(SGD, self).__init__(params, dict(lr=lr))
        
        def step(self):
            for group in self.param_groups:
                lr = group["lr"]
                for p in group["params"]:
                    grad = p.grad.data
                    p.data -= lr * grad  # θ = θ - η·∇L
    

    Momentum

    动量法:SGD + 梯度的指数加权平均

    \beta 是动量系数(通常取 0.9),m_t 不仅取决于当前梯度 g_t,还保留了 90% 的上一步的方向 m_{t-1}

    • m_t = \beta m_{t-1} + (1 - \beta) g_t
    • \theta_{t+1} = \theta_t - \eta \cdot m_t

    AdaGrad

    引入了自适应学习率:SGD + 累积历史梯度的平方和

    这个方法有个问题:分母会随时间不断变大,最后趋近 0(后期“学不动”)

    特点:有记忆,记住所有历史梯度的平方和,调整学习率(有状态)

    • v_t = v_{t-1} + g_t^2
    • \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{v_t + \epsilon}} \cdot g_t
    def step(self):
        for p in params:
            state = self.state[p]  # 🔑 这就是"状态"
            grad = p.grad.data
            
            # 从状态中取出历史信息
            g2 = state.get("g2", torch.zeros_like(grad))
            
            # 更新状态(累积梯度平方)
            g2 += grad ** 2
            state["g2"] = g2  # 保存回状态
            
            # 使用状态调整学习率
            p.data -= lr * grad / sqrt(g2 + 1e-5)

    模型训练中什么是状态?g2 一直在累积,这就是状态

    grad = 3.0
    g2 = 0 + 3^2 = 9
    w = 2.0 - 0.1 * 3.0 / sqrt(9) = 2.0 - 0.1 = 1.9
    
    状态:{"g2": 9}
    
    grad = 4.0
    g2 = 9 + 4^2 = 25  # 累积之前的 9
    w = 1.9 - 0.1 * 4.0 / sqrt(25) = 1.9 - 0.08 = 1.82
    
    状态:{"g2": 25}

    RMSProp

    解决了 AdaGrad 分母无限变大的问题:AdaGrad 梯度的平方和从"直接累加" -> "指数加权平均"

    这里的 \beta 是衰减率(通常取 0.99),使得很久以前的梯度平方会被忘记,v_t 不会一直变大

    • v_t = \beta v_{t-1} + (1 - \beta) g_t^2
    • \theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{v_t + \epsilon}} \cdot g_t

    Adam

    Adam = RMSProp + Momentum

    步骤 1:指数加权

    • m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t
    • v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t

    步骤 2:偏差修正

    • \hat{m}_t = \frac{m_t}{1 - \beta_1^t}
    • \hat{v}_t = \frac{v_t}{1 - \beta_2^t}

    步骤 3:更新参数(分母提供了动量方向,分子提供了自适应步长)

    • \theta_{t+1} = \theta_t - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon}

    不同优化器的状态对比

    优化器状态内容每个参数的状态大小
    SGD0
    Momentumv(动量)1× 参数大小
    AdaGradg2(梯度平方和)1× 参数大小
    RMSPropv(梯度平方指数平均)1× 参数大小
    Adamm(一阶动量)+ v(二阶动量)2× 参数大小

    Memory

    内存构成:

    • 参数:模型权重本身
    # 模型权重本身
    W1 = torch.randn(4, 4)  # 16 个数
    W2 = torch.randn(4, 1)  # 4 个数
    # 总共:20 个数
    • 激活值:神经网络每一层的输出,反向传播要用(batch_size × 维度 × 层数)
    • 对每一层、每一个数据点、每一个维度,都必须存储激活值
    • 但其实不需要存储所有激活值,可以重新计算它们,这种技术为激活检查点(activation checkpointing)
    # 输入
    x = torch.randn(2, 4)  # (batch=2, dim=4)
    
    # 第 1 层
    W1 = torch.randn(4, 4)
    h1 = x @ W1            # (2, 4) ← 保存!
    
    # 第 2 层
    W2 = torch.randn(4, 1)
    h2 = h1 @ W2           # (2, 1) ← 保存!
    
    # 计算损失
    loss = (h2 - target).pow(2).mean()
    
    # 计算 W2 的梯度
    dL/dW2 = h1.T @ (dL/dh2)  # 🔑 需要 h1!
    
    # 计算 W1 的梯度
    dL/dW1 = x.T @ (dL/dh1)   # 🔑 需要 x!
    • 梯度:每个参数的导数
    # 每个参数的梯度
    W1.grad = torch.randn(4, 4)  # 16 个数
    W2.grad = torch.randn(4, 1)  # 4 个数
    # 总共:20 个数(与参数量相同)
    • 优化器状态:历史梯度信息(SGD 无,Adam 2倍参数量)
    state = {
        W1: {
            "m": torch.zeros(4, 4),  # 一阶动量:16 个数
            "v": torch.zeros(4, 4)   # 二阶动量:16 个数
        },
        W2: {
            "m": torch.zeros(4, 1),  # 4 个数
            "v": torch.zeros(4, 1)   # 4 个数
        }
    }
    # 总共:40 个数(参数量的 2 倍)

    train_loop

    模型训练的基本步骤:

    def train(name: str, get_batch,
              D: int, num_layers: int,
              B: int, num_train_steps: int, lr: float):
        
        # 模型和优化器
        model = Cruncher(dim=D, num_layers=0).to(get_device())
        optimizer = SGD(model.parameters(), lr=0.01)
    
        for t in range(num_train_steps):
            # 获取数据
            x, y = get_batch(B=B)
    
            # 前向传播(计算 loss)
            pred_y = model(x)
            loss = F.mse_loss(pred_y, y)
    
            # 反向传播(计算梯度)
            loss.backward()
    
            # 更新参数,梯度清零
            optimizer.step()
            optimizer.zero_grad(set_to_none=True)

    checkpointing

    检查点:训练模型时间很长可能会遇到程序崩溃,所以如果不希望丢失训练进度,需要定期将模型(及优化器)保存到磁盘,之后就可以直接加载它们了

    def checkpointing():
    
        model = Cruncher(dim=64, num_layers=3).to(get_device())
        optimizer = AdaGrad(model.parameters(), lr=0.01)
    
        # 保存检查点
        checkpoint = {
            "model": model.state_dict(),
            "optimizer": optimizer.state_dict(),
        }
        torch.save(checkpoint, "model_checkpoint.pt")
    
        # 加载检查点
        loaded_checkpoint = torch.load("model_checkpoint.pt")

    mixed_precision_training

    训练中的混合精度:默认推荐使用 FP32,可以在前向传播用低精度如 BF16 / FP8

    pytorch 中提供了一些工具可以自动进行混合精度训练 AMP(automatic mixed precision)

    link("https://pytorch.org/docs/stable/amp.html")
    link("https://docs.nvidia.com/deeplearning/performance/mixed-precision-training/")

    虽然训练时需要高精度,但是到了推理阶段就可以大胆尝试了

    模型量化:降低神经网络模型中的数值精度,来减小模型体积、提升推理速度

    更多推荐