entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

3Dモデルの配信サーバーでRustとZstandardを採用して数倍のパフォーマンス向上を実現した

はじめに

こんにちは、VRoid部所属のエンジニアのyueです。

この度VRoid Hubで3Dモデルの配信サーバーの見直しを行い、技術選定から始めRustとZstandard (zstd)を採用した実装に切り替えました。

結論から見るに従来のNode.js製サーバーと比べて以下のことを実現しました。

  1. 最大のレスポンス時間が 1.5 ~ 2.5s から 300 ~ 400msまで低下
  2. 平均のレスポンス時間が 700 ~ 800ms から 150 ~ 200msまで低下
  3. サーバーのCPU使用率が ~ 50% から ~ 10%まで低下
  4. docker image のサイズが ~ 346mb から ~ 21mb程度まで削減
  5. 配信されるファイルサイズが平均 10 ~ 20% 軽量化されました

レスポンス時間

CPU使用量 (上からAVG(MAX), AVG, AVG(MIN))

メモリー使用量に関して以前より安定したこと以外大きな変化は見られませんでした。これは、処理の都合上モデルファイル全体をメモリーに展開する必要があったのと、allocatorの実装の都合が原因と考えられます。allocatorをjemallocまたmimalloc に変えることで改善される報告がありますが、現時点でも運用上特に問題がないため変更しませんでした。

メモリー使用量

ここから技術課題、rustとzstdの選定理由や実装と運用上注意したことなどを説明していきます。

VRM/glTFファイルの配信する上での課題

VRoid Hubで扱っている人型の3DモデルはVRMというglTFに基づいたフォーマットとして存在しています。glTFの設計理念上ランタイム向けに最適化されていることが特徴として挙げられますが、実際サービスとして配信するにはさまざまな課題があります。

通信レイヤーの圧縮フォーマット

VRoid Hubの都合上、特にコンテンツ保護の観点からオンデマンドでモデルファイルの加工を行い暗号化しています。暗号化してから圧縮すると、情報量が大きなファイルを圧縮することとなり、圧縮効率が下がるため、暗号化する前に自前で圧縮フォーマットを選ぶ必要(また選ぶ自由)があります。

圧縮・暗号化

既存のNode.jsサーバーをプロファイリングしたところ、数十MBのモデルファイルのgzip/gunzipにおいて大きな実行コストが発生していることがわかりました。

主流な圧縮フォーマットとして、gzip / zstd / lz4 / xz などが存在していますが、それぞれのパフォーマンスに特徴があります。

  • gzip / zstd 一般向け
  • lz4 低圧縮率高処理速度 / リアルタイム向け
  • xz 高圧縮率低処理速度 / アーカイブ向け

3Dデータの圧縮に特化したgoogle/dracoもありますが、過去検証した結果思ったほどのメリットを確認できなかったこともあり、今回も採用を見送りました。

今回モデルファイルを配信する上で、圧縮率、クライアント・サーバーサイドのリソース使用量全般を意識する必要があり、一般向けの gzip / zstd の中で具体的な実装レベルでさらに調査を行いました。また、マルチスレッド対応した pigz / zstdmt もありますが、運用にあたってさまざまな懸念があり、今回対象外にしました。

サーバーサイド

標準zlibは最も利用されているgzip の実装ですが、圧縮処理がCPUバウンドの点から近年パフォーマンス改善を目指したzlib-ngcloudflare/zlib などのフォークがあります。

zlib-ngdenoなどで採用されていて、LinuxのFedora distroも最近標準zlibからzlib-ng に置き換える動きがあります

実際のサンプルデータでテストした結果、デフォルト設定の場合のzlib-ngの圧縮率は標準のzlibより低く、同じくらいの圧縮率になるまで設定するとその分処理負荷も同じ水準に近づいてしまいます。zlib-ngcloudflare/zlibの間で大きな差は見られませんでした。

zstd は相対的に新しいフォーマットですが、Basis Universalテクスチャフォーマット経由で利用できたり、 圧縮率を担保しつつ処理時間と処理コストを約3~4倍下げることを確認できたため採用しました。

圧縮サイズ (x:MB y:MB) lower is better

処理時間 (x:MB y:second) lower is better

クライアント

圧縮のパフォーマンスはサーバーサイドだけではなく、クライアントサイドの伸長も含まれています。

VRoid Hubでは2022年からzlib browserify にfallbackしつつ DecompressionStreamAPIに移行しましたが、今回のzstd採用とともにクライアントでzstdのwasm decoderを利用しています。アルゴリズムレベルでzstdの伸長もgzipより優位ですが、実データで検証した結果、nativeのDecompressionStreamとほぼ同水準のパフォーマンスが出ていて、転送サイズが減ることを考慮するとクライアントサイドにもメリットがあると判断しました。

ブラウザ側でもzstdの採用が進んでおり、 Chrome 118から Content-EncodingとしてサポートされてDecompressionStreamAPIも対応する動きもあります

伸長速度 zstd decoder(wasm) vs Decompressionstream vs zlib browserify (x:MB y:ms) lower is better

圧縮・伸長・リソース使用量の観点においてどれもzstdを採用するメリットが大きく、これからクライアントサポートしている場合gzipよりzstdを選定する場合が増えていくと感じています。

暗号化

暗号化まわりは処理内容クリティカルの上実装によって結構なパフォーマンス差が出ます。今回は主に openssl 、golangの標準ライブラリー、 RustCrypto を比較しました。 実際opensslもgolangの標準ライブラリーもプラットフォームごとにアセンブリー言語で書かれているため、幾つRustCryptoよりパフォーマンスが出る結果を確認しました。また、この分野では一部コンパイラーフラグの有無にも大きな影響を与えます。特にARM向けに RUSTFLAGS="--cfg aes_armv8” をつけて ARMv8 Crypto Extensions を有効化したり、x86の場合、サイドチャンネル攻撃を回避する方向性として AES-NI 命令セットの利用も挙げられます。 aws-lc-rs も選択肢としてありえますが引き続き検証中です。

VRM / glTFの編集操作

glTF編集操作では glTF Transformgltf-rs/gltfqmuntal/gltf を比較しました。glTF Transformが業界内で一番クオリティと機能を持つものの、その規模に応じた検証が必要だったり、今回の処理において過剰なところがありました。qmuntal/gltfは業界内の採用事例がありますが、実データで検証したところかなりのカスタマイズをしない限り要件を満たすのが難しかったです。gltf-rs/gltf はゲームエンジンなどで採用されていますが、編集向けのAPIが少なかったため、シリアライザーなど一部だけの仕組みのみ利用する形になりました。

Rustを選んだ理由

ピクシブでは技術選択の自由は各チームにあります。Rustやzstdに関してすでにいくつかの場所で社内運用されていますが、ユーザー向けのサーバーサイド言語としてRustが採用されるのは初めてとなります。

今回のリプレイスにあたってTypeScript / Rust / golangでプロトタイピングを行って、比較して主に以下の理由でRustに決定しました。

パフォーマンス

サーバーの性質上モデルファイルを転送しつつ圧縮・暗号化などの処理を行うため、I/OバウンドでありCPUバウンドでもあります。tokioのようなasyncランタイムはI/Oの部分に対して役に立ちます。圧縮・暗号化に関してどの言語もzlib / zstd / opensslのbindingを何かの形で使うことになるものの、言語レベルのパフォーマンス差がその他の部分で実際レスポンスの速度とリソース使用量に反映されます。Rustを採用する場合、処理の並列化も考慮できるようになります。

コントロールと正確性

VRMファイルの編集にはバイナリバッファを操作するような処理が頻繁に発生するため、Rustような言語はより低レベルなコントロールができ、この処理に適しています。またRustはコードの正確性をフォーカスしている特徴があるため、メモリーの安全性を保証してくれているほか、暗黙的なcastingが禁止されています。このおかげで移行中にいくつか既存のバグを発見し解消できました。

ライブラリーの採用幅

Rustはゲームエンジンやブラウザなどの開発に向けてさまざまなライブラリーが作られており、私たちがやりたいこととのシナジーを感じました。圧縮ライブラリーも標準zlibに縛られず選択幅が広いです。比較すると、Node.jsにおいて mongodb-js/zstd もありますが、 zstd-rsnapi 経由でさらにラップした構成になっているいるため、依存レイヤーを増やすより直接zstd-rsを採用したかったです。

他処理との親和性

VRoid Hubではアップロード時のモデル最適化処理もありますが、そちらの処理の見直しも進行しており、既存のUnity製cliツールの移行候補としてRustが挙げられています。また、将来的にbindingとして他の言語に提供する可能性やwasmとしてフロントエンドに組み込む可能性もあります。

チームメンバーのスキル

技術面だけで見ると選択肢の中でTypeScript、 Rust、golangどれもそれぞれのメリットがありますが、実際チームの中でgolangよりRustが詳しいメンバーが多かったためTypeScriptとRustの二択になりまして、そこから他の要素と合わさってRustに決めました。社内においても横断的なRustコミュニティがあり相談しやすいため、採用に向けての懸念が少なかったです。

Rustを本番運用に向けての準備

構成

既存のエコシステムや運用しやすさを評価してtokio / axumでサーバーを構成していてます。プロダクションビルドは暗号化ライブラリーとの相性を踏まえてdockerのベースイメージはdistroless/ccを選んでいます。

CI / build cache

golangよりRustのコンパイル時間が非常に長いため、CI / build cache が重要でした。実際試したところgitlabで $CARGO_HOMEtarget をcacheするだけで十分な速度が出ていて、依存が変わらない限り約20秒くらいでビルドが終わります。

.gitlab-ci.yml のサンプル

variables:
  CACHE_VERSION: v1
  CARGO_HOME: $CI_PROJECT_DIR/.cargo
  FF_USE_FASTZIP: "true"

.build_cache: &build_cache
  cache:
    key:
      files:
        - Cargo.lock
      prefix: ${CACHE_VERSION}-build
    paths:
      - .cargo
      - target
    policy: pull-push

移行・監視

社内のエラー監視ツールとしてsentryとして利用されており、今回Sentry SDK for Rustを導入しました。

worker内のpanicはsentry側に送られないため、 tower_http::catch_panic を利用しています。もちろんここで取れる情報が限られていたり、panicの発生自体をできるだけ避けるべきです。

fn app() -> Router {
    Router::new()
        .route("/", ...)
                // ...
        .layer(CatchPanicLayer::custom(handle_panic))
                // ...
}

// https://docs.rs/tower-http/latest/tower_http/catch_panic/index.html#example
fn handle_panic(err: Box<dyn Any + Send + 'static>) -> Response {
    let details = if let Some(s) = err.downcast_ref::<String>() {
        s.as_str()
    } else if let Some(s) = err.downcast_ref::<&str>() {
        s
    } else {
        "Unknown panic message"
    };
    sentry::capture_message(details, sentry::Level::Error);

    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(json!({
            "message": "internal server panic"
        })),
    )
        .into_response()
}

同じくパフォーマンス計測にもsentryのperformance monitoringを利用していて、 #[tracing::instrument] を入れることで対応なメトリクスが自動的にsentry側に送られます。

#[tracing::instrument]
fn foo() {
    // ...
}

注意事項として #[tracing::instrument] デフォルト全てのパラメーターを記録する仕様になっており、パラメーターのサイズによってパフォーマンスコストが発生します。回避するために #[tracing::instrument(skip_all)]また#[tracing::instrument(skip(very_large_buffer, very_large_state))] を入れるといいでしょう。

#[tracing::instrument(skip_all)]
fn foo(very_large_buffer: Vec<u8>, very_large_state: LargeState) {
    // ...
}

最後に

今回のRust / zstd移行は単純な書き直しやリプレイスではなく、既存のアプリケーション全体を俯瞰して見直しを行いつつ、大幅なパフォーマンス改善を実現しました。リリースにあたって社内で横断的なサポートやレビューが得られました。関わった全員に深く感謝しています。

ちょうどリリースの時期的に hyperAWS SDK for Rustのv1がリリースされて、非常にタイミングがいいと思いました。私たちはOSSプロジェクトやエコシステムにコントリビューションし返すこともやっていきたいと考えています。

これからもサービスをよりよくすることに注力します。

yue
2021年よりVRoid部所属。RustとTypeScriptが好きです。いつかネコと暮らしたいと思っています。