Modal 上の vLLM(Gemma 4 26B-A4B)のコールドスタートを縮める

公開日

代表

当社が開発しているチャットサービスでは、チャットエージェントの返答を生成する LLM を、Modal の上の vLLM で動かしています。

このサービスでは GPU コストを削減するため、 一定時間リクエストがない場合、コンテナを止めるようにしています。

コストを削減できる一方、 コンテナが止まった後の最初のリクエストのレスポンスに約5分かかることが課題でした。

本記事では、起動時間の内訳の計測、ステージングで試した5つの策の結果、そして最終的に GPU メモリのスナップショットだけを採用した理由を順に書きます。

計測条件

項目値
モデルgoogle/gemma-4-26B-A4B-it(BF16、チェックポイント 48.07 GiB)+投機的デコーディング用のモデル
推論エンジンvLLM 0.31.0
実行基盤Modal(サーバーレス GPU)
GPUNVIDIA A100 80GB × 1
計測日2026-10-08〜2026-10-12

1. 起動の内訳:時間がかかっていたのは「重みの読み込み」ではなかった

今回の取り組みの前に、FlashInfer の JIT ビルドの結果を Volume に残すようにして、起動を一度短くしていました。

Modal のダッシュボードの Startup(コンテナの作成から起動まで)は次のとおりです。

状態起動時間
モデルと vLLM のコンパイル結果は Volume にある。FlashInfer の JIT ビルドは毎回走る約5〜7分
FlashInfer の JIT ビルドの結果も Volume に残すようにした後4分46秒

それでも5分近くかかるため、コンテナのログの時刻から起動の全区間を埋め、どこに時間がかかっているのかを調べました。

総起動時間は 4分21秒(261秒) です。

区間秒割合
受付 → vLLM 起動249%
→ EngineCore 初期化2811%
→ Loading model from scratch83%
重みの読み込み(48.07 GiB)3915%
init engine(profile + KV キャッシュ作成 + warmup)15058%
→ サーバーの応答開始125%

init engine の150秒の中身は次のとおりでした。

内訳秒出典
compilation24.4ログに明記
CUDA graph の取得24.0ログに明記
残り(profile + KV キャッシュ作成 + warmup)約102差分

48 GiB の重みの読み込みは全体の 15% にすぎませんでした。

いちばん大きいのは、vLLM が応答を受け付ける前に行う初期化(init engine)です。

ログからは、次の設定もわかりました。

  • CUDA graph を取る大きさが57通り(1〜512)
  • max_model_len が 262144(256K)
  • 重みは Modal の Volume(9P でマウントされる)に置いている

また、vLLM 自身が「ファイルシステムが 9P で、NFS や Lustre として認識できないため、重みの先読み(prefetch)を無効にしている」という警告を出していました。

2. 考えた策

内訳の計測結果に合わせて、策を次のように並べました。

#策対象の区間削減の上限
1GPU メモリのスナップショットinit engine約150秒
2--enforce-eagercompilation + CUDA graph約48秒
3--safetensors-load-strategy=prefetch(先読み)重みの読み込み39秒まで
4CUDA graph を取る大きさを絞るCUDA graph の一部24秒の一部
5重みをイメージに含める重みの読み込み39秒まで

本命は策1でした。

init engine の150秒(プロファイリング、KV キャッシュの確保、ウォームアップ)は、毎回同じ結果になる初期化です。

1回済ませて保存し、次からは復元すれば、その作業ごとなくせます。Modal の Memory Snapshots のドキュメントでは、起動の大部分が重みの読み込みの場合は効果が薄いとされています。

今回は重みの読み込みが 15% しかないので、この点は有利でした。

3. 試した策と結果

策1:GPU メモリのスナップショット

Modal の公式の例(vllm_low_latency.py)と同じ方法です。

なお、GPU メモリのスナップショットは、Modal ではまだアルファ版の機能です(Memory Snapshots)。

流れは次のとおりです。

  1. snap=True の enter で vLLM を起動する
  2. 生成を1回通す
  3. スリープ(/sleep?level=1)させた状態でスナップショットを取る
  4. 復元したあとは、snap=False の enter で /wake_up を呼んで起こす
@app.server(
    image=vllm_image,
    gpu="A100-80GB:1",
    enable_memory_snapshot=True,
    experimental_options={"enable_gpu_snapshot": True},
    # ...
)
class Server:
    @modal.enter(snap=True)
    def start(self):
        # vllm serve ... --enable-sleep-mode(環境変数 VLLM_SERVER_DEV_MODE=1)
        self.process = subprocess.Popen(cmd)
        wait_ready(self.process)
        # 生成を一度通しておき、実行時にしか起きない初期化もスナップショットに含める
        post("/v1/chat/completions", {...})
        post("/sleep?level=1")

    @modal.enter(snap=False)
    def wake_up(self):
        post("/wake_up")

公式の例は vLLM 0.11.2 です。vLLM 0.31.0・Gemma 4・投機的デコーディングの組み合わせで動くかは、試すまでわかりませんでした。

結果として、この組み合わせでスリープとスナップショットが動き、復元したコンテナで生成が正常に返ることを確認しました。

結果

起動
スナップショットなし4分21秒
スナップショットから復元1分51秒

約2分30秒(約6割)短くなりました。

init engine と重みの読み込みがなくなっています。

ただし、物差しは揃っていません。スナップショットなしの4分21秒はログの区間の合計です。

復元の1分51秒は、ログの Restoring Function from memory snapshot. から Ready までの時間です。リクエストの受付から Restoring までの時間(GPU の割り当てなど)はログに出ないので、1分51秒には含まれていません。

ほかにわかったことは次のとおりです。

  • スナップショットを作る回は遅い。 作成する起動は全体で約5分41秒かかり、スナップショットなしより遅くなりました。Modal のドキュメントでは、GPU の場合は GPU の種類ごとに2〜3回作られるとされています。コードや設定を変えてデプロイし直すと作り直しになります
  • 生成の速さは落ちない。 コンパイル結果と CUDA graph もスナップショットに含まれるためです

策2:--enforce-eager

compilation と CUDA graph の取得を丸ごと止める策です。変更は1行だけです。比べるために、同じ日に変更前の構成もデプロイし直し、同じ方法で計測しました。

区間変更前策2
重みの読み込み41秒23秒
init engine2分44秒6秒
起動の合計5分00秒1分18秒

init engine は約5秒まで縮みました。第1節で「プロファイリング・KV キャッシュの作成・ウォームアップ」と見積もった約102秒も、大半は eager でない場合にだけ行われる処理だったことになります。

なお、init engine 以外の区間(vLLM の起動、重みの読み込みなど)も、変更前のほうが倍ほど長くなっていました。策2とは関係のない区間なので、回によるばらつきと考えられます。策2の効果と言えるのは、init engine の約2分37秒です。

一方で、生成の速さに影響が出ました。

起動後1回目の返答最初の文字のあとの生成(中央値)
変更前2.2秒0.14秒(約100トークン/秒)
策214.3秒0.49秒(約25〜30トークン/秒)

最初の文字が出るまでの時間はほぼ同じでした。しかし、**そのあとの生成は約3.5倍遅くなりました。

** 今回の返答は9〜20トークンと短いので、1通あたりの差は約0.4秒です。返答が長いほど差は開きます。

また、起動後1回目の返答に14秒かかりました。起動で省いた準備が最初のリクエストに回ったのだと考えていますが、確認はしていません。

策3:重みの先読み(prefetch)

vLLM の警告に従って、先読みを強制する策です。3回起動して計測しました。

回数重みの読み込み
先読みなし4約39、21.81、20.50、37.62秒
先読みあり318.70、19.09、193.34秒

先読み自体は効いていました。ところが3回目は、2つ目のファイルの先読みに2分49秒かかりました。

Volume からの読み出しがその回だけ遅かったと考えています。

3回目を除くと先読みありは19秒前後で、先読みなしの速い回(20.5〜21.8秒)との差は1〜3秒でした。

→ 策3で起動が速くなったとは言えません。 この区間では、策の効果よりも読み出しの速さのぶれ(20〜193秒)のほうがはるかに大きかったからです。

策4:CUDA graph を取る大きさを絞る

上限を64に絞りました(--max-cudagraph-capture-size 64)。これで、CUDA graph を取る大きさは57通りから11通りに減ります。3回起動して計測しました。

1回目2回目3回目策4なし(それまでの計測の幅)
CUDA graph の取得24 + 5秒20 + 6秒34 + 4秒8〜66 + 10〜25秒
起動の合計4分53秒4分46秒2分58秒1分49秒〜5分43秒

大きさは約1/5になりましたが、時間は比例して減りませんでした。

策4なしでも18秒で済んだ回があり、この区間も回によるばらつきが大きいようです。遅い回が出なくなった可能性はありますが、3回では言い切れません。

生成の速さは変更前と変わりませんでした。

→ 起動時間への効果は、ばらつきに隠れて確認できませんでした。

策5:重みをイメージに含める

イメージのビルド時に重み(本体 48.1 GiB と投機的デコーディング用 0.81 GiB)を取得してイメージに置き、Volume から読まないようにしました。vLLM のログでも、重みのファイルシステムが 9P(Volume)から OVERLAY(イメージ)に変わったことを確認しています。

置き場所回数重みの読み込み
Volume(先読みなし)7約39、21.81、20.50、37.62、31.47、51.21、24.77秒
Volume(先読みあり)318.70、19.09、193.34秒
イメージ323.94、32.21、32.24秒

速くはなりませんでした。 速い回どうしで比べると、Volume のほうが数秒速いくらいです。

Modal のドキュメント(Storing model weights)にある「Performance is similar for the two methods」という記述と一致します。3回とも24〜32秒に収まり、Volume で出た37秒以上の回は出ませんでした。

ただ、3回だけでは遅い回がなくなったとまでは言えません。

運用面では、デプロイに約5分(299秒)かかるようになり、イメージは約50GBを超えます。

結果のまとめ

策起動への効果生成の速さ判断
1. GPU メモリのスナップショット復元は1分51秒(ばらつきより大きく短縮)変わらない採用
2. --enforce-eager起動は1分18秒約3.5倍遅い。起動後1回目は14秒不採用
3. 先読み確認できず(193秒の回もあった)—不採用
4. CUDA graph を絞る確認できず変わらない不採用
5. 重みをイメージに含める速くならなかった—(重みは同じ)不採用

4. 1回の計測では策の効果を見分けられない

策1〜4の計測を通して見ると、同じ設定でも起動時間は区間ごとに大きくばらついていました。

区間観測した幅
重みの読み込み18.70〜193.34秒
init engine31.65〜177.39秒
CUDA graph の取得(本体)8〜66秒
起動全体1分49秒〜5分43秒

vLLM が見た空きメモリは、89 GiB の回と 1017 GiB の回がありました。コンテナが性能の異なるマシンに置かれている可能性がありますが、確認はしていません。

起動全体の振れ幅が1分49秒〜5分43秒もあると、数十秒を縮める策の効果は1回や2回の計測では見えません。 策3・4・5の判断が「確認できなかった」になったのはこのためです。

ただし、策2の init engine(4.97秒)と、策1の復元(init engine 自体がない)は、ばらつきの下限(31.65秒)よりも明らかに短くなっています。

→ 区間そのものをなくす策は、ばらつきがあっても効果を見分けられました。

5. 結論:策1だけを採用

策1を選んだ理由

  • ばらつきの主な原因である2区間を両方なくせる。 ばらつきが大きかったのは、重みの読み込み(18.70〜193.34秒)と init engine(31.65〜177.39秒)の2区間でした。復元時には重みはスナップショットの CPU メモリにあり、init engine はスナップショットを取る前に済んでいます。ほかの策は、どちらか一方を少し短くするだけでした
  • 短くなった幅がばらつきより大きい。 復元から応答できるまでが1分51秒で、変更前(1分49秒〜5分43秒、中央付近は4〜5分)と比べて、ばらつき以上に短くなりました
  • 生成の速さが落ちない。 コンパイル結果と CUDA graph もスナップショットに含まれます

ほかの策を入れなかった理由

策理由
2. --enforce-eager起動は最短だが、生成が約3.5倍遅く、起動後1回目の返答に14秒かかる。策1と併用しても、復元の起動には効かない
3. 先読み効果を確認できなかった。策1では重みを読み込まない
4. CUDA graph を絞る起動への効果を確認できなかった。策1では CUDA graph はスナップショットを取る前に済んでいるため、復元には関係しない
5. 重みをイメージに含める速くならなかった。策1では重みを読み込まないうえ、デプロイが約5分に延びる

おわりに

「重いモデルだから起動が遅い」と思い込みがちですが、計測すると、重みの読み込みは全体の 15% にすぎませんでした。時間の大半は、vLLM が応答を受け付ける前の初期化にありました。そして、その初期化を毎回やり直さずに済ませる GPU メモリのスナップショットが、最も効果のある策でした。

もう一つの学びは、クラウドの GPU コンテナの起動時間は回ごとに大きくぶれるということです。数十秒を縮める策の効果は、1〜3回の計測ではばらつきに埋もれてしまいます。策を比べるときは、計測を重ねるか、ばらつきよりはっきり大きい差が出る策を選ぶ必要があります。

出典

公式情報

本記事の数値は上記の計測条件での当社実測です。GPU の割り当て先のマシン、Volume の状態、vLLM や Modal のバージョンなどによって変動します。