diff --git a/.agents/skills/serving-bench/SKILL.md b/.agents/skills/serving-bench/SKILL.md index a9123a8e42..df55d8cd48 100644 --- a/.agents/skills/serving-bench/SKILL.md +++ b/.agents/skills/serving-bench/SKILL.md @@ -16,6 +16,8 @@ image: vllm/vllm-openai:v0.29.0 # required model: Qwen/Qwen3.8-27B # HF repo id, never a local path gpus: 1 # GPUs the command needs replicas: 1 # optional: N engine copies, gpus/N GPUs each; N > 1 needs router: with {{engine_urls}} +host_cpus: 64 # optional, hardware-hcm-8x_* only: job CPUs / RAM GB (default 8 CPU + 64 GB per GPU +host_mem_gb: 192 # plus HiCache/Mooncake host pools); set only to ask for something else precision: bf16 # optional (bf16 default), naming only env: # optional, the engine's env vars; quote booleans PYTORCH_CUDA_ALLOC_CONF: expandable_segments:True @@ -76,11 +78,14 @@ It validates first. On `ERROR : ` lines, fix the YAML and rerun; not ## 4. Results -- Status: `gh run view --repo vngcloud/InferenceX`. One job per `conc`. Expect roughly 20 min of image pull, model load and warmup on top of the scenario duration (180 s smoke ≈ 25 min). -- Artifacts (`gh run download --repo vngcloud/InferenceX -n `), names start with `_c`: - - `agentic__c_…`: `results/` with `serving.yaml`, `router_command.txt`, `router.log`, `server_command.txt`, `server.log`, `benchmark_command.txt`, `benchmark.log`, `aiperf_artifacts/` - - `bmk_agentic__c_…`: aggregate result JSON - - `server_logs__c_…`: server logs +- Status: `gh run view --repo vngcloud/InferenceX`. One job per `conc`. Expect roughly 20 min of image pull, model load and warmup on top of the scenario duration (180 s smoke ≈ 25 min), plus the wait for the runner (hours when the box is busy). An old `gh` that prints no jobs: `gh api repos/vngcloud/InferenceX/actions/runs//jobs`. +- Artifacts: `gh run download --repo vngcloud/InferenceX -D ` takes all of them (`-n ` one). Names start with `_c`: + - `agentic__c_…`: `serving.yaml`, `dataset.txt`, `router_command.txt`, `router.log`, `server_command.txt`, `server.log`, `benchmark_command.txt`, `benchmark.log`, `gpu_metrics.csv`, `aiperf_artifacts/` (with `replicas: N`, replica `i` > 0 also has `server_command..txt` / `server..log`) + - `bmk_agentic__c_…` and `results_bmk/agg_bmk.json` (all concs): the aggregate result + - `server_logs_…`, `gpu_metrics_…`, `power_audit_…`, `run-stats` +- Read the aggregate: `request_metrics.latency.ttft` (s; mean/p50/p90/p95), `request_accounting` (`records_profiled`, `records_error_dropped`, `error_categories`; warmup drops are normal), `server_metrics.cache.gpu_cache_hit_rate`, `request_metrics.throughput`, `avg_total_gpu_power_w`. `tp` and the `tp` in names are the YAML's `gpus`, not the engine's TP: with `replicas` read the TP from `server_command.txt`. `scenario_type` says `agentic-coding` for every agentic scenario. +- LiveKit scenarios (`custom-agentic-livekit111868-*`): after downloading the whole run, from the repo root run `uv run -q --no-project --python 3.12 --with pyyaml --with huggingface_hub python utils/serving/livekit_ttft.py `. It prints TTFT per conc split by a room's first turn vs later turns; report both, since first turns set the p95 tail. It fetches the plan CSV from the lock (`-plan`, a public repo: no token needed); `--plan ` uses a local copy. On `-v1-warm` (dial warm-up) runs, warm-up requests are excluded from the TTFT views and reported on their own last line; report their p95 too, since they must finish before the room's first turn. +- Not failures: `503 Service Unavailable` lines in `server.log` / `router.log` before the warmup starts (readiness polling while the model loads), and errors in `router.log` at teardown after the profiling phase. Check the timestamps against `benchmark.log`. - Dataset errors appear as `ERROR dataset : …` lines in the job log (`gh run view --repo vngcloud/InferenceX --log-failed`); the first tau2 run downloads ~1.1 GB. - A failed job: read `server.log` first (engine flags, OOM, parser errors), then `benchmark.log` (aiperf). Router path: read `router.log` first when the job fails before aiperf starts. `gh run view --repo vngcloud/InferenceX --log-failed | tail -100` shows the launcher side (slurm, enroot). - If the engine crashes or the job fails, report the error and the config as given. Do not change flags or env, or redispatch a modified config, on your own: the config is the user's, a change is their decision. diff --git a/.github/workflows/benchmark-tmpl.yml b/.github/workflows/benchmark-tmpl.yml index 61ad4b71b2..af549184ef 100644 --- a/.github/workflows/benchmark-tmpl.yml +++ b/.github/workflows/benchmark-tmpl.yml @@ -240,6 +240,9 @@ env: ROUTER_PROFILE: ${{ inputs.serving != '' && fromJSON(inputs.serving).router-profile || '' }} ROUTER_METRICS_PORT: ${{ inputs.serving != '' && fromJSON(inputs.serving).router-metrics-port || '' }} REPLICAS: ${{ inputs.serving != '' && fromJSON(inputs.serving).replicas || '' }} + # Host CPUs / RAM GB of the allocation (empty: launcher default); only launch_hardware-hcm.sh reads these. + HOST_CPUS: ${{ inputs.serving != '' && fromJSON(inputs.serving).host-cpus || '' }} + HOST_MEM_GB: ${{ inputs.serving != '' && fromJSON(inputs.serving).host-mem-gb || '' }} # Storage sidecar (Mooncake master for HiCache L3 etc.); only launch_hardware-hcm.sh reads these. STORAGE_IMAGE: ${{ inputs.serving != '' && fromJSON(inputs.serving).storage-image || '' }} STORAGE_CMD: ${{ inputs.serving != '' && fromJSON(inputs.serving).storage-cmd || '' }} diff --git a/benchmarks/dynamo-vllm/Dockerfile b/benchmarks/dynamo-vllm/Dockerfile new file mode 100644 index 0000000000..8a425ee9f6 --- /dev/null +++ b/benchmarks/dynamo-vllm/Dockerfile @@ -0,0 +1,17 @@ +# vLLM + Dynamo (aggregated dynamo.vllm workers behind python3 -m dynamo.frontend) for serving YAMLs. +# +# vLLM stays the base image's: ai-dynamo is installed without its vllm extra, and aggregated +# serving needs no NIXL. 1.6.0.dev20261018 is the first ai-dynamo whose vLLM worker publishes +# Gemma-4's hybrid KV groups to the KV router (vLLM >= 0.31's get_kv_cache_group_metadata). +# +# docker build -t /dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018 benchmarks/dynamo-vllm +ARG VLLM_IMAGE=vllm/vllm-openai:v0.31.0 +FROM ${VLLM_IMAGE} +ARG DYN_VERSION=1.6.0.dev20261018 +RUN pip install --no-cache-dir "ai-dynamo==${DYN_VERSION}" "ai-dynamo-runtime==${DYN_VERSION}" blake3 uvloop \ + && python3 - <<'PY' +import vllm, dynamo.vllm, dynamo.frontend, dynamo._core +from vllm.v1.engine.core import EngineCore +assert hasattr(EngineCore, "get_kv_cache_group_metadata"), "vLLM lacks get_kv_cache_group_metadata" +print("vllm", vllm.__version__) +PY diff --git a/benchmarks/dynamo-vllm/Dockerfile.g4strip b/benchmarks/dynamo-vllm/Dockerfile.g4strip new file mode 100644 index 0000000000..715d88a9f2 --- /dev/null +++ b/benchmarks/dynamo-vllm/Dockerfile.g4strip @@ -0,0 +1,19 @@ +# The vllm-runtime image with a patched ai-dynamo-runtime: Gemma 4 keeps its reasoning parser on +# with thinking off, so the empty `<|channel>thought\n` the model opens after a tool +# result leaves `content` (ai-dynamo/dynamo#16781; vLLM's gemma4 reasoning parser does the same). +# Perf is unchanged; only the tool-result turn's content differs. +# +# The wheel is built from ai-dynamo main 3763cee608 plus the two-line gate change in +# lib/llm/src/preprocessor.rs; it is not published, so it goes in the build context: +# cp ai_dynamo_runtime-1.6.0-cp310-abi3-linux_x86_64.whl / +# docker build -f Dockerfile.g4strip -t /dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018-g4strip +ARG BASE_IMAGE=vcr.vngcloud.vn/108942-public69/dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018 +FROM ${BASE_IMAGE} +COPY ai_dynamo_runtime-1.6.0-cp310-abi3-linux_x86_64.whl /tmp/ +RUN pip install --no-cache-dir --no-deps --force-reinstall /tmp/ai_dynamo_runtime-1.6.0-cp310-abi3-linux_x86_64.whl \ + && rm /tmp/ai_dynamo_runtime-*.whl \ + && python3 - <<'PY' +import vllm, dynamo.vllm, dynamo.frontend, dynamo._core +assert vllm.__version__.startswith("0.31.0"), vllm.__version__ +print("vllm", vllm.__version__) +PY diff --git a/benchmarks/single_node/agentic/serve_generic.sh b/benchmarks/single_node/agentic/serve_generic.sh index 3264420f8a..74141e6613 100755 --- a/benchmarks/single_node/agentic/serve_generic.sh +++ b/benchmarks/single_node/agentic/serve_generic.sh @@ -21,6 +21,7 @@ umask 0000 # ROUTER_METRICS_PORT generic router: its /metrics port, empty = the router port # ENGINE_PORT generic router: the engine's HTTP port (launcher) # DYN_SYSTEM_PORT Dynamo engine metrics/health port (launcher) +# DYN_SYSTEM_PORTS, DYN_KV_EVENT_PORTS Dynamo replicas: one system / vLLM KV event port per replica (launcher) source "$(dirname "$0")/../../benchmark_lib.sh" @@ -72,8 +73,12 @@ HF_HUB_OFFLINE= TRANSFORMERS_OFFLINE= HF_DATASETS_OFFLINE= "$AIPERF_HF_CLI" down if [[ -n "${ROUTER_CMD:-}" ]]; then if [[ "${ROUTER_PROFILE:-}" == dynamo ]]; then check_env_vars DYN_SYSTEM_PORT - # Frontend (dynamo_frontend_*) + engine (sglang:* via the Dynamo system port). - export AIPERF_SERVER_METRICS_URLS="http://localhost:${PORT}/metrics,http://localhost:${DYN_SYSTEM_PORT}/metrics" + # Frontend (dynamo_frontend_*) + every engine (via its Dynamo system port). + metrics_urls="http://localhost:${PORT}/metrics" + for system_port in ${DYN_SYSTEM_PORTS:-$DYN_SYSTEM_PORT}; do + metrics_urls+=",http://localhost:${system_port}/metrics" + done + export AIPERF_SERVER_METRICS_URLS="$metrics_urls" else check_env_vars ENGINE_PORT # Router (its own metrics port, else the router port) + every engine's own /metrics. @@ -94,7 +99,41 @@ esac SERVER_LOG="$RESULT_DIR/server.log" eval "set -- $SERVE_CMD" -if [[ "${REPLICAS:-1}" -gt 1 ]]; then +if [[ "${REPLICAS:-1}" -gt 1 && "${ROUTER_PROFILE:-}" == dynamo ]]; then + # Dynamo replicas (schema: dynamo.vllm only): N engines, each on its own GPU slice, Dynamo + # system port (health + metrics, DYN_SYSTEM_PORTS) and vLLM KV event port (DYN_KV_EVENT_PORTS); + # the frontend finds them through file discovery. Replica 0 keeps server.log, as below. + read -r -a replica_ports <<< "${DYN_SYSTEM_PORTS:-}" + read -r -a kv_ports <<< "${DYN_KV_EVENT_PORTS:-}" + if [[ -n "${CUDA_VISIBLE_DEVICES:-}" ]]; then + IFS=, read -r -a replica_gpus <<< "$CUDA_VISIBLE_DEVICES" + else + mapfile -t replica_gpus < <(nvidia-smi --query-gpu=index --format=csv,noheader) + fi + if (( ${#replica_ports[@]} != REPLICAS || ${#kv_ports[@]} != REPLICAS || ${#replica_gpus[@]} == 0 || ${#replica_gpus[@]} % REPLICAS )); then + echo "ERROR: replicas $REPLICAS needs $REPLICAS ports in DYN_SYSTEM_PORTS/DYN_KV_EVENT_PORTS (got '${DYN_SYSTEM_PORTS:-}' / '${DYN_KV_EVENT_PORTS:-}') and a GPU count divisible by $REPLICAS" >&2 + exit 1 + fi + gpus_per_replica=$(( ${#replica_gpus[@]} / REPLICAS )) + replica_pids=() + for ((replica = 0; replica < REPLICAS; replica++)); do + suffix="" + (( replica == 0 )) || suffix=".$replica" + replica_cmd=("$@" --kv-events-config "{\"enable_kv_cache_events\":true,\"publisher\":\"zmq\",\"endpoint\":\"tcp://*:${kv_ports[replica]}\"}") + write_command "$RESULT_DIR/server_command${suffix}.txt" "${replica_cmd[@]}" + DYN_SYSTEM_PORT="${replica_ports[replica]}" \ + CUDA_VISIBLE_DEVICES="$(IFS=,; echo "${replica_gpus[*]:replica*gpus_per_replica:gpus_per_replica}")" \ + "${replica_cmd[@]}" > "$RESULT_DIR/server${suffix}.log" 2>&1 & + replica_pids+=("$!") + done + for ((replica = 0; replica < REPLICAS; replica++)); do + suffix="" + (( replica == 0 )) || suffix=".$replica" + wait_for_server_ready --port "${replica_ports[replica]}" --server-log "$RESULT_DIR/server${suffix}.log" \ + --server-pid "${replica_pids[replica]}" + done + SERVER_PID="${replica_pids[0]}" +elif [[ "${REPLICAS:-1}" -gt 1 ]]; then # Serving-YAML replicas (spec 2026-10-16): N copies of SERVE_CMD, each on its own GPU slice and # ENGINE_PORTS entry. Replica 0 keeps server.log / server_command.txt: the vLLM server-metrics # adapter reads "GPU KV cache size" from server.log (replicas are identical). diff --git a/docs/serving-yaml-procedures.md b/docs/serving-yaml-procedures.md index 99779c122b..062b14aa15 100644 --- a/docs/serving-yaml-procedures.md +++ b/docs/serving-yaml-procedures.md @@ -61,6 +61,10 @@ The first run of a large model spends extra time downloading it and uses node di The script runs the same validation CI runs. On `ERROR : ` lines nothing was dispatched: fix the YAML and rerun. On success it prints the run URL. Use `--ref ` to dispatch a branch other than the current one. 4. Do not redispatch if the script reports that the run did not show up; check `gh run list --repo vngcloud/InferenceX -w e2e-tests.yml -L 5` first. +### Host CPU / RAM (hardware-hcm) + +The `hardware-hcm-8x_*` launcher takes 8 CPUs + 64 GB RAM per GPU plus the engines' host KV pools (`--hicache-size`, `MOONCAKE_GLOBAL_SEGMENT_SIZE`), capped at 1950 GB. A YAML can override both with `host_cpus` / `host_mem_gb` (whole job: engines, router, sidecars, aiperf), e.g. 2× TP4 vLLM Gemma-4-31B at c96 peaks at ~70 GB: `host_cpus: 64`, `host_mem_gb: 192`. Slurm does not enforce `--mem` here, so asking for less than the peak oversubscribes the node. + ### Replicas Several copies of one engine (e.g. 2× TP1): set `replicas: N` and `gpus` = N × per-engine GPUs; the router command lists the engines as `{{engine_urls}}` (space-separated URLs). Not with the Dynamo frontend. @@ -115,7 +119,8 @@ The `scenarios/sweep-*.yaml` family (generated by [`utils/serving/gen_sweep_scen ## Read results and failures - Status: `gh run view --repo vngcloud/InferenceX`. One job per `conc` value. Expect about 20 minutes of image pull, model load and warmup on top of the scenario duration. -- Artifacts (`gh run download --repo vngcloud/InferenceX -n `) start with `_c`; `results/` holds `serving.yaml`, `server.log`, `benchmark.log`, `router.log` (router path) and `aiperf_artifacts/`. +- Artifacts (`gh run download --repo vngcloud/InferenceX -D ` for all, `-n ` for one) start with `_c`; `agentic__c_…` holds `serving.yaml`, `dataset.txt`, `server.log`, `benchmark.log`, `router.log` (router path) and `aiperf_artifacts/` (with `replicas: N`, replica `i` > 0 also logs to `server..log`). The aggregate result is `bmk_agentic_…` / `results_bmk/agg_bmk.json`; its `tp` is the YAML's `gpus`, not the engine TP. `503` lines before warmup are readiness polling, not failures. +- LiveKit scenarios (`custom-agentic-livekit111868-*`): `gh run download --repo vngcloud/InferenceX -D `, then `uv run -q --no-project --python 3.12 --with pyyaml --with huggingface_hub python utils/serving/livekit_ttft.py ` from the repo root prints TTFT per conc split by a room's first turn vs later turns (first turns set the p95 tail). It fetches the trace's plan CSV from the lock (`-plan`); `--plan ` uses a local copy. On `-v1-warm` (dial warm-up) runs the warm-up requests are left out of every TTFT view and get their own last line (latency, share done within 0.5/1/1.5/2 s). - On failure, read `server.log` first, then `benchmark.log`. For the router path, read `router.log` first when the job fails before aiperf starts. `gh run view --repo vngcloud/InferenceX --log-failed | tail -100` shows the launcher side (Slurm, enroot). - An engine crash describes the config as given. Changing flags is the config owner's decision, not the benchmark's. diff --git a/docs/serving-yaml-procedures_zh.md b/docs/serving-yaml-procedures_zh.md index cbaf98325e..4f8013d815 100644 --- a/docs/serving-yaml-procedures_zh.md +++ b/docs/serving-yaml-procedures_zh.md @@ -61,6 +61,10 @@ 脚本执行与 CI 相同的校验。出现 `ERROR : ` 时不会派发任何任务:修改 YAML 后重新运行。成功时会输出运行 URL。使用 `--ref ` 可派发当前分支以外的分支。 4. 如果脚本提示运行未出现,不要重复派发;先执行 `gh run list --repo vngcloud/InferenceX -w e2e-tests.yml -L 5` 检查。 +### 主机 CPU / 内存(hardware-hcm) + +`hardware-hcm-8x_*` 启动器默认每块 GPU 申请 8 个 CPU + 64 GB 内存,再加上引擎的主机 KV 池(`--hicache-size`、`MOONCAKE_GLOBAL_SEGMENT_SIZE`),上限 1950 GB。YAML 可用 `host_cpus` / `host_mem_gb` 覆盖两者(整个作业:引擎、路由、sidecar、aiperf),例如 2× TP4 vLLM Gemma-4-31B c96 峰值约 70 GB:`host_cpus: 64`、`host_mem_gb: 192`。这里 Slurm 不强制 `--mem`,申请低于峰值会导致节点超分。 + ### 副本(replicas) 「**副本(replicas)**」:同一引擎的多个副本(如 2×TP1)。设置 `replicas: N`,`gpus` = N × 每个引擎的 GPU 数;路由命令用 `{{engine_urls}}`(空格分隔的 URL)列出全部引擎。不支持 Dynamo frontend。 @@ -115,7 +119,8 @@ router: ## 查看结果与失败 - 状态:`gh run view --repo vngcloud/InferenceX`。每个 `conc` 值对应一个任务。除场景时长外,镜像拉取、模型加载与预热约需 20 分钟。 -- 产物(`gh run download --repo vngcloud/InferenceX -n `)以 `_c` 开头;`results/` 包含 `serving.yaml`、`server.log`、`benchmark.log`、`router.log`(Router 路径)与 `aiperf_artifacts/`。 +- 产物(全部:`gh run download --repo vngcloud/InferenceX -D `;单个:`-n `)以 `_c` 开头;`agentic__c_…` 包含 `serving.yaml`、`dataset.txt`、`server.log`、`benchmark.log`、`router.log`(Router 路径)与 `aiperf_artifacts/`(`replicas: N` 时,副本 `i` > 0 另写 `server..log`)。聚合结果为 `bmk_agentic_…` / `results_bmk/agg_bmk.json`;其中 `tp` 是 YAML 的 `gpus`,不是引擎 TP。warmup 之前的 `503` 行是就绪轮询,不是失败。 +- LiveKit 场景(`custom-agentic-livekit111868-*`):`gh run download --repo vngcloud/InferenceX -D `,然后在仓库根目录运行 `uv run -q --no-project --python 3.12 --with pyyaml --with huggingface_hub python utils/serving/livekit_ttft.py ` 按 conc 输出 TTFT,并区分房间首轮与后续轮次(p95 尾部由首轮决定)。脚本从锁文件(`-plan`)获取 trace 的 plan CSV;`--plan ` 使用本地副本。`-v1-warm`(拨号预热)运行中,预热请求不计入任何 TTFT 视图,单独在最后一行输出(延迟,以及 0.5/1/1.5/2 s 内完成的比例)。 - 失败时先看 `server.log`,再看 `benchmark.log`。Router 路径下,若任务在 aiperf 启动前失败,先看 `router.log`。`gh run view --repo vngcloud/InferenceX --log-failed | tail -100` 显示启动脚本侧(Slurm、enroot)的日志。 - 引擎崩溃反映的是原样提交的配置。是否修改参数由配置所有者决定,而不是由基准测试决定。 diff --git a/infx/matrix/validation.py b/infx/matrix/validation.py index ce50b00009..29c01e0143 100644 --- a/infx/matrix/validation.py +++ b/infx/matrix/validation.py @@ -327,9 +327,7 @@ class SingleNodeAgenticMatrixEntry(BaseModel): kv_offload_backend: KVOffloadBackendMetadata | None = Field( default=None, alias=Fields.KV_OFFLOAD_BACKEND.value ) - hicache_ratio: float | None = Field( - default=None, alias=Fields.HICACHE_RATIO.value, gt=0 - ) + hicache_ratio: float | None = Field(default=None, alias=Fields.HICACHE_RATIO.value, gt=0) router: ComponentMetadata | None = None total_cpu_dram_gb: int = Field(alias=Fields.TOTAL_CPU_DRAM_GB.value, ge=0) duration: int = Field(alias=Fields.DURATION.value) @@ -376,9 +374,7 @@ class MultiNodeAgenticMatrixEntry(BaseModel): kv_offload_backend: KVOffloadBackendMetadata | None = Field( default=None, alias=Fields.KV_OFFLOAD_BACKEND.value ) - hicache_ratio: float | None = Field( - default=None, alias=Fields.HICACHE_RATIO.value, gt=0 - ) + hicache_ratio: float | None = Field(default=None, alias=Fields.HICACHE_RATIO.value, gt=0) router: ComponentMetadata | None = None kv_p2p_transfer: str | None = Field( default=None, alias=Fields.KV_P2P_TRANSFER.value, min_length=1 @@ -650,9 +646,7 @@ class AgenticCodingSearchSpaceEntry(BaseModel): kv_offload_backend: KVOffloadBackendMetadata | None = Field( default=None, alias=Fields.KV_OFFLOAD_BACKEND.value ) - hicache_ratio: float | None = Field( - default=None, alias=Fields.HICACHE_RATIO.value, gt=0 - ) + hicache_ratio: float | None = Field(default=None, alias=Fields.HICACHE_RATIO.value, gt=0) router: ComponentMetadata | None = None kv_p2p_transfer: str | None = Field( default=None, alias=Fields.KV_P2P_TRANSFER.value, min_length=1 diff --git a/infx/results/agentic/backends/sglang.py b/infx/results/agentic/backends/sglang.py index 7caa05fc39..8370f15ec5 100644 --- a/infx/results/agentic/backends/sglang.py +++ b/infx/results/agentic/backends/sglang.py @@ -58,11 +58,12 @@ def populate( ) device_hits = cached_by_source.get("device") host_hits = cached_by_source.get("host") - external_hits = sum( - value - for source, value in cached_by_source.items() - if source.startswith("storage_") - ) or None + external_hits = ( + sum( + value for source, value in cached_by_source.items() if source.startswith("storage_") + ) + or None + ) total_cached = sum(cached_by_source.values()) if cached_by_source else None flat["server_gpu_cache_hit_rate"] = rate(device_hits, prompt_total) diff --git a/infx/serving/render.py b/infx/serving/render.py index 864656e53c..cd28ca80ce 100644 --- a/infx/serving/render.py +++ b/infx/serving/render.py @@ -46,8 +46,11 @@ def _python_module(argv: list[str]) -> str | None: return None +_DYNAMO_ENGINES = ("dynamo.sglang", "dynamo.vllm") + + def infer_framework(argv: list[str]) -> str: - if argv[:2] == ["vllm", "serve"]: + if argv[:2] == ["vllm", "serve"] or _python_module(argv) == "dynamo.vllm": return "vllm" if ( argv[:2] == ["sglang", "serve"] @@ -99,26 +102,31 @@ def _check_json_values(field: str, argv: list[str]) -> None: def _render_router(spec: ServingSpec, engine_argv: list[str]) -> dict[str, str]: - is_dynamo_engine = _python_module(engine_argv) == "dynamo.sglang" + is_dynamo_engine = _python_module(engine_argv) in _DYNAMO_ENGINES if spec.router is None: if is_dynamo_engine: raise ServingError( - "router: required for python3 -m dynamo.sglang (a Dynamo engine has no HTTP port); " + "router: required for python3 -m dynamo.sglang / dynamo.vllm (a Dynamo engine has no HTTP port); " "add a router block running python3 -m dynamo.frontend" ) return { - "router-image": "", "router-cmd": "", "router-env": "", - "router-profile": "", "router-metrics-port": "", + "router-image": "", + "router-cmd": "", + "router-env": "", + "router-profile": "", + "router-metrics-port": "", } prefix_env, argv = _pop_env_prefix(split_serve(spec.router.serve, "router.serve")) if not argv: raise ServingError("router.serve: empty command") is_dynamo_router = _python_module(argv) == "dynamo.frontend" if is_dynamo_router and not is_dynamo_engine: - raise ServingError("serve: python3 -m dynamo.frontend needs a python3 -m dynamo.sglang engine") + raise ServingError( + "serve: python3 -m dynamo.frontend needs a python3 -m dynamo.sglang or dynamo.vllm engine" + ) if is_dynamo_engine and not is_dynamo_router: raise ServingError( - "router.serve: a python3 -m dynamo.sglang engine needs python3 -m dynamo.frontend as its router" + "router.serve: a python3 -m dynamo.sglang / dynamo.vllm engine needs python3 -m dynamo.frontend as its router" ) _check_json_values("router.serve", argv) if is_dynamo_router: @@ -134,7 +142,9 @@ def _render_router(spec: ServingSpec, engine_argv: list[str]) -> dict[str, str]: "router-cmd": shlex.join(argv), "router-env": "\n".join(f"{key}={value}" for key, value in env.items()), "router-profile": "dynamo" if is_dynamo_router else "generic", - "router-metrics-port": "" if spec.router.metrics_port is None else str(spec.router.metrics_port), + "router-metrics-port": "" + if spec.router.metrics_port is None + else str(spec.router.metrics_port), } diff --git a/infx/serving/schema.py b/infx/serving/schema.py index a9d00274cd..6b5f6c11e0 100644 --- a/infx/serving/schema.py +++ b/infx/serving/schema.py @@ -3,6 +3,7 @@ V1 is an engine-only YAML (`vllm serve` / `python3 -m sglang.launch_server`, ServingSpec); V2 adds an optional `router:` block (RouterSpec); V3 lets it be any router (placeholders, metrics_port). V4 adds replicas: N engines behind the router (spec 2026-10-16). +host_cpus / host_mem_gb override the hardware-hcm allocation's measured-use default. Specs: docs/superpowers/specs/2026-10-12-serving-yaml-dispatch-design.md (V1) and docs/superpowers/specs/2026-10-13-serving-router-dynamo-design.md (V2); docs/superpowers/specs/2026-10-14-serving-router-generic-design.md (V3). @@ -50,6 +51,8 @@ "DYN_FILE_KV", "DYN_SYSTEM_ENABLED", "DYN_SYSTEM_PORT", + "DYN_SYSTEM_PORTS", + "DYN_KV_EVENT_PORTS", "ROUTER_IMAGE", "ROUTER_CMD", "ROUTER_ENV", @@ -58,6 +61,8 @@ "ENGINE_PORT", "ENGINE_PORTS", "REPLICAS", + "HOST_CPUS", + "HOST_MEM_GB", # Storage sidecar (Mooncake master etc.): set by runners/launch_hardware-hcm.sh. "STORAGE_IMAGE", "STORAGE_CMD", @@ -156,6 +161,10 @@ class ServingSpec(BaseModel): model: str = Field(min_length=1) gpus: int = Field(gt=0) replicas: int = Field(default=1, ge=1) + # Host CPUs / RAM (GB) for the whole job (engines, router, sidecars, aiperf). Unset: the + # launcher takes 8 CPUs + 64 GB per GPU plus the engines' host KV pools (HiCache / Mooncake). + host_cpus: int | None = Field(default=None, ge=4) + host_mem_gb: int | None = Field(default=None, ge=16) precision: str = Field(default="bf16", pattern=r"^[a-z0-9]+$") env: dict[str, str] = Field(default_factory=dict) serve: str = Field(min_length=1) @@ -163,7 +172,7 @@ class ServingSpec(BaseModel): storage: StorageSpec | None = None scenario: str | list[Annotated[str, Field(pattern=NAME_PATTERN)]] = Field( min_length=1, - description="One scenario name, or a list: one benchmark job per scenario in a single run" + description="One scenario name, or a list: one benchmark job per scenario in a single run", ) conc: list[Annotated[int, Field(gt=0)]] = Field(min_length=1) @@ -360,15 +369,28 @@ def load_serving(text: str, repo: Path = REPO_ROOT) -> ServingSpec: f"replicas: gpus {spec.gpus} is not a multiple of replicas {spec.replicas}; " "each replica gets gpus/replicas GPUs" ) + if (spec.host_cpus or spec.host_mem_gb) and not spec.runner.startswith("hardware-hcm-8x_"): + problems.append( + "host_cpus/host_mem_gb: only the hardware-hcm-8x_* launcher sizes its allocation; drop them" + ) if spec.replicas > 1: if spec.router is None: problems.append( "replicas: more than 1 replica needs a router: block whose serve lists {{engine_urls}}" ) elif "dynamo.frontend" in spec.router.serve: - problems.append( - "replicas: the Dynamo frontend discovers its engines itself; use replicas: 1" - ) + # The frontend discovers its engines itself; serve_generic.sh starts one dynamo.vllm + # engine per GPU slice and gives each its own system port and KV event port. + if "dynamo.vllm" not in spec.serve.split(): + problems.append( + "replicas: with the Dynamo frontend only python3 -m dynamo.vllm engines can be " + "replicated; use replicas: 1 (dynamo.sglang: --dp)" + ) + elif "--kv-events-config" in spec.serve: + problems.append( + "replicas: drop --kv-events-config; each dynamo.vllm replica gets its own " + "KV event port from the launcher" + ) from infx.serving.to_matrix import is_sweep # local import: to_matrix imports this module scenarios = [spec.scenario] if isinstance(spec.scenario, str) else list(spec.scenario) diff --git a/infx/serving/to_matrix.py b/infx/serving/to_matrix.py index 87722e1b98..3a19c89b6b 100644 --- a/infx/serving/to_matrix.py +++ b/infx/serving/to_matrix.py @@ -147,7 +147,9 @@ def build(text: str, repo: Path = REPO_ROOT) -> dict: sweep = is_sweep(scenario, repo) for conc in spec.conc: exp_name = ( - f"{spec.name}-{scenario}" if multi else (f"{spec.name}_search" if sweep else f"{spec.name}_c{conc}") + f"{spec.name}-{scenario}" + if multi + else (f"{spec.name}_search" if sweep else f"{spec.name}_c{conc}") ) rows.append(_row(spec, rendered["framework"], conc, duration, exp_name)) scenario_map[exp_name] = {"scenario": scenario, "loader": loader, "search": sweep} @@ -172,6 +174,8 @@ def build(text: str, repo: Path = REPO_ROOT) -> dict: "router-profile": rendered["router-profile"], "router-metrics-port": rendered["router-metrics-port"], "replicas": str(spec.replicas), + "host-cpus": str(spec.host_cpus or ""), + "host-mem-gb": str(spec.host_mem_gb or ""), "storage-image": rendered["storage-image"], "storage-cmd": rendered["storage-cmd"], "storage-env": rendered["storage-env"], diff --git a/runners/launch_hardware-hcm.sh b/runners/launch_hardware-hcm.sh index 244c36510a..1b64aa43f4 100755 --- a/runners/launch_hardware-hcm.sh +++ b/runners/launch_hardware-hcm.sh @@ -118,10 +118,12 @@ alloc_mem_gb() { total=$(( GPU_COUNT * (64 + hicache + segment) )) echo $(( total > 1950 ? 1950 : total )) } +# A serving YAML's host_cpus / host_mem_gb (HOST_CPUS / HOST_MEM_GB) override both, +# e.g. to ask for less than the 64G/GPU base when the engines need little host RAM. salloc --partition="$SLURM_PARTITION" --account="$SLURM_ACCOUNT" \ --nodelist="$SLURM_NODELIST" \ --gres=gpu:"$GPU_COUNT" \ - --cpus-per-task=$((GPU_COUNT * 8)) --mem="$(alloc_mem_gb)"G \ + --cpus-per-task="${HOST_CPUS:-$((GPU_COUNT * 8))}" --mem="${HOST_MEM_GB:-$(alloc_mem_gb)}G" \ --time=180 --no-shell --job-name="$RUNNER_NAME" JOB_ID=$(squeue --name="$RUNNER_NAME" -u "$USER" -h -o %A | head -n1) if [[ -z "$JOB_ID" ]]; then @@ -272,6 +274,11 @@ if [[ -n "${ROUTER_CMD:-}" ]]; then export DYN_DISCOVERY_BACKEND=file DYN_FILE_KV=/workspace/results/.dynkv DYN_SYSTEM_ENABLED=true # engine/bench only: exported after the router srun forks so the frontend does not inherit it DYN_SYSTEM_PORT="$(free_tcp_port_below_32768)" + # Replicas (dynamo.vllm): one system port and one KV event port each; the first system port is DYN_SYSTEM_PORT. + DYN_SYSTEM_PORTS="$DYN_SYSTEM_PORT" DYN_KV_EVENT_PORTS="$(free_tcp_port)" + for ((replica = 1; replica < ${REPLICAS:-1}; replica++)); do + DYN_SYSTEM_PORTS+=" $(free_tcp_port_below_32768)" DYN_KV_EVENT_PORTS+=" $(free_tcp_port)" + done else export ENGINE_PORT="$(free_tcp_port)" # Serving-YAML replicas (spec 2026-10-16): one engine port per replica; the first is ENGINE_PORT. @@ -305,7 +312,7 @@ if [[ -n "${ROUTER_CMD:-}" ]]; then ) & ROUTER_SRUN_PID=$! if [[ "${ROUTER_PROFILE:-}" == dynamo ]]; then - export DYN_SYSTEM_PORT + export DYN_SYSTEM_PORT DYN_SYSTEM_PORTS DYN_KV_EVENT_PORTS fi fi trap 'rc=$?; kill "$DCGM_SRUN_PID" $ROUTER_SRUN_PID $STORAGE_SRUN_PID 2>/dev/null || true; rm -rf "$GITHUB_WORKSPACE/results/.dynkv"; scancel "$JOB_ID" 2>/dev/null || true; exit "$rc"' EXIT INT TERM diff --git a/scenarios/custom-agentic-livekit111868-v1-warm.yaml b/scenarios/custom-agentic-livekit111868-v1-warm.yaml new file mode 100644 index 0000000000..f2f744b3d4 --- /dev/null +++ b/scenarios/custom-agentic-livekit111868-v1-warm.yaml @@ -0,0 +1,48 @@ +# custom-agentic family: LiveKit Agents voice-agent traces of customer 111868 on Gemma-4-31B +# (Green-Lab/2026-10-16-111868-agentic-traces, synthetic text with logged lengths/cache/timing; +# vmonitor-gateway-datasets spec 0004), 1217 rooms / 12902 requests (+1142 warm-ups), V1 = append-ideal. +# Dial warm-up variant (trace.v1-append-ideal-warm.jsonl, mkwarm.py U=64 D=3000): before each room's first +# request, the same prompt minus its last 64 tokens (the first utterance, unknown at dial time) with +# max_tokens 1, same session; the real first turn starts 3000 ms after the warm-up returns (75 rooms too +# short to warm). livekit_ttft.py drops the warm-ups (warm=1 in livekit-111868-v1-warm-plan) from the TTFT views. +# No request cap: lanes recycle rooms (fresh cache-bust marker per play) for the whole duration. +# Spec: docs/superpowers/specs/2026-10-16-serving-replicas-livekit-design.md +schemaVersion: "2.0" +benchmark: + scenario: inferencex-agentx-mvp + unsafeOverride: true + endpoint: + type: chat + path: /v1/chat/completions + streaming: true + useServerTokenCount: true + datasets: + - name: livekit-111868-v1-warm + type: file + format: mooncake_trace + path: ${IX_CUSTOM_DATASET_FILE} + entries: 1217 + randomSeed: 42 + traceIdleGapCapSeconds: 300.0 + # mooncake_trace defaults to sequential: lanes would replay room-0001, -0002, ... in file + # (chronological) order and a run covers only the first ~150 rooms. Shuffle = seeded random + # permutation of all 1217 rooms per pass. + sampling: shuffle + phases: + - name: profiling + kind: profiling + type: concurrency + duration: 1200.0 + gracePeriod: 30.0 + failedRequestThreshold: 0.1 + trajectoryStartMinRatio: 0.25 + trajectoryStartMaxRatio: 0.75 + warmupRequestsPerLane: 10 + agenticWarmupGracePeriod: 1800.0 + artifacts: + sliceDuration: 1.0 + tokenizer: + trustRemoteCode: true + runtime: + statsInterval: 30.0 +randomSeed: 42 diff --git a/scenarios/datasets/lock.yaml b/scenarios/datasets/lock.yaml index 9f5a63b1c0..ab8d9f686c 100644 --- a/scenarios/datasets/lock.yaml +++ b/scenarios/datasets/lock.yaml @@ -29,3 +29,26 @@ livekit-111868-v2: revision: 7e36287967360851a1b05ba05730ff1d256b60c7 file: trace.v2-observed.jsonl sha256: 190af677563ea782d060456bbc44f92e802e1d63e4322b147ef0290a49efd4f3 +# V1 + a dial warm-up request before each room's first turn (README of the HF repo; mkwarm.py 64 3000). +livekit-111868-v1-warm: + repo: Green-Lab/2026-10-16-111868-agentic-traces + revision: b2b84e6c2fe2c8210aea96e685f80f3d27fd8cdb + file: trace.v1-append-ideal-warm.jsonl + sha256: eaac3a4d93a57a5abc2205fe640831fb50a46c0aff45ffeb15f1cefb18e37eed +# Per-request plans shipped with the LiveKit traces (parent, shared, isl, timeout, ...), read by +# utils/serving/livekit_ttft.py as -plan. The v2 trace is identical at 002c98 and 7e3628. +livekit-111868-v1-plan: + repo: Green-Lab/2026-10-16-111868-agentic-traces + revision: 002c982432295d1603dcaafd68a09cdf5c37e4ac + file: plan.v1-append-ideal.csv + sha256: 96726f55c129ee099be4b0b852b571608e44ea3fdb26da4abacb6b7f4d95c308 +livekit-111868-v2-plan: + repo: Green-Lab/2026-10-16-111868-agentic-traces + revision: 002c982432295d1603dcaafd68a09cdf5c37e4ac + file: plan.v2-observed.csv + sha256: 9eb351fe0a095b092cedf3b08f30e8045ba7d2e6787c08a4cf9f3c0fb6d2a6d3 +livekit-111868-v1-warm-plan: + repo: Green-Lab/2026-10-16-111868-agentic-traces + revision: b2b84e6c2fe2c8210aea96e685f80f3d27fd8cdb + file: plan.v1-append-ideal-warm.csv + sha256: 8da2f45b5a0b83035fea3fc7ebba53f1b5bbb83a2100d19498cdbc60b87d65dd diff --git a/serving/gemma4-livekit-2tp4-dyn-kv-sa-warm-chunk.yaml b/serving/gemma4-livekit-2tp4-dyn-kv-sa-warm-chunk.yaml new file mode 100644 index 0000000000..a6c3fdeb63 --- /dev/null +++ b/serving/gemma4-livekit-2tp4-dyn-kv-sa-warm-chunk.yaml @@ -0,0 +1,52 @@ +# Gemma-4-31B FP8-block, 2x TP4 dynamo.vllm workers (vLLM 0.31.0) behind the Dynamo frontend KV +# router, LiveKit 111868 v1. Engine flags as the 2tp4 vllm-router arms; image: benchmarks/dynamo-vllm. +# Router cost = prefill_load_scale x (queued + own uncached prefill) + KV tokens in use; scale 16 keeps +# ~20k-token rooms on their worker. Hard session affinity pins every later turn of a room to the worker +# its first turn went to (x-dynamo-session-id = aiperf's per-conversation X-Correlation-ID). +# The frontend renders chat with its Rust preprocessor (no --dyn-chat-processor): the vLLM processor +# drops the session id before the router and adds ~0.1 s per request; the gemma4 tool/reasoning +# parsers go on the workers (--dyn-*-parser). Selector debug logs every decision's per-worker costs. +# + dial warm-up (scenario -v1-warm) and smaller steps (--max-num-batched-tokens 8192 +# --long-prefill-token-threshold 2048): the warm-up takes the ~14k-token first-turn prefill off the user's +# path, smaller steps cut the later-turn tail that a whole 16k step adds. Either alone does not move p95. +# Slurm reference (rbench job 4382, c96): TTFT p95 0.53 s (first 0.19 / 0.97, later p95 0.46), warm-ups +# p50 0.63 / p95 1.11 s, 0 errors. ~25% of first turns still miss the warm-up's cache: vLLM keeps the SWA +# tail only at the last full 32-token block, so a warm-up of N tokens with N mod 32 in 1..8 is lost. +name: gemma4-livekit-2tp4-dyn-kv-sa-warm-chunk +runner: hardware-hcm-8x_02 +image: vcr.vngcloud.vn/108942-public69/dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018-g4strip +model: RedHatAI/gemma-4-31B-it-FP8-block +gpus: 8 +replicas: 2 +# Host: rbench job 4382 (this arm, c96) peaked at ~70 GB RSS over its Slurm steps (sacct MaxRSS 65 GB + +# 4.4 GB + 0.3 GB); no host KV tier. ~3x headroom instead of the launcher's full node (192 CPU / 1.9 TB). +host_cpus: 64 +host_mem_gb: 192 +precision: fp8 +env: + VLLM_DISABLE_COMPILE_CACHE: "1" + NCCL_P2P_LEVEL: NVL + AIPERF_HTTP_X_DYNAMO_SESSION_ID_FROM_CORRELATION_ID: "true" +serve: | + python3 -m dynamo.vllm + --model RedHatAI/gemma-4-31B-it-FP8-block + --served-model-name RedHatAI/gemma-4-31B-it-FP8-block + --tensor-parallel-size 4 + --gpu-memory-utilization 0.92 + --max-model-len 262144 + --max-num-seqs 64 + --max-num-batched-tokens 8192 + --enable-chunked-prefill + --long-prefill-token-threshold 2048 + --kv-cache-metrics + --dyn-tool-call-parser gemma4 --dyn-reasoning-parser gemma4 +router: + image: vcr.vngcloud.vn/108942-public69/dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018-g4strip + env: + DYN_LOG: info,dynamo_kv_router::scheduling::selector=debug + serve: | + python3 -m dynamo.frontend + --router-mode=kv --router-prefill-load-scale=16 --router-min-initial-workers=2 + --router-session-affinity-ttl-secs=1800 --router-session-affinity-mode=hard +scenario: custom-agentic-livekit111868-v1-warm +conc: [96] diff --git a/serving/gemma4-livekit-2tp4-dyn-kv-sa.yaml b/serving/gemma4-livekit-2tp4-dyn-kv-sa.yaml new file mode 100644 index 0000000000..a7423c9a54 --- /dev/null +++ b/serving/gemma4-livekit-2tp4-dyn-kv-sa.yaml @@ -0,0 +1,43 @@ +# Gemma-4-31B FP8-block, 2x TP4 dynamo.vllm workers (vLLM 0.31.0) behind the Dynamo frontend KV +# router, LiveKit 111868 v1. Engine flags as the 2tp4 vllm-router arms; image: benchmarks/dynamo-vllm. +# Router cost = prefill_load_scale x (queued + own uncached prefill) + KV tokens in use; scale 16 keeps +# ~20k-token rooms on their worker. Hard session affinity pins every later turn of a room to the worker +# its first turn went to (x-dynamo-session-id = aiperf's per-conversation X-Correlation-ID). +# The frontend renders chat with its Rust preprocessor (no --dyn-chat-processor): the vLLM processor +# drops the session id before the router and adds ~0.1 s per request; the gemma4 tool/reasoning +# parsers go on the workers (--dyn-*-parser). Selector debug logs every decision's per-worker costs. +# Slurm reference (rbench, same flags, c96 x3): TTFT p95 0.66 / 0.64 / 0.65 s, 0 errors. +name: gemma4-livekit-2tp4-dyn-kv-sa +runner: hardware-hcm-8x_02 +image: vcr.vngcloud.vn/108942-public69/dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018-g4strip +model: RedHatAI/gemma-4-31B-it-FP8-block +gpus: 8 +replicas: 2 +precision: fp8 +env: + VLLM_DISABLE_COMPILE_CACHE: "1" + NCCL_P2P_LEVEL: NVL + AIPERF_HTTP_X_DYNAMO_SESSION_ID_FROM_CORRELATION_ID: "true" +serve: | + python3 -m dynamo.vllm + --model RedHatAI/gemma-4-31B-it-FP8-block + --served-model-name RedHatAI/gemma-4-31B-it-FP8-block + --tensor-parallel-size 4 + --gpu-memory-utilization 0.92 + --max-model-len 262144 + --max-num-seqs 64 + --max-num-batched-tokens 16384 + --enable-chunked-prefill + --long-prefill-token-threshold 8192 + --kv-cache-metrics + --dyn-tool-call-parser gemma4 --dyn-reasoning-parser gemma4 +router: + image: vcr.vngcloud.vn/108942-public69/dynamo-stack/vllm-runtime:0.31.0-dyn1.6.0.dev20261018-g4strip + env: + DYN_LOG: info,dynamo_kv_router::scheduling::selector=debug + serve: | + python3 -m dynamo.frontend + --router-mode=kv --router-prefill-load-scale=16 --router-min-initial-workers=2 + --router-session-affinity-ttl-secs=1800 --router-session-affinity-mode=hard +scenario: custom-agentic-livekit111868-v1 +conc: [96] diff --git a/utils/serving/check_scenario_golden.py b/utils/serving/check_scenario_golden.py index 08b7968388..84cd29f804 100644 --- a/utils/serving/check_scenario_golden.py +++ b/utils/serving/check_scenario_golden.py @@ -34,6 +34,7 @@ "custom-agentic-tau2": "tau2", "custom-agentic-tau2-smoke": "tau2", "custom-agentic-livekit111868-v1": "livekit", + "custom-agentic-livekit111868-v1-warm": "livekit", "custom-agentic-livekit111868-v2": "livekit", "custom-agentic-livekit111868-v1-nothink": "livekit", "custom-agentic-livekit111868-v2-nothink": "livekit", diff --git a/utils/serving/livekit_ttft.py b/utils/serving/livekit_ttft.py new file mode 100644 index 0000000000..212076b57a --- /dev/null +++ b/utils/serving/livekit_ttft.py @@ -0,0 +1,149 @@ +"""TTFT of a LiveKit (custom-agentic-livekit111868-*) run, split by a room's first turn vs later turns. + + python utils/serving/livekit_ttft.py ... [--plan ] + +A directory is searched for every aiperf_artifacts/profile_export.jsonl (one per conc); each is read +with the plan of the dataset its run used: dataset.txt names it (e.g. livekit-111868-v1), and the +lock entry -plan in scenarios/datasets/lock.yaml pins the plan CSV that ships with the trace +(fetched like the trace: GREENLAB_HF_TOKEN). --plan uses a local CSV instead. + +First turns (no parent in the plan) carry the room's whole system prompt uncached, so they set the +tail; "5s turns" are the requests whose logged client timeout is 5 s. Only the profiling phase counts. +Dial warm-up rows (warm=1 in the plan of a -warm dataset: the first prompt minus the user's utterance, +max_tokens 1, sent before the room's first turn) are left out of every view and get their own line: +they are not on the user's path, but must finish before the first turn arrives. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +from collections import defaultdict +from pathlib import Path + +UNCACHED_BUCKETS = ((0, 5000), (5000, 11000), (11000, 20000), (20000, None)) + + +def quantile(values: list[float], p: float) -> float: + values = sorted(values) + return values[min(len(values) - 1, int(p * len(values)))] if values else float("nan") + + +def load_plan(path: str | Path) -> dict[str, list[dict]]: + plan: dict[str, list[dict]] = defaultdict(list) + with open(path, newline="") as f: + for row in csv.DictReader(f): + plan[row["session_id"]].append(row) + for turns in plan.values(): + turns.sort(key=lambda r: int(r["i"])) + return plan + + +def load_requests(profile_export: Path, plan: dict[str, list[dict]]) -> list[dict]: + """One dict per profiling request with a TTFT: ttft (s), first, timeout_5s, uncached (tokens), warm.""" + rows = [] + with open(profile_export) as f: + for line in f: + record = json.loads(line) + meta, metrics = record["metadata"], record["metrics"] + if meta.get("benchmark_phase") != "profiling" or "time_to_first_token" not in metrics: + continue + turn = plan[meta["conversation_id"]][meta["turn_index"]] + rows.append({ + "ttft": metrics["time_to_first_token"]["value"] / 1e3, + "first": turn["parent"] == "", + "timeout_5s": float(turn["timeout"]) == 5.0, + "uncached": int(turn["isl"]) - int(turn["shared"]), + "warm": turn.get("warm") == "1", + }) + return rows + + +def describe(name: str, rows: list[dict]) -> list[str]: + ttft = [r["ttft"] for r in rows] + p95 = quantile(ttft, 0.95) + over = [r for r in rows if r["ttft"] > p95] + first = [r for r in rows if r["first"]] + lines = [(f"{name}: n={len(rows)} p95={p95:.2f}s first-turn share={len(first) / len(rows):.1%} " + f"first-turn share of >p95: {sum(r['first'] for r in over) / max(len(over), 1):.0%}")] + for label, sel in (("first", first), ("later", [r for r in rows if not r["first"]])): + x = [r["ttft"] for r in sel] + if not x: + continue + lines.append(f" {label}: n={len(x):5d} p50={quantile(x, .5):.2f} p90={quantile(x, .9):.2f} " + f"p95={quantile(x, .95):.2f} p99={quantile(x, .99):.2f} " + f">1s={sum(v > 1 for v in x) / len(x):.0%} " + f"median uncached={quantile([r['uncached'] for r in sel], .5)}") + return lines + + +def describe_warm(rows: list[dict]) -> list[str]: + x = [r["ttft"] for r in rows] + return [(f"warm-ups (excluded above): n={len(x)} p50={quantile(x, .5):.2f} p90={quantile(x, .9):.2f} " + f"p95={quantile(x, .95):.2f} p99={quantile(x, .99):.2f} max={max(x):.2f} done " + + " ".join(f"<={t:g}s={sum(v <= t for v in x) / len(x):.1%}" for t in (0.5, 1, 1.5, 2)))] + + +def report(rows: list[dict]) -> str: + warm = [r for r in rows if r["warm"]] + rows = [r for r in rows if not r["warm"]] + if not rows: + return "no profiling requests with a TTFT" + lines = describe("all", rows) + lines += describe("5s turns", [r for r in rows if r["timeout_5s"]]) if any(r["timeout_5s"] for r in rows) else [] + first = [r for r in rows if r["first"]] + for lo, hi in UNCACHED_BUCKETS: + x = [r["ttft"] for r in first if r["uncached"] >= lo and (hi is None or r["uncached"] < hi)] + if x: + lines.append(f" first turns uncached {lo}-{hi or ''}: n={len(x)} p50={quantile(x, .5):.2f} " + f"p95={quantile(x, .95):.2f} >1s={sum(v > 1 for v in x) / len(x):.0%}") + lines += describe_warm(warm) if warm else [] + return "\n".join(lines) + + +def find_exports(path: Path) -> list[Path]: + if path.is_file(): + return [path] + return sorted(path.rglob("profile_export.jsonl")) + + +def dataset_name(profile_export: Path) -> str: + """The dataset name from the run's dataset.txt, next to aiperf_artifacts/.""" + for parent in profile_export.parents: + txt = parent / "dataset.txt" + if txt.is_file(): + return txt.read_text().split()[0] + raise SystemExit(f"ERROR {profile_export}: no dataset.txt above it; pass --plan") + + +def plan_path(name: str) -> str: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from fetch_custom_dataset import fetch, load_lock + + if f"{name}-plan" not in load_lock(): + raise SystemExit(f"ERROR dataset {name}: no {name}-plan in scenarios/datasets/lock.yaml; pass --plan") + return fetch(f"{name}-plan") + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("paths", nargs="+", type=Path, help="artifact dirs or profile_export.jsonl files") + parser.add_argument("--plan", help="plan CSV to use instead of the locked one") + args = parser.parse_args(argv) + exports = [e for p in args.paths for e in find_exports(p)] + if not exports: + raise SystemExit("ERROR no profile_export.jsonl under " + " ".join(map(str, args.paths))) + plans: dict[str, dict] = {} + for export in exports: + key = args.plan or dataset_name(export) + if key not in plans: + plans[key] = load_plan(args.plan or plan_path(key)) + print(f"== {export}") + print(report(load_requests(export, plans[key]))) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/utils/test_serving_launcher_hcm.py b/utils/test_serving_launcher_hcm.py index 5c8a5fdd46..9e81e56217 100644 --- a/utils/test_serving_launcher_hcm.py +++ b/utils/test_serving_launcher_hcm.py @@ -13,7 +13,7 @@ def run_launcher(tmp_path: Path, extra_env: dict[str, str]) -> tuple[list[list[s log, envlog = tmp_path / "calls", tmp_path / "srun_env" stubs = { "hostname": "echo hgx-h200-02", "squeue": "echo 4711", "salloc": "", "scancel": "", - "srun": f'echo "${{DYN_DISCOVERY_BACKEND:-none}} ${{DYN_FILE_KV:-none}} ${{DYN_SYSTEM_PORT:-none}} ${{AIPERF_GPU_TELEMETRY_METRICS_CSV:-none}} $(case "$*" in *serve_router.sh*) echo router;; *) echo other;; esac) ${{ENGINE_PORT:-none}}" >> {envlog}\necho "${{ENGINE_PORTS:-none}}" >> {tmp_path}/ports', + "srun": f'echo "${{DYN_DISCOVERY_BACKEND:-none}} ${{DYN_FILE_KV:-none}} ${{DYN_SYSTEM_PORT:-none}} ${{AIPERF_GPU_TELEMETRY_METRICS_CSV:-none}} $(case "$*" in *serve_router.sh*) echo router;; *) echo other;; esac) ${{ENGINE_PORT:-none}}" >> {envlog}\necho "${{ENGINE_PORTS:-none}}" >> {tmp_path}/ports\necho "${{DYN_SYSTEM_PORTS:-none}}|${{DYN_KV_EVENT_PORTS:-none}}" >> {tmp_path}/dynports', } for name, body in stubs.items(): (bin_dir / name).write_text( @@ -139,3 +139,26 @@ def test_salloc_leaves_room_on_the_node(tmp_path, serve_cmd, serve_env, mem): calls, _ = run_launcher(tmp_path, {"SERVE_CMD": serve_cmd, "SERVE_ENV": serve_env}) salloc = next(c for c in calls if c[0] == "salloc") assert "--cpus-per-task=64" in salloc and f"--mem={mem}" in salloc, salloc + + +def test_dynamo_replicas_get_distinct_system_and_kv_event_ports(tmp_path): + extra = {"SERVE_CMD": "python3 -m dynamo.vllm --model m", "ROUTER_IMAGE": ROUTER_IMAGE, + "ROUTER_CMD": "python3 -m dynamo.frontend", "ROUTER_PROFILE": "dynamo", "REPLICAS": "2"} + _, envlog = run_launcher(tmp_path, extra) + seen = {line for line in (tmp_path / "dynports").read_text().splitlines() if line != "none|none"} + assert len(seen) == 1, seen # only the bench step gets them, not the router step + system, kv = (part.split() for part in seen.pop().split("|")) + assert len(system) == len(kv) == 2 and len(set(system + kv)) == 4 + assert all(0 < int(p) <= 32767 for p in system) + bench = {line.split()[2] for line in envlog.splitlines() if line.split()[0] == "file" and line.split()[2] != "none"} + assert bench == {system[0]} + + +@pytest.mark.parametrize("extra, cpus, mem", [ + ({}, "64", "512G"), + ({"HOST_CPUS": "48", "HOST_MEM_GB": "256"}, "48", "256G"), +], ids=["measured-default", "yaml-sized"]) +def test_salloc_host_resources(tmp_path, extra, cpus, mem): + calls, _ = run_launcher(tmp_path, {"SERVE_CMD": "python3 -m dynamo.sglang --model-path=m", **extra}) + salloc = next(c for c in calls if c[0] == "salloc") + assert f"--cpus-per-task={cpus}" in salloc and f"--mem={mem}" in salloc diff --git a/utils/test_serving_livekit_ttft.py b/utils/test_serving_livekit_ttft.py new file mode 100644 index 0000000000..4c1556d269 --- /dev/null +++ b/utils/test_serving_livekit_ttft.py @@ -0,0 +1,126 @@ +import hashlib +import json +import os +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +PLAN = textwrap.dedent("""\ + session_id,i,parent,shared,isl,rendered_isl,lcp_parent,osl,delay_ms,timeout + room-1,0,,0,12000,12000,0,10,,5.0 + room-1,1,0,11990,12100,12100,11990,10,800,5.0 + room-1,2,1,12090,12150,12150,12090,10,800,10.0 + room-2,0,,0,3000,3000,0,10,,5.0 +""") +FAKE_HUB = textwrap.dedent('''\ + import os + from . import errors + def hf_hub_download(repo_id, filename, revision=None, repo_type=None, token=None): + with open(os.environ["FAKE_HF_LOG"], "a") as f: + f.write(filename + "\\n") + return os.environ["FAKE_HF_FILE"] +''') +FAKE_ERRORS = textwrap.dedent('''\ + class HfHubHTTPError(Exception): + response = None + class RepositoryNotFoundError(HfHubHTTPError): pass +''') + + +def record(room, turn, ttft_ms, phase="profiling"): + return {"metadata": {"conversation_id": room, "turn_index": turn, "benchmark_phase": phase}, + "metrics": {"time_to_first_token": {"value": ttft_ms, "unit": "ms"}}} + + +@pytest.fixture() +def world(tmp_path): + """A mini repo with the script and a lock, a fake huggingface_hub serving the plan, and one run artifact.""" + repo = tmp_path / "repo" + (repo / "utils/serving").mkdir(parents=True) + (repo / "scenarios/datasets").mkdir(parents=True) + for name in ("livekit_ttft.py", "fetch_custom_dataset.py"): + shutil.copy(REPO / "utils/serving" / name, repo / "utils/serving/") + plan = tmp_path / "plan.csv" + plan.write_text(PLAN) + sha = hashlib.sha256(PLAN.encode()).hexdigest() + (repo / "scenarios/datasets/lock.yaml").write_text( + f"lk-v1-plan: {{repo: Green-Lab/x, revision: {'a' * 40}, file: plan.v1.csv, sha256: {sha}}}\n") + fake = tmp_path / "fake/huggingface_hub" + fake.mkdir(parents=True) + (fake / "__init__.py").write_text(FAKE_HUB) + (fake / "errors.py").write_text(FAKE_ERRORS) + + run = tmp_path / "artifacts/agentic_x_c96/aiperf_artifacts" + run.mkdir(parents=True) + (run.parent / "dataset.txt").write_text("lk-v1 Green-Lab/x@aaa file=trace.v1.jsonl sha256=0 path=/x\n") + rows = [record("room-1", 0, 900), record("room-1", 1, 100), record("room-1", 2, 300), + record("room-2", 0, 1500), record("room-2", 0, 50, phase="warmup")] + (run / "profile_export.jsonl").write_text("".join(json.dumps(r) + "\n" for r in rows)) + env = {**os.environ, "PYTHONPATH": str(tmp_path / "fake"), "FAKE_HF_LOG": str(tmp_path / "calls"), + "FAKE_HF_FILE": str(plan)} + env.pop("GREENLAB_HF_TOKEN", None) + env.pop("RESULT_DIR", None) + return {"repo": repo, "env": env, "plan": plan, "artifacts": tmp_path / "artifacts", + "calls": tmp_path / "calls"} + + +def run(world, *args): + return subprocess.run([sys.executable, str(world["repo"] / "utils/serving/livekit_ttft.py"), *map(str, args)], + env=world["env"], capture_output=True, text=True, check=False) + + +def test_artifact_dir_uses_the_locked_plan_of_its_dataset(world): + out = run(world, world["artifacts"]) + assert out.returncode == 0, out.stderr + assert world["calls"].read_text() == "plan.v1.csv\n" + lines = out.stdout.splitlines() + # 4 profiling requests (the warmup one is dropped); first turns 0.9 s and 1.5 s, later 0.1 s and 0.3 s. + assert lines[1] == "all: n=4 p95=1.50s first-turn share=50.0% first-turn share of >p95: 0%" + assert lines[2].startswith(" first: n= 2 p50=1.50 ") and ">1s=50%" in lines[2] + assert "median uncached=12000" in lines[2] + assert lines[3].startswith(" later: n= 2 p50=0.30 ") and "median uncached=110" in lines[3] + # The 10 s-timeout request leaves the "5s turns" view. + assert lines[4].startswith("5s turns: n=3 ") + assert " first turns uncached 0-5000: n=1 p50=1.50" in out.stdout + assert " first turns uncached 11000-20000: n=1 p50=0.90" in out.stdout + + +def test_local_plan_skips_the_fetch(world): + export = world["artifacts"] / "agentic_x_c96/aiperf_artifacts/profile_export.jsonl" + out = run(world, export, "--plan", world["plan"]) + assert out.returncode == 0, out.stderr + assert not world["calls"].exists() + assert "all: n=4 p95=1.50s" in out.stdout + + +def test_dataset_without_a_plan_entry_fails_with_a_hint(world): + (world["artifacts"] / "agentic_x_c96/dataset.txt").write_text("tau2-cs Green-Lab/y@b file=t sha256=0 path=/y\n") + out = run(world, world["artifacts"]) + assert out.returncode == 1 + assert "no tau2-cs-plan in scenarios/datasets/lock.yaml; pass --plan" in out.stderr + + +def test_warm_up_rows_are_reported_apart(world): + # A -warm plan: room-1's dial warm-up is turn 0, so its first turn is turn 1. + world["plan"].write_text(textwrap.dedent("""\ + session_id,i,parent,shared,isl,rendered_isl,lcp_parent,osl,delay_ms,timeout,warm + room-1,0,,0,11936,11936,,1,,5.0,1 + room-1,1,,0,12000,12000,0,10,3000,5.0, + room-1,2,1,11990,12100,12100,11990,10,800,5.0, + room-2,0,,0,3000,3000,0,10,,5.0, + """)) + export = world["artifacts"] / "agentic_x_c96/aiperf_artifacts/profile_export.jsonl" + rows = [record("room-1", 0, 700), record("room-1", 1, 120), record("room-1", 2, 100), record("room-2", 0, 1500)] + export.write_text("".join(json.dumps(r) + "\n" for r in rows)) + out = run(world, export, "--plan", world["plan"]) + assert out.returncode == 0, out.stderr + lines = out.stdout.splitlines() + assert lines[1].startswith("all: n=3 ") + assert lines[2].startswith(" first: n= 2 p50=1.50 ") + assert lines[-1] == ("warm-ups (excluded above): n=1 p50=0.70 p90=0.70 p95=0.70 p99=0.70 max=0.70 " + "done <=0.5s=0.0% <=1s=100.0% <=1.5s=100.0% <=2s=100.0%") diff --git a/utils/test_serving_render.py b/utils/test_serving_render.py index a9f5f67790..e2fc2cddd6 100644 --- a/utils/test_serving_render.py +++ b/utils/test_serving_render.py @@ -104,12 +104,12 @@ def test_dynamo_engine_without_router_rejected(): def test_dynamo_router_with_plain_engine_rejected(): - with pytest.raises(ServingError, match=r"^serve: python3 -m dynamo.frontend needs a python3 -m dynamo.sglang engine"): + with pytest.raises(ServingError, match=r"^serve: python3 -m dynamo.frontend needs a python3 -m dynamo.sglang or dynamo.vllm engine"): render(dyn(engine="python3 -m sglang.launch_server --model-path Qwen/Qwen3.8-27B")) def test_dynamo_engine_with_other_router_rejected(): - with pytest.raises(ServingError, match=r"^router.serve: a python3 -m dynamo.sglang engine needs python3 -m dynamo.frontend as its router"): + with pytest.raises(ServingError, match=r"^router.serve: a python3 -m dynamo.sglang / dynamo.vllm engine needs python3 -m dynamo.frontend as its router"): render(dyn(router="python3 -m sglang_router.launch_router --port={{router_port}}")) diff --git a/utils/test_serving_router.py b/utils/test_serving_router.py index 45dc606fcc..51dfb9f2e0 100644 --- a/utils/test_serving_router.py +++ b/utils/test_serving_router.py @@ -263,7 +263,8 @@ def run_generic_replicas(tmp_path, **extra): script.write_text(GENERIC.read_text()) bin_dir = tmp_path / "bin" bin_dir.mkdir() - for name, body in {"nvidia-smi": "", "engine": f'echo "$CUDA_VISIBLE_DEVICES $*" >> {tmp_path}/engines\nsleep 30\n'}.items(): + for name, body in {"nvidia-smi": "", "engine": f'echo "$CUDA_VISIBLE_DEVICES $*" >> {tmp_path}/engines\nsleep 30\n', + "dynengine": f'echo "$CUDA_VISIBLE_DEVICES $DYN_SYSTEM_PORT $*" >> {tmp_path}/engines\nsleep 30\n'}.items(): (bin_dir / name).write_text("#!/bin/sh\n" + body) (bin_dir / name).chmod(0o755) env = { @@ -302,6 +303,37 @@ def test_serve_generic_replicas_need_one_port_each(tmp_path): assert "replicas 2 needs 2 ports in ENGINE_PORTS" in out.stderr +DYNAMO_REPLICAS = {"SERVE_CMD": "dynengine --tensor-parallel-size 2", "ROUTER_CMD": "python3 -m dynamo.frontend", + "ROUTER_PROFILE": "dynamo", "DYN_SYSTEM_PORT": "9100", "DYN_SYSTEM_PORTS": "9100 9101", + "DYN_KV_EVENT_PORTS": "40001 40002", "ENGINE_PORT": "", "ENGINE_PORTS": ""} + + +def test_serve_generic_dynamo_replicas_get_own_gpus_system_and_kv_event_ports(tmp_path): + out = run_generic_replicas(tmp_path, **DYNAMO_REPLICAS) + assert out.returncode == 0, out.stderr + engines = tmp_path / "engines" + for _ in range(100): + if engines.exists() and len(engines.read_text().splitlines()) == 2: + break + time.sleep(0.05) + kv = '{"enable_kv_cache_events":true,"publisher":"zmq","endpoint":"tcp://*:%d"}' + assert sorted(engines.read_text().splitlines()) == [ + f"4,5 9100 --tensor-parallel-size 2 --kv-events-config {kv % 40001}", + f"6,7 9101 --tensor-parallel-size 2 --kv-events-config {kv % 40002}", + ] + calls = (tmp_path / "results/calls").read_text() + assert f"WAIT health --port 9100 --server-log {tmp_path}/results/server.log" in calls + assert f"WAIT health --port 9101 --server-log {tmp_path}/results/server.1.log" in calls + assert "METRICS http://localhost:8888/metrics,http://localhost:9100/metrics,http://localhost:9101/metrics" in calls + assert "--port" not in (tmp_path / "results/server_command.1.txt").read_text() + + +def test_serve_generic_dynamo_replicas_need_one_kv_event_port_each(tmp_path): + out = run_generic_replicas(tmp_path, **DYNAMO_REPLICAS | {"DYN_KV_EVENT_PORTS": "40001"}) + assert out.returncode == 1 + assert "replicas 2 needs 2 ports in DYN_SYSTEM_PORTS/DYN_KV_EVENT_PORTS" in out.stderr + + def test_serve_router_never_prints_router_env_values(tmp_path): cmd = "python3 -c 'print(1)'" out = subprocess.run(["bash", str(ROUTER)], cwd=REPO, capture_output=True, text=True, env={ diff --git a/utils/test_serving_schema.py b/utils/test_serving_schema.py index c27a1c87f2..b88955d273 100644 --- a/utils/test_serving_schema.py +++ b/utils/test_serving_schema.py @@ -68,7 +68,7 @@ def test_router_unknown_key_rejected(): def test_router_needs_hardware_hcm_runner(): text = dynamo().replace("runner: hardware-hcm-8x_02", "runner: h200-greennode-slurm-1x_01").replace("gpus: 8", "gpus: 1") - assert "router: needs a hardware-hcm-8x_* runner (only launch_hardware-hcm.sh starts the router)" in errors(text) + assert "router: needs a hardware-hcm-8x_* or hcm-rtx6000pro_* runner" in errors(text) @pytest.mark.parametrize("field, text", [ @@ -287,7 +287,7 @@ def test_storage_unknown_key_rejected(): def test_storage_needs_hardware_hcm_runner(): text = GOOD + STORAGE # h200-greennode runner, not hardware-hcm - assert "storage: needs a hardware-hcm-8x_* runner" in errors(text) + assert "storage: needs a hardware-hcm-8x_* or hcm-rtx6000pro_* runner" in errors(text) def test_storage_unknown_placeholder_rejected(): @@ -329,6 +329,15 @@ def test_env_placeholder_rejected_without_storage_block(): """) +DYNAMO_VLLM_REPLICAS = dynamo().replace("gpus: 8", "gpus: 8\nreplicas: 2").replace( + "serve: python3 -m dynamo.sglang --model-path=Qwen/Qwen3.8-27B --tp=8", + "serve: python3 -m dynamo.vllm --model Qwen/Qwen3.8-27B --tensor-parallel-size 4") + + +def test_dynamo_vllm_replicas_load(): + assert load_serving(DYNAMO_VLLM_REPLICAS).replicas == 2 + + def test_replicas_default_one_and_router_yaml_loads(): assert load_serving(GOOD).replicas == 1 assert load_serving(REPLICAS_YAML).replicas == 2 @@ -338,10 +347,13 @@ def test_replicas_default_one_and_router_yaml_loads(): (REPLICAS_YAML.replace("gpus: 2", "gpus: 3"), "replicas: gpus 3 is not a multiple of replicas 2"), (REPLICAS_YAML.split("router:")[0] + "scenario: agentx-cc-062126-smoke\nconc: [8]\n", "replicas: more than 1 replica needs a router: block"), - (dynamo().replace("gpus: 8", "gpus: 8\nreplicas: 2"), "replicas: the Dynamo frontend discovers its engines itself"), + (dynamo().replace("gpus: 8", "gpus: 8\nreplicas: 2"), "replicas: with the Dynamo frontend only python3 -m dynamo.vllm"), + (DYNAMO_VLLM_REPLICAS.replace("--tensor-parallel-size 4", "--tensor-parallel-size 4 --kv-events-config '{}'"), + "replicas: drop --kv-events-config"), (REPLICAS_YAML.replace("replicas: 2", "replicas: 0"), "replicas: Input should be greater than or equal to 1"), (REPLICAS_YAML.replace("gpus: 2\n", "gpus: 2\nenv: {ENGINE_PORTS: x}\n"), "env.ENGINE_PORTS: reserved"), -], ids=["indivisible", "no-router", "dynamo", "zero", "reserved-env"]) + (REPLICAS_YAML.replace("gpus: 2\n", "gpus: 2\nenv: {DYN_KV_EVENT_PORTS: x}\n"), "env.DYN_KV_EVENT_PORTS: reserved"), +], ids=["indivisible", "no-router", "dynamo-sglang", "dynamo-kv-events", "zero", "reserved-env", "reserved-dyn-env"]) def test_replicas_errors(text, error): assert error in errors(text) @@ -372,3 +384,18 @@ def test_non_sweep_scenario_keeps_conc_rule_unchanged(sweep_repo): text = with_line("conc: [4, 8]", "conc: [8, 16]") spec = load_serving(text, sweep_repo) assert spec.conc == [8, 16] + + +def test_host_resources_load_on_hardware_hcm(): + spec = load_serving(DYNAMO_VLLM_REPLICAS.replace("replicas: 2", "replicas: 2\nhost_cpus: 64\nhost_mem_gb: 256")) + assert (spec.host_cpus, spec.host_mem_gb) == (64, 256) + assert load_serving(DYNAMO_VLLM_REPLICAS).host_cpus is None + + +@pytest.mark.parametrize("text, error", [ + (GOOD.replace("gpus: 1", "gpus: 1\nhost_cpus: 16"), "host_cpus/host_mem_gb: only the hardware-hcm-8x_* launcher"), + (dynamo().replace("gpus: 8", "gpus: 8\nhost_cpus: 2"), "host_cpus: Input should be greater than or equal to 4"), + (dynamo().replace("gpus: 8", "gpus: 8\nhost_mem_gb: 8"), "host_mem_gb: Input should be greater than or equal to 16"), +], ids=["other-runner", "cpus-too-small", "mem-too-small"]) +def test_host_resources_errors(text, error): + assert error in errors(text) diff --git a/utils/test_serving_to_matrix.py b/utils/test_serving_to_matrix.py index 3bad6d3a3a..39617fb541 100644 --- a/utils/test_serving_to_matrix.py +++ b/utils/test_serving_to_matrix.py @@ -77,6 +77,8 @@ def test_serving_object(): "router-profile": "", "router-metrics-port": "", "replicas": "1", + "host-cpus": "", + "host-mem-gb": "", "storage-image": "", "storage-cmd": "", "storage-env": "", @@ -85,7 +87,7 @@ def test_serving_object(): GOLDEN = REPO / "utils/testdata/serving_dynamo_build_golden.json" NEW_SERVING_FIELDS = ( - "router-profile", "router-metrics-port", "replicas", + "router-profile", "router-metrics-port", "replicas", "host-cpus", "host-mem-gb", "storage-image", "storage-cmd", "storage-env", "search", # sweep scenarios; the goldens predate it "scenario-map", # per-row scenario payloads; the goldens predate it @@ -158,6 +160,19 @@ def test_dynamo_serving_object_and_rows(): assert result["matrix"][0]["tp"] == 8 +def test_dynamo_vllm_replicas_render_as_vllm_behind_the_frontend(): + text = (DYNAMO.replace("gpus: 8", "gpus: 8\nreplicas: 2") + .replace("python3 -m dynamo.sglang --model-path=PhalaCloud/GLM-5.3-W4AFP8 --served-model-name=zai-org/GLM-5.3 --tp=8 --enable-metrics", + "python3 -m dynamo.vllm --model PhalaCloud/GLM-5.3-W4AFP8 --served-model-name zai-org/GLM-5.3 --tensor-parallel-size 4")) + result = build(text) + serving = result["serving"] + assert serving["router-profile"] == "dynamo" + assert serving["replicas"] == "2" + assert serving["served-model-name"] == "zai-org/GLM-5.3" + validate_matrix(result["matrix"]) + assert {row["framework"] for row in result["matrix"]} == {"vllm"} + + @pytest.mark.parametrize("path", sorted((REPO / "serving").glob("*.yaml")), ids=lambda p: p.name) def test_every_checked_in_serving_yaml_builds(path): build(path.read_text())