《动手学深度学习》
Table Of Contents
《动手学深度学习》
Table Of Contents

命令式和符号式混合编程

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

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

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

对比这两种编程方式,我们可以看到

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

混合式编程取两者之长

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

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

本节将通过实验展示混合式编程的魅力。

使用 HybridSequential 类构造模型

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

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

def get_net():
    net = nn.HybridSequential()  # 这里使用 HybridSequential 类。
    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[3]:

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

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

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

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

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

计算性能

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

In [5]:
def benchmark(net, x):
    start = time.time()
    for i in range(1000):
        _ = net(x)
    nd.waitall()  # 等待所有计算完成方便计时。
    return time.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.4385 sec
after hybridizing: 0.2656 sec

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

获取符号式程序

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

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

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

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

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

使用 HybridBlock 类构造模型

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

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

In [8]:
class HybridNet(nn.HybridBlock):
    def __init__(self, **kwargs):
        super(HybridNet, self).__init__(**kwargs)
        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 [9]:
net = HybridNet()
net.initialize()
x = nd.random.normal(shape=(1, 4))
net(x)
F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda2/envs/d2l-zh-build/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
x:
[[-0.12225834  0.5429998  -0.9469352   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[9]:

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

再运行一次前向计算会得到同样的结果。

In [10]:
net(x)
F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda2/envs/d2l-zh-build/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
x:
[[-0.12225834  0.5429998  -0.9469352   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)>

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

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

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

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

再运行一次前向计算看看。

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

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

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

小结

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

练习

  • 在本节 HybridNet 类的hybrid_forward函数中第一行添加x.asnumpy(),运行本节全部代码,观察报错的位置和错误类型。
  • 如果在hybrid_forward函数中加入 Python 的iffor语句会怎么样?
  • 回顾前面几章中你感兴趣的模型,改用 HybridBlock 类或 HybridSequential 类实现。

扫码直达讨论区

image0