手写一个神经网络训练框架 (TinyTorch)

December 28, 2024

前段时间比较闲,正好在学习 AI 相关的东西,决定用 C++ 写一个神经网络训练框架,类似于图形学的入门项目软件渲染器,不依赖第三方库,从零开始实现,目标是能进行 MNIST 训练,经过几周的投入,目前算是初步完成了,代码已放到 Github:

https://github.com/keith2018/TinyTorch

之前的一些手写项目:

框架

项目基于 C++ 实现,可选依赖 OpenBLAS 进行矩阵乘法加速(gemm),可通过 cmake 参数 -DUSE_BLAS=ON/OFF 来开关,无其它三方依赖。C++ 类/接口主要模拟 PyTorch 的 API,包括 Tensor/Function/Module/Loss/Optimizer/Scheduler/Dataset/DataLoader 等,麻雀虽小,五脏俱全,demo 目录有几个示例程序,包括 MNIST 的完整训练代码 demo/demo_mnist.cpp

下面是 C++ 版的 MNIST 模型定义,与 PyTorch 写法类似,区别主要是多了手动 registerModules()(目前还不能自动注册):

class Net : public nn::Module {
 public:
  Net() { 
    registerModules({conv1, conv2, dropout1, dropout2, fc1, fc2}); 
  }

  Tensor forward(Tensor &x) override {
    x = conv1(x);
    x = Function::relu(x);
    x = conv2(x);
    x = Function::relu(x);
    x = Function::maxPool2d(x, 2);
    x = dropout1(x);
    x = Tensor::flatten(x, 1);
    x = fc1(x);
    x = Function::relu(x);
    x = dropout2(x);
    x = fc2(x);
    x = Function::logSoftmax(x, 1);
    return x;
  }

 private:
  nn::Conv2D conv1{1, 32, 3, 1};
  nn::Conv2D conv2{32, 64, 3, 1};
  nn::Dropout dropout1{0.25};
  nn::Dropout dropout2{0.5};
  nn::Linear fc1{9216, 128};
  nn::Linear fc2{128, 10};
};

自动微分的实现如下图所示:

对 Tensor 的计算都是通过 Function 来实现,不同的 Function 需要实现各自的 forward()backward() 方法,Function 中通过 nextFuncs_ 字段记录依赖关系,也就是说 Function 充当了计算图节点的角色,backward() 的时候再根据依赖关系反向遍历,详见 AutogradMeta::backward 函数的实现,自动微分更详细的解释可以参考这篇文章 自动微分(Automatic Differentiation):实现篇

Tensor

Tensor 实现了 torch.tensor (或 Numpy) 的常用接口,主要有:

  • Tensor 创建:shape、zeros、ones、randn、arange、linspace 等
  • 维度操作:view、reshape、transpose、squeeze、flatten、index、split、stack 等
  • 数学运算:加减乘除、matmul、sum、mean、var、min、max、sin、cos 等

项目里实际上是分成两个类来实现的:TensorImplTensorTensorImpl 只负责基础的张量运算,不涉及梯度相关功能,类似于 torch 的 at::Tensor,上层的 Tensor 再对 TensorImpl 进行包装,添加了梯度、反向传播相关功能实现。

TensorImpl 的数据结构如下:

typedef std::vector<int32_t> Shape;

class TensorImpl {
public:
  ...

protected:
  int32_t dimCount_ = 0;
  int32_t elemCount_ = 0;
  Shape shape_;
  Shape strides_;
  float *data_ = nullptr;
};

为了简化实现只支持了 float 数值类型,目前是纯 CPU 运算,且没做什么优化,部分接口的实现可能性能不高,如 transpose、reduce、broadcast 等,矩阵乘法 matmul 支持依赖 BLAS 库来加速。

Tensor 类则主要包含了数据 data_ 和梯度信息 gradMeta_,具体的反向传播计算由 AutogradMeta::backward() 完成

class Tensor {
public:
  ...

private:
  bool requiresGrad_ = false;
  std::shared_ptr<TensorImpl> data_;
  std::shared_ptr<AutogradMeta> gradMeta_ = nullptr;
};

struct AutogradMeta {
  void backward(const Tensor &grad);
  ...

  Tensor grad_;
  std::shared_ptr<Function> gradFunc_;
  ...
};

Function

Function 除了实现各类算子的 forward()backward(),还充当了计算图节点的角色,计算图的构建由 Function::callForward() 实现:

Tensor Function::callForward(const std::vector<const Tensor*>& inputs) {
  auto output = forward(inputs);

  auto requiresGrad = false;
  for (const auto input : inputs) {
    if (input->isRequiresGrad()) {
      requiresGrad = true;
      break;
    }
  }

  std::shared_ptr<Function> gradFunc = nullptr;
  if (NoGradScope::isGradEnabled() && requiresGrad) {
    for (const auto input : inputs) {
      if (input->isRequiresGrad()) {
        nextFuncs_.push_back(input->getGradFunc());
      }
    }
    gradFunc = shared_from_this();
  }

  return Tensor(std::move(output), requiresGrad, gradFunc);
}

Function 目前仅支持单个输出,首先调用子类的 forward() 得到 output,然后判断结果 Tensor 是否需要梯度(任意 input 需要梯度),如果需要梯度,则将所有 input 的 gradFunc 记录到 nextFuncs_ 字段,从而构建了计算图,最后将自身作为 gradFunc 存储到结果 Tensor 中,也就是说 Tensor 的 gradFunc 是生成这个 Tensor 的 Function,而对于叶子节点的 Tensor,其 gradFunc 是特殊的 FuncLeafFuncLeaf 不需要 forward()backward() 则是将梯度写回到 Tensor 中:

TensorImpl FuncLeaf::forward(const std::vector<const Tensor*>& inputs) {
  return {};
}

std::vector<TensorImpl> FuncLeaf::backward(const TensorImpl& grad) {
  auto retGrad = grad;
  auto owner = owner_.lock();
  ...
  *owner->grad_.data_ = retGrad;
  return {retGrad};
}

Function 目前只实现了训练 MNIST 所必需的一些,如加减乘除、relu、linear、conv2d、softmax 等

Module

Function 本身是不带参数的,Module 则在 Function 的基础上增加了参数管理,主要有如下接口:

class Module {
 public:
  virtual std::vector<Tensor *> parameters();
  virtual std::vector<Tensor *> states();

  virtual void zeroGrad();
  virtual Tensor forward(Tensor &x);

  void eval();
  void train(bool mode = true);
};

同时 Module 是一个递归结构,Module 本身可以添加子 Module,Module 可以设置 eval 和 train 两种模式,用于区分推理和训练,部分算子在两种模式下有区别,如 Dropout、BatchNorm 等,Module 的参数初始化单独放到了 Init 类中,目前支持 uniformkaimingUniform 两种。

Loss

Loss 也是 Module 的子类,通常 forward() 会有两个输入,同时会有一个 reduction 参数,可以取值 MEANSUM,目前项目中只实现了 MSELossNLLLoss

Optimizer

优化器接收 Module 返回的 parameters 参数列表,然后在 step() 中根据梯度更新参数

class Optimizer {
 public:
  Optimizer(std::vector<Tensor *> &&parameters, float lr, float weightDecay);

  void step();
  void zeroGrad();
};

项目中实现了常用的几种优化器: SGD、Adagrad、RMSprop、AdaDelta、Adam、AdamW 等,参数与 PyTorch 一致,L2 正则化 Weight Decay 作为基础功能放在了 Optimizer 基类中。

Scheduler

Scheduler 主要是用来调节学习率:在 step() 方法中更新 Optimizer 的 lr,作为示例,项目中只实现了 StepLR

class StepLR : public Scheduler {
 public:
  StepLR(Optimizer &optimizer, int32_t stepSize, float gamma = 0.1, int32_t lastEpoch = -1);

  float getLr() override;

 private:
  int32_t stepSize_;
  float gamma_;
};

Data

数据相关的接口,项目中模拟实现了 DatasetDataLoaderTransform,Dataset 主要是 size()getItem() 接口,定义如下:

class Dataset {
 public:
  virtual ~Dataset() = default;

  virtual size_t size() const = 0;
  virtual std::vector<Tensor> getItem(size_t idx) = 0;
};

为方便使用,项目中内置实现了 DatasetMNIST,可直接调用。DataLoader 则接收 datasetbatchSizeshuffle 等作为入参,然后实现迭代器接口,返回 stack 后的 batch 数据

class DataLoader {
 public:
  DataLoader(const std::shared_ptr<Dataset>& dataset, size_t batchSize,
             bool shuffle = false, bool dropLast = false);

  class Iterator {
   public:
    Iterator(const DataLoader& loader, size_t startIdx)
        : loader_(loader), batchIdx(startIdx) {}

    bool operator!=(const Iterator& other) const;
    bool operator==(const Iterator& other) const;

    Iterator& operator++();
    std::tuple<size_t, std::vector<Tensor>> operator*() const;

   private:
    const DataLoader& loader_;
    size_t batchIdx;
  };

  Iterator begin() const;
  Iterator end() const;

  size_t size() const;
};

Transform 则是模拟 torchvision.transforms 用于数据处理,同时也模拟了 Compose 将多个 Transform 组合在一起,作为示例,项目中只实现了 Normalize 用于 MNIST 数据的归一化

class Normalize : public Transform {
 public:
  Normalize(float mean, float std) : mean_(mean), std_(std) {}
  Tensor process(Tensor& input) const override;

 private:
  float mean_;
  float std_;
};

编译&运行

项目使用 CMake 作为构建工具,自测在 MacOS、Linux、Windows 上都能正常编译运行,如果打开了 USE_BLAS 开关,Linux 上需要提前安装好 OpenBLAS,可以参考文档 OpenBLAS../install.md,顺利的话,demo 跑起来可以看到如下 MNIST 的训练输出:

[DEBUG] demo_mnist ...
[DEBUG] train size: 60000
[DEBUG] test size: 10000
[DEBUG] Train Epoch: 0 [64/60000 0.11%], loss: 2.316415
[DEBUG] Train Epoch: 0 [128/60000 0.21%], loss: 3.804285
[DEBUG] Train Epoch: 0 [192/60000 0.32%], loss: 2.454196
[DEBUG] Train Epoch: 0 [256/60000 0.43%], loss: 2.216763
[DEBUG] Train Epoch: 0 [320/60000 0.53%], loss: 2.127527
[DEBUG] Train Epoch: 0 [384/60000 0.64%], loss: 2.279303
[DEBUG] Train Epoch: 0 [448/60000 0.75%], loss: 2.044209
[DEBUG] Train Epoch: 0 [512/60000 0.85%], loss: 1.981432
[DEBUG] Train Epoch: 0 [576/60000 0.96%], loss: 1.833691
[DEBUG] Train Epoch: 0 [640/60000 1.07%], loss: 1.979517
[DEBUG] Train Epoch: 0 [704/60000 1.17%], loss: 1.928144
[DEBUG] Train Epoch: 0 [768/60000 1.28%], loss: 1.788541