NNgenとFPGAで作るニューラルネットワーク・アクセラレータ (5)

前回までは、学習済みモデルから、VGG-11 専用のハードウェアを構築したりしました。

第5回 (最終回) では、NNgen を活用するために、NNgen で利用可能な演算や入力の紹介をします。加算、減算、畳み込みといった基本的な演算から、Extern と呼ばれる CPU 上のソフトウェアと連携を可能にする特殊なものまで、いろいろあります。

データの入力 (placeholder, variable, constant)

演算の前に、データや重みを入力するインタフェースをみてみましょう。

placeholder は、画像等の入力データを与えるためのクラスです。variable は、学習済みの重みを与えるためのクラス、constant は、モデル構築時に決定される定数を与えるものです。これらを起点にして、計算グラフを構築していきます。

a = ng.placeholder(dtype=ng.int16, shape=(16, 16), name='a')
b = ng.variable(dtype=ng.int16, shape=(16, 16), name='b')
c = ng.constant(np.ones((16, 16)), dtype=ng.int16)

constant に関連して、よく利用される定数値を表現するクラスがあります。zeros、one は、それぞれ0、1の値で埋められたテンソルを指定された形状 (shape) で作成します。full は、任意の値で埋められたテンソルを作成します。

既存のテンソルと同じ shape のものを作る方法として、zeros_like, ones_like, full_like があります。

a = ng.zeros((16, 16), dtype=ng.int16)
b = ng.ones((16, 16), dtype=ng.int16)
c = ng.full((16, 16), 100, dtype=ng.int16)

c = ng.zeros_like(a, dtype=ng.int16)
d = ng.ones_like(b, dtype=ng.int16)
e = ng.full_like(c, 200, dtype=ng.int16)

要素毎の演算 (_ElementwiseOperator)

テンソルの要素毎の計算を行うオペレータが一通り提供されています。

  • add: 2つテンソルの加算
  • sub: 2つテンソルの減算
  • add_n: n個のテンソルの加算
  • multiply: 2つのテンソルの乗算
  • multiply_shared: 2つのテンソルの乗算 (乗算器を共有)
  • multiply_add_rshift_clip: 3のテンソルによる積和 (x * y + z) の後、右シフトとクリップ
  • div: 2つのテンソルの除算
  • neg: 符号反転
  • abs: 絶対値
  • equal: 2つのテンソルの一致比較 (==)
  • not_equal: 2つのテンソルの不一致比較 (!=)
  • less: 2つのテンソルの比較 (<)
  • less_equal: 2つのテンソルの比較 (<=)
  • greater: 2つのテンソルの比較 (>)
  • greater_equal: 2つのテンソルの比較 (>=)
  • sign_binary: 正の値なら+1、ゼロまたは負の値なら-1を返す
  • sign_ternary: 正の値なら+1、負の値なら-1、ゼロなら0を返す
  • clip: ビット幅から決定される最大値、最小値でクリップ (飽和)
  • where: 三項演算子 (a? b : c)
  • lshift: 左シフト
  • rshift: 右シフト
  • rshift_round: 丸めあり右シフト
  • zeros_imm: 即値0を返す
  • ones_imm: 即値1を返す
  • full_imm: 任意の即値1つを返す
  • zeros_imm_like: zeros_likeと同様に、既存のテンソルと同じ shape の zeros_imm を返す
  • ones_imm_like: ones_likeと同様に、既存のテンソルと同じ shape の ones_imm を返す
  • full_imm_like: full_likeと同様に、既存のテンソルと同じ shape の full_imm を返す
  • relu: ReLU (Rectified Linear Unit)
  • leaky_relu: Leaky ReLU
  • exp: 指数関数
  • sigmoid: シグモイド関数

2つのテンソルを入力とする多くのオペレータでは、ブロードキャストに対応しています。入力の shape が同じ場合には、もちろん、そのまま演算されますが、shape が異なる場合には、次元の大きい方が次元が小さい方の shape を内包する場合には、次元が小さい方を繰り返して計算します。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='b')
c = ng.placeholder(dtype=ng.int16, shape=(16,), name='c')

d = ng.add(a, b)
e = ng.sub(a, c)  # cのshapeはaのshapeに内包されているので、cのテンソルを繰り返して利用する

zeros_imm、ones_imm、full_imm と、上記の constant の zeros、ones、full の違いはなんでしょうか。constant の場合には、メモリに展開される値として実現されます。実行時は、メモリからデータを読み出す操作を行います。一方、zeros_imm、ones_imm、full_imm は、それぞれ指定された値 (0、1、任意の定数) を必ず返す、演算器として実現されます。実行時は、外部からデータを読み出さずに、指定された値を返す演算器を駆動させます。

a = ng.zeros_imm((16, 16), dtype=ng.int16)
b = ng.ones_imm((16, 16), dtype=ng.int16)
c = ng.full_imm((16, 16), 100, dtype=ng.int16)

多くのオペレータは par オプションに並列度を指定することで、処理の並列化が指示できます。pa rの値は、2、4、8といった2の N 乗に限定されます。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='b')

c = ng.add(a, b, par=4)  # 4並列で加算

並列度は、attribute メソッドで後から設定することができます。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='b')

c = ng.add(a, b)

c.attribute(par=4)  # 4並列で加算

基本的には、各オペレータ毎に、計算の開始前に外部メモリからデータを読み出し、計算の終了後にデータを外部メモリに書き戻します。計算とデータ転送はオーバーラップされており、メモリ帯域を最大限利用することができます。しかし、すべてのオペレータ毎に外部メモリの読み書きを行うのは非効率ですので、Element-wise なオペレータについては、複数の演算をチェイニングするようになっています。Element-wise なオペレータを直列に並べると、それらをチェイニングした単一の演算器が構成され、データの読み書きはチェイニングされた演算器の入力と出口でのみ行われるため、無駄なデータ転送を削減できます。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='b')
c = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='c')

d = ng.add(a, b, par=4)
e = ng.sub(a, c, par=4)
f = ng.add(d, e, par=4)
# a, b, cを入力として、f = (a + b) + (a - c) を返す4並列の演算器が構成される。

NNgen では、演算器が再利用されるため、チェイニングの組み合わせによっては、思った通りの演算器の再利用ができないことがあります。あえてチェイニングを行わずに、演算器構成を分離するには、output_chainable = False を設定します。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='b')
c = ng.placeholder(dtype=ng.int16, shape=(8, 16), name='c')

d = ng.add(a, b, par=4)
d.output_chainable = False  # dの出力はチェイニングされず、一端メモリに書き戻される

e = ng.sub(a, c, par=4)
e.output_chainable = False  # eの出力はチェイニングされず、一端メモリに書き戻される

f = ng.add(d, e, par=4)
# dとeを入力として加算する4並列の演算器が構成されるが、dの加算と同じ演算器が再利用される。

リダクション演算 (_ReductionOperator)

リダクション処理を行うオペレータもいくつか提供されています。

  • reduce_sum: 合計
  • reduce_max: 最大値
  • reduce_min: 最小値
  • argmax: 最大値を与えるインデックス
  • argmin: 最小値を与えるインデックス

reduce_sum は、指定された軸または全体でのテンソルの合計値を求めるオペレータです。reduce_maxとreduce_min は、最大値または最小値を求めるオペレータです。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')

b = ng.reduce_max(a)
c = ng.reduce_min(a, axis=(1, 2))  # 下位2次元における、それぞれの最小値を求める

argmax と argmix は最大値および最小値をを与えるインデックスを返すオペレータです。

d = ng.placeholder(dtype=ng.int16, shape=(1000,), name='d')

e = ng.argmax(d)

これらのリダクション系のオペレータも並列度 pa rを指定することで、並列処理が可能です。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')

b = ng.reduce_max(a, par=4)  # 4並列で最大値検索

reshape, cast, transpose, expand_dims

一般的な DNN フレームワークでもよく用いられる、テンソルの読替を行うオペレータが提供されています。

  • reshape: shape を変更する
  • cast: データ型を変更する
  • expand_dims: 次元を増やす
  • transpose: 転置

reshape、cast、expand_dims の実現方法はそれぞれ2種類あります。入力テンソルと出力テンソルのメモリ上の配置に変化がない場合には、単純にメモリ上のデータを異なる shape やデータ型として読み替える View や _LazyReshape として実現されます。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')

b = ng.reshape(a, shape=(128, 32))  # メモリ上の配置に変化がないため、何もしない

一方、出力テンソルのメモリ上の配置に変化がある場合には、一端データを外部メモリからオンチップメモリに読み出して、適切な配置に変更した後に外部メモリに書き戻す操作が行われるため、実行処理性能が低下します。以下の例では、16ビット幅のデータを (128, 32, 1) という shape でメモリ上に配置しようとしています。

すべてのデータは最内次元でアラインされた状態で配置される必要があるため、外部メモリへのバス幅が32ビットの場合に16ビット幅の最内次元サイズ1 (もしくはその他の奇数) のデータを扱うと、パッド (ダミーのデータ) が挿入されるため、メモリ上の実際のshapeは (128, 32, 2) になります。そのため、reshape オペレータを挿入すると、実際にデータのレイアウトを変更する処理が入ります。パッドと実際のメモリ上の配置を意識した shape を選択することで、この無駄な処理を回避できます。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')

b = ng.reshape(a, shape=(128, 32, 1))
# 32ビット幅のバスを介して接続される場合、
# メモリ上では (128, 32, 2) というshapeになるため、
# データ再配置の処理が行われる。

transpose は、軸 (axis) の入れ替えを行います。そのため、この処理は非常に時間がかかります。

畳み込み、行列積、プーリング (conv2d, matmul, max_pool, avg_pool)

もちろんですが、NNgen では DNN を実現するために必要なオペレータが実装されています。

  • conv2d: 2D畳み込み
  • binary_weight_conv2d: 二値化された重みを用いた2D畳み込み
  • ternary_weight_conv2d: 3値化されt重みを用いた2D畳み込み
  • log_weight_conv2d: 対数量子化された重みを用いた2D畳み込み
  • matmul: 行列積(主に全結合層に利用)
  • max_pool: 最大値プーリング
  • avg_pool: 平均値プーリング
  • max_pool_serial: 最大値プーリングの軽量実装 (領域サイズがストライドより大きい場合に利用可能)
  • avg_pool_serial: 平均値プーリングの軽量実装 (領域サイズがストライドより大きい場合に利用可能)

conv2d は、乗算される重みに加えて、バイアス項とスケール項を与えることができます。最近の DNN では、バッチ正規化 (Batch Normalization) を用いることが一般的ですが、学習済みモデルにおいては、バッチ正規化の処理は一次式で表現できるため、バイアス項とスケール項を用いることで、畳み込み層とバッチ正規化層を結合することができます。また、活性化関数を渡すことで、畳み込み層、バッチ正規化層に加えて、活性化関数を結合した回路が生成されます。

NNgen の特徴として、同一種類の演算回路を利用する複数のオペレータは、同一の演算回路を再利用します。conv2d の場合には、同一のカーネルサイズであれば、異なる重み、バイアス、スケール、活性化関数を用いても回路は共有することができます。

act_dtype = ng.int8
weight_dtype = ng.int8
bias_dtype = ng.int32
scale_dtype = ng.int8
batchsize = 1

input_layer = ng.placeholder(dtype=act_dtype,
                             shape=(batchsize, 32, 32, 3),  # N, H, W, C
                             name='input_layer')

w0 = ng.variable(dtype=weight_dtype,
                 shape=(64, 3, 3, 3),  # Och, Ky, Kx, Ich
                 name='w0')
b0 = ng.variable(dtype=bias_dtype,
                 shape=(w0.shape[0],), name='b0')
s0 = ng.variable(dtype=scale_dtype,
                 shape=(w0.shape[0],), name='s0')

a0 = ng.conv2d(input_layer, w0,
               strides=(1, 1, 1, 1),
               bias=b0,
               scale=s0,
               act_func=ng.relu,
               dtype=act_dtype,
               sum_dtype=ng.int32)

conv2d の処理時間は、全体の処理時間の高い割合を占めるため、並列処理が重要です。conv2d は、デフォルトで畳み込みのカーネルサイズ分を並列処理しますが、par_ich、par_och、par_row、par_col を指定することで、更に並列度を高めることができます。それぞれ、par_ich は入力チャネル方向の並列度、par_och は出力チャネル方向の並列度、par_row、par_col は画素平面における並列度です。

par_ich = 2
par_och = 2
par_row = 2
par_col = 2

a0.attribute(par_ich=par_ich, par_och=par_och, par_row=par_row, par_col=par_col)

max_pool や avg_pool も同様に、同一のカーネルサイズが同じであれば共有されます。また、par を指定することで並列化することができます。

a0p = ng.max_pool_serial(a0,
                         ksize=(1, 2, 2, 1),
                         strides=(1, 2, 2, 1))

par = 2

a0p.attribute(par=par)

pad, slice_, concat, upsampling2d,

VGG-11 のようなシンプルなモデルであれば、上記の畳み込みやプーリングのみで構成することができますが、複雑なグラフ構造を持つモデルの実現には、その他のオペレータが必要となります。NNgen では、以下のような補助オペレータに対応しています。

  • pad: 余白を追加する
  • slice_: 一部を切り出す
  • concat: 複数のテンソルを結合する
  • upsampling2d: アップサンプリング (プーリングの逆)

正規化 (normalize, scaled_add, scaled_concat, scaled_multiply)

NNgen では、量子化により整数化された状態で計算を行います。そのため、量子化前と後で計算結果の整合性をできるだけ保つことが望まれます。その際に、加算や concat のオペレータでは、入力値のスケーリングが揃っていなければ、そのまま演算を適用することができません。例えば、加算であれば、量子化後の値で “1” の変化が、量子化前の値でいくつに相当するかが、入力すべてで揃っていなければ、足し合わせることができません。

NNgen では、スケーリング調整機能を持つ加算や concat を行うオペレータが提供されています。ONNX からのモデルを取り込む際は、加算や concat については、上記のネイティブの加算や concat ではなく、スケーリング機能付きの加算や concat を用いるようになっています。

  • normalize: 正規化層 (バッチ正規化に相当)
  • scaled_add: スケーリング係数乗算後に加算し右シフト
  • scaled_concat: スケーリング係数乗算後に concat し右シフト
  • scaled_multiply: 乗算後に右シフト
a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')  # scaling_factor = 10.0
b = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='b')  # scaling_factor = 8.0

c = ng.scaled_add(a, b)
# aとbに対して、(10.0 / 10.0) と (10.0 / 8.0) のそれぞれ整数倍の値を乗算してから加算し、右シフトする

外部ソフトウェアとの連携 (extern)

NNgen の特徴的で変わったオペレータとして、extern があります。extern を用いることで、ハードウェア・回路として実装しない (したくない) 演算をソフトウェアにオフロードすることができます。

以下の例では、extern オペレータの引数 (ここでは c, d) が揃うのを待ち合わせて、ソフトウェア側に割込を発生させます。ソフトウェア側は、extern オペレータからの割込を検出して、引数データ (c, d) を用いてソフトウェア処理を行い、指定されたメモリ場所にデータを書き込みます。そして、ハードウェアの制御レジスタにフラグを書き込むことで演算完了を通知します。

a = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='a')
b = ng.placeholder(dtype=ng.int16, shape=(8, 16, 32), name='b')
c = ng.add(a, b)
d = ng.sub(a, b)

e = ng.extern([c, d], shape=(8, 16, 32), opcode=0x1,
              func=lambda x, y: x + y)

f = ng.add(e, ...)

まとめ

今回は、NNgen を活用するために、NNgen で利用可能なオペレータを紹介しました。まだまだ、実装されていない機能がたくさんありますので、皆様からの提案や pull request をお待ちしております。

東京大学 大学院情報理工学系研究科 コンピュータ科学専攻 高前田 伸也

タイトルとURLをコピーしました