コンテンツへスキップ
戻る

バックエンド屋の性能最適化 実戦(1):問題を観測する

公開日:  at  08:00 午前

バックエンド屋として、新システムの開発・設計能力に加えて、「問題を発見する能力」も必携スキルの一つです。とりわけ、AI がもてはやされる今の環境ではなおさらです。すでに無数の人が問題を AI に丸投げし、AI にすべてを掌握させようとしています。これが正しいか間違っているか、私には論じる資格も能力もありません。しかし「正しいか間違っているか」よりはるかに重要なのは、私たちが意思決定の権限をすべて AI に渡してしまい、自分自身の能力を育てることを疎かにしているという事実です。これは間違いなく致命的です。致命的なのは、それが何か目先の害をもたらすからではありません。弾はしばらく飛ばせておけ——本業を外部委託した結果は、たいてい今この瞬間には表れません。ちょうどアメリカが 40 年経った今になって、国際化の名のもとに工場を海外移転したせいで国内の生産力がすっかり脆弱になったと気づき、だからこそアメリカ企業の国内回帰を大々的に推し進めているように。AI も同じ理屈です。七巨頭は際限なく計算資源を競い合っていますが、最後はやはりハードウェア資源の壁に突き当たり、半導体チップ業界の顔色をうかがうことになります。

話が長くなりました。とにかく私が強調したいのは、個人の能力を育てることは依然として無視できない、ということです。今回は AI に実際のテストを組み合わせて、AI の知識を学び、検証していきます。

まず、性能実戦の面では、いきなり最適化コードを書き始める前に、まず「性能が低い」とはどういうことかを話す必要があります。 現行の考え方では、性能低下の指標はたいてい次のようなものです。

CPU 使用率

CPU の問題によくある原因には、大量の計算集約的な処理(暗号化・復号、Context Switch など)、JVM の頻繁な GCシリアライズのオーバーヘッド、不適切なスレッドの宣言、無限ループ、などがあります。

Memory 使用率

CPU の話が済んだので、次は Memory の登場です。多くの場合、GCMemory はセットになっています——Memory をうまく管理できないと GC が頻繁に発動し、頻発の末路は Stop The World(STW) が頻出することです。サービスは停止するのに、ユーザーは使い続けます。Thread が次々に生成される状況では、異なる ThreadCPU 資源を奪い合って CPU 使用率が高騰し、各 Thread がまた Memory 資源を消費するので、最終的に CPUMemory も同じ泥沼に引きずり込まれます。だからこそ Memory 使用率が高いことは、たいてい CPU 使用率が高いことよりも警戒に値します。連鎖反応を起こしやすいからです。

その他のよくある原因には、Java Heap の設定問題、Memory LeakIO でファイル全体を直接読み込む、コネクションプールに TTL を設定していない、などがあります。

GPU 使用率

CPUMemory に比べると、GPU 使用率は伝統的なバックエンドサービスにとってはやや縁遠い指標です——サービスが AI 推論(Inference)、画像・動画処理、あるいは機械学習関連の Batch Job に関わっていない限り、普段はわざわざこの指標を監視することはありません。しかし今や AI サービスが至るところにあふれ、モデル推論、Embedding 演算、画像認識といった要件がバックエンドアーキテクチャに現れることがますます増えています。GPU 使用率も無視できなくなってきたのです。

よくある原因には、Batch Size の設定が不適切なこと——Batch SizeAI Model が一度に処理できるデータ量だと理解してよく、小さすぎると GPU の並列演算の強みを活かせず、大きすぎると VRAM を溢れさせやすい——モデル自体が最適化されていないこと(量子化 Quantization をしていない、TensorRT などの推論高速化フレームワークを使っていない)、複数の Process が同じ 1 枚の GPU を奪い合うこと(資源分離やスケジューリングをきちんとしていない)、などの問題があります。

この部分は目を通す程度で構いません。正直なところ私はこの分野にあまり詳しくなく、今後も扱わないので。

I/O Wait(ディスク読み書きの遅延)

I/O Wait とは、CPU がディスク(またはその他の I/O デバイス)のデータ読み書き完了を待つ間に生じる、遊休時間を指します。ここで念のため先に注意しておきますが、基本的にデータ読み取りの速さは保存場所によって異なり、速い順にレジスタキャッシュメモリ(L1、L2)主記憶ディスクとなり、このうちディスクの読み書き速度が最も遅いものです。

I/O Wait の概念は外部資源のようなもので、CPUMemory に直接負担をかけるわけではありませんが、CPU/Memory はどちらも I/O Wait を待たねばならないため、やはりシステムの反応速度を低下させ、間接的に CPUMemory が資源を解放できない状態を引き起こします。したがって、CPU 使用率は高くないのにシステムの反応が非常に遅い、というときは、I/O Wait が真っ先に疑うべき対象です。

よくある原因には、ディスクの読み書き速度が遅すぎること(従来型の HDD など)、大量の同期読み書き操作、Log の書き込みが頻繁すぎて Buffer の仕組みがないこと、データベースのクエリにインデックスがなく Full Table Scan を招くこと、などがあります。

特筆すべきは、I/O Wait が前述の MemoryGC の問題としばしば互いに絡み合うことです——Memory 不足が Swap を招き、Swap がまた大量の I/O を生み、I/O 遅延が全体の応答時間を引きずり下ろし、悪循環を形成します。これこそ、システムを観測するときに単一の指標だけを見てはならず、これらの指標を並べて突き合わせてこそ、本当の根本原因を見つけられる理由です。

Network Latency(ネットワーク遅延)

Network Latency とは、リクエストを送ってから応答を受け取るまでに経過する時間を指します。I/O Wait と似ている点は、これも一種の「外部資源を待つ」遅延であることです。ただし今回待つ相手はディスクではなく、ネットワークの向こう側——データベース、キャッシュサーバー、サードパーティ API、あるいはマイクロサービスアーキテクチャ下の別の Service かもしれません。

よくある原因には、DNS 解決の遅さ、リージョンをまたぐ・国をまたぐネットワークリクエスト、TCP のスリーウェイハンドシェイク(Three-way Handshake)と TLS Handshake の追加オーバーヘッド、下流サービス自体の応答が遅いこと(遅延をあなたに転嫁している)、マイクロサービスアーキテクチャ下で呼び出しチェーンが長すぎること(1 つの Request が結果を得るまでに何層もの Service を経由し、各層が遅延を積み重ねる)、Connection Pool の不足で Request が利用可能なコネクションを待って行列すること、などがあります。

Thread / Connection Pool の使用率

Thread PoolConnection Pool の概念は、「再利用できる資源をあらかじめ一定量用意しておき、毎回作り直すのを避ける」ことです。違いは、Thread Pool が管理するのはスレッド、Connection Pool が管理するのはコネクション(データベース接続、HTTP 接続など)である点です。 この 2 つの指標は徴候であり、CPU/Memory 使用率が急騰してボトルネックを突き止める必要があるとき、こうした方向へ調べていける手がかりになります。

Thread Pool の使用率が長期にわたって上限に張り付いているのを見たとき、その背後の原因は次のようなものかもしれません。ある Blocking 呼び出しが Thread を掴んで離さない(例えば外部 API への同期呼び出しに Timeout を設定していない)、データベースのクエリが遅すぎて Thread がなかなか解放されない、あるいは単純に Thread Pool Size の設定が保守的すぎる——ハードウェア資源は十分足りているのに、十分なスレッドを開放していない、といったことです。

Connection Pool のほうは、データベース接続や HTTP Client でよく見られます。Connection Pool の使用率が長期的に高止まりしていたり、さらには「利用可能なコネクションを取得できない」Timeout エラーが頻発したりする場合、よくある原因には、コネクションを使い終わったのに返却し忘れる(とりわけ例外発生時に、try-with-resourcesfinally でコネクションを正しくクローズ/解放していない)、Pool Size の設定が小さすぎる、単一の Request がコネクションを占有する時間が長すぎる(例えば 1 つの Transaction に不要なロジックを詰め込みすぎて、なかなか Commit してコネクションを解放しない)、などがあります。

特筆すべきは、この 2 つの指標は「早期警戒」に非常に向いていることです——問題が実際に起きてから初めて高騰することの多い CPUMemory と比べて、Thread PoolConnection Pool の使用率はしばしば早めに異常が現れます。本当に爆発する前の段階で、優先的に注視する価値のあるダッシュボードです。

GC(Garbage Collection)の頻度と所要時間

GC の概念は、誰も使っていないオブジェクトを回収し、Memory 空間を解放して、Heap が無限に膨張しないようにすることです。しかし GC は無償ではなく、とりわけ頻度と所要時間の 2 つは、いったん制御を失うと CPU/Memory の二重の問題を引き起こします。一般に GC の指標は CPU/Memory と併せて見るべきです。

GC がしょっちゅう発動するなら、それはオブジェクトの生成速度が速すぎることを意味します。これはたいてい、コードの中で大量の「使い捨て」の一時的オブジェクトが生成されていること(前述のシリアライズ、文字列連結、Stream 操作が生む中間オブジェクトによく見られます)、あるいは Heap の設定が小さすぎて、通常のトラフィック下でのオブジェクト生成速度を支えきれないことを意味します。

単一の GC 実行時間が延びたり、Full GCOld Generation を対象とした完全な回収)が発生したりすると、サービスに明確な遅延スパイクが現れ、Health Check がサービス停止と誤判定することさえあります。Full GC の所要時間が延びるよくある原因には、Old Generation に長期生存オブジェクトが詰め込まれすぎていること(本当の Memory Leak かもしれないし、Cache に淘汰の仕組みを設定していないだけかもしれない)、Heap を大きく設定しすぎて 1 回のスキャン範囲が広すぎること、GC アルゴリズムの選択が不適切であること(例えば高トラフィック・低遅延の場面で、大きな Heap の処理が苦手なアルゴリズムを使う)、などがあります。

GC という指標が特別なのは、それがほぼ CPU 問題と Memory 問題の交差点であることです——GC の頻発はたいてい CPU 使用率の上昇を伴い、GC の所要時間が長いことはしばしば Memory 設定やオブジェクトのライフサイクル管理の問題に対応します。ですから GC を観測することは、ある意味で CPUMemory という 2 つの健康状態を同時に観測していることになります。

Throughput(スループット)

Throughput とは、システムが単位時間内に処理できるリクエスト数を指します(よくある単位は TPSQPS——Transactions/Queries Per Second)。通常、システム全体の性能を評価したいときに Throughput を使い、利用場面はしばしば負荷テストや高トラフィックの場面で、Throughput の変化を観測することです。

Throughput をどう向上させるかは、しばしばシステム全体の問題であり、実際には前述のすべての指標に関わる、いわば集大成的な判断基準です。

Response Time(応答時間、P50 / P95 / P99 を含む)

Response Time とは、リクエストを発してから応答を受け取るまでに費やす総時間を指します。Throughput と同じく、これも総合指標ですが、両者は見る角度が異なります——Throughput が答えるのは「システム全体でどれだけのリクエストを支えられるか」、Response Time が答えるのは「ユーザーが実際に感じる待ち時間」です。

ここで特に P50P95P99 という用語に触れておきたいのは、平均値(Average)だけを見ていると誤解を招きやすいからで、私たちはさらに次を見なければなりません。

現実の場面では、平均値だけを凝視していては足りません。P99(さらには P99.9)こそが、システムのロングテール問題を捉える鍵です。前段のどこか一つでも不具合が起きれば、最終的にはすべて Response Time の上昇として表れます。

これで、私たちが性能低下の指標をどう評価するかが分かりました。指標は多く、各問題はしばしば連鎖反応であり、改善を施しても決してその場で効果が出るとは限りません。ですから修正する前に、より重要なのは問題を識別する手段であり、十分な観測と評価こそが、無駄骨を折るのを避けさせてくれます。というわけで次回は、続いてこれらの数値をどう評価し監視するかについて話していきます。


修正を提案する
この記事をシェアする:

前の記事
バックエンド屋の性能最適化 実戦(2):Spring Boot Actuator
次の記事
Buffer Pool と MySQL の Query Cache