- 引入Reshape operator,改变数据的类别与结构
- 考虑 training 相关的模块
- 考虑CNN的相关支持(相对较大的工作)
MetaNN 是一个深度学习框架的骨架,旨在探索 C++ 模板元编程在深度学习中的应用。它不支持多机并发训练。目前来说,它只支持 CPU ,包含了一些简单的构造。但 MetaNN 具有足够的扩展性,能够相对容易地支持诸如 GPU 或 FPGA 这样的设备。就目前来说,它所包含的计算逻辑只是示例性的,其速度并不快,但其框架非常灵活,基于这套框架进行的程序优化可以在保证易用性的同时,极大地提升深度学习系统的性能。
本文会简述 MetaNN 的主体设计思想,核心组件以及优化的可能性。
MetaNN 是一个 C++ 模板库,与其它模板库类似,它的核心逻辑都包含在头文件中(以 .h 结尾)。除了这些头文件外,MetaNN 还包含了若干测试工程:Tests/DataOpTest,Tests/LayerTest 与 Tests/OtherTests ,它们用于测试 MetaNN 的内部逻辑,同时也是简单的示例,展示了 MetaNN 中的数据与操作,层,以及其它部分的使用方式。它们会调用 MetaNN 中包含的逻辑实现具体的计算。 读者可以阅读这其中的代码从使用者的角度了解 MetaNN 的工作原理。
MetaNN 库以及测试项目均使用 CodeLite 进行开发。 测试程序可以在如下的环境上编译并运行:
- g++ 7.4.0
- g++ 8.3.0
- g++ 9.1.0
- clang++ 8.0.0
- clang++ 9.0.0 不建议使用较低版本的编译器编译本工程。MetaNN 使用了 C++ 17 中的特性。我并不能保证程序的每一行代码都完全符合 C++ 17 这一标准;进一步,标准中未明确定义的部分可能导致不同编译器有不同的行为;编译器中的 Bug也可能阻止程序的顺利编译。读者可以选择自己的编译器进行试验。这里推荐的运行环境是 Ubuntu & g++。
需要说明的一点是:由于要测试每种编译器的行为是否正确是一个比较麻烦的工作,因此我并不会在每次修改后都进行测试。而是会在积累若干修改后统一的进行测试。对于未来得及测试的情形,我会将没有测试的编译器标记一个问号。
MetaNN 是自成体系的。除了对 C++17 的依赖之外,作者有意没有依赖任何第三方库。这样不仅方便运行环境的搭建,也可以使程序的阅读者不会因对某个库的不熟悉而影响对程序的理解(当然,要想理解这个库,需要对 C++17 以及元编程有一定的了解)。
整个框架核心逻辑被包含在 MetaNN 目录中。与 MetaNN 目录同级的是 Tests 目录,其中包含了相应的测试程序。这里仅以 MetaNN 目录为例说明代码的组织形式。
- _root: 只包含了一个文件 meta_nn.h ,引用这个文件即可使用 MetaNN 所提供的全部功能
- data: 存放数据的目录,其中包含了深度学习框架可能会使用到的各种数据类型
- data_copy: 保存了用于复制数据的逻辑。目前该目录中只包含了一个函数,用于将一个 CPU 张量赋予另一个 CPU 张量,后续会根据需要引入更多的数据复制方法。
- evaluate: 保存了 MetaNN 中的求值逻辑
- facilities: 包含了一些辅助(元)函数与结构
- layers: “层”的实现逻辑,层是整个深度学习框架的核心
- model: 目标是存放一些模型相关的逻辑,如参数初始化,梯度收集等。
- operation: 包含了操作的实现逻辑。
- policies: 包含了 Policy 的相关实现。 Policy 可以被视为一种编译期的分支机制,在 MetaNN 中主要用于层的策略定制。我们会在后文讨论层的设计时说明 Policy 所起的作用
接下来,文本将从底向上依次讨论框架中的若干组件。阐述其设计理念并给出一些使用示例。
-
基本数据表示: MetaNN 之前的版本并不支持 Tensor ,而是使用诸如矩阵、标量这样的类模板 来表示数据。这样做的目的是为了引入概念相对清晰的数据结构。但随着开发的进行,作者发现使用原有的体系结构对扩展性造成了很大的限制。因此新的版本中屏弃了原有的设计,采用了流行深度学习框架中 Tensor 的表示形式。
-
支持数据扩展与分类: MetaNN 的数据类型支持若干维度的扩展。除了前文所述的可以引入新的数据类型外,还可以对已有数据类型(实际上是数据模板)所包含的元素类型(如
float
或者double
或者其它的定点类型)进行扩展;对数据类型所支持的计算设备(如CPU
或GPU
)进行扩展。 MetaNN 中的数据类型按照其类别进行划分,我们使用CategoryTags::Tensor<Dim>
来表示数据的类别。比如CategoryTags::Tensor<2>
表示矩阵;CategoryTags::Tensor<0>
表示标量。
MetaNN 中表示数据的最基本类型是 Tensor。比如,可以使用如下的方式声明一个 Tensor:
Tensor<int, DeviceTags::CPU, 2> matrix(10, 20);
这相当于定义了一个矩阵,包含 10 行,每行 20 列,该对象中的数据元素类型为int
,所支持的计算设备为CPU
。
系统同时为 Tensor
引入了若干别名,比如:
template <typename TElem, typename TDevice>
using Matrix = Tensor<TElem, TDevice, 2>;
因此,以下的定义与上面的定义等价:
Matrix<int, DeviceTags::CPU> matrix(10, 20);
MetaNN 还可以定义其它的张量对象:
// 向量,包含100个元素,第37个元素为0.3,其余为0
BiasVector(100, 37, Scalar<int, DeviceTags::CPU>{0.3});
// 一个 10*20 的矩阵,其中元素值均为100
TrivalTensor(Scalar<int, DeviceTags::CPU>{100}, 10, 20);
// 一个 10*20 的矩阵,其中元素值均为0
ZeroTensor<int, DeviceTags::CPU, 2> m3(10, 20)
还可以声明维度更高的张量:
// 张量对象,维度为 5*10*20,其中的元素均为 0
ZeroTensor<CheckElement, CheckDevice, 3>(5, 10, 20);
上述声明的类型不同,可以应用于不同的场景。每种类型都采用了不同的方式存储其内部数据,并提供不同的访问接口。MetaNN 将这些类型划分成不同的类别,可以用如下的方式获取特定数据类型所对应的类别:
IsMatrix<Matrix<int, DeviceTags::CPU>>; // true
IsMatrix<ZeroTensor<int, DeviceTags::CPU, 3>>; // false
IsThreeDArray<ZeroTensor<int, DeviceTags::CPU, 3>>; // true
IsMatrix<ZeroTensor<int, DeviceTags::CPU, 3>>; // false
或者使用如下的方式:
// CategoryTags::Tensor<2> (也即 CategoryTags::Matrix)
DataCategory<Matrix<int, DeviceTags::CPU>>;
// CategoryTags::Tensor<3>
DataCategory<ZeroTensor<int, DeviceTags::CPU, 3>>;
除了自身特有的接口外,具有相同类别的数据类型提供一组通用的接口。比如,所有的数据类型都提供了Shape()
接口来返回其形状。但Shape()
的返回对象的类型则根据数据类型的不同而不同:矩阵类别的对象需要提供接口返回行数与列数,而三维张量对应的Shape()
则会返回三个维度的数值。此外,每个数据类型都要提供求值接口以转换为相应主体类型的对象(我们会在后面讨论求值的过程)。
我们可以很简单地为 MetaNN 引入新的类型:
template <typename TElem, typename TDevice>
class MyMatrix
{
public:
using CategoryTag = CategoryTags::Matrix;
using ElementType = TElem;
using DeviceType = TDevice;
}
MyMatrix<float, DeviceTags::CPU> ...
注意上述定义的CategoryTag
会将MyMatrix
的类别设定为矩阵。这样,以下的调用将返回true
:
IsMatrix<MyMatrix<int, DeviceTags::CPU>>;
自定义的类型与 MetaNN 已经定义的类型地位相同。
除了上述类型外, MetaNN 还引入了一个特殊的类型模板: DynamicData<>
。它用于对不同的数据类别进行封装,掩盖其底层的具体类型信息,只暴露出该数据类型最核心的特性与接口。比如:
DynamicData<float, DeviceTags::CPU, CategoryTags::Matrix>
它可以被视为一个容器,其中可以放置元素类型为float
,设备类型为CPU
的矩阵:
vector<DynamicData<float, DeviceTags::CPU, CategoryTags::Matrix>> vec;
Matrix<float, DeviceTags::CPU> m1(100, 37);
vec.push_back(MakeDynamic(m1));
ZeroData<CategoryTags::Matrix, float, DeviceTags::CPU> m2(10, 20)
vec.push_back(MakeDynamic(m2));
MakeDynamic
是 MetaNN 提供的一个函数,给定一个具体的数据类型,可以转换为相应的Dynamic
版本。
同样的,我们也可以将三维张量置于DynamicData<... CategoryTags::Tensor<3>>
的容器中。
DynamicData
用于保存中间变量,它掩盖了其底层所包含的具体的数据类型。便于存储类型不同(类型未知)但类别相同(类别已知)的数据。但 DynamicData
的引入导致了具体信息类型的丢失,对编译期计算会产生一些不利的影响。因此,DynamicData
只会在必要时才被使用。
MetaNN 的每个数据类型都提供了 operator ==
与 operator !=
来与其它数据进行比较。但这里的比较仅供后续求值优化所使用。我们并不希望提供一个元素级的比较——即比较张量中的每个元素:一方面,浮点类型的数据无法精确比较;另一方面,这样的比较很耗时。MetaNN 中所提供的是一种快速的比较方案:
- 如果 A 复制自 B,那么 A 与 B 相等
- 如果 A 复制自 B,同时 C 与 D 是 A 与 B 求值的结果,那么 C 与 D 相等
除此之外, 数据通常是不相等的(除非底层数据的判等操作成本很低,MetaNN才会考虑使用底层数据来判断是否相等)。
MetaNN 包含了多个操作。每个操作都可以接收一到多个数据对象,返回操作结果。
MetaNN 使用表达式模板作为操作的结果。表达式模板对象的内部包含了表示该操作的操作数对象,它并不进行实际的计算。因此基于操作来构造操作结果模板的复杂度非常低。表达式模板实际的求值过程被推迟到显式调用求值时进行——这就为求值过程的优化提供了空间。
一方面,表达式模板类型可以被视为操作结果;另一方面,也可以将其看成一种特殊数据。与 MetaNN 中基本的数据类型相似,操作产生的表达式模板对象也会被划分为矩阵、标量等类别,并提供相应分类所需要支持的接口。这样,我们就可以将某个操作的结果用做另一个操作的操作数了。
MetaNN 是富类型的,并非任意类型都可以做为某个操作的操作数。 MetaNN 设计了相关的机制来防止操作数的误用(当然,我们可以通过扩展的方式使得某种操作支持新的操作数或操作数的组合)。
可以通过如下的方式来调用 MetaNN 中的操作并生成相应的表达式模板:
auto rm1 = Matrix<int, DeviceTags::CPU>(4, 5);
auto rm2 = Matrix<int, DeviceTags::CPU>(4, 5);
auto add = rm1 + rm2;
add
就是由两个操作数所形成的表达式模板。其实际类型为:
Operation<OpTags::Add,
OperandContainer<Matrix<int, DeviceTags::CPU>, Matrix<int, DeviceTags::CPU>>
PolicyContainer<>
>
通常来说,我们在实际的程序设计过程中不需要关心 add
的实际类型(只需要像上例中那样使用 auto
让编译器自动推导,或者使用前文所提供的动态类型对其进行封装即可)。但在这里,还是有必要了解一下 add
对象对应的类型所包含的信息的。
add
对象的实际类型是 Operation
模板实例化的结果。这表明这个类型是一个“操作”。操作的具体内容则由模板参数 OpTags::Add
给出。Operation
的第二个模板参数表示了操作数的类型。Operation
的第三个模板参数则用于保存一些编译期的参数。
模板表达式本身也可以视为一种数据,因此,我们可以这样写:
auto rm1 = Matrix<int, DeviceTags::CPU>(4, 5);
auto rm2 = Matrix<int, DeviceTags::CPU>(4, 5);
auto add = rm1 + rm2;
auto rm3 = ZeroTensor<CategoryTags::Matrix, DeviceTags::CPU, 2>(4, 5);
auto sub = add - rm3;
此时, sub
所对应的类型为:
Operation<OpTags::Substract,
OperandContainer<
Operation<OpTags::Add,
OperandContainer<
Matrix<int, DeviceTags::CPU>,
Matrix<int, DeviceTags::CPU>>,
PolicyContainer<>>
ZeroData<CategoryTags::Matrix, DeviceTags::CPU>,
PolicyContainer<>>
本质上,模板表达式形成了一种树的结构。而模板表达式的求值过程,也可以被视为一种树的遍历过程。这个过程可以被优化。我们将在后续讨论求值时讨论相关的优化方法。
模板表达式也可以被视为数据, MetaNN 自动实现了模板表达式的分类。比如:
IsMatrix<Operation<OpTags::Substract,
OperandContainer<
Operation<OpTags::Add,
OperandContainer<
Matrix<int, DeviceTags::CPU>,
Matrix<int, DeviceTags::CPU>>,
PolicyContainer<>>
ZeroData<CategoryTags::Matrix, DeviceTags::CPU>,
PolicyContainer<>>>; // true
这是因为两个矩阵相加的结果还是矩阵,而矩阵与矩阵相减的结果还是矩阵。
需要说明的是,并非所有的操作都像加减法这样“一目了然”——其操作数与操作结果的类别相一致。比如, MetaNN 中定义了 ReduceSum
操作,可以将张量中某些维度的数据求和,得到新的张量。此时,操作的输入输出类型就不相同了。 MetaNN 的操作各式各样,也可以相对容易地扩展,可以在扩展的同时指定操作数与操作结果的类别,以及一些其它的信
6D40
。
MetaNN 是富类型的,一个使用 MetaNN 的程序可能会在操作中使用多种类型。对于特定的操作,并非所有的操作数类型都是合法的。 MetaNN 引入了特定的元函数来描述某个操作可以接收的合法的操作数。同样以加法为例,相应的元函数为:
template <typename TOp1, typename TOp2>
constexpr bool IsValidOper<OpTags::Add, TOp1, TOp2>
= IsValidCategoryTag<DataCategory<TOp1>> && IsValidCategoryTag<DataCategory<TOp2>>;
namespace OperAddWithNum
{
template <typename TOp1, typename TOp2>
constexpr bool Valid()
{
if constexpr (IsValidCategoryTag<DataCategory<TOp1>> && !IsValidCategoryTag<DataCategory<TOp2>>)
{
return std::is_constructible_v<typename RemConstRef<TOp1>::ElementType, TOp2>;
}
else if constexpr (!IsValidCategoryTag<DataCategory<TOp1>> && IsValidCategoryTag<DataCategory<TOp2>>)
{
return std::is_constructible_v<typename RemConstRef<TOp2>::ElementType, TOp1>;
}
else
{
return false;
}
}
template <typename TOp1, typename TOp2>
constexpr bool IsValidOper<OpTags::AddWithNum, TOp1, TOp2> = OperAddWithNum::Valid<TOp1, TOp2>();
template <typename TP1, typename TP2,
std::enable_if_t<IsValidOper<OpTags::Add, TP1, TP2> ||
IsValidOper<OpTags::AddWithNum, TP1, TP2>>* = nullptr>
auto operator+ (TP1&& p_m1, TP2&& p_m2)
只有 IsValidOper<OpTags::Add, TP1, TP2>
或 IsValidOper<OpTags::AddWithNum, TP1, TP2>
为真时,类型为 TP1
与 TP2
的两个操作数才能相加。事实上,这就限制了:只有两个张量,或者一个张量与一个数值才能相加。
float a, b;
// 这不会触发 MetaNN 中的操作逻辑
float c = a + b;
Matrix<int, DeviceTags::CPU> rm1(2, 3);
Tensor<int, DeviceTags::CPU, 3> rm2(5, 2, 3);
// 触发 MetaNN 中的操作逻辑,相加结果为一个 5*2*3 的张量。
auto rm3 = rm1 + rm2;
当然,操作所支持的操作数组合也是可以扩展的,可以通过特化 IsValidOper
引入相应的扩展,但要在同时为相应的操作给出明确的定义。
层是 MetaNN 中相对高级的组件。当前很多深度学习框架都弱化了层的概念,转而将操作与层的概念合并起来。 MetaNN 认为区分层与操作,可以更好地描述不同层面上的抽象。本节讨论基本层。
层是 MetaNN 对外的接口。基本层封装了操作,调用具体的操作实现数据的正向、反向传播。每个基本层都能实现特定的功能,比如将两个矩阵相加,或者求矩阵的点乘。虽然基本层的功能相对单一,但其行为细节是可以配制的。比如,我们可以指定某个层是否要在训练的过程中更新其中所包含的参数。层应该了解其具体的行为细节,并根据其进行相应的优化。
通常情况下,层的具体行为极少在程序的执行过程中发生改变。因此,可以在编译期指定相应的信息,以确保尽早地利用这些信息进行最大限度的优化。
每个层都应当支持正向、反向传播。除此之外,层可能需要根据其自身特性提供若干额外的接口函数。比如,如果层中包含了参数矩阵,那么它就需要提供接口来对其所包含的矩阵进行初始化、读取或加载矩阵中的内容等。
这些接口大部分都以模板方法的形式提供。这就确保了可以采用不同类型的参数调用层所提供的方法——这一点对正向、反向传播尤其重要——它与表达式模板一起构成了性能优化的前提。我们会在后面看到这一点。
层在设计与使用上有很多独特之处,可以说,它是 MetaNN 框架中极富特色的一个组成部分。 MetaNN 中的很多子模块的引入都与层相关。本节将讨论基本层及其涉及到的子模块,下一节将在此基础上讨论组合层的一些特色之处。
通常来说,层会被声明为一个类模板,比如:
template <typename TInputs, typename TPolicies>
class AddLayer;
其中包含了两个模板参数,前者为输入映射的定义,后者为 Policy 的定义。接下来,我们将一一讨论这两部分内容。
每个层都会在其内部指定其能够接收的数据个数与名称,以及输出结果的个数与名称。比如,对于加法层来说:
template <typename TInputs, typename TPolicies>
class AddLayer
{
public:
using InputPortSet = LayerPortSet<struct LeftOperand, struct RightOperand>;
using OutputPortSet = LayerPortSet<struct LayerOutput>;
// ...
};
这表明加法层的输入接收两个参数,分命名为 LeftOperand
与 RightOperand
;它会产生一个输出结果,被命名为 LayerOutput
。
InputPortSet
与 OutputPortSet
称为层的输入/输出端口集合。端口集合中的名称会在正向、反向传播时使用。
输入/输出端口集合只指定了层的输入输出参数个数与名称,并未对层的具体输入类型进行限制,对于仅用于推导(inference)的层来说,这一点就够了。对于用于训练(train)的层来说,还需要给出每个参数的具体类型,这个信息是通过输入映射(层模板的第一个参数)指定的。
可以通过如下的方式来指定层的输入映射:
using CommonInputMap = LayerInMap<LayerKV<LeftOperand, Matrix<CheckElement, CheckDevice>>,
LayerKV<RightOperand, Matrix<CheckElement, CheckDevice>>
>;
AddLayer<CommonInputMap, ...> layer;
其中 CommonInputMap
就是一个输入映射,它表明 LeftOperand
与RightOperand
的类型均为 Matrix<CheckElement, CheckDevice>
。
对于仅用于推导的层来说,输入映射可以为空(使用 NullParameter
表示)。因此,以下的代码相当于构造了一个仅用于推导的层:
AddLayer<NullParameter, ...> layer;
MetaNN 同时提供了辅助函数 MakeTrainLayer
与 MakeInferLayer
来构造用于训练的层与用于推导的层。
我们希望能对层的具体行为进行控制,进一步,希望在编译期就指定层的具体行为——从而尽量早地获取信息,提升系统优化的空间。 MetaNN 使用 Policy 机制来实现这一点。
Policy 是一种编译期构造,它指定了函数或方法在使用时的具体行为。一个典型的 Policy 为:使用乘法而非加法进行累积——累积函数可以根据其中的信息改变其缺省的行为。
MetaNN 中所使用的 Policy 是被系统化地组织起来的。其中的每个 Policy 都包含了一个主体类别,次要类别,以及相关的缺省值。主体类别用于 Policy 的分类,次要类别用于 Policy 对象的互斥(我们很快就会讨论到 Policy 对象):
struct GradPolicy
{
using MajorClass = GradPolicy;
struct IsUpdateValueCate;
struct IsFeedbackOutputValueCate;
static constexpr bool IsUpdate = false;
static constexpr bool IsFeedbackOutput = false;
};
这段代码定义了两个 Policy : IsUpdate
与 IsFeedbackOutput
。其主体类别为 GradPolicy
——这表示了两个 Policy 与反向传播相关。它们所属的次要类别分别为 IsUpdateValueCate
与 IsFeedbackOutputValueCate
(次要类别使用 Policy 的名称作为前缀,这一点是专门设计的;而次要类别名称中的 ValueCate
则表示了该类别所对应的 Policy 是数值 Policy)。它们的缺省值均为 false
。
MetaNN 不仅支持数值 Policy ,还支持类型 Policy (由于 Policy 是编译期的构造,因此支持类型就并不奇怪了):
struct ParamPolicy
{
using MajorClass = ParamPolicy;
struct ParamTypeTypeCate;
using ParamType = NullParameter;
struct InitializerTypeCate;
using Initializer = NullParameter;
struct ElementTypeTypeCate;
using ElementType = float;
struct DeviceTypeTypeCate;
using DeviceType = DeviceTags::CPU;
};
ElementType
是类型 Policy ,其次要类型为 ElementTypeTypeCate
,缺省“值” 为 float
。
事实上, MetaNN 中的 Policy 还能是其它形式的,比如我们甚至可以定义取值为类模板的 Policy 。但数值、类型与数值 Policy 在 MetaNN 中使用的频率最高, MetaNN 也为这两种 Policy 专门提供了宏以建立 Policy 对象。
Policy 与 Policy 对象的关系很像类与其对象的关系。 Policy 对象可以被视做某个 Policy 实例化的结果。Policy 对象包含了相应 Policy 的主体与次要类别信息,但其取值可能不是 Policy 的缺省值:
ValuePolicyObj(PUpdate, GradPolicy, IsUpdate, true);
ValuePolicyObj(PNoUpdate, GradPolicy, IsUpdate, false);
上述代码定义了两个 Policy 对象:PUpdate
与 PNoUpdate
。它们均对应于之前提及的 IsUpdate
Policy。但其取值一个为 true
,一个为 false
。类似的,还可以定义取值为类型的 Policy 对象:
TypePolicyObj(PNormVarScale, VarScaleFillerPolicy, Distribute, Norm);
TypePolicyObj(PUniformVarScale, VarScaleFillerPolicy, Distribute, Uniform);
除了 ValuePolicyObj
与 TypePolicyObj
外, MetaNN 还提供了 ValuePolicyTemplate
与 TypePolicyTemplate
来构造 Policy 对象,比如:
ValuePolicyTemplate(PUpdateIs, GradPolicy, IsUpdate);
将构造一个 policy 对象模板 PUpdateIs
,我们可以将 PUpdateIs<true>
或者 PUpdateIs<false>
作为 Policy 对象使用。
接下来,让我们看一下如何使用 Policy 对象来指定层的行为细节。
MetaNN 中的大部分层都被声明为模板。接收两个模板参数,其中的之一是一个“容器”,可以存放零到多个 Policy 对象:
template <typename TInputMap, typename TPolicies>
class AddLayer;
// layer is an object with interfaces such as feed-backward and feed-forward;
AddLayer<TInputMap, PolicyContainer<PUpdate, PFeedbackOutput>> layer;
上述程序使用 Policy 对象的列表作为模板参数,实例化了 AddLayer
模板。可以利用 MetaNN 所提供的元函数使得定义更加清晰易懂:
using MyLayer = MakeInferLayer<AddLayer, PUpdate, PFeedbackOutput>;
MyLayer layer;