はじめに
個人開発しているWindows向けデスクトップアプリ「File Search Explorer」(Python + Tkinter製)のv0.7.4で、パフォーマンス周りを中心に大きく手を入れたので、実装のポイントをまとめます。
対象読者: Tkinterでそこそこ大きめのデスクトップアプリを書いている人、SQLiteをローカルインデックスとして使っている人。
v0.7.4での変更点
- インデックスの差分更新(新規/更新/削除を分けて集計)
- 検索処理のマルチスレッド化
- SQLiteに拡張子・サイズ・更新日時のインデックスを追加
- 初回起動時のハードウェア計測による自動チューニング
- プレビュー対応形式の拡大(PDF/Word/Excel/PowerPoint/音楽/動画)
- 重複ファイル検索を部分ハッシュ+マルチスレッドで高速化
- 100万件超の検索結果に対応する仮想スクロール
- セッション(タブ・検索条件)の復元
- 検索速度グラフの表示切り替え
このうち、パフォーマンスに直結する4つを中心に書きます。
1. インデックスの差分更新
以前のバージョンでも「変更されたファイルだけをUPDATE/INSERTする」処理はありましたが、新規作成なのか更新なのかを区別していませんでした。v0.7.4では走査時に既存レコードのmtimeと突き合わせて、
prev_mtime = existing.get(full_path)
if prev_mtime is None:
new_count += 1
elif prev_mtime != mtime:
updated_count += 1
のように新規/更新をその場で振り分け、削除判定(走査後に見つからなかったパス)と合わせて3種類の件数をUIに表示できるようにしました。地味な変更ですが、「今回のインデックス更新で何が起きたか」が一目で分かるようになり、大量のファイルを扱うフォルダでも安心して差分更新を回せるようになりました。
2. 検索処理のマルチスレッド化
通常検索(インデックスを使わないその場のos.walk探索)は、従来はシングルスレッドでディレクトリを歩きながら1件ずつ評価していました。ファイルI/Oはブロッキングが多く、GILがあってもスレッド並列化の恩恵を受けやすい処理です。
まずos.walkによるファイル列挙と、名前・拡張子・内容によるマッチ判定を分離しました。
entries_to_check = list(_iter_walk_entries(...))
worker_count = _decide_search_thread_count(len(entries_to_check))
列挙件数が少ない(4000件未満)場合はスレッド生成のオーバーヘッドの方が大きいため、そのままシングルスレッドで処理します。閾値を超えた場合のみThreadPoolExecutorで並列化し、結果はロック付きのバッチバッファに集約してから一定間隔でUIスレッドへ流します。
with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as executor:
futures = [executor.submit(process_entry, item) for item in entries_to_check]
for fut in futures:
if self._stop_event.is_set():
break
fut.result()
重複ファイル検索のハッシュ計算(後述)にも同じ考え方の並列化を適用しています。
3. SQLiteインデックスの追加
インデックス検索用のSQLiteスキーマに、拡張子用のext列と、mtimeをUNIXタイムスタンプで持つmtime_epoch列を追加し、それぞれにインデックスを張りました。
conn.execute("CREATE INDEX IF NOT EXISTS idx_files_ext ON files(root, ext)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_files_size ON files(root, size)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_files_mtime ON files(root, mtime_epoch)")
拡張子はname列から都度LOWER(SUBSTR(...))で計算すると式インデックスが効きにくいため、あえて非正規化してカラムとして持たせています。既存DBに対してはマイグレーションで1回だけUPDATEしてバックフィルしています。
検索時は、単一拡張子指定・サイズ範囲・更新日時範囲があればSQL側のWHERE句に先に組み込み、Python側のフィルタ処理に流れる件数自体を減らすようにしました。
if ext_filter and len(ext_filter) == 1:
sql += " AND ext = ?"
params.append(next(iter(ext_filter)).lower())
if size_min is not None:
sql += " AND size >= ?"
params.append(size_min)
あわせてPRAGMAもいくつか調整しています(synchronous=NORMAL、temp_store=MEMORY、cache_size、mmap_size)。
4. 初回起動時のハードウェア計測による自動チューニング
バッチサイズや並列スレッド数を固定値にしていると、非力なマシンでは重く、高性能なマシンでは性能を活かしきれません。v0.7.4では初回起動時に簡易ベンチマークを行い、結果に応じてパラメータを調整するようにしました。
- CPU: 単純な演算ループの実行時間からスコア化
- ディスク: 8MBの一時ファイルを書き込み→読み込みし、スループットからSSD相当かHDD相当かを判定(150MB/s超をSSD相当とみなす)
- メモリ: 総物理メモリ量
is_ssd_like = bool(disk_write_mbps and disk_write_mbps > 150)
thread_count = max(2, min(cpu_count, 8)) if is_ssd_like else max(1, min(cpu_count // 2, 4))
if not is_ssd_like:
params['batch_size'] = max(20, int(params['batch_size'] * 0.7))
HDD相当と判定した場合は、ランダムI/Oのシーク待ちを考慮してあえて並列度・バッチサイズを控えめにしています。計測中はモーダルではなく、ステータスバー風の小さいウィンドウ(プログレスバー+一言ステータス)を出すだけにして、圧迫感を減らしています。
まとめ
Tkinter製アプリでも、I/Oバウンドな処理をThreadPoolExecutorで並列化し、SQLite側のインデックス設計を見直すだけで体感速度はかなり変わります。差分更新やハードウェア計測のような「地味だが効く」改善を積み重ねるフェーズだったバージョンでした。
次のバージョンでは検索条件のさらなる高度化を予定しています。