12.1. 编译器和解释器
Open the notebook in Colab
Open the notebook in Colab
Open the notebook in Colab

目前为止,本书主要关注的是命令式编程(imperative programming)。命令式编程使用诸如print+if之类的语句来更改程序的状态。考虑下面这段简单的命令式程序。

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
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
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

Python是一种解释型语言(interpreted language)。因此,当对上面的 fancy_func 函数求值时,它按顺序执行函数体的操作。也就是说,它将通过对 e = add(a, b) 求值,并将结果存储为变量 e,从而更改程序的状态。接下来的两个语句 f = add(c, d)g = add(e, f) 也将执行类似地操作,即执行加法计算并将结果存储为变量。 图12.1.1 说明了数据流。

../_images/computegraph.svg

图12.1.1 命令式编程中的数据流。

尽管命令式编程很方便,但可能效率不高。一方面原因,Python 会单独执行这三个函数的调用,而没有考虑 add 函数在 fancy_func 中被重复调用。如果在一个 GPU(甚至多个 GPU)上执行这些命令,那么 Python 解释器产生的开销可能会非常大。此外,它需要保存 ef 的变量值,直到 fancy_func 中的所有语句都执行完毕。这是因为程序不知道在执行语句 e = add(a, b)f = add(c, d) 之后,其他部分是否会使用变量 ef

12.1.1. 符号式编程

考虑另一种选择符号式编程(symbolic programming),即代码通常只在完全定义了过程之后才执行计算。这个策略被多个深度学习框架使用,包括 Theano 和 TensorFlow(后者已经获得了命令式编程的扩展)。一般包括以下步骤:

  1. 定义计算流程。

  2. 将流程编译成可执行的程序。

  3. 给定输入,调用编译好的程序执行。

这将允许进行大量的优化。首先,在大多数情况下,我们可以跳过 Python 解释器。从而消除因为多个更快的 GPU 与单个 CPU 上 的单个 Python 线程搭配使用时产生的性能瓶颈。其次,编译器可以将上述代码优化和重写为 print((1 + 2) + (3 + 4)) 甚至 print(10)。因为编译器在将其转换为机器指令之前可以看到完整的代码,所以这种优化是可以实现的。例如,只要某个变量不再需要,编译器就可以释放内存(或者从不分配内存),或者将代码转换为一个完全等价的片段。下面,我们将通过模拟命令式编程来进一步了解符号式编程的概念。

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

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

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
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
def add_():
    return '''
def add(a, b):
    return a + b
'''

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

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
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
def add_():
    return '''
def add(a, b):
    return a + b
'''

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

def evoke_():
    return add_() + fancy_func_() + 'print(fancy_func(1, 2, 3, 4))'

prog = evoke_()
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

命令式(解释型)编程和符号式编程的区别如下:

  • 命令式编程更容易使用。在 Python 中,命令式编程的大部分代码都是简单易懂的。命令式编程也更容易调试,这是因为无论是获取和打印所有的中间变量值,或者使用 Python 的内置调试工具都更加简单。

  • 符号式编程运行效率更高,更易于移植。符号式编程更容易在编译期间优化代码,同时还能够将程序移植到与 Python 无关的格式中,从而允许程序在非 Python 环境中运行,避免了任何潜在的与 Python 解释器相关的性能问题。

12.1.2. 混合式编程

历史上,大部分深度学习框架都在命令式编程与符号式编程之间进行选择。例如,Theano、TensorFlow(灵感来自前者)、Keras 和 CNTK 采用了符号式编程。相反地,Chainer 和 PyTorch 采取了命令式编程。在后来的版本更新中,TensorFlow 2.0 和 Keras 增加了命令式编程。

开发人员在设计 Gluon 时思考了这个问题,有没有可能将这两种编程模式的优点结合起来。于是得到了一个混合式编程模型,既允许用户使用纯命令式编程进行开发和调试,还能够将大多数程序转换为符号式程序,以便在需要产品级计算性能和部署时使用。

这意味着我们在实际开发中使用的是 HybridBlock 类或 HybridSequential 类在构建模型。默认情况下,它们都与命令式编程中使用 Block 类或 Sequential 类的方式相同。其中,HybridSequential 类是 HybridBlock 的子类(就如 SequentialBlock 的子类一样)。当 hybridize 函数被调用时,Gluon 将模型编译成符号式编程中使用的形式。这将允许在不牺牲模型实现方式的情况下优化计算密集型组件。下面,我们通过将重点放在 SequentialBlock 上来详细描述其优点。

如上所述,PyTorch 是基于命令式编程并且使用动态计算图。为了能够利用符号式编程的可移植性和效率,开发人员思考能否将这两种编程模型的优点结合起来,于是就产生了 torchscript。torchscript 允许用户使用纯命令式编程进行开发和调试,同时能够将大多数程序转换为符号式程序,以便在需要产品级计算性能和部署时使用。

命令式编程现在是 TensorFlow 2 的默认选择,对于那些刚接触该语言的人来说是一个很好的改变。不过,符号式编程技术和计算图仍然存在于 TensorFlow 中,并且可以通过易于使用的装饰器 tf.function 进行访问。这为 TensorFlow 带来了命令式编程范式,允许用户定义更加直观的函数,然后使用被 TensorFlow 团队称为 autograph 的特性将它们封装,再自动编译成计算图。

12.1.3. Sequential的混合式编程

要了解混合式编程的工作原理,最简单的方法是考虑具有多层的深层网络。按照惯例,Python 解释器需要执行所有层的代码来生成一条指令,然后将该指令转发到 CPU 或 GPU。对于单个的(快速的)计算设备,这不会导致任何重大问题。另一方面,如果我们使用先进的 8-GPU 服务器,比如 AWS P3dn.24xlarge 实例,Python将很难让所有的 GPU 都保持忙碌。在这里,瓶颈是单线程的 Python 解释器。让我们看看如何通过将 Sequential 替换为 HybridSequential 来解决代码中这个瓶颈。首先,我们定义一个简单的多层感知机。

from mxnet import np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()

# 生产网络的工厂模式
def get_net():
    net = nn.HybridSequential()
    net.add(nn.Dense(256, activation='relu'),
            nn.Dense(128, activation='relu'),
            nn.Dense(2))
    net.initialize()
    return net

x = np.random.normal(size=(1, 512))
net = get_net()
net(x)
array([[ 0.16526186, -0.14005628]])

通过调用 hybridize 函数,我们就有能力编译和优化多层感知机中的计算,而模型的计算结果保持不变。

net.hybridize()
net(x)
array([[ 0.16526186, -0.14005628]])

我们只需将一个块指定为 HybridSequential,然后编写与之前相同的代码,再调用 hybridize,当完成这些任务后,网络就将得到优化(我们将在下面对性能进行基准测试)。不幸的是,这种魔法并不适用于每一层。也就是说,如果某个层是从 Block 类而不是从 HybridBlock 类继承的,那么它将不会得到优化。

import torch
from torch import nn
from d2l import torch as d2l


# 生产网络的工厂模式
def get_net():
    net = nn.Sequential(nn.Linear(512, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, 2))
    return net

x = torch.randn(size=(1, 512))
net = get_net()
net(x)
tensor([[-0.0148, -0.0529]], grad_fn=<AddmmBackward>)

通过使用 torch.jit.script 函数来转换模型,我们就有能力编译和优化多层感知机中的计算,而模型的计算结果保持不变。

net = torch.jit.script(net)
net(x)
tensor([[-0.0148, -0.0529]], grad_fn=<AddmmBackward>)

我们编写与之前相同的代码,再使用 torch.jit.script 简单地转换模型,当完成这些任务后,网络就将得到优化(我们将在下面对性能进行基准测试)。

import tensorflow as tf
from tensorflow.keras.layers import Dense
from d2l import tensorflow as d2l


# 生产网络的工厂模式
def get_net():
    net = tf.keras.Sequential()
    net.add(Dense(256, input_shape = (512,), activation = "relu"))
    net.add(Dense(128, activation = "relu"))
    net.add(Dense(2, activation = "linear"))
    return net

x = tf.random.normal([1,512])
net = get_net()
net(x)
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[-1.3659822 ,  0.22285272]], dtype=float32)>

一开始,TensorFlow 中构建的所有函数都是作为计算图构建的,因此默认情况下是 JIT 编译的。但是,随着 TensorFlow 2.X 和 EargeTensor 的发布,计算图就不再是默认行为。我们可以使用 tf.function 重新启用这个功能。tf.function 更常被用作函数装饰器,如下所示,它也可以直接将其作为普通的 Python 函数调用。模型的计算结果保持不变。

net = tf.function(net)
net(x)
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[-1.3659822 ,  0.22285272]], dtype=float32)>

我们编写与之前相同的代码,再使用 tf.function 简单地转换模型,当完成这些任务后,网络将以 TensorFlow 的 MLIR 中间表示形式构建为一个计算图,并在编译器级别进行大量优化以满足快速执行的需要(我们将在下面对性能进行基准测试)。通过将 jit_compile = True 标志添加到 tf.function() 的函数调用中可以显式地启用 TensorFlow 中的 XLA(线性代数加速)功能。在某些情况下,XLA 可以进一步优化 JIT 的编译代码。如果没有这种显式定义,图形模式将会被启用,但是 XLA 可以使某些大规模的线性代数的运算速度更快(与我们在深度学习应用程序中看到的操作类似),特别是在 GPU 环境中。

12.1.3.1. 通过混合式编程加速

为了证明通过编译获得了性能改进,我们比较了混合编程前后执行 net(x) 所需的时间。让我们先定义一个度量时间的函数,它在本章中在衡量(和改进)模型性能时将非常有用。

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

现在我们可以调用网络两次,一次使用混合式,一次不使用混合式。

net = get_net()
with Benchmark('无混合式'):
    for i in range(1000): net(x)
    npx.waitall()

net.hybridize()
with Benchmark('混合式'):
    for i in range(1000): net(x)
    npx.waitall()
无混合式: 1.2091 sec
混合式: 0.6699 sec

如以上结果所示,在 HybridSequential 的实例调用 hybridize 函数后,通过使用符号式编程提高了计算性能。

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

现在我们可以调用网络两次,一次使用 torchscript,一次不使用 torchscript。

net = get_net()
with Benchmark('无torchscript'):
    for i in range(1000): net(x)

net = torch.jit.script(net)
with Benchmark('有torchscript'):
    for i in range(1000): net(x)
无torchscript: 1.2786 sec
有torchscript: 1.3424 sec

如以上结果所示,在 nn.Sequential 的实例被函数 torch.jit.script 脚本化后,通过使用符号式编程提高了计算性能。

#@save
class Benchmark:
    def __init__(self, description='Done'):
        self.description = description

    def __enter__(self):
        self.timer = d2l.Timer()
        return self

    def __exit__(self, *args):
        print(f'{self.description}: {self.timer.stop():.4f} sec')

现在我们可以调用网络三次,一次使用 eager 模式,一次是使用图模式,一次使用 JIT 编译的 XLA。

net = get_net()
with Benchmark('Eager模式'):
    for i in range(1000): net(x)

net = tf.function(net)
with Benchmark('Graph模式'):
    for i in range(1000): net(x)
Eager模式: 0.6498 sec
Graph模式: 0.2635 sec

如以上结果所示,在 tf.keras.Sequential 的实例被函数 tf.function 脚本化后,通过使用 TensorFlow 中的图模式执行方式实现的符号式编程提高了计算性能。

12.1.3.2. 序列化

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这允许这些训练好的模型部署到其他设备上,并且还能方便地使用其他前端编程语言。同时,通常编译模型的代码执行速度也比命令式编程更快。让我们看看 export 的实际功能。

net.export('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 jenkins jenkins 643K Aug 17 17:52 my_mlp-0000.params
-rw-r--r-- 1 jenkins jenkins 3.0K Aug 17 17:52 my_mlp-symbol.json

模型被分解成两个文件,一个是大的二进制参数文件,一个是执行模型计算所需要的程序的 JSON 描述文件。这些文件可以被其他前端语言读取,例如 C++、R、Scala 和 Perl,只要这些语言能够被 Python 或者 MXNet 支持。让我们看看模型描述中的前几行。

!head my_mlp-symbol.json
{
  "nodes": [
    {
      "op": "null",
      "name": "data",
      "inputs": []
    },
    {
      "op": "null",
      "name": "dense3_weight",

之前,我们演示了在调用 hybridize 函数之后,模型能够获得优异的计算性能和可移植性。注意,混合式可能会影响模型的灵活性,特别是在控制流方面。

此外,与 Block 实例需要使用 forward 函数不同的是 HybridBlock 实例需要使用 hybrid_forward 函数。

class HybridNet(nn.HybridBlock):
    def __init__(self, **kwargs):
        super(HybridNet, self).__init__(**kwargs)
        self.hidden = nn.Dense(4)
        self.output = nn.Dense(2)

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

上述代码实现了一个具有 \(4\) 个隐藏单元和 \(2\) 个输出的简单网络。hybrid_forward 函数增加了一个必需的参数F,因为是否采用混合模式将影响代码使用稍微不同的库(ndarraysymbol)进行处理。这两个类执行了非常相似的函数,于是 MXNet 将自动确定这个参数。为了理解发生了什么,我们将打印参数作为了函数调用的一部分。

net = HybridNet()
net.initialize()
x = np.random.normal(size=(1, 3))
net(x)
module F:  <module 'mxnet.ndarray' from '/var/lib/jenkins/miniconda3/envs/d2l-zh-release-0/lib/python3.8/site-packages/mxnet/ndarray/__init__.py'>
value  x:  [[-0.6338663   0.40156594  0.46456942]]
result  :  [[0.01641375 0.         0.         0.        ]]
array([[0.00097611, 0.00019453]])

重复的前向计算将导致相同的输出(细节已被省略)。现在看看调用 hybridize 函数会发生什么。

net.hybridize()
net(x)
module F:  <module 'mxnet.symbol' from '/var/lib/jenkins/miniconda3/envs/d2l-zh-release-0/lib/python3.8/site-packages/mxnet/symbol/__init__.py'>
value  x:  <_Symbol data>
result  :  <_Symbol hybridnet0_relu0>
array([[0.00097611, 0.00019453]])

程序使用 symbol 模块替换了 ndarray 模块来表示 F。而且,即使输入是 ndarray 类型,流过网络的数据现在也转换为 symbol 类型,这种转换正是编译过程的一部分。再次的函数调用产生了令人惊讶的结果:

net(x)
array([[0.00097611, 0.00019453]])

这与我们在前面看到的情况大不相同。hybrid_forward 中定义的所有打印语句都被忽略了。实际上,在 net(x) 被混合执行时就不再使用 Python 解释器。这意味着任何 Python 代码(例如 print 语句)都会被忽略,以利于更精简的执行和更好的性能。MXNet 通过直接调用 C++ 后端替代 Python 解释器。另外请注意,symbol 模块不能支持某些函数(例如asnumpy),因此 a += ba[:] = a + b 等操作必须重写为 a = a + b。尽管如此,当速度很重要时,模型的编译也是值得的。速度的优势可以从很小的百分比到两倍以上,主要取决于模型的复杂性、CPU 的速度以及 GPU 的速度和数量。

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这允许这些训练好的模型部署到其他设备上,并且还能方便地使用其他前端编程语言。同时,通常编译模型的代码执行速度也比命令式编程更快。让我们看看 save 的实际功能。

net.save('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 jenkins jenkins 651K Aug 17 18:31 my_mlp

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这允许这些训练好的模型部署到其他设备上,并且还能方便地使用其他前端编程语言。同时,通常编译模型的代码执行速度也比命令式编程更快。在 TensorFlow 中保存模型的底层 API 是 tf.saved_model,让我们来看看saved_model的运行情况。

net = get_net()
tf.saved_model.save(net, 'my_mlp')
!ls -lh my_mlp*
WARNING:tensorflow:From /var/lib/jenkins/miniconda3/envs/d2l-zh-release-0/lib/python3.8/site-packages/tensorflow/python/training/tracking/tracking.py:111: Model.state_updates (from tensorflow.python.keras.engine.training) is deprecated and will be removed in a future version.
Instructions for updating:
This property should not be used in TensorFlow 2.0, as updates are applied automatically.
WARNING:tensorflow:From /var/lib/jenkins/miniconda3/envs/d2l-zh-release-0/lib/python3.8/site-packages/tensorflow/python/training/tracking/tracking.py:111: Layer.updates (from tensorflow.python.keras.engine.base_layer) is deprecated and will be removed in a future version.
Instructions for updating:
This property should not be used in TensorFlow 2.0, as updates are applied automatically.
INFO:tensorflow:Assets written to: my_mlp/assets
total 68K
drwxr-xr-x 2 jenkins jenkins 4.0K Aug 17 19:14 assets
-rw-r--r-- 1 jenkins jenkins  60K Aug 17 19:14 saved_model.pb
drwxr-xr-x 2 jenkins jenkins 4.0K Aug 17 19:14 variables

12.1.4. 小结

  • 命令式编程使得新模型的设计变得容易,因为可以依据控制流编写代码,并拥有相对成熟的 Python 软件生态。

  • 符号式编程要求我们先定义并且编译程序,然后再执行程序,其好处是提高了计算性能。

  • MXNet 能够根据用户需要,结合这两种方法(命令式编程和符号式编程)的优点。

  • HybridSequentialHybridBlock 类构造的模型能够通过调用 hybridize 函数将命令式程序转换为符号式程序。

12.1.5. 练习

  1. 在本节的 HybridNet 类的 hybrid_forward 函数的第一行中添加 x.asnumpy(),执行代码并观察遇到的错误。为什么会这样?

  2. 如果我们在 hybrid_forward 函数中添加控制流,即 Python 语句 iffor,会发生什么?

  3. 回顾前几章中你感兴趣的模型,你能通过重新实现它们来提高它们的计算性能吗?

Discussions

  1. 回顾前几章中你感兴趣的模型,你能通过重新实现它们来提高它们的计算性能吗?

Discussions

  1. 回顾前几章中你感兴趣的模型,你能通过重新实现它们来提高它们的计算性能吗?

Discussions