8.1. 命令式和符号式混合编程

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

  1. In [1]:
  1. def add(a, b):
  2. return a + b
  3.  
  4. def fancy_func(a, b, c, d):
  5. e = add(a, b)
  6. f = add(c, d)
  7. g = add(e, f)
  8. return g
  9.  
  10. fancy_func(1, 2, 3, 4)
  1. Out[1]:
  1. 10

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

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

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

  • 定义计算流程;
  • 把计算流程编译成可执行的程序;
  • 给定输入,调用编译好的程序执行。 下面我们用符号式编程重新实现本节开头给出的命令式编程代码。
  1. In [2]:
  1. def add_str():
  2. return '''
  3. def add(a, b):
  4. return a + b
  5. '''
  6.  
  7. def fancy_func_str():
  8. return '''
  9. def fancy_func(a, b, c, d):
  10. e = add(a, b)
  11. f = add(c, d)
  12. g = add(e, f)
  13. return g
  14. '''
  15.  
  16. def evoke_str():
  17. return add_str() + fancy_func_str() + '''
  18. print(fancy_func(1, 2, 3, 4))
  19. '''
  20.  
  21. prog = evoke_str()
  22. print(prog)
  23. y = compile(prog, '', 'exec')
  24. exec(y)
  1. def add(a, b):
  2. return a + b
  3.  
  4. def fancy_func(a, b, c, d):
  5. e = add(a, b)
  6. f = add(c, d)
  7. g = add(e, f)
  8. return g
  9.  
  10. print(fancy_func(1, 2, 3, 4))
  11.  
  12. 10

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

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

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

8.1.1. 混合式编程取两者之长

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

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

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

8.1.2. 使用HybridSequential类构造模型

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

  1. In [3]:
  1. from mxnet import nd, sym
  2. from mxnet.gluon import nn
  3. import time
  4.  
  5. def get_net():
  6. net = nn.HybridSequential() # 这里创建HybridSequential实例
  7. net.add(nn.Dense(256, activation='relu'),
  8. nn.Dense(128, activation='relu'),
  9. nn.Dense(2))
  10. net.initialize()
  11. return net
  12.  
  13. x = nd.random.normal(shape=(1, 512))
  14. net = get_net()
  15. net(x)
  1. Out[3]:
  1. [[0.08827581 0.00505182]]
  2. <NDArray 1x2 @cpu(0)>

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

  1. In [4]:
  1. net.hybridize()
  2. net(x)
  1. Out[4]:
  1. [[0.08827581 0.00505182]]
  2. <NDArray 1x2 @cpu(0)>

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

8.1.2.1. 计算性能

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

  1. In [5]:
  1. def benchmark(net, x):
  2. start = time.time()
  3. for i in range(1000):
  4. _ = net(x)
  5. nd.waitall() # 等待所有计算完成方便计时
  6. return time.time() - start
  7.  
  8. net = get_net()
  9. print('before hybridizing: %.4f sec' % (benchmark(net, x)))
  10. net.hybridize()
  11. print('after hybridizing: %.4f sec' % (benchmark(net, x)))
  1. before hybridizing: 0.4733 sec
  2. after hybridizing: 0.2195 sec

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

8.1.2.2. 获取符号式程序

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

  1. In [6]:
  1. 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类型的结果。

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

8.1.3. 使用HybridBlock类构造模型

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

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

  1. In [8]:
  1. class HybridNet(nn.HybridBlock):
  2. def __init__(self, **kwargs):
  3. super(HybridNet, self).__init__(**kwargs)
  4. self.hidden = nn.Dense(10)
  5. self.output = nn.Dense(2)
  6.  
  7. def hybrid_forward(self, F, x):
  8. print('F: ', F)
  9. print('x: ', x)
  10. x = F.relu(self.hidden(x))
  11. print('hidden: ', x)
  12. return self.output(x)

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

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

  1. In [9]:
  1. net = HybridNet()
  2. net.initialize()
  3. x = nd.random.normal(shape=(1, 4))
  4. net(x)
  1. F: <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/d2l-zh-build/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
  2. x:
  3. [[-0.12225834 0.5429998 -0.9469352 0.59643304]]
  4. <NDArray 1x4 @cpu(0)>
  5. hidden:
  6. [[0.11134676 0.04770704 0.05341475 0. 0.08091211 0.
  7. 0. 0.04143535 0. 0. ]]
  8. <NDArray 1x10 @cpu(0)>
  1. Out[9]:
  1. [[0.00370749 0.00134991]]
  2. <NDArray 1x2 @cpu(0)>

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

  1. In [10]:
  1. net(x)
  1. F: <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/d2l-zh-build/lib/python3.6/site-packages/mxnet/ndarray/__init__.py'>
  2. x:
  3. [[-0.12225834 0.5429998 -0.9469352 0.59643304]]
  4. <NDArray 1x4 @cpu(0)>
  5. hidden:
  6. [[0.11134676 0.04770704 0.05341475 0. 0.08091211 0.
  7. 0. 0.04143535 0. 0. ]]
  8. <NDArray 1x10 @cpu(0)>
  1. Out[10]:
  1. [[0.00370749 0.00134991]]
  2. <NDArray 1x2 @cpu(0)>

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

  1. In [11]:
  1. net.hybridize()
  2. net(x)
  1. F: <module 'mxnet.symbol' from '/var/lib/jenkins/miniconda3/envs/d2l-zh-build/lib/python3.6/site-packages/mxnet/symbol/__init__.py'>
  2. x: <Symbol data>
  3. hidden: <Symbol hybridnet0_relu0>
  1. Out[11]:
  1. [[0.00370749 0.00134991]]
  2. <NDArray 1x2 @cpu(0)>

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

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

  1. In [12]:
  1. net(x)
  1. Out[12]:
  1. [[0.00370749 0.00134991]]
  2. <NDArray 1x2 @cpu(0)>

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

8.1.4. 小结

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

8.1.5. 练习

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