状態を持つ部品を1つ動かして、体感を469ms→252msにした
近くに置いたはずのサーバーが、こっそり地球を半周していた——犯人は、間に挟んだ"状態"だった
体感 469ms → 252ms(−217ms)。中身は一行も速くしていない。 状態を持つ部品を、地図の上で正しい場所へ動かしただけだ。
推論をユーザーの近くに寄せたのに、体感はほとんど縮まなかった。分解すると往復は確かに消えていて、代わりに触ってもいない別の区間が 20ms → 175ms に跳ねていた。犯人は推論(135ms)ではなく、間に挟んだ健全性スイッチ一個の物理位置——処理だけを東京へ動かし、それが触る状態を米国に置き去りにしていた。
状態を持つ部品には、住所がある。 部分最適は、分散システムでは別の場所で相殺される。
深掘り「地理は、レイテンシの動かせない床だ」で、レイテンシ最大のレバーは性能ではなく置き場所だと書いた。だから当然の次の一手は、推論サーバーとその手前のルーティング処理を、ユーザーに近い地域へ寄せることだった。
寄せた。片方の区間は、狙いどおり速くなった。それなのに、ユーザーが体感する全体の時間は、期待したほど縮まなかった。区間の帳簿では200ms超を稼いだはずなのに、その大半がどこかへ消えていた。
近くに置いたはずなのに。何かが、まだ地球を半周していた。
まず、構図を説明させてほしい
自前の推論を持つ(セルフホスト)にあたって、端末とGPUの間には一枚、薄いルーティング層を挟んでいる。エッジ(Cloudflare Workers)で動く小さな関数だ。役割は二つ——リクエストを推論サーバー(pod)へ中継すること。そしてもう一つ、推論が落ちていないかを見張り、落ちていたら即座に外部APIへ逃がすこと(サーキットブレーカー、要は「壊れていないか」の健全性スイッチ)。
この「壊れていないか」の状態は、どこかに覚えておく必要がある。エッジは世界中の拠点(各地のデータセンター)に分散していて特定の場所を持たないので、状態だけは一箇所に固定される特別な部品に置く。Cloudflareでは Durable Object(以下DO)と呼ぶ。名前のとおり「永続する一個の実体」で、最初に作られた地域に、以後ずっと居座る。ここが、この物語の伏線になる。
当初、この構成はまるごと米国にあった。エッジ関数も、DOも、pod(当時はまだ米国の借りGPU)も、全部が米国。近所づきあいだったから、何も問題は見えていなかった。
「近くに寄せる」を実行した
pod をアジア(台湾の4090)へ移し、エッジ関数の実行地もアジア太平洋(APAC)へ寄せる設定を入れた。エッジ関数から pod までの往復(upstream)を測ると、狙いどおりだった。
| エッジ関数の配置 | 関数→pod 往復 | 内訳 | 外部API退避率 |
|---|---|---|---|
| 米国東(旧) | 424 ms | 推論135 + 往復290 | ~25% |
| APAC(東京へ寄せた) | ~200 ms | 推論135 + 往復~65 | 0% |
往復290msが65msへ。退避率(推論が締め切りに間に合わず外部APIへ逃げる割合)も25%から0%へ。区間だけ見れば、文句なしの勝ちだ。
(余談だが、ここにも「前提は測って殺せ」の教訓が埋まっていた。エッジのダッシュボードは入口の拠点を「東京(NRT——東京の空港コード)」と表示していたが、それは受付をした場所であって、関数が実行された場所ではなかった。往復290msは私のマシンから直接叩いた115msの2.5倍——つまり関数は東京の看板の裏で、実体は米国で走っていた。「どこで受け付けたか」と「どこで動いたか」は別物だ。)
なのに、全体が縮まらない
区間は速くなった。ところが、端末で測る翻訳が返るまでの時間(アプリが翻訳を要求してから結果を受け取るまで、端末側で計った実時間)は、期待ほど落ちない。以下、その中央値を p50(半数がこれより速い値)と呼ぶ。そこで、いつもの手癖——全体を区間に分解した。
分解すると、思わぬ区間が浮かんだ。エッジ関数は pod へリクエストを投げる前に、ある待機を挟んでいた。エッジ関数の中で計った、この「転送を始めるまでの待ち時間」——pod へ一文字も送っていないのに費やしている待機——が、一貫して約175msあった。関数が米国で動いていた頃、ここは~20msにすぎなかった。pod を近づけ、関数を東京へ寄せた——その同じ移動で、この待ち時間だけが~20ms → ~175msへ跳ね上がっていた。
(この175msは、あくまでエッジ関数の内側で計った待ち時間だ。端末で体感する時間には、さらに端末↔エッジの往復が別に乗る。だから「端末の全体 − 関数→pod」=175、という単純な引き算にはならない。区間の帳簿は最後にまとめて締める。)
犯人を突き止めるのは、もう一段の分解で済んだ。エッジ関数は、podへリクエストを投げる前に、例の「壊れていないか」スイッチを一度確認する。その確認先が、一箇所に固定されたDO——米国で最初に作られたまま、米国に居座り続けていたDOだ。
つまり、冒頭の図1のとおりだった。pod は近くに寄せた。だが、そこへ行く前の一手——「壊れていないか」の確認——が、毎回こっそり太平洋を往復していた。関数が米国にいた頃は、関数もDOも米国で隣どうし(確認は~20ms)。関数だけを東京に動かした瞬間、その一手が東京↔米国の往復に化けた。往復そのものが~165ms、これに確認処理の端数(~10ms)が乗って、投げる前の待ち時間は~175msになった(~20ms → ~175ms)。
geoを最適化して稼いだ利得の大半を、この見えない太平洋往復が相殺していた。近くに置いたはずのシステムの一部品が、リクエストのたびに地球を半周していたのだ。
直し方は、犯人を消すことではなかった
面白いのは、これが「推論が遅い」問題ではまったくなかったことだ。推論(int8量子化・135ms)は最初から最後まで無罪。犯人は、推論とは無関係な、健全性スイッチ一個の物理的な居場所だった。
最初に浮かんだ案は「確認をリクエスト転送と並列に投げる(待たない)」だった。だがこれは却下した。並列化しても、消したいはずのDOの往復そのものが、投機的に走って結局は律速に残る。しかも外部API退避の判定を先走らせると、推論が落ちている最中にダメなpodへリクエストを連打しかねない。部分最適が、別の障害モードを開く。
採ったのは、もっと素直な手だ——DOを、エッジ関数と同じアジア太平洋に置き直す。状態を持つ部品は生成地に固定されるので、「新しい世代の名前」で作り直して、生成地ごとAPACへ移した。
結果はこうだ。
| DO確認の待ち時間 (エッジ内・投げる前) | 体感(端末 p50) | |
|---|---|---|
| DOが米国固定 | 175 ms | 469 ms |
| DOをAPACへ共置 | 12 ms | 252 ms |
投げる前の待ち時間175ms → 12ms。端末で体感する p50 は469ms → 252ms(−217ms)。しかも中央値まわりが極めて一定で、p10からp50までの幅はわずか21ms——字幕という「一定のリズムで出続けること」が命の用途に、ちょうどいい安定性だ(右のテール、p90 460ms・p99 977ms は伸びるが、体感の芯にあたる中央付近が動かないのが効く)。
−217ms。これは、私がそれまでに引いたどんなモデル最適化より大きい削りだった。中身は一行も速くしていない。ただ、状態を持つ一個の部品を、地図の上で正しい場所へ動かしただけだ。
部分最適は、別の区間で牙をむく
この失敗の本質は、第2回「去年の正解が、今年の間違いになる」で書いた部分最適の罠の、いちばん意地の悪い形だ。あのときは「往復を消したら生成が間に合わなかった」——同じ経路上のトレードオフだった。今回はもっと厄介で、pod を近づけるという一手が、まったく別の区間(状態確認)を遠ざけていた。触ってすらいない場所が、悪化していたのだ。
分散システムでは、「どこで実行されるか」を動かすと、その関数が触るすべての状態依存の物理的な居場所が、同時に動く——あるいは、動かないことで取り残される。片方の区間だけを見て「速くなった」と喜ぶと、別の区間で静かに帳消しになっているのに気づけない。
そして、この構図はこれで終わりではない。状態を持つ部品は、これ以外にもある——キャッシュも、レート制限のカウンタも、同じように住所を持つ。今回は健全性スイッチ一個で済んだが、次に処理を動かすときも同じ点検が要る。気づけたのは、ただ一つ、全体(end-to-end)を区間に分解して測っていたからだ。「関数→pod が速くなった」で満足していたら、いまも太平洋を往復するスイッチを抱えたまま、「なぜか体感が縮まらない」と首をかしげていただろう。
教訓
-
部分最適は、別の区間で相殺される。 ある部品を近づける一手が、触ってもいない別の部品を遠ざけることがある。区間ごとの勝ちではなく、全体(E2E)で勝ち負けを判定する。
-
状態を持つ部品には、住所がある。 処理を動かすときは、それが触る状態の居場所も一緒に動かす。さもないと、状態だけが元の大陸に取り残される。ついでに「どこで受け付けたか」は「どこで動くか」ではない——看板ではなく実測の往復時間で確かめる。
-
犯人は、いちばん疑わしい部品ではないことが多い。 遅さの正体は推論(135ms)ではなく、無関係に見えた健全性スイッチ一個の物理位置だった。「重そうな処理」を睨む前に、まず分解する。犯人は数字が指し示す。
これは、マニフェストの2番目の原則——「まだ、安く・速くできる」。隔離したベンチではなく、本番の全体像で制約を測り直す——の、最も分散システムらしい現れだ。いちばん大きな−217msは、モデルの中にも、速くしたはずの区間の中にもなかった。誰も見ていなかった、地図の上の一点にあった。
付録:生データ
すべて実測 p50、n=25。
| 区間 | 実測 | 条件・留保 |
|---|---|---|
| エッジ関数→pod 往復 | 米国東(旧)424ms=推論135+往復290 → APAC東京 ~200ms=推論135+往復~65 | 外部API退避率 ~25% → 0% |
| 入口ラベルの罠 | upstream往復290ms は直叩き115msの 2.5倍 | ダッシュボードの「東京(NRT)」は受付拠点であって実行地ではない |
| 状態確認の待ち時間 | 関数=米国 ~20ms → 関数=東京・状態は米国固定 ~175ms → 状態もAPACへ共置 12ms | 175msの内訳は太平洋往復 ~165ms + 確認処理 ~10ms |
| 推論 | 135ms(最初から一定) | int8量子化の純推論。犯人ではなかった |
| 端末 callEnd(体感) | p50 469ms → 252ms(−217ms) | 分位 p10 231 / p90 460 / p95 549 / p99 977ms |
関連: 近接の動機は第5回、距離という床の実測は「地理は動かせない床だ」。