こんにちは。最近久しぶりに C# を書いていて便利な機能の追加に涙が止まらない、バックエンドアーキテクチャGの小林です。 あるプロジェクトで Rust 製のツールを導入し、 CI テストの効率を数割改善できた事例がありましたので紹介します。
あるプロジェクトで、制作チームから「アセット用リポジトリのテストが遅い」という問題が上がってきました。 曰く、そのテストの所要時間が通しで 20 分近いので高速化できないか、とのこと。 このアセット用リポジトリは元となる SVN リポジトリが存在しており、そこからの更新を Pull Request で GitHub のリポジトリに同期する際に テストが実行されるようになっています。 作業手順としては以下のような感じでした。
このように、新しくアセットの追加・同期をするためにはこのテストの実行を待たなければならないため、 テスト時間の短縮は制作チームメンバーの効率向上に直結していたのです。
というわけで、まずはテストの所要時間を改めて計測してみました。 テストスクリプト全体をいくつかのステップに分割し、各ステップの所要時間を最後に表示するようにしました。 テスト全体の時間はリポジトリのセットアップ等も含まれるので厳密にスクリプトの所要時間がテスト全体に一致するわけではないですが、 スクリプトの所要時間はおよそ 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
この中で問題なのは 3.
のソートです。本来、重複をチェックするだけならばソートする必要はありません。
このソートは後続の uniq
が連続した行でなければ重複とみなさない仕様のために挟まっているものです。
全ての行を正しい順番でソートするためには全ての行の読み込みが完了するのを待たなければなりません。
例えば最初にチェックした 2 つのファイルで重複が検出されても、
uniq
でそれを抽出するためには最後の行が読み込まれるのを待たなければなりません。
また、スレッド効率もあまりよくないです。 awk コマンド以降は基本的にはシングルスレッドで動作するため、パワフルな CI 用マシンのスペックを持て余してしまいます。 このステップには明らかに改善の余地がある、と僕は考えました。
GUID チェックのパフォーマンスを改善するために、次のようなアルゴリズムを考えました。
各グループ間でのチェックは独立に実行できるため、並列で処理できるようになります。 また、最初の 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のゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。