diff --git a/cvs/cli_plugins/run_plugin.py b/cvs/cli_plugins/run_plugin.py index 72a8c8d1..ab9936b0 100644 --- a/cvs/cli_plugins/run_plugin.py +++ b/cvs/cli_plugins/run_plugin.py @@ -23,8 +23,12 @@ def get_parser(self, subparsers): ) parser.add_argument( "--log-file", - default="/tmp/cvs/test.log", - help="Pytest: Path to file for logging output (default: /tmp/cvs/test.log)", + default=None, + metavar="PATH", + help=( + "Pytest: write logging output to this file (optional). " + "Parent directories are created automatically when set." + ), ) parser.add_argument( "--log-level", diff --git a/cvs/cli_plugins/unittests/test_run_plugin.py b/cvs/cli_plugins/unittests/test_run_plugin.py index ef73ad42..57fbfa1a 100644 --- a/cvs/cli_plugins/unittests/test_run_plugin.py +++ b/cvs/cli_plugins/unittests/test_run_plugin.py @@ -79,6 +79,35 @@ def test_run_test_multiple_functions(self, mock_exit, mock_pytest_main): mock_pytest_main.assert_called_once_with(expected_args) mock_exit.assert_called_once_with(0) + @patch("cvs.cli_plugins.run_plugin.pytest.main") + @patch("cvs.cli_plugins.run_plugin.sys.exit") + def test_run_test_omits_log_file_when_not_set(self, mock_exit, mock_pytest_main): + """No --log-file is passed to pytest when the user does not request file logging.""" + args = MagicMock() + args.test = "agfhc_cvs" + args.function = [] + args.cluster_file = "/path/to/cluster.json" + args.config_file = "/path/to/config.json" + args.html = None + args.self_contained_html = False + args.log_file = None + args.log_level = None + args.capture = None + args.extra_pytest_args = [] + + mock_pytest_main.return_value = 0 + + with patch.object(self.plugin, "get_test_file", return_value="/mock/path/test.py"): + self.plugin.run(args) + + expected_args = [ + "/mock/path/test.py", + "--cluster_file=/path/to/cluster.json", + "--config_file=/path/to/config.json", + ] + mock_pytest_main.assert_called_once_with(expected_args) + mock_exit.assert_called_once_with(0) + if __name__ == "__main__": unittest.main() diff --git a/cvs/input/cluster_file/mi300x_vllm_single.json b/cvs/input/cluster_file/mi300x_vllm_single.json deleted file mode 100644 index e2096212..00000000 --- a/cvs/input/cluster_file/mi300x_vllm_single.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "_comment": "Single-node MI300X cluster for the vllm_single PoC verification. Container backend with launch:true so CVS owns the container lifecycle; the actual image+name come from the variant config.json (container block), with this file providing only the cluster portion (node/user/key). Replace every with your node/deployment specifics before running.", - - "orchestrator": "container", - "username": "{user-id}", - "priv_key_file": "/data/{user-id}/.ssh/id_ed25519", - - "head_node_dict": { - "mgmt_ip": "" - }, - "env_vars": {}, - "node_dict": { - "": { - "bmc_ip": "NA", - "vpc_ip": "" - } - }, - - "_container_comment": "Stub container block; the testsuite config (variant config.json) overrides image/name/runtime.args via OrchestratorConfig.from_configs.", - "container": { - "lifetime": "per_run", - "name": "", - "image": "", - "runtime": { - "name": "docker", - "args": { - "network": "host", - "ipc": "host", - "privileged": true - } - } - } -} diff --git a/cvs/input/config_file/inference/inferencemax/mi300x_inferencemax_gpt_oss_120b_single.json b/cvs/input/config_file/inference/inferencemax/mi300x_inferencemax_gpt_oss_120b_single.json deleted file mode 100644 index 18a4d5a8..00000000 --- a/cvs/input/config_file/inference/inferencemax/mi300x_inferencemax_gpt_oss_120b_single.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "config": { - "container_image": "rocm/7.0:rocm7.0_ubuntu_22.04_vllm_0.10.1_instinct_20250927_rc1", - "container_name": "inference_max_rocm", - "_example_nnodes": "4", - "nnodes": "4", - "inferencemax_repo": "https://github.com/SemiAnalysisAI/InferenceX.git", - "benchmark_script_repo": "https://github.com/kimbochen/bench_serving.git", - "hf_token_file": "/home/{user-id}/.hf_token", - "shm_size": "128G", - "log_dir": "/home/{user-id}/LOGS", - "container_config": { - "device_list": [ - "/dev/dri", - "/dev/kfd" - ], - "volume_dict": { - "/home/{user-id}": "/home/{user-id}" - }, - "env_dict": {} - } - }, - "benchmark_params": { - "gpt-oss-120b": { - "backend": "vllm", - "base_url": "http://0.0.0.0", - "port_no": "8000", - "_example_dataset_name": "sharegpt|hf|random|sonnet|burstgpt", - "dataset_name": "random", - "max_concurrency": "64", - "model": "openai/gpt-oss-120b", - "num_prompts": "1000", - "input_sequence_length": "8192", - "output_sequence_length": "1024", - "burstiness": "1.0", - "seed": "0", - "max_model_length": "9216", - "random_range_ratio": "0.8", - "random_prefix_len": "0", - "tensor_parallelism": "8", - "_example_tokenizer_mode": "auto|slow|mistral|custom", - "tokenizer_mode": "auto", - "percentiles_metrics": "ttft,tpot,itl,e2el", - "metric_percentiles": "99", - "server_script": "gptoss_fp4_mi300x.sh", - "bench_serv_script": "benchmark_serving.py", - "result_dict": { - "output_throughput_per_sec": "4200", - "mean_ttft_ms": "500", - "mean_tpot_ms": "15" - } - } - } -} \ No newline at end of file diff --git a/cvs/input/config_file/inference/inferencemax/mi355x_inferencemax_gpt_oss_120b_single.json b/cvs/input/config_file/inference/inferencemax/mi355x_inferencemax_gpt_oss_120b_single.json deleted file mode 100644 index 516efca2..00000000 --- a/cvs/input/config_file/inference/inferencemax/mi355x_inferencemax_gpt_oss_120b_single.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "config": { - "container_image": "rocm/7.0:rocm7.0_ubuntu_22.04_vllm_0.10.1_instinct_20250927_rc1", - "container_name": "inference_max_rocm", - "_example_nnodes": "4", - "nnodes": "4", - "inferencemax_repo": "https://github.com/SemiAnalysisAI/InferenceX.git", - "benchmark_script_repo": "https://github.com/kimbochen/bench_serving.git", - "hf_token_file": "/home/{user-id}/.hf_token", - "shm_size": "128G", - "log_dir": "/home/{user-id}/LOGS", - "container_config": { - "device_list": [ - "/dev/dri", - "/dev/kfd" - ], - "volume_dict": { - "/home/{user-id}": "/home/{user-id}" - }, - "env_dict": {} - } - }, - "benchmark_params": { - "gpt-oss-120b": { - "backend": "vllm", - "base_url": "http://0.0.0.0", - "port_no": "8000", - "_example_dataset_name": "sharegpt|hf|random|sonnet|burstgpt", - "dataset_name": "random", - "max_concurrency": "64", - "model": "openai/gpt-oss-120b", - "num_prompts": "1000", - "input_sequence_length": "8192", - "output_sequence_length": "1024", - "burstiness": "1.0", - "seed": "0", - "max_model_length": "9216", - "random_range_ratio": "0.8", - "random_prefix_len": "0", - "tensor_parallelism": "8", - "_example_tokenizer_mode": "auto|slow|mistral|custom", - "tokenizer_mode": "auto", - "percentiles_metrics": "ttft,tpot,itl,e2el", - "metric_percentiles": "99", - "server_script": "gptoss_fp4_mi355x.sh", - "bench_serv_script": "benchmark_serving.py", - "result_dict": { - "output_throughput_per_sec": "4200", - "mean_ttft_ms": "500", - "mean_tpot_ms": "15" - } - } - } -} \ No newline at end of file diff --git a/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_config.json b/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_config.json new file mode 100644 index 00000000..9e30b8c0 --- /dev/null +++ b/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_config.json @@ -0,0 +1,89 @@ +{ + "_comment": "Single-node InferenceMax (schema_version 1). Set container.image and container.name. ISL+OSL must fit params.max_model_length. Volume bind home only; ContainerOrchestrator adds /home/:/workspace.", + "schema_version": 1, + "framework": "inferencemax_single", + "gpu_arch": "mi300x", + "enforce_thresholds": false, + "paths": { + "shared_fs": "/home/{user-id}", + "models_dir": "{shared_fs}/models", + "log_dir": "{shared_fs}/LOGS", + "hf_token_file": "{shared_fs}/.hf_token" + }, + "model": { + "id": "openai/gpt-oss-120b", + "remote": 0, + "precision": "bf16" + }, + "container": { + "lifetime": "per_run", + "name": "", + "image": "", + "runtime": { + "name": "docker", + "args": { + "network": "host", + "ipc": "host", + "privileged": true, + "shm_size": "128G", + "volumes": [ + "/home/{user-id}:/home/{user-id}" + ], + "devices": [ + "/dev/dri", + "/dev/kfd" + ] + } + } + }, + "roles": { + "server": { + "serve_args": { + "enforce-eager": true, + "gpu-memory-utilization": "0.95", + "block-size": 64, + "no-enable-prefix-caching": true + }, + "env": { + "AMDGCN_USE_BUFFER_OPS": "0", + "VLLM_ROCM_USE_AITER": "1", + "VLLM_ROCM_QUICK_REDUCE_QUANTIZATION": "INT4" + } + } + }, + "params": { + "backend": "vllm", + "base_url": "http://0.0.0.0", + "port_no": "8000", + "dataset_name": "random", + "burstiness": "1.0", + "seed": "0", + "request_rate": "inf", + "random_range_ratio": "0.8", + "random_prefix_len": "0", + "tensor_parallelism": "8", + "tokenizer_mode": "auto", + "percentile_metrics": "ttft,tpot,itl,e2el", + "metric_percentiles": "99", + "num_prompts": "1000", + "max_model_length": "8192", + "client_poll_count": "50", + "client_poll_wait_time": "60", + "bench_max_failed_requests": "0" + }, + "sweep": { + "sequence_combinations": [ + { + "name": "legacy_profile", + "isl": "7168", + "osl": "1024" + } + ], + "runs": [ + { + "combo": "legacy_profile", + "concurrency": 64 + } + ] + } +} diff --git a/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_threshold.json b/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_threshold.json new file mode 100644 index 00000000..aeea7717 --- /dev/null +++ b/cvs/input/config_file/inference/inferencemax_single/mi300x_gpt_oss_120b_single/mi300x_gpt_oss_120b_single_threshold.json @@ -0,0 +1,97 @@ +{ + "_comment": "client.* thresholds for ISL=7168,OSL=1024,TP=8,CONC=64. Every GATED_METRICS member has a spec so the loader coverage check passes. config.json sets enforce_thresholds=false until calibrated; output_throughput/mean_ttft_ms/mean_tpot_ms carry legacy verify_inference_results targets.", + "ISL=7168,OSL=1024,TP=8,CONC=64": { + "client.total_token_throughput": { + "kind": "min_tok_s", + "value": 0 + }, + "client.output_throughput": { + "kind": "min_tok_s", + "value": 4200 + }, + "client.mean_ttft_ms": { + "kind": "max_ms", + "value": 500 + }, + "client.median_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_tpot_ms": { + "kind": "max_ms", + "value": 15 + }, + "client.median_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.success_rate": { + "kind": "min", + "value": 0 + }, + "client.failed": { + "kind": "max", + "value": 1000000000 + } + } +} diff --git a/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_config.json b/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_config.json new file mode 100644 index 00000000..211084f5 --- /dev/null +++ b/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_config.json @@ -0,0 +1,85 @@ +{ + "_comment": "Single-node InferenceMax sample (schema_version 1). Set container.image and container.name. Verify server_script against your InferenceX revision.", + "schema_version": 1, + "framework": "inferencemax_single", + "gpu_arch": "mi355x", + "enforce_thresholds": false, + "paths": { + "shared_fs": "/home/{user-id}", + "models_dir": "{shared_fs}/models", + "log_dir": "{shared_fs}/LOGS", + "hf_token_file": "{shared_fs}/.hf_token" + }, + "model": { + "id": "openai/gpt-oss-120b", + "remote": 0, + "precision": "bf16" + }, + "container": { + "lifetime": "per_run", + "name": "", + "image": "", + "runtime": { + "name": "docker", + "args": { + "network": "host", + "ipc": "host", + "privileged": true, + "shm_size": "128G", + "volumes": [ + "/home/{user-id}:/home/{user-id}" + ], + "devices": [ + "/dev/dri", + "/dev/kfd" + ] + } + } + }, + "roles": { + "server": { + "serve_args": { + "enforce-eager": true, + "gpu-memory-utilization": "0.92", + "block-size": 64, + "no-enable-prefix-caching": true + }, + "env": {} + } + }, + "params": { + "backend": "vllm", + "base_url": "http://0.0.0.0", + "port_no": "8000", + "dataset_name": "random", + "burstiness": "1.0", + "seed": "0", + "request_rate": "inf", + "random_range_ratio": "0.8", + "random_prefix_len": "0", + "tensor_parallelism": "8", + "tokenizer_mode": "auto", + "percentile_metrics": "ttft,tpot,itl,e2el", + "metric_percentiles": "99", + "num_prompts": "1000", + "max_model_length": "8192", + "client_poll_count": "50", + "client_poll_wait_time": "60", + "bench_max_failed_requests": "0" + }, + "sweep": { + "sequence_combinations": [ + { + "name": "legacy_profile", + "isl": "7168", + "osl": "1024" + } + ], + "runs": [ + { + "combo": "legacy_profile", + "concurrency": 64 + } + ] + } +} diff --git a/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_threshold.json b/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_threshold.json new file mode 100644 index 00000000..fac248f2 --- /dev/null +++ b/cvs/input/config_file/inference/inferencemax_single/mi355x_inferencemax_gpt_oss_120b_single/mi355x_inferencemax_gpt_oss_120b_single_threshold.json @@ -0,0 +1,97 @@ +{ + "_comment": "client.* thresholds for ISL=7168,OSL=1024,TP=8,CONC=64. Placeholder values until MI355x is calibrated; enforce_thresholds=false in config.json.", + "ISL=7168,OSL=1024,TP=8,CONC=64": { + "client.total_token_throughput": { + "kind": "min_tok_s", + "value": 0 + }, + "client.output_throughput": { + "kind": "min_tok_s", + "value": 4200 + }, + "client.mean_ttft_ms": { + "kind": "max_ms", + "value": 500 + }, + "client.median_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_tpot_ms": { + "kind": "max_ms", + "value": 15 + }, + "client.median_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.success_rate": { + "kind": "min", + "value": 0 + }, + "client.failed": { + "kind": "max", + "value": 1000000000 + } + } +} diff --git a/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_config.json b/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_config.json similarity index 72% rename from cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_config.json rename to cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_config.json index 1fa4790e..3ceeac2e 100644 --- a/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_config.json +++ b/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_config.json @@ -5,24 +5,19 @@ "enforce_thresholds": false, "paths": { "shared_fs": "/home/{user-id}", - "models_dir": "/it-share/models", + "models_dir": "{shared_fs}/models", "log_dir": "{shared_fs}/LOGS", - "benchmark_scripts_dir": "{shared_fs}/benchmark_server_scripts", - "hf_token_file": "{shared_fs}/.hf_token" + "hf_token_file": "" }, "model": { "id": "amd/Llama-3.1-70B-Instruct-FP8-KV", "remote": 0, "precision": "fp8" }, - "image": { - "tag": "rocm/vllm-dev:nightly-sshd", - "remote": 1 - }, "container": { "lifetime": "per_run", "name": "w1_llama31_70b_fp8kv_perf_inference_rocm", - "image": "rocm/vllm-dev:nightly-sshd", + "image": "", "runtime": { "name": "docker", "args": { @@ -38,7 +33,9 @@ }, "roles": { "server": { - "server_script": "llama31-70b-fp8kv_mi300x_vllm.sh" + "serve_args": { + "kv-cache-dtype": "fp8" + } } }, "params": { @@ -49,28 +46,33 @@ "burstiness": "1.0", "seed": "0", "request_rate": "inf", - "max_model_length": "2304", "random_range_ratio": "0.8", "random_prefix_len": "0", "tensor_parallelism": "8", "tokenizer_mode": "auto", "percentile_metrics": "ttft,tpot,itl,e2el", - "metric_percentiles": "99", + "metric_percentiles": "50,90,95,99", "num_prompts": "3200", "client_poll_count": "90" }, "sweep": { "sequence_combinations": [ { + "name": "w1_isl=128_osl=2048", "isl": "128", "osl": "2048", - "name": "throughput" + "goodput_slo": { + "ttft_ms": 1000000000.0, + "tpot_ms": 1000000000.0, + "e2el_ms": 1000000000.0 + } } ], - "concurrency_levels": [ - 64, - 128, - 256 + "runs": [ + { + "combo": "w1_isl=128_osl=2048", + "concurrency": 16 + } ] } } diff --git a/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_threshold.json b/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_threshold.json new file mode 100644 index 00000000..c5b9fb46 --- /dev/null +++ b/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/llama31_70b_fp8_threshold.json @@ -0,0 +1,97 @@ +{ + "_comment": "PLACEHOLDER thresholds for W1 (Llama 3.1 70B FP8-KV, in=128/out=2048, TP=8, conc=16) -- the single cell the sweep enumerates. Every gated metric (vllm_parsing.GATED_METRICS: throughput, the full latency distribution mean/median/p90/p95/p99 per ttft/tpot/itl/e2el family, and success_rate/failed health) has a spec here so the loader gated-coverage check passes. Values are NOT calibrated (AMD publishes throughput-vs-latency curves, not per-cell scalars). config.json sets enforce_thresholds=false: the suite records metrics and skips pass/fail. Replace placeholders with real numbers and flip enforce_thresholds=true to assert.", + "ISL=128,OSL=2048,TP=8,CONC=16": { + "client.total_token_throughput": { + "kind": "min_tok_s", + "value": 0 + }, + "client.output_throughput": { + "kind": "min_tok_s", + "value": 0 + }, + "client.mean_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_ttft_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_tpot_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_itl_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.mean_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.median_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p90_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p95_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.p99_e2el_ms": { + "kind": "max_ms", + "value": 1000000 + }, + "client.success_rate": { + "kind": "min", + "value": 0 + }, + "client.failed": { + "kind": "max", + "value": 1000000000 + } + } +} diff --git a/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_threshold.json b/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_threshold.json deleted file mode 100644 index 96d2674c..00000000 --- a/cvs/input/config_file/inference/vllm_single/w1_llama31_70b_fp8kv/w1_llama31_70b_fp8kv_threshold.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "_comment": "PLACEHOLDER thresholds for W1 (Llama 3.1 70B FP8-KV, in=128/out=2048, TP=8). AMD's published source (performance-results.html) gives throughput-vs-latency curves, not tabulated per-cell numbers, so these are not yet calibrated. config.json sets enforce_thresholds=false: the suite records metrics and skips pass/fail. Replace min_tok_s/max_ms with real numbers and flip enforce_thresholds=true to make this cell assert.", - "ISL=128,OSL=2048,TP=8,CONC=64": { - "total_throughput_per_sec": { - "kind": "min_tok_s", - "value": 0 - }, - "mean_ttft_ms": { - "kind": "max_ms", - "value": 1000000 - }, - "mean_tpot_ms": { - "kind": "max_ms", - "value": 1000000 - } - }, - "ISL=128,OSL=2048,TP=8,CONC=128": { - "total_throughput_per_sec": { - "kind": "min_tok_s", - "value": 0 - }, - "mean_ttft_ms": { - "kind": "max_ms", - "value": 1000000 - }, - "mean_tpot_ms": { - "kind": "max_ms", - "value": 1000000 - } - }, - "ISL=128,OSL=2048,TP=8,CONC=256": { - "total_throughput_per_sec": { - "kind": "min_tok_s", - "value": 0 - }, - "mean_ttft_ms": { - "kind": "max_ms", - "value": 1000000 - }, - "mean_tpot_ms": { - "kind": "max_ms", - "value": 1000000 - } - } -} diff --git a/cvs/lib/dtni/config_loader.py b/cvs/lib/dtni/config_loader.py deleted file mode 100644 index d1010f2a..00000000 --- a/cvs/lib/dtni/config_loader.py +++ /dev/null @@ -1,301 +0,0 @@ -''' -Copyright 2025 Advanced Micro Devices, Inc. -All rights reserved. - -Typed config loader for the dtni vllm_single PoC. - -Loads a per-variant `config.json` + sibling `*_threshold.json`, validates -shape with pydantic v2 (extra="forbid"), and runs a 3-pass placeholder -substitution: - 1. cluster placeholders (`{user-id}`) anywhere - 2. self-reference within `paths` (e.g. `{shared_fs}`) - 3. cross-block (`{paths.models_dir}`, etc.) into the rest of the doc - -A loaded variant is returned as a `VariantConfig` instance whose `container` -field (`lifetime`, `name`, `image`, `runtime`) matches the dict shape that -`cvs.core.orchestrators.factory.OrchestratorConfig` already understands. -`runtime` is a nested `RuntimeSpec`, not a flat dict; `container.model_dump()` -serialises it to the `runtime: {name, args}` shape the factory consumes (the -vllm conftest does exactly this before building the orchestrator). The result -also carries a `thresholds` field with the parsed threshold contents. - -`model.remote=1` raises NotImplementedError -- schema is present, but the -download/resolve logic lives in cvs-dtni-v1's `resource_resolver.py` and -is out of scope for this PoC. - -GENERALIZATION SEAM (do this on the SECOND consumer, not speculatively): -`Paths`/`ModelSpec`/`ImageSpec`/`ContainerSpec`/`thresholds`, the placeholder -substitution, the `enforce_thresholds` gate, and `load_variant` are -framework-agnostic. `Params`, `Sweep`, `Roles`, and `cell_key`/`expected_cells` -(the ISL/OSL/TP/CONC key shape) are vllm_single-specific. When the next -framework lands (sglang-disagg, distributed, or a training suite), split this -into a `BaseVariantConfig` (the generic half) + per-framework subclasses -carrying their own `Params`/`Sweep`/`cell_key`. Extracting now -- with one -consumer -- would be guessing the seam. -''' - -from __future__ import annotations - -import getpass -import json -import re -import warnings -from pathlib import Path -from typing import Any, Dict, List - -from pydantic import BaseModel, ConfigDict, Field, model_validator -from typing_extensions import Literal - - -# ---------- pydantic models ---------- - - -class _Forbid(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class _Allow(BaseModel): - model_config = ConfigDict(extra="allow") - - -class Paths(_Forbid): - shared_fs: str - models_dir: str - log_dir: str - benchmark_scripts_dir: str - hf_token_file: str - - -class ModelSpec(_Forbid): - id: str - remote: Literal[0, 1] - precision: str - - -class ImageSpec(_Forbid): - tag: str - remote: Literal[0, 1] - - -class RuntimeSpec(_Allow): - name: str - args: Dict[str, Any] = Field(default_factory=dict) - - -class ContainerSpec(_Forbid): - lifetime: Literal["no_launch", "per_run", "persistent"] = "per_run" - name: str - image: str - runtime: RuntimeSpec - - -class RoleServer(_Forbid): - server_script: str - - -class Roles(_Forbid): - server: RoleServer - - -class SeqCombo(_Forbid): - isl: str - osl: str - name: str - - -class Sweep(_Forbid): - sequence_combinations: List[SeqCombo] - concurrency_levels: List[int] - - -class Params(_Forbid): - backend: str = "vllm" - base_url: str = "http://0.0.0.0" - port_no: str = "8888" - dataset_name: str = "random" - burstiness: str = "1.0" - seed: str = "0" - request_rate: str = "inf" - max_model_length: str = "9216" - random_range_ratio: str = "0.8" - random_prefix_len: str = "0" - tensor_parallelism: str = "1" - tokenizer_mode: str = "auto" - percentile_metrics: str = "ttft,tpot,itl,e2el" - metric_percentiles: str = "99" - num_prompts: str = "3200" - # Completion-poll budget for the bench client = client_poll_count * 60s - # (plus a 120s initial wait). Large-output cells (high osl) need a bigger - # budget; the poll loop exits as soon as the client finishes, so raising - # this never slows down fast cells. See regressions REG-20260609-001. - client_poll_count: str = "20" - - -class VariantConfig(_Forbid): - schema_version: Literal[1] - framework: Literal["vllm_single"] - gpu_arch: str - # When false, the threshold-coverage gate warns instead of raising and the - # test records metrics without asserting pass/fail (record-only). Use for - # un-calibrated shapes (e.g. a throughput characterization whose published - # numbers are curves, not tabulated values). Default true keeps the gate - # strict for calibrated configs -- no regression to the remediation work. - enforce_thresholds: bool = True - paths: Paths - model: ModelSpec - image: ImageSpec - container: ContainerSpec - roles: Roles - params: Params - sweep: Sweep - thresholds: Dict[str, Dict[str, Any]] = Field(default_factory=dict) - - # pydantic runs @model_validator(mode="after") hooks in definition order. - # This remote check is intentionally first: an unimplemented remote config - # fails fast (NotImplementedError) before _check_thresholds_cover_sweep runs, - # which is meaningless for a config we are going to reject anyway. - @model_validator(mode="after") - def _check_remote_not_implemented(self): - if self.model.remote == 1: - raise NotImplementedError( - "model.remote=1 (remote model download) is not implemented in the PoC. " - "Port from cvs-dtni-v1/resource_resolver.py before enabling." - ) - return self - - def cell_key(self, isl, osl, concurrency): - """The canonical threshold key for one sweep cell. - - Single source of truth shared by the loader's coverage check and the - test's verdict lookup -- so the two can never drift on whitespace, - ordering, or field names. - """ - return f"ISL={isl},OSL={osl},TP={self.params.tensor_parallelism},CONC={concurrency}" - - def expected_cells(self): - """Every (isl, osl, conc) cell the sweep will parametrize.""" - return [ - self.cell_key(combo.isl, combo.osl, conc) - for combo in self.sweep.sequence_combinations - for conc in self.sweep.concurrency_levels - ] - - @model_validator(mode="after") - def _check_thresholds_cover_sweep(self): - """Fail at load time if any sweep cell lacks a threshold entry. - - Without this, a mistyped/whitespaced threshold key (or a new sweep - entry without a matching threshold) makes the test silently skip its - verdict and report a green PASS with zero assertions. - - When `enforce_thresholds` is false the same mismatch is reported as a - warning rather than an error -- the config loads as a record-only - scaffold (metrics captured, no assertions) instead of failing. - """ - expected = set(self.expected_cells()) - present = set(self.thresholds.keys()) - missing = sorted(expected - present) - extra = sorted(present - expected) - problems = [] - if missing: - problems.append(f"sweep cells with no threshold entry: {missing}") - if extra: - problems.append(f"threshold keys matching no sweep cell (typo?): {extra}") - if problems: - msg = "threshold.json does not match the sweep matrix; " + "; ".join(problems) - if self.enforce_thresholds: - raise ValueError(msg) - warnings.warn(f"{msg} (enforce_thresholds=false -> record-only)", stacklevel=2) - return self - - -# ---------- placeholder substitution ---------- - -_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z0-9_.\-]+)\}") - - -def _walk_substitute(node, mapping): - if isinstance(node, str): - - def repl(m): - key = m.group(1) - if key in mapping: - return str(mapping[key]) - return m.group(0) - - return _PLACEHOLDER_RE.sub(repl, node) - if isinstance(node, list): - return [_walk_substitute(x, mapping) for x in node] - if isinstance(node, dict): - return {k: _walk_substitute(v, mapping) for k, v in node.items()} - return node - - -def _flatten_paths(d, prefix=""): - out = {} - for k, v in d.items(): - key = f"{prefix}.{k}" if prefix else k - if isinstance(v, dict): - out.update(_flatten_paths(v, key)) - elif isinstance(v, (str, int, float)): - out[key] = str(v) - return out - - -def _resolve_cluster_mapping(cluster_dict): - raw = cluster_dict.get("username") or "{user-id}" - user = getpass.getuser() if raw == "{user-id}" else raw - return {"user-id": user} - - -# ---------- public API ---------- - - -def load_variant(config_path, cluster_dict): - """Load and validate a single variant config + its sibling threshold file. - - The threshold file is the sole `*threshold.json` next to the config (e.g. - `w1_..._threshold.json` beside `w1_..._config.json`), so config and - threshold can share a descriptive per-variant prefix. - """ - config_path = Path(config_path) - if not config_path.is_file(): - raise FileNotFoundError(f"variant config not found: {config_path}") - - raw = json.loads(config_path.read_text()) - - threshold_candidates = sorted(config_path.parent.glob("*threshold.json")) - if not threshold_candidates: - raise FileNotFoundError(f"no *threshold.json next to config: {config_path.parent}") - if len(threshold_candidates) > 1: - raise ValueError(f"multiple *threshold.json files next to config (ambiguous): {threshold_candidates}") - threshold_path = threshold_candidates[0] - thresholds = json.loads(threshold_path.read_text()) - - # Pass 1: cluster placeholders ({user-id}) everywhere. - cluster_map = _resolve_cluster_mapping(cluster_dict) - raw = _walk_substitute(raw, cluster_map) - - # Pass 2: self-reference within paths ({shared_fs} inside paths.*). - paths_block = raw.get("paths", {}) - if isinstance(paths_block, dict): - for _ in range(len(paths_block) + 1): - new = { - k: _walk_substitute(v, {pk: pv for pk, pv in paths_block.items() if isinstance(pv, str)}) - for k, v in paths_block.items() - } - if new == paths_block: - break - paths_block = new - raw["paths"] = paths_block - - # Pass 3: cross-block ({paths.models_dir} -> anywhere else). - flat_map = _flatten_paths({"paths": raw.get("paths", {})}) - raw = _walk_substitute(raw, flat_map) - - # Drop threshold-file comment keys (e.g. "_comment") before coverage check. - thresholds = {k: v for k, v in thresholds.items() if not k.startswith("_")} - - # Validate sibling thresholds, attach, build VariantConfig. - raw["thresholds"] = thresholds - return VariantConfig(**raw) diff --git a/cvs/lib/dtni/vllm_benchmark_scripts/README.md b/cvs/lib/dtni/vllm_benchmark_scripts/README.md new file mode 100644 index 00000000..f73fa132 --- /dev/null +++ b/cvs/lib/dtni/vllm_benchmark_scripts/README.md @@ -0,0 +1,17 @@ +# vLLM benchmark server scripts (shared) + +Shell entrypoints for **`vllm serve`** kept for legacy **InferenceBaseJob** flows. + +- **`vllm_serve_mi300x.sh`** — reference MI300-class server flags (``enforce-eager``, ``gpu-memory-utilization``, etc.). **InferenceMax** and **vllm_single** encode equivalent flags in ``roles.server.serve_args`` instead of running this script. + +**Client benchmarks** use ``vllm bench serve`` (stock results artifact). CVS no longer clones a third-party ``bench_serving`` git repo for InferenceMax. + +If **both** the script path and the bench CLI are unavailable, install bench-capable vLLM in the image (e.g. `pip install 'vllm[bench]'`) or bind-mount a matching `benchmarks/` tree from a vLLM checkout. + +Client invocations also pass ``--temperature 0`` so greedy sampling matches the legacy +``benchmark_serving.py`` default, and clamp ``--random-range-ratio`` when the configured +spread would let ``(ISL+OSL)*(1+r)`` exceed ``max_model_length`` (otherwise vLLM rejects +most random prompts). + +Python API: ``bundled_scripts_dir()``, ``bash_export_bench_script_from_vllm_install()``, +``clamped_bench_random_range_ratio_str()``, ``validated_bench_script_basename()``. diff --git a/cvs/lib/dtni/vllm_benchmark_scripts/__init__.py b/cvs/lib/dtni/vllm_benchmark_scripts/__init__.py new file mode 100644 index 00000000..fefcafcb --- /dev/null +++ b/cvs/lib/dtni/vllm_benchmark_scripts/__init__.py @@ -0,0 +1,200 @@ +''' +Copyright 2025 Advanced Micro Devices, Inc. +All rights reserved. + +Canonical **vLLM benchmark server** shell helpers retained for legacy +:class:`~cvs.lib.inference.base.InferenceBaseJob` flows. **InferenceMax** and +**vllm_single** (:class:`~cvs.lib.inference.vllm_orch.VllmJob`) build +``vllm serve`` in Python via ``roles.server.serve_args``; they do not stage +these scripts at runtime. + +Client load generation uses the **vLLM install’s** ``benchmarks/