文章目录
- 一、Tensort介绍
- 二、Armadillo实现Tensor模板类
- 2.1 tensor类模板
- 2.2 Tensor类的设计
- 2.2.1 矩阵存储顺序
- 2.2.2 Tensor类具体实现
一、Tensort介绍
张量(Tensor)
是一个多维数组的通用化概念,在数学和计算科学中被广泛使用,特别是在机器学习、物理学和工程学等领域。它是标量(0维张量)、向量(1维张量)和矩阵(2维张量)的一般化,可以扩展到更高的维度。
Tensor可以看作是一个具有任意维数的多维数组。每个张量有以下属性:
- 秩(Rank): 张量的维数,即张量的轴的数量。0维张量是标量,1维张量是向量,2维张量是矩阵,依此类推。
- 形状(Shape): 每个维度的长度。形状决定了张量在每个维度上包含的元素数量。
- 数据类型(Data Type): 张量中的元素类型,如整型、浮点型等。
Tensor是深度学习推理框架必须提供的基础数据结构,神经网络的前向传播是基于 Tensor 类进行的。因此,在自制深度推理框架中我们需要实现Tensor类。
Armadillo
是一个高效的 C++ 线性代数库,通常用于矩阵和向量操作。虽然 Armadillo
本身没有直接提供张量(Tensor)类,但我们可以基于 Armadillo
提供的矩阵和向量功能,构建一个简单的张量模板类,使用 Armadillo
的 arma::fcube
来支持三维张量的基本操作。arma::fcube是
Armadillo库中的一个类型,表示一个三维浮点数矩阵(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类,主要做了以下的两个工作:
- 提供对外的接口,对外接口由
Tensor
类在fcube
类的基础上进行提供,以供用户更好地访问多维数据。 - 封装矩阵相关的计算功能,这样一来不仅有更友好的数据访问和使用方式,也能有高效的矩阵算法实现。
2.2 Tensor类的设计
选择在arma::fcube
(三维矩阵)的基础上进行开发。如下图所示,三维的arma::fcube
是由多个二维矩阵matrix
(arma::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_rows
、F.n_cols
和F.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::fcube
的subcube
方法用于获取或修改三维矩阵的子矩阵(子立方体),允许对指定的子区域进行操作。// 将原数据复制到新数据的中心位置 // 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;值得注意的是,如果当
channel
和rows
同时等于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 channelsthis->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