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倍高速化は、以下を示唆している:
refresh_cached_data内だけに限定された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キャッシュを最大限に活用することだ:
「正しさ第一、最適化第二」
この記事では、興味深いバグも紹介されている。最適化の過程でVectorDiff::Setの処理に無限ループを引き起こすバグが発生した。
浅いクローン(shallow clone)を持つ型の場合、最適化が原因で無限ループが発生する可能性があった。これは「早すぎる最適化」がバグを生む典型的な例だ。
教訓は明確だ:まず正しく動作させ、次に測定し、そして改善する。
実践への応用
いつData-oriented Designを適用すべきか
Data-oriented Designは、以下の状況で特に効果を発揮する:
測定の重要性
今回のケースでも、メモリプロファイリングとベンチマークが重要な役割を果たした。推測ではなく、実測値に基づいて最適化を行うことが不可欠だ。
参考リソース
Data-oriented Designを学ぶには、以下の資料が推奨されている:
- ゲーム開発の文脈でData-oriented Designを解説
- プログラムの唯一の目的は「データの変換」であると強調
- CPUキャッシュの仕組みとパフォーマンスへの影響を詳説
まとめ:現代のパフォーマンス最適化
Matrix Rust SDKの開発チームが経験したこのケースは、現代のパフォーマンス最適化の重要性を如実に示している。
キーポイント
98.7%削減の意味
53msから0.676msへの短縮は、単なる数字の遊びではない。これは:
- ユーザー体験の劇的改善: 5分のラグが1秒未満に
- リソース効率の向上: メモリ割り当てとCPUサイクルの節約
- スケーラビリティの確保: より多くのデータを処理可能に
Data-oriented Designは、単なる最適化テクニックではない。ハードウェアの特性を理解し、それに合わせてソフトウェアを設計するという、現代のプログラミングにおける重要なパラダイムだ。
参考リンク
- 元記事: About memory pressure, lock contention, and Data-oriented Design
- Matrix Rust SDK
- eyeball-im-util crate
- Mike Acton - Data-Oriented Design and C++ (CppCon 2014)
- Scott Meyers - CPU Caches and Why You Care
---
この記事が、パフォーマンス最適化とData-oriented Designの理解に役立つことを願っている。98.7%の高速化は魔法ではなく、ハードウェアの特性を理解し、適切な設計を選択した結果なのだ。


コメント