プログラミング
[sqlx::test] のリビルド時間を最適化する
Optimizing [sqlx:test] rebuild time (kobzol.github.io)
要約
筆者は、Rustの`sqlx::test`マクロを多用するプロジェクト(borsの再実装)において、テストのリビルドに8〜10秒かかる問題に直面しました。この遅延の原因が、`#[sqlx::test]`マクロが各テストの近くにすべてのデータベースマイグレーションのコードをインライン展開することにあると特定。この問題を解決するため、マイグレーションのロードをコンパイル時ではなくランタイムに移動するか、または共有変数として参照する既存の解決策(`#[sqlx::test(migrator = "crate::MIGRATOR")]`)を使用することで、リビルド時間を大幅に短縮できることを示しています。
全文翻訳
#[sqlx::test] テストを多数含むプロジェクトをお持ちの方にとって、この投稿は特に役立つかもしれません。私が過去数年間取り組んできたアップストリームのRustプロジェクトの一つに、rust-lang/rustのすべてのプルリクエストをマージするために使用するマージキューボットであるborsの書き換えがありました。このボットについてもっと知りたい場合は、RustWeek 2026での私の講演をチェックしてください。私はborsの結合テストスイートを非常に誇りに思っており、多大な労力を費やしたおかげで、2026年1月に本番環境にデプロイして以来、ボットはほぼ完璧に動作しています(最近のGitHubはしばしば…問題を抱えていますが)。
しかし、私が非常に不満に思っていることの一つは、bors、特にそのテストスイートの増分リビルド時間です。私のラップトップでは、変更があるたびにテストをリビルドするのに長い時間(約8〜10秒)かかり、これは生産性にとってかなり悪いです。最近、ついにビルド時間をプロファイリングする時間を見つけ、それがいくつかの要因の組み合わせによって引き起こされていることを知りました。デバッグ情報の生成に時間がかかります。これは既知の問題ですが、このケースではデバッグ情報を諦めたくありませんでした。なぜなら、私は実際にborsのテストを頻繁にデバッグしたりステップ実行したりするからです。rustcがインクリメンタルセッションをロードして永続化するのに時間がかかります。これについては今後調査する予定です。おそらくすべてのデバッグ情報(最終バイナリは220MiBもあります)のせいで、lldがテストをリンクするのに丸1秒(!)かかります。wildを使用すると、わずか約200msです。borsで多用しているsqlx::testのコンパイルに時間がかかります。この投稿では、これに焦点を当てます。
sqlxテストのコンパイルが遅いベンチマークの参照として、私は`touch <test-file> && time cargo test --no-run`を使用していました。何もしない変更の後でも、テストの再コンパイルに約7.5秒かかり、これは非常に遅いです。もちろん、sqlxのプロシージャルマクロがコンパイル時間を遅くする可能性があることはよく知られています。なぜなら、それらが「面白いこと」をたくさんするからです2。しかし、私が遭遇したケースはそれほど明らかではないかもしれません。私のケースでは、sqlxは実際にはデータベースに接続すらしていませんでした!なぜなら、私がSQLクエリを直接操作しない限り、SQLX_OFFLINE=1でコンパイルしているからです。そして、sqlxのドキュメントで推奨されている通り、sqlx-macrosクレートに`opt-level = 3`を設定しています。では、何が起こっているのでしょうか?それを知るためには、次のようなテストがあるときに何が起こるかを理解することが重要です。
```rust
#[sqlx::test]
async fn test_foo(pool: sqlx::PgPool) {}
```
#[sqlx::test] 属性は非常に便利です。なぜなら、テスト実行前に新しいデータベースを作成し、その上でマイグレーションを実行し、データベース接続プールを提供してくれるので、モックされたHashMap3ではなく、実際のデータベースに対してテストを実行できるからです。待って、マイグレーションと言いましたか?ええと、どこでそれらを見つけるのでしょうか?もちろん、ディスクからです!#[sqlx::test]の各使用は、ディスク上のディレクトリからすべてのマイグレーションを収集し、その後、各マイグレーションを読み込み、パースし、検証し、ハッシュ化します。おそらく直感に反しますが、この部分はそれほど遅くありません!Rustは実際にはかなり高速であることが判明しました(ご存知でしたか?)、そして数ギガバイトものマイグレーションがない限り、I/Oも問題ないでしょう4。より悪いのは、これらのマクロによって生成される出力です。そのような各テストについて、マクロは完全なマイグレーションリストを生成します。これには、テキストコンテンツと、バイト配列形式のチェックサムがRustソースコード内に定数として含まれます。したがって、マクロを展開すると、各テストの前に次のようなものが見つかるでしょう。
```rust
args.migrator(&::sqlx::migrate::Migrator {
migrations: ::std::borrow::Cow::Borrowed(&[
::sqlx::migrate::Migration {
version: 20240517094752i64,
description: ::std::borrow::Cow::Borrowed("create build"),
migration_type: ::sqlx::migrate::MigrationType::ReversibleUp,
sql: ::std::borrow::Cow::Borrowed("CREATE TABLE <skipped>)"),
no_tx: false,
checksum: ::std::borrow::Cow::Borrowed(&[193u8, 202u8, <skipped>]),
},
::sqlx::migrate::Migration {
<skipped>
},
<skipped>
]),
<skipped>
});
```
上記の例は短縮されており、多くのものが省略されています。実際に生成されるコードははるかに長くなり、もちろんマイグレーションの数(および内容)に応じてスケールします。さて、ソースコードにこのようなものが一度だけあるのであれば、それほど悪くはありません。しかし、borsには約350のsqlxテストと30のマイグレーションがあります。そしてその時点で、かなり急速に積み重なり始めます。マイグレーションがビルドの遅さの一因であるという私の仮説をテストするために、残りのマイグレーションを削除して、マイグレーションが1つだけの場合にどうなるかをテストしました。すると、リビルド時間はすぐに約7.5秒から約5秒に短縮されました!おそらくさらに顕著なのは、`cargo expand --lib --tests`の出力サイズが、30のマイグレーションで32 MiB(!)だったのが、単一のマイグレーションでは「わずか」6 MiBに減少したことです。追加の26 MiBのRustコードをコンパイルするのは、もちろん無料ではありません。しかし、生成されたコードのコンパイル時間だけの問題ではありませんでした。プロファイルでは、プロシージャルマクロの実行中にquoteクレートを使用してすべてのマイグレーション記述データをトークンに変換するのにもかなりの時間がかかっているように見えました。この動作は、プロジェクト開始時にはリビルドが高速だった(あるいは少なくとも速かった)ため、かなり目立たないかもしれません。しかし、テストが追加されるたび、マイグレーションが追加されるたびに、リビルド時間はゆっくりと増加し、いつの間にか問題が表面化します。昨年コンパイラに導入した実験的なプロシージャルマクロキャッシュ機能が役立つか試しましたが、効果はありませんでした。おそらく、生成されたコードのコンパイルに必要な時間がプロシージャルマクロ自体の実行時間を矮小化するため、プロシージャルマクロ自体がキャッシュされても助けにならないのでしょう。訂正:`#[sqlx::test]`は属性であり、deriveマクロではないため、このフラグは適用されません。@futileに感謝します。何ができるかまず、生成されるコードのサイズを削減すること、例えばチェックサムバイト配列をよりコンパクトな形式で表現することなどを考え始めました。これはおそらく役立つでしょうが、各テストの隣にすべてのマイグレーションのコードを生成し続ける限り、コードが多すぎると気づきました。次に、sqlxにパッチを当てて、マイグレーションのロードをコンパイル時から(テスト)ランタイムに移動させ、インライン化されたすべてのマイグレーションを取り除くことを試みました。これは実際に望ましい効果をもたらしました!リビルド時間は約5秒に短縮され、(少なくともborsの場合では)`cargo expand`の出力は〜6 MiBに削減され、テストの実行時間は測定可能な影響を受けませんでした。この変更はそれほど複雑ではありませんでした。必要なのは、プロシージャルマクロ内でその関数を呼び出し、生成されたソースコードにすべてのマイグレーションの説明を埋め込むのではなく、ランタイムにマイグレーションをロードする関数を呼び出すコードを生成することだけです。つまり、本質的には、次の(疑似コード)から:
```rust
fn sqlx_proc_macro() -> TokenStream {
let migrations = generate_migrations();
quote! {
Migrator { migrations: #migrations }
}
}
```
次のようになります:
```rust
fn sqlx_proc_macro() -> TokenStream {
quote! {
Migrator { migrations: ::sqlx::generate_migrations() }
}
}
```
しかし、ランタイムにマイグレーションをロードすることにはいくつかの欠点があるかもしれません。例えば、テストが自己完結型ではなくなるなどです。私はsqlx Discordサーバーに行き、人々が何か提案を持っているか尋ねました。私の提案は、sqlxがマイグレーションをランタイムにロードするか(上記で説明した通り)、またはすべてのテストが参照するマイグレーションの共有変数を設けて、生成されるコードの肥大化を避けるか、というものでした。面白いことに、ある人が返信してきて、2番目の解決策はすでに実装されている(まあ、ある程度は)と教えてくれました。実際、#[sqlx::test]で適用するマイグレーションを含む変数へのパスを指定できます。
```rust
// The macro generates the migrations, we store it in a single variable.
const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
// Each test just references the variable, instead of inlining all the
// migrations next to the test's source code.
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_1(pool: sqlx::PgPool) {}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_2(pool: sqlx::P
```