无论是当数据集很大还是计算资源或应用有约束条件时,深度学习十分关注计算性能。本章将重点介绍影响计算性能的重要因子:命令式编程、符号式编程、惰性计算、自动并行计算和多GPU计算。通过本章的学习,读者很可能进一步提升已有模型的计算性能,例如在不影响模型精度的前提下减少模型的训练时间。

命令式和符号式混合编程

其实,到目前为止我们一直都在使用命令式编程:使用编程语句改变程序状态。考虑下面这段简单的命令式编程代码。

In [1]:
def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g

fancy_func(1, 2, 3, 4)
Out[1]:
10

和我们预期的一样,在运行e = add(a, b)时,Python会做加法运算并将结果存储在变量e,从而令程序的状态发生了改变。类似地,后面的两个语句f = add(c, d)g = add(e, f)会依次做加法运算并存储变量。

虽然使用命令式编程很方便,但它的运行可能会慢。一方面,即使fancy_func函数中的add是被重复调用的函数,Python也会逐一执行这三个函数调用语句。另一方面,我们需要保存变量ef的值直到fancy_func中所有语句执行结束。这是因为在执行e = add(a, b)f = add(c, d)之前我们并不知道变量ef是否会被程序的其他部分使用。

与命令式编程不同,符号式编程通常在计算流程完全定义好后才被执行。大部分的深度学习框架,例如Theano和TensorFlow,都使用了符号式编程。通常,符号式编程的程序需要下面三个步骤:

  1. 定义计算流程;
  2. 把计算流程编译成可执行的程序;
  3. 给定输入,调用编译好的程序执行。

下面我们用符号式编程重新实现本节开头给出的命令式编程代码。

In [2]:
def add_str():
    return '''
def add(a, b):
    return a + b
'''

def fancy_func_str():
    return '''
def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g
'''

def evoke_str():
    return add_str() + fancy_func_str() + '''
print(fancy_func(1, 2, 3, 4))
'''

prog = evoke_str()
print(prog)
y = compile(prog, '', 'exec')
exec(y)

def add(a, b):
    return a + b

def fancy_func(a, b, c, d):
    e = add(a, b)
    f = add(c, d)
    g = add(e, f)
    return g

print(fancy_func(1, 2, 3, 4))

10

以上定义的三个函数都只是返回计算流程。最后,我们编译完整的计算流程并运行。由于在编译时系统能够完整地看到整个程序,因此有更多空间优化计算。例如,编译的时候可以将程序改写成print((1 + 2) + (3 + 4)),甚至直接改写成print(10)。这样不仅减少了函数调用,还节省了内存。

总结一下,

  • 命令式编程更方便。当我们在Python里使用命令式编程时,大部分代码编写起来都符合直觉。同时,命令式编程更容易除错。这是因为我们可以很方便地拿到所有的中间变量值并打印,或者使用Python的除错工具。
  • 符号式编程更高效并更容易移植。一方面,在编译的时候系统可以容易地做更多优化;另一方面,符号式编程可以将程序变成一个与Python无关的格式,从而可以使程序在非Python环境下运行。

混合式编程取两者之长

大部分的深度学习框架在命令式编程和符号式编程之间二选一。例如Theano和受其启发的后来者TensorFlow使用了符号式编程;Chainer和它的追随者PyTorch使用了命令式编程。开发人员在设计Gluon时思考了这个问题:有没有可能既拿到命令式编程的好处,又享受符号式编程的优势?开发者们认为,用户应该用纯命令式编程进行开发和调试;当需要产品级别的性能和部署时,用户可以将至少大部分程序转换成符号式来运行。

值得强调的是,Gluon可以通过混合式编程做到这一点。在混合式编程中,我们可以通过使用HybridBlock或者HybridSequential类构建模型。默认情况下,它们和Block或者Sequential类一样依据命令式编程的方式执行。当我们调用hybridize函数后,Gluon会转换成依据符号式编程的方式执行。事实上,绝大多数模型都可以享受符号式编程的优势。

本节将通过实验展示混合式编程的魅力。首先,导入本节中实验所需的包。

In [3]:
from mxnet.gluon import nn
from mxnet import nd, sym
from time import time

使用HybridSequential类构造模型

我们之前学习了如何使用Sequential类来串联多个层。为了使用混合式编程,下面我们将Sequential类替换成HybridSequential类。

In [4]:
def get_net():
    net = nn.HybridSequential()
    with net.name_scope():
        net.add(
            nn.Dense(256, activation="relu"),
            nn.Dense(128, activation="relu"),
            nn.Dense(2)
        )
    net.initialize()
    return net

x = nd.random.normal(shape=(1, 512))
net = get_net()
net(x)
Out[4]:

[[ 0.08827581  0.00505182]]
<NDArray 1x2 @cpu(0)>

我们可以通过调用hybridize函数来编译和优化HybridSequential实例中串联的层的计算。模型的计算结果不变。

In [5]:
net.hybridize()
net(x)
Out[5]:

[[ 0.08827581  0.00505182]]
<NDArray 1x2 @cpu(0)>

需要注意的是,只有继承HybridBlock的层才会被优化。例如,HybridSequential类和Gluon提供的Dense层都是HybridBlock的子类,它们都会被优化计算。如果一个层只是继承自Block而不是HybridBlock,那么它将不会被优化。我们接下会讨论如何使用HybridBlock。

性能

我们比较调用hybridize函数前后的计算时间来展示符号式编程的性能提升。这里我们计时1000次net模型计算。在net调用hybridize函数前后,它分别依据命令式编程和符号式编程做模型计算。

In [6]:
def benchmark(net, x):
    start = time()
    for i in range(1000):
        y = net(x)
    # 等待所有计算完成。
    nd.waitall()
    return time() - start

net = get_net()
print('Before hybridizing: %.4f sec' % (benchmark(net, x)))
net.hybridize()
print('After hybridizing: %.4f sec' % (benchmark(net, x)))
Before hybridizing: 0.3333 sec
After hybridizing: 0.1884 sec

由上面结果可见,在一个HybridSequential实例调用hybridize函数后,它可以通过符号式编程提升计算性能。

获取符号式程序

在模型net根据输入计算模型输出后,例如benchmark函数中的net(x),我们就可以通过export函数来保存符号式程序和模型参数到硬盘。

In [7]:
net.export('my_mlp')

此时生成的.json和.params文件分别为符号式程序和模型参数。它们可以被Python或MXNet支持的其他前端语言读取,例如C++。这样,我们就可以很方便地使用其他前端语言或在其他设备上部署训练好的模型。同时,由于部署时使用的是基于符号式编程的程序,计算性能往往比基于命令式编程更好。

在MXNet中,符号式程序指的是Symbol类型的程序。我们知道,当给net提供NDArray类型的输入x后,net(x)会根据x直接计算模型输出并返回结果。对于调用过hybridize函数后的模型,我们还可以给它输入一个Symbol类型的变量,net(x)会返回同样是Symbol类型的程序。

In [8]:
x = sym.var('data')
net(x)
Out[8]:
<Symbol hybridsequential1_dense2_fwd>

使用HybridBlock构造模型

Sequential类与Block之间的关系一样,HybridSequential类是HybridBlock的子类。跟Block需要实现forward函数不太一样的是,对于HybridBlock我们需要实现hybrid_forward函数。

前面我们展示了调用hybridize函数后的模型可以获得更好的计算性能和移植性。另一方面,调用hybridize后的模型会影响灵活性。为了解释这一点,我们先使用HybridBlock构造模型。

In [9]:
class HybridNet(nn.HybridBlock):
    def __init__(self, **kwargs):
        super(HybridNet, self).__init__(**kwargs)
        with self.name_scope():
            self.hidden = nn.Dense(10)
            self.output = nn.Dense(2)

    def hybrid_forward(self, F, x):
        print('F: ', F)
        print('x: ', x)
        x = F.relu(self.hidden(x))
        print('hidden: ', x)
        return self.output(x)

在继承HybridBlock时,我们需要在hybrid_forward函数中添加额外的输入F。我们知道,MXNet既有基于命令式编程的NDArray类,又有基于符号式编程的Symbol类。由于这两个类的函数基本一致,MXNet会根据输入来决定F使用NDArray或Symbol。

下面创建了一个HybridBlock实例。可以看到默认下F使用NDArray。而且,我们打印出了输入x和使用ReLU激活函数的隐藏层的输出。

In [10]:
net = HybridNet()
net.initialize()
x = nd.random.normal(shape=(1, 4))
net(x)
F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/gluon_zh_docs/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
x:
[[-0.12225834  0.5429998  -0.94693518  0.59643304]]
<NDArray 1x4 @cpu(0)>
hidden:
[[ 0.11134676  0.04770704  0.05341475  0.          0.08091211  0.          0.
   0.04143535  0.          0.        ]]
<NDArray 1x10 @cpu(0)>
Out[10]:

[[ 0.00370749  0.00134991]]
<NDArray 1x2 @cpu(0)>

再运行一次会得到同样的结果。

In [11]:
net(x)
F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/gluon_zh_docs/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
x:
[[-0.12225834  0.5429998  -0.94693518  0.59643304]]
<NDArray 1x4 @cpu(0)>
hidden:
[[ 0.11134676  0.04770704  0.05341475  0.          0.08091211  0.          0.
   0.04143535  0.          0.        ]]
<NDArray 1x10 @cpu(0)>
Out[11]:

[[ 0.00370749  0.00134991]]
<NDArray 1x2 @cpu(0)>

接下来看看调用hybridize函数后会发生什么。

In [12]:
net.hybridize()
net(x)
F:  <module 'mxnet.symbol' from '/var/lib/jenkins/miniconda3/envs/gluon_zh_docs/lib/python3.6/site-packages/mxnet/symbol/__init__.py'>
x:  <Symbol data>
hidden:  <Symbol hybridnet0_relu0>
Out[12]:

[[ 0.00370749  0.00134991]]
<NDArray 1x2 @cpu(0)>

可以看到,F变成了Symbol。而且,虽然输入数据还是NDArray,但hybrid_forward函数里,相同输入和中间输出全部变成了Symbol。

再运行一次看看。

In [13]:
net(x)
Out[13]:

[[ 0.00370749  0.00134991]]
<NDArray 1x2 @cpu(0)>

可以看到hybrid_forward函数里定义的三行打印语句都没有打印任何东西。这是因为上一次在调用hybridize函数后运行net(x)的时候,符号式程序已经得到。之后再运行net(x)的时候MXNet将不再访问Python代码,而是直接在C++后端执行符号式程序。这也是调用hybridize后模型计算性能会提升的一个原因。但它可能的问题是我们损失了写程序的灵活性。在上面这个例子中,如果我们希望使用那三行打印语句调试代码,执行符号式程序时会跳过它们无法打印。此外,对于少数Symbol不支持的函数,例如asnumpy,我们是无法在hybrid_forward函数中使用并在调用hybridize函数后进行模型计算的。

小结

  • 命令式编程和符号式编程各有优劣。MXNet通过混合式编程取两者之长。
  • 通过HybridSequential类和HybridBlock构建的模型可以调用hybridize来将将命令式程序转成符号式程序。我们建议读者使用这种方法获得计算性能的提升。

练习

  • 在本节HybridNethybrid_forward函数中第一行添加x.asnumpy(),运行本节全部代码,观察报错的位置和错误类型。
  • 回顾前面几章中你感兴趣的模型,改用HybridBlock或HybridSequential类实现。

扫码直达讨论区