SDSoC Example catalog(1)~Array Partitioning~
年内にFPGAの記事を1本書いておきたかったので、詳しくなりたいSDSoCについて書きます。 今はノリで使っていて、基礎ができていないので、1歩ずつやります。
何からやろうか、と考えたところ、私は具体例から物事を理解することが得意なので、Xilinx SDSoC Exampleを1つずつ詳細に解析していくことにしました。
今回は、Array Partitioning です。
SDxのバージョンは、2018.2です。(更新しないとなぁ)
Array Partitioning
array_partition/ This example shows how to use array partitioning to improve performance of a hardware function
Key Concepts - Hardware Function Optimization - Array Partitioning Keywords - #pragma HLS ARRAY_PARTITION - complete
説明から察するに、配列を分割してハードウェアで演算するようなロジックを生成するのだと思います。さっそく見ていきましょう。
Makefile
Makefileがあるので、まずこれを見ましょう。
default parameters
Platformのデフォルトはzcu102になるようです。他には、zc706とzc702が指定できるようです。
# FPGA Board Platform (Default ~ zcu102) PLATFORM := zcu102
ユーティリティディレクトリから、ライブラリをインクルードしています。インクルードしている内容については、後述します。
# Points to Utility Directory COMMON_REPO = ../../../ ABS_COMMON_REPO = $(shell readlink -f $(COMMON_REPO)) # Include Libraries include $(ABS_COMMON_REPO)/libs/sds_utils/sds_utils.mk
Makefile Data
リソースの指定は至って普通のMakefileです。
SRC_DIR := src OBJECTS += \ $(pwd)/$(BUILD_DIR)/main.o \ $(pwd)/$(BUILD_DIR)/matmul.o
SDSのオプションが出てきました。こういうものを1つずつ理解していきます。
# SDS Options HW_FLAGS := ifneq ($(TARGET), cpu_emu) HW_FLAGS += -sds-hw matmul_partition_accel matmul.cpp -sds-end endif
SDSoCの説明を探すと、次の記述が見つかりました。
-sds-hw と -sds-end オプションはペアで使用されます。 -sds-hw オプションでは、単一の関数記述のハードウェアへの移動が開始されます。 -sds-end オプションでは、その関数のコンフィギュレーションの詳細なリストが終了されます。
-sds-hw
と-sds-end
はただのラベルで、matmul_partition_accel
は、高位合成する関数名になります。
ソースコードから、matmul_partition_accelを探すと、同名の関数が宣言/定義されています。
cpp/getting_started/array_partition/matmul.h
// Pragma below Specifies sds++ Compiler to Generate a Programmable Logic Design // Which has Direct Memory Interface with DDR and PL. #pragma SDS data zero_copy(in1[0:mat_dim*mat_dim], in2[0:mat_dim*mat_dim], out[0:mat_dim*mat_dim]) void matmul_partition_accel(int *in1, // Read-Only Matrix 1 int *in2, // Read-Only Matrix 2 int *out, // Output Result int mat_dim); // Matrix Dim (assumed only square matrix)
コンパイルフラグを作成しています。普通です。
# Compilation and Link Flags IFLAGS := -I. CFLAGS = -Wall -O3 -c CFLAGS += -I$(sds_utils_HDRS) CFLAGS += $(ADDL_FLAGS) LFLAGS = "$@" "$<"
SDS driver
driver自体は普通ですね。CC
をsds++
にしています。
SDSFLAGS := -sds-pf $(PLATFORM) \ -target-os $(TARGET_OS) ifeq ($(VERBOSE), 1) SDSFLAGS += -verbose # SDS Compiler CC := sds++ $(SDSFLAGS) endif
コンパイルのコマンドも普通な感じです。
.PHONY: all all: $(BUILD_DIR)/$(EXECUTABLE) $(BUILD_DIR)/$(EXECUTABLE): $(OBJECTS) mkdir -p $(BUILD_DIR) @echo 'Building Target: $@' @echo 'Trigerring: SDS++ Linker' cd $(BUILD_DIR) ; $(CC) -o $(EXECUTABLE) $(OBJECTS) $(EMU_FLAGS) @echo 'SDx Completed Building Target: $@' @echo ' ' $(pwd)/$(BUILD_DIR)/%.o: $(pwd)/$(SRC_DIR)/%.cpp @echo 'Building file: $<' @echo 'Invoking: SDS++ Compiler' mkdir -p $(BUILD_DIR) cd $(BUILD_DIR) ; $(CC) $(CFLAGS) -o $(LFLAGS) $(HW_FLAGS) @echo 'Finished building: $<' @echo ' ' ifeq ($(TARGET), cpu_emu) @echo 'Ignore the warning which states that hw function is not a HW accelerator but has pragma applied for cpu_emu mode' @echo ' ' endif
make check
すると、エミュレータが走るようです。
# Check Rule Builds the Sources and Executes on Specified Target check: all ifneq ($(TARGET), hw) ifeq ($(TARGET_OS), linux) cp $(ABS_COMMON_REPO)/utility/emu_run.sh $(BUILD_DIR)/ cd $(BUILD_DIR) ; ./emu_run.sh $(EXECUTABLE) else cd $(BUILD_DIR) ; sdsoc_emulator -timeout 500 endif else $(info "This Release Doesn't Support Automated Hardware Execution") endif
emu_run.sh
を叩いています。
utility/emu_run.sh
を見ると、sdsoc_emulatorを呼び出しています。
sdsoc_emulatorは、qemuベースのエミュレータで、RTLのシミュレーションもできる(ボードがXilinx作成物の場合)もののようです。
#!/usr/bin/env bash if [ -f "$PWD/_sds/init.sh" ] then rm -rf $PWD/_sds/emulation/sd_card sdsoc_emulator -no-reboot |tee emulator.log else cat > "init.sh" <<EOT #!/bin/sh /mnt/$1 reboot EOT echo $PWD/_sds/init.sh >> "_sds/emulation/sd_card.manifest" mv init.sh _sds sdsoc_emulator -no-reboot |tee emulator.log fi
sds_utils.mk
少し寄り道をして、Makefileからライブラリをインクルードするための、sds_utils.mk
の内容を確認します。
libs/sds_utils/sds_utils.mk
sds_utils_HDRS:=$(ABS_COMMON_REPO)/libs/sds_utils/
ヘッダファイルの場所を定義しているだけですね。ヘッダの中身もExampleの中で使うパフォーマンス計測用ヘルパークラスが定義されているだけです。
libs/sds_utils/sds_utils.h
#ifndef SDS_UTILS_H_ #define SDS_UTILS_H_ #include <stdint.h> #include "sds_lib.h" namespace sds_utils { class perf_counter { private: uint64_t tot, cnt, calls; public: perf_counter() : tot(0), cnt(0), calls(0) {}; inline void reset() { tot = cnt = calls = 0; } inline void start() { cnt = sds_clock_counter(); calls++; }; inline void stop() { tot += (sds_clock_counter() - cnt); }; inline uint64_t avg_cpu_cycles() {return (tot / calls); }; }; } #endif
C++ソースコード解析
まずは、動作確認用に作られているソフトウェア実行の関数を確認します。
matrix[M][M]
のデータを乗算しているだけですね。
// Software Matrix Multiplication void matmul(int *C, int *A, int *B, int M) { for (int k = 0; k < M; k++) { for (int j = 0; j < M; j++) { for (int i = 0; i < M; i++) { C[k * M + j] += A[k * M + i] * B[i * M + j]; } } } }
同様の関数を高位合成の対象としたのが、下記のmatmul_partition_accel
になります。
なんでも良いのですが、API合わせておいて欲しいです…。引数の順番が違うんですが。
// Pragma below Specifies sds++ Compiler to Generate a Programmable Logic Design // Which has Direct Memory Interface with DDR and PL. #pragma SDS data zero_copy(in1[0:mat_dim*mat_dim], in2[0:mat_dim*mat_dim], out[0:mat_dim*mat_dim]) void matmul_partition_accel(int *in1, // Read-Only Matrix 1 int *in2, // Read-Only Matrix 2 int *out, // Output Result int mat_dim); // Matrix Dim (assumed only square matrix)
main関数
それでは、main関数を見ていきます。
cpp/getting_started/array_partition.cpp
int main(int argc, char **argv) { bool test_passed = true; static const int columns = MAX_SIZE; static const int rows = MAX_SIZE; // Allocate buffers using sds_alloc int *A = (int *) sds_alloc(sizeof(int) * columns * rows); int *B = (int *) sds_alloc(sizeof(int) * columns * rows); int *C = (int *) sds_alloc(sizeof(int) * columns * rows);
まずは、sds_alloc
でメモリ空間を確保しています。
sds_alloc
SDSoC 開発環境ヘルプ メモリ割り当てを参照して、何者か調べます。
sds_alloc/sds_free を使用してアクセラレータ専用のメモリを割り当てた方がデータが物理的に隣接したメモリに割り当てられて格納されるので、メモリへの読み出しおよび書き込みが速くなり、パフォーマンスを向上できます。
なるほど、連続領域にメモリが確保される、ということみたいですね(ソースコードは公開されているのかしら?)。
データの初期化をして、ソフトウェア実装、ハードウェア実装を順番に呼び出しています。 関数の実行にかかった時間を計測しており、ソフトウェア実装とハードウェア実装との一致比較をして、結果を検証しています。。
for (int i = 0; i < NUM_TIMES; i++) { // Data Initialization for(int i = 0; i < columns * rows; ++i) { A[i] = i; B[i] = i + i; gold[i] = 0; C[i] = 0; } sw_ctr.start(); //Launch the Software Solution matmul(gold, A, B, columns); sw_ctr.stop(); hw_ctr.start(); //Launch the Hardware Solution matmul_partition_accel(A, B, C, columns); hw_ctr.stop(); // Compare the results of hardware to the simulation test_passed = verify(gold, C, columns * rows); }
matmul_partition_accel関数宣言
高位合成対象関数matmul_partition_accel
についているpragmaの意味を調べます。
#pragma SDS data zero_copy(in1[0:mat_dim*mat_dim], in2[0:mat_dim*mat_dim], out[0:mat_dim*mat_dim]) void matmul_partition_accel(int *in1, // Read-Only Matrix 1 int *in2, // Read-Only Matrix 2 int *out, // Output Result int mat_dim); // Matrix Dim (assumed only square matrix)
SDS data zero_copy pragma
COPY プラグマを使用すると、データがホスト プロセッサ メモリからハードウェア関数にコピーされます。最適なデータ ムーバーによりデータ転送が実行されます。詳細は、『SDSoC 環境プロファイリングおよび最適化ガイド』 (UG1235) の「システム パフォーマンスの向上」を参照してください。 ZERO_COPY を使用すると、ハードウェア関数が AXI4 マスター バス インターフェイスを介して共有メモリからデータに直接アクセスします。
なるほど。データムーバーを使うかどうか、が変わってくるということですね。
COPY
を利用すると、データ転送するコードに置き換えられます。
#pragma SDS data copy(A[0:size*size], B[0:size*size]) void foo(int *A, int *B, int size) { ... } void _p0_foo_0(int *A, int *B, int size) { ... cf_send_i(&(_p0_swinst_foo_0.A), A, (size*size) * 4, &_p0_request_0); cf_receive_i(&(_p0_swinst_foo_0.B), B, (size*size) * 4, &_p0_request_1); ... }
ZERO_COPY
を利用すると、 参照 を送信するコードに置き換えられます。
#pragma SDS data zero_copy(A[0:size*size], B[0:size*size]) void foo(int *A, int *B, int size) { ... } void _p0_foo_0(int *A, int *B, int size) { ... cf_send_ref_i(&(_p0_swinst_foo_0.A), A, (size*size) * 4, &_p0_request_0); cf_receive_ref_i(&(_p0_swinst_foo_0.B), B, (size*size) * 4, &_p0_request_1); ... }
cf_send_ref_i および cf_receive_ref_i 関数は配列の参照またはポインターのみをアクセラレータに転送し、アクセラレータは直接メモリにアクセスします。
この違いは、生成されるブロックデザインを見ると明らかでした。
matmul_partition_accel定義
高位合成関数の本体は次の通りです。
void matmul_partition_accel(int *in1, // Read-Only Matrix 1 int *in2, // Read-Only Matrix 2 int *out, // Output Result int mat_dim) // Matrix Dimension { // Local memory is implemented as BRAM memory blocks int A[MAX_SIZE][MAX_SIZE]; int B[MAX_SIZE][MAX_SIZE]; int C[MAX_SIZE][MAX_SIZE]; //partitioning Array A and B #pragma HLS ARRAY_PARTITION variable=A dim=2 complete #pragma HLS ARRAY_PARTITION variable=B dim=1 complete // Burst reads on input matrices from DDR memory // Burst read for matrix A and B // Multiple memory interfaces are supported by default in SDSoC // It is possible to fetch both A and B concurrently. readA: for (int itr = 0, i = 0, j = 0; itr < mat_dim * mat_dim; itr++, j++) { #pragma HLS PIPELINE #pragma HLS LOOP_TRIPCOUNT min=c_size*c_size max=c_size*c_size if (j == mat_dim) { j = 0; i++; } A[i][j] = in1[itr]; B[i][j] = in2[itr]; } // By Default VHLS create single Memory with two ports for each local buffer // which allows maximum two read/write from buffer per clock. // Due to this restriction, lowest loop of mmmult can be unroll by 2 times only. // // However Partition gives instruction to VHLS Complier to split a large array // into small-small memory which allow user to get multiple concurrent accesses. // // To completely unroll the lowest loop of Mmult, A buffer is completely // partitioned for 2nd dimension, and B buffer is completely partitioned // for 1st dimension. Which eventually will improve the overall performance of // matrix multiplication. arraypart1: for (int i = 0; i < mat_dim; i++) { #pragma HLS LOOP_TRIPCOUNT min=c_size max=c_size arraypart2: for (int j = 0; j < mat_dim; j++) { #pragma HLS LOOP_TRIPCOUNT min=c_size max=c_size #pragma HLS PIPELINE int result = 0; arraypart3: for (int k = 0; k < MAX_SIZE; k++) { result += A[i][k] * B[k][j]; } C[i][j] = result; } } // Burst write from output matrices to DDR memory // Burst write from matrix C writeC: for (int itr = 0, i = 0, j = 0; itr < mat_dim * mat_dim; itr++, j++) { #pragma HLS PIPELINE #pragma HLS LOOP_TRIPCOUNT min=c_size*c_size max=c_size*c_size if (j == mat_dim) { j = 0; i++; } out[itr] = C[i][j]; } }
pragmaを1つずつ見ていきます。
HLS ARRAY_PARTITION pragma
まずは、ARRAY_PARTITION
で、ローカルメモリを分割します。
// Local memory is implemented as BRAM memory blocks int A[MAX_SIZE][MAX_SIZE]; int B[MAX_SIZE][MAX_SIZE]; int C[MAX_SIZE][MAX_SIZE]; //partitioning Array A and B #pragma HLS ARRAY_PARTITION variable=A dim=2 complete #pragma HLS ARRAY_PARTITION variable=B dim=1 complete
大型の配列を複数の配列または個別のレジスタに分割し、データへのアクセスを改善してブロック RAM のボトルネックを削除します。
そのままですね。 pragmaでググるとSDAccelの方だけ引っかかりますね。まあ良いのですが。
SDAccel 開発環境ヘルプ (2018.2 XDF 用) pragma HLS array_partition
この分割により、次のようになります。 1 つの大型メモリではなく、複数の小型メモリまたは複数のレジスタを含む RTL が生成されます。 ストレージの読み出しおよび書き込みポートの数が増加します。 デザインのスループットが向上する可能性があります。 より多くのメモリ インスタンスまたはレジスタが必要となります。
構文
#pragma HLS array_partition variable=<name> \ <type> factor=<int> dim=<int>
今回の例では、type
がcomplete
になっています。
complete: 完全分割では、配列を個々の要素に分割します。1 次元配列の場合は、メモリが個々のレジスタに分割されます。これがデフォルトの
です。
なるほど。complete
を指定すると、完全分割になるので、完全な並列アクセスが可能になる、という理解でよさそうです。
HLS PIPELINE / HLS LOOP_TRIPCOUNT pragma
DDRからデータをバースト読み込みしている部分を読みます。
// Burst reads on input matrices from DDR memory // Burst read for matrix A and B // Multiple memory interfaces are supported by default in SDSoC // It is possible to fetch both A and B concurrently. readA: for (int itr = 0, i = 0, j = 0; itr < mat_dim * mat_dim; itr++, j++) { #pragma HLS PIPELINE #pragma HLS LOOP_TRIPCOUNT min=c_size*c_size max=c_size*c_size if (j == mat_dim) { j = 0; i++; } A[i][j] = in1[itr]; B[i][j] = in2[itr]; }
SDAccel 開発環境ヘルプ (2018.2 XDF 用) pragma HLS pipeline
パイプライン処理された関数またはループは、N クロック サイクル (N は関数またはループの開始間隔) ごとに新しい入力を処理できます。PIPELINE プラグマのデフォルトの開始間隔は 1 で、クロック サイクルごとに 1 つの入力を処理します。
PIPELINE
pragmaにより、配列A / 配列Bへの転送が並列で実行されるようです。
SDAccel 開発環境ヘルプ (2018.2 XDF 用) pragma HLS loop_tripcount
LOOP_TRIPCOUNT
pragmaは解析用のpragmaで、合成結果に影響しません。
ループで実行される反復回数を手動で指定することで、解析精度を向上できるようですね。
さて、matrixの演算をしている部分を見ていきます。コメントで重要なことがたくさん書いてあります。
// By Default VHLS create single Memory with two ports for each local buffer // which allows maximum two read/write from buffer per clock. // Due to this restriction, lowest loop of mmmult can be unroll by 2 times only. // // However Partition gives instruction to VHLS Complier to split a large array // into small-small memory which allow user to get multiple concurrent accesses. // // To completely unroll the lowest loop of Mmult, A buffer is completely // partitioned for 2nd dimension, and B buffer is completely partitioned // for 1st dimension. Which eventually will improve the overall performance of // matrix multiplication. arraypart1: for (int i = 0; i < mat_dim; i++) { #pragma HLS LOOP_TRIPCOUNT min=c_size max=c_size arraypart2: for (int j = 0; j < mat_dim; j++) { #pragma HLS LOOP_TRIPCOUNT min=c_size max=c_size #pragma HLS PIPELINE int result = 0; arraypart3: for (int k = 0; k < MAX_SIZE; k++) { result += A[i][k] * B[k][j]; } C[i][j] = result; } }
通常、HLSはローカルバッファーにdual portのBRAMを割り当てるため、ループ展開しても、2並列までしか展開できません。 このExampleでは、ローカルバッファをpartitioningしているため、データ読み書きの並列度を向上することができます。
配列Aは完全分割されており、レジスタが割り当てられています。配列Bは1次元方向に完全分割されています。 結果、下のループは、完全に並列展開されるようになります。
arraypart3: for (int k = 0; k < MAX_SIZE; k++) { result += A[i][k] * B[k][j]; }
build
ビルドして、どのような回路が生成されるか確認します。
$ make all TARGET=hw
Vivadoでプロジェクトを開くと、次のような回路が生成されていました。
確かに、DMAデータムーバが居ません。
画像左側にあるmutmul_partition_accel_1
が高位合成で生成されたアクセラレータです。
m_axi_in1
, m_axi_in2
, m_axi_out_r
と3つのAXIインタフェースが生えています。このAXIインタフェースがMemory Map (AXIMM)という名前で、PSのFPDインタフェースに接続しています。これで、DDRに直接アクセスするわけですね。
正直、PSのAXIインタフェースの使い方がもったいない気がしますが…。
配列のアドレスは、in1_offset
, in2_offset
, out_offset
としてアクセラレータに与えられています。
上の方で見た通り、PSからはポインタだけが渡されています。
捕捉ですが、普通にコピーするアクセラレータを生成すると、adapter_v3_0
のIP内にBRAMまたはFIFOが生成されて、そこにデータがコピーされることになります。
このアクセラレータからDDRに直接アクセスする構成は、大きなデータのうち、一部のみをランダムアクセスするような回路を構成する場合に有効そうですね。
まとめ
- SDSoC環境でのMakefile記述方法を少し理解しました
- 次のSDSoc用関数およびpragmaを学びました
- sds_alloc
- SDS data copy / zero_copy
- HLS ARRAY_PARTITION
- HLS PIPELINE
- HLS LOOP_TRIPCOUNT
1つずつしっかり見ていくのは、重要ですね。 今後も継続していきたいと思います。