13.1. コンパイラとインタプリタ

これまで本書では、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

Python は インタプリタ型言語 である。上の fancy_func 関数を評価するとき、関数本体を構成する操作を 順番に 実行する。つまり、e = add(a, b) を評価して結果を変数 e に保存し、それによってプログラムの状態を変更する。続く 2 つの文 f = add(c, d)g = add(e, f) も同様に実行され、加算が行われて結果が変数として保存される。 図 13.1.1 はデータの流れを示している。

../_images/computegraph.svg

図 13.1.1 命令型プログラムにおけるデータフロー。

命令型プログラミングは便利であるが、非効率な場合がある。ひとつには、fancy_func 全体で add 関数が繰り返し呼び出されても、Python は 3 回の関数呼び出しを個別に実行するからである。これらが、たとえば GPU 上(あるいは複数 GPU 上)で実行される場合、Python インタプリタに起因するオーバーヘッドが非常に大きくなることがある。さらに、fancy_func 内のすべての文が実行されるまで、変数 ef の値を保存しておく必要がある。これは、文 e = add(a, b)f = add(c, d) が実行された後に、変数 ef がプログラムの他の部分で使われるかどうかが分からないためである。

13.1.1. シンボリックプログラミング

代替案として シンボリックプログラミング を考えよう。これは通常、処理が完全に定義されてから計算を行う方式である。この戦略は、Theano や TensorFlow(後者は命令型拡張を取り込んでいます)を含む複数の深層学習フレームワークで使われている。通常、次の手順を含む。

  1. 実行する操作を定義する。

  2. 操作を実行可能なプログラムにコンパイルする。

  3. 必要な入力を与え、コンパイル済みプログラムを呼び出して実行する。

これにより、大幅な最適化が可能になる。まず、多くの場合 Python インタプリタを省略できるため、単一の Python スレッドが CPU 上で動作しながら複数の高速 GPU を使うような状況で顕著になりうる性能ボトルネックを取り除ける。
第二に、コンパイラは上のコードを 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

命令型(インタプリタ型)プログラミングとシンボリックプログラミングの違いは次のとおりである。

  • 命令型プログラミングは簡単である。Python で命令型プログラミングを使う場合、コードの大部分は素直で書きやすいものである。また、命令型プログラミングのコードはデバッグしやすいである。これは、関連する中間変数の値をすべて取得して表示したり、Python に組み込まれたデバッグツールを使ったりしやすいためである。

  • シンボリックプログラミングはより効率的で、移植もしやすいである。シンボリックプログラミングでは、コンパイル中にコードを最適化しやすいだけでなく、Python に依存しない形式へプログラムを移植することもできる。これにより、Python 以外の環境でもプログラムを実行でき、Python インタプリタに関連する潜在的な性能問題を回避できる。

13.1.2. ハイブリッドプログラミング

歴史的に、多くの深層学習フレームワークは命令型かシンボリック型のどちらかを選んできた。たとえば、Theano、TensorFlow(前者に触発された)、Keras、CNTK はモデルをシンボリックに記述する。逆に、Chainer と PyTorch は命令型アプローチを採用している。TensorFlow 2.0 と Keras には、後の改訂で命令型モードが追加された。

上で述べたように、PyTorch は命令型プログラミングに基づいており、動的計算グラフを使用する。シンボリックプログラミングの移植性と効率性を活用するため、開発者たちは両方のプログラミングパラダイムの利点を組み合わせられるかどうかを検討した。その結果、ユーザーは純粋な命令型プログラミングで開発とデバッグを行いながら、製品レベルの計算性能やデプロイが必要なときには、ほとんどのプログラムをシンボリックプログラムへ変換して実行できる torchscript が生まれた。

13.1.3. Sequential クラスのハイブリッド化

ハイブリッド化の仕組みを理解する最も簡単な方法は、複数層を持つ深いネットワークを考えることである。従来は、Python インタプリタがすべての層のコードを実行して、CPU や GPU に転送できる命令を生成する必要があった。単一の(高速な)計算デバイスであれば、これは大きな問題にはならない。一方、AWS P3dn.24xlarge インスタンスのような高度な 8 GPU サーバーを使う場合、Python はすべての GPU を忙しく保つのに苦労する。ここでは単一スレッドの Python インタプリタがボトルネックになる。SequentialHybridSequential に置き換えることで、コードのかなりの部分についてこの問題にどう対処できるか見てみよう。まず、単純な MLP を定義する。

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

# 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.1385, 0.1115]], grad_fn=<AddmmBackward0>)
from d2l import mxnet as d2l
from mxnet import np, npx
from mxnet.gluon import nn
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)
[07:00:25] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
array([[ 0.16526175, -0.14005634]])
#@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')
from d2l import tensorflow as d2l
import tensorflow as tf
from tensorflow.keras.layers import Dense

# 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.5561402, -1.552362 ]], dtype=float32)>

torch.jit.script 関数を使ってモデルを変換することで、MLP 内の計算をコンパイルして最適化できる。モデルの計算結果は変わらない。

net = torch.jit.script(net)
net(x)
tensor([[0.1385, 0.1115]], grad_fn=<AddmmBackward0>)
net.hybridize()
net(x)
array([[ 0.16526175, -0.14005634]])
net = tf.function(net)
net(x)
<tf.Tensor: shape=(1, 2), dtype=float32, numpy=array([[ 1.5561402, -1.552362 ]], dtype=float32)>

これは、あまりにも都合がよすぎるように見えるかもしれない。以前と同じコードを書き、単に torch.jit.script を使ってモデルを変換するだけである。こうするとネットワークは最適化される(性能は後でベンチマークする)。

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

では、ネットワークを 2 回呼び出してみよう。1 回は torchscript なし、もう 1 回は 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: 1.7613 sec
With torchscript: 3.0537 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.6049 sec
With hybridization: 0.5470 sec
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.9632 sec
Graph Mode: 0.4807 sec

上の結果から分かるように、nn.Sequential のインスタンスを torch.jit.script 関数でスクリプト化した後は、シンボリックプログラミングの利用によって計算性能が向上する。

13.1.3.2. シリアライズ

モデルをコンパイルする利点のひとつは、モデルとそのパラメータをディスクにシリアライズ(保存)できることである。これにより、選択したフロントエンド言語に依存しない形でモデルを保存できる。したがって、学習済みモデルを他のデバイスへデプロイしたり、他のフロントエンドプログラミング言語を簡単に使ったりできる。同時に、コードは命令型プログラミングで達成できるものよりもしばしば高速である。save 関数を見てみよう。

net.save('my_mlp')
!ls -lh my_mlp*
-rw-r--r-- 1 ci ci 651K Aug 18 07:11 my_mlp
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
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 07:37 assets
-rw-r--r-- 1 ci ci  55 Aug 18 07:37 fingerprint.pb
-rw-r--r-- 1 ci ci 68K Aug 18 07:37 saved_model.pb
drwxr-xr-x 2 ci ci  66 Aug 18 07:37 variables

13.1.4. まとめ

  • 命令型プログラミングでは、制御フローを使え、Python の豊富なソフトウェアエコシステムを活用できるため、新しいモデルを設計しやすいである。

  • シンボリックプログラミングでは、実行前にプログラムを指定してコンパイルする必要がある。その利点は性能向上である。

13.1.5. 演習

  1. 以前の章で興味を持ったモデルを見直しよ。再実装することで計算性能を改善できるか。