SDSoC Example catalog(1)~Array Partitioning~

年内にFPGAの記事を1本書いておきたかったので、詳しくなりたいSDSoCについて書きます。 今はノリで使っていて、基礎ができていないので、1歩ずつやります。

何からやろうか、と考えたところ、私は具体例から物事を理解することが得意なので、Xilinx SDSoC Exampleを1つずつ詳細に解析していくことにしました。

今回は、Array Partitioning です。

SDxのバージョンは、2018.2です。(更新しないとなぁ)

Array Partitioning

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自体は普通ですね。CCsds++にしています。

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

pragma SDS data copy

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

SDSoC 環境ヘルプ パフォーマンスのための構造最適化

大型の配列を複数の配列または個別のレジスタに分割し、データへのアクセスを改善してブロック RAM のボトルネックを削除します。

そのままですね。 pragmaでググるとSDAccelの方だけ引っかかりますね。まあ良いのですが。

SDAccel 開発環境ヘルプ (2018.2 XDF 用) pragma HLS array_partition

この分割により、次のようになります。 1 つの大型メモリではなく、複数の小型メモリまたは複数のレジスタを含む RTL が生成されます。 ストレージの読み出しおよび書き込みポートの数が増加します。 デザインのスループットが向上する可能性があります。 より多くのメモリ インスタンスまたはレジスタが必要となります。

構文

#pragma HLS array_partition variable=<name> \
<type>  factor=<int>  dim=<int>

今回の例では、typecompleteになっています。

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でプロジェクトを開くと、次のような回路が生成されていました。

f:id:tomo-wait-for-it-yuki:20181229051323p:plain
SDSoCで生成される回路

確かに、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つずつしっかり見ていくのは、重要ですね。 今後も継続していきたいと思います。

参考