SwiftでRAG実装 Part 2:クエリに類似するドキュメント検索の試み

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化 の続き。

前回はドキュメントデータから埋め込み生成。今回はそこから検索クエリに類似するドキュメントを抽出してみる。先に結論を言うと、結果はいまひとつなので調整が必要そう。

今回実装する計算ロジックは以下(現状の筆者の理解であることに注意)。

  1. ドキュメントからそれぞれ埋め込み生成し、ベクトル化する
  2. ドキュメントごとのベクトル表現(D次元)を、ドキュメントの数(M個)分並べて MxD 行列を生成する(ドキュメント行列)
  3. クエリ文字列をベクトル化する。1. と同じ手法で生成しベクトル次元は一致(D次元)。(クエリベクトル)
  4. ドキュメント行列・クエリベクトル(内積)を計算する (MxD・Dx1 = Mx1)
    • つまりドキュメントごとのベクトルとクエリベクトルとの内積が結果として求まる。内積ベクトルのノルム(コサイン類似度)が1に近いほど類似度高く、0、-1に近づくほど類似度が低いと判定できる
  5. 4. の結果で得られた類似度の配列(要素数M)を降順にソート、上から任意の件数を上位ヒットとする
var docTensors: [MLTensor] = ...

// docTensors に含むドキュメントごとのクエリとの類似度を検索し、上位任意件数(maxCount)をヒット結果として返す
// (返すのは該当文書テンソルの docTensors 配列内におけるインデックス番号)
func search(query: String, maxCount: Int) async -> [Int] {
    // ドキュメントを集積した MxD 行列
    let flatteneds = tensors.map { $0.flattened() }
    let docsMat = MLTensor(stacking: flatteneds)
    // クエリベクトル Mx1 (encode関数は前回記事参照)
    guard let queryVec = try? embedder.encode(text: query, asColumn: true /*列ベクトルで出力*/) else { return [] }
    
    // ドキュメント行列・クエリベクトル (MxD・Dx1 = Mx1)
    let mulResult = docsMat.matmul(queryTensor)
    // 類似度スコア配列に変換
    let calcScores = await mulResult.shapedArray(of: Float.self).scalars
    // ソートして、トップの結果をインデックス番号として抽出
    let map = zip(Array(0..<docTensors.count), calcScores)
    let sorted = map.sorted { $0.1 > $1.1 }.prefix(maxCount)
    return sorted.map { $0.0 }
}

これでサンプルの日記データを検索したところ、肌感としてあまり精度高くなかった。

ただし、クエリの何かしらの特徴は反映していそうだった。たとえば「I watched a movie」のクエリで検索すると、映画を見た日記エントリはヒットしないが、上位ヒットは軒並み「I (動詞過去形)」のパターンで始まっていたり、更に分かりやすい例を挙げると、次のように「at a cafe」クエリに対し「At a/the」で始まるエントリが抽出されたりした。まったくの出鱈目ではなさそうだが、肝心の文章的なコンテキストは明らかに落ちているように見える。

Screenshot

追記(2025/10/01):後日性能改善できたので後々投稿するが、取り急ぎこちらの資料にソースコードを添付した。(enumerateTokenVectors を使うようにしただけ)

参加メモ:DroidKaigi 2025

会場エントランス

昨日までの二日間、渋谷で開催された DroidKaigi 2025 に参加してきた。

DroidKaigi 2025

スタッフとして知り合いが複数人参加していたり、会場にも顔見知りが多くいた関係で、常に誰かと話しているような状況だった。最後話したのがいつだったかも思い出せないくらい、久しぶりに顔を見た新卒時代の同僚とも、たくさん話すことができた。
何より同僚の登壇もあったので、最前列で応援することができて良かった。

iOSDC や try! Swift とはまた違った雰囲気だったのも印象的だった。スポンサーブースはその出展数の多さから祭りのように賑やかだった。その数40越え。スポンサースタンプラリー完走はほぼ不可能のように思えた。

他にもアイスがタダでもらえたり、バリスタコーヒーも選択肢が豊富だったり、ネイルが体験できるコーナーがあったりと文化祭感。発表40分、休憩20分と計1時間が交互に繰り返される点も、キレ良くて分かりやすかった。また、国内カンファレンスの割に外国人比率が高めだったのも意外だった。

アフターパーティではマグロの解体ショーも

聴講したセッションについてはまだ内容を咀嚼しきれておらず、きちんと復習したい。ありがたいことに、ほぼ即日で全セッションの動画が配信されていた。

DroidKaigi 2025 – YouTube

個人的に印象に残ったセッション3選。


最後に、初体験だったネイルの写真。

SwiftでRAG実装 Part 1:テキストをコンテキストベクトル化

Foundation Models のツール呼び出しに使う RAG に Spotlight を利用する試みが絶たれたので、自前で RAG 実装することにした。(本記事の作業は↓のワークショップで取り組んだもの)

経緯:参加メモ:新しいFoundation Modelフレームワークのアプリへの導入(ワークショップ)@Apple Japan

このブログでは繰り返し何度も貼っているが、Apple 武石さんのこのポストを頼みの綱に頑張る。もはやバイブル。


といってもちんぷんかんぷんなので ChatGPT に聞いてみた。

NLContextualEmbedding を使ってRAGを構築するにはどう言った実装が必要になりますか?

NLContextualEmbedding を使った “自前RAG” の実装は、だいたい次の4層で考えると設計しやすいです。

  1. 前処理(分割・メタデータ付与)
  2. 埋め込み生成(NLContextualEmbedding)
  3. ベクタ格納と近傍検索(SQLite + BLOB / CoreData / なんでも)
  4. 検索→再ランキング→プロンプト組み立て→生成(Foundation Models)

いったん前処理は省いて、まずは短い文字列を元にした埋め込み生成(コンテキストベクトル化)を試みた。ChatGPT の実装をもとに動くよう修正し、最終的にそれっぽくベクトルを得ることができた。(コメントは筆者理解の補足なので間違いあるかも)(2025/10/5 実装修正)

import Accelerate
import NaturalLanguage

let embedding: NLContextualEmbedding

/// 文字列をコンテキストベクトル化(平均プーリング+L2正規化)
func encode(text: String) throws -> [Float] {
    let result = try embedding.embeddingResult(for: text, language: overrideLanguage)

    let dim = embedding.dimension
    var sum = [Float](repeating: 0, count: dim)
    var count = 0 // トークン数

    result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vec_double, range in
        var vec_float = [Float](repeating: 0, count: dim)
        // double → float 変換
        vDSP_vdpsp(vec_double, 1, &vec_float, 1, vDSP_Length(dim))
        // vec_float を sum に足し合わせ
        vDSP_vadd(sum, 1, vec_float, 1, &sum, 1, vDSP_Length(dim))
        // トークン数をインクリメント
        count += 1
        return true
    }

    guard count > 0 else { return [Float](repeating: 0, count: dim) }

    // 平均プーリング(トークンベクトルの総和をトークン数で平均して畳み込み)
    var inv_n = 1.0 / Float(count)
    vDSP_vsmul(sum, 1, &inv_n, &sum, 1, vDSP_Length(dim))

    return l2Normalize(sum) // L2 normalization
}

// L2 正規化(ベクトル全体を二乗和平方根で割って正規化)
private func l2Normalize(_ v: [Float]) -> [Float] {
    var vec = v
    var norm: Float = 0
    vDSP_svesq(vec, 1, &norm, vDSP_Length(vec.count))
    norm = sqrtf(norm) + 1e-12
    vDSP_vsdiv(vec, 1, &norm, &vec, 1, vDSP_Length(vec.count))
    return vec
}
print(encode(text: "Hello, world.")

[-0.028537132, -0.014218736, -0.033890422, -0.024530113, 0.009770119, -0.01361734, 0.0034657633, 0.029605899, 0.013323085, -0.005046577, ..., 0.018509272, -0.026693422, -0.6423329, -0.03437927, 0.005926335, -0.022124525, 0.03561643, -0.056179043, 0.025543895, -0.00908023, 0.0050482955, 0.028503625]

ちなみに、今回 “Hello, world.” は Hello,, , world. の3トークンに分割された。


埋め込み生成の処理をおさらいすると

  1. 文字列をもとにベクトル埋め込みを生成(NaturalLanguage.NLContextualEmbedding.embeddingResult(for:language:)
  2. 文字列をトークン(サブテキスト単位)に分割
  3. トークンごとに、トークンベクトルを抽出(NaturalLanguage.NLContextualEmbeddingResult.tokenVector(at:)
  4. すべてのトークンベクトルの平均を計算 → コンテクストベクトル
  5. コンテクストベクトルを二乗和平方根で割って正規化

これ書きながら、2-3 のステップで頑張ってループ回しているところは enumerateTokenVectors(in:using:) 使ったほう良いかも、と思った。

参考:
埋め込み層 (Embedding Layer) [自然言語処理の文脈で] | CVMLエキスパートガイド
平均プーリング(Average Pooling) | CVMLエキスパートガイド
[iOS 17] 多言語BERT埋め込みモデルのサポート


Accelerate framework に馴染みがないので、ここで使われている関数を調べてみた。

  • vDSP_vdpsp: 倍精度のベクトルを単精度のベクトルに変換
  • vDSP_vadd: ベクトル同士の和 (stride 指定可能)
    • stride: 足し合わせ時の要素飛び石数 通常は1だが、オーディオバッファからLRチャンネルを分離して取得する時(stride=2)や、イメージバッファからRGBチャンネルを分離して取得する(stride=3)時に指定
  • vDSP_vsmul: ベクトル * スカラ値 の積算 (stride 指定可能)
  • vDSP_vsdiv: ベクトル / スカラ値 の除算 (stride 指定可能)
  • vDSP_svesq: ベクトルの二乗和 sum(a^2) 、結果はスカラ値

Apple “Awe Dropping” イベント雑感

朝1:45起床。WWDC 25 と同様に、Vision Pro をかぶってリアルタイムに視聴した。

Apple Vision Pro で湖畔越しに

だいたいリーク通りだったので驚きはなかった。しょっぱな AirPods Pro 3 は、イントロビデオのラストを見て、一瞬ブラックモデルが出るのかと思ったが、なかった。でも同じようなことを思った人は他にもいた。

ライブ翻訳機能は Pro 3 に限らず以前の一部モデルでも使えるようになるようだ。近い未来、AirPods 越しに外国語話者と会話するのが当たり前となる時代が来るかもしれない。


iPhone については 17 Pro で今回大幅にカメラアップデートがあったので 15 Pro から買い換え予定。Air も魅力的だったが、カメラ性能を最重視する身としては背に腹が代えられなかった。

iPhone Air と iPhone Pro 17では、「カメラバンプ」内にカメラモジュールだけでなくチップなどあらゆるコンポーネントを詰め込む設計となっているが、これを Apple は “(camera) plateau”(プラトー)と名付けたようだ。この見慣れない単語は「高原・台地」を意味するらしい。iPhone に、Dynamic Island に次ぐ、あらたな地名が誕生した。(ちなみに日本語字幕では「凸部」と訳されていた)そしてこの設計をフォルダブルへの布石と見た。

The new “plateau” / A screenshot from the Apple event

ちなみに、イベント全体を通してもっともテンションが上がったのは、Apple Watch Hermès モデルへの文字盤追加(Faubourg Party)の発表だった。Apple Watch Hermès ユーザーとしては毎度期待するところ。3年前に発表された Luckey Horse 以来の大胆なデザインで、今回も楽しみ。

Hermès Faubourg Party watch face / A screenshot from the Apple event

最後に、今回の夜のお供。

明治マカダミアチョコとオーザック

参加メモ:新しいFoundation Modelフレームワークのアプリへの導入(ワークショップ)@Apple Japan

アップルジャパンで開かれた Foundation Models のワークショップに参加してきた。結果から言うと、知りたいこと、気になっていたことはすべて明快にでき、お話もコーディングもたくさんできて、とても充実した時間となった。

参加者が少なかったおかげで、心置きなくテクノロジーエヴァンジェリスト武石さんとお話しできたのが良かった。Foundation Models に限らず、何をお尋ねしても的確な回答をくださったのがとても心強く、今後の取り組み方にも自信を持つことができた。自学自習だけだとこうはいかない。

ワークショップ:新しいFoundation Modelフレームワークのアプリへの導入

ワークショップとはいうものの、中身は事前に告知されていた通りにもくもく会で、冒頭5〜10分 Foundation Models framework に関するイントロがあったのち、3時間ほぼほぼ質問と作業時間に費やすことができた。

このブログで長々したためている通り、筆者は「日記を Spotlight にインデクスし Foundation Models に検索させ、対話に使う」ことをテーマに取り組んでおり、そのプロジェクトを持ち込んだ。課題が山積していて、相談はおおきく以下があった。

参考:メインで対応くださった武石さんのツイート


冒頭、いくつか tips の紹介があった。(メモしている範囲で)

  • Languages
    • デフォルトのレスポンス言語は、入力(プロンプト)の言語によって決定される
      • tool calling に英語が混ざっていたら英語になる可能性があるので、instruction で言語を指示すると良い
    • プロンプトには LocalizedStringLocale.current を instruction 指定に使うのを推奨(ユーザーの言語環境に応じられる)
  • Tool calling
    • ツールを強制的に呼び出す際は、instruction に強い表現を使うこと(プロンプトテクニック)
      • ALWAYS USE *** tool to find…
      • You MUST USE *** tool
  • Handling limited token
    • 4096 token というトークン上限
      • 日本語は、CJK は1文字1token(英語は3-4文字1token)
    • トークン消費を見るためのAPIはない
      • やりとりの他にも、生成された結果もセッション内でトークンに含めて管理しているのでややこしい
      • exeededContextWindowSize エラーを捕捉して新しいセッションを作る
      • GenerationOptions.maximumResponseTokens でコントロールする
      • 長い文章を扱う場合、NLTokenizer でチャンクを作る(Natural Language framework)、パラグラフベースで文章を切ることが可能

質疑で新たに知ることができたことのメモ。事前に実験したこと、考えていたことをもとにディスカッションくださった。

  • Core Spotlight のセマンティック検索挙動について
    • 部分一致的な挙動になるのが現状の性能限界
    • Siri の改善が追いついていない
    • WWDC24 セッションで言及されたサンプルコードも現状存在しない
  • ツール呼び出しをしてくれないケースがある
    • 指示文に “must use the tool (“SearchDiaryTool”)” と書いていたところを、よりシンプルに “must use the SearchDiaryTool” とするだけで変わるかも
    • 肌感としてツールの名称定義を、動詞-名詞 (SearchDiary, GetXXX) のようにするなどすると結果が変わったりする
    • ↑ など、指示文の細かい書き方のチューニングが効果ある
    • (ツール呼び出しに限らず)日本語よりも英語の方がパフォーマンスが良い
  • ツール呼び出しの制限
    • タイムアウトは特に明言されていなく、ない認識
    • だが、ツール呼び出し周りの情報がトークンウィンドウを溢れさせて終了することはある
      • セッション自体の指示文やプロンプトだけでなく、ツール定義(指示文など)もウィンドウを占有する。複数ツールを与えるとその分オーバーヘッドが増大する
      • ツール呼び出しでツールから取得した情報もすべて、最終的なレスポンスに関わらずトークンウィンドウを占有する
    • Foundation Models にツール呼び出しをさせるよりも、Foundation Models 外で情報を事前に取得し、プロンプトとして与える方が良い(ツール呼び出しの不確実性 < プロンプトで与えた情報が使われる確実性)
      • Foundation Models は自然言語的に回答を整形するのに使うという割り切りも検討
  • RAG 実装
    • ↑ 武石さんツイートの技術的な噛み砕きや、サンプルコードをもとにした解説
    • チャンク化→ベクトル化したものを、メタデータ付きの構造体としてラップしてあげ照合することで、より豊かな検索体験が作れそう
    • ベクトル化した情報は必ずしも SQL や CoreData に永続化する必要はない(規模によってはオンメモリでも十分)
  • そのほか
    • Foundation Models の得意分野として、結局、サマリや抽出が得意
      • Writing Tools も内部的に同じモデルを使っている
      • Writing Tools では、RAGの自前実装で行わなければいけないチャンク化など内部的に勝手にやってくれるのでラク

前半は上記の質疑を中心に、後半は RAG の実装にチャレンジしていて、つまりどころはその都度質問させていただいた。その際、Apple 社内で流通しているサンプルコードを参考に見せてくださったのは、実現方法が一目瞭然でとても嬉しかった。

ということで今日いただいた情報をもとに、Core Spotlight にはいったん見切りをつけて RAG を自前実装する方向に舵きりしたいと思う。

聴講メモ:集まれSwift好き!Swift愛好会 vol.95 @ 株式会社スマートバンク

オフライン参加のつもりが予定が狂い、今回はオンライン視聴。ラストの Liquid Glass 対応に関するトークはオフライン限定ということで、残念ながら聴講できず。最後のアセットコンパイルの話で、Assets に含めた svg ファイルが、ビルドの過程で各解像度の png に変換されていると知り目から鱗。

イベントページ:https://love-swift.connpass.com/event/367730/
アーカイブ動画:https://youtube.com/live/uZpxIPIg6F0


アプリの “かわいい” を支えるアニメーションツールRiveについて

上ちょ / uetyo さん

  • Rive とは? “インタラクティブ” アニメーションツール
  • ユーザーのタップを起点にアニメーション、音声再生、Haptic も可能
  • 複数種類のアニメーションを用意したい場合
    • Lottie アニメーションは種類分の個数が必要だが、Rive はファイルは一つのみでOK
    • Rive ファイル内で分岐が完結
  • RiveViewModel: Rive ファイルに指示するプロトコルを SDK が提供(型安全)
  • riveViewModel.view() でビュー生成
  • Screen に手を加える必要なし
  • アニメーション、再生タイミング、タイマー調整… は従来、エンジニア実装、デザイナー確認のラリーが発生していたが、 Rive ファイル内でデザイナー完結するのでラク
  • アニメーションの制作方法:
    • 初期:Rive ファイルの作成をエンジニアが主導+デザイナー巻き込み
    • 現在:デザイナーで完結
    • Rive は Duolingo が最も利用している、アニメータ専門チームが存在
  • Lottie Animation との使い分け
    • Rive:パフォーマンスが高い(メモリ使用量、CPUのレンダリングコスト)
    • 銀の弾丸ではない、ループ再生は Lottie を公式が推奨する
    • カスタムフォントはキャッシュ機構が必要(メモリ使用が増大し不安定に)
  • Q&A
    • Lottie と比べて微妙な点
      • Rive が高機能すぎて作りにくい
      • Lottie は AE から直接エクスポートできるが、Rive は専用のツール (Rive Editor) が必要で学習うコストが高い
      • 前述のパフォーマンスに対する考慮が必要
    • 後発の Rive だからこそ導入時の困りごと
      • トラブル時情報が少ない(フォーラムやGitHub Issueで質問→レスポンスに時間がかかる)
      • 日本語での情報が少ない、AIの学習も進んでいない
    • 採用すべき
      • ただのローディングだけであれば慣れ親しんだLottieが良さそう
      • 画面全体で利用したい、アニメーション途中のインタラクションをさせたいケースはおすすめ
    • 両方採用はあり?
      • ワンバンクは両方利用
      • ケースによって片方に倒すのもあり

異業種からiOSエンジニアへの道のり〜iOS開発の魅力〜

hinakko さん

“聴講メモ:集まれSwift好き!Swift愛好会 vol.95 @ 株式会社スマートバンク” の続きを読む

SwiftUI:.searchable でキーボードのエンターが押された時の処理

キーボードでエンターキーが押された時の処理実装においては .onSubmit { ... } modifier を使いイベント検知できるが、.searchable modifier による検索フィールドに対しては検索キーのタップイベントが捕捉できなかった。

.onSubmit には、実は隠れたパラメタ trigger: SubmitTriggers がある。デフォルトで .text が指定されており、入力補完で省略されると気がつけない。

onSubmit(of:_:) | Apple Developer Documentation

以下のように、trigger: .search と指定することで解決できた。

CustomScreen()
   .searchable(text: $text)
// .onSubmit {
//       ❌ 呼ばれない           
// }
   .onSubmit(of: .search, {
      // 👍 呼ばれる
   }

WWDC25:Optimize CPU performance with Instruments

Instruments を用いたCPUレベルでのパフォーマンス最適化のテクニック。バイナリサーチアルゴリズムの実装例をもとに、CPU内部の動作をマインドセットとして3ステップに分割し詳解。図解もあって直感的で、非常に見応えあったものの、後半にいくにつれ難解で理解を放棄してしまった。


0:00 – Introduction & Agenda

  • パフォーマンス予測の困難さ
    • Swift ソースコードから実際の実行環境の間に抽象化レイヤーがある
      • 機械語化されCPU実行
      • ランタイム、サポートコード、フレームワーク、カーネルも含まれ抽象化レイヤーは把握しづらい
    • CPU の並列実行、順不同(out-of-order)で実行、メモリキャッシュによる複雑性

2:28 – Performance mindset

  • オープンマインド:先入観を持たず、予想外の原因を想定、データ収集による仮説検証
  • 他の原因の考慮
    • スレッドのブロッキング(ファイルや共有状態の待機)
    • API の誤用(QoS クラス、過剰なスレッド作成)
    • 効率性の改題に対しては、アルゴリズムやデータ構造の変更を検討
    • ツール活用しボトルネックを特定(Xcode Gauges / System Trace / Hangs)
  • マイクロ最適化の注意点
    • コードの複雑化による拡張や推論の阻害や、脆弱な compiler 最適化への依存の可能性あり
  • 最適化の優先順位
    • マイクロ最適化を避けられないか検討
      • 代替手法:コード削除、遅延実行、事前計算、キャッシュ
      • これらを採用できない場合、CPUでの実行速度の向上が必要 ← 今回の主題
  • ユーザー体験に最大の影響を与えるクリティカルパスに焦点をあてて最適化すべき
    • ユーザー体験だけでなく、長時間の実行による電力消費の可能性
  • 定量化の難しい逐次的な最適化の継続により、小さな改善が大きな改善につながる
    • 継続改善のためのデータ収集用コード:
      • search クロージャを一定時間継続するまで再帰呼び出し
      • OSSignposter: 最適化対象をツールで絞り込めるようにするため、クロージャ呼び出しに使用(category: .pointsOfInterest
      • ContinuousClock: タイミング計測、Date と異なりオーバーヘッド小

8:50 – Profilers

  • Time Profiler vs CPU Profiler
    • Time Profiler:タイマーに基づきの定期的サンプリング、エイリアシング問題あり
      • エイリアシング: 定期処理がサンプリングタイマーと同じ頻度で発生、Instruments 上で結果に偏り
    • CPU Profiler:CPU クロック周波数ベース、より正確で公平な重み付けが可能
    • CPU 最適化において、CPU サイクルの最大消費箇所を特定するは CPU Profiler を利用
  • CPU Profiler の使用
    • deferred mode での低オーバーヘッド記録
    • Points of Interest での範囲設定
    • 例:Instrtuments上でコールツリーから protocol witness、allocation のオーバーヘッドを特定
      • Array の代替に Span を検討

13:20 – Span

  • Span の利点
    • Collection の代替として連続メモリ配置の要素に最適化
    • base address と count のみのシンプルな構造
    • エスケープやリークが防止され安全
  • 変更と結果:該当型を Span に変更するだけで4倍の高速化を実現
  • Span の境界チェックによるオーバーヘッドへの影響を調べる必要あり → Processor Trace

14:05 – Processor Trace

  • 革新的な機能
    • ユーザー空間で実行される全ての命令を完全にトレース(sampling bias なし)
    • 1% のみのパフォーマンス影響で無視可能
    • M4 Mac/iPad Pro、A18 iPhone での対応
  • 設定:Privacy & Security > Developer Tools での有効化
  • 使用方法
    • 数秒間の短時間トレースを推奨:記録データは数秒数GBに及ぶ可能性
    • 単一インスタンスの最適化でも十分
  • flame graph の詳細
    • 実際の実行順序通りの表示
    • 色分け
      • 茶:システム
      • マゼンダ:Swift ランタイム・標準フレームワーク
      • 青:アプリバイナリかカスタムフレームワーク
  • 発見
    • bounds check ではなく protocol metadata overhead が問題
    • 汎用的な Comparable が使用型に特化されていなかった
    • @inlinable アノテーションの追加またはInt型への手動特化の必要性
  • 結果:手動特化でコードの汎用性は失うが 1.7倍の高速化

19:51 – Bottleneck analysis

  • CPU の動作に関するメンタルモデル:2つのフェーズ
    • 命令送信(Instruction Delivery)
      • 命令がフェッチされ、マイクロ操作にデコードしCPUが実行しやすくする
    • 命令処理(Instruction Processing)
      • Map and Schedule ユニットへ送信、ルーティングとディスパッチ
      • 実行ユニット or メモリアクセス必要なら Load-Store ユニットに割り当て
    • 上記を逐次実行はフェッチ再開まで時間がかかるため、パイプライン化し並列実行
      • GCDなど複数CPUの異なるOSスレッドアクセスと異なり、1つのCPUが時間的に有利を得る
    • ユニット間でのやりとりにより、並列処理制限、パイプライン操作が停止される可能性:ボトルネック
  • ボトルネックの特定
    • CPU Counters のプリセットモード
    • 4つのカテゴリーによる CPU パフォーマンスの分析
  • 段階的な分析
    • CPU Bottlenecks レーン:Discarded bottleneck の高い割合を発見
    • Discarded Sampling セル:branch prediction miss の特定
  • CPU の命令実行の順不同性
    • 命令完了後に並べ替えるので、順番に実行したように見える(CPU による分岐予測機能)
    • 以前の実行に一貫したパターンがないと、誤った経路を辿る可能性
    • 今回はランダム性のある値比較による分岐で、予測に問題が発生した可能性
  • 変更
    • 条件付きの移動命令(conditional move instruction)を使用し、別の命令分岐を回避
    • 早期 return の除去
    • 未チェックの算術演算(unchecked arithmetic)の採用(&+)
  • 結果:2倍の高速化
  • memory hierarchy の課題
    • 予測可能なアクセスパターンでメモリアクセスすることによる高速化
    • L1、L2 キャッシュ とメインメモリへのアクセス速度差
      • L2 キャッシュは CPU 外にありヘッドルームが大幅増大
      • メインメモリは L1 キャッシュと比較し50倍低速
    • キャッシュはメモリをキャッシュラインにグループ化(64-128 bytes)
      • 4バイト要求の命令でもより多くのデータを引っ張ってくる
    • 例のバイナリサーチにおける「キャッシュミス」問題
      • 要素を並び替えてキャッシュしやすくし、検索ポイントを同じキャッシュラインに配置:エイツィンガー・レイアウト(Eytzinger layout)
  • Eytzinger layout
    • キャッシュフレンドリーな要素配置
    • breadth-first traversal によるツリー構造
    • search 速度向上と in-order traversal 速度低下のトレードオフ

31:33 – Recap

  • 全体的な成果:25倍の高速化を実現
  • 段階的なアプローチ
    1. CPU Profiler:Collection から Span への変更
    2. Processor Trace:unspecialized generics の overhead 発見
    3. Bottleneck Analysis:micro-optimization による大幅な性能向上
  • 重要な順序:software overhead の解決 → CPU bottleneck の最適化

32:13 – Next steps

  • 実践的なアプローチ
    • データ収集とパフォーマンスマインドセット
    • 繰り返し測定可能なパフォーマンステストの作成
    • Instruments の継続的な使用

WWDC25:Optimize SwiftUI performance with Instruments

Instruments を用いた SwiftUI のパフォーマンス改善。スクロール時などに発生するラグ(hangs and hitches)について、SwiftUI の描画更新ロジックを図示しながら、ケースとその因果関係を説明してくれていて、解決方法も含め非常に参考になった。シンプルな作りのアプリでも、調べてみると実は似たような状況が眠っているのではないかと思った。


0:00 – Introduction & Agenda

  • パフォーマンス問題の症状(Hitch or Hang):レスポンシブ性の低下、アニメーションの停止・ジャンプ、スクロール遅延
  • Instruments での分析:SwiftUI コードがボトルネックとなっているケースに焦点

2:19 – Discover the SwiftUI instrument

  • SwiftUI テンプレートの構成
    • SwiftUI Instrument:SwiftUI 固有のパフォーマンス問題を特定
    • Time Profiler:CPU での作業をサンプリング
    • Hangs and Hitches instruments:アプリの応答性を追跡
  • SwiftUI Instrument トラックの構造
    • 調査時はまずここのトップレベルの内容を確認する
    • Update Groups:SwiftUI が作業している時間を表示
      • ここが空いていて、Update Profiles のグラフが跳ねている場合は、SwiftUI 外が原因の可能性
    • Long View Body UpdatesViewbody プロパティが長時間実行されている場合を強調
    • Long Representable UpdatesViewViewControllerRepresentable の長時間更新を特定
    • Other Long Updates:その他の長時間 SwiftUI 更新を表示
  • 色分け:オレンジと赤で hitch や hang への寄与度を示す
    • まずは赤の更新箇所から確認することが出発点
  • 要件:Xcode 26 のインストールと最新 OS での SwiftUI traces サポート

4:20 – Diagnose and fix long view body updates

  • Command-I で リリースビルド+Instruments 起動、SwiftUI テンプレートを選択し、記録ボタンをクリック、アプリを操作
  • 問題の特定:トップレベルの 長い更新レーンを調査
    • Long View Body Updates のオレンジや赤に注目
  • Time Profiler での分析
    • View body 実行中の CPU 使用状況をコールスタックを展開して確認
    • 例:distance プロパティでの MeasurementFormatterNumberFormatter の重い処理を特定
  • レンダーループ
    • 正常な場合:イベント処理 → UI 更新 → フレーム期限前に完了 → レンダリング → 表示
    • hitch の場合:UI 更新が期限を超過 → 次のフレームが遅延 → 前フレームが長時間表示
  • パフォーマンスを高める上で、長くかかるビューの更新以外にも注意すべきこと
    • 更新が無駄に多い場合:多数の比較的高速な更新でもフレーム期限を逃す可能性

19:54 – Understand causes and effects of SwiftUI updates

  • SwiftUI の宣言的性質:UIKit のバックトレースとは異なり、SwiftUI では更新原因の特定が困難
  • AttributeGraph の動作
    • View protocol への準拠と body プロパティの実装
    • 親→子へ、属性 (attribute) を受け渡し、状態管理と依存関係の定義
      • ビュー構造体は頻繁に再作成されるが、属性がIDを維持し状態を維持する
    • state 変数変更時のトランザクション作成と期限切れのマーキング、依存関係チェーンで期限切れの更新伝播
    • 期限切れの依存関係がないものから更新を開始し、依存関係を追って逐次的に更新
  • 「なぜビュー本体が実行されたのか」→「何がビュー本体を期限切れとマークしたのか」を理解する → Cause & Effect Graph(原因と結果グラフ)
  • Cause & Effect Graph
    • 更新の原因と効果の関係をグラフで視覚化
    • 青いノード:自分のコードやユーザーアクション
    • 矢印:update や creation の関係を表示
  • ビューのデータ依存関係を細分化し、必要な箇所のみが更新されるようにするべき
    • 例:ビューがコレクションの表示する配列すべてへの依存関係を持つのではなく、ビューごとに @Observable な ViewModel を持たせる
  • Environment の考慮事項
    • EnvironmentValues 構造体への依存による更新伝播
    • 頻繁に変更される値(geometrytimer など)の environment 保存を避ける

35:01 – Next steps

  • ベストプラクティス
    • View body を高速に保つ
    • 不要な View body 更新の排除
    • データフローの設計で必要時のみ更新
    • 頻繁に変更される依存関係への注意
    • 開発中の定期的な Instruments 使用
  • 重要なポイントView body が高速かつ必要時のみに更新されることを保証する

Liquid Glass の展開する検索タブを試してみた

iOS 26 では、コンテンツを分けるタブ群と検索タブとが分離される。これにより、コンテンツ操作時と検索時とで、タブバー領域の見た目が明確に区別される作りとなっている。

実装は簡単。

// 検索画面
struct SearchTabView: View {
    @State private var text: String = ""
    
    var body: some View {
        NavigationStack {
            VStack {
                Image(systemName: "magnifyingglass")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text("Search")
            }
            .padding()
        }
        .searchable(text: $text)
        .tabViewSearchActivation(.automatic)
    }
}

...

// タブ定義
struct ContentView: View {
    var body: some View {
        TabView {
            Tab("Entries", systemImage: "doc.text") {
                EntriesTabView()
            }
            Tab("Answers", systemImage: "sparkles") {
                AnswerTabView()
            }
            Tab(role: .search) {
                SearchTabView()
            }
        }
    }
}

検索タブを選択した際に、検索フィールドにフォーカスを当てるか否かは、tabViewSearchActivation modifier で指定する。パラメタの TabSearchActivation はふたつあり、それぞれ挙動が異なる。

tabViewSearchActivation(_:) | Apple Developer Documentation
TabSearchActivation | Apple Developer Documentation

.automatic

検索タブをタップした際に、フィールドは展開するがフォーカスが当たるかは自動的に決まる。挙動を見るに、初期状態ではフォーカスは当たらず、フォーカスを当てたまま別タブに移動し、再度検索タブに戻った際にはフォーカス状態が復元されるように見える。

Music アプリがこの挙動をしており、検索画面として検索操作の前にあらゆる動線(カテゴリ)を見せたい場合に有効そう。

.searchTabSelection

検索タブをタップすると即フィールドが展開し、フィールドを閉じる(フィールド右のバツをタップする)と直前のタブに戻る。

Photos アプリがこの挙動をしている。前出の Music アプリと比較すると、以下の違いが見出せ、使い分けの参考にできそう。

  • Music が世界中の膨大なコンテンツから、検索キーワードだけでなく、ジャンル、アーティストといった軸をもとに探し出す体験
  • Photos はキーワードをもとに写真を検索する体験

ちなみに、検索タブの選択状態とカーソルフォーカスとは必ずしも連動しないため、検索タブを表示したままキーボードを閉じることはできる。(Photos ではこの状態で検索履歴の選択が行えるようになっている)


この検索タブの作りは、ミニマリズムでクリーンな印象を受ける一方で、上述したUIの仕組みやナビゲーションの構造を理解できていないと、やや使いこなすのが難しい気がしている。なぜなら iOS 18 以前は、タブ群はグローバルナビゲーションとして基本的に常時表示され、常に選択可能な状態であった。一方、iOS 26 では検索タブに遷移すると、タブ群はひとつのアイコンに集約され、選択肢が視認できなくなるからだ。

ユーザーが検索タブから任意のタブに移動したい場合、「メインのタブ群を展開する」→「目的のタブを選択する」という、2段階の操作を意識しなくてはいけない。ぼーっと触っているとこれが安易に頭から抜け、一瞬迷子になってしまうと感じている。