はじめに
メリークリスマス!
投稿が遅れてしまいましたが、oneAPIで話題のSYCLのメモリー管理インタフェースのことを調べて書いてみました。
SYCLとは、OpenCLなどと同じくKhronos Groupが制定した標準規格で、様々なベンダー/アーキテクチャーでの移植性に優れたソースコードを目指したプログラミングモデル・インタフェースです。こちらの記事が参考になります。
本稿では、SYCLのメモリー管理インタフェースを調べて書いています。基礎的な内容ですがよろしくお願いします。
SYCLのメモリー管理インタフェース
SYCLではメモリー管理インタフェースとして、「Buffer/Accessor方式」と「Unified Shared Memory方式(USM)」の2種類が提供されています。
Buffer/Accessor方式 [1]
buffer
クラスによりデータをカプセル化した状態でデバイスと共有します。デバイスに投入する関数オブジェクト内でaccessor
を定義することでアクセスできるようになります。また、ホスト側でデバイスの演算結果を取得するには再度host_accessor
を定義します。
queue q;
std::vector<int> v(N, 10);
buffer<int> buf(v.data(), v.size()); // バッファー
q.submit([&] (handler &h){
accessor data(buf, h, read_write); // アクセサー
h.parallel_for(range<1>(N), [=](id<1> i){
data[i] += 1;
});
});
host_accessor hv(buf, read_only); // ホストアクセサー
Unified Shared Memory方式(USM)
SYCL2020からの方式で、ホスト・デバイスからVisibleなメモリーを同一のポインターで記述することができます。
割り当てのタイプは3種類あり、データごとにアクセス範囲を指定します。
allocation type | description |
---|---|
host | デバイスからアクセス可能なホストメモリー |
device | ホストからアクセスできないデバイスメモリー |
shared | ホストとデバイスからアクセス可能な共有メモリー |
実装方法ですが、malloc形式とusm_allocatorクラスの2種類の方法があるため、それぞれの例を掲載します。
- malloc形式 [1]
malloc形式ではAPI(malloc_device
,malloc_host
,malloc_shared
)を利用してメモリーを割り当て、そのポインターを取得します。割り当て解除にはfree()
が必要となります。
queue q;
int* data = malloc_shared<int>(N, q);
for(int i=0; i < N; i++) data[i] = 10;
q.parallel_for(range<1>(N), [=](id<1> i) {
data[i] += 1;
}).wait();
...
free(data, q);
- usm_allocatorクラス [1], [2]
C++のSTLコンテナ向けのアロケータークラスで、以下のようにvectorなどに対してメモリーを割り当てられます。
AllocKind
として指定できるのはhost
とshared
のみで、device
は指定できません。
The AllocKind template parameter can be either usm::alloc::host or usm::alloc::shared, causing the allo
cator to make either host USM allocations or shared USM allocations.
There is no specialization for usm::alloc::device because an Allocator is required to
allocate memory that is accessible on the host.
queue q;
typedef usm_allocator<int, usm::alloc::shared> vec_alloc;
vec_alloc alloc(q);
std::vector<int, vec_alloc> data(N, alloc);
auto d = data.data();
for(int i=0; i < N; i++) d[i] = 10;
q.parallel_for(range<1>(N), [=](id<1> i) {
d[i] += 1;
}).wait();
USMはBuffer/Accessor方式と比べ、accessorが不要であり、それによってhandlerの記述も省略できます。これによってコードを簡素化することが可能です。
一方で、accessorではホストコードについてデータアクセスを行う場合、ホスト側のデータをブロックしてくれますが、mallocやusm_allocatorでは手動でホスト側の処理をブロックする必要があり、queue.wait()
が必要となります。このあたりはSYCL2020仕様書 [3]に記載されています。
また同仕様書では、USMの特徴を以下のように記載しています。USMの最大の利点としてはプログラマーが既存コードのポインターや配列をbufferに書き換える手間がないことであるようです。
- Easier integration into existing code bases by representing allocations as pointers rather than buffers, with full support for pointer arithmetic into allocations.
// ポインター操作が可能となるメモリー割り当て形式とすることで、既存のコードとの統合が容易となる。- Fine-grain control over ownership and accessibility of allocations, to optimally choose between performance and programmer convenience.
// メモリーの所有権とアクセス権を細かく制御することで、パフォーマンスとプログラマーの利便性を適切に選択できる。- A simpler programming model, by automatically migrating some allocations between SYCL devices
and the host.
// デバイスとホスト間のデータ移動の記述が簡潔化され、シンプルなプログラミングモデルとなる。
DPC++-LLVMによるコンパイルと実行
上記の2種類の方法で簡単なプログラムを書いて実行してみます。
ソースコードでは単純な演算をデバイスとホストでそれぞれ計算し結果を確認しています。
実行時間の比較のために差分となる部分にタイマーを入れています。
コンパイラはIntelのDPC++-LLVMを利用しました。DPC++-LLVMはIntelがLLVMベースでオープンソース開発を進めているコンパイラツールチェインであり、Intel以外(Nvidia, AMD等)のデバイスでもDPC++ toolchainを利用できます。
DPC++-LLVMは公式ドキュメント [4]を参考に構築しました。
Buffer/Accessor方式
- ソースコード
1 #include <vector>
2 #include <CL/sycl.hpp>
3 #include <chrono>
4 #include <algorithm>
5 using namespace sycl;
6
7 constexpr int size = 500000000;
8 int main() {
9 queue deviceQueue { gpu_selector_v }; // デバイスセレクターでGPUを指定
10
11 // デバイス情報出力
12 std::cout << "Device: "
13 << deviceQueue.get_device().get_info<info::device::name>()
14 << std::endl;
15
16 // データの生成
17 std::vector<int> a(size), b(size), c(size);
18 std::vector<int> cx(size);
19 for (int i = 0; i < size; ++i) {
20 a[i] = rand() % 100;
21 b[i] = rand() % 100;
22 c[i] = cx[i] = rand() % 100;
23 }
24
25 // タイマー
26 auto start_time = std::chrono::system_clock::now();
27
28 // buffer定義
29 buffer<int, 1> buf_a(a.data(), range<1>(size)),
30 buf_b(b.data(), range<1>(size)),
31 buf_c(c.data(), range<1>(size));
32
33 // デバイスで動作するカーネル部分
34 deviceQueue.submit([&](handler &h) {
35 accessor acc_a(buf_a, h, read_only);
36 accessor acc_b(buf_b, h, read_only);
37 accessor acc_c(buf_c, h, read_write);
38 h.parallel_for(range<1>(size),
39 [=](id<1> idx) {
40 acc_c[idx] += acc_a[idx] * acc_b[idx];
41 });
42 });
43
44 // ホストアクセサー
45 host_accessor hc(buf_c, read_only);
46
47 // タイマー
48 auto end_time =std::chrono::system_clock::now();
49 auto msec = std::chrono::duration_cast<std::chrono::
50 milliseconds>(end_time-start_time).count();
51
52 // 結果確認(ホスト側の演算と比較)
53 for (int i = 0; i < size; ++i) {
54 cx[i] += a[i] * b[i];
55 }
56 bool equal = std::equal(hc.cbegin(), hc.cend(), cx.cbegin());
57
58 // 結果出力
59 if(equal) { std::cout << "result: OK" << std::endl; }
60 else {std::cout << "result: NG" << std::endl; }
61
62 std::cout << "Time[ms] = " << msec << std::endl;
63 return 0;
64 }
- コンパイル
$ clang++ -std=c++17 -O3 -fsycl \
-fsycl-targets=nvptx64-nvidia-cuda --cuda-path=/usr/local/cuda-11.8 \
sample.cpp -o a.out
- 実行結果
Device: NVIDIA GeForce RTX 2080 Ti
result: OK
Time[ms] = 876
Unified Shared Memory方式
usm_allocatorを使います。
- ソースコード
1 #include <vector>
2 #include <CL/sycl.hpp>
3 #include <chrono>
4 #include <algorithm>
5 using namespace sycl;
6
7 constexpr int size = 500000000;
8 int main() {
9 queue deviceQueue { gpu_selector_v }; // デバイスセレクターでGPUを指定
10
11 // デバイス情報出力
12 std::cout << "Device: "
13 << deviceQueue.get_device().get_info<info::device::name>()
14 << std::endl;
15
16 // USM allocatorでsharedメモリーのvectorを定義
17 typedef usm_allocator<int, usm::alloc::shared> vec_alloc;
18 vec_alloc alloc(deviceQueue);
19 std::vector<int, vec_alloc>
20 a(size, alloc),
21 b(size, alloc),
22 c(size, alloc);
23
24 // kernelでアクセスするためのvector.dataへのポインターを取得
25 auto A = a.data();
26 auto B = b.data();
27 auto C = c.data();
28
29 // 結果確認用のデータ
30 std::vector<int> cx(size);
31
32 for (int i = 0; i < size; ++i) {
33 a[i] = rand() % 100;
34 b[i] = rand() % 100;
35 c[i] = cx[i] = rand() % 100;
36 }
37
38 // タイマー
39 auto start_time = std::chrono::system_clock::now();
40
41 // デバイスで動作するカーネル部分
42 deviceQueue.parallel_for(range<1>(size),
43 [=](id<1> idx) {
44 C[idx] += A[idx] * B[idx];
45 }).wait();
46
47 // タイマー
48 auto end_time =std::chrono::system_clock::now();
49 auto msec = std::chrono::duration_cast<std::chrono::
50 milliseconds>(end_time-start_time).count();
51
52 // 結果確認(ホスト側の演算と比較)
53 for (int i = 0; i < size; ++i) {
54 cx[i] += a[i] * b[i];
55 }
56 bool equal = std::equal(c.cbegin(), c.cend(), cx.cbegin());
57
58 // 結果出力
59 if(equal) { std::cout << "result: OK" << std::endl; }
60 else {std::cout << "result: NG" << std::endl; }
61
62 std::cout << "Time[ms] = " << msec << std::endl;
63 return 0;
64 }
- 実行結果
Device: NVIDIA GeForce RTX 2080 Ti
result: OK
Time[ms] = 930
実行時間としてはBuffer/Accessor方式のほうが若干早い気がしました。
内部動作がわからないので確証はないですが 。プロファイラなどで調べてみて、何か分かれば記事にしたいと思います。
まとめ
SYCLのメモリ管理インタフェースについて調べました。
今回分かったことは
- 記述方法としてBuffer/Accessor方式とUnified Shared Memory方式がある。
- Unified Shared Memory方式はmemcpy系APIとusm_allocatorクラスを利用する方法がある。どちらもAccessorは不要であり、ポインター演算をそのまま演算カーネルに記述できるため移植性に優れる。
- 実験したソースコードの場合、Buffer/Accessor方式はusm_allocatorクラスを利用する場合よりも幾分か高速であった。
HPCを含むヘテロジニアス環境ではSYCLが便利で注目されているため、基礎的な内容は押さえておきたいと思います。
動作環境
CPU: Intel Core(TM) i7-9700 CPU
GPU: NVIDIA GeForce RTX 2080 Ti (11GB GDDR6)
MEM: DDR4-2666
Docker image: nvidia/cuda:11.8.0-cudnn8-devel-ubuntu22.04
Intel/llvm: sycl-nightly/20221223
Docker: v20.10.22
Docker-compose: v1.29.2
Nvidia Driver: 525.60.13
※CUDA11.8はSYCL20221223ではpartially supportedとのこと。
参考・引用
[1] SYCL Reference documentation
[2] DPCPP Reference documentation
[3] SYCL 2020 Specification revision 6
[4] Getting Started with oneAPI DPC++
[5] codingame Buffers and Accessors