平均が健全に見えるのにタイムアウトでオークションを落とす Go のビッダーは、一つの症状の裏に二つの問題を抱えている。エクスチェンジのものであり往復を含むデッドラインと、入札をスコアリングする goroutine に mark assist を課すコレクタである。ここでは、デッドラインをどう分けるか、Go のどのつまみが効くか、そして Rust のホットパスが直さないものを扱う。
平均が健全に見えるのにタイムアウトでオークションを落とすビッダーは、間違った数字で説明されている。デッドラインはエクスチェンジのものであり、往復両方向のネットワークを含む。コレクタはプロセスの属性なので、その停止はすべての接続に一度に課される。一本の接続だけに出るスパイクには、別の説明が要る。
Rust で書き直すか、ランタイムを調整するか。これは誰も問うていない質問に対する二つの答えのあいだの選択である。デッドラインのどの部分が、何に使われているのか。以下では、予算とコレクタ、コレクタとスケジューラ、そして書き換えとそれに値するコンポーネントを切り分ける。
短い答えは構造にある。デッドラインを決めるのはエクスチェンジであり、そこにはネットワークが含まれる。だから最初の修理は、1接続の p99 をハンドラの時間、プロセッサを待つ時間、ワイヤ上の時間に分解することだ。amBrain が公に裏付けられること:私たちは RTBBidder を構築した。ゼロから納めたデマンドサイドプラットフォームであり、各入札の判断はインプレッションごとに数十のターゲティング条件を評価する。実測として公開している latency の数値はトレーディングの経路から出たものであって、広告のビッダーから出たものではない。以下のどの数字も、私たちのビッダーで計測したものではない。
OpenRTB の仕様は tmax を、インターネットの latency を含めて入札を受け取るためにエクスチェンジが許す最大時間(ミリ秒)と定義し、その値は事前のどんなガイダンスにも優先すると述べている。予算はリクエストごとに届く往復時間である。
あなたが判定されるしきい値は、自分のダッシュボードにある数字ではない。Google の Authorized Buyers のドキュメントは、取引拠点で計測してレスポンスの 85 percent がデッドラインの内側に届くことを要求し、それを外したビッダーにはスロットリングをかける。向こうの時計とこちらの時計のあいだには、計算でないものすべてが挟まっている:
だから内部のデッドラインは、その接続で答えを返すのにかかると自分のヒストグラムが示す分だけ、tmax より下に置く。しかも全台に一度決めるのではなく、エクスチェンジごとに導き直す。デッドラインは容量計画ではない。デッドラインで打ち切った仕事は、すでに CPU を使ってしまっている。
過負荷のもとでは、それは誰も数えないレスポンスに全額を払うことを意味する。だから欠けている修理はアドミッション制御である。tmax を読み、実測したキューの遅延と突き合わせ、計算が合わないときは no-bid を返す。速い no-bid は 85 percent に算入されるが、遅れた入札は算入されない。
ガベージコレクタはプロセスの属性なので、どの接続で引き起こされたサイクルでも、すべての接続に課される。最初に間に合わなくなるのは、tmax が最も厳しく、リクエストが最も重い接続である。コレクタが無くても同じ絵を作るものを、まず除外する:
四つのいずれも、コレクタの設定では直らない。そして分解には道具がある。CPU プロファイルはコレクタの請求をシンボルごとに分ける。ハンドラへの課金は runtime.gcAssistAlloc、バックグラウンドのマーキングは runtime.gcBgMarkWorker である。stop-the-world の時間は /sched/pauses/total/gc:seconds、実行可能状態での待ちは /sched/latencies:seconds、そして accept キューはプロセスの外から ListenOverflows で読む。
どの Go の修理が要るかは、一つの区別で決まる。stop-the-world の停止はすべての goroutine に一度に課されるので、全接続に平らなスパイクとして現れる。mark assist はアロケートした goroutine に課されるので、最も多くアロケートしたリクエストに落ちる。assist を下げるものは二つ。入札リクエストあたりのバイト数を減らすか、バックグラウンドのワーカーがマーキングをより多く受け持てるようサイクルを長くするかである。トラフィックの構成が変わっても残るのは、前者だけだ。
Go のコレクタは並行であり、公式ガイドは停止の長さがヒープサイズに比例しないと明言している。だから stop-the-world の遷移は短い。効いてくる発生源は assist のほうである。アロケーションが速いとき、goroutine はコレクタを手伝う。バックグラウンドのマーキングにはプロセッサの決まった四分の一しか与えられず、足りない分がアロケートした側に課されるからだ。
速度が、これを傾きではなくしきい値にする。アロケーション率は QPS に入札リクエストあたりのバイト数を掛けたものであり、固定されたバックグラウンドの取り分と向き合う。だからトラフィックが五分の一のときには一度も assist しないコードが、100K QPS ではほぼすべてのリクエストで assist しうる。
マーキングのコストは、ごみではなく生きているポインタのグラフに比例する。そしてビッダーは、そのために都合の悪い形を抱えている。キャンペーンのインデックス、オーディエンスセグメント、フリークエンシーのキャッシュだ。Discord は2020年に同じ結論を公開しており、そこではコレクタが、メモリが空いているかを判断するために LRU キャッシュ全体を走査していた。一つの言葉の下に、五つの仕組みが隠れている:
これは五つの別々の請求書として読む。コレクタの設定で片づくのはちょうど一つであり、分解を測る前に言語を変えて片づくものは一つも無い。
Gil Tene はこの失敗を coordinated omission と名付けた。クローズドループは応答を待ち、停滞のあいだ送信を止めるため、測定する側が被測定システムと歩調を合わせ、外れ値を測らずに済ませてしまう。ScyllaDB は2021年に比較を公開しており、あるワークロードの p99 はクローズドループで 249 microseconds、補正付きのオープン負荷で 665 ms と、約2,700倍のずれがあった。
応答を待つ負荷生成器は、まさに見つけようとしていた停滞のあいだに送信を止め、その沈黙を平均として結果に溶かし込む。あとから印字されるパーセンタイルは、あなたのビッダーではなく生成器を説明している。
チューニングを始める前に、どこで止めるかの数字を決める。消耗の末に決まった書き換えは、判断ではないからだ。数字は一つではなく二つある。assist を左右するリクエストあたりのヒープアロケーションと、マーキングを左右する生きているヒープである。
順序は、大きく勝てるところからではなく、上限より先に発生源である。assist が生まれる場所から始める。入札経路でのエスケープ解析、確保し直さず再利用するバッファ、そして新しいオブジェクトグラフを作らず必要なフィールドだけを読むコーデックだ。そのスライスは短命に保つ。リクエストバッファへのスライスは、入札が生きているあいだ、そのバッファ全体を固定してしまう。
この手法には天井がある。Uber は2021年、コンテナのメモリ上限に対して GOGC を調整した結果、ミッションクリティカルなサービス全体で約 70,000 cores を回収したと報告している。これはコストの成果であって、パーセンタイルの成果ではない。
RTB House は2025年6月、JVM の入札サービスについて記している。マイクロサービスへの分割が小さなリクエストを大量に生み、追加される latency は平均で約 2.5 ms のリクエストに対して 7 ms 以内に収める必要があったが、頻発する G1 の停止で98パーセンタイルと99パーセンタイルが崩れた。彼らは世代別 ZGC へ移り、その対価をメモリで払った。
その修理に何が必要だったかに注目したい。切り替え先となる二つ目のコレクタである。Go が積んでいるコレクタは一つで、差し替えはできない。だから Go の梃子は、アロケーション率、生きている集合の形、そして GOMEMLIMIT に対する GOGC になる。代わりに Rust のホットパスが取り除くものは、はっきりしている。assist が無く、バックグラウンドのマーキングが無く、強制サイクルが無い。消えないもののほうは、多くのチームの予想より長い:
裾がリクエストのパース、エクスチェンジへの接続、あるいはスケジューラのキューイングにあるなら、Rust はそのミリ秒を一つも返してくれない。間違ったコンポーネントを書き直せば、同じタイムアウト率を保つために四半期を一つ費やすことになる。
だから移すのはサービスではなく、アロケーションを抱える最小の部品である。インプレッションの評価ループとそのインデックス、候補の選択、ターゲティング、フリークエンシーと予算の参照、スコアリングだ。境界の費用は横断1回あたりで見積もる。フラットなバッファで入札リクエストごとに1回であって、ターゲティング規則ごとに1回では決してない。検証はミラーしたストリームを流す別インスタンスで行い、被測定プロセスの内側では決して行わない。そこではシャドウ経路が、測っている二つの量を倍にしてしまう。
どちらの経路でも使える受け入れテストがある。コレクタを切り、自分で決めたメモリ上限のもとで短い時間だけ流し、ハンドラ内部の p99.9 を記録する。実行するのはトラフィックの一部を受ける1インスタンスに限り、自動で元に戻す仕掛けを付ける。GOGC を切った状態でヒープが上限に当たると、ランタイムは連続したサイクルに入り、ガイドはその停滞が無期限になりうると述べているからだ。結果を読むのは、GC CPU リミッタが一度も作動しなかった場合だけにする。作動した時点で、そのパーセンタイルはリミッタを説明している。
問いの後半、この種の仕事を誰がやるのかには、ベンダー一覧を必要としない試験がある。GC が原因の latency を直す会社は、書き換えを売る会社とは振る舞いが違う:
どれも信頼を必要としない。いずれも最初の打ち合わせで求められる文書であり、曖昧なものが返ってきたなら、診断が飛ばされているということだ。
だから最初の問いは、どの言語かではない。タイムアウトする接続で失われているミリ秒を持っているのは、ハンドラ・スケジューラ・ワイヤという三つの合計のどれか、そしてそこに手を入れたとき、入札リクエストあたりのアロケーションバイト数が動くか、である。
amBrain が公に裏付けられること:amBrain は、トレーディングプラットフォーム、matching engine、リアルタイムビディングのシステム、そしてカジノプラットフォームのエンジニアリングを専門とするソフトウェア開発会社であり、2019年からソフトウェアを作ってきた。AdTech で私たちが作るのは、DSP 開発、リアルタイムビディングのプラットフォーム、アドエクスチェンジのエンジニアリングである。働き方は三つの形態がある。フルデリバリー、専任チーム、あるいはあなたのチームに入るエンジニアだ。書き換えとチューニングを天秤にかけているなら、価値のある話は、言語を選ぶ前に分解を走らせることから始まる。
現在のアーキテクチャと気になっている障害シナリオをお持ちください。30 分で一緒に確認します。