深度学习中的编程模型

翻译自 MXNet 官方文档
在深度学习领域有很多深度学习库, 每个库都有其独特之处. 从系统优化和用户体验角度讲, 每个库有什么优点和缺点呢? 这篇文章对编程模型做了比较, 讨论了他们基本的优缺点以及我们可以从中学习到什么.

这篇文章不会对各种深度学习库做 benchmark, 而是主要讨论编程模型本身, 而不是具体的实现. 根据用户接口的不同, 把库分成了几类, 讨论了接口的形式是怎样影响了灵活性和性能的. 这里的讨论并不仅限于深度学习, 这里使用深度学习只是为了使用具体的例子来说明使用案例和具体的优化.

Symbolic vs. Imperative Programs

首先来比较一下符号编程(也称为声明式编程) 和 命令式编程. 如果是使用 C++ 或者 Python, 那么, 对命令式编程应该是非常熟悉了, 命令式编程在运行他们的时候马上进行计算. 用 Python 写的大部分代码都是命令式的, 例如下面这一段 NumPy 的代码:

1
2
3
4
5
import numpy as np
a = np.ones(10)
b = np.ones(10) * 2
c = b * a
d = c + 1

当程序执行 c = b * a 的时候, 程序执行的是真是的计算.

符号式编程和命令式编程有一些区别, 下面这一段代码的功能和上面的代码相同, 只不过, 这里使用的是符号式编程完成的:

1
2
3
4
5
6
7
A = Variable('A')
B = Variable('B')
C = B * A
D = C + Constant(1)
# compiles the function
f = compile(D)
d = f(A=np.ones(10), B=np.ones(10)*2)

符号式和命令式的区别是: 在符号式中执行到 C = B * A 的时候, 并没有进行真正的计算, 取而代之的是, 这个操作仅仅是生成了一个表示当前计算的计算图. 下图表示了用来计算 D 的计算图


不管是显式的还是隐式的, 所有的符号式编程都有一个 compile 的步骤, 这个过程把计算图转换成一个可被调用的函数. 计算只是在代码的最后一行才真正执行. 符号式编程最大的特点是计算图的定义和编译有着清晰的边界.

命令式的深度学习库有 Torch, Chainer, Minerva, 符号式的深度学习库包括 Theano, CGT, TensorFlow 等. Caffe, CXXNet 这类使用配置文件的库也可以看作是符号式的, 其配置文件定义了计算图.

接下来, 对比一下符号式和命令式.

## Imperative Programs Are More Flexible
通常来说, 命令式比符号式更灵活. 当使用 Python 来写一段代码的时候, 使用的就是 Python 本身, 但是, 对于符号式来说就有所不同了. 例如下面这段代码, 怎样转换成符号式呢?
1
2
3
4
5
a = 2
b = a + 1
d = np.zeros(10)
for i in range(d):
d += np.zeros(10)


这里有点小困难, 因为上述代码中有一个 for 循环, 在符号式中, 要想支持循环这种语法比较困难. 使用 Python 写的符号式代码, 本质上并不是 Python 代码, 实际上, 在符号式中, Python 代码只是一种描述语言, Python 写出来的代码是符号式定义的一种 Domain Specific Language (DSL). 符号式是 DSL 的一种更强形式, 它可以生成计算图或者神经网络的配置结构. 从这个意义上讲, 所有配置文件输入的库都是符号式的.

命令式的编程更 native, 因此, 在命令式中可以非常容易的使用 native 的语法, 例如打印, 条件, 循环等宿主语言的特定.

## Symbolic Programs Are More Efficient
上面的分析表明, 命令式编程比较灵活而且可以充分利用宿主语言的特定, 那么, 为什么还有很多深度学习库使用符号式编程呢? 最主要的原因是符号式的效率更高, 包括内存使用效率和运行效率. 为了说明这个问题, 考虑前面的那个例子:
1
2
3
4
5
6
import numpy as np
a = np.ones(10)
b = np.ones(10) * 2
c = b * a
d = c + 1
...


假设数组中的内一个元素占用 8 字节的空间, 那么, 上述代码占用的空间为 4*10*8=320, C 和 D 可以共享内存, 因此, 实际上占用是 3*10*8=240.

符号式更严格, 对 D 做 compile 操作之后, 相当于明确告诉了系统, 我只关心 D 的输出, 其它的都不管, 因此, 中间状态 (C) 完全是透明的, 因此, 系统就可以放心大胆地去 reuse C 的内存空间.

另外一方面, 命令式的特点是, 它需要时时刻刻为将来考虑. 举例来说, 如果上述代码在 console 执行, 那么, 当前状态的下, 所有的变量都有可能在将来的某个时间会用到, 所以, 所有的变量都需要保持, 因此就无法进行变量内存的 reuse. 当然, 实际情况并没有这么严重, Python 拥有内存回收机制, 会在合适的时间回收内存. 这个例子是想说明, 命令式编程需要时时刻刻为将来的所有状态做好准备. 因此, 这就严重限制了内存的优化.

符号式还可以进行另外一种优化, 称为计算折叠. 在上述例子中, 乘法和加法可以折叠成一个操作. 如下图所示. 计算折叠什么优点呢? 举个例子来说, 如果代码是跑在 GPU 上面的, 那么, 未折叠的计算需要两个 GPU kernel, 通过计算折叠之后只需要一个 kernel. 这也是 Caffe 等库中需要手动优化的地方. 计算折叠大大提高了计算效率.

命令式是无法进行计算折叠的, 因为, 当前的变量有可能在将来会被用到. 符号式就不同了, 符号式中, 系统得到了整个计算图的结构, 因此可以马上知道哪些变量会用到, 哪些变量不会用到.

Model Checkpoint

深度学习中, 存储/载入模型很重要. 模型的存储包括两部分, 一部分是描述了网络结构的配置文件, 一部分是模型的参数. 符号式可以很容易的存储配置文件, 因为, 符号构建并没有任何计算, 因此, 可以直接序列化计算图, 并且, 在需要的时候直接载入. 因此, 网络结构的存储并不需要一个单独的 layer.

1
2
3
4
5
6
7
8
9
10
A = Variable('A')
B = Variable('B')
C = B * A
D = C + Constant(1)
D.save('mygraph')
...
D2 = load('mygraph')
f = compile([D2])
# more operations
...

对于命令式来说, 要么存储整个代码, 要么构建一个单独的用于存储的 layer.

Parameter Updates

命令式程序都是计算流图, 计算流图描述了如何进行技术, 但是, 没有描述怎样进行更新参数. 这是因为, 参数更新引入了数据改变, 计算流图无法支持. 大部分的符号式库都引入了一种特殊的语句来支持这种数据更新操作.

使用命令式来进行参数更新非常简单, 尤其是过个更新之间相互关联的情况. 对于符号式编程来说, update statement 被调用之后也是马上执行, 从这个意义上来说, 大部分的符号式编程在 update 的时候 fall back 到了命令式. 当然在计算梯度的时候仍然使用的是符号式.

There Is No Strict Boundary

通过以上的比较, 可以发现, 符号式无法完全胜过命令式, 命令式也不是完全优于符号式. 在实践中, 这两种方法并没有非常严格的界限, 例如, 可以给 Python 创建一个及时编译器来编译 Python 代码, 编译之后的代码就可以利用一些全局信息了.

Big vs. Small Operations

现在来讨论以下深度学习库中的操作, 通常, 这些操作可以分为两类:

  • 大操作, 例如 FullConncted 和 BatchNormalization
  • 小操作, 例如 element-wise 的乘法和加法

CXXNet 和 Caffe 支持的是大操作,Theano 和 Minerva 支持的是细粒度的操作.

Smaller Operations Can Be More Flexible

使用小操作很容易组合成大操作, 例如, sigmoid 操作很容易通过一个除法操作和一个幂操作来实现

1
sigmoid(x) = 1.0 / (1.0 + exp(-x))

把小抄做作为基础模块, 很容易构建出大操作, 这些操作与 Caffe 中的 layer 相比, 除了更小, 没有区别

1
SigmoidLayer(x) = EWiseDivisionLayer(1.0, AddScalarLayer(ExpLayer(-x), 1.0))

这个表达式由 3 个 layers 组成, 每一个 layer 都有其 forward 和 backward 操作. 使用小操作, 只需要对小抄做进行祝贺就可以快速地构建新的 layers.

Big Operations Are More Efficient

直接构建 sigmoid 层需要使用 3 个 layer operations 而不是一个

1
SigmoidLayer(x) = EWiseDivisionLayer(1.0, AddScalarLayer(ExpLayer(-x), 1.0))

这个代码产生计算和内存的开销(可以优化,但有成本)
CXXNet 和 Caffe 使用了一种不同的方案, 为了直接支持粗粒度的操作, 例如 BatchNormalization 和 Sigmoid, 计算核都是手动进行优化的, 在每个粗粒度的操作中, 只有一个或少数几个 CUDA kernel, 从而使得这种粗粒度的实现运行效率更高.

Compilation and Optimization