FPGAカーネル超入門 (1)

執筆者 : 小田 逸郎


1. はじめに

本記事は、Linuxカーネルならぬ、FPGAカーネルに関するもの(予定)です。 筆者は、一応、Linuxカーネル技術者の端くれですが、カーネルつながり(?)とは言え、 FPGAカーネルの方は、全くの素人ですので、「超」入門としましたが、果たして、お役に立てる 記事になるかどうか。

対象とするFPGAは、サーバにPCI-eで接続して使用するもので、アクセラレータとも呼ばれるものです。 アクセラレータとしては、GPUが最も一般的かと思いますが、FPGAも最近何かと話題になっています。

筆者は、最近、Xilinx社のAlveo U200 というアクセラレータカードを使用する機会に恵まれました。 そのため、本記事では、この機種での使用例を元に説明しています。

今回の記事では、ともかくFPGAカーネルを動作させるところまでチャレンジします。

補足
同じカーネルで紛らわしいですが、本記事では、FPGAカーネルが支配的なので、単にカーネルと 言った場合は、FPGAカーネルのことを指します。Linuxカーネルについては、その都度、何か分かる ように注記します。

2. 動作環境

ハードウェア(サーバ)については、Xilinx社が Alveo U200 のプラットフォームとして公式にサポート しているものであれば、問題ないはずです。普通のx64マシンを使用しています。

OSは、Ubuntu 18.04 を使用しています。

以下にインストールが必要なソフトウェアについて、示します。取得したURLも合わせて示します。

Vitis統合開発環境 2020.1

カーネルを開発するための開発環境です。

補足
最新は、2020.2 ですが、使用を始めた時点では、2020.1 が最新だったので、2020.1で 説明します。2020.1 では、Ubuntu 20.04 がサポートされていませんでしたが、2020.2 では サポートされているようです。将来、何かあれば、2020.2 への更新記事を書くかもしれません。
以下のパッケージは、2020.1に整合するものとなります。

XRTパッケージ

ホスト側プログラムのためのライブラリやアクセラレータカードのドライバが含まれます。

U200 プラットフォーム(運用用)

アクセラレータカードを動作させるために必要なものです。(機種依存)

  • xilinx-u200-xdma-201830.2-2580015_18.04.deb
    URLは、XRTパッケージと同じです。

補足
カーネルの実行に必要です。カーネルの開発を行うだけ、すなわち、 カーネルを実機で動かさない(アクセラレータが装備されていない)場合は、不要です。

U200 プラットフォーム(開発用)

カーネル開発時に必要な機種依存の情報が含まれます。

補足
カーネルの開発を行うのに必要です。既存のカーネルを実行するだけであれば、不要です。

インストール

インストールについて、以下に簡単に説明します。

Vitis

インストールには、GUI環境が必要です。インストーラを実行するとGUIが開くので、それに従って、 インストールすればよいです。

$ ./Xilinx_Unified_2020.1_0602_1208_Lin64.bin
...

デフォルトの指定で十分です。ディスク容量が結構必要で、150GBくらい用意しておいた方がよいです。 デフォルトでは、/tools/Xilinx ディレクトリ配下にインストールされます。/tools ディレクトリは あらかじめ存在している必要があります。

注意
公式には、インストーラが、Ubuntu 18.04.4 までしか対応していないようです。18.04.5 以降では、 インストーラのバグのため、インストーラがハングしてしまいます。回避するには、/etc/os-release ファイル中の「18.04.X」の文字列を「18.04.4」に変更すればよいです。インストールが終わったら、 戻しておきましょう。筆者は、18.04.5 で問題なく使用しています。保証はしませんが、ご参考まで。

XRT

$ sudo apt install ./xrt_202010.2.6.655_18.04-amd64-xrt.deb

ドライバ(カーネルモジュール)のビルドも行われます。

U200 プラットフォーム(運用用)

$ sudo apt install ./xilinx-u200-xdma-201830.2-2580015_18.04.deb
...

最後に、以下のコマンドを実行せよ、とメッセージが出るので、コピペして実行します。

sudo /opt/xilinx/xrt/bin/xbutil flash -a xilinx_u200_xdma_201830_2 -t 1561465320

$ sudo /opt/xilinx/xrt/bin/xbutil flash -a xilinx_u200_xdma_201830_2 -t 1561465320
...

最後にコールドリブートせよ、とメッセージが出るのでコールドリブートします。

U200 プラットフォーム(開発用)

$ sudo apt install ./xilinx-u200-xdma-201830.2-dev-2580015_18.04.deb

3. カーネルの作成

カーネルのソースコードの記述方法、ビルド方法は、いくつかあるのですが、ここでは 最も簡単なC言語で記述する方法を選択しています。

参照
Vitis統合ソフトウェアプラットフォームの資料 (以下、Vitisマニュアル)を参照しています。

以下に今回作成するカーネルのソースコードを示します。(本章で参照するソースコードは、 ここにもあります。)

void
vadd(const unsigned int *in1, const unsigned int *in2,
     unsigned int *out, int size)
{
#pragma HLS INTERFACE m_axi port=in1 offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=in2 offset=slave bundle=gmem
#pragma HLS INTERFACE m_axi port=out offset=slave bundle=gmem

        int i;

        for (i = 0; i < size; i++) {
                out[i] = in1[i] + in2[i];
        }
}

vadd関数の処理内容については、説明するまでもないですね。

補足
今回は、ともかくカーネルを作成して動かしてみるのが目的ですので、カーネルの内容 については、不問としてください。
少なくとも、ベクトルを一気に加算できるようになっていないと、アクセラレータで 実行する意味がなさそうですが、こんなコードでそうなるのか疑問ですよね。

アクセラレータでこのvadd関数が実行されるというところまでは想像が付きますが、引数の 取り扱いが気になるところです。それは、おいおい明らかになっていく予定ですが、 ここで説明するポイントは、ポインタ引数(in1、in2、out)に関してです。

アクセラレータからは、ホストのメモリを直接アクセスすることができません。ホストの メモリを読みたい場合は、一旦、アクセラレータのメモリに転送する必要があります。 また、アクセラレータで計算した結果は、アクセラレータのメモリに格納し、それを ホストのメモリに転送することにより、ホストからアクセスできるようになります。

ソースコード中のpragma文は、アクセラレータのメモリを使用すること、ホストとアクセラ レータ間で転送が必要なことを示します。ポインタ引数については、このおまじないが 必要です。port= に引数名を指定し、bundle= に適当な文字列(後で参照します)を 指定すれば、後はこのままでOKです。

補足
アクセラレータからホストのメモリを直接アクセス(DMA)できる機種もあります。少なくとも 現時点でのU200では、できません。
DMAがサポートされた暁には、また記事を書くかもしれません。

カーネルのビルドは、以下のようにします。2段階で行うことになります。

まずは、ビルドに先立ち、ビルドツールを使用するのに必要な環境変数を設定します。

$ source tools/Xilinx/Vitis/2020.1/settings64.sh

カーネル単独のバイナリファイル(.xo)を作成します。

$ v++ -t hw --platform xilinx_u200_xdma_201830_2 -c -k vadd -o vadd.xo vadd.c

-k でカーネル名を指定しています。

アクセラレータにロードするためのバイナリファイル(xclbin)を作成します。

$ v++ -t hw --platform xilinx_u200_xdma_201830_2 --config design.cfg -l -o vadd.xclbin vadd.xo

補足
xclbinには、複数のカーネルを含めることができます。今回は、ひとつだけです。

指定したコンフィグファイル design.cfg の内容を以下に示します。

[connectivity]
nk=vadd:3
sp=vadd_1.m_axi_gmem:DDR[1]
sp=vadd_2.m_axi_gmem:DDR[1]
sp=vadd_3.m_axi_gmem:DDR[1]

nk=は、vaddカーネルのインスタンスを3つ作成することを指定しています。個々のインスタンス名 を指定することもできますが、指定しなければ、vadd_1vadd_3のようにカーネル名_1からの 数字になります。

sp=は、アクセラレータで使用するメモリバンクを指定しています。m_aci_の後のgmemが ソースコードのpragma文のbundle=で指定した文字列に対応しています。

補足
u200は、メモリバンクが4つ(0~3)あり、ここでは、メモリバンク1を使用することを 指定しています。

コンフィグファイルの仕様は多岐に渡りますが、最小では、nk=文だけあればOKです。 今回は、後々(次回)の説明の都合上、sp=も使ってみました。

注意
xclbinの作成には、無茶苦茶時間がかかります。今回のような単純なカーネルでさえ、1時間半 ほど掛かっています。

4. ホスト側プログラムの作成

カーネルを実行するホスト側のプログラムを作成します。ホスト側のプログラムは、OpenCLを使用します。

参照
前章でも参照した、Vitisマニュアル を参照しています。また、OpenCL 1.2の仕様書を 参照しています。今回インストールしたXRTパッケージでサポートしているOpenCLのバージョンは、1.2です。

ここでは、OpenCLの解説をするつもりはなくて、カーネルを動かすプログラムをなるべく機械的に作れる ようなひな形を示すことを目的として説明します。

以下に処理の流れと実行するOpenCLの関数を示します。

  1. プラットフォーム(cl_platform_id)の取得
    clGetPlatformIDs
  2. デバイス(cl_device_id)の取得
    clGetDeviceIDs
  3. コンテキスト(cl_context)の作成
    clCreateContext
  4. コマンドキュー(cl_command_queue)の作成
    clCreateCommandQueue
  5. プラグラム(cl_program)のロード
    clCreateProgramWithBinary
  6. カーネル(cl_kernel)の取得
    clCreateKernel
  7. メモリの準備とバッファ(cl_mem)の作成
    clCreateBuffer
  8. カーネル引数の設定
    clSetKernelArg
  9. 入力データの転送要求の投入
    clEnqueueMigrateMemObjects
  10. カーネルの実行要求の投入
    clEnqueueTask
  11. 出力データの転送要求の投入
    clEnqueueMigrateMemObjects
  12. 完了待ち合わせ
    clFinish
  13. リソース解放
    関数割愛

この順序のとおりに実行していけばOKです。

サンプルプログラムの全体は、以下にありますので参照してください。

https://github.com/oda-g/FPGA/tree/main/xilinx/vadd/src/host/vadd_cl.c

ご覧いただくと分かるように、まさにこの順で実行しているだけです。

以下にサンプルプログラムのコード片を示しながら、ポイントを説明します。 (対照しやすいように行番号を付記します。)

プラットフォーム取得

61       err = clGetPlatformIDs(1, &platform_id, NULL);

サンプルプログラムは、プラットフォームは1種類と仮定しています。プラットフォームがある場合 (例えば、FPGAとGPU)は、複数取得した上で選択する必要があります。 プラットフォーム選択の際は、clGetPlatformInfoにて、各プラットフォームの情報を取得して選択します。 (サンプルプログラムに使用例があります。)

デバイス取得

82       err = clGetDeviceIDs(platform_id, CL_DEVICE_TYPE_ACCELERATOR,
83                 1, &device_id, NULL);

サンプルプログラムは、デバイスが1台と仮定しています。デバイスが 複数ある場合は、複数取得した上で選択する必要があります。デバイスの選択の際は、clGetDeviceInfo にて各デバイスの情報を取得して選択します。

コンテキスト作成

90       context = clCreateContext(NULL, 1, &device_id, NULL, NULL, &err);

補足
OpenCLの各リソース(クラス)の関係は結構複雑ですが、本プログラムは最も単純なパターンを使用していると言えます。

コマンドキュー作成

97       cmd_queue = clCreateCommandQueue(context, device_id,
98                 CL_QUEUE_PROFILING_ENABLE, &err);

プログラムのロード

22   #define XCLBIN "../../bin/vadd.xclbin"

105     fd = open(XCLBIN, O_RDONLY);

110     if (fstat(fd, &st) == -1) {

114     xclbin_size = st.st_size;

116     xclbin = (unsigned char *)mmap(NULL, xclbin_size, PROT_READ,
117             MAP_PRIVATE, fd, 0);

124     program = clCreateProgramWithBinary(context, 1, &device_id,
125               &xclbin_size, (const unsigned char **)&xclbin,
126               NULL, &err);

プログラム(cl_program)の取得は、clCreateProgramWithBinaryを使用する必要があります。 clCreateProgramWithBinaryには、xclbinファイルの内容を渡す必要がありますので、xclbinファイルのパス名が 分かっている必要があります。サンプルプログラム では、ファイルの内容の取得にmmapを使用しています。

補足
clCreateProgramWithBinaryの延長で、(必要であれば)ドライバにxclbinの内容がロードされます。

カーネルの取得

139      kernel = clCreateKernel(program, "vadd", &err);

Vitisマニュアルによると、「"vadd:{vadd_1}"」のように特定のカーネルインスタンスを指定することも できます(複数指定可)。

補足
"vadd"指定(あるいは、カーネルインスタンスを複数指定)の場合、どのカーネルインスタンスを実行 するかの選択は、ドライバが行っています。

メモリの準備とバッファの作成

146      data_in1 = (cl_int *)aligned_alloc(ALIGNMENT, sizeof(cl_int) * DATA_SIZE);

151     data_in2 = (cl_int *)aligned_alloc(ALIGNMENT, sizeof(cl_int) * DATA_SIZE);

156     data_out = (cl_int *)aligned_alloc(ALIGNMENT, sizeof(cl_int) * DATA_SIZE);

171     bo_in1 = clCreateBuffer(context,  CL_MEM_READ_ONLY | CL_MEM_USE_HOST_PTR,
172             sizeof(cl_int) * DATA_SIZE, data_in1, &err);

178     bo_in2 = clCreateBuffer(context,  CL_MEM_READ_ONLY | CL_MEM_USE_HOST_PTR,
179             sizeof(cl_int) * DATA_SIZE, data_in2, &err);

185     bo_out = clCreateBuffer(context,  CL_MEM_WRITE_ONLY | CL_MEM_USE_HOST_PTR,
186             sizeof(cl_int) * DATA_SIZE, data_out, &err);

プロセス側のメモリは、ページ境界に合っている必要があるので、aligned_allocを使用して います。

clCreateBufferでプロセスメモリと関連付けたバッファの作成を行っています。入力用と 出力用で一部引数が異なります。

バッファは、デバイス側のメモリを管理するためのリソースですが、ここでは、プロセスメモリ と紐づけているところがポイントです。

サンプルプログラムでは、入力用のデータを用意している箇所があります。

162      srand((unsigned int)time(NULL));
163     for (i = 0; i < DATA_SIZE; i++) {
164         data_in1[i] = rand();
165         data_in2[i] = rand();
166         cpu_out[i] = data_in1[i] + data_in2[i];
167         data_out[i] = 0;
168     }

cpu_outは、後で、FPGAの計算結果が正しいかチェックするためのものです。実運用のプログラムで こんなことをしていては、アクセラレータを使用する意味がありません。テスト用プログラム ならではの部分だと言えます。

カーネル引数の設定

193      err = clSetKernelArg(kernel, 0, sizeof(cl_mem), &bo_in1);

199     err = clSetKernelArg(kernel, 1, sizeof(cl_mem), &bo_in2);

205     err = clSetKernelArg(kernel, 2, sizeof(cl_mem), &bo_out);

211     err = clSetKernelArg(kernel, 3, sizeof(cl_int), &size);

このタイミング、すなわち、データ転送要求の前にカーネル引数の設定を行います。 当然のことですが、カーネルのインタフェースは理解している必要があります。

sizeに関しては、直感的に分かり易いと思いますが、カーネル引数と同じ型を用意して、渡し ます。ポインタ引数に関しては、バッファのアドレスを指定します。

補足
バッファは、OpenCLのリソースですから、OpenCLライブラリの延長で、よろしく何らかの変換が行われて いることが想像できます。(次回解説予定)

データ転送、カーネル実行要求

218      err = clEnqueueMigrateMemObjects(cmd_queue, 2, (cl_mem []){bo_in1, bo_in2},
219                 0, 0, NULL, NULL);

226     err = clEnqueueTask(cmd_queue, kernel, 0, NULL, NULL);

233     err = clEnqueueMigrateMemObjects(cmd_queue, 1, &bo_out,
234                CL_MIGRATE_MEM_OBJECT_HOST, 0, NULL, NULL);

入力データの転送要求、カーネルの実行要求、出力データの転送要求をこの順で、コマンドキューに 投入します。データの転送要求には、clEnqueueMigrateMemObjectsを使用します。入力用と出力用で 一部引数が異なります。

実行完了待ち合わせ

241      err = clFinish(cmd_queue);

コマンドキューに投入した要求の完了を待ち合わせします。

補足
本プログラムのパターンでは、実際のコマンド実行の契機にもなっているので、実は、この延長で 実際のデータ転送やカーネル実行が行われます。

ホスト側プログラムのビルドは以下のように行います。

$ gcc -o vadd_cl -I/opt/xilinx/xrt/include vadd_cl.c -L/opt/xilinx/xrt/lib -lxrt_core -lxrt_coreutil -lOpenCL -lpthread

実行は、以下のとおりです。(実行に先立ち、環境変数の設定が必要です。)

$ source /opt/xilinx/xrt/setup.sh
$ ./vadd_cl
...
OK.
...

問題なければ、「OK.」の出力があるはずです。

5. ドライバの解析

XRTパッケージのインストール時にドライバのインストールも行われますが、dkmsという仕組みを使っており、 インストール時にドライバのビルドが行われます。以下のコマンド実行で、dkmsの仕組みを 使っているものを把握することができます。

$ dkms status
xrt, 2.6.655, 4.15.0-60-generic, x86_64: installed

ソースコードは、/usr/src/xrt-2.6.655/ 配下にインストールされています。 ソースコードが参照できるのはありがたいですね。

補足
ソースコードは、元々、githubで公開されています。ドライバだけではなく、 XRTパッケージの元となったソースコード一式となります。今回使用したパッケージのソースコードを 参照するには、tag 202010.2.6.655 をチェックアウトすれば良いです。

ドライバのビルドがされたということは、ドライバのビルドに必要なパッケージは既にインストールされて おり、ビルドのためのお膳立てはできているということを意味します。

これは、手間が掛からず、ドライバのビルドができそうですが、実際、簡単にできます。

一応、その場でビルドするのは避けて、ソースコードを別の場所にコピーして行います。

$ cp -r /usr/src/xrt-2.6.655/ ~
$ cd ~/xrt-2.6.655/driver/xocl
$ sudo make KERNELDIR=/lib/modules/$(uname -r)/build SYMBOL=1

userpf/xocl.komgmtpf/xclmgmt.ko がビルドされたドライバになります。上記では、SYMBOL=1 を付けましたが、これは、デバッグ情報付きでビルドする指定になります。

補足
何か修正して、ドライバを置き換えたい場合は、既存のドライバをrmmod(ex. 「sudo rmmod xocl」)し、 作成したドライバをinsmod(ex.「sudo insmod userpf/xocl.ko」)すればよいです。上記は、デバッグ情報 を付けただけなので、置き換えは意味ありません。

デバッグ情報付きドライバ(Linuxカーネルモジュール)ができたので、これで、crashコマンドによる解析もできますね。

補足
crashを使用するためには、linuxのdebug symbolパッケージが必要です。crash、linuxのdebug symbol パッケージのインストールは、以下のページを参照してください。
https://github.com/oda-g/eBPF/blob/master/doc/kernel_analysis.rst

$ sudo crash /usr/lib/debug/boot/vmlinux-$(uname -r)
...
crash> mod |grep xocl
ffffffffc0778400  xocl                   786432  (not loaded)  [CONFIG_KALLSYMS]
crash>

先ほど作成した、デバッグ情報付きモジュールを使って見ましょう。(mod -s で、明示的に モジュールのパスを指定します。)

crash> mod -s xocl /home/hoge/xrt-2.6.655/driver/xocl/userpf/xocl.ko
     MODULE       NAME                     SIZE  OBJECT FILE
ffffffffc0778400  xocl                   786432  /home/hoge/xrt-2.6.655/driver/xocl/userpf/xocl.ko 
crash> struct -o exec_core
struct exec_core {
     [0] struct platform_device *pdev;
     [8] struct mutex exec_lock;
    [40] void *base;
    [48] void *csr_base;
    [56] void *cq_base;
    [64] unsigned int cq_size;
    [68] u32 intr_base;
    [72] u32 intr_num;
    [76] struct xocl_ert_sched_privdata ert_cfg_priv;
    [84] bool needs_reset;
    [88] wait_queue_head_t poll_wait_queue;
...
  [4896] unsigned int uid;
  [4900] unsigned int ip_reference[128];
}
SIZE: 5416
crash> 

ばっちりですね、ドライバ内に定義された構造体の情報も見れました。

これで、ドライバに何か不具合があっても、調べたり、直したりできることが分かったので一安心です。

6. 参考サイト

アダプティブコンピューティング研究推進体(ACRi)という団体があって、そのWebサイトが参考 になりました(特にブログ記事)。
https://www.acri.c.titech.ac.jp/wp/

今回使用した、Xilinx Alveo U200が装備されたマシンを無料で借りることもできます。と言っても、 ドライバを置き換えるような乱暴はできませんのでご注意(そもそも、sudoが使えない)。 作成したカーネルの動作確認等には有用かと思います。時間貸しなので、カーネルのビルドまでは、 手元のマシンで行えるようにしておくと良いかと思います。

7. おわりに

カーネルをビルドして動かせるようにはなりました。それだけでは、少し寂しいので、 筆者の守備範囲の話も足しておきました。ともかく、ホスト側については、いろいろと調べられる 環境は整えられたと思います。

次回は、OpenCLの裏(OpenCLよりも低レイヤ)でどんなことが行われているのか、見ていきます。 なかなかカーネル(FPGA)の話に行けませんが、中身(LinuxカーネルないしはLinuxカーネルに近いところで 何やっているか)が気になってしまうのは、カーネル(Linux)屋の性(さが)でしょうね。