MXNet Architecture : 深層学習のためのプログラミング・モデル

MXNet Architecture : 深層学習のためのプログラミング・モデル (翻訳・解説)
翻訳 : (株)クラスキャット セールスインフォメーション
日時 : 02/16/2017

* 本ページは、MXNet 本家サイトの “Architecture : Programming Models for Deep Learning” を翻訳した上で適宜、補足説明したものです:
    http://mxnet.io/architecture/program_model.html

 

多くの深層学習ライブラリがあり、それぞれはそれ自身のアプローチとともにあります。システム最適化とユーザ体験の視点から、各ライブラリの利点と欠点は何でしょう?このトピックではプログラミング・モデルを比較して、各々の基本的な利点と欠点を議論し、そしてどのようにそれらから学習できるかを探ります。

このトピックでは深層学習ライブラリのベンチマークはしません。実装の代わりに、プログラミング・モデル自身に焦点を当てます。ライブラリをユーザ・インターフェイスのタイプによって幾つかのカテゴリに分割し、そしてインターフェイス・スタイルがパフォーマンスと柔軟性にどのように影響を与えるかを議論します。議論は深層学習に特化されたものではありませんが、深層学習アプリケーションをユースケースそしてゴールとしての最適化のために使用します。

 

記号型 vs. 命令型プログラム

記号-スタイルのプログラムを命令-スタイルのプログラムと比較してみましょう。もし貴方が Python または C++ プログラマであるならば、既に貴方は命令型プログラムに慣れ親しんでいます。命令-スタイルのプログラムはそれらを実行した時に計算が遂行されます。次の NumPy snippet のように、Python で書いたコードの殆どは命令型です。

import numpy as np
a = np.ones(10)
b = np.ones(10) * 2
c = b * a
d = c + 1

プログラムが $c = b * a$ を実行する時、実際の計算が実行されます。

記号型プログラムは少し異なります。次の snippet は $d$ を計算する同じ目的を達成するための記号-スタイルのプログラムと同値です。

A = Variable('A')
B = Variable('B')
C = B * A
D = C + Constant(1)
# compiles the function
f = compile(D)
d = f(A=np.ones(10), B=np.ones(10)*2)

記号型プログラムにおける違いは $C = B * A$ が実行される時、計算は発生しないことです。代わりに、これらの演算は計算を表現する計算グラフ (記号型グラフ) を生成します。

記号-スタイルのプログラムの殆どは、明示的であれ暗黙的である、コンパイル・ステップを含みます。これは計算グラフを呼び出し可能な関数に変換します。計算はコードの最後のステップで発生します。記号型プログラムの主要な特性は計算グラフの定義とコンパイリングの明確な分離です。

命令-スタイルの深層学習ライブラリは Torch, Chainer, そして Minerva です。記号-スタイルの深層学習ライブラリは Theano, CGT, そして TensorFlow を含みます。CXXNet と Caffe のような、configuration ファイルを使用するライブラリもまた記号-スタイル・ライブラリとして見ることができます、そこでは configuration ファイルの内容が計算グラフを定義します。

2つのプログラミング・モデルの違いを理解した今、それらを比較してみましょう。

命令型プログラムはより柔軟である

これは一般的な主張で厳密には真ではないかもしれませんが、命令型プログラムは通常は記号型プログラムよりもより柔軟です。Python で命令-スタイルのプログラムを書いている時には、貴方は Python で書いています。記号型プログラムを書いている時には、それは異なります。次の命令型プログラムを考えて、これをどのように記号型プログラムに翻訳するかについて考えてください。

a = 2
b = a + 1
d = np.zeros(10)
for i in range(len(d)):
    d += np.zeros(10)

それは簡単ではありません、何故ならば記号型 API では容易にサポートされないかもしれない Python for-ループがあるからです。Python で記号型プログラムを書くとき、貴方は Python では書いていません。その代わり、貴方は記号型 API で定義されたドメイン固有言語 (DSL) を書いています。記号型 API は DSL のよりパワフルなバージョンで計算グラフあるいはニューラルネットワークの configuration を生成します。そういう意味で、config-file 入力ライブラリは全て記号型です。

命令型プログラムは記号型プログラムよりもより native ですので、native 言語の特徴 – 計算途中で値をプリントアウトしたりホスト言語で条件とループを使用したりというような – を使用して計算フローに注入することはより簡単です。

記号型プログラムはより効率的である

既に見たように、命令型プログラムは通常はより柔軟でホスト言語に対して native です。では何故より多い深層学習ライブラリは記号型なのでしょう?主要な理由は効率性です、メモリとランタイムという視点から。

このセクションの最初で使用したのと同じサンプルを考えてみましょう。

import numpy as np
a = np.ones(10)
b = np.ones(10) * 2
c = b * a
d = c + 1
...

配列の各セルは 8 バイト消費すると仮定します。Python コンソールでこのプログラムを実行するにはどのくらいのメモリが必要でしょう?サイズ 10 の 4 配列のためのメモリが必要で、これは $4 * 10 * 8 = 320$ バイト必要なことを意味します。一方で、計算グラフを実行するためには、最後の計算を適所で行うために、C と D のメモリは再利用できます。これは $3 * 10 * 8 = 240$ バイトだけを要求します。

記号型プログラムはより制限されています。D 上でコンパイラを呼び出せば、システムに D の値のみが必要であることを伝えます。計算の中間値、この場合 C ですが、は不可視です。これは記号型プログラムに、適所での計算のためにメモリを安全に再利用することを可能にします。

一方で、命令型プログラムは全ての可能性のある要求に遭遇するために準備される必要があります。前述のプログラムを Python コンソールで実行する場合、任意の変数は将来使用される可能性があります。これはシステムが変数メモリ空間を共有することを妨げます。

もちろん、これは幾分ミスリードです、何故なら命令型プログラムではガーベジ・コレクションが発生してメモリはそれから再利用されるからです。けれども、命令型プログラムは “全ての可能性のある要求に遭遇するために準備される” ことは必要でこれは遂行できる最適化を制限します。勾配計算のような、自明ではないケースに対して真で、これは次のセクションで議論します。

記号型プログラムは他の種類の最適化、operation folding を遂行できます。サンプル・プログラムでは、乗算と加算演算が一つの演算に fold されます(折りたたまれます)。計算が GPU プロセッサで実行されるならば、2つの代わりに、一つの GPU カーネルが実行されます。これが、CXXNet と Caffe のような最適化されたライブラリでお手製の演算に行なうことです。operation folding は計算を効率的に改善します。

命令型プログラムでは operation folding は遂行できません、何故ならば中間値が将来的に参照されるかもしれないからです。operation folding は記号型プログラムでは可能です、何故ならば全体の計算グラフとどの値が必要でどれがそうでないかのクリアな境界を得るからです。命令型プログラムはローカル演算上でのみ動作してそのようなクリアな境界を持ちません。

ケース・スタディ: Backprop と AutoDiff

このセクションでは、2つのプログラミング・モデルが自動微分、あるいは backpropagation の問題上でどのように実行するかを比較します。全ての深層学習ライブラリは勾配計算の問題を解く必要があります。命令型も記号型プログラムの両者とも勾配計算を遂行できます。

命令型プログラムで始めましょう。次のサンプルは Python コードがこのトピックで使用したサンプルで自動微分を遂行します。

class array(object) :
    """Simple Array object that support autodiff."""
    def __init__(self, value, name=None):
        self.value = value
        if name:
            self.grad = lambda g : {name : g}

    def __add__(self, other):
        assert isinstance(other, int)
        ret = array(self.value + other)
        ret.grad = lambda g : self.grad(g)
        return ret 

    def __mul__(self, other):
        assert isinstance(other, array)
        ret = array(self.value * other.value)
        def grad(g):
            x = self.grad(g * other.value)
            x.update(other.grad(g * self.value))
            return x
        ret.grad = grad
        return ret 

# some examples
a = array(1, 'a')
b = array(2, 'b')
c = b * a 
d = c + 1 
print d.value
print d.grad(1)
# Results
# 3 
# {'a': 2, 'b': 1}

このコードでは、各配列オブジェクトは grad 関数(実際には closure)を含みます。$d.grad$ を実行した時、それは再帰的にその入力の grad 関数を呼び出し、勾配の値を backprop して戻し、そして各々の入力の勾配の値を返します。

これは少し複雑に見えますので、記号型プログラムのための勾配計算を考えてみます。次のプログラムは同じタスクに対する記号型勾配計算を実行します。

A = Variable('A')
B = Variable('B')
C = B * A 
D = C + Constant(1)
# get gradient node.
gA, gB = D.grad(wrt=[A, B]) 
# compiles the gradient function.
f = compile([gA, gB])
grad_a, grad_b = f(A=np.ones(10), B=np.ones(10)*2)

D の grad 関数は backward 計算グラフを生成し、勾配ノード、$gA$, $gB$ を返します。

命令型プログラムは実際には記号型プログラムと同じことを行ないます。それは grad closure に backward 計算を暗黙的に保存します。$d.grad$ を実行した時、$d(D)$ から始まり、勾配を計算するためにグラフを backgrack し、そして結果を集めて返します。

記号型と命令型プログラミングの勾配計算は同じパターンをフォローします。では違いは何でしょう?命令型プログラムの要請 “be prepared to encounter all possible demands” を思い出してください。自動微分をサポートする配列ライブラリを作成しているならば、grad closure を計算に沿って保持しなければなりません。これはどの history variables もガーベージ・コレクションされないことを意味します、何故ならばそれらは関数 closure 経由で変数 $d$ により参照されるからです。

$d$ の値のみを計算することを望み、勾配の値を望まないとしたらどうでしょう?記号型プログラミングでは、これを $f=compiled([D])$ で宣言します。これはまたシステムに foward pass のみを計算することを望むことを伝え、計算の境界を宣言します。結果的に、システムは以前の結果のメモリを開放することができ、そして入力と出力の間でメモリを共有します。

n 層の深層ニューラルネットワークを実行することを想像してください。backward (勾配) pass ではなく、forward パスのみを実行するならば、中間層の値を保持するために(n コピーの代わりに)一時スペースの2つのコピーだけを割り当てる必要があります。けれども、命令型プログラムは勾配を得ることの全ての可能性のある要求に遭遇するために準備する必要があるので、中間値を保持しなければなりません、これは一時スペースの n コピーを必要とします。

貴方が見るように、最適化のレベルはできることの上での制限に依存します。記号型プログラムはコンパイリングあるいはその同値により計算の境界を明確に指定します。命令型プログラムは全ての可能性のある要求に遭遇するための準備をします。記号型プログラムは自然な利点を持ちます、何故ならば貴方が何を行ない何を行なわないかについて、より知っているからです。

もちろん、制限を課すように命令型プログラムを強化することもまたできます。例えば、前述の問題への一つの解は context 変数を導入します。勾配計算を無効にするための no gradient context 変数を導入できます。これは命令型プログラムにある制限を課すための能力を提供します、しかし効率性は現象します。

with context.NoGradient():
    a = array(1, 'a')
    b = array(2, 'b')
    c = b * a
    d = c + 1

けれども、この例は依然として全ての可能性のある要求に遭遇するために準備されなければなりません、これは forward pass においてメモリを再利用するために in-place 計算を実行できないことを意味します (GPU メモリ使用量を減少させるために一般に使われるトリック)。議論してきたテクニックは明確な backward pass を生成します。Caffe と CXXNet のようなライブラリの幾つかは backprop を同じグラフ上で暗黙的に実行します。このセクションで議論したアプローチはそれらにも適用されます。

CXXNet と Caffe のような configuration ファイル・ベースのライブラリの多くは一つあるいは2つの一般的な要求を満たすために設計されています : 各層の活性を得る、あるいは全ての重みの勾配を得ることです。これらのライブラリは同じ問題を持ちます: the more generic operations the library has to support, the less optimization (memory sharing) you can do, based on the same data structure.

貴方が見るように、制限と柔軟性の間のトレードオフは多くのケースで同じです。

Model Checkpoint

model を保存して後でロードし戻すことができることは重要です。貴方のワークを保存するためには異なる方法があります。通常は、ニューラルネットワークを保存するためには、2つのものを保存する必要があります: ニューラルネットワークの構造のためのネット configuration とニューラルネットワークの重みです。

configuration をチェックする能力は記号型プログラムに対してプラスです。記号型構築フェイズは計算を実行しますので、計算グラフを直接シリアライズして、後でそれをロードし戻すことができます。これは追加の層を導入することなしに configuration を保存する問題を解決します。

    A = Variable('A')
    B = Variable('B')
    C = B * A
    D = C + Constant(1)
    D.save('mygraph')
    ...
    D2 = load('mygraph')
    f = compile([D2])
    # more operations
    ...

命令型プログラムは計算を記述した時に実行されますので、コード自身を configuration として保存しなければなりません、あるいは他の configuration 層を命令型言語の上に構築しなければなりません。

パラメータ更新

多くの記号型プログラムはデータフロー (計算) グラフです。データフローは計算を記述します。しかしパラメータ更新を記述するためにグラフをどのように使うかは明確ではありません。それがパラメータ更新が mutation を導入する理由です、それはデータフローのコンセプトではありません。多くの記号型プログラムはプログラムのある恒久的なステートの更新のために特別な更新ステートメントを導入しています。

通常は命令型でパラメータ更新を書くのがより簡単です、特に互いに関係がある複数の更新が必要な時に。記号型プログラムに対しては、(それを呼び出す時)更新ステートメントがまた実行されます。従ってその意味で、記号型深層学習ライブラリの殆どは更新を実行するために命令型アプローチ上に fall back し、一方で勾配計算を実行するためには記号型アプローチを使用します。

厳密な境界はありません

2つのプログラミング・スタイルを比較する中で、議論の幾つかは必ずしも厳密には真ではありません。けれども、原理の多くは一般的に真を保持して深層学習ライブラリを作成する時に適用されます。プログラミング・スタイルの間に明瞭な境界はないことを結論づけました。例えば、命令的な Python プログラムをコンパイルするために Python で just-in-time (JIT) コンパイラを作成することができ、これは記号型プログラムで保持されるグローバルな情報の利点の幾つかを提供します。

 

Big vs. Small 演算

深層学習ライブラリによりサポートされる演算について話しましょう。通常、様々な深層学習ライブラリによりサポートされる演算には2つのタイプがあります :

  • Big 層演算、例えば FullyConnected と BatchNormalize のような。
  • Small 演算、例えば element-wise な加算と乗算のような。

CXXNet and Caffe のようなライブラリは層-レベル演算をサポートします。Theano と Minerva のようなライブラリはきめ細かい演算をサポートします。

より小さい演算子はより柔軟である

より大きい演算を構成するためにより小さい演算子を使うことは非常に自然です。例えば、sigmoid ユニットは単に除算と指数関数で構成されます :

sigmoid(x) = 1.0 / (1.0 + exp(-x))

より小さい演算子をビルディング・ブロックとして使用すれば、望む問題の多くを表現できます。CXXNet- あるいは Caffe-スタイルの層に慣れていれば、これらの演算は、より小さいことを除けば、層と違わないことに注意してください。

SigmoidLayer(x) = EWiseDivisionLayer(1.0, AddScalarLayer(ExpLayer(-x), 1.0))

この式は各々の forward と backward (勾配) 関数を定義することで、3つの層から構成されています。より小さな演算子を使うことは新しい層を迅速に構築する利点を与えます、何故ならばコンポーネントを組み立てる必要があるだけだからです。

大きな演算子はより効率的である

sigmoid 層を直接構成するには演算子の3つの層が、一つの代わりに必要です。

SigmoidLayer(x) = EWiseDivisionLayer(1.0, AddScalarLayer(ExpLayer(-x),   1.0))

このコードは(コストで最適化可能な)計算とメモリに対してオーバーヘッドを作ります。

CXXNet と Caffe のようなライブラリは異なるアプローチを取ります。直接的に BatchNormalization と SigmoidLayer のような粗挽きの (= coarse-grained) 演算子をサポートするために、各々の層で、計算カーネルが一つあるいは幾つかだけの CUDA カーネルが起動します。これはこれらの実装をより効率的にします。

コンパイレーションと最適化

小さい演算子は最適化可能でしょうか?もちろん、可能です。コンパイレーション・エンジンのシステム最適化パートを見てみましょう。2つのタイプの最適化が計算グラフ上で実行可能です。

  • メモリ割当て最適化、中間的な計算のメモリの再利用。
  • 演算フュージョン (融合) 、sigmoid のような sub-graph パターンを検出しそれらをより大きな演算子カーネルに融合します。

メモリ割当て最適化は小さい演算グラフに制限されてはいません。より大きい演算グラフにも使用できます。けれども、最適化は CXXNet と Caffe のようなより大きな演算ライブラリに対しては本質的ではないかもしれません、何故ならばそれらの中にコンパイレーション・ステップを見つけられないからです。けれども、それらのライブラリには (dump) コンパイレーション・ステップがあり、それは各々の演算を一つずつ実行することで、基本的には層を fix された foward, backprop 実行プランに翻訳します。

より小さい演算子による計算グラフでは、これらの最適化はパフォーマンスにとって重要です。
演算子が小さいので、マッチさせられる多くの sub-graph パターンがあります。また、最終的な生成された演算子は列挙され得ませんので、大きな演算ライブラリの定量のプリコンパイルされたカーネルとは対照的に、カーネルの明示的な再コンパイレーションが必要とされます。これは小さい演算子をサポートする記号型ライブラリのためにコンパイレーション・オーバーヘッドを作ります。コンパイレーション最適化の必要性はまたより小さい演算子を単独でサポートするライブラリのために engineering オーバーヘッドもまた作ります。

記号型 vs 命令型の場合のように、より大きい演算ライブラリは(一般的な層に) 制限を提供することを貴方に要求することで “だまし取り (=cheat) ” ます、その結果 sub-graph マッチングを実際に実行します。これはコンパイレーション・オーバーヘッドをリアルな頭脳に移つことで、それは通常はそれほど悪くはありません。

式テンプレートと静的型付け言語

小さい演算子を書きそれらを組み立てる必要性は常にあります。Caffe のようなライブラリはこれらのより大きなブロックを構築するために hand-crafted カーネルを用います。そうでないならば、Python でより小さい演算子を構成しなければならないでしょう。

非常に良く動作する第三の選択肢があります。これは式テンプレート (= expression template) と呼ばれます。基本的には、コンパイル時に式木 (= expression tree) からgeneric カーネルを生成するためにテンプレート・プログラミングを使用しています。詳細は Expression Template Tutorial を見てください。CXXNet は式テンプレートを広範囲に使用し、これは hand-crafted カーネルのパフォーマンスに匹敵するより短くてより読みやすいコードを作成することを可能にします。

式テンプレートの使用と Python カーネル生成の間の違いは既存の型で C++ のための式評価がコンパイル時に行なわれることで、そのため追加のランタイム・オーバーヘッドはありません。原理的には、これはテンプレートをサポートする他の静的型付け言語でも可能ですが、C++ でのみ使用されているこのトリックを見てきています。

式テンプレート・ライブラリは、C++ ユーザにより小さな演算子を組み立てることで効率的な大きな演算子を手作業で作ることを可能することにより、Python 演算子と hand-crafted な大きなカーネルとの間の妥協点を作成します。これは考慮に値するオプションです。

 

アプローチをミックスする

プログラミング・モデルを比較してきた今、どちらを選ぶべきでしょう?それを探求する前に、それは解決しようとする問題に依存していることを強調すべきで、比較は必ずしも大きなインパクトを持ちません。

アムダールの法則 (Amdahl’s law) を思い出してください: 問題の non-performance-critical パートを最適化しているならば、多大なパフォーマンス・ゲインは得られません。

見てきたように、通常は効率性、柔軟性、そして技術的複雑さの間にはトレードオフがあります。よりふさわしいプログラミングスタイルは解決しようとしている問題に依存します。
例えば、パラメータ更新のためには命令型プログラムが、勾配計算のためには記号型プログラムがより良いです。

私たちはアプローチをミックスすることを提唱します。アムダールの法則を思い出してください。柔軟であることを望むパートは必ずしも性能に重要ではありません。より柔軟な I/F をサポートするために少し杜撰 (sloppy) でも構いません。 機械学習では、メソッドの結合は通常は一つだけを使うよりも良いです。もし貴方がプログラミング・モデルを正しく結合できるならば、単一のプログラミング・モデルだけを使えるよりも良い結果を得られます。このセクションでは、どのようにそれを行なうかを議論します。

記号型と命令型プログラム

記号型と命令型プログラムをミックスするためには2つの道があります :

  • 記号型プログラムの中で命令型プログラムをコールバックとして使用する
  • 命令型プログラムの一部として記号型プログラムを使用する

通常はパラメータ更新を命令型で書き、そして勾配計算を記号型プログラムで実行することが役立つことを観察してきました。

記号型ライブラリは既にプログラムを mix しています、何故ならば Python 自身が命令型だからです。例えば、次のプログラムは記号型アプローチを命令型の NumPy で mix しています。

A = Variable('A')
B = Variable('B')
C = B * A
D = C + Constant(1)
# compiles the function
f = compile(D)
d = f(A=np.ones(10), B=np.ones(10)*2)
d = d + 1.0

記号型グラフは、命令的に実行され得るような関数にコンパイルされます。内部はユーザにブラックボックスです。これは丁度 C++ プログラムを書いてそれを Python に expose するようなもので、これは一般的に行ないます。

パラメータ・メモリは GPU 上に reside しますので、命令型コンポーネントとして NumPy を使用したくないかもしれません。GPU-互換な命令型ライブラリのサポートはより良い選択肢かもしれません。これは記号型のコンパイルされた関数と相互作用して記号型プログラムの実行において更新ステートメントのシンタックス更新の限定的な量を提供します。

Small と Big 演算子

small と big 演算子を結合するには良い理由があるかもしれません。損失関数を変更したり既存の構造に2、3のカスタマイズされた層を追加するようなタスクを実行するアプリケーションを考えてください。通常は既存のコンポーネントを構成するために大きな演算子を使い、新しいパートを組み立てるのにより小さな演算子を使用できます。

アムダールの法則を思いだしてください。しばしば、新しいコンポーネントは計算ボトルネックの原因ではありません。パフォーマンス・クリティカルなパートは既により大きい演算子で最適化されていますから、追加の小さい演算子を最適化することを差し控える、あるいは演算フュージョンと直接的にそれらを実行する代わりにメモリ最適化の制限された量を行なってもかまいません。

貴方自身のアプローチを選択する

このトピックのゴールは深層学習プログラムのアプローチを比較することです。普遍的な解はないかもしれないこと、しかし貴方のアプローチを選択できるあるいはアプローチを結合できること、より興味深く知的な深層学習ライブラリを作成するのも良いことを見出しました。

 

Contribute to This Topic

This topic is part of our effort to provide open-source system design notes for deep learning libraries. We welcome your contributions.

 

Next Steps

 

以上