前回の記事
AIアクセラレータ・IMAXの紹介 ~ (2) IMAXのツールチェーン及び環境構築方法
CGRAの思想の振り返り
1回目で紹介したCGRAの基本思想を振り返ってみましょう。CGRAは、FPGA同様、データを直接次の処理に渡せるため、データ転送効率や電力効率を向上させようとするものです。しかし、FPGAは、FPGAの物理特性で動作周波数を上げることが困難であること、プログラミングが複雑なことなどの問題があり、ASICはその目的でしか使えないので、必要な単位で再構成可能単位を粗くする、というのがCGRAの根端にある思想です。
IMAXは、それに付け加え、再構成の複雑さを削減するため、横方向のデータの受け渡しをやめ、線型に配置したことから、従来のCGRAに比べデータフローに集中でき、コンパイルの時間が短くなる利点があります。線型にCPUのようなユニットを配置しているため、基本的に高機能な演算を組み込んだCPUを線型に繋ぎ、空間上に広げだものだと考えてください。 これがIMAXのプログラミングにおいて重要となる考え方です。
ユニットでできる演算の種類
メモリアクセス
ロードとストア命令、そして見慣れないMEXという命令があります。それぞれ説明していきます。このメモリアクセスでは、基本ユニットの右側の二つのALUが使用されます。これらを他の数値演算に用いることは不可能なので、実際の数値演算はこの後で紹介する三段重ねのALUを用います。
ロード(LD)
- LDR: 指定されたアドレスから64ビットのデータを指定のレジスタにロードします。
- LDWR: LDRの32ビットバージョンです。
- LDBR: LDRの8ビットバージョンです。
- LDRQ: L指定されたアドレスから連続された64ビットのデータ四つを四つのレジスタにロードします。横並びの四つの基本ユニットで一斉に同じことを行います。
- LDDMQ: ユニットのメモリを使わずにダイレクトにメインメモリからデータをロードします。LDRQ同様、横並びの四つの基本ユニットで一斉に同じことを行います。
ストア(ST)
- STR: 指定あれたアドレスに64ビットのレジスタのデータをストアします。
- STWR: STRの32ビットバージョンです。
- STBR: STRの8ビットバージョンです。
- STRQ: 横並びの四つの基本ユニットで同じストア動作をします。LDRQのストアバージョンです。
- TR: トランザクションを発行します。僕も詳細はよくわかってないのですが、例題に出てきたら僕なりの理解を書いていきたいと思います。
MEX
疎行列ー疎行列積(SpGEMM)のために導入されました。比較演算を行い、アドレスを指定し、ロードを行います。疎行列やマージソード以外での出番はあまりないので今回は説明を割愛します。
共通事項
- (Ull*)d: ロード・ストア対象のレジスタ名です。
- base(++): ロード・ストア対象のベースメモリアドレスです。
- offs: ロード・ストア対象のメモリアドレスのオフセットです。base+offs番地のメモリとdのレジスタ間のデータ移動が基本となります。
- msk: ロード・ストア元のデータにマスクをかけ、特定のビットのデータだけ抜き取るための機能です。このマスクは、色々定義されていますが、追々説明していきます。
- top: ユニットごとのローカルメモリにロードするデータのアドレスです。頭のアドレスを入れます。
- len: ローカルメモリにロードするメモリサイズです。これだけバイトではなくワード(4バイト)単位なので要注意です。
- blk: 現状使いません。ライブラリ互換性のために残っているので、0を記入してください。
- force-read: 0の場合、できる限りメインメモリへのアクセスを減らすためにローカルメモリの参照先のアドレスが同じであればメインメモリからは持ってきませんが、1の場合は、演算の都合で強制的なメインメモリからのロード、ストアが必要な時に使用します。
- ptop: 次の演算で必要なデータが変わってくる場合、フリフェッチを行うための機能です。この機能を使用する場合、top同様にアドレスを指定します。しばらくはこの機能を使わない例を紹介していくので、NULLで構いません。
- plen: lenと同じく長さです。これも単位が1バイトではなく1ワードなので要注意です。
- ex: 条件付きストアで使います。しばらくこれも使わないので無条件ストアの3を入れます。
演算
結構な命令があります。一ユニットでできる限り多くの演算をさせるための工夫です。普通のCPUではあまり見かけることのできない三つの入力を使う演算がほとんどです。これは、IMAXでのアプリケーションで想定されている演算が前のデータを用いて演算値を足していくような演算が多いからです。
三段重ねのALUはここで使われます。三段重ねのALUを全部使う命令もあるし、そうでないものもあります。それではみていきましょう。CEXに関しては、今回は割愛します。
EX1,2,3を全て使う演算(浮動小数点演算)
- ex4.全般: 四つの横並びのユニットで同じ演算を行います。
- FMA: 浮動小数点の掛け算及び足し算です。二番目と三番目の乗算結果を一番目の値に足してレジスタに格納します。
- FMS: FMAの最後が引き算バージョンです。
- FAD: 一番目と二番目の値を足した結果をレジスタに格納します。
- FML: 一番目と二番目の値をかけた結果をレジスタに格納します。
- CFMA: 条件付きFMAです。二番目と三番目を比較し、FMAを実行します。
- SFMA: 確率的コンピューティングのための機能です。しばらくこの機能は使いません。
EX1だけで行える演算(整数演算)
- ex4.全般:先ほどと同じです。
- ADD3: 三つの値を足し合わせます。
- SUB3: 三つの値を引き合わせる...のではなく、一番目の値から二番目と三番目を足した値を引きます。
- ADD: 二つの値の足し算です。
- SUB: 二つの値の引き算です。
- CMP: 条件分岐です。値の比較を行います。
- CMOV: 条件付き値の移動とは書いてありますが、詳細はわからないので、例題で見つけたら説明します。
- MAUH3, MAUH, MSUH3, MSUH: それぞれADD3, SUB3, ADD, SUBに対応しています。しかし、値は16ビット扱いになります。
- MLUH: 掛け算を行います。少しビットの扱いが特殊なので、しばらく紹介することはないと思います。
- MMRG: 三つの変数のそれぞれの部分からビットを抜き取り、合体を行います。どのビットかは表を参考にしてください。
- MSSAD: 差分計算を並列に行うための命令です。使用場面も特殊なので、その例題で説明します。
- MSAD: 上同様です。しかし、一番目の値に計算結果を足すことはしません。
- MINL3: 三つの中で値を比較し、最小値を格納します。16ビットで行っています。
- MINL: 上同様です。
- MCAS: 値を比較し、その比較結果をレジスタに格納します。少し格納する値が特殊なので、これも例題に登場したら解説します。
- MMID3: 三つの中で中央値を出します。ここからは8ビットの扱いになるのでご注意ください。
- MMAX3: MMID3の最大値バージョンです。
- MMAX: 上同様ですが。二つで最大値を格納します。
- MMIN3, MMIN: MMAX3, MMAXの最小値バージョンです。
- MAJ, CH: SHA256を実装するための特殊命令です。SHA256の実装までの出番はなし。
EX2だけで行える演算(論理演算)
EX1の結果を元に、EX2で演算を行います。基本、EX1から渡された値に次の演算を行います。
- AND: 64ビットの論理積です。
- OR: 論理和です。
- XOR: 排他的論理和です。
- SUMHH: EX1から渡された値を16ビットずつわけ、3,4番目を格納先の4番目、1,2番目のデータを3番目に格納します。
- SUMHL: SUMHHと同じ演算ですが、格納先が1,2番目になります。
- ROTS: SHA256を実装するための特殊命令です。
EX3だけで行える演算(シフト演算)
- SLL: 32ビット論理左シフトです。
- SRL: 上の右シフトです。
- SRAA: 数値右シフトです。
- SRAB: 数値右シフトです。上との違いはシフト演算を行う対象です。
- SRLM: 16ビットの右シフトです。
共通事項
- 32ビットとか16ビットとかのやつは、一個のそのデータだけをロードするのではなく、64ビットをロードしてSIMD演算を行う仕組みになっています。 もし一個の64ビット未満のデータを扱いたい場合は、マスクをかけてその部分を0にする必要があります。
- 直観的ではない命令ですが、これは並列性を高めて性能を上げるための工夫ですので、そのような考え方に適応するしかありません。
- 割り算機能はありません。 割り算器はそれだけですごく回路が大きくなってしまうので、IMAXの使用で想定される範囲で割り算はあまり出番がないので、回路規模をや動作速度の都合で割り算の実装は省いています。
- 浮動小数点演算は一般のFPUより精度が劣る設計になっています。プログラム上の値比較のテストを通らない可能性があります。 目視での確認も必要となってきますが、これも想定されるアプリケーションを鑑みた結果なので、受け入れてください。
いざ、初IMAXプログラム作成!
準備
2回目で紹介したように、必要なファイルをインクルードしてください。
#include "emax6.h"
#include "emax6lib.c"
特殊な定義の変数
emax6lib.cによるシミュレーションではあまり影響しませんが、conv-c2cでは影響あるので、一部の変数は一定な記述方法によって記述する必要があります。
Ull BR[64][4][4];
Ull AR[64][4];
Ull CHIP, LOOP1, LOOP0, INIT1, INIT0;
これらはconv-c2cで特殊な扱いになっているので、絶対この通りの定義にしてください。適宜定義箇所を変えても構いませんが、変数名自体特殊な扱いになっています。 他の変数名は自由で構いませんが、Ull
型(64ビットの符号なし整数、emax6.hでtypedef Ull unsigned long long
として定義されている)にすることをおすすめします。メモリアドレスの場合は、扱いたいデータのサイズのポインタで構いません。
BR[64][4][4]
はそれぞれのユニットのレジスタ番号です。左から順に縦ユニット番号、横ユニット番号、ユニット内のレジスタ番号となります。データのロードやストア、そしてALUの入力データとして使います。
AR[64][4]
はALUの結果を格納するためのレジスタです。一個しかないので、二次元配列として定義されています。
CHIP
, LOOP1
, LOOP0
, INIT1
, INIT0
はループの定義に使います。これらも特殊な扱いになっています。それぞれ
- CHIP: 複数チップによる並列化(64ユニットつないだものが複数ある場合)
- LOOP1: 外側ループの実行回数
- LOOP0: 内側ループの実行回数
- INIT1: 外側ループの初期実行かどうかのステータス
- INIT0: 内側ループの初期実行かどうかのステータス
として使われます。CHIPの内側を二重ループにしなければならない決まりはないので、必要によってはINIT0
とLOOP0
だけ使う場合もあります。
ループの定義
このような形で定義します。
//EMAX5A begin name mapdist=0
for (CHIP=0;CHIP<NCHIP;CHIP++){
for (INIT1=1,LOOP1=N,some_var=some_var_init;LOOP1--;INIT1=0) {
for (INIT0=1,LOOP0=M,some_var_2=some_var_2_init;LOOP0--;INIT0=0) {
}
}
}
//EMAX5A end
//EMAX5A drain_dirty_lmm
多少変わったfor文の使い方をしていますが、conv-c2cの仕様上、こう定義しなければなりません。LOOP以外の他の変数も初期化を行えますが、一個しかできないので、ご注意ください。 先ほど言及した変数以外の値、変数は任意で構いません。必要によって変えてください。
まぁ、このように定義しなくてもループ自体は使えますが、この場合はIMAXの多重ループ実行機能は使えません。 ループ実行をIMAXだけで行えないようになるので、速度が落ちてしまいます。
命令の作成
命令の作成は、先ほど定義したループの中で行います。conv-c2cを通さないのであればなんでもできますが、conv-c2cを通して実機やcsimで動かしたいのであれば、決まったテンプレートの呼び出しコード以外書かないでください。 このテンプレートについては、例題を解説しながら説明したいと思います。
今回使うのはこの二つのテンプレートです。これは、IMAXのプログラミングで欠かせない存在なので、これからも使っていきます。
mop()
mop()
は主に、メモリのロード及びストアを行うときに使います。使える命令は先ほどのメモリ関係のところの命令とほぼ一致しています。ex4.関係はmo4()
になってしまいますが、しばらくは使わないので割愛。
mop(OP_X, ex9-0, &src|&dst, base, offset, mask, top, len, block, force, ptop, plen)
OP_X
に命令、ex9-0
に条件付きストアのための変数(無条件ストアの場合は3、ロードはOP_LDR
の場合は3、その他は1を入れてください)、&src|&dst
にレジスタや変数のアドレス、base
にロードまたはストアしたいメモリアドレスのベース、offset
にメモリアドレスのオフセット(オフセットに変数やレジスタの設定もできます)、mask
にロードするデータにかけるマスクの種類、top
にメインメモリから持ってくるデータのアドレス、len
にその長さ(ワード単位なので要注意)、block
は互換性のためにあるものなので0、force
は強制的なDMA実行有無(いらない場合は0、いる場合は1)、ptop
にプリフェッチするメモリアドレス(しない場合はNULL)、plen
にその長さを入れます(基本len
と同じでいいです)。
exe()
exe()
は主に、実行する命令を設定するときに使います。ex4.関係はex4()
になってしまいますが、これも今回は割愛。
exe(OP_X, &var|&AR[0-63][0-3], s1, e1, s2, e2, s3, e3, OP Y, s4, OP Z, s5)
OP_X
に命令の種類、&var|&AR[0-63][0-3]
に格納先のレジスタ、s1
にOP_X
の第一引数、e1
にs1
のマスク(ロードで使われるマスクとは違いますが、特定部分の値を複製できたりします)、s2
に第二引数、e2
にs2
のマスク、s3
とe3
も同様です。OP_Y
とOP_Z
はそれぞれEX2とEX3に対応しますが、浮動小数点演算を設定すると使えないのでそのときはOP_NOP
を入れます。s4
やs5
は、EX2とEX3を用いる場合の引数となります。
共通事項
メモリアドレスを渡すときは、(Ull)
を必ずつけてください。これもconv-c2cで定義されている特殊な文法となります。メモリアドレス自体、64ビットシステムでは64ビット変数として扱われますが、(Ull)
をつけてください。
メモリ割り当て
メモリ割り当て自体、conv-c2cを通さずemax6lib.cの関数呼び出しだけで行うのであればmalloc()
でもなんでも動きますが、実機などでは特定メモリアドレスに割り当てる必要があるため、専用の関数を用いてメモリ割り当てを予め行う必要があります。
Uchar *membase;
void sysinit(Uint memsize, Uint alignment) {
#if defined(ARMZYNQ) && defined(EMAX6)
if (emax6_open() == NULL)
exit(1);
membase = emax_info.ddr_mmap;
{int i; for (i=0; i<(memsize+sizeof(Dll)-1)/sizeof(Dll); i++) *((Dll*)membase+i)=0;}
#elif __linux__ == 1
posix_memalign(membase, alignment, memsize);
#else
membase = (void*)malloc(memsize+alignment);
if ((Ull)membase & (Ull)(alignment-1))
membase = (void*)(((Ull)*membase & ~(Ull)(alignment-1))+alignment);
#endif
#if !defined(ARMZYNQ) && defined(EMAX6)
emax_info.dma_phys = DMA_BASE2_PHYS; /* defined in emax6lib.h */
emax_info.dma_mmap = emax_info.dma_phys;
emax_info.reg_phys = REG_BASE2_PHYS; /* defined in emax6lib.h */
emax_info.reg_mmap = emax_info.reg_phys;
emax_info.lmm_phys = LMM_BASE2_PHYS;
emax_info.lmm_mmap = emax_info.lmm_phys;
emax_info.ddr_phys = *membase;
emax_info.ddr_mmap = emax_info.ddr_phys;
#endif
#if (defined(ARMSIML) || defined(ARMZYNQ)) && defined(EMAX6)
emax6.dma_ctrl = emax_info.dma_mmap;
emax6.reg_ctrl = emax_info.reg_mmap;
((struct reg_ctrl*)emax6.reg_ctrl)->i[0].cmd = CMD_RESET;
#if defined(ARMZYNQ)
usleep(1);
#endif
switch (((struct reg_ctrl*)emax6.reg_ctrl)->i[0].stat>>8 & 0xf) {
case 3:EMAX_DEPTH = 64;break;
case 2:EMAX_DEPTH = 32;break;
case 1:EMAX_DEPTH = 16;break;
default:EMAX_DEPTH = 8;break;
}
((struct reg_ctrl*)emax6.reg_ctrl)->i[0].adtr = emax_info.ddr_mmap - emax_info.lmm_phys;
((struct reg_ctrl*)emax6.reg_ctrl)->i[0].dmrp = 0LL;
#endif
}
このような関数を定義し、これでメモリ割り当てを行ってください。そうしないと動きません。 具体的な使い方はこちら。
sysinit(MM_SIZE*2*sizeof(float));
float *m1 = membase;
float *m2 = m1 + MM_SIZE;
トンカーブ例題
void tone_curve(unsigned int *r, unsigned int *d, unsigned char *t) {
Ull t1 = t;
Ull t2 = t + 256;
Ull t3 = t + 512;
Ull BR[16][4][4];
Ull r0, r1, r2;
int loop = WD;
//EMAX5A begin tone_curve mapdist=0
while (loop--) {
mop(OP_LDWR, 1, &BR[0][1][1], (Ull)(r++), 0LL, MSK_D0, (Ull)r, 320, 0, 0, (Ull)NULL, 320);
mop(OP_LDBR, 1, &BR[1][1][1], (Ull)t1, BR[0][1][1], MSK_B3, (Ull)t1, 64, 0, 0, (Ull)NULL, 64);
mop(OP_LDBR, 1, &BR[1][2][1], (Ull)t2, BR[0][1][1], MSK_B2, (Ull)t1, 64, 0, 0, (Ull)NULL, 64);
mop(OP_LDBR, 1, &BR[1][3][1], (Ull)t3, BR[0][1][1], MSK_B1, (Ull)t1, 64, 0, 0, (Ull)NULL, 64);
exe(OP_MMRG, &r1, BR[1][1][1], EXP_H3210, BR[1][2][1], EXP_H3210, BR[1][3][1], EXP_H3210, OP_NOP, 0, OP_NOP, 0);
mop(OP_STWR, 3, &r1, (Ull)(d++), 0LL, MSK_D0, (Ull)d, 320, 0, 0, (Ull)NULL, 320);
}
//EMAX5A end
//EMAX5A drain_dirty_lmm
}
rはRGBデータが入った入力画像、tはカラーマップ、dは出力となります。256段階の普通の24ビットで色を表現する画像を想定しています。
MSK_D0
はマスクをかけていない状態といえます。すべての64ビットデータがロードされます。MSK_B3
は、バイト単位でマスクをかけます。位置によってB0~B7まで定義できます。whileの中は次のようなフローになります。
EXP_H3210
は、64ビットの値をそのままつかうという意味になります。
- 1ピクセルの32ビットデータを
r
からロードしてBR[0][1][1]
に格納する -
BR[1][n][1]
のところにBR[0][1][1]
に格納されたRGBデータをマスクをかけて抽出、カラーマップから8ビットの該当データをロードする。 -
BR[1][n][1]
のデータをマージし、r1
に格納する。 -
r1
の32ビットデータをd
にストアする。
それぞれのユニットがつながり、データの受け渡しを効率よく行うようにマッピングされたことが確認できます。しかし、これだけでは1サイクル当たり1ピクセルの処理しかできないため、控えめに言って効率が悪いです。IMAXには、その問題を解決するための様々な並列化のための機能が存在します。追々紹介していきます。
おわりに
今回は、IMAXの命令セットと簡単な例題の解説をしました。次回は、今回取り上げた例題のさらなる並列化手法について解説していきたいと思います。
次回の記事
AIアクセラレータ・IMAXの紹介 ~ (4) SIMD化とループ効率化