动手打造深度学习框架
上QQ阅读APP看书,第一时间看更新

前言

本书讨论了如何将C++模板元编程(简称元编程)深入应用到一个相对较大的项目(深度学习框架)的开发过程中,通过元编程与编译期计算为运行期优化提供更多的可能。

本书内容将围绕两个主题展开:C++模板元编程与深度学习框架。在笔者看来,这两个主题都算是时下比较前沿的技术。深度学习框架不必多说,它几乎已经成为人工智能的代名词,无论是在自然语言处理、语音识别,还是图像识别等与人工智能相关的技术中,都可以看到深度学习的身影。本书的另一个主题——C++模板元编程,或者说与之相关的C++泛型编程,也是C++领域越来越热门的一种技术。从C++11到C++20,我们看到标准中引入了越来越多的与 C++模板元编程相关的内容。C++20 中非常振奋人心的特性可能要数Concepts与std::ranges了,前者是直接对元编程语法的增强,后者则深入应用了元编程技术。可以说,正是元编程技术的发展,使得C++这门已经被使用了40余年的语言焕发出新的活力。

C++并不容易上手。与Java等语言相比,它过于关注底层的机制,开发者需要人为地处理诸如内存的分配与释放、对象的生命周期管理等“零零碎碎”的问题;与C语言、汇编语言相比,它又包含了过多的语法细节,学习成本要高很多。但C++也有它自身的优势:它可以写出性能堪比C语言程序的代码,同时包含了足以用于构建大型程序的语法框架。这也让它在众多编程语言中脱颖而出,为很多开发者所钟爱。

严格来说,几乎所有的程序设计语言都是“图灵完备”的,这也就意味着大家能做的事情差不多。之所以要发明出这么多程序设计语言,一个主要的原因就是要在易用性与高性能之间取得一种平衡。关于这种平衡,不同的程序设计语言选择了不同的取舍方式:像Python、Java等语言更倾向于易用性,比如Python是弱类型的,我们可以使用一个变量名称指代不同类型的数据;Java则通过虚拟机隐藏了不同计算机之间的硬件与操作系统的差异,实现了“一次编译,到处运行”。相比之下,C++则将语言的天平更多地向性能倾斜,可以说,C++泛型编程与元编程正是这一点的体现。

举例来说,同样是构造容器保存数据,Python可以直接将数据放置到数组中,不需要考虑每个数据的具体类型。之所以可以这样做,是因为Python中的每个类型都派生自一个相同的类型,所以数组中所保存的元素本质上都是这个类型的指针——这是一种典型的面向对象编程方式。C++也支持这种方式,但除此之外,C++还可以通过模板引入专门的容器,来保存特定类型的数据。事实证明,后一种方式由于对存储的类型引入了更多的限制,因此有更多优化的空间,其性能也就更好。这种方式也被C++标准库所采用。

通过模板,我们可以编写一套相似的代码,并以不同的类型进行实例化,从而实现对不同的类型进行相似的处理。这种可以应用于不同类型的代码也被称为“泛型”代码。

在引入泛型机制的基础上,又产生了一些新的问题。比如,我们可能需要根据某个类型来推导出相应的指针类型,以间接引用该类型的某个变量;又如,虽然大部分情况下,我们可以使用一套代码来处理不同类型,但对于某些“特定的”类型来说,一些处理逻辑上的调整可能会极大地提升性能。因此,我们需要一种机制进行类型推导或逻辑调整。这种机制以程序作为输入与输出,是处理程序的程序,因此被称为元程序,而相应的代码编写方法则被称为元编程。

早期的元编程应用范围相对有限,一方面是因为这种代码的编写方式难以掌握;另一方面则是因为C++语法对其支持程度不高。随着人们对C++这门语言认识程度的加深,以及C++标准中引入了更多的相关工具,元编程的使用门槛也逐渐降低,以至于可以应用在很多复杂程序的开发之中。本书将元编程深入应用到深度学习框架的开发过程中,就是一次有益的尝试。

深度学习框架中有一个核心概念——张量。张量可以被视为多维数组,典型的张量包括一维向量、二维矩阵等。矩阵可被视为一个二维数组,其中的每个元素是一个数值,可以通过指定行数与列数获取该位置元素的值。

在一个相对复杂的系统中,可能涉及各种不同的矩阵。比如,在某些情况下我们可能需要引入某种数据类型来表示“元素全为0”的矩阵;或者一些情况可能需要基于某个矩阵表示出一个新的矩阵,新矩阵中的每个元素都是原有矩阵中相同位置元素乘 −1 后的结果。

如果采用面向对象的方式,我们可以很容易地想到引入一个基类来表示矩阵,在此基础上派生出若干具体的矩阵类型。比如:

1    class AbstractMatrix
2    {
3    public:
4        virtual int Value(int row, int column) = 0;
5    };
6    
7    class Matrix : public AbstractMatrix;
8    class ZeroMatrix : public AbstractMatrix;
9    class NegMatrix : public AbstractMatrix;

AbstractMatrix定义了表示矩阵的基类,其中的Value接口在传入行号与列号时,返回对应元素的值(这里假定它为int类型)。之后,我们引入了若干个派生类,使用Matrix来表示一般意义的矩阵;使用ZeroMatrix来表示元素全为0的矩阵;NegMatrix的内部则包含一个AbstractMatrix类型的对象指针,它表示的矩阵的每个元素为其中包含的Matrix对应元素乘−1的结果。

所有派生自AbstractMatrix的具体矩阵必须实现Value接口。比如,对ZeroMatrix来说,其Value接口的功能就是返回数值0;对NegMatrix来说,它会首先调用其内部对象的Value接口,之后将获取的值乘−1并返回。

现在考虑一下,如果我们要构造一个函数,输入两个矩阵并计算二者之和,该怎么实现。基于前文所定义的类,矩阵相加函数可以使用如下声明:

1    Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2);

由于每个矩阵都实现了AbstractMatrix所定义的接口,因此我们可以在这个函数中分别遍历两个矩阵中的元素,将对应元素求和并保存在结果矩阵Matrix中返回。

显然,这是一种相对通用的实现,能解决大部分问题,但对于一些特殊的情况则性能较差,比如:

  • 如果一个Matrix对象与一个ZeroMatrix对象相加,那么直接返回Matrix对象即可;
  • 如果一个Matrix对象与一个NegMatrix对象相加,同时我们能确保NegMatrix对象中,每个元素都是Matrix对象对应元素乘−1的结果,那么直接将结果矩阵中的每个元素赋值0即可。

为了在这类特殊情况中提升计算速度,我们可以在Add中引入动态类型转换,来尝试获取参数所对应的实际数据类型:

1    Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2)
2    {
3        if (auto ptr = dynamic_cast<const ZeroMatrix*>(mat1))
4            // 引入相应的处理
5        else if (...)
6            // 其他情况
7    }

这种设计有两个问题:首先,大量的if会使函数变得非常复杂,难以维护;其次,调用Add时需要对if的结果进行判断——这是一个涉及运行期的计算,引入过多的判断,甚至可能使函数的运行速度变慢。

以上问题有一个很经典的解决方案:函数重载。比如,我们可以引入如下若干函数:

1    Matrix Add(const AbstractMatrix* mat1, const AbstractMatrix* mat2);
2    Matrix Add(const ZeroMatrix* mat1, const AbstractMatrix* mat2);
3    ...
4    
5    ZeroMatrix m1;
6    Matrix m2;
7    Add(&m1, &m2); // 调用第二个优化算法

其中的第一个版本对应一般的情况,而其他版本则对应一些特殊的情况,为其提供相应的优化。

这种方案很常见,以至于我们可能意识不到这已经是在使用元编程了。我们相当于构造了一个元程序,其输入是具体的矩阵类型,输出是相应的求和算法。编译器会根据不同的输入选择不同的算法处理——整个计算过程在编译期完成。相应地,元编程也被称为编译期计算。

函数重载只是一种很简单的编译期计算——它虽然能够解决一些问题,但使用范围还是相对狭窄的。本书所要讨论的则是更加复杂的编译期计算方法:我们将使用模板来构造若干组件,其中显式包含了需要编译器处理的逻辑。编译器使用这些模板推导出来的值(或类型)来优化系统。这种用于编译期计算的模板被称为“模板元函数”,而相应的计算方法也被称为“元编程”或“C++模板元编程”。

元编程并非一个新概念。事实上,早在1994年,埃尔温·翁鲁(Erwin Unruh)就展示了一个程序,其可以利用编译期计算来输出质数。但由于种种原因,对 C++模板元编程的研究一直处于不温不火的状态。虽然一度也出现过若干元编程的库(比如Boost::MPL、Boost::Hana等),但应用这些库来解决实际问题的案例相对较少。即使偶尔出现,这些元编程的库与技术也往往处于一种辅助的地位,辅助面向对象的方法来构造程序。

随着C++标准的发展,我们欣喜地发现,其中引入了大量的工具与语法,使得元编程越来越容易。这也使得使用元编程构造相对复杂的程序成为可能。

在本书中,我们将构造一个相对复杂的系统——深度学习框架。元编程在这个系统中不再是辅助地位,而是整个系统的“主角”。在前文中,我们提到了元编程与编译期计算的优势之一就是更好地利用运算本身的信息,提升系统性能。这里将概述如何在大型系统中实现这一点。

一个大型系统往往包含若干概念,每一种概念可能存在不同的实现,这些实现各有优势。基于元编程,我们可以将同一概念所对应的不同实现组织成松散的结构。进一步可以通过使用标签等方式对不同的概念分类,从而便于维护已有的概念、引入新的概念,或者引入已有概念的新实现。

概念可以进行组合。典型地,两个矩阵相加可以构成新的矩阵。我们将讨论元编程中的一项非常有用的技术:表达式模板。它用于组合已有的类型,形成新的类型。新的类型中保留了原有类型中的全部信息,可以在编译期利用这些信息进行优化。

元编程的计算是在编译期进行的。深入使用元编程技术,一个随之而来的问题就是编译期与运行期该如何交互。通常来说,为了在高效性与可维护性之间取得平衡,我们必须考虑哪些计算是可以在编译期完成的、哪些则最好放在运行期、二者如何过渡。在深度学习框架实现的过程中,我们会看到大量编译期与运行期交互的例子。

编译期计算并非目的,而是手段:我们希望通过编译期计算来改善运行期性能。读者将在本书的第10章看到如何基于已有的编译期计算结果,来优化深度学习框架的性能。

本书将使用编译期计算与元编程打造一个深度学习框架。深度学习是当前研究的一个热点领域,以人工神经网络作为核心,包含了大量的技术与学术成果。本书主要讨论元编程与编译期计算的方法,因此并不考虑做一个大而全的工具包。但我们所打造的深度学习框架是可扩展的,能够用于人工神经网络的训练与预测。

尽管对讨论的范围进行了上述限定,但本书毕竟同时涉及元编程与深度学习,读者如果没有一定的背景知识很难完成学习。因此,我们假定读者对相关数学知识与C++都有一定的了解,具体有以下几点。

  • 读者需要对C++面向对象的开发技术、模板有一定的了解。本书并不是C++入门书,如果读者想了解C++的入门知识以及C++标准的有关内容,可以参考《C++ Primer Plus 第6版 中文版》或者其他类似的书籍。
  • 读者需要对线性代数的基本概念有所了解,知道矩阵、向量、矩阵乘法等概念。人工神经网络的许多操作都可以抽象为矩阵运算,因此基本的线性代数知识是不可缺少的。
  • 读者需要对高等数学中的微积分与导数的概念有基本的了解。梯度是微积分中的一个基本概念,在深度学习的训练过程中占据非常重要的地位——深度学习中的很大一部分操作就是梯度的传播与计算。虽然本书不会涉及微积分中很高深的知识,但要求读者了解偏导数∂y/∂x的基本含义。

使用元编程可以写出灵活高效的代码,但这并非没有代价。本书将集中讨论元编程,但在此之前有必要明确使用元编程的成本,从而对这项技术有更加全面的认识。

元编程的成本主要由两个方面构成:研发成本与使用成本。

1.研发成本

从本质上来说,元编程的研发成本并非来自这项技术本身,而是来自开发者编写代码的习惯转换所产生的成本。虽然本书讨论的是C++中的一项编程技术,但它与面向对象的C++开发技术有很大区别。从某种意义上来说,元编程更像一门可以与面向对象的 C++代码无缝衔接的新语言,想掌握并用好它还是要花费一些力气的。

对熟悉面向对象的C++开发者来说,学习并掌握这项新的编程技术,主要的难点在于建立函数式编程的思维模式。编译期涉及的所有元编程方法都是函数式的,构造的中间结果无法改变,由此产生的影响可能会比想象中要大一些。本书将会通过大量的示例来帮助读者逐步建立这样的思维模式。相信读完本书,读者会对其有相对深入的认识。

使用元编程的另一个问题是调试困难。原因也很简单:大部分C++开发者都在使用面向对象的方式编程,因此大部分编译器都会针对这一点进行优化。相应地,编译器在输出元编程的调试信息方面效果就会差很多。很多情况下,编译器输出的元程序错误信息更像是一篇短文。这个问题在C++20标准引入了Concepts后有所缓解,但目前主流的编译器还是支持C++17标准,对该问题没有什么特别好的解决方案。通常来说,我们要多动手做实验,多看编译器的输出信息,慢慢找到感觉。

还有一个问题:相对使用面向对象的C++开发者来说,使用元编程的开发者毕竟算是“小众”。这就造成了在多人协作开发时,使用元编程比较困难——因为别人看不懂你的代码,所以其学习与维护成本会比较高。笔者在工作中就经常遇到这样的问题,事实上也正是这个问题间接促进了本书的面世。如果你希望说服你的协作者使用元编程开发C++程序,可以向他推荐本书。

2.使用成本

元编程的研发成本是一种主观成本,可以通过开发者提升自身的编程水平来降低。相对地,元编程的使用成本则是一种客观成本,处理起来更棘手。

通常情况下,如果我们希望开发一个程序包并交付他人使用,那么程序包中往往会包含头文件与编译好的静态库或动态库,程序的主体逻辑是位于静态库或动态库中的。这样有两个好处:首先,程序包的提供者不必担心位于静态库或动态库中的主体逻辑遭到泄漏——使用者无法看到源码,要想获得程序包中的主体逻辑,需要通过逆向工程等手段实现,成本相对较高;其次,程序包的使用者可以较快地进行自身程序的编译并链接——因为程序包中的静态库、动态库都是已经编译好的,这一部分代码的编译过程会被省略。

但如果我们使用元编程开发一个程序包并交付他人使用,那么通常来说将无法获得上述两个好处:首先,元编程的逻辑往往是在模板中实现的,而模板是要放在头文件中的,这就造成元程序包的主体逻辑源代码在头文件中,其会随着程序包的发布提供给使用者,使用者了解并仿制相应逻辑的成本会大大降低;其次,调用元程序库的程序在每次编译过程中都需要编译头文件中的相应逻辑,这就会增加编译的时间。

如果我们无法承担由元编程所引入的使用成本,就要考虑一些折中的解决方案了。一种典型的方案是对程序包的逻辑进行拆分,将编译耗时长、不希望泄漏的逻辑先行编译,形成静态库或动态库,同时将编译时长较短、可以展示源代码的部分使用元程序编写,以头文件的形式提供,从而确保依旧可以利用元程序的优势。至于如何划分,则要视项目的具体情况而定。

本书包含两部分。第1部分(第1~3章)将讨论元编程的基础技术,这些技术将被用在第2部分(第4~10章)中,用于打造深度学习框架。

第1章 元编程基本方法。本章讨论元函数的基本概念,讨论将模板作为容器的可能性,在此基础上给出顺序、分支、循环代码的编写方法——这些方法构成整个编程体系的核心。在此之后,我们会进一步讨论一些典型的惯用法,包括奇特的递归模板式(Curiously Recurring Template Pattern,CRTP)等内容,它们都会在后文中被用到。

第2章 元数据结构与算法。本章在第1章的基础上进行了引伸,引入基本的数据结构与算法的概念。我们将讨论在编译期表示集合、映射(map)等数据结构的方法,同时给出编译期高效的数据索引与变换算法。这些算法都是泛型的,但与传统的C++泛型算法不同。传统的C++泛型算法的目的是处理运行期不同的数据,而这里的算法是为了处理编译期不同的数据(甚至是类型)。我们将这种高效处理的算法抽象出来,保存在一个元算法库中,供后续编写深度学习框架使用。

第3章 异类词典与policy模板。本章将会利用前两章的知识构造出两个组件。第1个组件是一个容器,用于保存不同类型的数据对象;第2个组件则是一个使用具名参数的policy 系统。这两个组件均将用于后续深度学习框架的打造。虽然本章偏重于基础技术的应用,但笔者还是将其归纳为泛型编程的基础技术,因为这两个组件都比较基础,可以作为基础组件应用于其他项目之中。这两个组件本身不涉及深度学习的相关知识,但我们会在后续打造深度学习框架时使用它们来辅助设计。

第4章 深度学习概述。从本章开始,我们将着手打造深度学习框架。本章将介绍深度学习框架的背景知识。如果读者之前没有接触过深度学习,那么通过阅读本章,可以对这一领域有一个大致的了解,从而明晰我们要打造的框架所要包含的主要功能。

第5章 类型体系与基本数据类型。本章讨论深度学习框架所涉及的数据。为了最大限度地发挥编译期计算的优势,我们将深度学习框架设计为富类型的,即它能够支持很多具体的数据类型。随着所支持数据类型的增多,如何有效地组织这些数据类型就成了一个重要的问题。本章讨论基于标签的数据类型组织形式,它是元编程中一种常见的分类方法。

第6章 运算与表达式模板。本章讨论深度学习框架中运算的设计。人工神经网络会涉及很多运算,包括矩阵相乘、矩阵相加、取元素对数值,以及更复杂的运算。为了能够在后续对运算进行优化,这里采用了表达式模板以及缓式求值的技术。

第7章 基本层。在运算的基础上,我们引入了层的概念。层将深度学习框架中相关的操作关联到一起,提供了正向、反向传播的接口,便于用户调用。本章将讨论基本层,描述如何使用第3章所构造的异类词典和policy模板来简化层的接口与设计。

第8章 复合层。基于第7章的知识,我们就可以构造各式各样的层,并使用这些层来搭建人工神经网络。但这种做法有一个问题:人工神经网络中的层是千变万化的,如果每一个之前没有出现过的层都手工编写代码实现,那么工作量还是比较大的,也不是我们希望看到的。在本章我们将构造一个特殊的层——复合层,用于组合其他的层来产生新的层。复合层中比较复杂的一块逻辑是自动梯度计算——这是人工神经网络在训练过程中的一个重要概念。可以说,如果无法实现自动梯度计算,那么复合层存在的意义将大打折扣。本章将会讨论自动梯度计算的一种实现方式,它也是本书的重点之一。

第9章 循环层。循环层的特殊之处在于需要对输入数据进行拆分,对拆分后的数据依次执行正向、反向传播逻辑,并将执行后的结果进行合并。我们将循环层的通用逻辑与具体的正向、反向传播算法分离出来,从而实现一个相对灵活的循环层组件。

第10章 求值与优化。人工神经网络是一种计算密集型的系统,无论对训练还是预测来说都是如此。一方面,我们可以采用多种方式来提升计算速度,典型地,可以使用批量计算同时处理多组数据,最大限度地利用计算机的处理能力;另一方面,我们可以对求值过程进行优化,从数学意义上简化与合并多个计算过程,从而提升计算速度。本章将讨论与此相关的主题。

本书是元编程的实战型图书,很多理论也是通过示例的方式进行阐述的。不可避免地,本书会涉及大量代码。我尽量避免将本书做成代码的堆砌(这是在浪费读者的时间与金钱),尽量做到书中只引用需要讨论的核心代码与逻辑,完整的代码则在随书源码中给出。

读者可以在https://github.com/liwei-cpp/MetaNN/tree/book_v2中下载本书的源码。源码中包含几个子目录。其中,MetaNN 子目录包含了深度学习框架中的全部逻辑,而其他子目录中则是一些测试逻辑,用来验证框架逻辑的正确性。本书所讨论的内容可以在MetaNN目录中找到对应的源码。阅读本书时,有一份可以参考的源码以便随时查阅是非常重要的。本书用了较多的篇幅阐述设计思想,但只是罗列了一些核心代码。因此,笔者强烈建议读者对照源代码来阅读本书,这样能对本书讨论的内容有更加深入的理解。

对于MetaNN中实现的每个技术要点,我们都引入了相应的测试用例。因此,读者可以在了解了某个技术要点的实现细节之后,通过阅读测试用例,进一步体会相应技术要点的使用方法。

MetaNN中的内容全部是头文件,测试用例则包含了一些CPP文件,可以编译成可执行程序。MetaNN中的代码主要基于C++17编写。因此,测试代码的编译器需要支持C++17标准。同时需要注意的是,由于代码中使用了大量的元编程技术,因此会给编译过程带来不小的负担。特别地,编译所需要的内存相对较多。因此,这里不建议采用32位编译器进行编译,否则可能会出现因编译器内存溢出而编译失败的情况。

笔者采用Linux系统,以GCC与Clang作为测试编译器,在GCC 、Clang 8.0.0等环境中完成编译测试。代码使用CodeLite工程进行组织,读者可以在Linux系统中安装CodeLite,导入代码中的MetaNN.workspace工程文件进行编译,也可以尝试使用自己熟悉的工具编译代码[1]

笔者尽量避免在讨论技术细节时罗列很多非核心的代码。同时,为了便于讨论,通常来说代码的每一行前面会包含一个行号:在对该代码进行分析时,一些情况下会使用行号来表示具体行的代码,说明该行所实现的功能。

行号只是为了便于后续的代码分析,并不表明该代码在源代码文件中的位置。一种典型的情况是,当要分析的核心代码比较长时,代码的展示与分析是交替进行的。此时,展示的每一段代码将均从行号1开始标记,即使当前展示的代码与上一段展示的代码存在先后关系,也是如此。如果读者希望阅读完整的代码,明确代码的先后关系,可以阅读随书源码。

除了第4章外,每一章的最后都包含了若干题目,用于读者巩固学到的知识。这些题目并不简单,有些也没有所谓标准答案。因此,如果读者在练习的过程中遇到了困难,请不要灰心,你可以选择继续阅读后文,在熟练掌握了本书所要传达的一些技巧后,回顾之前的题目,可能就迎刃而解了。再次声明,一些题目本就是开放性的,没有标准答案。因此如果做不出来,或者你的答案与别人的不同,请不要灰心。

由于笔者水平有限,而元编程又是一个比较有挑战性的领域,因此本书难免出现疏漏。对于本书描述隐晦不清之处以及其他可以改进的地方,欢迎发邮件到liwei.cpp@gmail.com与笔者交流。

李伟

2021年12月


[1] 需要说明的是,虽然很多编译器都支持C++17,但在支持的细节上有所差异。笔者尝试使用Visual Studio 2019编译测试代码,但有部分代码无法通过编译,系统提示编译器内部错误。