98.7%高速化の秘密:メモリ圧力・ロック競合・Data-oriented Designが変えた世界

Machine Learning

98.7%高速化の秘密:メモリ圧力・ロック競合・Data-oriented Designが変えた世界

はじめに:凍りついたRoom List

2026年2月、Matrix Rust SDKの開発チームは奇妙なバグに直面した。ユーザーが報告した問題は「Room List(ルームリスト)が凍りついている」というものだった。しかし、これは一般的なフリーズではなかった。Room Listは実際には「空白」になっていたのだ。

報告者たちは「凍りついている」と表現したが、実際には処理に5分以上かかっていた。そして何より不可解なのは、この問題が再現性がないことだった。同じ条件でテストしても、問題が発生したりしなかったりする。まるでランダムな振る舞い。

この謎を解き明かす過程で、開発チームはメモリ圧力ロック競合という2つの重大な問題を発見し、最終的にData-oriented Designを適用することで、実行時間を98.7%短縮し、スループットを7718.5%向上させることに成功した。

本記事では、この劇的なパフォーマンス改善の全貌を詳しく解説する。

問題の正体:322,042回のメモリ割り当て

ランダム性の謎

開発チームがまず疑ったのは、Room Listのソート処理だった。Element X(Matrix Rust SDKを使用するクライアント)のメモリ分析を行ったところ、驚愕の事実が判明した。

322,042回のメモリ割り当てが発生しており、その総量は743MiBに達していたのだ。原因はeyeball_im_util::vector::sort::SortBy型だった。

なぜランダムなのか?調査の結果、初期ソート処理でQuicksortが使用されており、そのピボット選択に疑似乱数生成器(PRNG)が使われていたことが判明した。ピボットの選び方によって比較回数が変わり、メモリ割り当ての回数も変動する。これが再現性のなさの原因だった。

メモリ圧力の実態

322,042回のメモリ割り当ては、単に「多い」というだけではない。その背後には深刻なメモリ圧力が存在していた。

#### レイテンシーの可視化

操作ごとのレイテンシーを「人間スケール」で比較すると、問題の深刻さが理解できる:

| 操作 | 時間(ナノ秒) | 人間スケール(1ns = 1分) |
|——|—————|—————————|
| L1キャッシュからのフェッチ | 1ns | 1分 |
| 分岐予測ミス | 3ns | 3分 |
| L2キャッシュからのフェッチ | 4ns | 4分 |
| Mutexロック/アンロック | 17ns | 17分 |
| メインメモリからのフェッチ | 100ns | 1時間40分 |
| SSDランダムリード | 16,000ns | 11.11日 |

L1キャッシュ(1ns)とメインメモリ(100ns)の差は、1分と1時間40分の差に相当する。つまり、メインメモリへのアクセスはL1キャッシュより100倍遅いのだ。

#### ソート処理の問題点

Room Listのソート処理では、latest_eventソーターとrecencyソーターが共にRoom::latest_event()メソッドを呼び出していた:

pub fn latest_event(&self) -> LatestEventValue {
    self.info.read().latest_event.clone()
}

このコードには2つの問題がある:

  • クローン作成: LatestEventValue型は144バイトあり、ソートの反復ごとに2回(左と右のRoomに対して)クローンされる
  • ロック取得: info.read()で読み取りロックを取得し、その後クローンを作成して解放する
  • 322,042回の割り当ては、この処理が繰り返された結果だ。さらに、2つのソーターが同じメソッドを呼び出すため、実際には2倍の負荷がかかっていた。

    第1の改善:メモリ圧力の軽減

    解決策:必要なデータだけを取得

    開発チームは、LatestEventValue全体をクローンするのではなく、各ソーターが必要な情報だけを取得するメソッドを作成した:

    pub fn latest_event_is_local(&self) -> bool {
        self.info.read().latest_event.is_local()
    }

    pub fn latest_event_timestamp(&self) -> Option { self.info.read().latest_event.timestamp() }

    • latest_event_is_local()boolを返す(スタックに収まる)
    • latest_event_timestamp()Optionを返す(これもスタックに収まる)

    この変更により、スループットが18%向上した。しかし、これだけでは不十分だった。報告者たちは5分のラグを訴えていたが、18%の改善では残り4分6秒が残るからだ。

    第2の問題:ロック競合

    322,042回のロック取得

    メモリ圧力は軽減されたが、まだ根本的な問題が残っていた:ロックの取得だ。

    self.info.read()という読み取りロックは、322,042回の割り当てと同じ回数だけ取得されていた。ロックの取得自体は17nsと高速だが、頻繁な取得はロック競合を引き起こす可能性がある。

    Data-oriented Designの導入

    開発チームは発想を変えた:

    • ソーターが必要とするのはデータ
    • ソート処理自体はデータを変更しない
    • データが変更された時だけ、ソーターが再実行される

    ならば、必要なデータを事前にキャッシュし、ソート処理ではロックを一切取得しない設計にすればよいのではないか?

    これがData-oriented Design(データ指向設計)のアプローチだ。

    RoomListItem型の導入

    新しい型RoomListItemを作成し、すべてのソーターが必要なデータを事前にキャッシュする:

    pub struct RoomListItem {
        /// Room::latest_event_timestampのキャッシュ
        cached_latest_event_timestamp: Option,
        
        /// Room::latest_event_is_localのキャッシュ
        cached_latest_event_is_local: bool,
        
        /// Room::recency_stampのキャッシュ
        cached_recency_stamp: Option,
        
        /// Room::cached_display_nameのキャッシュ(文字列として)
        cached_display_name: Option,
        
        /// Room::is_spaceのキャッシュ
        cached_is_space: bool,
        
        /// Room::stateのキャッシュ
        cached_state: RoomState,
    }

    impl RoomListItem { fn refresh_cached_data(&mut self, room: &Room) { self.cached_latest_event_timestamp = room.new_latest_event_timestamp(); // 他のフィールドも同様に更新 } }

    この型のサイズは64バイトで、現代のCPUのキャッシュラインサイズ(128バイト)に収まる。

    ソーターの簡素化

    新しいソーターは非常にシンプルになった:

    pub fn new_sorter() -> impl Sorter {
        let latest_events = |left: &RoomListItem, right: &RoomListItem| {
            (left.cached_latest_event_is_local, right.cached_latest_event_is_local)
        };
        
        move |left, right| -> Ordering { cmp(latest_events, left, right) }
    }
    

    ロック取得は一切なく、単純なフィールドアクセスだけだ。

    劇的な結果:78倍の高速化

    ベンチマーク結果

    改善前後のベンチマーク結果は衝撃的だった:

    改善前:

    RoomList/Create/1000 rooms × 1000 events
      time: [53.027 ms 53.149 ms 53.273 ms]
      thrpt: [18.771 Kelem/s 18.815 Kelem/s 18.858 Kelem/s]
    

    改善後:

    RoomList/Create/1000 rooms × 1000 events
      time: [676.29 µs 676.84 µs 677.50 µs]
      thrpt: [1.4760 Melem/s 1.4775 Melem/s 1.4787 Melem/s]
      change:
        time: [-98.725% -98.721% -98.716%] (p = 0.00 < 0.05)
        thrpt: [+7686.9% +7718.5% +7745.6%]
    

    数値で見る改善

    • 実行時間: 53ms → 0.676ms(78倍高速化
    • スループット: 18.8Kelem/s → 1.4Melem/s(7718.5%向上
    • 時間短縮率: 98.7%削減

    なぜこれほど高速化したのか?

    L1キャッシュアクセス(1-4ns)とメインメモリアクセス(100ns)の差は、平均して約40倍だ。今回の78倍高速化は、以下を示唆している:

  • L1キャッシュヒット率の向上: ほとんどのデータアクセスがL1キャッシュで完結している
  • ロック競合の完全排除: ロック取得がrefresh_cached_data内だけに限定された
  • メモリ割り当ての劇的削減: 322,042回からほぼゼロへ
  • Data-oriented Designの核心

    Array of Structures vs Structure of Arrays

    今回の実装は、Data-oriented Designの用語で言うとArray of Structures(AoS)に該当する:

    let rooms: Vec;  // AoS
    

    より効率的なのはStructure of Arrays(SoA)だ:

    struct RoomListItems {
        a: Vec,
        b: Vec,
        c: Vec,
    }
    let rooms: RoomListItems;  // SoA
    

    SoAは、単一のループで1つのフィールドだけを処理する場合、キャッシュ効率がさらに向上する。パディングが減り、無駄なバイトを読み込まなくて済むからだ。

    ただし、今回のケースでは複数のソーターが異なるフィールドにアクセスするため、AoSが適切だった。

    CPUキャッシュを意識した設計

    Data-oriented Designの核心は、CPUキャッシュを最大限に活用することだ:

  • キャッシュラインサイズを意識する: データ構造をキャッシュライン(通常128バイト)に収める
  • データの局所性を高める: 関連するデータを隣接して配置する
  • ポインタ追逐(pointer chasing)を避ける: 間接参照を減らし、シーケンシャルアクセスを増やす
  • 「正しさ第一、最適化第二」

    この記事では、興味深いバグも紹介されている。最適化の過程でVectorDiff::Setの処理に無限ループを引き起こすバグが発生した。

    浅いクローン(shallow clone)を持つ型の場合、最適化が原因で無限ループが発生する可能性があった。これは「早すぎる最適化」がバグを生む典型的な例だ。

    教訓は明確だ:まず正しく動作させ、次に測定し、そして改善する

    実践への応用

    いつData-oriented Designを適用すべきか

    Data-oriented Designは、以下の状況で特に効果を発揮する:

  • 大量のデータを処理する: 数千〜数百万のオブジェクト
  • 頻繁なイテレーション: 同じデータを何度も走査する
  • パフォーマンスがボトルネック: 実測値で問題が確認されている
  • 測定の重要性

    今回のケースでも、メモリプロファイリングとベンチマークが重要な役割を果たした。推測ではなく、実測値に基づいて最適化を行うことが不可欠だ。

    参考リソース

    Data-oriented Designを学ぶには、以下の資料が推奨されている:

  • Mike Acton「Data-Oriented Design and C++」(CppCon 2014)
  • - ゲーム開発の文脈でData-oriented Designを解説
    - プログラムの唯一の目的は「データの変換」であると強調

  • Scott Meyers「CPU Caches and Why You Care」(code::dive 2014)
  • - CPUキャッシュの仕組みとパフォーマンスへの影響を詳説

    まとめ:現代のパフォーマンス最適化

    Matrix Rust SDKの開発チームが経験したこのケースは、現代のパフォーマンス最適化の重要性を如実に示している。

    キーポイント

  • メモリ階層を理解する: L1/L2キャッシュとメインメモリの速度差は100倍
  • ロック競合を避ける: 頻繁なロック取得はパフォーマンスの敵
  • データレイアウトを最適化する: CPUキャッシュに優しい設計を選ぶ
  • 測定してから最適化する: 推測ではなく実測値に基づく
  • 98.7%削減の意味

    53msから0.676msへの短縮は、単なる数字の遊びではない。これは:

    • ユーザー体験の劇的改善: 5分のラグが1秒未満に
    • リソース効率の向上: メモリ割り当てとCPUサイクルの節約
    • スケーラビリティの確保: より多くのデータを処理可能に

    Data-oriented Designは、単なる最適化テクニックではない。ハードウェアの特性を理解し、それに合わせてソフトウェアを設計するという、現代のプログラミングにおける重要なパラダイムだ。

    参考リンク

    ---

    この記事が、パフォーマンス最適化とData-oriented Designの理解に役立つことを願っている。98.7%の高速化は魔法ではなく、ハードウェアの特性を理解し、適切な設計を選択した結果なのだ。

    コメント

    タイトルとURLをコピーしました