Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions llm_bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ When comparing multiple configurations, it's useful to aggregate results togethe

The typical workflow would be to run benchmark several times appending to the same CSV file. The resulting file can be imported into a spreadsheet or pandas for further analysis.

The final summary and CSV include `Total Requests`, `Successful Requests`, `Failed Requests`, and `Failure Ratio` (a fraction from 0 to 1). These count recorded inference POST requests, excluding synthetic metric events and model-discovery GET requests. `--max-fail-ratio` defaults to 0.01: a run fails when more than 1% of these requests fail. A run with no inference requests also fails, with a blank failure ratio. Failed runs still produce a summary and CSV row. In distributed runs, only the master evaluates the benchmark failure ratio and writes the final summary and CSV row.

`Num Requests` and `Qps` retain their existing meaning: completed responses with latency measurements. Intentional cancellations from `--cancel-rate` count as successful HTTP requests but do not contribute completed-response latency samples or advance `--max-requests`, which retains its successful-completion quota.

New CSV files include the applicable latency percentile columns even when the first run has no samples; unavailable values are blank. When appending to an existing CSV, its header and column order are preserved. New fields absent from that header are omitted with a warning; use a new summary file to capture all outcome fields.

### Custom prompts

Sometimes it's necessary to replay exact prompts, for example in the case of embedding images.
Expand Down
55 changes: 38 additions & 17 deletions llm_bench/load_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import traceback
from typing import Any, Optional
from locust import HttpUser, task, events, constant_pacing
from locust.runners import WorkerRunner
import copy
import json
import time
Expand Down Expand Up @@ -2176,27 +2177,31 @@ def init_parser(parser):

@events.quitting.add_listener
def _(environment, **kw):
if isinstance(environment.runner, WorkerRunner):
return
total_latency = environment.stats.entries[("total_latency", "METRIC")]
total = environment.stats.total
fail_ratio = (total.num_failures / total.num_requests) if total.num_requests > 0 else 1.0
if fail_ratio > environment.parsed_options.max_fail_ratio:
Comment thread
kiteretsu903 marked this conversation as resolved.
logger.error(f"Test failed: {total.num_failures}/{total.num_requests} requests failed ({fail_ratio:.2%})")
request_stats = [entry for (_, method), entry in environment.stats.entries.items() if method == "POST"]
num_requests = sum(entry.num_requests for entry in request_stats)
num_failures = sum(entry.num_failures for entry in request_stats)
fail_ratio = num_failures / num_requests if num_requests else None
if fail_ratio is None:
logger.error("Test failed: no inference requests recorded")
environment.process_exit_code = 1
return
# Explicitly set exit code 0 so that locust's default policy does not force
# a non-zero exit when runner.exceptions is non-empty (e.g. unhandled
# exceptions from non-request paths like dataset loading).
environment.process_exit_code = 0
elif fail_ratio > environment.parsed_options.max_fail_ratio:
logger.error(f"Test failed: {num_failures}/{num_requests} requests failed ({fail_ratio:.2%})")
environment.process_exit_code = 1
else:
environment.process_exit_code = 0

entries = copy.copy(InitTracker.logging_params)
entries = copy.copy(InitTracker.logging_params or {})
if environment.parsed_options.qps is not None:
entries["concurrency"] = f"QPS {environment.parsed_options.qps} {environment.parsed_options.qps_distribution}"
else:
entries["concurrency"] = InitTracker.users

def _avg(metric_name):
entry = environment.stats.entries.get((metric_name, "METRIC"))
return entry.avg_response_time if entry else ""
return entry.avg_response_time if entry and entry.num_requests else ""

if getattr(environment.parsed_options, "embeddings", False):
for metric_name in [
Expand Down Expand Up @@ -2241,14 +2246,20 @@ def _avg(metric_name):

entries["num_requests"] = total_latency.num_requests
entries["qps"] = total_latency.total_rps
entries["total_requests"] = num_requests
entries["successful_requests"] = num_requests - num_failures
entries["failed_requests"] = num_failures
entries["failure_ratio"] = fail_ratio if fail_ratio is not None else ""
percentile_to_report = [50, 90, 95, 99, 99.9]
for percentile_metric in percentile_metrics:
metric_entry = environment.stats.entries.get((percentile_metric, "METRIC"))
if metric_entry is None:
continue
for percentile in percentile_to_report:
name = f"P{percentile}_{percentile_metric}"
entries[name] = metric_entry.get_response_time_percentile(percentile / 100)
entries[name] = (
metric_entry.get_response_time_percentile(percentile / 100)
if metric_entry and metric_entry.num_requests
else ""
)

pretty_name = lambda s: " ".join([w.capitalize() for w in s.split("_")])
entries = {pretty_name(k): v for k, v in entries.items()}
Expand All @@ -2263,8 +2274,18 @@ def exit_printer(**kw):
print("=" * 80)

if environment.parsed_options.summary_file:
with open(environment.parsed_options.summary_file, "a") as f:
writer = csv.DictWriter(f, fieldnames=entries.keys())
if f.tell() == 0:
with open(environment.parsed_options.summary_file, "a+", newline="") as f:
f.seek(0)
fieldnames = next(csv.reader(f), None)
if fieldnames is not None:
missing_columns = entries.keys() - fieldnames
if missing_columns:
logger.warning(
"Summary CSV header omits columns %s; use a new file to include them",
", ".join(sorted(missing_columns)),
)
writer = csv.DictWriter(f, fieldnames=fieldnames or entries.keys(), extrasaction="ignore")
f.seek(0, os.SEEK_END)
if fieldnames is None:
writer.writeheader()
writer.writerow(entries)