Ctorch 教程 › 快速开始
快速开始
本节构建 Ctorch,并用线性回归学习 y = 2x + 1 。示例只需要 CPU。
本教程需要 C++ 基础。代码示例使用 C++20。交互演示由浏览器中的 JavaScript 计算,C++ 程序需要使用 Ctorch 编译运行。
入门课程按张量、数据、模型、梯度、训练、保存恢复的顺序组织。完成保存恢复后即可编写 CPU 训练程序。自定义模块、CUDA 和扩展接口可按实际需要阅读。
训练步骤
每次训练先计算预测和损失,再计算梯度并更新参数。点击下方步骤可查看对应代码。
交互导览
01 准备数据→
02 模型预测→
03 计算损失→
04 反向传播→
05 更新参数
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
Linux / macOS
Windows
../Ctorch-build/cpu/bin/ctorch_first_training
..\Ctorch-build\cpu\bin\ctorch_first_training.exe
基本概念
名称 在本例中的含义
样本与特征 每个 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 返回最大分数的位置,也就是预测类别编号。
交叉熵等于真实类别概率的负自然对数。上例真实标签为 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
输入 x 2.0
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
权重 w 0.0000
偏置 b 0.0000
当前 MSE 9.0000
学习率 η 0.10
更新 1 步 连续 50 步 重置 从 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 独立数值快照
参数增加 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
普通调用 模型自身 weight = 2 函数式调用 外部状态 weight = 5
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 融合
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 与示例索引
按任务查找头文件、手册和完整程序。
头文件
完整程序
常见问题
现象 处理方法
矩阵乘法或分类损失的形状不匹配 用 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 启用。
标记为已读