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

目前为止,本书主要关注的是命令式编程(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
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
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采取了命令式编程。在后来的版本更新中,TensorFlow2.0和Keras增加了命令式编程。

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

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

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

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

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

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

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

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

我们只需将一个块指定为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.0722, -0.0190]], grad_fn=<AddmmBackward0>)

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

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

我们编写与之前相同的代码,再使用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([[ 0.9541333 , -0.74289465]], dtype=float32)>

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

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

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

import warnings
from d2l import paddle as d2l

warnings.filterwarnings("ignore")
import paddle
from paddle import nn
from paddle.jit import to_static
from paddle.static import InputSpec


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

x = paddle.randn((1, 512))
net = get_net()
net(x)
Tensor(shape=[1, 2], dtype=float32, place=Place(cpu), stop_gradient=False,
       [[-0.19791023,  1.82723355]])

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

net = paddle.jit.to_static(net)
net(x)
Tensor(shape=[1, 2], dtype=float32, place=Place(cpu), stop_gradient=False,
       [[-0.19791023,  1.82723355]])

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

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()
无混合式: 0.5754 sec
混合式: 0.3950 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: 0.1361 sec
有torchscript: 0.1204 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模式: 1.2769 sec
Graph模式: 0.5811 sec

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

#@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)

# InputSpec用于描述模型输入的签名信息,包括shape、dtype和name
x_spec = InputSpec(shape=[-1, 512], name='x')
net = paddle.jit.to_static(get_net(),input_spec=[x_spec])
with Benchmark('飞桨静态图符号式编程'):
    for i in range(1000): net(x)
飞桨动态图命令式编程: 0.4343 sec
飞桨静态图符号式编程: 0.5762 sec

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

12.1.3.2. 序列化

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

net.export('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 ci ci 643K Aug 18 07:00 my_mlp-0000.params
-rw-r--r-- 1 ci ci 3.2K Aug 18 07:00 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 '/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模块替换了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 ci ci 651K Aug 18 06:58 my_mlp

编译模型的好处之一是我们可以将模型及其参数序列化(保存)到磁盘。这允许这些训练好的模型部署到其他设备上,并且还能方便地使用其他前端编程语言。同时,通常编译模型的代码执行速度也比命令式编程更快。在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 64K
drwxr-xr-x 2 ci ci   6 Aug 18 07:38 assets
-rw-r--r-- 1 ci ci 64K Aug 18 07:38 saved_model.pb
drwxr-xr-x 2 ci ci  66 Aug 18 07:38 variables

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

paddle.jit.save(net, './my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 ci ci 643K Aug 18 09:06 my_mlp.pdiparams
-rw-r--r-- 1 ci ci  340 Aug 18 09:06 my_mlp.pdiparams.info
-rw-r--r-- 1 ci ci  60K Aug 18 09:06 my_mlp.pdmodel

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