SwiftでJSIと対話する:SDK 56で変わったこと

SDK 56 では、Expo のネイティブモジュールが Apple プラットフォーム上で JSI を直接呼び出すようになった。Objective-C++ のレイヤーを挟まなくなったことで、呼び出し速度は 1.6〜2.3 倍に向上している。

日本語
コピー
Talking to JSI in Swift: what changed in SDK 56

要点

SDK 56 では、Apple プラットフォームのネイティブモジュール基盤を書き直し、Swift が JSI と直接やり取りできるようにした。これまで呼び出し経路に挟まっていた Objective-C++ の層は削除した。呼び出しは速くなり、コールスタックは単純になり、以前は扱いにくかったことが素直に書けるようになった。この記事は Apple 側の話だ。SDK 56 における Android の変更(Kotlin コンパイラプラグイン)は別記事で扱う。

旧アーキテクチャと、なぜ変える必要があったか

SDK 56 より前、Apple プラットフォームで JavaScript から Expo ネイティブモジュールを呼び出すには 3 つの言語をまたぐ必要があった。Swift のモジュールコードの下には Objective-C++ のバインディング層(EXJavaScriptRuntimeEXJavaScriptValueEXJavaScriptObject など)が敷かれ、その下に C++ で書かれた JSI がある。Objective-C++ の層が存在する理由はただ一つ、Swift は C++ を直接呼べず、Objective-C++ が唯一実用的な接着剤だったからだ。

その代償は至るところにあった。呼び出しのたびに言語境界を 2 回くぐり、戻るのにも 2 回くぐる。値はどちらの向きでも 2 回作り直される。std::stringNSString ↔ Swift Stringstd::vectorNSArray ↔ Swift Array、以下同様に。ジャンプのたびにアロケーションとコピーが発生する。ホットパスを 3 言語で保守し、問題が起きれば 3 言語で推論しなければならない。呼び出しの途中でスタックトレース、型、所有権が姿を変えるため、継ぎ目をまたいだデバッグはとりわけ厄介だった。

これが API の速度と見通しの良さに上限を課していた。今回の書き直しが解消するのは、この上限だ。

Swift/C++ 相互運用の登場

つい最近まで、同じプロジェクトで Swift と C++ を混ぜるには Objective-C と C++ の混成物、いわゆる Objective-C++ を経由するしかなかった。Swift は Objective-C と会話でき、Objective-C++ ファイルは Objective-C と C++ を自由に混ぜて書ける。つまり Swift から使いたい C++ の型は、まず Objective-C のクラスで包む必要があった。旧アーキテクチャは呼び出しのたびにこれをやっていた。

Swift 5.9 で導入された Swift/C++ 相互運用は、この中間のジャンプを取り除く。Swift は C++ のヘッダを直接 import できる。コンパイラが C++ の型を Swift の型にマッピングする。クラスと構造体は構築可能な Swift の型になり、メソッドは Swift のメソッドになる。間に Objective-C のクラスはなく、通過時に NSString/NSArray で作り直す必要もない。網羅性は完全ではない。たとえばテンプレートは Swift のジェネリクスとしては渡ってこない。それでも、まともな C++ API の表面の大部分はそのまま渡ってきて、しかも C++ の型は所有権モデルを保ったまま到達する。ムーブしかできない型を扱う ~Copyable と組み合わせれば、JSI の単一所有者の規律を Swift API まで保つには十分だ。

代償はこうだ。JSI 呼び出しは 3 言語のリレーから単一の Swift 式になり、そこから直接の C++ 呼び出しに落ちる。呼び出し 1 回のコストは、最初から C++ で書いた場合と同じになる。コストは別の場所へ移っただけだ。コンパイル時間、そして境界にいくつか鋭い落とし穴がある。後述する。

React Native の界隈でこれを最初に試したのは我々ではない。Nitro Modules のほうが早く、当時は Swift/C++ 相互運用が今より未成熟だった。

ExpoModulesJSI の設計

ExpoModulesJSI は JSI をまともな Swift の型で包む Swift package だ。名前とは裏腹に、Expo Modules については何も知らない。純粋に JSI の上に載る Swift のラッパーで、原理的には単体で公開できた。そうしなかったのは React Native に依存しているからだ(JSI はそこにある)。JSI には React Native の外にほとんど利用者がいない。だからこの名前は意図的に控えめにしてある。

中核の型システムは JSI と一対一で対応する。JavaScriptRuntimeJavaScriptValueJavaScriptObjectJavaScriptArrayJavaScriptFunctionJavaScriptArrayBufferJavaScriptTypedArrayJavaScriptBigIntJavaScriptNativeState。いずれも JSI の型にマッピングされるが、現代的で型安全な Swift API として公開される。

JSI の所有権セマンティクスには非コピー型で対応する。JSI の値型の多くは、C++ において設計上ムーブ可能でコピー不可だ。jsi::Valuejsi::Object を例に取ろう。これらはランタイムのリソースを保持するので、所有権の所在は明確だ。ある時点でその値を保持する場所はただ一つしかなく、2 つ目が欲しければ明示しなければならない。Swift 5.9 は対応する道具をくれた。~Copyable だ。我々のラッパー型はこれに従うので、Swift コンパイラが JSI の下層で前提としているのと同じ単一所有者のルールを強制する。予期しないコピーも、予期しない参照カウントの事故も、値が黙って 2 つになる API もない。

このパッケージ自体は SwiftPM package で、C++ 相互運用はここで有効になり、xcframework もここで生成される。ビルドスクリプトはプラットフォームごと(iOS 実機、iOS シミュレータ、tvOS、tvOS シミュレータ、macOS)に slice を 1 つずつコンパイルし、それぞれで相互運用を有効にしてヘッダへのエスケープハッチを繋ぎ、最後に単一の xcframework へまとめる。

ほとんどの React Native プロジェクトは CocoaPods 経由でネイティブ依存を取り込む。そこで、プリコンパイル済み xcframework をラップする podspec も用意した。podspec は pod install の時点でプレースホルダの xcframework を生成し、CocoaPods に正しいビルドフェーズを生成させる。その後、スクリプトフェーズが実際の SwiftPM ビルドを呼び出す(コンテンツハッシュによるキャッシュが効くため、pod install のたびに再ビルドされることはない)。これにより ExpoModulesJSI を Podfile からそのまま利用でき、ビルド自体は SwiftPM 側に残る——Swift/C++ 相互運用が一級サポートされている場所だ——しかも下流のモジュール作者からはその存在が見えない。

2 つの並行モデルを橋渡しする

React Native のスレッドモデルは Swift Concurrency より何年も前から存在する。JS のタスクは run loop に駆動される専用の JS スレッドで動き、ネイティブのタスクは dispatch_queue_tNSRunLoop の間を行き来する。actor も await ポイントも構造化キャンセルもなく、あるのはキューと block、そして「正しいスレッドでコールバックせよ」という約束だけだ。

公開する Swift API は現代の Swift らしく見えてほしい。async/await、構造化並行性、適切な箇所での actor 分離。Swift Concurrency は言語全体の進化の一部であり、Kotlin や C# なども同じような移行を経てコールバックチェーンを捨てた。つまり、Swift Concurrency と React Native の run loop + dispatch の世界を共存させつつ、どちらか一方が他方の不変条件を壊さないような層を設計する必要がある。作業の大半は境界で起きる。細部は省く。単独の記事に値するし、この設計はまだ負荷をかけて調整している途中だ。

トレードオフと落とし穴

Swift/C++ 相互運用はまだ実験的で、推移的で、コンパイルも遅い。個々の API の癖よりも、設計への影響のほうが大きい。以下は、着手前に他のチームに知っておいてほしかったことだ。

Swift/C++ 相互運用は依然として実験的。 Swift 5.9 のリリースから何年経っても、この機能は手動で有効化する必要がある(Package.swift の .interoperabilityMode(.Cxx))。公式ドキュメントにも進化の途中だと明記されており、つまり API や挙動が Swift のバージョンごとに変わりうる。頭には留めておくべきだが、こちらにとっての障害ではない。

できないこと、そして永遠にできないこともある。 Swift と C++ ではメモリと所有権のモデルが大きく異なる。片側には ARC と値セマンティクスがあり、もう片側には手動のライフタイム、RAII、生ポインタがある。多くの C++ イディオムにきれいな Swift の対応物はない。複雑なテンプレートメタプログラミング、一部の継承パターン、非自明な move/copy セマンティクスに依存するものすべて。一部はツールチェーンの穴で、時間とともに埋まる。残りは概念的に橋渡しできない。それを避けて設計するしかなく、実際そうした。

ビルドは遅く、相互運用はモジュールグラフに沿って広がる。だからプリコンパイル済み xcframework を直接配布している。 2 つの関連する問題がバイナリ配布へと押しやった。第一に、C++ 相互運用を有効にするとファイルごとのコンパイル時間が目に見えて増え、そのコストはモジュールグラフに沿って積み上がる。第二に、ある Swift モジュールで相互運用を有効にすると、そのソースをインポートする下流のモジュールすべてが相互運用を有効にせざるを得なくなる。どちらのコストも、すべての Expo アプリに広げたくはない。そこで ExpoModulesJSI を xcframework としてプリコンパイルし、公開成果物として配布している。アプリのビルドは相互運用を有効にしたソースを再コンパイルするのではなく、このバイナリをリンクする。下流のモジュールは普通の Swift ライブラリとしてインポートし、相互運用の境界は ExpoModulesJSI の内側に留まる。モジュール作者はビルド設定に一切触れなくていい。

生成される C++ ヘッダは巨大で、ときどき間違っている。 Swift は公開シンボルをすべて C++ に晒す C++ ヘッダを生成する。Swift のインターフェースがそこそこの規模になるとこのヘッダは急速に膨らみ、ビルドが遅くなる原因のひとつでもあるだろう。宣言を誤った順序で出力し、Swift API の形を変えない限り生成された C++ がコンパイルできない、という事例も見てきた。使えるが、起きるものだと知っておく必要がある。

ドキュメントに書かれていない脱出口がひとつある。-clang-header-expose-decls=has-expose-attr を使うと、C++ ヘッダを明示的にエクスポート指定した宣言だけに絞れる。このフラグは探せる限りの公式ドキュメントのどこにも載っていない。唯一の公開された言及は Swift コンパイラのソース内、FrontendOptions.td の数行だけだ。これを使うと生成ヘッダは明らかに小さくなり、順序の問題の一部も回避できる。

自分たちのものではない C++ 型に注釈を付ける:APINotes。 既定では、Swift はすべての C++ クラスと構造体を値型としてインポートする。Swift の struct のように。これは仮想メソッドを持つ型にとって問題になる。Swift では仮想ディスパッチが参照型(class)でしか効かないからだ。値型としてインポートされた仮想 jsi::Runtime::evaluateJavaScript は呼び出せない。この型を参照でインポートするよう Swift に伝える必要がある。自分たちで管理しているコードなら、C++ ヘッダに埋め込めるマクロ(SWIFT_SHARED_REFERENCE など)が Swift には用意されている。しかし jsi::Runtime は React Native の中にあり、JSI ヘッダを fork したりパッチを当てたりはしたくない。そこで Clang の APINotes の出番になる。サードパーティのヘッダに手を入れず、Swift のインポート属性を上乗せできるサイドカーの YAML ファイルだ。これを使って jsi::Runtime を参照型として印付けており、まさにこのおかげで仮想メソッドが Swift まで届く。JSI 呼び出しのほぼすべてがこのいずれかを通るので、これは決定的な依存だ。

C++ の例外は Swift の境界を越えない。 Swift では、throw する関数に throws を付けなければならず、コンパイラが呼び出し側でチェックを強制する。C++ にこれに対応する仕組みはない。noexcept を付けない限り、どの関数も例外を投げうる。Swift が C++ の関数を取り込むとき、この2つを区別する手がかりは何も得られないので、投げないものとして扱う。実際に投げられればアプリは落ちる。取り込んだ API を読むときも同じ問題が付きまとう。Swift に取り込まれた C++ のシグネチャからは、その関数が投げるかどうか読み取れない。C++ のソースを当たるか、推測するか、取り込んだ呼び出しをすべて致命的になりうるものとして扱うしかない。JSI について言えば、中核となるメソッドのいくつか(evaluateJavaScript、throw された JS 値へのプロパティアクセスなど)は実際に jsi::JSError を投げる。例外が Swift に逃げ込むと、スタック展開がその前提でコンパイルされていないフレームを貫通し、アプリは即座に落ちる。これに対する我々の答えが小さなブリッジだ。C++ 側で例外を捕捉してスレッドローカルストレージに格納し、呼び出しの終了後に Swift の error として投げ直す。ホストのコールバックから投げられた Swift error も同じ経路を逆方向にたどる。この throws の配管は自分で組む必要があり、コンパイラは助けてくれない。

Expo Module のパフォーマンス

今回の書き換えの目標ははっきりしている。より良い Swift API のために性能を犠牲にはしない。Turbo Module は React Native が現代のネイティブモジュールアーキテクチャに立てた基準であり、我々は速度と開発体験を引き換えにするのではなく、この基準に到達することを目指した。Swift Concurrency のサポート、~Copyable の所有権モデル、現代的な型システム——これらが我々の届けたいものであり、Turbo Module の性能に並ぶことはそれを現実のものにするための制約だ。以下の数字が、それを達成したことを示している。

ベンチマークの方法

4つのマイクロベンチマーク、3つのネイティブモジュールアーキテクチャ、2つの SDK バージョン。ベンチマークは JavaScript からネイティブを呼び出し、それぞれ 100,000 回反復し、3回の平均を取る。3つのアーキテクチャは Expo Module の経路(本稿の主題)、React Native の Turbo Module(core にある JSI ベースの現代的な経路)、そして旧来の Bridge——現在の React Native では、実質的には相互運用レイヤーを挟んだ Turbo Module にすぎない。4つのベンチマークは、同期の空関数、0 + 1 の加算、'hello''world' の連結、非同期の空関数。入力は意図的に無意味なものにしてある。測っているのは JS とネイティブの境界を越えるコストであり、算術や文字列処理のコストではない。以下の数字はすべて iPhone 16 Pro の release ビルドで計測したものだ。

非同期ベンチマークについては一言必要だ。Expo Module の経路は Swift Concurrency(async/await)を使っており、呼び出しごとにコールバック方式の非同期より多くの処理を行う。Task、continuation、スケジューラとのやり取り。Turbo Modules と Bridge はコールバック方式だ。つまり同じ仕組みを3通りに実装したのではなく、同じ論理操作をそれぞれの世界の慣習に従って書いたもの同士を比べている。これが誠実な比較だと我々は考えている。モジュールの Swift コードで async func を使って非同期関数を書くことはもちろん可能で、ここに示すコストはその書き方に払うことになるコストだ(我々の側では払う必要がない)。

ベンチマーク結果

同じテストを SDK 55(今回の書き換え前の最後のバージョン)と SDK 56 で走らせた。Turbo Modules と Bridge も参照として併記する。

ベンチマークSDK 55 ExpoSDK 56 Expo倍率SDK 56 TurboSDK 56 Bridge(interop)
同期 no-op135 ms68 ms2.0x70 ms214 ms
double 2つの加算212 ms92 ms2.3x93 ms379 ms
文字列連結220 ms137 ms1.6x150 ms430 ms
非同期 no-op1219 ms706 ms1.7x1091 ms1158 ms

Expo Module の経路は全体で 1.6〜2.3 倍速くなった。どのベンチマークでも大きな改善で、その幅は効くと見込んでいたアーキテクチャ上の変更とちょうど対応している。no-op のコストは主に境界から来ており、文字列のコストは主にデータのマーシャリングから来ており、非同期経路は削り取るべきコストが最も多かっただけに絶対量での利益が最大だ。

SDK 55 では、Expo Module の経路はどの項目でも Turbo Modules に後れを取っていた。非同期で 11%、同期 no-op と文字列で約 17%、double の加算では 66% の遅れだ。書き換え後はこの関係が逆転した。最も単純な同期経路では Turbo Modules と同等(丸め誤差の範囲内)、文字列のマーシャリングでは 9% 先行し、非同期では 1.55 倍先行している。この非同期の差をまず挙げたい。実際のアプリでコストが本当に積み上がるのはここだ——promise がモジュール境界をまたいで連なり、タスクがスケジュールされ、再開される。

この比較について、正直に言っておくべきことがもう一点ある。Turbo Modules と Bridge は SDK 55 から SDK 56 の間にも小幅に変化しており、主な要因は React Native 上流の改善だ(Turbo は addNumbers で明らかに速くなり、残りの項目も両者とも数パーセントの変動がある)。つまり、我々が追いかけている相手は静止した目標ではなく、今も改善を続けている目標であり、現時点で我々はわずかに追い越している。

Bridge の列が映しているのは、もっと以前の状況だ。同期呼び出しでは、JSI ベースの2つの経路より3〜4倍遅く、これは呼び出しごとに JSON シリアライズが入る分の差としてちょうど納得がいく。非同期になると差は1.6倍まで縮む。非同期のオーバーヘッドは主に Promise の割り当てとスケジューリングから来ており、この3つのアーキテクチャはそこにほぼ同じコストを払っているからだ。

注意点

マイクロベンチマークが測るのはミクロなものだ。何もしない処理を10万回繰り返しても分かるのは境界コストだけで、実際のアプリがどう感じられるかは分からない。実アプリでは通常、呼び出し頻度、ペイロードのサイズ、そして実際の処理そのもののコストが支配的になる。デバイス、OS バージョン、Hermes のビルドが違えば絶対値は上下する。この部分で安定しているのは比率だ。

これで何が可能になるか

  • Objective-C++ では苦痛でしかなかった、あるいはそもそも不可能だった機能への道が、以前よりスムーズになった。

  • 呼び出し経路が1つの言語だけになったことで、今後のパフォーマンス最適化に手を付けられるようになった。

これは終着点ではない。この基盤の上にさらに多くのものが載るが、本記事では一つひとつ触れない。今回の書き直しは、次の API 作業のための土台だ。

Expo SDK 56 ネイティブモジュールを使い始める

SDK 56 は iOS、tvOS、macOS で新しいネイティブモジュール経路を提供する。このバージョンの残りの内容は SDK 56 リリースノート を参照してほしい。バグ報告、設計変更の提案、コントリビュートをしたい場合は、expo-modules-jsi パッケージが GitHub にある。

Android は事情が異なる。SDK 56 の Android における主な収穫は Kotlin コンパイラプラグイン(別記事で扱う)で、より多くの処理をコンパイル時に移し、JSI で書き直すよりも大きな利益をもたらす。Kotlin 優先の JSI ラッパーも後々検討する予定だが、Android の JSI は当時の iOS より健全なので、この方向から得られる利益はおそらく限定的だ。

もう一点。AI は今回の書き直しで大いに役立った。JSI の C++ インターフェースのほぼ全体を Swift でカバーし、テストカバレッジを90%近くまで押し上げた。手作業だけなら、はるかに時間がかかっていただろう。

出典: Expo Blog← ホームへ戻る