この記事の要点
- Swift プログラムをデバッグ中に式を評価するには、LLDB が式に登場する型を定義した Swift モジュールを正確に見つけてインポートする必要があります。従来はモジュールを名前で探していたため、精度が低く、遅くなったりサイズが膨らんだりする問題がありました。
- Swift 6.3 から始まった変更により、明示的にビルドされたモジュール(explicitly-built modules)がデバッグ情報の中で正確に追跡されるようになります。SwiftPM や Xcode を使う大多数の開発者は、プロジェクトに手を加えなくても、より速く信頼性の高いデバッグと、より小さいビルド成果物という恩恵を自動的に受けられます。
- Bazel・Buck・CMake などで独自のビルドシステムを構築している場合は、Swift 6.4 以降でいくつかの調整が必要になることがあります。具体的には、これまでの
-modulewrapアクションと-add_ast_pathリンカオプションが不要になり、代わりに-debug-module-pathをコンパイラに渡す形に置き換わります。 - Swift 6.4 では、bridging header のインポートも高速化され、bridging header を使うプロジェクトのデバッグが体感できるほど速くなります。
背景: デバッガはどうやって Swift モジュールを使うのか
LLDB の強力な機能のひとつが式評価器です。LLDB は Clang と Swift のコンパイラを内部に組み込んでいるため、ブレークポイントで停止した状態で、任意の有効なソースコードを JIT コンパイルしてアプリケーションの文脈で実行できます。単に既存のコードを呼び出すだけでなく、新しい型・関数・クロージャをその場で定義することもできます。
ローカル変数を表示するだけなら、コンパイラの助けは要りません。
(lldb) p myObj
この場合、LLDB は myObj の位置をデバッグ情報から見つけ、その型をリフレクションメタデータから解決できます。しかし、次のように computed property へアクセスすると話が変わります。
(lldb) p myObj.myComputedProperty
myComputedProperty は実際には関数呼び出しなので、評価にはコンパイラが必要です。LLDB は式評価器を初期化するために、現在の関数が属する Swift モジュールの名前をデバッグ情報から探し、概念的には次のように式を関数で包んでコンパイルします。
import Foo
func lldb_expr(_ $__lldb_arg : UnsafeMutablePointer<Any>) {
let myObj: MyObject = /* some LLDB magic */
// Expression begins here:
myObj.myComputedProperty
}
問題は import Foo が曖昧なことです。Swift 言語では同名のモジュールを複数持てませんが、実際のアプリケーションには同じモジュールのコピーが複数存在しえます。たとえば非公開の宣言まで含んだ版と、公開インターフェイスだけを含む module interface が別々に存在したり、macOS 版と Mac Catalyst 版が同じプロセス内に共存したりします。名前だけでは、LLDB が正しいモジュールを選び当てられる保証がありません。
これまでのモジュール受け渡しと課題
LLDB がモジュールの場所を知るには、リンカの協力が必要でした。Darwin ではシステムリンカの -add_ast_path オプションで、リンク時にすべてのバイナリ Swift モジュールを列挙します。
# Linker invocation on macOS
ld -add_ast_path /path/to/Foo.swiftmodule Foo.o -o MyApplication
リンカはこれをシンボルテーブルのエントリに変換し、dsymutil がそれらを集めて dSYM バンドル内の __swift_ast セクションに格納します。リンカが Swift を認識しない Windows・Linux・FreeBSD などでは、コンパイラの -modulewrap アクションでバイナリ Swift モジュールをオブジェクトファイルに変換し、リンカでバイナリに埋め込みます。
# Modulewrap and linker invocation on Linux
swift-frontend -modulewrap Foo.swiftmodule -o Foo.swiftmodule.o
lld Foo.o Foo.swiftmodule.o -o MyApplication
この方式には、特に大規模アプリケーションで次のような課題がありました。
- モジュールファイルは大きくなりがちで、アプリ全体では SDK の大部分がバイナリに取り込まれ、バイナリサイズが問題になります。
- アプリが複雑になるほど、名前だけで正しいモジュールを見つけられる確率が下がります。
- バイナリ Swift モジュールは、それを生成した正確なコンパイラにバージョンが固定されます。これは長期保存を意図した dSYM バンドルの目的と相容れません。
- 一致する明示的なモジュールが見つからないと、LLDB は暗黙のモジュールインポートにフォールバックし、SDK の一部をソースから再コンパイルすることがあります。これは非常に遅くなりえます。
Swift 6.3 からの精密なモジュール追跡
Swift 6.3 から、コンパイラ・Swift ドライバ・LLDB にまたがる改善が段階的に導入されています。いずれも明示的にビルドされたモジュールを土台にしています。
明示的にビルドされたモジュールが Swift の依存関係も追跡する: 明示的にビルドされたバイナリ Swift モジュールは、以前から明示的な Clang モジュールの依存関係を記録していました。Swift 6.3 では、これに加えて Swift モジュールの依存関係も記録します。これにより、モジュールを名前で探す必要がなくなり、インポートが高速かつ曖昧さなく行えます。この動作は自動で、利用者側の変更は不要です。
オブジェクトファイル自身のモジュールへのパスをデバッグ情報に格納する: トップレベルのモジュールさえ見つかれば、LLDB はそれとすべての依存関係を正確にインポートできます。では、ブレークポイント地点の Swift ファイルが属するモジュールをどう特定するのか。Swift 6.3 では、コンパイラがそのパスをデバッグ情報に格納できます。オブジェクトファイル自身のモジュールはコンパイルの入力ではないため、各コンパイルにパスを伝える新しい -debug-module-path オプションが追加されました。
Swift ドライバがコンパイルジョブにモジュールパスを渡す: SwiftPM や Xcode の利用者はこれを意識する必要はありません。Swift ドライバが -debug-module-path を認識し、オブジェクトファイル自身のモジュールへのパスを自動でコンパイラに渡すからです。一方、Swift ドライバを介さずに Swift フロントエンドを直接呼び出す独自ビルドシステムでは、各コンパイルジョブにトップレベルモジュールへのパスを自分で伝える必要があります。
これらのパスは、分散ビルドシステム向けの標準的なパスのリマッピングオプションの対象にもなります。また、キャッシュを有効にしてコンパイルする場合、Swift モジュールやモジュールのデバッグ情報を指すパスはすべて content-addressable storage への参照(場所ではなく内容で識別される参照)になるため、コンパイルキャッシュとも透過的に連携します。
Swift 6.4 で不要になるビルドステップ
次の変更は Swift 6.4 から安全に行えます。
モジュールのパスがデバッグ情報とモジュールヘッダ自身を通じて伝わるようになったため、明示的なモジュールビルドを行う独自ビルドシステムでは、次を取り除けます。
- Linux・Windows でのすべての
-modulewrapアクション - Darwin(macOS・iOS など)での
-add_ast_pathリンカオプション
その結果、dsymutil はバイナリ Swift モジュールを処理しなくなり、dSYM バンドルは小さくなります。バイナリ Swift モジュールは生成元のツールチェーンでしか解析できないため、もともと長期保存フォーマットである dSYM バンドルとは相性が良くありませんでした。
デバッグへの影響を心配する必要はありません。ローカル変数の型解決は、Swift 5.6 以降 LLDB がバイナリ中のリフレクションメタデータから行えます。したがって、dSYM バンドルにバイナリ Swift モジュールがなくても、変数の中身を調べたり po でオブジェクトを表示したりする能力は損なわれません。関数呼び出しや computed getter のような複雑な式の評価にはバイナリ Swift モジュールが引き続き必要ですが、LLDB がそれらを元の場所(またはリマップ先)で見つけられる限り、式評価はこれまでどおり動作します。ビルドしたばかりのバイナリを同じマシンでデバッグする場合は常にこの条件を満たします。
Swift 6.4 で速くなる bridging header のインポート
Swift 6.3 までは、LLDB は bridging header を常にソースからコンパイルしており、bridging header を使うデバッグセッションでは無視できない時間がかかることがありました。Swift 6.4 では、LLDB が精密な明示的モジュール情報を使って、プリコンパイル済みの bridging header とその明示的な依存モジュールを直接インポートできるようになります。これにより、bridging header を使うプロジェクトのデバッグが、完全にモジュール化されたプロジェクトと同じくらい速く信頼できるものになります。
まとめ
明示的にビルドされたモジュールに対するこれらの変更により、次が実現されます。
- Windows・Linux のデバッグ情報付きバイナリと Darwin の dSYM バンドルが、バイナリ Swift モジュールを含まなくなって大幅に小さくなります(6.4 以降)。
- 名前による検索ではなく精密な追跡になることで、LLDB での文脈的なモジュールインポートの信頼性が上がります。
- モジュールインポートにまつわる性能の落ち込みが解消されます。
- 独自ビルドシステムでは
-modulewrapと-add_ast_pathを取り除ける一方、フロントエンドのオプションを Swift ドライバに任せていない場合は-debug-module-pathをコンパイラに渡す必要があります。 - リンク時に
-add_ast_pathを指定していなかった静的アーカイブ内でのデバッグの不具合という、見落とされがちだった問題群も設計上なくなります。
要するに、-modulewrap と -add_ast_path が -debug-module-path に置き換わり、デバッグ情報はより小さく、より正確になります。
関連リンク
- LLDB の式評価器 — LLDB がコンパイラを組み込んで式を評価する仕組み
- Swift LLDB フォーラム — ワークフローに問題が生じた場合のフィードバック先
- Swift のバグトラッカー — 不具合の報告先