Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

現代のGPUの実行スタイルとレイトレ (2023)

shocker_0x15
September 03, 2023

現代のGPUの実行スタイルとレイトレ (2023)

レイトレ合宿9 (https://sites.google.com/view/rtcamp9/home) のセミナーで使用した資料です。
現代のGPUにおけるプログラムの実行スタイルやレイテンシー隠蔽戦略の概要、そしてレイトレーシングとの関係について紹介しています。

https://bsky.app/profile/bsky.rayspace.xyz
https://twitter.com/Shocker_0x15

shocker_0x15

September 03, 2023
Tweet

More Decks by shocker_0x15

Other Decks in Programming

Transcript

  1. Wave/Warp 現代(といっても15年以上前から)のGPUは複数のスレッドがまとまった単位で命令を実行する Wavefront: AMD (64 or 32 threads), DirectX用語 (~128

    threads) Warp: NVIDIA用語 (32 threads) Wave中のスレッドのことをレーン (Lane)と呼ぶ Wave全体では一つのプログラム、同じ命令列を実行、各スレッドは異なるデータを読み書き SIMD: Single Instruction Multiple Data (同じく”SIMD”であるSSE/AVXと概念が少し異なる気もするが…) + SIMDを意識しなくて良いプログラミングモデルをSIMT (T:Threads)と呼んだりもする 異なるwaveは基本的には非同期に実行される void add(uint32_t threadIdx, const uint32_t* srcValuesA, const uint32_t* srcValuesB, uint32_t* dstValues) { dstValues[threadIdx] = srcValuesA[threadIdx] + srcValuesB[threadIdx]; } 0 31 lane wave 0 0 31 lane wave 1 0 31 lane wave 2 0 31 lane wave * thread 0 32 64 SIMD Unit ALUを並列に備えたユニットがWaveを処理 “SIMD Unit”と呼ぶことにする GPUはSIMD Unitを多数搭載 1~数サイクルで1つのwaveの1命令を処理
  2. Execution Divergence A; if (threadId < 16) { B; }

    else { C; } D; A threadId < 16 B C D 0 lane 1 15 16 30 31 実行ダイバージェンス wave 無効なスレッドには実行マスク(Execution Mask/Predication Mask)がかけられ命令を無視 異なるwave間ではダイバージェンスは発生しない 全スレッド無効なブロックはスキップ スレッドごとに異なる分岐を通る場合 Wave全体ではすべてのコードブロックを実行 実行ダイバージェンス (Execution Divergence)と呼ぶ ※ NVIDIA GPUはVolta以降スレッドごとにプログラムカウンターを持つことが可能(Independent Thread Scheduling)で、 異なる分岐に入れるが、命令発行は依然Wave全体で共通
  3. Scalar, Vector void kernel( uint32_t threadIdx, const uint32_t* srcBuffer, uint32_t

    numPasses uint32_t* dstBuffer) { uint32_t value = srcBuffer[threadIdx]; for (int pass = 0; pass < numPasses; ++pass) { value = process(value); // processはvalueに応じて異なる値を返す関数 } dstBuffer[threadIdx] = value; } Wave中でスレッドに依存して変わる値をベクター値、依存しない値をスカラー値と呼ぶ※ スカラー値による分岐は実行ダイバージェンスを発生させない スカラー分岐もコストゼロというわけではなく命令キャッシュまわりで多少ペナルティ △ GPUは分岐に弱い ◯ GPUはベクター分岐には弱いが、スカラー分岐のペナルティは比較的マシ (実行ダイバージェンス観点では。後述のレジスター消費観点では要注意。) 適当なGPUカーネル(シェーダー)例 ※コード中の変数とスカラーorベクターは一対一対応ではない 同じ変数でもコード次第で途中で変わりうる
  4. Data Divergence SIMD/SIMTではスレッドごとに異なるデータを読む 近傍のスレッド(特に同じwave内など)がメモリ上で遠くのデータにアクセスすると遅くなる  キャッシュミス頻発、Coalescedアクセスできない (後述) データダイバージェンス (Data Divergence)と呼ぶ

    0 31 lane wave void scatter(uint32_t threadIdx, const uint32_t* srcValues, const uint32_t* dstIndices, uint32_t* dstValues) { uint32_t value = srcValues[threadIdx]; dstValues[dstIndices[threadIdx]] = value; } Memory void gather(uint32_t threadIdx, const uint32_t* srcValues, const uint32_t* srcIndices, uint32_t* dstValues) { uint32_t value = srcValues[srcIndices[threadIdx]]; dstValues[threadIdx] = value; }
  5. ローカル共有メモリ いくつかのSIMDユニット間共有の比較的高速なメモリ NVIDIA: Shared Memory AMD: LDS (Local Data Share)

    HLSL: Group Shared Memory CUDA/GLSL: Shared Memory OpenCL: Local Memory SIMD Unit LDS 0 31 lane wave 0 0 31 lane wave 1 0 31 lane wave 2 0 31 lane wave 3 同期命令 同じプログラムを実行していても異なるWave間は普段は非同期に動作するが、 別のWaveと読み書きの同期を行う命令がある HW/SWごとに呼称が色々… 例: LDSに書き込み、読み出す前にwave間で同期
  6. 三項演算子 SomeType v if (someCondition) v = valueA; else v

    = valueB; 意味的には両者はほぼ同一 実際のアセンブリコードを見てみないとわからない 三項演算子とif文どっちが良い? vs SomeType v = someCondition ? valueA : valueB; 生成されるアセンブリには主に2つの可能性 • 2つのコードブロックとプログラムカウンターのジャンプを伴う分岐コード • マスクの値に応じて2つのレジスター間から値を選ぶ命令 (プログラムカウンターのジャンプを伴わない) コンパイラーが型やコードサイズ、スカラー・ベクター、後述のレジスター使用量など 様々な要因を考慮に入れて選ぶ 現実的・現代ではよっぽどナイーブなコンパイラーでない限り気にしなくて良い LDS使用や同期命令を使う場合は多少考慮が必要かもしれない
  7. メモリ階層 SIMD Unit L1$ / LDS L2$ VRAM 典型的なGPU GPUもCPU同様多段のメモリ階層からなる

    SIMD Unit内のシェーダーコアから近い順でレイテンシーの例 • シェーダーコア中の汎用レジスター 基本的にレイテンシー無し • L1キャッシュやLDS ~数十サイクル • L2キャッシュ 数十サイクル~百数十サイクル • メモリ 数百サイクル
  8. FLOPS ? 2023年時点でのGPUの理論性能は数十TFLOPS (FP32) 例:RTX 4090 (2022年10月): 82.58 TFLOPS どうやって計算?

    16384 [cores] x 2520 [MHz] x 2 [ops/cycle/core] = 82.58 [TFLOPS] 1 “SIMD Unit”あたり32 coresとかのイメージ 2 [ops/cycle/core]? MAD (Multiply-ADd)という命令、積和演算 asmイメージ: mad dst src0 src1 src2 // dst = src0 * src1 + src2 1命令で2個の浮動小数点演算 これを毎サイクルやれば理論値 レジスター以外のアクセスで数十~数百サイクルかかるし理論値は到底無理では?? まぁ正しい、でもそれなりに頑張る方法がある
  9. 複数Wave、Occupancy CPUに比べて大量のスレッドを同時に扱う(数千~数万): スレッドあたりのキャッシュ容量はとても小さい 基本的なALU命令は1~数サイクルで終了、一方メモリアクセスは数十~数百サイクル wave 0 wave 1 wave 2

    wave 3 1つのSIMDユニットあたり複数のWaveを管理、 命令発行対象のWaveを切り替えながら可能な限り毎サイクル命令実行 ユニット内に同時に存在するWaveの数や割合をOccupancyと呼ぶ 即時に切り替えるためには常に多くのWaveを保持している必要がある → Waveの情報を記憶しておく必要があるので無制限には保持できない あと、仕事(総スレッド数)がそもそも少なければ切り替える先のWaveが無くなる → GPUがレイテンシーを隠すためには大量の仕事が必要!
  10. SIMDユニットの共有リソース Wave保持のために必要な情報 • 汎用レジスター (各命令が読み書きする, GPR: General Purpose Register) •

    プログラムカウンター • その他細かい情報色々 汎用レジスターはSIMDユニットで共有のリソース 1 waveあたりが必要とするレジスター数でOccupancyが制限される 例:SIMDユニットあたり汎用レジスター数が256 Dwords (x waveの幅)を仮定 ※ a. 1 waveあたり16 DwordsのときOccupancy: 256 / 16 = 16 b. 1 waveあたり40 DwordsのときOccupancy: 256 / 40 = 6 c. 1 waveあたり160 DwordsのときOccupancy: 256 / 160 = 1 複雑なプログラムほど必要なレジスター数は大きくなる傾向 超巨大なGPUカーネルでレジスターに収まらない場合レジスタースピリング(Spilling)が発生 → メモリなどにレジスターの内容を書き出すことになり基本的にだいぶ遅い LDS使用量もOccupancyを制限する ※Apple M3 GPU (2023年10月)などはカーネル内で動的なレジスター確保を実装しているっぽい?ので少し話が複雑になる
  11. レジスター使用量を抑える 一般的なもの • Live-stateを減らす • 例: カーネル中で長く保持する複数のinteger、それぞれ32-bit必要? • 配列型を可能なら避ける •

    動的なインデックスでアクセスする配列はレジスター確保の自由度を下げ、使用量を大きくしがち • ループ展開抑制 (カーネル内のコンパイラーヒントを使う) • カーネルを分ける • 複数の機能を持ったカーネルを分岐で使い分けている場合(ダイバージェンスが無くとも問題)や 何段階かに分けられる処理をまとめている場合 レジスター使用量は最も多いブロックにあわせられる → 機能ごとや処理段階ごとに異なるカーネルを分けてディスパッチ ただし何かしらのIndirectionや追加のVRAM読み書きが必要になってトレードオフはある • etc (ハードウェア特有のもの含む) 基本的にはOccupancy向上のために頑張ることが多いが、 Occupancyは高ければ高いほど常に良いわけではない(処理内容にもよる) あまり高すぎると… waveごとに色んなところにアクセス → キャッシュにデータが載ったり吐き出されたり(キャッシュスラッシング (cache thrashing))
  12. ボトルネックと対策 GPUカーネル(シェーダー)の律速要因、つまりボトルネックとしては2つに大別される • メモリバウンド (メモリネック) • データ圧縮が効く (圧縮・展開のためのALUコストは隠される) • メモリから大きな計算済みデータを読むくらいなら再度計算してみる

    (同上) • もちろん再計算のために読む量が変わらないなら意味無し • データのメモリレイアウトを考える(例: AoS vs SoA) • 特に何段かのカーネルで構成されている処理の場合、 前後にレイアウト変更を行うカーネルを追加しても全体としては速くなることもある • Occupancy改善が効く • ALUバウンド (ALUネック、算術ネックなどとも言ったり) • アルゴリズムの改善 • スレッド間で分散できる計算があるならLDSやレーン間命令を使って処理分散 • 事前計算できる部分はテーブルを用意する (メモリレイテンシーは隠される) • その他 • 連続するGPUカーネル間に依存性が無いのにバリアを張っていないか? • バリアを張るとそれまでのカーネルが終わるのを待つので末端で機会損失 (末端はGPUを埋めるWaveが疎になる・バリア自体オーバーヘッドが少しある) • etc
  13. キャッシュライン、コアレッシング キャッシュはある一定の単位(キャッシュライン, 64Bや128B)でメモリのデータを載せる 複数のスレッドのアクセスが1つや少量のキャッシュラインに載ってると嬉しい struct AoS { uint32_t a; float

    b; int32_t c; ... }; void kernel(uint32_t threadIdx, const AoS* srcBuffer, uint32_t* dstBuffer) { dstBuffer[threadIdx] = srcBuffer[threadIdx].a; } struct SoA { uint32_t* a; float* b; int32_t* c; ... }; void kernel(uint32_t threadIdx, SoA srcBuffers, uint32_t* dstBuffer) { dstBuffer[threadIdx] = srcBuffers.a[threadIdx]; } 0 31 lane wave 0 31 lane wave 隣接スレッドが連続するメモリにアクセスするとリクエストがまとめられて効率的: コアレッシング、コアレストアクセス (Coalescing, Coalesced Access) キャッシュライン
  14. BVHトラバーサル on シェーダー struct BVHNode { AABB aabbs[2]; NodeIndex childIndices[2];

    … }; Hit traversal(const BVHNode* nodes, const Ray &ray) { NodeIndex stack[64]; stack[0] = NodeIndex::Root; uint32_t stackIdx = 1; while (stackIdx > 0) { const Node &node = nodes[stack[--stackIdx]]; … } } • BVHノードは巨大な構造体 (メモリ帯域圧迫、レジスター消費量大) • 何らかの圧縮によりコンパクトなBVHノードにするとメモリ帯域はマシになるが展開コスト大 • トラバーサルスタック (レジスター上の巨大な配列) • インスタンストランスフォーム (レジスターの消費、実行ダイバージェンス) • AABB交差判定 and/or 三角形交差判定 (実行ダイバージェンス・小さくない計算量) • レイの分布が散らばるほどトラバーサルの経路も多様に (実行・データダイバージェンス)
  15. RTコア SIMD Unit L1$/LDS RT core 2018年ごろのGPUから採用が始まる • 専用ユニットで交差判定やBVHトラバーサルを処理 •

    各社実装度合いは異なるが、強化していく流れ • シェーダーコアを膨大なレジスター消費やダイバージェンスから解放 • メモリ帯域消費は依然存在するが、専用ハードウェアが処理するため コンパクトなBVHフォーマットが使いやすい
  16. Shader Execution Reordering RTX 40シリーズ (2022年)から採用 (標準化も行っているらしい) シェーディング実行前に複数waveをまたいで スレッドのデータを並べ替える (Reorder)

    リオーダー基準 • シェーダーID 実行ダイバージェンスの抑制 • ユーザー定義フラグ アプリケーション特有の知識の活用 例: UberシェーダーでシェーダーIDは一種だが、 Uberシェーダー内の処理フラグでリオーダー • ヒット点の座標 データダイバージェンスの抑制 ただしリオーダリング自体のオーバーヘッドは大きいし、 リオーダーした結果データダイバージェンスが増える箇所もある 例: シェーディング結果の格納がスクリーンスペースでばらつく