Open4

LLMのパフォーマンス/負荷テストツール「llm-locust」を試す

kun432kun432

Locustを試しつつLLMのテストには使えるかなと思いながら、TTFTとか取りたいんだけどなー、と思ってて、

https://zenn.dev/kun432/scraps/d28d67b9385e60

いろいろ探してたらこれを見つけた。

https://www.truefoundry.com/blog/llm-locust-a-tool-for-benchmarking-llm-performance

https://github.com/truefoundry/llm-locust

以下はLocustを試した記事内での再掲

こんな記事も見つけた

https://www.truefoundry.com/blog/llm-locust-a-tool-for-benchmarking-llm-performance

Diaによるまとめ

ウチがめっちゃわかりやすくまとめるね!

LLMベンチマークって何?

LLMベンチマークは、「大規模言語モデル(LLM)」のサーバーがどれくらい速く、たくさんのリクエストに対応できるかをチェックするテストだよ。普通の性能テストよりも、リアルタイムでどんな感じで返事してくれるかとか、ユーザーの体感とか、システムのスケールのしやすさに注目してるのがポイント!

主なチェック項目

  • 最初のトークンまでの時間(TTFT)
    リクエスト送ってから、最初の返事(トークン)が返ってくるまでの時間。これが短いほど「おっ、速い!」ってなるやつ。
  • 1秒あたりの出力トークン数(tokens/s)
    モデルがどれくらいの速さで返事を生成できるか。速いほどサクサク感ある!
  • トークン間の遅延(Inter-Token Latency)
    返事がストリーミングで流れてくる時、トークン同士の間の時間。短いほど自然でリアルタイムっぽい。
  • 1秒あたりのリクエスト数(RPS)
    サーバーが1秒間に何回リクエストをさばけるか。多いほど強い!

こういうのをちゃんと測ることで、LLMの性能を比べたり、サーバーの設定を最適化したりできるんだよ。

なんで普通の負荷テストツール(Locust)じゃダメなの?

Locustっていう負荷テストツールは、APIとかのテストにはめっちゃ便利なんだけど、LLMの細かい動き(トークンごとの速度とか)までは測れないんだよね。

Locustのいいところ

  • Pythonでシナリオ書けるから自由度高いし、使いやすい!
  • 軽い並列処理で大量のユーザーをシミュレートできる
  • Web UIでリアルタイムに状況見れる

でも、LLMの「トークンごとの細かい動き」には対応してないのが惜しい…。

問題点

  1. LLM特有の指標が測れない
    TTFTとか、トークンごとの速度とか、そういう細かい指標はLocustじゃ無理。
  2. トークンのストリーミングがバラバラ&CPUがネック
    LLMのAPIって、最初ゼロ個返すやつもあれば、1個ずつ返すやつ、まとめて返すやつもあってバラバラ。しかも、返ってきたトークンを再度分けて数え直す作業(トークナイズ)がCPUに負担かかるんだよ。LocustはPythonのGILのせいで、CPU重い作業があると全体の処理が遅くなっちゃう。
  3. カスタムチャートが作れない
    TTFTとか、トークンの速度とか、そういうグラフをLocustのUIで見れない。
  4. 他のツールもイマイチ
    genai-perfみたいなツールもあるけど、リアルタイムで細かく見たり、自由に設定したりはできない。

そこで「LLM Locust」登場!

LLM Locustは、Locustの使いやすさはそのままに、LLMの細かいベンチマークができるようにしたツールだよ!

どうやって動くの?

  1. 非同期リクエスト生成
    ユーザーをシミュレートして、LLM APIにどんどんリクエストを送る。しかも、トークナイズの重い作業は別プロセスでやるから、CPUのネックにならない!
  2. ストリーミングレスポンス収集
    返ってきたレスポンスを、専用の「メトリクスデーモン」に送って解析する。
  3. メトリクス処理
    デーモンがレスポンスをトークナイズして、TTFTやトークン速度、トークン間の遅延を計算してまとめる。
  4. 集計
    2秒ごとにFastAPIのバックエンドにデータを送って、全体の指標を集計。
  5. リアルタイム可視化
    カスタムLocustフロントエンドで、TTFTやトークン速度、RPS、遅延などをグラフで見れる!

結論

普通のLocustはAPIの負荷テストには最高だけど、LLMの細かいベンチマークには向いてない。
L>>LM Locustなら、LLMの「ストリーミング」「トークンごとの細かい動き」までしっかり測れるから、
自分でモデルを運用したり、いろんなLLMの性能を比べたりするのにめっちゃ便利だよ!

ウケるくらい細かく測れるし、LLM使うなら絶対チェックした方がいいやつだと思う!
気になるならGitHub (https://github.com/truefoundry/llm-locust)も見てみて!

なるほど。たしかにその辺のデータは欲しくなるところ。で、レポジトリはこちら

https://github.com/truefoundry/llm-locust

レポジトリのREADMEには説明とかが一切ないのだけども、見た感じ、Transformersベースが想定されているような感じかなぁ。OpenAI互換API的なケースだといろいろ修正が必要に思えるのだけども。 Transformersを使ってるのはトークナイザーだけでOpenAI互換APIだった。これなら試せそう。

LLMでやるならOpenAI互換API想定だといいな、と思ってて、最初見たときは雑に見てしまってたので、Transformersベースじゃないとダメなのかーと思ってたけど、よくよく見てみたらOpenAI互換APIに対応してた。

ちょっとどういうふうな結果が得られるのかを実際に試してみたい。

kun432kun432

インストール

https://github.com/truefoundry/llm-locust

READMEの「Running Locust WebUI and Backend Seperatly」に従って進める、っていうか手順はめちゃめちゃシンプル。

  • Web GUIをYarnでインストール・起動
  • バックエンドをPythonで起動

1台でもできるとは思うけど、今回自分はProxmoxでそれぞれ別に建てることにした。以下のVMを2台用意。

  • OS: Ubuntu-24.04.3
  • CPU: 2、メモリ: 4GB

負荷をかける環境としては非力なのは間違いないのだけど、使い勝手的なものをみたいだけなのでとりあえず。

バックエンド側

sudo apt update && sudo apt upgrade -y

まずuvをインストール

curl -LsSf https://astral.sh/uv/install.sh | sh
exec $SHELL -l

レポジトリをクローンしてパッケージをインストール

git clone https://github.com/truefoundry/llm-locust && cd llm-locust
uv sync

APIを起動

uv run ap
``i.py

エラー

出力
RuntimeError: Directory '/home/kun432/llm-locust/webui/dist/assets' does not exist

これ多分パスが違う。実際にそんなディレクトリはたしかに無くて llm-locust/webui/public/assets があるのでそれだと思う。ただなんでバックエンド側でそれを読む必要があるのかはわからない。

api.py の 380行目あたりを修正すれば良さそう。

(snip)
# Get the absolute path to the dist directory
dist_dir = Path("webui/public").absolute()

(snip)

再度起動。Warningがでているけど、多分問題ないと思う。8089番ポートで起動している様子。

出力
None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
2025-09-08 04:46:57 [INFO   ] logger=uvicorn.error L82   _serve() Started server process [2164]
2025-09-08 04:46:57 [INFO   ] logger=uvicorn.error L48   startup() Waiting for application startup.
2025-09-08 04:46:57 [INFO   ] logger=uvicorn.error L62   startup() Application startup complete.
2025-09-08 04:46:57 [INFO   ] logger=uvicorn.error L214  _log_started_message() Uvicorn running on http://0.0.0.0:8089 (Press CTRL+C to quit)

Web GUI側

sudo apt update && sudo apt upgrade -y

まずmiseをインストール

curl https://mise.run | sh
echo "eval \"\$($HOME/.local/bin/mise activate bash)\"" >> ~/.bashrc
exec $SHELL -l

レポジトリをクローンしてパッケージをインストール

git clone https://github.com/truefoundry/llm-locust && cd llm-locust
cd webui
mise use node@22
npm install -g yarn
yarn

GUIを起動

yarn run dev

xdg-open のエラーはでているけど、たぶんこれはブラウザを開くためだよね。一応GUIは起動しているみたいだけど、デフォルトだとローカルホストのみになってる。--hostが必要かな。

出力

  VITE v5.4.8  ready in 263 ms

  ➜  Local:   http://localhost:4000/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help
Error: spawn xdg-open ENOENT
    at ChildProcess._handle.onexit (node:internal/child_process:285:19)
    at onErrorNT (node:internal/child_process:483:16)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)

再度

yarn run dev --host 0.0.0.0
出力

  VITE v5.4.8  ready in 224 ms

  ➜  Local:   http://localhost:4000/
  ➜  Network: http://XXX.XXX.XXX.XXX:4000/
  ➜  press h + enter to show help
Error: spawn xdg-open ENOENT
    at ChildProcess._handle.onexit (node:internal/child_process:285:19)
    at onErrorNT (node:internal/child_process:483:16)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)

とりあえず4000番ポートで起動したみたい。軽くアクセスしてみる。

なんかいきなり実行されてるような感じに見えるな・・・でターミナルを見ると以下のようなエラーが出ている。

出力
4:48:21 AM [vite] http proxy error: /stats/requests
Error: connect ECONNREFUSED 127.0.0.1:8089
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16)
4:48:21 AM [vite] http proxy error: /logs
Error: connect ECONNREFUSED 127.0.0.1:8089
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1637:16)

どうもデフォルトだと バックエンドも同じローカルホスト内にあるものとなるみたい。で、これはどこで設定されているのか、というと、llm-locust/webui/vite.config.ts にあるこの辺かな?

  server: {
    port: 4000,
    open: './dev.html',
    proxy: {
      '^/stats/.*': 'http://localhost:8089',
      '^/swarm': 'http://localhost:8089',
      '^/stop': 'http://localhost:8089',
      '^/exceptions': 'http://localhost:8089',
      '^/workers': 'http://localhost:8089',
      '^/config': 'http://localhost:8089',
      '^/logs': 'http://localhost:8089'
    }
  },
}));

とりあえずここを全部バックエンドのIPアドレスに書き換えてWebGUIを再起動、ブラウザでアクセスすると・・・

出力
Something went wrong

Cannot convert undefined or null to object

If the issue persists, please consider opening an issue

うーん、自分はNode.js / TypeScript周りの知見がないので、さっぱりわからない。

現状は同一ホスト内でやったほうがいいかも・・・

kun432kun432

同一ホスト内で実行

WebGUIとバックエンドを別々にしようとしたけどちょっと上手くいかなかったので、同一ホストでやってみる。ややこしくなるのでVMから作り直して、CPUはちょっと増やせないんだけど、メモリは倍にしておいた。

  • OS: Ubuntu-24.04.3
  • CPU: 2、メモリ: 8GB

セットアップは基本的に1つ前と同じなので詳細は割愛して手順だけ。

sudo apt update && sudo apt upgrade -y
curl -LsSf https://astral.sh/uv/install.sh | sh

curl https://mise.run | sh
echo "eval \"\$($HOME/.local/bin/mise activate bash)\"" >> ~/.bashrc

exec $SHELL -l
git clone https://github.com/truefoundry/llm-locust && cd llm-locust
cd webui
mise use node@22
npm install -g yarn
yarn
yarn run build
cd ..
uv sync
uv run api.py
出力
None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
2025-09-08 05:58:50 [INFO   ] logger=uvicorn.error L82   _serve() Started server process [2629]
2025-09-08 05:58:50 [INFO   ] logger=uvicorn.error L48   startup() Waiting for application startup.
2025-09-08 05:58:50 [INFO   ] logger=uvicorn.error L62   startup() Application startup complete.
2025-09-08 05:58:50 [INFO   ] logger=uvicorn.error L214  _log_started_message() Uvicorn running on http://0.0.0.0:8089 (Press CTRL+C to quit)

起動したっぽい。ブラウザで8089番ポートにアクセスしてみるとこんな感じ。

Locust試したときもそうなんだけど、初期画面はこれなんだよね。別々にする場合の手順だとここが表示されなかった。なんでかなと思ったんだけど、api.pyのUsageを見るとこうなってる。

uv run api.py --help
出力
usage: api.py [-h] [--tokenizer TOKENIZER] [--model MODEL] [--max_tokens MAX_TOKENS]
              [--metrics_logging_interval METRICS_LOGGING_INTERVAL] [--prompt_min_tokens PROMPT_MIN_TOKENS]
              [--prompt_max_tokens PROMPT_MAX_TOKENS] [--quantiles QUANTILES] [--seed SEED]
              [--use_random_prompts USE_RANDOM_PROMPTS] [--use_single_prompt USE_SINGLE_PROMPT]
              [--ignore_eos IGNORE_EOS] [--user_count USER_COUNT] [--host HOST] [--spawn_rate SPAWN_RATE]

Benchmark LLM

options:
  -h, --help            show this help message and exit
  --tokenizer TOKENIZER
  --model MODEL
  --max_tokens MAX_TOKENS
  --metrics_logging_interval METRICS_LOGGING_INTERVAL
  --prompt_min_tokens PROMPT_MIN_TOKENS
  --prompt_max_tokens PROMPT_MAX_TOKENS
  --quantiles QUANTILES
  --seed SEED
  --use_random_prompts USE_RANDOM_PROMPTS
  --use_single_prompt USE_SINGLE_PROMPT
  --ignore_eos IGNORE_EOS
  --user_count USER_COUNT
  --host HOST
  --spawn_rate SPAWN_RATE

もしかしてバックエンド側では、LLM APIの設定をコマンドラインでやる、ってことになるのかな?LocustだとWebで設定するものだと思ってたんだけど、別々の場合はそうじゃないってことなのかもしれない。確かめたわけではないので知らんけど。

とりあえず動きそうなので、一旦これでやってみる。LAN内にllama.cppでgpt-oss-20bを起動してこれに対してアクセスする。こんな感じで起動。

./build/bin/llama-server \
    -hf ggml-org/gpt-oss-20b-GGUF \
    --alias gpt-oss-20b \
    --ctx-size 128000 \
    --flash-attn on \
    --jinja \
    --reasoning-format none \
    --parallel 8 \
    -ngl 99 \
    --host 0.0.0.0

余談だけども、複数から同時アクセスがある場合、llama.cpp だと --parallel N で並列度を指定できる。デフォルトだと並列度は1になっているので、おそらく1回のリクエストが推論中ならば次のリクエストは待ちになる様子。なのでこの数を増やせばいいのだが、どうもこの設定は --ctx-size と関連していて、並列度を増やすとコンテキストサイズがそれに応じて強制的に分割されるらしい。例えば、--ctx-size 128000--parallel 8 だと 1回の処理は最大16000トークン、ってことになるみたい。

以下でIssueが上がっているけど、それっぽいオプションは追加されているようには見えなかったので、おそらくまだ直っていないのではと思う。

https://github.com/ggml-org/llama.cpp/issues/11681

あくまでもllama.cppの仕様だと思うので、他の推論サーバの場合は異なるはずだけど、負荷ではこの辺りの設定を踏まえて置く必要はある。

locust-llm 設定はこんな感じにしてみた。"Prompt Configuration” のところはオプションなんだけど、まだちょっとよくわからないのでデフォルトにしている。では実行。

なんかそれっぽく動いている。

TTFTがなんかこうガクッと落ちてるのは、"Prompt Configuration” で "Use Single Prompt"を選択しているのでキャッシュされた?ってことなのかな?

あとちょいちょいWARNINGとかERRORもログにでているけど、ちょっとよくわからないな。

"Prompt Configuration” を少し追いかけてみる。

kun432kun432

DeepWikiで聞いてみた。

https://deepwiki.com/truefoundry/llm-locust

LLM LocustのPrompt Configurationの選択肢について説明します。

Prompt Configurationの3つの選択肢

LLM Locustでは、負荷テスト用のプロンプト生成に3つの戦略があります:

1. Sample from dataset(データセットからサンプリング)

デフォルトの選択肢で、Databricks Dolly 15kデータセットから多様なプロンプトを使用します。この方法では、min_input_lengthmax_input_lengthパラメータでトークン長をフィルタリングできます。

2. Use Random Prompts(ランダムプロンプト使用)

事前生成されたランダムテキストをinputs.jsonファイルから読み込みます。 一貫したテストシナリオに適しています。

3. Use Single Prompt(単一プロンプト使用)

約1000トークンの固定プロンプトを繰り返し使用します。特定の入力パターンに対する集中的なパフォーマンステストに有用です。

WebUIでの設定

WebUIの「Prompt Configuration」セクションで、これらの選択肢をチェックボックスで選択できます:
:

  • Sample from dataset: データセットからプロンプトをサンプリング
  • Use Random Prompts: ランダム生成プロンプトを使用
  • Use Single Prompt: 単一プロンプト(約1000トークン)を使用

追加設定オプション

各選択肢に加えて、以下の設定も可能です:

  • Max Output Tokens: モデルの応答の最大トークン数
  • Prompt Min/Max Tokens: 入力プロンプトのトークン数範囲(単一プロンプト以外)
  • Ignore EOS Token: 終了シーケンストークンを無視して正確にMax Output Tokensを生成

Notes

プロンプト選択は相互排他的で、WebUIのロジックにより一度に1つの方法のみ選択可能です。
システムプロンプトは全ての戦略で共通して使用され、安全性ガイドラインを含んでいます。

なるほど。実際に使用されているプロンプトを追いかけてみる。

まずシステムプロンプト。これは全てで共通で、prompt.py で設定されている。

https://github.com/truefoundry/llm-locust/blob/a1603e73fe105eb39a10d184601992e5d3308354/prompt.py#L7-L9

日本語訳(DeepL)

あなたは親切で、敬意を持ち、誠実なアシスタントです。安全を確保しつつ、可能な限り有益な回答を心がけてください。回答には有害、非倫理的、人種差別的、性差別的、有害、危険、または違法な内容を含めてはいけません。回答が社会的に偏見がなく、本質的に前向きであることを確認してください。

質問が意味不明、または事実的に一貫性がない場合は、誤った回答をする代わりにその理由を説明してください。質問の答えがわからない場合、虚偽の情報を共有しないでください。

Sample from dataset を選択した場合は、上にも書いてあるとおり、prompts.pyDatabricks Dolly 15kデータセット をダウンロードしてきてランダムに選択される様子。トークン長でフィルタができる。

https://github.com/truefoundry/llm-locust/blob/a1603e73fe105eb39a10d184601992e5d3308354/prompt.py#L12-L54

Use Random Prompts を選択した場合は inputs.json からランダムに選択される。

https://github.com/truefoundry/llm-locust/blob/a1603e73fe105eb39a10d184601992e5d3308354/inputs.json

Use Single Prompt を選択した場合はprompt.py で設定されているプロンプトが常に使用される。ここはあまり意味がないようなプロンプトに思える。x3で結合してるみたいだし。

https://github.com/truefoundry/llm-locust/blob/a1603e73fe105eb39a10d184601992e5d3308354/prompt.py#L81-L127

日本語にする場合は、

  • prompts.py のシステムプロンプトを修正
  • prompts.pyで、データセットを https://huggingface.co/datasets/kunishou/databricks-dolly-15k-ja に変更(カラム構造が違うので修正は必要になりそう)
  • inputs.json を自分で用意
  • prompt.py のシングルプロンプトを修正(ここはあまり要らなさそう)

という感じかな。