[Dalendar DevLog 5] カレンダーアプリの見えない敵、パフォーマンス低下と戦った記録 (トラブルシューティング)
日付計算で発生するパフォーマンス低下問題を発見し、これを解決するために割り算演算を最適化したトラブルシューティング過程を共有します。
![[Dalendar DevLog 5] カレンダーアプリの見えない敵、パフォーマンス低下と戦った記録 (トラブルシューティング)](/images/blog/dalendar_dev_5_performance.png)
5編: カレンダーアプリの見えない敵、パフォーマンス低下と戦った記録 (トラブルシューティング)
1. 始めに: うまく動作「するかのように」見えたカレンダー
この文章は開発過程で経験した「危機と克服」を扱うシリーズの5番目の記録です。
初めて「ただのカレンダー」アプリ開発を始めたときは全てが順調に見えました。目標は明確でした。一画面に一ヶ月が見え、左右にスクロールすれば前月と翌月が現れる直感的なカレンダーを作ることでした。システム資源を効率的に使用するRecyclerViewの再活用メカニズムのおかげで初期プロトタイプは60fpsを安定的に維持し、ユーザー体験には全く問題がないように見えました。
しかし日程追加、繰り返し日程計算など機能実装が深くなるほど予想できなかった問題にぶつかりました。これはアプリが異常終了する明白な「バグ」ではありませんでした。それより狡猾で目によく見えない「見えないパフォーマンス低下」という敵でした。
2. 問題 (Problem): 特定日付計算時に発生する深刻な速度低下
問題は特定状況で明確に現れました。ユーザーがスクロールを通じて数十年後の日付へ素早く移動したり、非常に遠い未来の繰り返し日程を計算する機能をテストするとき、アプリが目立って遅くなったりひどい場合数秒間止まる現象が発生しました。
最初はこれ現象が複雑になったUIのレンダリング遅延問題だと思いました。しかしプロファイリング結果、問題はビュー(View)ではなくカレンダーの核心ロジック、つまり日付計算自体の非効率性から始まっていました。特定日付を求めるための演算過程が予想よりはるかに多いCPU資源を消耗してボトルネック(bottleneck)を引き起こしていたのです。
3. 原因 (Cause): カレンダー計算に隠された複雑性と高い演算
問題の根本原因を掘り下げると、私たちが当然のように使っていたグレゴリオ暦の計算が思ったより単純ではないという事実に直面しました。
一般的にカレンダーロジックを実装するとき使用する方式の問題点は次のように分析できました。
- 照会テーブル(Look-up tables)使用: 月別日数や特定月までの累積日数を配列(look-up table)にあらかじめ保存しておいて必要なとき照会する方式はよく使われます。しかしこの方法はL1キャッシュが冷たいとき(cold cache)接近費用が多くかかり、分岐(branching)を誘発して実行パイプラインに遅延を引き起こす可能性があります。
- 遅い割り算演算: 日付計算は本質的に多くの割り算(division)と余り(remainder)演算を含みます。問題は割り算が足し算、引き算、掛け算と比較して4つの基本算術演算の中で最も遅くCPUサイクルを多く消耗する「高い」演算だという点です。数千、数万回の演算が累積されるときこの費用は無視できないパフォーマンス低下に繋がります。
4. 解決 (Solution): 割り算を掛け算に変える魔法、EAF
問題の根本原因を掘り下げると、解決策はスタックオーバーフローや技術ブログではなく、はるかにより根源的な場所にありました。まさに「Euclidean Affine Functions and Applications to Calendar Algorithms」という論文でした。
核心アイデアは簡潔明瞭です。「費用の多くかかる割り算演算を数学的には同一だが、CPUの立場でははるかに速い掛け算とビットシフト(bit shift)演算に代替すること」 です。
改善前のコード (概念)
// 概念的コード: 「世紀の日(day of the century)」から「世紀の年(year of the century)」と「年の日(day of the year)」計算
// この方式は内部的に非効率的な割り算(n2 / 1461)と余り(n2 % 1461)演算を実行する。
val n2 = 4 * r1 + 3
val q2 = n2 / 1461 // 商(year)計算
val r2 = (n2 % 1461) / 4 // 余り(day of year)計算
改善後のコード (最適化)
// 最適化されたコード: 割り算を掛け算とビットシフトに代替
// あらかじめ計算された「魔法の数字(magic number)」を使用する。
val n2: Long = 4 * r1 + 3
val u2: Long = 2939745 * n2
val q2: Long = u2 ushr 32 // u2 / 2^32 と同一 (符号なし右シフト)
// 余り計算やはり依存性なしに並列処理が可能になる。
val r2_numerator: Long = (u2 and 0xFFFFFFFFL) * 1461 // u2 % 2^32 と類似
val r2: Long = (r2_numerator ushr 32) / 4
最適化の核心原理説明
改善されたコードは2つの核心的な長所を持ちます。
第一に、遅い割り算演算が速い掛け算とビットシフト演算に代替され演算速度が大きく向上します。 第二に、「データ依存性除去(breaking a data dependency)」 です。既存方式では商(q2)を計算してこそ余り(r2)を計算できましたが、最適化された方式では中間値u2が計算されればq2とr2をお互い待つ必要なく同時に(concurrently)計算できます。これは最新CPUの命令語レベル並列処理能力を極大化します。
5. 結論: 見えないバグを捕まえる開発者の姿勢
単によく動作するかのように見えたカレンダーアプリの裏側には「遅い割り算」と「データ依存性」という見えない性能の敵が隠れていました。今回のトラブルシューティングは「問題-原因-解決」の過程を通じてこの問題を解決した記録です。
今回の経験を通じて単に「動作する」コードを作成することを超えて、その裏側に隠された性能問題の根本原因を掘り下げる姿勢がどれほど重要かもう一度悟りました。
![[Dalendar DevLog 1] プロジェクトの始まりと技術スタック選定](/images/blog/dalendar_dev_1_ideation.png)
![[Dalendar DevLog 2] 堅固なアプリの骨組み - アーキテクチャとデータ構造設計](/images/blog/dalendar_dev_2_architecture.png)
![[Dalendar DevLog 3] コア機能実装 1 (バックエンド & ロジック)](/images/blog/dalendar_dev_3_math_logic.png)