Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

shocker_0x15
September 03, 2023

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

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

https://twitter.com/Shocker_0x15

shocker_0x15

September 03, 2023
Tweet

More Decks by shocker_0x15

Other Decks in Programming

Transcript

  1. 現代のGPUの実行スタイル
    とレイトレ (2023)
    @Shocker_0x15
    レイトレ合宿9
    https://sites.google.com/view/rtcamp9/home

    View full-size slide

  2. 1. SIMD/SIMT
    2. レイテンシー隠蔽戦略
    3. レイトレーシングとGPU

    View full-size slide

  3. 1. SIMD/SIMT
    2. レイテンシー隠蔽戦略
    3. レイトレーシングとGPU

    View full-size slide

  4. 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命令を処理

    View full-size slide

  5. 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)と呼ぶ

    View full-size slide

  6. 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ベクターは一対一対応ではない
    同じ変数でもコード次第で途中で変わりうる

    View full-size slide

  7. 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;
    }

    View full-size slide

  8. テクスチャー等のメモリレイアウト
    テクスチャーはバイニリアサンプリング等で上下左右近傍のデータにアクセスされがち
    → データダイバージェンスを下げるレイアウトが好ましい
    リニアレイアウト 例:Zカーブ
    Graphics APIで使用するレンダーターゲット等も
    (ハードウェア依存性が強いが)同様に何かしらのタイリングが使われる
    座標→アドレス計算がやや複雑になるがGPUのテクスチャーユニットは
    サンプリング点からメモリ上のアドレス計算をハードウェアとして実装しているため高速
    しかし読むデータが大きいならフルのソフトウェアレンダラーでも実装する価値がある

    View full-size slide

  9. ローカル共有メモリ
    いくつかの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間で同期

    View full-size slide

  10. 1. SIMD/SIMT
    2. レイテンシー隠蔽戦略
    3. レイトレーシングとGPU

    View full-size slide

  11. メモリ階層
    SIMD
    Unit
    L1$ / LDS
    L2$
    VRAM
    典型的なGPU GPUもCPU同様多段のメモリ階層からなる
    SIMD Unit内のシェーダーコアから近い順でレイテンシーの例
    • シェーダーコア中の汎用レジスター
    レイテンシー無し
    • L1キャッシュやLDS
    ~数十サイクル
    • L2キャッシュ
    数十サイクル~百数十サイクル
    • メモリ
    数百サイクル

    View full-size slide

  12. FLOPS ?
    2022年時点でのGPUの理論性能は数十TFLOPS (FP32)
    例:RTX 4090: 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個の浮動小数点演算
    これを毎サイクルやれば理論値
    レジスター以外のアクセスで数十~数百サイクルかかるし理論値は到底無理では??
    まぁ正しい、でもそれなりに頑張る方法がある

    View full-size slide

  13. 複数Wave、Occupancy
    CPUに比べて大量のスレッドを同時に扱う(数千~数万):
    スレッドあたりのキャッシュ容量はとても小さい
    基本的なALU命令は1~数サイクルで終了、一方メモリアクセスは数十~数百サイクル
    wave 0
    wave 1
    wave 2
    wave 3
    1つのSIMDユニットあたり複数のWaveを管理、
    命令発行対象のWaveを切り替えながら可能な限り毎サイクル命令実行
    ユニット内に同時に存在するWaveの数や割合をOccupancyと呼ぶ
    即時に切り替えるためには常に多くのWaveを保持している必要がある
    → Waveの情報を記憶しておく必要があるので無制限には保持できない
    あと、仕事(総スレッド数)がそもそも少なければ切り替える先のWaveが無くなる
    → GPUがレイテンシーを隠すためには大量の仕事が必要!

    View full-size slide

  14. SIMDユニットの共有リソース
    Wave保持のために必要な情報
    • 汎用レジスター (各命令が読み書きする, GPR: General Purpose Register)
    • プログラムカウンター
    • その他細かい情報色々
    汎用レジスターはSIMDユニットで共有のリソース
    1 waveあたりが必要とするレジスター数でOccupancyが制限される
    例:SIMDユニットあたり汎用レジスター数が256 Dwordsを仮定
    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を制限する

    View full-size slide

  15. レジスター使用量を抑える
    一般的なもの
    • Live-stateを減らす
    • 例: カーネル中で長く保持する複数のinteger、それぞれ32-bit必要?
    • 配列型を可能なら避ける
    • 動的なインデックスでアクセスする配列はレジスター確保の自由度を下げ、
    使用量を大きくしがち
    • ループ展開抑制
    • カーネルを分ける
    • 複数の機能を持ったカーネルを分岐で使い分けている場合
    レジスター使用量は最も多いブロックにあわせられる
    → 機能ごとに異なるカーネルを分けてディスパッチ
    • etc (ハードウェア特有のもの含む)
    ただしOccupancyは高ければ高いほど常に良いわけではない。
    あまり高すぎると…
    waveごとに色んなところにアクセス
    → キャッシュにデータが載ったり吐き出されたり(キャッシュスラッシング (cache thrashing))

    View full-size slide

  16. ボトルネックと対策
    GPUカーネル(シェーダー)の律速要因、つまりボトルネックとしては2つに大別される
    • メモリバウンド (メモリネック)
    • データ圧縮が効く (圧縮・展開のためのALUコストは隠される)
    • メモリから大きな計算済みデータを読むくらいなら再度計算してみる (同上)
    • もちろん再計算のために読む量が変わらないなら意味無し
    • データのメモリレイアウトを考える(例: AoS vs SoA)
    • 特に何段かのカーネルで構成されている処理の場合、
    前後にレイアウト変更を行うカーネルを追加しても全体としては速くなることもある
    • Occupancy改善が効く
    • ALUバウンド (ALUネック、算術ネックなどとも言ったり)
    • アルゴリズムの改善
    • スレッド間で分散できる計算があるならLDSやレーン間命令を使って処理分散
    • 事前計算できる部分はテーブルを用意する (メモリレイテンシーは隠される)
    • その他
    • 連続するGPUカーネル間に依存性が無いのにバリアを張っていないか?
    • バリアを張るとそれまでのカーネルが終わるのを待つので末端で機会損失
    (末端はGPUを埋めるWaveが疎になる・バリア自体オーバーヘッドが少しある)
    • etc

    View full-size slide

  17. キャッシュライン、コアレッシング
    キャッシュはある一定の単位(キャッシュライン, 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)

    View full-size slide

  18. 1. SIMD/SIMT
    2. レイテンシー隠蔽戦略
    3. レイトレーシングとGPU

    View full-size slide

  19. 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 三角形交差判定 (実行ダイバージェンス)
    • レイの分布が散らばるほどトラバーサルの経路も多様に (実行・データダイバージェンス)

    View full-size slide

  20. RTコア
    SIMD
    Unit
    L1$/LDS
    RT core
    2018年ごろのGPUから採用が始まる
    • 専用ユニットで交差判定やBVHトラバーサルを処理
    • 各社実装度合いは異なるが、強化していく流れ
    • シェーダーコアを膨大なレジスター消費やダイバージェンスから解放
    • メモリ帯域消費は依然存在するが、専用ハードウェアが処理するため
    コンパクトなBVHフォーマットが使いやすい

    View full-size slide

  21. シェーディングにおけるダイバージェンス
    RTコアでトラバーサル負荷が下がるとシェーディング負荷が露呈する
    例:パストレーシング
    プライマリーレイは比較的Wave中のマテリアルが均一になるが、
    セカンダリーレイ以降は、マテリアル種別的にも空間的にも非常にばらつく
    → 実行・データダイバージェンス
    ロシアンルーレットによるパスの終了もSIMD使用率低下を招く

    View full-size slide

  22. Shader Execution Reordering
    RTX 40シリーズ (2022年)から採用 (標準化も行っているらしい)
    シェーディング実行前に複数waveをまたいで
    スレッドのデータを並べ替える (Reorder)
    リオーダー基準
    • シェーダーID
    実行ダイバージェンスの抑制
    • ユーザー定義フラグ
    アプリケーション特有の知識の活用
    例: UberシェーダーでシェーダーIDは一種だが、
    Uberシェーダー内の処理フラグでリオーダー
    • ヒット点の座標
    データダイバージェンスの抑制
    ただしリオーダリング自体のオーバーヘッドは大きいし、
    リオーダーした結果データダイバージェンスが増える箇所もある
    例: シェーディング結果の格納がスクリーンスペースでばらつく

    View full-size slide