From 602bf3efccd631873730969762695c7eca1e28cc Mon Sep 17 00:00:00 2001 From: Matthew Kotila Date: Wed, 5 Jun 2024 09:28:36 -0700 Subject: [PATCH 01/55] Fix typo (#687) * Update README.md * Update tutorial.md --- src/c++/perf_analyzer/genai-perf/README.md | 8 ++++---- src/c++/perf_analyzer/genai-perf/docs/tutorial.md | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index eebea223c..e5d16aa0a 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -62,7 +62,7 @@ Available starting with the 24.03 release of the Run the Triton Inference Server SDK docker container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` @@ -85,7 +85,7 @@ Analyzer from source, see [here](../docs/install.md#build-from-source). ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" pip install "git+https://github.com/triton-inference-server/client.git@r${RELEASE}#subdirectory=src/c++/perf_analyzer/genai-perf" ``` @@ -111,7 +111,7 @@ genai-perf --help 1. Run Triton Inference Server with TensorRT-LLM backend container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-trtllm-python-py3 ``` @@ -148,7 +148,7 @@ triton start 1. Run Triton Inference Server SDK container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index 99d88ad07..0b0446492 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -38,7 +38,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 1. Run Triton Inference Server with TensorRT-LLM backend container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-trtllm-python-py3 ``` @@ -75,7 +75,7 @@ triton start 1. Run Triton Inference Server SDK container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` @@ -130,7 +130,7 @@ Request throughput (per sec): 4.44 1. Run Triton Inference Server with vLLM backend container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-vllm-python-py3 ``` @@ -160,7 +160,7 @@ triton start 1. Run Triton Inference Server SDK container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` @@ -227,7 +227,7 @@ docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model gpt2 - 1. Run Triton Inference Server SDK container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` @@ -292,7 +292,7 @@ docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model gpt2 - 1. Run Triton Inference Server SDK container: ```bash -export RELEASE="mm.yy" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` From f80783ed8f6cf94b8687a6f8b1d079d3c956f3dd Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:25:51 -0700 Subject: [PATCH 02/55] Document artifact-dir arg in README (#689) --- src/c++/perf_analyzer/genai-perf/README.md | 5 +++++ .../perf_analyzer/genai-perf/genai_perf/parser.py | 14 +++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index e5d16aa0a..358a0e625 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -466,6 +466,11 @@ infer per second and latency. (default: `999`) ## Output Options +##### `--artifact-dir` + +The directory to store all the (output) artifacts generated by GenAI-Perf and +Perf Analyzer. (default: `artifacts`) + ##### `--generate-plots` An option to enable the generation of plots. (default: False) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index ee886daf3..a01f2b640 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -437,6 +437,13 @@ def _add_endpoint_args(parser): def _add_output_args(parser): output_group = parser.add_argument_group("Output") + output_group.add_argument( + "--artifact-dir", + type=Path, + default=Path(DEFAULT_ARTIFACT_DIR), + help="The directory to store all the (output) artifacts generated by " + "GenAI-Perf and Perf Analyzer.", + ) output_group.add_argument( "--generate-plots", action="store_true", @@ -453,13 +460,6 @@ def _add_output_args(parser): "For example, if the profile export file is profile_export.json, the genai-perf file will be " "exported to profile_export_genai_perf.csv.", ) - output_group.add_argument( - "--artifact-dir", - type=Path, - default=Path(DEFAULT_ARTIFACT_DIR), - help="The directory to store all the (output) artifacts generated by " - "GenAI-Perf and Perf Analyzer.", - ) def _add_other_args(parser): From c4a15e18182cd42e7d8c74a8f515f1a1ad4e1ee1 Mon Sep 17 00:00:00 2001 From: Harshini Komali <157742537+lkomali@users.noreply.github.com> Date: Thu, 6 Jun 2024 12:34:40 -0700 Subject: [PATCH 03/55] Add a small line to catch error at line 58 (#693) --- .../client_backend/triton_c_api/triton_c_api_backend.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/client_backend/triton_c_api/triton_c_api_backend.cc b/src/c++/perf_analyzer/client_backend/triton_c_api/triton_c_api_backend.cc index 3803fbbf0..e97f1ea80 100644 --- a/src/c++/perf_analyzer/client_backend/triton_c_api/triton_c_api_backend.cc +++ b/src/c++/perf_analyzer/client_backend/triton_c_api/triton_c_api_backend.cc @@ -55,7 +55,8 @@ TritonCApiClientBackend::Create( std::unique_ptr triton_client_backend( new TritonCApiClientBackend()); - TritonLoader::Create(triton_server_path, model_repository_path, verbose); + RETURN_IF_ERROR( + TritonLoader::Create(triton_server_path, model_repository_path, verbose)); *client_backend = std::move(triton_client_backend); return Error::Success; } From f35df0fdb91dae2db1e11250cdcaf4550e0bc336 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:32:55 -0700 Subject: [PATCH 04/55] Update ITL calculation (#694) * Change ITL calculation * fix test * round the itl number * revert to keep old ITL * Fix CI and address feedback * Fix test fail --- .../genai-perf/genai_perf/llm_metrics.py | 132 ++++++++++-------- .../genai_perf/plots/plot_config_parser.py | 10 +- .../genai-perf/tests/test_llm_metrics.py | 88 ++++-------- 3 files changed, 102 insertions(+), 128 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py index 6b1b9e2bd..d3f862597 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py @@ -116,11 +116,12 @@ def __init__( request_throughputs: List[float] = [], request_latencies: List[int] = [], time_to_first_tokens: List[int] = [], - inter_token_latencies: List[List[int]] = [[]], + inter_token_latencies: List[int] = [], output_token_throughputs: List[float] = [], output_token_throughputs_per_request: List[int] = [], num_output_tokens: List[int] = [], num_input_tokens: List[int] = [], + chunked_inter_token_latencies: List[List[int]] = [[]], ) -> None: super().__init__(request_throughputs, request_latencies) self.time_to_first_tokens = time_to_first_tokens @@ -130,6 +131,10 @@ def __init__( self.num_output_tokens = num_output_tokens self.num_input_tokens = num_input_tokens + # Keeping chunked ITL (old) as a WAR to preserve visualization. + # Excluded from data. + self._chunked_inter_token_latencies = chunked_inter_token_latencies + # add base name mapping self._base_names["time_to_first_tokens"] = "time_to_first_token" self._base_names["inter_token_latencies"] = "inter_token_latency" @@ -164,25 +169,26 @@ def __init__(self, metrics: Metrics): self._metrics = metrics self._stats_dict: Dict = defaultdict(dict) for attr, data in metrics.data.items(): + if self._should_skip(data, attr): + continue + attr = metrics.get_base_name(attr) self._add_units(attr) - data = self._preprocess_data(data, attr) - if data: - self._calculate_mean(data, attr) - if not self._is_throughput_field(attr): - self._calculate_percentiles(data, attr) - self._calculate_minmax(data, attr) - self._calculate_std(data, attr) - - def _preprocess_data(self, data: List, attr: str) -> List[Union[int, float]]: - new_data = [] - if attr == "inter_token_latency": - # flatten inter token latencies to 1D - for d in data: - new_data += d - else: - new_data = data - return new_data + self._calculate_mean(data, attr) + if not self._is_throughput_field(attr): + self._calculate_percentiles(data, attr) + self._calculate_minmax(data, attr) + self._calculate_std(data, attr) + + def _should_skip(self, data: List[Union[int, float]], attr: str) -> bool: + """Checks if some metrics should be skipped.""" + # No data points + if len(data) == 0: + return True + # Skip ITL when non-streaming (all zero) + elif attr == "inter_token_latencies" and sum(data) == 0: + return True + return False def _calculate_mean(self, data: List[Union[int, float]], attr: str) -> None: avg = np.mean(data) @@ -534,7 +540,9 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: inter_token_latencies = [] output_token_throughputs_per_request = [] num_input_tokens = [] - num_generated_tokens = [] + num_output_tokens = [] + chunked_inter_token_latencies = [] + for request in requests: req_timestamp = request["timestamp"] req_inputs = request["request_inputs"] @@ -553,43 +561,51 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: max_res_timestamp = max(max_res_timestamp, res_timestamps[-1]) # request latencies - req_latency = res_timestamps[-1] - req_timestamp - request_latencies.append(req_latency) # nanosec - req_latency = req_latency / 1e9 # sec + req_latency_ns = res_timestamps[-1] - req_timestamp + request_latencies.append(req_latency_ns) # nanosec + req_latency_s = req_latency_ns / 1e9 # sec # time to first token - time_to_first_tokens.append(res_timestamps[0] - req_timestamp) + ttft = res_timestamps[0] - req_timestamp + time_to_first_tokens.append(ttft) # number of input tokens - input_tokens = self._tokenize_request_inputs(req_inputs) - num_input_tokens.append(len(input_tokens)) + input_token_count = self._get_input_token_count(req_inputs) + num_input_tokens.append(input_token_count) # output token throughput per request - output_tokens = self._tokenize_response_outputs(res_outputs) - num_output_tokens = list(map(len, output_tokens)) - total_output_tokens = np.sum(num_output_tokens) + output_token_counts = self._get_output_token_counts(res_outputs) + total_output_token = sum(output_token_counts) output_token_throughputs_per_request.append( - total_output_tokens / req_latency + total_output_token / req_latency_s ) - num_generated_tokens.append(total_output_tokens) - - # inter token latency - itl_per_request = [] + num_output_tokens.append(total_output_token) + + # inter token latencies + if total_output_token > 1: + inter_token_latency = (req_latency_ns - ttft) / (total_output_token - 1) + inter_token_latencies.append(round(inter_token_latency)) + + # The new ITL calculation above loses all token-level ITL information + # and as a result breaks ITL vs token position visualization. Keep + # the old version of inter token latency as a WAR to preserve the + # visualization. + chunked_inter_token_latency = [] for (t1, _), (t2, n2) in self._pairwise( - zip(res_timestamps, num_output_tokens) + zip(res_timestamps, output_token_counts) ): # TMA-1676: handle empty first/last responses # if the latter response has zero token (e.g. empty string), # then set it default to one for the sake of inter token latency # calculation and to avoid divide by zero. num_token = 1 if n2 == 0 else n2 - itl_per_request.append(round((t2 - t1) / num_token)) - inter_token_latencies.append(itl_per_request) + chunked_inter_token_latency.append(round((t2 - t1) / num_token)) + chunked_inter_token_latencies.append(chunked_inter_token_latency) # request & output token throughput benchmark_duration = (max_res_timestamp - min_req_timestamp) / 1e9 # nanosec request_throughputs = [len(requests) / benchmark_duration] - output_token_throughputs = [sum(num_generated_tokens) / benchmark_duration] + output_token_throughputs = [sum(num_output_tokens) / benchmark_duration] return LLMMetrics( request_throughputs, @@ -598,8 +614,9 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: inter_token_latencies, output_token_throughputs, output_token_throughputs_per_request, - num_generated_tokens, + num_output_tokens, num_input_tokens, + chunked_inter_token_latencies, ) def _pairwise(self, iterable): @@ -641,52 +658,47 @@ def _preprocess_response( res_timestamps.pop(index) res_outputs.pop(index) - def _tokenize_request_inputs(self, req_inputs: dict) -> List[int]: + def _get_input_token_count(self, req_inputs: dict) -> int: """Deserialize the request input and return tokenized inputs.""" if self._service_kind == "triton": - return self._tokenize_triton_request_input(req_inputs) + input_text = req_inputs["text_input"] elif self._service_kind == "openai": - return self._tokenize_openai_request_input(req_inputs) + input_text = self._get_openai_input_text(req_inputs) else: raise ValueError(f"Unknown service kind: '{self._service_kind}'.") - def _tokenize_triton_request_input(self, req_inputs: dict) -> List[int]: - """Tokenize the Triton request input texts.""" - encodings = self._tokenizer(req_inputs["text_input"]) - return encodings.data["input_ids"] + return len(self._tokenizer.encode(input_text)) - def _tokenize_openai_request_input(self, req_inputs: dict) -> List[int]: + def _get_openai_input_text(self, req_inputs: dict) -> str: """Tokenize the OpenAI request input texts.""" payload = json.loads(req_inputs["payload"]) if self._response_format == ResponseFormat.OPENAI_CHAT_COMPLETIONS: - input_text = payload["messages"][0]["content"] + return payload["messages"][0]["content"] elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: - input_text = payload["prompt"] + return payload["prompt"] else: raise ValueError( "Failed to parse OpenAI request input in profile export file." ) - encodings = self._tokenizer(input_text) - return encodings.data["input_ids"] - def _tokenize_response_outputs(self, res_outputs: dict) -> List[List[int]]: + def _get_output_token_counts(self, res_outputs: dict) -> List[int]: """Deserialize the response output and return tokenized outputs.""" if self._service_kind == "triton": - return self._tokenize_triton_response_output(res_outputs) + output_tokens = self._get_triton_output_tokens(res_outputs) elif self._service_kind == "openai": - return self._tokenize_openai_response_output(res_outputs) + output_tokens = self._get_openai_output_tokens(res_outputs) else: raise ValueError(f"Unknown service kind: '{self._service_kind}'.") - def _tokenize_triton_response_output(self, res_outputs: dict) -> List[List[int]]: - """Tokenize the Triton response output texts.""" - output_texts = [] - for output in res_outputs: - output_texts.append(output["text_output"]) + return list(map(len, output_tokens)) + + def _get_triton_output_tokens(self, res_outputs: dict) -> List[List[int]]: + """Return a list of Triton response output tokens.""" + output_texts = [r["text_output"] for r in res_outputs] return self._run_tokenizer(output_texts) - def _tokenize_openai_response_output(self, res_outputs: dict) -> List[List[int]]: - """Tokenize the OpenAI response output texts.""" + def _get_openai_output_tokens(self, res_outputs: dict) -> List[List[int]]: + """Return a list of OpenAI response output tokens.""" output_texts = [] for output in res_outputs: text = self._extract_openai_text_output(output["response"]) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py index c174024a2..cef7c85a9 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py @@ -108,14 +108,12 @@ def _get_metric(self, stats: Statistics, name: str) -> List[Union[int, float]]: if not name: # no metric return [] elif name == "inter_token_latencies": - # Flatten ITL since they are grouped by request - itl_flatten = [] - for request_itls in stats.metrics.data[name]: - itl_flatten += request_itls - return [scale(x, (1 / 1e6)) for x in itl_flatten] # ns to ms + itls = stats.metrics.data[name] + return [scale(x, (1 / 1e6)) for x in itls] # ns to ms elif name == "token_positions": + chunked_itls = getattr(stats.metrics, "_chunked_inter_token_latencies") token_positions: List[Union[int, float]] = [] - for request_itls in stats.metrics.data["inter_token_latencies"]: + for request_itls in chunked_itls: token_positions += list(range(1, len(request_itls) + 1)) return token_positions elif name == "time_to_first_tokens": diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index 7014780f8..ae7f34b00 100755 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -102,7 +102,7 @@ def test_csv_output(self, mock_read_write: pytest.MonkeyPatch) -> None: expected_content = [ "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", "Time To First Token (ns),2,2,2,2,2,2,2,2,2\r\n", - "Inter Token Latency (ns),2,1,3,3,3,3,2,2,2\r\n", + "Inter Token Latency (ns),2,1,2,2,2,2,2,2,1\r\n", "Request Latency (ns),8,7,9,9,9,9,8,8,8\r\n", "Num Output Token,4,3,6,6,6,6,5,4,4\r\n", "Num Input Token,4,3,4,4,4,4,4,4,3\r\n", @@ -126,12 +126,12 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N - experiment 1: [3 - 1, 4 - 2] = [2, 2] - experiment 2: [7 - 5, 6 - 3] = [2, 3] * inter token latencies - - experiment 1: [[(5 - 3)/1, (8 - 5)/1], [(7 - 4)/3, (11 - 7)/2]] - : [[2, 3], [3/3, 2]] - : [[2, 3], [1, 2]] - - experiment 2: [[(8 - 7)/1, (13 - 8)/1, (18 - 13)/1], [(8 - 6)/1, (11 - 8)/2]] - : [[1, 5, 5], [2, 3/2]] - : [[1, 5, 5], [2, 2]] # rounded + - experiment 1: [((8 - 1) - 2)/(3 - 1), ((11 - 2) - 2)/(6 - 1)] + : [2.5, 1.4] + : [2, 1] # rounded + - experiment 2: [((18 - 5) - 2)/(4 - 1), ((11 - 3) - 3)/(6 - 1)] + : [11/3, 1] + : [4, 1] # rounded * output token throughputs per request - experiment 1: [3/(8 - 1), 6/(11 - 2)] = [3/7, 6/9] - experiment 2: [4/(18 - 5), 6/(11 - 3)] = [4/13, 6/8] @@ -157,7 +157,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [2, 2] - assert metrics.inter_token_latencies == [[2, 3], [1, 2]] + assert metrics.inter_token_latencies == [2, 1] ottpr = [3 / ns_to_sec(7), 6 / ns_to_sec(9)] assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [9 / ns_to_sec(10)] @@ -168,7 +168,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N # Disable Pylance warnings for dynamically set attributes due to Statistics # not having strict attributes listed. assert stat.avg_time_to_first_token == 2 # type: ignore - assert stat.avg_inter_token_latency == 2 # type: ignore + assert stat.avg_inter_token_latency == 1.5 # type: ignore assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore np.mean(ottpr) ) @@ -176,7 +176,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.avg_num_input_token == 3.5 # type: ignore assert stat.p50_time_to_first_token == 2 # type: ignore - assert stat.p50_inter_token_latency == 2 # type: ignore + assert stat.p50_inter_token_latency == 1.5 # type: ignore assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) @@ -191,14 +191,14 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.min_num_input_token == 3 # type: ignore assert stat.max_time_to_first_token == 2 # type: ignore - assert stat.max_inter_token_latency == 3 # type: ignore + assert stat.max_inter_token_latency == 2 # type: ignore max_ottpr = 6 / ns_to_sec(9) assert stat.max_output_token_throughput_per_request == pytest.approx(max_ottpr) # type: ignore assert stat.max_num_output_token == 6 # type: ignore assert stat.max_num_input_token == 4 # type: ignore assert stat.std_time_to_first_token == np.std([2, 2]) # type: ignore - assert stat.std_inter_token_latency == np.std([2, 3, 1, 2]) # type: ignore + assert stat.std_inter_token_latency == np.std([2, 1]) # type: ignore assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore np.std(ottpr) ) @@ -214,7 +214,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [2, 3] - assert metrics.inter_token_latencies == [[1, 5, 5], [2, 2]] + assert metrics.inter_token_latencies == [4, 1] ottpr = [4 / ns_to_sec(13), 6 / ns_to_sec(8)] assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [2 / ns_to_sec(3)] @@ -223,7 +223,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.num_input_tokens == [3, 4] assert stat.avg_time_to_first_token == 2.5 # type: ignore - assert stat.avg_inter_token_latency == 3 # type: ignore + assert stat.avg_inter_token_latency == 2.5 # type: ignore assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore np.mean(ottpr) ) @@ -231,7 +231,7 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.avg_num_input_token == 3.5 # type: ignore assert stat.p50_time_to_first_token == 2.5 # type: ignore - assert stat.p50_inter_token_latency == 2 # type: ignore + assert stat.p50_inter_token_latency == 2.5 # type: ignore assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) @@ -246,14 +246,14 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.min_num_input_token == 3 # type: ignore assert stat.max_time_to_first_token == 3 # type: ignore - assert stat.max_inter_token_latency == 5 # type: ignore + assert stat.max_inter_token_latency == 4 # type: ignore max_ottpr = 6 / ns_to_sec(8) assert stat.max_output_token_throughput_per_request == pytest.approx(max_ottpr) # type: ignore assert stat.max_num_output_token == 6 # type: ignore assert stat.max_num_input_token == 4 # type: ignore assert stat.std_time_to_first_token == np.std([2, 3]) # type: ignore - assert stat.std_inter_token_latency == np.std([1, 5, 5, 2, 2]) # type: ignore + assert stat.std_inter_token_latency == np.std([4, 1]) # type: ignore assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore np.std(ottpr) ) @@ -274,9 +274,9 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N * time to first tokens - experiment 1: [5 - 1, 7 - 2] = [4, 5] * inter token latencies - - experiment 1: [[(8 - 5)/1, (12 - 8)/1], [(11 - 7)/3, (15 - 11)/2]] - : [[3, 4], [4/3, 2]] - : [[3, 4], [1, 2]] # rounded + - experiment 1: [((12 - 1) - 4)/(3 - 1), ((15 - 2) - 5)/(6 - 1)] + : [3.5, 1.6] + : [4, 2] # rounded * output token throughputs per request - experiment 1: [3/(12 - 1), 6/(15 - 2)] = [3/11, 6/13] * output token throughputs @@ -298,7 +298,7 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [4, 5] - assert metrics.inter_token_latencies == [[3, 4], [1, 2]] + assert metrics.inter_token_latencies == [4, 2] ottpr = [3 / ns_to_sec(11), 6 / ns_to_sec(13)] assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [9 / ns_to_sec(14)] @@ -307,7 +307,7 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.num_input_tokens == [3, 4] assert stat.avg_time_to_first_token == 4.5 # type: ignore - assert stat.avg_inter_token_latency == 2.5 # type: ignore + assert stat.avg_inter_token_latency == 3 # type: ignore assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore np.mean(ottpr) ) @@ -315,7 +315,7 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.avg_num_input_token == 3.5 # type: ignore assert stat.p50_time_to_first_token == 4.5 # type: ignore - assert stat.p50_inter_token_latency == 2.5 # type: ignore + assert stat.p50_inter_token_latency == 3 # type: ignore assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) @@ -323,7 +323,7 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.p50_num_input_token == 3.5 # type: ignore assert stat.min_time_to_first_token == 4 # type: ignore - assert stat.min_inter_token_latency == 1 # type: ignore + assert stat.min_inter_token_latency == 2 # type: ignore min_ottpr = 3 / ns_to_sec(11) assert stat.min_output_token_throughput_per_request == pytest.approx(min_ottpr) # type: ignore assert stat.min_num_output_token == 3 # type: ignore @@ -337,7 +337,7 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat.max_num_input_token == 4 # type: ignore assert stat.std_time_to_first_token == np.std([4, 5]) # type: ignore - assert stat.std_inter_token_latency == np.std([3, 4, 1, 2]) # type: ignore + assert stat.std_inter_token_latency == np.std([4, 2]) # type: ignore assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore np.std(ottpr) ) @@ -378,42 +378,6 @@ def test_merged_sse_response(self, mock_read_write: pytest.MonkeyPatch) -> None: pd._preprocess_response(res_timestamps, res_outputs) assert res_outputs[1]["response"] == expected_response - def test_no_special_tokens(self, mock_read_write: pytest.MonkeyPatch) -> None: - """Test special tokens are not included when counting input/output tokens.""" - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("openai_profile_export.json"), - tokenizer=tokenizer, - ) - - # There are 3 special tokens in the default tokenizer - # - : 0 (unknown) - # - : 1 (beginning of sentence) - # - : 2 (end of sentence) - special_token_ids = list(tokenizer._tokenizer.added_tokens_encoder.values()) - - # Check if special tokens are present in request input - req_input = {"text_input": "This is test input."} - tokens = pd._tokenize_triton_request_input(req_input) - assert all([s not in tokens for s in special_token_ids]) - - pd._response_format = ResponseFormat.OPENAI_COMPLETIONS - req_input = {"payload": '{"prompt":"This is test input."}'} - tokens = pd._tokenize_openai_request_input(req_input) - assert all([s not in tokens for s in special_token_ids]) - - pd._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS - req_input = {"payload": '{"messages":[{"content":"This is test input."}]}'} - tokens = pd._tokenize_openai_request_input(req_input) - assert all([s not in tokens for s in special_token_ids]) - - # Check if special tokens are present in the responses - res_outputs = ["This", "is", "test", "input."] - tokens = [] - for t in pd._run_tokenizer(res_outputs): - tokens += t - assert all([s not in tokens for s in special_token_ids]) - def test_llm_metrics_get_base_name(self) -> None: """Test get_base_name method in LLMMetrics class.""" # initialize with dummy values @@ -421,7 +385,7 @@ def test_llm_metrics_get_base_name(self) -> None: request_throughputs=[10.12, 11.33], request_latencies=[3, 44], time_to_first_tokens=[1, 2, 3], - inter_token_latencies=[[4, 5]], + inter_token_latencies=[4, 5], output_token_throughputs=[22.13, 9423.02], output_token_throughputs_per_request=[7, 8, 9], num_output_tokens=[3, 4], From e44ca7fc2a79a33e2c87aa3995e0be9be38cdf86 Mon Sep 17 00:00:00 2001 From: KrishnanPrash <140860868+KrishnanPrash@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:51:08 -0700 Subject: [PATCH 05/55] ci: Restrict numpy to version 1.x (#690) --- src/c++/perf_analyzer/genai-perf/pyproject.toml | 2 +- src/python/library/requirements/requirements.txt | 4 ++-- src/python/library/requirements/requirements_grpc.txt | 4 ++-- src/python/library/requirements/requirements_http.txt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/pyproject.toml b/src/c++/perf_analyzer/genai-perf/pyproject.toml index 7be2c8474..982ee24b7 100644 --- a/src/c++/perf_analyzer/genai-perf/pyproject.toml +++ b/src/c++/perf_analyzer/genai-perf/pyproject.toml @@ -46,7 +46,7 @@ maintainers = [] keywords = [] requires-python = ">=3.8,<4" dependencies = [ - "numpy", + "numpy<2", "pytest", "rich", "transformers", diff --git a/src/python/library/requirements/requirements.txt b/src/python/library/requirements/requirements.txt index 6f84e21f9..b53763f38 100644 --- a/src/python/library/requirements/requirements.txt +++ b/src/python/library/requirements/requirements.txt @@ -1,4 +1,4 @@ -# Copyright 2020-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright 2020-2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions @@ -24,6 +24,6 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -numpy>=1.19.1 +numpy>=1.19.1,<2 python-rapidjson>=0.9.1 urllib3>=2.0.7 diff --git a/src/python/library/requirements/requirements_grpc.txt b/src/python/library/requirements/requirements_grpc.txt index ea9fb9bec..fd7ebe67d 100644 --- a/src/python/library/requirements/requirements_grpc.txt +++ b/src/python/library/requirements/requirements_grpc.txt @@ -1,4 +1,4 @@ -# Copyright 2020-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# Copyright 2020-2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions @@ -28,7 +28,7 @@ # use known working version until the memory leak is resolved in the future # (see https://github.com/grpc/grpc/issues/28513) grpcio>=1.41.0 -numpy>=1.19.1 +numpy>=1.19.1,<2 packaging>=14.1 protobuf>=3.5.0,<5 python-rapidjson>=0.9.1 diff --git a/src/python/library/requirements/requirements_http.txt b/src/python/library/requirements/requirements_http.txt index 6e1906967..febc32a3f 100644 --- a/src/python/library/requirements/requirements_http.txt +++ b/src/python/library/requirements/requirements_http.txt @@ -1,4 +1,4 @@ -# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved. +# Copyright (c) 2020-2024, NVIDIA CORPORATION. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions @@ -26,5 +26,5 @@ aiohttp>=3.8.1,<4.0.0 geventhttpclient>=1.4.4,<=2.0.2 -numpy>=1.19.1 +numpy>=1.19.1,<2 python-rapidjson>=0.9.1 From ca9895093260edcf414b7a80de8e83f88849653e Mon Sep 17 00:00:00 2001 From: Matthew Kotila Date: Fri, 7 Jun 2024 14:35:16 -0700 Subject: [PATCH 06/55] Add documentation on installing PA dependencies on Ubuntu (#696) * Add documentation on installing PA dependencies on Ubuntu * Address feedback --- src/c++/perf_analyzer/genai-perf/README.md | 34 +++++++++++++--------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 358a0e625..fcb7e3039 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -67,22 +67,28 @@ export RELEASE="yy.mm" # e.g. export RELEASE="24.03" docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk ``` -Run GenAI-Perf: - -```bash -genai-perf --help -``` -
-To install from source: +Alternatively, to install from source: ## From Source -This method requires that Perf Analyzer is installed in your development -environment and that you have at least Python 3.10 installed. To build Perf -Analyzer from source, see -[here](../docs/install.md#build-from-source). +GenAI-Perf depends on Perf Analyzer. Here is how to install Perf Analyzer: + +### Install Perf Analyzer (Ubuntu, Python 3.8+) + +Note: you must already have CUDA 12 installed. + +```bash +pip install tritonclient + +apt update && apt install -y --no-install-recommends libb64-0d libcurl4 +``` + +Alternatively, you can install Perf Analyzer +[from source](../docs/install.md#build-from-source). + +### Install GenAI-Perf from source ```bash export RELEASE="yy.mm" # e.g. export RELEASE="24.03" @@ -90,15 +96,15 @@ export RELEASE="yy.mm" # e.g. export RELEASE="24.03" pip install "git+https://github.com/triton-inference-server/client.git@r${RELEASE}#subdirectory=src/c++/perf_analyzer/genai-perf" ``` +
+
+ Run GenAI-Perf: ```bash genai-perf --help ``` - -
- # Quick Start ## Measuring Throughput and Latency of GPT2 using Triton + TensorRT-LLM From e3d1a25b7c7c1ba2ca692a701d1fd70c967ba233 Mon Sep 17 00:00:00 2001 From: Izzy Putterman Date: Mon, 10 Jun 2024 09:00:54 -0700 Subject: [PATCH 07/55] Arbitrary Json extra input (#683) * Arb json input * Add unit testing to new dict support for extra inputs --------- Co-authored-by: Elias Bermudez --- src/c++/perf_analyzer/genai-perf/README.md | 1 + .../genai-perf/genai_perf/parser.py | 67 ++++++++++--------- .../genai-perf/tests/test_cli.py | 41 ++++++++++++ 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index fcb7e3039..4429de5f9 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -397,6 +397,7 @@ URL of the endpoint to target for benchmarking. (default: `None`) Provide additional inputs to include with every request. You can repeat this flag for multiple inputs. Inputs should be in an input_name:value format. +Alternatively, a string representing a json formatted dict can be provided. (default: `None`) ##### `--input-dataset {openorca,cnn_dailymail}` diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index a01f2b640..29481332f 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -25,6 +25,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import argparse +import json import os import sys from pathlib import Path @@ -227,7 +228,8 @@ def _add_input_args(parser): "--extra-inputs", action="append", help="Provide additional inputs to include with every request. " - "You can repeat this flag for multiple inputs. Inputs should be in an input_name:value format.", + "You can repeat this flag for multiple inputs. Inputs should be in an input_name:value format." + "Alternatively, a string representing a json formatted dict can be provided.", ) prompt_source_group = input_group.add_mutually_exclusive_group(required=False) @@ -493,38 +495,41 @@ def get_extra_inputs_as_dict(args: argparse.Namespace) -> dict: request_inputs = {} if args.extra_inputs: for input_str in args.extra_inputs: - semicolon_count = input_str.count(":") - if semicolon_count != 1: - raise ValueError( - f"Invalid input format for --extra-inputs: {input_str}\n" - "Expected input format: 'input_name:value'" - ) - input_name, value = input_str.split(":", 1) - - if not input_name or not value: - raise ValueError( - f"Input name or value is empty in --extra-inputs: {input_str}\n" - "Expected input format: 'input_name:value'" + if input_str.startswith("{") and input_str.endswith("}"): + request_inputs.update(json.loads(input_str)) + else: + semicolon_count = input_str.count(":") + if semicolon_count != 1: + raise ValueError( + f"Invalid input format for --extra-inputs: {input_str}\n" + "Expected input format: 'input_name:value'" + ) + input_name, value = input_str.split(":", 1) + + if not input_name or not value: + raise ValueError( + f"Input name or value is empty in --extra-inputs: {input_str}\n" + "Expected input format: 'input_name:value'" + ) + + is_bool = value.lower() in ["true", "false"] + is_int = value.isdigit() + is_float = value.count(".") == 1 and ( + value[0] == "." or value.replace(".", "").isdigit() ) - is_bool = value.lower() in ["true", "false"] - is_int = value.isdigit() - is_float = value.count(".") == 1 and ( - value[0] == "." or value.replace(".", "").isdigit() - ) - - if is_bool: - value = value.lower() == "true" - elif is_int: - value = int(value) - elif is_float: - value = float(value) - - if input_name in request_inputs: - raise ValueError( - f"Input name already exists in request_inputs dictionary: {input_name}" - ) - request_inputs[input_name] = value + if is_bool: + value = value.lower() == "true" + elif is_int: + value = int(value) + elif is_float: + value = float(value) + + if input_name in request_inputs: + raise ValueError( + f"Input name already exists in request_inputs dictionary: {input_name}" + ) + request_inputs[input_name] = value return request_inputs diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index 3066d554a..5cf84c360 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -24,6 +24,7 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import argparse from pathlib import Path import genai_perf.logging as logging @@ -133,6 +134,17 @@ def test_help_version_arguments_output_and_exit( ], {"extra_inputs": ["test_key:5", "another_test_key:6"]}, ), + ( + [ + "--extra-inputs", + '{"name": "Wolverine","hobbies": ["hacking", "slashing"],"address": {"street": "1407 Graymalkin Lane, Salem Center","city": "NY"}}', + ], + { + "extra_inputs": [ + '{"name": "Wolverine","hobbies": ["hacking", "slashing"],"address": {"street": "1407 Graymalkin Lane, Salem Center","city": "NY"}}' + ] + }, + ), (["--input-dataset", "openorca"], {"input_dataset": "openorca"}), (["--measurement-interval", "100"], {"measurement_interval": 100}), ( @@ -656,3 +668,32 @@ def test_compare_model_arg(self, monkeypatch, args, expected_model): args, _ = parser.parse_args() assert args.model == expected_model + + @pytest.mark.parametrize( + "extra_inputs_list, expected_dict", + [ + (["test_key:test_value"], {"test_key": "test_value"}), + ( + ["test_key:1", "another_test_key:2"], + {"test_key": 1, "another_test_key": 2}, + ), + ( + [ + '{"name": "Wolverine","hobbies": ["hacking", "slashing"],"address": {"street": "1407 Graymalkin Lane, Salem Center","city": "NY"}}' + ], + { + "name": "Wolverine", + "hobbies": ["hacking", "slashing"], + "address": { + "street": "1407 Graymalkin Lane, Salem Center", + "city": "NY", + }, + }, + ), + ], + ) + def test_get_extra_inputs_as_dict(self, extra_inputs_list, expected_dict): + namespace = argparse.Namespace() + namespace.extra_inputs = extra_inputs_list + actual_dict = parser.get_extra_inputs_as_dict(namespace) + assert actual_dict == expected_dict From 2064dfb1b43c1fadeb2b9e45325ee9f497358474 Mon Sep 17 00:00:00 2001 From: Kris Hung Date: Mon, 10 Jun 2024 18:38:27 -0700 Subject: [PATCH 08/55] Get output by name explcitly to compare (#697) --- src/c++/perf_analyzer/data_loader.cc | 4 +++- src/c++/perf_analyzer/infer_context.cc | 6 ++++-- src/c++/perf_analyzer/tensor_data.h | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/c++/perf_analyzer/data_loader.cc b/src/c++/perf_analyzer/data_loader.cc index c3a5170ce..3658f8a75 100644 --- a/src/c++/perf_analyzer/data_loader.cc +++ b/src/c++/perf_analyzer/data_loader.cc @@ -1,4 +1,4 @@ -// Copyright 2020-2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// Copyright 2020-2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions @@ -378,6 +378,7 @@ DataLoader::GetOutputData( data.data_ptr = nullptr; data.batch1_size = 0; data.is_valid = false; + data.name = ""; // If json data is available then try to retrieve the data from there if (!output_data_.empty()) { @@ -393,6 +394,7 @@ DataLoader::GetOutputData( data.is_valid = true; data.batch1_size = data_vec->size(); data.data_ptr = (const uint8_t*)data_vec->data(); + data.name = output_name; } } return cb::Error::Success; diff --git a/src/c++/perf_analyzer/infer_context.cc b/src/c++/perf_analyzer/infer_context.cc index 8929e6c99..aa868eba7 100644 --- a/src/c++/perf_analyzer/infer_context.cc +++ b/src/c++/perf_analyzer/infer_context.cc @@ -260,11 +260,13 @@ InferContext::ValidateOutputs(const cb::InferResult* result_ptr) { // Validate output if set if (!infer_data_.expected_outputs_.empty()) { - for (size_t i = 0; i < infer_data_.outputs_.size(); ++i) { + for (size_t i = 0; i < infer_data_.expected_outputs_.size(); ++i) { const uint8_t* buf = nullptr; size_t byte_size = 0; - result_ptr->RawData(infer_data_.outputs_[i]->Name(), &buf, &byte_size); for (const auto& expected : infer_data_.expected_outputs_[i]) { + // Request output by validation output's name explicitly, rather than + // relying on the array indices being sorted equally in both arrays. + result_ptr->RawData(expected.name, &buf, &byte_size); if (!expected.is_valid) { return cb::Error( "Expected output can't be invalid", pa::GENERIC_ERROR); diff --git a/src/c++/perf_analyzer/tensor_data.h b/src/c++/perf_analyzer/tensor_data.h index b989e4dc1..6f5cf7191 100644 --- a/src/c++/perf_analyzer/tensor_data.h +++ b/src/c++/perf_analyzer/tensor_data.h @@ -1,4 +1,4 @@ -// Copyright 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// Copyright 2023-2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions @@ -33,6 +33,7 @@ struct TensorData { const uint8_t* data_ptr{nullptr}; size_t batch1_size{0}; bool is_valid{false}; + std::string name; }; From 25185bf57f37e5b1926e70aa2bfb9915aff348e3 Mon Sep 17 00:00:00 2001 From: Neelay Shah Date: Wed, 12 Jun 2024 10:58:55 -0700 Subject: [PATCH 09/55] fix: Fix Client Http Async Code for Request Rate (#684) * squash! Set add special tokens to false (#672) * Fix OpenAI Client Http Async Code for Request Rate (#684) * Fix HTTP Client Async Code for Request Rate (#686) * Only require throughput stability for PA HTTP async case (#688) * Fix HTTP client REQUEST_END timestam (#691) --------- Co-authored-by: root Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> --- src/c++/library/http_client.cc | 194 ++++++++++-------- src/c++/library/http_client.h | 4 +- .../client_backend/openai/http_client.cc | 179 +++++++++------- .../client_backend/openai/http_client.h | 5 +- src/c++/perf_analyzer/inference_profiler.cc | 29 ++- src/c++/perf_analyzer/inference_profiler.h | 13 +- src/c++/perf_analyzer/perf_analyzer.cc | 13 +- .../perf_analyzer/test_inference_profiler.cc | 17 +- 8 files changed, 265 insertions(+), 189 deletions(-) diff --git a/src/c++/library/http_client.cc b/src/c++/library/http_client.cc index 9f2f5ab5e..a2651f2eb 100644 --- a/src/c++/library/http_client.cc +++ b/src/c++/library/http_client.cc @@ -1371,27 +1371,23 @@ InferenceServerHttpClient::InferenceServerHttpClient( InferenceServerHttpClient::~InferenceServerHttpClient() { - exiting_ = true; + { + std::lock_guard lock(mutex_); + exiting_ = true; + } + + curl_multi_wakeup(multi_handle_); // thread not joinable if AsyncInfer() is not called // (it is default constructed thread before the first AsyncInfer() call) if (worker_.joinable()) { - cv_.notify_all(); worker_.join(); } if (easy_handle_ != nullptr) { curl_easy_cleanup(reinterpret_cast(easy_handle_)); } - - if (multi_handle_ != nullptr) { - for (auto& request : ongoing_async_requests_) { - CURL* easy_handle = reinterpret_cast(request.first); - curl_multi_remove_handle(multi_handle_, easy_handle); - curl_easy_cleanup(easy_handle); - } - curl_multi_cleanup(multi_handle_); - } + curl_multi_cleanup(multi_handle_); } Error @@ -1887,25 +1883,28 @@ InferenceServerHttpClient::AsyncInfer( { std::lock_guard lock(mutex_); - auto insert_result = ongoing_async_requests_.emplace(std::make_pair( + if (exiting_) { + return Error("Client is exiting."); + } + + auto insert_result = new_async_requests_.emplace(std::make_pair( reinterpret_cast(multi_easy_handle), async_request)); if (!insert_result.second) { curl_easy_cleanup(multi_easy_handle); return Error("Failed to insert new asynchronous request context."); } + } - async_request->Timer().CaptureTimestamp(RequestTimers::Kind::SEND_START); - if (async_request->total_input_byte_size_ == 0) { - // Set SEND_END here because CURLOPT_READFUNCTION will not be called if - // content length is 0. In that case, we can't measure SEND_END properly - // (send ends after sending request header). - async_request->Timer().CaptureTimestamp(RequestTimers::Kind::SEND_END); - } + async_request->Timer().CaptureTimestamp(RequestTimers::Kind::SEND_START); + curl_multi_wakeup(multi_handle_); - curl_multi_add_handle(multi_handle_, multi_easy_handle); + if (async_request->total_input_byte_size_ == 0) { + // Set SEND_END here because CURLOPT_READFUNCTION will not be called if + // content length is 0. In that case, we can't measure SEND_END properly + // (send ends after sending request header). + async_request->Timer().CaptureTimestamp(RequestTimers::Kind::SEND_END); } - cv_.notify_all(); return Error::Success; } @@ -2249,88 +2248,103 @@ InferenceServerHttpClient::PreRunProcessing( void InferenceServerHttpClient::AsyncTransfer() { - int place_holder = 0; + int messages_in_queue = 0; + int still_running = 0; + int numfds = 0; CURLMsg* msg = nullptr; + AsyncReqMap ongoing_async_requests; do { - std::vector> request_list; + // Check for new requests and add them to ongoing requests + { + std::lock_guard lock(mutex_); + + for (auto& pair : new_async_requests_) { + curl_multi_add_handle( + multi_handle_, reinterpret_cast(pair.first)); - // sleep if no work is available - std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { - if (this->exiting_) { - return true; + ongoing_async_requests[pair.first] = std::move(pair.second); } - // wake up if an async request has been generated - return !this->ongoing_async_requests_.empty(); - }); - - CURLMcode mc = curl_multi_perform(multi_handle_, &place_holder); - int numfds; - if (mc == CURLM_OK) { - // Wait for activity. If there are no descriptors in the multi_handle_ - // then curl_multi_wait will return immediately - mc = curl_multi_wait(multi_handle_, NULL, 0, INT_MAX, &numfds); - if (mc == CURLM_OK) { - while ((msg = curl_multi_info_read(multi_handle_, &place_holder))) { - uintptr_t identifier = reinterpret_cast(msg->easy_handle); - auto itr = ongoing_async_requests_.find(identifier); - // This shouldn't happen - if (itr == ongoing_async_requests_.end()) { - std::cerr - << "Unexpected error: received completed request that is not " - "in the list of asynchronous requests" - << std::endl; - curl_multi_remove_handle(multi_handle_, msg->easy_handle); - curl_easy_cleanup(msg->easy_handle); - continue; - } + new_async_requests_.clear(); + } - long http_code = 400; - if (msg->data.result == CURLE_OK) { - curl_easy_getinfo( - msg->easy_handle, CURLINFO_RESPONSE_CODE, &http_code); - } else if (msg->data.result == CURLE_OPERATION_TIMEDOUT) { - http_code = 499; - } + CURLMcode mc = curl_multi_perform(multi_handle_, &still_running); - request_list.emplace_back(itr->second); - ongoing_async_requests_.erase(itr); - curl_multi_remove_handle(multi_handle_, msg->easy_handle); - curl_easy_cleanup(msg->easy_handle); - - std::shared_ptr async_request = request_list.back(); - async_request->http_code_ = http_code; - - if (msg->msg != CURLMSG_DONE) { - // Something wrong happened. - std::cerr << "Unexpected error: received CURLMsg=" << msg->msg - << std::endl; - } else { - async_request->Timer().CaptureTimestamp( - RequestTimers::Kind::REQUEST_END); - Error err = UpdateInferStat(async_request->Timer()); - if (!err.IsOk()) { - std::cerr << "Failed to update context stat: " << err - << std::endl; - } - } - } - } else { - std::cerr << "Unexpected error: curl_multi failed. Code:" << mc - << std::endl; - } - } else { + if (mc != CURLM_OK) { std::cerr << "Unexpected error: curl_multi failed. Code:" << mc << std::endl; + continue; } - lock.unlock(); - for (auto& this_request : request_list) { + while ((msg = curl_multi_info_read(multi_handle_, &messages_in_queue))) { + if (msg->msg != CURLMSG_DONE) { + // Something wrong happened. + std::cerr << "Unexpected error: received CURLMsg=" << msg->msg + << std::endl; + continue; + } + + uintptr_t identifier = reinterpret_cast(msg->easy_handle); + auto itr = ongoing_async_requests.find(identifier); + // This shouldn't happen + if (itr == ongoing_async_requests.end()) { + std::cerr << "Unexpected error: received completed request that is not " + "in the list of asynchronous requests" + << std::endl; + curl_multi_remove_handle(multi_handle_, msg->easy_handle); + curl_easy_cleanup(msg->easy_handle); + continue; + } + auto async_request = itr->second; + + uint32_t http_code = 400; + if (msg->data.result == CURLE_OK) { + curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &http_code); + async_request->Timer().CaptureTimestamp( + RequestTimers::Kind::REQUEST_END); + Error err = UpdateInferStat(async_request->Timer()); + if (!err.IsOk()) { + std::cerr << "Failed to update context stat: " << err << std::endl; + } + } else if (msg->data.result == CURLE_OPERATION_TIMEDOUT) { + http_code = 499; + } + + async_request->http_code_ = http_code; InferResult* result; - InferResultHttp::Create(&result, this_request); - this_request->callback_(result); + InferResultHttp::Create(&result, async_request); + async_request->callback_(result); + ongoing_async_requests.erase(itr); + curl_multi_remove_handle(multi_handle_, msg->easy_handle); + curl_easy_cleanup(msg->easy_handle); + } + + // Wait for activity on existing requests or + // explicit curl_multi_wakeup call + // + // If there are no descriptors in the multi_handle_ + // then curl_multi_poll will wait until curl_multi_wakeup + // is called + // + // curl_multi_wakeup is called when adding a new request + // or exiting + + mc = curl_multi_poll(multi_handle_, NULL, 0, INT_MAX, &numfds); + if (mc != CURLM_OK) { + std::cerr << "Unexpected error: curl_multi_poll failed. Code:" << mc + << std::endl; } } while (!exiting_); + + for (auto& request : ongoing_async_requests) { + CURL* easy_handle = reinterpret_cast(request.first); + curl_multi_remove_handle(multi_handle_, easy_handle); + curl_easy_cleanup(easy_handle); + } + + for (auto& request : new_async_requests_) { + CURL* easy_handle = reinterpret_cast(request.first); + curl_easy_cleanup(easy_handle); + } } size_t diff --git a/src/c++/library/http_client.h b/src/c++/library/http_client.h index e06b2eef3..7dbe1976d 100644 --- a/src/c++/library/http_client.h +++ b/src/c++/library/http_client.h @@ -643,9 +643,9 @@ class InferenceServerHttpClient : public InferenceServerClient { void* easy_handle_; // curl multi handle for processing asynchronous requests void* multi_handle_; - // map to record ongoing asynchronous requests with pointer to easy handle + // map to record new asynchronous requests with pointer to easy handle // or tag id as key - AsyncReqMap ongoing_async_requests_; + AsyncReqMap new_async_requests_; }; }} // namespace triton::client diff --git a/src/c++/perf_analyzer/client_backend/openai/http_client.cc b/src/c++/perf_analyzer/client_backend/openai/http_client.cc index 08e4b4b3c..17fb42e08 100644 --- a/src/c++/perf_analyzer/client_backend/openai/http_client.cc +++ b/src/c++/perf_analyzer/client_backend/openai/http_client.cc @@ -104,20 +104,19 @@ HttpClient::HttpClient( HttpClient::~HttpClient() { - exiting_ = true; + { + std::lock_guard lock(mutex_); + exiting_ = true; + } + + curl_multi_wakeup(multi_handle_); // thread not joinable if AsyncInfer() is not called // (it is default constructed thread before the first AsyncInfer() call) if (worker_.joinable()) { - cv_.notify_all(); worker_.join(); } - for (auto& request : ongoing_async_requests_) { - CURL* easy_handle = reinterpret_cast(request.first); - curl_multi_remove_handle(multi_handle_, easy_handle); - curl_easy_cleanup(easy_handle); - } curl_multi_cleanup(multi_handle_); { @@ -183,94 +182,120 @@ HttpClient::SetSSLCurlOptions(CURL* curl_handle) void HttpClient::Send(CURL* handle, std::unique_ptr&& request) { - std::lock_guard lock(mutex_); - - auto insert_result = ongoing_async_requests_.emplace( - std::make_pair(reinterpret_cast(handle), std::move(request))); - if (!insert_result.second) { - curl_easy_cleanup(handle); - throw std::runtime_error( - "Failed to insert new asynchronous request context."); + { + std::lock_guard lock(mutex_); + + if (exiting_) { + return; + } + + auto insert_result = new_async_requests_.emplace(std::make_pair( + reinterpret_cast(handle), std::move(request))); + if (!insert_result.second) { + curl_easy_cleanup(handle); + throw std::runtime_error( + "Failed to insert new asynchronous request context."); + } } - curl_multi_add_handle(multi_handle_, handle); - cv_.notify_all(); + curl_multi_wakeup(multi_handle_); } void HttpClient::AsyncTransfer() { - int place_holder = 0; + int messages_in_queue = 0; + int still_running = 0; + int numfds = 0; CURLMsg* msg = nullptr; + AsyncReqMap ongoing_async_requests; + do { - std::vector> request_list; + { + // Check for new requests and add them to ongoing requests + + std::lock_guard lock(mutex_); - // sleep if no work is available - std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { - if (this->exiting_) { - return true; + for (auto& pair : new_async_requests_) { + curl_multi_add_handle( + multi_handle_, reinterpret_cast(pair.first)); + + ongoing_async_requests[pair.first] = std::move(pair.second); } - // wake up if an async request has been generated - return !this->ongoing_async_requests_.empty(); - }); - - CURLMcode mc = curl_multi_perform(multi_handle_, &place_holder); - int numfds; - if (mc == CURLM_OK) { - // Wait for activity. If there are no descriptors in the multi_handle_ - // then curl_multi_wait will return immediately - mc = curl_multi_wait(multi_handle_, NULL, 0, INT_MAX, &numfds); - if (mc == CURLM_OK) { - while ((msg = curl_multi_info_read(multi_handle_, &place_holder))) { - uintptr_t identifier = reinterpret_cast(msg->easy_handle); - auto itr = ongoing_async_requests_.find(identifier); - // This shouldn't happen - if (itr == ongoing_async_requests_.end()) { - std::cerr - << "Unexpected error: received completed request that is not " - "in the list of asynchronous requests" + new_async_requests_.clear(); + } + + CURLMcode mc = curl_multi_perform(multi_handle_, &still_running); + + if (mc != CURLM_OK) { + std::cerr << "Unexpected error: curl_multi failed. Code:" << mc << std::endl; - curl_multi_remove_handle(multi_handle_, msg->easy_handle); - curl_easy_cleanup(msg->easy_handle); - continue; - } - - uint32_t http_code = 400; - if (msg->data.result == CURLE_OK) { - curl_easy_getinfo( - msg->easy_handle, CURLINFO_RESPONSE_CODE, &http_code); - } else if (msg->data.result == CURLE_OPERATION_TIMEDOUT) { - http_code = 499; - } - - request_list.emplace_back(std::move(itr->second)); - ongoing_async_requests_.erase(itr); - curl_multi_remove_handle(multi_handle_, msg->easy_handle); - curl_easy_cleanup(msg->easy_handle); - - std::unique_ptr& async_request = request_list.back(); - async_request->http_code_ = http_code; - - if (msg->msg != CURLMSG_DONE) { - // Something wrong happened. - std::cerr << "Unexpected error: received CURLMsg=" << msg->msg - << std::endl; - } - } - } else { - std::cerr << "Unexpected error: curl_multi failed. Code:" << mc + continue; + } + + while ((msg = curl_multi_info_read(multi_handle_, &messages_in_queue))) { + if (msg->msg != CURLMSG_DONE) { + // Something wrong happened. + std::cerr << "Unexpected error: received CURLMsg=" << msg->msg << std::endl; + continue; } - } else { + + uintptr_t identifier = reinterpret_cast(msg->easy_handle); + auto itr = ongoing_async_requests.find(identifier); + // This shouldn't happen + if (itr == ongoing_async_requests.end()) { + std::cerr << "Unexpected error: received completed request that is not " + "in the list of asynchronous requests" + << std::endl; + curl_multi_remove_handle(multi_handle_, msg->easy_handle); + curl_easy_cleanup(msg->easy_handle); + continue; + } + + uint32_t http_code = 400; + if (msg->data.result == CURLE_OK) { + curl_easy_getinfo(msg->easy_handle, CURLINFO_RESPONSE_CODE, &http_code); + } else if (msg->data.result == CURLE_OPERATION_TIMEDOUT) { + http_code = 499; + } + + itr->second->http_code_ = http_code; + itr->second->completion_callback_(itr->second.get()); + ongoing_async_requests.erase(itr); + curl_multi_remove_handle(multi_handle_, msg->easy_handle); + curl_easy_cleanup(msg->easy_handle); + } + + + // Wait for activity on existing requests or + // explicit curl_multi_wakeup call + // + // If there are no descriptors in the multi_handle_ + // then curl_multi_poll will wait until curl_multi_wakeup + // is called + // + // curl_multi_wakeup is called when adding a new request + // or exiting + + mc = curl_multi_poll(multi_handle_, NULL, 0, INT_MAX, &numfds); + + if (mc != CURLM_OK) { std::cerr << "Unexpected error: curl_multi failed. Code:" << mc << std::endl; } - lock.unlock(); - for (auto& this_request : request_list) { - this_request->completion_callback_(this_request.get()); - } } while (!exiting_); + + for (auto& request : ongoing_async_requests) { + CURL* easy_handle = reinterpret_cast(request.first); + curl_multi_remove_handle(multi_handle_, easy_handle); + curl_easy_cleanup(easy_handle); + } + + for (auto& request : new_async_requests_) { + CURL* easy_handle = reinterpret_cast(request.first); + curl_easy_cleanup(easy_handle); + } } }}}} // namespace triton::perfanalyzer::clientbackend::openai diff --git a/src/c++/perf_analyzer/client_backend/openai/http_client.h b/src/c++/perf_analyzer/client_backend/openai/http_client.h index 6b78d836e..7ff9bb14e 100644 --- a/src/c++/perf_analyzer/client_backend/openai/http_client.h +++ b/src/c++/perf_analyzer/client_backend/openai/http_client.h @@ -149,7 +149,6 @@ class HttpClient { std::thread worker_; std::mutex mutex_; - std::condition_variable cv_; // The server url const std::string url_; @@ -159,9 +158,9 @@ class HttpClient { using AsyncReqMap = std::map>; // curl multi handle for processing asynchronous requests void* multi_handle_; - // map to record ongoing asynchronous requests with pointer to easy handle + // map to record new asynchronous requests with pointer to easy handle // or tag id as key - AsyncReqMap ongoing_async_requests_; + AsyncReqMap new_async_requests_; bool verbose_; diff --git a/src/c++/perf_analyzer/inference_profiler.cc b/src/c++/perf_analyzer/inference_profiler.cc index 4d6af44b6..57a339424 100644 --- a/src/c++/perf_analyzer/inference_profiler.cc +++ b/src/c++/perf_analyzer/inference_profiler.cc @@ -484,6 +484,7 @@ InferenceProfiler::Create( uint64_t measurement_request_count, MeasurementMode measurement_mode, std::shared_ptr mpi_driver, const uint64_t metrics_interval_ms, const bool should_collect_metrics, const double overhead_pct_threshold, + const bool async_mode, const std::shared_ptr collector, const bool should_collect_profile_data) { @@ -492,7 +493,8 @@ InferenceProfiler::Create( (percentile != -1), percentile, latency_threshold_ms_, protocol, parser, profile_backend, std::move(manager), measurement_request_count, measurement_mode, mpi_driver, metrics_interval_ms, should_collect_metrics, - overhead_pct_threshold, collector, should_collect_profile_data)); + overhead_pct_threshold, async_mode, collector, + should_collect_profile_data)); *profiler = std::move(local_profiler); return cb::Error::Success; @@ -508,7 +510,7 @@ InferenceProfiler::InferenceProfiler( std::unique_ptr manager, uint64_t measurement_request_count, MeasurementMode measurement_mode, std::shared_ptr mpi_driver, const uint64_t metrics_interval_ms, const bool should_collect_metrics, - const double overhead_pct_threshold, + const double overhead_pct_threshold, const bool async_mode, const std::shared_ptr collector, const bool should_collect_profile_data) : verbose_(verbose), measurement_window_ms_(measurement_window_ms), @@ -519,7 +521,8 @@ InferenceProfiler::InferenceProfiler( measurement_request_count_(measurement_request_count), measurement_mode_(measurement_mode), mpi_driver_(mpi_driver), should_collect_metrics_(should_collect_metrics), - overhead_pct_threshold_(overhead_pct_threshold), collector_(collector), + overhead_pct_threshold_(overhead_pct_threshold), async_mode_(async_mode), + collector_(collector), should_collect_profile_data_(should_collect_profile_data) { load_parameters_.stability_threshold = stability_threshold; @@ -789,6 +792,14 @@ InferenceProfiler::ProfileHelper( completed_trials++; } while ((!early_exit) && (completed_trials < max_trials_)); + // For async requests, print a warning if the latency threshold is not met. + if (async_mode_ && !*is_stable && DetermineStability(load_status, false)) { + std::cerr << "Warning: Request latency is not stabilizing. " + "Please try lowering the request rate." + << std::endl; + *is_stable = true; + } + if (should_collect_metrics_) { metrics_manager_->StopQueryingMetrics(); } @@ -816,7 +827,8 @@ InferenceProfiler::ProfileHelper( } bool -InferenceProfiler::DetermineStability(LoadStatus& load_status) +InferenceProfiler::DetermineStability( + LoadStatus& load_status, bool check_latency) { bool stable = false; if (load_status.infer_per_sec.size() >= load_parameters_.stability_window) { @@ -830,16 +842,17 @@ InferenceProfiler::DetermineStability(LoadStatus& load_status) } } - stable = stable && CheckWindowForStability(idx, load_status); + stable = stable && CheckWindowForStability(idx, load_status, check_latency); } return stable; } bool -InferenceProfiler::CheckWindowForStability(size_t idx, LoadStatus& load_status) +InferenceProfiler::CheckWindowForStability( + size_t idx, LoadStatus& load_status, bool check_latency) { return IsInferWindowStable(idx, load_status) && - IsLatencyWindowStable(idx, load_status); + (!check_latency || IsLatencyWindowStable(idx, load_status)); } bool @@ -866,6 +879,8 @@ InferenceProfiler::IsLatencyWindowStable(size_t idx, LoadStatus& load_status) double max_latency = *latencies_per_sec_measurements.second; double min_latency = *latencies_per_sec_measurements.first; + auto is_stable = + max_latency / min_latency <= 1 + load_parameters_.stability_threshold; return max_latency / min_latency <= 1 + load_parameters_.stability_threshold; } diff --git a/src/c++/perf_analyzer/inference_profiler.h b/src/c++/perf_analyzer/inference_profiler.h index 013dd0483..cfd2a3b6e 100644 --- a/src/c++/perf_analyzer/inference_profiler.h +++ b/src/c++/perf_analyzer/inference_profiler.h @@ -260,6 +260,7 @@ class InferenceProfiler { uint64_t measurement_request_count, MeasurementMode measurement_mode, std::shared_ptr mpi_driver, const uint64_t metrics_interval_ms, const bool should_collect_metrics, const double overhead_pct_threshold, + const bool async_mode, const std::shared_ptr collector, const bool should_collect_profile_data); @@ -363,7 +364,7 @@ class InferenceProfiler { std::unique_ptr manager, uint64_t measurement_request_count, MeasurementMode measurement_mode, std::shared_ptr mpi_driver, const uint64_t metrics_interval_ms, const bool should_collect_metrics, - const double overhead_pct_threshold, + const double overhead_pct_threshold, const bool async_mode, const std::shared_ptr collector, const bool should_collect_profile_data); @@ -432,8 +433,9 @@ class InferenceProfiler { /// A helper function to determine if profiling is stable /// \param load_status Stores the observations of infer_per_sec and latencies + /// \param check_latency Whether to check latency for stability /// \return Returns if the threshold and latencies are stable. - bool DetermineStability(LoadStatus& load_status); + bool DetermineStability(LoadStatus& load_status, bool check_latency = true); /// Check if latency at index idx is within the latency threshold /// \param idx index in latency vector @@ -452,8 +454,10 @@ class InferenceProfiler { /// for a single window starting at idx /// \param idx index in latency vector /// \param load_status Stores the observations of infer_per_sec and latencies + /// \param check_latency Whether to check latency for stability /// \return Returns whether inference and latency are stable - bool CheckWindowForStability(size_t idx, LoadStatus& load_status); + bool CheckWindowForStability( + size_t idx, LoadStatus& load_status, bool check_latency); /// Check if observed inferences are within threshold /// for a single window starting at idx @@ -786,6 +790,9 @@ class InferenceProfiler { // Whether to collect profile data. bool should_collect_profile_data_{false}; + // Whether the client is operating in async mode. + const bool async_mode_{false}; + #ifndef DOCTEST_CONFIG_DISABLE friend NaggyMockInferenceProfiler; friend TestInferenceProfiler; diff --git a/src/c++/perf_analyzer/perf_analyzer.cc b/src/c++/perf_analyzer/perf_analyzer.cc index b8b4de7ea..c10101e1c 100644 --- a/src/c++/perf_analyzer/perf_analyzer.cc +++ b/src/c++/perf_analyzer/perf_analyzer.cc @@ -284,7 +284,7 @@ PerfAnalyzer::CreateAnalyzerObjects() params_->measurement_request_count, params_->measurement_mode, params_->mpi_driver, params_->metrics_interval_ms, params_->should_collect_metrics, params_->overhead_pct_threshold, - collector_, !params_->profile_export_file.empty()), + params_->async, collector_, !params_->profile_export_file.empty()), "failed to create profiler"); } @@ -311,11 +311,16 @@ PerfAnalyzer::PrerunReport() << std::endl; } + std::string stabilization_metric = "latency and throughput"; + if (params_->async) { + stabilization_metric = "throughput"; + } if (params_->percentile == -1) { - std::cout << " Stabilizing using average latency" << std::endl; - } else { - std::cout << " Stabilizing using p" << params_->percentile << " latency" + std::cout << " Stabilizing using average " << stabilization_metric << std::endl; + } else { + std::cout << " Stabilizing using p" << params_->percentile + << stabilization_metric << std::endl; } if (params_->measurement_mode == pa::MeasurementMode::TIME_WINDOWS) { diff --git a/src/c++/perf_analyzer/test_inference_profiler.cc b/src/c++/perf_analyzer/test_inference_profiler.cc index 8ff39605b..40813ce5b 100644 --- a/src/c++/perf_analyzer/test_inference_profiler.cc +++ b/src/c++/perf_analyzer/test_inference_profiler.cc @@ -81,16 +81,17 @@ class TestInferenceProfiler : public InferenceProfiler { ip.load_parameters_.stability_threshold = lp.stability_threshold; ip.load_parameters_.stability_window = lp.stability_window; - return ip.CheckWindowForStability(idx, ls); + return ip.CheckWindowForStability(idx, ls, true); }; - static bool TestDetermineStability(LoadStatus& ls, LoadParams& lp) + static bool TestDetermineStability( + LoadStatus& ls, LoadParams& lp, bool check_latency = true) { InferenceProfiler ip; ip.load_parameters_.stability_threshold = lp.stability_threshold; ip.load_parameters_.stability_window = lp.stability_window; - return ip.DetermineStability(ls); + return ip.DetermineStability(ls, check_latency); } static bool TestIsDoneProfiling( @@ -349,6 +350,16 @@ TEST_CASE("test_determine_stability") ls.infer_per_sec = {500.0, 520.0, 510.0}; CHECK(TestInferenceProfiler::TestDetermineStability(ls, lp) == true); } + + SUBCASE("test determine stability without latency check") + { + ls.infer_per_sec = {500.0, 520.0, 510.0}; + ls.latencies = {100, 106, 112}; + lp.stability_window = 3; + lp.stability_threshold = 0.1; + uint64_t latency_threshold_ms = 1; + CHECK(TestInferenceProfiler::TestDetermineStability(ls, lp, false) == true); + } } TEST_CASE("test_is_done_profiling") From 5c2d47af9f1611e3bd9b2e6d48722758da29b9fd Mon Sep 17 00:00:00 2001 From: Elias Bermudez <6505145+debermudez@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:15:33 -0700 Subject: [PATCH 10/55] Refactor the Statistics data source (#681) * Initial refactor for data export Add unit test for pretty printing Update csv and console to use new stats dictionary Add data exporter interface, factory, report output class Add new exporter files for json, csv, and console Remove constants defined elsewhere Scale metrics Update black formatter version Add testing for new exporter classes Remove pretty_print and export_to_csv method from the Stats class and associated testing Remove json, csv, and console exporting from main report_output method Add init file to tests to remove mypy package issue Fix mypy issues and unused imports Remove more unused imports Add testing for data exporter factory Reenable main * Address feedback from PR review Rename ReportOutput class to OutputReporter Remove data_parser storage in output reporter class Udpate scale docstring Update docstrings for test classes Update console exporter test to use defined stats and capsys Fix import Fix scaling to not impact plots yet and update tests * Refactor scaling to a single method call * Rework factory to use exporter config Remove extra line from docstring Move scaling to output reporter and update tests Change output reporter interface to use a Statistics object Update factory to use new exporter config * Clean up codeql and non streaming failure --- .pre-commit-config.yaml | 2 +- .../genai-perf/genai_perf/constants.py | 1 - .../export_data/console_exporter.py | 109 +++++++++ .../genai_perf/export_data/csv_exporter.py | 137 ++++++++++++ .../export_data/data_exporter_factory.py | 42 ++++ .../export_data/data_exporter_interface.py | 33 +++ .../genai_perf/export_data/exporter_config.py | 65 ++++++ .../genai_perf/export_data/json_exporter.py | 23 +- .../genai_perf/export_data/output_reporter.py | 60 +++++ .../genai-perf/genai_perf/llm_metrics.py | 192 +++------------- .../genai-perf/genai_perf/logging.py | 10 + .../genai-perf/genai_perf/main.py | 13 +- .../genai-perf/tests/__init__.py | 0 .../genai-perf/tests/test_console_exporter.py | 140 ++++++++++++ .../genai-perf/tests/test_csv_exporter.py | 167 ++++++++++++++ .../tests/test_data_exporter_factory.py | 83 +++++++ .../genai-perf/tests/test_json_exporter.py | 55 ++--- .../genai-perf/tests/test_llm_metrics.py | 206 ++++++++---------- 18 files changed, 1010 insertions(+), 328 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_factory.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_interface.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/__init__.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_data_exporter_factory.py mode change 100755 => 100644 src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41ba6434b..2d31aa2fe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - id: isort additional_dependencies: [toml] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 24.4.0 hooks: - id: black types_or: [python, cython] diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/constants.py b/src/c++/perf_analyzer/genai-perf/genai_perf/constants.py index df2f6f7bb..b951524bf 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/constants.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/constants.py @@ -31,7 +31,6 @@ OPEN_ORCA = "openorca" CNN_DAILY_MAIL = "cnn_dailymail" DEFAULT_INPUT_DATA_JSON = "llm_inputs.json" -DEFAULT_OUTPUT_DATA_JSON = "profile_export_genai_perf.json" DEFAULT_ARTIFACT_DIR = "artifacts" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py new file mode 100644 index 000000000..643e177ce --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py @@ -0,0 +1,109 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.llm_metrics import Metrics +from rich.console import Console +from rich.table import Table + + +class ConsoleExporter: + """ + A class to export the statistics and arg values to the console. + """ + + def __init__(self, config: ExporterConfig): + self._stats = config.stats + + def export(self) -> None: + singular_metric_rows = [] + table = Table(title="LLM Metrics") + + table.add_column("Statistic", justify="right", style="cyan", no_wrap=True) + stats = ["avg", "min", "max", "p99", "p90", "p75"] + for stat in stats: + table.add_column(stat, justify="right", style="green") + + for metric in Metrics.metric_labels: + formatted_metric = metric.replace("_", " ").capitalize() + + # Throughput fields are printed after the table + is_throughput_field = metric in Metrics.throughput_fields + if is_throughput_field: + value = self._stats.get(f"{metric}", -1).get(stats[0], -1) + formatted_metric += f" (per sec): {value:.2f}" + singular_metric_rows.append(formatted_metric) + continue + + # TODO (TMA-1712): need to decide if we need this metric. Remove + # from statistics display for now. + # TODO (TMA-1678): output_token_throughput_per_request is treated + # separately since the current code treats all throughput metrics to + # be displayed outside of the statistics table. + if metric == "output_token_throughput_per_request": + formatted_metric += f" (per sec)" + continue + + is_time_field = metric in Metrics.time_fields + if is_time_field: + formatted_metric += " (ms)" + + row_values = [formatted_metric] + for stat in stats: + value = self._stats.get(f"{metric}", -1) + # Need to check for -1 for the non streaming case + if value == -1: + row_values.append(f"{value:,.2f}") + else: + value = value.get(stat, -1) + row_values.append(f"{value:,.2f}") + + # Without streaming, there is no inter-token latency available, so do not print it. + if metric == "inter_token_latency": + if all(value == "-1" for value in row_values[1:]): + continue + # Without streaming, TTFT and request latency are the same, so do not print TTFT. + elif metric == "time_to_first_token": + unique_values = False + for stat in stats: + value_ttft = self._stats.get(f"{metric}", -1).get(stat, -1) + value_req_latency = self._stats.get("request_latency", -1).get( + stat, -1 + ) + if value_ttft != value_req_latency: + unique_values = True + break + if not unique_values: + continue + + table.add_row(*row_values) + + console = Console() + console.print(table) + + for row in singular_metric_rows: + print(row) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py new file mode 100644 index 000000000..3677fe357 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py @@ -0,0 +1,137 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +import csv + +import genai_perf.logging as logging +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.llm_metrics import Metrics + +DEFAULT_OUTPUT_DATA_CSV = "profile_export_genai_perf.csv" + +logger = logging.getLogger(__name__) + + +class CsvExporter: + """ + A class to export the statistics and arg values in a csv format. + """ + + def __init__(self, config: ExporterConfig): + self._stats = config.stats + self._output_dir = config.artifact_dir + + def export(self) -> None: + csv_filename = self._output_dir / DEFAULT_OUTPUT_DATA_CSV + logger.info(f"Generating {csv_filename}") + + multiple_metric_header = [ + "Metric", + "avg", + "min", + "max", + "p99", + "p95", + "p90", + "p75", + "p50", + "p25", + ] + + single_metric_header = [ + "Metric", + "Value", + ] + + with open(csv_filename, mode="w", newline="") as csvfile: + singular_metric_rows = [] + + csv_writer = csv.writer(csvfile) + csv_writer.writerow(multiple_metric_header) + + for metric in Metrics.metric_labels: + formatted_metric = metric.replace("_", " ").title() + + is_throughput_field = metric in Metrics.throughput_fields + is_time_field = metric in Metrics.time_fields + + if is_time_field: + formatted_metric += " (ms)" + elif is_throughput_field: + formatted_metric += " (per sec)" + # TODO (TMA-1712): need to decide if we need this metric. Do not + # include in the csv for now. + # TODO (TMA-1678): output_token_throughput_per_request is treated + # separately since the current code treats all throughput metrics + # to be displayed outside of the statistics table. + elif metric == "output_token_throughput_per_request": + formatted_metric += " (per sec)" + continue + + row_values = [formatted_metric] + + if is_throughput_field: + value = self._stats.get(f"{metric}", -1).get( + multiple_metric_header[1], -1 + ) + row_values.append(f"{value:.2f}") + singular_metric_rows.append(row_values) + continue + + for stat in multiple_metric_header[1:]: + value = self._stats.get(f"{metric}", -1) + # Need to check for -1 for the non streaming case + if value == -1: + row_values.append(f"{value:,.2f}") + else: + value = value.get(stat, -1) + row_values.append(f"{value:,.2f}") + + # Without streaming, there is no inter-token latency available, so do not print it. + if metric == "inter_token_latency": + if all(value == "-1" for value in row_values[1:]): + continue + # Without streaming, TTFT and request latency are the same, so do not print TTFT. + elif metric == "time_to_first_token": + unique_values = False + for stat in multiple_metric_header[1:]: + value_ttft = self._stats.get(f"{metric}", -1).get(stat, -1) + value_req_latency = self._stats.get("request_latency", -1).get( + stat, -1 + ) + if value_ttft != value_req_latency: + unique_values = True + break + if not unique_values: + continue + + csv_writer.writerow(row_values) + + csv_writer.writerow([]) + csv_writer.writerow(single_metric_header) + for row in singular_metric_rows: + csv_writer.writerow(row) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_factory.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_factory.py new file mode 100644 index 000000000..ac226bdf5 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_factory.py @@ -0,0 +1,42 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from typing import List + +from genai_perf.export_data.console_exporter import ConsoleExporter +from genai_perf.export_data.csv_exporter import CsvExporter +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.export_data.json_exporter import JsonExporter + +DataExporterList = [ConsoleExporter, JsonExporter, CsvExporter] + + +class DataExporterFactory: + def create_data_exporters(self, config: ExporterConfig) -> List: + data_exporters = [] + for exporter in DataExporterList: + data_exporters.append(exporter(config)) + return data_exporters diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_interface.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_interface.py new file mode 100644 index 000000000..56bde9a53 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/data_exporter_interface.py @@ -0,0 +1,33 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from typing import Protocol + + +class DataExporterInterface(Protocol): + def export(self): + pass diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py new file mode 100644 index 000000000..3f0451961 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py @@ -0,0 +1,65 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +class ExporterConfig: + def __init__(self): + self._stats = None + self._args = None + self._extra_inputs = None + self._artifact_dir = None + + @property + def stats(self): + return self._stats + + @stats.setter + def stats(self, stats_value): + self._stats = stats_value + + @property + def args(self): + return self._args + + @args.setter + def args(self, args_value): + self._args = args_value + + @property + def extra_inputs(self): + return self._extra_inputs + + @extra_inputs.setter + def extra_inputs(self, extra_inputs_value): + self._extra_inputs = extra_inputs_value + + @property + def artifact_dir(self): + return self._artifact_dir + + @artifact_dir.setter + def artifact_dir(self, artifact_dir_value): + self._artifact_dir = artifact_dir_value diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py index cd50f1c2c..c5a0f36cd 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py @@ -26,12 +26,15 @@ import json -from argparse import Namespace from enum import Enum -from pathlib import Path from typing import Dict -from genai_perf.constants import DEFAULT_OUTPUT_DATA_JSON +import genai_perf.logging as logging +from genai_perf.export_data.exporter_config import ExporterConfig + +DEFAULT_OUTPUT_DATA_JSON = "profile_export_genai_perf.json" + +logger = logging.getLogger(__name__) class JsonExporter: @@ -39,16 +42,18 @@ class JsonExporter: A class to export the statistics and arg values in a json format. """ - def __init__(self, stats: Dict, args: Namespace, extra_inputs: Dict): - self._stats = stats - self._args = dict(vars(args)) - self._extra_inputs = extra_inputs + def __init__(self, config: ExporterConfig): + self._stats: Dict = config.stats + self._args = dict(vars(config.args)) + self._extra_inputs = config.extra_inputs + self._output_dir = config.artifact_dir self._stats_and_args: Dict = {} self._prepare_args_for_export() self._merge_stats_and_args() - def export_to_file(self, output_dir: Path) -> None: - filename = output_dir / DEFAULT_OUTPUT_DATA_JSON + def export(self) -> None: + filename = self._output_dir / DEFAULT_OUTPUT_DATA_JSON + logger.info(f"Generating {filename}") with open(str(filename), "w") as f: f.write(json.dumps(self._stats_and_args, indent=2)) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py new file mode 100644 index 000000000..0189ccfaf --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py @@ -0,0 +1,60 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from argparse import Namespace + +from genai_perf.export_data.data_exporter_factory import DataExporterFactory +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.llm_metrics import Statistics +from genai_perf.parser import get_extra_inputs_as_dict + + +class OutputReporter: + """ + A class to orchestrate output generation. + """ + + def __init__(self, stats: Statistics, args: Namespace): + self.args = args + self.stats = stats + self.stats.scale_data() + + def report_output(self) -> None: + factory = DataExporterFactory() + exporter_config = self._create_exporter_config() + data_exporters = factory.create_data_exporters(exporter_config) + + for exporter in data_exporters: + exporter.export() + + def _create_exporter_config(self) -> ExporterConfig: + config = ExporterConfig() + config.stats = self.stats.stats_dict + config.args = self.args + config.artifact_dir = self.args.artifact_dir + config.extra_inputs = get_extra_inputs_as_dict(self.args) + return config diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py index d3f862597..f3e2bfeee 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py @@ -139,9 +139,9 @@ def __init__( self._base_names["time_to_first_tokens"] = "time_to_first_token" self._base_names["inter_token_latencies"] = "inter_token_latency" self._base_names["output_token_throughputs"] = "output_token_throughput" - self._base_names[ - "output_token_throughputs_per_request" - ] = "output_token_throughput_per_request" + self._base_names["output_token_throughputs_per_request"] = ( + "output_token_throughput_per_request" + ) self._base_names["num_output_tokens"] = "num_output_token" self._base_names["num_input_tokens"] = "num_input_token" @@ -192,40 +192,54 @@ def _should_skip(self, data: List[Union[int, float]], attr: str) -> bool: def _calculate_mean(self, data: List[Union[int, float]], attr: str) -> None: avg = np.mean(data) - self._stats_dict[attr]["avg"] = float(avg) setattr(self, "avg_" + attr, avg) + self._stats_dict[attr]["avg"] = float(avg) def _calculate_percentiles(self, data: List[Union[int, float]], attr: str) -> None: p25, p50, p75 = np.percentile(data, [25, 50, 75]) p90, p95, p99 = np.percentile(data, [90, 95, 99]) - self._stats_dict[attr]["p99"] = float(p99) - self._stats_dict[attr]["p95"] = float(p95) - self._stats_dict[attr]["p90"] = float(p90) - self._stats_dict[attr]["p75"] = float(p75) - self._stats_dict[attr]["p50"] = float(p50) - self._stats_dict[attr]["p25"] = float(p25) setattr(self, "p25_" + attr, p25) setattr(self, "p50_" + attr, p50) setattr(self, "p75_" + attr, p75) setattr(self, "p90_" + attr, p90) setattr(self, "p95_" + attr, p95) setattr(self, "p99_" + attr, p99) + self._stats_dict[attr]["p99"] = float(p99) + self._stats_dict[attr]["p95"] = float(p95) + self._stats_dict[attr]["p90"] = float(p90) + self._stats_dict[attr]["p75"] = float(p75) + self._stats_dict[attr]["p50"] = float(p50) + self._stats_dict[attr]["p25"] = float(p25) def _calculate_minmax(self, data: List[Union[int, float]], attr: str) -> None: min, max = np.min(data), np.max(data) - self._stats_dict[attr]["max"] = float(max) - self._stats_dict[attr]["min"] = float(min) setattr(self, "min_" + attr, min) setattr(self, "max_" + attr, max) + self._stats_dict[attr]["max"] = float(max) + self._stats_dict[attr]["min"] = float(min) def _calculate_std(self, data: List[Union[int, float]], attr: str) -> None: std = np.std(data) - self._stats_dict[attr]["std"] = float(std) setattr(self, "std_" + attr, std) + self._stats_dict[attr]["std"] = float(std) + + def scale_data(self, factor: float = 1 / 1e6) -> None: + for k1, v1 in self.stats_dict.items(): + if self._is_time_field(k1): + for k2, v2 in v1.items(): + if k2 != "unit": + self.stats_dict[k1][k2] = self._scale(v2, factor) + + def _scale(self, metric: float, factor: float = 1 / 1e6) -> float: + """ + Scale metrics from nanoseconds by factor. + Default is nanoseconds to milliseconds. + """ + return metric * factor def _add_units(self, key) -> None: if self._is_time_field(key): - self._stats_dict[key]["unit"] = "ns" + self._stats_dict[key]["unit"] = "ms" if key == "request_throughput": self._stats_dict[key]["unit"] = "requests/sec" if key.startswith("output_token_throughput"): @@ -260,156 +274,6 @@ def _is_throughput_field(self, field: str) -> bool: def _is_time_field(self, field: str) -> bool: return field in Metrics.time_fields - def pretty_print(self) -> None: - """Prints the statistics in a tabular format.""" - - singular_metric_rows = [] - table = Table(title="LLM Metrics") - - table.add_column("Statistic", justify="right", style="cyan", no_wrap=True) - stats = ["avg", "min", "max", "p99", "p90", "p75"] - for stat in stats: - table.add_column(stat, justify="right", style="green") - - for metric in Metrics.metric_labels: - formatted_metric = metric.replace("_", " ").capitalize() - - # Throughput fields are printed after the table - is_throughput_field = self._is_throughput_field(metric) - if is_throughput_field: - value = self.__dict__.get(f"{stats[0]}_{metric}", -1) - formatted_metric += f" (per sec): {value:.2f}" - singular_metric_rows.append(formatted_metric) - continue - - # TODO (TMA-1712): need to decide if we need this metric. Remove - # from statistics display for now. - # TODO (TMA-1678): output_token_throughput_per_request is treated - # separately since the current code treats all throughput metrics to - # be displayed outside of the statistics table. - if metric == "output_token_throughput_per_request": - formatted_metric += f" (per sec)" - continue - - is_time_field = self._is_time_field(metric) - if is_time_field: - formatted_metric += " (ns)" - - row_values = [formatted_metric] - - for stat in stats: - value = self.__dict__.get(f"{stat}_{metric}", -1) - row_values.append(f"{value:,.0f}") - - # Without streaming, there is no inter-token latency available, so do not print it. - if metric == "inter_token_latency": - if all(value == "-1" for value in row_values[1:]): - continue - # Without streaming, TTFT and request latency are the same, so do not print TTFT. - elif metric == "time_to_first_token": - unique_values = False - for stat in stats: - value_ttft = self.__dict__.get(f"{stat}_{metric}", -1) - value_req_latency = self.__dict__.get(f"{stat}_request_latency", -1) - if value_ttft != value_req_latency: - unique_values = True - break - if not unique_values: - continue - - table.add_row(*row_values) - - console = Console() - console.print(table) - - for row in singular_metric_rows: - print(row) - - def export_to_csv(self, csv_filename: str) -> None: - """Exports the statistics to a CSV file.""" - - multiple_metric_header = [ - "Metric", - "avg", - "min", - "max", - "p99", - "p95", - "p90", - "p75", - "p50", - "p25", - ] - - single_metric_header = [ - "Metric", - "Value", - ] - - with open(csv_filename, mode="w", newline="") as csvfile: - singular_metric_rows = [] - - csv_writer = csv.writer(csvfile) - csv_writer.writerow(multiple_metric_header) - - for metric in Metrics.metric_labels: - formatted_metric = metric.replace("_", " ").title() - - is_throughput_field = self._is_throughput_field(metric) - is_time_field = self._is_time_field(metric) - - if is_time_field: - formatted_metric += " (ns)" - elif is_throughput_field: - formatted_metric += " (per sec)" - # TODO (TMA-1712): need to decide if we need this metric. Do not - # include in the csv for now. - # TODO (TMA-1678): output_token_throughput_per_request is treated - # separately since the current code treats all throughput metrics - # to be displayed outside of the statistics table. - elif metric == "output_token_throughput_per_request": - formatted_metric += " (per sec)" - continue - - row_values = [formatted_metric] - - if is_throughput_field: - value = self.__dict__.get( - f"{multiple_metric_header[1]}_{metric}", -1 - ) - row_values.append(f"{value:.2f}") - singular_metric_rows.append(row_values) - continue - - for stat in multiple_metric_header[1:]: - value = self.__dict__.get(f"{stat}_{metric}", -1) - row_values.append(f"{value:.0f}") - - # Without streaming, there is no inter-token latency available, so do not print it. - if metric == "inter_token_latency": - if all(value == "-1" for value in row_values[1:]): - continue - # Without streaming, TTFT and request latency are the same, so do not print TTFT. - elif metric == "time_to_first_token": - unique_values = False - for stat in multiple_metric_header[1:]: - value_ttft = self.__dict__.get(f"{stat}_{metric}", -1) - value_req_latency = self.__dict__.get( - f"{stat}_request_latency", -1 - ) - if value_ttft != value_req_latency: - unique_values = True - break - if not unique_values: - continue - - csv_writer.writerow(row_values) - - csv_writer.writerow([]) - csv_writer.writerow(single_metric_header) - for row in singular_metric_rows: - csv_writer.writerow(row) - def export_parquet(self, artifact_dir: Path, filename: str) -> None: max_length = -1 col_index = 0 diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/logging.py b/src/c++/perf_analyzer/genai-perf/genai_perf/logging.py index db23dff06..f5cab490a 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/logging.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/logging.py @@ -80,6 +80,16 @@ def init_logging() -> None: "level": "DEBUG", "propagate": False, }, + "genai_perf.export_data.json_exporter": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + "genai_perf.export_data.csv_exporter": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, }, } logging.config.dictConfig(LOGGING_CONFIG) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index da5fd0e79..65b765d82 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -35,7 +35,7 @@ from genai_perf import parser from genai_perf.constants import DEFAULT_PARQUET_FILE from genai_perf.exceptions import GenAIPerfException -from genai_perf.export_data.json_exporter import JsonExporter +from genai_perf.export_data.output_reporter import OutputReporter from genai_perf.llm_inputs.llm_inputs import LlmInputs from genai_perf.llm_metrics import LLMProfileDataParser from genai_perf.plots.plot_config_parser import PlotConfigParser @@ -101,17 +101,10 @@ def report_output(data_parser: LLMProfileDataParser, args: Namespace) -> None: raise GenAIPerfException("No valid infer mode specified") stats = data_parser.get_statistics(infer_mode, load_level) - export_csv_name = args.profile_export_file.with_name( - args.profile_export_file.stem + "_genai_perf.csv" - ) - stats.export_to_csv(export_csv_name) - stats.export_parquet(args.artifact_dir, DEFAULT_PARQUET_FILE) - stats.pretty_print() + reporter = OutputReporter(stats, args) + reporter.report_output() if args.generate_plots: create_plots(args) - extra_inputs_dict = parser.get_extra_inputs_as_dict(args) - json_exporter = JsonExporter(stats.stats_dict, args, extra_inputs_dict) - json_exporter.export_to_file(args.artifact_dir) def create_plots(args: Namespace) -> None: diff --git a/src/c++/perf_analyzer/genai-perf/tests/__init__.py b/src/c++/perf_analyzer/genai-perf/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py new file mode 100644 index 000000000..0d3da88f8 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -0,0 +1,140 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from genai_perf.export_data.console_exporter import ConsoleExporter +from genai_perf.export_data.exporter_config import ExporterConfig + + +class TestConsoleExporter: + + def test_pretty_print_output(self, capsys) -> None: + config = ExporterConfig() + config.stats = stats + exporter = ConsoleExporter(config) + exporter.export() + + expected_content = ( + " LLM Metrics \n" + "┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┓\n" + "┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃\n" + "┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━┩\n" + "│ Time to first token (ms) │ 2.00 │ 2.00 │ 3.00 │ 2.99 │ 2.90 │ 2.75 │\n" + "│ Inter token latency (ms) │ 0.50 │ 0.00 │ 1.00 │ 0.99 │ 0.90 │ 0.75 │\n" + "│ Request latency (ms) │ 3.00 │ 3.00 │ 4.00 │ 3.99 │ 3.90 │ 3.75 │\n" + "│ Num output token │ 6.50 │ 6.00 │ 7.00 │ 6.99 │ 6.90 │ 6.75 │\n" + "│ Num input token │ 7.50 │ 7.00 │ 8.00 │ 7.99 │ 7.90 │ 7.75 │\n" + "└──────────────────────────┴──────┴──────┴──────┴──────┴──────┴──────┘\n" + "Output token throughput (per sec): 123.00\n" + "Request throughput (per sec): 456.00\n" + ) + + returned_data = capsys.readouterr().out + + assert returned_data == expected_content + + +stats = { + "request_throughput": {"unit": "requests/sec", "avg": 456.0}, + "request_latency": { + "unit": "ms", + "avg": 3.0, + "p99": 3.99, + "p95": 3.95, + "p90": 3.90, + "p75": 3.75, + "p50": 3.50, + "p25": 3.25, + "max": 4.0, + "min": 3.0, + "std": 3.50, + }, + "time_to_first_token": { + "unit": "ms", + "avg": 2.0, + "p99": 2.99, + "p95": 2.95, + "p90": 2.90, + "p75": 2.75, + "p50": 2.50, + "p25": 2.25, + "max": 3.00, + "min": 2.00, + "std": 2.50, + }, + "inter_token_latency": { + "unit": "ms", + "avg": 0.50, + "p99": 0.99, + "p95": 0.95, + "p90": 0.90, + "p75": 0.75, + "p50": 0.50, + "p25": 0.25, + "max": 1.00, + "min": 0.00, + "std": 0.50, + }, + "output_token_throughput": {"unit": "tokens/sec", "avg": 123.0}, + "output_token_throughput_per_request": { + "unit": "tokens/sec", + "avg": 300.00, + "p99": 300.00, + "p95": 300.00, + "p90": 300.00, + "p75": 300.00, + "p50": 300.00, + "p25": 300.00, + "max": 300.00, + "min": 300.00, + "std": 300.00, + }, + "num_output_token": { + "unit": "tokens", + "avg": 6.5, + "p99": 6.99, + "p95": 6.95, + "p90": 6.90, + "p75": 6.75, + "p50": 6.5, + "p25": 6.25, + "max": 7.0, + "min": 6.0, + "std": 6.5, + }, + "num_input_token": { + "unit": "tokens", + "avg": 7.5, + "p99": 7.99, + "p95": 7.95, + "p90": 7.90, + "p75": 7.75, + "p50": 7.5, + "p25": 7.25, + "max": 8.0, + "min": 7.0, + "std": 7.5, + }, +} diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py new file mode 100644 index 000000000..d48e9c340 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py @@ -0,0 +1,167 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +from io import StringIO +from pathlib import Path +from typing import Any, List + +import pytest +from genai_perf.export_data.csv_exporter import CsvExporter +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.llm_metrics import LLMProfileDataParser +from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer + + +class TestCsvExporter: + @pytest.fixture + def mock_read_write(self, monkeypatch: pytest.MonkeyPatch) -> List[str]: + """ + This function will mock the open function for specific files. + """ + + written_data = [] + + original_open = open + + def custom_open(filename, *args, **kwargs): + def write(self: Any, content: str) -> int: + written_data.append(content) + return len(content) + + if str(filename) == "triton_profile_export.json": + tmp_file = StringIO(json.dumps(triton_profile_data)) + return tmp_file + elif str(filename) == "profile_export_genai_perf.csv": + tmp_file = StringIO() + tmp_file.write = write.__get__(tmp_file) + return tmp_file + else: + return original_open(filename, *args, **kwargs) + + monkeypatch.setattr("builtins.open", custom_open) + + return written_data + + def test_csv_output(self, mock_read_write: pytest.MonkeyPatch) -> None: + """ + Collect LLM metrics from profile export data and confirm correct values are + printed in csv. + """ + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("triton_profile_export.json"), + tokenizer=tokenizer, + ) + stat = pd.get_statistics(infer_mode="concurrency", load_level="10") + + expected_content = [ + "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", + "Time To First Token (ms),2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00\r\n", + "Inter Token Latency (ms),1.50,1.00,2.00,1.99,1.95,1.90,1.75,1.50,1.25\r\n", + "Request Latency (ms),8.00,7.00,9.00,8.98,8.90,8.80,8.50,8.00,7.50\r\n", + "Num Output Token,4.50,3.00,6.00,5.97,5.85,5.70,5.25,4.50,3.75\r\n", + "Num Input Token,3.50,3.00,4.00,3.99,3.95,3.90,3.75,3.50,3.25\r\n", + "\r\n", + "Metric,Value\r\n", + "Output Token Throughput (per sec),900000000.00\r\n", + "Request Throughput (per sec),200000000.00\r\n", + ] + config = ExporterConfig() + config.stats = stat.stats_dict + config.artifact_dir = Path(".") + exporter = CsvExporter(config) + exporter.export() + + returned_data = mock_read_write + + assert returned_data == expected_content + + +triton_profile_data = { + "service_kind": "triton", + "endpoint": "", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": {"text_input": "This is test"}, + "response_timestamps": [3, 5, 8], + "response_outputs": [ + {"text_output": "I"}, + {"text_output": " like"}, + {"text_output": " dogs"}, + ], + }, + { + "timestamp": 2, + "request_inputs": {"text_input": "This is test too"}, + "response_timestamps": [4, 7, 11], + "response_outputs": [ + {"text_output": "I"}, + {"text_output": " don't"}, + {"text_output": " cook food"}, + ], + }, + ], + }, + { + "experiment": { + "mode": "request_rate", + "value": 2.0, + }, + "requests": [ + { + "timestamp": 5, + "request_inputs": {"text_input": "This is test"}, + "response_timestamps": [7, 8, 13, 18], + "response_outputs": [ + {"text_output": "cat"}, + {"text_output": " is"}, + {"text_output": " cool"}, + {"text_output": " too"}, + ], + }, + { + "timestamp": 3, + "request_inputs": {"text_input": "This is test too"}, + "response_timestamps": [6, 8, 11], + "response_outputs": [ + {"text_output": "it's"}, + {"text_output": " very"}, + {"text_output": " simple work"}, + ], + }, + ], + }, + ], +} diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_data_exporter_factory.py b/src/c++/perf_analyzer/genai-perf/tests/test_data_exporter_factory.py new file mode 100644 index 000000000..1a1628ac7 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_data_exporter_factory.py @@ -0,0 +1,83 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +from argparse import Namespace + +import genai_perf.export_data.data_exporter_factory as factory +from genai_perf.export_data.console_exporter import ConsoleExporter +from genai_perf.export_data.csv_exporter import CsvExporter +from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.export_data.json_exporter import JsonExporter +from genai_perf.parser import get_extra_inputs_as_dict + + +class TestOutputReporter: + stats = { + "request_latency": { + "unit": "ms", + "avg": 1, + "p99": 2, + "p95": 3, + "p90": 4, + "p75": 5, + "p50": 6, + "p25": 7, + "max": 8, + "min": 9, + "std": 0, + }, + } + args = { + "model": ["gpt2_vllm"], + "formatted_model_name": "gpt2_vllm", + "model_selection_strategy": "round_robin", + "func": "Should_be_removed", + "output_format": "Should_be_removed", + "profile_export_file": ".", + "artifact_dir": ".", + "extra_inputs": ["max_tokens:200"], + } + args_namespace = Namespace(**args) + + config = ExporterConfig() + config.stats = stats + config.args = args_namespace + config.artifact_dir = args_namespace.artifact_dir + config.extra_inputs = get_extra_inputs_as_dict(args_namespace) + f = factory.DataExporterFactory() + + def test_return_json_exporter(self) -> None: + exporter_list = self.f.create_data_exporters(self.config) + assert any(isinstance(exporter, JsonExporter) for exporter in exporter_list) + + def test_return_csv_exporter(self) -> None: + exporter_list = self.f.create_data_exporters(self.config) + assert any(isinstance(exporter, CsvExporter) for exporter in exporter_list) + + def test_return_console_exporter(self) -> None: + exporter_list = self.f.create_data_exporters(self.config) + assert any(isinstance(exporter, ConsoleExporter) for exporter in exporter_list) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index b97712e31..17ebbf4bc 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -27,14 +27,38 @@ import json import genai_perf.parser as parser +from genai_perf.export_data.exporter_config import ExporterConfig from genai_perf.export_data.json_exporter import JsonExporter class TestJsonExporter: + def test_generate_json(self, monkeypatch) -> None: + cli_cmd = [ + "genai-perf", + "-m", + "gpt2_vllm", + "--backend", + "vllm", + "--streaming", + "--extra-inputs", + "max_tokens:256", + "--extra-inputs", + "ignore_eos:true", + ] + monkeypatch.setattr("sys.argv", cli_cmd) + args, _ = parser.parse_args() + config = ExporterConfig() + config.stats = self.stats + config.args = args + config.extra_inputs = parser.get_extra_inputs_as_dict(args) + config.artifact_dir = args.artifact_dir + json_exporter = JsonExporter(config) + assert json_exporter._stats_and_args == json.loads(self.expected_json_output) + stats = { "request_throughput": {"unit": "requests/sec", "avg": "7"}, "request_latency": { - "unit": "ns", + "unit": "ms", "avg": 1, "p99": 2, "p95": 3, @@ -47,7 +71,7 @@ class TestJsonExporter: "std": 0, }, "time_to_first_token": { - "unit": "ns", + "unit": "ms", "avg": 11, "p99": 12, "p95": 13, @@ -60,7 +84,7 @@ class TestJsonExporter: "std": 10, }, "inter_token_latency": { - "unit": "ns", + "unit": "ms", "avg": 21, "p99": 22, "p95": 23, @@ -124,7 +148,7 @@ class TestJsonExporter: "avg": "7" }, "request_latency": { - "unit": "ns", + "unit": "ms", "avg": 1, "p99": 2, "p95": 3, @@ -137,7 +161,7 @@ class TestJsonExporter: "std": 0 }, "time_to_first_token": { - "unit": "ns", + "unit": "ms", "avg": 11, "p99": 12, "p95": 13, @@ -150,7 +174,7 @@ class TestJsonExporter: "std": 10 }, "inter_token_latency": { - "unit": "ns", + "unit": "ms", "avg": 21, "p99": 22, "p95": 23, @@ -242,22 +266,3 @@ class TestJsonExporter: } } """ - - def test_generate_json(self, monkeypatch) -> None: - cli_cmd = [ - "genai-perf", - "-m", - "gpt2_vllm", - "--backend", - "vllm", - "--streaming", - "--extra-inputs", - "max_tokens:256", - "--extra-inputs", - "ignore_eos:true", - ] - monkeypatch.setattr("sys.argv", cli_cmd) - args, _ = parser.parse_args() - extra_inputs = parser.get_extra_inputs_as_dict(args) - json_exporter = JsonExporter(self.stats, args, extra_inputs) - assert json_exporter._stats_and_args == json.loads(self.expected_json_output) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py old mode 100755 new mode 100644 index ae7f34b00..fc4e255bf --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # # Redistribution and use in source and binary forms, with or without @@ -86,38 +84,6 @@ def write(self: Any, content: str) -> int: return written_data - def test_csv_output(self, mock_read_write: pytest.MonkeyPatch) -> None: - """ - Collect LLM metrics from profile export data and confirm correct values are - printed in csv. - """ - - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("triton_profile_export.json"), - tokenizer=tokenizer, - ) - stat = pd.get_statistics(infer_mode="concurrency", load_level="10") - - expected_content = [ - "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", - "Time To First Token (ns),2,2,2,2,2,2,2,2,2\r\n", - "Inter Token Latency (ns),2,1,2,2,2,2,2,2,1\r\n", - "Request Latency (ns),8,7,9,9,9,9,8,8,8\r\n", - "Num Output Token,4,3,6,6,6,6,5,4,4\r\n", - "Num Input Token,4,3,4,4,4,4,4,4,3\r\n", - "\r\n", - "Metric,Value\r\n", - "Output Token Throughput (per sec),900000000.00\r\n", - "Request Throughput (per sec),200000000.00\r\n", - ] - - stat.export_to_csv("profile_export.csv") - - returned_data = mock_read_write - - assert returned_data == expected_content - def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: """Collect LLM metrics from profile export data and check values. @@ -152,8 +118,10 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N ) # experiment 1 metrics & statistics - stat = pd.get_statistics(infer_mode="concurrency", load_level="10") - metrics = stat.metrics + stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict + assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [2, 2] @@ -167,50 +135,51 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N # Disable Pylance warnings for dynamically set attributes due to Statistics # not having strict attributes listed. - assert stat.avg_time_to_first_token == 2 # type: ignore - assert stat.avg_inter_token_latency == 1.5 # type: ignore - assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["avg"] == 2 # type: ignore + assert stat["inter_token_latency"]["avg"] == 1.5 # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat.avg_num_output_token == 4.5 # type: ignore - assert stat.avg_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["avg"] == 4.5 # type: ignore + assert stat["num_input_token"]["avg"] == 3.5 # type: ignore - assert stat.p50_time_to_first_token == 2 # type: ignore - assert stat.p50_inter_token_latency == 1.5 # type: ignore - assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["p50"] == 2 # type: ignore + assert stat["inter_token_latency"]["p50"] == 1.5 # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat.p50_num_output_token == 4.5 # type: ignore - assert stat.p50_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["p50"] == 4.5 # type: ignore + assert stat["num_input_token"]["p50"] == 3.5 # type: ignore - assert stat.min_time_to_first_token == 2 # type: ignore - assert stat.min_inter_token_latency == 1 # type: ignore + assert stat["time_to_first_token"]["min"] == 2 # type: ignore + assert stat["inter_token_latency"]["min"] == 1 # type: ignore min_ottpr = 3 / ns_to_sec(7) - assert stat.min_output_token_throughput_per_request == pytest.approx(min_ottpr) # type: ignore - assert stat.min_num_output_token == 3 # type: ignore - assert stat.min_num_input_token == 3 # type: ignore + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["num_output_token"]["min"] == 3 # type: ignore + assert stat["num_input_token"]["min"] == 3 # type: ignore - assert stat.max_time_to_first_token == 2 # type: ignore - assert stat.max_inter_token_latency == 2 # type: ignore + assert stat["time_to_first_token"]["max"] == 2 # type: ignore + assert stat["inter_token_latency"]["max"] == 2 # type: ignore max_ottpr = 6 / ns_to_sec(9) - assert stat.max_output_token_throughput_per_request == pytest.approx(max_ottpr) # type: ignore - assert stat.max_num_output_token == 6 # type: ignore - assert stat.max_num_input_token == 4 # type: ignore + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["num_output_token"]["max"] == 6 # type: ignore + assert stat["num_input_token"]["max"] == 4 # type: ignore - assert stat.std_time_to_first_token == np.std([2, 2]) # type: ignore - assert stat.std_inter_token_latency == np.std([2, 1]) # type: ignore - assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["std"] == np.std([2, 2]) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([2, 1]) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat.std_num_output_token == np.std([3, 6]) # type: ignore - assert stat.std_num_input_token == np.std([3, 4]) # type: ignore + assert stat["num_output_token"]["std"] == np.std([3, 6]) # type: ignore + assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore oott = 9 / ns_to_sec(10) - assert stat.avg_output_token_throughput == pytest.approx(oott) # type: ignore + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore # experiment 2 statistics - stat = pd.get_statistics(infer_mode="request_rate", load_level="2.0") - metrics = stat.metrics + stat_obj = pd.get_statistics(infer_mode="request_rate", load_level="2.0") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [2, 3] @@ -222,46 +191,46 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.num_output_tokens == [4, 6] assert metrics.num_input_tokens == [3, 4] - assert stat.avg_time_to_first_token == 2.5 # type: ignore - assert stat.avg_inter_token_latency == 2.5 # type: ignore - assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["avg"] == pytest.approx(2.5) # type: ignore + assert stat["inter_token_latency"]["avg"] == pytest.approx(2.5) # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat.avg_num_output_token == 5 # type: ignore - assert stat.avg_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["avg"] == 5 # type: ignore + assert stat["num_input_token"]["avg"] == 3.5 # type: ignore - assert stat.p50_time_to_first_token == 2.5 # type: ignore - assert stat.p50_inter_token_latency == 2.5 # type: ignore - assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["p50"] == pytest.approx(2.5) # type: ignore + assert stat["inter_token_latency"]["p50"] == pytest.approx(2.5) # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat.p50_num_output_token == 5 # type: ignore - assert stat.p50_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["p50"] == 5 # type: ignore + assert stat["num_input_token"]["p50"] == 3.5 # type: ignore - assert stat.min_time_to_first_token == 2 # type: ignore - assert stat.min_inter_token_latency == 1 # type: ignore + assert stat["time_to_first_token"]["min"] == pytest.approx(2) # type: ignore + assert stat["inter_token_latency"]["min"] == pytest.approx(1) # type: ignore min_ottpr = 4 / ns_to_sec(13) - assert stat.min_output_token_throughput_per_request == pytest.approx(min_ottpr) # type: ignore - assert stat.min_num_output_token == 4 # type: ignore - assert stat.min_num_input_token == 3 # type: ignore + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["num_output_token"]["min"] == 4 # type: ignore + assert stat["num_input_token"]["min"] == 3 # type: ignore - assert stat.max_time_to_first_token == 3 # type: ignore - assert stat.max_inter_token_latency == 4 # type: ignore + assert stat["time_to_first_token"]["max"] == pytest.approx(3) # type: ignore + assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore max_ottpr = 6 / ns_to_sec(8) - assert stat.max_output_token_throughput_per_request == pytest.approx(max_ottpr) # type: ignore - assert stat.max_num_output_token == 6 # type: ignore - assert stat.max_num_input_token == 4 # type: ignore + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["num_output_token"]["max"] == 6 # type: ignore + assert stat["num_input_token"]["max"] == 4 # type: ignore - assert stat.std_time_to_first_token == np.std([2, 3]) # type: ignore - assert stat.std_inter_token_latency == np.std([4, 1]) # type: ignore - assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["std"] == np.std([2, 3]) * (1) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([4, 1]) * (1) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat.std_num_output_token == np.std([4, 6]) # type: ignore - assert stat.std_num_input_token == np.std([3, 4]) # type: ignore + assert stat["num_output_token"]["std"] == np.std([4, 6]) # type: ignore + assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore oott = 2 / ns_to_sec(3) - assert stat.avg_output_token_throughput == pytest.approx(oott) # type: ignore + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore # check non-existing profile data with pytest.raises(KeyError): @@ -293,8 +262,9 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N ) # experiment 1 statistics - stat = pd.get_statistics(infer_mode="concurrency", load_level="10") - metrics = stat.metrics + stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict assert isinstance(metrics, LLMMetrics) assert metrics.time_to_first_tokens == [4, 5] @@ -306,46 +276,46 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.num_output_tokens == [3, 6] assert metrics.num_input_tokens == [3, 4] - assert stat.avg_time_to_first_token == 4.5 # type: ignore - assert stat.avg_inter_token_latency == 3 # type: ignore - assert stat.avg_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["avg"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["avg"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat.avg_num_output_token == 4.5 # type: ignore - assert stat.avg_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["avg"] == 4.5 # type: ignore + assert stat["num_input_token"]["avg"] == 3.5 # type: ignore - assert stat.p50_time_to_first_token == 4.5 # type: ignore - assert stat.p50_inter_token_latency == 3 # type: ignore - assert stat.p50_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["p50"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["p50"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat.p50_num_output_token == 4.5 # type: ignore - assert stat.p50_num_input_token == 3.5 # type: ignore + assert stat["num_output_token"]["p50"] == 4.5 # type: ignore + assert stat["num_input_token"]["p50"] == 3.5 # type: ignore - assert stat.min_time_to_first_token == 4 # type: ignore - assert stat.min_inter_token_latency == 2 # type: ignore + assert stat["time_to_first_token"]["min"] == pytest.approx(4) # type: ignore + assert stat["inter_token_latency"]["min"] == pytest.approx(2) # type: ignore min_ottpr = 3 / ns_to_sec(11) - assert stat.min_output_token_throughput_per_request == pytest.approx(min_ottpr) # type: ignore - assert stat.min_num_output_token == 3 # type: ignore - assert stat.min_num_input_token == 3 # type: ignore + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["num_output_token"]["min"] == 3 # type: ignore + assert stat["num_input_token"]["min"] == 3 # type: ignore - assert stat.max_time_to_first_token == 5 # type: ignore - assert stat.max_inter_token_latency == 4 # type: ignore + assert stat["time_to_first_token"]["max"] == pytest.approx(5) # type: ignore + assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore max_ottpr = 6 / ns_to_sec(13) - assert stat.max_output_token_throughput_per_request == pytest.approx(max_ottpr) # type: ignore - assert stat.max_num_output_token == 6 # type: ignore - assert stat.max_num_input_token == 4 # type: ignore + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["num_output_token"]["max"] == 6 # type: ignore + assert stat["num_input_token"]["max"] == 4 # type: ignore - assert stat.std_time_to_first_token == np.std([4, 5]) # type: ignore - assert stat.std_inter_token_latency == np.std([4, 2]) # type: ignore - assert stat.std_output_token_throughput_per_request == pytest.approx( # type: ignore + assert stat["time_to_first_token"]["std"] == np.std([4, 5]) * (1) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([4, 2]) * (1) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat.std_num_output_token == np.std([3, 6]) # type: ignore - assert stat.std_num_input_token == np.std([3, 4]) # type: ignore + assert stat["num_output_token"]["std"] == np.std([3, 6]) # type: ignore + assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore oott = 9 / ns_to_sec(14) - assert stat.avg_output_token_throughput == pytest.approx(oott) # type: ignore + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore # check non-existing profile data with pytest.raises(KeyError): From 379736e0d42135a7cd93a13aad265d8bfb1da15b Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:05:44 -0700 Subject: [PATCH 11/55] Allow multiple prompts to be supplied via --input-file to GenAI-Perf (#702) --- src/c++/perf_analyzer/genai-perf/README.md | 4 +- .../genai_perf/llm_inputs/llm_inputs.py | 47 +++++++++++++++---- .../genai-perf/genai_perf/parser.py | 4 +- .../genai-perf/tests/test_llm_inputs.py | 31 ++++++++++++ 4 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 4429de5f9..8adb75211 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -407,7 +407,9 @@ The HuggingFace dataset to use for prompts. ##### `--input-file ` -The input file containing the single prompt to use for profiling. +The input file containing the prompts to use for profiling. +Each line should be a JSON object with a 'text_input' field in JSONL format. +Example: {\"text_input\": \"Your prompt here\"}" ##### `--num-prompts ` diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index 98792df4c..3613e5645 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -332,24 +332,55 @@ def _add_rows_to_generic_json( @classmethod def _get_input_dataset_from_file(cls, input_filename: Path) -> Dict: + """ + Reads the input prompts from a JSONL file and converts them into the required dataset format. + + Parameters + ---------- + input_filename : Path + The path to the input file containing the prompts in JSONL format. + + Returns + ------- + Dict + The dataset in the required format with the prompts read from the file. + """ cls.verify_file(input_filename) - input_file_prompt = cls._get_prompt_from_input_file(input_filename) + input_file_prompts = cls._get_prompts_from_input_file(input_filename) dataset_json: Dict[str, Any] = {} dataset_json["features"] = [{"name": "text_input"}] - dataset_json["rows"] = [] - dataset_json["rows"].append({"row": {"text_input": input_file_prompt}}) + dataset_json["rows"] = [ + {"row": {"text_input": prompt}} for prompt in input_file_prompts + ] return dataset_json + @classmethod + def _get_prompts_from_input_file(cls, input_filename: Path) -> List[str]: + """ + Reads the input prompts from a JSONL file and returns a list of prompts. + + Parameters + ---------- + input_filename : Path + The path to the input file containing the prompts in JSONL format. + + Returns + ------- + List[str] + A list of prompts read from the file. + """ + prompts = [] + with open(input_filename, mode="r", newline=None) as file: + for line in file: + if line.strip(): + prompts.append(json.loads(line).get("text_input", "").strip()) + return prompts + @classmethod def verify_file(cls, input_filename: Path) -> None: if not input_filename.exists(): raise FileNotFoundError(f"The file '{input_filename}' does not exist.") - @classmethod - def _get_prompt_from_input_file(cls, input_filename: Path) -> str: - with open(input_filename, mode="r", newline=None) as file: - return file.read() - @classmethod def _convert_generic_json_to_output_format( cls, diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 29481332f..24f98b426 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -247,7 +247,9 @@ def _add_input_args(parser): type=argparse.FileType("r"), default=None, required=False, - help="The input file containing the single prompt to use for profiling.", + help="The input file containing the prompts to use for profiling. " + "Each line should be a JSON object with a 'text_input' field in JSONL format. " + 'Example: {"text_input": "Your prompt here"}', ) input_group.add_argument( diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py index 4486ba3d9..c6351918e 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py @@ -17,6 +17,7 @@ import random import statistics from pathlib import Path +from unittest.mock import mock_open, patch import pytest import responses @@ -656,6 +657,36 @@ def test_get_input_file_without_file_existing(self): with pytest.raises(FileNotFoundError): LlmInputs._get_input_dataset_from_file(Path("prompt.txt")) + @patch("pathlib.Path.exists", return_value=True) + @patch( + "builtins.open", + new_callable=mock_open, + read_data='{"text_input": "single prompt"}\n', + ) + def test_get_input_file_with_single_prompt(self, mock_file, mock_exists): + expected_prompts = ["single prompt"] + dataset = LlmInputs._get_input_dataset_from_file(Path("prompt.txt")) + + assert dataset is not None + assert len(dataset["rows"]) == len(expected_prompts) + for i, prompt in enumerate(expected_prompts): + assert dataset["rows"][i]["row"]["text_input"] == prompt + + @patch("pathlib.Path.exists", return_value=True) + @patch( + "builtins.open", + new_callable=mock_open, + read_data='{"text_input": "prompt1"}\n{"text_input": "prompt2"}\n{"text_input": "prompt3"}\n', + ) + def test_get_input_file_with_multiple_prompts(self, mock_file, mock_exists): + expected_prompts = ["prompt1", "prompt2", "prompt3"] + dataset = LlmInputs._get_input_dataset_from_file(Path("prompt.txt")) + + assert dataset is not None + assert len(dataset["rows"]) == len(expected_prompts) + for i, prompt in enumerate(expected_prompts): + assert dataset["rows"][i]["row"]["text_input"] == prompt + @pytest.mark.parametrize( "seed, model_name_list, index,model_selection_strategy,expected_model", [ From 75b6654732409ee9f7611b5db5538e1f89957f99 Mon Sep 17 00:00:00 2001 From: Izzy Putterman Date: Thu, 13 Jun 2024 16:32:08 -0700 Subject: [PATCH 12/55] Calculate total output token from full text (#698) * Calculate total tokens for full * Small refactor and add tests * Address feedback * check total token count equals sum of output token counts * Address feedback --------- Co-authored-by: Hyunjae Woo --- .../genai-perf/genai_perf/llm_metrics.py | 42 +++++++----- .../genai-perf/tests/test_llm_metrics.py | 65 +++++++++++++++++++ 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py index f3e2bfeee..73ae3f540 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py @@ -438,8 +438,9 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: num_input_tokens.append(input_token_count) # output token throughput per request - output_token_counts = self._get_output_token_counts(res_outputs) - total_output_token = sum(output_token_counts) + output_token_counts, total_output_token = self._get_output_token_counts( + res_outputs + ) output_token_throughputs_per_request.append( total_output_token / req_latency_s ) @@ -545,37 +546,42 @@ def _get_openai_input_text(self, req_inputs: dict) -> str: "Failed to parse OpenAI request input in profile export file." ) - def _get_output_token_counts(self, res_outputs: dict) -> List[int]: - """Deserialize the response output and return tokenized outputs.""" + def _get_output_token_counts( + self, res_outputs: List[Dict] + ) -> Tuple[List[int], int]: + """Return response-level token counts and total token count.""" if self._service_kind == "triton": - output_tokens = self._get_triton_output_tokens(res_outputs) + output_texts = self._get_triton_output_tokens(res_outputs) elif self._service_kind == "openai": - output_tokens = self._get_openai_output_tokens(res_outputs) + output_texts = self._get_openai_output_tokens(res_outputs) else: raise ValueError(f"Unknown service kind: '{self._service_kind}'.") - return list(map(len, output_tokens)) + full_text_token_count = len(self._tokenizer.encode("".join(output_texts))) + + output_tokens = self._get_response_output_tokens(output_texts) + output_token_counts = list(map(len, output_tokens)) + return output_token_counts, full_text_token_count - def _get_triton_output_tokens(self, res_outputs: dict) -> List[List[int]]: - """Return a list of Triton response output tokens.""" - output_texts = [r["text_output"] for r in res_outputs] - return self._run_tokenizer(output_texts) + def _get_triton_output_tokens(self, res_outputs: List[Dict]) -> List[str]: + """Return a list of Triton response texts.""" + return [r["text_output"] for r in res_outputs] - def _get_openai_output_tokens(self, res_outputs: dict) -> List[List[int]]: - """Return a list of OpenAI response output tokens.""" + def _get_openai_output_tokens(self, res_outputs: List[Dict]) -> List[str]: + """Return a list of OpenAI response texts.""" output_texts = [] for output in res_outputs: text = self._extract_openai_text_output(output["response"]) output_texts.append(text) - return self._run_tokenizer(output_texts) + return output_texts - def _run_tokenizer(self, output_texts: List[str]) -> List[List[int]]: - # exclamation mark trick forces the llama tokenization to consistently + def _get_response_output_tokens(self, output_texts: List[str]) -> List[List[int]]: + """Return a list of response output tokens.""" + # Exclamation mark trick forces the llama tokenization to consistently # start each output with a specific token which allows us to safely skip # the first token of every tokenized output and get only the ones that # are returned by the model - output_texts = ["!" + txt for txt in output_texts] - encodings = self._tokenizer(output_texts) + encodings = self._tokenizer(["!" + txt for txt in output_texts]) return [out[1:] for out in encodings.data["input_ids"]] def _extract_openai_text_output(self, response: str) -> str: diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index fc4e255bf..cf1ed4fe5 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -348,6 +348,71 @@ def test_merged_sse_response(self, mock_read_write: pytest.MonkeyPatch) -> None: pd._preprocess_response(res_timestamps, res_outputs) assert res_outputs[1]["response"] == expected_response + def test_openai_output_token_counts( + self, mock_read_write: pytest.MonkeyPatch + ) -> None: + output_texts = [ + "Ad", + "idas", + " Orig", + "inals", + " are", + " now", + " available", + " in", + " more", + " than", + ] + res_outputs = [] + for text in output_texts: + response = f'data: {{"choices":[{{"delta":{{"content":"{text}"}}}}],"object":"chat.completion.chunk"}}\n\n' + res_outputs.append({"response": response}) + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("openai_profile_export.json"), + tokenizer=tokenizer, + ) + + output_token_counts, total_output_token = pd._get_output_token_counts( + res_outputs + ) + assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 + assert total_output_token == 9 + assert total_output_token != sum(output_token_counts) + + def test_triton_output_token_counts( + self, mock_read_write: pytest.MonkeyPatch + ) -> None: + output_texts = [ + "Ad", + "idas", + " Orig", + "inals", + " are", + " now", + " available", + " in", + " more", + " than", + ] + res_outputs = [] + for text in output_texts: + res_outputs.append({"text_output": text}) + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("triton_profile_export.json"), + tokenizer=tokenizer, + ) + + output_token_counts, total_output_token = pd._get_output_token_counts( + res_outputs + ) + assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 + assert total_output_token == 9 + assert total_output_token != sum(output_token_counts) + def test_llm_metrics_get_base_name(self) -> None: """Test get_base_name method in LLMMetrics class.""" # initialize with dummy values From 259c37e0e351f18e3d698c55dccad0aa748d35ff Mon Sep 17 00:00:00 2001 From: Timothy Gerdes <50968584+tgerdesnv@users.noreply.github.com> Date: Fri, 14 Jun 2024 11:31:36 -0500 Subject: [PATCH 13/55] Fix tutorials (#704) * test changes * fix tutorial --- .../perf_analyzer/genai-perf/docs/tutorial.md | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index 0b0446492..7914d19e0 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -26,9 +26,15 @@ OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --> -# Tutorial +# Tutorials -## Measuring Throughput and Latency of GPT2 using Triton + TensorRT-LLM +- [Profile GPT2 running on Triton + TensorRT-LLM](#tensorrt-llm) +- [Profile GPT2 running on Triton + vLLM](#triton-vllm) +- [Profile GPT2 running on OpenAI API-Compatible Server](#openai) + +--- + +## Profile GPT2 running on Triton + TensorRT-LLM ### Running GPT2 on Triton Inference Server using TensorRT-LLM @@ -46,14 +52,7 @@ docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ul 2. Install Triton CLI (~5 min): ```bash -pip install \ - --extra-index-url https://pypi.nvidia.com \ - -U \ - psutil \ - "pynvml>=11.5.0" \ - torch==2.1.2 \ - tensorrt_llm==0.8.0 \ - "git+https://github.com/triton-inference-server/triton_cli@0.0.6" +pip install "git+https://github.com/triton-inference-server/triton_cli@0.0.8" ``` 3. Download model: @@ -87,7 +86,6 @@ genai-perf \ -m gpt2 \ --service-kind triton \ --backend tensorrtllm \ - --prompt-source synthetic \ --num-prompts 100 \ --random-seed 123 \ --synthetic-input-tokens-mean 200 \ @@ -120,7 +118,7 @@ Output token throughput (per sec): 460.42 Request throughput (per sec): 4.44 ``` -## Measuring Throughput and Latency of GPT2 using Triton + vLLM +## Profile GPT2 running on Triton + vLLM ### Running GPT2 on Triton Inference Server using vLLM @@ -138,7 +136,7 @@ docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ul 2. Install Triton CLI (~5 min): ```bash -pip install "git+https://github.com/triton-inference-server/triton_cli@0.0.6" +pip install "git+https://github.com/triton-inference-server/triton_cli@0.0.8" ``` 3. Download model: @@ -172,7 +170,6 @@ genai-perf \ -m gpt2 \ --service-kind triton \ --backend vllm \ - --prompt-source synthetic \ --num-prompts 100 \ --random-seed 123 \ --synthetic-input-tokens-mean 200 \ @@ -205,7 +202,7 @@ Output token throughput (per sec): 290.24 Request throughput (per sec): 2.57 ``` -## Measuring Throughput and Latency of GPT2 using OpenAI API-Compatible Server +## Profile GPT2 running on OpenAI API-Compatible Server ### OpenAI Chat Completions API @@ -240,7 +237,6 @@ genai-perf \ --service-kind openai \ --endpoint v1/chat/completions \ --endpoint-type chat \ - --prompt-source synthetic \ --num-prompts 100 \ --random-seed 123 \ --synthetic-input-tokens-mean 200 \ @@ -305,7 +301,6 @@ genai-perf \ --service-kind openai \ --endpoint v1/completions \ --endpoint-type completions \ - --prompt-source synthetic \ --num-prompts 100 \ --random-seed 123 \ --synthetic-input-tokens-mean 200 \ From 827025e345fd0892e78fe3a3ebe3e679092586b7 Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Tue, 28 May 2024 17:08:53 -0700 Subject: [PATCH 14/55] Added unit test to check if artifacts and plots directories are created --- .../genai-perf/tests/test_artifacts.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py new file mode 100644 index 000000000..6bf8247b5 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -0,0 +1,55 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import pytest +from argparse import Namespace +from genai_perf.constants import DEFAULT_ARTIFACT_DIR +from genai_perf.main import create_artifacts_dirs +from unittest.mock import patch +from pathlib import Path + +import pytest +from argparse import Namespace +from genai_perf.constants import DEFAULT_ARTIFACT_DIR +from genai_perf.main import create_artifacts_dirs + +@pytest.fixture +def mock_makedirs(mocker): + return mocker.patch('os.makedirs') + +def test_create_artifacts_dirs(mock_makedirs): + mock_args = Namespace(artifact_dir=Path(DEFAULT_ARTIFACT_DIR)) + create_artifacts_dirs(mock_args) + assert mock_makedirs.called_with(DEFAULT_ARTIFACT_DIR, exist_ok=True), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" + assert mock_makedirs.called_with(Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" + assert mock_makedirs.call_count == 2 + + + + + + + From 4f2fbe77fb729a49b11405c2be4a07183749c66d Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Tue, 28 May 2024 17:10:26 -0700 Subject: [PATCH 15/55] Fix pre-commit errors --- .../genai-perf/tests/test_artifacts.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index 6bf8247b5..79ede8b3a 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -24,32 +24,27 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import pytest from argparse import Namespace -from genai_perf.constants import DEFAULT_ARTIFACT_DIR -from genai_perf.main import create_artifacts_dirs -from unittest.mock import patch from pathlib import Path +from unittest.mock import patch import pytest -from argparse import Namespace from genai_perf.constants import DEFAULT_ARTIFACT_DIR from genai_perf.main import create_artifacts_dirs + @pytest.fixture def mock_makedirs(mocker): - return mocker.patch('os.makedirs') + return mocker.patch("os.makedirs") + def test_create_artifacts_dirs(mock_makedirs): mock_args = Namespace(artifact_dir=Path(DEFAULT_ARTIFACT_DIR)) create_artifacts_dirs(mock_args) - assert mock_makedirs.called_with(DEFAULT_ARTIFACT_DIR, exist_ok=True), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" - assert mock_makedirs.called_with(Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" + assert mock_makedirs.called_with( + DEFAULT_ARTIFACT_DIR, exist_ok=True + ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" + assert mock_makedirs.called_with( + Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True + ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" assert mock_makedirs.call_count == 2 - - - - - - - From dc112ea06e767e4802b69b7bfe0c72e069c2b786 Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Tue, 28 May 2024 17:19:00 -0700 Subject: [PATCH 16/55] Fix codeql warning --- src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index 79ede8b3a..2231646a7 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -26,7 +26,6 @@ from argparse import Namespace from pathlib import Path -from unittest.mock import patch import pytest from genai_perf.constants import DEFAULT_ARTIFACT_DIR From 4e78005e7e22f1f1f5e59c7032a6c265f29b4c67 Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Thu, 30 May 2024 11:27:57 -0700 Subject: [PATCH 17/55] Fix comments --- .../genai-perf/tests/test_artifacts.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index 2231646a7..e860d3438 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -26,24 +26,21 @@ from argparse import Namespace from pathlib import Path +from unittest.mock import patch import pytest from genai_perf.constants import DEFAULT_ARTIFACT_DIR from genai_perf.main import create_artifacts_dirs -@pytest.fixture -def mock_makedirs(mocker): - return mocker.patch("os.makedirs") - - -def test_create_artifacts_dirs(mock_makedirs): +def test_create_artifacts_dirs(mocker): + mock_makedirs = mocker.patch("os.makedirs") mock_args = Namespace(artifact_dir=Path(DEFAULT_ARTIFACT_DIR)) create_artifacts_dirs(mock_args) - assert mock_makedirs.called_with( - DEFAULT_ARTIFACT_DIR, exist_ok=True + mock_makedirs.assert_any_call( + Path(DEFAULT_ARTIFACT_DIR), exist_ok=True ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" - assert mock_makedirs.called_with( - Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True + mock_makedirs.assert_any_call( + Path(Path(DEFAULT_ARTIFACT_DIR)) / "plots", exist_ok=True ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" assert mock_makedirs.call_count == 2 From 16231f0c250969e97d566bb121b1b3f8930de305 Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Thu, 30 May 2024 11:45:05 -0700 Subject: [PATCH 18/55] Fix comments --- .../perf_analyzer/genai-perf/tests/test_artifacts.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index e860d3438..f44899a60 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -26,21 +26,24 @@ from argparse import Namespace from pathlib import Path -from unittest.mock import patch import pytest from genai_perf.constants import DEFAULT_ARTIFACT_DIR from genai_perf.main import create_artifacts_dirs -def test_create_artifacts_dirs(mocker): - mock_makedirs = mocker.patch("os.makedirs") +@pytest.fixture +def mock_makedirs(mocker): + return mocker.patch("os.makedirs") + + +def test_create_artifacts_dirs(mock_makedirs): mock_args = Namespace(artifact_dir=Path(DEFAULT_ARTIFACT_DIR)) create_artifacts_dirs(mock_args) mock_makedirs.assert_any_call( Path(DEFAULT_ARTIFACT_DIR), exist_ok=True ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" mock_makedirs.assert_any_call( - Path(Path(DEFAULT_ARTIFACT_DIR)) / "plots", exist_ok=True + Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" assert mock_makedirs.call_count == 2 From 1999ecda9a518be67e1710a0a4bf9f4be9376016 Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Mon, 10 Jun 2024 16:10:37 -0700 Subject: [PATCH 19/55] Minor changes to testcase --- .../genai-perf/tests/test_artifacts.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index f44899a60..6915ebdd5 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -37,13 +37,14 @@ def mock_makedirs(mocker): return mocker.patch("os.makedirs") -def test_create_artifacts_dirs(mock_makedirs): - mock_args = Namespace(artifact_dir=Path(DEFAULT_ARTIFACT_DIR)) +def test_create_artifacts_dirs_custom_path(mock_makedirs): + artifacts_dir_path = "/genai_perf_artifacts" + mock_args = Namespace(artifact_dir=Path(artifacts_dir_path)) create_artifacts_dirs(mock_args) mock_makedirs.assert_any_call( - Path(DEFAULT_ARTIFACT_DIR), exist_ok=True - ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR} and exist_ok=True" + Path(artifacts_dir_path), exist_ok=True + ), f"Expected os.makedirs to create artifacts directory inside {artifacts_dir_path} path." mock_makedirs.assert_any_call( - Path(DEFAULT_ARTIFACT_DIR) / "plots", exist_ok=True - ), f"Expected os.makedirs to be called with {DEFAULT_ARTIFACT_DIR}/plots and exist_ok=True" + Path(artifacts_dir_path) / "plots", exist_ok=True + ), f"Expected os.makedirs to create plots directory inside {artifacts_dir_path}/plots path." assert mock_makedirs.call_count == 2 From 94054dfd77a8945dd536de3459017cf542fbadfd Mon Sep 17 00:00:00 2001 From: Lakshmi Sriharshini Komali Date: Mon, 10 Jun 2024 16:14:55 -0700 Subject: [PATCH 20/55] Fix codeql warning --- src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index 6915ebdd5..56b1b38de 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -28,7 +28,6 @@ from pathlib import Path import pytest -from genai_perf.constants import DEFAULT_ARTIFACT_DIR from genai_perf.main import create_artifacts_dirs From 50fb4c913770524cfac5059325906d94644d583e Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Fri, 14 Jun 2024 13:41:56 -0700 Subject: [PATCH 21/55] Change num input/output token -> input/output sequence length (#701) Change num input tokens -> input sequence lengths Change num_output_token -> output_sequence_length change to ISL/OSL in other codebase fix pre-commit --- src/c++/perf_analyzer/genai-perf/README.md | 17 ++-- ...ce_lengths_to_output_sequence_lengths.jpeg | Bin 0 -> 45885 bytes ...n_of_input_tokens_to_generated_tokens.jpeg | Bin 46715 -> 0 bytes ...first_token_vs_input_sequence_lengths.jpeg | Bin 0 -> 48427 bytes ...first_token_vs_number_of_input_tokens.jpeg | Bin 48383 -> 0 bytes .../perf_analyzer/genai-perf/docs/compare.md | 24 ++--- .../perf_analyzer/genai-perf/docs/files.md | 18 ++-- .../perf_analyzer/genai-perf/docs/tutorial.md | 26 ++--- .../genai-perf/genai_perf/llm_metrics.py | 34 +++---- .../genai_perf/plots/plot_config_parser.py | 16 +-- .../genai-perf/tests/test_console_exporter.py | 8 +- .../genai-perf/tests/test_csv_exporter.py | 4 +- .../genai-perf/tests/test_json_exporter.py | 8 +- .../genai-perf/tests/test_llm_metrics.py | 92 +++++++++--------- .../genai-perf/tests/test_plot_configs.py | 16 +-- 15 files changed, 134 insertions(+), 129 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_sequence_lengths_to_output_sequence_lengths.jpeg delete mode 100644 src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_tokens_to_generated_tokens.jpeg create mode 100644 src/c++/perf_analyzer/genai-perf/docs/assets/time_to_first_token_vs_input_sequence_lengths.jpeg delete mode 100644 src/c++/perf_analyzer/genai-perf/docs/assets/time_to_first_token_vs_number_of_input_tokens.jpeg diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 8adb75211..24c1efe3b 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -191,8 +191,8 @@ Example output: │ Time to first token (ns) │ 13,266,974 │ 11,818,732 │ 18,351,779 │ 16,513,479 │ 13,741,986 │ 13,544,376 │ │ Inter token latency (ns) │ 2,069,766 │ 42,023 │ 15,307,799 │ 3,256,375 │ 3,020,580 │ 2,090,930 │ │ Request latency (ns) │ 223,532,625 │ 219,123,330 │ 241,004,192 │ 238,198,306 │ 229,676,183 │ 224,715,918 │ -│ Num output token │ 104 │ 100 │ 129 │ 128 │ 109 │ 105 │ -│ Num input token │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ +│ Output sequence length │ 104 │ 100 │ 129 │ 128 │ 109 │ 105 │ +│ Input sequence length │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ └──────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ Output token throughput (per sec): 460.42 Request throughput (per sec): 4.44 @@ -221,9 +221,9 @@ genai-perf \ This will generate a [set of default plots](docs/compare.md#example-plots) such as: - Time to first token (TTFT) analysis - Request latency analysis -- TTFT vs Number of input tokens +- TTFT vs Input sequence lengths - Inter token latencies vs Token positions -- Number of input tokens vs Number of output tokens +- Input sequence lengths vs Output sequence lengths ## Using `compare` Subcommand to Visualize Multiple Runs @@ -245,15 +245,15 @@ Executing the above command will perform the following actions under the 1. Generate a YAML configuration file (e.g. `config.yaml`) containing the metadata for each plot generated during the comparison process. 2. Automatically generate the [default set of plots](docs/compare.md#example-plots) -(e.g. TTFT vs. Number of Input Tokens) that compare the two profile runs. +(e.g. TTFT vs. Input Sequence Lengths) that compare the two profile runs. ``` compare ├── config.yaml -├── distribution_of_input_tokens_to_generated_tokens.jpeg +├── distribution_of_input_sequence_lengths_to_output_sequence_lengths.jpeg ├── request_latency.jpeg ├── time_to_first_token.jpeg -├── time_to_first_token_vs_number_of_input_tokens.jpeg +├── time_to_first_token_vs_input_sequence_lengths.jpeg ├── token-to-token_latency_vs_output_token_position.jpeg └── ... ``` @@ -333,7 +333,8 @@ the inference server. | Time to First Token | Time between when a request is sent and when its first response is received, one value per request in benchmark | Avg, min, max, p99, p90, p75 | | Inter Token Latency | Time between intermediate responses for a single request divided by the number of generated tokens of the latter response, one value per response per request in benchmark | Avg, min, max, p99, p90, p75 | | Request Latency | Time between when a request is sent and when its final response is received, one value per request in benchmark | Avg, min, max, p99, p90, p75 | -| Number of Output Tokens | Total number of output tokens of a request, one value per request in benchmark | Avg, min, max, p99, p90, p75 | +| Output Sequence Length | Total number of output tokens of a request, one value per request in benchmark | Avg, min, max, p99, p90, p75 | +| Input Sequence Length | Total number of input tokens of a request, one value per request in benchmark | Avg, min, max, p99, p90, p75 | | Output Token Throughput | Total number of output tokens from benchmark divided by benchmark duration | None–one value per benchmark | | Request Throughput | Number of final responses from benchmark divided by benchmark duration | None–one value per benchmark | diff --git a/src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_sequence_lengths_to_output_sequence_lengths.jpeg b/src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_sequence_lengths_to_output_sequence_lengths.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1f9b2cba6d5a1b31e2ee56628f4d0760c5dcf1d3 GIT binary patch literal 45885 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 H*WrHw?G+jp literal 0 HcmV?d00001 diff --git a/src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_tokens_to_generated_tokens.jpeg b/src/c++/perf_analyzer/genai-perf/docs/assets/distribution_of_input_tokens_to_generated_tokens.jpeg deleted file mode 100644 index e51f5f49f2f1abb165ffeca253894351071b247c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46715 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 diff --git a/src/c++/perf_analyzer/genai-perf/docs/assets/time_to_first_token_vs_input_sequence_lengths.jpeg b/src/c++/perf_analyzer/genai-perf/docs/assets/time_to_first_token_vs_input_sequence_lengths.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1b81ef5320fbe97c0c9df78a0d5b6ef6a53800d2 GIT binary patch literal 48427 zcmeFZ2UL^WwlEq+#R4kQl&VzeQUcN~bchj>Py|!x0SN-q0t5(B0)#5k z30Ne(U9sRTY`IC&)j-5F6XFGY~#PJiSPM$t}>g1_Yrzy@+oIZ2*%&Ak9 zRFr4Woj*@?{xrn}>I>(oN&WMGB02Ksn`FmMl3qN2=F}O|=)c(Z-vX#k9(i}{4cQSk zz)`9rWK>7?>j2DuvPyR3&jvVbWXDgCA3J&U$SKle*m=MaG7{G(PMtn>ob1SnC&iBmxh`>jWh-_dhunO@|) zo?L|E0!rL=b#wRlBaMWX@lSGpQvE@bq^<90$w?0-r~pTglEgg*IDYiRQ8ED8pAV_X zj$OENTv+o2^-U8t5vRz{kCJI<^K05i*>90+ncjZ9vfmG&AUi@5jEo8(57-Al-~J8v z|Ly(1tN|~q2ZMoPF?JDNkPf7|00osj{UU#x0qg#jeT|DEQ}ySQR!GBRkSST4AOVIPmIy~EH&G3X&=x49oYwjrtSll^2EnNIen_+ z6DhbRFyRRSPHqwOHzAs4$oz%0=;tj>*1SCM2v?10Dn`+Z*cA>-L||q}ybd?;eO%N; zg4Pox2uCQ85tvpNy<&3ag?NA2+?JItGyj*el!Sp| z4zHQ8Z^j~gQT*Y%tN!uU;Wg1~21?20oozS0a?m(c&%{Lit;N7xH)#wrn7y|E$22oo zR%keETkisQzUN6S*0q56vL|=K7je-rS22&%HgopNc5e3@`QY0g_`r=~rIe|9ZAq*G zyil2Kjb~O)vI+(-wgIn(H@;Nc@j_R0no8SaPov_~K~S+sV!eZ1G-I5(D`FF=!^bj( z$q!XxOQlxroG5{BfQxtIN8`@|>*;MRMISGN+*7NJxbc>A`sRsJncY^BChkQaCoE$G z*JYrDGyJrD`+y6eeLxnSG2fP}>(J{(`QvuQ0guPS9Nfer4fzZlQtg3d=wiJ&rp8Kk zJJ*%UxiT~FHH+GlVo(k?FT%U@$o-$6qy4RC*WWokF zKC`$+q=I!S>Kw(J#f3!%Pi5<

0sSY$+=<&<|N(s>lO&LpE0KqszyARR%yIr$$36 z+-C#}6_4Q}^rQ7Sl<+{HW=L+x7-4K)*~URvMp#_9@5F|jP|}tV{)=*_tB$mNn}18mX+)K}^lZkU zTAu_&Vp?e-w%*w?4(Exsl4+2?Iuvz}F*7P(t& zm8}Gd*rt+wK<5tmK47@laUX!cMR%RQYM{8@GBZ$g2?^R;>naa33l#xY$Bt+r{2w9r|rW+Tie}K#=r;c_pzOu&PJwsX>Hwe zG4~dAZeWM?Q^8ymVb>EJI>n!XS(=3@ITJ@C^3O8XdG@#1DM^N*mYNXWftTZ!K7~Xv z1b^kGw{}k9i{)&R@>`CWro{Sp)lc^0_gpk~Tv@rsm@EQK`Zk*T0-TD4>cjf&_tZir zo7%OTyk|9wIzDVOh_~^vCp(mu_!L)$+nRb?7Vu_QTpasS%+Fouhvq!yxkIU&8s4b+ zEL)g%j3GOL(qMi(7|qp2tFc-|FX29+%2FvJk?hRYDWSFxh*k8;9^caTdwiBtUEPTk zI8F4YM{OPW>O2@NxobTTAx~xd7iDdkVd?^cOP&LK(N~<)I@pRi%)NNcy9gA;#60>? zs-m!jam!&o_F5dVW7Kwt^{NM#Vh%!pSv?i@G2C83Bg9B=@@`flobmy&C~7IE&7ZRR zN%oV;GI4uH>t0^jRFi?-v4RLX)4BLUewxik8)cYKX&P`w`r~jns0!^s>0Dx{wrw}Q zQfBw|y^^NWa~jI`g3B31s+yh6-C}$Vq!G5c>Kyz&Z1%S#=Vk6_Oqg}+(KkTbI26=n zq^Ui6BMJBo2@I1(z>+R@{_$0;K{FAFLSf5dUDzB@ufj23 zt=FlT6AucLgmGOCA9YJiLFId4)neZFd*DqUwZw2hhk&nWHC;XsKEZ6h`QswZr?R(P z{m!2Y73k5>ySWO@9Qfb^s>i)7Ah>47#Bo+Z<)S|Xf8JzN77(d+6Oj$dkYt!f7b)SX z7=n8EJyDynNiW#6$4)P{1Y>}&RS`+($8r{rVMCVHqg*(bZ}18W*z6c*hEtV~(~em6 zpt3*@YulJK1nb^C7-#ABE>7^mjky@DoZJy9I()8c;D>cS%{ByI0fF{?qN{LeEyF0B z%+g@*TIEfeo>t420l+sa_@^2LQ6`*-3WZ0TrmjS*S*u{_CDBJ%;f zT;?vz*(L!{SKovshu!iFLRKymsu4{q!jZ2;t!c-DS1byxIzPbGy3#TSmw2}9l0JM< zQfpv|u0PvP{dF5>bRYBoI2?r-Ec#VeZ&gA+CO0ktDM9lQRs^V4vRz^QZO@p*@ZiM9Ky%?$ON`PiXS3G(shq$9obD6` z2>N3FGUEK8dv%Kk-+BWk%-!Od;3=>yG$IUPj>_UpQjZlFFTyeHMR&wuCd~L5O@}u2 z0eM@z%?_DMZ#S!mMX!9k-$`@Tf=bQQC%O2l%uZpEx(;^X^Lmt&t^_cc8Tv&bzcGC# z8?99CaTxLHR$#IiJ>H}P3j17NMxh)Kbw+1+=|0r(bvU{VC6&p_FZ!WCsmCg2 zeGY5hZRabtbIG1NWt`$DKk#TG^(Y6*Sr`0#~)vjlO{gHdby+ZptlR<(+_noKx#Z~LK zicin+Odz`InYsC)Ps4(ihWIYq1x`um&_bp50nM!YfM+t0*gON}ChNuTi zB{|$5+DhN4(_y+&2-geYUt3J9BADfscGOMHsd5D*tO{ciU3Gxe?9-qGZF{HrLOX~_ z!J>8nb{=x;o2Fr)kgI6l{0y&Yt5-7G-s~#_Z3~hkfJ-nY8y=FVbiTeyP)9Kagw5Qp zV`UlQb@KJ0>~HO+x9%-ZdXvao$7iNJj9~<;QVP_}yk|Zt?;+#GR-%#X%}&cObhS&0 znO`Coz4*-L#aGa4Q@-O|Cm(l$Ed%;o`~+mBGg8#zc&EcKp;;><85-}WY=R(Q)%Y6i z<&s^00-eEF^{ahA%}@q@z{b|b9w%|y8ZnPs)#ZrJMf#nJUjyn$*eEK(3kgyh4`}K) zJsA59ecyd$F;Hm2pg3b)IJ`i|umRc=8!4Lw!Yyt5vHa~Y5y(VXbSG@Qxs8xSZJMJV z#D~?PkPhVK#Yk@BLd0II-q;+Ru*(cdTxogWuTp2rnw5j#uH#+5JfVHu&iRf-acZ49a6H1@WnP# z46Vni+@EcO)h?3`qt%CX5+RK;u#<6)x>G5*E%P|b;E?udF)&MA!ce@KY)fi402uf1 zfB&pQ!Fz8|{AG*#s zUIZ^9qqaXiyAHaEwQ|OH!n#j1^kPJmKX*hbp{VN&W%Th_FWqa}m(X=Op?Z`hpzd8& z@a_AVBP!)HmxmqV(hbwgJUW~ZY*;KWo=fko;i^AJe+9$wJMYBxAZE3je%B_tx_b5F zBm1UO@x%^d``gUG{9+7`fn2mwiI@&XP&LtuQJlJF)0RxnthQIHleX%yM3vzH)3oHO zY}54nuXtF8Y%2`r6AjPqK%0|GszS^))n9~3rJ6HJWmlV0D8mB!fJS_26FQj_S#e#s zc57StI<(Fbtp8&7e0v^8vrHl9lVV`uD{}YgK}oYr`+6Qc4Gh-9i`lw|ZlQNMgE5aZ zi4b!PY!UeM>iU)PqdQTU{9@UecVqmF0V{^EnBska{UpR>AjF}#$1%-vEGxfCEQ(8# zYM4I)B-m2nEvg`UG4`xp{sR{LsD`3))1pqWsa=z&GoHj`dFh^ZAN@GrH)Jn zvx~YZL(MiewpmzVpCa;%tEF{^rs6**%Lw4isXaf{xvR>_@U7poO#;Aw>=N4Ip zdqoNS8W}$07A$+faQMp}b|b890;-Y;k13ya5`QDngdyMz+f!i+>K5z8^Eh4u`% z@;o`1LVs3oXz6{@p8kn?yG=U0Q0I0s3aOAoIl0@5(4?}|$xoUY$6SR0H*RwgD^O#)u{VY-<;G8e&y zZ$`4Q-4{P^xQfVoL{d@KP_fR#NnG6#LLNe)5V+n}sE z>zcD9#=R zwYjKlIw0}(E3U4Xkz%Plhfkw_C#(rG4q;Nm=Qt5y9N}TvNXsy&Vsp^MlY5LD=!$}` zXK7rFnLQ*Mm0M}m9W9J1eKy+pU&R&JuHZPlQcKrXq|OWBrOVCxSjpM?GYHj3#GB)96esuopvN ztB0`b3AVM{2%KYPCuXUN_HME1e)%}zl43fQ3mZiWs%4ffUFg+rdIOw0vv=`bmb#u2 z!7OE1y9-QvhGSc)qn1BBJdWuVEt{?A9cCuVHL5$RS{D8BWs-~K_oO+ChIy8u7d6|f z!t)(yibC#V+>Qga)7`guxmAkVE1578E@p~~V{tqMUBf`eLooP8K7m=ahu6OOck&?8slO`|oFP5hoMd?7cC_b%uh8Pe(X=Att-p{2+*)o?F_aOU6|cMiJ>@ z+mnk*oP?t$6lN3@KDq2>ESOP^@wf&)bOeNd!fglE=RVxVBgZr4EVJ&WX(+4wJMBA zN(dpACZOYt(JfE~MaB2M=6z>dr`{5G#G}qxJrj(kCc@gC@%wlHu zdAP!zKM2piukY z-}wArivO(!Ok_BA=;t2Pz60k!$XGBr(zkfKrmafJ@!S8iu6{cy ze<$|5Lwg88w7bru!_|NF8t2>tJC-0>jBKVSfQw z{%4Q=OA>Dm;4j|zj?(hmFD(A|C?BNO84ws**_$DeqQegwPNvJhQ2aWu$kHW4xhi_D zPM>9JRb~|iPTP%`{v*kB7u;LhvX-~gyeR*lX6zrS^H*7|QtKES-Y==fETXESGJ?5@ z-@uIrJl1#mF)OF+1EN%@O#gxU=>Nky{YR|l@4#=qpomBMPUG>8QX&d_taWc={Ua4; z%{Q(^(2$fZl6b){WL@55l)JYYvT^NU)prX#_;t|d?q`YjEt{$vLS4Jh4rG0IWbyXf z5q61#_gKEeo1G-bog?!N9XSB;{0<*_`M{|EG)h_V%Y%eeq}3SCW!TR;WZj>7<$#E= zTM~G$!C&n>Tm9(;ritHt?ug%?zWv+afA5I@NNK;?`yZlAd~)6~-3}flpx#-r8}Y;b z?Oss-ww?3;qQL(F9Uh{Tx36PJ@vVrO;^i`EC8YsAzKsnbSnYh+oA6Bj#Ph!Cz(PMP zdUWT1)6D-+>a~|oZnOJyw0RQU4O+u_lXO1IAG#L3{Krmj;9kl3Gc+>fl85u$KVhi< z+`Pt$UpX;lb7dlK#<@gx`i}ay&FAqG>@2`RfOu?nufag2 zX7f~6yQ#rb4Hs=?{`Q-yj+wb}CLwn=VBv`pmHDCuK$Xl;2lP}Hicz>(yfcI^7e+TI z18b4vA zyCTZOy|NfW6z}$XA=nUQy6jon92YbiQc1_Hxs6mq6jyX}=-3DO+g5tU1r_o|2}SC^@gTbagS1 z536M`X>t0aD$uuGqbc2CPPr-CN!ZLbdQpdvMa6(|@J!au+uCcWcGgkWIR3S3H>|2W zfV5DT^f;C6g}L6KAAxL$Z!a;s9>~Q|rzwXYDZhc~Gv)n>k{~M%u4F6gq&F_>TPP?&-_FfQA!5}(GN+wgJVCCMSVKo_VKTdDX@Ft zf8@KrTQB03czJP-+f(--m}$N?q_f>X;!@q5yZj487*Y`D%oizR;TW7d`pq+8`rHt5 z_g?V`wyjNEVBC2PJO8QWm}kLw_ZjLbQSPXxY3 zMn^<(4<#o9h!-x=yny8#Th-h9B42tClmbj|oNZ!}B^=uKyDPjk47TM6?Eh=Q|GNVJ z*azGO&Gc*!-q>j2KSe9&nhPI?SGbLiUk^X5GBgFg5%5+hTo zJu9eSzJztu7x{M<5qKnk3H=!W*orb6;igRa1S*hb7!h)HF|ZkePJ@`1TyZ7nr1AhR_KQPzV9pcW zDV7)v-U)VH*kU90c%tmWR*&_7oW=10x_AqoB zYJ9II91s{kBZ`<5m59?;O~o>&LVK*wgS`X1`BIGSX=CO#?)8^p&<1Q7={8at2_eNJ;&1M;X3$PYH*n@okc>)QLC3_A496vng-lzn(JZa~~ zsb~R?l7@e_<2>I|gsMhtL|*J^(vLV6iKK2xbW|@36@6pHS4Hmwy!g3mksw6>C>Zn-^o8t`4s{HIMeMXzjJB#>WxaP z&V4|BQpX=B%W1#<3MHgCsksl3U${|8AFce21a;Im_7mXrjlb|if4F@=Q$JC-JLT^z z^7n+=*2IRx{B!Hh{OFtvehWzZ3yyPhKewrhQtH}U=6lh5L~g=^lZMvf}EFL;8094TIXknbu{H!*uu^t(&wt}SVP)){QEadP^I$mR*F9j8^;ky2B`eXrnO1HO-$TdNQ5RX}=9B8po8u(e zw5EeY*F3fF;pit-O+FJ2XRquS(5e(Rgt_?^7z}Ch;<~zAKl3h`#qfV|pUmxONKwH| zC*vCnd{0r%F@B>%y+oKGPn9?gV&XvjZ}Ri&`HN<`rt}yZl!DlMr?~>0Rv=eLLC^2y zP&?T-Qvrd!7ddizp;kvV)f@O4^l zG(#3c{!&M{(dn;1Ot;?yk&LslOrTdxy}&a2JE~(fNRb!Js$B6QD;#+6`tgJP1^(!Y z7e8srOBxKFQve*l{EMjcNi#N;4Eq3sz`ymVXdfO?`3n?l*Q|iSWYo6K4m9}ZlmbS6 zJ1@>JJpo?JUbt=evuEVkWEJt{(v2hU9RUDJddkH-E8Rj`^(Q$VB_O9Tb0xO78wD1Y zh&8il5qFi~>Ut`e=xuLFrITBd_ZCit^)Tu$(VsF8vd~x4qb)D<8;AEeDJVL#!!zU7 z*j8FR6W~n-j-M0HWfZu*DzlNppHvo{Ry*6>%2VzbUp=dhZL9mD45o;tmez0r8m%|W zL{bBdPCQaBS&Y)ro2sKS4Pq;~ZD&9KqH6&0$-Ibns^o=2fx^(5_3XuPSMQ?ifo%Sd zLypFKVB>wjO8VYYImA03&c+?pz7rHNRd8u#O1jp|1^N;&h$I4;C9qniACPnP`K$#s zL#XNd;Ay;zg2HXftGCpq+7Ql2H)iw>cc!zSr(Emj-CEyYoFhxhmW;}uDO2YMU@5V!5oH^@zEw>`S?Oa5Mw&i=9)Zk zCDK{AG{WR%@v`i&zHL}?*8Ngb-cno(uX)oB0&*+72{;y^F%;t^5sWZ8;S|oAH2oz% zG!gv7uVm4mtQ&sSE^2V%@uUtR#pgndN9Rq53@Ap*ipb#;^I@tI(>u846WKzj60!PJ zv?zT+JzSs^a^aJB-)vq>o*xEU#<7lQf@zFE?nI{C=)t|u)h2MO>o4+V21HqH$qfs5 zMtSKmb2fECyh=takQ!jOM?4uB7E-hJRh()zL@u;#AK&0$p#xg_7NVEmK%&uj{nj(4 zk*xa(G(|ixTEF4eSv|wRdoFZRo|sWxRJSKAVc{_E`F`Wq*>!F z03herK&QcKms3`4SGvP&$`^)i9z9+YlO1M(9Z;p7)J+ax@a8dVS(QZ=bG@H6cc0x( zYESy8e5QXgF>wcXS|z7kwyqa7YhNa0Z^^Ax&KJiH5p;<@N5f)KR9sIN{5}uKRx6 zoZnI}^`3(lX3lV?kq=LII)y@xqv5*piOyP`kQGo$<8h7nmlk*(MGXQ6sVpd)J(6kp z(<_adM)$?=`+{_&m8V2Kkrp@Rvs+ab5Ih@*7=4Tyy0P02Q=5!#8MZ7>vlD<%IJ5EM z!h8mV8-0sowEA;qC75iUe=W!$iO*5h3>n*IH+Ai-$(-L0q#CFG+Eq7m1C*hP@>{kn_Z zwTcfd_2OlEtj$u*p^oW358^$bJx!CA-oRyg4riWMwZIsH;HLVqPC42}@!ZrA!4iaJ z#_o_T2NDjS!$YRUFWp0($e$l8_o9Xbo5v3yBkl5}4N04;>>&?jljG}}ZlYhaK8HaK zHFoEGxdyIXJ~NjxXSEC&Wymax+v(a=eEefi2|NIe$#mnoI~QQF51PRE{Qek(T6`P;G ztN6(uOBh;5bJ_Tns8yE#Zrvuh6(fj#ur5b+@1YPTBrB;B{0HJdO1hIrw+~P${d++m zKdwSpdYM&Ie3Rp6HmBKK5srIEsRMc{N%w!(NlXpE_)uSm`s6?Vn}yi^XCQ+60Je}0 z!os~nyn3x^6OsN;#0?rJ;_KRE;`ad=TU1pwESGkJ83GMHm2|4Z_{<_P>*n@4yg*tiQNnf0O#WJq(cp6& zLmau)X;7wd3!nSAMZpyv^9OX8VlWug^l}1gx8OrXD%R0)CnBacCTm>A+mTkCxPz#C z(l#q=rr6_}t=GBg*f5|2axVYe;otjr?(S_x+pkR6+MK?q8Z;}+Xx*nDnGSWnQNRZy zeT-7bsqsZA<9IV?iwk`94*n+TgcFcB0?Kw#r@j)QHC3h`7=+pfaLD%m8ZmY&-X0lc z9%@9Z?pfhQLfqg`iNnB_y`X_ZcI@_fA$nxn*SOt=15I3LFN=x-y%4pC7u^R?$w%xV z#eGy(ev85XN|jWH@vHlP*7m;wLcL2Vk!n2eL^dFrB>~;46*s^&eRrm_3+Wcme=Ea zjBggG2Ij6sFJ3iGkL&y}3o~T4kWL>Kb(+@3u{-F^8_M5!)tYgy)H@Na_r^%DepB$$ z@GM8l8$$_KeWHtw2+C5GLaoYgfFY!_!@i!A-2SC`2r{)Vf0MsERDzR}j;6j;4=2HM zL5g3;-Ob*7)j@aC*U=BAHq%j02wWqWMY?9t3*8X&PQLDMk5Ca3*q@sw}o%!QE@3T8BpZt&H5$IZBDlD>2^I zO>%>SER*m&rYd=>$;$xpQlpTj)II;+M+jvgj^E`~U|Zt`jV1^_uL`J4v=tpv~vbEitj*7L&CCYe^=wjwB=2Tc4BTb-208bg*|IxPk8E6ZyDsN;?2xYM7D?w% z&x#oTingU26-Mgv!&YC}zw1O!rJ;1d=0&_VUaQ?$h(GJ`!i=?bJbQnmsAtfb3p50* zEh=<0HX_kB(JQa}(-s)yU3f0b1@UIA{;JP~{;xVR1YdXmXVELxfFtYoei-Wb^frT| zQg!#(&(1N$djPE;k4gWMum4Q-uqAM2`xf|wN(Jf0%8;X6-bHB- z?5#ZIJhyK5Isug=3Qbk6SzfNE(a!q3Zdfd>5X3gX%abRNnTJ$!i^NQ#BE!sB)BE_r zh-|m1@;47$jEtLHbHg-Ewkd>QXPIhxvne|Tdj7tOzYXkv{9{Qo(C&P^aixTN%qx`hL=F0V+}4z8H2Gk#4&?&%h8*s8<=ltz0L zq^a-XLM$ht1*SL#A-|q=Rib&S%6biOwhcPt2cf`=@v54|^x$E;SmSd`NiQCrV2h_8 z)gLHdpm`ZS0Bn+w3{SUEXXcf}&pEt+f6<5)o7ISwd+4*}cJWPVBR|sFH8O=Btg(52!}qMZZYP0cw}cE3Vp$5+{wQ8_mUmDvT=;#*Iqz;d)|8Kqn{lrT>vSa^s^n=y@MoH z>B*=2<5V(K5hMIb9o7A5TS7HtKX3Yhoqy|2eM5Wh1B71ym;yfk#BtC$vg$sxBHp#A z4i28b`o^+3pMR~{9$nE~q!QOOtJyStW78^GNh3^;X-bkBddC}&37iIw=)RKE57i5D ziZ<#l_IkANGAy#Vki6@o(GpXDx1N0uLurz%SZ;u2Hm7qr1jc}ge1|7}iaL^k5bWRc z&sah<#^t23t< zl8h~7}%J?VU>Vh?yI zqmEzm=(@c4IfX>xAy5cZJmRWLjAN=sL70@N@fg46*zvV?x6ZFNE0kh}uDkudx9ZRT z=SB{fbnq}rumc*jw>z(sEHTlYkhiOe&k>UvNZidAjjJgM| z2Rxo3-8&zOCib5n;i(EwZTBanowK7%YnRh%S@U%XlLAU?Mjo=DuoHawHzS0Y1|Ho$ zut&gwj931vk%%NxIRcN=4$DWxLP4+1kZFmIs`B03E}Ww+OO9)(p~{CZAQd8bG)uHA z+Syaqrz=6a^L}GgW*g#FF_v);Th6)a@%lHLtIN*)&ehZ49~VD0=n!mDIXJK|UiJOh zK7h`yB%=A780mQV%|DWH;eyPKsza_1;fhJ+7KdG%18<}I*G9$->;@F)t#pF}>YwEn z!6;E-yi+l_JeZ-DY-TquT%%wJgTY|EqaIr{V8N_dw-(k6S4Tsv{Y|k_?Zu5zW=|Hl zq+4qB=)B?j2ULOs^e!ytYT)khO5mbYAxMvO)y?Ivb&2`FbrlM`>XDaL8U-bske~+< zFaGqE?lw{w{O}d=yE)tb8=~8HA1!9vv+Oj)m>W{egvqaf$9!9gnG4JY`HDiGMw~Oc zbiC{lPy6#SB)20_fxw&v4_@M3zjOK(l@xto#MP}4$acK)EQrIcjL3aSRncCx+@?CF zG;Qxrb}=0IpuY5IS@SbdKREK;-klIeuiK35FkP&7iiF^~7Fzl`g)bO}YQnr#Zz0)j zzbG1m0VUiLT7`Y2K$%wwiGY2?1SU#(efd;zw&hp73%s0@6-ez`|6ug`Rr<17NU(Ns zr82vuJCo7b`Ft$i2&`z-K7{C^NUCRg5-nkLSalwO;}N(WbV2$)?RS0%P8 zDog8g5hEea zq-D{aumguvOtt6cis!Lx7Ps1C@Mj%UdPgdq`E`Dx`}a?$tU?RMCl~2RNom200qwr% zrxjU(Fc;u9SE~f~C0f^(S7o#!I#x(Y(Pj}WIBq_{Ur4#zdaZPAwW+Yp7P)xVH23p9 zV8>YCev%BU#^YaSE0W)IaUbr~$%`#J9J0Q|*p=AEZrYG)hO2{q1e%7}@AL3~K+c~F z64lS0%PsK``Sd^MFPxe67JNHBj^1-&HB6*UeoB2phdi+N&2prCM_xV!*P(+*o2nqg zc4B1-6~?c+6x%rVcx^xI15PE%e=vVY&QtN*#s=VIKu&;|x4BJCMqFcIsTvmix>MP& z*2hC^bA`b=!PHFIv7zrL%;;O~kiZ5$g;}&fjT%!p%sMz%zkz$_Ztq#!H}Oy1I?oQ_?007+PC+F+05nN}t&>;qT?`0^W0)b<8h<5n%o z4K8O7+ID6utz$bAW2R&DX2DsMe_TX&7HLO~qUILHtwLZnSUCAJZFKD!o<)cYoMDE| z>Vx^&&Q~DExJyXxm+O#E-`LdyGwRn$_1h`8F=7EeX|BTJSlv$km$Q*&gTtq4bOcXY z0tUYYDJo|AszoJ zsyaVEnA+Cao!m%z82F31j?W&h&S(Bs((e1>5bKH9`@K_bf}XMO%9_bob!RVlT|`P= ziGe~59m4307!35W3*(+&*rai*RNGhmG`u6ZDEopUI|_+OvDTx))Inhc zUxza&LABs>>^aJ5trvTAHa3$j#isCIBeV+$&^HI5g(|*?vBtG#}wRn(HFEsX8t*kNM{l2GMz1FiE zAxdkVAeP4;JmJ&dL52CoYx4*i?3JBU1J5RE~uB=t&XG5yjh>j<+m}Y|) z5m+fnd*4?lx00#SETi_()W(L|vco(GVn~#)UU=~Km|Pdxl+ve!6!dWS=K;>X%cN5h zul~p-wh3E7fAz!!>FCZ>nbj4MwtBgE@)mEGys7!>G1*12H#?B~26-!-t61SYT#FaE zc#&^{yT56b<`_m&5Q{|R6VG=Lw1|%-B)EmCx!8#enBcfdmyWpUMmgk<1gdCf&zeIL z(JF$ggu-&AJOnYjGO(fZdGDRP$ z)aC;7Q80Sooy4;(W%03LD4S}o5IVv9fxO5uKKrJ}NY#52&%1@&H)kUB;Jrggv{z$% z0l~Q$*nx*k0p*gLpX8a$WP`Ywo;!QVuZKB|uIvM*@U}6f6N{G__9RJ_TuFM88#_(R zXV4ZhPMwWdRStJsq-AE%7_J$G;Q|@UsiY1Q5pS&Tt3$Gop=`M&eeCy6WEAeEkG7;~ zbwhjgbf`1UZMgz4;<%Y`MeXu>mncpGp))DCLP~VtNcb~D^qjuuTu~IHQLx3`w`Usz z0Js5s+n*Kp6b%G)@$eI)95Uw|nUDtXqI|r~m}?ol-bc3w(-2%GlBX^Tg|?haule#I ze*I1e@%rjyyVCc(m5!W_P((K=Z$(N79VU)fw4Ga(;K7z}$P77Ej7 zhsb|I-(?t0qEl5nk93gj@XhOadwBKq{zuiIW!FJwi)^aPp{IKO7KV#&>S-M_FODfT z(m4s@k-95F-s=6Vi*-V-aqpD*SBnzTUUq(?E$ty-``Ur`{j4hd+4;AX7yjbjfV13Q zTO#7~luZLC`c{Uj+MTPK1Mvm%@gC>3<*H*-@{Lty#fnq8QzYj<2IA4y?zI8-DZxXU z5jKFyQ4u1`{L?(BN7cBLgqr|iITucXSWY`NbATRBm6JbS z0S5?161B#pY=xwoN}VYbwhWzu2k_6hTB=}i@{N-W7hv6yIo5cK$c|7hj7F2p|2*sE zm)G8pSz?6OmFSv8(TwWz8GQa@RcN=Kdg!!Vf=z6Ch=9YY1nKhvn~w|aBJ!n{;Y8Yz zQ)W`0CK?`Y{fetSZWzQqV5m)%DVDA(2Da6F?5-l&%lZ}l&>%v+rjAGcribxrPS4U8 z*tat2lR{+~i#Bsthaek8>og;ln9FCDvUc>ngC(H5xw49MS05eL)P)h{ZNZxAP{pHZ zl3gkO0dbxojloFLw+%JpzC7Tt&$+c{h%kai(-iQ6b$__FY@TgDUPujruzV$|(J3|y zif{w>cXnxhr^aJ9O|)^cy#@v)z`0)OucFY^nCnvw?tHF&#NmQn@A8f=DEQ9y_0e=? zz6(GX=3MxMA~9a1Gsd{yg&;NR_nj4`xg=ahW=iYXppR=0lG+<@Cx_h>-*R94>i+)d z4(363V#)!_iitQ?*#ovCfD^RONt*LNlot|1bJUFZes@etDikI#2&=$~ja0 zK4q=I=i-5b@Xx%SaN_3CD2W(Pet?cFXdn9pT@2w@cK&s8N5UG!Jk(zCxM$SD`UML# zoB9GdH;!3fOkww>#jSI+3cp2;w;eZT{l{*wC}Ge_N$NIW)jzDn3zeT3KlV1%fF=V$@-JHGEv1Eu7Fq{1L5ey+3qzNebC@h@ zHF+g({cbi4Fe|IMBRf782qjRv{Ni_d&%m?&4$ga(T=~SW&f(}{G@xjP zn!>6DKdj`*Sjw!eRt9*;Q3j0IZr-G(2KsG3@8D< zuT{J|u;oIpc>0^P6LzJ?HUEC^e@nyvoxHdcmXW&!J97IW$T8-03{LTxUKXTQV~XBB z^!Z@u6?`p#{x3+3zstYOroTnIGz@VVd|nQW?-6tkOi%~M59EeVyh)BMfF8t_KWI~ z)r1jPnkTuSx~D)xUw!GSCx7a4Jo6QKTPPnNAHUIFib6-Z%yX2b^$*pfD@h~=#@&o>LW!>fQxzWejL2KhA>2=%d zbDhiY>R%a}U)p-UguKqxf36q#^56c_aX-}@#z^Q275y4^`tSAf-;6g{uk^c7%|mJo z;_rjh7wi#4KC%^~UXL__d^dUytiFjQGYnn3ItYB7+v5;1VF=b|x@opJ0rP1la1eX! zQd3!}R8~;1eVeL+YbEZorKC?p$z^MHeu9YE)6kVc>mCg)Al0UTD~f;xIHrP(9_6@J z*d`ufA<#mueU;YYOg1Cn(6b0xbutViGdpAI_+~2IJfL=bAC;xrz*CZoijfS|#8r;C z8RAM0!8UT$80(xe{YR5eU63$I74!#~J!^Zo6}RQI-Bb`ijFm-~D<)ZzO_d95B+g#w z)YzN;@TH}w%e|6KA1;}c9=EjFm8cPO$0~Y$$Pr(ADol5)*Vdjm!8#b2EEY!!faZ_6ruovE)SO9t6NCJ>+6M z?dMtVcO@^DBvVTjuF?di0QCa(3c9>VXo;>+*{e_zbU!64xo#4&)lRgr(=cmOjvq2d%}4u|V0QkYMS_C}Mjj zn{r7TunlNRnH=A~{KTjSu?0bd9?KK7jfg>^LAR;AV+*}43};KfFvmBAW4@1`+@iK# z)cE*)7YIzt6yc9=d&P>-pu=UoITklodvNBdMV)Fu$2DPs4%Z(|7;9ZCM?e3wI7pG1 z;hVePymiM3>pCP{2S2R3^`;{$qrgI8$%Md6$r-c{RDTNTWaUOfC!^BXr_-ME+}7DX ztH0#c7WXqoe=^9a(RhCYvlC2$_%Sd9eR;eu1wmgv{C`#%)G=gJ-rw6Ecw}+o4-pQxJcXD3ky+;8wj? zkP~~iMz0ey@B@#>HS6m(3aZ?gP29nDNV$Wlpm^zSyg|TT@=O!s+WQ?~qKJQ_PlR@->Na7&8b zwj#g~c=m_?CmD2BcIfB~py~l@&g*B}Wmgo7Wk?Z7)-G*2m?yx+keyOsM`dPs6yu6q^ z+~L9E6&ZQgK_NX(0hZUTdtS&i2oLg3*=Wimbf*6XZZ!lO?y%qY8lVsfr)Ab&g? zN;gp#J@XzP=i;vl@p}SW8v)whrx`p&vvbWk_crs#^B}FoAjOlhiO$Oyi-@u!70gJv zi@vYV;*_=#$Se5f(;Qm9ot@Tnbt)1J9+hBJmcg@jgsVCkk(X{Q^?N_}5XyRHnQn-Y zXwt3T2Ti7pj9spNz`*+_z}DH;UT)lqRFZ3HS>Hu&yKK}3#>em6xJegjPV znB|o2W`n!6OvIngwjJ1XRjP=^$ttto0HWP&VyBL6k~SC^4xTVad5E(#yXb1fEO+)b z8S>TNFs+v>0!u;Co3!}EwXsg<>Ex{+mr4d!o`((?I+lR6v^DNhhi|=2+xN zc%5Jgs9H!!eJL4i%>!^T!Q)~8`yB-@w;gW0e&t&D!j+ms$ck{jZ{MnpY}athZ=j-c z!`{5M6~oXoXRM=q4U?Co_33bo&qk{{pemg2<6sbE!s2?ozsLMITnm@eToB=krcveP z_U=P++#4<~1gAK4sYrHD9SdbHT>?@{K=8ryefMiC za~u{sRYr_rxe+>+RzWN6nbSz`GErBQqYI$IKMo-d@2WPE-6Kzt7S%f5Z|iYun*SKF zdO59d>!`sffgUn$rgzW%+$Kn}M6*~>l#2$@gEcD{KN#X*A5zY6_Jz+c3=F4Ghgs!M zkZY{f6me!;e6yu8l}MT{NYcgju1-`etZ8QF^dnb)f~DN z7{Zz3xP)B}WK`1esz!KM>ThZ{#GSQ0tI}^E{-(c=K`pRJzYpB%7@?RpWZr5A%{AZ@p}CFDXkoHu1=&P*;0mQC&9o;_#ae8;zW zQXT&7mk)d||Epq`&JU*!{;QZY{!1cRd&B1OnZMfZVYy#EQTO%MkL_+UC@_wH=A|$1 zr+ytDOE?;N*kD4}rg1M1cey~Jl}&0c^j?4hd|s8Y?y14I{PD!S^k$+HRwPMAgUV8K zN`Cqo%iXu2`@Yn$(+|IUWkP^!N3WmW4Z6380tJyR2m>h=BaETi2`mQAILp*}uLlfG zh%vt=6%M zifO0y@(Jc|DDMTXgz5}SlxjAqh|J%QP#{g9&fF(efa(Ri)VJdc zw1`Cqsd+PMekJtq+GbVZ@NC|~iv~e=Xym$$$24{2-m%E4BAmnd7YRzrV8)^8u!3}u z@$Hz8;Th_GS-1$toxCvC-D`PxX!h;hTe+Z2Pw-Sz*dwuPao_)Sm7OPdYYJn(`1QxR znr*DrSe+y-4l+K=f|MhzR{|*(!_Bd$0b)nlTO_js2p_9QTL9R?~ zudwpu@h)m*secoyH`>EASwC^I7O@LIT-^Bl^p{Wm;!KY_{o}$rQ736>{D+gj9KAB2 zr2}Vfkqr&bk+wfE{4n^p;9BX5xpi$WZ)&~IE^qqhyN+Y&vAYvuawXRhd7DCB)8S_R zLFQ}W-=E(9Ox?(n#g)>lto}v!|H&%ueW`_4d1+L}e*36hBsIVTJTWD=$9(c2e9oo+ ze5j^Uo&Om@c)y=&ThR+LYJe!b&H*gAW6odAoAHic%{6Jx0f)6Bp+M>JsOaB6CY=y$ zx=$BJnMu({Sz>%LJmD%v!d>bS8SQY*068PlqAGv0+HTtXqFB-A%)B&ZK5NT_KeU?c1a5) z04|RR(~m*JJ}0!+R7i%!^mIbAe)eC&O^q6tav)q$=9qjIe67W zEcq}?ynoaA>YfFOvC9Yqk*iHxZ=XrzW>z)iHhr?$ne?5T`TmAd(Yssjl(Dl9QXfu) z7q1uzbc&L64F#fWp7{6BBZeST!77mSrbL>5n$ihDH>%ZrD%XI8n0F(gss7fk2kv0U z`9Y-pjrZeIv9|h3hrJ(a!mid2y085H55{XIIqzftXS@9E;b&fLJo!p7`mZH(od^5J|N*9a=0WbyC4;We#AD>o1-d0zSA+KN+ zTTa8hxSN}b4d(B?MMaEg2vHBXhFr)<*Tikc3zxbRdjcanZ!Yb|Iq2+i;>w*Zt zY#J39(L_v7*U;NinXj6AACnh@1XEg*mBfDujx7D?+eXg{g-Eyp~0VGrFXU6Jk%c`Fb>QvK$ikfPPxWD7jJq%#IBW&o%uxMLLtF*S{Vv`(q%e8!TI$IN0lQHwPBK zqCc00M0n6^)Z>{UwO-4R&->Gy+$}`*#O-Y*)QdE49_*{e((cX2Q(_O%_dN$I+~}pV zN2XywSmQr;eX6@Oc;$iwAa4H?gYDUJbQ-~kW8=AL+#SHjs1shk(vw*E&&wtUZ~WgA zR3HXVMk0Lf`v(v&S@zO_m7c4m`r(e~kjZ+7Am*hEPzzEoHM)r{2JngDRK(j)lIabM99V2@ zlj&Q%c>kHYll|rJ?cC7{-^tL`FpPpZh41Y1>BNcQCW{yp>Vi#d>fiLn;I4`X8u()_vC5b^_#_a)?LwTIMjd)b-s_N zeK+ZVMEu91#m^Qh;~hF0i|eWA52UCMCfb7ep%Va(*2O5o;;wiPlQ7;snpHD=a%71& z>G@HoYst5kMRx0DH!#c9)hQ)*Mz7+fE4>pupo3hdtVIDRBbjO-;G01Ylc3%m=57 z`@n~_b`NW;Z314JSmrt6kcx$x+}%EQI^e)(@OlTBZacL_*xMe3(@k> z(J;21x&I+GuWDv{UBV=HG)%OPt<-jR1h3=uQnMEPhU(Z|_D2N^LU?4&_wXUA z)^RSDYV@%{7vXDYKg%R$v5I&=?py)_*=rF9be_T)1beAwD5qoaP551vT10s3CkD2T z!I|~#;hn$=g6BcPpnuu(ZJyShv5L18@pVj&`wGOSxrOfd{@EV?@Y@dKX-_#B>Wz5B zCk9O((w;Knhi`v7HEV@*t$_Nw!@eK$WG&9+a1PLt2iF%A>ZDB@b~ka6#NE4Q7APQA zgt01C`8cJ3LVmO1@MgVq5Pw{kw9d1eSFMT5T+G6EL+#SV0hU?DDfRM*HC!mwgYF6Y z|3GJt)NSL^);9z~41EYviDhSX+O}W_;JolnlI!NEI8BxlY_&KezM2@r)*%uvZ6&7H zC$L#aOZNdnzA2;k6+o+r7X`qyZJGG&cPO%8nRWYyshn#Eizffy3T}5;&^kZ?EgKORRf^79aAS z?LE>cd>@GWx$EVP6-WKnK4lxHV6e^DK<7?mMT+V-Z}J=6ij5%x$fszcrry&L{>n@~ zfOt+fdMne@Kt;iucwsOI!>IBWc`#0lPEcPirZw{cB*ybR3tmpFpiFHKp!rTQ!~!|t zAL4=-Wefs!IRIIZnY$x7!2`rCK99|`bQFh0;N2fGVVs6saQmAb*r#-uL0qHgqoMac zAx5`N`fPh0o0adO2d-CEX(&xA?3eU!Yh*tGy-G1%36 z2NUXwLiWqD%ZsCo1X9Y?FzR=0fHC2z3D%hnBF z1(}9f&?cI|EjZI&TUnpdq03zaz@q5;9wW+~opOIDN7QUqymu8VF)y#$YOdq)Cx%Pw zHaE`=9wk_(7HF6#Cby7gtw9`)xVTRY((3y{MbU=Q*;;}Xs`13(y-YrO9PgO4#~#e9 zRJhGkW4I(Hp&lN@E?K3jP)(svyE|gzE!^t%jVuzi^BElRlbtxAz~utB2o`S@WF*l5 zMX;&2oRG*K!0DBg#^%w3;E%{6@gZT~PS)Zk#ty84zLLviOoElnZvZ*}IVA&g$3hyh zUZOYo$G-9>DELQ}Mzrn4aAgedr0lteNQAk7FEkpvC3u%eI#fP7+u^OJ7;Ii9aRKcH zS%n1e9MDTc>~y_{iS=yp)@75Yk6~+ zaMK|Zn+R`TySo*NF{ZL|MI~qA*EFbXk$1@BA>XViGKQuQT{7~!41EWocfTFsSD4Ee4O z{O$y^BquQ?Oy`ZWB7Kutpu9NE4^rLDqMcQH_|7J$HwQvQpUZ?;Be77|00=_90CzrE zQTMj zlWFQ|ZXMMP!cPy+iNhLY+DByd+EsWuMB86?_vph%T8dN&c>~}!kzRF#(09h6r4v|> zaboJv>x1GYWa?swp>FDW?e}kUuHuf>N85%ADwAbC#9K<5}HY zx_;>-r53r&$;)8%b3ckJH53YBN*K$Ab}+uA;%2+_F_7W6v?41hoxZmym?@AVQzKUq zp*uI}oiI0+5bky^g~jTJqW)Fyq|=$Eb zJ3jh0X7ew5U|w3HL+BA!-N@H4*8m$ll)h`W!JZq@s9bpdXxNA0opMFTV&#!%+Lg8o zQ?KNSHIYo5OYH|!On~ZFaByY8D-#s!473MI^N&8J4<^;OQV3(2?s%rb4dJw?qHycW zAoF7qjn)0GhrP0sX;`#jF~v1`nRdi)=fq%WvbrHGb1_avT}@ZoSU}geVaz}v%VWM7 ztbIVBsDB1dAme=QP=yG-?XY}lXD^G|39By0gYudRdlnFrzh)LSGF7a;f)lCb084aU zgD0hm(48F=djf#?7na#VN%tQ?-t}DI8-Joo$*_2lE#=4b$Llv#L|5ZK5}i2#W!DST z_oIe8sT9ztY+wo4rnn<={YVT_qy5Gh)LjJ?tm}~#RFI+-rVYw(+2;|O_i!FOp|Se- z?pDLo!-N|6#)tHv+qd8Ek3Lx}ynm1+TgYSs0(CniidsI%&{3IsZJC+acWvcZSZSK8 zO7`Oes~3Cc%AvGR3=OmIOvrAKEb9-mVN;}tfVm_tRA%t=Ay%(?5LdE5PTvHeFTgm! zZC`U&9EZan;*?a`9RqK!9r^SrCeeWxP9M|ZiZ&cTNo+2MAvS%g?Ay;7BF9c$j9E{= z{`=03m8AZlB8Q8>-RE34his{DnL<=M05;xLCkx@zGwwx1Y=%Aw^g*n~Nf zt%=8i^|@o!E#=jMe4JrthY`{e>G#nktZIuVaY4TNgK1;;CgrWP+aPIDY~8n@$5)Ap z+IM+VP7E4Bm0`Sr`aM_MHPjJ&M2XWB&>t@&8Do{^oBNW@5bCq8>VkGNocaAlD?oC# z#fGu3b%=|QVG7@K5%+RE*i-ZHo68svS$q%$YQt|DkU{*!z@F@1RYsb#R`{{u=e)#1 z-8f~80v(#lo8G!cEDMqA=6<-9zfWirD4MEq(^n+3+6b$*4$yu-dr#soMEEZs_(cAW zk;lMro6u!Z(I&K2xI?)51*(pYr1!&@`i|-^pK8aRb@0wg{c`HJ(a$#jzu&+4Z-E6L z<*uO779-DXuiM67pY3wTfN!^PVz&*qHHl#ah)bF4`I3WHSg3Fv!Xl>J-46M(-c%r9 zrl92wy^hRys+*pPaw4tnK!dLCwjNX}5K75}L?^+#UPeXCfF~_xOpfav!Fl`pU!`<8 zLAV+3MH@VB-ouAmCJ+@Ti)^iU3ktJLuvrbB*f7tH?;l(+!D+D)I&1Jb-I|2M+b#I+ zX4L_MTLacNERm}3oEPS&!_5xzk~Jv9TaZrj{CYyp?MPbk`n;fGnk-zQ*w(GW$8nx&piL>SK^#lWGccZ|=Yr@hIW3{a-|NbD2n!&q$~jA=R6=uCbKJsW z6V`7v+yH0~K;HmYZPpy~@Zr(8{pWq8$}pkQm84i;;rEs~rK5Sa`PpgA0x`x^5_%R# z=ONasm?xL}*XruT%tD7483775HPMQYI&rWypq3?u9L%yP@oKHAjz39=`C1?fpnE-j*(2~`rWs8Y+b+U=KO#(zm~Jt@NVH%y7d7DQ&{~2;!WF~ zVz23KniXlZ8vdFjb{zFFw`(Su@E7pwn~A43k?T!_$|slla;&Zvt3(}Lwk>O^wV179 z28Ce*Is>n^u?Px}r!-@LHyRBiaD(S_3mBk7YJ&!x1DH&axBM1K#hF&^x4Jds61D7! zReVIjyYzIV5p_F#ne(Y=6dTb-TP)Q-Jg(w&J19VSMLcLu49A>LP^Ju zL?%w{F!G*SN%LZ>PITi&?Zu(Ct85-3(j zR2O_7!-^c00{EL~xY8MTD>|Z+G>F{>I^mk-k^ePdeNx zK_dlQ6_&vipqRaA-eYwGCgh41a5%Ko*yz27j=0!c9jx@f8$Fun&1PPyu!{CmzpRUR z6fA6iAgLpvyoTSZD%uC~<(8T99rkG1(-6~yxpANQEyeVVgu$*?@4()ewWl}aUqRBi zuU~Il6)1UF!)PE_V^Xfe>2XVGog~*1qM$9R@qM* z!h}%HiE<_P0cBuSI#q4Pvr%y%ma!#b zE?|hd{-7^|mwyE9xSS-QP7PEZ2x#{n493=Wz6ytAcj>M~Yin4_Hcq5vsT4FAbyLXosDqk~NGq3?ljPYYUSIS2fw+&C8<+ zWSRa5D4veBI+SWqUSOGy-7fpYFexGzQ=On(qM@$g(wR43XU!44AhRbo9XV?{;At4E zA0lq^Y9l>wE7EW~_mccJHYq}WItR*GA>I`;W88310jt9qGF(2j#1zNeio1EK6MXy7 zF6k4)4(=gNxk>OfZ-tf-(7i2q4(zK#x24D42@Ar|bG1En264$arj*~8_xiXd>Tm;H zyU|M~>1ka8l3CXagX4_Bnq`FUSFEArv!?rg_fU5qQXjoF!n2N0ZeyB{_Oqr##1>qw z6Hz(M_liH3cuk5cnZEWNQ-z^u12|RdSC%S+b!Ym7-GE!Ho%Nhga>-S7sOin7bi_iu zkqClJ$tr$hL&TzreU(z|C=s9NL~m@U2m{O!;fXd!a+# zIS{$Yj=T}hpbx zthU5ICl)XGdpC5@!$IR{S92rXO{dI2YtxE^$V+KzRdG%k+n7stqZkyGQVS;1Fx~Il z!XnBuEn`Rff6PXb5oK7PDj$WsC~V3c6&!}er@cRt>-A>s-maY9)UVP&3?V~~k;l7K zoYj(BMOSriH3u2P{hW5yW&(7mP1&*07dcqhT)gCX{f%$C)q~78iB5G}KI`tbrWLvg zF`1e8Wcn(@l2R#Y0g@e8M-_-J-PRnc+A=*a7+G969ba>br=t?Sm#WUpEhF*&m=#^< zWfq%Q0rdj0D0j-wz4GVuL2tUhF^N%H$qm`&_7+QLn?aKx$+^yT7q{`r22ZQG4YLuPEb?^Q$7ADH2LZ;=RUo2_YsL$k3MP*y7sk}GM0jas(O?_- zip~n!hy1^9YyI4Pkbu|ynV9mcr9lrJ^QLuDR5+~G)zc#}J7f+kJQ1j>ZZ*AL7Bjm9 zZj%!M0|7JGS)x~5LT*P0O+6FY#a{Ff3!6=MtWj^J)iRu_FS= zDDP^P21}W=!~?l-D95Z){oG~zdD{yYO@NO%Vq<_MZ?<3Pgy+~2GgTmj6#vXMH`p8< zfyYLYJLPge)KhG*tjLn^a7_1?fYpeBjK=(^`^^Hyq1L>UN6M+` z>06oeA$C@@QTXU#A)G!d(O`crNq2!AdlH;rLi!%zjx6dwF&xveI`o;h%D){P{eBQH znskX`Ot~os$6m-oD=@QG0T<9j5qfEUI8|yg8taq;XL0$++StD%Vj$5-E~x2p^D85` z+vn%fqUf|Y_9}*9Kd{oVvAMj|e8uAm(=#pPClV;EZopQE*HPbUWTA@OZIi7Qms>OU z@?%U^h&mux9Ih#>`yfSd_~p^q71!DN2#UdC%=77(tb}4QgkEnQ5SVHt#OFWE4E830 zq>bAQ_oW{~v?2-XjNk)`xtoLKg88>#^&*ue4jnQDMHvuWuSHP2AQ%LMzCTSSsVtEq zo*L_2d7x) ztH7pKOP`g>rdv$$S~rz3V4shOG*Xc%^L*kzxzJi^h>|!c90^eJQBCTCs%S(Ow(*;L zHZ^I*_+}S&xTub@Y=!~hOs1I6EHVp(T(JO=#MwBy=jdL|HMtjS-dVnM+2EvoC$!7ygq zRNuh*d3h!ZG6s1A8(H14=Vr`qxWAkZzf&!*;zIr-5bpp2{=2ZB;$p zxkV!zRGp-JpLJ>S%2LpMj$&gQrr-R}0-T9!sU zWflBb18-Ozn|B*O6}1x=QSQfgn|C5wdcQB`#-Y>0UExhC?)aCm-m;8}CSUlV;;w)s zSsU&E84+0gsV8=7dmiDa`%wYCAEx9`HMfLI@#TK(i7bSA1LZC$d1gNz9ZUy{zOR-AUSlg_xxeP?=7mu2P)+|8tcq7S zW*SQTrdd8wPt2A?^s%($&kTO)g7S9yGp+=jjQ)N8bkF=Y2UmJ;?&T|_TvCMhfm*j> zn9jUIX19I7)!Z4kno^ACR?_xM1_eXn8UE`-#MQk!`z|k#0)&EcTKDTFgq(3)G!^Pd z_m1$aQgew*URlM1OA>PICW<;20su~dKu7@+X{vxQ5JB^;soiXPrz2@HfCoh=K9z1% z?b5&9Nt8FGd~lNQxG*^sKE`Z*BO}-1yfDXt_3#K^8)qdSfS9Xit#UR@VTZai_M|++ zi&)a{vg+OA{Stve6?q_=RCc^_RURcVcWAovdpXHphwli*TH=YT-8zi3C<~x5dqA z1aYqEp)Fyfqt&q<>23`9*@3>e#(o}h9RZ1#-*VVi*}O7vOZ9RB z^YEk&3|@@|nh~DI=CJ9Sb2%pq>68)Pry6B2sY$3W20BEFJBw8{2==*|f}t2i7gO1x z;|A$Y6|wH>%N3S1@BL9dY_hP-M48>;JDpwK&;_0Dysnsixaj&xowjodaPS@5V48|| zzc0rguUjxY9ngYp&Q#&fq2#=SW-j>QRJn|@R8~Jj*rX2DiNmj%J^`_@6H7nR3jx#? z9^L?YF8{>fEWDQ3NfwQDzMf-rUsb8c*F3~UX>T@UFnA$)r?aG0tWY(v!hweI@n$;% z(hdk3sCRwpWl@|RR7T4;vF|a3QV8e@WmHX>N=CP9^M%`2I^gKKHYMT7N5ulpb2Y$8 zx(h-f&Du!zM0VGC>0_^N6XdXUr38zQbCIp3izt=bnp@49yHZ=1q?<+Ke62RUEbFf- zYl=}wRVu^v%oV(<^f7_UbU+$2WdJy1mK-z}a{LiRHwFFhVip?{o%p>pIr|zt{9x|& zgzq(=A@w?e_%8iZIiq|k1+WrWv0f)=VOB5c_H1==aF(mvVAzp@?rD>BPfHu|F`;L$ z&cqKADTNV%)$-H6Y8l(-XWQ^7*|})B8?av+3T`j>tp#ugz3~3Ta4-K#Z-v=(4u_9> z$BVlp}YBZ!KMn#Fjy>VU0m06x zK@`gxQY`joQLsdII`?dO)*#Xs?yHRzvF@24uflVcSQ+L(d&7O$m=5(6#~eemsY({w%9-IH`P3}Btn9*IpgH6z z4AM(>@6999(MioSou=r>4b`f6+I5_Mmk33%aQ?$lT*g+1MbpA4DVE?A`|P1_QmE}z z^MbEksLnQ+l~WQeoS`X&T~NMPb3@=;Ez^N~asrSM z?A%-jaFPg*un0M0jfr;lIt~sCKPE*4o01PynJj&lxe-$Sy`qJDNb4m}j2yHoW-fi1 zAyg3a+nxRC4n4SOjRb*38e#fc#jEG9SepV*dp)RB$)L&lg7ig0H_e{~o^==8*R9p8 zQBJ2pogqo>_FqZaHvQNmyjBnk#X!d*T?Gb=@ANQ7y`FndOJey5%Z$&BdgV z(=lA~6$*24_4c)<^s{ z9&*K}KqX!>@B}3PUqg4K$IkwIamhBJifDb8Zlgpbb>~VZ_3S(wbstzL`o7ZDy;}C$ zD^Wz2+>$I&Q{`ZE7I1e znBx7qA7K!*I?5|v{;)?QsB(Q_e%!6c%%t1&*RjX5_9S@7SvdgzMxaL49se#+QR zfJ}V;liHuDtSa{0lROay1;n=4Yv-qfJdeRs`$tc&T}^TEQwVJ<7mnC5HL|gS78Vo` z$S_-T0Jve|Z44=gIWJ8l5!$eqYtDUc_w%ZrU;%+GN^gKL;C^+4Qy#6ud#vqXyr{Vd z9y7l7WaRTtUH-hRnK52LOY@1dHJ`8H>NM*8dGv=dUVfPJ4`1Job87m(p%j+f7-%=}Jm_LH= z>Cj44SkraQT)BNIj%P;S!+A(yV5HDd-tSck7_zN5!<25GtFEQZU#tO1cr2(7bvR^v z(W31_@J1oDnb-hFe7xzOmjVTP^~@v&cU5T*g_`p-645fqjO8)L71>T!9CMDYxuN(0 zJ0V+Bmy@1x#(<1&XiUb$&K|^tGQQe@oR3_1+4ESKU0a_3vJb`A(T9dBw9SFR$JX}a z@DrlU#-tZtUT2KUb4pBe;n7;bL*<(GGgbx$>Pay3b|NJvZ`)*`v^OfGCY?8-;I{&dzs44n1twy8%BArtvftE+|9 z$5EpKKtb`R!4xBLOouAF_wnCAm&mzI5QT%fo#b?OO^X z-^K~F{(+#*_VLQ+t1ey5nyxXQP%yc@uk?`n#bm>UC9)}p0v^U`&Z?A>8^Wkr27<(9 zcxUo$Wo8O}2k8mo{fc}wYP4-`X^Fb*mI*;(U*y_DWnDgk2+&pk$#P}kg`NjXPsLFq zMi(D+U&ylJkOY|(P+6HR3g`qdIK$+~d82U%kVAe<0h=4$Xj=B(G_E4fX!4f=ne(Ee zuxa#Bhird8H$U>zw!iPz)#i#DN~qwC*qi$(F~H=a;6^q)AKP@@w(Y5_kG$Xa_Yqi* zZ1o_Z2$+?XtB((V)!10$2pRj;ji_--jlcvhqV^!HIuw-f1d5eeW-2+9D5kPAP~euztO>_zmt z@_wTgm*k8@)udh{!2CG3rVler?^Eyw`P<_8We&LKZE!iq@{l|;{-TjZsBi^KjvmYJ z1VWs;$-2gPUf#^Od+KLF^LdY%G`7?1sB!l;DwJJr)+{GIu_@v3B(vu(fBa@p)+-{T zQP#x~D|mN58|$EtXqlOup)f1AL?1FM2I@JKJRDLkwrx0g^s{Snk^(>wlW*Sh5Tzyb zbf=`9R4~j6W{J3)%zd&=fF-LtUClg|UaoOA*?N^(A#WQt;TGw7u_A^yR`HgFbx8xp z7~f^Dx?I-^rWKt5eYbi;u)%?_iJ4HY7ifhlG+PI)ETc-`k+cG*fc|Glhg1kMr8L9Q{U?BY~$bQF5~@`KB3ODvgvvl^MIb!(d?x0aWhl7mTS z&N7*a`CGJUMJ|_qi~(HVy;lSw8|!U?=}99x=qYKN@SDo;S34vXkZ!K<&SXriA+eoi zF~Oe%*Ru78L4Ax(FBbT~S;1Ff7wAoT>rh>Jf9eQ(ld|fJ>5g8I8T= zb;4q6Qt4uD)Yjfy^Qz#*I*~);j;|D!V)3?$W{9#(vXmT(7ZWTZX@*WfLrPwIjbtq= zon28~(G6?>TL~``T)HsaTGAYGNUk|P1)gSak+deMg(=*n>d3Ds&dqy|+P#r~U|!kQ z`cbs#16aUT%y&=;Sd8v)kM+}4`_B8lG|)=2D6cmc)`U=3>k6v+yE5kLYOAB`5{JRQ zSCpN|mwLRVPu0%w*Ypg#5c#8Dwhp>nM z9^`SJ0VxS-B8^Q%z+RI*FtD_S0hgETiMBd1K)esiiJnd89eiRpy!L~9kK$uqW2!$4 z&!*h!FNm~vEA%O!0(%fD1%fDem}O2>OkQuMckrqRjw|8CtEsVs#B0s{ulA~|*FAGW zOg<$3d^ud(TQXlCK$!F|P`AcyDLLqqGd32r8WR)o>@4lHJCHwj^~VO@JjDnV23T%m+DI_2>U7%7wG6@odSiG?p;L{r?a)>yvrC73f$=yrHgt!bT@$vx!my8Nm`Ax z?46<}fVc__`JjfaVIA`i)--aiRcigiZj4N>SJh83Z@XE}_)DJTRC)M!KEqW|Ub8`; zWRf0b1WW3y_Hn`zGoci=48`xMa9+pfK`uM~u6vKRL9$r*q4fQ_f_M}KWF!ZqCjk6& zt?lK$W|l1RlKe#1z-UJ|4{ePxVTm&hjxtjaY2#P&Qq*5OW@N9w%x=@wBl}jRqR?6j zVDLL*bq#A(dxLWk84>k{d%PFz{OnS^%U1JT(YmLlr`;yc*0Hi$MA0*WC}VXnq-1kV zvG4CE=#Cm`alN)U!X({ttYfx*_hZ9w;RPR9h22kssx51)y)Ry5)c2U&uaD5GKfeH> zA^1CiKu`<463z0^|HtRA_6+(S-=bb-E%{)j^;5HCtaO>m;+#n8oWyrj2>nfc>h~dG zxDPW;ay3|iZ$JAl-Xw9KJN-V22A;tBx7M;PO?rEMZQ{ZdQK1wm1oA%8=y@nI}S_)-_z8{+# zUDog<$#v*qrIy341&N9x)6GWkx82Zu3`$d-tOekcMC$ce4EK1=Pb6_2uzzg~dBYSV z+%hn8G-|8i3|f8%5c5y7TfwiHlGuyL_jw6nD&^9?cC${~GbShB-itmqrCecf6nVD* z+##AEOY|_@mOV5I7u{=QG28w@e)FwEw z4{vC+A~l6v&v&sBpRJ3$)%4`t5T|3KZ>96i{y7!CXtvm-oK_N+xaA4nCLtoC{#o|vHziC0FV`I8bgQ4`@<%{64N)oK3H!9(zXAz3F=qEuT|b%3s*O$ z7(M!$!R_i*b<42GI7fe`bEcc49F13>v6gu%6t`VbQpPM2ks4r4{CDFt_3?<`?Ptqq ziZ?mpBH!3%eqxBi?bC%m;zVCn$dUweEjRTk`|ygRmW+yDvhR%y3Thl#^W%OH*K^dhbgae0<;yah_ZEK-@7Su!qt zZ8x}X8&S?pJsWx&Br;(WX!G4YybI6wU)u&870ndil7F~rKi8{j_BvNaI&dmC`L(R` z{P23KC+T`Tq0VcR7K%%uW9-W6RV&+DpNsDNedr%vUZ|BLWc{hsGy*gsIHc`weLEwj z*`Vv6!hn8E62$V?h5j0nk+YxsNA%zh#-utL;22*RDRg&6Z(8p_yIO7-?rryFCQZ}l z!x`a7)jO~-)#57x+k@Awu--pLm<#b-8pk@-G9XfZISH@);~x&z8oTGALopBEfJO`N zcXmODg~le@j?wU~qzljDg$PIjJ!gmLP+agv*;$G76hMgp_ZJlH<{}Qms)<=kC&$Cp zU6ez)ulu_X0jsBiYmuZ^|IOD#+o9F-udbH%m__$onkhj9nh6U-VF0K=*Br_2@tJzl z>E5cg91<@fJB{?Yo2I%fCW=l)#f_qI@fJO%lpJtN zp7(12NWbFa(I*B@nO1#Sv})=<7Qc`iy=J6w2`!33B4O!u;>LO<^m)#B+5G@HxHS`# z)R28!Ur>h!=gjo~PhWSt4`~jJUac!{3(R`sy}3~hm8HpD8ga-np9^BY zc)#E`(Tju)$*Ls^R}~#@I`StOWi|IITgLo{SdN$r;YDUQ0Ur#LZG9&#V^dp%v4#7@ zeB`!aSFM+~+eFUQcULO*QjXf~2_A7Hg*>jU`!No$WtX0!hX=&e&l3}Ko^9%XJEMG$ zra-vVrCF6Vz&SiSO~+z{l-Hl|%HF#$h6n5~n-;MZ-_a*m4=#D6kBNF4RI0Zc%14yh{vffF-HhqNJM4?P z(~@+3{78L_XSe<@!TWX!Pa_=?u#W3jJAOqv^*bVZ5RX^!X3Px`Km?%pQN>8S7B$4u zX|9g&ZJT6}NFj~kTdN~WHcY<*`JO^0TrK3`@teCT_-es&8(ok7t6ghIj_It!w;{Yz zV4eSzOTA^~MO?~M1(w^NOjphuf{YY4;*Y$drP9rPZyho!H)>k9BP)wxwg=^X$($Fo z-6tx?mo@=z$8_&Qwz{gGi9d`jsdf6#k7vDH8QmA~?j2#cUPI$|{YHGXlI)3do$Y_` zGT`O$nu&R!Z`hJzM`a(EaUPgU6Kj{Z&-e!mm3&^cubaRX@8JdL+`S5rK4av*?|*C5 zZ{y)zHd#_%7HffD(O;XVx4z8P4(49LYTWIokPUIuxO8mA$PM&epT*G8*TbDU!<1Jx zMz#HK?oZNbzWtjJlX3@@)CP_AfyhFnYwLlT2+fh?ATMy<@SJ{yJCNS^CaA>^?5n4| z2W?OH4S+^6;tXS!&wLy)(85rn4@Rt$NTp6O2Ok7fd$G)Tvkj2 zgl=r%KEqD0{;4jbP`9AhtG%E%;Lj@YXuNLAkGOd8cik<2Ri)YEz*OpAyu5Q| z(Gs_SQ)z{bC#YhAEoGWat`G~1fuN7qJXJ@){wfujjm(xc}lK^qzZ=i z;WU}fUQ-KodeLhOGvn0)W*MHa<|B{|TAn4<+$(XwMUxXEA`ZS4haYG&jwl@rw`Aw1 zzk^Uha7pvDa~y5*ayvOD&~JE7K*5`^Nu(>$m}1DOF`~6M*d>e{6A`6 zX^y$A(u`s!&#w6}nZ5uD!`D-ONhr^>y)y(DW*^>*%}z{YGvkBWrofV>{Vuxo*b{!T z6RgqJ&`{%H(gz9hH5v89UfA)G4v#_ctVtZ2CL4L;+!(bLP}uBy;dt3i*YLiR=Qdc6 z7J9P*wE&@&IXiJSC8E{S^cI_=5r}%-NA{z`6*=@^%K4$9?X36|rGZYE-g9%VtAY;F zekz@mxS7OIF*|yf%}mzIaL)M<fi(wHoTb$d*e?EhJIezc0or)_0KDpV}H7vuEK3zpz(GG|O zdqUeK+UX@J$0f-2bnl*u?4`O7pA=kcHZIuetFTmuw0@G8dBGT(2^=0HZTbAw@pNnK zu0*-b1Kl2BbZzk5sty;E8B6i^+QCK?%$IGXy!8C64x6`PHu%rs(vI;{I)C%8l5wmh z)BFj)Dw&`yu(U$oE}_JY?c6B0x!4`8I-1dfguI~{a|U#cuJ<8fHaV2lN`-W{t{>8% z3q%?~s$E3ED$yOE*nb!+`nLOOn;v43+L72oT|j%KduOQbfAG!JZt!hjJ}&vdDE?ZK z-uhvE&VDq;(bOes1DD<%Yj_Ic*~Y14=VzsoNN&qK{pzVMoWn1Kv=Ei&ZV_XIT1lVN zL}y8Necznux1W0DtR&4!&_M=vDqkY1L%5&WtbTgZ=A0MHFcSmht6Uf&3so+C10VH_ z?Wqu3_qn(*UUb3_A$y;wL-|K(UPfv>atfLcnC~&KRncx=Dw?pueJ83?riqD`VrgnrwI4k zhIjA0|4oLu-66?17EJ~jN7@oe{#R@u{8gpkB~01nWB{l^I%PCIZBeT{{JwnvN6X9f z6)x7$X!{>nvU(;*?Kj+-SJ#r*)6Itu!{3Qy%$!3g)+0SrB-IRsjRF?JpV|L>-URv~ z_MQ&>ETcglfo~4;3!3t8R*lTnn&z=HiWu20s#%})?G8cP%JWn>_f0fIhBGpSByakY zjm+)SHL~#pZC1>KlOA~AH^*g6wr2mBT}Up|LmMKfcUdsS$j6muig&fC#JE=Cx3aE( z|1_=aU4)h^bPM)Q((YE?MeDU@Y#wnuKgERJEe^rVgo-``m38E83}N@SJE2KcIZ10j zJ9yNO=&h<;u*}#ORt@{n%+lfzN(`<(YgF#zj)e3PtB;**05Q{fLqh+6b6aT;luS+w z|Hm*Hm@A_d1X4+J?2NX~ux`<6}iOfPu5x z_y%rB)_o+PIC;lNS2Gd+;BBBTy9@5I3s!z!SiWyISEac zv)IAk9xo06$&KBC>^kflshqVoH`Wpcv*VJE zYkDBNqhHQi__-v?t1L845G&k_R#x7!){tTmf|1DU-?{J~F8%Pgeb>MG;_V%ch#Q|1 z+`oqr>DG zIAhZ$id_F-$n&+FfXanHjh<=I5r>L;>j(W?psS90$_Q~`UWT=Il&}FCy`HZoENhTK(;e&FTKg;n2KB)rxUo7)gy354xz%nbW_ppwBd_1x!;UnnSDAUck9OXGboehx@ z!S((tVnwH$A1+zNpacG^XeD`Zpuon5+O{-9}EyWiH1NH_jP54FSU$7x==QWDVV<$ce-Dt z_GPIG-~qkmYPp5Oz~+q*CWPy=?V7}GEh z#HGc}2GwGMR-JC#qT*_v*o1Qn3*^LvT3K+|UT!>&sHsJt4=*+M&Ob2kc9|xjSknrO>9YdiXzU{;&!>q^QyWbiXCh{og)8sR ze|}z-!WRWK4xFJEt6tcJn3s+3HS7=X>5*8-id`l+44ZK|F8XK+t4AY&pWZ^6p~~sP zoh$IpQ<6C2v0H^r+C~wVXJHOrRNvg|P~nLF3dplW^q~_Dt`8+{d^onl0+!I#Xz(bLiCG~d@(E+mE@sj$0Y z^ptaOP?t?x-g{3_Q~BnXZI31| z$lLuMJ}j43#@3E6Uq8iutQvg~^(6kx?rTiui4hMRyO3(U(~j-|(-K3hK8&2x_o{ywsHgKru;I7dY4@Gq?H6|G!`Rf{DUic5m zFs~n)tXm77m88~g2<$|STz{(dP>9vsXwDAXLbkjawNnRiZE<>9&XwZ674qu7#nS;f zr9AM{E9KV8^ZF`v#;0@k7sf{c!U{8JyIl9ILb*1*MOL5&5>4xn6EJra8KzGU&?3}K zmG1SBN7FV&lrP(Y(@!d`+IWy8Hi1ZbN!Z!^oYaysj)$bdk3V}X@!63u>6%#u`=RS( z{a_?Y#t$Agb8vn2b_-ra4GdqO4 z^JSY|OHL2OeXW{e{FBBDy)Ba`hH2@VG@(;8Cv%_BH`YT^A>8Z#OseJPZ_Oh%K}}>l z@*$`fr_m#pF(J-Fxh$T~X3X%zJ+mr}jJL!AwQ^WccW8zbNA94ERTv@>lOMYk>|3g$ zuMw(7WWIq!)92DLOP3nT?6U&}9hv<)USGC7&?EGY4wi$_wh=*>AwjOL8`m%0D6bKX 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 diff --git a/src/c++/perf_analyzer/genai-perf/docs/compare.md b/src/c++/perf_analyzer/genai-perf/docs/compare.md index a7234a035..5d1a36413 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/compare.md +++ b/src/c++/perf_analyzer/genai-perf/docs/compare.md @@ -76,11 +76,11 @@ plot2: - profile3.json output: compare plot3: - title: Distribution of Input Tokens to Generated Tokens - x_metric: num_input_tokens - y_metric: num_output_tokens - x_label: Number of Input Tokens Per Request - y_label: Number of Generated Tokens Per Request + title: Distribution of Input Sequence Lengths to Output Sequence Lengths + x_metric: input_sequence_lengths + y_metric: output_sequence_lengths + x_label: Input Sequence Length + y_label: Output Sequence Length width: 1200 height: 450 type: heatmap @@ -90,10 +90,10 @@ plot3: - profile3.json output: compare plot4: - title: Time to First Token vs Number of Input Tokens - x_metric: num_input_tokens + title: Time to First Token vs Input Sequence Lengths + x_metric: input_sequence_lengths y_metric: time_to_first_tokens - x_label: Number of Input Tokens + x_label: Input Sequence Length y_label: Time to First Token (ms) width: 1200 height: 700 @@ -234,8 +234,8 @@ configuration file. Here are the list of sample plots that gets created by default from running the `compare` subcommand: -### Distribution of Input Tokens to Generated Tokens - +### Distribution of Input Sequence Lengths to Output Sequence Lengths + ### Request Latency Analysis @@ -243,8 +243,8 @@ Here are the list of sample plots that gets created by default from running the ### Time to First Token Analysis -### Time to First Token vs. Number of Input Tokens - +### Time to First Token vs. Input Sequence Lengths + ### Token-to-Token Latency vs. Output Token Position diff --git a/src/c++/perf_analyzer/genai-perf/docs/files.md b/src/c++/perf_analyzer/genai-perf/docs/files.md index fb33410d2..6ebdf69fa 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/files.md +++ b/src/c++/perf_analyzer/genai-perf/docs/files.md @@ -58,14 +58,14 @@ The data subdirectory contains the raw and processed performance data files. ##### GZIP Files - all_data.gzip: Aggregated performance data from all collected metrics. -- input_tokens_vs_generated_tokens.gzip: This contains data on the number of -input tokens versus the number of generated tokens for each request. +- input_sequence_lengths_vs_output_sequence_lengths.gzip: This contains data on +the input sequence lengths versus the output sequence lengths for each request. - request_latency.gzip: This contains the latency for each request. - time_to_first_token.gzip: This contains the time to first token for each request. - token_to_token_vs_output_position.gzip: This contains the time from one token generation to the next versus the position of the output token for each token. -- ttft_vs_input_tokens.gzip: This contains the time to first token versus -the number of input tokens for each request. +- ttft_vs_input_sequence_lengths.gzip: This contains the time to first token +versus the input sequence length for each request. ##### JSON Files @@ -85,14 +85,14 @@ The images subdirectory contains visual representations of the performance data. All images are in both HTML and JPEG formats. ##### HTML and JPEG Files -- input_tokens_vs_generated_tokens: A heat map showing the relationship -between input and generated tokens. +- input_sequence_lengths_vs_output_sequence_lengths: A heat map showing the +relationship between input and generated tokens. - request_latency: A box plot showing request latency. - time_to_first_token: A box plot showing time to first token. - token_to_token_vs_output_position: A scatterplot showing token-to-token time versus output token position. -- ttft_vs_input_tokens: A scatterplot showing token-to-token time versus the -number of input tokens. +- ttft_vs_input_sequence_lengths: A scatterplot showing token-to-token time +versus the input sequence lengths. ## Usage Instructions @@ -126,4 +126,4 @@ View .html visualizations in a web browser for interactive data exploration. ### JPEG Files -Use an image software to open .jpeg images for static visual representations. \ No newline at end of file +Use an image software to open .jpeg images for static visual representations. diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index 7914d19e0..bc9dec71b 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -111,8 +111,8 @@ Example output: │ Time to first token (ns) │ 13,266,974 │ 11,818,732 │ 18,351,779 │ 16,513,479 │ 13,741,986 │ 13,544,376 │ │ Inter token latency (ns) │ 2,069,766 │ 42,023 │ 15,307,799 │ 3,256,375 │ 3,020,580 │ 2,090,930 │ │ Request latency (ns) │ 223,532,625 │ 219,123,330 │ 241,004,192 │ 238,198,306 │ 229,676,183 │ 224,715,918 │ -│ Num output token │ 104 │ 100 │ 129 │ 128 │ 109 │ 105 │ -│ Num input token │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ +│ Output sequence length │ 104 │ 100 │ 129 │ 128 │ 109 │ 105 │ +│ Input sequence length │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ └──────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ Output token throughput (per sec): 460.42 Request throughput (per sec): 4.44 @@ -195,8 +195,8 @@ Example output: │ Time to first token (ns) │ 15,786,560 │ 11,437,189 │ 49,550,549 │ 40,129,652 │ 21,248,091 │ 17,824,695 │ │ Inter token latency (ns) │ 3,543,380 │ 591,898 │ 10,013,690 │ 6,152,260 │ 5,039,278 │ 4,060,982 │ │ Request latency (ns) │ 388,415,721 │ 312,552,612 │ 528,229,817 │ 518,189,390 │ 484,281,365 │ 459,417,637 │ -│ Num output token │ 113 │ 105 │ 123 │ 122 │ 119 │ 115 │ -│ Num input token │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ +│ Output sequence length │ 113 │ 105 │ 123 │ 122 │ 119 │ 115 │ +│ Input sequence length │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ └──────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ Output token throughput (per sec): 290.24 Request throughput (per sec): 2.57 @@ -261,8 +261,8 @@ Example output: │ Time to first token (ns) │ 13,546,815 │ 9,821,658 │ 48,317,756 │ 34,361,913 │ 16,541,625 │ 14,612,026 │ │ Inter token latency (ns) │ 2,560,813 │ 457,703 │ 6,507,334 │ 3,754,617 │ 3,059,158 │ 2,953,540 │ │ Request latency (ns) │ 283,597,027 │ 240,098,890 │ 361,730,568 │ 349,164,037 │ 323,279,761 │ 306,507,562 │ -│ Num output token │ 114 │ 103 │ 142 │ 136 │ 122 │ 119 │ -│ Num input token │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ +│ Output sequence length │ 114 │ 103 │ 142 │ 136 │ 122 │ 119 │ +│ Input sequence length │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ └──────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ Output token throughput (per sec): 401.62 Request throughput (per sec): 3.52 @@ -318,13 +318,13 @@ Example output: ``` LLM Metrics -┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ -┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ -┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ -│ Request latency (ns) │ 296,990,497 │ 43,312,449 │ 332,788,242 │ 327,475,292 │ 317,392,767 │ 310,343,333 │ -│ Num output token │ 109 │ 11 │ 158 │ 142 │ 118 │ 113 │ -│ Num input token │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ -└──────────────────────┴─────────────┴────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ +┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ +┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ +│ Request latency (ns) │ 296,990,497 │ 43,312,449 │ 332,788,242 │ 327,475,292 │ 317,392,767 │ 310,343,333 │ +│ Output sequence length │ 109 │ 11 │ 158 │ 142 │ 118 │ 113 │ +│ Input sequence length │ 1 │ 1 │ 1 │ 1 │ 1 │ 1 │ +└────────────────────────┴─────────────┴────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ Output token throughput (per sec): 366.78 Request throughput (per sec): 3.37 ``` diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py index 73ae3f540..05c1ce59f 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py @@ -58,8 +58,8 @@ class Metrics: "output_token_throughput", "output_token_throughput_per_request", "request_throughput", - "num_output_token", - "num_input_token", + "output_sequence_length", + "input_sequence_length", ] time_fields = [ @@ -119,8 +119,8 @@ def __init__( inter_token_latencies: List[int] = [], output_token_throughputs: List[float] = [], output_token_throughputs_per_request: List[int] = [], - num_output_tokens: List[int] = [], - num_input_tokens: List[int] = [], + output_sequence_lengths: List[int] = [], + input_sequence_lengths: List[int] = [], chunked_inter_token_latencies: List[List[int]] = [[]], ) -> None: super().__init__(request_throughputs, request_latencies) @@ -128,8 +128,8 @@ def __init__( self.inter_token_latencies = inter_token_latencies self.output_token_throughputs = output_token_throughputs self.output_token_throughputs_per_request = output_token_throughputs_per_request - self.num_output_tokens = num_output_tokens - self.num_input_tokens = num_input_tokens + self.output_sequence_lengths = output_sequence_lengths + self.input_sequence_lengths = input_sequence_lengths # Keeping chunked ITL (old) as a WAR to preserve visualization. # Excluded from data. @@ -142,8 +142,8 @@ def __init__( self._base_names["output_token_throughputs_per_request"] = ( "output_token_throughput_per_request" ) - self._base_names["num_output_tokens"] = "num_output_token" - self._base_names["num_input_tokens"] = "num_input_token" + self._base_names["output_sequence_lengths"] = "output_sequence_length" + self._base_names["input_sequence_lengths"] = "input_sequence_length" class Statistics: @@ -244,7 +244,7 @@ def _add_units(self, key) -> None: self._stats_dict[key]["unit"] = "requests/sec" if key.startswith("output_token_throughput"): self._stats_dict[key]["unit"] = "tokens/sec" - if key == "num_input_token" or key == "num_output_token": + if "sequence_length" in key: self._stats_dict[key]["unit"] = "tokens" def __repr__(self) -> str: @@ -403,8 +403,8 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: time_to_first_tokens = [] inter_token_latencies = [] output_token_throughputs_per_request = [] - num_input_tokens = [] - num_output_tokens = [] + input_sequence_lengths = [] + output_sequence_lengths = [] chunked_inter_token_latencies = [] for request in requests: @@ -434,8 +434,8 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: time_to_first_tokens.append(ttft) # number of input tokens - input_token_count = self._get_input_token_count(req_inputs) - num_input_tokens.append(input_token_count) + input_seq_len = self._get_input_token_count(req_inputs) + input_sequence_lengths.append(input_seq_len) # output token throughput per request output_token_counts, total_output_token = self._get_output_token_counts( @@ -444,7 +444,7 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: output_token_throughputs_per_request.append( total_output_token / req_latency_s ) - num_output_tokens.append(total_output_token) + output_sequence_lengths.append(total_output_token) # inter token latencies if total_output_token > 1: @@ -470,7 +470,7 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: # request & output token throughput benchmark_duration = (max_res_timestamp - min_req_timestamp) / 1e9 # nanosec request_throughputs = [len(requests) / benchmark_duration] - output_token_throughputs = [sum(num_output_tokens) / benchmark_duration] + output_token_throughputs = [sum(output_sequence_lengths) / benchmark_duration] return LLMMetrics( request_throughputs, @@ -479,8 +479,8 @@ def _parse_requests(self, requests: dict) -> LLMMetrics: inter_token_latencies, output_token_throughputs, output_token_throughputs_per_request, - num_output_tokens, - num_input_tokens, + output_sequence_lengths, + input_sequence_lengths, chunked_inter_token_latencies, ) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py index cef7c85a9..1072bc30f 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py @@ -167,11 +167,11 @@ def create_init_yaml_config(filenames: List[Path], output_dir: Path) -> None: output: {output_dir} plot3: - title: Distribution of Input Tokens to Generated Tokens - x_metric: num_input_tokens - y_metric: num_output_tokens - x_label: Number of Input Tokens Per Request - y_label: Number of Generated Tokens Per Request + title: Distribution of Input Sequence Lengths to Output Sequence Lengths + x_metric: input_sequence_lengths + y_metric: output_sequence_lengths + x_label: Input Sequence Length + y_label: Output Sequence Length width: {1200 if len(filenames) > 1 else 700} height: 450 type: heatmap @@ -179,10 +179,10 @@ def create_init_yaml_config(filenames: List[Path], output_dir: Path) -> None: output: {output_dir} plot4: - title: Time to First Token vs Number of Input Tokens - x_metric: num_input_tokens + title: Time to First Token vs Input Sequence Lengths + x_metric: input_sequence_lengths y_metric: time_to_first_tokens - x_label: Number of Input Tokens + x_label: Input Sequence Length y_label: Time to First Token (ms) width: {1200 if len(filenames) > 1 else 700} height: 450 diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py index 0d3da88f8..53ea1702b 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -44,8 +44,8 @@ def test_pretty_print_output(self, capsys) -> None: "│ Time to first token (ms) │ 2.00 │ 2.00 │ 3.00 │ 2.99 │ 2.90 │ 2.75 │\n" "│ Inter token latency (ms) │ 0.50 │ 0.00 │ 1.00 │ 0.99 │ 0.90 │ 0.75 │\n" "│ Request latency (ms) │ 3.00 │ 3.00 │ 4.00 │ 3.99 │ 3.90 │ 3.75 │\n" - "│ Num output token │ 6.50 │ 6.00 │ 7.00 │ 6.99 │ 6.90 │ 6.75 │\n" - "│ Num input token │ 7.50 │ 7.00 │ 8.00 │ 7.99 │ 7.90 │ 7.75 │\n" + "│ Output sequence length │ 6.50 │ 6.00 │ 7.00 │ 6.99 │ 6.90 │ 6.75 │\n" + "│ Input sequence length │ 7.50 │ 7.00 │ 8.00 │ 7.99 │ 7.90 │ 7.75 │\n" "└──────────────────────────┴──────┴──────┴──────┴──────┴──────┴──────┘\n" "Output token throughput (per sec): 123.00\n" "Request throughput (per sec): 456.00\n" @@ -111,7 +111,7 @@ def test_pretty_print_output(self, capsys) -> None: "min": 300.00, "std": 300.00, }, - "num_output_token": { + "output_sequence_length": { "unit": "tokens", "avg": 6.5, "p99": 6.99, @@ -124,7 +124,7 @@ def test_pretty_print_output(self, capsys) -> None: "min": 6.0, "std": 6.5, }, - "num_input_token": { + "input_sequence_length": { "unit": "tokens", "avg": 7.5, "p99": 7.99, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py index d48e9c340..5372612ec 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py @@ -84,8 +84,8 @@ def test_csv_output(self, mock_read_write: pytest.MonkeyPatch) -> None: "Time To First Token (ms),2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00\r\n", "Inter Token Latency (ms),1.50,1.00,2.00,1.99,1.95,1.90,1.75,1.50,1.25\r\n", "Request Latency (ms),8.00,7.00,9.00,8.98,8.90,8.80,8.50,8.00,7.50\r\n", - "Num Output Token,4.50,3.00,6.00,5.97,5.85,5.70,5.25,4.50,3.75\r\n", - "Num Input Token,3.50,3.00,4.00,3.99,3.95,3.90,3.75,3.50,3.25\r\n", + "Output Sequence Length,4.50,3.00,6.00,5.97,5.85,5.70,5.25,4.50,3.75\r\n", + "Input Sequence Length,3.50,3.00,4.00,3.99,3.95,3.90,3.75,3.50,3.25\r\n", "\r\n", "Metric,Value\r\n", "Output Token Throughput (per sec),900000000.00\r\n", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index 17ebbf4bc..c59c688e9 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -113,7 +113,7 @@ def test_generate_json(self, monkeypatch) -> None: "min": 49, "std": 40, }, - "num_output_token": { + "output_sequence_length": { "unit": "tokens", "avg": 51, "p99": 52, @@ -126,7 +126,7 @@ def test_generate_json(self, monkeypatch) -> None: "min": 59, "std": 50, }, - "num_input_token": { + "input_sequence_length": { "unit": "tokens", "avg": 61, "p99": 62, @@ -203,7 +203,7 @@ def test_generate_json(self, monkeypatch) -> None: "min": 49, "std": 40 }, - "num_output_token": { + "output_sequence_length": { "unit": "tokens", "avg": 51, "p99": 52, @@ -216,7 +216,7 @@ def test_generate_json(self, monkeypatch) -> None: "min": 59, "std": 50 }, - "num_input_token": { + "input_sequence_length": { "unit": "tokens", "avg": 61, "p99": 62, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index cf1ed4fe5..d221b7595 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -104,10 +104,10 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N * output token throughputs - experiment 1: [(3 + 6)/(11 - 1)] = [9/10] - experiment 2: [(4 + 6)/(18 - 3)] = [2/3] - * num output tokens + * output sequence lengths - experiment 1: [3, 6] - experiment 2: [4, 6] - * num input tokens + * input sequence lengths - experiment 1: [3, 4] - experiment 2: [3, 4] """ @@ -130,8 +130,8 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [9 / ns_to_sec(10)] assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.num_output_tokens == [3, 6] - assert metrics.num_input_tokens == [3, 4] + assert metrics.output_sequence_lengths == [3, 6] + assert metrics.input_sequence_lengths == [3, 4] # Disable Pylance warnings for dynamically set attributes due to Statistics # not having strict attributes listed. @@ -140,38 +140,38 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat["num_output_token"]["avg"] == 4.5 # type: ignore - assert stat["num_input_token"]["avg"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore assert stat["time_to_first_token"]["p50"] == 2 # type: ignore assert stat["inter_token_latency"]["p50"] == 1.5 # type: ignore assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat["num_output_token"]["p50"] == 4.5 # type: ignore - assert stat["num_input_token"]["p50"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore assert stat["time_to_first_token"]["min"] == 2 # type: ignore assert stat["inter_token_latency"]["min"] == 1 # type: ignore min_ottpr = 3 / ns_to_sec(7) assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["num_output_token"]["min"] == 3 # type: ignore - assert stat["num_input_token"]["min"] == 3 # type: ignore + assert stat["output_sequence_length"]["min"] == 3 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore assert stat["time_to_first_token"]["max"] == 2 # type: ignore assert stat["inter_token_latency"]["max"] == 2 # type: ignore max_ottpr = 6 / ns_to_sec(9) assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["num_output_token"]["max"] == 6 # type: ignore - assert stat["num_input_token"]["max"] == 4 # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore assert stat["time_to_first_token"]["std"] == np.std([2, 2]) # type: ignore assert stat["inter_token_latency"]["std"] == np.std([2, 1]) # type: ignore assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat["num_output_token"]["std"] == np.std([3, 6]) # type: ignore - assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore + assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore oott = 9 / ns_to_sec(10) assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore @@ -188,46 +188,46 @@ def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [2 / ns_to_sec(3)] assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.num_output_tokens == [4, 6] - assert metrics.num_input_tokens == [3, 4] + assert metrics.output_sequence_lengths == [4, 6] + assert metrics.input_sequence_lengths == [3, 4] assert stat["time_to_first_token"]["avg"] == pytest.approx(2.5) # type: ignore assert stat["inter_token_latency"]["avg"] == pytest.approx(2.5) # type: ignore assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat["num_output_token"]["avg"] == 5 # type: ignore - assert stat["num_input_token"]["avg"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["avg"] == 5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore assert stat["time_to_first_token"]["p50"] == pytest.approx(2.5) # type: ignore assert stat["inter_token_latency"]["p50"] == pytest.approx(2.5) # type: ignore assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat["num_output_token"]["p50"] == 5 # type: ignore - assert stat["num_input_token"]["p50"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["p50"] == 5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore assert stat["time_to_first_token"]["min"] == pytest.approx(2) # type: ignore assert stat["inter_token_latency"]["min"] == pytest.approx(1) # type: ignore min_ottpr = 4 / ns_to_sec(13) assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["num_output_token"]["min"] == 4 # type: ignore - assert stat["num_input_token"]["min"] == 3 # type: ignore + assert stat["output_sequence_length"]["min"] == 4 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore assert stat["time_to_first_token"]["max"] == pytest.approx(3) # type: ignore assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore max_ottpr = 6 / ns_to_sec(8) assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["num_output_token"]["max"] == 6 # type: ignore - assert stat["num_input_token"]["max"] == 4 # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore assert stat["time_to_first_token"]["std"] == np.std([2, 3]) * (1) # type: ignore assert stat["inter_token_latency"]["std"] == np.std([4, 1]) * (1) # type: ignore assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat["num_output_token"]["std"] == np.std([4, 6]) # type: ignore - assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore + assert stat["output_sequence_length"]["std"] == np.std([4, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore oott = 2 / ns_to_sec(3) assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore @@ -250,9 +250,9 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N - experiment 1: [3/(12 - 1), 6/(15 - 2)] = [3/11, 6/13] * output token throughputs - experiment 1: [(3 + 6)/(15 - 1)] = [9/14] - * num output tokens + * output sequence lengths - experiment 1: [3, 6] - * num input tokens + * input sequence lengths - experiment 1: [3, 4] """ tokenizer = get_tokenizer(DEFAULT_TOKENIZER) @@ -273,46 +273,46 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) ott = [9 / ns_to_sec(14)] assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.num_output_tokens == [3, 6] - assert metrics.num_input_tokens == [3, 4] + assert metrics.output_sequence_lengths == [3, 6] + assert metrics.input_sequence_lengths == [3, 4] assert stat["time_to_first_token"]["avg"] == pytest.approx(4.5) # type: ignore assert stat["inter_token_latency"]["avg"] == pytest.approx(3) # type: ignore assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore np.mean(ottpr) ) - assert stat["num_output_token"]["avg"] == 4.5 # type: ignore - assert stat["num_input_token"]["avg"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore assert stat["time_to_first_token"]["p50"] == pytest.approx(4.5) # type: ignore assert stat["inter_token_latency"]["p50"] == pytest.approx(3) # type: ignore assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore np.percentile(ottpr, 50) ) - assert stat["num_output_token"]["p50"] == 4.5 # type: ignore - assert stat["num_input_token"]["p50"] == 3.5 # type: ignore + assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore assert stat["time_to_first_token"]["min"] == pytest.approx(4) # type: ignore assert stat["inter_token_latency"]["min"] == pytest.approx(2) # type: ignore min_ottpr = 3 / ns_to_sec(11) assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["num_output_token"]["min"] == 3 # type: ignore - assert stat["num_input_token"]["min"] == 3 # type: ignore + assert stat["output_sequence_length"]["min"] == 3 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore assert stat["time_to_first_token"]["max"] == pytest.approx(5) # type: ignore assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore max_ottpr = 6 / ns_to_sec(13) assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["num_output_token"]["max"] == 6 # type: ignore - assert stat["num_input_token"]["max"] == 4 # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore assert stat["time_to_first_token"]["std"] == np.std([4, 5]) * (1) # type: ignore assert stat["inter_token_latency"]["std"] == np.std([4, 2]) * (1) # type: ignore assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore np.std(ottpr) ) - assert stat["num_output_token"]["std"] == np.std([3, 6]) # type: ignore - assert stat["num_input_token"]["std"] == np.std([3, 4]) # type: ignore + assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore oott = 9 / ns_to_sec(14) assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore @@ -423,8 +423,8 @@ def test_llm_metrics_get_base_name(self) -> None: inter_token_latencies=[4, 5], output_token_throughputs=[22.13, 9423.02], output_token_throughputs_per_request=[7, 8, 9], - num_output_tokens=[3, 4], - num_input_tokens=[12, 34], + output_sequence_lengths=[3, 4], + input_sequence_lengths=[12, 34], ) assert metrics.get_base_name("time_to_first_tokens") == "time_to_first_token" assert metrics.get_base_name("inter_token_latencies") == "inter_token_latency" @@ -432,8 +432,12 @@ def test_llm_metrics_get_base_name(self) -> None: metrics.get_base_name("output_token_throughputs_per_request") == "output_token_throughput_per_request" ) - assert metrics.get_base_name("num_output_tokens") == "num_output_token" - assert metrics.get_base_name("num_input_tokens") == "num_input_token" + assert ( + metrics.get_base_name("output_sequence_lengths") == "output_sequence_length" + ) + assert ( + metrics.get_base_name("input_sequence_lengths") == "input_sequence_length" + ) with pytest.raises(KeyError): metrics.get_base_name("hello1234") diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_plot_configs.py b/src/c++/perf_analyzer/genai-perf/tests/test_plot_configs.py index 1e1391e4c..8a1dfee7a 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_plot_configs.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_plot_configs.py @@ -51,11 +51,11 @@ class TestPlotConfigParser: output: test_output_1 plot2: - title: Num Input Token vs Num Output Token - x_metric: num_input_tokens - y_metric: num_output_tokens - x_label: Input Tokens - y_label: Output Tokens + title: Input Sequence Length vs Output Sequence Length + x_metric: input_sequence_lengths + y_metric: output_sequence_lengths + x_label: Input Sequence Length + y_label: Output Sequence Length width: 1234 height: 5678 type: scatter @@ -97,9 +97,9 @@ def test_generate_configs(self, monkeypatch) -> None: assert prd.y_metric == [1, 2, 3] # plot config 2 - assert pc2.title == "Num Input Token vs Num Output Token" - assert pc2.x_label == "Input Tokens" - assert pc2.y_label == "Output Tokens" + assert pc2.title == "Input Sequence Length vs Output Sequence Length" + assert pc2.x_label == "Input Sequence Length" + assert pc2.y_label == "Output Sequence Length" assert pc2.width == 1234 assert pc2.height == 5678 assert pc2.type == PlotType.SCATTER From 375c11ee2b75afa986b5917eaa0d9f05b1394cd1 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:21:58 -0700 Subject: [PATCH 22/55] Remove ITL from console when nonstreaming (#706) --- .../export_data/console_exporter.py | 2 +- .../genai-perf/tests/test_console_exporter.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py index 643e177ce..bbd02b75b 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py @@ -84,7 +84,7 @@ def export(self) -> None: # Without streaming, there is no inter-token latency available, so do not print it. if metric == "inter_token_latency": - if all(value == "-1" for value in row_values[1:]): + if all(float(value) < 0 for value in row_values[1:]): continue # Without streaming, TTFT and request latency are the same, so do not print TTFT. elif metric == "time_to_first_token": diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py index 53ea1702b..2bf41441d 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -26,6 +26,7 @@ from genai_perf.export_data.console_exporter import ConsoleExporter from genai_perf.export_data.exporter_config import ExporterConfig +from genai_perf.llm_metrics import LLMMetrics, Statistics class TestConsoleExporter: @@ -55,6 +56,40 @@ def test_pretty_print_output(self, capsys) -> None: assert returned_data == expected_content + def test_nonstreaming_llm_output(self, capsys) -> None: + metrics = LLMMetrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + time_to_first_tokens=[4, 5, 6], # same as request_latency + inter_token_latencies=[], # no ITL + output_token_throughputs=[456], + output_sequence_lengths=[1, 2, 3], + input_sequence_lengths=[5, 6, 7], + ) + stats = Statistics(metrics=metrics) + + config = ExporterConfig() + config.stats = stats.stats_dict + exporter = ConsoleExporter(config) + exporter.export() + + # No TTFT and ITL in the output + expected_content = ( + " LLM Metrics \n" + "┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┓\n" + "┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃\n" + "┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━┩\n" + "│ Request latency (ms) │ 5.00 │ 4.00 │ 6.00 │ 5.98 │ 5.80 │ 5.50 │\n" + "│ Output sequence length │ 2.00 │ 1.00 │ 3.00 │ 2.98 │ 2.80 │ 2.50 │\n" + "│ Input sequence length │ 6.00 │ 5.00 │ 7.00 │ 6.98 │ 6.80 │ 6.50 │\n" + "└────────────────────────┴──────┴──────┴──────┴──────┴──────┴──────┘\n" + "Output token throughput (per sec): 456.00\n" + "Request throughput (per sec): 123.00\n" + ) + + returned_data = capsys.readouterr().out + assert returned_data == expected_content + stats = { "request_throughput": {"unit": "requests/sec", "avg": 456.0}, From a346f147e09022dd2bc7540d8d809f8fe3c2213a Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:49:47 -0700 Subject: [PATCH 23/55] PA should check whether user-supplied inputs exist in the model (#612) --- CMakeLists.txt | 3 ++ src/c++/CMakeLists.txt | 6 +++ src/c++/perf_analyzer/data_loader.cc | 43 +++++++++++++++++++ src/c++/perf_analyzer/data_loader.h | 15 +++++++ src/c++/perf_analyzer/load_manager.cc | 2 + src/c++/perf_analyzer/test_dataloader.cc | 12 ++++++ .../test_request_rate_manager.cc | 24 +++++++---- 7 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9db890fc5..5628f0a27 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,9 @@ project(tritonclient LANGUAGES C CXX) # Use C++17 standard as Triton's minimum required. set(TRITON_MIN_CXX_STANDARD 17 CACHE STRING "The minimum C++ standard which features are requested to build this target.") +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + # # Options # diff --git a/src/c++/CMakeLists.txt b/src/c++/CMakeLists.txt index ab9810905..a54253172 100644 --- a/src/c++/CMakeLists.txt +++ b/src/c++/CMakeLists.txt @@ -28,6 +28,12 @@ cmake_minimum_required(VERSION 3.17) project(cc-clients LANGUAGES C CXX) +# Use C++17 standard as Triton's minimum required. +set(TRITON_MIN_CXX_STANDARD 17 CACHE STRING "The minimum C++ standard which features are requested to build this target.") + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + # # Options # diff --git a/src/c++/perf_analyzer/data_loader.cc b/src/c++/perf_analyzer/data_loader.cc index 3658f8a75..38bfe9403 100644 --- a/src/c++/perf_analyzer/data_loader.cc +++ b/src/c++/perf_analyzer/data_loader.cc @@ -38,6 +38,34 @@ DataLoader::DataLoader(const size_t batch_size) { } +cb::Error +DataLoader::ValidateIOExistsInModel( + const std::shared_ptr& inputs, + const std::shared_ptr& outputs, + const std::string& data_directory) +{ + if (!std::filesystem::exists(data_directory) || + !std::filesystem::is_directory(data_directory)) { + return cb::Error( + "Error: Directory does not exist or is not a directory: " + + std::string(data_directory), + pa::GENERIC_ERROR); + } + + for (const auto& file : std::filesystem::directory_iterator(data_directory)) { + std::string io_name = file.path().filename().string(); + if (inputs->find(io_name) == inputs->end() && + outputs->find(io_name) == outputs->end()) { + return cb::Error( + "Provided data file '" + io_name + + "' does not correspond to a valid model input or output.", + pa::GENERIC_ERROR); + } + } + + return cb::Error::Success; +} + cb::Error DataLoader::ReadDataFromDir( const std::shared_ptr& inputs, @@ -449,9 +477,11 @@ DataLoader::ReadTensorData( const std::shared_ptr& tensors, const int stream_index, const int step_index, const bool is_input) { + std::unordered_set model_io_names; auto& tensor_data = is_input ? input_data_ : output_data_; auto& tensor_shape = is_input ? input_shapes_ : output_shapes_; for (const auto& io : *tensors) { + model_io_names.insert(io.first); if (step.HasMember(io.first.c_str())) { std::string key_name( io.first + "_" + std::to_string(stream_index) + "_" + @@ -540,6 +570,19 @@ DataLoader::ReadTensorData( } } + // Add allowed non-model inputs/outputs to the model_io_names set + model_io_names.insert("model"); + + for (auto itr = step.MemberBegin(); itr != step.MemberEnd(); ++itr) { + if (model_io_names.find(itr->name.GetString()) == model_io_names.end()) { + return cb::Error( + "The input or output '" + std::string(itr->name.GetString()) + + "' is not found in the model configuration", + pa::GENERIC_ERROR); + } + } + + return cb::Error::Success; } diff --git a/src/c++/perf_analyzer/data_loader.h b/src/c++/perf_analyzer/data_loader.h index 0a7e91aec..2f83f959f 100644 --- a/src/c++/perf_analyzer/data_loader.h +++ b/src/c++/perf_analyzer/data_loader.h @@ -25,7 +25,9 @@ // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #pragma once +#include #include +#include #include "model_parser.h" #include "perf_utils.h" @@ -56,9 +58,22 @@ class DataLoader { return 0; } + /// Validate user-supplied inputs and outputs exist in the model + /// \param inputs The pointer to the map holding the information about + /// input tensors of a model + /// \param outputs The pointer to the map holding the information about + /// output tensors of a model + /// \param data_directory The path to the directory containing the data + cb::Error ValidateIOExistsInModel( + const std::shared_ptr& inputs, + const std::shared_ptr& outputs, + const std::string& data_directory); + /// Reads the input data from the specified data directory. /// \param inputs The pointer to the map holding the information about /// input tensors of a model + /// \param outputs The pointer to the map holding the information about + /// output tensors of a model /// \param data_directory The path to the directory containing the data cb::Error ReadDataFromDir( const std::shared_ptr& inputs, diff --git a/src/c++/perf_analyzer/load_manager.cc b/src/c++/perf_analyzer/load_manager.cc index ac9150a9d..1f648a7f4 100644 --- a/src/c++/perf_analyzer/load_manager.cc +++ b/src/c++/perf_analyzer/load_manager.cc @@ -218,6 +218,8 @@ LoadManager::InitManagerInputs( // Read provided data if (!user_data.empty()) { if (IsDirectory(user_data[0])) { + RETURN_IF_ERROR(data_loader_->ValidateIOExistsInModel( + parser_->Inputs(), parser_->Outputs(), user_data[0])); RETURN_IF_ERROR(data_loader_->ReadDataFromDir( parser_->Inputs(), parser_->Outputs(), user_data[0])); } else { diff --git a/src/c++/perf_analyzer/test_dataloader.cc b/src/c++/perf_analyzer/test_dataloader.cc index c9296fa3c..656571cb9 100644 --- a/src/c++/perf_analyzer/test_dataloader.cc +++ b/src/c++/perf_analyzer/test_dataloader.cc @@ -193,6 +193,18 @@ TEST_CASE("dataloader: ParseData: Misc error cases") expected_message = "missing tensor INPUT1 ( Location stream id: 0, step id: 0)"; } + SUBCASE("Invalid input") + { + json_str = R"({"data": + [{ + "INPUT1": [2], + "INVALID_INPUT": [2] + }] + })"; + expected_message = + "The input or output 'INVALID_INPUT' is not found in the model " + "configuration"; + } MockDataLoader dataloader; std::shared_ptr inputs = std::make_shared(); diff --git a/src/c++/perf_analyzer/test_request_rate_manager.cc b/src/c++/perf_analyzer/test_request_rate_manager.cc index e4870b95b..07b9016dd 100644 --- a/src/c++/perf_analyzer/test_request_rate_manager.cc +++ b/src/c++/perf_analyzer/test_request_rate_manager.cc @@ -509,7 +509,7 @@ class TestRequestRateManager : public TestLoadManagerBase, // CHECK( delay_average == - doctest::Approx(expected_delay_average).epsilon(0.01)); + doctest::Approx(expected_delay_average).epsilon(0.1)); CHECK_LT(delay_variance, max_allowed_delay_variance); } else { throw std::invalid_argument("Unexpected distribution type"); @@ -1008,21 +1008,22 @@ TEST_CASE( ModelTensor model_tensor2 = model_tensor1; model_tensor2.name_ = "INPUT2"; - std::string json_str{R"({ - "data": [ - { "INPUT1": [1], "INPUT2": [21] }, - { "INPUT1": [2], "INPUT2": [22] }, - { "INPUT1": [3], "INPUT2": [23] } - ]})"}; - size_t num_requests = 4; size_t num_threads = 1; + std::string json_str; const auto& ParameterizeTensors{[&]() { SUBCASE("one tensor") { tensors.push_back(model_tensor1); + json_str = R"({ + "data": [ + { "INPUT1": [1] }, + { "INPUT1": [2] }, + { "INPUT1": [3] } + ]})"; + switch (params.batch_size) { case 1: expected_results = {{1}, {2}, {3}, {1}}; @@ -1043,6 +1044,13 @@ TEST_CASE( tensors.push_back(model_tensor1); tensors.push_back(model_tensor2); + json_str = R"({ + "data": [ + { "INPUT1": [1], "INPUT2": [21] }, + { "INPUT1": [2], "INPUT2": [22] }, + { "INPUT1": [3], "INPUT2": [23] } + ]})"; + switch (params.batch_size) { case 1: expected_results = {{1, 21}, {2, 22}, {3, 23}, {1, 21}}; From 676d8b7fcda47fd082efd48d50c135adbb949956 Mon Sep 17 00:00:00 2001 From: Matthew Kotila Date: Mon, 24 Jun 2024 16:37:52 -0700 Subject: [PATCH 24/55] Update input data docs to include allowing outputs in provided directory (#705) --- src/c++/perf_analyzer/docs/input_data.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/c++/perf_analyzer/docs/input_data.md b/src/c++/perf_analyzer/docs/input_data.md index aa2448632..af2328fcd 100644 --- a/src/c++/perf_analyzer/docs/input_data.md +++ b/src/c++/perf_analyzer/docs/input_data.md @@ -37,9 +37,10 @@ of your model. You can select a different input data mode with the generates random data once per input and reuses that for all inferences - _zero_: Send zeros for each input. - directory path: A path to a directory containing a binary file for each input, - named the same as the input. Each binary file must contain the data required - for that input for a batch-1 request. Each file should contain the raw binary - representation of the input in row-major order. + named the same as the input (and optionally a binary file for each output for + validation, named the same as the output). Each binary file must contain the + data required for that input/output for a batch-1 request. Each file should + contain the raw binary representation of the input/output in row-major order. - file path: A path to a JSON file containing data to be used with every inference request. See the "Real Input Data" section for further details. [`--input-data`](cli.md#--input-datazerorandompath) can be provided multiple From 264aebf656480b36f045a533bb470199184719da Mon Sep 17 00:00:00 2001 From: Misha Chornyi <99709299+mc-nv@users.noreply.github.com> Date: Thu, 27 Jun 2024 19:16:18 -0700 Subject: [PATCH 25/55] Update GenAI-Perf development version (#718) --- src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py index 025456b0f..cb5c26999 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py @@ -24,4 +24,4 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.3dev" +__version__ = "0.0.4dev" From 5b2c1a4dd7dd14226c91c07622c8b80bd7780e61 Mon Sep 17 00:00:00 2001 From: Timothy Gerdes <50968584+tgerdesnv@users.noreply.github.com> Date: Fri, 28 Jun 2024 15:07:25 -0500 Subject: [PATCH 26/55] Clamp window for request-count (#695) * Clamp the window for request-count cases * unit test * remove commented out lines * Update src/c++/perf_analyzer/inference_profiler.cc Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> * Update src/c++/perf_analyzer/inference_profiler.h Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> * Update src/c++/perf_analyzer/inference_profiler.h Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> * Update src/c++/perf_analyzer/inference_profiler.h Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> * Update src/c++/perf_analyzer/inference_profiler.h Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> * add comment --------- Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> --- src/c++/perf_analyzer/inference_profiler.cc | 57 +++++++++++++++---- src/c++/perf_analyzer/inference_profiler.h | 28 ++++++--- .../perf_analyzer/test_inference_profiler.cc | 40 +++++++++++++ 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/src/c++/perf_analyzer/inference_profiler.cc b/src/c++/perf_analyzer/inference_profiler.cc index 57a339424..a36f51c10 100644 --- a/src/c++/perf_analyzer/inference_profiler.cc +++ b/src/c++/perf_analyzer/inference_profiler.cc @@ -723,13 +723,22 @@ InferenceProfiler::ProfileHelper( measurement_perf_status.request_rate = experiment_perf_status.request_rate; RETURN_IF_ERROR(manager_->CheckHealth()); + MeasureConfig measure_config; if (measurement_mode_ == MeasurementMode::TIME_WINDOWS) { - error.push( - Measure(measurement_perf_status, measurement_window_ms_, false)); + measure_config.measurement_window = measurement_window_ms_; + measure_config.is_count_based = false; } else { - error.push( - Measure(measurement_perf_status, measurement_request_count_, true)); + measure_config.measurement_window = measurement_request_count_; + measure_config.is_count_based = true; } + + // When request_count is not 0, the experiment will run for exactly X + // requests. In that case, we are not measuring based on window stability, + // and instead need to clamp the windows to be from the start of the + // first request to the end of the last request of the request count + // + measure_config.clamp_window = (request_count != 0); + error.push(Measure(measurement_perf_status, measure_config)); measurement_perf_statuses.push_back(measurement_perf_status); if (error.size() > load_parameters_.stability_window) { @@ -1169,8 +1178,7 @@ InferenceProfiler::GetServerSideStatus( // Used for measurement cb::Error -InferenceProfiler::Measure( - PerfStatus& perf_status, uint64_t measurement_window, bool is_count_based) +InferenceProfiler::Measure(PerfStatus& perf_status, MeasureConfig config) { std::map start_status; std::map end_status; @@ -1207,10 +1215,10 @@ InferenceProfiler::Measure( } } - if (!is_count_based) { + if (!config.is_count_based) { // Wait for specified time interval in msec std::this_thread::sleep_for( - std::chrono::milliseconds((uint64_t)(measurement_window_ms_ * 1.2))); + std::chrono::milliseconds((uint64_t)(config.measurement_window * 1.2))); } else { do { // Check the health of the worker threads. @@ -1218,7 +1226,7 @@ InferenceProfiler::Measure( // Wait for 1s until enough samples have been collected. std::this_thread::sleep_for(std::chrono::milliseconds((uint64_t)1000)); - } while (manager_->CountCollectedRequests() < measurement_window); + } while (manager_->CountCollectedRequests() < config.measurement_window); } uint64_t window_end_ns = @@ -1249,7 +1257,7 @@ InferenceProfiler::Measure( RETURN_IF_ERROR(Summarize( start_status, end_status, start_stat, end_stat, perf_status, - window_start_ns, window_end_ns)); + window_start_ns, window_end_ns, config.clamp_window)); return cb::Error::Success; } @@ -1259,7 +1267,8 @@ InferenceProfiler::Summarize( const std::map& start_status, const std::map& end_status, const cb::InferStat& start_stat, const cb::InferStat& end_stat, - PerfStatus& summary, uint64_t window_start_ns, uint64_t window_end_ns) + PerfStatus& summary, uint64_t window_start_ns, uint64_t window_end_ns, + bool clamp_window) { size_t valid_sequence_count = 0; size_t delayed_request_count = 0; @@ -1267,13 +1276,19 @@ InferenceProfiler::Summarize( // Get measurement from requests that fall within the time interval std::pair valid_range{window_start_ns, window_end_ns}; - uint64_t window_duration_ns = valid_range.second - valid_range.first; std::vector latencies; std::vector valid_requests{}; ValidLatencyMeasurement( valid_range, valid_sequence_count, delayed_request_count, &latencies, response_count, valid_requests); + + if (clamp_window) { + auto [start, end] = ClampWindow(valid_requests); + } + + uint64_t window_duration_ns = window_end_ns - window_start_ns; + if (should_collect_profile_data_) { CollectData( summary, window_start_ns, window_end_ns, std::move(valid_requests)); @@ -1366,6 +1381,24 @@ InferenceProfiler::ValidLatencyMeasurement( std::sort(valid_latencies->begin(), valid_latencies->end()); } +std::pair +InferenceProfiler::ClampWindow(std::vector& requests) +{ + auto earliest_start = + std::chrono::time_point::max(); + auto latest_end = std::chrono::time_point::min(); + + for (auto x : requests) { + earliest_start = std::min(earliest_start, x.start_time_); + latest_end = std::max(latest_end, x.response_timestamps_.back()); + } + + return std::make_pair( + earliest_start.time_since_epoch().count(), + latest_end.time_since_epoch().count()); +} + + void InferenceProfiler::CollectData( PerfStatus& summary, uint64_t window_start_ns, uint64_t window_end_ns, diff --git a/src/c++/perf_analyzer/inference_profiler.h b/src/c++/perf_analyzer/inference_profiler.h index cfd2a3b6e..a73651319 100644 --- a/src/c++/perf_analyzer/inference_profiler.h +++ b/src/c++/perf_analyzer/inference_profiler.h @@ -77,6 +77,13 @@ struct LoadStatus { uint64_t avg_latency = 0; }; +/// Configuration for the Measure function +struct MeasureConfig { + uint64_t measurement_window{0}; + bool is_count_based{false}; + bool clamp_window{false}; +}; + // Holds the total of the timiming components of composing models of an // ensemble. struct EnsembleDurations { @@ -475,14 +482,9 @@ class InferenceProfiler { /// Helper function to perform measurement. /// \param status_summary The summary of this measurement. - /// \param measurement_window Indicating the number of requests or the - /// duration in milliseconds to collect requests. - /// \param is_count_based determines whether measurement_window is indicating - /// time or count. + /// \param config The configuration for measurement. /// \return cb::Error object indicating success or failure. - cb::Error Measure( - PerfStatus& status_summary, uint64_t measurement_window, - bool is_count_based); + cb::Error Measure(PerfStatus& status_summary, MeasureConfig config); /// Gets the server side statistics /// \param model_status Returns the status of the models provided by @@ -501,12 +503,15 @@ class InferenceProfiler { /// \param summary Returns the summary of the measurement. /// \param window_start_ns The window start timestamp in nanoseconds. /// \param window_end_ns The window end timestamp in nanoseconds. + /// \param clamp_window If true, the actual window range is reduced to the + /// start of the first request to the final response. /// \return cb::Error object indicating success or failure. cb::Error Summarize( const std::map& start_status, const std::map& end_status, const cb::InferStat& start_stat, const cb::InferStat& end_stat, - PerfStatus& summary, uint64_t window_start_ns, uint64_t window_end_ns); + PerfStatus& summary, uint64_t window_start_ns, uint64_t window_end_ns, + bool clamp_window); /// \param valid_range The start and end timestamp of the measurement window. /// \param valid_sequence_count Returns the number of completed sequences @@ -522,6 +527,13 @@ class InferenceProfiler { std::vector* latencies, size_t& response_count, std::vector& valid_requests); + /// Clamp a window around a set of requests, from the earliest start time to + /// the latest response + /// \param requests A vector of requests to clamp the window around. + /// \return std::pair object containing of the window. + std::pair ClampWindow( + std::vector& requests); + /// Add the data from the request records to the Raw Data Collector /// \param perf_status PerfStatus of the current measurement /// \param window_start_ns The window start timestamp in nanoseconds. diff --git a/src/c++/perf_analyzer/test_inference_profiler.cc b/src/c++/perf_analyzer/test_inference_profiler.cc index 40813ce5b..2941867fc 100644 --- a/src/c++/perf_analyzer/test_inference_profiler.cc +++ b/src/c++/perf_analyzer/test_inference_profiler.cc @@ -107,6 +107,11 @@ class TestInferenceProfiler : public InferenceProfiler { return ip.IsDoneProfiling(ls, &is_stable); }; + std::pair ClampWindow(std::vector& reqs) + { + return InferenceProfiler::ClampWindow(reqs); + } + cb::Error MergeMetrics( const std::vector>& all_metrics, Metrics& merged_metrics) @@ -1060,6 +1065,41 @@ TEST_CASE( } } +TEST_CASE("clamp window") +{ + TestInferenceProfiler tip{}; + std::vector reqs{}; + + auto clock_epoch{std::chrono::time_point()}; + + auto request1_timestamp{clock_epoch + std::chrono::nanoseconds(5)}; + auto response1_timestamp{clock_epoch + std::chrono::nanoseconds(20)}; + + reqs.emplace_back( + request1_timestamp, + std::vector>{ + response1_timestamp}); + + auto request2_timestamp{clock_epoch + std::chrono::nanoseconds(3)}; + auto response2_timestamp{clock_epoch + std::chrono::nanoseconds(15)}; + reqs.emplace_back( + request2_timestamp, + std::vector>{ + response2_timestamp}); + + auto request3_timestamp{clock_epoch + std::chrono::nanoseconds(7)}; + auto response3_timestamp{clock_epoch + std::chrono::nanoseconds(17)}; + reqs.emplace_back( + request3_timestamp, + std::vector>{ + response3_timestamp}); + + auto window = tip.ClampWindow(reqs); + + CHECK(window.first == 3); + CHECK(window.second == 20); +} + TEST_CASE("summarize_client_stat: testing the SummarizeClientStat function") { MockInferenceProfiler mock_inference_profiler{}; From a45c580e3dcea25305292ac6bb678c8d53b13fdf Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:51:53 -0700 Subject: [PATCH 27/55] Reorganize metrics and data parser (#712) * Reorganize metrics and data parser * Add missing test file --- .../export_data/console_exporter.py | 2 +- .../genai_perf/export_data/csv_exporter.py | 2 +- .../genai_perf/export_data/output_reporter.py | 2 +- .../genai-perf/genai_perf/llm_metrics.py | 619 ------------------ .../genai-perf/genai_perf/main.py | 5 +- .../genai-perf/genai_perf/metrics/__init__.py | 29 + .../genai_perf/metrics/llm_metrics.py | 69 ++ .../genai-perf/genai_perf/metrics/metrics.py | 96 +++ .../genai_perf/metrics/statistics.py | 188 ++++++ .../genai_perf/plots/plot_config_parser.py | 3 +- .../profile_data_parser/__init__.py | 28 + .../llm_profile_data_parser.py | 293 +++++++++ .../profile_data_parser.py | 97 +++ .../genai-perf/tests/test_console_exporter.py | 2 +- .../genai-perf/tests/test_csv_exporter.py | 2 +- .../genai-perf/tests/test_llm_metrics.py | 559 +--------------- .../tests/test_llm_profile_data_parser.py | 587 +++++++++++++++++ 17 files changed, 1398 insertions(+), 1185 deletions(-) delete mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py create mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py create mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py create mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py create mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py create mode 100755 src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py index bbd02b75b..8f910b697 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py @@ -26,7 +26,7 @@ from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.llm_metrics import Metrics +from genai_perf.metrics import Metrics from rich.console import Console from rich.table import Table diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py index 3677fe357..a763417ad 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py @@ -29,7 +29,7 @@ import genai_perf.logging as logging from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.llm_metrics import Metrics +from genai_perf.metrics import Metrics DEFAULT_OUTPUT_DATA_CSV = "profile_export_genai_perf.csv" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py index 0189ccfaf..9733709ea 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py @@ -29,7 +29,7 @@ from genai_perf.export_data.data_exporter_factory import DataExporterFactory from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.llm_metrics import Statistics +from genai_perf.metrics import Statistics from genai_perf.parser import get_extra_inputs_as_dict diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py deleted file mode 100755 index 05c1ce59f..000000000 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_metrics.py +++ /dev/null @@ -1,619 +0,0 @@ -#!/usr/bin/env python3 - -# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# * Neither the name of NVIDIA CORPORATION nor the names of its -# contributors may be used to endorse or promote products derived -# from this software without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY -# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY -# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -import csv -import json -from collections import defaultdict -from enum import Enum, auto -from itertools import tee -from pathlib import Path -from typing import Dict, List, Tuple, Union - -import numpy as np -import pandas as pd -from genai_perf.tokenizer import Tokenizer -from genai_perf.utils import load_json, remove_sse_prefix -from rich.console import Console -from rich.table import Table - - -class ResponseFormat(Enum): - OPENAI_CHAT_COMPLETIONS = auto() - OPENAI_COMPLETIONS = auto() - TRITON = auto() - - -class Metrics: - """A base class for all the metrics class that contains common metrics.""" - - metric_labels = [ - "time_to_first_token", - "inter_token_latency", - "request_latency", - "output_token_throughput", - "output_token_throughput_per_request", - "request_throughput", - "output_sequence_length", - "input_sequence_length", - ] - - time_fields = [ - "inter_token_latency", - "time_to_first_token", - "request_latency", - ] - - # TODO (TMA-1678): output_token_throughput_per_request is not on this list - # since the current code treats all the throughput metrics to be displayed - # outside of the statistics table. - throughput_fields = [ - "request_throughput", - "output_token_throughput", - ] - - def __init__( - self, - request_throughputs: List[float] = [], - request_latencies: List[int] = [], - ) -> None: - self.request_throughputs = request_throughputs - self.request_latencies = request_latencies - self._base_names = { - "request_throughputs": "request_throughput", - "request_latencies": "request_latency", - } - - def __repr__(self): - attr_strs = [] - for k, v in self.__dict__.items(): - if not k.startswith("_"): - attr_strs.append(f"{k}={v}") - return f"Metrics({','.join(attr_strs)})" - - @property - def data(self) -> dict: - """Returns all the metrics.""" - return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} - - def get_base_name(self, metric_name: str) -> str: - """Returns singular name of a given metric.""" - if metric_name in self._base_names: - return self._base_names[metric_name] - else: - raise KeyError(f"No metric named '{metric_name}' exists.") - - -class LLMMetrics(Metrics): - """A simple dataclass that holds core LLM performance metrics.""" - - def __init__( - self, - request_throughputs: List[float] = [], - request_latencies: List[int] = [], - time_to_first_tokens: List[int] = [], - inter_token_latencies: List[int] = [], - output_token_throughputs: List[float] = [], - output_token_throughputs_per_request: List[int] = [], - output_sequence_lengths: List[int] = [], - input_sequence_lengths: List[int] = [], - chunked_inter_token_latencies: List[List[int]] = [[]], - ) -> None: - super().__init__(request_throughputs, request_latencies) - self.time_to_first_tokens = time_to_first_tokens - self.inter_token_latencies = inter_token_latencies - self.output_token_throughputs = output_token_throughputs - self.output_token_throughputs_per_request = output_token_throughputs_per_request - self.output_sequence_lengths = output_sequence_lengths - self.input_sequence_lengths = input_sequence_lengths - - # Keeping chunked ITL (old) as a WAR to preserve visualization. - # Excluded from data. - self._chunked_inter_token_latencies = chunked_inter_token_latencies - - # add base name mapping - self._base_names["time_to_first_tokens"] = "time_to_first_token" - self._base_names["inter_token_latencies"] = "inter_token_latency" - self._base_names["output_token_throughputs"] = "output_token_throughput" - self._base_names["output_token_throughputs_per_request"] = ( - "output_token_throughput_per_request" - ) - self._base_names["output_sequence_lengths"] = "output_sequence_length" - self._base_names["input_sequence_lengths"] = "input_sequence_length" - - -class Statistics: - """A class that aggregates various statistics from given metrics class. - - The Statistics class goes through each metric in the metrics class and - calculates several statistics such as: - - average (arithmetic mean) - - percentiles (p25, p50, p75, p90, p95, p99) - - minimum & maximum - - standard deviation - The class will store each calculated statistics as part of its attribute. - - Example: - - >>> metrics = LLMMetrics(request_throughputs=[2, 4]) - >>> stats = Statistics(metrics) - >>> print(stats.avg_request_throughput) # output: 3 - """ - - def __init__(self, metrics: Metrics): - # iterate through Metrics to calculate statistics and set attributes - self._metrics = metrics - self._stats_dict: Dict = defaultdict(dict) - for attr, data in metrics.data.items(): - if self._should_skip(data, attr): - continue - - attr = metrics.get_base_name(attr) - self._add_units(attr) - self._calculate_mean(data, attr) - if not self._is_throughput_field(attr): - self._calculate_percentiles(data, attr) - self._calculate_minmax(data, attr) - self._calculate_std(data, attr) - - def _should_skip(self, data: List[Union[int, float]], attr: str) -> bool: - """Checks if some metrics should be skipped.""" - # No data points - if len(data) == 0: - return True - # Skip ITL when non-streaming (all zero) - elif attr == "inter_token_latencies" and sum(data) == 0: - return True - return False - - def _calculate_mean(self, data: List[Union[int, float]], attr: str) -> None: - avg = np.mean(data) - setattr(self, "avg_" + attr, avg) - self._stats_dict[attr]["avg"] = float(avg) - - def _calculate_percentiles(self, data: List[Union[int, float]], attr: str) -> None: - p25, p50, p75 = np.percentile(data, [25, 50, 75]) - p90, p95, p99 = np.percentile(data, [90, 95, 99]) - setattr(self, "p25_" + attr, p25) - setattr(self, "p50_" + attr, p50) - setattr(self, "p75_" + attr, p75) - setattr(self, "p90_" + attr, p90) - setattr(self, "p95_" + attr, p95) - setattr(self, "p99_" + attr, p99) - self._stats_dict[attr]["p99"] = float(p99) - self._stats_dict[attr]["p95"] = float(p95) - self._stats_dict[attr]["p90"] = float(p90) - self._stats_dict[attr]["p75"] = float(p75) - self._stats_dict[attr]["p50"] = float(p50) - self._stats_dict[attr]["p25"] = float(p25) - - def _calculate_minmax(self, data: List[Union[int, float]], attr: str) -> None: - min, max = np.min(data), np.max(data) - setattr(self, "min_" + attr, min) - setattr(self, "max_" + attr, max) - self._stats_dict[attr]["max"] = float(max) - self._stats_dict[attr]["min"] = float(min) - - def _calculate_std(self, data: List[Union[int, float]], attr: str) -> None: - std = np.std(data) - setattr(self, "std_" + attr, std) - self._stats_dict[attr]["std"] = float(std) - - def scale_data(self, factor: float = 1 / 1e6) -> None: - for k1, v1 in self.stats_dict.items(): - if self._is_time_field(k1): - for k2, v2 in v1.items(): - if k2 != "unit": - self.stats_dict[k1][k2] = self._scale(v2, factor) - - def _scale(self, metric: float, factor: float = 1 / 1e6) -> float: - """ - Scale metrics from nanoseconds by factor. - Default is nanoseconds to milliseconds. - """ - return metric * factor - - def _add_units(self, key) -> None: - if self._is_time_field(key): - self._stats_dict[key]["unit"] = "ms" - if key == "request_throughput": - self._stats_dict[key]["unit"] = "requests/sec" - if key.startswith("output_token_throughput"): - self._stats_dict[key]["unit"] = "tokens/sec" - if "sequence_length" in key: - self._stats_dict[key]["unit"] = "tokens" - - def __repr__(self) -> str: - attr_strs = [] - for k, v in self.__dict__.items(): - if not k.startswith("_"): - attr_strs.append(f"{k}={v}") - return f"Statistics({','.join(attr_strs)})" - - @property - def data(self) -> dict: - """Return all the aggregated statistics.""" - return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} - - @property - def metrics(self) -> Metrics: - """Return the underlying metrics used to calculate the statistics.""" - return self._metrics - - @property - def stats_dict(self) -> Dict: - return self._stats_dict - - def _is_throughput_field(self, field: str) -> bool: - return field in Metrics.throughput_fields - - def _is_time_field(self, field: str) -> bool: - return field in Metrics.time_fields - - def export_parquet(self, artifact_dir: Path, filename: str) -> None: - max_length = -1 - col_index = 0 - filler_list = [] - df = pd.DataFrame() - - # Data frames require all columns of the same length - # find the max length column - for key, value in self._metrics.data.items(): - max_length = max(max_length, len(value)) - - # Insert None for shorter columns to match longest column - for key, value in self._metrics.data.items(): - if len(value) < max_length: - diff = max_length - len(value) - filler_list = [None] * diff - df.insert(col_index, key, value + filler_list) - diff = 0 - filler_list = [] - col_index = col_index + 1 - - filepath = artifact_dir / f"{filename}.gzip" - df.to_parquet(filepath, compression="gzip") - - -class ProfileDataParser: - """Base profile data parser class that reads the profile data JSON file to - extract core metrics and calculate various performance statistics. - """ - - def __init__(self, filename: Path) -> None: - data = load_json(filename) - self._get_profile_metadata(data) - self._parse_profile_data(data) - - def _get_profile_metadata(self, data: dict) -> None: - self._service_kind = data["service_kind"] - if self._service_kind == "openai": - if data["endpoint"] == "v1/chat/completions": - self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS - elif data["endpoint"] == "v1/completions": - self._response_format = ResponseFormat.OPENAI_COMPLETIONS - else: - # TPA-66: add PA metadata to handle this case - # When endpoint field is either empty or custom endpoint, fall - # back to parsing the response to extract the response format. - request = data["experiments"][0]["requests"][0] - response = request["response_outputs"][0]["response"] - if "chat.completion" in response: - self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS - elif "text_completion" in response: - self._response_format = ResponseFormat.OPENAI_COMPLETIONS - else: - raise RuntimeError("Unknown OpenAI response format.") - - elif self._service_kind == "triton": - self._response_format = ResponseFormat.TRITON - else: - raise ValueError(f"Unknown service kind: {self._service_kind}") - - def _parse_profile_data(self, data: dict) -> None: - """Parse through the entire profile data to collect statistics.""" - self._profile_results = {} - for experiment in data["experiments"]: - infer_mode = experiment["experiment"]["mode"] - load_level = experiment["experiment"]["value"] - requests = experiment["requests"] - - metrics = self._parse_requests(requests) - - # aggregate and calculate statistics - statistics = Statistics(metrics) - self._profile_results[(infer_mode, str(load_level))] = statistics - - def _parse_requests(self, requests: dict) -> LLMMetrics: - """Parse each request in profile data to extract core metrics.""" - raise NotImplementedError - - def get_statistics(self, infer_mode: str, load_level: str) -> Statistics: - """Return profile statistics if it exists.""" - if (infer_mode, load_level) not in self._profile_results: - raise KeyError(f"Profile with {infer_mode}={load_level} does not exist.") - return self._profile_results[(infer_mode, load_level)] - - def get_profile_load_info(self) -> List[Tuple[str, str]]: - """Return available (infer_mode, load_level) tuple keys.""" - return [k for k, _ in self._profile_results.items()] - - -class LLMProfileDataParser(ProfileDataParser): - """A class that calculates and aggregates all the LLM performance statistics - across the Perf Analyzer profile results. - - The LLMProfileDataParser class parses profile export JSON file, collects the - core LLM performance metrics, and calculates summary statistics for each - different Perf Analyzer runs/experiments. - - Example: - - >>> ... # run Perf Analyzer with concurrency level 10 - >>> - >>> from transformers import AutoTokenizer - >>> - >>> tokenizer = AutoTokenizer.from_pretrained("gpt2") - >>> pd = LLMProfileDataParser( - >>> filename="profile_export.json", - >>> tokenizer=tokenizer, - >>> ) - >>> stats = pd.get_statistics(infer_mode="concurrency", level=10) - >>> - >>> print(stats) # output: Statistics(avg_time_to_first_token=...) - >>> stats.pretty_print() # Output: time_to_first_token_s: ... - """ - - def __init__( - self, - filename: Path, - tokenizer: Tokenizer, - ) -> None: - self._tokenizer = tokenizer - super().__init__(filename) - - def _parse_requests(self, requests: dict) -> LLMMetrics: - """Parse each requests in profile export data to extract key metrics.""" - min_req_timestamp, max_res_timestamp = float("inf"), 0 - request_latencies = [] - time_to_first_tokens = [] - inter_token_latencies = [] - output_token_throughputs_per_request = [] - input_sequence_lengths = [] - output_sequence_lengths = [] - chunked_inter_token_latencies = [] - - for request in requests: - req_timestamp = request["timestamp"] - req_inputs = request["request_inputs"] - res_timestamps = request["response_timestamps"] - res_outputs = request["response_outputs"] - - self._preprocess_response(res_timestamps, res_outputs) - - # Skip requests with empty response. This happens sometimes when the - # model returns a single response with empty string. - if not res_timestamps: - continue - - # track entire benchmark duration - min_req_timestamp = min(min_req_timestamp, req_timestamp) - max_res_timestamp = max(max_res_timestamp, res_timestamps[-1]) - - # request latencies - req_latency_ns = res_timestamps[-1] - req_timestamp - request_latencies.append(req_latency_ns) # nanosec - req_latency_s = req_latency_ns / 1e9 # sec - - # time to first token - ttft = res_timestamps[0] - req_timestamp - time_to_first_tokens.append(ttft) - - # number of input tokens - input_seq_len = self._get_input_token_count(req_inputs) - input_sequence_lengths.append(input_seq_len) - - # output token throughput per request - output_token_counts, total_output_token = self._get_output_token_counts( - res_outputs - ) - output_token_throughputs_per_request.append( - total_output_token / req_latency_s - ) - output_sequence_lengths.append(total_output_token) - - # inter token latencies - if total_output_token > 1: - inter_token_latency = (req_latency_ns - ttft) / (total_output_token - 1) - inter_token_latencies.append(round(inter_token_latency)) - - # The new ITL calculation above loses all token-level ITL information - # and as a result breaks ITL vs token position visualization. Keep - # the old version of inter token latency as a WAR to preserve the - # visualization. - chunked_inter_token_latency = [] - for (t1, _), (t2, n2) in self._pairwise( - zip(res_timestamps, output_token_counts) - ): - # TMA-1676: handle empty first/last responses - # if the latter response has zero token (e.g. empty string), - # then set it default to one for the sake of inter token latency - # calculation and to avoid divide by zero. - num_token = 1 if n2 == 0 else n2 - chunked_inter_token_latency.append(round((t2 - t1) / num_token)) - chunked_inter_token_latencies.append(chunked_inter_token_latency) - - # request & output token throughput - benchmark_duration = (max_res_timestamp - min_req_timestamp) / 1e9 # nanosec - request_throughputs = [len(requests) / benchmark_duration] - output_token_throughputs = [sum(output_sequence_lengths) / benchmark_duration] - - return LLMMetrics( - request_throughputs, - request_latencies, - time_to_first_tokens, - inter_token_latencies, - output_token_throughputs, - output_token_throughputs_per_request, - output_sequence_lengths, - input_sequence_lengths, - chunked_inter_token_latencies, - ) - - def _pairwise(self, iterable): - """Generate pairs of consecutive elements from the given iterable.""" - a, b = tee(iterable) - next(b, None) - return zip(a, b) - - def _preprocess_response( - self, res_timestamps: List[int], res_outputs: List[Dict[str, str]] - ) -> None: - """Helper function to preprocess responses of a request.""" - if self._service_kind == "openai": - # PA sometimes receives multiple SSE responses at once (as a single - # response). Handle these responses by merging into a single response. - for i in range(len(res_outputs)): - response = res_outputs[i]["response"] - responses = response.strip().split("\n\n") - if len(responses) > 1: - merged_response = json.loads(remove_sse_prefix(responses[0])) - if ( - merged_response["choices"][0]["delta"].get("content", None) - is None - ): - merged_response["choices"][0]["delta"]["content"] = "" - for r in responses[1:]: - text = self._extract_openai_text_output(r) - merged_response["choices"][0]["delta"]["content"] += text - - res_outputs[i] = {"response": json.dumps(merged_response)} - - # Remove responses without any content - indices_to_remove = [] - for idx, out in enumerate(res_outputs): - if self._is_openai_empty_response(out["response"]): - indices_to_remove.append(idx) - indices_to_remove.sort(reverse=True) - for index in indices_to_remove: - res_timestamps.pop(index) - res_outputs.pop(index) - - def _get_input_token_count(self, req_inputs: dict) -> int: - """Deserialize the request input and return tokenized inputs.""" - if self._service_kind == "triton": - input_text = req_inputs["text_input"] - elif self._service_kind == "openai": - input_text = self._get_openai_input_text(req_inputs) - else: - raise ValueError(f"Unknown service kind: '{self._service_kind}'.") - - return len(self._tokenizer.encode(input_text)) - - def _get_openai_input_text(self, req_inputs: dict) -> str: - """Tokenize the OpenAI request input texts.""" - payload = json.loads(req_inputs["payload"]) - if self._response_format == ResponseFormat.OPENAI_CHAT_COMPLETIONS: - return payload["messages"][0]["content"] - elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: - return payload["prompt"] - else: - raise ValueError( - "Failed to parse OpenAI request input in profile export file." - ) - - def _get_output_token_counts( - self, res_outputs: List[Dict] - ) -> Tuple[List[int], int]: - """Return response-level token counts and total token count.""" - if self._service_kind == "triton": - output_texts = self._get_triton_output_tokens(res_outputs) - elif self._service_kind == "openai": - output_texts = self._get_openai_output_tokens(res_outputs) - else: - raise ValueError(f"Unknown service kind: '{self._service_kind}'.") - - full_text_token_count = len(self._tokenizer.encode("".join(output_texts))) - - output_tokens = self._get_response_output_tokens(output_texts) - output_token_counts = list(map(len, output_tokens)) - return output_token_counts, full_text_token_count - - def _get_triton_output_tokens(self, res_outputs: List[Dict]) -> List[str]: - """Return a list of Triton response texts.""" - return [r["text_output"] for r in res_outputs] - - def _get_openai_output_tokens(self, res_outputs: List[Dict]) -> List[str]: - """Return a list of OpenAI response texts.""" - output_texts = [] - for output in res_outputs: - text = self._extract_openai_text_output(output["response"]) - output_texts.append(text) - return output_texts - - def _get_response_output_tokens(self, output_texts: List[str]) -> List[List[int]]: - """Return a list of response output tokens.""" - # Exclamation mark trick forces the llama tokenization to consistently - # start each output with a specific token which allows us to safely skip - # the first token of every tokenized output and get only the ones that - # are returned by the model - encodings = self._tokenizer(["!" + txt for txt in output_texts]) - return [out[1:] for out in encodings.data["input_ids"]] - - def _extract_openai_text_output(self, response: str) -> str: - """Extracts text/content of the OpenAI response object.""" - response = remove_sse_prefix(response) - - if response == "[DONE]": - return "" - - data = json.loads(response) - completions = data["choices"][0] - - text_output = "" - if "object" not in data: - # FIXME: TPA-47 workaround for vLLM not following OpenAI Completions - # API specification when streaming, missing 'object' field: - # https://platform.openai.com/docs/api-reference/completions - text_output = completions.get("text", "") - elif data["object"] == "text_completion": # legacy - text_output = completions.get("text", "") - elif data["object"] == "chat.completion": # non-streaming - text_output = completions["message"].get("content", "") - elif data["object"] == "chat.completion.chunk": # streaming - text_output = completions["delta"].get("content", "") - else: - obj_type = data["object"] - raise ValueError(f"Unknown OpenAI response object type '{obj_type}'.") - return text_output - - def _is_openai_empty_response(self, response: str) -> bool: - """Returns true if the response is an openai response with no content (or empty content)""" - text = self._extract_openai_text_output(response) - if text: - return False - return True diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index 65b765d82..e8f43a91b 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -33,13 +33,12 @@ import genai_perf.logging as logging from genai_perf import parser -from genai_perf.constants import DEFAULT_PARQUET_FILE from genai_perf.exceptions import GenAIPerfException from genai_perf.export_data.output_reporter import OutputReporter from genai_perf.llm_inputs.llm_inputs import LlmInputs -from genai_perf.llm_metrics import LLMProfileDataParser from genai_perf.plots.plot_config_parser import PlotConfigParser from genai_perf.plots.plot_manager import PlotManager +from genai_perf.profile_data_parser import LLMProfileDataParser, ProfileDataParser from genai_perf.tokenizer import Tokenizer, get_tokenizer @@ -83,7 +82,7 @@ def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: ) -def calculate_metrics(args: Namespace, tokenizer: Tokenizer) -> LLMProfileDataParser: +def calculate_metrics(args: Namespace, tokenizer: Tokenizer) -> ProfileDataParser: return LLMProfileDataParser( filename=args.profile_export_file, tokenizer=tokenizer, diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py new file mode 100644 index 000000000..50a63e709 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py @@ -0,0 +1,29 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from genai_perf.metrics.llm_metrics import LLMMetrics +from genai_perf.metrics.metrics import Metrics, ResponseFormat +from genai_perf.metrics.statistics import Statistics diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py new file mode 100755 index 000000000..a0d1365fb --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from typing import List + +from genai_perf.metrics.metrics import Metrics + + +class LLMMetrics(Metrics): + """A simple dataclass that holds core LLM performance metrics.""" + + def __init__( + self, + request_throughputs: List[float] = [], + request_latencies: List[int] = [], + time_to_first_tokens: List[int] = [], + inter_token_latencies: List[int] = [], + output_token_throughputs: List[float] = [], + output_token_throughputs_per_request: List[int] = [], + output_sequence_lengths: List[int] = [], + input_sequence_lengths: List[int] = [], + chunked_inter_token_latencies: List[List[int]] = [[]], + ) -> None: + super().__init__(request_throughputs, request_latencies) + self.time_to_first_tokens = time_to_first_tokens + self.inter_token_latencies = inter_token_latencies + self.output_token_throughputs = output_token_throughputs + self.output_token_throughputs_per_request = output_token_throughputs_per_request + self.output_sequence_lengths = output_sequence_lengths + self.input_sequence_lengths = input_sequence_lengths + + # Keeping chunked ITL (old) as a WAR to preserve visualization. + # Excluded from data. + self._chunked_inter_token_latencies = chunked_inter_token_latencies + + # add base name mapping + self._base_names["time_to_first_tokens"] = "time_to_first_token" + self._base_names["inter_token_latencies"] = "inter_token_latency" + self._base_names["output_token_throughputs"] = "output_token_throughput" + self._base_names["output_token_throughputs_per_request"] = ( + "output_token_throughput_per_request" + ) + self._base_names["output_sequence_lengths"] = "output_sequence_length" + self._base_names["input_sequence_lengths"] = "input_sequence_length" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py new file mode 100755 index 000000000..c68cf542e --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from enum import Enum, auto +from typing import List + + +class ResponseFormat(Enum): + OPENAI_CHAT_COMPLETIONS = auto() + OPENAI_COMPLETIONS = auto() + TRITON = auto() + + +class Metrics: + """A base class for all the metrics class that contains common metrics.""" + + metric_labels = [ + "time_to_first_token", + "inter_token_latency", + "request_latency", + "output_token_throughput", + "output_token_throughput_per_request", + "request_throughput", + "output_sequence_length", + "input_sequence_length", + ] + + time_fields = [ + "inter_token_latency", + "time_to_first_token", + "request_latency", + ] + + # TODO (TMA-1678): output_token_throughput_per_request is not on this list + # since the current code treats all the throughput metrics to be displayed + # outside of the statistics table. + throughput_fields = [ + "request_throughput", + "output_token_throughput", + ] + + def __init__( + self, + request_throughputs: List[float] = [], + request_latencies: List[int] = [], + ) -> None: + self.request_throughputs = request_throughputs + self.request_latencies = request_latencies + self._base_names = { + "request_throughputs": "request_throughput", + "request_latencies": "request_latency", + } + + def __repr__(self): + attr_strs = [] + for k, v in self.__dict__.items(): + if not k.startswith("_"): + attr_strs.append(f"{k}={v}") + return f"Metrics({','.join(attr_strs)})" + + @property + def data(self) -> dict: + """Returns all the metrics.""" + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} + + def get_base_name(self, metric_name: str) -> str: + """Returns singular name of a given metric.""" + if metric_name in self._base_names: + return self._base_names[metric_name] + else: + raise KeyError(f"No metric named '{metric_name}' exists.") diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py new file mode 100755 index 000000000..36c8cb9b6 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 + +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Union + +import numpy as np +import pandas as pd +from genai_perf.metrics.metrics import Metrics + + +class Statistics: + """A class that aggregates various statistics from given metrics class. + + The Statistics class goes through each metric in the metrics class and + calculates several statistics such as: + - average (arithmetic mean) + - percentiles (p25, p50, p75, p90, p95, p99) + - minimum & maximum + - standard deviation + The class will store each calculated statistics as part of its attribute. + + Example: + + >>> metrics = LLMMetrics(request_throughputs=[2, 4]) + >>> stats = Statistics(metrics) + >>> print(stats.avg_request_throughput) # output: 3 + """ + + def __init__(self, metrics: Metrics): + # iterate through Metrics to calculate statistics and set attributes + self._metrics = metrics + self._stats_dict: Dict = defaultdict(dict) + for attr, data in metrics.data.items(): + if self._should_skip(data, attr): + continue + + attr = metrics.get_base_name(attr) + self._add_units(attr) + self._calculate_mean(data, attr) + if not self._is_throughput_field(attr): + self._calculate_percentiles(data, attr) + self._calculate_minmax(data, attr) + self._calculate_std(data, attr) + + def _should_skip(self, data: List[Union[int, float]], attr: str) -> bool: + """Checks if some metrics should be skipped.""" + # No data points + if len(data) == 0: + return True + # Skip ITL when non-streaming (all zero) + elif attr == "inter_token_latencies" and sum(data) == 0: + return True + return False + + def _calculate_mean(self, data: List[Union[int, float]], attr: str) -> None: + avg = np.mean(data) + setattr(self, "avg_" + attr, avg) + self._stats_dict[attr]["avg"] = float(avg) + + def _calculate_percentiles(self, data: List[Union[int, float]], attr: str) -> None: + p25, p50, p75 = np.percentile(data, [25, 50, 75]) + p90, p95, p99 = np.percentile(data, [90, 95, 99]) + setattr(self, "p25_" + attr, p25) + setattr(self, "p50_" + attr, p50) + setattr(self, "p75_" + attr, p75) + setattr(self, "p90_" + attr, p90) + setattr(self, "p95_" + attr, p95) + setattr(self, "p99_" + attr, p99) + self._stats_dict[attr]["p99"] = float(p99) + self._stats_dict[attr]["p95"] = float(p95) + self._stats_dict[attr]["p90"] = float(p90) + self._stats_dict[attr]["p75"] = float(p75) + self._stats_dict[attr]["p50"] = float(p50) + self._stats_dict[attr]["p25"] = float(p25) + + def _calculate_minmax(self, data: List[Union[int, float]], attr: str) -> None: + min, max = np.min(data), np.max(data) + setattr(self, "min_" + attr, min) + setattr(self, "max_" + attr, max) + self._stats_dict[attr]["max"] = float(max) + self._stats_dict[attr]["min"] = float(min) + + def _calculate_std(self, data: List[Union[int, float]], attr: str) -> None: + std = np.std(data) + setattr(self, "std_" + attr, std) + self._stats_dict[attr]["std"] = float(std) + + def scale_data(self, factor: float = 1 / 1e6) -> None: + for k1, v1 in self.stats_dict.items(): + if self._is_time_field(k1): + for k2, v2 in v1.items(): + if k2 != "unit": + self.stats_dict[k1][k2] = self._scale(v2, factor) + + def _scale(self, metric: float, factor: float = 1 / 1e6) -> float: + """ + Scale metrics from nanoseconds by factor. + Default is nanoseconds to milliseconds. + """ + return metric * factor + + def _add_units(self, key) -> None: + if self._is_time_field(key): + self._stats_dict[key]["unit"] = "ms" + if key == "request_throughput": + self._stats_dict[key]["unit"] = "requests/sec" + if key.startswith("output_token_throughput"): + self._stats_dict[key]["unit"] = "tokens/sec" + if "sequence_length" in key: + self._stats_dict[key]["unit"] = "tokens" + + def __repr__(self) -> str: + attr_strs = [] + for k, v in self.__dict__.items(): + if not k.startswith("_"): + attr_strs.append(f"{k}={v}") + return f"Statistics({','.join(attr_strs)})" + + @property + def data(self) -> dict: + """Return all the aggregated statistics.""" + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} + + @property + def metrics(self) -> Metrics: + """Return the underlying metrics used to calculate the statistics.""" + return self._metrics + + @property + def stats_dict(self) -> Dict: + return self._stats_dict + + def _is_throughput_field(self, field: str) -> bool: + return field in Metrics.throughput_fields + + def _is_time_field(self, field: str) -> bool: + return field in Metrics.time_fields + + def export_parquet(self, artifact_dir: Path, filename: str) -> None: + max_length = -1 + col_index = 0 + filler_list = [] + df = pd.DataFrame() + + # Data frames require all columns of the same length + # find the max length column + for key, value in self._metrics.data.items(): + max_length = max(max_length, len(value)) + + # Insert None for shorter columns to match longest column + for key, value in self._metrics.data.items(): + if len(value) < max_length: + diff = max_length - len(value) + filler_list = [None] * diff + df.insert(col_index, key, value + filler_list) + diff = 0 + filler_list = [] + col_index = col_index + 1 + + filepath = artifact_dir / f"{filename}.gzip" + df.to_parquet(filepath, compression="gzip") diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py index 1072bc30f..00588f6bb 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/plots/plot_config_parser.py @@ -33,8 +33,9 @@ # Skip type checking to avoid mypy error # Issue: https://github.com/python/mypy/issues/10632 import yaml # type: ignore -from genai_perf.llm_metrics import LLMProfileDataParser, Statistics +from genai_perf.metrics import Statistics from genai_perf.plots.plot_config import PlotConfig, PlotType, ProfileRunData +from genai_perf.profile_data_parser import LLMProfileDataParser from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer from genai_perf.utils import load_yaml, scale diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py new file mode 100644 index 000000000..55859f4bc --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py @@ -0,0 +1,28 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from genai_perf.profile_data_parser.llm_profile_data_parser import LLMProfileDataParser +from genai_perf.profile_data_parser.profile_data_parser import ProfileDataParser diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py new file mode 100755 index 000000000..184654046 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 + +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +from itertools import tee +from pathlib import Path +from typing import Dict, List, Tuple + +from genai_perf.metrics import LLMMetrics, Metrics, ResponseFormat +from genai_perf.profile_data_parser.profile_data_parser import ProfileDataParser +from genai_perf.tokenizer import Tokenizer +from genai_perf.utils import remove_sse_prefix + + +class LLMProfileDataParser(ProfileDataParser): + """A class that calculates and aggregates all the LLM performance statistics + across the Perf Analyzer profile results. + + The LLMProfileDataParser class parses profile export JSON file, collects the + core LLM performance metrics, and calculates summary statistics for each + different Perf Analyzer runs/experiments. + + Example: + + >>> ... # run Perf Analyzer with concurrency level 10 + >>> + >>> from transformers import AutoTokenizer + >>> + >>> tokenizer = AutoTokenizer.from_pretrained("gpt2") + >>> pd = LLMProfileDataParser( + >>> filename="profile_export.json", + >>> tokenizer=tokenizer, + >>> ) + >>> stats = pd.get_statistics(infer_mode="concurrency", level=10) + >>> + >>> print(stats) # output: Statistics(avg_time_to_first_token=...) + >>> stats.pretty_print() # Output: time_to_first_token_s: ... + """ + + def __init__( + self, + filename: Path, + tokenizer: Tokenizer, + ) -> None: + self._tokenizer = tokenizer + super().__init__(filename) + + def _parse_requests(self, requests: dict) -> Metrics: + """Parse each requests in profile export data to extract key metrics.""" + min_req_timestamp, max_res_timestamp = float("inf"), 0 + request_latencies = [] + time_to_first_tokens = [] + inter_token_latencies = [] + output_token_throughputs_per_request = [] + input_sequence_lengths = [] + output_sequence_lengths = [] + chunked_inter_token_latencies = [] + + for request in requests: + req_timestamp = request["timestamp"] + req_inputs = request["request_inputs"] + res_timestamps = request["response_timestamps"] + res_outputs = request["response_outputs"] + + self._preprocess_response(res_timestamps, res_outputs) + + # Skip requests with empty response. This happens sometimes when the + # model returns a single response with empty string. + if not res_timestamps: + continue + + # track entire benchmark duration + min_req_timestamp = min(min_req_timestamp, req_timestamp) + max_res_timestamp = max(max_res_timestamp, res_timestamps[-1]) + + # request latencies + req_latency_ns = res_timestamps[-1] - req_timestamp + request_latencies.append(req_latency_ns) # nanosec + req_latency_s = req_latency_ns / 1e9 # sec + + # time to first token + ttft = res_timestamps[0] - req_timestamp + time_to_first_tokens.append(ttft) + + # number of input tokens + input_seq_len = self._get_input_token_count(req_inputs) + input_sequence_lengths.append(input_seq_len) + + # output token throughput per request + output_token_counts, total_output_token = self._get_output_token_counts( + res_outputs + ) + output_token_throughputs_per_request.append( + total_output_token / req_latency_s + ) + output_sequence_lengths.append(total_output_token) + + # inter token latencies + if total_output_token > 1: + inter_token_latency = (req_latency_ns - ttft) / (total_output_token - 1) + inter_token_latencies.append(round(inter_token_latency)) + + # The new ITL calculation above loses all token-level ITL information + # and as a result breaks ITL vs token position visualization. Keep + # the old version of inter token latency as a WAR to preserve the + # visualization. + chunked_inter_token_latency = [] + for (t1, _), (t2, n2) in self._pairwise( + zip(res_timestamps, output_token_counts) + ): + # TMA-1676: handle empty first/last responses + # if the latter response has zero token (e.g. empty string), + # then set it default to one for the sake of inter token latency + # calculation and to avoid divide by zero. + num_token = 1 if n2 == 0 else n2 + chunked_inter_token_latency.append(round((t2 - t1) / num_token)) + chunked_inter_token_latencies.append(chunked_inter_token_latency) + + # request & output token throughput + benchmark_duration = (max_res_timestamp - min_req_timestamp) / 1e9 # nanosec + request_throughputs = [len(requests) / benchmark_duration] + output_token_throughputs = [sum(output_sequence_lengths) / benchmark_duration] + + return LLMMetrics( + request_throughputs, + request_latencies, + time_to_first_tokens, + inter_token_latencies, + output_token_throughputs, + output_token_throughputs_per_request, + output_sequence_lengths, + input_sequence_lengths, + chunked_inter_token_latencies, + ) + + def _pairwise(self, iterable): + """Generate pairs of consecutive elements from the given iterable.""" + a, b = tee(iterable) + next(b, None) + return zip(a, b) + + def _preprocess_response( + self, res_timestamps: List[int], res_outputs: List[Dict[str, str]] + ) -> None: + """Helper function to preprocess responses of a request.""" + if self._service_kind == "openai": + # PA sometimes receives multiple SSE responses at once (as a single + # response). Handle these responses by merging into a single response. + for i in range(len(res_outputs)): + response = res_outputs[i]["response"] + responses = response.strip().split("\n\n") + if len(responses) > 1: + merged_response = json.loads(remove_sse_prefix(responses[0])) + if ( + merged_response["choices"][0]["delta"].get("content", None) + is None + ): + merged_response["choices"][0]["delta"]["content"] = "" + for r in responses[1:]: + text = self._extract_openai_text_output(r) + merged_response["choices"][0]["delta"]["content"] += text + + res_outputs[i] = {"response": json.dumps(merged_response)} + + # Remove responses without any content + indices_to_remove = [] + for idx, out in enumerate(res_outputs): + if self._is_openai_empty_response(out["response"]): + indices_to_remove.append(idx) + indices_to_remove.sort(reverse=True) + for index in indices_to_remove: + res_timestamps.pop(index) + res_outputs.pop(index) + + def _get_input_token_count(self, req_inputs: dict) -> int: + """Deserialize the request input and return tokenized inputs.""" + if self._service_kind == "triton": + input_text = req_inputs["text_input"] + elif self._service_kind == "openai": + input_text = self._get_openai_input_text(req_inputs) + else: + raise ValueError(f"Unknown service kind: '{self._service_kind}'.") + + return len(self._tokenizer.encode(input_text)) + + def _get_openai_input_text(self, req_inputs: dict) -> str: + """Tokenize the OpenAI request input texts.""" + payload = json.loads(req_inputs["payload"]) + if self._response_format == ResponseFormat.OPENAI_CHAT_COMPLETIONS: + return payload["messages"][0]["content"] + elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: + return payload["prompt"] + else: + raise ValueError( + "Failed to parse OpenAI request input in profile export file." + ) + + def _get_output_token_counts( + self, res_outputs: List[Dict] + ) -> Tuple[List[int], int]: + """Return response-level token counts and total token count.""" + if self._service_kind == "triton": + output_texts = self._get_triton_output_tokens(res_outputs) + elif self._service_kind == "openai": + output_texts = self._get_openai_output_tokens(res_outputs) + else: + raise ValueError(f"Unknown service kind: '{self._service_kind}'.") + + full_text_token_count = len(self._tokenizer.encode("".join(output_texts))) + + output_tokens = self._get_response_output_tokens(output_texts) + output_token_counts = list(map(len, output_tokens)) + return output_token_counts, full_text_token_count + + def _get_triton_output_tokens(self, res_outputs: List[Dict]) -> List[str]: + """Return a list of Triton response texts.""" + return [r["text_output"] for r in res_outputs] + + def _get_openai_output_tokens(self, res_outputs: List[Dict]) -> List[str]: + """Return a list of OpenAI response texts.""" + output_texts = [] + for output in res_outputs: + text = self._extract_openai_text_output(output["response"]) + output_texts.append(text) + return output_texts + + def _get_response_output_tokens(self, output_texts: List[str]) -> List[List[int]]: + """Return a list of response output tokens.""" + # Exclamation mark trick forces the llama tokenization to consistently + # start each output with a specific token which allows us to safely skip + # the first token of every tokenized output and get only the ones that + # are returned by the model + encodings = self._tokenizer(["!" + txt for txt in output_texts]) + return [out[1:] for out in encodings.data["input_ids"]] + + def _extract_openai_text_output(self, response: str) -> str: + """Extracts text/content of the OpenAI response object.""" + response = remove_sse_prefix(response) + + if response == "[DONE]": + return "" + + data = json.loads(response) + completions = data["choices"][0] + + text_output = "" + if "object" not in data: + # FIXME: TPA-47 workaround for vLLM not following OpenAI Completions + # API specification when streaming, missing 'object' field: + # https://platform.openai.com/docs/api-reference/completions + text_output = completions.get("text", "") + elif data["object"] == "text_completion": # legacy + text_output = completions.get("text", "") + elif data["object"] == "chat.completion": # non-streaming + text_output = completions["message"].get("content", "") + elif data["object"] == "chat.completion.chunk": # streaming + text_output = completions["delta"].get("content", "") + else: + obj_type = data["object"] + raise ValueError(f"Unknown OpenAI response object type '{obj_type}'.") + return text_output + + def _is_openai_empty_response(self, response: str) -> bool: + """Returns true if the response is an openai response with no content (or empty content)""" + text = self._extract_openai_text_output(response) + if text: + return False + return True diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py new file mode 100755 index 000000000..efc051189 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from pathlib import Path +from typing import List, Tuple + +from genai_perf.metrics import Metrics, ResponseFormat, Statistics +from genai_perf.utils import load_json + + +class ProfileDataParser: + """Base profile data parser class that reads the profile data JSON file to + extract core metrics and calculate various performance statistics. + """ + + def __init__(self, filename: Path) -> None: + data = load_json(filename) + self._get_profile_metadata(data) + self._parse_profile_data(data) + + def _get_profile_metadata(self, data: dict) -> None: + self._service_kind = data["service_kind"] + if self._service_kind == "openai": + if data["endpoint"] == "v1/chat/completions": + self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS + elif data["endpoint"] == "v1/completions": + self._response_format = ResponseFormat.OPENAI_COMPLETIONS + else: + # TPA-66: add PA metadata to handle this case + # When endpoint field is either empty or custom endpoint, fall + # back to parsing the response to extract the response format. + request = data["experiments"][0]["requests"][0] + response = request["response_outputs"][0]["response"] + if "chat.completion" in response: + self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS + elif "text_completion" in response: + self._response_format = ResponseFormat.OPENAI_COMPLETIONS + else: + raise RuntimeError("Unknown OpenAI response format.") + + elif self._service_kind == "triton": + self._response_format = ResponseFormat.TRITON + else: + raise ValueError(f"Unknown service kind: {self._service_kind}") + + def _parse_profile_data(self, data: dict) -> None: + """Parse through the entire profile data to collect statistics.""" + self._profile_results = {} + for experiment in data["experiments"]: + infer_mode = experiment["experiment"]["mode"] + load_level = experiment["experiment"]["value"] + requests = experiment["requests"] + + metrics = self._parse_requests(requests) + + # aggregate and calculate statistics + statistics = Statistics(metrics) + self._profile_results[(infer_mode, str(load_level))] = statistics + + def _parse_requests(self, requests: dict) -> Metrics: + """Parse each request in profile data to extract core metrics.""" + raise NotImplementedError + + def get_statistics(self, infer_mode: str, load_level: str) -> Statistics: + """Return profile statistics if it exists.""" + if (infer_mode, load_level) not in self._profile_results: + raise KeyError(f"Profile with {infer_mode}={load_level} does not exist.") + return self._profile_results[(infer_mode, load_level)] + + def get_profile_load_info(self) -> List[Tuple[str, str]]: + """Return available (infer_mode, load_level) tuple keys.""" + return [k for k, _ in self._profile_results.items()] diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py index 2bf41441d..b947a3d61 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -26,7 +26,7 @@ from genai_perf.export_data.console_exporter import ConsoleExporter from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.llm_metrics import LLMMetrics, Statistics +from genai_perf.metrics import LLMMetrics, Statistics class TestConsoleExporter: diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py index 5372612ec..283d5f791 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py @@ -32,7 +32,7 @@ import pytest from genai_perf.export_data.csv_exporter import CsvExporter from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.llm_metrics import LLMProfileDataParser +from genai_perf.profile_data_parser import LLMProfileDataParser from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index d221b7595..5bd2389ad 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -24,394 +24,11 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import json -from io import StringIO -from pathlib import Path -from typing import Any, List, Union - -import numpy as np import pytest -from genai_perf.llm_metrics import LLMMetrics, LLMProfileDataParser, ResponseFormat -from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer - - -def ns_to_sec(ns: int) -> Union[int, float]: - """Convert from nanosecond to second.""" - return ns / 1e9 - - -class TestLLMProfileDataParser: - @pytest.fixture - def mock_read_write(self, monkeypatch: pytest.MonkeyPatch) -> List[str]: - """ - This function will mock the open function for specific files: - - - For "triton_profile_export.json", it will read and return the - contents of self.triton_profile_data - - For "openai_profile_export.json", it will read and return the - contents of self.openai_profile_data - - For "profile_export.csv", it will capture all data written to - the file, and return it as the return value of this function - - For all other files, it will behave like the normal open function - """ - - written_data = [] - - original_open = open - - def custom_open(filename, *args, **kwargs): - def write(self: Any, content: str) -> int: - written_data.append(content) - return len(content) - - if filename == "triton_profile_export.json": - tmp_file = StringIO(json.dumps(self.triton_profile_data)) - return tmp_file - elif filename == "openai_profile_export.json": - tmp_file = StringIO(json.dumps(self.openai_profile_data)) - return tmp_file - elif filename == "empty_profile_export.json": - tmp_file = StringIO(json.dumps(self.empty_profile_data)) - return tmp_file - elif filename == "profile_export.csv": - tmp_file = StringIO() - tmp_file.write = write.__get__(tmp_file) - return tmp_file - else: - return original_open(filename, *args, **kwargs) - - monkeypatch.setattr("builtins.open", custom_open) - - return written_data - - def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: - """Collect LLM metrics from profile export data and check values. - - Metrics - * time to first tokens - - experiment 1: [3 - 1, 4 - 2] = [2, 2] - - experiment 2: [7 - 5, 6 - 3] = [2, 3] - * inter token latencies - - experiment 1: [((8 - 1) - 2)/(3 - 1), ((11 - 2) - 2)/(6 - 1)] - : [2.5, 1.4] - : [2, 1] # rounded - - experiment 2: [((18 - 5) - 2)/(4 - 1), ((11 - 3) - 3)/(6 - 1)] - : [11/3, 1] - : [4, 1] # rounded - * output token throughputs per request - - experiment 1: [3/(8 - 1), 6/(11 - 2)] = [3/7, 6/9] - - experiment 2: [4/(18 - 5), 6/(11 - 3)] = [4/13, 6/8] - * output token throughputs - - experiment 1: [(3 + 6)/(11 - 1)] = [9/10] - - experiment 2: [(4 + 6)/(18 - 3)] = [2/3] - * output sequence lengths - - experiment 1: [3, 6] - - experiment 2: [4, 6] - * input sequence lengths - - experiment 1: [3, 4] - - experiment 2: [3, 4] - """ - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("triton_profile_export.json"), - tokenizer=tokenizer, - ) - - # experiment 1 metrics & statistics - stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") - metrics = stat_obj.metrics - stat = stat_obj.stats_dict - - assert isinstance(metrics, LLMMetrics) - - assert metrics.time_to_first_tokens == [2, 2] - assert metrics.inter_token_latencies == [2, 1] - ottpr = [3 / ns_to_sec(7), 6 / ns_to_sec(9)] - assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) - ott = [9 / ns_to_sec(10)] - assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.output_sequence_lengths == [3, 6] - assert metrics.input_sequence_lengths == [3, 4] - - # Disable Pylance warnings for dynamically set attributes due to Statistics - # not having strict attributes listed. - assert stat["time_to_first_token"]["avg"] == 2 # type: ignore - assert stat["inter_token_latency"]["avg"] == 1.5 # type: ignore - assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore - np.mean(ottpr) - ) - assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore - assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["p50"] == 2 # type: ignore - assert stat["inter_token_latency"]["p50"] == 1.5 # type: ignore - assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore - np.percentile(ottpr, 50) - ) - assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore - assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["min"] == 2 # type: ignore - assert stat["inter_token_latency"]["min"] == 1 # type: ignore - min_ottpr = 3 / ns_to_sec(7) - assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["output_sequence_length"]["min"] == 3 # type: ignore - assert stat["input_sequence_length"]["min"] == 3 # type: ignore - - assert stat["time_to_first_token"]["max"] == 2 # type: ignore - assert stat["inter_token_latency"]["max"] == 2 # type: ignore - max_ottpr = 6 / ns_to_sec(9) - assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["output_sequence_length"]["max"] == 6 # type: ignore - assert stat["input_sequence_length"]["max"] == 4 # type: ignore - - assert stat["time_to_first_token"]["std"] == np.std([2, 2]) # type: ignore - assert stat["inter_token_latency"]["std"] == np.std([2, 1]) # type: ignore - assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore - np.std(ottpr) - ) - assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore - assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore - - oott = 9 / ns_to_sec(10) - assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore - - # experiment 2 statistics - stat_obj = pd.get_statistics(infer_mode="request_rate", load_level="2.0") - metrics = stat_obj.metrics - stat = stat_obj.stats_dict - assert isinstance(metrics, LLMMetrics) - - assert metrics.time_to_first_tokens == [2, 3] - assert metrics.inter_token_latencies == [4, 1] - ottpr = [4 / ns_to_sec(13), 6 / ns_to_sec(8)] - assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) - ott = [2 / ns_to_sec(3)] - assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.output_sequence_lengths == [4, 6] - assert metrics.input_sequence_lengths == [3, 4] - - assert stat["time_to_first_token"]["avg"] == pytest.approx(2.5) # type: ignore - assert stat["inter_token_latency"]["avg"] == pytest.approx(2.5) # type: ignore - assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore - np.mean(ottpr) - ) - assert stat["output_sequence_length"]["avg"] == 5 # type: ignore - assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["p50"] == pytest.approx(2.5) # type: ignore - assert stat["inter_token_latency"]["p50"] == pytest.approx(2.5) # type: ignore - assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore - np.percentile(ottpr, 50) - ) - assert stat["output_sequence_length"]["p50"] == 5 # type: ignore - assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["min"] == pytest.approx(2) # type: ignore - assert stat["inter_token_latency"]["min"] == pytest.approx(1) # type: ignore - min_ottpr = 4 / ns_to_sec(13) - assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["output_sequence_length"]["min"] == 4 # type: ignore - assert stat["input_sequence_length"]["min"] == 3 # type: ignore - - assert stat["time_to_first_token"]["max"] == pytest.approx(3) # type: ignore - assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore - max_ottpr = 6 / ns_to_sec(8) - assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["output_sequence_length"]["max"] == 6 # type: ignore - assert stat["input_sequence_length"]["max"] == 4 # type: ignore - - assert stat["time_to_first_token"]["std"] == np.std([2, 3]) * (1) # type: ignore - assert stat["inter_token_latency"]["std"] == np.std([4, 1]) * (1) # type: ignore - assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore - np.std(ottpr) - ) - assert stat["output_sequence_length"]["std"] == np.std([4, 6]) # type: ignore - assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore +from genai_perf.metrics import LLMMetrics - oott = 2 / ns_to_sec(3) - assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore - # check non-existing profile data - with pytest.raises(KeyError): - pd.get_statistics(infer_mode="concurrency", load_level="30") - - def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: - """Collect LLM metrics from profile export data and check values. - - Metrics - * time to first tokens - - experiment 1: [5 - 1, 7 - 2] = [4, 5] - * inter token latencies - - experiment 1: [((12 - 1) - 4)/(3 - 1), ((15 - 2) - 5)/(6 - 1)] - : [3.5, 1.6] - : [4, 2] # rounded - * output token throughputs per request - - experiment 1: [3/(12 - 1), 6/(15 - 2)] = [3/11, 6/13] - * output token throughputs - - experiment 1: [(3 + 6)/(15 - 1)] = [9/14] - * output sequence lengths - - experiment 1: [3, 6] - * input sequence lengths - - experiment 1: [3, 4] - """ - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("openai_profile_export.json"), - tokenizer=tokenizer, - ) - - # experiment 1 statistics - stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") - metrics = stat_obj.metrics - stat = stat_obj.stats_dict - assert isinstance(metrics, LLMMetrics) - - assert metrics.time_to_first_tokens == [4, 5] - assert metrics.inter_token_latencies == [4, 2] - ottpr = [3 / ns_to_sec(11), 6 / ns_to_sec(13)] - assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) - ott = [9 / ns_to_sec(14)] - assert metrics.output_token_throughputs == pytest.approx(ott) - assert metrics.output_sequence_lengths == [3, 6] - assert metrics.input_sequence_lengths == [3, 4] - - assert stat["time_to_first_token"]["avg"] == pytest.approx(4.5) # type: ignore - assert stat["inter_token_latency"]["avg"] == pytest.approx(3) # type: ignore - assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore - np.mean(ottpr) - ) - assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore - assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["p50"] == pytest.approx(4.5) # type: ignore - assert stat["inter_token_latency"]["p50"] == pytest.approx(3) # type: ignore - assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore - np.percentile(ottpr, 50) - ) - assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore - assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore - - assert stat["time_to_first_token"]["min"] == pytest.approx(4) # type: ignore - assert stat["inter_token_latency"]["min"] == pytest.approx(2) # type: ignore - min_ottpr = 3 / ns_to_sec(11) - assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore - assert stat["output_sequence_length"]["min"] == 3 # type: ignore - assert stat["input_sequence_length"]["min"] == 3 # type: ignore - - assert stat["time_to_first_token"]["max"] == pytest.approx(5) # type: ignore - assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore - max_ottpr = 6 / ns_to_sec(13) - assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore - assert stat["output_sequence_length"]["max"] == 6 # type: ignore - assert stat["input_sequence_length"]["max"] == 4 # type: ignore - - assert stat["time_to_first_token"]["std"] == np.std([4, 5]) * (1) # type: ignore - assert stat["inter_token_latency"]["std"] == np.std([4, 2]) * (1) # type: ignore - assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore - np.std(ottpr) - ) - assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore - assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore - - oott = 9 / ns_to_sec(14) - assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore - - # check non-existing profile data - with pytest.raises(KeyError): - pd.get_statistics(infer_mode="concurrency", load_level="40") - - def test_merged_sse_response(self, mock_read_write: pytest.MonkeyPatch) -> None: - """Test merging the multiple sse response.""" - res_timestamps = [0, 1, 2, 3] - res_outputs = [ - { - "response": 'data: {"choices":[{"delta":{"content":"aaa"}}],"object":"chat.completion.chunk"}\n\n' - }, - { - "response": ( - 'data: {"choices":[{"delta":{"content":"abc"}}],"object":"chat.completion.chunk"}\n\n' - 'data: {"choices":[{"delta":{"content":"1234"}}],"object":"chat.completion.chunk"}\n\n' - 'data: {"choices":[{"delta":{"content":"helloworld"}}],"object":"chat.completion.chunk"}\n\n' - ) - }, - {"response": "data: [DONE]\n\n"}, - ] - expected_response = '{"choices": [{"delta": {"content": "abc1234helloworld"}}], "object": "chat.completion.chunk"}' - - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("openai_profile_export.json"), - tokenizer=tokenizer, - ) - - pd._preprocess_response(res_timestamps, res_outputs) - assert res_outputs[1]["response"] == expected_response - - def test_openai_output_token_counts( - self, mock_read_write: pytest.MonkeyPatch - ) -> None: - output_texts = [ - "Ad", - "idas", - " Orig", - "inals", - " are", - " now", - " available", - " in", - " more", - " than", - ] - res_outputs = [] - for text in output_texts: - response = f'data: {{"choices":[{{"delta":{{"content":"{text}"}}}}],"object":"chat.completion.chunk"}}\n\n' - res_outputs.append({"response": response}) - - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("openai_profile_export.json"), - tokenizer=tokenizer, - ) - - output_token_counts, total_output_token = pd._get_output_token_counts( - res_outputs - ) - assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 - assert total_output_token == 9 - assert total_output_token != sum(output_token_counts) - - def test_triton_output_token_counts( - self, mock_read_write: pytest.MonkeyPatch - ) -> None: - output_texts = [ - "Ad", - "idas", - " Orig", - "inals", - " are", - " now", - " available", - " in", - " more", - " than", - ] - res_outputs = [] - for text in output_texts: - res_outputs.append({"text_output": text}) - - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("triton_profile_export.json"), - tokenizer=tokenizer, - ) - - output_token_counts, total_output_token = pd._get_output_token_counts( - res_outputs - ) - assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 - assert total_output_token == 9 - assert total_output_token != sum(output_token_counts) +class TestLLMMetrics: def test_llm_metrics_get_base_name(self) -> None: """Test get_base_name method in LLMMetrics class.""" @@ -440,175 +57,3 @@ def test_llm_metrics_get_base_name(self) -> None: ) with pytest.raises(KeyError): metrics.get_base_name("hello1234") - - def test_empty_response(self, mock_read_write: pytest.MonkeyPatch) -> None: - """Check if it handles all empty responses.""" - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - - # Should not throw error - _ = LLMProfileDataParser( - filename=Path("empty_profile_export.json"), - tokenizer=tokenizer, - ) - - empty_profile_data = { - "service_kind": "openai", - "endpoint": "v1/chat/completions", - "experiments": [ - { - "experiment": { - "mode": "concurrency", - "value": 10, - }, - "requests": [ - { - "timestamp": 1, - "request_inputs": { - "payload": '{"messages":[{"role":"user","content":"This is test"}],"model":"llama-2-7b","stream":true}', - }, - "response_timestamps": [3, 5, 8], - "response_outputs": [ - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}\n\n' - }, - {"response": "data: [DONE]\n\n"}, - ], - }, - ], - }, - ], - } - - openai_profile_data = { - "service_kind": "openai", - "endpoint": "v1/chat/completions", - "experiments": [ - { - "experiment": { - "mode": "concurrency", - "value": 10, - }, - "requests": [ - { - "timestamp": 1, - "request_inputs": { - "payload": '{"messages":[{"role":"user","content":"This is test"}],"model":"llama-2-7b","stream":true}', - }, - # the first, and the last two responses will be ignored because they have no "content" - "response_timestamps": [3, 5, 8, 12, 13, 14], - "response_outputs": [ - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":" like"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":" dogs"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' - }, - {"response": "data: [DONE]\n\n"}, - ], - }, - { - "timestamp": 2, - "request_inputs": { - "payload": '{"messages":[{"role":"user","content":"This is test too"}],"model":"llama-2-7b","stream":true}', - }, - # the first, and the last two responses will be ignored because they have no "content" - "response_timestamps": [4, 7, 11, 15, 18, 19], - "response_outputs": [ - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"don\'t"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"cook food"},"finish_reason":null}]}\n\n' - }, - { - "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' - }, - {"response": "data: [DONE]\n\n"}, - ], - }, - ], - }, - ], - } - - triton_profile_data = { - "service_kind": "triton", - "endpoint": "", - "experiments": [ - { - "experiment": { - "mode": "concurrency", - "value": 10, - }, - "requests": [ - { - "timestamp": 1, - "request_inputs": {"text_input": "This is test"}, - "response_timestamps": [3, 5, 8], - "response_outputs": [ - {"text_output": "I"}, - {"text_output": " like"}, - {"text_output": " dogs"}, - ], - }, - { - "timestamp": 2, - "request_inputs": {"text_input": "This is test too"}, - "response_timestamps": [4, 7, 11], - "response_outputs": [ - {"text_output": "I"}, - {"text_output": " don't"}, - {"text_output": " cook food"}, - ], - }, - ], - }, - { - "experiment": { - "mode": "request_rate", - "value": 2.0, - }, - "requests": [ - { - "timestamp": 5, - "request_inputs": {"text_input": "This is test"}, - "response_timestamps": [7, 8, 13, 18], - "response_outputs": [ - {"text_output": "cat"}, - {"text_output": " is"}, - {"text_output": " cool"}, - {"text_output": " too"}, - ], - }, - { - "timestamp": 3, - "request_inputs": {"text_input": "This is test too"}, - "response_timestamps": [6, 8, 11], - "response_outputs": [ - {"text_output": "it's"}, - {"text_output": " very"}, - {"text_output": " simple work"}, - ], - }, - ], - }, - ], - } diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py new file mode 100644 index 000000000..75976189d --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py @@ -0,0 +1,587 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +from io import StringIO +from pathlib import Path +from typing import Any, List, Union + +import numpy as np +import pytest +from genai_perf.metrics import LLMMetrics +from genai_perf.profile_data_parser import LLMProfileDataParser +from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer + + +def ns_to_sec(ns: int) -> Union[int, float]: + """Convert from nanosecond to second.""" + return ns / 1e9 + + +class TestLLMProfileDataParser: + @pytest.fixture + def mock_read_write(self, monkeypatch: pytest.MonkeyPatch) -> List[str]: + """ + This function will mock the open function for specific files: + + - For "triton_profile_export.json", it will read and return the + contents of self.triton_profile_data + - For "openai_profile_export.json", it will read and return the + contents of self.openai_profile_data + - For "profile_export.csv", it will capture all data written to + the file, and return it as the return value of this function + - For all other files, it will behave like the normal open function + """ + + written_data = [] + + original_open = open + + def custom_open(filename, *args, **kwargs): + def write(self: Any, content: str) -> int: + written_data.append(content) + return len(content) + + if filename == "triton_profile_export.json": + tmp_file = StringIO(json.dumps(self.triton_profile_data)) + return tmp_file + elif filename == "openai_profile_export.json": + tmp_file = StringIO(json.dumps(self.openai_profile_data)) + return tmp_file + elif filename == "empty_profile_export.json": + tmp_file = StringIO(json.dumps(self.empty_profile_data)) + return tmp_file + elif filename == "profile_export.csv": + tmp_file = StringIO() + tmp_file.write = write.__get__(tmp_file) + return tmp_file + else: + return original_open(filename, *args, **kwargs) + + monkeypatch.setattr("builtins.open", custom_open) + + return written_data + + def test_triton_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Collect LLM metrics from profile export data and check values. + + Metrics + * time to first tokens + - experiment 1: [3 - 1, 4 - 2] = [2, 2] + - experiment 2: [7 - 5, 6 - 3] = [2, 3] + * inter token latencies + - experiment 1: [((8 - 1) - 2)/(3 - 1), ((11 - 2) - 2)/(6 - 1)] + : [2.5, 1.4] + : [2, 1] # rounded + - experiment 2: [((18 - 5) - 2)/(4 - 1), ((11 - 3) - 3)/(6 - 1)] + : [11/3, 1] + : [4, 1] # rounded + * output token throughputs per request + - experiment 1: [3/(8 - 1), 6/(11 - 2)] = [3/7, 6/9] + - experiment 2: [4/(18 - 5), 6/(11 - 3)] = [4/13, 6/8] + * output token throughputs + - experiment 1: [(3 + 6)/(11 - 1)] = [9/10] + - experiment 2: [(4 + 6)/(18 - 3)] = [2/3] + * output sequence lengths + - experiment 1: [3, 6] + - experiment 2: [4, 6] + * input sequence lengths + - experiment 1: [3, 4] + - experiment 2: [3, 4] + """ + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("triton_profile_export.json"), + tokenizer=tokenizer, + ) + + # experiment 1 metrics & statistics + stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict + + assert isinstance(metrics, LLMMetrics) + + assert metrics.time_to_first_tokens == [2, 2] + assert metrics.inter_token_latencies == [2, 1] + ottpr = [3 / ns_to_sec(7), 6 / ns_to_sec(9)] + assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) + ott = [9 / ns_to_sec(10)] + assert metrics.output_token_throughputs == pytest.approx(ott) + assert metrics.output_sequence_lengths == [3, 6] + assert metrics.input_sequence_lengths == [3, 4] + + # Disable Pylance warnings for dynamically set attributes due to Statistics + # not having strict attributes listed. + assert stat["time_to_first_token"]["avg"] == 2 # type: ignore + assert stat["inter_token_latency"]["avg"] == 1.5 # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore + np.mean(ottpr) + ) + assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["p50"] == 2 # type: ignore + assert stat["inter_token_latency"]["p50"] == 1.5 # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore + np.percentile(ottpr, 50) + ) + assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["min"] == 2 # type: ignore + assert stat["inter_token_latency"]["min"] == 1 # type: ignore + min_ottpr = 3 / ns_to_sec(7) + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["output_sequence_length"]["min"] == 3 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore + + assert stat["time_to_first_token"]["max"] == 2 # type: ignore + assert stat["inter_token_latency"]["max"] == 2 # type: ignore + max_ottpr = 6 / ns_to_sec(9) + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore + + assert stat["time_to_first_token"]["std"] == np.std([2, 2]) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([2, 1]) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore + np.std(ottpr) + ) + assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore + + oott = 9 / ns_to_sec(10) + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore + + # experiment 2 statistics + stat_obj = pd.get_statistics(infer_mode="request_rate", load_level="2.0") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict + assert isinstance(metrics, LLMMetrics) + + assert metrics.time_to_first_tokens == [2, 3] + assert metrics.inter_token_latencies == [4, 1] + ottpr = [4 / ns_to_sec(13), 6 / ns_to_sec(8)] + assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) + ott = [2 / ns_to_sec(3)] + assert metrics.output_token_throughputs == pytest.approx(ott) + assert metrics.output_sequence_lengths == [4, 6] + assert metrics.input_sequence_lengths == [3, 4] + + assert stat["time_to_first_token"]["avg"] == pytest.approx(2.5) # type: ignore + assert stat["inter_token_latency"]["avg"] == pytest.approx(2.5) # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore + np.mean(ottpr) + ) + assert stat["output_sequence_length"]["avg"] == 5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["p50"] == pytest.approx(2.5) # type: ignore + assert stat["inter_token_latency"]["p50"] == pytest.approx(2.5) # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore + np.percentile(ottpr, 50) + ) + assert stat["output_sequence_length"]["p50"] == 5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["min"] == pytest.approx(2) # type: ignore + assert stat["inter_token_latency"]["min"] == pytest.approx(1) # type: ignore + min_ottpr = 4 / ns_to_sec(13) + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["output_sequence_length"]["min"] == 4 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore + + assert stat["time_to_first_token"]["max"] == pytest.approx(3) # type: ignore + assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore + max_ottpr = 6 / ns_to_sec(8) + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore + + assert stat["time_to_first_token"]["std"] == np.std([2, 3]) * (1) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([4, 1]) * (1) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore + np.std(ottpr) + ) + assert stat["output_sequence_length"]["std"] == np.std([4, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore + + oott = 2 / ns_to_sec(3) + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore + + # check non-existing profile data + with pytest.raises(KeyError): + pd.get_statistics(infer_mode="concurrency", load_level="30") + + def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Collect LLM metrics from profile export data and check values. + + Metrics + * time to first tokens + - experiment 1: [5 - 1, 7 - 2] = [4, 5] + * inter token latencies + - experiment 1: [((12 - 1) - 4)/(3 - 1), ((15 - 2) - 5)/(6 - 1)] + : [3.5, 1.6] + : [4, 2] # rounded + * output token throughputs per request + - experiment 1: [3/(12 - 1), 6/(15 - 2)] = [3/11, 6/13] + * output token throughputs + - experiment 1: [(3 + 6)/(15 - 1)] = [9/14] + * output sequence lengths + - experiment 1: [3, 6] + * input sequence lengths + - experiment 1: [3, 4] + """ + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("openai_profile_export.json"), + tokenizer=tokenizer, + ) + + # experiment 1 statistics + stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict + assert isinstance(metrics, LLMMetrics) + + assert metrics.time_to_first_tokens == [4, 5] + assert metrics.inter_token_latencies == [4, 2] + ottpr = [3 / ns_to_sec(11), 6 / ns_to_sec(13)] + assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) + ott = [9 / ns_to_sec(14)] + assert metrics.output_token_throughputs == pytest.approx(ott) + assert metrics.output_sequence_lengths == [3, 6] + assert metrics.input_sequence_lengths == [3, 4] + + assert stat["time_to_first_token"]["avg"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["avg"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore + np.mean(ottpr) + ) + assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["p50"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["p50"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore + np.percentile(ottpr, 50) + ) + assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["min"] == pytest.approx(4) # type: ignore + assert stat["inter_token_latency"]["min"] == pytest.approx(2) # type: ignore + min_ottpr = 3 / ns_to_sec(11) + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["output_sequence_length"]["min"] == 3 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore + + assert stat["time_to_first_token"]["max"] == pytest.approx(5) # type: ignore + assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore + max_ottpr = 6 / ns_to_sec(13) + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore + + assert stat["time_to_first_token"]["std"] == np.std([4, 5]) * (1) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([4, 2]) * (1) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore + np.std(ottpr) + ) + assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore + + oott = 9 / ns_to_sec(14) + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore + + # check non-existing profile data + with pytest.raises(KeyError): + pd.get_statistics(infer_mode="concurrency", load_level="40") + + def test_merged_sse_response(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Test merging the multiple sse response.""" + res_timestamps = [0, 1, 2, 3] + res_outputs = [ + { + "response": 'data: {"choices":[{"delta":{"content":"aaa"}}],"object":"chat.completion.chunk"}\n\n' + }, + { + "response": ( + 'data: {"choices":[{"delta":{"content":"abc"}}],"object":"chat.completion.chunk"}\n\n' + 'data: {"choices":[{"delta":{"content":"1234"}}],"object":"chat.completion.chunk"}\n\n' + 'data: {"choices":[{"delta":{"content":"helloworld"}}],"object":"chat.completion.chunk"}\n\n' + ) + }, + {"response": "data: [DONE]\n\n"}, + ] + expected_response = '{"choices": [{"delta": {"content": "abc1234helloworld"}}], "object": "chat.completion.chunk"}' + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("openai_profile_export.json"), + tokenizer=tokenizer, + ) + + pd._preprocess_response(res_timestamps, res_outputs) + assert res_outputs[1]["response"] == expected_response + + def test_openai_output_token_counts( + self, mock_read_write: pytest.MonkeyPatch + ) -> None: + output_texts = [ + "Ad", + "idas", + " Orig", + "inals", + " are", + " now", + " available", + " in", + " more", + " than", + ] + res_outputs = [] + for text in output_texts: + response = f'data: {{"choices":[{{"delta":{{"content":"{text}"}}}}],"object":"chat.completion.chunk"}}\n\n' + res_outputs.append({"response": response}) + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("openai_profile_export.json"), + tokenizer=tokenizer, + ) + + output_token_counts, total_output_token = pd._get_output_token_counts( + res_outputs + ) + assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 + assert total_output_token == 9 + assert total_output_token != sum(output_token_counts) + + def test_triton_output_token_counts( + self, mock_read_write: pytest.MonkeyPatch + ) -> None: + output_texts = [ + "Ad", + "idas", + " Orig", + "inals", + " are", + " now", + " available", + " in", + " more", + " than", + ] + res_outputs = [] + for text in output_texts: + res_outputs.append({"text_output": text}) + + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("triton_profile_export.json"), + tokenizer=tokenizer, + ) + + output_token_counts, total_output_token = pd._get_output_token_counts( + res_outputs + ) + assert output_token_counts == [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # total 10 + assert total_output_token == 9 + assert total_output_token != sum(output_token_counts) + + def test_empty_response(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Check if it handles all empty responses.""" + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + + # Should not throw error + _ = LLMProfileDataParser( + filename=Path("empty_profile_export.json"), + tokenizer=tokenizer, + ) + + empty_profile_data = { + "service_kind": "openai", + "endpoint": "v1/chat/completions", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"messages":[{"role":"user","content":"This is test"}],"model":"llama-2-7b","stream":true}', + }, + "response_timestamps": [3, 5, 8], + "response_outputs": [ + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}\n\n' + }, + {"response": "data: [DONE]\n\n"}, + ], + }, + ], + }, + ], + } + + openai_profile_data = { + "service_kind": "openai", + "endpoint": "v1/chat/completions", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"messages":[{"role":"user","content":"This is test"}],"model":"llama-2-7b","stream":true}', + }, + # the first, and the last two responses will be ignored because they have no "content" + "response_timestamps": [3, 5, 8, 12, 13, 14], + "response_outputs": [ + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":" like"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":" dogs"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' + }, + {"response": "data: [DONE]\n\n"}, + ], + }, + { + "timestamp": 2, + "request_inputs": { + "payload": '{"messages":[{"role":"user","content":"This is test too"}],"model":"llama-2-7b","stream":true}', + }, + # the first, and the last two responses will be ignored because they have no "content" + "response_timestamps": [4, 7, 11, 15, 18, 19], + "response_outputs": [ + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"don\'t"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{"content":"cook food"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","created":123,"model":"llama-2-7b","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' + }, + {"response": "data: [DONE]\n\n"}, + ], + }, + ], + }, + ], + } + + triton_profile_data = { + "service_kind": "triton", + "endpoint": "", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": {"text_input": "This is test"}, + "response_timestamps": [3, 5, 8], + "response_outputs": [ + {"text_output": "I"}, + {"text_output": " like"}, + {"text_output": " dogs"}, + ], + }, + { + "timestamp": 2, + "request_inputs": {"text_input": "This is test too"}, + "response_timestamps": [4, 7, 11], + "response_outputs": [ + {"text_output": "I"}, + {"text_output": " don't"}, + {"text_output": " cook food"}, + ], + }, + ], + }, + { + "experiment": { + "mode": "request_rate", + "value": 2.0, + }, + "requests": [ + { + "timestamp": 5, + "request_inputs": {"text_input": "This is test"}, + "response_timestamps": [7, 8, 13, 18], + "response_outputs": [ + {"text_output": "cat"}, + {"text_output": " is"}, + {"text_output": " cool"}, + {"text_output": " too"}, + ], + }, + { + "timestamp": 3, + "request_inputs": {"text_input": "This is test too"}, + "response_timestamps": [6, 8, 11], + "response_outputs": [ + {"text_output": "it's"}, + {"text_output": " very"}, + {"text_output": " simple work"}, + ], + }, + ], + }, + ], + } From 3cb4a4202725be8453f65b174553ceeba81973fe Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 26 Jun 2024 10:48:33 -0700 Subject: [PATCH 28/55] Add embedding support (#713) --- src/c++/perf_analyzer/genai-perf/README.md | 7 + .../genai_perf/llm_inputs/llm_inputs.py | 212 +++++++++++++++--- .../genai-perf/genai_perf/main.py | 1 + .../genai-perf/genai_perf/parser.py | 37 ++- .../genai-perf/genai_perf/wrapper.py | 1 + .../genai-perf/tests/test_cli.py | 32 +++ .../genai-perf/tests/test_json_exporter.py | 1 + .../tests/test_llm_inputs_embeddings.py | 172 ++++++++++++++ 8 files changed, 431 insertions(+), 32 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_embeddings.py diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 24c1efe3b..b114364d7 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -394,6 +394,13 @@ URL of the endpoint to target for benchmarking. (default: `None`) ## Input Options +##### `-b ` +##### `--batch-size ` + +The batch size of the requests GenAI-Perf should send. +This is currently only supported with the embeddings endpoint type. +(default: `1`) + ##### `--extra-inputs ` Provide additional inputs to include with every request. You can repeat this diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index 3613e5645..842fe9978 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -41,6 +41,7 @@ class PromptSource(Enum): class OutputFormat(Enum): OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() + OPENAI_EMBEDDINGS = auto() TENSORRTLLM = auto() VLLM = auto() @@ -64,6 +65,7 @@ class LlmInputs: DEFAULT_TENSORRTLLM_MAX_TOKENS = 256 + DEFAULT_BATCH_SIZE = 1 DEFAULT_RANDOM_SEED = 0 DEFAULT_PROMPT_TOKENS_MEAN = 550 DEFAULT_PROMPT_TOKENS_STDDEV = 0 @@ -99,6 +101,7 @@ def create_llm_inputs( add_stream: bool = False, tokenizer: Tokenizer = get_tokenizer(DEFAULT_TOKENIZER), extra_inputs: Optional[Dict] = None, + batch_size: int = 1, output_dir: Path = Path(""), ) -> Dict: """ @@ -134,6 +137,8 @@ def create_llm_inputs( The standard deviation of the length of the output to generate. This is only used if output_tokens_mean is provided. output_tokens_deterministic: If true, the output tokens will set the minimum and maximum tokens to be equivalent. + batch_size: + The number of inputs per request (currently only used for v1/embeddings) Required Synthetic Prompt Generation Parameters ----------------------------------------------- @@ -156,36 +161,21 @@ def create_llm_inputs( input_type, dataset_name, starting_index, length, tokenizer ) - if input_type == PromptSource.DATASET: - dataset = cls._get_input_dataset_from_url( - dataset_name, starting_index, length - ) - generic_dataset_json = cls._convert_input_url_dataset_to_generic_json( - dataset - ) - elif input_type == PromptSource.SYNTHETIC: - random.seed(random_seed) - synthetic_dataset = cls._get_input_dataset_from_synthetic( - tokenizer, - prompt_tokens_mean, - prompt_tokens_stddev, - num_of_output_prompts, - ) - generic_dataset_json = ( - cls._convert_input_synthetic_or_file_dataset_to_generic_json( - synthetic_dataset - ) - ) - elif input_type == PromptSource.FILE: - input_filename = cast(Path, input_filename) - input_file_dataset = cls._get_input_dataset_from_file(input_filename) - generic_dataset_json = ( - cls._convert_input_synthetic_or_file_dataset_to_generic_json( - input_file_dataset - ) - ) - else: - raise GenAIPerfException("Input source is not recognized.") + random.seed(random_seed) + + generic_dataset_json = cls.get_generic_dataset_json( + input_type, + output_format, + dataset_name, + starting_index, + length, + tokenizer, + prompt_tokens_mean, + prompt_tokens_stddev, + num_of_output_prompts, + batch_size, + input_filename, + ) if extra_inputs is None: extra_inputs = {} @@ -206,6 +196,125 @@ def create_llm_inputs( return json_in_pa_format + @classmethod + def get_generic_dataset_json( + cls, + input_type: PromptSource, + output_format: OutputFormat, + dataset_name: str, + starting_index: int, + length: int, + tokenizer: Tokenizer, + prompt_tokens_mean: int, + prompt_tokens_stddev: int, + num_of_output_prompts: int, + batch_size: int, + input_filename: Optional[Path], + ) -> Dict: + """ + Retrieve and convert the dataset based on the input type. + + Parameters + ---------- + input_type: + Specify how the input is received + output_format: + Specify the output format + dataset_name: + The name of the dataset + starting_index: + Offset from within the list to start gathering inputs + length: + Number of entries to gather + tokenizer: + The tokenizer to use when generating synthetic prompts + prompt_tokens_mean: + The mean length of the prompt to generate + prompt_tokens_stddev: + The standard deviation of the length of the prompt to generate + num_of_output_prompts: + The number of synthetic output prompts to generate + batch_size: + The number of inputs per request (currently only used for v1/embeddings) + input_filename: + The path to the input file containing the prompts in JSONL format. + Returns + ------- + Dict: + The generic dataset JSON + """ + if output_format == OutputFormat.OPENAI_EMBEDDINGS: + if input_type == PromptSource.FILE: + input_filename = cast(Path, input_filename) + input_file_dataset = cls._get_input_dataset_from_embeddings_file( + input_filename, + batch_size, + num_of_output_prompts, + ) + generic_dataset_json = ( + cls._convert_input_synthetic_or_file_dataset_to_generic_json( + input_file_dataset + ) + ) + else: + raise GenAIPerfException("OpenAI embeddings only supports file input.") + else: + if input_type == PromptSource.DATASET: + dataset = cls._get_input_dataset_from_url( + dataset_name, starting_index, length + ) + generic_dataset_json = cls._convert_input_url_dataset_to_generic_json( + dataset + ) + elif input_type == PromptSource.SYNTHETIC: + synthetic_dataset = cls._get_input_dataset_from_synthetic( + tokenizer, + prompt_tokens_mean, + prompt_tokens_stddev, + num_of_output_prompts, + ) + generic_dataset_json = ( + cls._convert_input_synthetic_or_file_dataset_to_generic_json( + synthetic_dataset + ) + ) + elif input_type == PromptSource.FILE: + input_filename = cast(Path, input_filename) + input_file_dataset = cls._get_input_dataset_from_file(input_filename) + generic_dataset_json = ( + cls._convert_input_synthetic_or_file_dataset_to_generic_json( + input_file_dataset + ) + ) + else: + raise GenAIPerfException("Input source is not recognized.") + + return generic_dataset_json + + @classmethod + def _get_input_dataset_from_embeddings_file( + cls, input_filename: Path, batch_size: int, num_prompts: int + ) -> Dict[str, Any]: + with open(input_filename, "r") as file: + file_content = [json.loads(line) for line in file] + + texts = [item["text"] for item in file_content] + + if batch_size > len(texts): + raise ValueError( + "Batch size cannot be larger than the number of available texts" + ) + + dataset_json: Dict[str, Any] = {} + dataset_json["features"] = [{"name": "input"}] + dataset_json["rows"] = [] + + for _ in range(num_prompts): + sampled_texts = random.sample(texts, batch_size) + dataset_json["rows"].append({"row": {"payload": {"input": sampled_texts}}}) + + return dataset_json + @classmethod def _check_for_valid_args( cls, @@ -419,6 +528,13 @@ def _convert_generic_json_to_output_format( model_name, model_selection_strategy, ) + elif output_format == OutputFormat.OPENAI_EMBEDDINGS: + output_json = cls._convert_generic_json_to_openai_embeddings_format( + generic_dataset, + extra_inputs, + model_name, + model_selection_strategy, + ) elif output_format == OutputFormat.VLLM: output_json = cls._convert_generic_json_to_vllm_format( generic_dataset, @@ -520,6 +636,42 @@ def _convert_generic_json_to_openai_completions_format( return pa_json + @classmethod + def _convert_generic_json_to_openai_embeddings_format( + cls, + generic_dataset: Dict, + extra_inputs: Dict, + model_name: list = [], + model_selection_strategy: ModelSelectionStrategy = ModelSelectionStrategy.ROUND_ROBIN, + ) -> Dict[str, Any]: + pa_json: Dict[str, Any] = {"data": []} + + for index, entry in enumerate(generic_dataset["rows"]): + iter_model_name = cls._select_model_name( + model_name, index, model_selection_strategy + ) + payload = entry.get("payload", {}) + input_values = payload.get("input") + + if input_values is None: + raise ValueError("Missing required fields 'input' in dataset entry") + if not isinstance(input_values, list): + raise ValueError( + f"Required field 'input' must be a list (actual: {type(input_values)})" + ) + + payload = { + "input": input_values, + "model": iter_model_name, + } + + for key, value in extra_inputs.items(): + payload[key] = value + + pa_json["data"].append({"payload": [payload]}) + + return pa_json + @classmethod def _convert_generic_json_to_vllm_format( cls, diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index e8f43a91b..d44b5c6b1 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -78,6 +78,7 @@ def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: add_stream=args.streaming, tokenizer=tokenizer, extra_inputs=extra_input_dict, + batch_size=args.batch_size, output_dir=args.artifact_dir, ) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 24f98b426..62a06916b 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -52,7 +52,11 @@ logger = logging.getLogger(__name__) -_endpoint_type_map = {"chat": "v1/chat/completions", "completions": "v1/completions"} +_endpoint_type_map = { + "chat": "v1/chat/completions", + "completions": "v1/completions", + "embeddings": "v1/embeddings", +} def _check_model_args( @@ -110,6 +114,8 @@ def _check_conditional_args( args.output_format = OutputFormat.OPENAI_CHAT_COMPLETIONS elif args.endpoint_type == "completions": args.output_format = OutputFormat.OPENAI_COMPLETIONS + elif args.endpoint_type == "embeddings": + args.output_format = OutputFormat.OPENAI_EMBEDDINGS if args.endpoint is not None: args.endpoint = args.endpoint.lstrip(" /") @@ -141,9 +147,26 @@ def _check_conditional_args( "The --output-tokens-mean-deterministic option is only supported with the Triton service-kind." ) + _check_conditional_args_embeddings(parser, args) + return args +def _check_conditional_args_embeddings( + parser: argparse.ArgumentParser, args: argparse.Namespace +): + if args.endpoint_type == "embeddings": + if args.streaming: + parser.error( + "The --streaming option is not supported with the embeddings endpoint type." + ) + else: + if args.batch_size != LlmInputs.DEFAULT_BATCH_SIZE: + parser.error( + "The --batch-size option is currently only supported with the embeddings endpoint type." + ) + + def _check_load_manager_args(args: argparse.Namespace) -> argparse.Namespace: """ Check inference load args @@ -224,6 +247,16 @@ def _convert_str_to_enum_entry(args, option, enum): def _add_input_args(parser): input_group = parser.add_argument_group("Input") + input_group.add_argument( + "--batch-size", + "-b", + type=int, + default=LlmInputs.DEFAULT_BATCH_SIZE, + required=False, + help=f"The batch size of the requests GenAI-Perf should send. " + "This is currently only supported with the embeddings endpoint type.", + ) + input_group.add_argument( "--extra-inputs", action="append", @@ -404,7 +437,7 @@ def _add_endpoint_args(parser): endpoint_group.add_argument( "--endpoint-type", type=str, - choices=["chat", "completions"], + choices=["chat", "completions", "embeddings"], required=False, help=f"The endpoint-type to send requests to on the " 'server. This is only used with the "openai" service-kind.', diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py b/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py index e5f704423..dbaacc32b 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py @@ -64,6 +64,7 @@ def build_cmd(args: Namespace, extra_args: Optional[List[str]] = None) -> List[s skip_args = [ "artifact_dir", "backend", + "batch_size", "concurrency", "endpoint_type", "extra_inputs", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index 5cf84c360..721733224 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -79,6 +79,28 @@ def test_help_version_arguments_output_and_exit( ["--artifact-dir", "test_artifact_dir"], {"artifact_dir": Path("test_artifact_dir")}, ), + ( + [ + "--batch-size", + "5", + "--endpoint-type", + "embeddings", + "--service-kind", + "openai", + ], + {"batch_size": 5}, + ), + ( + [ + "-b", + "5", + "--endpoint-type", + "embeddings", + "--service-kind", + "openai", + ], + {"batch_size": 5}, + ), (["--concurrency", "3"], {"concurrency": 3}), ( ["--endpoint-type", "completions", "--service-kind", "openai"], @@ -472,6 +494,16 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ], "The --output-tokens-mean-deterministic option is only supported with the Triton service-kind", ), + ( + [ + "genai-perf", + "-m", + "test_model", + "--batch-size", + "10", + ], + "The --batch-size option is currently only supported with the embeddings endpoint type", + ), ], ) def test_conditional_errors(self, args, expected_output, monkeypatch, capsys): diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index c59c688e9..a29360806 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -234,6 +234,7 @@ def test_generate_json(self, monkeypatch) -> None: "formatted_model_name": "gpt2_vllm", "model_selection_strategy": "round_robin", "backend": "vllm", + "batch_size": 1, "endpoint": null, "endpoint_type": null, "service_kind": "triton", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_embeddings.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_embeddings.py new file mode 100644 index 000000000..0cefa38a7 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_embeddings.py @@ -0,0 +1,172 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest +from genai_perf.llm_inputs.llm_inputs import LlmInputs, ModelSelectionStrategy + + +class TestLlmInputsEmbeddings: + @patch("pathlib.Path.exists", return_value=True) + @patch( + "builtins.open", + new_callable=mock_open, + read_data="\n".join( + [ + '{"text": "What production company co-owned by Kevin Loader and Rodger Michell produced My Cousin Rachel?"}', + '{"text": "Who served as the 1st Vice President of Colombia under El Libertador?"}', + '{"text": "Are the Barton Mine and Hermiston-McCauley Mine located in The United States of America?"}', + '{"text": "what state did they film daddy\'s home 2"}', + ] + ), + ) + def test_get_input_dataset_from_embeddings_file(self, mock_file, mock_exists): + input_filename = Path("embeddings.jsonl") + batch_size = 3 + dataset = LlmInputs._get_input_dataset_from_embeddings_file( + input_filename, batch_size, num_prompts=100 + ) + + assert dataset is not None + assert len(dataset["rows"]) == 100 + for row in dataset["rows"]: + assert "row" in row + assert "payload" in row["row"] + payload = row["row"]["payload"] + assert "input" in payload + assert isinstance(payload["input"], list) + assert len(payload["input"]) == batch_size + + # Try error case where batch size is larger than the number of available texts + with pytest.raises( + ValueError, + match="Batch size cannot be larger than the number of available texts", + ): + LlmInputs._get_input_dataset_from_embeddings_file( + input_filename, 5, num_prompts=10 + ) + + def test_convert_generic_json_to_openai_embeddings_format(self): + generic_dataset = { + "rows": [ + {"payload": {"input": ["text 1", "text 2"]}}, + {"payload": {"input": ["text 3", "text 4"]}}, + ] + } + + expected_result = { + "data": [ + { + "payload": [ + { + "input": ["text 1", "text 2"], + "model": "test_model", + } + ] + }, + { + "payload": [ + { + "input": ["text 3", "text 4"], + "model": "test_model", + } + ] + }, + ] + } + + result = LlmInputs._convert_generic_json_to_openai_embeddings_format( + generic_dataset, + extra_inputs={}, + model_name=["test_model"], + model_selection_strategy=ModelSelectionStrategy.ROUND_ROBIN, + ) + + assert result is not None + assert "data" in result + assert len(result["data"]) == len(expected_result["data"]) + + for i, item in enumerate(expected_result["data"]): + assert "payload" in result["data"][i] + assert result["data"][i]["payload"] == item["payload"] + + def test_convert_generic_json_to_openai_embeddings_format_with_extra_inputs(self): + generic_dataset = { + "rows": [ + {"payload": {"input": ["text 1", "text 2"]}}, + {"payload": {"input": ["text 3", "text 4"]}}, + ] + } + + extra_inputs = { + "encoding_format": "base64", + "truncate": "END", + "additional_key": "additional_value", + } + + expected_result = { + "data": [ + { + "payload": [ + { + "input": ["text 1", "text 2"], + "model": "test_model", + "encoding_format": "base64", + "truncate": "END", + "additional_key": "additional_value", + } + ] + }, + { + "payload": [ + { + "input": ["text 3", "text 4"], + "model": "test_model", + "encoding_format": "base64", + "truncate": "END", + "additional_key": "additional_value", + } + ] + }, + ] + } + + result = LlmInputs._convert_generic_json_to_openai_embeddings_format( + generic_dataset, + extra_inputs=extra_inputs, + model_name=["test_model"], + model_selection_strategy=ModelSelectionStrategy.ROUND_ROBIN, + ) + + assert result is not None + assert "data" in result + assert len(result["data"]) == len(expected_result["data"]) + + for i, item in enumerate(expected_result["data"]): + assert "payload" in result["data"][i] + assert result["data"][i]["payload"] == item["payload"] From 9443bf8ad9d2629b4a9959897cfa7a246cbec4bd Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Thu, 27 Jun 2024 15:35:03 -0700 Subject: [PATCH 29/55] Support embedding metrics and output export (#719) * Refactor metrics and console exporter * Refactor console and csv exporter * Parse profile data for embedding * Fix tests and add base parser test * Add tests and small refactor * Revert check for ranking * Address feedback * Change Metric to MetricMetadata --- .../export_data/console_exporter.py | 109 +++++---- .../genai_perf/export_data/csv_exporter.py | 150 ++++++------ .../genai_perf/export_data/exporter_config.py | 12 + .../genai_perf/export_data/json_exporter.py | 5 +- .../genai_perf/export_data/output_reporter.py | 1 + .../genai-perf/genai_perf/main.py | 13 +- .../genai-perf/genai_perf/metrics/__init__.py | 2 +- .../genai_perf/metrics/llm_metrics.py | 41 +++- .../genai-perf/genai_perf/metrics/metrics.py | 44 ++-- .../genai_perf/metrics/statistics.py | 22 +- .../profile_data_parser.py | 28 ++- .../genai-perf/tests/test_console_exporter.py | 196 ++++++++-------- .../genai-perf/tests/test_csv_exporter.py | 215 +++++++++++------- .../genai-perf/tests/test_json_exporter.py | 1 - .../genai-perf/tests/test_llm_metrics.py | 46 ++++ .../genai-perf/tests/test_metrics.py | 64 ++++++ .../tests/test_profile_data_parser.py | 146 ++++++++++++ 17 files changed, 728 insertions(+), 367 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_metrics.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py index 8f910b697..994dee2d1 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py @@ -26,7 +26,6 @@ from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.metrics import Metrics from rich.console import Console from rich.table import Table @@ -36,74 +35,70 @@ class ConsoleExporter: A class to export the statistics and arg values to the console. """ + STAT_COLUMN_KEYS = ["avg", "min", "max", "p99", "p90", "p75"] + def __init__(self, config: ExporterConfig): self._stats = config.stats + self._metrics = config.metrics + self._args = config.args + + def _get_title(self): + if self._args.endpoint_type == "embeddings": + return "Embeddings Metrics" + return "LLM Metrics" def export(self) -> None: - singular_metric_rows = [] - table = Table(title="LLM Metrics") + table = Table(title=self._get_title()) table.add_column("Statistic", justify="right", style="cyan", no_wrap=True) - stats = ["avg", "min", "max", "p99", "p90", "p75"] - for stat in stats: + for stat in self.STAT_COLUMN_KEYS: table.add_column(stat, justify="right", style="green") - for metric in Metrics.metric_labels: - formatted_metric = metric.replace("_", " ").capitalize() + # Request metrics table + self._construct_table(table) - # Throughput fields are printed after the table - is_throughput_field = metric in Metrics.throughput_fields - if is_throughput_field: - value = self._stats.get(f"{metric}", -1).get(stats[0], -1) - formatted_metric += f" (per sec): {value:.2f}" - singular_metric_rows.append(formatted_metric) - continue + console = Console() + console.print(table) - # TODO (TMA-1712): need to decide if we need this metric. Remove - # from statistics display for now. - # TODO (TMA-1678): output_token_throughput_per_request is treated - # separately since the current code treats all throughput metrics to - # be displayed outside of the statistics table. - if metric == "output_token_throughput_per_request": - formatted_metric += f" (per sec)" + # System metrics are printed after the table + for metric in self._metrics.system_metrics: + line = metric.name.replace("_", " ").capitalize() + value = self._stats[metric.name]["avg"] + line += f" ({metric.unit}): {value:.2f}" + print(line) + + def _construct_table(self, table: Table) -> None: + for metric in self._metrics.request_metrics: + if self._should_skip(metric.name): continue - is_time_field = metric in Metrics.time_fields - if is_time_field: - formatted_metric += " (ms)" - - row_values = [formatted_metric] - for stat in stats: - value = self._stats.get(f"{metric}", -1) - # Need to check for -1 for the non streaming case - if value == -1: - row_values.append(f"{value:,.2f}") - else: - value = value.get(stat, -1) - row_values.append(f"{value:,.2f}") - - # Without streaming, there is no inter-token latency available, so do not print it. - if metric == "inter_token_latency": - if all(float(value) < 0 for value in row_values[1:]): - continue - # Without streaming, TTFT and request latency are the same, so do not print TTFT. - elif metric == "time_to_first_token": - unique_values = False - for stat in stats: - value_ttft = self._stats.get(f"{metric}", -1).get(stat, -1) - value_req_latency = self._stats.get("request_latency", -1).get( - stat, -1 - ) - if value_ttft != value_req_latency: - unique_values = True - break - if not unique_values: - continue + metric_str = metric.name.replace("_", " ").capitalize() + metric_str += f" ({metric.unit})" if metric.unit != "tokens" else "" + row_values = [metric_str] + for stat in self.STAT_COLUMN_KEYS: + value = self._stats[metric.name][stat] + row_values.append(f"{value:,.2f}") table.add_row(*row_values) - console = Console() - console.print(table) - - for row in singular_metric_rows: - print(row) + # (TMA-1976) Refactor this method as the csv exporter shares identical method. + def _should_skip(self, metric_name: str) -> bool: + if self._args.endpoint_type == "embeddings": + return False # skip nothing + + # TODO (TMA-1712): need to decide if we need this metric. Remove + # from statistics display for now. + # TODO (TMA-1678): output_token_throughput_per_request is treated + # separately since the current code treats all throughput metrics to + # be displayed outside of the statistics table. + if metric_name == "output_token_throughput_per_request": + return True + + # When non-streaming, skip ITL and TTFT + streaming_metrics = [ + "inter_token_latency", + "time_to_first_token", + ] + if not self._args.streaming and metric_name in streaming_metrics: + return True + return False diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py index a763417ad..efbb9b754 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/csv_exporter.py @@ -29,7 +29,6 @@ import genai_perf.logging as logging from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.metrics import Metrics DEFAULT_OUTPUT_DATA_CSV = "profile_export_genai_perf.csv" @@ -41,97 +40,80 @@ class CsvExporter: A class to export the statistics and arg values in a csv format. """ + REQUEST_METRICS_HEADER = [ + "Metric", + "avg", + "min", + "max", + "p99", + "p95", + "p90", + "p75", + "p50", + "p25", + ] + + SYSTEM_METRICS_HEADER = [ + "Metric", + "Value", + ] + def __init__(self, config: ExporterConfig): self._stats = config.stats + self._metrics = config.metrics self._output_dir = config.artifact_dir + self._args = config.args def export(self) -> None: csv_filename = self._output_dir / DEFAULT_OUTPUT_DATA_CSV logger.info(f"Generating {csv_filename}") - multiple_metric_header = [ - "Metric", - "avg", - "min", - "max", - "p99", - "p95", - "p90", - "p75", - "p50", - "p25", - ] - - single_metric_header = [ - "Metric", - "Value", - ] - with open(csv_filename, mode="w", newline="") as csvfile: - singular_metric_rows = [] - csv_writer = csv.writer(csvfile) - csv_writer.writerow(multiple_metric_header) - - for metric in Metrics.metric_labels: - formatted_metric = metric.replace("_", " ").title() - - is_throughput_field = metric in Metrics.throughput_fields - is_time_field = metric in Metrics.time_fields - - if is_time_field: - formatted_metric += " (ms)" - elif is_throughput_field: - formatted_metric += " (per sec)" - # TODO (TMA-1712): need to decide if we need this metric. Do not - # include in the csv for now. - # TODO (TMA-1678): output_token_throughput_per_request is treated - # separately since the current code treats all throughput metrics - # to be displayed outside of the statistics table. - elif metric == "output_token_throughput_per_request": - formatted_metric += " (per sec)" - continue - - row_values = [formatted_metric] - - if is_throughput_field: - value = self._stats.get(f"{metric}", -1).get( - multiple_metric_header[1], -1 - ) - row_values.append(f"{value:.2f}") - singular_metric_rows.append(row_values) - continue - - for stat in multiple_metric_header[1:]: - value = self._stats.get(f"{metric}", -1) - # Need to check for -1 for the non streaming case - if value == -1: - row_values.append(f"{value:,.2f}") - else: - value = value.get(stat, -1) - row_values.append(f"{value:,.2f}") - - # Without streaming, there is no inter-token latency available, so do not print it. - if metric == "inter_token_latency": - if all(value == "-1" for value in row_values[1:]): - continue - # Without streaming, TTFT and request latency are the same, so do not print TTFT. - elif metric == "time_to_first_token": - unique_values = False - for stat in multiple_metric_header[1:]: - value_ttft = self._stats.get(f"{metric}", -1).get(stat, -1) - value_req_latency = self._stats.get("request_latency", -1).get( - stat, -1 - ) - if value_ttft != value_req_latency: - unique_values = True - break - if not unique_values: - continue - - csv_writer.writerow(row_values) - + self._write_request_metrics(csv_writer) csv_writer.writerow([]) - csv_writer.writerow(single_metric_header) - for row in singular_metric_rows: - csv_writer.writerow(row) + self._write_system_metrics(csv_writer) + + def _write_request_metrics(self, csv_writer) -> None: + csv_writer.writerow(self.REQUEST_METRICS_HEADER) + for metric in self._metrics.request_metrics: + if self._should_skip(metric.name): + continue + + metric_str = metric.name.replace("_", " ").title() + metric_str += f" ({metric.unit})" if metric.unit != "tokens" else "" + row_values = [metric_str] + for stat in self.REQUEST_METRICS_HEADER[1:]: + value = self._stats[metric.name][stat] + row_values.append(f"{value:,.2f}") + + csv_writer.writerow(row_values) + + def _write_system_metrics(self, csv_writer) -> None: + csv_writer.writerow(self.SYSTEM_METRICS_HEADER) + for metric in self._metrics.system_metrics: + metric_str = metric.name.replace("_", " ").title() + metric_str += f" ({metric.unit})" + value = self._stats[metric.name]["avg"] + csv_writer.writerow([metric_str, f"{value:.2f}"]) + + def _should_skip(self, metric_name: str) -> bool: + if self._args.endpoint_type == "embeddings": + return False # skip nothing + + # TODO (TMA-1712): need to decide if we need this metric. Remove + # from statistics display for now. + # TODO (TMA-1678): output_token_throughput_per_request is treated + # separately since the current code treats all throughput metrics to + # be displayed outside of the statistics table. + if metric_name == "output_token_throughput_per_request": + return True + + # When non-streaming, skip ITL and TTFT + streaming_metrics = [ + "inter_token_latency", + "time_to_first_token", + ] + if not self._args.streaming and metric_name in streaming_metrics: + return True + return False diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py index 3f0451961..0d9c7cd0b 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/exporter_config.py @@ -25,9 +25,13 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from genai_perf.metrics import Metrics + + class ExporterConfig: def __init__(self): self._stats = None + self._metrics = None self._args = None self._extra_inputs = None self._artifact_dir = None @@ -40,6 +44,14 @@ def stats(self): def stats(self, stats_value): self._stats = stats_value + @property + def metrics(self): + return self._metrics + + @metrics.setter + def metrics(self, metrics: Metrics): + self._metrics = metrics + @property def args(self): return self._args diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py index c5a0f36cd..2ec24fae1 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/json_exporter.py @@ -58,8 +58,9 @@ def export(self) -> None: f.write(json.dumps(self._stats_and_args, indent=2)) def _prepare_args_for_export(self) -> None: - del self._args["func"] - del self._args["output_format"] + self._args.pop("func", None) + self._args.pop("output_format", None) + self._args.pop("input_file", None) self._args["profile_export_file"] = str(self._args["profile_export_file"]) self._args["artifact_dir"] = str(self._args["artifact_dir"]) for k, v in self._args.items(): diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py index 9733709ea..ec8123b95 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/output_reporter.py @@ -54,6 +54,7 @@ def report_output(self) -> None: def _create_exporter_config(self) -> ExporterConfig: config = ExporterConfig() config.stats = self.stats.stats_dict + config.metrics = self.stats.metrics config.args = self.args config.artifact_dir = self.args.artifact_dir config.extra_inputs = get_extra_inputs_as_dict(self.args) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index d44b5c6b1..c6a555ec2 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -84,13 +84,16 @@ def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: def calculate_metrics(args: Namespace, tokenizer: Tokenizer) -> ProfileDataParser: - return LLMProfileDataParser( - filename=args.profile_export_file, - tokenizer=tokenizer, - ) + if args.endpoint_type == "embeddings": + return ProfileDataParser(args.profile_export_file) + else: + return LLMProfileDataParser( + filename=args.profile_export_file, + tokenizer=tokenizer, + ) -def report_output(data_parser: LLMProfileDataParser, args: Namespace) -> None: +def report_output(data_parser: ProfileDataParser, args: Namespace) -> None: if args.concurrency: infer_mode = "concurrency" load_level = f"{args.concurrency}" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py index 50a63e709..438f86203 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py @@ -25,5 +25,5 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from genai_perf.metrics.llm_metrics import LLMMetrics -from genai_perf.metrics.metrics import Metrics, ResponseFormat +from genai_perf.metrics.metrics import MetricMetadata, Metrics, ResponseFormat from genai_perf.metrics.statistics import Statistics diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py index a0d1365fb..13dff8a63 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/llm_metrics.py @@ -28,12 +28,25 @@ from typing import List -from genai_perf.metrics.metrics import Metrics +from genai_perf.metrics.metrics import MetricMetadata, Metrics class LLMMetrics(Metrics): """A simple dataclass that holds core LLM performance metrics.""" + LLM_REQUEST_METRICS = [ + MetricMetadata("time_to_first_token", "ms"), + MetricMetadata("inter_token_latency", "ms"), + MetricMetadata("output_token_throughput_per_request", "tokens/sec"), + MetricMetadata("output_sequence_length", "tokens"), + MetricMetadata("input_sequence_length", "tokens"), + ] + + LLM_SYSTEM_METRICS = [ + # (TMA-1977) Make the unit consistent with statistics dict (e.g. tokens/sec) + MetricMetadata("output_token_throughput", "per sec"), + ] + def __init__( self, request_throughputs: List[float] = [], @@ -67,3 +80,29 @@ def __init__( ) self._base_names["output_sequence_lengths"] = "output_sequence_length" self._base_names["input_sequence_lengths"] = "input_sequence_length" + + @property + def request_metrics(self) -> List[MetricMetadata]: + base_metrics = super().request_metrics # base metrics + + # (TMA-1975) The order is hardcoded as below to avoid introducing any + # breaking changes to the users who might be parsing the outputs. However, + # we would eventually want to impose some consistent order such as a + # base metrics first and then task specific metrics. Uncomment the below + # line to enable this order: + # return base_metrics + self.LLM_REQUEST_METRICS + return ( + self.LLM_REQUEST_METRICS[:2] + base_metrics + self.LLM_REQUEST_METRICS[2:] + ) + + @property + def system_metrics(self) -> List[MetricMetadata]: + base_metrics = super().system_metrics # base metrics + + # (TMA-1975) The order is hardcoded as below to avoid introducing any + # breaking changes to the users who might be parsing the outputs. However, + # we would eventually want to impose some consistent order such as a + # base metrics first and then task specific metrics. Uncomment the below + # line to enable this order: + # return base_metrics + self.LLM_SYSTEM_METRICS + return self.LLM_SYSTEM_METRICS + base_metrics diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py index c68cf542e..811bd9a66 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py @@ -26,6 +26,7 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from dataclasses import dataclass from enum import Enum, auto from typing import List @@ -33,35 +34,26 @@ class ResponseFormat(Enum): OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() + OPENAI_EMBEDDINGS = auto() TRITON = auto() +@dataclass +class MetricMetadata: + name: str + unit: str + + class Metrics: - """A base class for all the metrics class that contains common metrics.""" - - metric_labels = [ - "time_to_first_token", - "inter_token_latency", - "request_latency", - "output_token_throughput", - "output_token_throughput_per_request", - "request_throughput", - "output_sequence_length", - "input_sequence_length", - ] + """A base class that contains common request level metrics.""" - time_fields = [ - "inter_token_latency", - "time_to_first_token", - "request_latency", + REQUEST_METRICS = [ + MetricMetadata("request_latency", "ms"), ] - # TODO (TMA-1678): output_token_throughput_per_request is not on this list - # since the current code treats all the throughput metrics to be displayed - # outside of the statistics table. - throughput_fields = [ - "request_throughput", - "output_token_throughput", + SYSTEM_METRICS = [ + # (TMA-1977) Make the unit consistent with statistics dict (e.g. tokens/sec) + MetricMetadata("request_throughput", "per sec"), ] def __init__( @@ -83,6 +75,14 @@ def __repr__(self): attr_strs.append(f"{k}={v}") return f"Metrics({','.join(attr_strs)})" + @property + def request_metrics(self) -> List[MetricMetadata]: + return self.REQUEST_METRICS + + @property + def system_metrics(self) -> List[MetricMetadata]: + return self.SYSTEM_METRICS + @property def data(self) -> dict: """Returns all the metrics.""" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py index 36c8cb9b6..ced6196ff 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py @@ -64,7 +64,7 @@ def __init__(self, metrics: Metrics): attr = metrics.get_base_name(attr) self._add_units(attr) self._calculate_mean(data, attr) - if not self._is_throughput_field(attr): + if not self._is_system_metric(metrics, attr): self._calculate_percentiles(data, attr) self._calculate_minmax(data, attr) self._calculate_std(data, attr) @@ -114,7 +114,7 @@ def _calculate_std(self, data: List[Union[int, float]], attr: str) -> None: def scale_data(self, factor: float = 1 / 1e6) -> None: for k1, v1 in self.stats_dict.items(): - if self._is_time_field(k1): + if self._is_time_metric(k1): for k2, v2 in v1.items(): if k2 != "unit": self.stats_dict[k1][k2] = self._scale(v2, factor) @@ -127,7 +127,7 @@ def _scale(self, metric: float, factor: float = 1 / 1e6) -> float: return metric * factor def _add_units(self, key) -> None: - if self._is_time_field(key): + if self._is_time_metric(key): self._stats_dict[key]["unit"] = "ms" if key == "request_throughput": self._stats_dict[key]["unit"] = "requests/sec" @@ -157,11 +157,17 @@ def metrics(self) -> Metrics: def stats_dict(self) -> Dict: return self._stats_dict - def _is_throughput_field(self, field: str) -> bool: - return field in Metrics.throughput_fields - - def _is_time_field(self, field: str) -> bool: - return field in Metrics.time_fields + def _is_system_metric(self, metrics: Metrics, attr: str) -> bool: + return attr in [m.name for m in metrics.system_metrics] + + def _is_time_metric(self, field: str) -> bool: + # TPA-188: Remove the hardcoded time metrics list + time_metrics = [ + "inter_token_latency", + "time_to_first_token", + "request_latency", + ] + return field in time_metrics def export_parquet(self, artifact_dir: Path, filename: str) -> None: max_length = -1 diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py index efc051189..8d0f08283 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py @@ -50,6 +50,8 @@ def _get_profile_metadata(self, data: dict) -> None: self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS elif data["endpoint"] == "v1/completions": self._response_format = ResponseFormat.OPENAI_COMPLETIONS + elif data["endpoint"] == "v1/embeddings": + self._response_format = ResponseFormat.OPENAI_EMBEDDINGS else: # TPA-66: add PA metadata to handle this case # When endpoint field is either empty or custom endpoint, fall @@ -60,6 +62,8 @@ def _get_profile_metadata(self, data: dict) -> None: self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS elif "text_completion" in response: self._response_format = ResponseFormat.OPENAI_COMPLETIONS + elif "embedding" in response: + self._response_format = ResponseFormat.OPENAI_EMBEDDINGS else: raise RuntimeError("Unknown OpenAI response format.") @@ -84,7 +88,29 @@ def _parse_profile_data(self, data: dict) -> None: def _parse_requests(self, requests: dict) -> Metrics: """Parse each request in profile data to extract core metrics.""" - raise NotImplementedError + min_req_timestamp, max_res_timestamp = float("inf"), 0 + request_latencies = [] + + for request in requests: + req_timestamp = request["timestamp"] + res_timestamps = request["response_timestamps"] + + # track entire benchmark duration + min_req_timestamp = min(min_req_timestamp, req_timestamp) + max_res_timestamp = max(max_res_timestamp, res_timestamps[-1]) + + # request latencies + req_latency = res_timestamps[-1] - req_timestamp + request_latencies.append(req_latency) + + # request throughput + benchmark_duration = (max_res_timestamp - min_req_timestamp) / 1e9 # to seconds + request_throughputs = [len(requests) / benchmark_duration] + + return Metrics( + request_throughputs, + request_latencies, + ) def get_statistics(self, infer_mode: str, load_level: str) -> Statistics: """Return profile statistics if it exists.""" diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py index b947a3d61..ca11377ed 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -24,39 +24,78 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from genai_perf import parser from genai_perf.export_data.console_exporter import ConsoleExporter from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.metrics import LLMMetrics, Statistics +from genai_perf.metrics import LLMMetrics, Metrics, Statistics class TestConsoleExporter: - def test_pretty_print_output(self, capsys) -> None: + def test_streaming_llm_output(self, monkeypatch, capsys) -> None: + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "chat", + "--streaming", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() + + metrics = LLMMetrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + time_to_first_tokens=[7, 8, 9], + inter_token_latencies=[10, 11, 12], + output_token_throughputs=[456], + output_sequence_lengths=[1, 2, 3], + input_sequence_lengths=[5, 6, 7], + ) + stats = Statistics(metrics=metrics) + config = ExporterConfig() - config.stats = stats + config.stats = stats.stats_dict + config.metrics = stats.metrics + config.args = args + exporter = ConsoleExporter(config) exporter.export() expected_content = ( - " LLM Metrics \n" - "┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┓\n" - "┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃\n" - "┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━┩\n" - "│ Time to first token (ms) │ 2.00 │ 2.00 │ 3.00 │ 2.99 │ 2.90 │ 2.75 │\n" - "│ Inter token latency (ms) │ 0.50 │ 0.00 │ 1.00 │ 0.99 │ 0.90 │ 0.75 │\n" - "│ Request latency (ms) │ 3.00 │ 3.00 │ 4.00 │ 3.99 │ 3.90 │ 3.75 │\n" - "│ Output sequence length │ 6.50 │ 6.00 │ 7.00 │ 6.99 │ 6.90 │ 6.75 │\n" - "│ Input sequence length │ 7.50 │ 7.00 │ 8.00 │ 7.99 │ 7.90 │ 7.75 │\n" - "└──────────────────────────┴──────┴──────┴──────┴──────┴──────┴──────┘\n" - "Output token throughput (per sec): 123.00\n" - "Request throughput (per sec): 456.00\n" + " LLM Metrics \n" + "┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓\n" + "┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃\n" + "┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━┩\n" + "│ Time to first token (ms) │ 8.00 │ 7.00 │ 9.00 │ 8.98 │ 8.80 │ 8.50 │\n" + "│ Inter token latency (ms) │ 11.00 │ 10.00 │ 12.00 │ 11.98 │ 11.80 │ 11.50 │\n" + "│ Request latency (ms) │ 5.00 │ 4.00 │ 6.00 │ 5.98 │ 5.80 │ 5.50 │\n" + "│ Output sequence length │ 2.00 │ 1.00 │ 3.00 │ 2.98 │ 2.80 │ 2.50 │\n" + "│ Input sequence length │ 6.00 │ 5.00 │ 7.00 │ 6.98 │ 6.80 │ 6.50 │\n" + "└──────────────────────────┴───────┴───────┴───────┴───────┴───────┴───────┘\n" + "Output token throughput (per sec): 456.00\n" + "Request throughput (per sec): 123.00\n" ) returned_data = capsys.readouterr().out - assert returned_data == expected_content - def test_nonstreaming_llm_output(self, capsys) -> None: + def test_nonstreaming_llm_output(self, monkeypatch, capsys) -> None: + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "chat", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() + metrics = LLMMetrics( request_throughputs=[123], request_latencies=[4, 5, 6], @@ -70,6 +109,9 @@ def test_nonstreaming_llm_output(self, capsys) -> None: config = ExporterConfig() config.stats = stats.stats_dict + config.metrics = stats.metrics + config.args = args + exporter = ConsoleExporter(config) exporter.export() @@ -90,86 +132,42 @@ def test_nonstreaming_llm_output(self, capsys) -> None: returned_data = capsys.readouterr().out assert returned_data == expected_content + def test_embedding_output(self, monkeypatch, capsys) -> None: + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "embeddings", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() -stats = { - "request_throughput": {"unit": "requests/sec", "avg": 456.0}, - "request_latency": { - "unit": "ms", - "avg": 3.0, - "p99": 3.99, - "p95": 3.95, - "p90": 3.90, - "p75": 3.75, - "p50": 3.50, - "p25": 3.25, - "max": 4.0, - "min": 3.0, - "std": 3.50, - }, - "time_to_first_token": { - "unit": "ms", - "avg": 2.0, - "p99": 2.99, - "p95": 2.95, - "p90": 2.90, - "p75": 2.75, - "p50": 2.50, - "p25": 2.25, - "max": 3.00, - "min": 2.00, - "std": 2.50, - }, - "inter_token_latency": { - "unit": "ms", - "avg": 0.50, - "p99": 0.99, - "p95": 0.95, - "p90": 0.90, - "p75": 0.75, - "p50": 0.50, - "p25": 0.25, - "max": 1.00, - "min": 0.00, - "std": 0.50, - }, - "output_token_throughput": {"unit": "tokens/sec", "avg": 123.0}, - "output_token_throughput_per_request": { - "unit": "tokens/sec", - "avg": 300.00, - "p99": 300.00, - "p95": 300.00, - "p90": 300.00, - "p75": 300.00, - "p50": 300.00, - "p25": 300.00, - "max": 300.00, - "min": 300.00, - "std": 300.00, - }, - "output_sequence_length": { - "unit": "tokens", - "avg": 6.5, - "p99": 6.99, - "p95": 6.95, - "p90": 6.90, - "p75": 6.75, - "p50": 6.5, - "p25": 6.25, - "max": 7.0, - "min": 6.0, - "std": 6.5, - }, - "input_sequence_length": { - "unit": "tokens", - "avg": 7.5, - "p99": 7.99, - "p95": 7.95, - "p90": 7.90, - "p75": 7.75, - "p50": 7.5, - "p25": 7.25, - "max": 8.0, - "min": 7.0, - "std": 7.5, - }, -} + metrics = Metrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + ) + stats = Statistics(metrics=metrics) + + config = ExporterConfig() + config.stats = stats.stats_dict + config.metrics = stats.metrics + config.args = args + + exporter = ConsoleExporter(config) + exporter.export() + + expected_content = ( + " Embeddings Metrics \n" + "┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━┓\n" + "┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃\n" + "┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━┩\n" + "│ Request latency (ms) │ 5.00 │ 4.00 │ 6.00 │ 5.98 │ 5.80 │ 5.50 │\n" + "└──────────────────────┴──────┴──────┴──────┴──────┴──────┴──────┘\n" + "Request throughput (per sec): 123.00\n" + ) + + returned_data = capsys.readouterr().out + assert returned_data == expected_content diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py index 283d5f791..bd2d3bb81 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py @@ -24,16 +24,15 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -import json from io import StringIO from pathlib import Path from typing import Any, List import pytest +from genai_perf import parser from genai_perf.export_data.csv_exporter import CsvExporter from genai_perf.export_data.exporter_config import ExporterConfig -from genai_perf.profile_data_parser import LLMProfileDataParser -from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer +from genai_perf.metrics import LLMMetrics, Metrics, Statistics class TestCsvExporter: @@ -52,10 +51,7 @@ def write(self: Any, content: str) -> int: written_data.append(content) return len(content) - if str(filename) == "triton_profile_export.json": - tmp_file = StringIO(json.dumps(triton_profile_data)) - return tmp_file - elif str(filename) == "profile_export_genai_perf.csv": + if str(filename) == "profile_export_genai_perf.csv": tmp_file = StringIO() tmp_file.write = write.__get__(tmp_file) return tmp_file @@ -66,102 +62,149 @@ def write(self: Any, content: str) -> int: return written_data - def test_csv_output(self, mock_read_write: pytest.MonkeyPatch) -> None: + def test_streaming_llm_csv_output( + self, monkeypatch, mock_read_write: pytest.MonkeyPatch + ) -> None: """ Collect LLM metrics from profile export data and confirm correct values are printed in csv. """ - - tokenizer = get_tokenizer(DEFAULT_TOKENIZER) - pd = LLMProfileDataParser( - filename=Path("triton_profile_export.json"), - tokenizer=tokenizer, + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "chat", + "--streaming", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() + + metrics = LLMMetrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + time_to_first_tokens=[7, 8, 9], + inter_token_latencies=[10, 11, 12], + output_token_throughputs=[456], + output_sequence_lengths=[1, 2, 3], + input_sequence_lengths=[5, 6, 7], ) - stat = pd.get_statistics(infer_mode="concurrency", load_level="10") + stats = Statistics(metrics=metrics) + + config = ExporterConfig() + config.stats = stats.stats_dict + config.metrics = stats.metrics + config.artifact_dir = Path(".") + config.args = args + + exporter = CsvExporter(config) + exporter.export() expected_content = [ "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", - "Time To First Token (ms),2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00,2.00\r\n", - "Inter Token Latency (ms),1.50,1.00,2.00,1.99,1.95,1.90,1.75,1.50,1.25\r\n", - "Request Latency (ms),8.00,7.00,9.00,8.98,8.90,8.80,8.50,8.00,7.50\r\n", - "Output Sequence Length,4.50,3.00,6.00,5.97,5.85,5.70,5.25,4.50,3.75\r\n", - "Input Sequence Length,3.50,3.00,4.00,3.99,3.95,3.90,3.75,3.50,3.25\r\n", + "Time To First Token (ms),8.00,7.00,9.00,8.98,8.90,8.80,8.50,8.00,7.50\r\n", + "Inter Token Latency (ms),11.00,10.00,12.00,11.98,11.90,11.80,11.50,11.00,10.50\r\n", + "Request Latency (ms),5.00,4.00,6.00,5.98,5.90,5.80,5.50,5.00,4.50\r\n", + "Output Sequence Length,2.00,1.00,3.00,2.98,2.90,2.80,2.50,2.00,1.50\r\n", + "Input Sequence Length,6.00,5.00,7.00,6.98,6.90,6.80,6.50,6.00,5.50\r\n", "\r\n", "Metric,Value\r\n", - "Output Token Throughput (per sec),900000000.00\r\n", - "Request Throughput (per sec),200000000.00\r\n", + "Output Token Throughput (per sec),456.00\r\n", + "Request Throughput (per sec),123.00\r\n", ] + returned_data = mock_read_write + assert returned_data == expected_content + + def test_nonstreaming_llm_csv_output( + self, monkeypatch, mock_read_write: pytest.MonkeyPatch + ) -> None: + """ + Collect LLM metrics from profile export data and confirm correct values are + printed in csv. + """ + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "chat", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() + + metrics = LLMMetrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + time_to_first_tokens=[4, 5, 6], # same as request_latency + inter_token_latencies=[], # no ITL + output_token_throughputs=[456], + output_sequence_lengths=[1, 2, 3], + input_sequence_lengths=[5, 6, 7], + ) + stats = Statistics(metrics=metrics) + config = ExporterConfig() - config.stats = stat.stats_dict + config.stats = stats.stats_dict + config.metrics = stats.metrics config.artifact_dir = Path(".") + config.args = args + exporter = CsvExporter(config) exporter.export() + expected_content = [ + "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", + "Request Latency (ms),5.00,4.00,6.00,5.98,5.90,5.80,5.50,5.00,4.50\r\n", + "Output Sequence Length,2.00,1.00,3.00,2.98,2.90,2.80,2.50,2.00,1.50\r\n", + "Input Sequence Length,6.00,5.00,7.00,6.98,6.90,6.80,6.50,6.00,5.50\r\n", + "\r\n", + "Metric,Value\r\n", + "Output Token Throughput (per sec),456.00\r\n", + "Request Throughput (per sec),123.00\r\n", + ] returned_data = mock_read_write - assert returned_data == expected_content + def test_embedding_csv_output( + self, monkeypatch, mock_read_write: pytest.MonkeyPatch + ) -> None: + argv = [ + "genai-perf", + "-m", + "model_name", + "--service-kind", + "openai", + "--endpoint-type", + "embeddings", + ] + monkeypatch.setattr("sys.argv", argv) + args, _ = parser.parse_args() + + metrics = Metrics( + request_throughputs=[123], + request_latencies=[4, 5, 6], + ) + stats = Statistics(metrics=metrics) + + config = ExporterConfig() + config.stats = stats.stats_dict + config.metrics = stats.metrics + config.artifact_dir = Path(".") + config.args = args -triton_profile_data = { - "service_kind": "triton", - "endpoint": "", - "experiments": [ - { - "experiment": { - "mode": "concurrency", - "value": 10, - }, - "requests": [ - { - "timestamp": 1, - "request_inputs": {"text_input": "This is test"}, - "response_timestamps": [3, 5, 8], - "response_outputs": [ - {"text_output": "I"}, - {"text_output": " like"}, - {"text_output": " dogs"}, - ], - }, - { - "timestamp": 2, - "request_inputs": {"text_input": "This is test too"}, - "response_timestamps": [4, 7, 11], - "response_outputs": [ - {"text_output": "I"}, - {"text_output": " don't"}, - {"text_output": " cook food"}, - ], - }, - ], - }, - { - "experiment": { - "mode": "request_rate", - "value": 2.0, - }, - "requests": [ - { - "timestamp": 5, - "request_inputs": {"text_input": "This is test"}, - "response_timestamps": [7, 8, 13, 18], - "response_outputs": [ - {"text_output": "cat"}, - {"text_output": " is"}, - {"text_output": " cool"}, - {"text_output": " too"}, - ], - }, - { - "timestamp": 3, - "request_inputs": {"text_input": "This is test too"}, - "response_timestamps": [6, 8, 11], - "response_outputs": [ - {"text_output": "it's"}, - {"text_output": " very"}, - {"text_output": " simple work"}, - ], - }, - ], - }, - ], -} + exporter = CsvExporter(config) + exporter.export() + + expected_content = [ + "Metric,avg,min,max,p99,p95,p90,p75,p50,p25\r\n", + "Request Latency (ms),5.00,4.00,6.00,5.98,5.90,5.80,5.50,5.00,4.50\r\n", + "\r\n", + "Metric,Value\r\n", + "Request Throughput (per sec),123.00\r\n", + ] + returned_data = mock_read_write + assert returned_data == expected_content diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index a29360806..998cc8865 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -241,7 +241,6 @@ def test_generate_json(self, monkeypatch) -> None: "streaming": true, "u": null, "input_dataset": null, - "input_file": null, "num_prompts": 100, "output_tokens_mean": -1, "output_tokens_mean_deterministic": false, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index 5bd2389ad..05de5b122 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -30,6 +30,52 @@ class TestLLMMetrics: + def test_llm_metric_request_metrics(self) -> None: + """Test request_metrics property.""" + m = LLMMetrics( + request_throughputs=[10.12, 11.33], + request_latencies=[3, 44], + time_to_first_tokens=[1, 2, 3], + inter_token_latencies=[4, 5], + output_token_throughputs=[22.13, 9423.02], + output_token_throughputs_per_request=[7, 8, 9], + output_sequence_lengths=[3, 4], + input_sequence_lengths=[12, 34], + ) + req_metrics = m.request_metrics + assert len(req_metrics) == 6 + assert req_metrics[0].name == "time_to_first_token" + assert req_metrics[0].unit == "ms" + assert req_metrics[1].name == "inter_token_latency" + assert req_metrics[1].unit == "ms" + assert req_metrics[2].name == "request_latency" + assert req_metrics[2].unit == "ms" + assert req_metrics[3].name == "output_token_throughput_per_request" + assert req_metrics[3].unit == "tokens/sec" + assert req_metrics[4].name == "output_sequence_length" + assert req_metrics[4].unit == "tokens" + assert req_metrics[5].name == "input_sequence_length" + assert req_metrics[5].unit == "tokens" + + def test_llm_metric_system_metrics(self) -> None: + """Test system_metrics property.""" + m = LLMMetrics( + request_throughputs=[10.12, 11.33], + request_latencies=[3, 44], + time_to_first_tokens=[1, 2, 3], + inter_token_latencies=[4, 5], + output_token_throughputs=[22.13, 9423.02], + output_token_throughputs_per_request=[7, 8, 9], + output_sequence_lengths=[3, 4], + input_sequence_lengths=[12, 34], + ) + sys_metrics = m.system_metrics + assert len(sys_metrics) == 2 + assert sys_metrics[0].name == "output_token_throughput" + assert sys_metrics[0].unit == "per sec" + assert sys_metrics[1].name == "request_throughput" + assert sys_metrics[1].unit == "per sec" + def test_llm_metrics_get_base_name(self) -> None: """Test get_base_name method in LLMMetrics class.""" # initialize with dummy values diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_metrics.py new file mode 100644 index 000000000..2af489fc4 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_metrics.py @@ -0,0 +1,64 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import pytest +from genai_perf.metrics import Metrics + + +class TestMetrics: + + def test_metric_request_metrics(self) -> None: + """Test request_metrics property.""" + m = Metrics( + request_throughputs=[10.12, 11.33], + request_latencies=[3, 44], + ) + req_metrics = m.request_metrics + assert len(req_metrics) == 1 + assert req_metrics[0].name == "request_latency" + assert req_metrics[0].unit == "ms" + + def test_metric_system_metrics(self) -> None: + """Test system_metrics property.""" + m = Metrics( + request_throughputs=[10.12, 11.33], + request_latencies=[3, 44], + ) + sys_metrics = m.system_metrics + assert len(sys_metrics) == 1 + assert sys_metrics[0].name == "request_throughput" + assert sys_metrics[0].unit == "per sec" + + def test_metrics_get_base_name(self) -> None: + """Test get_base_name method in Metrics class.""" + metrics = Metrics( + request_throughputs=[10.12, 11.33], + request_latencies=[3, 44], + ) + assert metrics.get_base_name("request_throughputs") == "request_throughput" + assert metrics.get_base_name("request_latencies") == "request_latency" + with pytest.raises(KeyError): + metrics.get_base_name("hello1234") diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py new file mode 100644 index 000000000..d1b93e51a --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py @@ -0,0 +1,146 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import json +from io import StringIO +from pathlib import Path +from typing import Any, List, Union + +import numpy as np +import pytest +from genai_perf.metrics import Metrics +from genai_perf.profile_data_parser import ProfileDataParser + + +def ns_to_sec(ns: int) -> Union[int, float]: + """Convert from nanosecond to second.""" + return ns / 1e9 + + +class TestProfileDataParser: + @pytest.fixture + def mock_read_write(self, monkeypatch: pytest.MonkeyPatch) -> List[str]: + """ + This function will mock the open function for specific files: + + - For "triton_profile_export.json", it will read and return the + contents of self.triton_profile_data + - For "openai_profile_export.json", it will read and return the + contents of self.openai_profile_data + - For "profile_export.csv", it will capture all data written to + the file, and return it as the return value of this function + - For all other files, it will behave like the normal open function + """ + + written_data = [] + + original_open = open + + def custom_open(filename, *args, **kwargs): + def write(self: Any, content: str) -> int: + written_data.append(content) + return len(content) + + if filename == "embedding_profile_export.json": + tmp_file = StringIO(json.dumps(self.embedding_profile_data)) + return tmp_file + elif filename == "profile_export.csv": + tmp_file = StringIO() + tmp_file.write = write.__get__(tmp_file) + return tmp_file + else: + return original_open(filename, *args, **kwargs) + + monkeypatch.setattr("builtins.open", custom_open) + + return written_data + + def test_base_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Collect base metrics from profile export data and check values. + + Metrics + * request latencies + - experiment 1: [3 - 1, 5 - 2] = [2, 3] + * request throughputs + - experiment 1: [2 / (5e-9 - 1e-9)] = [5e8] + """ + pd = ProfileDataParser(filename=Path("embedding_profile_export.json")) + + # experiment 1 statistics + stats = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stats.metrics + stats_dict = stats.stats_dict + assert isinstance(metrics, Metrics) + + assert metrics.request_latencies == [2, 3] + assert metrics.request_throughputs == [pytest.approx(5e8)] + + assert stats_dict["request_latency"]["avg"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["p50"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["min"] == pytest.approx(2) # type: ignore + assert stats_dict["request_latency"]["max"] == pytest.approx(3) # type: ignore + assert stats_dict["request_latency"]["std"] == np.std([2, 3]) # type: ignore + + assert stats_dict["request_throughput"]["avg"] == pytest.approx(5e8) # type: ignore + + embedding_profile_data = { + "service_kind": "openai", + "endpoint": "v1/embeddings", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"input":"This is test","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + }, + "response_timestamps": [3], + "response_outputs": [ + { + "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":7,"total_tokens":7}}' + }, + ], + }, + { + "timestamp": 2, + "request_inputs": { + "payload": '{"input":"This is test too","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + }, + "response_timestamps": [5], + "response_outputs": [ + { + "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3, 4],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":8,"total_tokens":8}}' + }, + ], + }, + ], + }, + ], + } From 3238945d141479c22472306cb518fac42796d30c Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:05:44 -0700 Subject: [PATCH 30/55] Update GenAI-Perf metric unit assignment to avoid overwrites (#721) --- .../genai-perf/genai_perf/metrics/statistics.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py index ced6196ff..f0d12cef6 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/statistics.py @@ -129,12 +129,14 @@ def _scale(self, metric: float, factor: float = 1 / 1e6) -> float: def _add_units(self, key) -> None: if self._is_time_metric(key): self._stats_dict[key]["unit"] = "ms" - if key == "request_throughput": + elif key == "request_throughput": self._stats_dict[key]["unit"] = "requests/sec" - if key.startswith("output_token_throughput"): + elif key.startswith("output_token_throughput"): self._stats_dict[key]["unit"] = "tokens/sec" - if "sequence_length" in key: + elif "sequence_length" in key: self._stats_dict[key]["unit"] = "tokens" + else: + self._stats_dict[key]["unit"] = "" def __repr__(self) -> str: attr_strs = [] From 052fd22a5ba275b957ac552d4c19b97dc4e86d20 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Fri, 28 Jun 2024 11:28:12 -0700 Subject: [PATCH 31/55] Support ranking API profile data parsing and metrics calculation (#723) * Support profile data parsing for rankings API * Add test for ranking profile data * Address feedback --- .../export_data/console_exporter.py | 5 +- .../genai-perf/genai_perf/main.py | 2 +- .../genai-perf/genai_perf/metrics/__init__.py | 2 +- .../genai-perf/genai_perf/metrics/metrics.py | 8 -- .../profile_data_parser/__init__.py | 5 +- .../llm_profile_data_parser.py | 7 +- .../profile_data_parser.py | 15 ++- .../tests/test_profile_data_parser.py | 94 +++++++++++++++++-- 8 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py index 994dee2d1..460fe5976 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/export_data/console_exporter.py @@ -45,7 +45,10 @@ def __init__(self, config: ExporterConfig): def _get_title(self): if self._args.endpoint_type == "embeddings": return "Embeddings Metrics" - return "LLM Metrics" + elif self._args.endpoint_type == "rankings": + return "Rankings Metrics" + else: + return "LLM Metrics" def export(self) -> None: table = Table(title=self._get_title()) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index c6a555ec2..c4d3dff9a 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -84,7 +84,7 @@ def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: def calculate_metrics(args: Namespace, tokenizer: Tokenizer) -> ProfileDataParser: - if args.endpoint_type == "embeddings": + if args.endpoint_type in ["embeddings", "rankings"]: return ProfileDataParser(args.profile_export_file) else: return LLMProfileDataParser( diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py index 438f86203..01ca53c59 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/__init__.py @@ -25,5 +25,5 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from genai_perf.metrics.llm_metrics import LLMMetrics -from genai_perf.metrics.metrics import MetricMetadata, Metrics, ResponseFormat +from genai_perf.metrics.metrics import MetricMetadata, Metrics from genai_perf.metrics.statistics import Statistics diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py index 811bd9a66..7e047094d 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/metrics/metrics.py @@ -27,17 +27,9 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from dataclasses import dataclass -from enum import Enum, auto from typing import List -class ResponseFormat(Enum): - OPENAI_CHAT_COMPLETIONS = auto() - OPENAI_COMPLETIONS = auto() - OPENAI_EMBEDDINGS = auto() - TRITON = auto() - - @dataclass class MetricMetadata: name: str diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py index 55859f4bc..2e7798c40 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/__init__.py @@ -25,4 +25,7 @@ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from genai_perf.profile_data_parser.llm_profile_data_parser import LLMProfileDataParser -from genai_perf.profile_data_parser.profile_data_parser import ProfileDataParser +from genai_perf.profile_data_parser.profile_data_parser import ( + ProfileDataParser, + ResponseFormat, +) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py index 184654046..cbb2da5ee 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -31,8 +31,11 @@ from pathlib import Path from typing import Dict, List, Tuple -from genai_perf.metrics import LLMMetrics, Metrics, ResponseFormat -from genai_perf.profile_data_parser.profile_data_parser import ProfileDataParser +from genai_perf.metrics import LLMMetrics, Metrics +from genai_perf.profile_data_parser.profile_data_parser import ( + ProfileDataParser, + ResponseFormat, +) from genai_perf.tokenizer import Tokenizer from genai_perf.utils import remove_sse_prefix diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py index 8d0f08283..7fa069fbb 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py @@ -26,13 +26,22 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from enum import Enum, auto from pathlib import Path from typing import List, Tuple -from genai_perf.metrics import Metrics, ResponseFormat, Statistics +from genai_perf.metrics import Metrics, Statistics from genai_perf.utils import load_json +class ResponseFormat(Enum): + OPENAI_CHAT_COMPLETIONS = auto() + OPENAI_COMPLETIONS = auto() + OPENAI_EMBEDDINGS = auto() + RANKINGS = auto() + TRITON = auto() + + class ProfileDataParser: """Base profile data parser class that reads the profile data JSON file to extract core metrics and calculate various performance statistics. @@ -52,6 +61,8 @@ def _get_profile_metadata(self, data: dict) -> None: self._response_format = ResponseFormat.OPENAI_COMPLETIONS elif data["endpoint"] == "v1/embeddings": self._response_format = ResponseFormat.OPENAI_EMBEDDINGS + elif data["endpoint"] == "v1/ranking": + self._response_format = ResponseFormat.RANKINGS else: # TPA-66: add PA metadata to handle this case # When endpoint field is either empty or custom endpoint, fall @@ -64,6 +75,8 @@ def _get_profile_metadata(self, data: dict) -> None: self._response_format = ResponseFormat.OPENAI_COMPLETIONS elif "embedding" in response: self._response_format = ResponseFormat.OPENAI_EMBEDDINGS + elif "ranking" in response: + self._response_format = ResponseFormat.RANKINGS else: raise RuntimeError("Unknown OpenAI response format.") diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py index d1b93e51a..e63643e39 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py @@ -67,6 +67,9 @@ def write(self: Any, content: str) -> int: if filename == "embedding_profile_export.json": tmp_file = StringIO(json.dumps(self.embedding_profile_data)) return tmp_file + if filename == "ranking_profile_export.json": + tmp_file = StringIO(json.dumps(self.ranking_profile_data)) + return tmp_file elif filename == "profile_export.csv": tmp_file = StringIO() tmp_file.write = write.__get__(tmp_file) @@ -78,14 +81,56 @@ def write(self: Any, content: str) -> int: return written_data - def test_base_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + # ================================================ + # EMBEDDINGS API + # ================================================ + embedding_profile_data = { + "service_kind": "openai", + "endpoint": "v1/embeddings", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"input":"This is test","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + }, + "response_timestamps": [3], + "response_outputs": [ + { + "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":7,"total_tokens":7}}' + }, + ], + }, + { + "timestamp": 2, + "request_inputs": { + "payload": '{"input":"This is test too","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + }, + "response_timestamps": [5], + "response_outputs": [ + { + "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3, 4],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":8,"total_tokens":8}}' + }, + ], + }, + ], + }, + ], + } + + def test_embedding_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: """Collect base metrics from profile export data and check values. Metrics * request latencies - - experiment 1: [3 - 1, 5 - 2] = [2, 3] + - [3 - 1, 5 - 2] = [2, 3] * request throughputs - - experiment 1: [2 / (5e-9 - 1e-9)] = [5e8] + - [2 / (5e-9 - 1e-9)] = [5e8] """ pd = ProfileDataParser(filename=Path("embedding_profile_export.json")) @@ -106,9 +151,12 @@ def test_base_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: assert stats_dict["request_throughput"]["avg"] == pytest.approx(5e8) # type: ignore - embedding_profile_data = { + # ================================================ + # RANKINGS API + # ================================================ + ranking_profile_data = { "service_kind": "openai", - "endpoint": "v1/embeddings", + "endpoint": "v1/ranking", "experiments": [ { "experiment": { @@ -119,24 +167,24 @@ def test_base_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: { "timestamp": 1, "request_inputs": { - "payload": '{"input":"This is test","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + "payload": '{"query":{"text":"This is a test."},"passages":[{"text":"test output one"},{"text":"test output two"},{"text":"test output three"}],"model":"nv-rerank-qa-mistral-4b:1","truncate":"END"}', }, "response_timestamps": [3], "response_outputs": [ { - "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":7,"total_tokens":7}}' + "response": '{"rankings":[{"index":0,"logit":-5.98828125},{"index":1,"logit":-6.828125},{"index":2,"logit":-7.60546875}]}' }, ], }, { "timestamp": 2, "request_inputs": { - "payload": '{"input":"This is test too","model":"NV-Embed-QA","input_type":"passage","encoding_format":"float","truncate":"NONE"}', + "payload": '{"query":{"text":"This is a test."},"passages":[{"text":"test output one"},{"text":"test output two"},{"text":"test output three"}],"model":"nv-rerank-qa-mistral-4b:1","truncate":"END"}', }, "response_timestamps": [5], "response_outputs": [ { - "response": '{"object":"list","data":[{"index":0,"embedding":[1, 2, 3, 4],"object":"embedding"}],"model":"NV-Embed-QA","usage":{"prompt_tokens":8,"total_tokens":8}}' + "response": '{"rankings":[{"index":2,"logit":-6.15625},{"index":1,"logit":-7.83984375},{"index":0,"logit":-7.84765625}]}' }, ], }, @@ -144,3 +192,31 @@ def test_base_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: }, ], } + + def test_ranking_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Collect base metrics from profile export data and check values. + + Metrics + * request latencies + - [3 - 1, 5 - 2] = [2, 3] + * request throughputs + - [2 / (5e-9 - 1e-9)] = [5e8] + """ + pd = ProfileDataParser(filename=Path("ranking_profile_export.json")) + + # experiment 1 statistics + stats = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stats.metrics + stats_dict = stats.stats_dict + assert isinstance(metrics, Metrics) + + assert metrics.request_latencies == [2, 3] + assert metrics.request_throughputs == [pytest.approx(5e8)] + + assert stats_dict["request_latency"]["avg"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["p50"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["min"] == pytest.approx(2) # type: ignore + assert stats_dict["request_latency"]["max"] == pytest.approx(3) # type: ignore + assert stats_dict["request_latency"]["std"] == np.std([2, 3]) # type: ignore + + assert stats_dict["request_throughput"]["avg"] == pytest.approx(5e8) # type: ignore From 6977e6edda183bce9f01bbe44290a8cb9c191caf Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:11:59 -0700 Subject: [PATCH 32/55] Document how to profile embeddings models (#717) --- src/c++/perf_analyzer/genai-perf/README.md | 5 +- .../genai-perf/docs/embeddings.md | 93 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/docs/embeddings.md diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index b114364d7..d9f288996 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -373,7 +373,7 @@ model config to not echo the input tokens in the output. (default: tensorrtllm) Set a custom endpoint that differs from the OpenAI defaults. (default: `None`) -##### `--endpoint-type {chat,completions}` +##### `--endpoint-type {chat,completions,embeddings}` The endpoint-type to send requests to on the server. This is only used with the `openai` service-kind. (default: `None`) @@ -398,7 +398,8 @@ URL of the endpoint to target for benchmarking. (default: `None`) ##### `--batch-size ` The batch size of the requests GenAI-Perf should send. -This is currently only supported with the embeddings endpoint type. +This is currently only supported with the +[embeddings endpoint type](docs/embeddings.md). (default: `1`) ##### `--extra-inputs ` diff --git a/src/c++/perf_analyzer/genai-perf/docs/embeddings.md b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md new file mode 100644 index 000000000..e61c397d9 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md @@ -0,0 +1,93 @@ + + +# Profiling Embeddings Models with GenAI-Perf + +GenAI-Perf allows you to profile embedding models running on an +[OpenAI Embeddings API](https://platform.openai.com/docs/api-reference/embeddings)-compatible server. + +## Creating a Sample Embeddings Input File + +To create a sample embeddings input file, use the following command: + +```bash +echo '{"text": "What was the first car ever driven?"} +{"text": "Who served as the 5th President of the United States of America?"} +{"text": "Is the Sydney Opera House located in Australia?"} +{"text": "In what state did they film Shrek 2?"}' > embeddings.jsonl +``` + +This will generate a file named embeddings.jsonl with the following content: +```jsonl +{"text": "What was the first car ever driven?"} +{"text": "Who served as the 5th President of the United States of America?"} +{"text": "Is the Sydney Opera House located in Australia?"} +{"text": "In what state did they film Shrek 2?"} +``` + +## Starting an OpenAI Embeddings-Compatible Server +To start an OpenAI embeddings-compatible server, run the following command: +```bash +docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model intfloat/e5-mistral-7b-instruct --dtype float16 --max-model-len 1024 +``` + +## Running GenAI-Perf +To profile embeddings models using GenAI-Perf, use the following command: + +```bash +genai-perf \ + -m intfloat/e5-mistral-7b-instruct \ + --service-kind openai \ + --endpoint-type embeddings \ + --batch-size 2 \ + --input-file embeddings.jsonl +``` + +This will use default values for optional arguments. You can also pass in +additional arguments with the `--extra-inputs` [flag](../README.md#input-options). +For example, you could use this command: + +```bash +genai-perf \ + -m intfloat/e5-mistral-7b-instruct \ + --service-kind openai \ + --endpoint-type embeddings \ + --extra-inputs user:sample_user +``` + +Example output: + +``` + Embeddings Metrics +┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓ +┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ +┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━┩ +│ Request latency (ms) │ 42.21 │ 28.18 │ 318.61 │ 56.50 │ 49.21 │ 43.07 │ +└──────────────────────┴───────┴───────┴────────┴───────┴───────┴───────┘ +Request throughput (per sec): 23.63 +``` \ No newline at end of file From 9feb4be624d6115b0f7003167571bc8b16d4e068 Mon Sep 17 00:00:00 2001 From: Elias Bermudez <6505145+debermudez@users.noreply.github.com> Date: Fri, 28 Jun 2024 16:51:06 -0700 Subject: [PATCH 33/55] Add rankings support (#724) * Initial support for rankings api Add file or directory support for rankings Update testing * Remove commented code * Address feedback * Add llm_inputs ranking tests * Update comments to include rankings in batch-size documentation * Update error messages --- .../genai_perf/llm_inputs/llm_inputs.py | 125 ++++++++++-- .../genai-perf/genai_perf/main.py | 3 +- .../genai-perf/genai_perf/parser.py | 70 ++++++- .../genai-perf/tests/test_cli.py | 29 ++- .../tests/test_llm_inputs_rankings.py | 182 ++++++++++++++++++ 5 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_rankings.py diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index 842fe9978..d7384f6b8 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -42,6 +42,7 @@ class OutputFormat(Enum): OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() OPENAI_EMBEDDINGS = auto() + RANKINGS = auto() TENSORRTLLM = auto() VLLM = auto() @@ -243,21 +244,39 @@ def get_generic_dataset_json( Dict: The generic dataset JSON """ + if output_format == OutputFormat.OPENAI_EMBEDDINGS: - if input_type == PromptSource.FILE: - input_filename = cast(Path, input_filename) - input_file_dataset = cls._get_input_dataset_from_embeddings_file( - input_filename, - batch_size, - num_of_output_prompts, + if input_type != PromptSource.FILE: + raise GenAIPerfException( + f"{OutputFormat.OPENAI_EMBEDDINGS.to_lowercase()} only supports a file as input." ) - generic_dataset_json = ( - cls._convert_input_synthetic_or_file_dataset_to_generic_json( - input_file_dataset - ) + input_filename = cast(Path, input_filename) + input_file_dataset = cls._get_input_dataset_from_embeddings_file( + input_filename, + batch_size, + num_of_output_prompts, + ) + generic_dataset_json = ( + cls._convert_input_synthetic_or_file_dataset_to_generic_json( + input_file_dataset ) - else: - raise GenAIPerfException("OpenAI embeddings only supports file input.") + ) + elif output_format == OutputFormat.RANKINGS: + if input_type != PromptSource.FILE: + raise GenAIPerfException( + f"{OutputFormat.RANKINGS.to_lowercase()} only supports a directory as input." + ) + queries_filename = cast(Path, input_filename) / "queries.jsonl" + passages_filename = cast(Path, input_filename) / "passages.jsonl" + input_file_dataset = cls._get_input_dataset_from_rankings_files( + queries_filename, passages_filename, batch_size, num_of_output_prompts + ) + + generic_dataset_json = ( + cls._convert_input_synthetic_or_file_dataset_to_generic_json( + input_file_dataset + ) + ) else: if input_type == PromptSource.DATASET: dataset = cls._get_input_dataset_from_url( @@ -315,6 +334,41 @@ def _get_input_dataset_from_embeddings_file( return dataset_json + @classmethod + def _get_input_dataset_from_rankings_files( + cls, + queries_filename: Path, + passages_filename: Path, + batch_size: int, + num_prompts: int, + ) -> Dict[str, Any]: + + with open(queries_filename, "r") as file: + queries_content = [json.loads(line) for line in file] + queries_texts = [item for item in queries_content] + + with open(passages_filename, "r") as file: + passages_content = [json.loads(line) for line in file] + passages_texts = [item for item in passages_content] + + if batch_size > len(passages_texts): + raise ValueError( + "Batch size cannot be larger than the number of available passages" + ) + + dataset_json: Dict[str, Any] = {} + dataset_json["features"] = [{"name": "input"}] + dataset_json["rows"] = [] + + for _ in range(num_prompts): + sampled_texts = random.sample(passages_texts, batch_size) + query_sample = random.choice(queries_texts) + entry_dict = {} + entry_dict["query"] = query_sample + entry_dict["passages"] = sampled_texts + dataset_json["rows"].append({"row": {"payload": entry_dict}}) + return dataset_json + @classmethod def _check_for_valid_args( cls, @@ -535,6 +589,13 @@ def _convert_generic_json_to_output_format( model_name, model_selection_strategy, ) + elif output_format == OutputFormat.RANKINGS: + output_json = cls._convert_generic_json_to_rankings_format( + generic_dataset, + extra_inputs, + model_name, + model_selection_strategy, + ) elif output_format == OutputFormat.VLLM: output_json = cls._convert_generic_json_to_vllm_format( generic_dataset, @@ -672,6 +733,46 @@ def _convert_generic_json_to_openai_embeddings_format( return pa_json + @classmethod + def _convert_generic_json_to_rankings_format( + cls, + generic_dataset: Dict, + extra_inputs: Dict, + model_name: list = [], + model_selection_strategy: ModelSelectionStrategy = ModelSelectionStrategy.ROUND_ROBIN, + ) -> Dict[str, Any]: + pa_json: Dict[str, Any] = {"data": []} + + for index, entry in enumerate(generic_dataset["rows"]): + iter_model_name = cls._select_model_name( + model_name, index, model_selection_strategy + ) + payload = entry.get("payload", {}) + query_values = payload.get("query") + passage_values = payload.get("passages") + + if query_values is None: + raise ValueError("Missing required fields 'query' in dataset entry") + if passage_values is None: + raise ValueError("Missing required fields 'passages' in dataset entry") + if not isinstance(passage_values, list): + raise ValueError( + f"Required field 'query' must be a list (actual: {type(query_values)})" + ) + + payload = { + "query": query_values, + "passages": passage_values, + "model": iter_model_name, + } + + for key, value in extra_inputs.items(): + payload[key] = value + + pa_json["data"].append({"payload": [payload]}) + + return pa_json + @classmethod def _convert_generic_json_to_vllm_format( cls, diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index c4d3dff9a..b43e9b4f8 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -51,7 +51,8 @@ def create_artifacts_dirs(args: Namespace) -> None: def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: # TODO (TMA-1759): review if add_model_name is always true - input_filename = Path(args.input_file.name) if args.input_file else None + filepath, _ = args.input_file + input_filename = Path(filepath) if filepath else None add_model_name = True try: extra_input_dict = parser.get_extra_inputs_as_dict(args) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 62a06916b..6c8dfe4cb 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -28,7 +28,9 @@ import json import os import sys +from enum import Enum, auto from pathlib import Path +from typing import Tuple import genai_perf.logging as logging import genai_perf.utils as utils @@ -50,12 +52,22 @@ from . import __version__ + +class PathType(Enum): + FILE = auto() + DIRECTORY = auto() + + def to_lowercase(self): + return self.name.lower() + + logger = logging.getLogger(__name__) _endpoint_type_map = { "chat": "v1/chat/completions", "completions": "v1/completions", "embeddings": "v1/embeddings", + "rankings": "v1/ranking", } @@ -116,6 +128,8 @@ def _check_conditional_args( args.output_format = OutputFormat.OPENAI_COMPLETIONS elif args.endpoint_type == "embeddings": args.output_format = OutputFormat.OPENAI_EMBEDDINGS + elif args.endpoint_type == "rankings": + args.output_format = OutputFormat.RANKINGS if args.endpoint is not None: args.endpoint = args.endpoint.lstrip(" /") @@ -147,25 +161,42 @@ def _check_conditional_args( "The --output-tokens-mean-deterministic option is only supported with the Triton service-kind." ) - _check_conditional_args_embeddings(parser, args) + _check_conditional_args_embeddings_rankings(parser, args) return args -def _check_conditional_args_embeddings( +def _check_conditional_args_embeddings_rankings( parser: argparse.ArgumentParser, args: argparse.Namespace ): - if args.endpoint_type == "embeddings": + + if args.output_format in [ + OutputFormat.OPENAI_EMBEDDINGS, + OutputFormat.RANKINGS, + ]: if args.streaming: parser.error( - "The --streaming option is not supported with the embeddings endpoint type." + f"The --streaming option is not supported with the {args.endpoint_type} endpoint type." ) else: if args.batch_size != LlmInputs.DEFAULT_BATCH_SIZE: parser.error( - "The --batch-size option is currently only supported with the embeddings endpoint type." + "The --batch-size option is currently only supported with the embeddings and rankings endpoint types." ) + if args.input_file: + _, path_type = args.input_file + if args.output_format != OutputFormat.RANKINGS: + if path_type == "directory": + parser.error( + "A directory is only currently supported for the rankings endpoint type." + ) + else: + if path_type == PathType.FILE: + parser.error( + "The rankings endpoint-type requires a directory value for the --input-file flag." + ) + def _check_load_manager_args(args: argparse.Namespace) -> argparse.Namespace: """ @@ -224,7 +255,12 @@ def _infer_prompt_source(args: argparse.Namespace) -> argparse.Namespace: logger.debug(f"Input source is the following dataset: {args.input_dataset}") elif args.input_file: args.prompt_source = PromptSource.FILE - logger.debug(f"Input source is the following file: {args.input_file.name}") + if args.endpoint_type == "rankings": + logger.debug( + f"Input source is the following directory: {args.input_file[0]}" + ) + else: + logger.debug(f"Input source is the following file: {args.input_file[0]}") else: args.prompt_source = PromptSource.SYNTHETIC logger.debug("Input source is synthetic data") @@ -241,6 +277,18 @@ def _convert_str_to_enum_entry(args, option, enum): return args +### Types ### + + +def file_or_directory(path: str) -> Tuple[Path, PathType]: + if os.path.isfile(path): + return (Path(path), PathType.FILE) + elif os.path.isdir(path): + return (Path(path), PathType.DIRECTORY) + else: + raise ValueError(f"'{path}' is not a valid file or directory") + + ### Parsers ### @@ -254,7 +302,7 @@ def _add_input_args(parser): default=LlmInputs.DEFAULT_BATCH_SIZE, required=False, help=f"The batch size of the requests GenAI-Perf should send. " - "This is currently only supported with the embeddings endpoint type.", + "This is currently only supported with the embeddings and rankings endpoint types.", ) input_group.add_argument( @@ -277,12 +325,14 @@ def _add_input_args(parser): prompt_source_group.add_argument( "--input-file", - type=argparse.FileType("r"), + type=file_or_directory, default=None, required=False, help="The input file containing the prompts to use for profiling. " "Each line should be a JSON object with a 'text_input' field in JSONL format. " - 'Example: {"text_input": "Your prompt here"}', + 'Example: {"text_input": "Your prompt here"}' + "For the rankings endpoint-type, a directory should be passed in instead with " + 'a "queries.jsonl" file and a "passages.jsonl" file with the same format.', ) input_group.add_argument( @@ -437,7 +487,7 @@ def _add_endpoint_args(parser): endpoint_group.add_argument( "--endpoint-type", type=str, - choices=["chat", "completions", "embeddings"], + choices=["chat", "completions", "embeddings", "rankings"], required=False, help=f"The endpoint-type to send requests to on the " 'server. This is only used with the "openai" service-kind.', diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index 721733224..bf8fd023e 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -35,6 +35,7 @@ OutputFormat, PromptSource, ) +from genai_perf.parser import PathType class TestCLIArguments: @@ -110,6 +111,10 @@ def test_help_version_arguments_output_and_exit( ["--endpoint-type", "chat", "--service-kind", "openai"], {"endpoint": "v1/chat/completions"}, ), + ( + ["--endpoint-type", "rankings", "--service-kind", "openai"], + {"endpoint": "v1/ranking"}, + ), ( [ "--endpoint-type", @@ -279,7 +284,7 @@ def test_multiple_model_args( assert captured.out == "" def test_file_flags_parsed(self, monkeypatch, mocker): - mocked_open = mocker.patch("builtins.open", mocker.mock_open(read_data="data")) + _ = mocker.patch("os.path.isfile", return_value=True) combined_args = [ "genai-perf", "--model", @@ -289,9 +294,11 @@ def test_file_flags_parsed(self, monkeypatch, mocker): ] monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() - assert ( - args.input_file == mocked_open.return_value - ), "The file argument should be the mock object" + filepath, pathtype = args.input_file + assert filepath == Path( + "fakefile.txt" + ), "The file argument should be the path to the file" + assert pathtype == PathType.FILE @pytest.mark.parametrize( "arg, expected_path", @@ -304,6 +311,10 @@ def test_file_flags_parsed(self, monkeypatch, mocker): ["--service-kind", "openai", "--endpoint-type", "completions"], "artifacts/test_model-openai-completions-concurrency1", ), + ( + ["--service-kind", "openai", "--endpoint-type", "rankings"], + "artifacts/test_model-openai-rankings-concurrency1", + ), ( ["--service-kind", "triton", "--backend", "tensorrtllm"], "artifacts/test_model-triton-tensorrtllm-concurrency1", @@ -502,7 +513,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): "--batch-size", "10", ], - "The --batch-size option is currently only supported with the embeddings endpoint type", + "The --batch-size option is currently only supported with the embeddings and rankings endpoint types", ), ], ) @@ -538,6 +549,10 @@ def test_conditional_errors(self, args, expected_output, monkeypatch, capsys): ], OutputFormat.OPENAI_COMPLETIONS, ), + ( + ["--service-kind", "openai", "--endpoint-type", "rankings"], + OutputFormat.RANKINGS, + ), ( ["--service-kind", "triton", "--backend", "tensorrtllm"], OutputFormat.TENSORRTLLM, @@ -603,6 +618,8 @@ def test_inferred_prompt_source( self, monkeypatch, mocker, args, expected_prompt_source ): _ = mocker.patch("builtins.open", mocker.mock_open(read_data="data")) + _ = mocker.patch("os.path.isfile", return_value=True) + _ = mocker.patch("os.path.isdir", return_value=True) combined_args = ["genai-perf", "--model", "test_model"] + args monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -611,6 +628,8 @@ def test_inferred_prompt_source( def test_prompt_source_assertions(self, monkeypatch, mocker, capsys): _ = mocker.patch("builtins.open", mocker.mock_open(read_data="data")) + _ = mocker.patch("os.path.isfile", return_value=True) + _ = mocker.patch("os.path.isdir", return_value=True) args = [ "genai-perf", "--model", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_rankings.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_rankings.py new file mode 100644 index 000000000..bfe2be482 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs_rankings.py @@ -0,0 +1,182 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from pathlib import Path +from unittest.mock import mock_open, patch + +import pytest +from genai_perf.llm_inputs.llm_inputs import LlmInputs, ModelSelectionStrategy + + +class TestLlmInputsRankings: + + def open_side_effects(filepath, *args, **kwargs): + queries_content = "\n".join( + [ + '{"text": "What production company co-owned by Kevin Loader and Rodger Michell produced My Cousin Rachel?"}', + '{"text": "Who served as the 1st Vice President of Colombia under El Libertador?"}', + '{"text": "Are the Barton Mine and Hermiston-McCauley Mine located in The United States of America?"}', + ] + ) + passages_content = "\n".join( + [ + '{"text": "Eric Anderson (sociologist) Eric Anderson (born January 18, 1968) is an American sociologist"}', + '{"text": "Kevin Loader is a British film and television producer. "}', + '{"text": "Barton Mine, also known as Net Lake Mine, is an abandoned surface and underground mine in Northeastern Ontario"}', + ] + ) + + file_contents = { + "queries.jsonl": queries_content, + "passages.jsonl": passages_content, + } + return mock_open( + read_data=file_contents.get(filepath, file_contents["queries.jsonl"]) + )() + + mock_open_obj = mock_open() + mock_open_obj.side_effect = open_side_effects + + @patch("pathlib.Path.exists", return_value=True) + @patch("builtins.open", mock_open_obj) + def test_get_input_dataset_from_rankings_file(self, mock_file): + queries_filename = Path("queries.jsonl") + passages_filename = Path("passages.jsonl") + batch_size = 2 + dataset = LlmInputs._get_input_dataset_from_rankings_files( + queries_filename, passages_filename, batch_size, num_prompts=100 + ) + + assert dataset is not None + assert len(dataset["rows"]) == 100 + for row in dataset["rows"]: + assert "row" in row + assert "payload" in row["row"] + payload = row["row"]["payload"] + assert "query" in payload + assert "passages" in payload + assert isinstance(payload["passages"], list) + assert len(payload["passages"]) == batch_size + + # Try error case where batch size is larger than the number of available texts + with pytest.raises( + ValueError, + match="Batch size cannot be larger than the number of available passages", + ): + LlmInputs._get_input_dataset_from_rankings_files( + queries_filename, passages_filename, 5, num_prompts=10 + ) + + def test_convert_generic_json_to_openai_rankings_format(self): + generic_dataset = { + "rows": [ + { + "payload": { + "query": {"text": "1"}, + "passages": [{"text": "2"}, {"text": "3"}, {"text": "4"}], + } + } + ] + } + + expected_result = { + "data": [ + { + "payload": [ + { + "query": {"text": "1"}, + "passages": [{"text": "2"}, {"text": "3"}, {"text": "4"}], + "model": "test_model", + } + ] + } + ] + } + + result = LlmInputs._convert_generic_json_to_rankings_format( + generic_dataset, + extra_inputs={}, + model_name=["test_model"], + model_selection_strategy=ModelSelectionStrategy.ROUND_ROBIN, + ) + + assert result is not None + assert "data" in result + assert len(result["data"]) == len(expected_result["data"]) + + for i, item in enumerate(expected_result["data"]): + assert "payload" in result["data"][i] + assert result["data"][i]["payload"] == item["payload"] + + def test_convert_generic_json_to_openai_rankings_format_with_extra_inputs(self): + generic_dataset = { + "rows": [ + { + "payload": { + "query": {"text": "1"}, + "passages": [{"text": "2"}, {"text": "3"}, {"text": "4"}], + } + } + ] + } + + extra_inputs = { + "encoding_format": "base64", + "truncate": "END", + "additional_key": "additional_value", + } + + expected_result = { + "data": [ + { + "payload": [ + { + "query": {"text": "1"}, + "passages": [{"text": "2"}, {"text": "3"}, {"text": "4"}], + "model": "test_model", + "encoding_format": "base64", + "truncate": "END", + "additional_key": "additional_value", + } + ] + } + ] + } + + result = LlmInputs._convert_generic_json_to_rankings_format( + generic_dataset, + extra_inputs=extra_inputs, + model_name=["test_model"], + model_selection_strategy=ModelSelectionStrategy.ROUND_ROBIN, + ) + + assert result is not None + assert "data" in result + assert len(result["data"]) == len(expected_result["data"]) + + for i, item in enumerate(expected_result["data"]): + assert "payload" in result["data"][i] + assert result["data"][i]["payload"] == item["payload"] From 76892ce29562e8a8e3b3a613654ef40908636466 Mon Sep 17 00:00:00 2001 From: Elias Bermudez <6505145+debermudez@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:44:48 -0700 Subject: [PATCH 34/55] Fix filepath access bug when None (#729) --- src/c++/perf_analyzer/genai-perf/genai_perf/main.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index b43e9b4f8..7692d02e5 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -51,8 +51,11 @@ def create_artifacts_dirs(args: Namespace) -> None: def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: # TODO (TMA-1759): review if add_model_name is always true - filepath, _ = args.input_file - input_filename = Path(filepath) if filepath else None + if args.input_file: + filepath, _ = args.input_file + input_filename = Path(filepath) + else: + input_filename = None add_model_name = True try: extra_input_dict = parser.get_extra_inputs_as_dict(args) From f3c4f530c3777b916539afd6565c83440f53959d Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:35:16 -0700 Subject: [PATCH 35/55] Guard GenAI-Perf plot generation (#732) --- .../genai-perf/genai_perf/main.py | 4 +- .../genai-perf/genai_perf/parser.py | 5 ++ .../genai-perf/tests/test_artifacts.py | 12 ++++- .../genai-perf/tests/test_cli.py | 52 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index 7692d02e5..912ee4725 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -43,10 +43,10 @@ def create_artifacts_dirs(args: Namespace) -> None: - # TMA-1911: support plots CLI option plot_dir = args.artifact_dir / "plots" os.makedirs(args.artifact_dir, exist_ok=True) - os.makedirs(plot_dir, exist_ok=True) + if hasattr(args, "generate_plots") and args.generate_plots: + os.makedirs(plot_dir, exist_ok=True) def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 6c8dfe4cb..64178fd4c 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -178,6 +178,11 @@ def _check_conditional_args_embeddings_rankings( parser.error( f"The --streaming option is not supported with the {args.endpoint_type} endpoint type." ) + + if args.generate_plots: + parser.error( + f"The --generate-plots option is not currently supported with the {args.endpoint_type} endpoint type." + ) else: if args.batch_size != LlmInputs.DEFAULT_BATCH_SIZE: parser.error( diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py index 56b1b38de..cdcc4afc9 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_artifacts.py @@ -38,7 +38,7 @@ def mock_makedirs(mocker): def test_create_artifacts_dirs_custom_path(mock_makedirs): artifacts_dir_path = "/genai_perf_artifacts" - mock_args = Namespace(artifact_dir=Path(artifacts_dir_path)) + mock_args = Namespace(artifact_dir=Path(artifacts_dir_path), generate_plots=True) create_artifacts_dirs(mock_args) mock_makedirs.assert_any_call( Path(artifacts_dir_path), exist_ok=True @@ -47,3 +47,13 @@ def test_create_artifacts_dirs_custom_path(mock_makedirs): Path(artifacts_dir_path) / "plots", exist_ok=True ), f"Expected os.makedirs to create plots directory inside {artifacts_dir_path}/plots path." assert mock_makedirs.call_count == 2 + + +def test_create_artifacts_disable_generate_plots(mock_makedirs): + artifacts_dir_path = "/genai_perf_artifacts" + mock_args = Namespace(artifact_dir=Path(artifacts_dir_path)) + create_artifacts_dirs(mock_args) + mock_makedirs.assert_any_call( + Path(artifacts_dir_path), exist_ok=True + ), f"Expected os.makedirs to create artifacts directory inside {artifacts_dir_path} path." + assert mock_makedirs.call_count == 1 diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index bf8fd023e..cc005beef 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -515,6 +515,58 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ], "The --batch-size option is currently only supported with the embeddings and rankings endpoint types", ), + ( + [ + "genai-perf", + "-m", + "test_model", + "--service-kind", + "openai", + "--endpoint-type", + "embeddings", + "--streaming", + ], + "The --streaming option is not supported with the embeddings endpoint type", + ), + ( + [ + "genai-perf", + "-m", + "test_model", + "--service-kind", + "openai", + "--endpoint-type", + "rankings", + "--streaming", + ], + "The --streaming option is not supported with the rankings endpoint type", + ), + ( + [ + "genai-perf", + "-m", + "test_model", + "--service-kind", + "openai", + "--endpoint-type", + "embeddings", + "--generate-plots", + ], + "The --generate-plots option is not currently supported with the embeddings endpoint type", + ), + ( + [ + "genai-perf", + "-m", + "test_model", + "--service-kind", + "openai", + "--endpoint-type", + "rankings", + "--generate-plots", + ], + "The --generate-plots option is not currently supported with the rankings endpoint type", + ), ], ) def test_conditional_errors(self, args, expected_output, monkeypatch, capsys): From d3fadc1f21860e9218061d387686937eb4af2bec Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 3 Jul 2024 09:26:43 -0700 Subject: [PATCH 36/55] Add support for Hugging Face Text Embeddings Interface's re-ranker API (#728) --- src/c++/perf_analyzer/genai-perf/README.md | 5 +- .../genai-perf/docs/embeddings.md | 10 +- src/c++/perf_analyzer/genai-perf/docs/lora.md | 6 +- .../perf_analyzer/genai-perf/docs/rankings.md | 100 ++++++++++++++++++ .../genai_perf/llm_inputs/llm_inputs.py | 44 ++++++-- .../llm_profile_data_parser.py | 2 + .../profile_data_parser.py | 5 +- .../tests/test_profile_data_parser.py | 77 +++++++++++++- 8 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/docs/rankings.md diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index d9f288996..9c553115d 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -373,7 +373,7 @@ model config to not echo the input tokens in the output. (default: tensorrtllm) Set a custom endpoint that differs from the OpenAI defaults. (default: `None`) -##### `--endpoint-type {chat,completions,embeddings}` +##### `--endpoint-type {chat,completions,embeddings,rankings}` The endpoint-type to send requests to on the server. This is only used with the `openai` service-kind. (default: `None`) @@ -400,7 +400,8 @@ URL of the endpoint to target for benchmarking. (default: `None`) The batch size of the requests GenAI-Perf should send. This is currently only supported with the [embeddings endpoint type](docs/embeddings.md). -(default: `1`) +(default: `1`) and +[rankings endpoint type](docs/rankings.md). ##### `--extra-inputs ` diff --git a/src/c++/perf_analyzer/genai-perf/docs/embeddings.md b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md index e61c397d9..bc6e2d413 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/embeddings.md +++ b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md @@ -26,12 +26,12 @@ OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --> -# Profiling Embeddings Models with GenAI-Perf +# Profile Embeddings Models with GenAI-Perf GenAI-Perf allows you to profile embedding models running on an [OpenAI Embeddings API](https://platform.openai.com/docs/api-reference/embeddings)-compatible server. -## Creating a Sample Embeddings Input File +## Create a Sample Embeddings Input File To create a sample embeddings input file, use the following command: @@ -50,13 +50,13 @@ This will generate a file named embeddings.jsonl with the following content: {"text": "In what state did they film Shrek 2?"} ``` -## Starting an OpenAI Embeddings-Compatible Server +## Start an OpenAI Embeddings-Compatible Server To start an OpenAI embeddings-compatible server, run the following command: ```bash docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model intfloat/e5-mistral-7b-instruct --dtype float16 --max-model-len 1024 ``` -## Running GenAI-Perf +## Run GenAI-Perf To profile embeddings models using GenAI-Perf, use the following command: ```bash @@ -90,4 +90,4 @@ Example output: │ Request latency (ms) │ 42.21 │ 28.18 │ 318.61 │ 56.50 │ 49.21 │ 43.07 │ └──────────────────────┴───────┴───────┴────────┴───────┴───────┴───────┘ Request throughput (per sec): 23.63 -``` \ No newline at end of file +``` diff --git a/src/c++/perf_analyzer/genai-perf/docs/lora.md b/src/c++/perf_analyzer/genai-perf/docs/lora.md index 60be30c95..b3ddbe479 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/lora.md +++ b/src/c++/perf_analyzer/genai-perf/docs/lora.md @@ -26,17 +26,17 @@ OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. --> -# Profiling Multiple LoRA Adapters +# Profile Multiple LoRA Adapters GenAI-Perf allows you to profile multiple LoRA adapters on top of a base model. -## Selecting LoRA Adapters +## Select LoRA Adapters To do this, list multiple adapters after the model name option `-m`: ```bash genai-perf -m lora_adapter1 lora_adapter2 lora_adapter3 ``` -## Choosing a Strategy for Selecting Models +## Choose a Strategy for Selecting Models When profiling with multiple models, you can specify how the models should be assigned to prompts using the `--model-selection-strategy` option: diff --git a/src/c++/perf_analyzer/genai-perf/docs/rankings.md b/src/c++/perf_analyzer/genai-perf/docs/rankings.md new file mode 100644 index 000000000..d195b25db --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/docs/rankings.md @@ -0,0 +1,100 @@ + + +# Profile Ranking Models with GenAI-Perf + + +GenAI-Perf allows you to profile ranking models compatible with Hugging Face's +[Text Embeddings Interface's re-ranker API](https://huggingface.co/docs/text-embeddings-inference/en/quick_tour#re-rankers). + +## Create a Sample Rankings Input Directory + +To create a sample rankings input directory, follow these steps: + +Create a directory called rankings_jsonl: +```bash +mkdir rankings_jsonl +``` + +Inside this directory, create a JSONL file named queries.jsonl with queries data: + +```bash +echo '{"text": "What was the first car ever driven?"} +{"text": "Who served as the 5th President of the United States of America?"} +{"text": "Is the Sydney Opera House located in Australia?"} +{"text": "In what state did they film Shrek 2?"}' > rankings_jsonl/queries.jsonl +``` + +Create another JSONL file named passages.jsonl with passages data: + +```bash +echo '{"text": "Eric Anderson (born January 18, 1968) is an American sociologist and sexologist."} +{"text": "Kevin Loader is a British film and television producer."} +{"text": "Francisco Antonio Zea Juan Francisco Antonio Hilari was a Colombian journalist, botanist, diplomat, politician, and statesman who served as the 1st Vice President of Colombia."} +{"text": "Daddys Home 2 Principal photography on the film began in Massachusetts in March 2017 and it was released in the United States by Paramount Pictures on November 10, 2017. Although the film received unfavorable reviews, it has grossed over $180 million worldwide on a $69 million budget."}' > rankings_jsonl/passages.jsonl +``` + +## Start a Hugging Face Re-Ranker-Compatible Server +To start a Hugging Face re-ranker-compatible server, run the following commands: + +```bash +model=BAAI/bge-reranker-large +revision=refs/pr/4 +volume=$PWD/data + +docker run --gpus all -p 8080:80 -v $volume:/data --pull always ghcr.io/huggingface/text-embeddings-inference:1.3 --model-id $model --revision $revision +``` + +## Run GenAI-Perf +To profile ranking models using GenAI-Perf, use the following command: + +```bash +genai-perf \ + -m BAAI/bge-reranker-large \ + --service-kind openai \ + --endpoint-type rankings \ + --endpoint rerank \ + --input-file rankings_jsonl/ \ + -u localhost:8080 \ + --extra-inputs rankings:tei \ + --batch-size 2 +``` + +This command specifies the use of Hugging Face's ranking API with `--endpoint rerank` and `--extra-inputs rankings:tei`. + +Example output: + +``` + Rankings Metrics +┏━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━┳━━━━━━┓ +┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ +┡━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━╇━━━━━━┩ +│ Request latency (ms) │ 5.48 │ 2.50 │ 23.91 │ 10.27 │ 8.34 │ 6.07 │ +└──────────────────────┴──────┴──────┴───────┴───────┴──────┴──────┘ +Request throughput (per sec): 180.11 +``` diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index d7384f6b8..de528aac4 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -139,7 +139,7 @@ def create_llm_inputs( output_tokens_deterministic: If true, the output tokens will set the minimum and maximum tokens to be equivalent. batch_size: - The number of inputs per request (currently only used for v1/embeddings) + The number of inputs per request (currently only used for the embeddings and rankings endpoints) Required Synthetic Prompt Generation Parameters ----------------------------------------------- @@ -236,7 +236,7 @@ def get_generic_dataset_json( num_of_output_prompts: The number of synthetic output prompts to generate batch_size: - The number of inputs per request (currently only used for v1/embeddings) + The number of inputs per request (currently only used for the embeddings and rankings endpoints) input_filename: The path to the input file containing the prompts in JSONL format. Returns @@ -733,6 +733,16 @@ def _convert_generic_json_to_openai_embeddings_format( return pa_json + @classmethod + def contains_rankings_tei(cls, extra_inputs: Optional[Dict]) -> bool: + """ + Check if user specified that they are using the Hugging Face + Text Embeddings Interface for ranking models + """ + if extra_inputs and extra_inputs.get("rankings") == "tei": + return True + return False + @classmethod def _convert_generic_json_to_rankings_format( cls, @@ -742,6 +752,7 @@ def _convert_generic_json_to_rankings_format( model_selection_strategy: ModelSelectionStrategy = ModelSelectionStrategy.ROUND_ROBIN, ) -> Dict[str, Any]: pa_json: Dict[str, Any] = {"data": []} + use_tei_format = cls.contains_rankings_tei(extra_inputs) for index, entry in enumerate(generic_dataset["rows"]): iter_model_name = cls._select_model_name( @@ -749,25 +760,36 @@ def _convert_generic_json_to_rankings_format( ) payload = entry.get("payload", {}) query_values = payload.get("query") - passage_values = payload.get("passages") + + if use_tei_format: + passage_values = payload.get("passages", []) + passage_values = [item.get("text", "") for item in passage_values] + else: + passage_values = payload.get("passages") if query_values is None: raise ValueError("Missing required fields 'query' in dataset entry") if passage_values is None: - raise ValueError("Missing required fields 'passages' in dataset entry") + raise ValueError( + f"Missing required fields '{'texts' if use_tei_format else 'passages'}' in dataset entry" + ) if not isinstance(passage_values, list): raise ValueError( - f"Required field 'query' must be a list (actual: {type(query_values)})" + f"Required field '{'texts' if use_tei_format else 'passages'}' must be a list (actual: {type(passage_values)})" ) - payload = { - "query": query_values, - "passages": passage_values, - "model": iter_model_name, - } + if use_tei_format: + payload = {"query": query_values["text"], "texts": passage_values} + else: + payload = { + "query": query_values, + "passages": passage_values, + "model": iter_model_name, + } for key, value in extra_inputs.items(): - payload[key] = value + if not (key == "rankings" and value == "tei"): + payload[key] = value pa_json["data"].append({"payload": [payload]}) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py index cbb2da5ee..d42c4fb63 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -218,6 +218,8 @@ def _get_openai_input_text(self, req_inputs: dict) -> str: return payload["messages"][0]["content"] elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: return payload["prompt"] + elif self._response_format == ResponseFormat.HUGGINGFACE_RANKINGS: + return payload["query"] else: raise ValueError( "Failed to parse OpenAI request input in profile export file." diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py index 7fa069fbb..d18d8f6fb 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py @@ -35,6 +35,7 @@ class ResponseFormat(Enum): + HUGGINGFACE_RANKINGS = auto() OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() OPENAI_EMBEDDINGS = auto() @@ -55,7 +56,9 @@ def __init__(self, filename: Path) -> None: def _get_profile_metadata(self, data: dict) -> None: self._service_kind = data["service_kind"] if self._service_kind == "openai": - if data["endpoint"] == "v1/chat/completions": + if data["endpoint"] == "rerank": + self._response_format = ResponseFormat.HUGGINGFACE_RANKINGS + elif data["endpoint"] == "v1/chat/completions": self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS elif data["endpoint"] == "v1/completions": self._response_format = ResponseFormat.OPENAI_COMPLETIONS diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py index e63643e39..fe303c514 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_profile_data_parser.py @@ -67,9 +67,12 @@ def write(self: Any, content: str) -> int: if filename == "embedding_profile_export.json": tmp_file = StringIO(json.dumps(self.embedding_profile_data)) return tmp_file - if filename == "ranking_profile_export.json": + elif filename == "ranking_profile_export.json": tmp_file = StringIO(json.dumps(self.ranking_profile_data)) return tmp_file + elif filename == "huggingface_ranking_profile_export.json": + tmp_file = StringIO(json.dumps(self.huggingface_ranking_profile_data)) + return tmp_file elif filename == "profile_export.csv": tmp_file = StringIO() tmp_file.write = write.__get__(tmp_file) @@ -220,3 +223,75 @@ def test_ranking_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None assert stats_dict["request_latency"]["std"] == np.std([2, 3]) # type: ignore assert stats_dict["request_throughput"]["avg"] == pytest.approx(5e8) # type: ignore + + # ================================================ + # HUGGINGFACE RANKINGS API + # ================================================ + huggingface_ranking_profile_data = { + "service_kind": "openai", + "endpoint": "rerank", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"query":"What was the first car ever driven?","texts":["Daddys Home 2 Principal photography on the film began in Massachusetts in March 2017 and it was released in the United States by Paramount Pictures on November 10, 2017. Although the film received unfavorable reviews, it has grossed over $180 million worldwide on a $69 million budget.","Kevin Loader is a British film and television producer."]}' + }, + "response_timestamps": [3], + "response_outputs": [ + { + "response": '[{"index":0,"score":0.0032476764},{"index":1,"score":0.00036117696}]' + }, + ], + }, + { + "timestamp": 2, + "request_inputs": { + "payload": '{"query":"In what state did they film Shrek 2?","texts":["Francisco Antonio Zea Juan Francisco Antonio Hilari was a Colombian journalist, botanist, diplomat, politician, and statesman who served as the 1st Vice President of Colombia.","Daddys Home 2 Principal photography on the film began in Massachusetts in March 2017 and it was released in the United States by Paramount Pictures on November 10, 2017. Although the film received unfavorable reviews, it has grossed over $180 million worldwide on a $69 million budget."]}' + }, + "response_timestamps": [5], + "response_outputs": [ + { + "response": '[{"index":0,"score":0.020177318},{"index":1,"score":0.01461567}]' + }, + ], + }, + ], + }, + ], + } + + def test_huggingface_ranking_profile_data( + self, mock_read_write: pytest.MonkeyPatch + ) -> None: + """Collect base metrics from HuggingFace ranking profile export data and check values. + + Metrics + * request latencies + - [3 - 1, 5 - 2] = [2, 3] + * request throughputs + - [2 / (5e-9 - 1e-9)] = [5e8] + """ + pd = ProfileDataParser(filename=Path("huggingface_ranking_profile_export.json")) + + # experiment 1 statistics + stats = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stats.metrics + stats_dict = stats.stats_dict + assert isinstance(metrics, Metrics) + + assert metrics.request_latencies == [2, 3] + assert metrics.request_throughputs == [pytest.approx(5e8)] + + assert stats_dict["request_latency"]["avg"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["p50"] == pytest.approx(2.5) # type: ignore + assert stats_dict["request_latency"]["min"] == pytest.approx(2) # type: ignore + assert stats_dict["request_latency"]["max"] == pytest.approx(3) # type: ignore + assert stats_dict["request_latency"]["std"] == np.std([2, 3]) # type: ignore + + assert stats_dict["request_throughput"]["avg"] == pytest.approx(5e8) # type: ignore From 44ed1215960e72cd241aef07ea6a33ce3d727265 Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 3 Jul 2024 10:52:51 -0700 Subject: [PATCH 37/55] Remove unnecessary input text branch (#734) --- .../genai_perf/profile_data_parser/llm_profile_data_parser.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py index d42c4fb63..cbb2da5ee 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -218,8 +218,6 @@ def _get_openai_input_text(self, req_inputs: dict) -> str: return payload["messages"][0]["content"] elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: return payload["prompt"] - elif self._response_format == ResponseFormat.HUGGINGFACE_RANKINGS: - return payload["query"] else: raise ValueError( "Failed to parse OpenAI request input in profile export file." From 989b3f7983115c87302cc3202bc6b9cb627f3aad Mon Sep 17 00:00:00 2001 From: Francesco Petrini Date: Mon, 8 Jul 2024 15:36:04 -0700 Subject: [PATCH 38/55] Update version 0.0.4 (#739) * Update version 0.0.4 --- src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py index cb5c26999..5e15090be 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py @@ -24,4 +24,4 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.4dev" +__version__ = "0.0.4" From 21562b939407229889ea5cd598a9a7fd0b11dab1 Mon Sep 17 00:00:00 2001 From: Francesco Petrini Date: Mon, 8 Jul 2024 15:40:27 -0700 Subject: [PATCH 39/55] Revert "Update version 0.0.4 (#739)" (#740) This reverts commit 989b3f7983115c87302cc3202bc6b9cb627f3aad. --- src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py index 5e15090be..cb5c26999 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py @@ -24,4 +24,4 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.4" +__version__ = "0.0.4dev" From e83862adf0054bb7db367589d6f4a42445d497c1 Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Tue, 9 Jul 2024 15:14:47 -0700 Subject: [PATCH 40/55] Update the name of Hugging Face TEI (#744) --- src/c++/perf_analyzer/genai-perf/docs/rankings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/docs/rankings.md b/src/c++/perf_analyzer/genai-perf/docs/rankings.md index d195b25db..5cd1a4812 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/rankings.md +++ b/src/c++/perf_analyzer/genai-perf/docs/rankings.md @@ -30,7 +30,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. GenAI-Perf allows you to profile ranking models compatible with Hugging Face's -[Text Embeddings Interface's re-ranker API](https://huggingface.co/docs/text-embeddings-inference/en/quick_tour#re-rankers). +[Text Embeddings Inference's re-ranker API](https://huggingface.co/docs/text-embeddings-inference/en/quick_tour#re-rankers). ## Create a Sample Rankings Input Directory From cb5710c484ccb5cefbfe9a624e8e45eb4eba4eb6 Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 10 Jul 2024 15:14:19 -0700 Subject: [PATCH 41/55] Clarify new arguments in documentation (#746) --- src/c++/perf_analyzer/docs/cli.md | 7 +++++++ src/c++/perf_analyzer/genai-perf/README.md | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/c++/perf_analyzer/docs/cli.md b/src/c++/perf_analyzer/docs/cli.md index 399596fd6..bd82415c8 100644 --- a/src/c++/perf_analyzer/docs/cli.md +++ b/src/c++/perf_analyzer/docs/cli.md @@ -157,6 +157,13 @@ will also be reported in the results. Default is `-1` indicating that the average latency is used to determine stability. +#### `--request-count=` + +Specifies a total number of requests to use for measurement. + +Default is `0`, which means that there is no request count and the measurement +will proceed using windows until stabilization is detected. + #### `-r ` #### `--max-trials=` diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 9c553115d..45159cc15 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -301,8 +301,8 @@ options: When the dataset is coming from a file, you can specify the following options: -* `--input-file `: The input file containing the single prompt to - use for benchmarking. +* `--input-file `: The input file containing the prompts to + use for benchmarking as JSON objects. For any dataset, you can specify the following options: * `--output-tokens-mean `: The mean number of tokens in each output. Ensure From ade066d486498c5e5dd0cb701cca81bcaddb24c6 Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:12:10 -0700 Subject: [PATCH 42/55] Move GenAI-Perf profiling to its own subcommand (#745) --- src/c++/perf_analyzer/genai-perf/README.md | 4 +- .../genai-perf/docs/embeddings.md | 4 +- src/c++/perf_analyzer/genai-perf/docs/lora.md | 2 +- .../perf_analyzer/genai-perf/docs/rankings.md | 2 +- .../perf_analyzer/genai-perf/docs/tutorial.md | 8 +- .../genai-perf/genai_perf/parser.py | 114 ++++++++++++------ .../genai-perf/genai_perf/test_end_to_end.py | 12 +- .../genai-perf/tests/test_cli.py | 74 +++++++----- .../genai-perf/tests/test_console_exporter.py | 3 + .../genai-perf/tests/test_csv_exporter.py | 3 + .../genai-perf/tests/test_json_exporter.py | 3 +- .../genai-perf/tests/test_wrapper.py | 28 ++++- 12 files changed, 169 insertions(+), 88 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 45159cc15..1d03b3dd0 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -162,7 +162,7 @@ docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE} 2. Run GenAI-Perf: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind triton \ --backend tensorrtllm \ @@ -209,7 +209,7 @@ current profile run. This is disabled by default but users can easily enable it by passing the `--generate-plots` option when running the benchmark: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind triton \ --backend tensorrtllm \ diff --git a/src/c++/perf_analyzer/genai-perf/docs/embeddings.md b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md index bc6e2d413..e508f9eff 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/embeddings.md +++ b/src/c++/perf_analyzer/genai-perf/docs/embeddings.md @@ -60,7 +60,7 @@ docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model intflo To profile embeddings models using GenAI-Perf, use the following command: ```bash -genai-perf \ +genai-perf profile \ -m intfloat/e5-mistral-7b-instruct \ --service-kind openai \ --endpoint-type embeddings \ @@ -73,7 +73,7 @@ additional arguments with the `--extra-inputs` [flag](../README.md#input-options For example, you could use this command: ```bash -genai-perf \ +genai-perf profile \ -m intfloat/e5-mistral-7b-instruct \ --service-kind openai \ --endpoint-type embeddings \ diff --git a/src/c++/perf_analyzer/genai-perf/docs/lora.md b/src/c++/perf_analyzer/genai-perf/docs/lora.md index b3ddbe479..d30867eda 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/lora.md +++ b/src/c++/perf_analyzer/genai-perf/docs/lora.md @@ -41,7 +41,7 @@ When profiling with multiple models, you can specify how the models should be assigned to prompts using the `--model-selection-strategy` option: ```bash -genai-perf \ +genai-perf profile \ -m lora_adapter1 lora_adapter2 lora_adapter3 \ --model-selection-strategy round_robin ``` diff --git a/src/c++/perf_analyzer/genai-perf/docs/rankings.md b/src/c++/perf_analyzer/genai-perf/docs/rankings.md index 5cd1a4812..a316ef857 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/rankings.md +++ b/src/c++/perf_analyzer/genai-perf/docs/rankings.md @@ -74,7 +74,7 @@ docker run --gpus all -p 8080:80 -v $volume:/data --pull always ghcr.io/huggingf To profile ranking models using GenAI-Perf, use the following command: ```bash -genai-perf \ +genai-perf profile \ -m BAAI/bge-reranker-large \ --service-kind openai \ --endpoint-type rankings \ diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index bc9dec71b..6d6f3e301 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -82,7 +82,7 @@ docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE} 2. Run GenAI-Perf: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind triton \ --backend tensorrtllm \ @@ -166,7 +166,7 @@ docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE} 2. Run GenAI-Perf: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind triton \ --backend vllm \ @@ -232,7 +232,7 @@ docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE} 2. Run GenAI-Perf: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind openai \ --endpoint v1/chat/completions \ @@ -296,7 +296,7 @@ docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE} 2. Run GenAI-Perf: ```bash -genai-perf \ +genai-perf profile \ -m gpt2 \ --service-kind openai \ --endpoint v1/completions \ diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 64178fd4c..521b30e53 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -61,6 +61,14 @@ def to_lowercase(self): return self.name.lower() +class Subcommand(Enum): + PROFILE = auto() + COMPARE = auto() + + def to_lowercase(self): + return self.name.lower() + + logger = logging.getLogger(__name__) _endpoint_type_map = { @@ -77,7 +85,7 @@ def _check_model_args( """ Check if model name is provided. """ - if not args.subcommand and not args.model: + if not args.model: parser.error("The -m/--model option is required and cannot be empty.") args = _convert_str_to_enum_entry( args, "model_selection_strategy", ModelSelectionStrategy @@ -102,9 +110,8 @@ def _check_compare_args( """ Check compare subcommand args """ - if args.subcommand == "compare": - if not args.config and not args.files: - parser.error("Either the --config or --files option must be specified.") + if not args.config and not args.files: + parser.error("Either the --config or --files option must be specified.") return args @@ -573,13 +580,6 @@ def _add_other_args(parser): help="An option to enable verbose mode.", ) - other_group.add_argument( - "--version", - action="version", - version="%(prog)s " + __version__, - help=f"An option to print the version and exit.", - ) - def get_extra_inputs_as_dict(args: argparse.Namespace) -> dict: request_inputs = {} @@ -626,10 +626,10 @@ def get_extra_inputs_as_dict(args: argparse.Namespace) -> dict: def _parse_compare_args(subparsers) -> argparse.ArgumentParser: compare = subparsers.add_parser( - "compare", + Subcommand.COMPARE.to_lowercase(), description="Subcommand to generate plots that compare multiple profile runs.", ) - compare_group = compare.add_argument_group("Compare") + compare_group = compare.add_argument_group("Input") mx_group = compare_group.add_mutually_exclusive_group(required=False) mx_group.add_argument( "--config", @@ -651,6 +651,20 @@ def _parse_compare_args(subparsers) -> argparse.ArgumentParser: return compare +def _parse_profile_args(subparsers) -> argparse.ArgumentParser: + profile = subparsers.add_parser( + Subcommand.PROFILE.to_lowercase(), + description="Subcommand to profile LLMs and Generative AI models.", + ) + _add_endpoint_args(profile) + _add_input_args(profile) + _add_profile_args(profile) + _add_output_args(profile) + _add_other_args(profile) + profile.set_defaults(func=profile_handler) + return profile + + ### Handlers ### @@ -659,12 +673,6 @@ def create_compare_dir() -> None: os.mkdir(DEFAULT_COMPARE_DIR) -def profile_handler(args, extra_args): - from genai_perf.wrapper import Profiler - - Profiler.run(args=args, extra_args=extra_args) - - def compare_handler(args: argparse.Namespace): """Handles `compare` subcommand workflow.""" if args.files: @@ -679,45 +687,75 @@ def compare_handler(args: argparse.Namespace): plot_manager.generate_plots() -### Entrypoint ### +def profile_handler(args, extra_args): + from genai_perf.wrapper import Profiler + Profiler.run(args=args, extra_args=extra_args) -def parse_args(): - argv = sys.argv +### Parser Initialization ### + + +def init_parsers(): parser = argparse.ArgumentParser( prog="genai-perf", description="CLI to profile LLMs and Generative AI models with Perf Analyzer", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) - parser.set_defaults(func=profile_handler) - - # Conceptually group args for easier visualization - _add_endpoint_args(parser) - _add_input_args(parser) - _add_profile_args(parser) - _add_output_args(parser) - _add_other_args(parser) + parser.add_argument( + "--version", + action="version", + version="%(prog)s " + __version__, + help=f"An option to print the version and exit.", + ) # Add subcommands subparsers = parser.add_subparsers( help="List of subparser commands.", dest="subcommand" ) - compare_parser = _parse_compare_args(subparsers) + _ = _parse_compare_args(subparsers) + _ = _parse_profile_args(subparsers) + subparsers.required = True + + return parser - # Check for passthrough args + +def get_passthrough_args_index(argv: list) -> int: if "--" in argv: passthrough_index = argv.index("--") logger.info(f"Detected passthrough args: {argv[passthrough_index + 1:]}") else: passthrough_index = len(argv) + return passthrough_index + + +def refine_args( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> argparse.Namespace: + if args.subcommand == Subcommand.PROFILE.to_lowercase(): + args = _infer_prompt_source(args) + args = _check_model_args(parser, args) + args = _check_conditional_args(parser, args) + args = _check_load_manager_args(args) + args = _set_artifact_paths(args) + elif args.subcommand == Subcommand.COMPARE.to_lowercase(): + args = _check_compare_args(parser, args) + else: + raise ValueError(f"Unknown subcommand: {args.subcommand}") + + return args + + +### Entrypoint ### + + +def parse_args(): + argv = sys.argv + + parser = init_parsers() + passthrough_index = get_passthrough_args_index(argv) args = parser.parse_args(argv[1:passthrough_index]) - args = _infer_prompt_source(args) - args = _check_model_args(parser, args) - args = _check_conditional_args(parser, args) - args = _check_compare_args(compare_parser, args) - args = _check_load_manager_args(args) - args = _set_artifact_paths(args) + args = refine_args(parser, args) return args, argv[passthrough_index + 1 :] diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py b/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py index 3cc2999f5..a44304348 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py @@ -10,7 +10,7 @@ # For all cases but vllm_openai, it assumes that the server will be on port 9999 # # This script will run a sweep of all combinations of values in the testing matrix -# by appending those options on to the genai-pa base command +# by appending those options on to the genai-perf base command # @@ -20,11 +20,11 @@ ] base_commands = { - "nim_chat": "genai-perf -s 999 -p 20000 -m llama-2-7b-chat -u http://localhost:9999 --service-kind openai --endpoint-type chat", - "nim_completions": "genai-perf -s 999 -p 20000 -m llama-2-7b -u http://localhost:9999 --service-kind openai --endpoint-type completions", - "vllm_openai": "genai-perf -s 999 -p 20000 -m mistralai/Mistral-7B-v0.1 --service-kind openai --endpoint-type chat", - "triton_tensorrtllm": "genai-perf -s 999 -p 20000 -m llama-2-7b -u 0.0.0.0:9999 --service-kind triton --backend tensorrtllm", - "triton_vllm": "genai-perf -s 999 -p 20000 -m gpt2_vllm --service-kind triton --backend vllm", + "nim_chat": "genai-perf profile -s 999 -p 20000 -m llama-2-7b-chat -u http://localhost:9999 --service-kind openai --endpoint-type chat", + "nim_completions": "genai-perf profile -s 999 -p 20000 -m llama-2-7b -u http://localhost:9999 --service-kind openai --endpoint-type completions", + "vllm_openai": "genai-perf profile -s 999 -p 20000 -m mistralai/Mistral-7B-v0.1 --service-kind openai --endpoint-type chat", + "triton_tensorrtllm": "genai-perf profile -s 999 -p 20000 -m llama-2-7b -u 0.0.0.0:9999 --service-kind triton --backend tensorrtllm", + "triton_vllm": "genai-perf profile -s 999 -p 20000 -m gpt2_vllm --service-kind triton --backend vllm", } testname = "" diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index cc005beef..eb891fd02 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -52,10 +52,7 @@ class TestCLIArguments: [ (["-h"], expected_help_output), (["--help"], expected_help_output), - (["-m", "abc", "--help"], expected_help_output), - (["-m", "abc", "-h"], expected_help_output), (["--version"], expected_version_output), - (["-m", "abc", "--version"], expected_version_output), ], ) def test_help_version_arguments_output_and_exit( @@ -226,7 +223,7 @@ def test_help_version_arguments_output_and_exit( ) def test_non_file_flags_parsed(self, monkeypatch, arg, expected_attributes, capsys): logging.init_logging() - combined_args = ["genai-perf", "--model", "test_model"] + arg + combined_args = ["genai-perf", "profile", "--model", "test_model"] + arg monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -267,7 +264,7 @@ def test_multiple_model_args( self, monkeypatch, models, expected_model_list, formatted_name, capsys ): logging.init_logging() - combined_args = ["genai-perf"] + models + combined_args = ["genai-perf", "profile"] + models monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -287,6 +284,7 @@ def test_file_flags_parsed(self, monkeypatch, mocker): _ = mocker.patch("os.path.isfile", return_value=True) combined_args = [ "genai-perf", + "profile", "--model", "test_model", "--input-file", @@ -340,7 +338,7 @@ def test_default_profile_export_filepath( self, monkeypatch, arg, expected_path, capsys ): logging.init_logging() - combined_args = ["genai-perf", "--model", "test_model"] + arg + combined_args = ["genai-perf", "profile", "--model", "test_model"] + arg monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -380,7 +378,7 @@ def test_model_name_artifact_path( self, monkeypatch, arg, expected_path, expected_output, capsys ): logging.init_logging() - combined_args = ["genai-perf"] + arg + combined_args = ["genai-perf", "profile"] + arg monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -390,7 +388,9 @@ def test_model_name_artifact_path( def test_default_load_level(self, monkeypatch, capsys): logging.init_logging() - monkeypatch.setattr("sys.argv", ["genai-perf", "--model", "test_model"]) + monkeypatch.setattr( + "sys.argv", ["genai-perf", "profile", "--model", "test_model"] + ) args, _ = parser.parse_args() assert args.concurrency == 1 captured = capsys.readouterr() @@ -398,7 +398,8 @@ def test_default_load_level(self, monkeypatch, capsys): def test_load_level_mutually_exclusive(self, monkeypatch, capsys): monkeypatch.setattr( - "sys.argv", ["genai-perf", "--concurrency", "3", "--request-rate", "9.0"] + "sys.argv", + ["genai-perf", "profile", "--concurrency", "3", "--request-rate", "9.0"], ) expected_output = ( "argument --request-rate: not allowed with argument --concurrency" @@ -412,7 +413,7 @@ def test_load_level_mutually_exclusive(self, monkeypatch, capsys): assert expected_output in captured.err def test_model_not_provided(self, monkeypatch, capsys): - monkeypatch.setattr("sys.argv", ["genai-perf"]) + monkeypatch.setattr("sys.argv", ["genai-perf", "profile"]) expected_output = "The -m/--model option is required and cannot be empty." with pytest.raises(SystemExit) as excinfo: @@ -423,7 +424,7 @@ def test_model_not_provided(self, monkeypatch, capsys): assert expected_output in captured.err def test_pass_through_args(self, monkeypatch): - args = ["genai-perf", "-m", "test_model"] + args = ["genai-perf", "profile", "-m", "test_model"] other_args = ["--", "With", "great", "power"] monkeypatch.setattr("sys.argv", args + other_args) _, pass_through_args = parser.parse_args() @@ -435,6 +436,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): "sys.argv", [ "genai-perf", + "profile", "-m", "nonexistent_model", "--wrong-arg", @@ -453,12 +455,20 @@ def test_unrecognized_arg(self, monkeypatch, capsys): "args, expected_output", [ ( - ["genai-perf", "-m", "test_model", "--service-kind", "openai"], + [ + "genai-perf", + "profile", + "-m", + "test_model", + "--service-kind", + "openai", + ], "The --endpoint-type option is required when using the 'openai' service-kind.", ), ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -469,12 +479,20 @@ def test_unrecognized_arg(self, monkeypatch, capsys): "The --endpoint-type option is required when using the 'openai' service-kind.", ), ( - ["genai-perf", "-m", "test_model", "--output-tokens-stddev", "5"], + [ + "genai-perf", + "profile", + "-m", + "test_model", + "--output-tokens-stddev", + "5", + ], "The --output-tokens-mean option is required when using --output-tokens-stddev.", ), ( [ "genai-perf", + "profile", "-m", "test_model", "--output-tokens-mean-deterministic", @@ -484,6 +502,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--output-tokens-mean-deterministic", @@ -493,6 +512,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -508,6 +528,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--batch-size", @@ -518,6 +539,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -531,6 +553,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -544,6 +567,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -557,6 +581,7 @@ def test_unrecognized_arg(self, monkeypatch, capsys): ( [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", @@ -613,7 +638,9 @@ def test_conditional_errors(self, args, expected_output, monkeypatch, capsys): ], ) def test_inferred_output_format(self, monkeypatch, args, expected_format): - monkeypatch.setattr("sys.argv", ["genai-perf", "-m", "test_model"] + args) + monkeypatch.setattr( + "sys.argv", ["genai-perf", "profile", "-m", "test_model"] + args + ) parsed_args, _ = parser.parse_args() assert parsed_args.output_format == expected_format @@ -644,7 +671,7 @@ def test_inferred_output_format(self, monkeypatch, args, expected_format): ], ) def test_repeated_extra_arg_warning(self, monkeypatch, args, expected_error): - combined_args = ["genai-perf", "-m", "test_model"] + args + combined_args = ["genai-perf", "profile", "-m", "test_model"] + args monkeypatch.setattr("sys.argv", combined_args) parsed_args, _ = parser.parse_args() @@ -672,7 +699,7 @@ def test_inferred_prompt_source( _ = mocker.patch("builtins.open", mocker.mock_open(read_data="data")) _ = mocker.patch("os.path.isfile", return_value=True) _ = mocker.patch("os.path.isdir", return_value=True) - combined_args = ["genai-perf", "--model", "test_model"] + args + combined_args = ["genai-perf", "profile", "--model", "test_model"] + args monkeypatch.setattr("sys.argv", combined_args) args, _ = parser.parse_args() @@ -684,6 +711,7 @@ def test_prompt_source_assertions(self, monkeypatch, mocker, capsys): _ = mocker.patch("os.path.isdir", return_value=True) args = [ "genai-perf", + "profile", "--model", "test_model", "--input-dataset", @@ -758,20 +786,6 @@ def test_compare_not_provided(self, monkeypatch, capsys): captured = capsys.readouterr() assert expected_output in captured.err - @pytest.mark.parametrize( - "args, expected_model", - [ - (["--files", "profile1.json", "profile2.json", "profile3.json"], None), - (["--config", "config.yaml"], None), - ], - ) - def test_compare_model_arg(self, monkeypatch, args, expected_model): - combined_args = ["genai-perf", "compare"] + args - monkeypatch.setattr("sys.argv", combined_args) - args, _ = parser.parse_args() - - assert args.model == expected_model - @pytest.mark.parametrize( "extra_inputs_list, expected_dict", [ diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py index ca11377ed..dda62e04a 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_console_exporter.py @@ -35,6 +35,7 @@ class TestConsoleExporter: def test_streaming_llm_output(self, monkeypatch, capsys) -> None: argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", @@ -86,6 +87,7 @@ def test_streaming_llm_output(self, monkeypatch, capsys) -> None: def test_nonstreaming_llm_output(self, monkeypatch, capsys) -> None: argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", @@ -135,6 +137,7 @@ def test_nonstreaming_llm_output(self, monkeypatch, capsys) -> None: def test_embedding_output(self, monkeypatch, capsys) -> None: argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py index bd2d3bb81..6a60bc2dc 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_csv_exporter.py @@ -71,6 +71,7 @@ def test_streaming_llm_csv_output( """ argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", @@ -126,6 +127,7 @@ def test_nonstreaming_llm_csv_output( """ argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", @@ -174,6 +176,7 @@ def test_embedding_csv_output( ) -> None: argv = [ "genai-perf", + "profile", "-m", "model_name", "--service-kind", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index 998cc8865..e4a29267d 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -35,6 +35,7 @@ class TestJsonExporter: def test_generate_json(self, monkeypatch) -> None: cli_cmd = [ "genai-perf", + "profile", "-m", "gpt2_vllm", "--backend", @@ -257,7 +258,7 @@ def test_generate_json(self, monkeypatch) -> None: "artifact_dir": "artifacts/gpt2_vllm-triton-vllm-concurrency1", "tokenizer": "hf-internal-testing/llama-tokenizer", "verbose": false, - "subcommand": null, + "subcommand": "profile", "prompt_source": "synthetic", "extra_inputs": { "max_tokens": 256, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_wrapper.py b/src/c++/perf_analyzer/genai-perf/tests/test_wrapper.py index 184a47f11..fd4c34b51 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_wrapper.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_wrapper.py @@ -43,7 +43,14 @@ class TestWrapper: ], ) def test_url_exactly_once_triton(self, monkeypatch, arg): - args = ["genai-perf", "-m", "test_model", "--service-kind", "triton"] + arg + args = [ + "genai-perf", + "profile", + "-m", + "test_model", + "--service-kind", + "triton", + ] + arg monkeypatch.setattr("sys.argv", args) args, extra_args = parser.parse_args() cmd = Profiler.build_cmd(args, extra_args) @@ -70,7 +77,14 @@ def test_url_exactly_once_triton(self, monkeypatch, arg): ], ) def test_profile_export_filepath(self, monkeypatch, arg, expected_filepath): - args = ["genai-perf", "-m", "test_model", "--service-kind", "triton"] + arg + args = [ + "genai-perf", + "profile", + "-m", + "test_model", + "--service-kind", + "triton", + ] + arg monkeypatch.setattr("sys.argv", args) args, extra_args = parser.parse_args() cmd = Profiler.build_cmd(args, extra_args) @@ -87,7 +101,14 @@ def test_profile_export_filepath(self, monkeypatch, arg, expected_filepath): ], ) def test_service_triton(self, monkeypatch, arg): - args = ["genai-perf", "-m", "test_model", "--service-kind", "triton"] + arg + args = [ + "genai-perf", + "profile", + "-m", + "test_model", + "--service-kind", + "triton", + ] + arg monkeypatch.setattr("sys.argv", args) args, extra_args = parser.parse_args() cmd = Profiler.build_cmd(args, extra_args) @@ -111,6 +132,7 @@ def test_service_triton(self, monkeypatch, arg): def test_service_openai(self, monkeypatch, arg): args = [ "genai-perf", + "profile", "-m", "test_model", "--service-kind", From f1803604ebf6679346b1971e66bf0b5533ee8ce0 Mon Sep 17 00:00:00 2001 From: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> Date: Thu, 11 Jul 2024 17:00:16 -0700 Subject: [PATCH 43/55] When JSON parsing fails, return the failed string (#750) --- .../genai_perf/llm_inputs/llm_inputs.py | 11 ++++++----- .../genai-perf/genai_perf/parser.py | 2 +- .../llm_profile_data_parser.py | 8 ++++---- .../perf_analyzer/genai-perf/genai_perf/utils.py | 16 +++++++++++++++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index de528aac4..39abc7ece 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -24,6 +24,7 @@ from genai_perf.exceptions import GenAIPerfException from genai_perf.llm_inputs.synthetic_prompt_generator import SyntheticPromptGenerator from genai_perf.tokenizer import DEFAULT_TOKENIZER, Tokenizer, get_tokenizer +from genai_perf.utils import load_json_str from requests import Response @@ -315,7 +316,7 @@ def _get_input_dataset_from_embeddings_file( cls, input_filename: Path, batch_size: int, num_prompts: int ) -> Dict[str, Any]: with open(input_filename, "r") as file: - file_content = [json.loads(line) for line in file] + file_content = [load_json_str(line) for line in file] texts = [item["text"] for item in file_content] @@ -344,11 +345,11 @@ def _get_input_dataset_from_rankings_files( ) -> Dict[str, Any]: with open(queries_filename, "r") as file: - queries_content = [json.loads(line) for line in file] + queries_content = [load_json_str(line) for line in file] queries_texts = [item for item in queries_content] with open(passages_filename, "r") as file: - passages_content = [json.loads(line) for line in file] + passages_content = [load_json_str(line) for line in file] passages_texts = [item for item in passages_content] if batch_size > len(passages_texts): @@ -363,7 +364,7 @@ def _get_input_dataset_from_rankings_files( for _ in range(num_prompts): sampled_texts = random.sample(passages_texts, batch_size) query_sample = random.choice(queries_texts) - entry_dict = {} + entry_dict: Dict = {} entry_dict["query"] = query_sample entry_dict["passages"] = sampled_texts dataset_json["rows"].append({"row": {"payload": entry_dict}}) @@ -536,7 +537,7 @@ def _get_prompts_from_input_file(cls, input_filename: Path) -> List[str]: with open(input_filename, mode="r", newline=None) as file: for line in file: if line.strip(): - prompts.append(json.loads(line).get("text_input", "").strip()) + prompts.append(load_json_str(line).get("text_input", "").strip()) return prompts @classmethod diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 521b30e53..901cf6ca2 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -586,7 +586,7 @@ def get_extra_inputs_as_dict(args: argparse.Namespace) -> dict: if args.extra_inputs: for input_str in args.extra_inputs: if input_str.startswith("{") and input_str.endswith("}"): - request_inputs.update(json.loads(input_str)) + request_inputs.update(utils.load_json_str(input_str)) else: semicolon_count = input_str.count(":") if semicolon_count != 1: diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py index cbb2da5ee..4ec1bec62 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -37,7 +37,7 @@ ResponseFormat, ) from genai_perf.tokenizer import Tokenizer -from genai_perf.utils import remove_sse_prefix +from genai_perf.utils import load_json_str, remove_sse_prefix class LLMProfileDataParser(ProfileDataParser): @@ -178,7 +178,7 @@ def _preprocess_response( response = res_outputs[i]["response"] responses = response.strip().split("\n\n") if len(responses) > 1: - merged_response = json.loads(remove_sse_prefix(responses[0])) + merged_response = load_json_str(remove_sse_prefix(responses[0])) if ( merged_response["choices"][0]["delta"].get("content", None) is None @@ -213,7 +213,7 @@ def _get_input_token_count(self, req_inputs: dict) -> int: def _get_openai_input_text(self, req_inputs: dict) -> str: """Tokenize the OpenAI request input texts.""" - payload = json.loads(req_inputs["payload"]) + payload = load_json_str(req_inputs["payload"]) if self._response_format == ResponseFormat.OPENAI_CHAT_COMPLETIONS: return payload["messages"][0]["content"] elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: @@ -268,7 +268,7 @@ def _extract_openai_text_output(self, response: str) -> str: if response == "[DONE]": return "" - data = json.loads(response) + data = load_json_str(response) completions = data["choices"][0] text_output = "" diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py b/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py index a10befe13..6f66230c4 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py @@ -29,10 +29,14 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Type +import genai_perf.logging as logging + # Skip type checking to avoid mypy error # Issue: https://github.com/python/mypy/issues/10632 import yaml # type: ignore +logger = logging.getLogger(__name__) + def remove_sse_prefix(msg: str) -> str: prefix = "data: " @@ -49,7 +53,17 @@ def load_yaml(filepath: Path) -> Dict[str, Any]: def load_json(filepath: Path) -> Dict[str, Any]: with open(str(filepath), encoding="utf-8", errors="ignore") as f: - return json.load(f) + content = f.read() + return load_json_str(content) + + +def load_json_str(json_str: str) -> Dict[str, Any]: + try: + return json.loads(json_str) + except json.JSONDecodeError: + snippet = json_str[:200] + ("..." if len(json_str) > 200 else "") + logger.error("Failed to parse JSON string: '%s'", snippet) + raise def remove_file(file: Path) -> None: From b9bab2098961ff2e5e67d2d593d289d84b1fa06a Mon Sep 17 00:00:00 2001 From: Neelay Shah Date: Thu, 11 Jul 2024 17:33:41 -0700 Subject: [PATCH 44/55] feat: Treat HTTP request completion as final response Adds support for sending a final response on HTTP request completion for streaming cases that do not send an explicit `[DONE` Checks to ensure that only one final response is sent. Full end to end testing to be enable with further changes to support Triton generate endpoint --- .../perf_analyzer/client_backend/openai/openai_client.cc | 7 ++++++- .../perf_analyzer/client_backend/openai/openai_client.h | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/client_backend/openai/openai_client.cc b/src/c++/perf_analyzer/client_backend/openai/openai_client.cc index cd517f6a6..9b167fae1 100644 --- a/src/c++/perf_analyzer/client_backend/openai/openai_client.cc +++ b/src/c++/perf_analyzer/client_backend/openai/openai_client.cc @@ -63,6 +63,7 @@ namespace openai { void ChatCompletionRequest::SendResponse(bool is_final, bool is_null) { + final_response_sent_ = is_final; response_callback_(new ChatCompletionResult( http_code_, std::move(response_buffer_), is_final, is_null, request_id_)); } @@ -172,7 +173,11 @@ ChatCompletionClient::AsyncInfer( request->timer_.CaptureTimestamp( triton::client::RequestTimers::Kind::REQUEST_END); UpdateInferStat(request->timer_); - if (!request->is_stream_) { + + // Send final response on request completion + // if it has not already been sent. + // (e.g. in the case of seeing [DONE] in streaming case) + if (!request->IsFinalResponseSent()) { request->SendResponse(true /* is_final */, false /* is_null */); } }; diff --git a/src/c++/perf_analyzer/client_backend/openai/openai_client.h b/src/c++/perf_analyzer/client_backend/openai/openai_client.h index aadcb3252..00ccbd5fa 100644 --- a/src/c++/perf_analyzer/client_backend/openai/openai_client.h +++ b/src/c++/perf_analyzer/client_backend/openai/openai_client.h @@ -121,12 +121,14 @@ class ChatCompletionRequest : public HttpRequest { request_id_(request_id) { } + bool IsFinalResponseSent() { return final_response_sent_; }; void SendResponse(bool is_final, bool is_null); bool is_stream_{false}; std::function response_callback_{nullptr}; // The timers for infer request. triton::client::RequestTimers timer_; const std::string request_id_; + bool final_response_sent_{false}; }; class ChatCompletionClient : public HttpClient { From db888f1aca588a10f5e4a4b02a4e4ff60d437b6f Mon Sep 17 00:00:00 2001 From: AndyDai-nv Date: Fri, 12 Jul 2024 10:33:03 -0700 Subject: [PATCH 45/55] Update GAP tutorial of vllm backend (#743) * Update GAP tutorial to be testable --------- Co-authored-by: tgerdes Co-authored-by: Timothy Gerdes <50968584+tgerdesnv@users.noreply.github.com> Co-authored-by: David Yastremsky <58150256+dyastremsky@users.noreply.github.com> --- .../perf_analyzer/genai-perf/docs/tutorial.md | 113 +++++++----------- 1 file changed, 44 insertions(+), 69 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index 6d6f3e301..1a37baf39 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -30,57 +30,47 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - [Profile GPT2 running on Triton + TensorRT-LLM](#tensorrt-llm) - [Profile GPT2 running on Triton + vLLM](#triton-vllm) -- [Profile GPT2 running on OpenAI API-Compatible Server](#openai) +- [Profile GPT2 running on OpenAI Chat Completions API-Compatible Server](#openai-chat) +- [Profile GPT2 running on OpenAI Completions API-Compatible Server](#openai-completions) --- ## Profile GPT2 running on Triton + TensorRT-LLM -### Running GPT2 on Triton Inference Server using TensorRT-LLM +### Run GPT2 on Triton Inference Server using TensorRT-LLM

See instructions -1. Run Triton Inference Server with TensorRT-LLM backend container: +Run Triton Inference Server with TensorRT-LLM backend container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-trtllm-python-py3 -``` - -2. Install Triton CLI (~5 min): +docker run -it --net=host --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-trtllm-python-py3 -```bash +# Install Triton CLI (~5 min): pip install "git+https://github.com/triton-inference-server/triton_cli@0.0.8" -``` -3. Download model: - -```bash +# Download model: triton import -m gpt2 --backend tensorrtllm -``` -4. Run server: - -```bash +# Run server: triton start ```
-### Running GenAI-Perf +### Run GenAI-Perf -1. Run Triton Inference Server SDK container: +Run GenAI-Perf from Triton Inference Server SDK container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" - -docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -``` +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -2. Run GenAI-Perf: +docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk +# Run GenAI-Perf in the container: ```bash genai-perf profile \ -m gpt2 \ @@ -120,51 +110,41 @@ Request throughput (per sec): 4.44 ## Profile GPT2 running on Triton + vLLM -### Running GPT2 on Triton Inference Server using vLLM +### Run GPT2 on Triton Inference Server using vLLM
See instructions -1. Run Triton Inference Server with vLLM backend container: +Run Triton Inference Server with vLLM backend container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-vllm-python-py3 -``` -2. Install Triton CLI (~5 min): +docker run -it --net=host --gpus=1 --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-vllm-python-py3 -```bash +# Install Triton CLI (~5 min): pip install "git+https://github.com/triton-inference-server/triton_cli@0.0.8" -``` - -3. Download model: -```bash +# Download model: triton import -m gpt2 --backend vllm -``` - -4. Run server: -```bash +# Run server: triton start ```
-### Running GenAI-Perf +### Run GenAI-Perf -1. Run Triton Inference Server SDK container: +Run GenAI-Perf from Triton Inference Server SDK container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -``` - -2. Run GenAI-Perf: +docker run -it --net=host --gpus=1 nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk +# Run GenAI-Perf in the container: ```bash genai-perf profile \ -m gpt2 \ @@ -202,35 +182,31 @@ Output token throughput (per sec): 290.24 Request throughput (per sec): 2.57 ``` -## Profile GPT2 running on OpenAI API-Compatible Server - -### OpenAI Chat Completions API +## Profile GPT2 running on OpenAI Chat API-Compatible Server -#### Running GPT2 on [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat)-compatible server +### Run GPT2 on [OpenAI Chat Completions API](https://platform.openai.com/docs/api-reference/chat)-compatible server
See instructions -1. Run the vLLM inference server: +Run the vLLM inference server: ```bash -docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model gpt2 --dtype float16 --max-model-len 1024 +docker run -it --net=host --gpus=all vllm/vllm-openai:latest --model gpt2 --dtype float16 --max-model-len 1024 ```
-#### Running GenAI-Perf +### Run GenAI-Perf -1. Run Triton Inference Server SDK container: +Run GenAI-Perf from Triton Inference Server SDK container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -``` - -2. Run GenAI-Perf: +docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk +# Run GenAI-Perf in the container: ```bash genai-perf profile \ -m gpt2 \ @@ -268,33 +244,32 @@ Output token throughput (per sec): 401.62 Request throughput (per sec): 3.52 ``` -### OpenAI Completions API +## Profile GPT2 running on OpenAI Completions API-Compatible Server -#### Running GPT2 on [OpenAI Completions API](https://platform.openai.com/docs/api-reference/completions)-compatible server +### Running GPT2 on [OpenAI Completions API](https://platform.openai.com/docs/api-reference/completions)-compatible server
See instructions -1. Run the vLLM inference server: +Run the vLLM inference server: ```bash -docker run -it --net=host --rm --gpus=all vllm/vllm-openai:latest --model gpt2 --dtype float16 --max-model-len 1024 +docker run -it --net=host --gpus=all vllm/vllm-openai:latest --model gpt2 --dtype float16 --max-model-len 1024 ```
-#### Running GenAI-Perf +### Run GenAI-Perf -1. Run Triton Inference Server SDK container: +Run GenAI-Perf from Triton Inference Server SDK container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" -docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -``` +docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -2. Run GenAI-Perf: +# Run GenAI-Perf in the container: ```bash genai-perf profile \ -m gpt2 \ From e4d9ef04e3f2274add8fdf5cdbc87f1f2d9dce80 Mon Sep 17 00:00:00 2001 From: Harshini Komali <157742537+lkomali@users.noreply.github.com> Date: Thu, 18 Jul 2024 14:19:31 -0700 Subject: [PATCH 46/55] Update default behavior for max threads PA async mode (#737) * Set max_threads to concurrency * Address comments * Cleaning up PR * Fix comments --- src/c++/perf_analyzer/command_line_parser.cc | 3 +- src/c++/perf_analyzer/constants.h | 1 + .../perf_analyzer/test_command_line_parser.cc | 89 +++++++++++++++++-- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/c++/perf_analyzer/command_line_parser.cc b/src/c++/perf_analyzer/command_line_parser.cc index bd3d72d73..8003be711 100644 --- a/src/c++/perf_analyzer/command_line_parser.cc +++ b/src/c++/perf_analyzer/command_line_parser.cc @@ -1715,7 +1715,8 @@ CLParser::ParseCommandLine(int argc, char** argv) // Overriding the max_threads default for request_rate search if (!params_->max_threads_specified && params_->targeting_concurrency()) { - params_->max_threads = 16; + params_->max_threads = + std::max(DEFAULT_MAX_THREADS, params_->concurrency_range.end); } if (params_->using_custom_intervals) { diff --git a/src/c++/perf_analyzer/constants.h b/src/c++/perf_analyzer/constants.h index 443806781..fbcd911b8 100644 --- a/src/c++/perf_analyzer/constants.h +++ b/src/c++/perf_analyzer/constants.h @@ -41,6 +41,7 @@ constexpr static const uint32_t STABILITY_ERROR = 2; constexpr static const uint32_t OPTION_ERROR = 3; constexpr static const uint32_t GENERIC_ERROR = 99; +constexpr static const size_t DEFAULT_MAX_THREADS = 16; const double DELAY_PCT_THRESHOLD{1.0}; diff --git a/src/c++/perf_analyzer/test_command_line_parser.cc b/src/c++/perf_analyzer/test_command_line_parser.cc index 765def112..f697a52a7 100644 --- a/src/c++/perf_analyzer/test_command_line_parser.cc +++ b/src/c++/perf_analyzer/test_command_line_parser.cc @@ -371,10 +371,12 @@ class TestCLParser : public CLParser { void CheckValidRange( std::vector& args, char* option_name, TestCLParser& parser, - PAParamsPtr& act, bool& using_range, Range& range) + PAParamsPtr& act, bool& using_range, Range& range, + size_t* max_threads) { SUBCASE("start:end provided") { + *max_threads = 400; args.push_back(option_name); args.push_back("100:400"); // start:end @@ -392,6 +394,7 @@ CheckValidRange( SUBCASE("start:end:step provided") { + *max_threads = 400; args.push_back(option_name); args.push_back("100:400:10"); // start:end:step @@ -525,7 +528,7 @@ TEST_CASE("Testing Command Line Parser") // Most common defaults exp->model_name = model_name; // model_name; - exp->max_threads = 16; + exp->max_threads = DEFAULT_MAX_THREADS; SUBCASE("with no parameters") { @@ -1111,11 +1114,14 @@ TEST_CASE("Testing Command Line Parser") SUBCASE("Option : --concurrency-range") { char* option_name = "--concurrency-range"; + uint64_t concurrency_range_start; + uint64_t concurrency_range_end; SUBCASE("start provided") { + concurrency_range_start = 100; args.push_back(option_name); - args.push_back("100"); // start + args.push_back(std::to_string(concurrency_range_start).data()); // start int argc = args.size(); char* argv[argc]; @@ -1125,13 +1131,13 @@ TEST_CASE("Testing Command Line Parser") CHECK(!parser.UsageCalled()); exp->using_concurrency_range = true; - exp->concurrency_range.start = 100; + exp->concurrency_range.start = concurrency_range_start; + exp->max_threads = DEFAULT_MAX_THREADS; } CheckValidRange( args, option_name, parser, act, exp->using_concurrency_range, - exp->concurrency_range); - + exp->concurrency_range, &(exp->max_threads)); CheckInvalidRange(args, option_name, parser, act, check_params); SUBCASE("wrong separator") @@ -1173,6 +1179,75 @@ TEST_CASE("Testing Command Line Parser") check_params = false; } + + concurrency_range_start = 10; + SUBCASE("Max threads set to default when concurrency-range.end < 16") + { + concurrency_range_end = 10; + std::string concurrency_range_str = + std::to_string(concurrency_range_start) + ":" + + std::to_string(concurrency_range_end); + args.push_back(option_name); + args.push_back(concurrency_range_str.data()); + + int argc = args.size(); + char* argv[argc]; + std::copy(args.begin(), args.end(), argv); + + REQUIRE_NOTHROW(act = parser.Parse(argc, argv)); + CHECK(!parser.UsageCalled()); + + exp->using_concurrency_range = true; + exp->concurrency_range.start = concurrency_range_start; + exp->concurrency_range.end = concurrency_range_end; + exp->max_threads = DEFAULT_MAX_THREADS; + } + + SUBCASE("Max_threads set to default when concurrency-range.end = 16") + { + concurrency_range_end = 16; + std::string concurrency_range_str = + std::to_string(concurrency_range_start) + ":" + + std::to_string(concurrency_range_end); + args.push_back(option_name); + args.push_back(concurrency_range_str.data()); + + int argc = args.size(); + char* argv[argc]; + std::copy(args.begin(), args.end(), argv); + + REQUIRE_NOTHROW(act = parser.Parse(argc, argv)); + CHECK(!parser.UsageCalled()); + + exp->using_concurrency_range = true; + exp->concurrency_range.start = concurrency_range_start; + exp->concurrency_range.end = concurrency_range_end; + exp->max_threads = DEFAULT_MAX_THREADS; + } + + SUBCASE( + "Max_threads set to concurrency-range.end when concurrency-range.end > " + "16") + { + concurrency_range_end = 40; + std::string concurrency_range_str = + std::to_string(concurrency_range_start) + ":" + + std::to_string(concurrency_range_end); + args.push_back(option_name); + args.push_back(concurrency_range_str.data()); + + int argc = args.size(); + char* argv[argc]; + std::copy(args.begin(), args.end(), argv); + + REQUIRE_NOTHROW(act = parser.Parse(argc, argv)); + CHECK(!parser.UsageCalled()); + + exp->using_concurrency_range = true; + exp->concurrency_range.start = concurrency_range_start; + exp->concurrency_range.end = concurrency_range_end; + exp->max_threads = exp->concurrency_range.end; + } } SUBCASE("Option : --periodic-concurrency-range") @@ -1210,7 +1285,7 @@ TEST_CASE("Testing Command Line Parser") CheckValidRange( args, option_name, parser, act, exp->is_using_periodic_concurrency_mode, - exp->periodic_concurrency_range); + exp->periodic_concurrency_range, &(exp->max_threads)); CheckInvalidRange(args, option_name, parser, act, check_params); From 30af885bd04ffcee3e5c046dab31f8101bf4e1a1 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Thu, 18 Jul 2024 14:38:56 -0700 Subject: [PATCH 47/55] Support Vision Language Model in GenAI-Perf (#756) * POC LLaVA VLM support (#720) * POC for LLaVA support * non-streaming request in VLM tests * image component sent in "image_url" field instead of HTML tag * generate sample image instead of loading from docs * add vision to endpoint mapping * fixes for handling OutputFormat * refactor - extract image preparation to a separate module * fixes to the refactor * replace match-case syntax with if-elseif-else * Update image payload format and fix tests * Few clean ups and tickets added for follow up tasks * Fix and add tests for vision format * Remove output format from profile data parser * Revert irrelevant code change * Revert changes * Remove unused dependency * Comment test_extra_inputs --------- Co-authored-by: Hyunjae Woo * Support multi-modal input from file for OpenAI Chat Completions (#749) * add synthetic image generator (#751) * synthetic image generator * format randomization * images should be base64-encoded arbitrarly * randomized image format * randomized image shape * prepare SyntheticImageGenerator to support different image sources * read from files * python 3.10 support fixes * remove unused imports * skip sampled image sizes with negative values * formats type fix * remove unused variable * synthetic image generator encodes images to base64 * image format not randomized * sample each dimension independently Co-authored-by: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> * apply code-review suggestsions * update class name * deterministic synthetic image generator * add typing to SyntheticImageGenerator * SyntheticImageGenerator doesn't load files * SyntheticImageGenerator always encodes images to base64 * remove unused imports * generate gaussian noise instead of blank images --------- Co-authored-by: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> * Add command line arguments for synthetic image generation (#753) * Add CLI options for synthetic image generation * read image format from file when --input-file is used * move encode_image method to utils * Lazy import some modules * Support synthetic image generation in GenAI-Perf (#754) * support synthetic image generation for VLM model * add test * integrate sythetic image generator into LlmInputs * add source images for synthetic image data * use abs to get positive int --------- Co-authored-by: Marek Wawrzos --- .../genai_perf/llm_inputs/llm_inputs.py | 185 ++++++++++++++++-- .../llm_inputs/source_images/dlss.png | Bin 0 -> 150094 bytes .../llm_inputs/source_images/h100.jpeg | Bin 0 -> 152564 bytes .../llm_inputs/source_images/h200.jpeg | Bin 0 -> 101670 bytes .../llm_inputs/source_images/jensen.jpeg | Bin 0 -> 109460 bytes .../llm_inputs/synthetic_image_generator.py | 79 ++++++++ .../genai-perf/genai_perf/main.py | 5 + .../genai-perf/genai_perf/parser.py | 75 ++++++- .../llm_profile_data_parser.py | 3 + .../profile_data_parser.py | 19 +- .../genai-perf/genai_perf/test_end_to_end.py | 92 --------- .../genai-perf/genai_perf/utils.py | 17 ++ .../genai-perf/genai_perf/wrapper.py | 5 + .../perf_analyzer/genai-perf/pyproject.toml | 1 + .../genai-perf/tests/test_cli.py | 41 +++- .../genai-perf/tests/test_json_exporter.py | 5 + .../genai-perf/tests/test_llm_inputs.py | 122 +++++++++++- .../genai-perf/tests/test_llm_metrics.py | 1 + .../tests/test_llm_profile_data_parser.py | 155 +++++++++++++++ .../tests/test_synthetic_image_generator.py | 99 ++++++++++ 20 files changed, 793 insertions(+), 111 deletions(-) create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/dlss.png create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/h100.jpeg create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/h200.jpeg create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/jensen.jpeg create mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py delete mode 100644 src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py create mode 100644 src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py index 39abc7ece..057c33562 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/llm_inputs.py @@ -20,11 +20,17 @@ from typing import Any, Dict, List, Optional, Tuple, cast import requests +from genai_perf import utils from genai_perf.constants import CNN_DAILY_MAIL, DEFAULT_INPUT_DATA_JSON, OPEN_ORCA from genai_perf.exceptions import GenAIPerfException +from genai_perf.llm_inputs.synthetic_image_generator import ( + ImageFormat, + SyntheticImageGenerator, +) from genai_perf.llm_inputs.synthetic_prompt_generator import SyntheticPromptGenerator from genai_perf.tokenizer import DEFAULT_TOKENIZER, Tokenizer, get_tokenizer from genai_perf.utils import load_json_str +from PIL import Image from requests import Response @@ -43,6 +49,7 @@ class OutputFormat(Enum): OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() OPENAI_EMBEDDINGS = auto() + OPENAI_VISION = auto() RANKINGS = auto() TENSORRTLLM = auto() VLLM = auto() @@ -75,6 +82,11 @@ class LlmInputs: DEFAULT_OUTPUT_TOKENS_STDDEV = 0 DEFAULT_NUM_PROMPTS = 100 + DEFAULT_IMAGE_WIDTH_MEAN = 100 + DEFAULT_IMAGE_WIDTH_STDDEV = 0 + DEFAULT_IMAGE_HEIGHT_MEAN = 100 + DEFAULT_IMAGE_HEIGHT_STDDEV = 0 + EMPTY_JSON_IN_VLLM_PA_FORMAT: Dict = {"data": []} EMPTY_JSON_IN_TENSORRTLLM_PA_FORMAT: Dict = {"data": []} EMPTY_JSON_IN_OPENAI_PA_FORMAT: Dict = {"data": []} @@ -97,6 +109,11 @@ def create_llm_inputs( output_tokens_deterministic: bool = False, prompt_tokens_mean: int = DEFAULT_PROMPT_TOKENS_MEAN, prompt_tokens_stddev: int = DEFAULT_PROMPT_TOKENS_STDDEV, + image_width_mean: int = DEFAULT_IMAGE_WIDTH_MEAN, + image_width_stddev: int = DEFAULT_IMAGE_WIDTH_STDDEV, + image_height_mean: int = DEFAULT_IMAGE_HEIGHT_MEAN, + image_height_stddev: int = DEFAULT_IMAGE_HEIGHT_STDDEV, + image_format: ImageFormat = ImageFormat.PNG, random_seed: int = DEFAULT_RANDOM_SEED, num_of_output_prompts: int = DEFAULT_NUM_PROMPTS, add_model_name: bool = False, @@ -139,6 +156,16 @@ def create_llm_inputs( The standard deviation of the length of the output to generate. This is only used if output_tokens_mean is provided. output_tokens_deterministic: If true, the output tokens will set the minimum and maximum tokens to be equivalent. + image_width_mean: + The mean width of images when generating synthetic image data. + image_width_stddev: + The standard deviation of width of images when generating synthetic image data. + image_height_mean: + The mean height of images when generating synthetic image data. + image_height_stddev: + The standard deviation of height of images when generating synthetic image data. + image_format: + The compression format of the images. batch_size: The number of inputs per request (currently only used for the embeddings and rankings endpoints) @@ -175,6 +202,11 @@ def create_llm_inputs( prompt_tokens_mean, prompt_tokens_stddev, num_of_output_prompts, + image_width_mean, + image_width_stddev, + image_height_mean, + image_height_stddev, + image_format, batch_size, input_filename, ) @@ -210,6 +242,11 @@ def get_generic_dataset_json( prompt_tokens_mean: int, prompt_tokens_stddev: int, num_of_output_prompts: int, + image_width_mean: int, + image_width_stddev: int, + image_height_mean: int, + image_height_stddev: int, + image_format: ImageFormat, batch_size: int, input_filename: Optional[Path], ) -> Dict: @@ -236,6 +273,16 @@ def get_generic_dataset_json( The standard deviation of the length of the prompt to generate num_of_output_prompts: The number of synthetic output prompts to generate + image_width_mean: + The mean width of images when generating synthetic image data. + image_width_stddev: + The standard deviation of width of images when generating synthetic image data. + image_height_mean: + The mean height of images when generating synthetic image data. + image_height_stddev: + The standard deviation of height of images when generating synthetic image data. + image_format: + The compression format of the images. batch_size: The number of inputs per request (currently only used for the embeddings and rankings endpoints) input_filename: @@ -280,6 +327,12 @@ def get_generic_dataset_json( ) else: if input_type == PromptSource.DATASET: + # (TMA-1990) support VLM input from public dataset + if output_format == OutputFormat.OPENAI_VISION: + raise GenAIPerfException( + f"{OutputFormat.OPENAI_VISION.to_lowercase()} currently " + "does not support dataset as input." + ) dataset = cls._get_input_dataset_from_url( dataset_name, starting_index, length ) @@ -292,6 +345,12 @@ def get_generic_dataset_json( prompt_tokens_mean, prompt_tokens_stddev, num_of_output_prompts, + image_width_mean, + image_width_stddev, + image_height_mean, + image_height_stddev, + image_format, + output_format, ) generic_dataset_json = ( cls._convert_input_synthetic_or_file_dataset_to_generic_json( @@ -301,6 +360,9 @@ def get_generic_dataset_json( elif input_type == PromptSource.FILE: input_filename = cast(Path, input_filename) input_file_dataset = cls._get_input_dataset_from_file(input_filename) + input_file_dataset = cls._encode_images_in_input_dataset( + input_file_dataset + ) generic_dataset_json = ( cls._convert_input_synthetic_or_file_dataset_to_generic_json( input_file_dataset @@ -309,6 +371,14 @@ def get_generic_dataset_json( else: raise GenAIPerfException("Input source is not recognized.") + # When the generic_dataset_json contains multi-modal data (e.g. images), + # convert the format of the content to OpenAI multi-modal format: + # see https://platform.openai.com/docs/guides/vision + if output_format == OutputFormat.OPENAI_VISION: + generic_dataset_json = cls._convert_to_openai_multi_modal_content( + generic_dataset_json + ) + return generic_dataset_json @classmethod @@ -405,17 +475,36 @@ def _get_input_dataset_from_synthetic( prompt_tokens_mean: int, prompt_tokens_stddev: int, num_of_output_prompts: int, + image_width_mean: int, + image_width_stddev: int, + image_height_mean: int, + image_height_stddev: int, + image_format: ImageFormat, + output_format: OutputFormat, ) -> Dict[str, Any]: dataset_json: Dict[str, Any] = {} dataset_json["features"] = [{"name": "text_input"}] dataset_json["rows"] = [] for _ in range(num_of_output_prompts): + row: Dict["str", Any] = {"row": {}} synthetic_prompt = cls._create_synthetic_prompt( tokenizer, prompt_tokens_mean, prompt_tokens_stddev, ) - dataset_json["rows"].append({"row": {"text_input": synthetic_prompt}}) + row["row"]["text_input"] = synthetic_prompt + + if output_format == OutputFormat.OPENAI_VISION: + synthetic_image = cls._create_synthetic_image( + image_width_mean=image_width_mean, + image_width_stddev=image_width_stddev, + image_height_mean=image_height_mean, + image_height_stddev=image_height_stddev, + image_format=image_format, + ) + row["row"]["image"] = synthetic_image + + dataset_json["rows"].append(row) return dataset_json @@ -497,29 +586,37 @@ def _add_rows_to_generic_json( @classmethod def _get_input_dataset_from_file(cls, input_filename: Path) -> Dict: """ - Reads the input prompts from a JSONL file and converts them into the required dataset format. + Reads the input prompts and images from a JSONL file and converts them + into the required dataset format. Parameters ---------- input_filename : Path - The path to the input file containing the prompts in JSONL format. + The path to the input file containing the prompts and/or images in + JSONL format. Returns ------- Dict - The dataset in the required format with the prompts read from the file. + The dataset in the required format with the prompts and/or images + read from the file. """ cls.verify_file(input_filename) - input_file_prompts = cls._get_prompts_from_input_file(input_filename) + prompts, images = cls._get_prompts_from_input_file(input_filename) dataset_json: Dict[str, Any] = {} dataset_json["features"] = [{"name": "text_input"}] - dataset_json["rows"] = [ - {"row": {"text_input": prompt}} for prompt in input_file_prompts - ] + dataset_json["rows"] = [] + for prompt, image in zip(prompts, images): + content = {"text_input": prompt} + content.update({"image": image} if image else {}) + dataset_json["rows"].append({"row": content}) + return dataset_json @classmethod - def _get_prompts_from_input_file(cls, input_filename: Path) -> List[str]: + def _get_prompts_from_input_file( + cls, input_filename: Path + ) -> Tuple[List[str], List[str]]: """ Reads the input prompts from a JSONL file and returns a list of prompts. @@ -530,21 +627,63 @@ def _get_prompts_from_input_file(cls, input_filename: Path) -> List[str]: Returns ------- - List[str] - A list of prompts read from the file. + Tuple[List[str], List[str]] + A list of prompts and images read from the file. """ prompts = [] + images = [] with open(input_filename, mode="r", newline=None) as file: for line in file: if line.strip(): prompts.append(load_json_str(line).get("text_input", "").strip()) - return prompts + images.append(load_json_str(line).get("image", "").strip()) + return prompts, images @classmethod def verify_file(cls, input_filename: Path) -> None: if not input_filename.exists(): raise FileNotFoundError(f"The file '{input_filename}' does not exist.") + @classmethod + def _convert_to_openai_multi_modal_content( + cls, generic_dataset_json: Dict[str, List[Dict]] + ) -> Dict[str, List[Dict]]: + """ + Converts to multi-modal content format of OpenAI Chat Completions API. + """ + for row in generic_dataset_json["rows"]: + if row["image"]: + row["text_input"] = [ + { + "type": "text", + "text": row["text_input"], + }, + { + "type": "image_url", + "image_url": {"url": row["image"]}, + }, + ] + + return generic_dataset_json + + @classmethod + def _encode_images_in_input_dataset(cls, input_file_dataset: Dict) -> Dict: + for row in input_file_dataset["rows"]: + filename = row["row"].get("image") + if filename: + img = Image.open(filename) + if img.format.lower() not in utils.get_enum_names(ImageFormat): + raise GenAIPerfException( + f"Unsupported image format '{img.format}' of " + f"the image '{filename}'." + ) + + img_base64 = utils.encode_image(img, img.format) + payload = f"data:image/{img.format.lower()};base64,{img_base64}" + row["row"]["image"] = payload + + return input_file_dataset + @classmethod def _convert_generic_json_to_output_format( cls, @@ -559,7 +698,10 @@ def _convert_generic_json_to_output_format( model_name: list = [], model_selection_strategy: ModelSelectionStrategy = ModelSelectionStrategy.ROUND_ROBIN, ) -> Dict: - if output_format == OutputFormat.OPENAI_CHAT_COMPLETIONS: + if ( + output_format == OutputFormat.OPENAI_CHAT_COMPLETIONS + or output_format == OutputFormat.OPENAI_VISION + ): output_json = cls._convert_generic_json_to_openai_chat_completions_format( generic_dataset, add_model_name, @@ -1424,3 +1566,20 @@ def _create_synthetic_prompt( return SyntheticPromptGenerator.create_synthetic_prompt( tokenizer, prompt_tokens_mean, prompt_tokens_stddev ) + + @classmethod + def _create_synthetic_image( + cls, + image_width_mean: int, + image_width_stddev: int, + image_height_mean: int, + image_height_stddev: int, + image_format: ImageFormat, + ) -> str: + return SyntheticImageGenerator.create_synthetic_image( + image_width_mean=image_width_mean, + image_width_stddev=image_width_stddev, + image_height_mean=image_height_mean, + image_height_stddev=image_height_stddev, + image_format=image_format, + ) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/dlss.png b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/dlss.png new file mode 100644 index 0000000000000000000000000000000000000000..cdba23dd3771bd3633b3961113b21e3f9b170634 GIT binary patch literal 150094 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ȁ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

tNO8h-~2OHWbmAhbktQUwtYe=k74@e9X4-V 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 F{tvaC)!hI9 literal 0 HcmV?d00001 diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/h200.jpeg b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/source_images/h200.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..eb0633b270acdb5b42885de4a025e3bf652ebb2d GIT binary patch literal 101670 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 z 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 e{R9)1mIO6TR;!jOy&(5?(Jc3H5_iOiD z0TabjAfG(+fBuSDRA+n`*rHGVek0shh8M8mDK2jx8UbhE#I(YDw{Sqwf<|Caz1S$+ z;I)$fJyl$uKt@cv(PkA#PskliSA1oLAqE)*dsXOfVf9;oOFvGjrM7j>C&zScO|a_K zC}Yr~xy3*|Oc;20^nm*g4PkI*vg^}buLSm&S%Aqk#OCtaoH-iP_*5Ji`w4O=YT8mcn{u)A7Cvws6+ zMGy8dI#hEbdzpGer;YCJCFR3aGNNh0c|xM!D{FY2Wy*@SxGF7FPgRwDa-}eI37ri7 zrKT8#KF}loq@Sz8n49G}#=Quh=aF7Lh${fRAM3 zn=_nBwIA6v8$u4F(V>7?(_u=!t_MBV2o{{^?-Xms%-$k4A3K_y3asV@1Qh-`J!lGi zRT_mFIKFzUai~YJpEsF@<^V4J2j0!D-~^9=tUngTI%3v-KDHi~>iQwigD3iolO4l|JyU#h7S@_qvyWH^2710v(Z>hl}?M__Mh9$eHW+9o|Tbo1kHG~NU9 z4eS>fQurO0*T;C2aaeBQEYo;gD!EXYKE)?%B?9^S_c}R9zPfVVjA=~-waTO!=U1l&3G=s7_`1<@v zpoBF(G-)XN?UYDb8N2>Ib$lJndCJwNCccvuqrB2t1`TE=|5Ij{Jj7ZIt#S6t&@=DD z$cTMz@T;&(9WWRruPndYJak>jE%~A*+0frFszyJrK0>XL4RU|`ggcMF%5Dj9)Kr zc{S9^7i<&_;$J>3dm0X!1h#uR3pIu~o$q+|-*}I$iTj6{50dyBZ)DBdPLHeJ-@)uf zw@beF;<}77=x@y_{hUMDG*yfnjsH| zHR{Q=8R}8?nI8c*BhMy(P=a83tt+x^qbgHmkb(b(#o#Y)-oT-URMTrgQy>gb7~*~54{r(lT_uo>K^8SEFy;CFvAob*W+ze2oz9v~s4+c{ zms~6}IFRL&iOfK)dn6N|@!MQ~g|3suirdHLz?BSPG&n!o;*d#SHgdQ#TTF&8IWig# zs`Nhxwl?jr978bATYzy|UT=rr+f?o8sW1z~oUEV=4qxwL!>CV0fiUmZ zl0t}P;Ern)hqvrE+43^`f!NaZAT}Cn<#PYjpRCU@{bOPNc|Wfr>q(8*mI7U9QRAs) zI6tG-ra0Y{frBmW8M}`5sp6TF{|E@mzt0t0XFE@3I~ySW+0%I+q2OLx%+2lGJolo@ zA_jEJqWbiAuxZZyJsx|p*>1F{Ch@7(?*QrI@7@1d-v4j8r@q@q@Q8E$&T?+}0pw~F zxLH#0?p9O{TA;+KtHA{Q)vXc-S^xVVf#VG91*UcUK#R`!G@HLnjIL8)PA}y|b&@Ng zucC0@;gthgDY)dI8#ZDFe*|Cu3dk|LZ%P$hPQu}jkB{ybZXCV>2&*XX6qq!BSdv=rA;PB^JgBB0&vhoF-rEbg;|qe_QK>|75_|UbV%J0;GS2V z7MS8XTPzG4f4*vF7WuEnm1IIJVlQ1Kz>h5TVnu0Ov&Bbtzc6#pR+Fn&Z90HZ^uwzb zKF%phY8Pozb)$ct5WF4^sSw||+w^-hzbIYG?n~Df3N`*aN%V#bAN>h#F|a{o7A_K9 zkU8--00o(gmG*gI7;Qa1tCz06%$CNUU}d}Wb)owHR!2uBA~+|X&JSvC!8zrNE}t}8 zG)J*1wHO+HxS6%LKkipR6bAi4%@_Lm%5;$QPwo#~1MXa#Fb2(l^SWl!q|sV@QRsJu zuRjSW?|K@}5C2Edu!jc3SK$g|`1f0A16{ki-(vd!=|CEh1%#16?&lS~PVBp>yE=BV zjiHvds#AYzS|BJlxRuc#OrOH7Bo*}3_;+-G%L^IXC`1RGqciwB*A)22&CPA|c&(6=QfrvT9QxA)0CFz+Qeo|k z>MaB}jT;;N7P~GJv}1Dtz>1|52?$2H&wAMUIBMX{)DtevM|J<4zF{~N8~d(F82!mE{;laoSeP;l$(L$b_FZ`e zq{>s;5saJ@3gW77Ij7;=k$#Rewj4LeHq6XkGTAxvg;mdip-Wgm1qwYa>;pAS08615 zg?gQ&8bR0_wy;i?UdhPx12wrJ4opdEkbMyto4Vp^>e{$Q)bF?cYx8-R^9)QDgOxIK z6_CThspa%GAPHofmoe?)-e#Ma^u{VPIumZmGoig0uJ|7h><(yVBABM1+#zJViM{#M z<+u`rWj$R*oM!b~b4uQ_H=UC89tOi-PgA(!MI8$*@qm+9Sg~B~&(xae+A^xw%e(ds zp~UoZw+j~pA@pzU+5imR0HN}*3BRBDGkDj;%Zp75Y&Eq8O#%{^N-I<^L2K$|J&BLn z@0hsJm-;Ths*?1*gRA78a5Wuh`#2dN{72xvKm5e}V25$ia^yDnq@qK^h@DQQff-6{ zlJ9~KgxGuCiyP~zB zh$deC;RMZJwpctQn2^l2Sn0QgMISU7S-ngA8f9_kYfoy*H@e`_vDA<2c~9Cc$MQ{= z7SYJt2@%C0Y8R^#aPhL8BLOc*g{$s80;&|XEfiF&HuyY1`_}sxrUVP!-botv>@>5q z4OM)4)}FXsR8Z5DliPGQ|M#I(!#}I6gsl12&TBt>lK3Q^>brlG?(OuPV(dfacTA(4 z$Q1`jq^=r^S8A9c+O-)TFQ@*vYBgtr>y6{m+a<5o8eK2_7<>s@j@$oFx8jPuboo9UtaMXl+ zIKla;eVKei%+&`eeW-*VFHe?P$ji&!EGbe9@BgfF0xjvqn%tlzfrzX@i!1pqjN$O_ zx=AZRFqiLK_T*wyWoBdE0bGxb2^MU>)fK%);bNe1>KL`O0qX9;88DVa)i@*PH7lI0 zS*LF4miIGnTD!f7PU8$4JLjsbNy?60`pZDFu2~#ELufIf9i2_n-rgw(rj$ExB2$cs zet(HABC<;nh&1!fIhKDt{8Eyw4u}b=p~T%KLm6zkMZskAtxZ=@3;RQxLl*&=Hzs;b zsXbTTl3CD>};U z8gWY4k?yywZasPt(Bt@W+hl#@Jm?f8s?&hvr4aN>?nJETmAFBT2g)ef=G z>Y<-FpGFc-$vQr`vHSSuE|=n!EynHVl}j#bed}C`+0oG_uZQw}p?NE^e7&UF(B%P6 z4|X5Zc%c~{HU%eEhRJ!g^sBckgv3ZL>Lq?pwPy)q{e1-wlC!1Keib^4KL2GJR&cEl z5%OHM$jR@@1|&HCu&0kilX1k2rM<)~8@tAr*o1FaLY_@WmKB)o>o>eKHGlSz;={)*m?Nbs?+J10{j>lxRe1}J z=sAji4KVc}CM_tovTnJ*bcs?iRZ`^b>F9Xn*+BV^5$f7E43WzybxKi6a~=1ag3hPQ zxU9ZR#P-Wa2tlr}XN+!K!^5_T?9S*oFT%N3v6FW6>mGszZWamTufT(k8% zgmLLg=CAhTZJa(D5vU}E7Eg~Za=7mI{Pu_k_K|Iq9p1=|(mm)qzPZ@+ECJeE^MkhF zxr2$4MmIWzPcU{Y1YFRZ0-=jNP~wb*zeGQ#j{hWa2>H6Ln;)ih3?0}W5W)-La@dak zVcZ7t7<{~AgH=JH=4O4nI+B|8(POvi4oe-K(|pYd!pX$_3?Kd|!Irj!C`bI9?)B9T zdF>8QS4OUn>%L$0qRSbW9wDZgGVKMJ(5|AWbe^xGFtBVNU!%%WKzyb&eynh|J z(rh)g#BPl_ZD&wuY@iqF-x?YHTrW>C{RdM{-8E}6#k9bL(8AOmt-E?@KPFSW;@!ND z(cTx1zuiLuQnwd(i(uT2@*&kMB5!H=WIwPPZFKlhb=30z+ zm7DGy51-sLiPQV$6l%4v(a)4sNmW#dqUF{r{EOph zt|Y;=*Drq4j0vI`&iOwN(CpVFxVT=0hvGu4POF?Pr;ah+!Eey4JipiL&-y~x@`%JA z3(%^zwpSOJO+9I4Hae-s8L74qr9xUHvi`$nMYOwnJ0z+<(ikJ0 zNLeiOVgGr*KA5|PzG-_lHSol5-Zi-o_8!C%pU#aiGt9Rdo=we!mT_*jRBq7(Q#Sq$ zyto8C_HShZ(fqk=O;P^7ShZ8zhHUSf)S~$OK>h8<@ViCf)A!^~L8`bPI2DB$d6S*58ELi1e+6UP+5R;gSy2+^-99 z(lp3z(dWHFJR>X|zax`&s8on-h%ZP<3qb7#V!^H%$M(qEHXD7pY#XCfM7qTK{g|sfKQy~N`syGijQc9B z*E<{Gr{V`Y3)iO5z{Xoscq{Ku^?HWqevALmX*%umLU}~2{$+|W=Xn2@7TaI2$O%hZ;?B~J zz{>XpQ|Z_MYiTZHe?zH%VJYQ5?@x@X5}YS$%+K=z4Q@eY>Y@OpabsoEl_^;)g zqGE~VV(uO8lZsR*^5A6u_toJ#bE zxKw=iYB*;6ZUiw02aGxGl5`bMZ+@qaf`oz!%9ehSrH0ov2L8=0Xzx0$Ja~8 zO@qcXR*?D|U>rrn>bfgHqDQBe+RZ4%PIB)@YFaYWiQRL0(%#x52*A5lU=H$@?@FIGsBc9k+o_`+!}R3bs0cJ&ADtmyglFQWH+_rJ<75GSp`!M} zilV-vja-j91BZgQyNdJ>&6YgVsdvODJq8jtn_@O+>IeMCN_A*``GZj0_TMq@_a0uo zBPfGZ)CjbpbN??Wxv;_BN1_HI);P7=q)^Pz<>a<_y>(M*&35wxFcyHq*_ex+Hj(g{ zNnFU`PavPj_1SY8o*5VjgT3s{+(BdKh^mEH_jhdkIP5RqALe0HK{zfUw5aVKj2%~T znt^pX)y}^+WDEAF00V&*@~jriKR*1hLP5+!lDFb4ykYEQmE(u3N1EI}A*bORmjiy& zH7@zHEDm*)&l>J)O{R3Qk4Uo*bmyf(hnVd?bV0j5z$1Xc2;9l3<}t9-edU2M7{F!} zJZ4jA#wY`>Ubxfy_(2caD#z-D2GL&O^cT>CLit%UL=JuOv2Hq(NYQdxw~>iw94=D} zLjPS|;_}jEzEChnqmg$YdrP~B(HCJt(hn|l6*&WgG{G}?*_(FwdL*dv^2n{&nT=A6 z1b}Tt;iz2gayohO%-7nDv`J3c0iA-F!IO)LCn<%anE|xeo;9Aa4&?=%?W9n=G`!+a z*Zd9Xr@k?{h;Vj2a#BTT+icPu6EJ7Ct@OPc<<18o{dtU?BxZ-J^7S`me}$quA2m*I zdwpBLeYmumgjqr4r^S|M4h~gT7V02 z*!+I@`Wp3$#$_Hw2&=!@W=8kb$~jTyo+0Kh#rG)Hmh4D63EhIF5sn{D=mlE1yqS-T z4+pOiRcxj~czkNs53(VAY?~9E!>W+r8#|LoRHI3mVt!3fhg2^}i_((P+|C# zkdjQ-<9E{U^;;XpGf@|@??EH7oV(mlJT-)FqPb6Ks&ohQ7r^e6w7{71m@ z7BHs{i~fHj9)gLxoqz}+40=17OMg=fII49P+py5nrZ$DhcDmCyn_TG`TaV4h)$(Va zVVW}RgEs|2rZqBcCi}||0*#Y=O2DZyt=&NZqyy4~Ar}7;6x8(R;kX>}5l{%Jxa~XKkcwO4OYbZcC99rZ*-Du$_u1zCEL%|mZ!}CAYoVPCFG#2XMfO$nRo|Q zD{s*42v|N4`QR!_@C-P|Yth5i=Sf;Kw{-gtdaMa%E-VI-)9uO|YR8UwZ!xng+X>qX z&R5KmJa48@wlr1P#-NywMex`!LO1GUduKUf@~4g@F8=8LRzoIb~Ml#(oif zo~G~feLOJX?^cJeN3b=V90|QI!`ox1y+S4x>L8<+zHIdERK=F^wd++f0cKSKA%Zyh zH8kBVc}oPTht?*O8`57sfRV=P!_>is*x#FYjO0Kb6Hc=KmIX5rdm+53@c0mYZ-13z z+Re7PF>}?1voSZIFB0X%ZB@tFqa5qBu;sa)2Urm(Ll#WffHAToTeJ!GmS?)LKfDLY z3-yVv;;QXvo%z`WEEO0s50>ax1qxTW*bvGPE7Z?@pbLeyxCtlYH z5OrM9TPD}yw~3rBXDa!2+&8G%uJww%`vNcl+WKN#o6aokB9mH^uPEJVcUiBV_(yVx zi|3!0WZJ;476`+=3mjZ-06zQkO)ZS??OjShgaI4(JUuG+FU+_rCna=+7-`uN_GTn_71FRacrl& zI`lB9*7kHAGG{Df(PgWbxx_!(sk1r5l;=DVGripNybN!OzPQRb@A#DOIyX^Pi|Y=| zA{?Wz1&EA$f=b4vFvbMC{kuK*eGP72|8ylX11sRbr=`%F%tpRHjYExEoIhQsmB zTjfrf#J*_AqXg||P*t@@y!D%dJD(kY<9b76oYAcR^{Lk?-%X)Bf5@x?C5)yJuXf7S z=YGf-6@_Pq6aqlgMKLJrni;@PYgF0vRilLjePOrta>s;1Kg-21(y>{XaF2@Mt!dj| z$kE;vE9UpAEGdlcz2+`gl1o5Jzp&CO9iE-7o){CY1|kHP_{VK6@~F&<89(<-_8KJc zn$j<-Jn=$EHGS_9$rEuc?Waa_=S_n!y70tXT3kI@m{<~ta|%S~QQN6Pj9}-In~ziL z>h~IKQEhAw_F#B*!>{iL27CMW#i_NQG`RNpIjZVvsVKH#J&m4};iw)tz zIc`)X#fkxX_NE3_k=Y}$HWqVwj^;4griO5n*kcPluZQ4`AUA317}eFlH9X*J+dRQj zmSP*A@U6jE*rQXvjlMwILJtgR^Ff4={wkaj=N|X+w?b^Sg%D>dUJOz}# zv&@_0lJUHIK$FPWvqP10`MHu_MNB11UHQ48D`jJrd)(aMU!blLexBKzW2^ALMr?s%n&!^vp#nB z@~zc7WR#$*g~lFkkN!D(!*JIk@40(<<*t;X3&Xn<(tli#q5kdD0gcM?xs(X=L=Ey4gG+t4EaNHd z(~gGvgNZ+QH!4gQsr}ztdRQh!pN)INj~~0)_9_}-dQo-xPNlp^%X8?kzUQv?JV*=P zZL6?eDB0C?nb66Clj!mC*boX|!=$b#drvh~1Z{$7)QgqBKl~bV|FO*A+q#eMppDgz zZAz9(RiHTPJi{v1th! zL9J({8V_h22>#z6To;6KDeWnq4UvF(;gEh#Qwa}#V&(7uwl_t#9++Qj%!-R7h3UO- z0Dv~e^i1|fw&CG7h5b-0^CD6m08euW|4ObKUhW|M!BFx4cN(9@#R>oT)aSE8NepZ2*XYKQH1_8y^2lIgr zUr7bZoynD5$7m5?=EMpn{^Uw~6_H9itEuPe^6kkkFy&x)P3l8H*B_z3cXV1;q?c8v zsdB_Xoi6*yqj~voXQt2=h`_!;V972F@(kVKkSI?{#%&=^iMw zlMQcq)7bvzKY~XlgE@9EVH`!ix=*zxW=W$X9d)hS)F`6iCTCP89L*1AqAyZ2}s8Zzcoy8}XN?i$8+=t##i8U&LsQX?_o(ACwt2CIdMZB{@$+~tk%?@7k!loqr+6U z`XtAAbxfF~XF8UTW(!uZ7H8<4a%uJ`&knB99jWu#3W@CEM_kuMbDxv^2j+puFkv8K zO9aM?qgekmg=b&4V(QI!1SWnvhZPQP-L++-TsaUaT(-8Az9j5K z<*(|G?TfWqPuMb$i8S%NMwsm42TyZ45FH0*iX@BXMX50YyDCqXtTzfotQ>)h%(2iYHh7>9ttfal2XB=yg>-(=*<&&~h4$a^$dS>;J`;oMgWqDwsTU6bI_Yjr~^j(ZJUet z(g^ZwBhpW2?+_bys@&B_hT_|*iEAix$Klbq3?w9A8qkJAZopniru+dz^V4s z>xeB0g$%q^{3*)!d320k`X`e{+4a%4&v&y;a_~?zpFQW?yr1`CF6L@xE@tKz&+~oOd~ZZ72do4&aAIHF8cE6 zf?_-bjjg^?pkVbVQC5Znl0vOzFE5ohH?4O%Vzi>6`_opbNi+iz(YquBli$CtehyV7 zrD{bXNd9>en!&<~&sw4P`^wz(xL3+N?WyhhZEkM%vZ*8ZkvHcNvigEtyV{!qA^36T zw6RFwAGa4aF9Bj)bzaik!9h|7@$KeWwWnDehhGC=Ur+>kOM0TQb-NT*jc+GbIcEuzW;E{A4Uh*}int zB{@T8)CjX)n62J}Tx$jjXL;S*nGA=NIzG7Z8;g&@x98V2McQ)AQLSdS^yY1b^GFND zGuK&wmaP(2AdI-Q9(`^>=ZQTw14{xdC01@Db%AmUSw%je!_)CG&=InbKHGHAQZjMM zlI;_n9tX0of*wAuu`XKOYWG3MZn-HPKI77ZrJPA-6qOCe;_{Wh$5%}nh>1Fg5K&i@ z>co?6mzEbTt*e7=()OEPzcrSpo&kCgMd5f?C#5;{@yNKVOf_D-TEYj+Nppe_Au7$u zs!`URExLcP0)L;%gJazJd`B$Ic?6 z$2>RHn(^&jp||Goj{Rg<(N`9(le$0CS;Ps*$i6p{K*oUIdp{cLv>zBilA((Y`qSX! zv+AdpbmTuJ0qjuHnQOrQ`foU`M7a@ZR5R~6NH~>~WlxXmn+Ovb7b7lf4if)G!1_)V zY$1}z(BykJa7KYCtrvy+&Dn71n>L()%me?!WJ5cHP(CaK(*NY*Vh*5Pb-yh2JISwar zxWArqHpZR<*E62M6FLN*XJB(B$ERwqjZx%s77@O!+AV%5k@HQmlrsyP^Nl>`mac>w z;G1=vF&26F8#5&|tKmkl_V)QU2$1Qg-^Uplr%9aFW$;!J2Qa`__{N)TbR9ckoub1k zfWF;6-$mSy)`yTuAg*f9oxF{L*!VZ!NERM;W1z4)UYawjJ27co6xYbuFVQfUrAvN( zfR$4hCK1Dn{@32lFh7Rf*>p_1*-yyzzNOgs`nbFO{OGx>kzI*D*GN(}OnrFLl?KWth z`)9kn)0UQvEf8QqD{{usC)_^-9}ivE{qb?Ulal{E*;T-8)N`!ln{8ayfnd(rKx_}5 zW*3_I$Aq^Vr3vLz!Z^t)Q0|G&{VZQ5`F(P($>dXIg;A%)BP~ZiCZN%lZ2w5T$~oSi z(&x<)RU$lc5ABCG!R0UD3 zT}LLa+l0p~rfxrkMCPX zy9datYF#Biqke}uMMAt%zoPEat+8mqJ%4+0SDU{mJ&91;TH>zqd_?S-^H^bLj*Dvl zeSN^hUY^{vVfwOC18l!kxPE_N!{V}UR(+!HD*y9SLu0J5X2R|4>}N~TNV=%f}V`K`NHKOQTlr-S}O$s1-b17}ojSS{Nwt`9P?GfDD{jD1VB4hQUR zrsUFQ?m&F<@t@1QG$O^jGAisTQ*RB#!E zATCtV?YO=X`S&2rS-+L)>S(#9AnEx!q!+m^+ia(M`>9D9?fECKc|T>|HD__cy8){d zx%omHf?tm5@yl_&fa*DhZ|DbcAJ&6s5m%4if#S~vFMRe$ooHXe4{`4zSo?O~Kgy<* zl8s)!qU95!Jus{NkCq7N_R*0Xjih~o1ooV|0i9H`&a(CqB1Qb%_mE_kAXGnC}?-AW&gGW zDsH{09&j>$?=>$u$}PEuee{9}8QPdE>NFY&0In9DHbLH`Dw{T(H5lps|pGK}4K&9dctGPw5C##7eI!jF^% z(xQNMxWr&>pm0yQ^kt6Fi^;yTINNa=dK*L;6l`!=v2{GXQ0!E*<2h?!mSbLWw^(=3 zsHNbM`9FCbG#mYNQD%Y&2{?N125~UWNk%XgeE4gJe5r3y-@{(YE4Q*Q4{Bp3(7z;M zN?e~tEL)q?jHq(_n47a`*=aP+UA}d1{xRc%PgdiV?>J=0!{bgX7O(s!p*DkaWp^{6 zDT(yZbptMo#&o&cfqTva4lblvO8UC}$FP)mu+Wq-wp{yuxjJEXOmt4PyO#4A>&kT5 za)D!SN6>&IepIC~%ivfZwS3s1h4GiPV2$u*(h|&)#u_cO4f`}*#KXzeP}6hZw&grt9;$@COZcA&#n zc38u1xcQs`u)Bc-bcD;`0$<0%S*9VCPx zLL?Emh18N8f2#M&H+}m;RjsP7D~IX%u6}h1heu>Z11F%%f#kv9%;$6*xk^%>g@83a z&2)gaCsemWqI{I74UAq2dCIpqqw4n1B6S0$Gm~kPjtOLOtivBoBEw+IrAl#qD!#FV z<)R`K>bg|7iCR8tio8%G-eBWfpMNhu#{$i^3f{e+b3bRjHT*Ob*yu1bVWpmqPry=y zcC?clTssjGDwIYcX2hBO5Tez#_(Yg-D*}(NE?)}7>Vnsv-qS73$#2!-`63J7L35l_ zh;g`MT8=s_B<{j1N{~1XTjbUy{ALRGh>QBX!H4hn6AyCB5dG3`4kC8+)0gnpXA0HQ6Uur zXKatl6=XhSE>f53%liotUZY{VXavbvY0NPWh{)Ew!)y~f#~b>gK`t7xhy>EPy9WmB z;C0%7w?pvdf3IpBtU5Y4&yKavmtjkm)CBfTV9Mo#ywTp+;C(Pj_=9wxY0NhdK z21;@Hy&;f$-3A35zP1W!EQ}pL918=75!@lpO>?N7QB%QiFu`=OdHuQ>u;N%w)5d|S+_gK%cjW}Rj}Op&C@J%=dM9rhYop(YNbQ^_PU;H?H!9I#*%xhrlPuxMlin# zGPB@`1O8s(TM!nvG^xG}AR}4UVJ|EhPirY*yGIP2u}f)I#(FAloe2@(2$$(KBS{$U zB2He%6heoomso5|S@mB|a>wdF?0+j;ov1pxBx?(0ao{rI6XXYpL4OB5|RoN#^Q%)^bkji(6wd)W2#F*Y{FzIxnMGwf?=w^O$x9THeW^dgMN zKN-xIhT|26)bwQ#7#SU>jP9wHsxt8#_tmI;(31o64UIh-xDEf6LB{QT^EazSAFf7K zZmaK@z2PP_-U+htL%TKb76DR08+}~IlGL1G9jDdQNtKoOSsp#lM{waBx;RfqDyd*r zTstR1=nKg}DPtzeh#SLFFGziXK0-3I=gH+ZNPM0o*kD2~r$l`N=kq;JJL@?4Njs49 zJb15HtdvJgm=|N!21lRgiyo!fJey+j?m=^-XKoS9v=YU5=H z&mip~M7}C6Zx9~(4Xy1i?;NiX&O4fhrGz1Ny-xXgA;e4n?Mx<|c zg*0H5^&p)#0+5&wa3a;s2~~xen@*O?^Q)J4Av90CvjryelB^ED%@s%{7NX$zTZMDZ zl+*vXhoie_jusc{dYo(3hQHz12<41(muo2&m+RevA>(JCi`b@<+rH>oOpQS|GR*x< z;R(1qaB2x-VfO+aQV7TTaIywe^NVIB()+_$oNJw)u^7OFnE4f-YVb3d`hU;z|L-|T zW#M64!H=6D3At&f{W&49(Iqyn^w^q@s)v{BwrG(_;#ZH!3--VxV_~P?yb?vfSUM<%rmEHFXaU%Lv z^lhW&qh29yo}0(&?@e8rt#d*`WjZzx3?jXGI-4#}M0UE-4~`BuXO=s9vt=E#_*vw_ zJ)l)wX>XHNC6e)m;t9g4itwE`%=#`?h%Dqq8+r*Hj6PkOIp*`uF-1T%!=2BqIygz% zB7vZ3=!h|93*RdF{8Z{RY04b(W$vV;d-c-Sjn=_Sj6S7-*-D0l*y`zbz^+aSCgfGjX_CBOHRoq`dNK- zm|};ja;{oPhe*$b>i6Ny9b2!gbhd}mc~|6&g=M+L7HwVlud@Z9$VLF9`mBy_V1@04 zCh+~A=}+{>l=H=8L>)F}14!3@=oP(6tUn;Xx9XF@XKG0cG_D>bTshJP zx+B>d$x6DI)PDMWg1$+!Vz$iKH=oR2ibqK#9Kz zeiAbLz*tL4aq@M^7!$kK*a2Ugp~8fkBfaDu zW0y;L!m;wO2btMBd2MH=6<3ZH*8P{(ig$Cn$l2B(JkFn zsLnmr@D7J6CEK;g$Sue*|0cULedqx@-Hg^k%~26lDzL8TWOI-b#Vj|@m+7!3@@+qQ zZ)&I98SV@hQSdFqD6WI&Lt8vBPGo_MLCri#dj`Tli%4cD3?Ry8Ce2 zBQl&v;cvJesF2{JU0YWA2ZrWvdA&=h!O<_%{%uFu#t!iD_o~nGe+PDqZyrGa+om zqC|_4Z!{J=&(`LiIDSu6AHhC2s;XLP-ljfLdD~v!bk-PYY)VxRxdRqw2pk1KCHz*r z6H>ara3EH{7CLmv5n*wBll{8OQX;(fj!HZa+rK#QKa4!}9A7kLVRyb+Y zGi3We6k22$j!eYjlwr~A9xDug)2IKNtInYK?iX%GOHV|x8EZk&dRiiQDYlpahmQdd zg-ZD`V<+;5q}|TcHyPaPZc<_h+~q6)9x@~Y@k!XxPqUsuM)I*7Xd}#F#{J&qOpV@c z03Sy-S?KVjLGG71FD5ebF>P0Mwa3JahSIy{ zemu{Gm-*7QwBLvf)AY0M25y3vUPneuCbWuG)u-%=*Z0${h*vuET(`uXA zvium2sV9YM*{w&xSt3SQ+M_PPZ`|%LnMKS=?u7iVMS%D2z80Wc&DG~bld+l%GQ5cQ zA8eN|#Q^O6!7yqRW#&k2l$zJjm#e{Grk1W`+uWG+RJqLe$n@Y=u;ZTs&^AmM7@VTQ8_JQAZC68g7lHBtS!y}43r zn~%ie*LUg}{l*j>*A#!&fWe#VdOIfvNgmp;Zoyn_aC33C&Pp>=TweA5)XzYVoj7?@ z{$Hy=%E`|WTtn0~Gc*U_`gR`4bo*Z*<8e)0nnNq;E4mq?m6NwDd$CL*z7U%%E;|FB(3pmLSKZ&<`4;XX|kwP9h> zwOvX=;Y6BRtN+>XRs7}FS?c?|O^oVx0&)Iy`#j6Uq3%w-5AD&T=_ajqiM#oX_usXF z?~_5o5%kFulzZP+3*E)g!s9gA=y!Zrns*_jJJYO=7tJBFPZu#E9nL3dO|=0KNaIHL z6JEVv#D-`1)1G%0z`c%&q?wb0 zUV|_9X>wwS8BmAjmxY&{xxTAFAo9k}@CxGnii%T-SWa+-IOdOHh5M2%E<{g2t)EyE zR5Z>`3S%T*P8=S!q%i4PoB7P2JlLhF)hpMorg#Qgu3XWxYdQ<=vYs!g6}t9c^PYX$ zC^Nn}^pXX9qmu-RV3P`_I1DU(IiisD4Qi*? zne*Y63`ztPa>Zg>qAg>*Q|S}t3XT#^&$Ojq)N|CS7uAGp&x+K_dCLDo$$FXyl!Ng! z!>$G?%f3Hpm907$O=h^mHL!~?Y_x$9`wO2e9gT}$D>G9V$Wg`Xx-w%~S30B7b`5;~ z4~6UMY(?mC`qAWxaynSZ*YgX(%F5BY$Uktl6rFa|E3&$uptpHJ!XbXBug57&s6b|n z0>t?yA>oumDdqN*Y3oT9#z3188v$82w4Tb!qIa$WSFhiX$ugvUL(hIX5Ew7}S~cVB ziHz*=UQAnA%g8mJuVm?%gzAwPZlv|80%b2Kf-4(NYJZiG_VpIib3AW~0;mZEX5rW5 z!!^$}_2~@*@X(~8p0DDY@1?|Z?PjGKjwT85WSK+{O5H9t;6nIJ+#`=gwdnB}4?eU9 z(vSm|g}?u9*JQTwAYeC|wYb$kr)<5df!#cBY2I#*8H83*G0}#$QT>NPVSK{HW7`y_ zxDXYd{iyasiDWA_`EB`UtQC{Eeu<|#@ub9o^r@*tGc{}r>T4MucRaq0Qn8&f+1!P^ z&TC}mYEr#2Ia9n}&kgE^*deZYuP@;C0gF7pECer z1p_>;c529R4x;~xyitjX@MAy&2;8twjn!X;4Zr$&2Y{>hpG5Lyu;vhFj-H{Qz*nMdD!;fPIjMFPuZWW5{jvjtnEJ(dg%m&3mjc*M)3ZWzLi!d0v8on2V!Ktrz=?NML{y*mAdQ9 z2Thf>%}*~X9TtpUehW?EfsVGPxqc-Y9?lpy#uS;an3zYT&j_}MN{R3FjXSy#*9mA4 z=z?TY<7l5&Bq~P6fXo`KfKV*>9Q8Bb!%H`^+mr5M>ED`%^Q2Xd3r!?MbzpCEvp^-{ zj5U)bOjU-~!NzKx5sS(l4cY4w4o)=41RUqHVmsSxB+pLj6AL3mSzYj8oY#)BE~P6z zMh%d0#j^ca=}+UUl2oRj4CR6On=PsubGAFU9MKhe&7@(v2EI2`Z!dX@G%NF!Zg&t2TgWjR zYcIFPa2Me}!2+z81v+%WtcxdwtvD%t@(#yh<1m&oudPJ~>*T9XbMF`9ESF#U=Rw(1 z;JCTzf>HC>?B#mI-Btn_2*{=7NYH?U<1;<_(G&DJe;0RGtKW_7!@N z+A@#M-s>#Qfa~;%oR#@r^k{P)C#~$}87omlAB$Dzyk8x#xUy^wwN4j_8dJs^QSdZy zBqEt2A6$%9t7gJ1u7dhAa|;{n`yUm~bIDbTH&!h7J67|gWv4%u1vmzrhwv>5BR`yE zq#2syE*_48O?SdqOiF@8eP`5v#;H)IN2#Mn4tq#G%kJuuJRl)CZwL`GKaCJYz~{mj z>gDS0&|sd14M(U~hkc^2%P`k3Q_B&6tW`Ajy{ehq&(9Q?mS#HU*(6txa+PqPPC7i` z?(`)@;BT&p_2TMdO`KH02$Fs=lK3l{?f*r0{j_3|jJ>-46^-&AiiQk=)f{PPV2SMQ zUShp<`GcU?tPR(<8`!{-WcrP`GH;)54A>w?_$X z8tj&R-@ZNdMD%!DfzXmpbA&(kn$fh*5e`_45i3Qs-Nxi4ACoZb@|Y-Cr=Q%p(V_ef|LoS)rS z>(;rBP2kvUne`Gi79G&((=F1{8#~C<9oRclYgW-L)c4ZEobk@~v{CCUVW}w?F-qve zk2J@Zq$-v#F;c^#k$GmYUS%O1r4+3HWFirjt+{StD;fGT{B<{a_m{gMyW2?EznMDj zvPi*BHuaIQnSP=D5)V)2bU}JHdu~hpB*VDgw7TBfMRV+@k1cSr(Aw$?A(u1J!mea& z7Jf`a1!~TA>uhWKi(56aWb(J5}8vY6%NpTy$n-WSDD|I=@*LqZdd6hs0w_0< z1ADj1Gb-(b8hy|D<>Ri=nxHzS1c2F?DHLBF^QcuxD=fa<_I_R*k;Z{F9j))7!d+%P z13asqUypyD>SME6IxSa;XDQ&!Xxpp>oX+mYmCi)%%6XDsO}^Xeld9A zT?~{jT05rPM}CcXApz~}F_lU!T`hiVyX`GL&ChW3zh2CXxr?VA(j3ScwaTm!Q;Bi| zcd_L;kO>lt{f$z*t=;+CbWBww))*O2?(2|zy5*CBwvEU@>+D>`ZOdr-oYRu*V+k)7 zgFFI7TLsR?`A>;o^Qy#ytBz#Iv{mhSxSf9jtuz|%8$iXk84g49`OHcNmOwm=#9L^` z&RAJ&Wc}X0iqTog?&lCp0y@*x-L7)Rn>LrUTS}?$rc20dEnEuav5*PPo?5N;6P1oF+op&D9X@g3M z@Q%x#Om9dGE%$ApfrAhiQ8G)Y{A@uHz^1d3X(WW78T>XmEm5sqK-w} zof$kDjP6u>^oL+`^Ik7=cVL$wW{1~V8z8Xi;qV&4IJxnDGAMe)Y39A#Fu+GVzw@%g zfUJ&B)d!y(AabC}Q)~PKrhV7>*P2g{)%FZaTic;~&jD0=db{GX>@mqS_UDNtyCV5} z$&c}}u>M*9;Fk_7b-rl-E)cM}WTxFLB&L8EIgHw%^mXi8+#u;-wqX77LfKm`lwR#t z8dU?qM>Cm3TS$KaKnS@0IW#Lz(dJcG2z{@Y<*uGjv4lFV6G_Q{w0(QJ{+vb#L z`PmISh`Wxt!2JT%g>w&}pJ6mp2F(t>PZ!$_J+uA}nC{S)7o2Xv*u{?taV%KQr;>Wr zZLbmDMHj$}YIcmS0pEyp;$D^CobU%h$swYjpEPjkny@qCOT-9T)^yW@)Hrc=0$VlLxP}8-(J-=Std)jCM3>Gsoc80Y3$cP>O zioU;;`(Kd?C|(pdwMQ{8m{YZZk_f3sfMY%_65NTDG^zqV zi%62T87+@RCF>stWSC&Sl9@c{t9^BBPAm;TP;IRzhu`2+ASyq?&RS8W&2RIbNM|O8 z>t#{L1yXtpMt?qQXt;zfEvET42`eCMdSW9t&eRHo*$=yOd12)GgsS~QtkupZH5Q~lD zWsze8Y3IbTa^uI1P9mihcvMufDmq?(JGZnMZbRCBKmp!IUVQOZdn=7L&Tp!#)lp(PfA96E zbnIhvTbkac(33J+FMOvdG*In@TO1{5AW*4N`q75hdC4o@ewmya{Ar6LzS>SOEql=v zY%?~~o#FD&liP;}ObN+{3fVn)%5ZhtZ_p`~FPT86(AwXXq{UTnQ?$0Xv}BAZrwPBk zKj7j(e6h5p?cfr}T+Y7@QBWw~iwwW$kH0H;NpJ`I%G8|5jg3{*$lVZzR` zKmIXkUOxC&ICs606H{6JXXywk+&D8#RH(n%VZ6;rW4t3=P_qV=57{ygACvT}Rh%72 z_gjzr4j0f%Z_$GBxAa)*I)({yz-IOveodE*|2LBGB zkw3anf`_ub2HT(oB!OP=rXLSJ1LkoH?83Zz|GG#6Mdnz;`kOBrk)0i(Lq__j{W+Ys z==-f}>KFak5|siWqd#%8xmKf;x{G#3jYTTF#W~)M6r)jM9OVG5*sAc9WV1vW(%_|!sS(<7zB3E!y7@SMV$N=M%D|aT+8*}a!C;m;8DGr^Vf4G!ww*J&>CbtuXA@wofV_@hjr<9)I ztV8jkK)xbH8=piYB{#ujweGuy+C0J7vh=iKKTLp4UVT4(N=msS9(B73&&e*C5K-YJ4U{CfK{;JyLSl+Qpg{k=ESaG-U}xQlVL%aW~T53917XXA7b;5_6G$SY}YkwIZkbHZ`V!%9KF02yv{|{iJ_X-?ybR z>F!P<7ks9BVQV>tg4S%N{-FxQ%hHw2Howa;Nr1O!)yx2-roh`jp#RqVEos7O$sQ(M z=0Y7^FTs$s=w`br&Zg&w$u{$PP^FOmoXMW?x(T{*ZbZ?vR~DRyqzk+`v=jvwE8v{J zuG6)lV2cOUW9olKK#6zX;o%FdA~rNN@DDjEjL)F4W^&0(mx~c^;wMf?Fae$tf7DSp zXYCh+AHc;K+I-63n6cvD`d5-a3G`G$$#z*SBWyMWP!xmx;N3qao3{QoEDTexhLzpg z%ynqlGQiP7UP+T32GcE+xR2x36yPCsWJdM&T~mJTXSkK14Os?A(&@oW(K2=PU-a*Y z0IgZ1xC@R~R5iSBZoX<_qOecuR)1rrkXr6*Xj7~$a0ehy;Tbs)QGv1kc=CGWdFc=4 zfvr+5x!@m}zcU^ni`kYCZ7iE<HPhbA3LG?Yef>9RLopU`IJF_B!|{F_tV_--9=+lC(xrg zCp00rvF1Xg$_8lPqP9dCM!3dN@p$^gDGK04#U3(tNw27g)vX~G;j|)7A>aC3FDlp5w6rUs zONu&W-^c&vU_u`!%ab*-rjQ|7XrOXmv6#dYM;Wi4l(%jf?b9;9pi@OM4^1&N^RE5@ zOYr{8_p-R7$zdfpp*z0{UgwpYqi^88qVUX}aWX%>=#6dNs=PQ^ApK$KZC_k7IYvn) zI;wXdlBL10FYOy7e0$guMQ~SKves*o+5fI59fgNq@hUX-w{O*b|SO@0i=|oe}49ab8~byx-AYw^b!tPVroNQ~TmCpj1fZO6!r4&>WFbL8DmQckNdl z_?a0VUnT#b41A(ukDJuf1(R5cij3Lf|40dt{H&O@%7jvA(yGP8|Mfo6=Uima9wZhRWBH}1(g4Ftxf-q zSJcKEO09Bip@so*L!~wGCDX=T6r%+?`Tus=Nt^NSy@lC4u@_J~Lu^-%a+K({+HT`6@1rno?epT;tq{!?S(?N$3g2YIZZ$9Jm{LDv|+l z*bK&jl?)0Hd7GUM6;o#%$;GWZVFh_$YOmyqCbCInre@GLEy!Y$%M;f%en>J? z52DnG4s)pacvd!mEKKspNBt>38z%=&hmfQ+D^z!q%wBh{G!-QH-+udz@X%?U6%zeu z@UFej6w|k4Nts`sMR=6TRWITdy66E(OekME`KDDfoq1VFfccQS48+NqBn>$c#|)}R z*|VLZy7Uf3m{>I3pjdWD*$`Se+}FjGl`3gto(tq}%yi+o?WM$BI0mx|)IlG>WxHyL z;j+C_{JuL6JLm~6{;uApO9$j(yR-1hHogx(z+B4=w&jG~&ajx|Zb*vZt z_0#?JUAa|O;#c5NzCgjY=p?^bujCE!t2z${sd`K+7yh|Kz6E7eQYqrS#OtFZ{j-Y? zhJEib!T>eWwwni13C$*WU(|RWL{WHKN10upe&eAyRldfjc6Xaat>IQJQE;#JDjg>l zX!2S92wL;GD zb6VvlY%IK7shq;y>w7!NYYt<-5~>ezz4o-I5ceX|IBi*OOv|gOd2qPzy!+jlx3;KS zy-;bMG><@up^faHsfkGX#jNQ8<1SZTNMSgc+t7!Aiof8%)%^aDVk(R<_ac3u-1s@G|(I zq4>#>BP$?Y^m-w^4dKQWRrNFO>XL1!L+!XyiO7YDsdk&W;KMy@(d?yk-RzT4G4erF zkzC9kuBc;1x&T+$`+vVB(`q9g|G3ef_Ud3fpD{(z|1`;C^20L|%wXVl)=LfR^7dq;3-(g( zAXW9Ke&t9!k}~{Nx6X!9Gx~HT(HTnS6|nI{(!MG3t%J8TkOwjS(_G%A@=596HQ1ZV zx-K0gF%_RhP!@ zv4nyck~AthoT_AGoYRh9$Pk^ihtwO?RBJA{UIn+_-A@ zH-2x3v$fUv4Pqd(%gbd8&&?99pa>D(-xZs1A$yhMW1>_m!Mrok!4kluOj^PQ{@3ot zi|@Q0e1%7p!oTEK!z||NSrxE7nB9MznQuh?d&a-a z-oVj^>M+0AEy&wNz8;abi5*Au!IaQCF(;3JbPb@14^8Htp#4|UFIci-hqh~%WR4lq zSGFh=>DVcuSP=DEdH?OFKZo9b#w9os74c-pZii15^LC~%o;2DT8Wuwu(wv!p&}fBX zf*ITe?u@hEif-ceqzrN1Z!XcfiWodS%IrGBg zvE~#e$yP3Fjw|~1Gg*0Fp5|uvKs4LeN3pD1RP8r#7I?{7ZEWym^1(j+v9drTY^zOd zs!`8QMTkDNyb^H0k^;1^UG`gj@eZ*|SWYpSznP$&S=30s8x)_|Yow&ju$Eh!2;oR= zj>MS`Ps`ROyCgiWdc(5A^!MMSX6nQ&5C`~@s!Bk~L8ma4M$GZ$j8s%5@vtW=% z>q)f97mCAwm9GFjxZ%8&f6lE5CJZ!+*>)Mom@WJFL|3695?Nhk>##HD@~=@pyGKEj z6ytq8KwV!kNt1s|S*CP@@`mnPH))4NkjpkVYrp@g&I%CVDEU691{2Mxs@$E}OqP$r z1n_k$wMiw(j5(O0@O$Mb7x`&_5<^n0IE4W3~j$Tzn!Fl$S{DWeh%w{>opH`VM1Kz>ItA zq`jRE3sE%KJh-{g1z1(gb|N#tbJ6LAa$lO5sFQy^7eZoh$mw|sZ4y+v0>K>S#}_LF zn;KsOUp*?S`5|kp7ZRQVmu=1!CVpT!y1=K(t#NtUX%D~OqnMA9@r7sArU$Liy0R&n!9jh?E4>_6PL$>?~E z2k&6YkY2^^&3rn7ZcCY{wiy!#kBvayxto=C7yJ`fzYMx116)9|!q$XWlXG*6VHge1 zt^Dqng*pKRR_M?6P~Hjgmy8hSgcZ#vCq(OZErw$f(tM`1DMKK%A6M-_hO9SQ8wg*{ z4L1&wIZr@E@%zv-kZog|LOQFyf(1eeUbd-T4jWTgysiwZLE!6krgO6zeAxK%hc}CU z7BV9Drn!?KuWpK}6S9$R9aof;y)HvV_q|L^gCI0E571gMYxGFEe!Or;x1Q~A`+iU^ zmlLSHf9q54(yiOkZvK=|@Ee()i|6|P7pDJa8mm=J=T4E_qM-|&d5H0Nkc`@_6Zc+w zXLnb78a zknYzPT1L3cj~nbDPK9aDcAwVHIo_IFfM!1>d(Uo1eb>^@a5INIMkR~K`$ziVLAr+c z@aVYjl*jge*gC7A zIJ$6aLx2E*K!D&HJh;1r1RLDleSpC=SRlAFSn%NPFoR2QcN^S;GeB_o&Yb`9uXE8i zUDaLHyLxx;{l05GYq3-TUmd8ztHPmtXJA_Xcdkc4%UCwM*r(eT^!s!zcgd?O`sC(* zsm~Otdn?8iAt<)nthYl{rUQ8qLy&}!FB&xG=G2;Oj_h27E7k(Xd<)m~5NoOP!6zdJ zEWdEDgJD+X6Klr1{ww1mrR^Z?AjHM6p`H!imn2WqP`O?7zSh=p~ zzd#pF`%fjfM7rm9Cx$16$y5q%pxPQ=y=oPpa{<-vRIT

Alternatively, to install from source: -## From Source - -GenAI-Perf depends on Perf Analyzer. Here is how to install Perf Analyzer: +Since GenAI-Perf depends on Perf Analyzer, +you'll need to install the Perf Analyzer binary: ### Install Perf Analyzer (Ubuntu, Python 3.8+) -Note: you must already have CUDA 12 installed. +**NOTE**: you must already have CUDA 12 installed +(checkout the [CUDA installation guide](https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html)). ```bash pip install tritonclient @@ -85,83 +100,70 @@ pip install tritonclient apt update && apt install -y --no-install-recommends libb64-0d libcurl4 ``` -Alternatively, you can install Perf Analyzer -[from source](../docs/install.md#build-from-source). +You can also build Perf Analyzer [from source](../docs/install.md#build-from-source) as well. ### Install GenAI-Perf from source ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +git clone https://github.com/triton-inference-server/client.git && cd client -pip install "git+https://github.com/triton-inference-server/client.git@r${RELEASE}#subdirectory=src/c++/perf_analyzer/genai-perf" +pip install -e . ```
-
- -Run GenAI-Perf: - -```bash -genai-perf --help -``` - -# Quick Start - -## Measuring Throughput and Latency of GPT2 using Triton + TensorRT-LLM - -### Running GPT2 on Triton Inference Server using TensorRT-LLM - -
-See instructions -1. Run Triton Inference Server with TensorRT-LLM backend container: +
-```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" + -docker run -it --net=host --rm --gpus=all --shm-size=2g --ulimit memlock=-1 --ulimit stack=67108864 nvcr.io/nvidia/tritonserver:${RELEASE}-trtllm-python-py3 -``` +## Quick Start -2. Install Triton CLI (~5 min): +In this quick start, we will use GenAI-Perf to run performance benchmarking on +the GPT-2 model running on Triton Inference Server with a TensorRT-LLM engine. -```bash -pip install \ - --extra-index-url https://pypi.nvidia.com \ - -U \ - psutil \ - "pynvml>=11.5.0" \ - torch==2.1.2 \ - tensorrt_llm==0.8.0 \ - "git+https://github.com/triton-inference-server/triton_cli@0.0.6" -``` +### Serve GPT-2 TensorRT-LLM model using Triton CLI -3. Download model: +You can follow the [quickstart guide](https://github.com/triton-inference-server/triton_cli?tab=readme-ov-file#serving-a-trt-llm-model) +on Triton CLI github repo to run GPT-2 model locally. +The full instructions are copied below for convenience: ```bash +# This container comes with all of the dependencies for building TRT-LLM engines +# and serving the engine with Triton Inference Server. +docker run -ti \ + --gpus all \ + --network=host \ + --shm-size=1g --ulimit memlock=-1 \ + -v /tmp:/tmp \ + -v ${HOME}/models:/root/models \ + -v ${HOME}/.cache/huggingface:/root/.cache/huggingface \ + nvcr.io/nvidia/tritonserver:24.05-trtllm-python-py3 + +# Install the Triton CLI +pip install git+https://github.com/triton-inference-server/triton_cli.git@0.0.8 + +# Build TRT LLM engine and generate a Triton model repository pointing at it +triton remove -m all triton import -m gpt2 --backend tensorrtllm -``` -4. Run server: - -```bash +# Start Triton pointing at the default model repository triton start ``` -
- ### Running GenAI-Perf -1. Run Triton Inference Server SDK container: +Now we can run GenAI-Perf from Triton Inference Server SDK container: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" docker run -it --net=host --rm --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk -``` -2. Run GenAI-Perf: - -```bash +# Run GenAI-Perf in the container: genai-perf profile \ -m gpt2 \ --service-kind triton \ @@ -184,25 +186,31 @@ genai-perf profile \ Example output: ``` - LLM Metrics -┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ -┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ -┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ -│ Time to first token (ns) │ 13,266,974 │ 11,818,732 │ 18,351,779 │ 16,513,479 │ 13,741,986 │ 13,544,376 │ -│ Inter token latency (ns) │ 2,069,766 │ 42,023 │ 15,307,799 │ 3,256,375 │ 3,020,580 │ 2,090,930 │ -│ Request latency (ns) │ 223,532,625 │ 219,123,330 │ 241,004,192 │ 238,198,306 │ 229,676,183 │ 224,715,918 │ -│ Output sequence length │ 104 │ 100 │ 129 │ 128 │ 109 │ 105 │ -│ Input sequence length │ 199 │ 199 │ 199 │ 199 │ 199 │ 199 │ -└──────────────────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘ -Output token throughput (per sec): 460.42 -Request throughput (per sec): 4.44 + LLM Metrics +┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━┓ +┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━┩ +│ Time to first token (ms) │ 11.70 │ 9.88 │ 17.21 │ 14.35 │ 12.01 │ 11.87 │ +│ Inter token latency (ms) │ 1.46 │ 1.08 │ 1.89 │ 1.87 │ 1.62 │ 1.52 │ +│ Request latency (ms) │ 161.24 │ 153.45 │ 200.74 │ 200.66 │ 179.43 │ 162.23 │ +│ Output sequence length │ 103.39 │ 95.00 │ 134.00 │ 120.08 │ 107.30 │ 105.00 │ +│ Input sequence length │ 200.01 │ 200.00 │ 201.00 │ 200.13 │ 200.00 │ 200.00 │ +└──────────────────────────┴────────┴────────┴────────┴────────┴────────┴────────┘ +Output token throughput (per sec): 635.61 +Request throughput (per sec): 6.15 ``` See [Tutorial](docs/tutorial.md) for additional examples.
-# Visualization + + +## Visualization GenAI-Perf can also generate various plots that visualize the performance of the current profile run. This is disabled by default but users can easily enable it @@ -226,12 +234,12 @@ This will generate a [set of default plots](docs/compare.md#example-plots) such - Input sequence lengths vs Output sequence lengths -## Using `compare` Subcommand to Visualize Multiple Runs +### Using `compare` Subcommand to Visualize Multiple Runs The `compare` subcommand in GenAI-Perf facilitates users in comparing multiple profile runs and visualizing the differences through plots. -### Usage +#### Usage Assuming the user possesses two profile export JSON files, namely `profile1.json` and `profile2.json`, they can execute the `compare` subcommand using the `--files` option: @@ -258,7 +266,7 @@ compare └── ... ``` -### Customization +#### Customization Users have the flexibility to iteratively modify the generated YAML configuration file to suit their specific requirements. They can make alterations to the plots according to their preferences and execute @@ -277,7 +285,13 @@ See [Compare documentation](docs/compare.md) for more details.
-# Model Inputs + + +## Model Inputs GenAI-Perf supports model input prompts from either synthetically generated inputs, or from the HuggingFace @@ -323,7 +337,13 @@ You can optionally set additional model inputs with the following option:
-# Metrics + + +## Metrics GenAI-Perf collects a diverse set of metrics that captures the performance of the inference server. @@ -340,14 +360,20 @@ the inference server.
-# Command Line Options + + +## Command Line Options ##### `-h` ##### `--help` Show the help message and exit. -## Endpoint Options: +### Endpoint Options: ##### `-m ` ##### `--model ` @@ -392,7 +418,7 @@ An option to enable the use of the streaming API. (default: `False`) URL of the endpoint to target for benchmarking. (default: `None`) -## Input Options +### Input Options ##### `-b ` ##### `--batch-size ` @@ -458,7 +484,7 @@ data. (default: `550`) The standard deviation of number of tokens in the generated prompts when using synthetic data. (default: `0`) -## Profiling Options +### Profiling Options ##### `--concurrency ` @@ -483,7 +509,7 @@ stable. The measurement is considered as stable if the ratio of max / min from the recent 3 measurements is within (stability percentage) in terms of both infer per second and latency. (default: `999`) -## Output Options +### Output Options ##### `--artifact-dir` @@ -502,7 +528,7 @@ exported to `_genai_perf.csv`. For example, if the profile export file is `profile_export.json`, the genai-perf file will be exported to `profile_export_genai_perf.csv`. (default: `profile_export.json`) -## Other Options +### Other Options ##### `--tokenizer ` @@ -518,7 +544,15 @@ An option to enable verbose mode. (default: `False`) An option to print the version and exit. -# Known Issues +
+ + + +## Known Issues * GenAI-Perf can be slow to finish if a high request-rate is provided * Token counts may not be exact From 18a377c3c897945505575f7aadb7c736e4f4983b Mon Sep 17 00:00:00 2001 From: AndyDai-nv Date: Wed, 24 Jul 2024 09:51:05 -0700 Subject: [PATCH 53/55] Fix typo (#747) * Update Homepage and Bug tracker links Co-authored-by: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> --------- Co-authored-by: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> --- src/c++/perf_analyzer/genai-perf/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/pyproject.toml b/src/c++/perf_analyzer/genai-perf/pyproject.toml index 68d5e3740..f1f78a7e2 100644 --- a/src/c++/perf_analyzer/genai-perf/pyproject.toml +++ b/src/c++/perf_analyzer/genai-perf/pyproject.toml @@ -67,8 +67,8 @@ dependencies = [ genai-perf = "genai_perf.main:main" [project.urls] -"Homepage" = "https://github.com/triton-inference-server/" -"Bug Tracker" = "https://github.com/triton-inference-server/server/issues" +"Homepage" = "https://github.com/triton-inference-server/client" +"Bug Tracker" = "https://github.com/triton-inference-server/client/issues" # Build [build-system] From c514dea11125e39af918f33b48d0b9897d10a639 Mon Sep 17 00:00:00 2001 From: pvijayakrish Date: Wed, 24 Jul 2024 08:55:42 -0700 Subject: [PATCH 54/55] Updating genAI perf version post 24.07 release --- src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py index cb5c26999..d656fe629 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/__init__.py @@ -24,4 +24,4 @@ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "0.0.4dev" +__version__ = "0.0.5dev" From 442915d806a9a6170f1ac138681a48200ec93899 Mon Sep 17 00:00:00 2001 From: AndyDai-nv Date: Thu, 25 Jul 2024 12:24:52 -0700 Subject: [PATCH 55/55] Add testing for DataLoader (#727) * Add testing for DataLoader in PA --- src/c++/perf_analyzer/perf_utils.h | 2 +- src/c++/perf_analyzer/test_dataloader.cc | 194 +++++++++++++++++++++++ src/c++/perf_analyzer/test_perf_utils.cc | 1 + 3 files changed, 196 insertions(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/perf_utils.h b/src/c++/perf_analyzer/perf_utils.h index 7166936a9..6975d694b 100644 --- a/src/c++/perf_analyzer/perf_utils.h +++ b/src/c++/perf_analyzer/perf_utils.h @@ -56,7 +56,7 @@ constexpr uint64_t NANOS_PER_MILLIS = 1000000; // Will use the characters specified here to construct random strings std::string const character_set = - "abcdefghijklmnaoqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 .?!"; + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 .?!"; // A boolean flag to mark an interrupt and commencement of early exit extern volatile bool early_exit; diff --git a/src/c++/perf_analyzer/test_dataloader.cc b/src/c++/perf_analyzer/test_dataloader.cc index 656571cb9..c8db7df66 100644 --- a/src/c++/perf_analyzer/test_dataloader.cc +++ b/src/c++/perf_analyzer/test_dataloader.cc @@ -28,6 +28,7 @@ #include "doctest.h" #include "mock_data_loader.h" + namespace triton { namespace perfanalyzer { /// Helper class for testing the DataLoader @@ -104,6 +105,199 @@ TEST_CASE("dataloader: GetTotalSteps") CHECK_EQ(dataloader.GetTotalSteps(2), 0); } +TEST_CASE("dataloader: ValidateIOExistsInModel") +{ + MockDataLoader dataloader; + std::shared_ptr inputs = std::make_shared(); + std::shared_ptr outputs = std::make_shared(); + ModelTensor input1 = TestDataLoader::CreateTensor("INPUT1"); + ModelTensor output1 = TestDataLoader::CreateTensor("OUTPUT1"); + inputs->insert(std::make_pair(input1.name_, input1)); + outputs->insert(std::make_pair(output1.name_, output1)); + + SUBCASE("Directory does not exist") + { + std::string data_directory = "non_existent_directory"; + cb::Error status = + dataloader.ValidateIOExistsInModel(inputs, outputs, data_directory); + CHECK( + status.Message() == + "Error: Directory does not exist or is not a directory: " + "non_existent_directory"); + CHECK(status.Err() == pa::GENERIC_ERROR); + } + + SUBCASE("Directory is not a directory") + { + std::string data_directory = "tmp/test.txt"; + std::ofstream file(data_directory); + cb::Error status = + dataloader.ValidateIOExistsInModel(inputs, outputs, data_directory); + CHECK( + status.Message() == + "Error: Directory does not exist or is not a directory: tmp/test.txt"); + CHECK(status.Err() == pa::GENERIC_ERROR); + std::remove(data_directory.c_str()); + } + + SUBCASE("Valid directory but no corresponding files") + { + std::string data_directory = "valid_directory"; + std::filesystem::create_directory(data_directory); + std::ofstream(data_directory + "/invalid_file").close(); + cb::Error status = + dataloader.ValidateIOExistsInModel(inputs, outputs, data_directory); + std::filesystem::remove_all(data_directory); + CHECK( + status.Message() == + "Provided data file 'invalid_file' does not correspond to a valid " + "model input or output."); + CHECK(status.Err() == pa::GENERIC_ERROR); + } + + SUBCASE("Valid directory with corresponding files") + { + std::string data_directory = "valid_directory"; + std::filesystem::create_directory(data_directory); + std::ofstream(data_directory + "/INPUT1").close(); + std::ofstream(data_directory + "/OUTPUT1").close(); + cb::Error status = + dataloader.ValidateIOExistsInModel(inputs, outputs, data_directory); + std::filesystem::remove_all(data_directory); + CHECK(status.Message().empty()); + CHECK(status.IsOk()); + } + + SUBCASE("Valid directory with multiple input and output tensors") + { + ModelTensor input2 = TestDataLoader::CreateTensor("INPUT2"); + ModelTensor output2 = TestDataLoader::CreateTensor("OUTPUT2"); + + inputs->insert(std::make_pair(input2.name_, input2)); + outputs->insert(std::make_pair(output2.name_, output2)); + + std::string data_directory = "valid_directory_multiple"; + std::filesystem::create_directory(data_directory); + std::ofstream(data_directory + "/INPUT1").close(); + std::ofstream(data_directory + "/INPUT2").close(); + std::ofstream(data_directory + "/OUTPUT1").close(); + std::ofstream(data_directory + "/OUTPUT2").close(); + + cb::Error status = + dataloader.ValidateIOExistsInModel(inputs, outputs, data_directory); + std::filesystem::remove_all(data_directory); + CHECK(status.Message().empty()); + CHECK(status.IsOk()); + } +} + +TEST_CASE("dataloader: ReadDataFromJSON") +{ + DataLoader dataloader; + std::shared_ptr inputs = std::make_shared(); + std::shared_ptr outputs = std::make_shared(); + ModelTensor input1 = TestDataLoader::CreateTensor("INPUT1"); + ModelTensor output1 = TestDataLoader::CreateTensor("OUTPUT1"); + + inputs->insert(std::make_pair(input1.name_, input1)); + outputs->insert(std::make_pair(output1.name_, output1)); + + SUBCASE("File does not exist") + { + std::string json_file = "non_existent_file.json"; + cb::Error status = dataloader.ReadDataFromJSON(inputs, outputs, json_file); + CHECK(status.Message() == "failed to open file for reading provided data"); + CHECK(status.Err() == pa::GENERIC_ERROR); + } + + SUBCASE("Valid JSON file") + { + std::string json_file = "valid_file.json"; + std::ofstream out(json_file); + out << R"({ + "data": [ + { "INPUT1": [1] }, + { "INPUT1": [2] }, + { "INPUT1": [3] } + ], + "validation_data": [ + { "OUTPUT1": [4] }, + { "OUTPUT1": [5] }, + { "OUTPUT1": [6] } + ]})"; + out.close(); + + cb::Error status = dataloader.ReadDataFromJSON(inputs, outputs, json_file); + std::filesystem::remove(json_file); + CHECK(status.Message().empty()); + CHECK(status.IsOk()); + } + + SUBCASE("Invalid JSON file") + { + std::string json_file = "invalid_file.json"; + std::ofstream out(json_file); + out << R"({invalid_json: 1,)"; + out.close(); + + cb::Error status = dataloader.ReadDataFromJSON(inputs, outputs, json_file); + std::filesystem::remove(json_file); + + CHECK( + status.Message() == + "failed to parse the specified json file for reading provided data"); + CHECK(status.Err() == pa::GENERIC_ERROR); + } + + SUBCASE("Multiple input and output tensors") + { + ModelTensor input2 = TestDataLoader::CreateTensor("INPUT2"); + ModelTensor output2 = TestDataLoader::CreateTensor("OUTPUT2"); + + inputs->insert(std::make_pair(input2.name_, input2)); + outputs->insert(std::make_pair(output2.name_, output2)); + + std::string json_file = "valid_file_multiple_input_output.json"; + std::ofstream out(json_file); + out << R"({ + "data": [ + { + "INPUT1": [1], + "INPUT2": [4] + }, + { + "INPUT1": [2], + "INPUT2": [5] + }, + { + "INPUT1": [3], + "INPUT2": [6] + } + ], + "validation_data": [ + { + "OUTPUT1": [4], + "OUTPUT2": [7] + }, + { + "OUTPUT1": [5], + "OUTPUT2": [8] + }, + { + "OUTPUT1": [6], + "OUTPUT2": [9] + } + ] + })"; + out.close(); + + cb::Error status = dataloader.ReadDataFromJSON(inputs, outputs, json_file); + std::filesystem::remove(json_file); + CHECK(status.Message().empty()); + CHECK(status.IsOk()); + } +} + TEST_CASE("dataloader: GetInputData missing data") { MockDataLoader dataloader; diff --git a/src/c++/perf_analyzer/test_perf_utils.cc b/src/c++/perf_analyzer/test_perf_utils.cc index 34a08a108..74bf6afb4 100644 --- a/src/c++/perf_analyzer/test_perf_utils.cc +++ b/src/c++/perf_analyzer/test_perf_utils.cc @@ -144,6 +144,7 @@ TEST_CASE("perf_utils: ConvertDTypeFromTFS") std::make_pair("DT_DOUBLE", "FP64"), std::make_pair("DT_INT32", "INT32"), std::make_pair("DT_INT16", "INT16"), + std::make_pair("DT_UINT16", "UINT16"), std::make_pair("DT_INT8", "INT8"), std::make_pair("DT_UINT8", "UINT8"), std::make_pair("DT_STRING", "BYTES"),

q*szT>7Qprkm__h6-!W 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 literal 0 HcmV?d00001 diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py new file mode 100644 index 000000000..02f260497 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py @@ -0,0 +1,79 @@ +# Copyright 2024, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of NVIDIA CORPORATION nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import glob +import random +from enum import Enum, auto +from pathlib import Path + +from genai_perf import utils +from PIL import Image + + +class ImageFormat(Enum): + PNG = auto() + JPEG = auto() + + +class SyntheticImageGenerator: + """A simple synthetic image generator that generates multiple synthetic + images from the source images. + """ + + @classmethod + def create_synthetic_image( + cls, + image_width_mean: int, + image_width_stddev: int, + image_height_mean: int, + image_height_stddev: int, + image_format: ImageFormat, + ) -> str: + """Generate base64 encoded synthetic image using the source images.""" + width = cls._sample_random_positive_integer( + image_width_mean, image_width_stddev + ) + height = cls._sample_random_positive_integer( + image_height_mean, image_height_stddev + ) + + image = cls._sample_source_image() + image = image.resize(size=(width, height)) + + img_base64 = utils.encode_image(image, image_format.name) + return f"data:image/{image_format.name.lower()};base64,{img_base64}" + + @classmethod + def _sample_source_image(cls): + """Sample one image among the source images.""" + filepath = Path(__file__).parent.resolve() / "source_images" / "*" + filenames = glob.glob(str(filepath)) + return Image.open(random.choice(filenames)) + + @classmethod + def _sample_random_positive_integer(cls, mean: int, stddev: int) -> int: + n = int(abs(random.gauss(mean, stddev))) + return n if n != 0 else 1 # avoid zero diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py index 912ee4725..9ff7b5b9a 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/main.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/main.py @@ -76,6 +76,11 @@ def generate_inputs(args: Namespace, tokenizer: Tokenizer) -> None: output_tokens_mean=args.output_tokens_mean, output_tokens_stddev=args.output_tokens_stddev, output_tokens_deterministic=args.output_tokens_mean_deterministic, + image_width_mean=args.image_width_mean, + image_width_stddev=args.image_width_stddev, + image_height_mean=args.image_height_mean, + image_height_stddev=args.image_height_stddev, + image_format=args.image_format, random_seed=args.random_seed, num_of_output_prompts=args.num_prompts, add_model_name=add_model_name, diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 901cf6ca2..7b7e02091 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -46,6 +46,7 @@ OutputFormat, PromptSource, ) +from genai_perf.llm_inputs.synthetic_image_generator import ImageFormat from genai_perf.plots.plot_config_parser import PlotConfigParser from genai_perf.plots.plot_manager import PlotManager from genai_perf.tokenizer import DEFAULT_TOKENIZER @@ -76,6 +77,7 @@ def to_lowercase(self): "completions": "v1/completions", "embeddings": "v1/embeddings", "rankings": "v1/ranking", + "vision": "v1/chat/completions", } @@ -115,6 +117,25 @@ def _check_compare_args( return args +def _check_image_input_args( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> argparse.Namespace: + """ + Sanity check the image input args + """ + if args.image_width_mean <= 0 or args.image_height_mean <= 0: + parser.error( + "Both --image-width-mean and --image-height-mean values must be positive." + ) + if args.image_width_stddev < 0 or args.image_height_stddev < 0: + parser.error( + "Both --image-width-stddev and --image-height-stddev values must be non-negative." + ) + + args = _convert_str_to_enum_entry(args, "image_format", ImageFormat) + return args + + def _check_conditional_args( parser: argparse.ArgumentParser, args: argparse.Namespace ) -> argparse.Namespace: @@ -138,6 +159,11 @@ def _check_conditional_args( elif args.endpoint_type == "rankings": args.output_format = OutputFormat.RANKINGS + # (TMA-1986) deduce vision format from chat completions + image CLI + # because there's no openai vision endpoint. + elif args.endpoint_type == "vision": + args.output_format = OutputFormat.OPENAI_VISION + if args.endpoint is not None: args.endpoint = args.endpoint.lstrip(" /") else: @@ -411,6 +437,51 @@ def _add_input_args(parser): ) +def _add_image_input_args(parser): + input_group = parser.add_argument_group("Image Input") + + input_group.add_argument( + "--image-width-mean", + type=int, + default=LlmInputs.DEFAULT_IMAGE_WIDTH_MEAN, + required=False, + help=f"The mean width of images when generating synthetic image data.", + ) + + input_group.add_argument( + "--image-width-stddev", + type=int, + default=LlmInputs.DEFAULT_IMAGE_WIDTH_STDDEV, + required=False, + help=f"The standard deviation of width of images when generating synthetic image data.", + ) + + input_group.add_argument( + "--image-height-mean", + type=int, + default=LlmInputs.DEFAULT_IMAGE_HEIGHT_MEAN, + required=False, + help=f"The mean height of images when generating synthetic image data.", + ) + + input_group.add_argument( + "--image-height-stddev", + type=int, + default=LlmInputs.DEFAULT_IMAGE_HEIGHT_STDDEV, + required=False, + help=f"The standard deviation of height of images when generating synthetic image data.", + ) + + input_group.add_argument( + "--image-format", + type=str, + choices=utils.get_enum_names(ImageFormat), + default="png", + required=False, + help=f"The compression format of the images.", + ) + + def _add_profile_args(parser): profile_group = parser.add_argument_group("Profiling") load_management_group = profile_group.add_mutually_exclusive_group(required=False) @@ -499,7 +570,7 @@ def _add_endpoint_args(parser): endpoint_group.add_argument( "--endpoint-type", type=str, - choices=["chat", "completions", "embeddings", "rankings"], + choices=["chat", "completions", "embeddings", "rankings", "vision"], required=False, help=f"The endpoint-type to send requests to on the " 'server. This is only used with the "openai" service-kind.', @@ -658,6 +729,7 @@ def _parse_profile_args(subparsers) -> argparse.ArgumentParser: ) _add_endpoint_args(profile) _add_input_args(profile) + _add_image_input_args(profile) _add_profile_args(profile) _add_output_args(profile) _add_other_args(profile) @@ -737,6 +809,7 @@ def refine_args( args = _infer_prompt_source(args) args = _check_model_args(parser, args) args = _check_conditional_args(parser, args) + args = _check_image_input_args(parser, args) args = _check_load_manager_args(args) args = _set_artifact_paths(args) elif args.subcommand == Subcommand.COMPARE.to_lowercase(): diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py index 4ec1bec62..183f21fd2 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/llm_profile_data_parser.py @@ -218,6 +218,9 @@ def _get_openai_input_text(self, req_inputs: dict) -> str: return payload["messages"][0]["content"] elif self._response_format == ResponseFormat.OPENAI_COMPLETIONS: return payload["prompt"] + elif self._response_format == ResponseFormat.OPENAI_VISION: + content = payload["messages"][0]["content"] + return " ".join(c["text"] for c in content if c["type"] == "text") else: raise ValueError( "Failed to parse OpenAI request input in profile export file." diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py index d18d8f6fb..74eb48a23 100755 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/profile_data_parser/profile_data_parser.py @@ -39,6 +39,7 @@ class ResponseFormat(Enum): OPENAI_CHAT_COMPLETIONS = auto() OPENAI_COMPLETIONS = auto() OPENAI_EMBEDDINGS = auto() + OPENAI_VISION = auto() RANKINGS = auto() TRITON = auto() @@ -59,7 +60,15 @@ def _get_profile_metadata(self, data: dict) -> None: if data["endpoint"] == "rerank": self._response_format = ResponseFormat.HUGGINGFACE_RANKINGS elif data["endpoint"] == "v1/chat/completions": - self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS + # (TPA-66) add PA metadata to deduce the response format instead + # of parsing the request input payload in profile export json + # file. + request = data["experiments"][0]["requests"][0] + request_input = request["request_inputs"]["payload"] + if "image_url" in request_input: + self._response_format = ResponseFormat.OPENAI_VISION + else: + self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS elif data["endpoint"] == "v1/completions": self._response_format = ResponseFormat.OPENAI_COMPLETIONS elif data["endpoint"] == "v1/embeddings": @@ -67,13 +76,17 @@ def _get_profile_metadata(self, data: dict) -> None: elif data["endpoint"] == "v1/ranking": self._response_format = ResponseFormat.RANKINGS else: - # TPA-66: add PA metadata to handle this case + # (TPA-66) add PA metadata to handle this case # When endpoint field is either empty or custom endpoint, fall # back to parsing the response to extract the response format. request = data["experiments"][0]["requests"][0] + request_input = request["request_inputs"]["payload"] response = request["response_outputs"][0]["response"] if "chat.completion" in response: - self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS + if "image_url" in request_input: + self._response_format = ResponseFormat.OPENAI_VISION + else: + self._response_format = ResponseFormat.OPENAI_CHAT_COMPLETIONS elif "text_completion" in response: self._response_format = ResponseFormat.OPENAI_COMPLETIONS elif "embedding" in response: diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py b/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py deleted file mode 100644 index a44304348..000000000 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/test_end_to_end.py +++ /dev/null @@ -1,92 +0,0 @@ -import itertools -import os -import subprocess -import sys - -# How to run: -# test_end_to_end.py -# Where target is "nim_chat" or "nim_completions" or "vllm_openai" or "triton_tensorrtllm" -# -# For all cases but vllm_openai, it assumes that the server will be on port 9999 -# -# This script will run a sweep of all combinations of values in the testing matrix -# by appending those options on to the genai-perf base command -# - - -testing_matrix = [ - ["--concurrency 1", "--concurrency 32", "--request-rate 1", "--request-rate 32"], - ["--streaming", ""], -] - -base_commands = { - "nim_chat": "genai-perf profile -s 999 -p 20000 -m llama-2-7b-chat -u http://localhost:9999 --service-kind openai --endpoint-type chat", - "nim_completions": "genai-perf profile -s 999 -p 20000 -m llama-2-7b -u http://localhost:9999 --service-kind openai --endpoint-type completions", - "vllm_openai": "genai-perf profile -s 999 -p 20000 -m mistralai/Mistral-7B-v0.1 --service-kind openai --endpoint-type chat", - "triton_tensorrtllm": "genai-perf profile -s 999 -p 20000 -m llama-2-7b -u 0.0.0.0:9999 --service-kind triton --backend tensorrtllm", - "triton_vllm": "genai-perf profile -s 999 -p 20000 -m gpt2_vllm --service-kind triton --backend vllm", -} -testname = "" - -if len(sys.argv) == 2: - # The second element in sys.argv is the input string - testname = sys.argv[1] -else: - options = " ".join(base_commands.keys()) - print(f"This script requires exactly one argument. It must be one of {options}") - exit(1) - -base_command = base_commands[testname] - - -def rename_files(files: list, substr: str) -> None: - for f in files: - name, ext = f.rsplit(".", 1) - # Insert the substring and reassemble the filename - new_filename = f"{testname}__{name}__{substr}.{ext}" - try: - os.rename(f, new_filename) - except FileNotFoundError: - # Just ignore the error, since if PA failed these files may not exist - pass - - -def print_summary(): - # FIXME -- print out a few basic metrics. Maybe from the csv? - pass - - -def sanity_check(): - # FIXME -- add in some sanity checking? Throughput isn't 0? - pass - - -# Loop through all combinations -for combination in itertools.product(*testing_matrix): - options_string = " ".join(combination) - command_with_options = f"{base_command} {options_string}" - command_array = command_with_options.split() - - file_options_string = "__".join(combination) - file_options_string = file_options_string.replace(" ", "") - file_options_string = file_options_string.replace("-", "") - output_file = testname + "__" + file_options_string + ".log" - - with open(output_file, "w") as outfile: - print(f"\nCMD: {command_with_options}") - print(f" Output log is {output_file}") - proc = subprocess.run(command_array, stdout=outfile, stderr=subprocess.STDOUT) - - if proc.returncode != 0: - print(f" Command failed with return code: {proc.returncode}") - else: - print(f" Command executed successfully!") - print_summary() - sanity_check() - - files = [ - "profile_export.json", - "profile_export_genai_pa.csv", - "llm_inputs.json", - ] - rename_files(files, file_options_string) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py b/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py index 6f66230c4..4b625352a 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/utils.py @@ -34,10 +34,27 @@ # Skip type checking to avoid mypy error # Issue: https://github.com/python/mypy/issues/10632 import yaml # type: ignore +from PIL import Image logger = logging.getLogger(__name__) +def encode_image(img: Image, format: str): + """Encodes an image into base64 encoding.""" + # Lazy import for vision related endpoints + import base64 + from io import BytesIO + + # JPEG does not support P or RGBA mode (commonly used for PNG) so it needs + # to be converted to RGB before an image can be saved as JPEG format. + if format == "JPEG" and img.mode != "RGB": + img = img.convert("RGB") + + buffered = BytesIO() + img.save(buffered, format=format) + return base64.b64encode(buffered.getvalue()).decode("utf-8") + + def remove_sse_prefix(msg: str) -> str: prefix = "data: " if msg.startswith(prefix): diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py b/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py index dbaacc32b..76ef3e321 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/wrapper.py @@ -93,6 +93,11 @@ def build_cmd(args: Namespace, extra_args: Optional[List[str]] = None) -> List[s "synthetic_input_tokens_stddev", "subcommand", "tokenizer", + "image_width_mean", + "image_width_stddev", + "image_height_mean", + "image_height_stddev", + "image_format", ] utils.remove_file(args.profile_export_file) diff --git a/src/c++/perf_analyzer/genai-perf/pyproject.toml b/src/c++/perf_analyzer/genai-perf/pyproject.toml index 982ee24b7..68d5e3740 100644 --- a/src/c++/perf_analyzer/genai-perf/pyproject.toml +++ b/src/c++/perf_analyzer/genai-perf/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "pytest-mock", "pyyaml", "responses", + "pillow", ] # CLI Entrypoint diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py index eb891fd02..2ef5d52ba 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_cli.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_cli.py @@ -31,16 +31,18 @@ import pytest from genai_perf import __version__, parser from genai_perf.llm_inputs.llm_inputs import ( + ImageFormat, ModelSelectionStrategy, OutputFormat, PromptSource, ) +from genai_perf.llm_inputs.synthetic_image_generator import ImageFormat from genai_perf.parser import PathType class TestCLIArguments: # ================================================ - # GENAI-PERF COMMAND + # PROFILE COMMAND # ================================================ expected_help_output = ( "CLI to profile LLMs and Generative AI models with Perf Analyzer" @@ -215,6 +217,23 @@ def test_help_version_arguments_output_and_exit( ["--synthetic-input-tokens-stddev", "7"], {"synthetic_input_tokens_stddev": 7}, ), + ( + ["--image-width-mean", "123"], + {"image_width_mean": 123}, + ), + ( + ["--image-width-stddev", "123"], + {"image_width_stddev": 123}, + ), + ( + ["--image-height-mean", "456"], + {"image_height_mean": 456}, + ), + ( + ["--image-height-stddev", "456"], + {"image_height_stddev": 456}, + ), + (["--image-format", "png"], {"image_format": ImageFormat.PNG}), (["-v"], {"verbose": True}), (["--verbose"], {"verbose": True}), (["-u", "test_url"], {"u": "test_url"}), @@ -732,6 +751,26 @@ def test_prompt_source_assertions(self, monkeypatch, mocker, capsys): captured = capsys.readouterr() assert expected_output in captured.err + @pytest.mark.parametrize( + "args", + [ + # negative numbers + ["--image-width-mean", "-123"], + ["--image-width-stddev", "-34"], + ["--image-height-mean", "-123"], + ["--image-height-stddev", "-34"], + # zeros + ["--image-width-mean", "0"], + ["--image-height-mean", "0"], + ], + ) + def test_positive_image_input_args(self, monkeypatch, args): + combined_args = ["genai-perf", "profile", "-m", "test_model"] + args + monkeypatch.setattr("sys.argv", combined_args) + + with pytest.raises(SystemExit) as excinfo: + parser.parse_args() + # ================================================ # COMPARE SUBCOMMAND # ================================================ diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index e4a29267d..c792018ba 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -249,6 +249,11 @@ def test_generate_json(self, monkeypatch) -> None: "random_seed": 0, "synthetic_input_tokens_mean": 550, "synthetic_input_tokens_stddev": 0, + "image_width_mean": 100, + "image_width_stddev": 0, + "image_height_mean": 100, + "image_height_stddev": 0, + "image_format": "png", "concurrency": 1, "measurement_interval": 10000, "request_rate": null, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py index c6351918e..028e72849 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_inputs.py @@ -16,6 +16,7 @@ import os import random import statistics +from collections import namedtuple from pathlib import Path from unittest.mock import mock_open, patch @@ -30,7 +31,9 @@ OutputFormat, PromptSource, ) -from genai_perf.tokenizer import Tokenizer +from genai_perf.llm_inputs.synthetic_image_generator import ImageFormat +from genai_perf.tokenizer import DEFAULT_TOKENIZER, get_tokenizer +from PIL import Image mocked_openorca_data = { "features": [ @@ -78,6 +81,7 @@ class TestLlmInputs: ("triton", "tensorrtllm", OutputFormat.TENSORRTLLM), ("openai", "v1/completions", OutputFormat.OPENAI_COMPLETIONS), ("openai", "v1/chat/completions", OutputFormat.OPENAI_CHAT_COMPLETIONS), + ("openai", "v1/chat/completions", OutputFormat.OPENAI_VISION), ] @pytest.fixture @@ -550,6 +554,94 @@ def test_llm_inputs_with_defaults(self, default_configured_url): # else: # assert False, f"Unsupported output format: {output_format}" + def test_add_image_inputs_openai_vision(self) -> None: + generic_json = { + "rows": [ + {"text_input": "test input one", "image": "test_image1"}, + {"text_input": "test input two", "image": "test_image2"}, + ] + } + + generic_json = LlmInputs._convert_to_openai_multi_modal_content(generic_json) + + row1 = generic_json["rows"][0]["text_input"] + assert row1 == [ + { + "type": "text", + "text": "test input one", + }, + { + "type": "image_url", + "image_url": {"url": "test_image1"}, + }, + ] + + row2 = generic_json["rows"][1]["text_input"] + assert row2 == [ + { + "type": "text", + "text": "test input two", + }, + { + "type": "image_url", + "image_url": {"url": "test_image2"}, + }, + ] + + @patch( + "genai_perf.llm_inputs.llm_inputs.LlmInputs._create_synthetic_prompt", + return_value="This is test prompt", + ) + @patch( + "genai_perf.llm_inputs.llm_inputs.LlmInputs._create_synthetic_image", + return_value="test_image_base64", + ) + @pytest.mark.parametrize( + "output_format", + [ + OutputFormat.OPENAI_CHAT_COMPLETIONS, + OutputFormat.OPENAI_COMPLETIONS, + OutputFormat.OPENAI_EMBEDDINGS, + OutputFormat.RANKINGS, + OutputFormat.OPENAI_VISION, + OutputFormat.VLLM, + OutputFormat.TENSORRTLLM, + ], + ) + def test_get_input_dataset_from_synthetic( + self, mock_prompt, mock_image, output_format + ) -> None: + _placeholder = 123 # dummy value + num_prompts = 3 + + dataset_json = LlmInputs._get_input_dataset_from_synthetic( + tokenizer=get_tokenizer(DEFAULT_TOKENIZER), + prompt_tokens_mean=_placeholder, + prompt_tokens_stddev=_placeholder, + num_of_output_prompts=num_prompts, + image_width_mean=_placeholder, + image_width_stddev=_placeholder, + image_height_mean=_placeholder, + image_height_stddev=_placeholder, + image_format=ImageFormat.PNG, + output_format=output_format, + ) + + assert len(dataset_json["rows"]) == num_prompts + + for i in range(num_prompts): + row = dataset_json["rows"][i]["row"] + + if output_format == OutputFormat.OPENAI_VISION: + assert row == { + "text_input": "This is test prompt", + "image": "test_image_base64", + } + else: + assert row == { + "text_input": "This is test prompt", + } + # def test_trtllm_default_max_tokens(self, default_tokenizer: Tokenizer) -> None: # input_name = "max_tokens" # input_value = 256 @@ -687,6 +779,34 @@ def test_get_input_file_with_multiple_prompts(self, mock_file, mock_exists): for i, prompt in enumerate(expected_prompts): assert dataset["rows"][i]["row"]["text_input"] == prompt + @patch("pathlib.Path.exists", return_value=True) + @patch("PIL.Image.open", return_value=Image.new("RGB", (10, 10))) + @patch( + "builtins.open", + new_callable=mock_open, + read_data=( + '{"text_input": "prompt1", "image": "image1.png"}\n' + '{"text_input": "prompt2", "image": "image2.png"}\n' + '{"text_input": "prompt3", "image": "image3.png"}\n' + ), + ) + def test_get_input_file_with_multi_modal_data( + self, mock_exists, mock_image, mock_file + ): + Data = namedtuple("Data", ["text_input", "image"]) + expected_data = [ + Data(text_input="prompt1", image="image1.png"), + Data(text_input="prompt2", image="image2.png"), + Data(text_input="prompt3", image="image3.png"), + ] + dataset = LlmInputs._get_input_dataset_from_file(Path("somefile.txt")) + + assert dataset is not None + assert len(dataset["rows"]) == len(expected_data) + for i, data in enumerate(expected_data): + assert dataset["rows"][i]["row"]["text_input"] == data.text_input + assert dataset["rows"][i]["row"]["image"] == data.image + @pytest.mark.parametrize( "seed, model_name_list, index,model_selection_strategy,expected_model", [ diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py index 05de5b122..689e366cd 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_metrics.py @@ -69,6 +69,7 @@ def test_llm_metric_system_metrics(self) -> None: output_sequence_lengths=[3, 4], input_sequence_lengths=[12, 34], ) + sys_metrics = m.system_metrics assert len(sys_metrics) == 2 assert sys_metrics[0].name == "output_token_throughput" diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py b/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py index 75976189d..d776a6a85 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_llm_profile_data_parser.py @@ -71,6 +71,9 @@ def write(self: Any, content: str) -> int: elif filename == "openai_profile_export.json": tmp_file = StringIO(json.dumps(self.openai_profile_data)) return tmp_file + elif filename == "openai_vlm_profile_export.json": + tmp_file = StringIO(json.dumps(self.openai_vlm_profile_data)) + return tmp_file elif filename == "empty_profile_export.json": tmp_file = StringIO(json.dumps(self.empty_profile_data)) return tmp_file @@ -322,6 +325,91 @@ def test_openai_llm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> N with pytest.raises(KeyError): pd.get_statistics(infer_mode="concurrency", load_level="40") + def test_openai_vlm_profile_data(self, mock_read_write: pytest.MonkeyPatch) -> None: + """Collect LLM metrics from profile export data and check values. + + Metrics + * time to first tokens + - experiment 1: [5 - 1, 7 - 2] = [4, 5] + * inter token latencies + - experiment 1: [((12 - 1) - 4)/(3 - 1), ((15 - 2) - 5)/(6 - 1)] + : [3.5, 1.6] + : [4, 2] # rounded + * output token throughputs per request + - experiment 1: [3/(12 - 1), 6/(15 - 2)] = [3/11, 6/13] + * output token throughputs + - experiment 1: [(3 + 6)/(15 - 1)] = [9/14] + * output sequence lengths + - experiment 1: [3, 6] + * input sequence lengths + - experiment 1: [3, 4] + """ + tokenizer = get_tokenizer(DEFAULT_TOKENIZER) + pd = LLMProfileDataParser( + filename=Path("openai_vlm_profile_export.json"), + tokenizer=tokenizer, + ) + + # experiment 1 statistics + stat_obj = pd.get_statistics(infer_mode="concurrency", load_level="10") + metrics = stat_obj.metrics + stat = stat_obj.stats_dict + assert isinstance(metrics, LLMMetrics) + + assert metrics.time_to_first_tokens == [4, 5] + assert metrics.inter_token_latencies == [4, 2] + ottpr = [3 / ns_to_sec(11), 6 / ns_to_sec(13)] + assert metrics.output_token_throughputs_per_request == pytest.approx(ottpr) + ott = [9 / ns_to_sec(14)] + assert metrics.output_token_throughputs == pytest.approx(ott) + assert metrics.output_sequence_lengths == [3, 6] + assert metrics.input_sequence_lengths == [3, 4] + + assert stat["time_to_first_token"]["avg"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["avg"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["avg"] == pytest.approx( # type: ignore + np.mean(ottpr) + ) + assert stat["output_sequence_length"]["avg"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["avg"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["p50"] == pytest.approx(4.5) # type: ignore + assert stat["inter_token_latency"]["p50"] == pytest.approx(3) # type: ignore + assert stat["output_token_throughput_per_request"]["p50"] == pytest.approx( # type: ignore + np.percentile(ottpr, 50) + ) + assert stat["output_sequence_length"]["p50"] == 4.5 # type: ignore + assert stat["input_sequence_length"]["p50"] == 3.5 # type: ignore + + assert stat["time_to_first_token"]["min"] == pytest.approx(4) # type: ignore + assert stat["inter_token_latency"]["min"] == pytest.approx(2) # type: ignore + min_ottpr = 3 / ns_to_sec(11) + assert stat["output_token_throughput_per_request"]["min"] == pytest.approx(min_ottpr) # type: ignore + assert stat["output_sequence_length"]["min"] == 3 # type: ignore + assert stat["input_sequence_length"]["min"] == 3 # type: ignore + + assert stat["time_to_first_token"]["max"] == pytest.approx(5) # type: ignore + assert stat["inter_token_latency"]["max"] == pytest.approx(4) # type: ignore + max_ottpr = 6 / ns_to_sec(13) + assert stat["output_token_throughput_per_request"]["max"] == pytest.approx(max_ottpr) # type: ignore + assert stat["output_sequence_length"]["max"] == 6 # type: ignore + assert stat["input_sequence_length"]["max"] == 4 # type: ignore + + assert stat["time_to_first_token"]["std"] == np.std([4, 5]) * (1) # type: ignore + assert stat["inter_token_latency"]["std"] == np.std([4, 2]) * (1) # type: ignore + assert stat["output_token_throughput_per_request"]["std"] == pytest.approx( # type: ignore + np.std(ottpr) + ) + assert stat["output_sequence_length"]["std"] == np.std([3, 6]) # type: ignore + assert stat["input_sequence_length"]["std"] == np.std([3, 4]) # type: ignore + + oott = 9 / ns_to_sec(14) + assert stat["output_token_throughput"]["avg"] == pytest.approx(oott) # type: ignore + + # check non-existing profile data + with pytest.raises(KeyError): + pd.get_statistics(infer_mode="concurrency", load_level="40") + def test_merged_sse_response(self, mock_read_write: pytest.MonkeyPatch) -> None: """Test merging the multiple sse response.""" res_timestamps = [0, 1, 2, 3] @@ -522,6 +610,73 @@ def test_empty_response(self, mock_read_write: pytest.MonkeyPatch) -> None: ], } + openai_vlm_profile_data = { + "service_kind": "openai", + "endpoint": "v1/chat/completions", + "experiments": [ + { + "experiment": { + "mode": "concurrency", + "value": 10, + }, + "requests": [ + { + "timestamp": 1, + "request_inputs": { + "payload": '{"messages":[{"role":"user","content":[{"type":"text","text":"This is test"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abcdef"}}]}],"model":"llava-1.6","stream":true}', + }, + # the first, and the last two responses will be ignored because they have no "content" + "response_timestamps": [3, 5, 8, 12, 13, 14], + "response_outputs": [ + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" like"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" dogs"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' + }, + {"response": "data: [DONE]\n\n"}, + ], + }, + { + "timestamp": 2, + "request_inputs": { + "payload": '{"messages":[{"role":"user","content":[{"type":"text","text":"This is test too"},{"type":"image_url","image_url":{"url":"data:image/png;base64,abcdef"}}]}],"model":"llava-1.6","stream":true}', + }, + # the first, and the last two responses will be ignored because they have no "content" + "response_timestamps": [4, 7, 11, 15, 18, 19], + "response_outputs": [ + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"I"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"don\'t"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"cook food"},"finish_reason":null}]}\n\n' + }, + { + "response": 'data: {"id":"abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":null}]}\n\n' + }, + {"response": "data: [DONE]\n\n"}, + ], + }, + ], + }, + ], + } + triton_profile_data = { "service_kind": "triton", "endpoint": "", diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py b/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py new file mode 100644 index 000000000..b04e65744 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py @@ -0,0 +1,99 @@ +import base64 +import random +from io import BytesIO + +import pytest +from genai_perf.llm_inputs.synthetic_image_generator import ( + ImageFormat, + SyntheticImageGenerator, +) +from PIL import Image + + +def decode_image(base64_string): + _, data = base64_string.split(",") + decoded_data = base64.b64decode(data) + return Image.open(BytesIO(decoded_data)) + + +@pytest.mark.parametrize( + "expected_image_size", + [ + (100, 100), + (200, 200), + ], +) +def test_different_image_size(expected_image_size): + expected_width, expected_height = expected_image_size + base64_string = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=expected_width, + image_width_stddev=0, + image_height_mean=expected_height, + image_height_stddev=0, + image_format=ImageFormat.PNG, + ) + + image = decode_image(base64_string) + assert image.size == expected_image_size, "image not resized to the target size" + + +def test_negative_size_is_not_selected(): + # exception is raised, when PIL.Image.resize is called with negative values + _ = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=-1, + image_width_stddev=10, + image_height_mean=-1, + image_height_stddev=10, + image_format=ImageFormat.PNG, + ) + + +@pytest.mark.parametrize( + "width_mean, width_stddev, height_mean, height_stddev", + [ + (100, 15, 100, 15), + (123, 10, 456, 7), + ], +) +def test_generator_deterministic(width_mean, width_stddev, height_mean, height_stddev): + random.seed(123) + img1 = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=width_mean, + image_width_stddev=width_stddev, + image_height_mean=height_mean, + image_height_stddev=height_stddev, + image_format=ImageFormat.PNG, + ) + + random.seed(123) + img2 = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=width_mean, + image_width_stddev=width_stddev, + image_height_mean=height_mean, + image_height_stddev=height_stddev, + image_format=ImageFormat.PNG, + ) + + assert img1 == img2, "generator is nondererministic" + + +@pytest.mark.parametrize("image_format", [ImageFormat.PNG, ImageFormat.JPEG]) +def test_base64_encoding_with_different_formats(image_format): + img_base64 = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=100, + image_width_stddev=100, + image_height_mean=100, + image_height_stddev=100, + image_format=image_format, + ) + + # check prefix + expected_prefix = f"data:image/{image_format.name.lower()};base64," + assert img_base64.startswith(expected_prefix), "unexpected prefix" + + # check image format + data = img_base64[len(expected_prefix) :] + img_data = base64.b64decode(data) + img_bytes = BytesIO(img_data) + image = Image.open(img_bytes) + assert image.format == image_format.name From a1dc2b9fe3010803fe267699a471af8dcbebc494 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Fri, 19 Jul 2024 11:20:54 -0700 Subject: [PATCH 48/55] Fix typo in tutorial.md (#757) --- src/c++/perf_analyzer/genai-perf/docs/tutorial.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md index 1a37baf39..15cc53efe 100644 --- a/src/c++/perf_analyzer/genai-perf/docs/tutorial.md +++ b/src/c++/perf_analyzer/genai-perf/docs/tutorial.md @@ -71,7 +71,6 @@ export RELEASE="yy.mm" # e.g. export RELEASE="24.06" docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk # Run GenAI-Perf in the container: -```bash genai-perf profile \ -m gpt2 \ --service-kind triton \ @@ -145,7 +144,6 @@ export RELEASE="yy.mm" # e.g. export RELEASE="24.06" docker run -it --net=host --gpus=1 nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk # Run GenAI-Perf in the container: -```bash genai-perf profile \ -m gpt2 \ --service-kind triton \ @@ -207,7 +205,6 @@ export RELEASE="yy.mm" # e.g. export RELEASE="24.06" docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk # Run GenAI-Perf in the container: -```bash genai-perf profile \ -m gpt2 \ --service-kind openai \ @@ -270,7 +267,6 @@ docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3- # Run GenAI-Perf in the container: -```bash genai-perf profile \ -m gpt2 \ --service-kind openai \ From 3e1dbb1e8d3d4b0f00a6bc7e21e23ef2131a44f5 Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Fri, 19 Jul 2024 16:50:25 -0700 Subject: [PATCH 49/55] Add documentation for multi-modal support in GenAI-Perf (#758) * Add doc for multi-modal profiling * Minor update * Add instruction to run VLM model * shorten source image directory * Address feedback * Fix typo Co-authored-by: Elias Bermudez <6505145+debermudez@users.noreply.github.com> --------- Co-authored-by: Elias Bermudez <6505145+debermudez@users.noreply.github.com> --- .../genai-perf/docs/multi_modal.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/c++/perf_analyzer/genai-perf/docs/multi_modal.md diff --git a/src/c++/perf_analyzer/genai-perf/docs/multi_modal.md b/src/c++/perf_analyzer/genai-perf/docs/multi_modal.md new file mode 100644 index 000000000..bb9f33c60 --- /dev/null +++ b/src/c++/perf_analyzer/genai-perf/docs/multi_modal.md @@ -0,0 +1,122 @@ + + +# Profile Vision-Language Models with GenAI-Perf + +GenAI-Perf allows you to profile Vision-Language Models (VLM) running on +[OpenAI Chat Completions API](https://platform.openai.com/docs/guides/chat-completions)-compatible server +by sending [multi-modal content](https://platform.openai.com/docs/guides/vision) to the server. +Currently, you can send multi-modal contents with GenAI-Perf using the following two approaches: +1. The synthetic data generation approach, where GenAI-Perf generates the multi-modal data for you. +2. The Bring Your Own Data (BYOD) approach, where you provide GenAI-Perf with the data to send. + +Before we dive into the two approaches, +you can start OpenAI API compatible server with a VLM model using following command: + +```bash +docker run --runtime nvidia --gpus all \ + -p 8000:8000 --ipc=host \ + vllm/vllm-openai:latest \ + --model llava-hf/llava-v1.6-mistral-7b-hf --dtype float16 +``` + + +## Approach 1: Synthetic Multi-Modal Data Generation + +GenAI-Perf can generate synthetic multi-modal data such as texts or images using +the parameters provide by the user through CLI. + +```bash +genai-perf profile \ + -m llava-hf/llava-v1.6-mistral-7b-hf \ + --service-kind openai \ + --endpoint-type vision \ + --image-width-mean 512 \ + --image-width-stddev 30 \ + --image-height-mean 512 \ + --image-height-stddev 30 \ + --image-format png \ + --synthetic-input-tokens-mean 100 \ + --synthetic-input-tokens-stddev 0 \ + --streaming +``` + +> [!Note] +> Under the hood, GenAI-Perf generates synthetic images using a few source images +> under the `llm_inputs/source_images` directory. +> If you would like to add/remove/edit the source images, +> you can do so by directly editing the source images under the directory. +> GenAI-Perf will pickup the images under the directory automatically when +> generating the synthetic images. + + +## Approach 2: Bring Your Own Data (BYOD) + +Instead of letting GenAI-Perf create the synthetic data, +you can also provide GenAI-Perf with your own data using +[`--input-file`](../README.md#--input-file-path) CLI option. +The file needs to be in JSONL format and should contain both the prompt and +the filepath to the image to send. + +For instance, an example of input file would look something as following: +```bash +// input.jsonl +{"text_input": "What is in this image?", "image": "path/to/image1.png"} +{"text_input": "What is the color of the dog?", "image": "path/to/image2.jpeg"} +{"text_input": "Describe the scene in the picture.", "image": "path/to/image3.png"} +... +``` + +After you create the file, you can run GenAI-Perf using the following command: + +```bash +genai-perf profile \ + -m llava-hf/llava-v1.6-mistral-7b-hf \ + --service-kind openai \ + --endpoint-type vision \ + --input-file input.jsonl \ + --streaming +``` + +Running GenAI-Perf using either approach will give you an example output that +looks like below: + +```bash + LLM Metrics +┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓ +┃ Statistic ┃ avg ┃ min ┃ max ┃ p99 ┃ p90 ┃ p75 ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩ +│ Time to first token (ms) │ 321.05 │ 291.30 │ 537.07 │ 497.88 │ 318.46 │ 317.35 │ +│ Inter token latency (ms) │ 12.28 │ 11.44 │ 12.88 │ 12.87 │ 12.81 │ 12.53 │ +│ Request latency (ms) │ 1,866.23 │ 1,044.70 │ 2,832.22 │ 2,779.63 │ 2,534.64 │ 2,054.03 │ +│ Output sequence length │ 126.68 │ 59.00 │ 204.00 │ 200.58 │ 177.80 │ 147.50 │ +│ Input sequence length │ 100.00 │ 100.00 │ 100.00 │ 100.00 │ 100.00 │ 100.00 │ +└──────────────────────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘ +Output token throughput (per sec): 67.40 +Request throughput (per sec): 0.53 +``` From 3e2d54f3b8ec2092a92ff94d2d405e4a42c87012 Mon Sep 17 00:00:00 2001 From: Marek Wawrzos Date: Tue, 23 Jul 2024 21:26:58 +0200 Subject: [PATCH 50/55] Random image format in SyntheticImageGenerator (#759) * Sample image format at random, if the format is not selected * simplify format randomization * remove unused import * update test --------- Co-authored-by: Hyunjae Woo --- .../llm_inputs/synthetic_image_generator.py | 5 +++- .../genai-perf/genai_perf/parser.py | 4 ++-- .../genai-perf/tests/test_json_exporter.py | 2 +- .../tests/test_synthetic_image_generator.py | 24 +++++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py index 02f260497..a2df14d87 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/llm_inputs/synthetic_image_generator.py @@ -28,6 +28,7 @@ import random from enum import Enum, auto from pathlib import Path +from typing import Optional from genai_perf import utils from PIL import Image @@ -50,9 +51,11 @@ def create_synthetic_image( image_width_stddev: int, image_height_mean: int, image_height_stddev: int, - image_format: ImageFormat, + image_format: Optional[ImageFormat] = None, ) -> str: """Generate base64 encoded synthetic image using the source images.""" + if image_format is None: + image_format = random.choice(list(ImageFormat)) width = cls._sample_random_positive_integer( image_width_mean, image_width_stddev ) diff --git a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py index 7b7e02091..776535d15 100644 --- a/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py +++ b/src/c++/perf_analyzer/genai-perf/genai_perf/parser.py @@ -476,9 +476,9 @@ def _add_image_input_args(parser): "--image-format", type=str, choices=utils.get_enum_names(ImageFormat), - default="png", required=False, - help=f"The compression format of the images.", + help=f"The compression format of the images. " + "If format is not selected, format of generated image is selected at random", ) diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py index c792018ba..f82e59312 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_json_exporter.py @@ -253,7 +253,7 @@ def test_generate_json(self, monkeypatch) -> None: "image_width_stddev": 0, "image_height_mean": 100, "image_height_stddev": 0, - "image_format": "png", + "image_format": null, "concurrency": 1, "measurement_interval": 10000, "request_rate": null, diff --git a/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py b/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py index b04e65744..5a79794bb 100644 --- a/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py +++ b/src/c++/perf_analyzer/genai-perf/tests/test_synthetic_image_generator.py @@ -97,3 +97,27 @@ def test_base64_encoding_with_different_formats(image_format): img_bytes = BytesIO(img_data) image = Image.open(img_bytes) assert image.format == image_format.name + + +def test_random_image_format(): + random.seed(123) + img1 = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=100, + image_width_stddev=100, + image_height_mean=100, + image_height_stddev=100, + image_format=None, + ) + + random.seed(456) + img2 = SyntheticImageGenerator.create_synthetic_image( + image_width_mean=100, + image_width_stddev=100, + image_height_mean=100, + image_height_stddev=100, + image_format=None, + ) + + # check prefix + assert img1.startswith("data:image/png") + assert img2.startswith("data:image/jpeg") From ff99f5a64efcdee8a5b5be09d9e1bcbc3a3fd4ac Mon Sep 17 00:00:00 2001 From: Harshini Komali <157742537+lkomali@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:03:23 -0700 Subject: [PATCH 51/55] Fix: PA unit test failure due to temporary object issue (#761) * Set max_threads to concurrency * Set max_threads to concurrency * Address comments * Cleaning up PR * Fix comments * Fix Perf Analyzer base unit tests failure due to temporary object issue * Remove duplicates introduced by resolving merge conflicts --- src/c++/perf_analyzer/test_command_line_parser.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/c++/perf_analyzer/test_command_line_parser.cc b/src/c++/perf_analyzer/test_command_line_parser.cc index f697a52a7..2d17bbc24 100644 --- a/src/c++/perf_analyzer/test_command_line_parser.cc +++ b/src/c++/perf_analyzer/test_command_line_parser.cc @@ -1120,8 +1120,10 @@ TEST_CASE("Testing Command Line Parser") SUBCASE("start provided") { concurrency_range_start = 100; + std::string concurrency_range_str = + std::to_string(concurrency_range_start); args.push_back(option_name); - args.push_back(std::to_string(concurrency_range_start).data()); // start + args.push_back(concurrency_range_str.data()); // start int argc = args.size(); char* argv[argc]; From ebafa2d6d5098968c26083261914c5b1342073af Mon Sep 17 00:00:00 2001 From: Hyunjae Woo <107147848+nv-hwoo@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:31:50 -0700 Subject: [PATCH 52/55] Update GenAI-Perf README.md (#760) * list new featuers that genai-perf supports * Update build from source * update quickstart * address feedback * add link to cuda installation --- src/c++/perf_analyzer/genai-perf/README.md | 212 ++++++++++++--------- 1 file changed, 123 insertions(+), 89 deletions(-) diff --git a/src/c++/perf_analyzer/genai-perf/README.md b/src/c++/perf_analyzer/genai-perf/README.md index 1d03b3dd0..53e510541 100644 --- a/src/c++/perf_analyzer/genai-perf/README.md +++ b/src/c++/perf_analyzer/genai-perf/README.md @@ -29,13 +29,13 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # GenAI-Perf GenAI-Perf is a command line tool for measuring the throughput and latency of -generative AI models as served through an inference server. For large language -models (LLMs), GenAI-Perf provides metrics such as +generative AI models as served through an inference server. +For large language models (LLMs), GenAI-Perf provides metrics such as [output token throughput](#output_token_throughput_metric), [time to first token](#time_to_first_token_metric), [inter token latency](#inter_token_latency_metric), and -[request throughput](#request_throughput_metric). For a full list of metrics -please see the [Metrics section](#metrics). +[request throughput](#request_throughput_metric). +For a full list of metrics please see the [Metrics section](#metrics). Users specify a model name, an inference server URL, the type of inputs to use (synthetic or from dataset), and the type of load to generate (number of @@ -43,41 +43,56 @@ concurrent requests, request rate). GenAI-Perf generates the specified load, measures the performance of the inference server and reports the metrics in a simple table as console output. -The tool also logs all results in a csv file that can be used to derive +The tool also logs all results in a csv and json file that can be used to derive additional metrics and visualizations. The inference server must already be running when GenAI-Perf is run. +You can use GenAI-Perf to run performance benchmarks on +- [Large Language Models](docs/tutorial.md) +- [Vision Language Models](docs/multi_modal.md) +- [Embedding Models](docs/embeddings.md) +- [Ranking Models](docs/rankings.md) +- [Multiple LoRA Adapters](docs/lora.md) + > [!Note] > GenAI-Perf is currently in early release and under rapid development. While we > will try to remain consistent, command line options and functionality are > subject to change as the tool matures. -# Installation +
-## Triton SDK Container + -Available starting with the 24.03 release of the -[Triton Server SDK container](https://ngc.nvidia.com/catalog/containers/nvidia:tritonserver). +## Installation -Run the Triton Inference Server SDK docker container: +The easiest way to install GenAI-Perf is through +[Triton Server SDK container](https://ngc.nvidia.com/catalog/containers/nvidia:tritonserver). +Install the latest release using the following command: ```bash -export RELEASE="yy.mm" # e.g. export RELEASE="24.03" +export RELEASE="yy.mm" # e.g. export RELEASE="24.06" docker run -it --net=host --gpus=all nvcr.io/nvidia/tritonserver:${RELEASE}-py3-sdk + +# Check out genai_perf command inside the container: +genai-perf --help ```