CPUが100%でも「遊んでいる」状態とは

データベース性能のボトルネックはメモリ遅延?CPUが100%でも遅い根本原因を深掘りのイメージ画像

データベースの運用において、CPU使用率が常に100%を示しているにもかかわらず、システムのパフォーマンスが期待通りに向上しないという状況に遭遇したことはないでしょうか。これは一見するとCPUがフル稼働しているように見えますが、実際には異なるメカニズムが働いている可能性があります。ブログ記事「It's the memory latency, stupid」(Hacker News)では、この現象の主な原因がメモリ遅延にあると指摘しています。

CPUは100%稼働しているように見えても、実際にはメモリからのデータ取得待ちでアイドル状態になっていることが多い。

現代のCPUはナノ秒単位で4〜8命令を処理できる一方で、メインメモリ(RAM)へのアクセスは50〜100ナノ秒かかることがあります。この速度差は非常に大きく、CPUがメモリからのデータ到着を待つ間、多くの命令処理機会を失っていることを意味します。つまり、CPUは「タスクがない」わけではないものの、「処理すべきデータがない」ために待機している状態が発生するのです。

CPUキャッシュの重要性:GROUP BY処理を例に

データベース性能のボトルネックはメモリ遅延?CPUが100%でも遅い根本原因を深掘りのイメージ画像

CPUがこのメモリ遅延を回避するために設計されたのが、CPUキャッシュです。L1キャッシュはCPUに最も近く、メモリ操作はわずか1ナノ秒程度で完了します。これはRAMと比べて50〜100倍も高速です。

しかし、L1キャッシュは容量が小さく、現代のCPUでは約64KB程度しかありません。L2、L3キャッシュも数百MBに留まります。

記事では、GROUP BY集計を例にキャッシュの活用がいかに重要かを説明しています。ナイーブな実装として、一意のグループキーをキーとするハッシュテーブルを構築し、各行でキーの存在確認と集計値の更新を行う方法が考えられます。しかし、数百万ものユニークなグループが存在する場合、ハッシュテーブルのアクセスはほとんどが「コールドヒット」となり、頻繁にRAMへのアクセスが発生します。

項目 CPU内部演算 RAMアクセス L1キャッシュアクセス
所要時間(目安) 0.1-0.25ns 50-100ns 約1ns
速度差(RAM比) 200-1000倍 1倍 50-100倍

ハッシュテーブルが大きすぎると、キャッシュミスが頻発し、CPUはRAMからのデータ取得を待ち続けることになります。

これにより、見た目のCPU使用率が100%であっても、実際にはCPUがメモリ待ちで多くの時間を費やしているため、期待するパフォーマンスが出ないという状況に陥ります。データベースのパフォーマンスエンジニアリングにおいて、キャッシュの最適化が最優先事項の一つとされているのは、このためです。

実際の検証:素朴なGROUP BYの実装

記事では、この問題を具体的に示すため、Pivotエンジンという現代的なデータベースエンジンを意図的に最適化されていない素朴なGROUP BY実装に置き換えて検証しています。具体的には、ユーザーIDごとのカウントを集計する SELECT count(*), UserID FROM hits GROUP BY UserID クエリを実行し、約1,700万の一意のユーザーIDを持つデータセット(全9,900万行)を使用しました。

この検証は、CherryServersのベアメタルサーバー(AMD EPYC 9124 Genoa、16コア、128 GB DDR5)上で行われました。AWSやGCPのような一般的なクラウド環境ではなくベアメタルサーバーが選ばれたのは、DRAMコントローラからのメモリ転送量など、セキュリティ上の理由でクラウドでは公開されていない低レベルなカウンタにアクセスするためです。(Hacker News)これにより、CPUの振る舞いやメモリアクセスの実態をより詳細に分析することが可能になります。

低レベルなカウンタにアクセスすることで、クラウド環境では見えにくいメモリ転送の実態を把握できるのは、貴重な知見につながるでしょう。

この素朴な実装では、1コアに処理を限定しており、大量のユニークキーが存在する状況でのパフォーマンス低下が顕著に現れることが示唆されています。これは、キャッシュを効率的に利用できない場合の現実的なボトルネックを浮き彫りにする良い例と言えるでしょう。

エンジニア目線で見ると:キャッシュ効率はアプリケーション性能の生命線

今回の記事は、データベースのパフォーマンスチューニングにおいて、単にCPU使用率を見るだけでは不十分であり、メモリ階層全体を意識することの重要性を改めて教えてくれます。特に、PythonやTypeScriptで日頃アプリケーション開発をしていると、低レベルなメモリ挙動を直接意識する機会は少ないかもしれません。しかし、大規模データ処理や高スループットが求められるバックエンドサービスでは、フレームワークやライブラリの裏側でこのような最適化が絶えず行われていることを理解しておくべきだと感じます。

Pythonで dict を多用するケースや、TypeScriptで巨大な Map オブジェクトを扱う場合、キーのカーディナリティ(一意性の高さ)によっては、今回のGROUP BYの例と同じようなキャッシュミスによる性能劣化が発生する可能性があります。特に、個人プロダクトでリアルタイム性が求められる分析基盤などを構築する際には、BigQueryのような最適化されたDWHサービスに任せるだけでなく、自前で実装する部分ではデータの局所性やキャッシュ効率を意識したデータ構造・アルゴリズムの選択が重要になってくるのではないでしょうか。

例えば、データを小さいチャンクに分割して処理したり、キャッシュヒット率の高いデータ構造を選定したりすることで、CPUがフル稼働しているのに遅い、という事態を回避できると考えます。Claude Codeにこの手の最適化コードを投げてみたら、案外すぐに適切なデータ構造や処理の改善案を提示してくれそうな予感がしますね。

開発者としては、アプリケーションレベルでキャッシュ効率を意識した設計を心がけるべきだと考えます。

また、今回の検証でベアメタルサーバーが使われたのは興味深い点です。クラウド環境ではハードウェアの詳細が抽象化されるため、低レベルなパフォーマンスプロファイリングには限界があります。GCPのCloud RunやBigQueryを主軸に使っている私としては、普段あまり意識しないハードウェアレイヤーでの最適化の奥深さを感じさせられます。

GPU搭載の自作PCでローカルLLMを動かす際にも、メモリ帯域やキャッシュサイズがモデルの推論速度に直結するため、この知識は非常に有用だと感じます。メモリ価格の動向も常にチェックしている身としては、DDR5のような高速メモリの普及が、今後のデータベース性能にどう影響していくのかにも注目していきたいですね。