Libtorch c++ 基本概念

上一节在VS 2019上配置了Libtorch c++,并进行了测试。有了基本的环境设置,可以进入更有序的学习。

首先,讨论怎么利用面向c++的接口定义模块(module)并与之交互。从最基本、最小规模的模块开始,然后利用面向c++接口内置的模块搭建完整的对抗生成网络模型。

1、libtorch的主要模块的分类

Component

Description
torch::Tensor可以自动微分,支持CPU和GPU的张量计算库
torch::nn用于神经网络建模的一系列可组合模块A collection of composable modules for neural network modeling
torch::optimOptimization algorithms like SGD, Adam or RMSprop to train your models
torch::dataDatasets, data pipelines and multi-threaded, asynchronous data loader
torch::serializeA serialization API for storing and loading model checkpoints
torch::pythonGlue to bind your C++ models into Python
torch::jitPure C++ access to the TorchScript JIT compiler

下面这个链接介绍了全部的Libtorch 类及其组织结构

https://pytorch.org/cppdocs/api/library_root.html

2、模块接口的基础

与python的接口一致,面向c++的Libtorch神经网络接口也是由可重复使用的可组合模块组成,称之为模块modules。有一个模块module的基类,所有其他模块都继承于它。在python中,这个基类是torch.nn.module,在c++接口下,这个基类是torch::nn::Module。另外,forward()方法执行模块包含的算法,一个模块通常有三个部分组成,分别是参数parameter,缓冲区buffers(相当于数据)和子模块(submodules)。

参数parameter和缓冲区buffers以张量的形式存储状态,参数记录梯度,但是缓冲区不保存。参数通常是神经网络可训练的权重,比如缓冲区包含了批量表转化的均值和方差。为了可以重复利用特定的逻辑块和状态,Libtorch的接口允许模块可以相互嵌套。被嵌套的模块成为子模块submodules。

由于python由很多反射功能,可以通过示例获取关于类型的很多参数,而c++语言本身没有这些功能,这增加了Libtorch的复杂性。一个显著的特点是,参数parameter、缓冲区buffer和子模块submodule必须显式地注册。注册之后,才可以通过类似parameters()和buffers()的方法获取整个模块及其子模块的参数和缓冲区数据。类似地,像to(...)的方法,比如to(torch::kCUDA)可以把整个模块和子模块的所有参数和缓冲区从cpu转移到CUDA内存。

3、定义模块并注册参数

注册参数的方法是register_parameter(),比如下面的代码通过注册参数形成了线性神经网络模块/单元。

#include <torch/torch.h>

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M) {
    W = register_parameter("W", torch::randn({N, M}));
    b = register_parameter("b", torch::randn(M));
  }
  torch::Tensor forward(torch::Tensor input) {
    return torch::addmm(b, input, W);
  }
  torch::Tensor W, b;
};

像Python一样,我们定义类型Net(这里为了简便,采用了结构struct,而不是class)并让它继承自模块的基类torch::nn::Module。在构造函数内,用随机数函数torch::randn,与python中的torch.randn一样,定义矩阵。一个有趣的不同是如何注册参数。在python中,直接把矩阵用torch.nn.Parameter包括即可,但是在c++中,必须通过register_parameter方法,注册为参数。这么做的原因是Python的接口能探测torch.nn.Parameter的属性,并自动给矩阵注册。但是,c++中,反射非常有限,所以提供了更传统的方法。

4、注册子模块并遍历模块层次结构

按照类似的方法可以注册模块,方法为register_module。下面的定义的网格Net中注册的线性模块torch::nn::Linear

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M)
      : linear(register_module("linear", torch::nn::Linear(N, M))) {
    another_bias = register_parameter("b", torch::randn(M));
  }
  torch::Tensor forward(torch::Tensor input) {
    return linear(input) + another_bias;
  }
  torch::nn::Linear linear;
  torch::Tensor another_bias;
};

Libtorch提供了很多内置的神经网络模块,除了这里用到的torch::nn::Linear,还有torch::nn::Dropout, torch::nn::Conv2d等,全部的内置神经网络模块可以在这个链接看到,这些内置的神经网络模块多达122种,通过这些模块基本可以组合出大部分自己想要的复杂神经网络模型。实际上,我感觉,深度学习主要任务由四部分(1)样本准备/采集;(2)模型搭建/组合;(3)训练调试;(4)部署应用。其中第二部分,神经网络搭建,基本是最关键的部分(需要针对具体问题,搭建合适的模型),就是这些模块的组合搭配。

https://pytorch.org/cppdocs/api/namespace_torch__nn.html

上述代码比较微妙的地方在于为什么的模块的创建需要构造函数参数列表,而参数在构造函数内创建。这么做的主要原因,我们将在下面c++端所有权模型中触及到。但是,最总结果是,我们可以像python那样,递归地获取所有模块树的参数。通过parameters()方法可以返回矩阵std::vectortorch::Tensor,它可以通过迭代方式访问。

int main() {
  Net net(4, 5);
  for (const auto& p : net.parameters()) {
    std::cout << p << std::endl;
  }
}

运行结果

类似于python 中的三参数形式,c++接口也可以查看带名字的参数named_parameters(),它的返回结果是OrderedDict,这个类型在python的接口中同样存在。

int main()
{
    Net net(4, 5);
    for (const auto& pair : net.named_parameters()) {
        std::cout << pair.key() << ":\n" << pair.value() << std::endl;
    }

    return EXIT_SUCCESS;
}

5、模块所有权模型

此时,我们已经知道如何用c++接口定义模块,注册参数,注册模块,通过类似parameters()的方法便利模块体系,最后也能运行模块的正演forward()方法。但是,在c++接口中,还有其他很多方法、类型和主题需要学习。其中很重要的一个概念是模块所有权模型(ownership model),它涉及到torch::nn::Module的所有子类型。

可以这么讲,所有权模型表示模块的存储和传递方式,它决定了谁或什么拥有特定的模块实例。在python中,对象动态地在堆上创建,可能有多个引用语义,这使得python的对象使用起来很简单直接。实际上,在python中,基本可以忘记对象在哪,如何引用,只关注如何完成要做的事情。

但是,c++作为低级别的语言,提供了更多的可控选项。这增加了复杂度,严重影响了c++端口的设计和人工工程学改造。具体来说,对c++接口的模块,我们有的选项是利用值语义和引用语义,前者最简单,对象创建在栈上,当传递给函数时可以拷贝、移动完成,或通过引用或值传递。如下所示

struct Net : torch::nn::Module { };

void a(Net net) { }
void b(Net& net) { }
void c(Net* net) { }

int main() {
  Net net;
  a(net);
  a(std::move(net));
  b(net);
  c(&net);
}

对第二种情况,引用语义,我们采用智能指针std::shared_ptr, 引用语义的好处是,类似于python,它减少了模块在函数传递过程中的过度考虑预计如何声明。

struct Net : torch::nn::Module {};
void a(std::shared_ptr<Net> net) { }

int main() {
  auto net = std::make_shared<Net>();
  a(net);
}

我们的经验表明,来自动态语言的研究者更倾向于引用语义,而不是值语义,尽管c++语言更倾向于使用后者。还需要注意的是,torch::nn::Module的设计,为了接近Python API的人体工程学,依赖于共享所有权。比如上面的例子对Net的定义(这里有所简化):

struct Net : torch::nn::Module {
  Net(int64_t N, int64_t M)
    : linear(register_module("linear", torch::nn::Linear(N, M)))
  { }
  torch::nn::Linear linear;
};

为了利用linear子模块,我们希望直接把他保存在类中。但是,也想让基类知道并获取这个字类。这样,就必须保存一个字类的引用。此时,我们已经知道共享所有权的必要性。由于torch::nn::Module基类和Net具体类都需要字类的引用,因此,基类需要以共享指针shared_ptr方式保存子模块,而且具体类也需要这么做。

但是,等等。上述代码中并没有看到共享指针share_ptr,为什么?因为,std::shared_ptr<MyModule>相关的代码太多了,这里为了给研究者保持高产出,和简便性,我们想出了一个精心策划的策略来隐藏对shared-ptr的提及,这通常是为值语义保留的一个好处,同时保留了引用语义。实际上是做了很多封装的工作,在文档中可以具体看到。

总之,你会使用哪种所有权模型、哪种语义?面向C++的接口很好地支持模块保持着提供的所有权模型。这种机制仅有的缺点是,在模块声明下面需要额外的一行引用(boilerplate)。也就是说,最简单的模型仍是值语义模型。但是,迟早你会发现,由于技术原因,值语义模型不能适应全部场合。必须,序列化的接口,(torch::save和torch::load)仅支持模块保持着(module holder),更直白地说就是共享指针(share_ptr)。这样,模块保持着的接口是推荐使用的c++接口的模块定义方法,后续教程中会使用这种接口。

https://pytorch.org/tutorials/advanced/cpp_frontend.html

posted @ 2022-08-21 10:13  Oliver2022  阅读(366)  评论(0编辑  收藏  举报