1.Overview

2.Resource Accounting

2.1 Memory

2.1.1 Tensor Concept

张量是常见的数据结构的拓展:例如:标量:零维张量;向量:一维张量;矩阵:二维张量。

在机器学习中,之所以使用张量,是因为:1.硬件加速。硬件对张量有专门的设计,适配GPU进行快速计算(谷歌还专门设计了TPU);2.自动求导。设置了requires_grad = True 时,这个张量会记录自己是怎么被算出来的(即记录父节点)。在后续训练中,只需调用.backward(),梯度就能自动沿着张量传播回来;3.广播机制(Broadcasting)。将一个标量(0维张量)与一个矩阵(2维张量)相加,系统会自动把标量“扩展”成同样大小的矩阵再相加。

2.1.2 Types of floats

float32(fp32):单精度,一个数字32位,4bytes(较大) 

自己研究了一下一个小数的存储方法,有点意思。例如19303.0890是怎么存成一个float32的,这下懂了为啥会出现 0.1 + 0.2 = 0.300000001这种类似的问题了

float16(fp16):半精度,动态范围较小只有5位,很容易出现下溢到0或上溢到无穷大的现象

bfloat16(bf16):

fp8:by Nvidia in 2022

混合精度训练(mix-precision-training):在训练的pipeline中,例如在前向传播、反向传播、优化器或是梯度累积阶段,不同阶段用到的精度可能不一样。

2.2 Compute

import torch

memory_allocated = torch.cuda.memory_allocated()

z = torch.zeros(32, 32, device="cuda:0")

new_memory_allocated = torch.cuda.memory_allocated()

memory_used = new_memory_allocated - memory_allocated

print(memory_used)

结果输出4096 (32 * 32 * 4, 一个float32占4bytes)

2.2.1 Tensor Storage

一个大小为 4 x 4的矩阵,在内存中是一个一维长数组。张量在 PyTorch 中是指向已分配内存的指针。张量对象本身拥有元数据,指定了如何在数组中找到特定的地址。在这个二维矩阵中,元数据具有两个维度:stride[0] 和 stride[1],分别代表从各自的维度到下一级要跨越多少个单位(即stride[0] 代表每过一行要跨越4个单位,stride[0] = 4; 而 stride[1] 代表每过一列要跨越1个单位,stride[1] = 1)

2.2.2 Tensor Operations

a. Tensor Slicing

张量的很多操作实际上并不会创建一个新的张量,它们只会创建一个不同的视图,而不会进行复制

import torch


def print_storage(x: torch.Tensor):
    print(x.untyped_storage().data_ptr())


x = torch.tensor([[1., 2, 3], [4, 5, 6]])
y1 = x[0]
y2 = x[:, 1]
y3 = x.view(3, 2)
y4 = x.transpose(1, 0)

但 print_storage() 输出的结果一致,即占用同一块内存空间。

x[0, 0] = 100
print(y4[0, 0])          # tensor(100.)
print(y4[0, 0].item())   # 100.0

所以当修改 x 时,其他 Tensor 中对应元素也会修改。

强制复制张量的操作:

1.显式调用 clone();

2.当操作要求连续内存而视图不连续时调用.contiguous():某些操作例如转置,会破坏原本张量的连续性(contiguous)。遍历具有连续性的张量意味着能够平滑地遍历存储中的一维数组;反之则是跳跃访问的。

对一个转置后的视图调用 .view() 改变形状,会报错。.contiguous()的作用是把逻辑顺序散乱的数据重新连续排列。这个时候会发生一次复制,对 x 和 y5 进行 print_storage(),得到的结果不一样。

b. Tensor Elementwise

下列操作会得到新的张量,很好理解——计算结果总是要保存的

x = torch.tensor([1, 4, 9])
assert torch.equal(x.pow(2), torch.tensor([1, 16, 81]))
assert torch.equal(x.sqrt(), torch.tensor([1, 2, 3]))
assert torch.equal(x.rsqrt(), torch.tensor([1, 1 / 2, 1 / 3]))  # i -> 1/sqrt(x_i)

assert torch.equal(x + x, torch.tensor([2, 8, 18]))
assert torch.equal(x * 2, torch.tensor([2, 8, 18]))
assert torch.equal(x / 0.5, torch.tensor([2, 8, 18]))


y = torch.ones(3, 3).triu()  # triu()   # 创建上三角矩阵
assert torch.equal(y, torch.tensor([
    [1, 1, 1],
    [0, 1, 1],
    [0, 0, 1]],
))
c. Tensor Matmul

对于一个 16 x 32 和一个 32 x 2的矩阵,得到的结果是一个 16 x 2 的结果。

但在深度学习中,常见的张量形式是一个四维矩阵:w = [a, b, c,d],其中 a 为 batch,b 为 sequence。c 和 d 为数据。而对 w 的乘法,是将每个 batch 、每个 sequence 都进行一次矩阵的乘法,即:

x = torch.ones(4, 8, 16, 32)
w = torch.ones(32, 2)
y = x @ w    
# @ 只会对张量的“最后两个维度”执行矩阵乘法
# 前面的所有维度都当作“批量维度(Batch)”原样保留。
assert y.size() == torch.Size([4, 8, 16, 2])

2.2.3 Tensor Einops

为了张量代码的可读性,在操作张量时我们命名张量的所有维度,而不像以前那样本质上只依赖于索引

a. jaxtyping basics
# old way : 通过注释等记录
x = torch.ones(2, 2, 1, 3)    # batch seq heads hidden

# eniops way
x: Float[torch.Tensor, "batch seq heads hidden"] = torch.ones(2, 2, 1, 3)
b. einops einsum

本质上还是矩阵乘法

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)   # 将倒数第一维度和倒数第二维度转置   
z = einsum(x, y, "batch seq1 hidden, batch seq2 hidden -> batch seq1 seq2")
# 两种方式等价


z = einsum(x, y, "... seq1 hidden, ... seq2 hidden -> ... seq1 seq2") 
# 用 ... 来表示对任意数量的维度进行广播
c. einops reduce

对张量的某个维度做“归约(聚合)”操作,比如求和、求平均、取最大值等

import torch
from einops import reduce

x = torch.ones(2, 3, 4)

# 对 hidden 维度求和,消掉它
y = reduce(x, "batch seq hidden -> batch seq", "sum")
print(y.shape)  # torch.Size([2, 3])
print(y)

d. einops rearrange
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)   from (2,3,8)

2.2.4 Tensor Operation Flops

张量操作本质上是浮点操作(floating-point operation,FLOP),而浮点操作是指任何涉及浮点数的运算,比如加法或乘法

区分 FLOPs 以及 FLOPS。前者小写 s 说明是复数形式,代表浮点操作次数,衡量的是完成的计算总量;后者大写 S 说明是 per second,代表每秒浮点运算次数,衡量的是硬件的速度 。

Linear Model:

假设一个矩阵 M 大小为(B,D),乘以一个矩阵( D, K)将 D 维向量映射到一个 K 维向量,则:

M = torch.ones(B, D, device=device)
# 要得到的矩阵N 形状为 B x K ,则线性操作矩阵T形状为 D x K
T = torch.randn(D, K, device=device)
N = M @ T

要进行的 FLOPs  = 2 * B * D * K (对于 N 中每个元素,都要计算: D 次乘法以及 D - 1 次加法,共 2D - 1 次操作;而 N 中有 B * K个元素,故 FLOPs  = 2 * B * D * K )

其他操作的浮点运算次数通常与矩阵或张量的大小呈线性关系。在深度学习中,对于足够大的矩阵,矩阵乘法是所有操作中最耗时的

推广到 Transformer——

B: the number of data points

D * K : the number of parameters

FLOPs for forward pass is (2 * tokens * parameters) (忽略了序列长度和其他因素)

得到了浮点运算的次数,计算实际运行的时间:

def time_matmul(a: torch.Tensor, b: torch.Tensor) -> float:
    """Return the number of seconds required to perform `a @ b`."""

    # Wait until previous CUDA threads are done
    if torch.cuda.is_available():
        torch.cuda.synchronize()
        # PyTorch 在 GPU 上运行张量运算时,是异步的。
        # Python 会立刻把指令 a @ b 发给 GPU,然后不等 GPU 算完,Python 就继续执行下一行代码了。
        # torch.cuda.synchronize() 的作用就是强制 Python 停下来等待, 直到 GPU 上的所有线程全部执行完毕.
        # 保证时间计算准确

    def run():
        # Perform the operation
        a @ b

        # Wait until CUDA threads are done
        if torch.cuda.is_available():
            torch.cuda.synchronize()

    # Time the operation `num_trials` times
    num_trials = 5
    total_time = timeit.timeit(run, number=num_trials)

    return total_time / num_trials

# B = 16384 D = 32768 K = 8192
actual_num_flops = 2 * B * D * K
actual_time = time_matmul(M, T)  # @inspect actual_time
actual_flop_per_sec = actual_num_flops / actual_time

print(actual_num_flops)       # 8796093022208        8.80e12
print(actual_time)            # 1.2285193999996409
print(actual_flop_per_sec)    # 7159913813498.241    7.16e12

模型浮点运算利用率 (Model Flops Utilization,MFU),即实际浮点次数除以承诺的浮点运算次数

mfu = actual_flop_per_sec / promised_flop_per_sec

查了一下3070的 promised_flop_per_sec = 20.31TFLOPs(但没详细写明是什么float数据格式)

默认利用的float类型为float32, 将其换成为bfloat16,得到的结果:

M = M.to(torch.bfloat16)
T = T.to(torch.bfloat16)

# actual_num_flops = 8.80e12 不变
# actual_time = 0.42
# actual_flop_per_sec = 21.06 TFLOPs

每秒浮点运算次数(FLOP/s)取决于硬件以及数据类型。5090 > 4090,bfloat16 > float32

2.2.5 Gradients FLOPs

整个计算过程中除了计算矩阵乘法之外,还有一部分用于计算梯度

Example:

M = torch.ones(B, D, device=device)
T1 = torch.randn(D, D, device=device, requires_grad=True)
T2 = torch.randn(D, K, device=device, requires_grad=True)
# 一个两层线性模型
# M --T1--> N1 --T2--> N2 -> loss

N1 = M @ T1
N2 = N1 @ T2
loss = N2.pow(2).mean()

前向传播计算量:2 * B * D * D + 2 * B * D * K

计算梯度过程:

forward pass :   2 x ( data points) x ( parameters) FLOPs

backward pass: 4 x ( data points) x ( parameters) FLOPs

total: 6 x ( data points) x ( parameters) FLOPs

3.Model

3.1 Module Parameters

import torch
from torch import nn

input_dim = 16384
output_dim = 32

w = nn.Parameter(torch.randn(input_dim, output_dim))
assert isinstance(w, torch.Tensor)
# 检查 w 是不是 torch.Tensor 的一个实例
# Parameter 在行为上完全是一个张量,可以对它做 w @ x、w.view()、w.transpose() 等所有张量操作

assert type(w.data) == torch.Tensor
# w.data 的类型是精确的 torch.Tensor(而非子类),不带 Parameter 的“身份标记”
# 在训练过程中,应通过 w.data 来修改底层数据(比如做权重裁剪、初始化),而不是重新赋值 w 本身。 
# 否则,w 会丢失 Parameter 的身份,优化器将不再更新它

# nn.Parameter 相较于普通张量 torch.Tensor
# 前者默认 requires_grad = True
# 会被自动添加到模型的 .parameters() 迭代器

parameter initialization

发现问题:

(x的形状为(intput_dim),却能和形状为(input_dim,output_dim)的w相乘,得到形状为(output_dim)的output,是PyTorch默认把一维张量当作行向量来参与矩阵乘法)

得到的结果较大,这是因为数值增长的量级本质上与隐藏维度的平方根成正比

输入数据x的标准差是 1,经过一个全连接层(y = x @ w)后,输出 y 的方差会变成 input_dim × 1,所以 y 的标准差会变成 sqrt(input_dim),输出结果与标准差量级相同,发生数值发散的爆炸现象,导致模型训练不稳定

解决方法:

w = nn.Parameter(torch.randn(input_dim, output_dim) / np.sqrt(input_dim))
# 将 w 除以输入维度的平方根
# Xavier initialization

得到的结果集中在类似 Normal(0, 1) 的正态分布附近。但由于正态分布曲线永远不会碰到横轴,理论上会出现及其庞大的数据(特别是在大模型参数动辄几十亿数量级的情况下)。故更安全的方法将尾部截断,例如将范围限制在 [-3, 3]

3.2 Custom Model

class Cruncher(nn.Module):
    def __init__(self, dim: int, num_layers: int):
        super().__init__()
        self.layers = nn.ModuleList([
            Linear(dim, dim)
            for i in range(num_layers)
            # Linear(dim, dim):把输入张量的最后一个维度从 dim 映射到 dim,也就是做一次线性变换
        ])
        # nn.ModuleList 是 PyTorch 里一个专门用来存放多个子模块(如 Linear、Conv2d)的容器
        # 当把层放进 nn.ModuleList 时,PyTorch 会自动“记住”这些层,让优化器能找到它们的参数

        self.final = Linear(dim, 1)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Apply linear layers
        B, D = x.size()
        for layer in self.layers:
            x = layer(x)

        # Apply final head
        x = self.final(x)
        assert x.size() == torch.Size([B, 1])

        # Remove the last dimension
        x = x.squeeze(-1)
        assert x.size() == torch.Size([B])

        return x


D = 64  # Dimension
num_layers = 2
model = Cruncher(dim=D, num_layers=num_layers)
device = get_device()
model = model.to(device)

B = 8  # Batch size
x = torch.randn(B, D, device=device)
y = model(x)
assert y.size() == torch.Size([B])

(当 num_layers = 2 时)这个模型一共有 3 层,两层 Linear 和一个 final 层。前两层的参数量都为 D * D,最后一层的参数量为 D * 1。

3.3 Note about Randomness

# Torch
seed = 0
torch.manual_seed(seed)

# NumPy
import numpy as np
np.random.seed(seed)

# Python
import random
random.seed(seed)

将 Torch、NumPy 和 Python 引入随机性的地方全部“锁死”,让代码每次运行时产生的随机数序列完全一样,增加代码的可复现性。

3.4 Data Loading

data = np.memmap("data.npy", dtype=np.int32)
# "data.npy":这是一个已存在硬盘上的文件
# np.memmap 的返回值:data 是一个内存映射对象
# 它看起来、用起来和普通 NumPy 数组几乎一模一样(支持索引、切片、算术运算)
# 但数据本身还留在硬盘上,没有加载到内存里。
assert np.array_equal(data, orig_data)

使用 np.memmap() ,适合处理超大规模数据集,内存无法直接 load 整个数据集时。当尝试访问数据时,会按需加载文件

原理: 虚拟内存 + 缺页异常 + 手动管理内存

3.5 Optimizar

class AdaGrad(torch.optim.Optimizer):    # 父类
    def __init__(self, params: Iterable[nn.Parameter], lr: float = 0.01):
        super(AdaGrad, self).__init__(params, dict(lr=lr))
        # 调用 PyTorch 优化器的基类构造函数,把参数和学习率注册进去,这样 self.param_groups 里就有了所有参数的信息

    def step(self):
        for group in self.param_groups:     # self.param_groups 在父类定义,
            lr = group["lr"]
            for p in group["params"]:
                # Optimizer state
                state = self.state[p]
                # 一个字典,用来记录优化器的“状态”,记录每个参数的历史信息
                # 在 AdaGrad 里,它记录的是历史梯度平方的累加和
                grad = p.grad.data  # 在 loss.backward() 反向传播过程中得到

                # Get squared gradients g2 = sum_{i<t} g_i^2
                g2 = state.get("g2", torch.zeros_like(grad))   # g2 为历史梯度平方的累加和

                # Update optimizer state
                g2 += torch.square(grad)    # 把当前梯度的平方累加进去
                state["g2"] = g2
                # g2 的值只会增加,不会减少

                # Update parameters
                p.data -= lr * grad / torch.sqrt(g2 + 1e-5)
                # 如果某个参数的历史梯度平方和 g2 很大(说明这个参数一直在剧烈更新),那么 grad / sqrt(g2) 就会变小,相当于学习率被自动缩小。

optimizer = AdaGrad(model.parameters(), lr=0.01)
x = torch.randn(B, D, device=get_device())
y = torch.tensor([4., 5.], device=get_device())
pred_y = model(x)
loss = F.mse_loss(input=pred_y, target=y)
loss.backward()

optimizer.step()
# 在这里优化器真正发挥作用
state = model.state_dict()
# model.state_dict() 返回一个 Python 字典,里面包含模型所有参数张量的副本
# 注意和优化器里的 state 区分
optimizer.zero_grad(set_to_none=True)
# 释放内存

优化器状态的内存需求:

参数数量 num_parameters : D * D * num_layers + D

激活值数量 num_activations(前向传播过程中,每一层计算出的中间输出张量):对于每一层、每一个数据点、每一个维度,都必须存储激活值。即结果为 B * D * num_layers

梯度数量 num_gradients :梯度数量与参数数量相同,反向传播为每一个参数计算出一个梯度

优化器状态数量 num_optimizer_states :对于AdaGrad,存储的状态为每个参数对应一个g2,即数量同样与参数数量相同

故总内存量(假设数据格式为Float32,即4个字节)total_memory = 4 * ( num_parameters + num_activations + num_gradients + num_optimizer_states )

FLOPs = 6 * B * num_parameters

3.6 Train Loop

import torch
from torch import nn
from torch.nn import Linear
from torch.optim import SGD
import torch.nn.functional as F

class Cruncher(nn.Module):
    def __init__(self, dim: int, num_layers: int):
        super().__init__()
        self.layers = nn.ModuleList([
            Linear(dim, dim)
            for i in range(num_layers)
        ])
       
        self.final = Linear(dim, 1)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Apply linear layers
        B, D = x.size()
        for layer in self.layers:
            x = layer(x)

        # Apply final head
        x = self.final(x)
        assert x.size() == torch.Size([B, 1])

        # Remove the last dimension
        x = x.squeeze(-1)
        assert x.size() == torch.Size([B])

        return x


def train(name: str, get_batch,
          D: int, num_layers: int,
          B: int, num_train_steps: int, lr: float):
    device = "cuda:0"
    model = Cruncher(dim=D, num_layers=0).to(device)
    # num_layers = 0 说明只有最后的 final 层,输出形状 (B, 1)
    optimizer = SGD(model.parameters(), lr=0.01)

    for t in range(num_train_steps):
        # Get data
        x, y = get_batch(B=B)

        # Forward (compute loss)
        pred_y = model(x)
        loss = F.mse_loss(pred_y, y)

        # Backward (compute gradients)
        loss.backward()

        # Update parameters
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
        if t % 2 == 0:  # 每2步打印一次
            print(f"Step {t}: loss = {loss.item():.6f}")

    print("Final model weight:")
    print(model.final.weight)
    print("True weight:")
    print(true_w)


D = 16
true_w = torch.arange(D, dtype=torch.float32, device="cuda:0")


def get_batch(B: int) -> tuple[torch.Tensor, torch.Tensor]:
    x = torch.randn(B, D).to("cuda:0")
    true_y = x @ true_w
    return (x, true_y)
# 获取数据对,x 以及 true_y, 用来与训练出的结果 pred_y 对比


train("simple", get_batch, D=D, num_layers=0, B=4, num_train_steps=100, lr=0.01)

num_train_steps 分别设为 10 和 100 跑了两次,结果如下:

3.7 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")
# 加载

3.8 Mix Precision Trainig

精度更高,则更准确、更稳定,但成本更高;低精度则相反

a. 对第五个前向传播使用较低的精度,但对其余部分使用 float32

b. PyTorch提供了自动进行混合精度训练(automatic mixed precision,AMP)的工具

更多推荐