6月29日、microlink.ioが「WebGL without a GPU」と題した記事を公開した。GPUを持たないコモディティLinuxサーバー上でWebGLをCPUソフトウェアレンダリングで動作させ、スクリーンショット取得速度を約4倍改善した手法の全貌を詳しく解説している。
変更したのはChrome起動フラグ1行だ。ただしその1行には「一緒に書いてはいけないフラグ」が存在し、そのうちの一つはヘッドレスブラウザのチュートリアルで世界中にコピーされ続けているフラグでもある。さらに設定を誤っても「成功に見えたまま間違った結果を返し続ける」サイレント失敗が待っている。コード変更は1行でも、「それが正しい1行だ」と証明するのに数週間を要したという経緯も記事は詳しく述べている。
問題:GPUなしのサーバーでWebGLをどう動かすか
MicrolinkはWebページのスクリーンショット取得APIを提供しているサービスだ。サーバーはグラフィックカードを持たないコモディティLinuxノードで動いており、/dev/dri(LinuxのDirect Rendering Infrastructure)も存在しない。クラウド上でGPUインスタンスを避けることはコスト削減・運用シンプル化の観点から合理的な選択だが、WebGLはGPU向けのAPIであるため、何かがCPU上でエミュレートしなければならない。ヘッドレスブラウザを使ったスクリーンショットAPIやOGP画像生成サービス、E2Eテスト基盤など、GPUレス環境でWebGLを動かす必要に迫られるケースは近年増えており、この問題は多くの開発者に共通する課題だ。
その「何か」の選択が、スクリーンショット1枚に24秒かかるか、6秒で済むかの差を生んだ。
ChromeはWebGLを直接描かない。ANGLEが描く。
ChromeのWebGL処理を担うのは**ANGLE(Almost Native Graphics Layer Engine)というレイヤーだ。ANGLEはWebGL呼び出しをプラットフォームのバックエンド(Direct3D、Metal、ネイティブOpenGL、Vulkan等)に変換する中間層で、Chromeのクロスプラットフォームなグラフィックスパイプラインを支えている。GPUがない場合はソフトウェアレンダラーに落ちる。なぜここを説明するかといえば、どのソフトウェアレンダラーを使うかはこのANGLEを通じて制御する**からだ。
GPUレス環境でChromeが使えるソフトウェアレンダラーは2つある:
- SwiftShader:Chrome同梱のデフォルト。正確な描画を優先した保守的な実装。
- **Mesa llvmpipe**:システムのOpenGLスタック。LLVMを使ったJITコンパイルで動く。
なぜ4倍の差がつくか
SwiftShaderはパイプライン全体をインタープリタ的にエミュレートする。「どこでも正しく描ける」ことが優先で、重い3Dシーンでは約24秒かかる。
llvmpipeは設計が根本的に異なる:
- LLVMがシェーダーとGLステートをx86-64のネイティブコードにJITコンパイルする。インタープリタループがない。
- タイル分割+マルチスレッド処理。マシンの全コアを実際に使う。
同じピクセルを出力しながら速度が大きく変わる。記事中ではsimdWidth: 256(AVX2使用中)が確認されており、これが速度の大部分を担っていると説明されている。
変更はたった1行、ただし罠が2つある
- '--use-angle=swiftshader',
+ '--use-angle=gl',
この1行と組み合わせてはいけないフラグが2つある:
- **
--disable-gpu**:サイレントにSwiftShaderへフォールバックさせる。ヘッドレスブラウザのチュートリアルで最もコピーされているフラグだが、これを入れると--use-angle=glの効果が完全に消える。 - **
--in-process-gpu**:ANGLEが必要とするGLサーフェスを壊す。
どちらもエラーは出ない。静かに効果だけが消える。
もう一つの落とし穴:「成功に見えて間違う」サイレント失敗
--use-angle=glはGLサーフェスのバインドが必要で、そのためにはXディスプレイが必要だ(ヘッドレス環境でも)。ディスプレイがないと、WebGLはサイレントに2Dフォールバックへ劣化する。スクリーンショットは成功し、HTTPレスポンスは200を返す。出力は「それらしく見えるが間違っている」状態だ。
このサイレント失敗がこの構成の最大の罠だ。デバッグログにも何も出ない。
対策として、各コンテナはChrome起動前にXvfb(仮想Xサーバー)を起動し、LIBGL_ALWAYS_SOFTWARE=1でMesaをllvmpipeに固定している。
Mesaをソースからビルドする理由
Ubuntu jammyのシステムMesaは古すぎて使えない。バックポート用PPAも現在は消滅している。そのため、Dockerイメージ内でMesaをソースコンパイルしている:
meson setup build \
-Dbuildtype=release -Dgallium-drivers=llvmpipe -Dvulkan-drivers= \
-Dllvm=enabled -Dshared-llvm=enabled
llvmpipeのみ、Vulkanなし、shared LLVM(JITコンパイル速度の源泉)という構成だ。ビルドに必要なツールチェーン(LLVM、clang、Rust、約160個の-devパッケージ)は巨大なため、Dockerfileはマルチステージビルド構成にしている。コンパイル後のアーティファクトだけをクリーンイメージにコピーすることで、4.5GBから2.65GBに削減している。
「正しいパスで動いている」をどう証明するか
レンダラーが何を使っているかは外から見えない。apt listは(パッケージを上書きインストールしているため)嘘をつく。そこでMicrolinkが開発するOSSブラウザ操作ライブラリbrowserlessにreport()メソッドを実装している:
const browserless = require('browserless')
const report = await browserless.report()
console.log(report)
gpuブロックの主要フィールド:
- **
type**:llvmpipeなら正常。swiftshaderならフォールバック中。hardwareならGPUが出現している。 - **
mesa**:dpkgではなくロード中のlibgallium-<ver>.soから直接読む。 - **
simdWidth: 256**:AVX2が有効であることの確認。
report({ benchmark: true })を使うと、固定シェーダーを使った決定論的なベンチマーク(llvmpipe上で約300ms)が実行できる。さらにこのレポートをCIのゲートとして使っている。gpu.typeがsoftware、gpu.deviceがllvmpipeでなければビルドが失敗する。2Dフォールバックは成功に見えるため、このアサーションがなければ壊れた状態を本番に出荷し続けることになる。
計測の難しさ
コード変更は1行だが、それが「正しい1行」だと証明するのに数週間かかったと記事は述べている。2つの罠があった:
- 開発機が嘘をつく:実GPUを持つ開発機では正常に描画されるページが、本番では黒くなる。全計測を本番同等ハードウェアで行う必要があった。
- 単発計測が嘘をつく:コールドJIT、初回描画の競合、コア共有。最も速く見えた結果が「2Dフォールバックが約1秒早く返っていただけ」というケースもあった。
最終的な数値
同一の3Dチャート、同一のGPUレスハードウェア、本番環境での計測結果:
| 条件 | SwiftShader(変更前) | Mesa llvmpipe(変更後) |
|---|---|---|
| レンダリング時間(単独) | 約24s | 約6s(約4×) |
| レンダリング時間(負荷時) | 約24s | 7〜14s(約2×) |
| 失敗リクエスト | タイムアウト → エラー | なし |
負荷時は約2倍に留まるが、従来タイムアウトしていたリクエストが完了するようになった点が実運用上の大きな改善だ。
残る限界
ソフトウェアGLで差を大きく縮めたが、全部ではない。重いフラグメントシェーダーを持つページでは、キャプチャ時点でキャンバスの描画が完了していない場合がある。これはレンダラーの問題ではなく「初回描画タイミングとのレースコンディション」であり、どのフラグでも解決できない。Microlinkは現在、初回描画を検知してからキャプチャするアプローチで対処しているとのことだ。
詳細はWebGL without a GPUを参照していただきたい。