diff --git a/llm_bench/README.md b/llm_bench/README.md index bd1cbd9..d10ee47 100644 --- a/llm_bench/README.md +++ b/llm_bench/README.md @@ -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. diff --git a/llm_bench/load_test.py b/llm_bench/load_test.py index cfc9ed8..6c6abb2 100644 --- a/llm_bench/load_test.py +++ b/llm_bench/load_test.py @@ -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 @@ -2176,19 +2177,23 @@ 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: - 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: @@ -2196,7 +2201,7 @@ def _(environment, **kw): 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 [ @@ -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()} @@ -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)