この記事は、『The Parallel Universe Magazine 45 号』に掲載されている「Reduction Operations in Data Parallel C++」の日本語参考訳です。
前号の記事「データ並列 C++ のリダクション操作」では、総和演算子を使用して 1,000 万要素の配列を単一の値にレデュースするカーネル数を調査しました。この記事では、マルチブロック・インターリーブ・リダクションと呼ばれる別のリダクション手法を紹介します。第 9 世代と第 12 世代のインテル GPU でインテル® VTune™ プロファイラーを使用してすべてのリダクション操作を比較し、カーネル間のパフォーマンスの違いの理由を説明します。
マルチブロック・インターリーブ・リダクション
データ並列 C++ (DPC++) では、ショートベクトルをロード/ストアや算術演算子の操作が定義された基本データ型として定義しています。これらのショートベクトルを使用して別のレベルのブロックを追加し、サポートするアーキテクチャー向けに非常に長いベクトル演算をコンパイラーに生成させることができます。vec<int, 8> データ型は、8 つの整数からなるベクトルです。このデータ型を使用して、図 1 に示すリダクション操作を実装します。図に示すように、アクセスパターンはベクトルサイズが 2、サブグループ・サイズが 4 で、ワーク項目ごとに入力ベクトルの 4 要素を処理します。

図 1. 要素のベクトルを読み込み、その要素に対してベクトル・リダクション操作を行い、最終結果のベクトルをレデュース
以下のコードは、ベクトルサイズ 8、サブグループ・サイズ 16 で、ワーク項目ごとに入力ベクトルの 256 要素を処理する、上記のメモリー・アクセス・パターンのリダクション操作を実装します。
void multiBlockInterleavedReduction(sycl::queue &q,
sycl::buffer<int> inbuf,
int &res) {
const size_t data_size = inbuf.get_size()/sizeof(int);
int work_group_size =
q.get_device().get_info<sycl::info::device::max_work_group_size>();
int elements_per_work_item = 256;
int num_work_items = data_size / elements_per_work_item;
int num_work_groups = num_work_items / work_group_size;
sycl::buffer<int> sum_buf(&res, 1);
q.submit([&](auto &h) {
const sycl::accessor buf_acc(inbuf, h);
sycl::accessor sum_acc(sum_buf, h, sycl::write_only, sycl::noinit);
sycl::accessor<sycl::vec<int, 8>, 1, sycl::access::mode::read_write,
sycl::access::target::local>
scratch(work_group_size, h);
h.parallel_for(sycl::nd_range<1>{num_work_items, work_group_size},
[=](sycl::nd_item<1> item)
[[intel::reqd_sub_group_size(16)]] {
size_t glob_id = item.get_global_id(0);
size_t group_id = item.get_group(0);
size_t loc_id = item.get_local_id(0);
sycl::ONEAPI::sub_group sg = item.get_sub_group();
size_t sg_size = sg.get_local_range()[0];
size_t sg_id = sg.get_group_id()[0];
sycl::vec<int, 8> sum{0, 0, 0, 0, 0, 0, 0, 0};
using global_ptr =
sycl::multi_ptr<int,sycl::access::address_space::global_space>;
int base = (group_id * work_group_size + sg_id * sg_size)
* elements_per_work_item;
for (size_t i = 0; i < elements_per_work_item / 8; i++)
sum += sg.load<8>(global_ptr(&buf_acc[base + i * 8 * sg_size]));
scratch[loc_id] = sum;
for (int i = work_group_size / 2; i > 0; i >>= 1) {
item.barrier(sycl::access::fence_space::local_space);
if (loc_id < i)
scratch[loc_id] += scratch[loc_id + i];
}
if (loc_id == 0) {
int sum=0;
for (int i = 0; i < 8; i++)
sum += scratch[0][i];
auto v = sycl::ONEAPI::atomic_ref<int,
sycl::ONEAPI::memory_order::relaxed,
sycl::ONEAPI::memory_scope::device,
sycl::access::address_space::global_space>(
sum_acc[0]);
v.fetch_add(sum);
}
});
});
}
このカーネルは、明示的にアドレスを計算する代わりに、ベクトルロード操作を使用してエンコードすることもできます。また、各ワーク項目がロードしたベクトルを最初にローカルでレデュースするように変更することもできます(この実装のアクセスパターンを図 2 に示します)。

図 2. 要素のベクトルを読み込み、ベクトルを単一の結果にレデュースしてからリダクションを行う
void multiBlockInterleavedReductionVector(sycl::queue &q,
sycl::buffer<int> inbuf,
int &res) {
const size_t data_size = inbuf.get_size()/sizeof(int);
int work_group_size =
q.get_device().get_info<sycl::info::device::max_work_group_size>();
int elements_per_work_item = 256;
int num_work_items = data_size / 4;
int num_work_groups = num_work_items / work_group_size;
sycl::buffer<int> sum_buf(&res, 1);
q.submit([&](auto &h) {
const sycl::accessor buf_acc(inbuf, h);
sycl::accessor sum_acc(sum_buf, h, sycl::write_only, sycl::noinit);
sycl::accessor<int, 1, sycl::access::mode::read_write,
sycl::access::target::local>
scratch(1, h);
h.parallel_for(sycl::nd_range<1>{num_work_items, work_group_size},
[=](sycl::nd_item<1> item)
[[intel::reqd_sub_group_size(16)]] {
size_t glob_id = item.get_global_id(0);
size_t group_id = item.get_group(0);
size_t loc_id = item.get_local_id(0);
if (loc_id==0)
scratch[0]=0;
sycl::vec<int, 4> val;
val.load(glob_id,buf_acc);
int sum=val[0]+val[1]+val[2]+val[3];
item.barrier(sycl::access::fence_space::local_space);
auto vl = sycl::ONEAPI::atomic_ref<int,
sycl::ONEAPI::memory_order::relaxed,
sycl::ONEAPI::memory_scope::work_group,
sycl::access::address_space::local_space>(
scratch[0]);
vl.fetch_add(sum);
item.barrier(sycl::access::fence_space::local_space);
if (loc_id==0) {
auto v = sycl::ONEAPI::atomic_ref<int,
sycl::ONEAPI::memory_order::relaxed,
sycl::ONEAPI::memory_scope::device,
sycl::access::address_space::global_space>(
sum_acc[0]);
v.fetch_add(scratch[0]);
}
});
});
}
リダクション・カーネルのパフォーマンス解析
これらのカーネルのパフォーマンスを評価するため、2 つの異なるインテル GPU で実行します。
- インテル® HD グラフィックス 630(第 9 世代統合グラフィックス、略称 Gen9)。この GPU には 24 の実行ユニット(EU)が搭載されており、それぞれ 7 スレッドを利用できます。
- インテル® Iris® Xe グラフィックス(第 12 世代統合グラフィックス、略称 Gen12)。この GPU には 96 の実行ユニット(EU)が搭載されており、それぞれ 7 スレッドを利用できます。
インテル® VTune™ プロファイラーを使用してカーネルのパフォーマンスを解析します。また、カーネルの実行時間を長くして適切なプロファイル・データを収集できるように、大規模なリダクション(1,000 万要素の代わりに、5.12 億要素)を実行します。各カーネルのパフォーマンスを表 1 に示します。これは、各カーネルを 16 回実行して、平均パフォーマンスを記録したものです。データの収集には、インテル® oneAPI ベース・ツールキット(v2021.2.0)を使用しました。
製品とパフォーマンス情報
1実際の性能は利用法、構成、その他の要因によって異なります。詳細については、www.Intel.com/PerformanceIndex (英語) を参照してください。

