概要
大学院時代からGPUプログラムの高速化に何度か取り組んできました。最近もリアルタイムGPUアプリケーションの開発に携わる機会があり、その中で得た知見を簡単にまとめます。
リアルタイムGPU推論アプリケーションのパフォーマンス最適化では、ロックフリー並行キューとCUDAマルチストリームの組み合わせが、計算とデータ転送のオーバーラップを実現する強力な手法となります。
対象読者: CUDAを用いた推論パイプラインの最適化に取り組むエンジニア
0. リアルタイムGPU推論の応用例
本記事で解説する技術は、以下のような幅広い分野で活用されています。
a. 監視カメラAI(Computer Vision)
リアルタイムで映像を解析し、侵入者検知や異常行動検出、車両・人物追跡などを行います。
- 典型的な構成: カメラ映像をリアルタイムで処理し、物体検出(YOLO, EfficientDet等)や姿勢推定(OpenPose等)を実行
- 課題: 映像フレームの受信と推論のパイプライン化、検出結果のリアルタイム送信
b. リアルタイム音声処理AI(Audio Processing)
音声認識やノイズキャンセリング、話者分離をリアルタイムで実行します。
- 典型的な構成: RNN/Transformer系モデル(Whisper, Conformer等)をストリーミング推論
- 課題: 時系列データのバッファ管理、GPU/CPU間の頻繁なデータ転送、音声入出力デバイスとの同期
c. ライブ配信・放送(Video Broadcasting)
リアルタイム映像への背景置換、顔認識、字幕生成、コンテンツモデレーションなどを行います。
- 典型的な構成: セグメンテーション、顔検出・認識、OCR、不適切コンテンツ検出の並行処理
- 課題: 高解像度処理、複数チャネルの同時処理、配信遅延の最小化
d. 産業用ロボットビジョン(Industrial Robotics)
製品検査やピッキング、組立作業における物体認識、インスタントセグメンテーション、位置推定をリアルタイムで実行します。
- 典型的な構成: 物体検出、姿勢推定、欠陥検査モデルを組み合わせたマルチステージ処理
- 課題: 工場環境での高速処理、PLCや産業機器との統合、照明変動への対応
これらのアプリケーションは、10〜120FPSのスループットやミリ秒単位のレイテンシ要件を満たす必要があり、GPUリソースの効率的な活用が不可欠です。
本記事で解説するCUDAマルチストリームと並行キューの技術は、スループット向上とレイテンシ削減に直接貢献します。
1. リアルタイム推論システムのマルチスレッド設計
1.1 アーキテクチャの全体像
リアルタイムCUDA推論システムでは、複数のスレッドを適切に分離することで、CPUとGPU間の効率的なパイプライン処理が可能になります。典型的な構成は以下の通りです。
ステージ・スレッド構成:
- 入力ステージ: カメラやネットワークからフレームを取得
- 推論ステージ(メインステージ): GPU前処理と推論の起動
- 後処理ステージ: GPU結果の取得とCPU後処理(例: 物体検知の場合はNMSなど)
- 出力ステージ: 結果の送信や表示
各ステージは1つ以上の独立したスレッドで実行されます。つまり、最低でも4つのスレッドが存在します。
この設計により、各ステージが独立して動作し、全体のスループットが向上します。
1.2 スレッド間通信とロックフリーキュー
スレッド間のデータ受け渡しにはProducer-Consumerパターンを採用します。前のステージがデータ(結果)を生成し、次のステージがそれを消費します。
リアルタイム性を確保するためには、ロックフリーキューが有効です。つまり、同じキューにロックを使わず複数スレッドから同時にアクセスできるキューが望ましいです。代表例としてmoodycamel/concurrentqueueがあります。このライブラリはマルチプロデューサー・マルチコンシューマー(MPMC)のロックフリーキューを提供しています。
1.3 メモリプール
リアルタイムシステムでは、動的メモリ確保によるレイテンシを避けるため、メモリプールの利用が有効です。アプリ起動時に必要なメモリを事前に確保し、動作中はそのメモリを使い回します。new(malloc)やdelete(free)の使用を極力避けます。
このため、ステージ間では2つのキューを使います:
- resultQueue: 前のステージは処理を終えた結果をこのキューに入れ、後のステージはこのキューから結果を取得します。
-
freeMemoryPool: 後のステージは
resultQueueから取得した結果を使い終わったら、メモリをこのプールに返却し再利用可能にします。前のステージはこのプールからメモリを取得して新しい処理に使います。
※プールの上限を設定し、メモリ枯渇を防ぐ必要があります。
2. CUDAマルチストリームと非同期実行
2.1 CUDAストリームの基礎
CUDAストリームは、デバイス上で順次実行される操作のシーケンスです。異なるストリーム間では、ハードウェアが許す限り並行実行が可能です。これにより、複数の入力フレームを同時に処理し、GPUの利用率を最大化できます。
実装ではcudaStream_t型の変数を用意し、cudaStreamCreate()またはcudaStreamCreateWithFlags()でストリームを作成します。CUDA API関数(カーネル起動、メモリ転送など)に希望のストリームを指定します(ストリームを指定しない場合はデフォルトストリーム(ストリーム0)で実行されます)。
2.2 計算とメモリ転送のオーバーラップ
GPUの性能を最大限に引き出すには、計算(カーネル実行)とメモリ転送(H2D/D2H)を同時に実行することが重要です。これには以下の条件が必要です。
-
ピン留めホストメモリ: ホスト側メモリは
cudaMallocHost()またはcudaHostRegister()で確保する必要があります。ページング可能なメモリでは非同期転送が機能しません(cudaMemcpyAsync()にページング可能メモリを渡すと、内部で同期転送にフォールバックします)。 - 非デフォルトストリーム: 独立したストリームを使い、操作を明示的に分離します。
- ハードウェアサポート: コピーエンジンと計算ユニットの並行動作をサポートするGPU(現代のほとんどのGPU)。
デバイス同期の制御:
-
イベント:
cudaEventRecord()とcudaStreamWaitEvent()により、ホストをブロックせずにストリーム間の依存関係を表現できます。例えば「ストリームAの前処理完了後にストリームBの推論を開始」といった制御が可能です。 -
ホストコールバック:
cudaLaunchHostFunc()は、ストリーム上の先行タスク完了後にホスト側関数を実行します。この関数内でタスクの完了フラグを設定し、後処理スレッドに通知できます。
注意:
- 過度なピン留めメモリ確保: ピン留めメモリは物理メモリを消費し、OSのページング性能に影響します。必要最小限に留めましょう。
-
cudaLaunchHostFunc()と似たAPIにcudaStreamAddCallback()がありますが、こちらは非推奨です。
参考資料:
- CUDA Runtime API: Stream Sync Behavior
- CUDA C++ Best Practices Guide
- CUDA Runtime API: Stream Management
- CUDA Runtime API: API Sync Behavior
- How to Overlap Data Transfers in CUDA C/C++
2.3 TensorRTの明示的テンソル実行モデル
TensorRT 8.x以降では、明示的なテンソルアドレス設定とenqueueV3()を用いた非同期実行がサポートされています。この方式により、各タスクが独立したストリームで推論を実行できます。
マルチタスクのC++実装パターン
GPUタスクの定義:
const int NUM_TASKS = 3; // 並行処理するタスク数
struct CudaTask {
void* hostInputBuffer; // CPU入力バッファ(ピン留めメモリ)
void* hostOutputBuffer; // CPU出力バッファ(ピン留めメモリ)
void* deviceInputBuffer; // GPU入力バッファ
void* deviceOutputBuffer; // GPU出力バッファ
cudaStream_t stream;
nvinfer1::IExecutionContext* context;
bool isResultSynced; // 結果が同期されたか、後処理スレッドが確認するためのフラグ
};
初期化:
CudaTask tasks[NUM_TASKS];
for (int i = 0; i < NUM_TASKS; i++) {
// バッファ確保
cudaMallocHost(&tasks[i].hostInputBuffer, inputSize);
cudaMallocHost(&tasks[i].hostOutputBuffer, outputSize);
cudaMalloc(&tasks[i].deviceInputBuffer, inputSize);
cudaMalloc(&tasks[i].deviceOutputBuffer, outputSize);
// 非ブロッキングストリーム作成
cudaStreamCreateWithFlags(&tasks[i].stream, cudaStreamNonBlocking);
// TensorRTコンテキスト作成(タスクごとに独立)
tasks[i].context = engine->createExecutionContext();
tasks[i].context->setInputTensorAddress("input0", tasks[i].deviceInputBuffer);
tasks[i].context->setOutputTensorAddress("output0", tasks[i].deviceOutputBuffer);
}
推論実行(ラウンドロビンでタスクを選択):
int nextTask = 0;
while (true) {
CudaTask& task = tasks[nextTask];
// freeMemoryPoolからメモリを取得し、データを準備
// ...
// Host to Device転送
cudaMemcpyAsync(task.deviceInputBuffer, task.hostInputBuffer,
inputSize, cudaMemcpyHostToDevice, task.stream);
// TensorRT推論
task.context->enqueueV3(task.stream);
// Device to Host転送
cudaMemcpyAsync(task.hostOutputBuffer, task.deviceOutputBuffer,
outputSize, cudaMemcpyDeviceToHost, task.stream);
// 完了通知(ホストコールバック)
cudaLaunchHostFunc(task.stream, [](void* userData) {
CudaTask* task = static_cast<CudaTask*>(userData);
task->isResultSynced = true;
}, task);
// 次のタスクへ
nextTask = (nextTask + 1) % NUM_TASKS;
}
この設計により、以下のオーバーラップが実現されます:
- タスク1: 前処理中
- タスク2: 推論中
- タスク3: D2H転送中
各タスクが異なるステージを並行実行することで、GPUの稼働率が大幅に向上します。
参考資料:
- TensorRT Developer Guide
- TensorRT Architecture: Capabilities
- TensorRT Sample Support Guide
- CUDA Runtime API: Stream Capture
- CUDA Runtime API: Execution Control
まとめ
本記事では、リアルタイムGPU推論パイプラインの高速化技術について解説しました。
主要なポイント
1. マルチスレッド設計
- 入力・推論・後処理・出力の各ステージを独立したスレッドに分離
- ロックフリーキューによるスレッド間通信
- メモリプールによる動的メモリ確保のオーバーヘッド削減
2. CUDAマルチストリーム活用
- 複数のストリームによる並行処理でGPU稼働率を向上
- ピン留めメモリと非同期転送による計算とメモリ転送のオーバーラップ
- イベントとホストコールバックによる効率的な同期制御
3. TensorRTの明示的テンソル実行
- タスクごとに独立した
IExecutionContextとストリームを使用 -
enqueueV3()による非同期推論実行 - ラウンドロビンスケジューリングで複数タスクを並行処理
これらの技術を適切に組み合わせることで、スループット向上とレイテンシ削減を実現できます。監視カメラ、音声処理、ライブ配信、産業用ロボットなど、多様なリアルタイムAIアプリケーションで、実用的な性能改善が期待できます。