はじめに

ISUCON2026 にチーム「ゲームセンターヤギ」で参加し、最終スコア 475,546 点で総合2位になりました。Rust で参加したチームの中では1位でした。

この記事では、当日の 10:00 から 18:00 までに何をしたかを時系列で書きます。

レポジトリは https://github.com/ichyo/isucon2026-public に公開しています。

問題の要点

お題の ISUReal は、通知が来たら写真を投稿し、友達の写真を眺める SNS です。サーバーは asia に2台、europe と america に1台ずつで、リージョン間の往復は約 100ms、帯域は約 13MB/s でした。europe と america の利用者の得点には 1.5 倍の倍率が付きます。

設計を一番縛ったのは、書き込みが成功したら、すべてのリージョンの読み取りにすぐ反映されていなければならないという条件です。消した投稿がフィードに残っていると、その走行は FAIL になります。

進め方

当日は、Claude Code(Opus 5.5、effort は xhigh)に、あらかじめ書いておいたプロンプトを渡して始めました。プロンプトには、短時間の競技なので保守性よりスコアを優先すること、サーバーの状態をなるべく git で管理していつでも戻せるようにすること、サブエージェントで作業を分担することなどを書いておきました。

初動は、クリーンルーム設計のように役割を2つに分けました。

  1. 1つ目のサブエージェントに初期実装と当日マニュアルを渡し、アプリの外部仕様を文書にしてもらう
  2. 2つ目のサブエージェントには、その外部仕様とマニュアルだけを渡し、自然で高速なインメモリの Rust 実装を、使うミドルウェアも含めて考えてもらう

初期実装を見せないのは、わざと非効率に書かれた元の設計に引っ張られないようにするためです。そのあとは、計測してボトルネックを探して直す、いつものループです。

あとは動いている様子を眺めながら、ときどき質問を挟む程度でした。「asia2 を空けているのはなぜか」「asia 以外のサーバーでは署名が速いのか」といった質問が、次の改善のきっかけになりました。

10:00〜10:40 まず測る(3,628)

CloudFormation でサーバーを作り、コードと設定を git に入れました。サーバー上に置かれていた AGENTS.md は退避してから消しました。最初のスコアは 3,628 点でした。

リージョン間の遅延を測ると、asia1 と asia2 の間だけが約 1ms で、それ以外は約 100ms でした。データベースは asia2 にしかないので、europe と america のアプリは SQL を1本投げるたびに 100ms 待っていました。実際、最終日に europe と america で検証を通った利用者は0人でした。

あわせて、ブラウザを使わずにベンチマークを回せるように、ポータルを叩くコマンドを作りました。

10:40〜12:30 Rust で全部書き直す(FAIL → 92,537)

10:37 に外部仕様の文書(約 1,000 行)ができあがりました。これを読むと、画像を除いたデータ(利用者 5,000 人、投稿 62.5 万件など)はメモリに載る大きさでした。また、4台すべてのローカルの Postgres に初期データが入っていました。そこで、次の構成で Rust に書き直すことにしました。

  • 各リージョンのアプリが全データのコピーをメモリに持ち、読み取りは手元で返す
  • 書き込みはすべて asia1 に集めて順番を決め、他のリージョンへ同期で複製し、受信確認がそろってから応答する
  • 画像約 183 万枚(13GB)は、各サーバーのローカルの Postgres から1つのファイルへ書き出し、そこから直接読む
  • Postgres と nginx は止め、アプリが 80 番ポートで直接受ける

土台を作ったあと、API を3つのグループに分けて並行して実装しました。元の Go 実装と同じリクエストを投げてレスポンスを比べるツールも作り、初期データの読み取り結果が一致することを確かめてから走らせました。

12:04 の最初の走行は FAIL でした。見えてはいけない投稿がフィードに返っていました。新しい画像も書き込みと同じ経路で複製していたので、13MB/s の回線が詰まって複製の接続が切れ、追いついていないサーバーが古いデータを返していました。大きな画像は複製せず、持ち主のサーバーから必要なときに取ってくる形に変え、追いついていないサーバーでは読み取りを待たせるようにしました。12:27 に 92,537 点で初めて通りました。

12:30〜13:30 asia1 だけが忙しい(92,537 → 285,574)

走行中の CPU を見ると、asia1 だけが 100% に張り付き、他の3台はほとんど遊んでいました。asia の2台は世代の古い c5 で SHA の命令を持たず、画像 URL の署名(HMAC を 1000 回)1件に 0.59ms かかっていました。europe と america の c7i では 0.09ms です。

「利用者の8割は asia だが、処理は軽いので asia1 だけで足りる」という前提が崩れたので、配置を考え直しました。

  • asia1 に HAProxy を置き、asia2 を2台目の asia のアプリとして使う
  • 署名を AVX-512 で 16 件まとめて計算する(1コアあたり毎秒 1,700 件から 3 万件へ)
  • 署名の有効期限を丸めてキャッシュを効かせる(期限の変更は clarification で許可されていました)

13:02 に 204,650 点が出ました。さらに複製の処理を優先度の高い専用スレッドに分けると、13:21 に 273,384 点になりました。

次に効きそうだったのは書き込みの待ち時間です。追加するだけの書き込みを非同期で複製してみましたが、FAIL でした。ベンチマーカーは asia の利用者でログインした直後に europe1 へリクエストを送り、403(リージョン違い)が返ることを確かめていて、ログインがまだ届いていない europe1 は 401 を返していました。すぐに戻して 285,574 点でした。

13:30〜15:40 30万点前後で止まる(285,574 → 323,454)

プロファイルを取ると、CPU の使い道は JPEG のデコード、カーネルのネットワーク処理、HAProxy、署名の順でした。投稿画像の検証は、壊れた JPEG を確実に弾くために全体を読む必要があります。そこで Huffman の復号だけを行う専用の検証を書き、1枚 11ms かかっていたものを 2.5ms にしました。asia の CPU 使用率は平均 30% まで下がり、CPU はボトルネックではなくなりました。

ここから 30 万点前後で伸びなくなります。この時間帯の一番の失敗は、投稿の接続が切れるエラーへの対策として、リクエストボディを最後まで読んでから応答するようにしたことです。スコアが少しずつ下がっていくので原因を調べると、ルーターを通る上り通信の約6割が、ベンチマーカーが送ってくる 10〜32MB の不正なプロフィール画像(1走行で 3.7GB)と、BAN した IP からのボディ(0.8GB)でした。ボディを読み切る変更で、それをすべて受信していたわけです。

いったん 31.6 万点のビルドに戻し、不正なアップロードは先頭の数バイトで判断してすぐに接続を閉じる処理だけを足すと、323,454 点になりました。

15:40〜16:50 データだけを見て考え直す(323,454 → 466,166)

15 時過ぎに、朝から続けてきた作業を引き継ぎ資料にまとめて仕切り直しました。このとき、これまでの考察を一度捨て、アクセスログとメトリクスだけから大きく効く案を出し直しました。ここからは Claude Code に加えて Codex も使い始めました。効いたのは次の3つで、どれも Codex が案を出して実装したものです。

時刻変更スコア
16:12初期画像の署名を起動時にまとめて計算し、署名待ちの行列をなくす316,155 → 326,810
16:18セッションの発行・検証・失効を、利用者の担当リージョンで完結させる326,810 → 394,038
16:39セッションの fsync をまとめ、認証のロックとディスク同期のロックを分ける412,119 → 466,166

2つ目と3つ目が大きく効いた理由は、得点の構造にありました。ログインの大半は、初期データの利用者が「ログイン → Memories → 画像 → ログアウト → エクスポート」を休みなく繰り返す巡回によるもので、この1周が速いほど点が増えます。一方でログインは1走行で約 1.5 万回あり、平均 120〜170ms のほぼすべてがリージョン間の複製待ちでした。

セッションを担当リージョンで管理する変更は、13 時台に FAIL した非同期化と同じく「ログインを速くする」ためのものです。ただし今回は、セッションの持ち主を1か所に決めて、他のリージョンからは持ち主に問い合わせる形なので、どのリージョンでも正しい結果が返ります。ログインの平均は約 141ms から 45ms になりました。3つ目は、asia2 の軽い GET がそろって 600〜700ms に悪化する様子から、共通の経路で詰まっていると見て見つけたものです。

複数のセッションが同じサーバーにデプロイしてベンチマークを走らせるようになったので、互いに衝突しないよう、ロックを取ってから実行する仕組みもこの時間に作りました。

16:50〜18:00 ばらつきと最後の1回(466,166 → 475,546)

16:49 にコードを変えずにディスクの計測だけをした走行が、この日の最高の 503,172 点でした。同じコードでも走行ごとに 41 万〜50 万点に散らばっていて、終盤に試した変更の効果はこの幅に埋もれました。たとえば、初期画像を投稿者ごとに並べ直してエクスポートを 493ms から 35ms にした変更は、スコアが 6% 下がったので採用しませんでした。

17:24 から最終版の準備に入り、計測用のコードとログを外しました。最初のデプロイではアプリの起動と同期が終わる前にベンチマークを始めてしまい、初期化で FAIL しましたが、やり直して 473,800 点でした。その後、4台とルーターを2回再起動し、自動で復帰すること、ベンチマークが通ること、再起動の前に書いたデータが読めることを確かめました。

最終スコアは最後の走行のスコアで決まります。最後は、それまでのスコアを正規分布とみなし、「残りの回数を使って最適に止めた場合の期待値」を閾値にして、それを上回ったら止めるというルールで回しました。スコアは 464,451、446,763、468,954、470,650、452,697 と上下し、17:53 に 475,546 点が出たところで止めました。

3位のチームは 464,395 点だったので、1つ前の 452,697 点で終わっていたら4位でした。最後の1回の判断で順位が2つ変わったことになります。

結果

  • 最終スコア:475,546 点(総合2位、Rust 言語賞1位)
  • 競技中の最高:503,172 点(16:49)
  • 負荷走行:82 回(0 点の4回を含む)
結果の時刻スコア入れた変更
10:163,628初期実装(Go)
12:2892,537Rust で書き直し、全データをメモリに複製
13:04204,650asia2 を2台目のアプリに、署名を AVX-512 で計算
13:23273,384複製を優先度の高い専用スレッドへ
14:23309,814JPEG の検証を Huffman の復号だけに
15:11323,454不正なアップロードをすぐに拒否
16:18394,038セッションを担当リージョンで管理
16:39466,166セッションの fsync をまとめる
16:50503,172(変更なし、当日の最高)
17:54475,546最終(計測用のコードを外した版)

ふりかえり

一番効いたのは、15:40 に考察を捨てて、ログから得点の構造を見直したことでした。それまでは CPU や投稿の速さを追っていましたが、点を生んでいたのはログインを繰り返す巡回で、ログインの複製待ちを消すことが本当の課題でした。これにもっと早く気づいていれば、と思います。