モノトーンの伝説日記

Apex Legends, Splatoon, Programming, and so on...

C++ (fork) Advent Calendar 2013 24 日目 ~C++ AMP 入門~

 この記事は C++ (fork) Advent Calendar 2013 24 日目の記事です。


 こんにちは。今日は、C++ AMP についてお話していきたいと思います。

概要

  1. C++ AMP とは何なのか
  2. C++ AMP を始めるにあたって
  3. C++ AMP でマトリックスの加算を書いてみよう!
  4. まとめ

1. C++ AMP とは何なのか

 C++ AMP の “AMP” とは、Accelerated Massive Parallelism ということです。C++ を使ってヘテロ*1な開発環境を準備し、それにより生産性や移植性を上げるということです。AMD さんの協力により開発していったというのもあると思います。Channel 9 のほうにありますしね。現状、Visual Studio の一部なので、逆にいえば Linux などでは使えません。Visual Studio では GPU を使った強力なデバッグ環境もサポートされており、デバッグするのも容易かと思います。

 C++ AMP の一番の売りは、C++er にとって STL-like な記述方法で CPU または GPU による並列化アルゴリズムを記述できる点です。従来であれば、SIMD (Single-Instruction, Multiple-Data) と CUDA や HLSL、OpenGL など CPU と GPU 両方対応するには同じ意味のコードを 2 つ用意しなければなりません。ところが、C++ AMP は GPU が使えない場合、自動で SIMD レベルでの実行に落としてくれるというメリットもあります。これは一見デメリットに見えて、メリットもあります。

 まず、今は SSE から AVX への移行の過渡期です。SSE と AVX は混ぜて使わないように、と言われています。それは両命令の違いから、コンテクスト切り替えの必要としたハードウエア実装をしているため、オーバーヘッドが生じるためです。しかし、AVX を利用するプログラミングを、1 C++ 開発者は作るでしょうか? いいえ、作りません。ましてや、SSE ですら考慮したコードを書かない人も多いでしょう。ところが、C++ AMP を利用するだけで、GPU が使える、あるいは GPU が無理ならこのような CPU 命令レベルに落としてくれるというのは、非常に重たい処理であれば十分有効な方法でしょう。

 私も初めてなので、間違っていることもあるかもしれませんが、その場合は指摘していただければ幸いです。

2. C++ AMP を始めるにあたって

 まずはデータ構造のイメージをつかみましょう。

 たとえば、std::vector<T> で 2 次元配列 (i, j) を実現するとき (using V = std::vector)、

  1. V<T>::size を i * j
  2. V<V<T>> で V<T>::size を j、V<V<T>>::size を i

の 2 種類が思いつくと思います。どちらにせよ、V<T> というデータは以下のようにつながっているイメージが重要です。

f:id:mntone:20131224132829p:plain

 今回、これらと相互運用する中に重要なものがあります。C++14 の string_view をイメージしてもらえばいいと思いますが、concurrency::array_view<T, R> というクラスがあります。これを用いると、たとえば、上のような 2 次元配列の状態を保持しデータを加工することができます (これは C++ AMP との相互運用上で重要なクラスです)。

 つまり、通常 V<T> は「2 次元配列 (rank = 2)」という意味を持っていませんでしたが、この array_view<T, R> は「2 次元配列」という意味を保持し、体系的に処理を行うことができるようになっています。以下のようなグループ分けが行われた、というイメージです。

f:id:mntone:20131224132820p:plain

 これらをコードで表してみると以下のような感じになります。

const int i = 4, j = 2;
std::vector<double> v( i * j );
const concurrency::array_view<const double, 2> a( i, j, v );

 簡単ですね! これで i, j マトリックス (2 次元配列)という意味になります。もちろん、3 次元も同様にできます。

 「何? こんなの何の意味があるんだって? 簡単すぎやしないか?」

  その通り。これは前段階の説明です。ほかに若干説明しておきます。

concurrency::index<R>

 ランク R での index を表す。array_view[index<R>] で簡単にアクセスできるようになっています。なお、今回は明示的に使いません。array_view::extent (property: get=get_extent) にて取得できます。

3. C++ AMP でマトリックスの加算を書いてみよう!

 行列の加算なんて簡単ですよね? こんなところで書きませんよ…

 定義 Z = X + Y なマトリックス演算を行う。V<T> の T は double を用いる。

3.1. CPU コード

 そのままの CPU コードです。これならだれもが書いたことあるはず…

std::vector<double> calc_cpu( const std::vector<double>& v1, const std::vector<double>& v2 )
{
    std::vector<double> ret( matrix );

    auto i = v1.cbegin(), j = v2.cbegin();
    auto k = ret.begin();
    while( k != ret.end() )
    {
        *k = *i + *j;
        ++i; ++j; ++k;
    }

    return ret;
}

 行列演算が何行何列になろうとこれで問題なく行えます。次は C++ AMP 版です。

3.2. AMP コード

std::vector<double> calc_amp( const std::vector<double>& v1, const std::vector<double>& v2 )
{
    using namespace concurrency;

    std::vector<double> ret( matrix );

    const array_view<const double, 2> i( matrix_w, matrix_h, v1 ), j( matrix_w, matrix_h, v2 );
    array_view<double, 2> k( matrix_w, matrix_h, ret );

    k.discard_data();
    parallel_for_each( k.extent, [=]( const index<2>& idx ) restrict( amp )
    {
        k[idx] = i[idx] + j[idx];
    } );
    k.synchronize();

    return ret;
}

 こんな感じです。魔法の呪文の「restrict( amp )」もお忘れなく。

 const が付いていないものはデータの変更があるため、データの同期を行ったりということを考えなければなりません。そこで一度、discard_data() でデータの同期を止め、そして synchronize() でデータを同期するという指示を与えています。

4. まとめ

 今日では、Windows ストア アプリでは ARM を使ったデバイスが登場しています。イントリンシック命令やアセンブリ、あるいは HLSL などを使って実装することもできますが、C++ 開発者にとってそれは敷居の高いところになるでしょう。そんなとき、NEON*2 や SSE/AVX、あるいは GPU などを自動的に駆使してくれる C++ AMP はとても頼りになるのではないのでしょうか? まだまだ使われている印象はありませんが、もし興味を持ったならいじってみるのもいいかと思います。

 次回は、もう少し高度なネタでも書こうかな? とか思ってたりしますが、どうなるかはわかりません。 おしまい

*1:ギリシア語で「異なる」という意味。ここでは CPU と GPU という異種なハードで同じアルゴリズムを使って生産性を上げるという意味

*2:ARM の Advanced SIMD