OpenCL:汎用性と高いパフォーマンス、またはそれほど単純ではありませんか?

OpenCL、CUDA、GPGPUのHabrには、パフォーマンスの比較、基本的な概念、例に関する記事が既にありました。そのため、ここでは作業の基本と原則については説明しません。コードも表示しません。 しかし、GPUを使用する際の本当の難しさ(制限とその結果について)、CPUとGPUのパフォーマンスを比較できない理由、そしてOpenCLの「普遍性」について説明したいと思います。



まえがき



GPGPUとの私の知り合いは1.5年前に始まり、研究プロジェクトの積極的な開発という形で今日まで続いています。 その後、OpenCLまたはCUDAを選択しましたが、その時点での選択に大きな違いはありませんでしたが、大学ではOpenCLに関するコースを読み始めたので、それを選択しました。 NVidiaのアーキテクチャを持つカードについてのみ書いたとすぐに言わなければならないので、それについて話します(ほとんどの場合、Fermiについて)。



この時点で、GPUの計算分野の歴史と状況に関する大きなパラグラフがありましたが、問題を説明した後、投稿が長すぎて、パラグラフが大幅にカットされました(次のパートで戻ることが期待されます)。 したがって、GPUに移植されたアルゴリズムが常に高速に動作しない理由、つまり、 実際には、CPUに比べて20X-100Xの約束された20X-100Xの代わりに、0.5X-10Xのパフォーマンスの向上を与えます(そうでない場合、各アプリケーションはそれを使用します)。



どれくらいゆっくりですか?



したがって、GPUのアーキテクチャがCPUとはかなり異なることは誰もが知っていますが、この違いがどれほど大きく、GPUのアルゴリズムの開発にどの程度影響するかについてはほとんど考えていません。 人間は、かなり並列なシステムですが、アルゴリズムを順番に考えるのに慣れています。 過去28年間、プロセッサはこれに夢中になっており、私たちは皆、あるコマンドが次々と実行されることに慣れています。 また、プログラムで使用できるリソースは事実上無制限であり(マイクロコントローラーについては考えていません)、データはほとんどすぐに取得できるという事実に慣れています。 ほぼすべてのプログラミングおよび最適化手法はこれに基づいています。 しかし、これはGPUでは機能しません。私たちの習慣の結果を説明したいと思います。



最初の制限: 32スレッド(ワープ)は常に1つのコマンドを実行します


このコマンドの前のどこかにブランチがあり、スレッドが異なる方法で進んだ場合、GPUは両方のブランチを順番に実行します。

したがって、特定のケースの計算を単純化しようとすると(問題に対する一般的で短い解決策がわかっている場合)、より高速な計算(常にCPUで発生します)ではなく、一般的および特殊なケースの計算時間が追加されます。

別の例:各コアは、データの種類に応じて異なるアルゴリズムを選択します。たとえば、ポイントから幾何学的図形までの距離を計算する必要があり、各コアは異なる形状、したがって異なるアルゴリズムを受け取ります。 その結果、合計時間は各オブジェクトのアルゴリズムの実行時間の合計になります。

そして、GPUの逐次計算が数十倍遅くなる場合にのみ、すべてをCPUとまったく同じであるとみなします(そして多くのネストされたブランチでは、CPUよりもGPUではるかに多くカウントします)。 プログラム内のifの数に注意してください。ただし、32個のスレッドすべてが同じパスに進む場合、すべては問題ありませんが、これはすべてのブランチで頻繁に発生しますか?



2番目の制限: 各メモリアクセスでは、1バイトしか必要ない場合でも、128バイトが常に順番に読み取られます


また、別のスレッドは、一度にこれらの128バイトのうち16バイトしかアクセスできません。

その結果、メモリ帯域幅は150GB / sを超えますが、128バイトすべてが常に使用されるという条件でのみです。 各スレッドが1つの大きな構造(40バイトの重量)を読み取る必要がある場合、各スレッドは3つのメモリ要求を行い、3 * 128バイトをダウンロードする必要があります。 また、各ストリームのデータが異なる場所にある場合(そしてストリームがそれらへのポインターを受け取ってロードする場合、メモリが合理的に消費される場合のCPUの通常の状況)、有効なメモリ帯域幅は40 * 32 /(128 * 3 * 32) 、つまり、実際の約10%です。

繰り返しになりますが、CPUの使用可能なメモリ帯域幅に近づいています。 キャッシュがあることは確かに覚えていますが、キャッシュはFermiでのみ表示され、それほど大きくはありませんが、かなり役立ちます。 一方、GPUの最初のバージョンでは、128バイトを順番に読み込む場合でも、順番に読み込まれず、かつ/または少なくとも1バイトオフセットされている場合、スレッドごとに個別のメモリリクエストが行われます。



3番目の制限: メモリレイテンシはリクエストごとに約800サイクルです


そして最後の例では、すべてのプロセスでデータを取得するには、3 * 32のクエリを作成する必要があります...約8万サイクル...この時点で何をすべきですか? 他のスレッドを実行すると、新しい制限が表示されます。



4番目の制限: 32kレジスタは、マルチプロセッサのすべてのアクティブなスレッドに割り当てられます


最初は多数あるように見えますが、実行中のスレッドだけでなく、すべてのアクティブなスレッドに割り当てられます(さらに、最悪のブランチで必要なだけ静的に最大に割り当てられるため、多くのスレッドが割り当てられます)。 また、メモリのレイテンシを隠すために1536個のアクティブなスレッドが必要です(前の例から80,000サイクルを簡単に隠すことができるかどうかを数えてみてください)。つまり、スレッドごとに21個のレジスタがあります。 複雑なアルゴリズムを実装し、21個のレジスタ内に保持するようにしてください(これらは変数だけでなく、演算の中間結果、サイクルカウントなどでもあります)。 一方、1.5未満のアクティブスレッドを使用しようとすると、次の制限が表示されます。



5番目の制限: Fermiスレッドスケジューラーは、512個のグループでスレッドを開始することしかできません(Fermiより前の方が簡単で、約128)


つまり、使用できるオプションは3つのみです。それぞれが21未満のレジスタを使用する場合は1536スレッド、32未満のレジスタまたは512スレッドを使用する場合は1024スレッド、いずれにせよそれ以下です。 さらに、スレッドの数が少ないということは、メモリのレイテンシとマルチプロセッサ全体のダウンタイムを何千サイクルも隠そうとするという深刻な問題を意味します。

そして、これはCPUよりもはるかに悪いです。 また、各スレッドが64個を超えるレジスタを使用すると、最悪の事態が発生します。



6番目の制限: ストリームが64個を超えるレジスタを使用する場合、それらはグローバルメモリに格納されます


私はまだローカルメモリではなくグローバルメモリでそれを信じることができませんが、ドキュメントにはそう書かれています。 つまり、追加のメモリ要求が表示されます。 ところで、スタックは関数の呼び出しに使用されますが、これもレジスタを使用します(はい、はい、関数は不良です)。



レジスタの使用とロードの最適化に対抗するために、共有メモリがまだあります(共有メモリ、ロシア語で正しくする方法を覚えていません)。 ただし、16 / 48Kbのみであり、すべてのアクティブなグループに分割されます。つまり、各グループが25kbのメモリを消費すると、複数のグループを起動できず、その後の結果がすべて発生します。



7番目の制限: 各コア起動にはわずかな遅延が伴います


実際、ここではすべてがそれほど怖いわけではありません。この遅延は数十マイクロ秒で測定されます。 しかし、カーネルを1000回実行すると、これはすでに数十ミリ秒になります。これは、リアルタイム計算(レンダリングなど)の場合、計算自体の時間を考慮しなくても、すぐに15FPSの制限を作成します。



結論があったはずですが、次回はそうなるでしょう


私が解散するとすぐに、リストはすでに長すぎました。 ただし、同期、アトミック操作、デバイスへのデータのコピー、各カードのロードバランシング(ここではSLIは機能しません)、精度、特殊機能、ドライバーカーブ、デバッグなどについて覚えておく必要があります。 そして、OpenCLの真の汎用性について多くのことを言わなければなりません。 まあ、次の部分に置いておきます。



しかし、一般的に、開発者は多くの(すべてではない)制限について(多くの苦痛を経験した後)確かに知っており、それらを回避するようにコードを最適化しようとしますが、振り返らずに開発されたアルゴリズムを作り直すのにどれくらい時間がかかるか想像してくださいGPU上で、すべてのアルゴリズムを原則的にやり直すことができるわけではありません。 しかし、私はゆっくりと「Zenを理解」し、すべてがそれほど悪いわけではなく、約束されたテラフロップスを手に入れることができることを理解しています。また、OpenCLについてのストーリーの以下の部分でこれについて書くことも約束します。



All Articles