13.1. 编译器和解释器
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

到目前为止,本书主要关注命令式编程,它使用如 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 是一门*解释型语言*。当评估上面的 fancy_func 函数时,它会*按顺序*执行构成该函数主体的操作。也就是说,它会评估 e = add(a, b) 并将结果存为变量 e,从而改变程序的状态。接下来的两个语句 f = add(c, d)g = add(e, f) 也会以类似的方式执行,进行加法运算并存储结果为变量。 图 13.1.1阐释了数据流。

../_images/computegraph.svg

图 13.1.1 命令式程序中的数据流。

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

13.1.1. 符号式编程

考虑另一种选择,即*符号式编程*,其中计算通常只在整个过程被完全定义后才执行一次。这种策略被包括 Theano 和 TensorFlow(后者已获得命令式扩展)在内的多个深度学习框架使用。它通常涉及以下步骤:

  1. 定义要执行的操作。

  2. 将操作编译成可执行程序。

  3. 提供所需输入并调用编译后的程序执行。

这允许进行大量的优化。首先,在很多情况下我们可以跳过 Python 解释器,从而消除一个性能瓶颈,这个瓶颈在多个快速 GPU 与单个 CPU 上的 Python 线程配合使用时可能变得很严重。其次,编译器可能会将上述代码优化并重写为 print((1 + 2) + (3 + 4)) 甚至 print(10)。这是可能的,因为编译器在将其转换为机器指令之前可以看到完整的代码。例如,它可以在一个变量不再需要时释放内存(或从不分配内存)。或者它可以将代码完全转换为等效的代码片段。为了更好地理解,请看下面这个对命令式编程的模拟(毕竟它还是 Python)。

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 解释器相关的任何潜在性能问题。

13.1.2. 混合式编程

历史上,大多数深度学习框架都在命令式或符号式方法之间进行选择。例如,Theano、TensorFlow(受前者启发)、Keras 和 CNTK 都以符号式方式构建模型。相反,Chainer 和 PyTorch 采用命令式方法。在后来的版本中,TensorFlow 2.0 和 Keras 添加了命令式模式。

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

在设计 Gluon 时,开发人员考虑了是否有可能结合两种编程范式的优点。这催生了一种混合模型,允许用户使用纯命令式编程进行开发和调试,同时能够将大多数程序转换为符号式程序,以便在需要产品级计算性能和部署时运行。

在实践中,这意味着我们使用 HybridBlockHybridSequential 类来构建模型。默认情况下,它们中的任何一个都以与命令式编程中 BlockSequential 类相同的方式执行。HybridSequential 类是 HybridBlock 的子类(就像 SequentialBlock 的子类一样)。当调用 hybridize 函数时,Gluon 将模型编译成符号式编程中使用的形式。这使得我们可以在不牺牲模型实现方式的情况下优化计算密集型组件。我们将在下面说明其好处,重点关注序贯模型和块。

命令式编程范式现在是 Tensorflow 2 的默认设置,对于该语言的新手来说,这是一个受欢迎的改变。然而,相同的符号式编程技术和随后的计算图仍然存在于 TensorFlow 中,并且可以通过易于使用的 tf.function 装饰器来访问。这为 TensorFlow 带来了命令式编程范式,允许用户定义更直观的函数,然后使用 TensorFlow 团队称之为 autograph 的功能将它们包装并自动编译成计算图。

13.1.3. 混合化 Sequential

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

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


# Factory for networks
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.1602,  0.0003]], grad_fn=<AddmmBackward0>)

通过使用 torch.jit.script 函数转换模型,我们能够编译和优化 MLP 中的计算。模型的计算结果保持不变。

net = torch.jit.script(net)
net(x)
tensor([[-0.1602,  0.0003]], grad_fn=<AddmmBackward0>)

这似乎好得令人难以置信:像以前一样编写相同的代码,然后简单地使用 torch.jit.script 转换模型。一旦发生这种情况,网络就会被优化(我们将在下面进行性能基准测试)。

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

npx.set_np()

# Factory for networks
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)
[22:07:10] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
array([[ 0.16526175, -0.14005634]])

通过调用 hybridize 函数,我们能够编译和优化 MLP 中的计算。模型的计算结果保持不变。

net.hybridize()
net(x)
array([[ 0.16526175, -0.14005634]])

这似乎好得令人难以置信:只需将一个块指定为 HybridSequential,像以前一样编写相同的代码,然后调用 hybridize。一旦发生这种情况,网络就会被优化(我们将在下面进行性能基准测试)。不幸的是,这并不能神奇地对每一层都起作用。也就是说,如果一个层继承自 Block 类而不是 HybridBlock 类,它将不会被优化。

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


# Factory for networks
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.0301805, -2.5926495]], dtype=float32)>

以前,所有在 TensorFlow 中构建的函数都是作为计算图构建的,因此默认情况下都是 JIT 编译的。然而,随着 TensorFlow 2.X 和 EagerTensor 的发布,这不再是默认行为。我们可以使用 tf.function 重新启用此功能。tf.function 更常用作函数装饰器,但也可以像下面展示的那样直接作为普通 python 函数调用。模型的计算结果保持不变。

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

这似乎好得令人难以置信:像以前一样编写相同的代码,然后简单地使用 tf.function 转换模型。一旦发生这种情况,网络就会在 TensorFlow 的 MLIR 中间表示中构建为计算图,并在编译器级别进行深度优化以实现快速执行(我们将在下面进行性能基准测试)。在 tf.function() 调用中明确添加 jit_compile = True 标志会在 TensorFlow 中启用 XLA(加速线性代数)功能。在某些情况下,XLA 可以进一步优化 JIT 编译的代码。即使没有这个明确的定义,图模式执行也是启用的,但是 XLA 可以使某些大型线性代数运算(就像我们在深度学习应用中看到的那样)更快,尤其是在 GPU 环境中。

13.1.3.1. 通过混合化加速

为了展示编译带来的性能提升,我们比较了在混合化前后评估 net(x) 所需的时间。让我们先定义一个类来测量这个时间。当我们着手测量(和改进)性能时,它将在本章中派上用场。

#@save
class Benchmark:
    """For measuring running time."""
    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,一次不使用。

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

net = torch.jit.script(net)
with Benchmark('With torchscript'):
    for i in range(1000): net(x)
Without torchscript: 2.1447 sec
With torchscript: 4.0545 sec

如上述结果所示,在使用 torch.jit.script 函数对 nn.Sequential 实例进行脚本化后,通过使用符号式编程,计算性能得到了提升。

#@save
class Benchmark:
    """For measuring running time."""
    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('Without hybridization'):
    for i in range(1000): net(x)
    npx.waitall()

net.hybridize()
with Benchmark('With hybridization'):
    for i in range(1000): net(x)
    npx.waitall()
Without hybridization: 0.7242 sec
With hybridization: 0.4670 sec

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

#@save
class Benchmark:
    """For measuring running time."""
    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')

现在我们可以调用网络三次,一次是即时执行,一次是图模式执行,还有一次是使用 JIT 编译的 XLA。

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

net = tf.function(net)
with Benchmark('Graph Mode'):
    for i in range(1000): net(x)
Eager Mode: 1.9038 sec
Graph Mode: 0.4864 sec

如上述结果所示,在使用 tf.function 函数对 tf.keras.Sequential 实例进行脚本化后,通过在 tensorflow 中使用图模式执行的符号式编程,计算性能得到了提升。

13.1.3.2. 序列化

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这使我们能够以一种独立于所选前端语言的方式存储模型。这使我们能够将训练好的模型部署到其他设备,并轻松使用其他前端编程语言。同时,代码通常比在命令式编程中可以实现的速度更快。让我们看看 save 函数的实际操作。

net.save('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 ci ci 651K Aug 18 19:32 my_mlp

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这使我们能够以一种独立于所选前端语言的方式存储模型。这使我们能够将训练好的模型部署到其他设备,并轻松使用其他前端编程语言。同时,代码通常比在命令式编程中可以实现的速度更快。让我们看看 export 函数的实际操作。

net.export('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 ci ci 643K Aug 18 22:07 my_mlp-0000.params
-rw-r--r-- 1 ci ci 3.2K Aug 18 22:07 my_mlp-symbol.json

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

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

早些时候,我们展示了在调用 hybridize 函数后,模型能够实现卓越的计算性能和可移植性。但请注意,混合化可能会影响模型的灵活性,尤其是在控制流方面。

此外,与需要使用 forward 函数的 Block 实例相反,对于 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 '/opt/mxnet/python/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 '/opt/mxnet/python/mxnet/symbol/__init__.py'>
value  x:  <_Symbol data>
result  :  <_Symbol hybridnet0_relu0>
array([[0.00097611, 0.00019453]])

我们现在使用 symbol 模块作为 F,而不是 ndarray。此外,尽管输入是 ndarray 类型,但流经网络的数据现在作为编译过程的一部分被转换为 symbol 类型。重复函数调用会产生一个令人惊讶的结果:

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

这与我们之前看到的完全不同。所有在 hybrid_forward 中定义的打印语句都被省略了。实际上,在混合化之后,net(x) 的执行不再涉及 Python 解释器。这意味着任何无关的 Python 代码(如打印语句)都被省略,以换取更简化的执行和更好的性能。相反,MXNet 直接调用 C++ 后端。还要注意,symbol 模块中不支持某些函数(例如,asnumpy),并且原地操作,如 a += ba[:] = a + b 必须重写为 a = a + b。尽管如此,每当速度至关重要时,编译模型是值得的。根据模型的复杂性、CPU 的速度以及 GPU 的速度和数量,性能提升的范围可以从几个百分点到超过两倍的速度。

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这使我们能够以一种独立于所选前端语言的方式存储模型。这使我们能够将训练好的模型部署到其他设备,并轻松使用其他前端编程语言或在服务器上执行训练好的模型。同时,代码通常比在命令式编程中可以实现的速度更快。在 tensorflow 中允许我们保存的底层 API 是 tf.saved_model。让我们看看 saved_model 实例的实际操作。

net = get_net()
tf.saved_model.save(net, 'my_mlp')
!ls -lh my_mlp*
INFO:tensorflow:Assets written to: my_mlp/assets
total 72K
drwxr-xr-x 2 ci ci   6 Aug 18 19:55 assets
-rw-r--r-- 1 ci ci  56 Aug 18 19:55 fingerprint.pb
-rw-r--r-- 1 ci ci 68K Aug 18 19:55 saved_model.pb
drwxr-xr-x 2 ci ci  66 Aug 18 19:55 variables

13.1.4. 总结

  • 命令式编程使得设计新模型变得容易,因为它可以使用控制流,并能够利用大量的 Python 软件生态系统。

  • 符号式编程要求我们在执行程序之前指定并编译它。其好处是性能得到提升。

  • MXNet 能够根据需要结合这两种方法的优点。

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

13.1.5. 练习

  1. 回顾之前章节中你感兴趣的模型。你是否可以通过重新实现它们来提高它们的计算性能?

讨论

  1. x.asnumpy() 添加到本节 HybridNet 类的 hybrid_forward 函数的第一行。执行代码并观察你遇到的错误。它们为什么会发生?

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

  3. 回顾之前章节中你感兴趣的模型。你是否可以通过重新实现它们来提高它们的计算性能?

讨论

  1. 回顾之前章节中你感兴趣的模型。你是否可以通过重新实现它们来提高它们的计算性能?

讨论