GCPのGPUインスタンスでレンダリングを高速化
2017/04/10
  • このエントリーをはてなブックマークに追加

はじめに

カブクの甘いもの担当兼コーヒーマシン清掃担当の高橋憲一です。
今回は最近GCPで使用可能になったGPUインスタンスを活用して3Dモデルのレンダリングを高速化したことについて書きます。

カブクのレンダリングエンジン

Kabuku Connect、Kabuku MMS、Rinkak等、カブクが提供しているサービスで使うための3Dモデルのプレビュー画像はサーバーサイドで動作する自社開発エンジンでレンダリングしています。グラフィクスAPIとしてOpenGLを使用しており、これまではOpenGL互換のライブラリであるMesaを使ってオフスクリーンレンダリングを行っていました。それにより、GPUを搭載していないマシンでもCPUの処理のみでレンダリングすることができます。ただしCPUのみでのレンダリングはやはり時間がかかるため、クラウドのインスタンスでのGPU活用は2年程前のエンジン運用投入当初より切望していたことでした。
llvmpipe版というLLVMのランタイムコード生成でシェーダーやラスタライズを行うバージョンのMesaを使用することで、CPUのみの環境でも少しでも速くレンダリングできるようにはしています)

このレンダリング機能は、創業初期はblenderをpythonで制御して実現していたのですが、3Dモデルの解析エンジンを開発した際に、モデルデータをパースして頂点座標をロードできているのだから後はOpenGLの呼び出しをすればレンダリングエンジンにできるのではないかと考えたことから開発を始めたものです。OpenGLを使ったのは私が慣れ親しんでいたAPIだということもありますが、OpenGLを使っていれば将来クラウド環境でもGPUが使えるようになったときにコードはほぼそのままで高速化が可能になるということを開発当初から念頭に置いていました。

GCP

カブクではGoogle Cloud Platform (GCP)を使用してサービスを提供しており、レンダリングエンジンはGoogle Compute Engine (GCE)で動いています。そのGCEでGPUを搭載したインスタンスを使うことができるようになったのを受けて、GPUのパワーを活かしてレンダリングできるように対応しました。

GPU対応するにあたりエンジンの改修で必要だったのは、EGLによるOpenGLのコンテキスト生成、オフスクリーンでレンダリングするためのフレームバッファオブジェクトの生成の2つでした。

LinuxでのOpenGL

OpenGLのAPIを呼び出すにはまずコンテキストの生成が必要です。OpenGL自体はクロスプラットフォームなAPIですが、各OS毎にそのコンテキストの生成と画面描画システム(ウィンドウシステム)とのバインディングを担う層があります。通常、LinuxでOpenGLを使おうとするとGLXとXlibというX Windowに関連する層のAPIを使用することになります。X serverを動作させたり、さらにディスプレイがつながれていないヘッドレスなサーバーで実行する(オフスクリーンでのレンダリング)となると設定に難儀するなと当初は考えあぐねておりました。そこで今時Xを使わずに済む、もっとシンプルな方法はないものかと調べたところEGLを使う方法が見つかりました。

NVIDIAのデベロッパーブログEGL Eye: OpenGL Visualization without an X Serverに「X Serverを使わずにOpenGLビジュアライゼーションを行う」ための情報が詳しく載っており、「ついこの間まではOpenGLのコンテキストを管理するにはX serverを走らせておく必要があったが、今はEGLを使うことができる」という内容になっています。

ここでEGLと聞いて身に覚えがあるのは、数年前AndroidでJavaではなくNDKを使ってC++でOpenGL ESを使う実装をした時のことです。そこから来る認識はEGLはOpenGLに使うものでなく、OpenGL ESとセットで使う組み込み用途...というものでした。しかし、NVIDIAのドライバーのバージョン355以降ではOpenGLでも使用可能になっています。

EGLの取扱自体はNVIDIAの記事にも "creating an OpenGL context with EGL is not rocket science!" (EGLでOpenGLのコンテキストを生成することはロケット科学のような難しいことではない!)とあるようにとてもシンプルで、実装に時間はかかりませんでした。そしてOpenGLのAPIを呼ぶところや頂点シェーダーおよびフラグメントシェーダーはMesaで使用していたものをそのまま変更することなく使えますし、手元でのテスト用にMac OS X用に実装していたフレームバッファオブジェクト生成のコードもプラットフォーム非依存のOpenGLのAPIのために共通で使うことができました。

Mesaを使用する場合と異なるところはEGLに関する部分と、フレームバッファオブジェクトの生成部分で以下のようになります。

EGLでのコンテキスト生成(NVIDIAのブログにあったコードを参考にエラーチェックを追加したもの)

int width = 1024;
int height = 1024;
EGLDisplay display;
EGLContext context;

// 1. EGLの初期化
display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (display == EGL_NO_DISPLAY) {
    fprintf(stderr, "failed to eglGetDisplay\n");
    return false;
}
EGLint major, minor;
EGLBoolean eglStatus = eglInitialize(display, &major, &minor);
if (eglStatus == EGL_FALSE) {
    fprintf(stderr, "failed to eglInitialize\n");
    return false;
}
fprintf(stderr, "EGL Version %d . %d\n", major, minor);

// 2. RGBやdepthバッファのビット数等の設定を選択
static const EGLint configAttribs[] = {
      EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
      EGL_BLUE_SIZE, 8,
      EGL_GREEN_SIZE, 8,
      EGL_RED_SIZE, 8,
      EGL_DEPTH_SIZE, 8,
      EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
      EGL_NONE
};
EGLint numConfigs;
EGLConfig eglCfg;
eglStatus = eglChooseConfig(display, configAttribs, &eglCfg, 1, &numConfigs);
if (eglStatus == EGL_FALSE) {
    fprintf(stderr, "failed to eglChooseConfig\n");
    return false;
}
// 3. サーフェスの生成
const EGLint pbufferAttribs[] = {
    EGL_WIDTH, width,
    EGL_HEIGHT, height,
    EGL_NONE
};
EGLSurface surface = eglCreatePbufferSurface(display, eglCfg, pbufferAttribs);
if (surface == EGL_NO_SURFACE) {
    fprintf(stderr, "failed to eglCreatePbufferSurface\n");
    return false;
}
// 4. API(OpenGL)のバインド
eglStatus = eglBindAPI(EGL_OPENGL_API);
if (eglStatus == EGL_FALSE) {
    fprintf(stderr, "failed to eglBindAPI\n");
    return false;
}
// 5. コンテキストを生成し、カレントにする
context = eglCreateContext(display, eglCfg, EGL_NO_CONTEXT, NULL);
if (context == EGL_NO_CONTEXT) {
    fprintf(stderr, "failed to eglCreateContext\n");
    return false;
}
eglStatus = eglMakeCurrent(display, surface, surface, context);
if (eglStatus == EGL_FALSE) {
    fprintf(stderr, "failed to eglMakeCurrent\n");
    return false;
}

フレームバッファオブジェクト生成

GLuint frameBuffer;
GLuint renderBuffer;

glGenFramebuffers(1, &frameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, frameBuffer);

// RGBA用のバッファ
glGenRenderbuffers(1, &renderBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, renderBuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
                 GL_RENDERBUFFER, renderBuffer);

// depthバッファ
GLuint depthRenderbuffer;
glGenRenderbuffers(1, &depthRenderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, depthRenderbuffer);
glRenderbufferStorage( GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, width, height);
glFramebufferRenderbuffer(GL_FRAMEBUFFER,
                      GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRenderbuffer);

status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
    fprintf(stderr, "failed to initialize frame buffer object\n");
}

あとは1フレームあたりに必要なOpenGLのAPIを一通り実行したら以下のようにするとフレームバッファの内容をあらかじめ確保した領域に読み込むことができるので、その領域を画像ファイルとして保存します。

uchar *imageBuffer = new uchar[width * height * 4];
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, imageBuffer);

余談ですが、実はMesaでオフスクリーンレンダリングする際は上記でやっていたことに相当するコードは以下の部分だけです。

int width = 1024;
int height = 1024;
uchar *imageBuffer = new uchar[width * height * 4];
OSMesaContext context = OSMesaCreateContextExt(GL_RGBA, 24, 0, 0, 0);
OSMesaMakeCurrent(context, imageBuffer, GL_UNSIGNED_BYTE, width, height);
後は1フレームあたりに必要なOpenGLのAPIを一通り実行したらimageBufferが示す領域にはレンダリングされた画像が入っているので、その内容を適切な画像ファイルとして保存するだけです。(glReadPixlesも必要ありません)

いざ実行してみると...

まずはローカルのLinuxマシン(Tensorflowを使ったDeep LearningにおけるGPU性能調査でも登場したGTX980Tiを積んだ開発機)で高速レンダリングできることを確認し、意気揚々とクラウドのインスタンスで実行してみると...ローカルのマシンで得られたような期待する性能が出ません。遅くなっている箇所を調査したところ、EGLコンテキスト生成に異常に時間がかかっていることが分かり、手元にあるマシンで実行した際は0.01秒で済んでいたものが2.60秒もかかるのです。これでは折角のGPUによる高速化の意味も半減してしまいますので、現象を再現するためのテストプログラム(EGLコンテキスト生成と1枚だけ単純な図形をレンダリングするもの)を作成してGoogleに「どうなってるんだ?」と詰め寄る問い合わせをする準備を進めていました。

別の作業もあり2週間ほどの時間が経過していたのですが、問い合わせをする前にあらためて実行してみると...
何と0.06秒でコンテキスト生成が完了するではありませんか!

ベータ期間だったということもあってか、その間にコンテキスト生成のオーバーヘッドの問題が改善されていたようです。これは嬉しい驚きで、晴れてGPUのパワーを享受できることになりました。

パフォーマンス比較

今回、インスタンスに選択した構成はNVIDIAのK80を使用するもので、実際にポリゴン数が134万で頂点カラーを持つモデルをレンダリングして性能計測をした結果は以下の通りです。比較のためにMesa(CPUのみで実行)を使った場合とローカルのマシンでの結果も上げておきます。

GCP インスタンスのスペック
マシンタイプ: n1-standard-4-k80x1
CPU: Intel(R) Xeon(R) CPU @ 2.50GHz x 4
メモリ: 15GB
GPU: NVIDIA Tesla K80 x 1

ローカルマシンのスペック
CPU: Intel(R) Core(TM) i7-6700K CPU @ 4.00GHz x 8
メモリ: 32GB
GPU: NVIDIA GeForce GTX980Ti x 1

30フレーム分の画像のレンダリング時間 (単位:秒)
シャドウ OFFレンダリングjpegエンコードと保存他の処理も含む合計レンダリング性能比
local (GPU GTX980i)0.1328080.1856380.48303968.17031354
local (CPU)6.2675090.1836376.5706841.444523335
GCP (GPU K80)0.1861320.7724571.35451848.64055079
GCP (CPU)9.0535630.75665910.0728771
シャドウ ONレンダリングjpegエンコードと保存他の処理も含む合計レンダリング性能比
local (GPU GTX980i)0.1671660.1848540.538712166.2302861
local (CPU)13.553530.18310213.8579212.050244623
GCP (GPU K80)0.2475290.7763221.398778112.2618037
GCP (CPU)27.7880520.73665928.804281


当たり前のことではありますがGPUを使用した場合の性能には圧倒的な差があり、特にシャドウをONにした際に顕著です。
その一方で、ローカルマシンのGTX980TiよりGCPのインスタンス上のK80の方が遅いという結果が出ていることが気になりますが、localとGCPのレンダリング性能比を計算してみると、それぞれ、
・CPUレンダリング: 9.053563 / 6.267509 = 1.444523
・GPUレンダリング: 0.186132 / 0.132808 = 1.401511
となります。OpenGLでレンダリングする際の処理は全てGPUで処理されるわけではなく、OpenGLのドライバ内でCPUにて処理する部分があり、今回の測定環境であるlocalとGCPのCPUクロック比が 4.0GHz / 2.5GHz = 1.6 という差があることから考えると、CPUの性能差が影響しているのではないかと推測します。

今後の課題

GPUを活用することで高速なレンダリングができるようにはなりましたが、性能の特製をつかむためにもレンダリングにかかる時間におけるGPUとCPUの正確な比率の分析、および「jpegエンコードと保存」の値においてはlocalとGCPの間でCPUの性能差、ストレージの性能差(両方ともSSD)以上の開きがあるため、jpegエンコード時のGPU活用度等の観点でさらなる検証が必要です。

最後に

今回はOpenGLを使ってレンダリング処理を高速化しましたが、3Dモデルデータの他の解析処理もCUDAを使って実装することでGPUを活用して高速化する計画が進行中です。現在この辺りの実装は私を含む2名体制で進めていますが、引き続きカブクではそういった技術を活用して一緒に速さを追い求めてくれるエンジニアを募集しております。


※ この画像はGCPでGPUを使用したバージョンのエンジンで3方向からの光源を配置し、8xのマルチサンプルのアンチエイリアスをかけて、フレーム毎にY軸で回転させて30枚の連続画像としてレンダリングしたものです。