GPUクラウドのRunPodで画像生成を回している私のところに、ふとした疑問がやってきました。
「ここにLLMも置けないだろうか」
置けるなら、日本語の指示を画像生成用の英語プロンプトに変換する仕事を、手元で完結できます。そこで推論サーバの SGLang を実際に立てて、どのくらいの速度が出るのかを測ってみました。
結論から言うと、RTX3090が1枚あれば、同時64本のリクエストを14秒で捌けました。
ただ、この記事で一番お伝えしたいのは速度の話ではありません。「画像生成と同居できない一番の理由はVRAMの容量だろう」と踏んでいたのが、測ってみたら外れていたという話です。本当の壁は別のところにありました。
この記事を読み終えると、次のことができるようになります。
RunPodにSGLangの推論サーバを立てて同時実行のスループットを自分で測り、既存のGPUに同居させてよいかを、推測ではなく実測値で判断できる。
使ったコードは全部GitHubに置いてあります。
SGLangとは(3行で)
LLMを動かすとき、transformers で直接呼ぶ方法もありますが、リクエストが増えると途端に苦しくなります。推論サーバは、その苦しいところを引き受けてくれる専門職です。
SGLang はその一種で、OllamaやvLLMの仲間だと思ってください。特徴はたくさんのリクエストをまとめて捌くのが得意なことと、OpenAI互換のAPIを喋ることです。後者が地味に効きます。呼び出し側のコードは、OpenAIを使うときとほぼ同じでいいのです。
「同居させたい」という最初の欲
私のRunPod環境では、すでにStable Diffusion WebUI Forgeが動いています。素直に考えれば、同じPodにSGLangも入れるのが節約になります。GPUは1枚で済むし、Podも1つで済みます。
やめました。理由は3つあります。
| 懸念 | 中身 |
|---|---|
| VRAM | RTX3090は24GB。SGLangは既定でVRAMの大半を先に確保するので、画像生成側が場所を失う |
| 依存ライブラリ | SGLangはPyTorchのバージョンを固定している。同じ環境に入れると、画像生成側のPyTorchを上書きしてしまう |
| CUDA | 既存環境はCUDA 12.1。SGLangはもっと新しい世代を想定している |
……と、ここまでは全部「たぶんそうだろう」という推測でした。この推測が後で1つ覆り、1つ裏付けられます。そこがこの記事の山場です。
とりあえず今回は、専用のPodを別に立てる方針で進めます。既存の環境を壊してしまったら、画像生成が止まってしまいますから。
Podを立てる(1回目、失敗)
RunPodのGraphQL APIでPodを作ります。公式イメージがあるので、自分でビルドする必要はありません。
fields = [
"cloudType: SECURE",
"gpuCount: 1",
'gpuTypeId: "NVIDIA GeForce RTX 3090"',
"volumeInGb: 0",
"containerDiskInGb: 80",
'imageName: "lmsysorg/sglang:latest"',
'ports: "30000/http,22/tcp"',
"startSsh: true",
]
Podは無事にできました。RUNNING と表示されています。ではSSHで入って、サーバを起動しましょう。
Error response from daemon: container ... is not running
入れません。 SSHの接続そのものは通っているのに、その先のコンテナが動いていないと言われます。
APIでPodの状態を見に行くと、答えが書いてありました。
{ "uptimeInSeconds": -1, "container": { "cpuPercent": 0 } }
稼働時間がマイナス。CPU使用率がゼロ。このコンテナ、生まれた瞬間に力尽きています。
原因は「イメージに仕事を与えていなかった」
lmsysorg/sglang のイメージは、常駐するプロセスを持っていません。推論サーバは引数で起動させる前提の作りで、何も指示しないと「やることがない」と判断して終了します。
私が引っかかったのは、画像生成のイメージの感覚をそのまま持ち込んだからでした。あちらは中でWebUIがずっと動き続けるので、Podを作れば中で何かが動いているのが当たり前だと思い込んでいたのです。
対処は1行です。コンテナに「とりあえず寝ていてくれ」と伝えます。
"dockerArgs": "bash -c 'sleep infinity'"
これでコンテナは起きたまま待機します。サーバの起動は、SSHで入ってから自分の手でやります。Pod作成と同時にサーバを起動させる方法もありますが、失敗したときに原因が見えないまま作り直しになるので、分けておくのがおすすめです。
サーバを起動する
Podに入って、次のコマンドを叩きます。
setsid nohup python3 -m sglang.launch_server \
--model-path Qwen/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 30000 \
> /root/sglang.log 2>&1 < /dev/null &
ここに、後から効いてくる小さな部品が2つ入っています。
-
--host 0.0.0.0— これがないと、サーバはPodの中からしか見えません。外から届かなくなります -
setsid nohup— SSHを切ってもサーバを生かしておくための呪文です。nohupだけだと、切断のときに巻き添えで落ちることがあります
起動ログを覗くと、内部で何が起きているのかがよく分かります。
| 段階 | かかった時間 |
|---|---|
| モデルの重みを読み込む | 78.41秒(14.30GB) |
| KVキャッシュを確保する | 約5GB(94,637トークン分) |
| CUDAグラフを固める | 26.05秒 |
そして最後に、こう出ます。
The server is fired up and ready to roll!
準備完了です。「fired up」という表現が、なんだかやる気に満ちていて好きです。
ここまで、Podを作ってから約3分。約15GBのモデルをダウンロードした時間も含めての3分です。クラウド側の回線が速いおかげで、手元でモデルを落とすより圧倒的に速く終わります。
手元から叩く
RunPodは、公開したポートに外からアクセスできるURLを用意してくれます。形はこうです。
https://<POD_ID>-30000.proxy.runpod.net
まずは生きているか確認します。
curl -s https://<POD_ID>-30000.proxy.runpod.net/v1/models
{"object":"list","data":[{"id":"Qwen/Qwen2.5-7B-Instruct","max_model_len":32768}]}
次に、日本語で話しかけてみます。
curl -s https://<POD_ID>-30000.proxy.runpod.net/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"Qwen/Qwen2.5-7B-Instruct",
"messages":[{"role":"user","content":"あなたは何ができますか。日本語で3行で答えてください。"}],
"max_tokens":256}'
返ってきた答えがこちらです。
私は情報を検索し、質問に答え、文章を書きます。
さまざまなトピックについて会話ができるようになっています。
また、翻訳やアドバイスも提供します。
**3行で答えてくれました。**言われたことを守る子です。
見てのとおり、パスは /v1/chat/completions。OpenAIのAPIと同じ形です。つまりPythonから使うときも、openai ライブラリの base_url をこのURLに差し替えるだけで動きます。
速度を測る——最初の数字に騙されない
さて本題の速度です。上の短い返答で測ってみました。
10.7 tokens/sec。
……遅い。7Bのモデルを24GBのGPUで動かして、これはいくらなんでも遅すぎます。
ここで数字を鵜呑みにせず、もっと長い文章を生成させて測り直しました。
| 測り方 | 生成トークン数 | 時間 | tokens/sec |
|---|---|---|---|
| 短い返答 | 43 | 4.01秒 | 10.7 |
| 長い返答① | 512 | 11.15秒 | 45.9 |
| 長い返答② | 440 | 10.21秒 | 43.1 |
4倍以上違います。
からくりは単純で、1回のリクエストには固定でかかる時間があります。接続を確立する時間と、最初の1文字が出てくるまでの考える時間です。この固定費は生成が短くても長くても同じだけかかるので、短い生成だと固定費の割合が大きくなり、遅く見えるわけです。
というわけで、このサーバの実力は 43〜46 tokens/sec と読むのが正しい。ベンチマークを取るときは、短い出力だけで判断しないのが大事です。
本題——同時に何本まで捌けるのか
推論サーバを使う一番の理由がここです。1本ずつ順番に処理するのではなく、まとめて捌くのが彼らの本業です。
同時本数を1から64まで増やして、全部同じ長さの生成をさせました。
| 同時本数 | 合算 tokens/sec | 1本あたり tokens/sec | 全体時間 | 失敗 |
|---|---|---|---|---|
| 1 | 45.9 | 45.9 | 11.15秒 | 0 |
| 4 | 159.9 | 42.4 | 12.81秒 | 0 |
| 8 | 355.3 | 44.4 | 11.53秒 | 0 |
| 16 | 689.6 | 43.1 | 11.88秒 | 0 |
| 32 | 1310.3 | 40.9 | 12.39秒 | 0 |
| 64 | 2266.8 | 35.4 | 14.29秒 | 0 |
64倍の仕事を投げたのに、全体時間は11秒から14秒になっただけでした。
1本あたりの体感速度も、46から35へ落ちただけ。77%を保っています。64人が同時に話しかけてきても、一人ひとりへの返事はほとんど遅くならない、ということです。
順番に1本ずつ処理していたら、64本で約11分かかる計算です。それが14秒。
しかも、まだ余裕があります。起動ログには max_running_requests=2048 と書いてありました。64本は、サーバから見れば軽い準備運動のようなものです。
最後に、最初の推測を答え合わせする
冒頭で「同居させない理由」を3つ挙げました。全部推測でしたが、実物を測ったので答え合わせをします。
VRAMの心配は、当たっていました。
21310 MiB / 24576 MiB
サーバが起動した時点で、24GBのうち**87%**を使っています。画像生成が入る余地は残っていません。
CUDAの心配は、想像より深刻でした。
Podの中でPyTorchのバージョンを見てみます。
sglang 0.5.16
torch 2.11.0+cu130
CUDA 13.0。私の画像生成環境は12.1なので、世代が2つ違います。同じ場所に入れていたら、間違いなく壊していました。
ところが、ここで見立てが引っくり返る
「じゃあもっと小さいモデルなら同居できるのでは?」——当然そう考えます。1.5Bクラスなら4〜5GBで済むので、画像生成の5〜6GBと足しても10GB前後。24GBには余裕で収まります。
しかも、SGLangには先に確保するVRAM量を絞るオプション(--mem-fraction-static)があります。VRAMの問題は、設定で解けるのです。
つまり、私が「一番の壁」だと思っていたVRAMは、実は壁ではありませんでした。
本当の壁は2番目と3番目、依存ライブラリとCUDAの世代差のほうです。そしてこちらは、モデルを小さくしても1ミリも解決しません。CUDA 13.0と12.1の差は、モデルのサイズとは何の関係もないからです。
容量の話だと思っていたら、中身の話だった。測る前と後で、問題の所在そのものが動きました。
(残された道は、同じPodの中でPython環境を分けるか、両方入りのイメージを自分で作るか。次はそこを試すつもりです)
まとめ
- ⭐ 容量の問題に見えたものが、実は依存の問題だった。 VRAMは設定と小さいモデルで解けるが、CUDAの世代差はモデルを小さくしても消えない。推測で決めず、測ってから判断すると景色が変わる
- SGLangはRunPodで問題なく動く。 公式イメージを使えばビルド作業はなく、Pod作成から3分で待ち受けまで届く
-
lmsysorg/sglangは常駐プロセスを持たない。dockerArgsにsleep infinityを渡さないとコンテナが即終了する -
--host 0.0.0.0を忘れない。 外から届かなくなる - 短い生成だけでベンチマークを取らない。 固定費に埋もれて、実力の4分の1に見える
- RTX3090一枚で、同時64本を14秒。 1本あたりの速度は77%を維持し、失敗はゼロ
なお、今回測っていないものも書いておきます。同時128本以上、数千トークンの長い入力、ストリーミング応答は未確認です。誰かが測ったら、ぜひ教えてください。
コードはこちらです。Pod作成から後片付けまで一通り入っています。