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

底层实现:训练框架

模型训练通常被压缩成几行代码:执行前向计算、得到损失、反向传播梯度,再由优化器更新参数。表面上,这只是对几个接口的依次调用;实际上,每一步都依赖前一步留下的数据和状态。把它们连接起来的是训练框架:它需要定义张量如何存储,记录算子之间的依赖,按照链式法则传递并累加梯度,还要组织模型参数,使同一套训练过程能够在不同模型和计算设备上稳定运行。

本书以 Zero 为基础,从一个可运行的训练框架出发,逐层拆解它的底层实现。Zero 保留了完成真实训练所需的主要结构,又将代码控制在能够直接阅读和追踪的范围内。重点不是复刻完整的 PyTorch,也不是罗列接口用法,而是沿着一次计算的实际路径,观察张量、自动微分、算子、模型和参数更新如何组成一个完整系统。在这个过程中,既要说明每个抽象解决什么问题,也要关注它们之间的数据所有权、形状约束和调用关系,让数学规则能够落实到具体代码。

全书首先介绍 Zero 的整体训练流程,随后进入自动微分、张量存储和算子实现。这三部分构成训练框架的计算基础,模型组织与参数更新在其上形成训练闭环,Python 前端则展示如何在保留 C++ 核心的同时提供更灵活的模型表达方式。最后两篇讨论 GPU 计算与 GPT 训练,进一步引入设备内存、CUDA Kernel、注意力和更复杂的训练任务,展示同一套框架如何从 CPU 上的简单模型扩展到 GPU 上的语言模型。

Zero 框架介绍

引言

从使用者的角度看,模型训练通常表现为一个不断重复的循环。数据和模型准备完成以后,每一轮只需要清理旧梯度、执行前向计算、计算损失、反向传播,再更新参数,核心代码通常只有几行:

import zero

optimizer.zero_grad()

predictions = model(images)
loss = zero.cross_entropy(predictions, labels)

loss.backward()
optimizer.step()

但这几行背后存在一条完整的数据链路:张量保存输入和参数,模型通过算子完成前向计算,算子在产生结果的同时记录依赖,损失再从计算图末端发起反向传播。优化器读取参数梯度并完成更新,下一轮计算随即从新的参数开始。

输入与参数
    ↓
模型调用算子
    ↓
预测结果 → 损失
    ↓        ↓
  计算图 ← 记录依赖
    ↓
反向传播 → 参数梯度
              ↓
           参数更新

Zero 将这条链路实现为一个小型训练框架。沿着一次训练迭代向下追踪,就能看到上层接口如何落到张量存储、算子执行和梯度计算。

实现

Zero 的计算核心使用 C++17 编写,同时支持 CPU 和 CUDA;轻量的 Python 前端则从中暴露完成基础训练所需的 CPU 功能。它参考 PyTorch 的动态图设计,但不追求覆盖完整的框架功能,也不以替代现有训练框架为目标。

它的定位是教学:用一套规模可控、能够实际运行的代码,把张量、自动微分、算子、模型、参数更新和 GPU Kernel 放在一起观察。各部分不仅具有独立接口,还通过明确的数据所有权、形状约束和执行顺序彼此连接。

Zero 可以在 CPU 上训练 MNIST,也可以使用同一套基础设施在 GPU 上训练 GPT。MNIST 用于验证最基本的训练闭环,GPT 则展示同一套框架扩展到注意力和更多参数后的形态。

张量存储

前向计算从 Tensor 开始。一个张量除了数据,还要保存形状、步长、数据类型和所在设备。ViewTranspose 可以共享底层存储,却以不同的元数据解释它;CPU 和 CUDA 张量则由不同的内存上下文负责分配。

张量还承担自动微分中的身份。前向表达式产生的结果需要记住由哪个算子生成、依赖哪些输入。存储共享、视图关系与梯度关系必须分别处理,否则一次看似普通的浅拷贝或转置就可能破坏反向传播。

算子执行

每个可微算子都实现两件事:根据输入计算结果,以及根据上游梯度计算各输入的梯度。例如乘法 \(z=x\cdot y\) 的反向规则为:

$$ \frac{\partial L}{\partial x} =\frac{\partial L}{\partial z}y, \qquad \frac{\partial L}{\partial y} =\frac{\partial L}{\partial z}x $$

自动微分

Zero 在前向执行算子的同时动态建立计算图。调用 backward() 后,底层先得到拓扑顺序,再逆序执行每个算子的局部反向规则。一个张量经过多条路径影响损失时,来自各路径的梯度还要累加到一起。

同一个算子接口可以派发到 CPU 或 CUDA Kernel。设备差异被限制在数值实现和内存管理中,计算图仍然使用相同的节点和反向规则。

模型训练

Parameter 是需要优化的张量,Module 将参数和子模块组织成模型。Linear、MLP、EmbeddingLayerNorm 和 Attention 都建立在张量算子之上,而不是绕开自动微分单独实现训练逻辑。

优化器持有参数集合。zero_grad() 清理上一轮梯度,step() 根据 SGD 或 AdamW 等规则修改参数。底层更新过程不会记录计算图,否则参数更新本身也会进入下一轮反向传播。

由此,一次训练迭代形成闭环:

Parameter
   ↓ 前向
Loss
   ↓ backward
Gradient
   ↓ optimizer.step
新的 Parameter

训练任务

MNIST 中的 MLP 已经会经过矩阵乘法、Bias 广播、激活函数、交叉熵、反向传播和参数更新,因此足以验证训练框架的基础结构是否正确。

GPT 没有改变这套基本过程,而是提高了对框架的要求:Embedding 引入索引操作,多头注意力引入批量矩阵乘法和因果遮罩,LayerNorm 与 FFN 产生更多中间张量,GPU 执行则需要考虑 Kernel 启动、内存池、归约和算子融合。

所以本书不是从一个简单框架跳到另一套 GPT 框架,而是沿着同一套训练框架逐步增加模型和设备复杂度。

Autograd 自动微分

引言

深度学习训练的目标,是找到一组使损失尽可能小的模型参数。梯度给出了损失相对于各个参数的变化率,优化器据此更新参数。为了得到这些梯度,训练框架必须解决两个问题:如何记录前向计算,以及如何沿着计算过程反向传播梯度。

Zero 使用反向模式自动微分。它在前向计算时动态构建计算图;调用 Backward() 后,从损失的梯度开始,按照与前向相反的顺序依次执行各个算子的反向规则。每个算子接收输出端传来的梯度,计算各个输入的梯度并继续向前传递;如果同一个张量参与了多条计算路径,来自不同路径的梯度还需要累加。这个过程一直进行到模型参数,得到参数更新所需的梯度。

链式法则

考虑复合函数:

$$ y=f(g(x)) $$

它对 \(x\) 的导数可以拆成两个局部导数的乘积:

$$ \begin{aligned} \frac{\partial y}{\partial x} &= \frac{\partial y}{\partial g} \frac{\partial g}{\partial x} \end{aligned} $$

训练引擎不需要一次求出整个模型的导函数。每个算子只需知道自己的局部导数,然后将上游梯度乘上局部导数即可。

例如 \(z=x\cdot y\) 的上游梯度为 \(g\),乘法算子返回:

$$ \frac{\partial L}{\partial x}=g\cdot y, \qquad \frac{\partial L}{\partial y}=g\cdot x $$

如果一个张量被多个后续算子使用,那么它的总梯度是每条路径贡献之和:

$$ \begin{aligned} \frac{\partial L}{\partial x} &= \sum_i \frac{\partial L}{\partial y_i} \frac{\partial y_i}{\partial x} \end{aligned} $$

这也是反向传播中梯度必须累加,而不能直接覆盖的原因。

计算图

表达式 \(z=x\cdot y+x\) 可以写成一张有向无环图:

x ──┐
    ├── Mul ──┐
y ──┘          ├── Add ── z
x ────────────┘

图中的张量保存数值,算子保存局部的前向和反向规则。前向计算得到 z 时,还需要记住 Add 的输入;否则反向阶段无法继续找到 Mulxy

设 \(x=3\)、\(y=2\),前向计算得到 \(x\cdot y=6\)、\(z=9\)。反向计算从 \(\partial z/\partial z=1\) 开始:

                     ∂z/∂z = 1
                          │
                    Add::Backward
                    ┌─────┴─────┐
              传给 Mul:1    传给 x:1
                    │
                    │
               Mul::Backward
               ┌────┴────┐
       传给 x:1 × y   传给 y:1 × x
               │
               └──→ x 的两条梯度相加

Add 将上游梯度分别传给两个输入,Mul 再根据局部导数计算 \(x\) 和 \(y\) 的梯度。由于 \(x\) 同时经过 MulAdd 到达 \(z\),它需要累加两条路径的贡献。

最终得到:

$$ \frac{\partial z}{\partial x}=y+1=3, \qquad \frac{\partial z}{\partial y}=x=3 $$

这个例子同时说明了两件事:算子只计算局部梯度,训练引擎负责按照计算图组合并累加它们。

Zero 在 Tensor 中使用 Back 保存这条连接:

struct Back {
    std::unique_ptr<TensorOp> op;
    std::vector<Tensor> inputs;
};

op 是产生当前结果的算子,inputs 是该算子的输入。Back 被当前逻辑张量的所有副本共享,算子的生命周期则由 unique_ptr 明确管理。

实现

动态建图

每次运算都会创建一个新的算子实例,再通过 TensorOp::Apply 执行:

Tensor Tensor::operator*(const Tensor& other) const {
    return RunOp<OpMul>({*this, other});
}

Apply 先计算结果,然后检查输入是否需要梯度:

Tensor result = op->OpValue_(inputs);

bool need_grad = std::any_of(
    inputs.begin(), inputs.end(),
    [](const Tensor& t) { return t.RequiresGrad(); });

if (need_grad) {
    result.RequiresGrad(true);
    result.back_->op = std::move(op);
    result.back_->inputs = std::move(inputs);
}

只有至少一个输入需要梯度时,结果才会记录计算图。推理计算和普通数据处理因此不会携带不必要的反向状态。优化器更新参数时使用 RunOpNoGrad,也是为了防止更新过程自身进入计算图。

反向传播

一个节点的梯度可能来自多条路径。在执行该节点的反向函数之前,必须先收集完所有下游贡献。Zero 首先从损失张量出发进行深度优先遍历,得到拓扑序,再逆序执行每个算子的 Backward

对上面的例子,前向的依赖顺序是:

x, y → Mul → Add → z

反向传播则按照相反顺序处理:

z → Add → Mul → x, y

拓扑排序的意义不只是“倒着遍历”,而是保证处理某个节点时,它的所有下游梯度已经准备完成。

根节点的起始梯度为全 1 张量,对应:

$$ \frac{\partial L}{\partial L}=1 $$

反向遍历的核心过程可以概括为:

for (auto it = topo.rbegin(); it != topo.rend(); ++it) {
    Tensor* current = *it;
    if (!current->back_ || !current->back_->op) continue;

    Tensor grad = RowMajorGradOf(*current);
    auto input_grads = current->back_->op->Backward(
        grad, current->back_->inputs);

    for (size_t i = 0; i < current->back_->inputs.size(); ++i) {
        auto& input = current->back_->inputs[i];
        if (input.RequiresGrad()) {
            AccumulateGrad(input, input_grads[i]);
        }
    }
}

第一次写入某个梯度槽时,AccumulateGrad 直接接管算子产生的存储;如果该梯度已经存在,则就地累加。这既满足多路径的链式法则,又避免了每次累加都分配新张量。

张量身份

Back::inputs 保存的是 Tensor 值副本。如果单纯使用对象地址识别计算图节点,同一个逻辑张量的不同副本就会被当成多个节点,导致上游梯度重复计算。

Zero 为每个逻辑张量设置一个共享的梯度槽:

struct GradHolder {
    std::shared_ptr<TensorNode> grad_node;
};

普通 Tensor 副本共享 GradHolder,因此反向传播通过图中副本写入的梯度,用户持有的原张量也能立即看到。拓扑排序也以 GradHolder 的身份去重,而不是使用 Tensor* 或底层数据地址。

视图算子是一个特例。TransposeView 可以共享数据存储,但形状和步长已经改变,因此必须使用独立的 GradHolder。张量的存储共享与梯度共享并不是同一件事,这一点会在下一篇详细讨论。

示例

有了动态计算图和反向传播,最小训练循环已经可以表达:

auto w = Parameter(Tensor::From<float>({0.1f}, {1}));
auto b = Parameter(Tensor::From<float>({0.0f}, {1}));

for (int step = 0; step < 200; ++step) {
    w.ZeroGrad();
    b.ZeroGrad();

    auto pred = w * x + b;
    auto diff = pred - y;
    auto loss = diff * diff;
    loss.Backward();

    w.Update(w.Grad() * learning_rate);
    b.Update(b.Grad() * learning_rate);
}

前向表达式在计算数值的同时建立计算图,Backward() 将损失梯度传回 wb,最后再由优化器或参数更新函数修改数值。这条链路是训练引擎的核心,后续各篇将在它之上逐步扩展数据规模、模型结构和执行设备。

Tensor 张量存储

引言

前一篇使用张量保存计算结果,并将它们连接成计算图,但张量本身还没有展开。最简单的实现似乎只需要一个 std::vector<float>:申请一段连续内存,再把数值依次放进去。然而模型处理的不只是向量,还包括批量输入、权重矩阵和多维激活。同一组数值可能需要被变形或转置,也可能位于 CPU 或 GPU,并在反向传播时拥有对应的梯度。单独一段内存无法表达这些信息。

因此,张量不只是一组数值。它还需要记录形状、步长、数据类型、设备和自动微分状态,并明确这些信息在复制与变形时怎样共享。Zero 将“数据存储”与“数据解释”分开:底层内存只保存数值,元数据决定如何把它解释成多维数组。理解这个区别,是理解零拷贝视图和张量共享语义的起点。

多维数组

考虑一个两行三列的矩阵:

[[1, 2, 3],
 [4, 5, 6]]

从数学上看,它有两个维度;从内存角度看,它仍然只是连续排列的六个数值。张量需要把这两种视角连接起来。Zero 使用统一的 Tensor 接口表示向量、矩阵和更高维数组:

Tensor a = Tensor::Zeros({2, 3});
Tensor b = Tensor::Ones({2, 3});
Tensor c = Tensor::Randn({2, 3});

Tensor flat = a.View({6});
Tensor transposed = a.T();
Tensor product = a.MatMul(b.T());
Tensor shifted = a + 5.0f;

{2, 3} 表示两行三列。abc 具有相同形状,却可以采用不同的初始化方式;View 将六个元素重新解释成一维数组,T() 交换两个维度,MatMul 和加法则在这些结构之上执行计算。

这些接口背后需要处理不同的关系:ViewTranspose 应该复用已有数据,计算结果需要拥有新的存储,普通张量副本又必须共享正确的计算图和梯度。如果这些边界没有定义清楚,减少一次数据拷贝就可能换来错误的布局或反向传播。张量设计的关键,正是确定哪些状态应该共享,哪些状态必须彼此独立。

实现

无论张量具有多少个维度,底层内存最终都是一段一维的连续地址。多维坐标不能直接用于访问这段内存,框架必须先把它转换成相对于起始位置的下标。以一个矩阵为例,沿列移动通常只需访问下一个元素,沿行移动则要跨过一整行;转置以后,两个方向的移动方式还会互换。形状描述每个维度包含多少个元素,步长描述沿各个维度移动一次需要跨过多少个内存位置,二者共同建立多维坐标与线性存储之间的对应关系。

形状与步长

TensorMeta 保存张量的元数据:

struct TensorMeta {
    std::vector<int64_t> sizes;
    std::vector<int64_t> strides;
    Device device;
    DataType datatype;
};

sizes 定义每个维度的长度,strides 定义对应坐标增加 \(1\) 时,内存下标需要跨过多少个元素。

对一个行优先存储的 \(2\times3\) 矩阵:

矩阵:[[1, 2, 3],
      [4, 5, 6]]

内存:[1, 2, 3, 4, 5, 6]
sizes:   [2, 3]
strides: [3, 1]

位置 \((i,j)\) 对应的线性下标为:

$$ \operatorname{offset}(i,j)=i\cdot3+j\cdot1 $$

例如 \((1,0)\) 对应下标 \(3\),所以读到数值 \(4\)。

TensorMeta::InitStrides 从最后一维开始计算默认步长:

void InitStrides() {
    strides.resize(sizes.size());
    int64_t stride = 1;
    for (int i = sizes.size() - 1; i >= 0; --i) {
        strides[i] = stride;
        stride *= sizes[i];
    }
}

对形状 [2, 3, 4],结果是 [12, 4, 1]NumElements() 将所有维度相乘,NumBytes() 再乘以单个元素的字节数,得到需要分配的内存大小。

形状回答“每个维度有多长”,步长回答“沿某个维度移动时怎样访问内存”。二者共同决定张量的布局,也使同一块内存可以拥有不止一种解释方式。

零拷贝视图

转置一个 \(2\times3\) 矩阵时,不需要移动数据,只需交换形状和步长:

转置前:sizes=[2, 3], strides=[3, 1]
转置后:sizes=[3, 2], strides=[1, 3]

转置后的 \((0,1)\) 对应内存下标:

$$ 0\cdot1+1\cdot3=3 $$

因此读到原矩阵 \((1,0)\) 位置的数值 \(4\)。底层数据没有改变,改变的只是访问方式。

Zero 的 Transpose 通过 ViewWithMeta 创建这种视图:

Tensor Tensor::ViewWithMeta(TensorMeta new_meta) const {
    Tensor view;
    view.node_ = node_;
    view.meta_ = std::move(new_meta);
    view.grad_holder_ = std::make_shared<GradHolder>();
    return view;
}

视图共享 node_,因此指向同一块数据;但它拥有自己的 meta_GradHolder。原张量和转置视图的梯度形状不同,如果共享梯度槽,反向传播就会以错误的步长解释梯度。

View 同样可以共享数据,但它有一个限制:当前布局必须能够用新形状合法表示。这样的设计避免了形状操作中不易察觉的数据拷贝。

数据存储

视图能够共享数据,是因为原始内存没有直接放在某个 Tensor 对象中,而是由独立的 TensorNode 管理。它只负责一块原始内存:

class TensorNode {
public:
    TensorNode(TensorContext* ctx, size_t num_bytes)
        : ctx_(ctx), data_ptr_(ctx_->NewMem(num_bytes)) {}

    ~TensorNode() {
        if (data_ptr_) ctx_->DeleteMem(data_ptr_);
    }

private:
    TensorContext* ctx_;
    std::byte* data_ptr_;
};

它在构造时申请内存,析构时归还内存,并禁止自身拷贝和移动,避免多个对象重复管理同一指针。Tensor 通过 shared_ptr<TensorNode> 共享它,最后一个引用消失后才真正归还内存。

反向传播还会在某个中间结果的数据不再被后续算子使用时,调用 ReleaseData() 提前归还存储。这能避免所有前向激活都一直保留到整张计算图被销毁。

内存分配器

一次前向和反向计算会产生大量中间张量。矩阵乘法、激活函数和梯度计算都需要临时结果,如果每次都向系统申请并立即释放内存,分配成本可能超过一些小算子本身的计算成本。Zero 通过 TensorContext 抽象屏蔽具体分配策略:

class TensorContext {
public:
    virtual std::byte* NewMem(size_t size) = 0;
    virtual void DeleteMem(std::byte* ptr) = 0;
};

CPU 分配器

CPUTensorContext 使用空闲链表保存已释放的内存块。新请求先按 \(16\) 字节对齐,再查找能容纳请求的最小空闲块。如果找到的块过大,则重新分配,避免一个小张量长期占用大块内存。空闲块数量过多时,多余内存会真正返回系统。

CUDA 分配器

cudaMalloc 不仅是分配操作,还会引入驱动层同步开销。CUDATensorContext 因此将请求向上对齐到 \(512\) 字节,并按大小放入不同的缓存桶:

申请内存 → 对齐到桶大小 → 命中空闲块?
                              ├─ 是:直接复用
                              └─ 否:cudaMalloc

释放内存 → 放回对应的空闲桶

缓存的块默认不会立即返回 CUDA。当训练从大 batch 切换到单样本生成时,张量形状发生大幅改变,旧桶往往无法复用。此时可以调用 TrimFreePool() 将空闲块真正返回设备,为新阶段腾出显存。

张量组成

至此,数值由 TensorNode 保存,形状与步长由 TensorMeta 解释,计算图和梯度又有各自的生命周期。它们不能简单地绑在同一个对象中,否则一次浅拷贝或视图变换就会同时改变所有状态。

当前 Tensor 因此由四部分组成:

class Tensor {
    std::shared_ptr<TensorNode> node_;
    TensorMeta meta_;
    std::shared_ptr<Back> back_;
    std::shared_ptr<GradHolder> grad_holder_;
};

它们分别遵循不同的共享规则:

成员作用拷贝 Tensor
node_底层数据共享存储
meta_形状、步长、设备和类型按值拷贝
back_产生当前结果的算子与输入共享计算图记录
grad_holder_梯度存储槽共享梯度

因此 Tensor b = a 是浅拷贝:ab 代表同一个逻辑张量,共享数据、计算图位置和梯度槽。这不仅避免了大块数据拷贝,也保证计算图中的副本写入梯度后,用户持有的张量可以读到相同结果。

视图则不同:它与原张量共享 node_,却有自己的 meta_back_grad_holder_。回到开头的 \(2\times3\) 矩阵,转置视图和原张量读取的是同样六个数值,却拥有不同的形状、步长和梯度关系。这组共享语义决定了零拷贝视图和自动微分能否同时正确工作。

设备与类型

TensorOption 用于创建张量时指定设备、数据类型和是否需要梯度:

TensorOption option;
option.SetDevice(Device::CUDA())
      .SetDataType(F32)
      .RequiresGrad(true);

Tensor x({2, 3}, option);

Zero 目前支持 F32I32,设备支持 CPU 和 CUDA。DefaultTensorContext::Get(device) 为两类设备分别返回默认分配器,Tensor::To(device) 则通过 DeviceTransfer 搬运数据及已有梯度。

这种设计使张量的上层接口不需要区分 CPU 和 CUDA 存储。无论数据位于哪种设备,张量都使用形状和步长解释内存,并通过相同的共享规则参与计算图。

张量至此解决了“数据是什么”以及“数据怎样存放”的问题。下一篇将继续处理“数据怎样计算”:算子如何读取不同布局的张量,并根据设备选择对应的 Kernel。

Operator 算子实现

引言

张量解决了数据如何表示和存储,但它本身并不知道两个张量能否相加、矩阵乘法会产生什么形状,也不知道计算结果应当怎样参与反向传播。这些规则由算子定义。一次看似简单的乘法,既要检查输入形状、分配输出并完成数值计算,也要记录输入之间的依赖,并在反向阶段根据上游梯度计算每个输入的梯度。

如果把这些工作全部放进 Tensor,数据结构会同时承担存储、计算和设备实现;如果每个算子分别处理 CPU 与 CUDA,形状规则和反向逻辑又容易重复。Zero 因此将一次算子调用分成三层:

Tensor 接口
    ↓
TensorOp:形状检查、建图、前向/反向规则
    ↓
Ops:按 Device 派发到 CPU 或 CUDA Kernel

Tensor 提供面向使用者的运算接口,TensorOp 定义算子的形状语义、前向与反向规则,并决定是否记录计算图;最底层的 Ops 只负责把数值计算派发到对应设备。三层之间各自处理一种变化:用户表达式保持不变,算子规则与设备实现可以独立扩展。

因此,同一段 Tensor 表达式既可以运行在 CPU 上,也可以迁移到 GPU,而自动微分仍然沿用相同的计算图和反向规则。下面先从连接这三层的 TensorOp 开始。

实现

算子抽象

TensorOp 只要求子类实现两个核心函数:

class TensorOp {
public:
    static Tensor Apply(
        std::unique_ptr<TensorOp> op,
        std::vector<Tensor> inputs,
        bool no_grad = false);

    std::vector<Tensor> Backward(
        const Tensor& grad_output,
        const std::vector<Tensor>& inputs) const;

protected:
    virtual Tensor OpValue_(
        const std::vector<Tensor>& inputs) const = 0;

    virtual std::vector<Tensor> Backward_(
        const Tensor& grad_output,
        const std::vector<Tensor>& inputs) const = 0;
};

OpValue_ 计算前向结果,Backward_ 接收结果的上游梯度,返回与输入一一对应的梯度张量。Apply 在前向完成后判断是否需要建图,而公开的 Backward 还会包装算子性能计时。

用户接口通过 RunOp 为每次调用创建独立算子:

template <typename Op, typename... Args>
Tensor RunOp(std::vector<Tensor> inputs, Args&&... args) {
    return TensorOp::Apply(
        std::make_unique<Op>(std::forward<Args>(args)...),
        std::move(inputs));
}

Zero 不设置全局算子注册表,而是为每次前向调用创建独立的算子对象。算子只保存少量参数,构造成本相比张量计算可以忽略;算子的状态与生命周期则明确归属于它产生的计算图节点。

设备派发

TensorOp 不直接实现数值循环,而是调用 ops 命名空间中的统一入口。例如逐元素乘法:

inline void mul(const Tensor& a, const Tensor& b, Tensor& out) {
    on_gpu(a) ? mul_cuda(a, b, out)
              : mul_cpu(a, b, out);
}

inline void mul_backward(
    const Tensor& grad, const Tensor& a, const Tensor& b,
    Tensor& grad_a, Tensor& grad_b) {
    on_gpu(grad)
        ? mul_backward_cuda(grad, a, b, grad_a, grad_b)
        : mul_backward_cpu(grad, a, b, grad_a, grad_b);
}

算子层只处理形状、参数和自动微分,CPU 与 CUDA 的具体算法分别放在 tensor_ops_cpu.cctensor_ops_gpu.cu。这样新增算子时,边界非常明确:

  1. ops.hops.cc 定义前向、反向及形状语义。
  2. tensor_ops_cpu.* 实现 CPU Kernel。
  3. tensor_ops_gpu.* 实现 CUDA Kernel。
  4. tensor_ops.h 增加设备派发入口。

逐元素算子

加、减、乘、除的直接情况是两个输入形状相同。以乘法为例,前向计算为:

$$ z_i=x_i y_i $$

反向计算为:

$$ \begin{aligned} \frac{\partial L}{\partial x_i} &= \frac{\partial L}{\partial z_i}y_i, \qquad \frac{\partial L}{\partial y_i} &= \frac{\partial L}{\partial z_i}x_i \end{aligned} $$

相应的算子实现只需要分配结果与梯度,再调用设备无关的派发入口:

Tensor OpMul::OpValue_(const std::vector<Tensor>& inputs) const {
    Tensor out(inputs[0].Sizes(), Tensor::LikeOption(inputs[0]));
    ops::mul(inputs[0], inputs[1], out);
    return out;
}

std::vector<Tensor> OpMul::Backward_(
    const Tensor& grad, const std::vector<Tensor>& inputs) const {
    Tensor grad_a(inputs[0].Sizes(), Tensor::LikeOption(grad));
    Tensor grad_b(inputs[1].Sizes(), Tensor::LikeOption(grad));
    ops::mul_backward(grad, inputs[0], inputs[1], grad_a, grad_b);
    return {grad_a, grad_b};
}

实际代码还会检查输入数量和广播关系。这些检查属于算子语义,不应散落在 CPU 和 CUDA Kernel 中。

广播

当两个张量的形状不同时,逐元素算子可以按广播规则对齐它们。规则从最后一维向前比较,每一维需要满足以下条件之一:

  • 两个维度相等。
  • 其中一个维度为 1
  • 其中一个维度不存在,视为 1
A: [2, 3, 4]
B: [   3, 1]
             → [2, 3, 4]

A: [2, 3, 4]
B: [2, 4, 5]
             → 不兼容

Zero 当前会将较小的输入显式广播到目标形状,再执行形状相同的 Kernel。广播 Kernel 将输出线性下标还原为多维坐标,再映射回输入;输入中大小为 1 的维度,坐标始终为 0

例如将 [10, 20, 30][3] 广播到 [2, 3]

[[10, 20, 30],
 [10, 20, 30]]

前向过程中的重复意味着反向过程中的求和。如果原始元素 \(x_j\) 被映射到多个输出位置,它的梯度为:

$$ \begin{aligned} \frac{\partial L}{\partial x_j} &= \sum_{i\mapsto j} \frac{\partial L}{\partial y_i} \end{aligned} $$

OpSumToSize 使用 broadcast_backward 将大形状的梯度收缩回原输入形状。因此所有二元算子都可以共用同一组流程:

前向:广播小张量 → 执行算子
反向:计算大形状梯度 → SumToSize 回原形状

线性层的 Bias 加法是一个高频特例。对于最后一维为 \(C\) 的输入和形状 [C] 的 Bias,OpAdd 会走专用路径,避免先生成一个完整的 Bias 广播中间张量。

矩阵乘法

对于矩阵 \(A\in\mathbb{R}^{M\times K}\) 和 \(B\in\mathbb{R}^{K\times N}\),前向计算为:

$$ C_{mn}=\sum_{k=1}^{K}A_{mk}B_{kn} $$

其反向公式为:

$$ \begin{aligned} \frac{\partial L}{\partial A} &= \frac{\partial L}{\partial C}B^T, \qquad \frac{\partial L}{\partial B} &= A^T\frac{\partial L}{\partial C} \end{aligned} $$

Zero 同时支持普通矩阵乘法、批量矩阵乘法和 batch 维度广播:

[M, K]       @ [K, N]       → [M, N]
[B, M, K]    @ [B, K, N]    → [B, M, N]
[1, M, K]    @ [B, K, N]    → [B, M, N]

当 batch 维度需要广播时,前向先对齐两个输入;反向计算完成后,再将梯度收缩回各自的原始形状。

步长处理

线性层常用 x.MatMul(weight.T())T() 是零拷贝视图,所以 weight.T() 不是默认行优先布局。如果 Kernel 用 m * K + k 这类连续下标读取它,就会把转置前的存储误当成转置后的矩阵。

正确的读取方式必须使用张量步长:

$$ \begin{aligned} \operatorname{offset}_A(m,k) &= m\cdot s_{A,-2}+k\cdot s_{A,-1} \end{aligned} $$

CPU 和 CUDA 的 MatMul Kernel 都必须遵守这条规则。这不是一个可选优化,而是计算转置视图时的正确性要求。

视图反向

ViewTranspose 虽然只改变张量的形状或步长,不拷贝前向数据,但它们仍然是计算图中的算子。反向传播时,需要将视图形状下的梯度转换回输入的形状和布局。

Transpose 的反向过程需要将梯度的对应维度再交换一次:

auto grad_view = grad_output.ViewWithMeta(
    SwapDims(grad_output.Meta(), dim0_, dim1_));

return {OpBroadcastTo(grad_view, inputs[0].Sizes())};

这里的 OpBroadcastTo 不是在逻辑上扩展形状,而是利用通用的 stride-aware 拷贝将视图梯度实体化为连续存储。

View 在输入连续时只重新解释形状;如果输入来自 Transpose 等非连续视图,Zero 会先生成行优先的连续副本,再改变形状。这样才能保证 Attention 中的 Transpose(...).View(...) 按正确顺序解释底层数据。

融合

基础算子让模型能够自由组合,但组合得越细,执行时需要保存的中间张量和计算图节点就越多。在 GPU 上,每个小算子通常还对应一次 Kernel 启动和一次显存读写;计算本身很少时,这些固定开销会变得格外明显。

减少调度

以线性变换为例,普通实现需要先完成矩阵乘法,再单独执行 Bias 加法:

x @ Wᵀ → 中间结果 → Bias Add → y

OpLinearBias 使用 cuBLASLt 的 Bias Epilogue,在矩阵乘法结束时直接完成加法:

x、W、b → LinearBias → y

这样可以减少一次 Kernel 启动,也不必把矩阵乘法结果完整写回显存后再读取。OpLayerNorm 采用相同思路,将均值、方差、归一化和仿射变换放进一个算子,避免由多个基础算子反复产生临时结果。

控制存储

融合还可以改变中间结果的保存方式。语言模型输出层会先计算形状为 [N, V] 的 Logits,再计算交叉熵;当词表较大时,这个张量会占用大量显存。OpLinearCrossEntropy 在前向阶段只返回每个 Token 的损失,不让 Logits 长期留在计算图中,反向传播时再根据输入和权重重新计算。

OpFFNOpFlashAttention 也采用这种策略。它们在反向阶段重新计算部分激活,以减少需要跨越整个前向过程保存的中间张量。这是一种计算与显存之间的取舍:反向计算有所增加,但训练能够使用更大的 Batch 或序列长度。

融合边界

Zero 在保留基础算子的同时,只为高频且边界稳定的组合提供融合实现:

  • OpLayerNorm
  • OpLinearBias
  • OpFFN
  • OpLinearCrossEntropy
  • OpFlashAttention

融合算子仍然遵守 TensorOp 的统一接口:前向接收张量并返回结果,反向接收上游梯度并返回各输入的梯度。它没有改变模型的数学定义,只是把多个小算子的计算和存储安排合并到更粗的执行单元中。

如果所有表达式都改写为专用融合算子,框架会失去组合能力,代码也难以维护。因此,基础算子负责表达完整语义,融合算子只处理已经成为性能或显存瓶颈的关键路径。张量和算子由此构成计算基础,下一篇将在这套接口之上组织参数与模型层。

Module 模型组织

引言

自动微分能够沿计算图求出梯度,却不知道哪些张量代表训练数据,哪些张量需要在每轮训练后更新。对于只有权重和 Bias 的线性模型,可以手动保存两个张量;当模型包含多个线性层、归一化、注意力和重复堆叠的 Block 时,逐个管理参数很快就会变得困难。

模型还需要保留自身的层级关系。一个参数不仅是一块需要梯度的存储,还属于某个具体的层;优化器需要遍历所有参数,模型内部则需要稳定地持有参数和子模块。如果参数散落在普通 C++ 成员中,每增加一层,都要同步修改参数收集和生命周期管理代码。

Zero 用 Parameter 区分可训练张量,用 Module 注册参数和子模块。小模块可以继续组合成更大的模型,而 Module 能够递归展开整棵结构,为优化器提供统一的参数列表。模型的前向计算仍然由张量算子完成,自动微分也不需要理解模型层级。

实现

模型组织从单个可训练张量开始。首先需要让参数具有明确的梯度和更新语义,再由模块接管参数的生命周期,并递归管理更小的子模块。在此基础上,线性层等具体网络层只需定义自己的参数和前向过程;训练模式则负责控制 Dropout 这类在训练与评估阶段行为不同的模块。

Parameter

Parameter 是一种默认需要梯度的 Tensor

class Parameter : public Tensor {
public:
    Parameter(const Tensor& tensor) : Tensor(tensor) {
        RequiresGrad(true);
    }

    void Update(const Tensor& delta) {
        *this = RunOpNoGrad<OpSub>({*this, delta});
        RequiresGrad(true);
    }
};

训练数据和模型参数都用 Tensor 存储,但语义不同:数据通常不需要梯度,参数则需要在每轮反向传播后更新。Parameter 在构造时开启梯度跟踪,让这个区别成为类型自身的不变式。

Updateno_grad 模式下执行减法。如果参数更新也记录自动微分图,后一轮训练就会继续引用前一轮的图,导致计算图和内存不断增长。

Module

Module 同时保存直接参数和子模块:

class Module {
protected:
    std::unordered_map<std::string,
        std::unique_ptr<Parameter>> parameters_;

    std::unordered_map<std::string,
        std::shared_ptr<Module>> modules_;
};

参数由所属模块独占,因此使用 unique_ptr;子模块可以被组合和复用,使用 shared_ptrRegisterParameterRegisterModule 建立所有权关系,Parameters() 则递归遍历整棵模块树:

model
├── block0.attention.weight_q
├── block0.attention.bias_q
├── block0.ffn.fc1.weight
├── block0.ffn.fc1.bias
└── ...

返回值中同时包含层级化名称和 Parameter*。优化器不需要理解模型结构,只需接收这个扁平参数列表。

Linear

线性层实现仿射变换:

$$ y=xW^T+b $$

Zero 将权重存储为 [out_features, in_features],使用时通过零拷贝转置视图参与矩阵乘法:

class Linear : public Module {
public:
    Tensor OpValue(const Tensor& input) {
        return input.MatMul(weight_->T()) + *bias_;
    }

private:
    Parameter* weight_;
    Parameter* bias_;
};

weight_bias_ 是不拥有对象的原始指针,只用于快速访问;参数的生命周期由 Module::parameters_ 中的 unique_ptr 管理。

权重使用 He 初始化:

$$ W_{ij}\sim\mathcal{N}\left(0,\frac{2}{\operatorname{fan_in}}\right) $$

这种初始化适合后续使用 ReLU 或 GeLU 的网络,能减少层数增加时激活和梯度尺度的剧烈变化。

训练模式

部分模块在训练和推理时行为不同。例如 Dropout 在训练时随机丢弃激活,在评估时应该直接返回输入:

Tensor Dropout::OpValue(const Tensor& x) {
    if (p_ == 0.0f || !IsTraining()) return x;
    return RunOp<OpDropout>({x}, p_);
}

Zero 默认处于训练模式。验证或生成时可以在局部作用域中创建 EvalGuard

{
    EvalGuard guard;
    auto output = model.OpValue(input);
} // 离开作用域后恢复原模式

RAII 保证即使中途返回或抛出异常,训练状态也能正确恢复。

示例

复杂模型由小模块组合而成。例如一个单隐藏层 MLP:

class SimpleMLP : public Module {
public:
    SimpleMLP(int64_t input_size,
              int64_t hidden_size,
              int64_t output_size) {
        fc1_ = std::make_shared<Linear>(input_size, hidden_size);
        fc2_ = std::make_shared<Linear>(hidden_size, output_size);

        RegisterModule("fc1", fc1_);
        RegisterModule("fc2", fc2_);
    }

    Tensor OpValue(const Tensor& input) {
        return fc2_->OpValue(ReLU(fc1_->OpValue(input)));
    }

private:
    std::shared_ptr<Linear> fc1_;
    std::shared_ptr<Linear> fc2_;
};

同样的组合方式也用于 FFNMultiHeadAttentionTransformerBlock。各层只提供自己的前向表达式,参数收集和自动微分由统一机制完成。

调用 model.Parameters() 会递归得到:

fc1.weight
fc1.bias
fc2.weight
fc2.bias

模型内部仍然保留由子模块构成的层级结构,对外则提供一个带名称的扁平参数列表。下一篇中的优化器只需要遍历这个列表,无须了解参数属于线性层、注意力还是其他模块。

模型完成前向计算并收集参数以后,还需要由优化器统一读取梯度并更新这些参数。

Optimizer 参数更新

引言

反向传播只负责计算参数梯度,并不会自动修改参数。训练框架还需要决定怎样使用梯度,以及每次更新采用多大的步长,这些工作由优化器完成。

优化器不需要理解模型的层级结构,只需遍历 Module::Parameters() 返回的扁平参数列表。除了执行具体的更新公式,它还要清理上一轮梯度,并为 AdamW 这类算法保存跨越多个训练步骤的状态。参数更新本身不能进入自动微分图,否则不同训练步骤的计算图会被连接起来。

完整训练流程可以概括为:

清空梯度 → 前向传播 → 计算损失 → 反向传播 → 更新参数

实现

参数更新并不只是把某个优化公式翻译成代码。优化器需要为参数保存跨越多个训练步骤的状态,训练循环还可能根据当前进度调整学习率。二者共同决定参数在这一轮发生多大变化。

Zero 用 Optimizer 统一管理参数和梯度清理,并使用 AdamW 完成参数更新。AdamW 为每个参数保存一阶、二阶动量,其计算由张量算子实现,因此同一份代码可以运行在 CPU 和 CUDA 上。SetLr 则让训练循环能够在每一步改变学习率。

下面先从统一接口开始,再说明 AdamW 的更新过程和学习率变化。

Optimizer

Optimizer 基类持有扁平参数列表,并提供统一的梯度清零接口:

class Optimizer {
public:
    void InsertParameters(
        const std::vector<Module::NameParameter>& parameters);

    virtual void ZeroGrad();
    virtual void Step() = 0;
};

ZeroGrad() 遍历参数并清空上一轮的梯度,Step() 由具体优化算法实现。因为反向传播会累加梯度,每轮训练开始前必须先调用 ZeroGrad()

AdamW

AdamW 为每个参数维护一阶动量 m 和二阶动量 v

$$ \begin{aligned} m_t &= \beta_1m_{t-1}+(1-\beta_1)g_t \\ v_t &= \beta_2v_{t-1}+(1-\beta_2)g_t^2 \end{aligned} $$

使用偏差修正后的更新量为:

$$ \begin{aligned} \alpha_t &= \eta\frac{\sqrt{1-\beta_2^t}}{1-\beta_1^t} \\ \Delta\theta &= \alpha_t\frac{m_t}{\sqrt{v_t+\epsilon}} +\eta\lambda\theta \\ \theta &\leftarrow\theta-\Delta\theta \end{aligned} $$

权重衰减项 \eta\lambda\theta 与梯度动量分开,这是 AdamW 与将 L2 正则项直接加入梯度的 Adam 之间的关键区别。

AdamW 的更新完全由张量算子构成,所以同一份代码可以运行在 CPU 和 CUDA 上。所有更新算子都使用 RunOpNoGrad,不记录到模型的自动微分图中。

学习率调度

AdamW::SetLr 允许训练循环在每步更新学习率。GPT 训练使用线性 warmup 与余弦衰减:

$$ \eta_t= \begin{cases} \eta_{\max}\dfrac{t+1}{T_w}, & t<T_w \\ \eta_{\min}+\dfrac{1}{2}(\eta_{\max}-\eta_{\min}) \left(1+\cos(\pi r_t)\right), & t\ge T_w \end{cases} $$

其中 \(T_w\) 是 warmup 步数,\(r_t\) 是衰减阶段当前进度。warmup 避免训练初期在动量统计尚不稳定时使用过大步长,余弦衰减则让后期更新逐渐变小。

示例

MNIST 可以用一个很小的模型验证参数更新是否真正有效:如果前向计算、反向传播或优化器中的任意一环出错,损失就无法稳定下降。这个示例不依赖复杂网络,能够把注意力集中在一轮训练如何完成。

数据

MNIST 的每张图像包含 \(28\times28\) 个灰度像素。数据加载器读取 IDX 二进制文件,将其中的大端整数转换为主机字节序,再把像素从 [0, 255] 归一化到 [0, 1]。一个 Batch 最终形成形状为 [B, 784]F32 张量,标签则使用形状为 [B]I32 张量。

图像:[B, 28, 28] → 展平 → [B, 784]
标签:[B]

F32 图像参与矩阵计算,I32 标签用于在交叉熵中索引正确类别。两者分开表示,也说明张量的数据类型是算子语义的一部分。

模型

示例使用一个单隐藏层 MLP:

[B, 784] → Linear(784, 512) → ReLU
         → Linear(512, 10) → Logits [B, 10]

它直接复用上一篇的 ModuleLinear

class SimpleMLP : public Module {
public:
    SimpleMLP(int64_t input_size,
              int64_t hidden_size,
              int64_t output_size) {
        fc1_ = std::make_shared<Linear>(input_size, hidden_size);
        fc2_ = std::make_shared<Linear>(hidden_size, output_size);
        RegisterModule("fc1", fc1_);
        RegisterModule("fc2", fc2_);
    }

    Tensor OpValue(const Tensor& input) {
        return fc2_->OpValue(ReLU(fc1_->OpValue(input)));
    }

private:
    std::shared_ptr<Linear> fc1_;
    std::shared_ptr<Linear> fc2_;
};

model->Parameters() 会递归收集两个线性层的权重和 Bias,并将同一份参数列表交给设备迁移与优化器:

auto all_params = model->Parameters();

训练循环

训练循环的核心代码只有几步:

optimizer->ZeroGrad();

auto logits = model->OpValue(images);
auto loss = CrossEntropyLoss(logits, targets);

loss.Backward();
optimizer->Step();

ZeroGrad() 清除上一个 Batch 的梯度,前向过程将 [B, 784] 的输入转换成 [B, 10] 的 Logits,CrossEntropyLoss 根据标签产生损失。Backward() 将梯度传回四个参数,Step() 再根据当前优化算法更新它们。下一个 Batch 会使用更新后的参数重新开始这条链路。

设备选择

CPU 和 GPU 使用相同的 AdamW。如果选择 CUDA,需要先将模型参数迁移到 GPU,再创建优化器状态;每个 Batch 的输入也要迁移到同一设备:

if (use_cuda) {
    for (auto& [name, parameter] : all_params) {
        parameter->Cuda();
    }
}

auto optimizer = std::make_unique<AdamW>(0.001f);
optimizer->InsertParameters(all_params);

if (use_cuda) images.Cuda();

AdamW::InsertParameters() 会为每个参数创建一阶和二阶动量,因此应在参数迁移之后调用,保证优化器状态与参数位于同一设备。除此之外,模型结构、损失函数和训练循环不需要因设备不同而改变。

训练过程中可以记录每个 Batch 的损失和分类准确率。损失持续下降说明参数更新方向有效,准确率上升则说明这些更新确实改善了模型输出。这个示例的价值不在于模型规模,而在于它打通了数据、模型、损失、自动微分和优化器之间的完整链路。

这条训练链路不依赖具体设备,下一篇将说明相同算子如何派发到 CUDA,并在 GPU 上高效执行。

Python 前端

引言

前面的章节已经在 C++ 中实现了张量、自动微分、算子和参数更新。这些能力适合承担数值计算,却不一定适合直接描述模型和训练流程。C++ 需要显式处理类型、所有权和编译过程;Python 则更适合快速组合网络层、准备输入并控制训练循环。

训练代码经常需要反复调整模型结构、损失函数、学习率和数据处理方式。如果这些变化都写在 C++ 中,每次修改后都要重新编译,再运行程序观察结果。Python 可以直接执行新的模型与训练逻辑,也便于在运行过程中打印张量形状、损失和梯度,缩短发现问题、修改代码和重新验证之间的周期。只有底层算子或绑定发生变化时,才需要重新编译 C++ 扩展。

Python 前端并不是重新实现一套训练框架,而是在 C++ 核心之上提供另一种表达方式。两层之间的关系可以概括为:

Python:模型组合与训练流程
              ↓
PyBind11:类型与调用转换
              ↓
C++:Tensor、Autograd 与 Operator

Python 创建张量并调用算子,实际的数值计算和计算图仍由 C++ 完成。这样既保留底层实现的性能和统一语义,又让上层训练代码保持简洁。

实现

Python 前端需要解决两个问题:首先将 C++ 的张量和算子安全地暴露给 Python,然后用这些基础接口组织模型与参数更新。绑定层只负责跨越语言边界,ModuleLinearSGD 则使用普通 Python 代码完成组合。

PyBind11

Zero 使用 PyBind11 将 C++ 类型封装为 _zero 扩展模块。绑定层暴露 TensorParameter 和常用算子,并负责在两种语言之间转换参数与返回值:

py::class_<Tensor>(m, "Tensor")
    .def("backward", &Tensor::Backward)
    .def_property_readonly("grad", [](const Tensor& tensor) {
        return tensor.Grad();
    })
    .def("__matmul__", [](const Tensor& lhs, const Tensor& rhs) {
        return lhs.MatMul(rhs);
    });

当 Python 执行 loss.backward() 时,调用会直接进入 C++ 的 Tensor::Backward()。矩阵乘法、加减乘、转置、变形、ReLU 和交叉熵也沿用同一套 C++ 算子,因此绑定前后共享相同的前向与反向规则。

Python 列表创建张量时,绑定层先检查嵌套列表是否具有规则形状,再将数值展开为连续数组。张量返回 Python 时,则根据 shape 重新构造嵌套列表。这个边界只负责数据表示的转换,不参与数值计算。

Module

Python 层的 Module 保持得很小。__call__ 将调用转发给 forwardparameters() 则递归收集对象属性中的 Parameter 和子模块:

class Module:
    def parameters(self):
        result = []
        for value in vars(self).values():
            if isinstance(value, Parameter):
                result.append(value)
            elif isinstance(value, Module):
                result.extend(value.parameters())
        return result

    def __call__(self, *args, **kwargs):
        return self.forward(*args, **kwargs)

Linear

Linear 由参数和已有张量运算组成:

class Linear(Module):
    def __init__(self, in_features, out_features):
        scale = math.sqrt(2.0 / in_features)
        self.weight = Parameter(
            randn([out_features, in_features]) * scale
        )
        self.bias = Parameter(randn([out_features]) * 0.0)

    def forward(self, x):
        return x @ self.weight.T + self.bias

前向过程不需要特殊的模型执行器。每一次 Python 运算都会调用对应的 C++ 算子,并动态建立计算图。

SGD

Python 版 SGD 保存 model.parameters() 返回的参数。zero_grad() 清除上一轮梯度,step() 按学习率生成更新量:

def step(self):
    for parameter in self.parameters:
        if parameter.grad is not None:
            parameter.update(parameter.grad * self.lr)

Parameter.update() 最终仍在 C++ 中完成无梯度的减法。这样,Python 可以控制训练循环,而参数存储、梯度和更新语义仍由底层统一管理。

示例

下面的线性回归同时经过 Python 模型、C++ 算子、自动微分和参数更新:

import zero

model = zero.nn.Linear(2, 1)
optimizer = zero.optim.SGD(model.parameters(), lr=0.05)

x = zero.tensor([[1.0, 2.0], [2.0, 1.0]])
target = zero.tensor([[5.0], [4.0]])

for _ in range(100):
    prediction = model(x)
    difference = prediction - target
    loss = (difference * difference).mean()

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

model(x) 从 Python 进入绑定层,并调用 C++ 的矩阵乘法、转置和加法;loss.backward() 沿 C++ 计算图生成参数梯度;optimizer.step() 再通过 Parameter.update() 修改底层参数。循环仍由 Python 控制,但每一步张量计算都复用了同一套 C++ 实现。

Python 前端由此展示了训练框架的一条边界:底层提供稳定的计算能力,上层提供灵活的表达方式。接口不需要覆盖完整的 PyTorch,只要足以把前面实现的组件组合成一次可运行的训练即可。

GPU 计算

引言

CPU 适合复杂控制流和低延迟任务,GPU 则通过大量计算单元同时处理数据。深度学习中的逐元素运算、归约和矩阵乘法往往需要对大量元素执行相同或相似的计算,因此能够利用 GPU 的并行能力。

将训练迁移到 GPU 并不只是把张量复制到显存。框架还要把计算划分给线程,管理设备内存,选择适合的矩阵乘法实现,并处理 Kernel 的异步执行。与此同时,GPU 版本必须遵守与 CPU 相同的形状、步长和反向传播规则,否则同一模型会在两种设备上产生不同结果。

Zero 不在模型层区分 CPU 和 GPU。张量记录自己所在的设备,算子根据设备选择对应实现;模型、计算图和参数更新仍然使用相同接口。设备差异被限制在内存管理、数值 Kernel 和派发过程之中。

实现

GPU 执行从 CUDA 的线程模型开始,再逐步进入设备抽象、显存管理和算子派发。逐元素计算可以直接映射到大量线程,归约与矩阵乘法则需要更专门的并行策略;异步执行最终把这些操作连接成一条连续的设备任务流。

CUDA

CUDA Kernel 是一个由 CPU 发起、在 GPU 上并行执行的函数:

__global__ void scale_kernel(float* data, float scale, size_t size) {
    size_t idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < size) {
        data[idx] *= scale;
    }
}

线程按两层结构组织:

Grid
├── Block 0
│   ├── Thread 0
│   ├── Thread 1
│   └── ...
├── Block 1
└── ...

Zero 的一维逐元素 Kernel 通常使用 256 个线程组成一个 Block,Block 数向上取整:

const int block = 256;
const int grid = (size + block - 1) / block;
scale_kernel<<<grid, block>>>(data, scale, size);

最后一个 Block 可能只有部分线程对应有效元素,因此 Kernel 内部必须检查 idx < size。GPU 还会以 \(32\) 个线程为一个 Warp 调度指令,同一 Warp 中过多的条件分支会降低执行效率。

并行模式

逐元素运算

如果每个输出元素只依赖相同位置的输入,就可以让每个线程处理一个元素。加法、乘法、GeLU 和梯度缩放都属于这一类:

template<typename T>
__global__ void add_kernel(
    const T* a, const T* b, T* out, size_t size) {
    size_t idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < size) out[idx] = a[idx] + b[idx];
}

归约

均值、方差、Softmax 和梯度范数需要将多个输入合并为更少的输出。归约 Kernel 通常让每个线程先计算局部结果,再在 Block 内使用共享内存或 CUB 完成合并。

输入元素
    ↓ 每个线程处理一部分
线程局部结果
    ↓ BlockReduce
Block 结果
    ↓ 必要时再次合并
最终结果

广播反向也是归约。多个大张量位置可能映射到同一个小张量元素,Zero 使用原子加法将这些梯度累加到同一位置。

Device

Device 记录设备类型和设备编号:

class Device {
public:
    enum class DeviceType { CPU, CUDA };

    static Device CPU();
    static Device CUDA(int index = 0);

    bool IsCpu() const;
    bool IsCuda() const;
};

创建张量时,TensorOption 将设备信息传给 TensorMeta,再由 DefaultTensorContext::Get(device) 选择对应的内存分配器:

auto option = TensorOption()
    .SetDevice(Device::CUDA())
    .SetDataType(F32);

Tensor x({1024, 1024}, option);

Tensor::Cuda()Tensor::Cpu() 通过 Tensor::To 在设备之间搬运数据。如果梯度已经存在,梯度存储也会一起迁移:

auto x = Tensor::Randn({1024});
x.Cuda();
auto y = x * 2.0f;
y.Cpu();

CPU 到 CUDA 使用 cudaMemcpyHostToDevice,CUDA 到 CPU 使用 cudaMemcpyDeviceToHost。同设备之间的迁移由 Tensor::To 直接忽略,不进入 DeviceTransfer

GPU 内存

GPU 上的频繁 cudaMalloc 会引入显著的驱动和同步开销。CUDATensorContext 因此将申请大小对齐到 \(512\) 字节,并按大小缓存已释放的显存块。

NewMem(size)
  → RoundUp(size, 512)
  → 尝试从同尺寸空闲桶取块
  → 未命中时才调用 cudaMalloc

DeleteMem(ptr)
  → 放回对应尺寸的空闲桶

分配器同时统计 live_bytespeak_bytesreserved_bytes 和缓存命中次数。当训练切换到不同形状的生成阶段时,TrimFreePool() 可以释放已无法复用的旧桶。

这部分与前文的张量存储属于同一套机制:TensorNode 只负责持有指针,TensorContext 决定指针如何分配和复用。

Kernel 派发

算子通过 tensor_ops.h 中的统一入口调用 Kernel:

inline void add(const Tensor& a, const Tensor& b, Tensor& out) {
    on_gpu(a) ? add_cuda(a, b, out)
              : add_cpu(a, b, out);
}

公开的张量接口不包含 CUDA 分支:

auto a = Tensor::Ones({1024});
auto b = Tensor::Ones({1024});

auto cpu_result = a + b;

a.Cuda();
b.Cuda();
auto gpu_result = a + b;

派发器以第一个输入所在设备为准,因此同一算子的所有输入必须位于同一设备。如果混用 CPU 和 CUDA 张量,算子可能把主机指针传给 CUDA Kernel,造成非法内存访问。进入计算前应先显式将输入迁移到相同设备。

广播等高维 Kernel 需要获取形状和步长。Zero 将最多八维的定长 DimsArg 直接作为 Kernel 参数传递,避免每次运算都为元数据额外执行 cudaMalloccudaMemcpy

OpAdd

把两个同形状的 CUDA 张量相加,可以看到一次算子调用怎样穿过前面的各层:

a + b
  ↓ Tensor::operator+
RunOp<OpAdd>
  ↓ TensorOp::Apply
OpAdd::OpValue_
  ↓ ops::add
add_cuda
  ↓
add_kernel<<<Grid, Block>>>

入口仍然是普通的 C++ 运算符:

Tensor Tensor::operator+(const Tensor& other) const {
    return RunOp<OpAdd>({*this, other});
}

RunOp 为这次调用创建独立的 OpAddTensorOp::Apply 再执行它的前向过程。对于形状相同的输入,OpAdd::OpValue_ 创建一个同形状输出,并继承输入的设备与数据类型:

Tensor out(a.Sizes(), Tensor::LikeOption(a));
ops::add(a, b, out);

由于 a 位于 CUDA,输出内存由 CUDATensorContext 分配,ops::add 也会选择 add_cuda。CUDA 入口根据张量的数据类型选择模板实例,最后启动逐元素 Kernel:

template<typename T>
__global__ void add_kernel(
    const T* a, const T* b, T* out, size_t size) {
    size_t idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < size) out[idx] = a[idx] + b[idx];
}

每个线程只负责一个输出位置。Kernel 启动以后,TensorOp::Apply 不必等待 GPU 完成全部计算;如果输入需要梯度,它会把 OpAdd 和两个输入保存到结果的计算图记录中。

调用 Backward() 后,这条路径会反向执行:

Tensor::Backward
  ↓ OpAdd::Backward_
ops::add_backward
  ↓ add_backward_cuda
add_backward_kernel
  ↓
累加到 a.grad 与 b.grad

加法对两个输入的局部导数都是 \(1\),因此反向 Kernel 将上游梯度分别写入 grad_agrad_b。这两个梯度张量同样分配在 CUDA 上,随后由自动微分系统累加到输入的梯度槽。至此,一次 a + b 已经经过输出分配、设备派发、前向计算、计算图记录和反向传播,而模型层始终不需要出现 CUDA 分支。

矩阵乘法

逐元素算子适合自行编写 Kernel,矩阵乘法则需要更复杂的分块、数据搬运和硬件调度。Zero 使用 cuBLAS 执行 F32 MatMul:

  • 普通矩阵使用 cublasGemmEx
  • 批量矩阵使用 cublasGemmBatchedEx
  • 线性层使用 cuBLASLt 的 Bias Epilogue,将矩阵乘法和 Bias 加法融合。

cuBLAS 以列优先布局解释矩阵,Zero 的张量默认为行优先。实现通过交换乘数和转置标志,在不拷贝连续数据的前提下完成两种布局之间的对应。

对于内层维度不是单位步长的视图,cuBLAS 无法用一个 leading dimension 表示其布局。Zero 会先将这类输入实体化为连续张量,再交给 cuBLAS。这与前文“算子必须正确处理 stride”的要求一致。

默认情况下,输入、输出和内部计算都使用 FP32。设置 MX_FAST_MATMUL=1 后,cuBLAS 可在保持 FP32 输入输出的同时,使用 FP16 Tensor Core 执行内部乘加。这能提高吞吐量,但会引入额外数值误差,因此需要显式开启。

异步执行

CUDA Kernel 启动默认是异步的。CPU 将 Kernel 放入默认 Stream 后即可继续运行,同一 Stream 中的 Kernel 会按提交顺序执行。Zero 不在每次 Kernel 启动后调用 cudaDeviceSynchronize(),否则 CPU 会在每个小算子后等待 GPU,丧失异步执行的优势。

cudaMemcpy 在 CPU 和 GPU 之间搬运数据时会形成必要的同步点。因此训练循环应尽量让数据和中间结果停留在 GPU 上,只在需要记录损失或输出结果时复制少量数据回 CPU。

至此,训练框架已经具备从张量到 GPU 执行的完整链路。最后一篇将用 GPT 把这些组件组合成一个真实的语言模型训练程序。

GPT 训练

引言

GPT 是一种自回归语言模型。给定一段 Token 序列,模型根据已经出现的上下文预测下一个 Token。Token 可以表示字符、子词或其他离散单元;模型只接收整数 ID,并不依赖词表采用哪种构造方式。

这一篇将前面的张量、自动微分、模块、优化器和 GPU Kernel 连成一条完整训练链路。

自回归目标

对序列 \(x_1,x_2,\ldots,x_T\),联合概率可以按条件概率展开:

$$ P(x_1,x_2,\ldots,x_T) = \prod_{t=1}^{T}P(x_t\mid x_1,\ldots,x_{t-1}) $$

训练数据通过错位一个 Token 构造输入和目标:

原序列:  [x0, x1, x2, x3, x4]
输入 x:   [x0, x1, x2, x3]
目标 y:   [x1, x2, x3, x4]

模型在每个位置输出词表上的 Logits,再使用交叉熵最小化正确下一 Token 的负对数似然:

$$ \begin{aligned} \mathcal{L} &= -\frac{1}{BT} \sum_{b=1}^{B}\sum_{t=1}^{T} \log P(y_{b,t}\mid x_{b,1:t}) \end{aligned} $$

\(B\) 是 Batch 大小,\(T\) 是序列长度。一次前向计算可以同时训练所有位置,但必须防止某个位置看到未来的 Token。

模型

GPT 接收一批 Token ID,输出每个位置对下一 Token 的概率预测。要完成这个转换,模型首先需要把离散 ID 映射为连续向量,再让每个位置从已有上下文中提取信息,最后把隐藏表示投影回词表空间。三个阶段分别对应 Embedding、Transformer Block 和输出层。

上下文建模是其中的核心。单个 Token 的向量只包含自身和位置信息,经过多头注意力后才能聚合前文;FFN 再对每个位置的表示进行非线性变换。多个 Block 重复这一过程,使当前位置逐层形成对已有序列的表示。因果掩码贯穿所有注意力层,保证这种表示始终只依赖当前位置及其之前的 Token。

整体结构

Zero 使用 Transformer Decoder 结构,将这些阶段连接起来:

Token ID
  → Token Embedding + Position Embedding
  → Dropout
  → TransformerBlock × N
  → LayerNorm
  → Token Embedding 权重的转置
  → Logits

输入张量形状为 [B, T],其中每个元素是一个 Token ID。经过嵌入后,张量变为 [B, T, C],\(C\) 是隐藏维度;所有 Transformer Block 都保持这个形状,因此残差分支可以直接相加。最终输出层将隐藏维度 \(C\) 投影到词表维度 \(V\),得到形状为 [B, T, V] 的 Logits。

Embedding

Token ID 是离散整数,不能直接参与神经网络运算。Embedding 使用索引从参数矩阵中选取对应行:

$$ H_0=E_{\mathrm{token}}(x)+E_{\mathrm{position}}(0,1,\ldots,T-1) $$

Token 嵌入矩阵形状为 [V, C],位置嵌入矩阵形状为 [T_max, C]。自注意力本身不区分 Token 的先后顺序,因此必须额外加入位置信息。

auto token_embeddings = token_embedding_->OpValue(tokens);
auto pos_ids = GetPositionIds(batch_size, seq_len, option);
auto position_embeddings = position_embedding_->OpValue(pos_ids);
auto x = dropout_emb_.OpValue(
    token_embeddings + position_embeddings);

嵌入权重初始化为标准差 0.02 的正态分布。较小的初始尺度能避免与输出层权重共享时 Logits 过大,从而使 Softmax 过早饱和。

Attention

对输入 \(X\in\mathbb{R}^{B\times T\times C}\),首先做三次线性投影:

$$ Q=XW^Q+b^Q, \qquad K=XW^K+b^K, \qquad V=XW^V+b^V $$

为了让投影转化为一次大矩阵乘法,Zero 先将 [B, T, C] 展平为 [B*T, C],再使用融合的 OpLinearBias。得到 Q、K、V 后,将它们重塑为多头布局:

[B*T, C]
  → View [B, T, H, D]
  → Transpose [B, H, T, D]

其中 \(C=H\times D\),\(H\) 是注意力头数,\(D\) 是单头维度。每个头的缩放点积注意力为:

$$ \operatorname{Attention}(Q,K,V) = \operatorname{softmax}\left( \frac{QK^T}{\sqrt{D}}+M \right)V $$

\(M\) 是因果掩码。对于位置 \(i\) 和 \(j\):

$$ M_{ij}= \begin{cases} 0, & j\le i \\ -10^4, & j>i \end{cases} $$

对角线上方的大负数经 Softmax 后接近零,使第 \(i\) 个位置只能使用自己和之前的上下文:

       x0  x1  x2  x3
x0     ✓   ×   ×   ×
x1     ✓   ✓   ×   ×
x2     ✓   ✓   ✓   ×
x3     ✓   ✓   ✓   ✓

有掩码时,Zero 使用 OpFlashAttentionQ @ K^T、缩放掩码 Softmax 和 probs @ V 组织成一个自动微分算子。它不在计算图中长期保留 [B, H, T, T] 的 Scores 和 Probabilities,而在反向传播时重新计算,用计算换取显存。

Transformer Block

Zero 使用 Pre-LN 结构,两个子层都在输入端做 LayerNorm,再通过残差连接加回主干:

$$ \begin{aligned} H’ &= H+\operatorname{Dropout}( \operatorname{Attention}(\operatorname{LN}_1(H))) \\ H’’ &= H’+\operatorname{Dropout}( \operatorname{FFN}(\operatorname{LN}_2(H’))) \end{aligned} $$

残差连接为梯度提供直接路径。LayerNorm 放在子层之前,可以在较深的网络中改善训练稳定性。

FFN 对每个 Token 位置独立执行两次线性变换,中间维度是隐藏维度的四倍:

$$ \operatorname{FFN}(x) = \operatorname{GeLU}(xW_1+b_1)W_2+b_2 $$

OpFFN 将两个线性层、Bias 和 GeLU 放在同一个算子内,减少中间张量和自动微分节点。Dropout 分别用在嵌入、注意力残差分支和 FFN 残差分支上。

输出层

所有 Transformer Block 之后,先应用最终 LayerNorm,再将隐藏向量投影到词表:

$$ \operatorname{logits}=H_{\mathrm{final}}E_{\mathrm{token}}^T $$

Zero 直接复用 Token Embedding 的权重,不为输出层另外创建 [V, C] 参数。这种权重共享不仅减少参数量,也使输入和输出使用同一个 Token 表示空间。

普通 OpValue 返回 [B, T, V] Logits,适合推理。训练使用 OpValueLoss,将输出投影与交叉熵融合:

auto x_2d = x.View({B * T, C});
return RunOp<OpLinearCrossEntropy>(
    {x_2d, *token_weight, target_flat});

词表较大时,[B*T, V] Logits 会占用大量显存。融合算子不让这个张量进入长期保留的自动微分图,可以显著降低训练峰值显存。

训练

训练阶段需要先将原始文本转换为整数序列,再确定模型规模和更新策略。数据批次经过模型得到每个位置的损失,反向传播后还要完成梯度裁剪、学习率调整和参数更新。

数据准备

原始文本首先由 Tokenizer 转换成整数 ID。训练程序不需要知道一个 ID 对应字符还是子词,只要求训练集与验证集使用同一份词表,并能把 ID 还原成 Token。

整数 ID 按 int32 二进制格式写入 train.binval.bin。训练时从整个 ID 序列中随机选择 \(B\) 个起点,每个起点连续取 \(T+1\) 个 ID,再错位生成输入和目标。随机起点让不同 Batch 覆盖语料中的不同位置,连续片段则保留了语言模型需要学习的上下文关系。

训练配置

训练程序使用一组规模较小的 GPT 配置:

HyperparameterValue
Sequence Length256
Batch Size64
Hidden Size384
Transformer Blocks6
Attention Heads6
Dropout0.2
AdamW \(\beta_1,\beta_2\)0.9, 0.99
Weight Decay0.1
Gradient Norm Threshold1.0
Max / Min Learning Rate\(10^{-3},10^{-4}\)
Warmup Steps100

训练循环

每一步的核心训练过程为:

opt->ZeroGrad();

auto per_token_loss = model->OpValueLoss(x, mask, y);
auto loss = per_token_loss.Mean(0, false);
loss.Backward();

float grad_norm = ClipGradNorm(all_params, 1.0f);
opt->SetLr(lr_for_iter(iter));
opt->Step();

GPT 的计算图较深,个别 Batch 可能产生异常大的梯度。ClipGradNorm 先计算所有参数梯度共同组成的 L2 范数;当范数超过阈值 \(c\) 时,将所有梯度按同一比例缩小:

$$ g_p\leftarrow g_p\cdot\frac{c}{\lVert g\rVert_2+10^{-6}} $$

统一缩放不会改变整体梯度方向。这里将阈值设为 1.0,裁剪完成后再由 AdamW 读取梯度并更新参数。

学习率前 100 步线性增长,之后余弦衰减。每 500 步在验证集上估计损失,验证和采样都使用 EvalGuard 关闭 Dropout。

推理

生成过程

生成时取最近 \(T\) 个 Token 作为上下文,对最后一个位置的 Logits 应用温度缩放:

$$ p_i = \frac{\exp(z_i/\tau)}{\sum_j\exp(z_j/\tau)} $$

实现中会先减去最大 logit,避免指数溢出,再用 std::discrete_distribution 根据概率采样下一 Token。温度 \(\tau\) 越小,分布越尖锐;温度越大,生成结果越随机。

采样前使用 EvalGuard 关闭 Dropout,并暂时关闭所有参数的梯度跟踪,避免为每一个生成步骤构建计算图。训练 Batch 与单样本采样的张量形状差异较大,因此采样前后还会调用 TrimFreePool() 清理无法复用的 CUDA 缓存桶。

当前生成路径没有 KV Cache,每产生一个 Token 都会重新计算整个上下文窗口。这个实现足以验证模型的训练结果,但不适合高吞吐推理。

示例

训练 StoneGPT

仓库中的 prepare_stone.py 将 UTF-8 文本转换成字符级训练数据。把语料保存为根目录下的 FourBooks.txt,再执行:

python3 prepare_stone.py

脚本收集语料中出现的所有 Unicode 字符,排序后将每个字符映射为一个整数 ID。这种方式不需要额外的子词 Tokenizer,便于直接观察训练流程;代价是序列较长,也难以直接使用更大粒度的语义单元。

脚本生成 data/stone/vocab.txt,并按照九比一划分训练集和验证集:

data/stone/
├── vocab.txt
├── train.bin
└── val.bin

构建 Zero 后,可以先在 CPU 上运行少量步骤,确认数据读取、前向计算和反向传播能够连通:

./build/apps/stone_gpt

默认只训练 30 步。完整训练更适合使用 GPU,并通过命令行指定训练步数和数据目录:

./build/apps/stone_gpt cuda 5000 data/stone

程序每隔固定步数输出训练损失,并定期在验证集上估计损失和生成文本。训练损失反映模型对当前 Batch 的拟合程度,验证损失用于观察模型对未参与更新的数据是否同样改善,生成结果则提供更直观的检查。三者需要结合观察:训练损失下降并不必然意味着模型已经学会稳定生成,也可能只是对训练语料拟合得更好。

如果希望使用其他语料,只需生成相同格式的 vocab.txttrain.binval.bin,再将目录作为第三个参数传给 stone_gpt。模型结构和训练流程不需要改变。