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

本コースの第2回目は、ニューラルネットワークのモデル記述からどのようにハードウェアが生成されるか、NNgen コンパイラの仕組みを紹介します。

NNgen の全体構成

第1回の記事からの再掲ですが、NNgen のコンパイラ構成は下図の通りです。

NNgen でハードウェア化するニューラルネットワークのモデル表現方法は、NNgen のオペレータを組み合わせて、プログラマが計算グラフを定義する方法と、Tensorflow や Pytorch などの一般的な DNN フレームワーク上のモデル定義を、DNN の共通フォーマットの ONNX を介して、NNgen 形式の計算グラフへと変換する方法の2つがあります。

NNgen コンパイラは、NNgen 形式の計算グラフから、演算器やオンチップメモリ等の計算を行う回路と、それらの制御回路を生成し、最終的な生成物であるハードウェア記述と IP コア設定ファイルを出力するのが仕事です。第1回で取り扱った、NNgen のサンプルコード (hello_nngen.py) について、計算グラフの記述からハードウェア記述が生成されるまでを、手順を追って見ていきましょう。

計算グラフ

以下に、サンプルコード中のグラフ定義を抜粋します。

# --------------------
# (1) Represent a DNN model as a dataflow by NNgen operators
# --------------------

# data types
act_dtype = ng.int8
weight_dtype = ng.int8
bias_dtype = ng.int32
scale_dtype = ng.int8
batchsize = 1

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

# layer 0: conv2d (with bias and scale (= batchnorm)), relu, max_pool
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)

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

# ...

# output
output_layer = ng.matmul(a2, w3,
                         bias=b3,
                         scale=s3,
                         transposed_b=True,
                         name='output_layer',
                         dtype=act_dtype,
                         sum_dtype=ng.int32)

計算グラフの構造は以下の図の通りです。NNgen では現状、再帰型ニューラルネットワーク (RNN) には対応していないため、有向非巡回グラフ (DAG) になります。

dag.png

この計算グラフから、どのようにハードウェアが生成されるかを見ていきましょう。

NNgen が生成するハードウェア構造

NNgen が最終的に生成する回路構造は、下図の点線で囲まれた部分のようになります。conv2d や matmul、max_pool といった計算オペレータに対応する回路と、各オペレータ回路を構成する演算器群 (Substream Pool) とメモリ群 (RAM Pool)、それらを接続するカスタムのチップ内ネットワーク (Substream Interconnect, Memory Interconnect) 、全体を制御する制御回路 (Main Thread) 、NNgen ハードウェアの外部とデータ転送を行う DMA コントローラなどで構成されます。

hw.png

重要な点として、計算グラフ中で同一種類のオペレータが複数回用いられている場合であっても、そのオペレータの回路は1つしか生成されない点です。今回取り扱うサンプルコードでは、conv2d および matmul はそれぞれ2回ずつ用いられていますが、それぞれの回路は1つずつしか生成されていません。計算グラフを解析し、最大公約数的なハードウェアを生成するのが NNgen の特徴と言えます。

また、各オペレータで用いる演算器は、演算器プール (Substream Pool) にまとめて生成され、各オペレータはチップ内ネットワーク (Substream Interconnect) を介して、プール化された演算器を共有します。例えば、conv2d や matmul のどちらも積和演算を行いますが、それぞれ個別に積和演算器が生成されるのではなく、同一の演算器群を共有します。オペレータ回路および演算器を共有することで、ニューラルネットワークのレイヤー数が増えても、回路規模の増加を抑えることができます。

計算グラフの解析

以下のように、計算グラフを to_ipxact または to_verilog という関数を用いて、IP-XACT 形式の IP コアや Verilog HDL のソースコードテキストへと変換します。出力層のオブジェクトを引数として渡します。この例では、”output_layer” を出力層として渡しています。

targ = ng.to_ipxact([output_layer], 'hello_nngen', silent=silent,
                    config={'maxi_datawidth': axi_datawidth})

ここで、NNgen コンパイラの内部を覗いてみましょう。”nngen/verilog.py” に定義されている、to_ipxact 関数
から辿ります。

def to_ipxact(objs, name, ipname=None, config=None, silent=False):

    if ipname is None:
        ipname = name

    config = load_default_config(config)

    m = _to_veriloggen_module(objs, name, config,
                              silent=silent, where_from='to_ipxact', output=ipname)

    clk_name = config['clock_name']
    rst_name = config['reset_name']
    rst_polarity = ('ACTIVE_LOW'
                    if 'low' in config['reset_polarity'] else
                    'ACTIVE_HIGH')
    irq_name = config['interrupt_name']
    irq_sensitivity = 'LEVEL_HIGH'

    clk_ports = [(clk_name, (rst_name,))]
    rst_ports = [(rst_name, rst_polarity)]
    irq_ports = [(irq_name, irq_sensitivity)] if config['interrupt_enable'] else []

    ipxact.to_ipxact(m, ipname,
                     clk_ports=clk_ports,
                     rst_ports=rst_ports,
                     irq_ports=irq_ports)

    return m

to_ipxact 関数では、_to_veriloggen_module 関数を呼び出して、Veriloggen 形式のハードウェア記述を生成しています。Veriloggen はマルチパラダイム型高位合成コンパイラであり、内部では Python のオブジェクトとして、ハードウェア記述を表現されています。次に、同じ “nngen/verilog.py” に定義されている _to_veriloggen_module 関数を覗いてみましょう。

def _to_veriloggen_module(objs, name, config=None,
                          silent=False, where_from=None, output=None):

    if not isinstance(objs, (list, tuple)):
        objs = [objs]

    (objs, num_storages,
     num_input_storages, num_output_storages) = analyze(config, objs)
    m, clk, rst, maxi, saxi = make_module(config, name, objs,
                                          num_storages, num_input_storages,
                                          num_output_storages)

    schedule_table = schedule(config, objs)

    header_info = make_header_addr_map(config, saxi)

    (ram_dict, substrm_dict, ram_set_cache,
     stream_cache, control_cache, main_fsm,
     global_map_info, global_mem_map) = allocate(config, m, clk, rst,
                                                 maxi, saxi, objs, schedule_table)

    reg_map = make_reg_map(config, global_map_info, header_info)

    if not silent:
        dump_config(config, where_from, output)
        dump_schedule_table(schedule_table)
        dump_rams(ram_dict)
        dump_substreams(substrm_dict)
        dump_streams(stream_cache)
        dump_controls(control_cache, main_fsm)
        dump_register_map(reg_map)
        dump_memory_map(global_mem_map)

    return m

analyze 関数では、引数 objs として渡されているニューラルネットワークの出力層から、入力側へと逆に計算グラフを辿って、計算グラフを構成するすべてのオブジェクトを収集します。

schedule 関数では、計算グラフ中の各オペレータの演算タイミングをスケジューリングします。

allocate 関数では、スケジュール結果に基づいて、演算器、メモリ、制御回路を合成します。では、どのように実際の回路構造を決定するかを知るために、allocate 関数の内部を覗いてみましょう。

演算器、メモリ、制御回路の生成

def allocate(config, m, clk, rst, maxi, saxi, objs, schedule_table):
    set_storage_name(objs)
    merge_shared_attrs(objs)

    max_stream_rams = calc_max_stream_rams(config, schedule_table)
    ram_dict = make_rams(config, m, clk, rst, maxi, schedule_table, max_stream_rams)
    ram_set_cache = make_ram_sets(config, schedule_table, ram_dict, max_stream_rams)

    control_param_dict = make_control_params(config, schedule_table)

    substrm_dict = make_substreams(config, m, clk, rst, maxi, schedule_table)
    stream_cache = make_streams(config, schedule_table, ram_dict, substrm_dict)

    (global_addr_map, local_addr_map,
     global_map_info, global_mem_map) = make_addr_map(config, objs, saxi)

    if config['use_map_ram']:
        global_map_ram, local_map_ram = make_addr_map_rams(config, m, clk, rst, maxi,
                                                           global_addr_map, local_addr_map)
    else:
        global_map_ram = None
        local_map_ram = None

    control_cache, main_fsm = make_controls(
        config, m, clk, rst, maxi, saxi,
        schedule_table, control_param_dict,
        global_addr_map, local_addr_map,
        global_map_ram, local_map_ram)

    disable_unused_ram_ports(ram_dict)

    return (ram_dict, substrm_dict, ram_set_cache, stream_cache, control_cache,
            main_fsm, global_map_info, global_mem_map)

まず、calc_max_stream_rams 関数で、スケジューリング結果に基づいて、生成すべきオンチップメモリの大きさと数を求めます。その結果に基づいて、make_rams 関数でメモリ回路を生成します。

make_control_params 関数では、各演算の制御パラメータ (積和演算の回数やストライド幅など) を決定します。各オペレータ回路は、複数のオペレータの間で再利用されますが、オペレータ毎に異なる振る舞いをさせる必要があります。この段階で、各オペレータの制御パラメータを求めます。

make_substreams 関数では、共有演算器 (演算器プール (Substream Pool)) を生成します。この段階で、積和演算器や加算ツリー等が生成されます。

make_streams 関数では、事前に生成したメモリや共有演算器を組み合わせて、実際に畳み込み計算等を行う、オペレータ回路全体を生成します。内部では、各オペレータ回路で占有される、比較的小規模な演算器や、共有演算器および共有メモリを接続する、チップ内ネットワークを生成しています。

その後、make_addr_map 関数で、NNgen ハードウェアの外部から制御するための AXIS レジスタを生成します。そして、make_controls 関数で、各オペレータのタイミングを管理する状態遷移機械 (FSM) を生成し、ハードウェア全体の構成要素が揃いました。

生成されたハードウェア構造は Veriloggen 形式で表現されているので、この後、実際のハードウェア記述 (Verilog HDL) のソースコードを生成しています。

まとめ

今回は、NNgen が生成するハードウェア構造と、計算グラフからハードウェアが生成されるまでの NNgen コンパイラの仕組みを解説しました。

次回は、具体的なニューラルネットワークを動かしてみましょう。

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

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