ドメイン:低レイテンシルール
測定値はウォームかコールドかを明示し、本番環境のレジームと一致する [R:a-measurement-matches-the-regime-it-reports]
RustとJavaの読者向けに記述。
JMHとそのサンプル(Aleksey Shipilev)から書き起こし。JMHの存在自体が、JVM上での手作りマイクロベンチマークはデフォルトで間違っているという主張を裏付けている。
測定値をどのレジームで取得したかを明示し、それが本番環境のレジームと一致していることを確認せよ。このルールには2つのエラーが潜んでおり、そのうち片方だけが広く認知されている。
本番がウォームなのにコールドで測定する。 初回実行はインタプリタ実行やコールド命令キャッシュから始まり、分岐予測子は訓練されておらず、ページテーブルは未設定、マネージドランタイムではコンパイルコードも存在しない。この数値は定常状態より1~2桁悪くなることがあり、実際の仕事ではなくウォームアップを計測していることになる。JVMでは段階的にエスカレーションする(インタプリタ→C1→C2、長期ループはOSRあり)ため、数千回のイテレーションを要する。これが手作りタイミングループがデフォルトで間違っている理由であり、ハーネスが存在する理由でもある。
本番がコールドなのにウォームで測定する。 この半分は厳密さに見えるためほとんど注目されない。1分に1回しか実行されないパスは決してウォームにならない。マーケットオープンのバーストはコールドコードに到着する。エラーハンドラはその日初めて、最悪のタイミングで実行される。これらのすべてにおいて、ウォームの数値はユーザーが目にすることのない数字であり、慎重に破棄されたウォームアップそのものが本番の挙動である。そこでの破棄は衛生管理ではなく、測定の削除である。
したがって、このルールは手続きではなくスコープの一致である:どのレジームを測定したか、本番がどのレジームにあるかを述べ、それらが同じであることを示せ。本番が本当に両方である場合(定常的なストリームにコールドバーストが混ざる場合)は、2つの測定と2つの報告数値が必要であり、1つに平均化して意味をなくしてはならない。
これは [R:measure-the-claim-not-a-subset] を集合ではなく時間に適用したものである:ウォームアップサンプルは部分集合であり、それらを破棄するのはスコープの決定である。そして、反射的に行われるスコープの決定が、測定されたという権威を持つ間違った数値を生み出す。
障害モードチェック:本番パスは重要な瞬間にウォームかコールドか?私はどちらを測定したか? ベンチマークが最初のN回のイテレーションを破棄した場合、本番のどの部分がそのイテレーションに対応するかを挙げよ。もし対応するものが何もなければ、破棄された部分こそが答えだったことになる。
隣接する2つのルールは意図的に分離されている。[R:no-coordinated-omission] は計測器が重要なイベント中にサンプルを取得できないことに関するものである。このルールは間違ったレジームでサンプルを取得することに関するもの。そして [R:verify-the-abstraction-compiled-away] は最適化によってベンチマークが計測していた作業が削除されるケースを扱う——何もないもののウォーム測定である。
無制限キューは過負荷をレイテンシに変換し、拒否にしない [R:a-queue-without-a-bound-has-no-overload-behaviour]
RustとJavaの読者向けに記述。
Littleの法則(J. D. C. Little, 1961)から書き起こし:L = λW。したがって、決して拒否しないキューでは、到着率がサービス率を超えると待ち時間が無制限に増加する。
消費が生産に追いつかないとき、キューが何をするかを決定せよ。成長させることは決定ではない。
キュー深度はレイテンシそのものである——これはLittleの法則であり、ヒューリスティックではない:到着率λ、平均キュー長L、平均待ち時間はL/λとなる。したがって、無制限のLは無制限の待ち時間を意味する。容量制限のないキューはレイテンシの限界を持たず、その上流で宣言されたレイテンシ要件は何によっても守られていない。
この障害は特有の形状を持ち、診断が遅れる。プロデューサーは決してブロックされないため、上流で問題が報告されることはない。スループットは正しく見える——すべてのアイテムは最終的に処理されるためだ。そして唯一の症状はレイテンシが単調に増加することだけである。それが可視化される頃には、バックログは誰も望まない作業を行っている——消費されるすべてのアイテムはすでに古くなっており、キャパシティは過ぎ去った瞬間の質問に答えるために費やされている。これが、負荷が正常に戻ってもシステムが回復しない理由である。まずドレインする必要があり、そのレートは追いつけなかったときと同じである。
したがって、キューを制限し、制限時の振る舞いを選択せよ。4つの正当な選択肢があり、その選択はドメイン上の決定であり、技術的なものではない:
- プロデューサーをブロックする。 真のバックプレッシャー——最も遅いコンシューマーが全員のレートを決定し、プレッシャーは実際にそれを軽減できる者に伝播する。プロデューサーを停止できない場合には誤りであり、これが
[R:no-stall-inside-a-publication-window]の対象となる。 - 最も古いものをドロップする。 マーケットデータ、センサー読み取り、テレメトリに適切:古いティックは負の価値を持ち、最新のものだけが保持する価値がある。
- 最も新しいものをドロップする。 バックログが公平なキューであり、後から来た者に何も約束されていない場合に適切。
- 拒否し、そのことを伝える。 注文エントリや、呼び出し元が結果を知る必要があるものに適切。黙ってドロップされた注文は、拒否されたものよりも、呼び出し元がその呼び出しに寄せた信頼の全体幅だけ悪い。
選択しないことは、誰も意図的に選ばない第5の振る舞いを選択することになる:すべてを吸収し、すべてのアイテムのレイテンシを均等に劣化させ、最終的にメモリ枯渇で失敗する——あるいは、マネージドランタイムでは、バックログ自体によるコレクションポーズが発生し、コンシューマーをさらに遅くする。これは本番において符号が逆のフィードバックループに最も近いものである。
無制限構造は、制限が実際に別の場所にある場合には依然として正しい選択である——プロデューサーが上流でレート制限されているキューや、コンシューマーがあらゆる到着レートよりも確実に高速であることが証明できる場合。コード内で、キューとともにその理由を述べよ。理由を明示した無制限キューは決定である。理由のないものは、同じ形状をした決定の不在である。
障害モードチェック:消費が10秒間生産に追いつかない場合、このキューは何をするか?誰がそれを知るか? 答えが「成長する」と「誰も知らない」であれば、ここには過負荷時の振る舞いはなく、過負荷の症状が待っているだけである。
構造体は、その証明が想定した並行性レジームの下でのみ正しい [R:a-structure-keeps-the-regime-it-was-proved-under]
RustとJavaの読者向けに記述。
Dmitry Vyukov、Intrusive MPSCノードベースキュー、1024coresから書き起こし——レジームはアルゴリズムの名前に記載されており、コード内にはどこにもない。
構造体の正当性の議論からレジームを読み取り、コード内でその違反を防ぐものを指定せよ。「ドキュメントにシングルコンシューマーと書いてある」はメカニズムではない。
MPSCはマルチプロデューサー、シングルコンシューマーを意味し、その「シングル」は説明ではなく前提条件である。Vyukovの mpscq_pop は self->tail を読み取り、そこから辿り、書き戻す——プレーンなロードとストアであり、同期はない。なぜなら、それを実行するスレッドは1つだけであると仮定されていたからである。2つのコンシューマーがそこでインターリーブすると、両方が同じノードを返すか、一方がノードを完全に失う可能性がある。どちらの結果も何も発生させない。アサーションも、競合するロックも、エラーパスもない:キューはもっともらしい値を返し、プログラムは重複または消失したアイテムで継続する。
その静けさこそが問題の全体であり、負荷によって複合される。100回の操作を実行する2つのコンシューマーが、問題となるウィンドウで決してインターリーブしないかもしれない。テストスイートは通過し、ステージングのソークテストは通過し、インターリーブは本番トラフィックとともに現れる。再現不可能な条件下でのみ現れる欠陥は、外部からは、まったく別の場所にある欠陥と区別がつかない。
したがって、言語が許す限り、レジームを型に埋め込め。構築時に正確に1つのコンシューマーハンドルを配布せよ。それを Clone にも Sync にもしない。pop に &mut self を取らせ、借用チェッカーが排他性を強制できるようにせよ。そうすれば、「2つのコンシューマー」というのは発見すべきバグではなく、コンパイルできないプログラムとなる——[R:make-illegal-states-unrepresentable] であり、[R:typestate-for-protocols] でもある(レジームが構造体のライフタイムを通じて変化する場合)。これはドメイン全体におけるその規律の最もレバレッジの高いインスタンスである。なぜなら、代替手段はヘッダーコメント内の一文であり、その防止する障害は原因特定が不可能だからである。
言語がそれを表現できない場合(Javaにはそのようなハンドルはない)、制御は実際に動作するものでなければならない:最初の使用時に所有スレッドを記録し、以降のすべての呼び出しでデバッグビルド時にアサートするか、すべての消費を単一のオブジェクトにルーティングし、その構築が不変条件を述べる唯一の場所となるようにする。コメントは制御ではない([R:guarantee-needs-a-reader])。
このクラスはキューよりも広く、他のメンバーも同じように失敗する。2つのプロデューサーを持つSPSCリング。リエントラントではないと文書化されているが、シグナルハンドラやコールバックから呼び出される構造体。3回のリファクタリング後にそれを保持していたフィールドが共有可能になったため、2つのスレッドからアクセスされる HashMap。ミューテーション中に保持されるイテレータ。それぞれの場合で、レジームは正当性の議論の仮説であり、アーティファクト内には何も記録されていない。
障害モードチェック:この構造体の正当性の議論はどのレジームを仮定しているか?今それを破った場合、何が失敗するか? 正直な答えが「名前がそう言っている」または「注意している」である場合、その構造体は防御されておらず、次にそれに手を伸ばす人は知らないだろう。
一度も触れられていないメモリはまだメモリではない [R:allocated-is-not-resident]
RustとJavaの読者向けに記述。
Ulrich Drepper、「What Every Programmer Should Know About Memory」(2007)から書き起こし——デマンドページングと最初のタッチのコストについて。
事前割り当ては準備ではない。成功した割り当てはアドレス空間を購入するだけである。ページは最初のタッチで到着し、そのタッチこそがコストを要する。
malloc と mmap は仮想アドレスを返す。物理ページは遅延供給される:各ページへの最初の書き込みはカーネルにトラップし、カーネルはフレームを見つけ、ゼロ埋めし(以前のテナントのデータが漏洩しないように)、マッピングする。したがって、起動時に割り当てられ、ホットパスで初めて書き込まれる1ギガバイトのバッファには、約25万回のページフォールトが含まれており、1ページにつき1回、その最初のパス全体に分散して発生する。そのパス上のすべての割り当てフリーのアサーションは満たされるが、それらのすべてが間違ったものを測定している:アロケータを呼んだものは何もない。
また、一度達成された常駐性も永続的ではない。ページはメモリプレッシャー下で再利用されたり、スワップされたりする可能性がある。したがって、起動時にバッファにタッチしてからアイドル状態にある長寿命プロセスは、1週間フォールトフリーだったパスで再びフォールトする可能性がある。これが、治療法が2つの半分を持ち、最初の半分だけを行うことが一般的で高価な間違いである理由である。
したがって、デッドラインのあるパス上のものについては:パスがタッチするすべてのページに、起動時に、実際の作業が到着する前に書き込め。タッチは書き込みでなければならない——タッチされていないページの読み取りは共有ゼロページで処理される可能性があり、何も証明できない。そして、それを固定せよ:mlockall(MCL_CURRENT | MCL_FUTURE)、そしてボックス上でスワップ無効に。JVMでは、-XX:+AlwaysPreTouch がヒープに対して最初の半分を行い、起動時間が遅くなる価値がある。まさにそのコストが境界を持つパスから移動されるからである。
ヒュージページは両方向で計算を変え、デフォルトではなく独自の決定に値する。より少なく、より大きなページは、より少ないフォールトとより少ないTLBプレッシャーを意味する。各フォールトはより大きく、透過的ヒュージページは、最も悪い瞬間にスレッドをミリ秒単位でストールさせる可能性のあるデフラグメンテーションパスをもたらす。ブート時に予約された明示的ヒュージページはそれを回避する。THPを always にしたまま期待することは、このドメインが繰り返し名指ししなければならない形状である。
メモリを設定するスレッドではなく、それを所有するスレッドからタッチせよ。 マルチソケットマシンでは、最初の書き込みが配置も決定する:ページはそれをフォルトしたスレッドのノードに割り当てられる。したがって、このルールを満たす明白な方法——初期化スレッド内の1つのループですべてのバッファをウォークする——は、すべてのページをそのスレッドのノードに配置し、別のソケットに固定されたワーカーはインターコネクト経由でそれらすべてを読み取ることになり、ローカルレイテンシの約2倍のコストがかかる。パスはフォールトフリーであるが、完全にリモートとなる:より速い最初のメッセージと引き換えに、より遅い定常状態を得ることになる。まず所有スレッドを固定し、次にそれぞれが使用するものにタッチさせよ。
障害モードチェック:このパスがタッチするすべてのページは、それを読み取るスレッドによって書き込まれたか?そしてそれが再利用されるのを防ぐものは何か? 3つの質問である。最初の質問だけに答えると、アイドル状態になるまでは高速だが、その後は遅くなるシステムができる。最初の2つだけに答えると、シングルソケットでは高速だが、マルチソケットでは遅いシステムができる。
[R:no-allocation-on-the-hot-path] は兄弟ルールであり、どちらも他方を包含しない——前者はアロケータに入ることに関するものであり、こちらはアロケータがすでに返したメモリに関するものである。[R:answer-the-requirement-at-its-layer] は、なぜ治療法がすべてメモリポリシー、ロック、またはブートパラメータであるかを説明する——コンテナやクレートの選択はこれに到達しない。[R:no-stall-inside-a-publication-window] は、これが最も強く影響するところである——ライターの2つのストアの間に発生したページフォールトは、その全期間にわたってウィンドウを開いたままにするからである。
要件をその存在するレイヤーで満たせ [R:answer-the-requirement-at-its-layer]
RustとJavaの読者向けに記述。
何かを選択する前に、要件がどのレイヤーに存在するかを述べ、そこでそれを満たせ。
マシンに関する要件——隣接する分離コアを妨害しない、送信パスでページフォールトを発生させない、リアルタイムスレッドが競合するロックを取らない——は、より高速なクレート、より評判の良いトランスポート、または非同期APIを選択することでは満たされない。これらはライブラリレベルの決定である。正しいかもしれないし、助けになるかもしれない。しかし、システムレベルの制約を保持するものではなく、それらをそのように扱うことは、未解決の質問をその背後に何もない閉じた質問に変換する。
障害は間違った選択ではなく、間違った台帳への記入である。満たされたとマークされた制約は、検査されなくなる。未解決の制約は、依然として検査される。このルールは、上記のすべてのルールの上に適用される。