unityアセットのguidチェックを自作ツールで10倍以上高速化しました

こんにちは。最近久しぶりに C# を書いていて便利な機能の追加に涙が止まらない、バックエンドアーキテクチャGの小林です。 あるプロジェクトで Rust 製のツールを導入し、 CI テストの効率を数割改善できた事例がありましたので紹介します。

背景

あるプロジェクトで、制作チームから「アセット用リポジトリのテストが遅い」という問題が上がってきました。 曰く、そのテストの所要時間が通しで 20 分近いので高速化できないか、とのこと。 このアセット用リポジトリは元となる SVN リポジトリが存在しており、そこからの更新を Pull Request で GitHub のリポジトリに同期する際に テストが実行されるようになっています。 作業手順としては以下のような感じでした。

  1. アセットを追加・修正する
  2. テストを 20 分待つ
  3. GitHub リポジトリにマージして実機で確認する
  4. アセットを追加・修正する
  5. テストを 20 分待つ
  6. 以後繰り返し......

このように、新しくアセットの追加・同期をするためにはこのテストの実行を待たなければならないため、 テスト時間の短縮は制作チームメンバーの効率向上に直結していたのです。

計測

というわけで、まずはテストの所要時間を改めて計測してみました。 テストスクリプト全体をいくつかのステップに分割し、各ステップの所要時間を最後に表示するようにしました。 テスト全体の時間はリポジトリのセットアップ等も含まれるので厳密にスクリプトの所要時間がテスト全体に一致するわけではないですが、 スクリプトの所要時間はおよそ 13 分程度でした。そしてそのうち、特定のステップだけで 6 分間近くを占めていました。

そのステップの処理内容、それは、「Unity アセットの .meta ファイルの GUID の重複チェック」でした。 .meta ファイルに記録されている GUID は、 Unity でアセットをインポートした際に自動で割り当てられ、重複することはありません。 しかし、制作チームと開発チームの間でアセットに様々な変更を加える中で、 Unity 側が想定していない操作が含まれてしまうことがあります。 その際に GUID が書き換わったり意図しない残り方をしてしまうことがあるのです。 重複した状態で Unity で読み込むとエラーが発生するので、事前にテストで弾いているというわけです。

調査

ではなぜこのステップだけにそこまでの時間がかかってしまっているのか。 テストスクリプトを読むと、 GUID チェックには次のようなコマンドが使用されていました。

git grep -e '^guid: [0-9a-f]\{32\}'   
    | awk '{sub(".*:","");print $0;}' 
    | sort                            
    | uniq -d                         
> result.txt
  1. .meta ファイルから GUID が含まれる行を抽出する。
  2. GUID 以外の部分を除去する。
  3. GUID 文字列をソートする。
  4. 重複しているもののみを抽出する。

この中で問題なのは 3. のソートです。本来、重複をチェックするだけならばソートする必要はありません。 このソートは後続の uniq が連続した行でなければ重複とみなさない仕様のために挟まっているものです。

全ての行を正しい順番でソートするためには全ての行の読み込みが完了するのを待たなければなりません。 例えば最初にチェックした 2 つのファイルで重複が検出されても、 uniq でそれを抽出するためには最後の行が読み込まれるのを待たなければなりません。

また、スレッド効率もあまりよくないです。 awk コマンド以降は基本的にはシングルスレッドで動作するため、パワフルな CI 用マシンのスペックを持て余してしまいます。 このステップには明らかに改善の余地がある、と僕は考えました。

アルゴリズムの改善

GUID チェックのパフォーマンスを改善するために、次のようなアルゴリズムを考えました。

  1. .meta ファイルから GUID 文字列を抽出して 16byte のバイト列にパースする。
  2. 先頭の 1byte で 256 グループに分ける。
  3. 各グループごとに hash set で重複をチェックする。

各グループ間でのチェックは独立に実行できるため、並列で処理できるようになります。 また、最初の GUID の抽出もマルチスレッドで行うようにしてしまえば良いでしょう。 このようにすることで、特定のグループでの重複チェックが他のグループの待ち時間にならなくなり、全体としてより短時間で処理できることが予想されます。

コマンドラインツールの開発

というわけで、上記のアルゴリズムを実装したツールを Rust で書きました。題して "validity-checker"。 C# で書くという選択肢もありましたが、既存のコードベースと相互運用する必要はなく、大量のファイルを扱うのでメモリ効率を重視したいなどの観点で Rust を選択しました。あと単純に社内に Rust をもっと普及させたかったという気持ちもあります。

スタンドアロンのコマンドラインアプリケーションとして動作します。機能の紹介も兼ねてヘルプテキストを紹介します。

$ validity-checker --help
validity-checker 0.5.0
リポジトリのさまざまなファイルを検証するツール

使用可能な正規表現は https://github.com/google/re2/wiki/Syntax を参照

USAGE:
    validity-checker [FLAGS] [OPTIONS] <SUBCOMMAND>

FLAGS:
    -h, --help          Prints help information
    -i, --invert        結果を反転し、最低 1 つのファイルが検証に引っかかった場合を成功とみなす
    -I, --invert-all    結果を完全に反転し、全てのファイルが検証に引っかかった場合のみを成功とみなす
    -l, --lossy         UTF-8 のバイト列として不正な部分を代替文字に置き換えて続行する
    -B, --remove-bom    BOM を取り除く
    -s, --stdin         ファイル名リストを標準入力から取り込む
    -V, --version       Prints version information

OPTIONS:
    -f, --file-list <file-list>    ファイル名リストをテキストファイルで指定する
    -g, --glob <glob>              ファイル名リストを glob パターンで指定する
    -p, --parallel <parallel>      同時に開くファイル数を指定する(デフォルト: 256)

SUBCOMMANDS:
    help             Prints this message or the help of the given subcommand(s)
    search-bytes     どのファイルにも正規表現にマッチする「バイト列」がないことを検証する
    search-string    どのファイルにも正規表現にマッチする「文字列」がないことを検証する
    unity-guid       全ての Unity アセットの .meta ファイルの GUID が重複していないことを検証する

メインの機能は unity-guid サブコマンドで実装されていますが、おまけで改行コードや行末の空白をチェックする機能も実装されています。 おまけ機能でチェックする内容は EditorConfig で統一できる内容ではありますが、このプロジェクトでは CI のチェック項目として成立させるために 専用のツールが用意されていました。標準入力からチェック対象のファイル名を取得して 1 ファイルずつ開いてチェックする構造で、 C 言語で書かれていました。 同様の理由で Rust で置き換えるのにふさわしい場面だったので、遠慮なく置き換えてしまいました。

次に具体的な使い方です。カレントディレクトリより下の .meta ファイルの GUID をチェックしたい場合は次のようにします。 これだけで並列に対象ファイルを読み込んでチェックを開始します。重複があった場合はエラーログを表示し、非ゼロの exit code を返します。

$ validity-checker -g './**/*.meta' unity-guid

行末の余分な空白をチェックしたい場合は次のようにします。

$ validity-checker -g './**/*.meta' search-string '\s$'

簡単ですね!

結果

該当の処理を validity-checker に置き換えたところで、もう一度冒頭のテストを実行して計測してみます。その結果、当初 6 分程度かかっていた GUID チェックが 同じ環境で 20 秒未満で完了するようになりました!複数回計測しても平均で 10 倍以上高速化していました。 元々テスト全体で 20 分近く要していたわけですから、そのうちの 5 分以上、割合にして 3 割程度が削減できました。大成功です。

あとがき

よくある sort | uniq という処理も、元の行数と目的によっては不必要に長い処理時間を要してしまうことがわかります。 「重いなあ」「妙に時間がかかるなあ」と感じたとき、定型的な処理を「そういうものだから」で済ませず、よりよい方法を適用できないか今一度考えてみるのも重要かもしれません。

また、この事例で社内での Rust の活路を見出せたことには技術的な挑戦の側面で大きな意義があると考えています。 今回のようなプロジェクト横断でも使えるようなツールの実装など、今後も社内での Rust 活用事例が増えていくといいですね。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。