快速开始

本节构建 Ctorch,并用线性回归学习 y = 2x + 1。示例只需要 CPU。

本教程需要 C++ 基础。代码示例使用 C++20。交互演示由浏览器中的 JavaScript 计算,C++ 程序需要使用 Ctorch 编译运行。

入门课程按张量、数据、模型、梯度、训练、保存恢复的顺序组织。完成保存恢复后即可编写 CPU 训练程序。自定义模块、CUDA 和扩展接口可按实际需要阅读。

训练步骤

每次训练先计算预测和损失,再计算梯度并更新参数。点击下方步骤可查看对应代码。

交互导览
→ → → →
auto input = dl::from_span<float>(x, {5, 1});

把五个样本放入一个批次。shape 的第一维是样本数,第二维是每个样本的特征数。

构建并运行

从 GitHub 仓库取得代码,或在版本下载中下载源码包。离线教程包解压后打开根目录的 index.html 即可阅读。

在仓库根目录执行。需要 CMake 3.24+、Ninja 和支持 C++20 的编译器;Windows 可使用已配置的 MSVC 开发终端。

cmake --preset cpu -DCTORCH_BUILD_EXAMPLES=ON
cmake --build --preset cpu --target ctorch_first_training
../Ctorch-build/cpu/bin/ctorch_first_training

基本概念

名称在本例中的含义
样本与特征每个 x 是一个样本,每个样本只有一个数值特征。
目标对应的 y 是模型需要预测的数值。
模型与参数模型计算 wx+b。w 和 b 是训练时更新的参数。
损失预测与目标之间的误差。本例计算所有样本误差平方的平均值。
梯度损失对参数的导数。它表示参数小幅变化时损失如何变化。
优化器与学习率SGD 用梯度更新参数。学习率控制每次更新的大小。
批次、epoch 与 step批次是一组一起计算的样本。epoch 表示完整遍历一次训练集。step 在本教程中表示一次优化器更新。

一个完整的训练程序

参数从零开始。经过 100 次 SGD 更新,权重接近 2、偏置接近 1;初始均方误差是 9。

#include <dl/dl.h>
#include <array>
#include <iostream>

int main() {
    const std::array<float, 5> x{-2, -1, 0, 1, 2}, y{-3, -1, 1, 3, 5};
    auto input = dl::from_span<float>(x, {5, 1});
    auto target = dl::from_span<float>(y, {5, 1});
    dl::nn::Linear model(dl::zeros({1, 1}, {.requires_grad = true}),
                         dl::zeros({1}, {.requires_grad = true}));
    dl::optim::SGD optimizer(model.parameters(), {.learning_rate = 0.1});

    for (int step = 0; step < 100; ++step) {
        optimizer.zero_grad();
        auto loss = dl::mse_loss(model(input), target);
        loss.backward();
        optimizer.step();
    }
    dl::NoGradGuard no_grad;
    std::cout << "weight " << model.weight().item<float>()
              << ", bias " << model.bias()->item<float>() << '\n';
}

zero_grad() 清空上一次更新的梯度。backward() 计算本次损失对参数的梯度。step() 使用这些梯度更新参数。运行结果应接近 weight 2, bias 1。

后续章节主要展示接口片段。可直接编译的完整程序列在示例索引中。

代码与结果

from_span 把数组复制为张量。形状 {5,1} 表示 5 个样本,每个样本 1 个特征。{.requires_grad = true} 是 C++20 指定成员初始化,表示创建时启用参数求导。

model(input) 调用模型的前向计算。初始 w 和 b 都是 0,五个预测也都是 0,所以均方误差是 (9+1+1+9+25)/5 = 9。训练后 w 接近 2、b 接近 1,预测接近给定的目标。

上方程序输出 weight 2, bias 1 附近的数值。下载的完整示例还打印初始损失和最终损失。极小的非零损失是浮点计算的舍入结果。

auto 让编译器推导变量类型。Tensor 变量持有张量句柄。NoGradGuard 在构造时暂停记录求导图,在离开作用域时恢复原来的模式。

在自己的 CMake 工程中使用

先安装 Ctorch,再通过导入目标链接。以下命令在仓库根目录运行。安装目录和构建目录都可以自行指定。

cmake --install ../Ctorch-build/cpu --prefix ../Ctorch-install

应用工程包含 main.cpp 和以下 CMakeLists.txt。导入目标会传递头文件路径、C++20 要求和依赖。

cmake_minimum_required(VERSION 3.24)
project(training_app LANGUAGES CXX)
find_package(Ctorch CONFIG REQUIRED)
add_executable(training_app main.cpp)
target_link_libraries(training_app PRIVATE ctorch::ctorch)

配置应用时用 -DCMAKE_PREFIX_PATH=<Ctorch 安装目录> 指定安装位置。共享库构建还需要让运行时找到库文件,详见构建与安装。

张量与存储

Tensor 记录数据类型、设备、形状和步长。多个张量可以共享同一块存储。

维度、形状与元素个数

维度数是轴的数量,shape 记录每个轴的长度。标量的形状是 [],向量可以是 [3],矩阵可以是 [2,3]。矩阵的两个轴分别有 2 和 3 个位置,共有 6 个元素。

轴从 0 开始编号。形状为 [2,3] 时,size(0) 是 2,size(1) 是 3,numel() 是 6。步长 stride 表示沿某个轴移动一个位置时,在底层存储中跨过多少个元素。

形状、步长与存储

每个格子显示“值 / 存储下标”。选择一个元素,观察逻辑位置与底层存储之间的对应。

布局实验
逻辑视图
共享存储 · 6 个连续的 float

transpose 改变索引步长,底层六个值的顺序保持不变。

创建与读取

const float values[] = {1, 2, 3, 4, 5, 6};
auto x = dl::from_span<float>(values, {2, 3});
auto transposed = x.transpose(0, 1);
std::cout << dl::describe(transposed) << '\n';
auto independent = transposed.clone();

describe 只读取元信息。to_vector<float>() 按逻辑顺序取得宿主数值;读取 CUDA 数据时会产生设备传输。

视图与复制

操作存储与求导关系
复制 Tensor 句柄继续持有同一身份和存储。
transpose / slice建立共享存储的视图,反向映射到输入。
clone()复制数值,梯度仍可回到输入。
detach()共享存储,脱离当前求导历史。
detach().clone()获得独立、无历史的数值副本。

广播可以让 [2, 3] 与 [3] 相加。反向时,广播出来的维度会归约回输入形状。更多算子见张量与梯度手册。

数据类型与设备

类型常见用途
f32默认浮点类型。通常用于模型参数和输入。
f64CPU 双精度计算和数值梯度比较。
f16 / bf16CUDA 混合精度计算。
i64类别标签、索引和 Embedding 输入。
boolean比较结果和注意力掩码。
auto weight = dl::randn({3, 2}, {.requires_grad = true});
auto precise = dl::zeros({2, 3}, {.dtype = dl::DType::f64});
auto converted = precise.astype(dl::DType::f32);
auto scalar = dl::full({}, 2.0);  // 空 shape 表示标量。
float value = scalar.item<float>();

to(device) 改变设备,astype(dtype) 改变类型。data<T>() 返回张量所在设备的地址;CUDA 地址不能由 CPU 直接解引用。非连续视图的原始地址也不代表按逻辑顺序排列的数组。读取逻辑值可使用 to_vector<T>()。

广播、矩阵乘法与归约

auto input = dl::ones({4, 3});
auto weight = dl::randn({3, 2});
auto bias = dl::zeros({2});
auto output = dl::matmul(input, weight) + bias;  // [4, 2]
auto loss = dl::mean(output * output);           // 标量
auto first_two = output.narrow(0, 0, 2);         // [2, 2]
auto flattened = output.reshape({-1});           // [8]

广播从末维对齐形状。两个维度相等或其中一个为 1 时可以相容。matmul 对最后两个维度做矩阵乘法,并广播前面的批次维度。reshape 保持元素总数,最多使用一个 -1 推导维度;布局不能直接复用时会整理数据。

逐元素乘法与矩阵乘法

a * b 按对应位置相乘,并遵循广播规则。matmul(a, b) 用矩阵的行与列计算内积。例如两个 [2,2] 张量做两种乘法,输出形状相同,但数值通常不同。

const float values[] = {1, 2, 3, 4};
auto a = dl::from_span<float>(values, {2, 2});
auto elementwise = a * a;       // [[1, 4], [9, 16]]
auto matrix = dl::matmul(a, a); // [[7, 10], [15, 22]]
练习:把 [2,3] 转置后,形状、步长和元素个数分别是多少?

形状是 [3,2],步长是 [1,3],元素个数仍是 6。转置调整索引方式,不改变存储中的值。

数据与批次

DataLoader 按样本编号整理批次。输入与标签在第一维对应同一个样本。

输入与标签

任务输入标签
表格分类浮点数 [N, F]i64 [N],值为 0 到 C−1
图像分类浮点数 [N, C, H, W]i64 [N]
回归浮点特征与模型输出对应的浮点目标
语言模型i64 token 编号 [N, L]下一 token 编号 [N, L]

N 表示样本数,F 表示特征数,C 表示类别数或图像通道数,H、W 表示图像尺寸,L 表示序列长度。

创建数据加载器

const float features[] = {-1, -1, -1, 1, 1, -1, 1, 1};
const dl::Index labels[] = {0, 0, 0, 1};
dl::data::TensorDataset dataset{
    dl::from_span<float>(features, {4, 2}),
    dl::from_span<dl::Index>(labels, {4})
};
dl::Generator sampling{19};
dl::data::DataLoader loader(dataset, {.batch_size = 2, .shuffle = true}, sampling);

loader.reset();
while (auto batch = loader.next()) {
    std::cout << dl::describe(batch->input) << '\n';
}

reset() 开始新一轮采样。next() 返回下一个批次,到达结尾时返回空值。默认保留不足一个完整批次的剩余样本;drop_last = true 会丢弃它们。

训练加载器通常使用打乱顺序。验证和测试使用固定顺序并保留末批。将训练与验证交给不同的 Generator,可以避免评估改变训练采样序列。

缩放、轴顺序与标签

先确认读取器交付的数据类型、数值范围和轴顺序。下面用四个已读取的像素演示缩放和 NHWC 到 NCHW 的转换。

const float pixels[] = {0, 64, 128, 255};
auto image = dl::from_span<float>(pixels, {1, 2, 2, 1}); // NHWC
auto input = (image / 255.0).permute({0, 3, 1, 2});    // NCHW [1,1,2,2]

除以 255 后像素处于 [0,1]。permute 交换轴顺序;reshape 只重新解释逻辑形状,不能代替 NHWC 到 NCHW 的轴交换。load_mnist 已经返回缩放后的 NCHW 数据,不需要再次除以 255。

训练、验证和预测使用相同的固定预处理。按均值和标准差标准化时,只从训练集计算这些统计量,并在其他数据上复用。随机裁剪等增强通常只用于训练。分类标签保存为 i64 类别编号,不做像素归一化,也不需要为 cross_entropy 转成 one-hot。

读取文件和变长样本

数据不适合一次放入张量时,使用 DataLoader(size, collate, options, generator)。collate 接收一批样本编号并返回 Batch{input, target}。它可以读取文件、执行增强或填充变长序列。所引用的数据集必须在加载器使用期间有效。

MNIST 可使用 dl::data::load_mnist(images, labels) 读取已解压的 IDX 文件。返回图像为 f32[N,1,H,W],像素除以 255;标签为 i64[N]。完整用法见示例说明。

编写整理函数

下面复用前面的 dataset 和 sampling。加载器提供样本编号,整理函数取出相应样本并沿第 0 轴堆叠。文件读取和数据增强也可放在同一个函数中。

auto collate = [&dataset](std::span<const dl::Index> indices) {
    std::vector<dl::Tensor> inputs, targets;
    for (auto index : indices) {
        inputs.push_back(dataset.input.select(0, index));
        targets.push_back(dataset.target.select(0, index));
    }
    return dl::data::Batch{dl::stack(inputs), dl::stack(targets)};
};
dl::data::DataLoader custom(dataset.size(), collate, {.batch_size = 2}, sampling);

单个输入形状是 [2],堆叠后为 [批次大小,2]。单个标签是标量,堆叠后为 [批次大小]。这里只展示 Collate 的接口;已放入 TensorDataset 的数据可以直接使用前面的加载器。

练习:10 个样本按 batch_size=4 加载,末批有多少个样本?

默认得到 4、4、2 三个批次。启用 drop_last 后只保留前两个批次。评估通常保留末批,使指标覆盖全部样本。

划分训练、验证和测试集

先按任务划分数据,再生成批次。训练集用于计算梯度,验证集用于选择模型和超参数,测试集用于最终评估。归一化统计量只从训练集计算。序列数据先划分连续区间,再截取窗口,避免同一段内容跨集合重复出现。

设备预取

dl::data::PrefetchLoader batches(loader, device, 2);
batches.reset();
while (auto batch = batches.next()) {
    auto prediction = model(batch->input);
}

device 是模型所在设备。预取器提前准备有限数量的批次,并复用 CPU 暂存缓冲区。使用预取器时,通过它调用 reset() 和 next()。collate 在调用线程执行,不会自动创建多进程读取器。保存预取训练状态的方法见保存训练状态。

常用模型

Sequential 可以组合按顺序执行的层。分支、残差连接和多输入模型使用自定义 Module。

首次编写分类程序时,先完成本节的 MLP、分类输出和损失函数,再阅读自动求导与训练。CNN、循环网络和 Transformer 用于对应的图像或序列任务。

多层感知机

dl::nn::Sequential model;
model.append<dl::nn::Linear>("hidden", 2, 8);
model.append<dl::nn::ReLU>("activation");
model.append<dl::nn::Linear>("output", 8, 2);
auto logits = model(dl::zeros({16, 2}));  // [16, 2]

Linear 对输入末维做线性变换。上例输入每个样本有两个特征,输出两个类别的 logits。交叉熵直接接收 logits,前面不需要加 softmax。

MLP 的形状与参数

步骤输出形状作用
输入[16,2]16 个样本,每个样本有两个特征
Linear(2,8)[16,8]每个样本得到 8 个线性组合
ReLU[16,8]负数变为 0,非负数保持原值
Linear(8,2)[16,2]每个样本得到两个类别分数

第一层有 8×2 个权重和 8 个偏置。第二层有 2×8 个权重和 2 个偏置,总共 42 个参数。ReLU 没有参数。加入非线性激活后,多层网络才不等价于一次线性变换。dl::summary(model) 可以查看模块和参数数量。

分类分数、概率与损失

logits 是模型输出的类别分数,可以为负,也不要求相加等于 1。softmax 把分数转换为非负且总和为 1 的概率。argmax 返回最大分数的位置,也就是预测类别编号。

二分类输出类别 0 的分数固定为 0
0.11920.8808
预测类别1
交叉熵损失0.1269

交叉熵等于真实类别概率的负自然对数。上例真实标签为 1 时,增大类别 1 的分数会降低损失。切换真实标签不会改变模型输出,只会改变损失。训练时把原始 logits 交给 cross_entropy;需要显示概率时再调用 softmax。

const float scores[] = {0, 2};
const dl::Index labels[] = {1};
auto logits = dl::from_span<float>(scores, {1, 2});
auto target = dl::from_span<dl::Index>(labels, {1});
auto probabilities = dl::softmax(logits, 1); // 约 [0.1192, 0.8808]
auto predicted = dl::argmax(logits, 1);      // 类别 1
auto loss = dl::cross_entropy(logits, target); // 约 0.1269

概率最大并不保证分类正确。判断模型效果时,应在未参与训练的数据上比较预测和标签。

卷积网络

dl::nn::Sequential cnn;
cnn.append<dl::nn::Conv2d>("conv", 1, 8, dl::Size2{3},
                          dl::Conv2dOptions{.padding = 1});
cnn.append<dl::nn::BatchNorm2d>("norm", 8);
cnn.append<dl::nn::ReLU>("relu");
cnn.append<dl::nn::MaxPool2d>("pool");
cnn.append<dl::nn::Flatten>("flatten");
cnn.append<dl::nn::Linear>("output", 8 * 14 * 14, 10);
auto logits = cnn(dl::zeros({4, 1, 28, 28}));  // [4, 10]

卷积输入采用 NCHW 布局。上例卷积保持 28×28 空间尺寸,默认 2×2 池化得到 14×14。BatchNorm 在训练时更新运行统计量,评估时使用已保存的统计量。完整分类程序见 mnist_cnn.cpp。

RNN、GRU 与 LSTM

dl::nn::GRU recurrent(6, 12);
auto sequence = dl::randn({10, 4, 6});
auto result = recurrent.run(sequence);
std::cout << dl::describe(result.output) << '\n';  // [10, 4, 12]
std::cout << dl::describe(result.hidden) << '\n';  // [1, 4, 12]

默认序列布局是 [时间, 批次, 特征]。batch_first = true 改为 [批次, 时间, 特征]。隐藏状态布局为 [层数×方向数, 批次, 隐藏维度]。LSTM 还返回 cell 状态。

跨片段保留状态时,把上一段的 hidden 和 cell 传给下一段。截断反向传播时先对它们调用 detach(),使前一段的计算图结束。完整 GRU 训练见 recurrent_train.cpp。

Embedding 与 Transformer

dl::nn::Embedding tokens(256, 32);
dl::nn::TransformerEncoder encoder(2, 32, 4, 128, 0.1);
auto ids = dl::zeros({4, 16}, {.dtype = dl::DType::i64});
auto features = tokens(ids);                    // [4, 16, 32]
auto keep = dl::causal_mask(16, 16);
auto encoded = encoder.forward(features, keep); // [4, 16, 32]

注意力输入采用 [批次, 序列, 特征]。特征维度必须能被头数整除。布尔掩码中 true 表示允许关注,因果掩码只保留当前位置和之前的位置。位置编码需由模型显式加入,TransformerEncoder 不会自动生成位置编码。

字节语言模型演示词表 Embedding、位置 Embedding、因果注意力、共享输出权重和下一字节预测。

输出与损失函数

任务损失输出与目标
回归mse_loss浮点预测和同形状目标
单标签分类cross_entropylogits [N,C] 与 i64 标签 [N]
二分类或多标签分类binary_cross_entropy_with_logitslogits 与同形状的 0/1 浮点目标
序列分类cross_entropy把 logits 展平为 [N×L,C],标签展平为 [N×L]

高维分类输出遵循 [N,C,...] 的类别轴约定。序列模型的类别通常在末维,因此上表先把序列维展平。

LossReduction::mean 求平均值,sum 求总和,none 保留逐项损失。序列填充位置可以使用交叉熵的 ignore_index 参数,计算指标时也应排除这些位置。

自动求导

自动求导根据前向运算计算梯度。同一变量参与多个分支时,反向传播会把各分支的梯度相加。

计算图与链式法则

前向计算把张量运算连接为计算图。参数通常是手动创建的叶张量,前向产生的中间张量记录自己的来源。开启求导的浮点叶张量可以通过 grad() 取得累计梯度。

如果 h=3x、L=h²,那么 dL/dh=2h、dh/dx=3,链式法则给出 dL/dx=6h=18x。x=2 时梯度为 36。多个分支使用同一个变量时,各分支的贡献再相加。

分支梯度相加

链式法则L = x² + x
x2.0
x × x 贡献 2x = 4.0
x 贡献 1
L6.00
dL/dx = 2x + 1 = 5.0
叶梯度 空

每次点击代表重新前向,再执行 backward。移动 x 会开始一组新的演示;连续反向会累计叶梯度。

求导与累计

auto x = dl::full({}, 2.0, {.requires_grad = true});
auto loss = x * x + x;
loss.backward();
std::cout << x.grad()->item<float>();  // 5

// 下一次前向建立新的计算图,叶梯度继续累计。
(x * x + x).backward();
std::cout << x.grad()->item<float>();  // 10
x.zero_grad();

评估模式与求导模式

model.eval() 控制 Dropout、BatchNorm 等层的行为;NoGradGuard 控制是否记录求导图。评估通常同时使用二者。

model.eval();
dl::NoGradGuard no_grad;
auto prediction = model(input);
一次更新的梯度边界

普通训练在每次更新前清空梯度。微批训练则先累计多个前向的加权损失梯度,再执行一次更新。Ctorch 当前提供一阶反向求导。

直接返回梯度

auto x = dl::full({3}, 2.0, {.requires_grad = true});
auto output = x * x;
auto seed = dl::ones_like(output);
auto gradients = dl::grad(output, {x}, seed);
auto dx = gradients[0]->to_vector<float>();  // {4, 4, 4}

非标量输出需要提供同形状的上游梯度。上例用全 1 的上游梯度求输出总和对 x 的导数。dl::grad 返回梯度而不写入叶张量的 grad()。未连接到输出的输入对应空的 optional。

一次反向会消费当前计算图。需要再次反向时应重新执行前向。当前求导接口只建立一阶反向计算,不对反向过程继续构图。

冻结参数

parameter.requires_grad_(false) 停止记录该参数的梯度。module.requires_grad_(false) 冻结整个子模块。冻结之前已有的梯度需清空。eval() 不会冻结参数,requires_grad_ 也不会切换 Dropout 或 BatchNorm 的模式。

练习:x=2 时对 L=x²+x 连续执行两次新的前向和反向,叶梯度是多少?

第一次为 5,第二次累计为 10。两次之间调用 x.zero_grad() 后,第二次得到 5。重新前向是因为每次反向会消费对应的计算图。

优化器与训练

本节介绍参数更新、批次训练、评估指标和学习率调度。下面的线性回归演示使用快速开始中的五个样本。

线性回归演示

SGD 实验ŷ = wx + b
目标样本 当前预测
均方误差 · log₁₀ 刻度
更新次数0
权重 w0.0000
偏置 b0.0000
当前 MSE9.0000
从 w = 0、b = 0 开始。

这里直接计算同一个 MSE 和 SGD 公式,无动量、无权重衰减。浏览器使用 JavaScript 双精度;C++ 示例默认 FP32,舍入结果可能略有不同。

均方误差与 SGD

L = mean((wx + b − y)²)∂L/∂w = 2 · mean((wx + b − y)x)∂L/∂b = 2 · mean(wx + b − y)w ← w − η ∂L/∂w    b ← b − η ∂L/∂b

初始梯度为 dw = −8、db = −2。学习率 0.1 时,第一次更新得到 w = 0.8、b = 0.2,更新后的 MSE 为 3.52。增大学习率后,参数可能在目标值两侧振荡。学习率过大会导致训练不收敛。

训练循环的计数

数据章节的四样本示例每批读取两个样本,一轮完成两次更新。下面运行 20 轮,共完成 40 次更新。页面末尾的完整分类程序使用 64 个训练样本和 batch_size=16,一轮四次更新,20 轮共 80 次。

梯度累计时,多个微批共同完成一次更新。因此微批次数不一定等于 step。学习率、保存间隔和日志中的计数应使用同一个约定。

批次训练与学习率调度

以下代码使用数据章节中的加载器和两层分类模型。Adam 的一阶、二阶矩估计在多次更新之间保留,优化器应在循环外创建。

dl::optim::Adam optimizer(model.parameters(), {.learning_rate = 0.03});
dl::optim::StepLR scheduler(optimizer, 20, 0.5);
for (int epoch = 0; epoch < 20; ++epoch) {
    model.train();
    loader.reset();
    while (auto batch = loader.next()) {
        optimizer.zero_grad();
        auto logits = model(batch->input);
        auto loss = dl::cross_entropy(logits, batch->target);
        loss.backward();
        optimizer.step();
        scheduler.step();
    }
}

上例每完成 20 次参数更新,把学习率乘以 0.5。调度器每调用一次 step() 就前进一步;也可以按轮调用,但间隔就以轮数计。混合精度跳过参数更新时,调度器也应保持原位。

优化器更新特点
SGD按当前梯度更新,可加入动量和 Nesterov 动量。
Adam / AdamW维护梯度的一阶和二阶矩。AdamW 单独施加权重衰减。
RMSprop用梯度平方的移动平均缩放更新。
Adagrad用累计梯度平方调整各参数的步长。

ParameterGroup 可以为不同参数组指定学习率和权重衰减。分阶段冻结和微调见 finetune.cpp。其他调度器包括 MultiStepLR、ExponentialLR、CosineAnnealingLR 和 LinearLR。

验证损失与准确率

struct Metrics {
    double loss;
    double accuracy;
};

// 最后一个批次可能较小,因此损失按样本数加权。
Metrics evaluate(dl::nn::Sequential& model, dl::data::DataLoader& loader) {
    model.eval();
    dl::NoGradGuard no_grad;
    loader.reset();
    double loss_sum = 0;
    dl::Index count = 0, correct = 0;
    while (auto batch = loader.next()) {
        auto logits = model(batch->input);
        const auto size = batch->target.numel();
        loss_sum += dl::cross_entropy(logits, batch->target).item<float>() * size;
        auto matches = dl::eq(dl::argmax(logits, 1), batch->target);
        correct += static_cast<dl::Index>(dl::sum(matches.astype(dl::DType::f32)).item<float>());
        count += size;
    }
    return {loss_sum / count, static_cast<double>(correct) / count};
}

validation 是单独的验证加载器。每批的平均损失乘以有效样本数后再累加,最后除以总样本数,避免末批较小时指标失真。evaluate(model, validation) 返回平均损失与准确率。NoGradGuard 在函数返回时恢复求导模式。

如何解释评估结果

loss 是平均损失,accuracy 是预测类别正确的样本比例。准确率只看最大分数对应的类别;损失还反映真实类别的分数,所以准确率不变时损失仍可能下降。

验证损失连续上升而训练损失继续下降时,可能发生过拟合。可以按验证损失保存最佳权重,并检查数据划分、预处理、训练轮数和模型容量。测试集不用于选择训练轮数或调节超参数。

evaluate 会把模型设为评估模式。下一轮训练先调用 model.train()。函数内的 NoGradGuard 会在返回时结束,指标使用普通数值累计,不保留训练计算图。

梯度累计与裁剪

显存不足时,把一次更新所需的批次拆成多个微批。假设整批有 N 个有效样本,每个微批有 n 个,平均损失乘以 n/N 后再反向。整个更新窗口只清空一次梯度并执行一次 step()。忽略标签和变长序列按有效目标数加权。

裁剪在所有微批反向结束后进行。dl::optim::clip_grad_norm(model.parameters(), 1.0) 把总梯度范数限制为 1。AMP 的梯度还原顺序见梯度还原与裁剪。

完整分类程序

完整程序见 training_basics.cpp。程序生成 64 个训练样本和 25 个不同位置的验证样本,完成批次训练、评估、学习率调度和保存恢复,不需要下载数据。

cmake --build --preset cpu --target ctorch_training_basics
../Ctorch-build/cpu/bin/ctorch_training_basics ../Ctorch-build/training-basics.ct

Windows 运行命令为 ..\Ctorch-build\cpu\bin\ctorch_training_basics.exe ..\Ctorch-build\training-basics.ct。程序输出每五轮的验证指标以及恢复后的指标。恢复前后的指标应一致;这个简单数据集只用于学习训练流程。

完整程序的输出

程序每五轮打印一次验证结果。训练完成后,先恢复检查点并重新评估,再预测一个新样本。一次 FP32 CPU 运行的末尾输出如下,具体浮点尾数可能随编译器变化。

epoch 20, loss 0.027944, accuracy 1.000000
restored step 80, loss 0.027944, accuracy 1.000000
prediction 1, class 1 probability 0.999995

restored step 80 表示已完成 80 次优化器更新。保存前后的验证指标相同,说明恢复后的参数用于同一验证集得到了相同结果。这个小数据集由固定规则生成,准确率 1.0 不能代表图像或文本任务的效果。

数据集训练示例

数据加载器负责采样顺序、批次整理与游标。训练集更新参数,验证集选择模型,测试集在选模完成后评估。优化器只在一个梯度累计窗口结束时更新。

MNIST 与语言模型示例从指定文件读取数据。数据准备和运行参数见示例说明。

保存与恢复

state_dict() 返回的张量与模型共享存储。保存最佳模型时应先复制数值,使快照独立于后续更新。

选择保存内容

用途保存内容恢复方式
预测或评估模型参数和缓冲区构造模型后用 load_state_dict 加载
继续训练模型、优化器、随机状态,以及使用中的加载器、调度器和 GradScaler构造对应对象后用 restore_checkpoint 恢复
记录最佳模型独立的模型数值副本用 clone_state 取得快照,再用于最终评估

优化器状态可能包含动量、梯度矩估计和步数。只恢复模型权重后重新创建优化器,后续更新通常不会等同于从中断位置继续训练。

状态表与独立快照

状态实验初始 weight = 1
model1当前参数
state_dict()1共享存储的状态表
clone_state(...)1独立数值快照
auto view = model.state_dict();
auto best = dl::clone_state(view);  // 独立 CPU 副本,脱离求导历史。
// 后续更新改变 model 和 view,best 保留采集时的数值。
model.load_state_dict(best);
dl::io::save("weights.ct", model.state_dict());

保存训练状态

// 在一次 optimizer.step() 完成后保存。
auto saved = dl::io::capture_checkpoint(model, optimizer, {.step = 100});
dl::io::save_checkpoint("training.ct", saved);

// 先构造相同结构、迁移设备,再创建优化器。
auto loaded = dl::io::load_checkpoint("training.ct");
auto progress = dl::io::restore_checkpoint(loaded, model, optimizer);

训练快照包含参数、buffer、优化器槽、训练进度和默认随机流。使用 DataLoader、调度器或 GradScaler 时,把对应对象一并传给快照接口。恢复从下一个更新窗口开始。

共享参数的名称

同一张量登记在 a.weight 与 b.weight 时,以字典序最小的 a.weight 作为规范名,只更新一次。改变子模块登记顺序不改变规范名。

state_aliases() 展示其他路径与规范名的对应。加载仍接受当前模块树中的旧别名,因此旧检查点可沿相同的登记路径恢复。如果修改了参数路径,需要先把检查点中的名称映射为新名称。

同时保存采样器和调度器

下面的代码使用同一组已构造对象演示保存和恢复。新进程中需要先构造相同结构的模型、优化器、数据集和调度器。

std::array schedules{dl::io::NamedScheduler{"learning_rate", scheduler}};
auto checkpoint = dl::io::capture_checkpoint(
    model, optimizer, {.epoch = epoch, .step = step}, &loader, {}, schedules);
dl::io::save_checkpoint("training.ct", checkpoint);

auto progress = dl::io::restore_checkpoint(
    dl::io::load_checkpoint("training.ct"), model, optimizer, &loader, {}, schedules);

在轮次中间恢复时,直接调用 loader.next() 继续当前排列,不要先调用 reset()。一轮结束后再开始新轮。按轮末保存的程序可以用 epoch 记录下一轮编号;这个计数约定由训练循环决定。

额外 Generator 通过 NamedGenerator 传入。使用 AMP 时,把 &scaler 作为最后一个参数传入。使用 PrefetchLoader 时,传入预取器本身以保存未交付批次。C++ 模型结构、数据文件和数据预处理配置由应用保存。

只加载权重进行推理

model.load_state_dict(dl::io::load("weights.ct"));
model.eval();
dl::NoGradGuard no_grad;
auto prediction = model(input);

权重文件保存名称、形状、类型和数值,不保存任意 C++ 代码。先建立对应模型,再加载权重。最佳模型权重适合最终评估;继续训练应加载同一时刻的完整训练检查点。

预测一个新样本

以下代码使用已训练的两输入、两输出分类模型。样本坐标为 (0.7,0.4),按示例规则属于类别 1。即使只有一个样本,也保留批次轴,把形状写成 [1,2]。

void predict(dl::nn::Sequential& model) {
    model.eval();
    dl::NoGradGuard no_grad;
    const float point[] = {0.7f, 0.4f};
    auto input = dl::from_span<float>(point, {1, 2});
    auto logits = model(input);
    auto probabilities = dl::softmax(logits, 1).to_vector<float>();
    auto category = dl::argmax(logits, 1).item<dl::Index>();
    std::cout << category << ' ' << probabilities[1] << '\n';
}

调用 predict(model) 后,category 是预测类别,probabilities[1] 是类别 1 的概率。函数返回时 NoGradGuard 结束。实际应用还需要保存类别编号与名称的对应关系,并复用训练时的输入预处理。

从轮末继续训练

training_basics 在第 20 轮结束后保存,epoch 记录下一轮的编号 20,加载器已经耗尽。恢复后可从这个编号继续,并在每一轮开始时重置加载器。下面把训练继续到第 25 轮。

for (dl::Index epoch = progress.epoch; epoch < 25; ++epoch) {
    model.train();
    loader.reset();
    while (auto batch = loader.next()) {
        optimizer.zero_grad();
        auto loss = dl::cross_entropy(model(batch->input), batch->target);
        loss.backward();
        optimizer.step();
        scheduler.step();
        ++progress.step;
    }
    progress.epoch = epoch + 1;
}

这里的 model、optimizer、loader、scheduler 和 progress 都是恢复后的对象。循环应放在求导开启的作用域中。若在一轮中间保存,则先用 next 完成剩余批次,再 reset 开始下一轮,不能套用上面每轮先 reset 的写法。

自定义模块

自定义模块继承 Module 或 UnaryModule,登记参数并实现前向计算。下面的 Scale 层只有一个可学习参数。

登记参数

class Scale : public dl::nn::UnaryModule {
public:
    Scale() : weight_(register_parameter(
        "weight", dl::full({}, 2.0, {.requires_grad = true}))) {}

    dl::Tensor forward(const dl::Tensor& input) override {
        return input * bound(weight_);
    }

private:
    dl::Tensor weight_;
};

register_parameter 让优化器和状态保存能发现参数;bound 让前向读取本次调用的参数。同一层的参数、buffer 和子模块共用名称空间,名称非空、无点且唯一。

Scale 继承 UnaryModule,因为它接收一个张量并返回一个张量。构造函数登记名为 weight 的叶参数,forward 使用输入和参数相乘。register_parameter 返回已登记的句柄,成员变量 weight_ 和模块状态表引用同一参数。

普通调用与函数式调用

参数绑定演示input = 3
3 × bound(weight) = 6

普通调用读取模块登记的参数。外部状态的更改保留在自己的存储中。

Scale model;
dl::nn::FunctionalModule functional(model);
auto state = functional.state();
state.parameters[0] = dl::full({}, 5.0, {.requires_grad = true});
auto input = dl::full({}, 3.0);
auto result = functional.call(state, dl::RandomKey{7}, [&] {
    return model(input);  // bound(weight_) 在此读取外部状态。
});

查看模块与参数

dl::summary(model) 展示模块路径、训练模式、参数量、冻结状态和共享关系;named_parameters() 提供可遍历的名称与张量。

已有算子组成的前向会自动记录梯度。外部运算需要提供反向公式,见自定义 VJP。

子模块、缓冲区与残差连接

register_module 登记子模块,让参数遍历、设备迁移、train/eval 和状态保存递归作用于它。register_buffer 登记不由优化器更新的状态,例如运行均值。前向中使用 bound_buffer(buffer) 读取本次绑定的缓冲区。参数使用 bound,缓冲区使用 bound_buffer。

残差连接写成 input + branch(input)。分支输出与输入需要形状相容。多个层共享权重时登记同一个 Tensor 句柄,优化器会按身份去重。复制数值产生的新张量不属于共享权重。

完整的参数登记与前向实现见 parameter_layer.cpp;复合模块和函数式缓冲区更新见 functional_module.cpp。

设备与混合精度

CPU 和 CUDA 使用相同的张量与模块接口。模型参数和输入必须位于同一设备。混合精度训练保留 FP32 参数,并对部分前向算子使用低精度计算。

模型与数据迁移

const auto device = dl::Device::cuda(0);
model.to(device);
input = input.to(device);
target = target.to(device);
dl::optim::AdamW optimizer(model.parameters(), {.learning_rate = 0.001});

主参数保持 FP32。autocast 选择 GEMM、卷积等算子的低精度输入,GradScaler 管理 FP16 梯度的数值范围。CUDA 构建方式见构建手册。

一个 FP16 更新窗口

dl::GradScaler scaler;
// 每次更新复用同一个 scaler,保留动态倍率。
optimizer.zero_grad();
auto loss = [&] {
    dl::AutocastGuard autocast(dl::DeviceType::cuda, dl::DType::f16);
    return dl::mse_loss(model(input), target);
}();
scaler.scale(loss).backward();
scaler.step(optimizer);  // 还原梯度、按有限性更新参数,再调整倍率。

梯度还原与裁剪

将上例最后一行替换为以下代码。unscale_ 原地还原梯度,裁剪读取还原后的数值,然后分别完成参数更新和倍率调整。

const bool finite = scaler.unscale_(optimizer);
if (finite) {
    dl::optim::clip_grad_norm(model.parameters(), 1.0);
    optimizer.step();
}
scaler.update(finite);
CUDA 数据读取

CUDA 的 item() 和 to_vector() 会把数值带回 CPU。集中读取阶段指标,让连续的训练更新尽量留在设备上。

cuDNN 卷积和融合注意力属于可选后端。低精度舍入与运算次序可能不同,比较结果应使用相同输入、精度与适合该精度的误差标准。

选择精度

路径参数与计算
CPU常规训练使用 FP32,数值比较可使用 FP64。
CUDA FP32参数、梯度和主要计算均使用 FP32。
CUDA FP16 AMP参数保持 FP32,适用算子使用 FP16,配合 GradScaler。
CUDA BF16 AMP参数保持 FP32,适用算子使用 BF16,通常不需要梯度缩放。

autocast 只改变支持该路径的算子,不把模型中所有运算统一转换为低精度。标签继续使用 i64,掩码继续使用布尔类型。优化器更新主参数时使用 FP32。

AMP 与累计窗口

一个累计窗口使用同一个缩放倍率。每个微批先按有效样本数加权,再调用 scaler.scale(loss).backward()。所有微批完成后只还原、裁剪、更新和调整倍率一次。scaler.step(optimizer) 已包含倍率调整,不要随后再次调用 update。

传输与计时

数据预取见设备预取。自定义异步传输可以使用 Transfer;其生命周期覆盖传输过程,源数据在传输完成前保持不变。设备计算通常异步提交,性能计时需要覆盖实际完成时间。减少循环内逐次读取标量,可减少 CPU 等待 CUDA 的次数。

函数式状态与执行计划

函数式调用显式接收参数、缓冲区和随机状态。执行计划预先记录固定的运算结构,并在每次调用时接收输入。

函数式状态

FunctionalModule::state() 交付独立参数和 buffer。call 读取外部参数,返回本次更新后的 buffer;调用者显式接回这些状态。

dl::nn::FunctionalModule functional(model);
auto state = functional.state();
dl::RandomKey key{73};
auto result = functional.call(state, key, [&] { return model(input); });
state.buffers = std::move(result.buffers);
++key.stream;  // 下一次前向使用新的显式随机流。
auto gradients = dl::grad(dl::mean(result.output), state.parameters);

显式随机 key

dl::RandomKey root{73};
auto [initialization, augmentation] = dl::split(root);
auto weight = dl::randn(initialization, {3, 2}, {.requires_grad = true});
auto step_key = dl::fold_in(augmentation, 5);
auto noise = dl::randn(step_key, {4, 3});

相同 key 和参数产生相同的随机序列,不消费 Generator 的状态。split 为不同用途派生 key,fold_in 可把步数或样本编号加入随机流。重复使用相同 key 会重复采样,应为不同调用分配不同的标签或子 key。

函数式优化器

std::optional<dl::optim::AdamMoments> moments;
for (int step = 0; step < 20; ++step) {
    auto loss = dl::mse_loss(dl::matmul(input, weight), target);
    auto gradients = dl::grad(loss, {weight});
    auto next = dl::optim::adam(weight, *gradients[0], moments,
                                {.learning_rate = 0.01});
    weight = std::move(next.parameter);
    moments = std::move(next.moments);
}

上例的 weight 是参与求导的叶张量。更新函数返回新的参数和状态,调用者负责接回它们。新参数不保留上一步的计算图。原地版本如 adam_ 复用参数和状态存储。更多算法见 functional_optimizer.cpp。

add 与 ReLU 融合

执行计划示意
xbias
→
Add + ReLU
→output
计划操作数1
融合次数1

示意范围是相邻、单一消费者的 add → ReLU。融合减少一次调度及中间张量;这里展示结构,不测量运行速度。

dl::plan::Graph graph;
auto x = graph.input(dl::tensor_spec(input), "input");
auto b = graph.input(dl::tensor_spec(bias), "bias");
auto sum = graph.call(dl::plan::ops::add(), {x, b});
auto y = graph.call(dl::plan::ops::relu(), {sum});
auto plan = graph.compile({y});
std::cout << dl::describe(plan) << '\n';
auto workspace = plan.prepare();
auto outputs = workspace({input, bias});

何时使用固定计划

输入规格和执行路径稳定时,计划复用宿主调度结构与工作区。每次调用重新绑定输入和参数,新的求导图仍然在本次调用中建立。不同规格或路径对应新的计划。

运行时缓冲由实际持有者决定生命周期;长期保存输出会增加存活存储。计划不是通用 JIT,也不会把任意 C++ 控制流自动转换成图。多输出、激活重算与函数式组合见执行手册。

激活重算

auto features = dl::checkpoint({input, weight, bias}, [](auto values) {
    return dl::relu(dl::matmul(values[0], values[1]) + values[2]);
});

激活重算减少前向保留的中间值,并在反向时重新计算该区域。区域使用的数据和可求导参数都通过输入传入。回调中的持久缓冲区更新应移到区域之外,避免重算时再次修改状态。默认会重放随机流;显式 key 的例子见 activation_checkpoint.cpp。

C17、DLPack 与 NPU

C17 接口用于 C 程序。custom_vjp 用于自定义反向计算。DLPack 用于交换张量存储。OpenVINO 扩展用于加载 ONNX 或 IR 模型并推理。

C17 接口的拥有权

<dl/c_api.h> 提供 opaque handle 和状态码。创建和运算返回拥有型句柄,调用方通过对应的 release 结束生命周期;错误信息由 dl_last_error() 提供。

外部计算与张量交换

目标入口负责的内容
组合已有算子普通函数 / Module由已有算子自动连接梯度。
外部数值计算custom_vjp提供前向及输入梯度公式。
共享张量存储DLPack 扩展转交存储拥有权与设备 stream 依赖;求导由应用连接。
Intel NPU 推理OpenVINO 扩展编译静态 ONNX / IR,管理推理请求与宿主输入输出。

自定义 VJP

VJP 表示上游梯度与局部雅可比矩阵的乘积。下面为 y = factor × x² 提供反向公式。外部计算也可以按相同方式接入。

dl::Tensor scaled_square(const dl::Tensor& input, double factor) {
    return dl::custom_vjp({input},
        [factor](dl::VjpContext& context, std::span<const dl::Tensor> values) {
            context.save_for_backward({values[0]});
            return values[0] * values[0] * factor;
        },
        [factor](const dl::VjpContext& context, const dl::Tensor& gradient) {
            dl::VjpGradients result(context.input_count());
            if (context.needs_input_grad(0))
                result[0] = gradient * context.saved_tensors()[0] * (2 * factor);
            return result;
        });
}

反向返回的每项对应一个输入,形状、类型和设备与该输入匹配。通过 save_for_backward 保留反向需要的值。前向与梯度公式需要一起验证,见 tests。

DLPack 存储交接

DLPack 扩展通过托管描述符共享张量存储。导入会消费描述符的拥有权,后续由接收方触发释放;已经消费的描述符不能再次转交。共享存储不等于共享求导图,跨框架反向需要 custom_vjp。

CUDA 交接还需要建立生产方与消费方 stream 的依赖。具体导入、导出与同步步骤见扩展手册以及 dlpack_vjp.cpp。

OpenVINO 推理

#include <dl/npu.h>

dl::npu::CompiledModel model("model.onnx", {
    .performance = dl::npu::Performance::latency,
    .input_shapes = {{1, 3, 224, 224}}
});
auto request = model.create_request();
request.set_input(0, image);
request.infer();
auto prediction = request.output();

启用 CTORCH_USE_NPU 后,应用链接 ctorch::npu。输入需要符合模型端口及预处理要求。此实验性扩展提供推理,不参与 CPU/CUDA 自动求导,也不把 .ct 权重自动导出成 ONNX。

NPU 异步请求

OpenVINO 扩展需要 OpenVINO 2026.0+、可用的目标设备插件和对应驱动。输入形状在编译时确定。完成预处理后,set_input 将数据交给请求;输出是 CPU 张量。

auto request = model.create_request();
request.set_input(0, image);
request.start_async();
// 此处可以准备下一次请求所需的数据。
request.wait();
auto output = request.output();

一个请求完成后再复用其输入和输出。并行推理使用不同的请求对象,output_into 可复用输出缓冲区。NPU 路径目前用于推理,不提供 NPU 训练或 Ctorch 模型导出器。

扩展示例与手册

扩展算子后,可使用独立测试入口比较前向结果与数值梯度。测试目标默认关闭。

API 与示例索引

按任务查找头文件、手册和完整程序。

头文件

入口内容
dl.hC++ 核心接口的统一入口
tensor.h / ops.h张量、视图、算子和损失
autograd.hbackward、grad、自定义 VJP 与激活重算
nn.h模块、网络层和模型状态
data.h数据集、加载器、整理函数和预取
optim.h / scheduler.h优化器、参数组、梯度处理与调度器
io.h / inspect.h保存恢复、张量信息与模型摘要
amp.h / random.h混合精度、梯度缩放与随机状态
functional.h / plan.h函数式模块和执行计划
c_api.hC17 类型、句柄与状态码

完整程序

学习内容程序
线性回归first_training.cpp
数据、分类、评估与恢复training_basics.cpp
图像分类mnist_mlp.cpp、mnist_cnn.cpp
序列模型recurrent_train.cpp、byte_transformer.cpp
模块、微调与共享参数parameter_layer.cpp、finetune.cpp
函数式更新functional_module.cpp、functional_optimizer.cpp
执行计划与内存execution_plan.cpp、activation_checkpoint.cpp
C17、外部计算与交换c_train.c、custom_vjp.cpp、dlpack_vjp.cpp

常见问题

现象处理方法
矩阵乘法或分类损失的形状不匹配用 describe 查看形状。检查矩阵内维、分类输出的类别轴以及标签的 i64 类型。
训练时梯度越来越大检查是否在每次更新前清空梯度,以及累计损失是否按有效样本数加权。
同一个 loss 不能再次反向重新执行前向生成计算图。
评估结果随训练模式变化评估时同时使用 model.eval() 和 NoGradGuard。
最佳模型随训练一起改变使用 clone_state 复制状态表中的数值。
恢复后采样顺序改变保存加载器和随机状态。轮次中间恢复后直接 next,不先 reset。
GPU 更新之间频繁等待检查循环内 item、to_vector 和设备同步;集中结算指标。
OpenVINO 无法读取 .ct.ct 是 Ctorch 状态文件。OpenVINO 需要 ONNX 或 IR 模型。

支持范围与手册

本教程覆盖单进程 CPU 和单卡 CUDA 训练、一阶反向求导、常用网络层、训练状态管理与扩展接口。分布式训练、高阶自动求导和任意 C++ 控制流的自动编译不属于当前接口范围。OpenVINO 是独立推理扩展。

更多接口约定见张量与梯度、训练与状态、执行计划、扩展和NPU。构建选项见构建手册。测试程序单独放在 tests,通过 CTORCH_BUILD_TESTS=ON 启用。