過去10年間、ハードウェアは目覚ましい進歩を遂げてきました。消費者向けGPUの動作方法を再定義したユニファイドメモリから、ラップトップ上で数十億パラメータのAIモデルを実行できるニューラルエンジンまで。

それでも、ソフトウェアは依然として遅いままです。シンプルなサーバーレス関数のコールドスタートに数秒かかったり、CSVファイルをデータベースの行に変換するだけのETLパイプラインに何時間もかかったりします。

2011年、高頻度取引エンジニアのMartin Thompsonはこれらの問題に気づき、それらを機械的共感(Mechanical Sympathy)の欠如に起因するとしました。彼はこのフレーズをF1チャンピオンから借用しました:

レーシングドライバーになるためにエンジニアである必要はありませんが、機械的共感は必要です。

-- サー・ジャッキー・スチュワート、F1ワールドチャンピオン

私たちは(通常)レースカーを運転しているわけではありませんが、この考え方はソフトウェア開発者にも当てはまります。ソフトウェアが動作するハードウェアに「共感」を持つことで、驚くほどパフォーマンスの高いシステムを作成できます。機械的共感に基づくLMAXアーキテクチャは、単一のJavaスレッドで毎秒数百万件のイベントを処理します。

Martinの研究に触発され、私は過去10年間、パフォーマンスを重視したシステムの作成に取り組んできました。Wayfairで数百万の製品を提供するAI推論プラットフォームから、Protocol Buffersを上回る新しいバイナリエンコーディングまで。

本記事では、このようなシステムを作成するために私が日常的に使用している機械的共感の原則について解説します。これらの原則は、どのような規模でも、ほとんどの場所に適用できます。

非ランダムなメモリアクセス

機械的共感は、CPUがメモリをどのように保存、アクセス、共有するかを理解することから始まります。

cpu-memory-structure.png

図1:CPUメモリの構成を示す抽象図

現代のほとんどのCPU(IntelのチップからAppleのシリコンまで)は、メモリをレジスタ、バッファ、キャッシュの階層に整理しており、それぞれに異なるアクセスレイテンシがあります:

  • 各CPUコアには、高速なレジスタとバッファがあり、ローカル変数や実行中の命令などを保存するために使用されます。
  • 各CPUコアには独自のLevel 1 (L1) キャッシュがあり、コアのレジスタやバッファよりもはるかに大きいですが、少し遅いです。
  • 各CPUコアには独自のLevel 2 (L2) キャッシュがあり、L1キャッシュよりもさらに大きく、L1とL3キャッシュの間のバッファとして機能します。
  • 複数のCPUコアがLevel 3 (L3) キャッシュを共有しており、これはこれまでで最大のキャッシュですが、L1やL2キャッシュよりもはるかに遅いです。このキャッシュはCPUコア間でデータを共有するために使用されます。
  • すべてのCPUコアはメインメモリ、つまりRAMへのアクセスを共有します。このメモリは、CPUがアクセスする中で桁違いに遅いです。

CPUのバッファは非常に小さいため、プログラムは頻繁に低速なキャッシュやメインメモリにアクセスする必要があります。このアクセスのコストを隠すために、CPUは賭けのような動作をします:

  • 最近アクセスされたメモリは、おそらくすぐに再びアクセスされるでしょう。
  • 最近アクセスされたメモリの近くのメモリは、おそらくすぐにアクセスされるでしょう。
  • メモリアクセスはおそらく同じパターンを繰り返すでしょう。

実際には、 これらの賭けにより、リニアアクセスは同じページ内のアクセスよりも優位であり、それはページをまたぐランダムアクセスよりも圧倒的に優位です。

予測可能でシーケンシャルなデータアクセスを可能にするアルゴリズムやデータ構造を優先してください。たとえば、ETLパイプラインを構築する際には、キーごとに1つずつエントリをクエリするのではなく、ソースデータベース全体をシーケンシャルにスキャンして無関係なキーを除外してください。

キャッシュラインとフォールスシェアリング

L1、L2、L3キャッシュ内では、メモリは通常「チャンク」として保存され、これをキャッシュラインと呼びます。キャッシュラインは常に2のべき乗の連続した長さで、しばしば64バイトです。

CPUは常にキャッシュラインの倍数でメモリをロード(「読み込み」)またはストア(「書き込み」)します。これにより微妙な問題が生じます:2つのCPUが同じキャッシュライン内の2つの別々の変数に書き込んだ場合、何が起こるでしょうか?

cpu-false-sharing.png

図2:2つのCPUが異なる変数にアクセスしていても、それらの変数が同じキャッシュライン内にある場合に競合が発生する様子を示す抽象図。

これをフォールスシェアリング(False Sharing)と呼びます:同じキャッシュライン内の2つの異なる変数へのアクセスを2つのCPUが争い、CPUが共有L3キャッシュを介して交互に変数にアクセスせざるを得なくなる状態です。

フォールスシェアリングを防ぐため、多くの低レイテンシアプリケーションでは、各ラインに実質的に1つの変数しか含まれないように、キャッシュラインを空のデータで「パディング」します。その差は驚くべきものです:

  • パディングがない場合、キャッシュラインのフォールスシェアリングにより、スレッドの追加に伴ってレイテンシがほぼ直線的に増加します。
  • パディングがある場合、スレッドの追加に伴ってレイテンシはほぼ一定に保たれます。

重要なのは、フォールスシェアリングは変数が書き込まれる場合にのみ発生するということです。読み取られる場合には、各CPUはキャッシュラインをローカルキャッシュやバッファにコピーでき、他のCPUのコピーとそれらのキャッシュラインの状態を同期させる必要はありません。

この動作のため、フォールスシェアリングの最も一般的な被害者の1つはアトミック変数です。これらは(ほとんどの言語で)スレッド間(およびCPUコア間)で安全に共有および変更できる数少ないデータ型の1つです。

マルチスレッドアプリケーションで最後の1ビットの性能を追求している場合、複数のスレッドによって書き込まれているデータ構造があるかどうか、そしてそれがフォールスシェアリングの被害者である可能性があるかどうかを確認してください。

シングルライター原則

マルチスレッドシステムを構築する際に生じる問題は、フォールスシェアリングだけではありません。安全性と正確性の問題(レースコンディションなど)、スレッド数がCPUコア数を超えた場合のコンテキストスイッチのコスト、ミューテックス(「ロック」)の苛烈なオーバーヘッドがあります。

これらの観察から、私が最も使用している機械的共感の原則に至ります:シングルライター原則(Single Writer Principle)です。

概念としてはシンプルです:アプリケーションが書き込むデータ(インメモリ変数など)やリソース(TCPソケットなど)がある場合、それらの書き込みはすべて単一のスレッドによって行われるべきです。

テキストを消費し、そのテキストのベクトル埋め込みを生成するHTTPサービスの最小限の例を考えてみましょう。これらの埋め込みは、テキスト埋め込みAIモデルを介してサービス内で生成されます。この例ではONNXモデルを想定しますが、Tensorflow、PyTorch、またはその他のAIランタイムでも動作します。

multiple-writers.png

図3:素朴なテキスト埋め込みサービスを示す抽象図

このサービスはすぐに問題に直面します:ほとんどのAIランタイムは、モデルへの推論呼び出しを一度に1つしか実行できません。上記の素朴なアーキテクチャでは、この問題を回避するためにミューテックスを使用しています。残念ながら、複数のリクエストが同時にサービスに到達した場合、それらはミューテックスを待機してキューイングされ、ヘッドオブラインブロッキングに陥ります。

single-writer.png

図4:シングルライター原則とバッチ処理を使用したテキスト埋め込みサービスを示す抽象図

シングルライター原則を用いてリファクタリングすることで、これらの問題を排除できます。まず、モデルへのアクセスを専用のアクタースレッドでラップします。リクエストスレッドがミューテックスを競合する代わりに、非同期メッセージをアクターに送信するようになります。

アクターがシングルライターであるため、独立したリクエストを単一のバッチ推論呼び出しとして基盤のモデルにグループ化し、結果を個々のリクエストスレッドに非同期で送信できます。

書き込み可能なリソースをミューテックスで保護することを避けてください。代わりに、すべての書き込みを所有する単一のスレッド(「アクター」)を割り当て、非同期メッセージングを使用して他のスレッドからの書き込みをアクターに送信してください。

自然なバッチ処理

シングルライター原則を使用することで、シンプルなAIサービスからミューテックスを排除し、バッチ推論呼び出しのサポートを追加しました。しかし、アクターはこれらのバッチをどのように作成すべきでしょうか?

所定のバッチサイズを待つ場合、リクエストは十分なリクエストが入ってくるまで無制限にブロックされる可能性があります。固定間隔でバッチを作成する場合、リクエストは各バッチの間で有界な時間ブロックされます

これらのアプローチのどちらよりも優れた方法があります:自然なバッチ処理(Natural Batching)です。

自然なバッチ処理では、アクターはリクエストがキューで利用可能になった時点でバッチの作成を開始し、最大バッチサイズに到達した時点、またはキューが空になった時点でバッチを完了させます。

Martinの自然なバッチ処理に関する元の記事から借用した具体例により、時間経過に伴うリクエストあたりのレイテンシをどのように平準化できるかを見ることができます:

戦略最良 (µs)最悪 (µs)
タイムアウト200400
自然100200

この例では、各バッチが固定レイテンシ100µsを持つと仮定しています。

タイムアウトベースのバッチ処理戦略では、タイムアウトを100µsと仮定すると、バッチ内のすべてのリクエストが同時に受信された場合の最良ケースのレイテンシは200µs(リクエスト自体に100µs、バッチを送信する前にさらにリクエストを待つために100µs)になります。一部のリクエストがやや遅れて受信された場合の最悪ケースのレイテンシは400µsになります。

自然なバッチ処理戦略では、バッチ内のすべてのリクエストが同時に受信された場合の最良ケースのレイテンシは100µsになります。一部のリクエストがやや遅れて受信された場合の最悪ケースのレイテンシは200µsになります。

どちらの場合も、自然なバッチ処理のパフォーマンスはタイムアウトベースの戦略の2倍優れています。

シングルライターが書き込み(または読み取り!)のバッチを処理する場合、各バッチを貪欲に構築してください:データが利用可能になった時点でバッチを開始し、データのキューが空になるかバッチが満杯になった時点で終了してください。

これらの原則は個々のアプリケーションでうまく機能しますが、システム全体にもスケールします。シーケンシャルで予測可能なデータアクセスは、インメモリアレイと同様にビッグデータレイクにも適用されます。シングルライター原則は、IO集約型アプリケーションのパフォーマンスを向上させたり、CQRSアーキテクチャの強力な基盤を提供したりできます。

機械的共感に基づいてソフトウェアを書くとき、パフォーマンスはあらゆる規模で自然に得られます。

ただし、去る前に:最適化の前に可観測性を優先してください。 測定できないものは改善できません。 これらの原則を適用する前に、SLI、SLO、SLAを定義して、どこに焦点を当てるべきか、いつ止めるべきかを把握してください。

最適化の前に可観測性を優先し、これらの原則を適用する前に、パフォーマンスを測定し、目標を理解してください。