From 5e26679d3fda2d70adee4d27d78d37c9c93f345b Mon Sep 17 00:00:00 2001 From: furionw Date: Tue, 15 Sep 2026 21:10:11 -0700 Subject: [PATCH 1/7] bench: compare native EC H2D overlap Co-authored-by: OpenAI Codex Signed-off-by: furionw --- benchmarks/multimodal/sweep/README.md | 15 ++ .../embedding_cache/vllm_serve.yaml | 57 ++++---- benchmarks/multimodal/sweep/orchestrator.py | 8 +- .../multimodal/sweep/repetition_plan.py | 27 ++++ benchmarks/multimodal/sweep/server.py | 12 +- .../workflows/run_vllm_ec_uuid_repetitions.sh | 131 ++++++++++++++---- .../multimodal/sweep/workflows/vllm_serve.sh | 131 ++++++++++++++++-- .../multimodal/sweep/test_orchestrator.py | 6 + .../multimodal/sweep/test_repetition_plan.py | 53 +++++++ .../multimodal/sweep/test_server.py | 52 +++++++ .../sweep/test_vllm_serve_workflow.py | 107 ++++++++++++++ 11 files changed, 529 insertions(+), 70 deletions(-) create mode 100644 benchmarks/multimodal/sweep/repetition_plan.py create mode 100644 tests/benchmarks/multimodal/sweep/test_repetition_plan.py create mode 100644 tests/benchmarks/multimodal/sweep/test_server.py create mode 100644 tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py diff --git a/benchmarks/multimodal/sweep/README.md b/benchmarks/multimodal/sweep/README.md index 40f57abe92d8..8eafc1f52555 100644 --- a/benchmarks/multimodal/sweep/README.md +++ b/benchmarks/multimodal/sweep/README.md @@ -57,6 +57,21 @@ configs: extra_args: [--no-enable-prefix-caching, --multimodal-embedding-cache-capacity-gb, "10"] ``` +### vLLM workflow environment + +| Variable | Purpose | +|---|---| +| `DYN_DISABLE_NSYS` | Set to `0` to profile the vLLM server; defaults to `1`. | +| `DYN_NSYS_BIN` | Nsight Systems executable path. | +| `DYN_NSYS_DIR` / `DYN_NSYS_TMPDIR` | Final report and temporary capture directories. | +| `DYN_NSYS_TRACE` | Nsight trace domains; defaults to `cuda,nvtx`. | +| `DYN_NSYS_OUTPUT_PREFIX` | Report prefix; the orchestrator appends the arm label. | +| `DYN_SERVER_TERMINATE_TIMEOUT` | Orchestrator shutdown timeout; defaults to 300 seconds with profiling and 15 otherwise. | +| `DYN_SERVER_SHUTDOWN_GRACE_SECONDS` | Wrapper grace period before SIGKILL; defaults to 150 seconds with profiling and 10 otherwise. | +| `DYN_PYTHON` | Python executable used by the repetition wrapper. | +| `VLLM_SOURCE_REVISION` | Required tested-vLLM revision recorded by the repetition wrapper. | + + ## CLI Overrides Any top-level YAML field can be overridden from the command line: diff --git a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml index a87b92d6cd42..2f1fa76c0c33 100644 --- a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml +++ b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Qwen3.5-122B three-arm embedding-cache comparison on 2xH100. +# Qwen3.5-122B two-arm native embedding-cache comparison on 2xH100. # Repeated image payloads carry stable UUIDs and are stripped after their first # use in each session. The mm processor cache is deliberately identical across # arms; only the post-encoder embedding-cache connector changes. @@ -13,7 +13,7 @@ conversation_num: 30 warmup_count: 2 port: 8000 timeout: 2400 -output_dir: /dynamo-tmp/logs/09-14/qwen35-122b-vllm-ec-uuid/default +output_dir: /dynamo-tmp/logs/09-15/qwen35-122b-vllm-ec-h2d-overlap/default skip_plots: true restart_server_every_benchmark: true uuid_and_strip: true @@ -79,32 +79,33 @@ configs: - --ec-transfer-config - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":4294967296}}' - - label: vllm-serve-dynamo-ec - workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh - extra_args: - - --tensor-parallel-size - - "2" - - --quantization - - fp8 - - --enable-expert-parallel - - --gpu-memory-utilization - - "0.90" - - --max-model-len - - "32768" - - --max-num-batched-tokens - - "32768" - - --max-num-seqs - - "50" - - --mm-encoder-tp-mode - - data - - --mm-processor-cache-gb - - "30" - - --limit-mm-per-prompt - - '{"image":5}' - - --no-enable-prefix-caching - - --no-enable-log-requests - - --multimodal-embedding-cache-capacity-gb - - "4" + # Dynamo EC is parked while this experiment isolates native vLLM EC. + # - label: vllm-serve-dynamo-ec + # workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + # extra_args: + # - --tensor-parallel-size + # - "2" + # - --quantization + # - fp8 + # - --enable-expert-parallel + # - --gpu-memory-utilization + # - "0.90" + # - --max-model-len + # - "32768" + # - --max-num-batched-tokens + # - "32768" + # - --max-num-seqs + # - "50" + # - --mm-encoder-tp-mode + # - data + # - --mm-processor-cache-gb + # - "30" + # - --limit-mm-per-prompt + # - '{"image":5}' + # - --no-enable-prefix-caching + # - --no-enable-log-requests + # - --multimodal-embedding-cache-capacity-gb + # - "4" # Historical cache-on/off matrix retained for reference. Keep it commented so # the canonical file continues to describe one runnable experiment. diff --git a/benchmarks/multimodal/sweep/orchestrator.py b/benchmarks/multimodal/sweep/orchestrator.py index 73c070433d01..5514f450a265 100644 --- a/benchmarks/multimodal/sweep/orchestrator.py +++ b/benchmarks/multimodal/sweep/orchestrator.py @@ -118,6 +118,10 @@ def _run_config( ) -> None: """Run all sweep values for a single benchmark config.""" workflow_abs = _resolve_workflow(bench_cfg.workflow, repo_root) + arm_env_overrides = { + **env_overrides, + "DYN_BENCHMARK_ARM": bench_cfg.label, + } _print_banner(f"Config: {bench_cfg.label}", char="#") # Collect pending runs, skipping those with existing results. @@ -151,7 +155,7 @@ def _run_config( workflow_script=workflow_abs, model=config.model, extra_args=bench_cfg.extra_args, - env_overrides=env_overrides, + env_overrides=arm_env_overrides, ) try: @@ -166,7 +170,7 @@ def _run_config( workflow_script=workflow_abs, model=config.model, extra_args=bench_cfg.extra_args, - env_overrides=env_overrides, + env_overrides=arm_env_overrides, ) try: diff --git a/benchmarks/multimodal/sweep/repetition_plan.py b/benchmarks/multimodal/sweep/repetition_plan.py new file mode 100644 index 000000000000..bdb6ef77a44a --- /dev/null +++ b/benchmarks/multimodal/sweep/repetition_plan.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + + +def balanced_config_orders( + configs: Sequence[dict[str, Any]], repetitions: int +) -> list[list[dict[str, Any]]]: + """Return deterministic rotations followed by their reverse orders.""" + if not configs: + raise ValueError("At least one benchmark config is required") + if repetitions < 1: + raise ValueError("repetitions must be positive") + + labels = [config["label"] for config in configs] + if len(labels) != len(set(labels)): + raise ValueError("Benchmark config labels must be unique") + + rotations = [ + list(configs[index:]) + list(configs[:index]) for index in range(len(configs)) + ] + balanced = rotations + [list(reversed(order)) for order in rotations] + return [balanced[index % len(balanced)] for index in range(repetitions)] diff --git a/benchmarks/multimodal/sweep/server.py b/benchmarks/multimodal/sweep/server.py index a8f1ae4ec378..2036df7ae1d6 100644 --- a/benchmarks/multimodal/sweep/server.py +++ b/benchmarks/multimodal/sweep/server.py @@ -21,6 +21,7 @@ class ServerManager: def __init__(self, port: int = 8000, timeout: int = 600) -> None: self.port = port self.timeout = timeout + self.terminate_timeout = 15.0 self._process: Optional[subprocess.Popen] = None @property @@ -50,6 +51,15 @@ def start( env = os.environ.copy() if env_overrides: env.update(env_overrides) + env["DYN_HTTP_PORT"] = str(self.port) + default_terminate_timeout = ( + 300.0 if env.get("DYN_DISABLE_NSYS", "1") != "1" else 15.0 + ) + self.terminate_timeout = float( + env.get("DYN_SERVER_TERMINATE_TIMEOUT", default_terminate_timeout) + ) + if self.terminate_timeout <= 0: + raise ValueError("DYN_SERVER_TERMINATE_TIMEOUT must be positive") print(f"Launching: {' '.join(cmd)}", flush=True) self._process = subprocess.Popen( @@ -111,7 +121,7 @@ def stop(self) -> None: pass try: - self._process.wait(timeout=15) + self._process.wait(timeout=self.terminate_timeout) except subprocess.TimeoutExpired: try: os.killpg(pid, signal.SIGKILL) diff --git a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh index 4e5d795f3486..f1b6a43a1eda 100755 --- a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh +++ b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh @@ -5,8 +5,11 @@ set -euo pipefail CONFIG="${1:-benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml}" -OUTPUT_BASE="${2:-/dynamo-tmp/logs/09-14/qwen35-122b-vllm-ec-uuid}" -REPETITIONS="${3:-3}" +OUTPUT_BASE="${2:-/dynamo-tmp/logs/09-15/qwen35-122b-vllm-ec-h2d-overlap}" +REPETITIONS="${3:-4}" +PYTHON_BIN="${DYN_PYTHON:-python}" + +: "${VLLM_SOURCE_REVISION:?VLLM_SOURCE_REVISION must identify the tested vLLM commit}" if [[ ! "$REPETITIONS" =~ ^[1-9][0-9]*$ ]]; then echo "REPETITIONS must be a positive integer, got: $REPETITIONS" >&2 @@ -14,11 +17,12 @@ if [[ ! "$REPETITIONS" =~ ^[1-9][0-9]*$ ]]; then fi mkdir -p "$OUTPUT_BASE" -python - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" <<'PY' +"$PYTHON_BIN" - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" <<'PY' import datetime import json import os import pathlib +import shutil import subprocess import sys from importlib.metadata import version @@ -27,6 +31,8 @@ import dynamo._core import vllm import yaml +from benchmarks.multimodal.sweep.repetition_plan import balanced_config_orders + output = pathlib.Path(sys.argv[1]) config_path = pathlib.Path(sys.argv[2]) output_base = pathlib.Path(sys.argv[3]) @@ -36,11 +42,10 @@ configs = config["configs"] if not configs: raise ValueError(f"No configs found in {config_path}") -rotations = [configs[index:] + configs[:index] for index in range(len(configs))] -balanced_orders = rotations + [list(reversed(order)) for order in rotations] arm_orders = [] -for iteration in range(1, repetitions + 1): - ordered_configs = balanced_orders[(iteration - 1) % len(balanced_orders)] +for iteration, ordered_configs in enumerate( + balanced_config_orders(configs, repetitions), start=1 +): labels = [item["label"] for item in ordered_configs] arm_orders.append(labels) iteration_config = dict(config) @@ -50,20 +55,21 @@ for iteration in range(1, repetitions + 1): ) metadata = { - "profile": "122b", - "model": "Qwen/Qwen3.5-122B-A10B-FP8", - "preset": "dl-H100x2", - "tp": 2, - "arms": ["vllm-serve", "vllm-serve-native-ec", "vllm-serve-dynamo-ec"], + "model": config["model"], + "arms": [item["label"] for item in configs], "arm_orders": arm_orders, - "ec_capacity_gb": 4, - "nvtx": False, - "uuid_and_strip": True, + "tensor_parallel_sizes": {}, + "ec_cpu_capacity_bytes": {}, + "nvtx": config.get("env", {}).get("DYN_DISABLE_NSYS", "1") != "1", + "uuid_and_strip": config.get("uuid_and_strip", False), "aiperf_version": subprocess.check_output( ["aiperf", "--version"], text=True ).strip(), "vllm_file": vllm.__file__, "vllm_version": vllm.__version__, + "vllm_executable": shutil.which("vllm"), + "vllm_source_revision": os.environ["VLLM_SOURCE_REVISION"], + "python_executable": sys.executable, "dynamo_core_file": dynamo._core.__file__, "dynamo_version": version("ai-dynamo"), "dynamo_runtime_version": version("ai-dynamo-runtime"), @@ -71,37 +77,112 @@ metadata = { "container_image_digest": os.environ["CONTAINER_IMAGE_DIGEST"], "container_image_file": os.environ["CONTAINER_IMAGE_FILE"], "harness_revision": os.environ["HARNESS_REVISION"], - "dataset": "/dynamo-tmp/data/30u_8t_5w_8000word_base64_uuid_seed42.jsonl", - "concurrency": 30, + "dataset": config["input_files"][0], + "concurrency": config["concurrencies"][0], "utc_start_time": datetime.datetime.now(datetime.timezone.utc).isoformat(), } +for arm in configs: + args = arm.get("extra_args", []) + label = arm["label"] + if "--tensor-parallel-size" in args: + index = args.index("--tensor-parallel-size") + metadata["tensor_parallel_sizes"][label] = int(args[index + 1]) + if "--ec-transfer-config" in args: + index = args.index("--ec-transfer-config") + ec_config = json.loads(args[index + 1]) + capacity = ec_config.get("ec_connector_extra_config", {}).get("ec_cpu_bytes") + if capacity is not None: + metadata["ec_cpu_capacity_bytes"][label] = int(capacity) output.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") PY +arms_raw="$("$PYTHON_BIN" - "$CONFIG" <<'PY' +import pathlib +import sys + +import yaml + +config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) +for item in config["configs"]: + print(item["label"]) +PY +)" +mapfile -t arms <<< "$arms_raw" +if [[ ${#arms[@]} -eq 0 || -z "${arms[0]}" ]]; then + echo "No benchmark arms found in $CONFIG" >&2 + exit 2 +fi + +shape_raw="$("$PYTHON_BIN" - "$CONFIG" <<'PY' +import json +import pathlib +import sys +from collections import defaultdict + +import yaml + +config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) +dataset = pathlib.Path(config["input_files"][0]) +seen = defaultdict(set) +included_sessions = set() +conversation_limit = config.get("conversation_num") +content = 0 +stripped = 0 +for line_index, line in enumerate(dataset.open()): + item = json.loads(line) + session_id = item.get("session_id", f"row-{line_index}") + if session_id not in included_sessions: + if ( + conversation_limit is not None + and len(included_sessions) >= conversation_limit + ): + continue + included_sessions.add(session_id) + session_seen = seen[session_id] + for image_uuid in item.get("image_uuids", []): + if image_uuid in session_seen: + stripped += 1 + else: + session_seen.add(image_uuid) + content += 1 + +print(dataset.stem.replace(" ", "_")) +print(f"concurrency{config['concurrencies'][0]}") +print(content) +print(stripped) +PY +)" +mapfile -t sweep_shape <<< "$shape_raw" +dataset_tag="${sweep_shape[0]}" +sweep_tag="${sweep_shape[1]}" +expected_content="${sweep_shape[2]}" +expected_stripped="${sweep_shape[3]}" + for ((iteration = 1; iteration <= REPETITIONS; iteration++)); do iteration_config="$OUTPUT_BASE/config-rep-$iteration.yaml" - iteration_order="$(python - "$iteration_config" <<'PY' + iteration_order="$("$PYTHON_BIN" - "$iteration_config" <<'PY' +import pathlib import sys import yaml -config = yaml.safe_load(open(sys.argv[1])) +config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) print(" -> ".join(item["label"] for item in config["configs"])) PY )" echo "[sweep] ITERATION_ORDER_${iteration}=${iteration_order}" - python -m benchmarks.multimodal.sweep \ + "$PYTHON_BIN" -m benchmarks.multimodal.sweep \ --config "$iteration_config" \ --output-dir "$OUTPUT_BASE/rep-$iteration" \ --skip-plots echo "[sweep] END_ITER_${iteration}" if [[ "$iteration" == "1" ]]; then - for arm in vllm-serve vllm-serve-native-ec vllm-serve-dynamo-ec; do - artifact="$OUTPUT_BASE/rep-1/30u_8t_5w_8000word_base64_uuid_seed42/$arm/concurrency30" - python -m benchmarks.multimodal.jsonl.validate_uuid_transport \ + for arm in "${arms[@]}"; do + artifact="$OUTPUT_BASE/rep-1/$dataset_tag/$arm/$sweep_tag" + "$PYTHON_BIN" -m benchmarks.multimodal.jsonl.validate_uuid_transport \ "$artifact/inputs.json" \ - --expect-content 360 \ - --expect-stripped 840 \ + --expect-content "$expected_content" \ + --expect-stripped "$expected_stripped" \ --output "$artifact/uuid_transport_summary.json" done echo "[sweep] UUID_TRANSPORT_VALIDATED" diff --git a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh index 313c9108fcfc..4d9cf2524b9d 100755 --- a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh +++ b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh @@ -2,12 +2,19 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Minimal vllm serve wrapper for benchmark sweeps. -# Launched by the sweep orchestrator via: bash vllm_serve.sh --model [extra_args...] +# vLLM serve wrapper for benchmark sweeps with opt-in Nsight Systems capture. + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +SWEEP_REPO_ROOT="$(readlink -f "$SCRIPT_DIR/../../../..")" +source "$SWEEP_REPO_ROOT/examples/common/gpu_utils.sh" +source "$SWEEP_REPO_ROOT/examples/common/launch_utils.sh" MODEL="" CAPACITY_GB=0 EXTRA_ARGS=() +MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}" while [[ $# -gt 0 ]]; do case "$1" in @@ -20,6 +27,18 @@ while [[ $# -gt 0 ]]; do esac done +has_gpu_mem_override=0 +for ((i = 0; i < ${#EXTRA_ARGS[@]}; i++)); do + case "${EXTRA_ARGS[$i]}" in + --max-model-len) + MAX_MODEL_LEN="${EXTRA_ARGS[$((i + 1))]}" + ;; + --gpu-memory-utilization|--kv-cache-memory-bytes) + has_gpu_mem_override=1 + ;; + esac +done + if [[ -z "$MODEL" ]]; then echo "ERROR: --model is required" >&2 exit 1 @@ -35,17 +54,101 @@ if [[ "$CAPACITY_GB" != "0" ]]; then }") fi -GPU_MEM_UTIL=".9" -KV_BYTES="${_PROFILE_OVERRIDE_VLLM_KV_CACHE_BYTES:-}" -if [[ -n "$KV_BYTES" ]]; then - GPU_MEM_ARGS="--kv-cache-memory-bytes $KV_BYTES --gpu-memory-utilization 0.01" -else - GPU_MEM_ARGS="--gpu-memory-utilization $GPU_MEM_UTIL" +GPU_MEM_ARGS="" +if [[ "$has_gpu_mem_override" == "0" ]]; then + GPU_MEM_ARGS="$(build_vllm_gpu_mem_args)" + if [[ -z "$GPU_MEM_ARGS" ]]; then + GPU_MEM_ARGS="--gpu-memory-utilization .9" + fi +fi +GPU_MEM_ARGV=() +if [[ -n "$GPU_MEM_ARGS" ]]; then + read -r -a GPU_MEM_ARGV <<< "$GPU_MEM_ARGS" +fi + +HTTP_PORT="${DYN_HTTP_PORT:-8000}" +print_launch_banner --multimodal "Launching standalone vLLM" "$MODEL" "$HTTP_PORT" + +VLLM_CMD=( + vllm serve "$MODEL" + --port "$HTTP_PORT" + --enable-log-requests +) +if [[ ! " ${EXTRA_ARGS[*]} " =~ [[:space:]]--max-model-len[[:space:]] ]]; then + VLLM_CMD+=(--max-model-len "$MAX_MODEL_LEN") fi +if [[ ${#GPU_MEM_ARGV[@]} -gt 0 ]]; then + VLLM_CMD+=("${GPU_MEM_ARGV[@]}") +fi +if [[ ${#EC_ARGS[@]} -gt 0 ]]; then + VLLM_CMD+=("${EC_ARGS[@]}") +fi +if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + VLLM_CMD+=("${EXTRA_ARGS[@]}") +fi + +LAUNCH_PREFIX=() +if [[ "${DYN_DISABLE_NSYS:-1}" != "1" ]]; then + NSYS_BIN="${DYN_NSYS_BIN:-/opt/nvidia/nsight-systems-cli/2026.2.1/bin/nsys}" + if [[ ! -x "$NSYS_BIN" ]]; then + echo "ERROR: nsys is not executable at $NSYS_BIN" >&2 + exit 1 + fi + + NSYS_DIR="${DYN_NSYS_DIR:-/dynamo-tmp/nsys}" + NSYS_TMPDIR="${DYN_NSYS_TMPDIR:-/dynamo-tmp/nsys-staging}" + NSYS_PREFIX="${DYN_NSYS_OUTPUT_PREFIX:-vllm}-${DYN_BENCHMARK_ARM:-standalone}" + mkdir -p "$NSYS_DIR" "$NSYS_TMPDIR" + export TMPDIR="$NSYS_TMPDIR" -vllm serve "$MODEL" \ - --enable-log-requests \ - --max-model-len 16384 \ - $GPU_MEM_ARGS \ - "${EC_ARGS[@]}" \ - "${EXTRA_ARGS[@]}" + timestamp="$(date +%Y%m%d_%H%M%S)" + nsys_output="$NSYS_DIR/${NSYS_PREFIX}_${timestamp}.nsys-rep" + LAUNCH_PREFIX=( + "$NSYS_BIN" profile + --trace="${DYN_NSYS_TRACE:-cuda,nvtx}" + --sample=none + --cpuctxsw=none + --kill=sigterm + --force-overwrite=true + -o "$nsys_output" + ) + echo "[nsys] vllm-serve -> $nsys_output" >&2 +fi + +server_pid=0 +cleanup() { + local exit_code="${1:-0}" + trap - EXIT INT TERM + if [[ "$server_pid" -gt 0 ]] && kill -0 "$server_pid" 2>/dev/null; then + kill -INT "$server_pid" 2>/dev/null || true + shutdown_grace="${DYN_SERVER_SHUTDOWN_GRACE_SECONDS:-}" + if [[ -z "$shutdown_grace" ]]; then + if [[ "${DYN_DISABLE_NSYS:-1}" == "1" ]]; then + shutdown_grace=10 + else + shutdown_grace=150 + fi + fi + for _ in $(seq 1 "$shutdown_grace"); do + kill -0 "$server_pid" 2>/dev/null || break + sleep 1 + done + if kill -0 "$server_pid" 2>/dev/null; then + kill -KILL "$server_pid" 2>/dev/null || true + fi + wait "$server_pid" 2>/dev/null || true + fi + exit "$exit_code" +} +trap 'cleanup 0' INT TERM +trap 'cleanup $?' EXIT + +if [[ ${#LAUNCH_PREFIX[@]} -gt 0 ]]; then + "${LAUNCH_PREFIX[@]}" "${VLLM_CMD[@]}" & +else + "${VLLM_CMD[@]}" & +fi +server_pid=$! +wait "$server_pid" +server_pid=0 +trap - EXIT diff --git a/tests/benchmarks/multimodal/sweep/test_orchestrator.py b/tests/benchmarks/multimodal/sweep/test_orchestrator.py index c1356a68aa6b..b487b96c38bb 100644 --- a/tests/benchmarks/multimodal/sweep/test_orchestrator.py +++ b/tests/benchmarks/multimodal/sweep/test_orchestrator.py @@ -211,3 +211,9 @@ def test_uuid_and_strip_propagates_to_aiperf( run_sweep(config, repo_root=tmp_path) assert mock_aiperf.call_args.kwargs["uuid_and_strip"] is True + assert ( + mock_server_cls.return_value.start.call_args.kwargs["env_overrides"][ + "DYN_BENCHMARK_ARM" + ] + == "cfg-0" + ) diff --git a/tests/benchmarks/multimodal/sweep/test_repetition_plan.py b/tests/benchmarks/multimodal/sweep/test_repetition_plan.py new file mode 100644 index 000000000000..4679ca528ec5 --- /dev/null +++ b/tests/benchmarks/multimodal/sweep/test_repetition_plan.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any + +import pytest + +from benchmarks.multimodal.sweep.repetition_plan import balanced_config_orders + +pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] + + +def _labels(orders: list[list[dict[str, Any]]]) -> list[list[str]]: + return [[config["label"] for config in order] for order in orders] + + +def test_two_arm_four_repetition_order_is_balanced() -> None: + configs = [{"label": "a"}, {"label": "b"}] + + orders = balanced_config_orders(configs, repetitions=4) + + assert _labels(orders) == [["a", "b"], ["b", "a"], ["b", "a"], ["a", "b"]] + + +def test_three_arm_orders_cover_rotations_reverses_and_wrap() -> None: + configs = [{"label": "a"}, {"label": "b"}, {"label": "c"}] + + orders = _labels(balanced_config_orders(configs, repetitions=8)) + + expected_cycle = [ + ["a", "b", "c"], + ["b", "c", "a"], + ["c", "a", "b"], + ["c", "b", "a"], + ["a", "c", "b"], + ["b", "a", "c"], + ] + assert orders == expected_cycle + expected_cycle[:2] + + +@pytest.mark.parametrize( + ("configs", "repetitions", "error"), + [ + ([], 1, "At least one"), + ([{"label": "a"}], 0, "positive"), + ([{"label": "a"}, {"label": "a"}], 1, "unique"), + ], +) +def test_invalid_repetition_plan_is_rejected( + configs: list[dict[str, Any]], repetitions: int, error: str +) -> None: + with pytest.raises(ValueError, match=error): + balanced_config_orders(configs, repetitions) diff --git a/tests/benchmarks/multimodal/sweep/test_server.py b/tests/benchmarks/multimodal/sweep/test_server.py new file mode 100644 index 000000000000..746bc888d6ac --- /dev/null +++ b/tests/benchmarks/multimodal/sweep/test_server.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from unittest.mock import MagicMock, patch + +import pytest + +from benchmarks.multimodal.sweep.server import ServerManager + +pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] + + +@pytest.mark.parametrize( + ("env", "expected"), + [ + ({"DYN_DISABLE_NSYS": "1"}, 15.0), + ({"DYN_DISABLE_NSYS": "0"}, 300.0), + ({"DYN_SERVER_TERMINATE_TIMEOUT": "42"}, 42.0), + ], +) +def test_start_resolves_termination_timeout_and_port( + tmp_path, env: dict[str, str], expected: float +) -> None: + workflow = tmp_path / "workflow.sh" + workflow.write_text("#!/bin/bash\n") + process = MagicMock() + + with ( + patch( + "benchmarks.multimodal.sweep.server.subprocess.Popen", + return_value=process, + ) as popen, + patch.object(ServerManager, "wait_for_ready"), + ): + manager = ServerManager(port=8123) + manager.start(str(workflow), "model", env_overrides=env) + + assert manager.terminate_timeout == expected + assert popen.call_args.kwargs["env"]["DYN_HTTP_PORT"] == "8123" + + +def test_start_rejects_non_positive_termination_timeout(tmp_path) -> None: + workflow = tmp_path / "workflow.sh" + workflow.write_text("#!/bin/bash\n") + manager = ServerManager() + + with pytest.raises(ValueError, match="must be positive"): + manager.start( + str(workflow), + "model", + env_overrides={"DYN_SERVER_TERMINATE_TIMEOUT": "0"}, + ) diff --git a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py new file mode 100644 index 000000000000..e223d1bc88c6 --- /dev/null +++ b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] + +REPO_ROOT = Path(__file__).parents[4] +WORKFLOW = REPO_ROOT / "benchmarks/multimodal/sweep/workflows/vllm_serve.sh" +CONFIG = REPO_ROOT / ( + "benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml" +) + + +def test_embedding_cache_sweep_selects_only_requested_arms() -> None: + config = yaml.safe_load(CONFIG.read_text()) + + assert [item["label"] for item in config["configs"]] == [ + "vllm-serve", + "vllm-serve-native-ec", + ] + assert config["env"]["DYN_DISABLE_NSYS"] == "1" + + +def _run_workflow(tmp_path: Path, *args: str, enable_nsys: bool = False): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + vllm = bin_dir / "vllm" + vllm.write_text("#!/bin/bash\nprintf '[fake-vllm] %s\\n' \"$*\"\n") + vllm.chmod(0o755) + nsys_bin = bin_dir / "nsys" + nsys_bin.write_text( + "#!/bin/bash\n" + "printf '[fake-nsys] %s\\n' \"$*\"\n" + "while [[ $# -gt 0 ]]; do\n" + ' if [[ $1 == vllm ]]; then exec "$@"; fi\n' + " shift\n" + "done\n" + "exit 2\n" + ) + nsys_bin.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "DYNAMO_HOME": str(REPO_ROOT), + "DYN_DISABLE_NSYS": "0" if enable_nsys else "1", + "DYN_NSYS_BIN": str(nsys_bin), + "DYN_NSYS_DIR": str(tmp_path / "nsys"), + "DYN_NSYS_TMPDIR": str(tmp_path / "staging"), + "DYN_NSYS_TRACE": "cuda,nvtx", + "PATH": f"{bin_dir}:{env['PATH']}", + } + ) + + result = subprocess.run( + ["bash", str(WORKFLOW), "--model", "test-model", *args], + check=True, + capture_output=True, + env=env, + text=True, + ) + + return result + + +@pytest.mark.skipif(sys.platform == "darwin", reason="workflow requires GNU readlink") +def test_vllm_workflow_profiles_only_when_enabled(tmp_path: Path) -> None: + result = _run_workflow(tmp_path, "--max-num-seqs", "2", enable_nsys=True) + + assert "[fake-nsys] profile --trace=cuda,nvtx" in result.stdout + assert "[fake-vllm] serve test-model" in result.stdout + assert "--max-num-seqs 2" in result.stdout + + +@pytest.mark.skipif(sys.platform == "darwin", reason="workflow requires GNU readlink") +def test_vllm_workflow_launches_without_profiler(tmp_path: Path) -> None: + result = _run_workflow(tmp_path, "--max-num-seqs", "2") + + assert "[fake-nsys]" not in result.stdout + assert "[fake-vllm] serve test-model" in result.stdout + assert "--port 8000" in result.stdout + + +@pytest.mark.skipif(sys.platform == "darwin", reason="workflow requires GNU readlink") +def test_vllm_workflow_builds_dynamo_ec_config(tmp_path: Path) -> None: + result = _run_workflow(tmp_path, "--multimodal-embedding-cache-capacity-gb", "4") + + assert "[fake-vllm] serve test-model" in result.stdout + assert "DynamoMultimodalEmbeddingCacheConnector" in result.stdout + assert '"multimodal_embedding_cache_capacity_gb": 4' in result.stdout + + +@pytest.mark.skipif(sys.platform == "darwin", reason="workflow requires bash 4.3") +def test_vllm_workflow_requires_model() -> None: + result = subprocess.run(["bash", str(WORKFLOW)], capture_output=True, text=True) + + assert result.returncode != 0 + assert "--model is required" in result.stderr From 9032ce63033ab4fc11590aebac8341cbb8147d18 Mon Sep 17 00:00:00 2001 From: furionw Date: Tue, 15 Sep 2026 22:05:26 -0700 Subject: [PATCH 2/7] test(bench): harden EC sweep validation Signed-off-by: furionw --- benchmarks/multimodal/sweep/README.md | 4 + benchmarks/multimodal/sweep/dataset_shape.py | 50 ++++++++++ benchmarks/multimodal/sweep/orchestrator.py | 6 +- benchmarks/multimodal/sweep/server.py | 18 +++- .../workflows/run_vllm_ec_uuid_repetitions.sh | 96 ++++++++++--------- .../multimodal/sweep/workflows/vllm_serve.sh | 30 ++++-- .../multimodal/sweep/test_dataset_shape.py | 58 +++++++++++ .../multimodal/sweep/test_server.py | 46 ++++++++- .../sweep/test_vllm_serve_workflow.py | 58 +++++++++++ 9 files changed, 305 insertions(+), 61 deletions(-) create mode 100644 tests/benchmarks/multimodal/sweep/test_dataset_shape.py diff --git a/benchmarks/multimodal/sweep/README.md b/benchmarks/multimodal/sweep/README.md index 8eafc1f52555..666125c9c826 100644 --- a/benchmarks/multimodal/sweep/README.md +++ b/benchmarks/multimodal/sweep/README.md @@ -70,6 +70,10 @@ configs: | `DYN_SERVER_SHUTDOWN_GRACE_SECONDS` | Wrapper grace period before SIGKILL; defaults to 150 seconds with profiling and 10 otherwise. | | `DYN_PYTHON` | Python executable used by the repetition wrapper. | | `VLLM_SOURCE_REVISION` | Required tested-vLLM revision recorded by the repetition wrapper. | +| `CONTAINER_IMAGE` | Required runtime image reference recorded by the repetition wrapper. | +| `CONTAINER_IMAGE_DIGEST` | Required immutable runtime image digest. | +| `CONTAINER_IMAGE_FILE` | Required imported image/squashfs path used by the GPU run. | +| `HARNESS_REVISION` | Required Dynamo benchmark-harness commit. | ## CLI Overrides diff --git a/benchmarks/multimodal/sweep/dataset_shape.py b/benchmarks/multimodal/sweep/dataset_shape.py index 5360ff2a7428..bb4bced8337d 100644 --- a/benchmarks/multimodal/sweep/dataset_shape.py +++ b/benchmarks/multimodal/sweep/dataset_shape.py @@ -4,7 +4,9 @@ from __future__ import annotations import json +from collections import defaultdict from pathlib import Path +from typing import Hashable def count_session_ids(jsonl_path: str | Path) -> int: @@ -31,3 +33,51 @@ def count_session_ids(jsonl_path: str | Path) -> int: else: sessions.add(str(sid)) return len(sessions) + anon_count + + +def count_uuid_expectations( + jsonl_path: str | Path, + conversation_num: int | None = None, +) -> tuple[int, int]: + """Count image payloads expected to be present and stripped by AIPerf. + + UUIDs are deduplicated independently within each session. Only the first + ``conversation_num`` sessions are included, matching the sequential + sampler used by the sweep. Missing or null session IDs make a row its own + anonymous session, using the same policy as :func:`count_session_ids`. + """ + if conversation_num is not None and conversation_num < 0: + raise ValueError("conversation_num must be non-negative") + + seen: dict[Hashable, set[Hashable]] = defaultdict(set) + included_sessions: set[Hashable] = set() + content = 0 + stripped = 0 + + with open(jsonl_path) as f: + for line_index, line in enumerate(f): + line = line.strip() + if not line: + continue + row = json.loads(line) + sid = row.get("session_id") + session_key: Hashable = ( + ("anonymous", line_index) if sid is None else ("session", str(sid)) + ) + if session_key not in included_sessions: + if ( + conversation_num is not None + and len(included_sessions) >= conversation_num + ): + continue + included_sessions.add(session_key) + + session_seen = seen[session_key] + for image_uuid in row.get("image_uuids", []): + if image_uuid in session_seen: + stripped += 1 + else: + session_seen.add(image_uuid) + content += 1 + + return content, stripped diff --git a/benchmarks/multimodal/sweep/orchestrator.py b/benchmarks/multimodal/sweep/orchestrator.py index 5514f450a265..2490a3bd2f08 100644 --- a/benchmarks/multimodal/sweep/orchestrator.py +++ b/benchmarks/multimodal/sweep/orchestrator.py @@ -121,6 +121,7 @@ def _run_config( arm_env_overrides = { **env_overrides, "DYN_BENCHMARK_ARM": bench_cfg.label, + "DYN_BENCHMARK_SWEEP": "multi", } _print_banner(f"Config: {bench_cfg.label}", char="#") @@ -170,7 +171,10 @@ def _run_config( workflow_script=workflow_abs, model=config.model, extra_args=bench_cfg.extra_args, - env_overrides=arm_env_overrides, + env_overrides={ + **arm_env_overrides, + "DYN_BENCHMARK_SWEEP": f"{sweep_mode}{value}", + }, ) try: diff --git a/benchmarks/multimodal/sweep/server.py b/benchmarks/multimodal/sweep/server.py index 2036df7ae1d6..24390d9b104c 100644 --- a/benchmarks/multimodal/sweep/server.py +++ b/benchmarks/multimodal/sweep/server.py @@ -52,14 +52,24 @@ def start( if env_overrides: env.update(env_overrides) env["DYN_HTTP_PORT"] = str(self.port) - default_terminate_timeout = ( - 300.0 if env.get("DYN_DISABLE_NSYS", "1") != "1" else 15.0 - ) + profiling = env.get("DYN_DISABLE_NSYS", "1") != "1" + default_terminate_timeout = 300.0 if profiling else 15.0 + default_shutdown_grace = 150.0 if profiling else 10.0 + raw_terminate_timeout = env.get("DYN_SERVER_TERMINATE_TIMEOUT") + raw_shutdown_grace = env.get("DYN_SERVER_SHUTDOWN_GRACE_SECONDS") self.terminate_timeout = float( - env.get("DYN_SERVER_TERMINATE_TIMEOUT", default_terminate_timeout) + raw_terminate_timeout or default_terminate_timeout ) + shutdown_grace = float(raw_shutdown_grace or default_shutdown_grace) if self.terminate_timeout <= 0: raise ValueError("DYN_SERVER_TERMINATE_TIMEOUT must be positive") + if shutdown_grace <= 0: + raise ValueError("DYN_SERVER_SHUTDOWN_GRACE_SECONDS must be positive") + if self.terminate_timeout <= shutdown_grace: + raise ValueError( + "DYN_SERVER_TERMINATE_TIMEOUT must exceed " + "DYN_SERVER_SHUTDOWN_GRACE_SECONDS" + ) print(f"Launching: {' '.join(cmd)}", flush=True) self._process = subprocess.Popen( diff --git a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh index f1b6a43a1eda..a3a4d9058207 100755 --- a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh +++ b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh @@ -10,6 +10,10 @@ REPETITIONS="${3:-4}" PYTHON_BIN="${DYN_PYTHON:-python}" : "${VLLM_SOURCE_REVISION:?VLLM_SOURCE_REVISION must identify the tested vLLM commit}" +: "${CONTAINER_IMAGE:?CONTAINER_IMAGE must identify the tested runtime image}" +: "${CONTAINER_IMAGE_DIGEST:?CONTAINER_IMAGE_DIGEST must identify the tested image digest}" +: "${CONTAINER_IMAGE_FILE:?CONTAINER_IMAGE_FILE must identify the imported image file}" +: "${HARNESS_REVISION:?HARNESS_REVISION must identify the benchmark harness commit}" if [[ ! "$REPETITIONS" =~ ^[1-9][0-9]*$ ]]; then echo "REPETITIONS must be a positive integer, got: $REPETITIONS" >&2 @@ -41,6 +45,9 @@ config = yaml.safe_load(config_path.read_text()) configs = config["configs"] if not configs: raise ValueError(f"No configs found in {config_path}") +concurrencies = config.get("concurrencies") +sweep_mode = "concurrency" if concurrencies else "request_rate" +sweep_values = concurrencies or config.get("request_rates") or [4, 8, 16, 32, 64] arm_orders = [] for iteration, ordered_configs in enumerate( @@ -60,7 +67,10 @@ metadata = { "arm_orders": arm_orders, "tensor_parallel_sizes": {}, "ec_cpu_capacity_bytes": {}, - "nvtx": config.get("env", {}).get("DYN_DISABLE_NSYS", "1") != "1", + "nvtx": config.get("env", {}).get( + "DYN_DISABLE_NSYS", os.environ.get("DYN_DISABLE_NSYS", "1") + ) + != "1", "uuid_and_strip": config.get("uuid_and_strip", False), "aiperf_version": subprocess.check_output( ["aiperf", "--version"], text=True @@ -77,8 +87,9 @@ metadata = { "container_image_digest": os.environ["CONTAINER_IMAGE_DIGEST"], "container_image_file": os.environ["CONTAINER_IMAGE_FILE"], "harness_revision": os.environ["HARNESS_REVISION"], - "dataset": config["input_files"][0], - "concurrency": config["concurrencies"][0], + "datasets": config["input_files"], + "sweep_mode": sweep_mode, + "sweep_values": sweep_values, "utc_start_time": datetime.datetime.now(datetime.timezone.utc).isoformat(), } for arm in configs: @@ -114,50 +125,38 @@ if [[ ${#arms[@]} -eq 0 || -z "${arms[0]}" ]]; then fi shape_raw="$("$PYTHON_BIN" - "$CONFIG" <<'PY' -import json import pathlib import sys -from collections import defaultdict import yaml +from benchmarks.multimodal.sweep.config import input_file_tag +from benchmarks.multimodal.sweep.dataset_shape import count_uuid_expectations + config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) -dataset = pathlib.Path(config["input_files"][0]) -seen = defaultdict(set) -included_sessions = set() -conversation_limit = config.get("conversation_num") -content = 0 -stripped = 0 -for line_index, line in enumerate(dataset.open()): - item = json.loads(line) - session_id = item.get("session_id", f"row-{line_index}") - if session_id not in included_sessions: - if ( - conversation_limit is not None - and len(included_sessions) >= conversation_limit - ): - continue - included_sessions.add(session_id) - session_seen = seen[session_id] - for image_uuid in item.get("image_uuids", []): - if image_uuid in session_seen: - stripped += 1 - else: - session_seen.add(image_uuid) - content += 1 - -print(dataset.stem.replace(" ", "_")) -print(f"concurrency{config['concurrencies'][0]}") -print(content) -print(stripped) +concurrencies = config.get("concurrencies") +sweep_mode = "concurrency" if concurrencies else "request_rate" +sweep_values = concurrencies or config.get("request_rates") or [4, 8, 16, 32, 64] +for dataset in config["input_files"]: + content, stripped = count_uuid_expectations( + dataset, conversation_num=config.get("conversation_num") + ) + for value in sorted(sweep_values): + print( + "\t".join( + ( + input_file_tag(dataset), + f"{sweep_mode}{value}", + str(content), + str(stripped), + ) + ) + ) PY )" -mapfile -t sweep_shape <<< "$shape_raw" -dataset_tag="${sweep_shape[0]}" -sweep_tag="${sweep_shape[1]}" -expected_content="${sweep_shape[2]}" -expected_stripped="${sweep_shape[3]}" +mapfile -t sweep_shapes <<< "$shape_raw" +nsys_output_prefix_base="${DYN_NSYS_OUTPUT_PREFIX:-vllm}" for ((iteration = 1; iteration <= REPETITIONS; iteration++)); do iteration_config="$OUTPUT_BASE/config-rep-$iteration.yaml" iteration_order="$("$PYTHON_BIN" - "$iteration_config" <<'PY' @@ -169,21 +168,26 @@ import yaml config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) print(" -> ".join(item["label"] for item in config["configs"])) PY -)" + )" echo "[sweep] ITERATION_ORDER_${iteration}=${iteration_order}" + export DYN_NSYS_OUTPUT_PREFIX="${nsys_output_prefix_base}-rep-${iteration}" "$PYTHON_BIN" -m benchmarks.multimodal.sweep \ --config "$iteration_config" \ --output-dir "$OUTPUT_BASE/rep-$iteration" \ --skip-plots echo "[sweep] END_ITER_${iteration}" if [[ "$iteration" == "1" ]]; then - for arm in "${arms[@]}"; do - artifact="$OUTPUT_BASE/rep-1/$dataset_tag/$arm/$sweep_tag" - "$PYTHON_BIN" -m benchmarks.multimodal.jsonl.validate_uuid_transport \ - "$artifact/inputs.json" \ - --expect-content "$expected_content" \ - --expect-stripped "$expected_stripped" \ - --output "$artifact/uuid_transport_summary.json" + for shape in "${sweep_shapes[@]}"; do + IFS=$'\t' read -r dataset_tag sweep_tag expected_content expected_stripped \ + <<< "$shape" + for arm in "${arms[@]}"; do + artifact="$OUTPUT_BASE/rep-1/$dataset_tag/$arm/$sweep_tag" + "$PYTHON_BIN" -m benchmarks.multimodal.jsonl.validate_uuid_transport \ + "$artifact/inputs.json" \ + --expect-content "$expected_content" \ + --expect-stripped "$expected_stripped" \ + --output "$artifact/uuid_transport_summary.json" + done done echo "[sweep] UUID_TRANSPORT_VALIDATED" fi diff --git a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh index 4d9cf2524b9d..b4234db47597 100755 --- a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh +++ b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh @@ -28,12 +28,21 @@ while [[ $# -gt 0 ]]; do done has_gpu_mem_override=0 +has_max_model_len=0 for ((i = 0; i < ${#EXTRA_ARGS[@]}; i++)); do case "${EXTRA_ARGS[$i]}" in --max-model-len) + if ((i + 1 >= ${#EXTRA_ARGS[@]})); then + echo "ERROR: --max-model-len requires a value" >&2 + exit 2 + fi + has_max_model_len=1 MAX_MODEL_LEN="${EXTRA_ARGS[$((i + 1))]}" ;; - --gpu-memory-utilization|--kv-cache-memory-bytes) + --max-model-len=*) + has_max_model_len=1 + ;; + --gpu-memory-utilization|--gpu-memory-utilization=*|--kv-cache-memory-bytes|--kv-cache-memory-bytes=*) has_gpu_mem_override=1 ;; esac @@ -74,7 +83,7 @@ VLLM_CMD=( --port "$HTTP_PORT" --enable-log-requests ) -if [[ ! " ${EXTRA_ARGS[*]} " =~ [[:space:]]--max-model-len[[:space:]] ]]; then +if [[ "$has_max_model_len" == "0" ]]; then VLLM_CMD+=(--max-model-len "$MAX_MODEL_LEN") fi if [[ ${#GPU_MEM_ARGV[@]} -gt 0 ]]; then @@ -98,6 +107,9 @@ if [[ "${DYN_DISABLE_NSYS:-1}" != "1" ]]; then NSYS_DIR="${DYN_NSYS_DIR:-/dynamo-tmp/nsys}" NSYS_TMPDIR="${DYN_NSYS_TMPDIR:-/dynamo-tmp/nsys-staging}" NSYS_PREFIX="${DYN_NSYS_OUTPUT_PREFIX:-vllm}-${DYN_BENCHMARK_ARM:-standalone}" + if [[ -n "${DYN_BENCHMARK_SWEEP:-}" ]]; then + NSYS_PREFIX="${NSYS_PREFIX}-${DYN_BENCHMARK_SWEEP}" + fi mkdir -p "$NSYS_DIR" "$NSYS_TMPDIR" export TMPDIR="$NSYS_TMPDIR" @@ -119,8 +131,8 @@ server_pid=0 cleanup() { local exit_code="${1:-0}" trap - EXIT INT TERM - if [[ "$server_pid" -gt 0 ]] && kill -0 "$server_pid" 2>/dev/null; then - kill -INT "$server_pid" 2>/dev/null || true + if [[ "$server_pid" -gt 0 ]] && kill -0 -- "-$server_pid" 2>/dev/null; then + kill -INT -- "-$server_pid" 2>/dev/null || true shutdown_grace="${DYN_SERVER_SHUTDOWN_GRACE_SECONDS:-}" if [[ -z "$shutdown_grace" ]]; then if [[ "${DYN_DISABLE_NSYS:-1}" == "1" ]]; then @@ -130,11 +142,11 @@ cleanup() { fi fi for _ in $(seq 1 "$shutdown_grace"); do - kill -0 "$server_pid" 2>/dev/null || break + kill -0 -- "-$server_pid" 2>/dev/null || break sleep 1 done - if kill -0 "$server_pid" 2>/dev/null; then - kill -KILL "$server_pid" 2>/dev/null || true + if kill -0 -- "-$server_pid" 2>/dev/null; then + kill -KILL -- "-$server_pid" 2>/dev/null || true fi wait "$server_pid" 2>/dev/null || true fi @@ -144,9 +156,9 @@ trap 'cleanup 0' INT TERM trap 'cleanup $?' EXIT if [[ ${#LAUNCH_PREFIX[@]} -gt 0 ]]; then - "${LAUNCH_PREFIX[@]}" "${VLLM_CMD[@]}" & + setsid "${LAUNCH_PREFIX[@]}" "${VLLM_CMD[@]}" & else - "${VLLM_CMD[@]}" & + setsid "${VLLM_CMD[@]}" & fi server_pid=$! wait "$server_pid" diff --git a/tests/benchmarks/multimodal/sweep/test_dataset_shape.py b/tests/benchmarks/multimodal/sweep/test_dataset_shape.py new file mode 100644 index 000000000000..3d80d1db6262 --- /dev/null +++ b/tests/benchmarks/multimodal/sweep/test_dataset_shape.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import json + +import pytest + +from benchmarks.multimodal.sweep.dataset_shape import count_uuid_expectations + +pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] + + +def _write_jsonl(tmp_path, rows: list[dict]) -> str: + dataset = tmp_path / "dataset.jsonl" + dataset.write_text("".join(json.dumps(row) + "\n" for row in rows)) + return str(dataset) + + +def test_uuid_expectations_are_deduplicated_per_session(tmp_path) -> None: + dataset = _write_jsonl( + tmp_path, + [ + {"session_id": "a", "image_uuids": ["x", "y"]}, + {"session_id": "b", "image_uuids": ["x"]}, + {"session_id": "a", "image_uuids": ["x", "z"]}, + {"session_id": "b", "image_uuids": ["x", "y"]}, + ], + ) + + assert count_uuid_expectations(dataset) == (5, 2) + + +def test_uuid_expectations_respect_conversation_limit(tmp_path) -> None: + dataset = _write_jsonl( + tmp_path, + [ + {"session_id": "a", "image_uuids": ["a0"]}, + {"session_id": "b", "image_uuids": ["b0"]}, + {"session_id": "a", "image_uuids": ["a0"]}, + {"session_id": "c", "image_uuids": ["c0"]}, + ], + ) + + assert count_uuid_expectations(dataset, conversation_num=2) == (2, 1) + + +def test_uuid_expectations_treat_null_and_missing_sessions_as_rows(tmp_path) -> None: + dataset = _write_jsonl( + tmp_path, + [ + {"session_id": None, "image_uuids": ["x", "x"]}, + {"image_uuids": ["x"]}, + {"session_id": 7, "image_uuids": ["z"]}, + {"session_id": "7", "image_uuids": ["z"]}, + ], + ) + + assert count_uuid_expectations(dataset) == (3, 2) diff --git a/tests/benchmarks/multimodal/sweep/test_server.py b/tests/benchmarks/multimodal/sweep/test_server.py index 746bc888d6ac..fb4b8fffe63a 100644 --- a/tests/benchmarks/multimodal/sweep/test_server.py +++ b/tests/benchmarks/multimodal/sweep/test_server.py @@ -26,6 +26,7 @@ def test_start_resolves_termination_timeout_and_port( process = MagicMock() with ( + patch.dict("os.environ", {}, clear=True), patch( "benchmarks.multimodal.sweep.server.subprocess.Popen", return_value=process, @@ -45,8 +46,51 @@ def test_start_rejects_non_positive_termination_timeout(tmp_path) -> None: manager = ServerManager() with pytest.raises(ValueError, match="must be positive"): + with patch.dict("os.environ", {}, clear=True): + manager.start( + str(workflow), + "model", + env_overrides={"DYN_SERVER_TERMINATE_TIMEOUT": "0"}, + ) + + +def test_start_rejects_timeout_shorter_than_wrapper_grace(tmp_path) -> None: + workflow = tmp_path / "workflow.sh" + workflow.write_text("#!/bin/bash\n") + manager = ServerManager() + + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="must exceed"), + ): manager.start( str(workflow), "model", - env_overrides={"DYN_SERVER_TERMINATE_TIMEOUT": "0"}, + env_overrides={ + "DYN_SERVER_TERMINATE_TIMEOUT": "15", + "DYN_SERVER_SHUTDOWN_GRACE_SECONDS": "20", + }, ) + + +def test_start_treats_empty_timeout_override_as_unset(tmp_path) -> None: + workflow = tmp_path / "workflow.sh" + workflow.write_text("#!/bin/bash\n") + process = MagicMock() + + with ( + patch.dict("os.environ", {}, clear=True), + patch( + "benchmarks.multimodal.sweep.server.subprocess.Popen", + return_value=process, + ), + patch.object(ServerManager, "wait_for_ready"), + ): + manager = ServerManager() + manager.start( + str(workflow), + "model", + env_overrides={"DYN_SERVER_TERMINATE_TIMEOUT": ""}, + ) + + assert manager.terminate_timeout == 15.0 diff --git a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py index e223d1bc88c6..3cf11a29bd6e 100644 --- a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py +++ b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py @@ -4,8 +4,10 @@ from __future__ import annotations import os +import signal import subprocess import sys +import time from pathlib import Path import pytest @@ -105,3 +107,59 @@ def test_vllm_workflow_requires_model() -> None: assert result.returncode != 0 assert "--model is required" in result.stderr + + +@pytest.mark.skipif(sys.platform == "darwin", reason="workflow requires GNU setsid") +def test_vllm_workflow_kills_server_group_after_grace(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + child_pid_file = tmp_path / "child.pid" + vllm = bin_dir / "vllm" + vllm.write_text( + "#!/bin/bash\n" + 'echo $$ > "$DYN_TEST_CHILD_PID_FILE"\n' + "trap '' INT TERM\n" + "while true; do sleep 60; done\n" + ) + vllm.chmod(0o755) + env = os.environ.copy() + env.update( + { + "DYNAMO_HOME": str(REPO_ROOT), + "DYN_DISABLE_NSYS": "1", + "DYN_SERVER_SHUTDOWN_GRACE_SECONDS": "1", + "DYN_TEST_CHILD_PID_FILE": str(child_pid_file), + "PATH": f"{bin_dir}:{env['PATH']}", + } + ) + process = subprocess.Popen( + ["bash", str(WORKFLOW), "--model", "test-model"], + env=env, + start_new_session=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + child_pid: int | None = None + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and not child_pid_file.exists(): + time.sleep(0.05) + assert child_pid_file.exists() + child_pid = int(child_pid_file.read_text()) + + os.killpg(process.pid, signal.SIGTERM) + process.communicate(timeout=5) + + assert process.returncode == 0 + with pytest.raises(ProcessLookupError): + os.kill(child_pid, 0) + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + if child_pid is not None: + try: + os.killpg(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass From 048a4da875304b25824b2c85d7ddc6e1e9cc57af Mon Sep 17 00:00:00 2001 From: furionw Date: Wed, 16 Sep 2026 00:17:25 -0700 Subject: [PATCH 3/7] perf(bench): compare baseline and overlapped EC Signed-off-by: furionw --- benchmarks/multimodal/sweep/README.md | 8 +++- benchmarks/multimodal/sweep/config.py | 2 + .../embedding_cache/vllm_serve.yaml | 45 +++++++++++++++++-- benchmarks/multimodal/sweep/orchestrator.py | 17 +++++++ .../multimodal/sweep/repetition_plan.py | 26 +++++++++++ .../workflows/run_vllm_ec_uuid_repetitions.sh | 32 +++++++++++-- .../multimodal/sweep/test_orchestrator.py | 18 +++++++- .../multimodal/sweep/test_repetition_plan.py | 27 ++++++++++- .../sweep/test_vllm_serve_workflow.py | 5 ++- 9 files changed, 168 insertions(+), 12 deletions(-) diff --git a/benchmarks/multimodal/sweep/README.md b/benchmarks/multimodal/sweep/README.md index 666125c9c826..45e39b8304ff 100644 --- a/benchmarks/multimodal/sweep/README.md +++ b/benchmarks/multimodal/sweep/README.md @@ -46,7 +46,8 @@ input_files: - benchmarks/multimodal/jsonl/1000req_1img_200pool_400word_http.jsonl - benchmarks/multimodal/jsonl/1000req_4img_200pool_400word_http.jsonl -# Each config launches the workflow with its own extra_args +# Each config launches the workflow with its own extra_args and optional env. +# Per-arm env values expand variables from the harness process. configs: - label: cache-off workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh @@ -54,6 +55,8 @@ configs: - label: cache-on workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + env: + PYTHONPATH: "${VLLM_PATCHED_PYTHONPATH}" extra_args: [--no-enable-prefix-caching, --multimodal-embedding-cache-capacity-gb, "10"] ``` @@ -69,7 +72,10 @@ configs: | `DYN_SERVER_TERMINATE_TIMEOUT` | Orchestrator shutdown timeout; defaults to 300 seconds with profiling and 15 otherwise. | | `DYN_SERVER_SHUTDOWN_GRACE_SECONDS` | Wrapper grace period before SIGKILL; defaults to 150 seconds with profiling and 10 otherwise. | | `DYN_PYTHON` | Python executable used by the repetition wrapper. | +| `DYN_BENCHMARK_ORDER_SEED` | Seed for randomized per-repetition arm order; defaults to 42. | | `VLLM_SOURCE_REVISION` | Required tested-vLLM revision recorded by the repetition wrapper. | +| `VLLM_BASELINE_SOURCE_REVISION` | Required baseline vLLM revision for the native-EC comparison. | +| `VLLM_BASELINE_PYTHONPATH` / `VLLM_PATCHED_PYTHONPATH` | Source trees selected by the baseline and overlap arms. | | `CONTAINER_IMAGE` | Required runtime image reference recorded by the repetition wrapper. | | `CONTAINER_IMAGE_DIGEST` | Required immutable runtime image digest. | | `CONTAINER_IMAGE_FILE` | Required imported image/squashfs path used by the GPU run. | diff --git a/benchmarks/multimodal/sweep/config.py b/benchmarks/multimodal/sweep/config.py index 23e5e83edfb8..b231b82fd59c 100644 --- a/benchmarks/multimodal/sweep/config.py +++ b/benchmarks/multimodal/sweep/config.py @@ -18,6 +18,7 @@ class BenchmarkConfig: label: str workflow: str extra_args: List[str] = field(default_factory=list) + env: Dict[str, str] = field(default_factory=dict) @dataclass @@ -96,6 +97,7 @@ def _parse_benchmark_config(raw: Dict[str, Any]) -> BenchmarkConfig: label=raw["label"], workflow=raw["workflow"], extra_args=[str(a) for a in raw.get("extra_args", [])], + env={str(k): str(v) for k, v in raw.get("env", {}).items()}, ) diff --git a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml index 2f1fa76c0c33..281adcd639f4 100644 --- a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml +++ b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml @@ -1,10 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Qwen3.5-122B two-arm native embedding-cache comparison on 2xH100. +# Qwen3.5-122B three-arm native embedding-cache comparison on 2xH100. # Repeated image payloads carry stable UUIDs and are stripped after their first -# use in each session. The mm processor cache is deliberately identical across -# arms; only the post-encoder embedding-cache connector changes. +# use in each session. The baseline source tree runs both the no-EC control and +# synchronous native EC; the patched tree runs native EC with same-step H2D / +# vision-encoder overlap. All other vLLM arguments are identical. model: Qwen/Qwen3.5-122B-A10B-FP8 concurrencies: [30] @@ -29,6 +30,9 @@ input_files: configs: - label: vllm-serve workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + env: + PYTHONPATH: "${VLLM_BASELINE_PYTHONPATH}" + DYN_VLLM_SOURCE_REVISION: "${VLLM_BASELINE_SOURCE_REVISION}" extra_args: - --tensor-parallel-size - "2" @@ -52,8 +56,41 @@ configs: - --no-enable-prefix-caching - --no-enable-log-requests - - label: vllm-serve-native-ec + - label: vllm-serve-native-ec-baseline workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + env: + PYTHONPATH: "${VLLM_BASELINE_PYTHONPATH}" + DYN_VLLM_SOURCE_REVISION: "${VLLM_BASELINE_SOURCE_REVISION}" + extra_args: + - --tensor-parallel-size + - "2" + - --quantization + - fp8 + - --enable-expert-parallel + - --gpu-memory-utilization + - "0.90" + - --max-model-len + - "32768" + - --max-num-batched-tokens + - "32768" + - --max-num-seqs + - "50" + - --mm-encoder-tp-mode + - data + - --mm-processor-cache-gb + - "30" + - --limit-mm-per-prompt + - '{"image":5}' + - --no-enable-prefix-caching + - --no-enable-log-requests + - --ec-transfer-config + - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":4294967296}}' + + - label: vllm-serve-native-ec-overlap + workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + env: + PYTHONPATH: "${VLLM_PATCHED_PYTHONPATH}" + DYN_VLLM_SOURCE_REVISION: "${VLLM_PATCHED_SOURCE_REVISION}" extra_args: - --tensor-parallel-size - "2" diff --git a/benchmarks/multimodal/sweep/orchestrator.py b/benchmarks/multimodal/sweep/orchestrator.py index 2490a3bd2f08..0d67b137487e 100644 --- a/benchmarks/multimodal/sweep/orchestrator.py +++ b/benchmarks/multimodal/sweep/orchestrator.py @@ -3,6 +3,8 @@ from __future__ import annotations +import os +import re from pathlib import Path from typing import List, Optional @@ -45,6 +47,20 @@ def _print_banner(title: str, char: str = "=", width: int = 70) -> None: print(f"{char * width}", flush=True) +def _expand_arm_env(env: dict[str, str]) -> dict[str, str]: + """Expand host variables in per-arm environment values.""" + expanded = {key: os.path.expandvars(value) for key, value in env.items()} + unresolved = { + key: value + for key, value in expanded.items() + if re.search(r"\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^}]+\})", value) + } + if unresolved: + names = ", ".join(sorted(unresolved)) + raise ValueError(f"Unresolved variables in benchmark arm env: {names}") + return expanded + + def run_sweep( config: SweepConfig, repo_root: Optional[Path] = None, @@ -120,6 +136,7 @@ def _run_config( workflow_abs = _resolve_workflow(bench_cfg.workflow, repo_root) arm_env_overrides = { **env_overrides, + **_expand_arm_env(bench_cfg.env), "DYN_BENCHMARK_ARM": bench_cfg.label, "DYN_BENCHMARK_SWEEP": "multi", } diff --git a/benchmarks/multimodal/sweep/repetition_plan.py b/benchmarks/multimodal/sweep/repetition_plan.py index bdb6ef77a44a..25ca772cc987 100644 --- a/benchmarks/multimodal/sweep/repetition_plan.py +++ b/benchmarks/multimodal/sweep/repetition_plan.py @@ -3,6 +3,8 @@ from __future__ import annotations +import itertools +import random from collections.abc import Sequence from typing import Any @@ -25,3 +27,27 @@ def balanced_config_orders( ] balanced = rotations + [list(reversed(order)) for order in rotations] return [balanced[index % len(balanced)] for index in range(repetitions)] + + +def randomized_config_orders( + configs: Sequence[dict[str, Any]], repetitions: int, seed: int +) -> list[list[dict[str, Any]]]: + """Return seeded random orders, avoiding repeats until all orders are used.""" + if not configs: + raise ValueError("At least one benchmark config is required") + if repetitions < 1: + raise ValueError("repetitions must be positive") + + labels = [config["label"] for config in configs] + if len(labels) != len(set(labels)): + raise ValueError("Benchmark config labels must be unique") + + rng = random.Random(seed) + # Sweep configs are intentionally small. Enumerating permutations gives a + # reproducible sample without replacement for each complete cycle. + permutations = list(itertools.permutations(configs)) + orders: list[list[dict[str, Any]]] = [] + while len(orders) < repetitions: + rng.shuffle(permutations) + orders.extend(list(order) for order in permutations) + return orders[:repetitions] diff --git a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh index a3a4d9058207..b912c3dae3d6 100755 --- a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh +++ b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh @@ -6,10 +6,14 @@ set -euo pipefail CONFIG="${1:-benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml}" OUTPUT_BASE="${2:-/dynamo-tmp/logs/09-15/qwen35-122b-vllm-ec-h2d-overlap}" -REPETITIONS="${3:-4}" +REPETITIONS="${3:-5}" PYTHON_BIN="${DYN_PYTHON:-python}" +ORDER_SEED="${DYN_BENCHMARK_ORDER_SEED:-42}" : "${VLLM_SOURCE_REVISION:?VLLM_SOURCE_REVISION must identify the tested vLLM commit}" +: "${VLLM_BASELINE_SOURCE_REVISION:?VLLM_BASELINE_SOURCE_REVISION must identify the baseline vLLM commit}" +: "${VLLM_BASELINE_PYTHONPATH:?VLLM_BASELINE_PYTHONPATH must select the baseline vLLM tree}" +: "${VLLM_PATCHED_PYTHONPATH:?VLLM_PATCHED_PYTHONPATH must select the patched vLLM tree}" : "${CONTAINER_IMAGE:?CONTAINER_IMAGE must identify the tested runtime image}" : "${CONTAINER_IMAGE_DIGEST:?CONTAINER_IMAGE_DIGEST must identify the tested image digest}" : "${CONTAINER_IMAGE_FILE:?CONTAINER_IMAGE_FILE must identify the imported image file}" @@ -19,9 +23,15 @@ if [[ ! "$REPETITIONS" =~ ^[1-9][0-9]*$ ]]; then echo "REPETITIONS must be a positive integer, got: $REPETITIONS" >&2 exit 2 fi +if [[ ! "$ORDER_SEED" =~ ^[0-9]+$ ]]; then + echo "DYN_BENCHMARK_ORDER_SEED must be a non-negative integer, got: $ORDER_SEED" >&2 + exit 2 +fi + +export VLLM_PATCHED_SOURCE_REVISION="${VLLM_PATCHED_SOURCE_REVISION:-$VLLM_SOURCE_REVISION}" mkdir -p "$OUTPUT_BASE" -"$PYTHON_BIN" - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" <<'PY' +"$PYTHON_BIN" - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" "$ORDER_SEED" <<'PY' import datetime import json import os @@ -35,12 +45,13 @@ import dynamo._core import vllm import yaml -from benchmarks.multimodal.sweep.repetition_plan import balanced_config_orders +from benchmarks.multimodal.sweep.repetition_plan import randomized_config_orders output = pathlib.Path(sys.argv[1]) config_path = pathlib.Path(sys.argv[2]) output_base = pathlib.Path(sys.argv[3]) repetitions = int(sys.argv[4]) +order_seed = int(sys.argv[5]) config = yaml.safe_load(config_path.read_text()) configs = config["configs"] if not configs: @@ -51,7 +62,7 @@ sweep_values = concurrencies or config.get("request_rates") or [4, 8, 16, 32, 64 arm_orders = [] for iteration, ordered_configs in enumerate( - balanced_config_orders(configs, repetitions), start=1 + randomized_config_orders(configs, repetitions, order_seed), start=1 ): labels = [item["label"] for item in ordered_configs] arm_orders.append(labels) @@ -65,6 +76,7 @@ metadata = { "model": config["model"], "arms": [item["label"] for item in configs], "arm_orders": arm_orders, + "arm_order_seed": order_seed, "tensor_parallel_sizes": {}, "ec_cpu_capacity_bytes": {}, "nvtx": config.get("env", {}).get( @@ -79,6 +91,8 @@ metadata = { "vllm_version": vllm.__version__, "vllm_executable": shutil.which("vllm"), "vllm_source_revision": os.environ["VLLM_SOURCE_REVISION"], + "vllm_source_revisions": {}, + "vllm_pythonpaths": {}, "python_executable": sys.executable, "dynamo_core_file": dynamo._core.__file__, "dynamo_version": version("ai-dynamo"), @@ -95,6 +109,16 @@ metadata = { for arm in configs: args = arm.get("extra_args", []) label = arm["label"] + arm_env = { + key: os.path.expandvars(str(value)) + for key, value in arm.get("env", {}).items() + } + if "DYN_VLLM_SOURCE_REVISION" in arm_env: + metadata["vllm_source_revisions"][label] = arm_env[ + "DYN_VLLM_SOURCE_REVISION" + ] + if "PYTHONPATH" in arm_env: + metadata["vllm_pythonpaths"][label] = arm_env["PYTHONPATH"] if "--tensor-parallel-size" in args: index = args.index("--tensor-parallel-size") metadata["tensor_parallel_sizes"][label] = int(args[index + 1]) diff --git a/tests/benchmarks/multimodal/sweep/test_orchestrator.py b/tests/benchmarks/multimodal/sweep/test_orchestrator.py index b487b96c38bb..0dc99d96e6fc 100644 --- a/tests/benchmarks/multimodal/sweep/test_orchestrator.py +++ b/tests/benchmarks/multimodal/sweep/test_orchestrator.py @@ -10,7 +10,7 @@ import pytest from benchmarks.multimodal.sweep.config import BenchmarkConfig, SweepConfig -from benchmarks.multimodal.sweep.orchestrator import run_sweep +from benchmarks.multimodal.sweep.orchestrator import _expand_arm_env, run_sweep pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] @@ -217,3 +217,19 @@ def test_uuid_and_strip_propagates_to_aiperf( ] == "cfg-0" ) + + +def test_per_arm_env_expands_and_overrides_top_level( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VLLM_TREE", "/workspace/vllm-baseline") + + assert _expand_arm_env({"PYTHONPATH": "${VLLM_TREE}", "DYN_SOURCE": "base"}) == { + "PYTHONPATH": "/workspace/vllm-baseline", + "DYN_SOURCE": "base", + } + + +def test_per_arm_env_rejects_unresolved_variables() -> None: + with pytest.raises(ValueError, match="PYTHONPATH"): + _expand_arm_env({"PYTHONPATH": "${MISSING_VLLM_TREE}"}) diff --git a/tests/benchmarks/multimodal/sweep/test_repetition_plan.py b/tests/benchmarks/multimodal/sweep/test_repetition_plan.py index 4679ca528ec5..926a408dfc59 100644 --- a/tests/benchmarks/multimodal/sweep/test_repetition_plan.py +++ b/tests/benchmarks/multimodal/sweep/test_repetition_plan.py @@ -5,7 +5,10 @@ import pytest -from benchmarks.multimodal.sweep.repetition_plan import balanced_config_orders +from benchmarks.multimodal.sweep.repetition_plan import ( + balanced_config_orders, + randomized_config_orders, +) pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] @@ -38,6 +41,26 @@ def test_three_arm_orders_cover_rotations_reverses_and_wrap() -> None: assert orders == expected_cycle + expected_cycle[:2] +def test_three_arm_five_repetition_order_is_seeded_and_unique() -> None: + configs = [{"label": "a"}, {"label": "b"}, {"label": "c"}] + + first = _labels(randomized_config_orders(configs, repetitions=5, seed=42)) + second = _labels(randomized_config_orders(configs, repetitions=5, seed=42)) + + assert first == second + assert len({tuple(order) for order in first}) == 5 + assert all(sorted(order) == ["a", "b", "c"] for order in first) + + +def test_randomized_orders_wrap_after_all_permutations() -> None: + configs = [{"label": "a"}, {"label": "b"}] + + orders = _labels(randomized_config_orders(configs, repetitions=5, seed=7)) + + assert all(sorted(order) == ["a", "b"] for order in orders) + assert len(orders) == 5 + + @pytest.mark.parametrize( ("configs", "repetitions", "error"), [ @@ -51,3 +74,5 @@ def test_invalid_repetition_plan_is_rejected( ) -> None: with pytest.raises(ValueError, match=error): balanced_config_orders(configs, repetitions) + with pytest.raises(ValueError, match=error): + randomized_config_orders(configs, repetitions, seed=42) diff --git a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py index 3cf11a29bd6e..97341f0cb72c 100644 --- a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py +++ b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py @@ -27,9 +27,12 @@ def test_embedding_cache_sweep_selects_only_requested_arms() -> None: assert [item["label"] for item in config["configs"]] == [ "vllm-serve", - "vllm-serve-native-ec", + "vllm-serve-native-ec-baseline", + "vllm-serve-native-ec-overlap", ] assert config["env"]["DYN_DISABLE_NSYS"] == "1" + assert config["configs"][0]["env"]["PYTHONPATH"] == ("${VLLM_BASELINE_PYTHONPATH}") + assert config["configs"][2]["env"]["PYTHONPATH"] == ("${VLLM_PATCHED_PYTHONPATH}") def _run_workflow(tmp_path: Path, *args: str, enable_nsys: bool = False): From ebf79e62e2fdd6db36305654add400ae1d9491d7 Mon Sep 17 00:00:00 2001 From: furionw Date: Wed, 16 Sep 2026 14:20:07 -0700 Subject: [PATCH 4/7] perf(bench): record GPU placement metadata --- .../embedding_cache/vllm_serve.yaml | 2 +- .../workflows/run_vllm_ec_uuid_repetitions.sh | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml index 281adcd639f4..d3de4729aa79 100644 --- a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml +++ b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Qwen3.5-122B three-arm native embedding-cache comparison on 2xH100. +# Qwen3.5-122B three-arm native embedding-cache comparison on two GPUs. # Repeated image payloads carry stable UUIDs and are stripped after their first # use in each session. The baseline source tree runs both the no-EC control and # synchronous native EC; the patched tree runs native EC with same-step H2D / diff --git a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh index b912c3dae3d6..1729cb87e7f7 100755 --- a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh +++ b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh @@ -36,6 +36,7 @@ import datetime import json import os import pathlib +import platform import shutil import subprocess import sys @@ -60,6 +61,23 @@ concurrencies = config.get("concurrencies") sweep_mode = "concurrency" if concurrencies else "request_rate" sweep_values = concurrencies or config.get("request_rates") or [4, 8, 16, 32, 64] + +def command_output(args: list[str]) -> str | None: + try: + return subprocess.check_output(args, text=True).strip() + except (OSError, subprocess.CalledProcessError): + return None + + +gpu_inventory = command_output( + [ + "nvidia-smi", + "--query-gpu=index,uuid,pci.bus_id,name,memory.total", + "--format=csv,noheader,nounits", + ] +) +gpu_topology = command_output(["nvidia-smi", "topo", "-m"]) + arm_orders = [] for iteration, ordered_configs in enumerate( randomized_config_orders(configs, repetitions, order_seed), start=1 @@ -101,6 +119,13 @@ metadata = { "container_image_digest": os.environ["CONTAINER_IMAGE_DIGEST"], "container_image_file": os.environ["CONTAINER_IMAGE_FILE"], "harness_revision": os.environ["HARNESS_REVISION"], + "node": os.environ.get( + "SLURMD_NODENAME", os.environ.get("SLURM_NODELIST") + ), + "platform_machine": platform.machine(), + "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES"), + "gpu_inventory": gpu_inventory.splitlines() if gpu_inventory else [], + "gpu_topology": gpu_topology, "datasets": config["input_files"], "sweep_mode": sweep_mode, "sweep_values": sweep_values, @@ -194,6 +219,7 @@ print(" -> ".join(item["label"] for item in config["configs"])) PY )" echo "[sweep] ITERATION_ORDER_${iteration}=${iteration_order}" + echo "[sweep] CUDA_VISIBLE_DEVICES_${iteration}=${CUDA_VISIBLE_DEVICES:-}" export DYN_NSYS_OUTPUT_PREFIX="${nsys_output_prefix_base}-rep-${iteration}" "$PYTHON_BIN" -m benchmarks.multimodal.sweep \ --config "$iteration_config" \ From 5c5a32dcf2e88fd9a4ef0706cb994ff79d59178d Mon Sep 17 00:00:00 2001 From: furionw Date: Wed, 16 Sep 2026 23:06:38 -0700 Subject: [PATCH 5/7] perf(benchmarks): add shared-prefix EC overlap sweep --- .../multimodal/jsonl/generate_images.py | 3 +- .../jsonl/prepare_ec_h2d_overlap.py | 265 ++++++++++++++++++ benchmarks/multimodal/sweep/config.py | 16 ++ .../embedding_cache/vllm_serve.yaml | 101 ++++--- benchmarks/multimodal/sweep/orchestrator.py | 20 ++ benchmarks/multimodal/sweep/server.py | 64 ++++- .../workflows/run_vllm_ec_uuid_repetitions.sh | 96 ++++++- .../multimodal/sweep/workflows/vllm_serve.sh | 14 + .../benchmarks/multimodal/jsonl/test_main.py | 11 + .../jsonl/test_prepare_ec_h2d_overlap.py | 75 +++++ .../multimodal/sweep/test_orchestrator.py | 27 ++ .../multimodal/sweep/test_server.py | 75 +++++ .../sweep/test_vllm_serve_workflow.py | 13 +- 13 files changed, 728 insertions(+), 52 deletions(-) create mode 100755 benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py create mode 100644 tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py diff --git a/benchmarks/multimodal/jsonl/generate_images.py b/benchmarks/multimodal/jsonl/generate_images.py index 6c63fdc171d6..056014f2bb4a 100644 --- a/benchmarks/multimodal/jsonl/generate_images.py +++ b/benchmarks/multimodal/jsonl/generate_images.py @@ -36,10 +36,11 @@ def generate_image_pool_base64( ) -> list[str]: """Generate pool_size random PNG files and return their paths.""" image_dir.mkdir(parents=True, exist_ok=True) + width, height = image_size pool: list[str] = [] for idx in range(pool_size): path = image_dir / f"img_{idx:04d}.png" - pixels = np_rng.integers(0, 256, (*image_size, 3), dtype=np.uint8) + pixels = np_rng.integers(0, 256, (height, width, 3), dtype=np.uint8) Image.fromarray(pixels).save(path) pool.append(str(path.resolve())) print( diff --git a/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py b/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py new file mode 100755 index 000000000000..413868a71f9a --- /dev/null +++ b/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare deterministic workloads for the native CPU EC overlap benchmark.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Protocol + +import numpy as np + +try: + from transformers import AutoTokenizer +except ModuleNotFoundError: # Optional for tokenizer-independent unit tests. + AutoTokenizer = None + +from benchmarks.multimodal.jsonl.generate_images import ( + compute_image_uuid, + generate_image_pool_base64, +) + +DEFAULT_MODEL = "Qwen/Qwen3.5-122B-A10B-FP8" +DEFAULT_OUTPUT_DIR = Path("/dynamo-tmp/data") +DEFAULT_IMAGE_DIR = DEFAULT_OUTPUT_DIR / "ec_h2d_overlap_images_2400x1080_seed42" +SYSTEM_PROMPT_TOKENS = 8000 +USER_TEXT = "Describe the newest image and summarize only its visible content." +SYSTEM_CONTEXT = ( + " The attached images may contain objects, text, diagrams, tables, labels," + " quantities, and annotations. Consider visual details in context, distinguish" + " similar elements carefully, and report only information supported by the images." +) + + +class Tokenizer(Protocol): + chat_template: str | dict[str, str] | None + init_kwargs: dict[str, Any] + + def encode(self, text: str, *, add_special_tokens: bool = False) -> list[int]: + ... + + def decode( + self, + ids: Sequence[int], + *, + skip_special_tokens: bool = True, + clean_up_tokenization_spaces: bool = False, + ) -> str: + ... + + def apply_chat_template(self, conversation: list[dict[str, str]], **kwargs: Any): + ... + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def count_tokens(tokenizer: Tokenizer, text: str) -> int: + return len(tokenizer.encode(text, add_special_tokens=False)) + + +def make_exact_text(tokenizer: Tokenizer, target_tokens: int) -> str: + """Return deterministic text with exactly ``target_tokens`` tokenizer tokens.""" + if target_tokens <= 0: + raise ValueError("target_tokens must be positive") + + unit_tokens = count_tokens(tokenizer, SYSTEM_CONTEXT) + repeats = max(2, target_tokens // unit_tokens + 2) + keep = target_tokens + for _ in range(64): + ids = tokenizer.encode(SYSTEM_CONTEXT * repeats, add_special_tokens=False) + while len(ids) < keep: + repeats *= 2 + ids = tokenizer.encode(SYSTEM_CONTEXT * repeats, add_special_tokens=False) + text = tokenizer.decode( + ids[:keep], + skip_special_tokens=True, + clean_up_tokenization_spaces=False, + ) + observed = count_tokens(tokenizer, text) + if observed == target_tokens: + return text + keep += target_tokens - observed + raise RuntimeError(f"could not construct exact {target_tokens}-token text") + + +def build_chat_template(base_template: str, system_prompt: str) -> str: + """Prepend the benchmark system message without changing client payloads.""" + prompt_literal = json.dumps(system_prompt, ensure_ascii=False) + prefix = ( + "{%- if not messages or messages[0]['role'] != 'system' -%}" + "{%- set messages = [{'role': 'system', 'content': " + f"{prompt_literal}" + "}] + messages -%}" + "{%- endif -%}\n" + ) + return prefix + base_template + + +def write_sliding_dataset( + path: Path, + image_pool: Sequence[str], + *, + num_users: int, + turns_per_user: int, + window_size: int, + images_per_user: int, + user_text: str, +) -> dict[str, int]: + """Write one turn-major sliding-window dataset from a shared image pool.""" + required_images = num_users * images_per_user + if len(image_pool) < required_images: + raise ValueError( + f"image pool has {len(image_pool)} entries; {required_images} required" + ) + if window_size + turns_per_user - 1 > images_per_user: + raise ValueError("images_per_user cannot cover every sliding window") + + path.parent.mkdir(parents=True, exist_ok=True) + unique_refs: set[str] = set() + rows = 0 + with path.open("w", encoding="utf-8") as output: + for turn_idx in range(turns_per_user): + for user_idx in range(num_users): + offset = user_idx * images_per_user + turn_idx + images = list(image_pool[offset : offset + window_size]) + unique_refs.update(images) + row = { + "session_id": f"user_{user_idx}", + "text": user_text, + "images": images, + "image_uuids": [compute_image_uuid(ref) for ref in images], + } + output.write(json.dumps(row, separators=(",", ":")) + "\n") + rows += 1 + + total_slots = rows * window_size + return { + "rows": rows, + "unique_images": len(unique_refs), + "content_images": len(unique_refs), + "stripped_images": total_slots - len(unique_refs), + } + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + parser.add_argument("--image-dir", type=Path, default=DEFAULT_IMAGE_DIR) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--force", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + args.output_dir.mkdir(parents=True, exist_ok=True) + + prompt_path = args.output_dir / "qwen35_shared_system_8000.txt" + template_path = args.output_dir / "qwen35_shared_system_8000.jinja" + manifest_path = args.output_dir / "qwen35_ec_h2d_overlap_manifest.json" + dataset_paths = { + 5: args.output_dir / "30u_8t_5w_shared8k_base64_uuid_seed42.jsonl", + 10: args.output_dir / "30u_8t_10w_shared8k_base64_uuid_seed42.jsonl", + } + artifacts = [prompt_path, template_path, manifest_path, *dataset_paths.values()] + if not args.force and all(path.is_file() for path in artifacts): + print(f"All workload artifacts already exist; keeping {manifest_path}") + return + + if AutoTokenizer is None: + raise RuntimeError("transformers is required to prepare EC workloads") + tokenizer = AutoTokenizer.from_pretrained( + args.model, + trust_remote_code=True, + local_files_only=True, + ) + base_template = tokenizer.chat_template + if not isinstance(base_template, str): + raise TypeError("target tokenizer must expose one string chat_template") + + system_prompt = make_exact_text(tokenizer, SYSTEM_PROMPT_TOKENS) + prompt_path.write_text(system_prompt, encoding="utf-8") + template = build_chat_template(base_template, system_prompt) + template_path.write_text(template, encoding="utf-8") + + rendered_ids = tokenizer.apply_chat_template( + [{"role": "user", "content": USER_TEXT}], + chat_template=template, + tokenize=True, + add_generation_prompt=True, + ) + + num_users = 30 + turns_per_user = 8 + max_window_size = max(dataset_paths) + images_per_user = max_window_size + turns_per_user - 1 + image_pool = generate_image_pool_base64( + np.random.default_rng(args.seed), + num_users * images_per_user, + args.image_dir, + (2400, 1080), + ) + + datasets: dict[str, dict[str, Any]] = {} + for window_size, path in dataset_paths.items(): + counts = write_sliding_dataset( + path, + image_pool, + num_users=num_users, + turns_per_user=turns_per_user, + window_size=window_size, + images_per_user=images_per_user, + user_text=USER_TEXT, + ) + datasets[str(window_size)] = { + "path": str(path), + "sha256": _sha256(path), + "window_size": window_size, + **counts, + } + + manifest = { + "model": args.model, + "tokenizer_commit": tokenizer.init_kwargs.get("_commit_hash"), + "seed": args.seed, + "system_prompt": { + "path": str(prompt_path), + "tokens": count_tokens(tokenizer, system_prompt), + "sha256": _sha256(prompt_path), + }, + "chat_template": { + "path": str(template_path), + "sha256": _sha256(template_path), + }, + "user_text": USER_TEXT, + "user_text_tokens": count_tokens(tokenizer, USER_TEXT), + "rendered_text_prompt_tokens": len(rendered_ids), + "images": { + "directory": str(args.image_dir), + "count": len(image_pool), + "width": 2400, + "height": 1080, + }, + "datasets": datasets, + } + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(manifest, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/multimodal/sweep/config.py b/benchmarks/multimodal/sweep/config.py index b231b82fd59c..0e983281278c 100644 --- a/benchmarks/multimodal/sweep/config.py +++ b/benchmarks/multimodal/sweep/config.py @@ -43,6 +43,8 @@ class SweepConfig: skip_plots: bool = False restart_server_every_benchmark: bool = True uuid_and_strip: bool = False + prompt_manifest: Optional[str] = None + prefix_cache_probe_min_cached_tokens: Optional[int] = None env: Dict[str, str] = field(default_factory=dict) @property @@ -70,6 +72,16 @@ def validate(self, repo_root: Optional[Path] = None) -> None: if not Path(f).is_file(): raise FileNotFoundError(f"Input file not found: {f}") + if self.prompt_manifest and not Path(self.prompt_manifest).is_file(): + raise FileNotFoundError( + f"Prompt manifest not found: {self.prompt_manifest}" + ) + if ( + self.prefix_cache_probe_min_cached_tokens is not None + and self.prefix_cache_probe_min_cached_tokens <= 0 + ): + raise ValueError("prefix_cache_probe_min_cached_tokens must be positive") + for cfg in self.configs: script = Path(cfg.workflow) if repo_root and not script.is_absolute(): @@ -140,6 +152,10 @@ def load_config( skip_plots=raw.get("skip_plots", False), restart_server_every_benchmark=raw.get("restart_server_every_benchmark", True), uuid_and_strip=raw.get("uuid_and_strip", False), + prompt_manifest=raw.get("prompt_manifest"), + prefix_cache_probe_min_cached_tokens=raw.get( + "prefix_cache_probe_min_cached_tokens" + ), env=raw.get("env", {}), ) diff --git a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml index d3de4729aa79..e49622da2371 100644 --- a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml +++ b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml @@ -1,11 +1,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Qwen3.5-122B three-arm native embedding-cache comparison on two GPUs. -# Repeated image payloads carry stable UUIDs and are stripped after their first -# use in each session. The baseline source tree runs both the no-EC control and -# synchronous native EC; the patched tree runs native EC with same-step H2D / -# vision-encoder overlap. All other vLLM arguments are identical. +# Qwen3.5-122B native CPU embedding-cache comparison on two GPUs. A custom +# server-side chat template injects the same 8000-token system prompt into +# every request while AIPerf keeps its stock input-file and UUID-strip paths. +# The only measured difference is the vLLM source tree: synchronous native EC +# baseline versus same-step H2D / vision-encoder overlap. model: Qwen/Qwen3.5-122B-A10B-FP8 concurrencies: [30] @@ -14,10 +14,12 @@ conversation_num: 30 warmup_count: 2 port: 8000 timeout: 2400 -output_dir: /dynamo-tmp/logs/09-15/qwen35-122b-vllm-ec-h2d-overlap/default +output_dir: /dynamo-tmp/logs/09-16/qwen35-122b-vllm-ec-h2d-overlap-shared8k/default skip_plots: true restart_server_every_benchmark: true uuid_and_strip: true +prompt_manifest: /dynamo-tmp/data/qwen35_ec_h2d_overlap_manifest.json +prefix_cache_probe_min_cached_tokens: 7936 env: DYN_DISABLE_NSYS: "1" @@ -25,42 +27,50 @@ env: VLLM_USE_V2_MODEL_RUNNER: "1" input_files: - - /dynamo-tmp/data/30u_8t_5w_8000word_base64_uuid_seed42.jsonl + - /dynamo-tmp/data/30u_8t_5w_shared8k_base64_uuid_seed42.jsonl + - /dynamo-tmp/data/30u_8t_10w_shared8k_base64_uuid_seed42.jsonl configs: - - label: vllm-serve - workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh - env: - PYTHONPATH: "${VLLM_BASELINE_PYTHONPATH}" - DYN_VLLM_SOURCE_REVISION: "${VLLM_BASELINE_SOURCE_REVISION}" - extra_args: - - --tensor-parallel-size - - "2" - - --quantization - - fp8 - - --enable-expert-parallel - - --gpu-memory-utilization - - "0.90" - - --max-model-len - - "32768" - - --max-num-batched-tokens - - "32768" - - --max-num-seqs - - "50" - - --mm-encoder-tp-mode - - data - - --mm-processor-cache-gb - - "30" - - --limit-mm-per-prompt - - '{"image":5}' - - --no-enable-prefix-caching - - --no-enable-log-requests + # The no-EC control is intentionally parked: this experiment isolates the + # scheduling difference between two otherwise identical native EC servers. + # - label: vllm-serve + # workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh + # env: + # PYTHONPATH: "${VLLM_BASELINE_PYTHONPATH}" + # DYN_VLLM_SOURCE_REVISION: "${VLLM_BASELINE_SOURCE_REVISION}" + # DYN_VLLM_EXPECTED_ROOT: "${VLLM_BASELINE_PYTHONPATH}" + # extra_args: + # - --tensor-parallel-size + # - "2" + # - --quantization + # - fp8 + # - --enable-expert-parallel + # - --gpu-memory-utilization + # - "0.90" + # - --max-model-len + # - "65536" + # - --max-num-batched-tokens + # - "32768" + # - --max-num-seqs + # - "50" + # - --mm-encoder-tp-mode + # - data + # - --mm-processor-cache-gb + # - "30" + # - --limit-mm-per-prompt + # - '{"image":10}' + # - --chat-template + # - /dynamo-tmp/data/qwen35_shared_system_8000.jinja + # - --enable-prefix-caching + # - --enable-prompt-tokens-details + # - --no-enable-log-requests - label: vllm-serve-native-ec-baseline workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh env: PYTHONPATH: "${VLLM_BASELINE_PYTHONPATH}" DYN_VLLM_SOURCE_REVISION: "${VLLM_BASELINE_SOURCE_REVISION}" + DYN_VLLM_EXPECTED_ROOT: "${VLLM_BASELINE_PYTHONPATH}" extra_args: - --tensor-parallel-size - "2" @@ -70,7 +80,7 @@ configs: - --gpu-memory-utilization - "0.90" - --max-model-len - - "32768" + - "65536" - --max-num-batched-tokens - "32768" - --max-num-seqs @@ -80,17 +90,21 @@ configs: - --mm-processor-cache-gb - "30" - --limit-mm-per-prompt - - '{"image":5}' - - --no-enable-prefix-caching + - '{"image":10}' + - --chat-template + - /dynamo-tmp/data/qwen35_shared_system_8000.jinja + - --enable-prefix-caching + - --enable-prompt-tokens-details - --no-enable-log-requests - --ec-transfer-config - - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":4294967296}}' + - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":8589934592}}' - label: vllm-serve-native-ec-overlap workflow: benchmarks/multimodal/sweep/workflows/vllm_serve.sh env: PYTHONPATH: "${VLLM_PATCHED_PYTHONPATH}" DYN_VLLM_SOURCE_REVISION: "${VLLM_PATCHED_SOURCE_REVISION}" + DYN_VLLM_EXPECTED_ROOT: "${VLLM_PATCHED_PYTHONPATH}" extra_args: - --tensor-parallel-size - "2" @@ -100,7 +114,7 @@ configs: - --gpu-memory-utilization - "0.90" - --max-model-len - - "32768" + - "65536" - --max-num-batched-tokens - "32768" - --max-num-seqs @@ -110,11 +124,14 @@ configs: - --mm-processor-cache-gb - "30" - --limit-mm-per-prompt - - '{"image":5}' - - --no-enable-prefix-caching + - '{"image":10}' + - --chat-template + - /dynamo-tmp/data/qwen35_shared_system_8000.jinja + - --enable-prefix-caching + - --enable-prompt-tokens-details - --no-enable-log-requests - --ec-transfer-config - - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":4294967296}}' + - '{"ec_connector":"ECCPUConnector","ec_role":"ec_both","ec_connector_extra_config":{"ec_cpu_bytes":8589934592}}' # Dynamo EC is parked while this experiment isolates native vLLM EC. # - label: vllm-serve-dynamo-ec diff --git a/benchmarks/multimodal/sweep/orchestrator.py b/benchmarks/multimodal/sweep/orchestrator.py index 0d67b137487e..8a1dccaecac0 100644 --- a/benchmarks/multimodal/sweep/orchestrator.py +++ b/benchmarks/multimodal/sweep/orchestrator.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import re from pathlib import Path @@ -47,6 +48,18 @@ def _print_banner(title: str, char: str = "=", width: int = 70) -> None: print(f"{char * width}", flush=True) +def _first_user_text(input_file: str) -> str: + with open(input_file, encoding="utf-8") as source: + for line in source: + if not line.strip(): + continue + value = json.loads(line).get("text") + if not isinstance(value, str): + raise ValueError(f"First row in {input_file} has no string text field") + return value + raise ValueError(f"Dataset is empty: {input_file}") + + def _expand_arm_env(env: dict[str, str]) -> dict[str, str]: """Expand host variables in per-arm environment values.""" expanded = {key: os.path.expandvars(value) for key, value in env.items()} @@ -195,6 +208,13 @@ def _run_config( ) try: + if config.prefix_cache_probe_min_cached_tokens is not None: + server.validate_prefix_cache( + model=config.model, + user_text=_first_user_text(input_file), + min_cached_tokens=(config.prefix_cache_probe_min_cached_tokens), + output_path=artifact_dir / "prefix_cache_probe.json", + ) run_aiperf_single( model=config.model, port=config.port, diff --git a/benchmarks/multimodal/sweep/server.py b/benchmarks/multimodal/sweep/server.py index 24390d9b104c..cc7bc0d78db3 100644 --- a/benchmarks/multimodal/sweep/server.py +++ b/benchmarks/multimodal/sweep/server.py @@ -3,12 +3,15 @@ from __future__ import annotations +import json import os import signal import subprocess import time +import urllib.error +import urllib.request from pathlib import Path -from typing import List, Optional +from typing import Any, List, Optional class ServerManager: @@ -82,9 +85,6 @@ def start( def wait_for_ready(self, model: str) -> None: """Poll /v1/models until the expected model name appears.""" - import urllib.error - import urllib.request - url = f"http://localhost:{self.port}/v1/models" deadline = time.monotonic() + self.timeout @@ -114,6 +114,62 @@ def wait_for_ready(self, model: str) -> None: self.stop() raise TimeoutError(f"Server did not become ready within {self.timeout}s") + def validate_prefix_cache( + self, + model: str, + user_text: str, + min_cached_tokens: int, + output_path: Path, + ) -> dict[str, Any]: + """Warm and verify the shared text prefix through chat completions.""" + if min_cached_tokens <= 0: + raise ValueError("min_cached_tokens must be positive") + + url = f"http://localhost:{self.port}/v1/chat/completions" + payload = { + "model": model, + "messages": [{"role": "user", "content": user_text}], + "max_tokens": 1, + "temperature": 0, + "stream": False, + } + + def send() -> dict[str, Any]: + request = urllib.request.Request( + url, + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + raise RuntimeError( + f"prefix-cache probe failed with HTTP {exc.code}: {body}" + ) from exc + + responses = [send(), send()] + usages = [response.get("usage", {}) for response in responses] + cached_tokens = (usages[1].get("prompt_tokens_details") or {}).get( + "cached_tokens", 0 + ) + summary = { + "minimum_cached_tokens": min_cached_tokens, + "first_usage": usages[0], + "second_usage": usages[1], + "passed": cached_tokens >= min_cached_tokens, + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + if not summary["passed"]: + raise RuntimeError( + "prefix-cache probe cached " + f"{cached_tokens} tokens; expected at least {min_cached_tokens}" + ) + print(f"Prefix-cache probe passed: cached_tokens={cached_tokens}", flush=True) + return summary + def stop(self) -> None: """Stop the server by killing its process group.""" if self._process is None: diff --git a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh index 1729cb87e7f7..0c4a2d306beb 100755 --- a/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh +++ b/benchmarks/multimodal/sweep/workflows/run_vllm_ec_uuid_repetitions.sh @@ -5,8 +5,10 @@ set -euo pipefail CONFIG="${1:-benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml}" -OUTPUT_BASE="${2:-/dynamo-tmp/logs/09-15/qwen35-122b-vllm-ec-h2d-overlap}" +OUTPUT_BASE="${2:-/dynamo-tmp/logs/09-16/qwen35-122b-vllm-ec-h2d-overlap-shared8k}" REPETITIONS="${3:-5}" +DATASET_FILTER="${4:-}" +PROFILE="${5:-0}" PYTHON_BIN="${DYN_PYTHON:-python}" ORDER_SEED="${DYN_BENCHMARK_ORDER_SEED:-42}" @@ -27,12 +29,17 @@ if [[ ! "$ORDER_SEED" =~ ^[0-9]+$ ]]; then echo "DYN_BENCHMARK_ORDER_SEED must be a non-negative integer, got: $ORDER_SEED" >&2 exit 2 fi +if [[ "$PROFILE" != "0" && "$PROFILE" != "1" ]]; then + echo "PROFILE must be 0 or 1, got: $PROFILE" >&2 + exit 2 +fi export VLLM_PATCHED_SOURCE_REVISION="${VLLM_PATCHED_SOURCE_REVISION:-$VLLM_SOURCE_REVISION}" mkdir -p "$OUTPUT_BASE" -"$PYTHON_BIN" - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" "$ORDER_SEED" <<'PY' +"$PYTHON_BIN" - "$OUTPUT_BASE/run_metadata.json" "$CONFIG" "$OUTPUT_BASE" "$REPETITIONS" "$ORDER_SEED" "$DATASET_FILTER" "$PROFILE" <<'PY' import datetime +import hashlib import json import os import pathlib @@ -53,7 +60,24 @@ config_path = pathlib.Path(sys.argv[2]) output_base = pathlib.Path(sys.argv[3]) repetitions = int(sys.argv[4]) order_seed = int(sys.argv[5]) +dataset_filter = sys.argv[6] +profile = sys.argv[7] == "1" config = yaml.safe_load(config_path.read_text()) +if dataset_filter: + selected = [ + path + for path in config["input_files"] + if path == dataset_filter or pathlib.Path(path).name == dataset_filter + ] + if len(selected) != 1: + raise ValueError( + f"Dataset filter {dataset_filter!r} matched {len(selected)} inputs" + ) + config["input_files"] = selected +if profile: + config.setdefault("env", {})["DYN_DISABLE_NSYS"] = "0" + config["env"]["DYN_NSYS_TRACE"] = "cuda,nvtx" + config["env"]["DYN_NSYS_DIR"] = str(output_base / "nsys") configs = config["configs"] if not configs: raise ValueError(f"No configs found in {config_path}") @@ -69,6 +93,14 @@ def command_output(args: list[str]) -> str | None: return None +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + gpu_inventory = command_output( [ "nvidia-smi", @@ -127,10 +159,21 @@ metadata = { "gpu_inventory": gpu_inventory.splitlines() if gpu_inventory else [], "gpu_topology": gpu_topology, "datasets": config["input_files"], + "dataset_sha256": { + path: sha256(pathlib.Path(path)) for path in config["input_files"] + }, "sweep_mode": sweep_mode, "sweep_values": sweep_values, "utc_start_time": datetime.datetime.now(datetime.timezone.utc).isoformat(), } +prompt_manifest = config.get("prompt_manifest") +if prompt_manifest: + prompt_manifest_path = pathlib.Path(prompt_manifest) + metadata["prompt_manifest"] = prompt_manifest + metadata["prompt_manifest_sha256"] = sha256(prompt_manifest_path) + metadata["prompt_manifest_contents"] = json.loads( + prompt_manifest_path.read_text() + ) for arm in configs: args = arm.get("extra_args", []) label = arm["label"] @@ -173,7 +216,7 @@ if [[ ${#arms[@]} -eq 0 || -z "${arms[0]}" ]]; then exit 2 fi -shape_raw="$("$PYTHON_BIN" - "$CONFIG" <<'PY' +shape_raw="$("$PYTHON_BIN" - "$OUTPUT_BASE/config-rep-1.yaml" <<'PY' import pathlib import sys @@ -225,6 +268,53 @@ PY --config "$iteration_config" \ --output-dir "$OUTPUT_BASE/rep-$iteration" \ --skip-plots + "$PYTHON_BIN" - "$iteration_config" "$OUTPUT_BASE/rep-$iteration" <<'PY' +import json +import pathlib +import sys + +import yaml + +from benchmarks.multimodal.sweep.config import input_file_tag + +config = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) +output = pathlib.Path(sys.argv[2]) +concurrencies = config.get("concurrencies") +sweep_mode = "concurrency" if concurrencies else "request_rate" +sweep_values = concurrencies or config.get("request_rates") or [4, 8, 16, 32, 64] +for dataset in config["input_files"]: + expected_requests = sum( + 1 for line in pathlib.Path(dataset).read_text().splitlines() if line.strip() + ) + for arm in config["configs"]: + for value in sweep_values: + artifact = ( + output + / input_file_tag(dataset) + / arm["label"] + / f"{sweep_mode}{value}" + ) + profile_path = artifact / "profile_export_aiperf.json" + profile = json.loads(profile_path.read_text()) + completed = int(profile["request_count"]["avg"]) + if completed != expected_requests: + raise ValueError( + f"{profile_path}: completed {completed}, expected {expected_requests}" + ) + if profile.get("was_cancelled"): + raise ValueError(f"{profile_path}: benchmark was cancelled") + if profile.get("error_summary"): + raise ValueError( + f"{profile_path}: errors present: {profile['error_summary']}" + ) + minimum = config.get("prefix_cache_probe_min_cached_tokens") + if minimum is not None: + probe_path = artifact / "prefix_cache_probe.json" + probe = json.loads(probe_path.read_text()) + if not probe.get("passed"): + raise ValueError(f"{probe_path}: prefix-cache probe failed") +print("[sweep] ARTIFACTS_VALIDATED") +PY echo "[sweep] END_ITER_${iteration}" if [[ "$iteration" == "1" ]]; then for shape in "${sweep_shapes[@]}"; do diff --git a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh index b4234db47597..e7ce6f30bd76 100755 --- a/benchmarks/multimodal/sweep/workflows/vllm_serve.sh +++ b/benchmarks/multimodal/sweep/workflows/vllm_serve.sh @@ -53,6 +53,20 @@ if [[ -z "$MODEL" ]]; then exit 1 fi +if [[ -n "${DYN_VLLM_EXPECTED_ROOT:-}" ]]; then + expected_root="${DYN_VLLM_EXPECTED_ROOT%%:*}" + active_vllm="$(python -c 'import pathlib, vllm; print(pathlib.Path(vllm.__file__).resolve())')" + case "$active_vllm" in + "$expected_root"/*) ;; + *) + echo "ERROR: active vLLM $active_vllm is not under $expected_root" >&2 + exit 2 + ;; + esac + python -c 'import vllm._C_stable_libtorch, vllm._custom_ops' + echo "[vllm] source=${DYN_VLLM_SOURCE_REVISION:-unknown} file=$active_vllm" +fi + EC_ARGS=() if [[ "$CAPACITY_GB" != "0" ]]; then EC_ARGS=(--ec-transfer-config "{ diff --git a/tests/benchmarks/multimodal/jsonl/test_main.py b/tests/benchmarks/multimodal/jsonl/test_main.py index 8c55afd312f3..0a694e8c7f5e 100644 --- a/tests/benchmarks/multimodal/jsonl/test_main.py +++ b/tests/benchmarks/multimodal/jsonl/test_main.py @@ -9,7 +9,9 @@ from pathlib import Path from unittest.mock import patch +import numpy as np import pytest +from PIL import Image pytest.importorskip("PIL", reason="Pillow required for image generation benchmarks") @@ -17,6 +19,7 @@ JSONL_DIR = Path(__file__).resolve().parents[4] / "benchmarks" / "multimodal" / "jsonl" sys.path.insert(0, str(JSONL_DIR)) +from generate_images import generate_image_pool_base64 # noqa: E402 from generate_videos import generate_synthetic_video_pool # noqa: E402 from main import main # noqa: E402 @@ -78,6 +81,14 @@ def test_default_produces_independent_requests(self, tmp_path: Path) -> None: assert len(line["images"]) == 2 assert "session_id" not in line + def test_non_square_image_size_is_width_then_height(self, tmp_path: Path) -> None: + pool = generate_image_pool_base64( + np.random.default_rng(1), 1, tmp_path / "imgs", (40, 24) + ) + + with Image.open(pool[0]) as image: + assert image.size == (40, 24) + class TestSlidingWindow: """sliding-window produces causal sessions with image overlap.""" diff --git a/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py b/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py new file mode 100644 index 000000000000..0600441677b3 --- /dev/null +++ b/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks.multimodal.jsonl.prepare_ec_h2d_overlap import ( + build_chat_template, + make_exact_text, + write_sliding_dataset, +) + +pytestmark = [pytest.mark.unit, pytest.mark.pre_merge, pytest.mark.gpu_0] + + +class CharacterTokenizer: + def encode(self, text: str, *, add_special_tokens: bool = False) -> list[int]: + return [ord(character) for character in text] + + def decode( + self, + ids: list[int], + *, + skip_special_tokens: bool = True, + clean_up_tokenization_spaces: bool = False, + ) -> str: + return "".join(chr(token_id) for token_id in ids) + + +def test_exact_text_hits_requested_token_count() -> None: + tokenizer = CharacterTokenizer() + + value = make_exact_text(tokenizer, 257) + + assert len(tokenizer.encode(value)) == 257 + + +def test_chat_template_prepends_system_message_only_when_missing() -> None: + base = "{% for message in messages %}[{{ message['role'] }}]{{ message['content'] }}{% endfor %}" + template = build_chat_template(base, "shared prefix") + + assert "messages[0]['role'] != 'system'" in template + assert "'role': 'system', 'content': \"shared prefix\"" in template + assert template.endswith(base) + + +def test_sliding_datasets_share_fixed_text_and_stable_overlap(tmp_path: Path) -> None: + pool = [str(tmp_path / f"image-{index}.png") for index in range(8)] + output = tmp_path / "dataset.jsonl" + + counts = write_sliding_dataset( + output, + pool, + num_users=2, + turns_per_user=2, + window_size=3, + images_per_user=4, + user_text="same text", + ) + rows = [json.loads(line) for line in output.read_text().splitlines()] + + assert counts == { + "rows": 4, + "unique_images": 8, + "content_images": 8, + "stripped_images": 4, + } + assert {row["text"] for row in rows} == {"same text"} + user_zero = [row for row in rows if row["session_id"] == "user_0"] + assert user_zero[0]["images"][1:] == user_zero[1]["images"][:-1] + assert all(len(row["image_uuids"]) == 3 for row in rows) diff --git a/tests/benchmarks/multimodal/sweep/test_orchestrator.py b/tests/benchmarks/multimodal/sweep/test_orchestrator.py index 0dc99d96e6fc..7683bf8e888e 100644 --- a/tests/benchmarks/multimodal/sweep/test_orchestrator.py +++ b/tests/benchmarks/multimodal/sweep/test_orchestrator.py @@ -219,6 +219,33 @@ def test_uuid_and_strip_propagates_to_aiperf( ) +@patch("benchmarks.multimodal.sweep.orchestrator.run_aiperf_single") +@patch("benchmarks.multimodal.sweep.orchestrator.ServerManager") +def test_prefix_cache_probe_runs_before_aiperf( + mock_server_cls: MagicMock, + mock_aiperf: MagicMock, + tmp_path: Path, +) -> None: + config = _make_config( + tmp_path, + num_configs=1, + num_input_files=1, + request_rates=[4], + ) + Path(config.input_files[0]).write_text('{"text":"same question"}\n') + config.prefix_cache_probe_min_cached_tokens = 7936 + mock_server_cls.return_value.is_running = False + + run_sweep(config, repo_root=tmp_path) + + probe = mock_server_cls.return_value.validate_prefix_cache + probe.assert_called_once() + assert probe.call_args.kwargs["user_text"] == "same question" + assert probe.call_args.kwargs["min_cached_tokens"] == 7936 + assert probe.call_args.kwargs["output_path"].name == "prefix_cache_probe.json" + assert mock_aiperf.call_count == 1 + + def test_per_arm_env_expands_and_overrides_top_level( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/benchmarks/multimodal/sweep/test_server.py b/tests/benchmarks/multimodal/sweep/test_server.py index fb4b8fffe63a..919170099dc3 100644 --- a/tests/benchmarks/multimodal/sweep/test_server.py +++ b/tests/benchmarks/multimodal/sweep/test_server.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +import json from unittest.mock import MagicMock, patch import pytest @@ -94,3 +95,77 @@ def test_start_treats_empty_timeout_override_as_unset(tmp_path) -> None: ) assert manager.terminate_timeout == 15.0 + + +def test_validate_prefix_cache_persists_successful_probe(tmp_path) -> None: + responses = [ + {"usage": {"prompt_tokens": 8016, "prompt_tokens_details": {}}}, + { + "usage": { + "prompt_tokens": 8016, + "prompt_tokens_details": {"cached_tokens": 8000}, + } + }, + ] + + class FakeResponse: + def __init__(self, value: dict) -> None: + self.value = value + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> bytes: + return json.dumps(self.value).encode() + + output = tmp_path / "probe.json" + with patch( + "benchmarks.multimodal.sweep.server.urllib.request.urlopen", + side_effect=[FakeResponse(value) for value in responses], + ) as urlopen: + result = ServerManager(port=8123).validate_prefix_cache( + model="model", + user_text="question", + min_cached_tokens=7936, + output_path=output, + ) + + assert urlopen.call_count == 2 + assert result["passed"] is True + assert json.loads(output.read_text()) == result + + +def test_validate_prefix_cache_rejects_uncached_prompt(tmp_path) -> None: + response = { + "usage": { + "prompt_tokens": 8016, + "prompt_tokens_details": {"cached_tokens": 128}, + } + } + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> bytes: + return json.dumps(response).encode() + + with ( + patch( + "benchmarks.multimodal.sweep.server.urllib.request.urlopen", + side_effect=[FakeResponse(), FakeResponse()], + ), + pytest.raises(RuntimeError, match="cached 128 tokens"), + ): + ServerManager().validate_prefix_cache( + model="model", + user_text="question", + min_cached_tokens=7936, + output_path=tmp_path / "probe.json", + ) diff --git a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py index 97341f0cb72c..8d4e5a3030f8 100644 --- a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py +++ b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py @@ -26,13 +26,22 @@ def test_embedding_cache_sweep_selects_only_requested_arms() -> None: config = yaml.safe_load(CONFIG.read_text()) assert [item["label"] for item in config["configs"]] == [ - "vllm-serve", "vllm-serve-native-ec-baseline", "vllm-serve-native-ec-overlap", ] assert config["env"]["DYN_DISABLE_NSYS"] == "1" + assert config["prefix_cache_probe_min_cached_tokens"] == 7936 + assert len(config["input_files"]) == 2 assert config["configs"][0]["env"]["PYTHONPATH"] == ("${VLLM_BASELINE_PYTHONPATH}") - assert config["configs"][2]["env"]["PYTHONPATH"] == ("${VLLM_PATCHED_PYTHONPATH}") + assert config["configs"][1]["env"]["PYTHONPATH"] == ("${VLLM_PATCHED_PYTHONPATH}") + for benchmark in config["configs"]: + assert "--enable-prefix-caching" in benchmark["extra_args"] + assert "--enable-prompt-tokens-details" in benchmark["extra_args"] + assert "--chat-template" in benchmark["extra_args"] + ec_config = benchmark["extra_args"][ + benchmark["extra_args"].index("--ec-transfer-config") + 1 + ] + assert '"ec_cpu_bytes":8589934592' in ec_config def _run_workflow(tmp_path: Path, *args: str, enable_nsys: bool = False): From 681b4f7578eed28a5ecca291dfeaaee0520533c7 Mon Sep 17 00:00:00 2001 From: furionw Date: Thu, 17 Sep 2026 05:37:55 -0700 Subject: [PATCH 6/7] fix(benchmarks): count rendered prompt input IDs --- .../multimodal/jsonl/prepare_ec_h2d_overlap.py | 15 +++++++++++++-- .../jsonl/test_prepare_ec_h2d_overlap.py | 11 +++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py b/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py index 413868a71f9a..31da3eacdba1 100755 --- a/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py +++ b/benchmarks/multimodal/jsonl/prepare_ec_h2d_overlap.py @@ -9,7 +9,7 @@ import argparse import hashlib import json -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Any, Protocol @@ -69,6 +69,17 @@ def count_tokens(tokenizer: Tokenizer, text: str) -> int: return len(tokenizer.encode(text, add_special_tokens=False)) +def tokenized_length(tokenized: Any) -> int: + """Count input IDs returned as a sequence or tokenizer batch encoding.""" + if isinstance(tokenized, Mapping): + tokenized = tokenized["input_ids"] + if tokenized and isinstance(tokenized[0], Sequence): + if len(tokenized) != 1: + raise ValueError("expected exactly one rendered conversation") + tokenized = tokenized[0] + return len(tokenized) + + def make_exact_text(tokenizer: Tokenizer, target_tokens: int) -> str: """Return deterministic text with exactly ``target_tokens`` tokenizer tokens.""" if target_tokens <= 0: @@ -246,7 +257,7 @@ def main() -> None: }, "user_text": USER_TEXT, "user_text_tokens": count_tokens(tokenizer, USER_TEXT), - "rendered_text_prompt_tokens": len(rendered_ids), + "rendered_text_prompt_tokens": tokenized_length(rendered_ids), "images": { "directory": str(args.image_dir), "count": len(image_pool), diff --git a/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py b/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py index 0600441677b3..937f2fe57fde 100644 --- a/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py +++ b/tests/benchmarks/multimodal/jsonl/test_prepare_ec_h2d_overlap.py @@ -11,6 +11,7 @@ from benchmarks.multimodal.jsonl.prepare_ec_h2d_overlap import ( build_chat_template, make_exact_text, + tokenized_length, write_sliding_dataset, ) @@ -39,6 +40,16 @@ def test_exact_text_hits_requested_token_count() -> None: assert len(tokenizer.encode(value)) == 257 +@pytest.mark.parametrize( + ("tokenized", "expected"), + [([1, 2, 3], 3), ({"input_ids": [1, 2, 3]}, 3), ({"input_ids": [[1, 2, 3]]}, 3)], +) +def test_tokenized_length_accepts_sequence_and_batch_encoding_shapes( + tokenized: object, expected: int +) -> None: + assert tokenized_length(tokenized) == expected + + def test_chat_template_prepends_system_message_only_when_missing() -> None: base = "{% for message in messages %}[{{ message['role'] }}]{{ message['content'] }}{% endfor %}" template = build_chat_template(base, "shared prefix") From 92c32c8c64d93287a296766f9d2f699ae119c228 Mon Sep 17 00:00:00 2001 From: furionw Date: Thu, 17 Sep 2026 05:53:06 -0700 Subject: [PATCH 7/7] fix(benchmarks): account for hybrid prefix alignment --- .../sweep/experiments/embedding_cache/vllm_serve.yaml | 5 ++++- .../benchmarks/multimodal/sweep/test_vllm_serve_workflow.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml index e49622da2371..42bd15ed74b1 100644 --- a/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml +++ b/benchmarks/multimodal/sweep/experiments/embedding_cache/vllm_serve.yaml @@ -19,7 +19,10 @@ skip_plots: true restart_server_every_benchmark: true uuid_and_strip: true prompt_manifest: /dynamo-tmp/data/qwen35_ec_h2d_overlap_manifest.json -prefix_cache_probe_min_cached_tokens: 7936 +# Qwen3.5's hybrid Mamba/KV cache aligns this 8026-token rendered prompt to +# 6288 reusable tokens. Require a stable majority hit without assuming pure-KV +# block accounting. +prefix_cache_probe_min_cached_tokens: 6000 env: DYN_DISABLE_NSYS: "1" diff --git a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py index 8d4e5a3030f8..19204127742a 100644 --- a/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py +++ b/tests/benchmarks/multimodal/sweep/test_vllm_serve_workflow.py @@ -30,7 +30,7 @@ def test_embedding_cache_sweep_selects_only_requested_arms() -> None: "vllm-serve-native-ec-overlap", ] assert config["env"]["DYN_DISABLE_NSYS"] == "1" - assert config["prefix_cache_probe_min_cached_tokens"] == 7936 + assert config["prefix_cache_probe_min_cached_tokens"] == 6000 assert len(config["input_files"]) == 2 assert config["configs"][0]["env"]["PYTHONPATH"] == ("${VLLM_BASELINE_PYTHONPATH}") assert config["configs"][1]["env"]["PYTHONPATH"] == ("${VLLM_PATCHED_PYTHONPATH}")