Skip to content

Large Library Performance Review

log5r edited this page Oct 3, 2026 · 5 revisions

3000曲規模のライブラリ性能レビュー

2026-10-02、main の 56e39dc320ba72b0c53208d9618464055df42e9c を対象に確認した。アプリのソースは変更していない。実装のレビューと、実ソースを抽出した小規模な測定に基づく記録であり、3000曲を入れたアプリ全体の操作試験ではない。

修正用のIssue: #9 一覧再計算、#10 画像メモリ、#11 アルバム集計、#12 一括編集の同期I/O、#13 拡張形式のキャッシュ生成。#9・#10をP1、残りをP2として記録した。

確認した問題

一覧更新時に検索と並べ替えを繰り返す

MainView.swift:465–469 の filteredItems は、毎回全件の絞り込みと並べ替えを行う計算プロパティ。画面には同じ結果を渡す箇所が複数あり(320、321、368)、選択項目の取得(474)でも再計算する。検索文字列や選択項目が変わったとき、MainActor上で同じ計算を繰り返す。現在の追加日順では比較的軽いが、タイトル順・アーティスト順では3000曲でも1回に数十〜100msを要した。

改善案は、データ・検索条件・並べ替え条件の変更時に一覧を計算し、同じ結果を各表示と再生キューで共有すること。単にbody内で1回へまとめても再計算自体は残る。バックグラウンド計算にするなら、SwiftDataモデルをそのまま別アクターへ渡さず、値のスナップショットと検索世代の検証を使う。LibrarySearchFilter.activeCriteria の正規化も各行で繰り返している。LibrarySortField.fallbackCompare は最初の比較で決着しても配列リテラル内の全比較を評価するため、同じアーティスト名が多い場合に追加の負荷となる。

画像付きモデルの全件取得でメモリ使用量が増える

MediaItem.swift:22 はアートワークをモデル内の Data として保存し、MainView.swift:283 は全件のモデルを取得する。実モデルの永続ストアを別プロセスから取得し、タイトルだけを読んだ測定でも、画像相当のDataを付けると最大RSSが大きく増えた。

3000件の保存内容 取得後の最大RSS
アートワークなし 約21MiB
1件100KiBの合成Dataあり 約382〜412MiB

合成DataはJPEGではない。画像1枚の一般的な容量を測った結果ではなく、モデルと同じレコードに保存したバイナリを全件取得する挙動の再現。画像のデコード・描画やアプリのほかの処理は含めていない。SQLiteに保存されたDataの総量は307,200,000 bytesだった。

改善案は、一覧用メタデータとアートワークを別モデルまたはファイルに分離し、表示対象の画像だけを容量制限付きキャッシュへ読み込むこと。MediaListView.swift:1729 が全行の artworkData を画像の有無判定のため参照しているので、externalStorage指定だけに頼らず、有無を示す軽いプロパティも分離する。

アルバムのアーティスト集計が二乗時間になる

MediaListView.swift:1127–1137 の summarizedArtist は重複を配列の線形探索で確認する。アルバム名が未設定の曲は同じ「Unknown Album」にまとめられ、albumArtistも未設定ならこの集計を通る。3000曲がそれぞれ異なるアーティストの場合、4,498,500回の文字列比較が発生した。処理はアルバム画面の計算プロパティから同期的に呼ばれる。

表示に必要なのは単一アーティスト名または「Various Artists」なので、最初と異なる名前を見つけた時点で終了できる。アルバム一覧そのものも計算プロパティで、グリッドとonChangeのID取得などから再構築されている。

一括編集の開始時にMainActorで全件の同期I/Oを行う

2026-10-03 の修正と検証結果は 一括編集の非同期編集可否判定 に記録した。

MediaListView.swift:1866–1867 の .task は、選択した全曲へ同期的に LibraryService.canEditEmbeddedMetadata を呼ぶ。LibraryService はMainActorに属し、ブックマーク解決は全形式で行う。FLAC/WAV/Ogg/Opusでは AdditionalAudioMetadata.swift:7–14 がファイルを開いてヘッダーを読む。Ogg系では追加のページ読み込みもある。

途中にawaitやyieldがなく、選択数とストレージ応答時間に応じてUIを占有する。停止時間は未測定。モデルからブックマーク等の値を取り出して、解決・形式検証をバックグラウンドで行い、閉じた画面や前の選択に属する結果を適用しないようにする必要がある。

拡張形式の全曲キャッシュ生成が大量取り込みに適さない

LibraryService.swift:526–534 はWMA/WavPack/APE/Musepackの取り込み時に、毎曲の完全デコードを待つ。ExtendedAudioSource.swift:146–190 は曲全体をPCMのCAFへ書き込み、キャッシュ全体が8GiBを超えると古いものを削除する。

4分・44.1kHz・ステレオ32bit PCMと仮定すると、1曲のPCMは84,672,000 bytes。3000曲で約236.6GiBを書き込み、残るのは約101曲分となる。これは計算上の概算で、3000曲の実デコード計測ではない。破棄された曲の再生時には、再び完全デコードが必要となる。

加えて、151行目で取得する共通ロックはキャッシュヒットの確認より前に取得され、デコードと剪定が終わるまで保持される。取り込みで別曲を変換中なら、キャッシュ済みの拡張形式を再生するときも AudioEngineService.swift:165 で待たされる。UIそのもののロック待ちではなく、再生準備の待ち時間となる。

改善案は、取り込み時の妥当性検証と再生キャッシュ生成を分離して、先読みを限定すること。終端のデコード失敗を見逃さない要件は維持する。キャッシュの排他制御も項目ごとに調整し、別曲のヒット確認を妨げない設計を検討する。

計算処理の測定

環境はmacOS 27.0(26A428)、Xcode 27.0(27A266a)、Swift 6.4。Swift 6モード・-O で実ソースのMediaItem、LibrarySearchFilter、LibrarySortFieldをコンパイルし、in-memory SwiftDataへ保存してから処理した。表示用のローカライズと列名定義だけは測定に必要なスタブを使った。

データは追加日順、タイトルは擬似乱順、アーティスト100種類、アルバム250種類。ブックマークは空で画像は付けていない。初回のあと5回を測り、その中央値を示す。

検索・並べ替え1回 100曲 3000曲 10000曲
空検索+追加日順 0.18ms 4.73ms 18.50ms
空検索+タイトル順 1.70ms 67.57ms 251.17ms
空検索+アーティスト順 1.72ms 110.70ms 542.00ms
全件ヒット検索+タイトル順 1.95ms 81.07ms 276.50ms
全件不一致検索 0.79ms 27.87ms 84.57ms

同じ計算を3〜4回行う場合、タイトル順で約200〜270ms、アーティスト順で約330〜440msに相当する。ただし、これは回数を掛けた推定であり、SwiftUIでの操作から描画完了までの実測値ではない。

アーティスト集計は、3000曲・albumArtist未設定・同じUnknown Albumを用意し、初回のあと3回の中央値を測定した。グループ化や並べ替え、画像処理は含まず、summarizedArtistだけの時間。

異なるアーティスト数 集計1回
1 1.54ms
100 26.09ms
1000 287.08ms
3000 896.82ms

再現用コード

このWikiリポジトリの benchmarks/large-library-2026-10-02/ に測定用ソースを保存した。アプリのソースから抽出した当日時点のスナップショットであり、修正後は再抽出して比較する。Wikiリポジトリをcloneしたディレクトリから、空の一時ディレクトリを作って実行する。

probe_dir=$(mktemp -d)
sources=benchmarks/large-library-2026-10-02
swiftc -O -swift-version 6 -parse-as-library "$sources/Models.swift" "$sources/Benchmark.swift" -o "$probe_dir/query"
"$probe_dir/query"
swiftc -O -swift-version 6 -parse-as-library "$sources/Models.swift" "$sources/AlbumBenchmark.swift" -o "$probe_dir/album"
"$probe_dir/album"
swiftc -O -swift-version 6 -parse-as-library "$sources/MemoryBenchmark.swift" -o "$probe_dir/memory"
"$probe_dir/memory" create "$probe_dir/noart.store" 0
"$probe_dir/memory" create "$probe_dir/art.store" 102400
"$probe_dir/memory" load "$probe_dir/noart.store"
"$probe_dir/memory" load "$probe_dir/art.store"

MemoryBenchmarkの作成処理と取得処理は別プロセスで実行する。取得後の最大RSSはgetrusageで測る。上記の作成処理は約300MBの画像相当データを一時ストアへ保存する。

補助的な懸念と未検証項目

  • macOSのM4A系取り込みは、曲ごとにMusicアプリ向けNSAppleScriptを生成・実行する。失敗時もエラー表示を抑えるだけで次の曲で再試行する。3000曲では外部呼び出しも3000回となるが、Music内部の検索計算量や待ち時間は測っていない。二乗時間とは断定しない。
  • 音量正規化のキャッシュは256件で、超過時に64件を削除する。機能を有効にして3000曲を巡回すると再測定が繰り返される。初期設定はOFFで、圧縮形式やiPhoneの消費電力への影響は未測定。
  • 再生エンジンには専用キューと準備用のバックグラウンドタスクがあり、音楽解析は現在の曲だけが対象。曲数だけを理由に常時全曲解析や毎秒30回の全件検索が起きるとは確認できなかった。
  • macOS/iPhone/iPad実機での起動、スクロール、連続検索入力、一括編集開始、取り込み中の再生開始は未測定。実アプリの最大メモリ、音切れ、電池消費はこの調査では保証できない。
  • 修正確認では、画像付き3000曲とアルバム名未設定の曲群を用意し、検索・選択時のMainActor占有、最大RSS、一括編集開始時の同期I/O、拡張形式取り込み中にキャッシュ済み曲を選んだときの再生開始待ちを個別に測る。通常の機能テストの成功だけで性能改善済みとは判定しない。

Issue #9 の修正記録(2026-10-03)

表示中の一覧と、その一覧から作る再生キュー・選択項目・書き出し対象を、一度計算した結果から取得する実装へ変更した。MainActorで検索とソートに必要なメタデータだけを値のスナップショットへコピーし、バックグラウンドで検索とソートを行う。SwiftDataモデルはバックグラウンドへ渡さず、返ってきたIDをMainActorで元のモデルへ対応付ける。選択項目の取得にはIDの辞書を使う。

検索文字列やソート条件だけの変更では、監視済みのメタデータを再利用する。同じ条件での更新と行選択では、一覧を再計算しない。検索条件の正規化は一覧ごとに一度だけ行い、同値項目のソート比較は、先のフィールドで順序が決まればそこで終了する。macOS/iPadとiPhoneで同じ計算処理を使い、プレイリストの順序を検索後も維持する。

メタデータとプレイリストの項目・順序はObservationで監視し、ライブラリの挿入・削除はQueryの取得結果の変更から検出する。変更を検出すると、データの世代を進めてスナップショットを更新する。結果適用時にはデータと要求の両方の世代を確認する。Observationの変更通知はsetterの完了前に発生するため、その場で世代を無効化し、更新がMainActorへ戻る前に古い結果が適用される競合も確認対象とした。

削除を保存した後に削除フラグが戻り、ModelContextとの関連が外れる場合は、以前の登録状態と照合する。Query配列の更新が遅れていても、結果反映時と選択項目の取得時に削除済みモデルを除外する。プレイリストでは曲IDとentry IDを一緒に保持し、曲が重複する場合もentryごとの削除状態を確認する。

3000件の修正前後比較

修正前は 85cbb31e4e170e03f8ebe31069a70f55b605a437、修正後は 50cb22b6d8ed1d1e1758bb79a4c2fb8d1419902c の実ソースを抽出した。環境とデータは前の計測と同じで、Swift6・-O、in-memory SwiftData、タイトルは擬似乱順、アーティスト100種類、アルバム250種類。初回後5回の中央値を示す。抽出ソースと生の測定出力を再現コードとともに保存した。

条件 修正前:一覧計算1回 修正前:同じ一覧計算4回 修正後:条件変更の同期update 修正後:MainActorでの結果復元 修正後:結果の反映まで
空検索+追加日順 5.14ms 20.43ms 0.0042ms 1.18ms 2.15ms
空検索+タイトル順 63.16ms 272.57ms 0.0050ms 1.35ms 30.84ms
空検索+アーティスト順 110.33ms 460.24ms 0.0058ms 1.44ms 24.41ms
全件ヒット検索+タイトル順 73.97ms 313.84ms 0.0050ms 1.33ms 33.26ms
全件不一致検索 25.76ms 102.14ms 0.0046ms 0.0029ms 17.60ms

修正前の4回計算は、desktop画面が同じ結果を参照する4箇所に対応した再現測定である。修正後の条件変更は、既存スナップショットを再利用する操作だけを測った。キャッシュ済みの結果から3回の件数参照と1回の選択項目取得を行う時間は約0.0007ms、選択項目の辞書参照は約0.0008ms。条件不変のsource付きupdateは約0.30msだった。

ただし、初回updateの全件スナップショット取得・監視登録には、MainActorで約85〜86msかかった。データ変更やcancelした画面の再表示でも全件取得を行う。3000件の検索入力と選択ではこの処理を再実行しないが、取り込み中の更新頻度や、メタデータ編集直後の実UI応答時間は未測定である。

結果復元にもMainActor上で件数に比例する処理が残るため、測定専用コピーのpublishへ時計の記録を挿入し、同期updateとは別に測った。結果の反映までの時間にはworkerとschedulerの待ちが含まれ、MainActorの占有総量とは扱わない。SwiftUIの描画完了や実際のキー入力からの時間も含まない。

完了条件と確認方法

実装の確認方法を次のように対応付けた。関連単体テストはmacOSで54件・6 suites、iPhone Simulatorで47件・5 suitesが成功した。3000件の一覧を使うUI試験はmacOSとiPhone/iPad Simulatorで成功し、並べ替え、検索、複数選択の件数と、検索条件変更後に古い行が消えることを確認した。macOSのサイドバーからのフォーカス移動、iPhoneの既存プレイリスト操作(項目追加・プレイリストの改名・項目の取り除き)、iPadのレイアウト試験も成功した。

完了条件 確認方法
選択と条件不変の更新で一覧を再計算しない LibraryListProjectionTests.unchangedInputsAndSelectionReuseOneComputation と上のキャッシュ参照計測
検索条件を続けて変更しても最後の結果を使う workerの完了順を逆転する newerSearchResultWinsWhenWorkersFinishInReverseOrder
同一IDの全検索・ソート対象メタデータ変更に追従する everySearchAndSortFieldInvalidatesTheObservedSource とsetter通知後・更新前の競合テスト
挿入・削除とプレイリストの追加・削除・順序変更に追従する source更新、Query更新前の保存済み削除を再現する savedDeletionCannotBePublishedBeforeTheQueryReceivesItsNewArray、playlistEntriesOrderAndRelationshipChangesAreObserved、既存 PlaylistOrderingTests
画面切り替えと閉じた画面の古いworkerを区別する destination変更時のキュー消去、cancel後の同条件再表示、世代確認の単体テスト
検索のAND/OR、空白、未設定値、大小文字・アクセント差とソート順を維持する LibrarySearchAndSortTests と既存の検索・ソートテスト
3000件のfixtureを検索で絞り、並べ替え・複数選択を操作できる LibraryListUpdatesUITests.testLargeLibrarySortingSearchingAndSelection をmacOS/iPhone/iPadで対象実行
両OS向けにコンパイルでき、新しいlint違反を追加しない macOS/iOS Simulatorビルド、関連単体テスト、SwiftLint

最初のmacOS UI試験では、3000件の全件を含むアクセシビリティ検索がタイムアウトした。検索で件数を絞ってから対象要素を取得する試験へ変更したため、UI試験の成功は操作の整合性を示す。全件のアクセシビリティ取得時間や実UIの描画応答時間は、今回の性能値へ含めていない。

再現コードは benchmarks/issue-9-2026-10-03/ に保存した。修正前の1回・4回計算、修正後のスナップショット取得・検索条件変更・キャッシュ参照を別々に実行する手順と、アプリから実ソースを再抽出するscriptを同ディレクトリの README.md に記載した。2026-10-02の測定資料は比較用に保持している。

Issue #11 の修正記録(2026-10-03)

アルバムのアーティスト表示は、先頭の曲と異なるアーティストを見つけた時点で「Various Artists」を返す実装へ変更した。全件が同じなら先頭の表示名を返す。既存の localizedStandardCompare をそのまま使い、表記差を含む名前の比較結果を維持する。アルバムアーティストが設定されている場合は、従来どおり最初の非空の値を優先する。

3000件の修正前後比較

修正前は 9842b65d4bc31f5e5750a874ee22f237ec1be36b、修正後は 04f42cd12f996af11cd30ac80c862be98fbaed7b の実装を測った。実ソースの LibraryAlbum 全体と MediaItem.swift、Localization.swift を抽出し、Swift 6・-O・アプリと同じ MainActor の既定分離設定でコンパイルした。環境は macOS 27.0.1(26A434)、Xcode 27.0(27A266a)、Swift 6.4、arm64、ja_JP。in-memory SwiftData に3000曲を保存し、全件を同じ Unknown Album、albumArtist 未設定、画像なしとした。並行ビルド・テストを停止した状態で、初回後3回の中央値を測った。

異なるアーティスト数 比較回数:修正前 → 修正後 集計1回:修正前 → 修正後 アルバム構築1回:修正前 → 修正後 アルバム構築2回+ID取得:修正前 → 修正後
1 2,999 → 2,999 1.7149 → 1.6216ms 16.0113 → 15.2886ms 31.8779 → 30.5525ms
100 151,400 → 1 34.4361 → 0.0020ms 47.4381 → 12.9695ms 99.5185 → 26.5948ms
1000 1,500,500 → 1 361.9517 → 0.0022ms 374.3750 → 13.4627ms 750.9210 → 26.6554ms
3000 4,498,500 → 1 1093.1497 → 0.0018ms 1104.0867 → 12.7543ms 2203.8897 → 25.1732ms

比較回数は、時計の計測外で測定専用コピーにカウンターを入れて取得した。時間の測定はカウンターを入れていない実装で行った。複数アーティストの入力は先頭2曲で違いが見つかる順序である。全曲が同じ名前の場合や、最初の違いが末尾にある場合、修正後は最大2999回比較する。この上限は異なるアーティスト数による二乗増加を含まない。

アルバム構築は @MainActor の同期関数内で測り、実際に MainActor とメインスレッドで実行されていることを確認した。LibraryAlbum.grouped のグループ化・曲順の並べ替え・アルバムアーティスト検索・集計・付加情報生成・アルバム順の並べ替えを含む。2回の構築はグリッドと onChange の ID 取得が同じ計算プロパティを参照する処理を再現したもので、回数を掛けた推定ではない。両方とも await や yield を含まず、この区間は MainActor を占有する。

ただし、計測用の同期呼び出しには SwiftUI のレイアウト・描画を含めていない。実際の操作で SwiftUI が何回再評価するか、3000曲を入れた実アプリのスクロールや入力から描画完了までの時間、画像付きデータ、iPhone/iPad の性能は未測定である。大きな単一アーティストのアルバムでは全件走査が残り、グループ化・ソートも同期処理として残る。L10n は実ソースを使ったが、単体実行ファイルにはアプリの文字列カタログを同梱していないため、ログの表示名は英語のフォールバック値となる。名前比較は同じ ja_JP 環境で localizedStandardCompare を使用した。

再現コード・実ソースのスナップショット・全測定値は benchmarks/issue-11-2026-10-03/ に保存した。実行と再抽出の手順は同ディレクトリの README.md に記載した。2026-10-02の再現コードと測定資料は比較用に保持している。

完了条件と確認方法

関連単体テストは macOS と iPhone 18 Pro Simulator(iOS 27、日本語)でそれぞれ21 tests・3 suitesが成功した。追加したアルバムの7 tests・15 casesは、同じ Simulator の英語設定でも成功した。macOS と iOS Simulator のビルド、変更したソースとテストの strict lint も成功した。全体の lint は既存の43警告・エラーなしだった。ベースラインは、今回減ったファイル長の既存診断だけを更新し、新しい違反は追加していない。

完了条件 確認方法
同一名と表記差を含む複数名の表示を維持する LibraryAlbumTests の単一名・空白の正規化・大文字小文字・アクセント・Unicode・全角・数値表記の比較、未設定値、アルバムアーティストの優先順位と曲順のテスト。日本語・英語の Simulator 設定で成功
3000曲・多数のアーティストでも比較回数が二乗増加しない 計測用コピーの比較カウンターで4,498,500回から1回を確認。同一名は2,999回。製品コードは先頭以外の各曲を最大1回比較する
大きな未分類アルバムの MainActor 上の構築時間を修正前後で確認する 実ソースの LibraryAlbum.grouped を MainActor 上で同期実行し、1回・2回構築の全測定値を保存。画面の同期集計処理を再現した測定で、実画面の描画や操作時間は未測定
macOS と iPhone/iPad 向けにコンパイルでき、新しい lint 違反を追加しない macOS/iOS Simulator ビルド、関連単体テスト、変更ファイルの strict lint

Clone this wiki locally