自制深度学习推理框架之Tensor模板类的设计与实现

一、Tensort介绍

张量(Tensor)是一个多维数组的通用化概念,在数学和计算科学中被广泛使用,特别是在机器学习、物理学和工程学等领域。它是标量(0维张量)、向量(1维张量)和矩阵(2维张量)的一般化,可以扩展到更高的维度。

Tensor可以看作是一个具有任意维数的多维数组。每个张量有以下属性:

  • 秩(Rank): 张量的维数,即张量的轴的数量。0维张量是标量,1维张量是向量,2维张量是矩阵,依此类推。
  • 形状(Shape): 每个维度的长度。形状决定了张量在每个维度上包含的元素数量。
  • 数据类型(Data Type): 张量中的元素类型,如整型、浮点型等。

Tensor是深度学习推理框架必须提供的基础数据结构,神经网络的前向传播是基于 Tensor 类进行的。因此,在自制深度推理框架中我们需要实现Tensor类。

Armadillo 是一个高效的 C++ 线性代数库,通常用于矩阵和向量操作。虽然 Armadillo 本身没有直接提供张量(Tensor)类,但我们可以基于 Armadillo 提供的矩阵和向量功能,构建一个简单的张量模板类,使用 Armadilloarma::fcube 来支持三维张量的基本操作。arma::fcubeArmadillo库中的一个类型,表示一个三维浮点数矩阵(3D cube)。具体来说,它是一个包含float 类型元素的三维数组。fcube` 可以用于表示三维的张量数据结构,常用于处理彩色图像、体数据(如医学图像),或其它具有三个维度的数据。

将arma::fcube封装成Tensor可以使其更方便地在深度学习等领域中使用,提供了更加直观和易用的接口,同时与其他深度学习框架(如TensorFlow、PyTorch等)进行对接时也比较方便。除了Armadillo库,还有一些其他的常用的线性代数C++库,比如Eigen。

二、Armadillo实现Tensor模板类

Tensor用于在算子之间传递数据,Tensor不仅有存放数据的功能,还可以处理基本的Tensor操作。对于一个Tensor类而言,数据将被设计成依次摆放的三维格式,分别是channels(通道数), rows(行数),cols(列数)。一个张量类主要由以下部分组成:

  • 数据本身存储在该类的数据空间中,数据可包括双精度(double)、单精度(float)或整型(int),以满足不同的精度需求。

  • 为了处理多维张量数据,需要使用shape变量来存储张量的维度信息。例如,对于一个维度为3,长和宽均为224的张量,其维度信息可以表示为(3, 224, 224)

  • Tensor 类中定义了多个类方法,比如加、减、乘、除、返回张量的宽度、高度、填充数据和张量变形 (reshape)等操作。

2.1 tensor类模板

从上面可以知道,Tensor类是对armdillo库中cube类的封装,cube是多个Matrix的集合(二维矩阵的集合)。Tensor类模板如下所示:

// 定义一个通用的模板类 Tensor,T 是模板参数,默认为 float 类型
template <typename T = float>
class Tensor
{
};

// 完全特化的模板类 Tensor,用于 uint8_t 类型
template <>
class Tensor<uint8_t>
{
    // 待实现
};

// 完全特化的模板类 Tensor,用于 float 类型。
template <>
class Tensor<float>
{
public:
    explicit Tensor() = default;

    explicit Tensor(uint32_t channels, uint32_t rows, uint32_t cols);
	......

    uint32_t rows() const;

    uint32_t cols() const;

    uint32_t channels() const;
    
    ......
    float at(uint32_t channel, uint32_t row, uint32_t col) const;

    void Padding(const std::vector<uint32_t> &pads, float padding_value);

    void Fill(const std::vector<float> &values, bool row_major = true);

    std::vector<float> values(bool row_major = true);

    void Reshape(const std::vector<uint32_t> &shapes, bool row_major = false);

    void Flatten(bool row_major = false);

    void Transform(const std::function<float(float)> &filter);
	......

private:
    std::vector<uint32_t> raw_shapes_; // 张量数据的实际尺寸大小
    arma::fcube data_;                 // 张量数据
};

// 定义别名
using ftensor = Tensor<float>;
using sftensor = std::shared_ptr<Tensor<float>>;

Tensor类模板中,Tensor共有两个类型,一个类型是Tensor<float>,另一个类型是Tensor<uint8_t>, Tensor<uint8_t>可能会在后续的量化中进行使用。

Tensor<float> 类实现了各种接口和功能,以便处理三维浮点数张量的数据。以下是实现的主要功能和接口的部分描述:

1.构造函数

实现了默认构造、拷贝构造、移动构造以及创建具有指定通道数、行数和列数的张量。

2.赋值运算符

实现移动赋值运算符拷贝赋值运算符

3.数据访问与操作

实现获取张量的行数、列数、通道数和大小、设置张量的数据、访问张量的数据等操作。

4.张量的操作

实现填充张量、Reshape、Flatten、Transform等操作。

5.数据成员

  • raw_shapes_: 保存张量的实际维度信息。

  • data_: 保存张量的 arma::fcube 数据。

对于Tensor类,主要做了以下的两个工作:

  1. 提供对外的接口,对外接口由Tensor类在fcube类的基础上进行提供,以供用户更好地访问多维数据。
  2. 封装矩阵相关的计算功能,这样一来不仅有更友好的数据访问和使用方式,也能有高效的矩阵算法实现。

2.2 Tensor类的设计

选择在arma::fcube(三维矩阵)的基础上进行开发。如下图所示,三维的arma::fcube是由多个二维矩阵matrixarma::fmat)沿通道维度叠加得到。在此基础上,张量类将在叠加而成的三维矩阵arma::fcube的基础上提供扩充和封装,以使其更适用于推理框架项目。

在这里插入图片描述

2.2.1 矩阵存储顺序

矩阵的存储形式可以分为两种:行主序(row-major order)和列主序(column-major order)。这两种方式在矩阵的内存布局上有着不同的顺序和特点。

  • 行主序(row-major order)

在行主序中,矩阵的数据按的顺序连续存储在内存中。这意味着矩阵的第一行的所有元素会依次存储在内存的连续位置上,然后是第二行,依此类推。

假如有一个 3×3的矩阵,这9个数据在一个行主序的3 x 3 矩阵中有如下的排布形式,其中箭头指示了内存地址的增长方向。从图中可以看出,内存地址增长的方向先是横向,然后是纵向,呈Z字形

在这里插入图片描述

在行主序中,这个矩阵在内存中的存储顺序是:[0,1,2,3,4,5,6,7,8]。

  • 列主序(column-major order)

在列主序中,矩阵的数据按的顺序连续存储在内存中。这意味着矩阵的第一列的所有元素会依次存储在内存的连续位置上,然后是第二列,依此类推。

同样的 3×3矩阵,将它摆放到一个列主序3 x 3的矩阵当中,并有如下的形式,其中箭头指示了内存地址的增长方向。从图中可以看出,内存地址增长的方向先是纵向,然后是横向,呈倒Z字形

在列主序中,这个矩阵在内存中的存储顺序是:[0,1,2,3,4,5,6,7,8]。

armadillo中默认的顺序就是列主序的,而Pytorch张量默认顺序是行主序的,所以在程序中需要进行一定适应和调整(转置)。了解矩阵的存储顺序可以帮助我们在进行矩阵运算时优化性能,特别是在涉及大规模数据和矩阵操作时,可以减少缓存未命中(cache miss),提高内存访问的效率。

2.2.2 Tensor类具体实现

Tensor类的各种操作其实底层都是对cube或者fcube的操作,下面先介绍fcube的基本操作,然后介绍如何封装成我们的Tensor类方法。

  • 构造与初始化

    arma::fcube F;  // 创建一个空的 fcube 对象,没有分配内存
    
    // 创建一个大小为 n_rows x n_cols x n_slices 的浮点立方体,元素默认未初始化
    // n_rows:矩阵的行数。
    // n_cols:矩阵的列数。
    // n_slices:矩阵的切片数(即第三维的大小)。
    arma::fcube F(uword n_rows, uword n_cols, uword n_slices);
    
    // 创建并用指定的方式初始化所有元素,例如 fill::zeros 用零填充,fill::ones 用一填充
    arma::fcube F(uword n_rows, uword n_cols, uword n_slices, fill::fill_type fill_value);
    
    
    
  • 访问元素

    可以使用 F(x, y, z) 来访问 fcube 中的某个位置的元素。

    float val = F(2, 3, 1);
    
  • 获取大小:可以使用 F.n_rowsF.n_colsF.n_slices 来获取 fcube 的维度信息。

    uword rows = F.n_rows;
    uword cols = F.n_cols;
    uword slices = F.n_slices;
    
  • 切片操作:可以使用 F.slice(z) 来获取 fcube 的某一层,它返回一个 arma::fmat 对象,表示这一层的二维矩阵。

    arma::fmat slice1 = F.slice(1);
    

    arma::fcubesubcube 方法用于获取或修改三维矩阵的子矩阵(子立方体),允许对指定的子区域进行操作。

    // 将原数据复制到新数据的中心位置
    // subcube( first_row, first_col, first_slice, last_row, last_col, last_col )
    // first_row   子矩阵的起始行索引
    // first_col   子矩阵的起始列索引
    // first_slice 子矩阵的起始切片(第三维度)索引
    
    // last_row    子矩阵的结束行索引
    // last_col    子矩阵的结束列索引
    // last_col    子矩阵的结束切片(第三维度)索引
    
    new_data.subcube(pad_rows1, pad_cols1, 
    				0, pad_rows1 + orig_rows - 1, 
    				pad_cols1 + orig_cols - 1, orig_slices - 1) = this->data_;
    
  • 填充数据:可以使用 F.fill(value) 来用某个值填充整个立方体,或使用 F.zeros()F.ones() 等方法快速填充。

下面以如何创建张量为例,怎么使用fcube创建张量,其它成员函数的实现也是一样的。

对于Tensor这个多维矩阵需要用一个raw_shapes变量来存储张量的维度,而不同维度将决定了arma::fcube的具体结构。需要根据输入的维度信息创建相应维度的arma::fcube,且创建一个用于存储维度的变量。同时使用**data_**保存张量的 arma::fcube 数据。

  • 如果张量是1维的,则raw_shapes的长度就等于1;

  • 如果张量是2维的,则raw_shapes的长度就等于2,以此类推;

  • 在创建3维张量时,则raw_shapes的长度为3;

    值得注意的是,如果当channelrows同时等于1时,raw_shapes的长度也会是1,表示此时Tensor是一维的;而当channel等于1时,raw_shapes的长度等于2,表示此时Tensor是二维的。

// 创建1维张量
Tensor<float>::Tensor(uint32_t size) {
  data_ = arma::fcube(1, size, 1); // 传入的参数依次是,rows cols channels
  this->raw_shapes_ = std::vector<uint32_t>{size};
}

// 创建2维张量
Tensor<float>::Tensor(uint32_t rows, uint32_t cols) {
  data_ = arma::fcube(rows, cols, 1); // 传入的参数依次是, rows cols channels 
  this->raw_shapes_ = std::vector<uint32_t>{rows, cols};
}

// 创建3维张量
Tensor<float>::Tensor(uint32_t channels, uint32_t rows, uint32_t cols) {
  data_ = arma::fcube(rows, cols, channels);
  if (channels == 1 && rows == 1) {
    // 当channel和rows同时等于1时,raw_shapes的长度也会是1,表示此时Tensor是一维的
    this->raw_shapes_ = std::vector<uint32_t>{cols};
  } else if (channels == 1) {
    // 当channel等于1时,raw_shapes的长度等于2,表示此时Tensor是二维的
    this->raw_shapes_ = std::vector<uint32_t>{rows, cols};
  } else {
    // 在创建3维张量时,则raw_shapes的长度为3,表示此时Tensor是三维的
    this->raw_shapes_ = std::vector<uint32_t>{channels, rows, cols};
  }
}

使用fcube封装Tensor后,就可以不用关心底层fcube,只需要使用提供的接口就可以完成张量的实例化。

// 将创建一个包含5个元素的张量,内部使用arma::fcube(1, 5, 1)存储数据。raw_shapes_设置为 {5},表示这是一个一维张量
Tensor<float> tensor1d(5);

// 创建一个3行4列的二维张量,内部使用arma::fcube(3, 4, 1)存储数据。raw_shapes_设置为 {3, 4},表示这是一个二维张量
Tensor<float> tensor2d(3, 4);

// 创建一个2通道3行4列的三维张量,使用 arma::fcube(3, 4, 2)存储数据。raw_shapes_设置为 {2, 3, 4},表示这是一个三维张量
Tensor<float> tensor3d(2, 3, 4);

对于返回张量的维度信息,底层也是使用fcube的成员函数实现的,如下所示。

uint32_t Tensor<float>::rows() const {
  CHECK(!this->data_.empty());
  return this->data_.n_rows;
}

uint32_t Tensor<float>::cols() const {
  CHECK(!this->data_.empty());
  return this->data_.n_cols;
}

uint32_t Tensor<float>::channels() const {
  CHECK(!this->data_.empty());
  return this->data_.n_slices;
}

uint32_t Tensor<float>::size() const {
  CHECK(!this->data_.empty());
  return this->data_.size();
}

这四个方法分别返回张量的行数(rows)、列数(cols)、维度(channels)以及张量中数据的总数量(size)。

假设有一个大小为(3 × 3 × 2)的张量数据。

Tensor<float> tensor(3,3,2);
tensor.rows(); // 返回3
tensor.cols(); // 返回3
tensor.channels() // 返回2
tensor.size(); // 返回18
  • 15
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Super.Bear

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值