From 759d6bab17fdadb069aa3e119f71f99b783c3eab Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 1 Oct 2026 17:31:43 -0700 Subject: [PATCH 1/2] feat: add experimental Praxis inference benchmark treatment Signed-off-by: Daneyon Hansen --- inference/README.md | 1 + .../gke/tests/test-run-gke-campaign.sh | 22 ++++ inference/reporting/common/rendering.py | 49 +++++++-- inference/reporting/generate.py | 38 +++++++ inference/reporting/tests/test_generate.py | 67 +++++++++++- inference/run-benchmark.sh | 71 +++++++++++-- inference/run-gke-campaign.sh | 37 ++++++- inference/run-gke-smoke.sh | 4 + inference/suites/llm-d-benchmark/PRAXIS.md | 100 ++++++++++++++++++ inference/suites/llm-d-benchmark/README.md | 8 ++ .../scenarios/gateways/praxis/standalone.yaml | 69 ++++++++++++ .../scripts/build-praxis-image.sh | 53 ++++++++++ .../scripts/render-scenario.py | 11 ++ .../scripts/standalone-invariant.py | 12 ++- .../scripts/verify-praxis-runtime.py | 60 +++++++++++ .../tests/test-run-benchmark.sh | 20 ++++ .../tests/test_render_scenario.py | 73 +++++++++++++ .../tests/test_verify_praxis_runtime.py | 61 +++++++++++ 18 files changed, 730 insertions(+), 26 deletions(-) create mode 100644 inference/suites/llm-d-benchmark/PRAXIS.md create mode 100644 inference/suites/llm-d-benchmark/scenarios/gateways/praxis/standalone.yaml create mode 100644 inference/suites/llm-d-benchmark/scripts/build-praxis-image.sh create mode 100644 inference/suites/llm-d-benchmark/scripts/verify-praxis-runtime.py create mode 100644 inference/suites/llm-d-benchmark/tests/test_verify_praxis_runtime.py diff --git a/inference/README.md b/inference/README.md index 8724691..4bb8247 100644 --- a/inference/README.md +++ b/inference/README.md @@ -42,6 +42,7 @@ repetitions whose inference backend, workload, and infrastructure must match. | `agentgateway-standalone` | agentgateway in the standalone EPP topology | | `agentgateway-gateway` | agentgateway on Kubernetes with EPP | | `envoy-standalone` | Envoy sidecar in the standalone EPP topology | +| `praxis-standalone` | Experimental custom Praxis AI sidecar; see [build and qualification requirements](suites/llm-d-benchmark/PRAXIS.md) | Required variables: diff --git a/inference/provisioning/gke/tests/test-run-gke-campaign.sh b/inference/provisioning/gke/tests/test-run-gke-campaign.sh index d343c20..25c733f 100755 --- a/inference/provisioning/gke/tests/test-run-gke-campaign.sh +++ b/inference/provisioning/gke/tests/test-run-gke-campaign.sh @@ -140,4 +140,26 @@ fi grep -q 'BENCHMARK_GKE_CLUSTER_LIFECYCLE must be retain or destroy' \ "${TEMP_DIR}/invalid-lifecycle.log" +: >"${TEST_COMMAND_LOG}" +PRAXIS_IMAGE="example.invalid/praxis@sha256:$(printf '%064d' 1)" \ +BENCHMARK_TREATMENTS='agentgateway-standalone praxis-standalone' \ +BENCHMARK_CAMPAIGN_ID=test-praxis-comparison \ + "${CAMPAIGN_SCRIPT}" >"${TEMP_DIR}/praxis.log" +[[ "$(grep -c '^target=benchmark ' "${TEST_COMMAND_LOG}")" == 2 ]] +grep -q '^target=benchmark treatment=praxis-standalone ' "${TEST_COMMAND_LOG}" +grep -q '^target=benchmark-report .*comparisons=agentgateway-standalone:praxis-standalone$' "${TEST_COMMAND_LOG}" + +for treatments in 'service praxis-standalone' 'service typo' 'service service'; do + : >"${TEST_COMMAND_LOG}" + if PRAXIS_IMAGE=example.invalid/praxis:latest \ + BENCHMARK_TREATMENTS="${treatments}" BENCHMARK_CAMPAIGN_ID=test-invalid-matrix \ + "${CAMPAIGN_SCRIPT}" >"${TEMP_DIR}/invalid-matrix.log" 2>&1; then + echo "invalid campaign matrix unexpectedly accepted: ${treatments}" >&2 + exit 1 + fi + [[ ! -s "${TEST_COMMAND_LOG}" ]] || { + echo "invalid matrix reached cloud orchestration" >&2; exit 1; + } +done + echo "GKE campaign orchestration tests passed" diff --git a/inference/reporting/common/rendering.py b/inference/reporting/common/rendering.py index 8af2f94..3037df4 100644 --- a/inference/reporting/common/rendering.py +++ b/inference/reporting/common/rendering.py @@ -12,10 +12,11 @@ def display_name(name: str) -> str: names = { - "service": "k8s service (RR)", + "service": "k8s service", "agentgateway-standalone": "agentgateway standalone", "agentgateway-gateway": "agentgateway on Kubernetes", "envoy-standalone": "Envoy standalone", + "praxis-standalone": "Praxis AI standalone", } return names.get(name, name.replace("-", " ")) @@ -38,6 +39,19 @@ def agentgateway_mode_note(name: str) -> str | None: "(https://agentgateway.dev/docs/kubernetes/latest/llm/inference/" "inference-routing/)." ), + "praxis-standalone": ( + "**Praxis mode:** A custom Praxis AI build enables its experimental " + "`llmd-ext-proc` feature. Praxis runs beside the same EPP, uses " + "localhost gRPC for request scheduling, and forwards to the EPP-selected " + "model endpoint. The chart's generic Envoy slot is replaced by the " + "Praxis binary and configuration. This is a benchmark adapter, not " + "native Praxis Gateway API support. At the pinned source revision, the " + "ext_proc filter rejects response-body forwarding to the EPP and " + "trailer processing. This adapter sends request headers/body and " + "response headers only. Matching EPP configuration does not prove " + "equivalent request-lifecycle accounting; verify that separately " + "before interpreting GPU or scheduling results." + ), } return notes.get(name) @@ -210,21 +224,31 @@ def write_markdown( delta_heading = "Δ% vs k8s" if baseline.name == "service" else f"Δ% vs {b_name}" comparison_description = ( f"Graphs below compare {c_name} routing to a stock Kubernetes Service " - f"that round-robins requests across the same {identity['replicas']} " - f"{backend} pods (no EPP, no scoring). Tables abbreviate this baseline " - "as `k8s service (RR)`, where RR means round-robin." + f"across the same {identity['replicas']} {backend} pods (no EPP, no " + "scoring). Service balancing occurs at connection level; persistent " + "connections do not guarantee round-robin distribution of requests. " + "This comparison includes both gateway and scheduler effects and does " + "not isolate proxy overhead." if baseline.name == "service" else f"Graphs below compare {c_name} to {b_name} across the same " f"{identity['replicas']} {backend} pods." ) - lines = [ - "# Benchmark Report", - "", + topology = ( f"The benchmark runs model {identity['model']} on {gpu_count} × {accelerator} " f"GPUs, distributed across {identity['replicas']} {backend} " f"model servers ({identity['tensor_parallelism']} GPUs per server with " - f"TP={identity['tensor_parallelism']}). Workload is " + f"TP={identity['tensor_parallelism']})." + if identity.get("accelerator_type") == "gpu" and backend != "sim" + else f"The benchmark uses {identity['replicas']} {backend} backend pods " + f"on {identity.get('accelerator_type', 'unspecified')} resources. " + "Simulator results measure routing and transport behavior, not GPU " + "inference throughput or model quality." + ) + lines = [ + "# Benchmark Report", + "", + f"{topology} Workload is " f"`{identity['workload']}` driven across the configured request-rate ladder.", "", "> [!NOTE]", @@ -241,6 +265,15 @@ def write_markdown( if mode_note: lines.extend([mode_note, ""]) lines.extend([comparison_description, ""]) + if baseline.name.endswith("-standalone") and candidate.name.endswith("-standalone"): + lines.extend([ + "Both treatments use the same EPP policy, backend topology, workload, " + "and proxy resource allocation. Differences measure the complete " + "gateway integration path, including EPP communication and streaming; " + "they do not demonstrate that one scheduler is more effective. " + "EPP and GPU variability remain part of end-to-end measurements.", + "", + ]) if include_images: lines.extend( [ diff --git a/inference/reporting/generate.py b/inference/reporting/generate.py index cd96eaf..47b1d38 100644 --- a/inference/reporting/generate.py +++ b/inference/reporting/generate.py @@ -5,6 +5,7 @@ import argparse import os +import re import shutil from pathlib import Path @@ -81,6 +82,42 @@ def validate_registered_repetitions( ) +def validate_praxis_comparison(manifest: dict, baseline: str, candidate: str) -> None: + """Refuse to publish a Praxis comparison with missing or unequal controls.""" + if "praxis-standalone" not in (baseline, candidate): + return + treatments = manifest["treatments"] + praxis_runs = treatments["praxis-standalone"]["repetitions"] + builds = set() + for run in praxis_runs.values(): + gateway = run.get("gateway", {}) + image = gateway.get("image", "") + source = gateway.get("source_revision", "") + if not re.fullmatch(r"[^\s@]+@sha256:[0-9a-f]{64}", image): + raise ValueError("Praxis comparison requires an immutable image digest") + if not re.fullmatch(r"[0-9a-f]{40}", source): + raise ValueError("Praxis comparison requires a source revision") + if gateway.get("build_features") != "full,llmd-ext-proc": + raise ValueError("Praxis comparison requires full,llmd-ext-proc build evidence") + builds.add((image, source)) + if len(builds) != 1: + raise ValueError("Praxis image/source must remain fixed across repetitions") + if not all(name.endswith("-standalone") for name in (baseline, candidate)): + return + controls = set() + for treatment in (baseline, candidate): + for run in treatments[treatment]["repetitions"].values(): + digest = run.get("router_chart_digest", "") + invariant = run.get("standalone_invariant_sha256", "") + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ValueError("standalone comparison requires the router chart digest") + if not re.fullmatch(r"[0-9a-f]{64}", invariant): + raise ValueError("standalone comparison requires the configuration invariant") + controls.add((run.get("router_chart_version"), digest, invariant)) + if len(controls) != 1: + raise ValueError("standalone comparison has different charts, EPP policies, resources or workloads") + + def main() -> None: args = parser().parse_args() campaign = args.campaign.resolve() @@ -112,6 +149,7 @@ def main() -> None: validate_registered_repetitions( campaign, treatment, registered_treatments[treatment] ) + validate_praxis_comparison(manifest, baseline_name, candidate_name) prism_treatments.update((baseline_name, candidate_name)) baseline = read_treatment(campaign, baseline_name) candidate = read_treatment(campaign, candidate_name) diff --git a/inference/reporting/tests/test_generate.py b/inference/reporting/tests/test_generate.py index 04c00d8..e500d2f 100644 --- a/inference/reporting/tests/test_generate.py +++ b/inference/reporting/tests/test_generate.py @@ -176,13 +176,14 @@ def test_generates_upstream_style_artifacts(self) -> None: self.assertIn("agentgateway standalone", markdown) self.assertNotIn("Agentgateway", markdown) self.assertIn("
", markdown) - self.assertIn("| Rate | k8s service (RR) Output", markdown) + self.assertIn("| Rate | k8s service Output", markdown) self.assertIn( - "a stock Kubernetes Service that round-robins requests", + "a stock Kubernetes Service across the same", markdown, ) self.assertIn("(no EPP, no scoring)", markdown) - self.assertIn("where RR means round-robin", markdown) + self.assertIn("do not guarantee round-robin distribution of requests", markdown) + self.assertIn("does not isolate proxy overhead", markdown) self.assertIn("**agentgateway mode:**", markdown) self.assertIn("standalone request scheduler mode", markdown) self.assertIn( @@ -317,5 +318,65 @@ def test_represents_all_failed_stage_without_fabricated_latency(self) -> None: ) +class PraxisComparisonTest(unittest.TestCase): + def create_praxis_campaign(self, root: Path) -> Path: + campaign = GenerateReportTest.create_campaign(self, root) + (campaign / "runs/service").rename(campaign / "runs/praxis-standalone") + manifest_path = campaign / "campaign-manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text()) + manifest["identity"].update(accelerator_type="sim", backend_type="inference-sim") + treatments = manifest["treatments"] + treatments["praxis-standalone"] = treatments.pop("service") + for entry in treatments.values(): + entry["repetitions"]["1"].update({ + "router_chart_version": "v0.9.0", + "router_chart_digest": "sha256:" + "a" * 64, + "standalone_invariant_sha256": "b" * 64, + }) + treatments["praxis-standalone"]["repetitions"]["1"]["gateway"] = { + "image": "example.invalid/praxis@sha256:" + "c" * 64, + "source_revision": "d" * 40, + "build_features": "full,llmd-ext-proc", + } + manifest_path.write_text(yaml.safe_dump(manifest)) + return campaign + + def test_praxis_report_describes_gateway_path_and_simulator_scope(self) -> None: + with tempfile.TemporaryDirectory() as directory: + campaign = self.create_praxis_campaign(Path(directory)) + subprocess.run([ + sys.executable, str(GENERATOR), "--campaign", str(campaign), + "--comparison", "agentgateway-standalone:praxis-standalone", + "--formats", "markdown,csv", + ], check=True, capture_output=True, text=True) + markdown = (campaign / "generated/agentgateway-standalone-vs-praxis-standalone/README.md").read_text() + self.assertIn("Praxis AI standalone", markdown) + self.assertIn("experimental", markdown) + self.assertIn("not GPU inference throughput", markdown) + self.assertIn("do not demonstrate that one scheduler is more effective", markdown) + self.assertNotIn("16 × H100 GPUs", markdown) + + def test_praxis_report_rejects_incomplete_or_different_controls(self) -> None: + for field, value in ( + ("standalone_invariant_sha256", "e" * 64), + ("standalone_invariant_sha256", ""), + ("router_chart_digest", "sha256:" + "e" * 64), + ("gateway", {"image": "praxis:latest"}), + ): + with self.subTest(field=field, value=value), tempfile.TemporaryDirectory() as directory: + campaign = self.create_praxis_campaign(Path(directory)) + path = campaign / "campaign-manifest.yaml" + manifest = yaml.safe_load(path.read_text()) + manifest["treatments"]["praxis-standalone"]["repetitions"]["1"][field] = value + path.write_text(yaml.safe_dump(manifest)) + result = subprocess.run([ + sys.executable, str(GENERATOR), "--campaign", str(campaign), + "--comparison", "agentgateway-standalone:praxis-standalone", + "--formats", "markdown,csv", + ], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((campaign / "generated/agentgateway-standalone-vs-praxis-standalone/README.md").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/inference/run-benchmark.sh b/inference/run-benchmark.sh index 43f1d0c..a8b1a1f 100755 --- a/inference/run-benchmark.sh +++ b/inference/run-benchmark.sh @@ -10,7 +10,8 @@ # # Required: BENCHMARK_TREATMENT and BENCHMARK_CAMPAIGN_ID. Supported treatments # are service, agentgateway-standalone, agentgateway-gateway, and -# envoy-standalone. +# envoy-standalone, praxis-standalone. Praxis requires PRAXIS_IMAGE pinned +# by sha256 and built with full,llmd-ext-proc; see the Praxis suite guide. # Optional: BENCHMARK_CLUSTER_PROVIDER (kind or gke; default: kind), # BENCHMARK_REPETITION (positive campaign repetition; default: 1), # BENCHMARK_ACCELERATOR_TYPE and BENCHMARK_BACKEND_TYPE (default together to @@ -90,6 +91,9 @@ HELM_DIFF_HELM4_VERSION="${BENCHMARK_HELM_DIFF_HELM4_VERSION:-v3.15.11}" AGW_VERSION="${AGW_VERSION:-v1.4.1}" AGW_IMAGE="${AGW_IMAGE:-cr.agentgateway.dev/agentgateway:${AGW_VERSION}}" +PRAXIS_IMAGE="${PRAXIS_IMAGE:-}" +PRAXIS_SOURCE_REVISION="${PRAXIS_SOURCE_REVISION:-f5f51a751d6f25acde96fb840665b24f2696ce46}" +GATEWAY_IMAGE="${AGW_IMAGE}" AGW_CONTROLLER_NAMESPACE="${AGW_CONTROLLER_NAMESPACE:-agentgateway-system}" WORKLOAD_FILE_PATH="" WORKLOAD="" @@ -481,6 +485,20 @@ validate_configuration() { envoy-standalone) BENCHMARK_GATEWAY_IMPLEMENTATION=envoy BENCHMARK_ROUTER_MODE=standalone + GATEWAY_IMAGE=docker.io/envoyproxy/envoy:distroless-v1.33.2 + ;; + praxis-standalone) + BENCHMARK_GATEWAY_IMPLEMENTATION=praxis + BENCHMARK_ROUTER_MODE=standalone + [[ "${PRAXIS_IMAGE}" =~ ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ ]] || { + log "praxis-standalone requires PRAXIS_IMAGE=registry/repository@sha256:digest (full,llmd-ext-proc build)" + return 2 + } + [[ "${PRAXIS_SOURCE_REVISION}" =~ ^[0-9a-f]{40}$ ]] || { + log "PRAXIS_SOURCE_REVISION must be a full immutable Git revision" + return 2 + } + GATEWAY_IMAGE="${PRAXIS_IMAGE}" ;; "") log "BENCHMARK_TREATMENT is required" @@ -511,7 +529,7 @@ validate_configuration() { return 2 fi case "${BENCHMARK_ROUTER_MODE}" in standalone|gateway) ;; *) log "unsupported BENCHMARK_ROUTER_MODE: ${BENCHMARK_ROUTER_MODE}"; return 2 ;; esac - case "${BENCHMARK_GATEWAY_IMPLEMENTATION}" in agentgateway|envoy) ;; *) log "unsupported BENCHMARK_GATEWAY_IMPLEMENTATION: ${BENCHMARK_GATEWAY_IMPLEMENTATION}"; return 2 ;; esac + case "${BENCHMARK_GATEWAY_IMPLEMENTATION}" in agentgateway|envoy|praxis) ;; *) log "unsupported BENCHMARK_GATEWAY_IMPLEMENTATION: ${BENCHMARK_GATEWAY_IMPLEMENTATION}"; return 2 ;; esac case "${BENCHMARK_ROUTING_POLICY}" in default|optimized-baseline|cache-only|load-only) ;; *) log "unsupported BENCHMARK_ROUTING_POLICY: ${BENCHMARK_ROUTING_POLICY}"; return 2 ;; @@ -1059,7 +1077,7 @@ render_scenario() { --gateway-implementation "${BENCHMARK_GATEWAY_IMPLEMENTATION}" --router-mode "${BENCHMARK_ROUTER_MODE}" --routing-policy "${BENCHMARK_ROUTING_POLICY}" - --gateway-image "${AGW_IMAGE}" + --gateway-image "${GATEWAY_IMAGE}" --agentgateway-version "${AGW_VERSION}" --router-chart-version "${BENCHMARK_ROUTER_CHART_VERSION}" --workload "${WORKLOAD}" @@ -1173,8 +1191,8 @@ def choose(suffix): if treatment == "service": selected = choose("-direct") -elif treatment in ("envoy-standalone", "agentgateway-standalone"): - # Both standalone implementations are deployed behind the router-epp +elif treatment in ("envoy-standalone", "agentgateway-standalone", "praxis-standalone"): + # Standalone implementations are deployed behind the router-epp # Service. Selecting that ClusterIP keeps their measured network path # identical and avoids the external GKE load balancer. selected = choose("-router-epp") @@ -1208,6 +1226,25 @@ PY return "${status}" } +verify_praxis_runtime() { + [[ "${BENCHMARK_TREATMENT}" == "praxis-standalone" ]] || return 0 + local pods pod + pods="$(mktemp "${TMPDIR:-/tmp}/praxis-runtime.XXXXXX.json")" + kubectl get pods --namespace "${SCENARIO_NAME}" -o json > "${pods}" + pod="$("${LLM_D_BENCHMARK_DIR}/.venv/bin/python" \ + "${SUITE_DIR}/scripts/verify-praxis-runtime.py" \ + --input "${pods}" --image "${PRAXIS_IMAGE}" \ + --source-revision "${PRAXIS_SOURCE_REVISION}" \ + --output "${SPEC_DIR}/praxis-runtime-inventory.json")" || { + rm -f "${pods}" + return 1 + } + rm -f "${pods}" + kubectl exec --namespace "${SCENARIO_NAME}" "${pod}" --container praxis-proxy -- \ + praxis-ai --validate --config /etc/praxis/config.yaml + log "Praxis runtime preflight: immutable image and feature-dependent configuration verified" +} + verify_reference_runtime() { local expected_vllm_version case "${BENCHMARK_REFERENCE_PROFILE}" in @@ -2265,9 +2302,15 @@ for component in stack: if treatment != "service": proxy = scenario.get("router", {}).get("proxy", {}) - proxy_image = proxy.get("presets", {}).get(gateway_implementation, {}).get( + proxy_image = proxy.get("image") or proxy.get("presets", {}).get(gateway_implementation, {}).get( "image", "" ) + # The upstream chart identifies its preset slot as Envoy even though the + # actual container is Praxis. Do not publish that scaffolding as a runtime. + if gateway_implementation == "praxis": + stack[:] = [component for component in stack if "envoy" not in str( + component.get("standardized", {}).get("tool", "") + ).lower()] has_gateway_component = any( "gateway" in " ".join( @@ -2279,7 +2322,7 @@ if treatment != "service": ).lower() for component in stack ) - if not has_gateway_component: + if not has_gateway_component or gateway_implementation == "praxis": native_config = copy.deepcopy(proxy) cfg_id = hashlib.sha256( yaml.safe_dump(native_config, sort_keys=True).encode("utf-8") @@ -2332,6 +2375,9 @@ write_prism_supporting_artifacts() { done cp "${SPEC_DIR}/scenario.yaml" "${artifact_dir}/benchmark-scenario.yaml" + if [[ -f "${SPEC_DIR}/praxis-runtime-inventory.json" ]]; then + cp "${SPEC_DIR}/praxis-runtime-inventory.json" "${artifact_dir}/praxis-runtime-inventory.json" + fi cp "${source_dir}/config.yaml" "${artifact_dir}/config.yaml" cp "${source_dir}/run_metadata.yaml" "${artifact_dir}/run_metadata.yaml" cp "${source_dir}/stdout.log" "${artifact_dir}/inference-perf-stdout.log" @@ -2443,9 +2489,9 @@ write_campaign_manifest() { "${BENCHMARK_ROUTER_CHART_VERSION}" "${ROUTER_CHART_DIGEST}" \ "${STANDALONE_INVARIANT_SHA256}" "${RUNTIME_METRICS_ENABLED}" \ "${METRICS_INTERVAL}" "${GKE_MONITORING_MODE}" "${FAST_COLLECT}" \ - "${GPU_RELEASE_POLICY}" "${GKE_GPU_NODEPOOL}" "${AGW_IMAGE}" \ + "${GPU_RELEASE_POLICY}" "${GKE_GPU_NODEPOOL}" "${GATEWAY_IMAGE}" \ "${LLM_D_BENCHMARK_REF}" "${BENCHMARK_REFERENCE_PROFILE}" \ - "${BENCHMARK_ENDPOINT_PATH}" "${AGW_VERSION}" <<'PY' + "${BENCHMARK_ENDPOINT_PATH}" "${AGW_VERSION}" "${PRAXIS_SOURCE_REVISION}" <<'PY' import os import sys from pathlib import Path @@ -2496,6 +2542,7 @@ import yaml reference_profile, endpoint_path, agentgateway_version, + praxis_source_revision, ) = sys.argv[1:] path = Path(metadata_path) @@ -2580,6 +2627,9 @@ treatment_entry.setdefault("repetitions", {})[str(repetition)] = { agentgateway_version if treatment.startswith("agentgateway-") else "not-applicable" ), + "source_revision": praxis_source_revision if treatment == "praxis-standalone" else "not-recorded", + "build_features": "full,llmd-ext-proc" if treatment == "praxis-standalone" else "not-applicable", + "chart_adapter": "envoy-slot-with-praxis-overrides" if treatment == "praxis-standalone" else "native", }, "storage": { "model": { @@ -2665,7 +2715,7 @@ main() { render_bundled_upstream_workload resolve_campaign_input_hashes resolve_router_chart_digest - if [[ "${BENCHMARK_CLUSTER_PROVIDER}" == "kind" ]]; then + if [[ "${BENCHMARK_CLUSTER_PROVIDER}" == "kind" && "${BENCHMARK_TREATMENT}" == agentgateway-* ]]; then command -v skopeo >/dev/null || { log "skopeo is required for the kind provider" return 1 @@ -2694,6 +2744,7 @@ PY ensure_agentgateway_gateway_controller configure_agentgateway_gateway_image configure_gke_monitoring + verify_praxis_runtime log "smoketest: ${TREATMENT_ID} (${SCENARIO_NAME})" llmdbench --spec "${SPEC_DIR}/spec.yaml" --workspace "$(workspace_dir)" \ smoketest -p "${SCENARIO_NAME}" diff --git a/inference/run-gke-campaign.sh b/inference/run-gke-campaign.sh index 6756d03..8ee03d0 100755 --- a/inference/run-gke-campaign.sh +++ b/inference/run-gke-campaign.sh @@ -49,6 +49,39 @@ set_defaults() { : "${BENCHMARK_SECRET_NAMESPACE:=benchmark-secrets}" : "${BENCHMARK_HF_SECRET_NAME:=llm-d-hf-token}" : "${BENCHMARK_REPORT_FORMATS:=markdown,png,csv}" + : "${BENCHMARK_TREATMENTS:=service agentgateway-standalone agentgateway-gateway}" + + # Validate the entire matrix before any cloud operation. A typo or a missing + # custom image must not acquire GPU capacity. + local treatment seen=" " baseline="" candidate comparison left right + read -r -a CAMPAIGN_TREATMENTS <<<"${BENCHMARK_TREATMENTS}" + (( ${#CAMPAIGN_TREATMENTS[@]} >= 2 )) || die "BENCHMARK_TREATMENTS requires at least two treatments" + for treatment in "${CAMPAIGN_TREATMENTS[@]}"; do + case "${treatment}" in + service|agentgateway-standalone|agentgateway-gateway|envoy-standalone) ;; + praxis-standalone) + [[ "${PRAXIS_IMAGE:-}" =~ ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ ]] || \ + die "praxis-standalone requires PRAXIS_IMAGE=registry/repository@sha256:digest" + ;; + *) die "unsupported campaign treatment: ${treatment}" ;; + esac + [[ "${seen}" != *" ${treatment} "* ]] || die "duplicate campaign treatment: ${treatment}" + seen+="${treatment} " + done + if [[ -z "${BENCHMARK_COMPARISONS:-}" ]]; then + baseline="${CAMPAIGN_TREATMENTS[0]}" + BENCHMARK_COMPARISONS="" + for candidate in "${CAMPAIGN_TREATMENTS[@]:1}"; do + BENCHMARK_COMPARISONS+="${BENCHMARK_COMPARISONS:+ }${baseline}:${candidate}" + done + fi + for comparison in ${BENCHMARK_COMPARISONS}; do + left="${comparison%%:*}" + right="${comparison#*:}" + [[ "${comparison}" == *:* && "${left}" != "${right}" && \ + "${seen}" == *" ${left} "* && "${seen}" == *" ${right} "* ]] || \ + die "comparison must name two selected treatments: ${comparison}" + done export BENCHMARK_GKE_PROJECT BENCHMARK_GKE_LOCATION BENCHMARK_GKE_CLUSTER export BENCHMARK_KUBE_CONTEXT BENCHMARK_GKE_CPU_NODEPOOL @@ -64,6 +97,7 @@ set_defaults() { export BENCHMARK_GPU_RELEASE_POLICY BENCHMARK_REPETITION BENCHMARK_CAMPAIGN_ID export BENCHMARK_SECRET_NAMESPACE BENCHMARK_HF_SECRET_NAME export BENCHMARK_REPORT_FORMATS BENCHMARK_CLUSTER_PROVIDER=gke + export BENCHMARK_TREATMENTS BENCHMARK_COMPARISONS case "${BENCHMARK_GKE_CLUSTER_LIFECYCLE}" in retain|destroy) ;; @@ -162,7 +196,7 @@ main() { ensure_hf_secret local treatment - for treatment in service agentgateway-standalone agentgateway-gateway; do + for treatment in "${CAMPAIGN_TREATMENTS[@]}"; do # Scale-up itself is billable and must be reversed if the process is # interrupted before the benchmark creates its first namespace. CAMPAIGN_CLEANUP_REQUIRED=true @@ -178,7 +212,6 @@ main() { results_root="${BENCHMARK_RESULTS_DIR:-${SCRIPT_DIR}/results/${BENCHMARK_SUITE}}" campaign_dir="${results_root}/${BENCHMARK_CAMPAIGN_ID}" export BENCHMARK_CAMPAIGN_DIR="${campaign_dir}" - export BENCHMARK_COMPARISONS="service:agentgateway-standalone service:agentgateway-gateway" log "generating reports" run_make benchmark-report log "campaign evidence: ${campaign_dir}" diff --git a/inference/run-gke-smoke.sh b/inference/run-gke-smoke.sh index 8570aef..e373426 100755 --- a/inference/run-gke-smoke.sh +++ b/inference/run-gke-smoke.sh @@ -44,6 +44,10 @@ set_defaults() { esac case "${BENCHMARK_SMOKE_TREATMENT}" in service|agentgateway-standalone|agentgateway-gateway|envoy-standalone) ;; + praxis-standalone) + [[ "${PRAXIS_IMAGE:-}" =~ ^[^[:space:]@]+@sha256:[0-9a-f]{64}$ ]] || \ + die "praxis-standalone requires PRAXIS_IMAGE=registry/repository@sha256:digest" + ;; *) die "unsupported BENCHMARK_SMOKE_TREATMENT=${BENCHMARK_SMOKE_TREATMENT}" ;; esac [[ "${BENCHMARK_SMOKE_GPU_TARGET_NODES}" == 1 ]] || \ diff --git a/inference/suites/llm-d-benchmark/PRAXIS.md b/inference/suites/llm-d-benchmark/PRAXIS.md new file mode 100644 index 0000000..0f27a69 --- /dev/null +++ b/inference/suites/llm-d-benchmark/PRAXIS.md @@ -0,0 +1,100 @@ +# Experimental Praxis inference treatment + +`praxis-standalone` adds a Praxis AI proxy beside the same endpoint picker (EPP) +used by `agentgateway-standalone`. It is a benchmark adapter requiring runtime +qualification, not a claim of native Praxis Gateway API inference support or +verified parity with agentgateway. No comparative performance results accompany +this change. + +## Exact candidate and build + +The integration is pinned to Praxis AI source +`f5f51a751d6f25acde96fb840665b24f2696ce46`, whose workspace depends on Praxis +core 0.7.2. The default published Praxis AI image does **not** enable the +experimental `llmd-ext-proc` feature. Build the full profile plus that feature: + +```sh +git clone https://github.com/praxis-proxy/ai.git /tmp/praxis-ai +git -C /tmp/praxis-ai checkout f5f51a751d6f25acde96fb840665b24f2696ce46 +inference/suites/llm-d-benchmark/scripts/build-praxis-image.sh \ + /tmp/praxis-ai YOUR_REGISTRY/praxis-ai:benchmark +``` + +The helper checks a clean source tree and Cargo.lock SHA-256, builds the full +workspace with `--locked`, and retains the upstream runtime/license files. It +adds source, feature and lockfile labels. It does not push. Record the build log, +resolved builder/runtime base-image digests and toolchain versions. Push to the +registry used by your test cluster, then use the resolved image digest: + +```sh +export PRAXIS_IMAGE='YOUR_REGISTRY/praxis-ai@sha256:YOUR_DIGEST' +export PRAXIS_SOURCE_REVISION=f5f51a751d6f25acde96fb840665b24f2696ce46 +``` + +The source revision is declared provenance; the runtime inventory does not +independently attest the image's source. Retain the helper output and image +labels beside the campaign. Tests validate rendering and metadata gates; a +native Linux/amd64 source build and real EPP run are still required. Emulated +amd64 containers on ARM are unsuitable for performance comparisons. + +## Adapter and semantic limits + +The pinned llm-d-router chart v0.9.0 supports `envoy` and `agentgateway` preset +names. This adapter uses its generic Envoy slot but replaces the actual image, +container name, executable, arguments, configuration, mounts and probes with +Praxis. The rendered pod must contain `praxis-proxy` and `epp`, with no Envoy +proxy. EPP remains the same sidecar and uses plaintext gRPC over loopback. + +The Praxis source validator permits full-duplex **request** body processing but +rejects all response-body modes other than `none`, and rejects trailer `send`. +The configuration therefore sends request headers/body and response headers, +with response body `none` and both trailer modes `skip`. HTTP responses still +flow to clients; response bodies are not delivered to the external processor. +Do not assume EPP inflight accounting, cache tracking, cancellation and stream +completion are equivalent merely because endpoint selection succeeds. + +Before a GPU campaign, validate real EPP endpoint selection and request bodies, +streaming completion, cancellation, upstream/processor errors, and EPP request +lifecycle state. Praxis's upstream environment tests use a deterministic mock +processor, not the real llm-d Go EPP. Preserve qualification failures and stop +GPU measurement if the required semantics are unavailable. + +The default proxy resources match the chart's other standalone proxy: +4 CPU/8 GiB requests and 16 GiB memory limit. The EPP also requests resources; +size nodes from the complete rendered pod, not just the proxy. Rendered chart +and normalized configuration hashes prevent comparisons across different +policies/resources/workloads. They do not prove runtime semantic parity. + +## Qualification and campaigns + +Run the simulator first in an isolated cluster. This command can provision +billable CPU infrastructure; select project, cluster and lifecycle explicitly: + +```sh +export BENCHMARK_GKE_PROJECT=YOUR_PROJECT +export BENCHMARK_GKE_CLUSTER=YOUR_DEDICATED_CLUSTER +export BENCHMARK_SMOKE_TREATMENT=praxis-standalone +make benchmark-gke-smoke-sim +``` + +Once integration qualification passes, use the ordinary treatment runner or +select the GKE campaign treatments and comparisons explicitly: + +```sh +export BENCHMARK_TREATMENTS='agentgateway-standalone praxis-standalone' +export BENCHMARK_COMPARISONS='agentgateway-standalone:praxis-standalone' +# Configure model, resource topology, costs and cleanup per the GKE guide first. +make benchmark-gke-all +``` + +The full campaign defaults are large GPU workloads. Do not run that final +command as a smoke test. Choose the same immutable agentgateway image, EPP, +model, serving engine, workload, deadlines and repetitions, and reverse +`BENCHMARK_TREATMENTS` between repetitions. Keep service-baseline reports +separate: Kubernetes Service balances connections, not necessarily individual +requests on persistent connections. + +Reports distinguish simulator transport/routing from real GPU inference and +identify the custom Praxis build. Compare successful goodput, TTFT, token +latencies and errors with all repetitions and resource samples. A shared EPP +comparison measures gateway integration differences, not a better scheduler. diff --git a/inference/suites/llm-d-benchmark/README.md b/inference/suites/llm-d-benchmark/README.md index f3ec7d7..b2c4c2e 100644 --- a/inference/suites/llm-d-benchmark/README.md +++ b/inference/suites/llm-d-benchmark/README.md @@ -8,3 +8,11 @@ Execution and report rendering are intentionally separate. Suite scripts capture native evidence and standardized Benchmark Report v0.2 stages; `inference/reporting` turns completed treatments into comparison reports. + +## Praxis candidate + +The experimental [`praxis-standalone` treatment](PRAXIS.md) uses a pinned custom +Praxis AI build with `full,llmd-ext-proc`. Read the build and EPP lifecycle +qualification requirements before running it; the released default image does +not include this integration. No benchmark results are implied by rendering a +valid deployment. diff --git a/inference/suites/llm-d-benchmark/scenarios/gateways/praxis/standalone.yaml b/inference/suites/llm-d-benchmark/scenarios/gateways/praxis/standalone.yaml new file mode 100644 index 0000000..cc18e1e --- /dev/null +++ b/inference/suites/llm-d-benchmark/scenarios/gateways/praxis/standalone.yaml @@ -0,0 +1,69 @@ +# The pinned llm-d-router chart has two validated preset slots. Its Envoy slot +# provides generic sidecar scaffolding; the preset is removed by the renderer +# and all executable/configuration fields below describe Praxis AI. +scenario: + - name: praxis-standalone + router: + proxy: + enabled: true + mode: sidecar + proxyType: envoy + name: praxis-proxy + command: praxis-ai + args: ["--config", "/etc/praxis/config.yaml"] + imagePullPolicy: IfNotPresent + failOpen: false + ports: + - name: http + containerPort: 8081 + readinessProbe: + exec: + command: ["wget", "-qO-", "http://127.0.0.1:9901/ready"] + periodSeconds: 5 + timeoutSeconds: 2 + startupProbe: + exec: + command: ["wget", "-qO-", "http://127.0.0.1:9901/healthy"] + periodSeconds: 1 + timeoutSeconds: 2 + failureThreshold: 60 + volumeMounts: + - name: praxis-config + mountPath: /etc/praxis + readOnly: true + volumes: + - name: praxis-config + configMap: + name: praxis + configMap: + name: praxis + data: + config.yaml: | + listeners: + - name: inference + address: "0.0.0.0:8081" + filter_chains: [llmd] + filter_chains: + - name: llmd + filters: + - filter: ext_proc + target: "http://127.0.0.1:9002" + message_timeout_ms: 1000000 + lifecycle_timeout_ms: 300000 + status_on_error: 503 + processing_mode: + request_header_mode: send + response_header_mode: send + request_body_mode: full_duplex_streamed + response_body_mode: none + request_trailer_mode: skip + response_trailer_mode: skip + - filter: endpoint_selector + source_header: x-gateway-destination-endpoint + required: true + status_on_required_failure: 503 + strip_header: true + admin: + address: "127.0.0.1:9901" + insecure_options: + allow_private_endpoints: true diff --git a/inference/suites/llm-d-benchmark/scripts/build-praxis-image.sh b/inference/suites/llm-d-benchmark/scripts/build-praxis-image.sh new file mode 100644 index 0000000..3583653 --- /dev/null +++ b/inference/suites/llm-d-benchmark/scripts/build-praxis-image.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Build the pinned experimental llm-d integration. Never pushes an image. +set -euo pipefail + +SOURCE_REVISION=f5f51a751d6f25acde96fb840665b24f2696ce46 +LOCK_SHA256=32decbcebee5e611c1c75b08759391442e626e25b19196991ce7545d5f4c088a +FEATURES=full,llmd-ext-proc + +if [[ $# -ne 2 ]]; then + echo "usage: $0 PRAXIS_AI_SOURCE_DIR OUTPUT_IMAGE_TAG" >&2 + exit 2 +fi +source_dir="$(cd "$1" && pwd)" +image_tag="$2" +[[ "$(git -C "${source_dir}" rev-parse HEAD)" == "${SOURCE_REVISION}" ]] || { + echo "Praxis AI source must be checked out at ${SOURCE_REVISION}" >&2; exit 2; +} +[[ -z "$(git -C "${source_dir}" status --porcelain)" ]] || { + echo "Praxis AI source must be clean, including untracked files" >&2; exit 2; +} +[[ "$(shasum -a 256 "${source_dir}/Cargo.lock" | cut -d ' ' -f 1)" == "${LOCK_SHA256}" ]] || { + echo "Praxis Cargo.lock differs from the pinned lockfile" >&2; exit 2; +} +containerfile="$(mktemp "${TMPDIR:-/tmp}/praxis-benchmark-Containerfile.XXXXXX")" +trap 'rm -f -- "${containerfile}"' EXIT +# Keep the full workspace so --locked does not prune workspace members from +# Cargo.lock. The upstream cache recipe removes them and is unsuitable for a +# strict lockfile build. Retain its runtime stage and license notices verbatim. +python3 - "${source_dir}/Containerfile" "${containerfile}" <<'PYTHON' +from pathlib import Path +import sys +source = Path(sys.argv[1]).read_text() +runtime = source[source.index("FROM alpine:3.24\n"):] +builder = """FROM rust:1.98-alpine3.24 AS builder +ARG PRAXIS_AI_FEATURES=full,llmd-ext-proc +ENV RUSTFLAGS="-C target-feature=-crt-static" +RUN apk add --no-cache musl-dev openssl-dev pkgconf cmake make g++ +WORKDIR /src +COPY . . +RUN cargo build --locked --release -p praxis-ai-proxy --no-default-features --features "${PRAXIS_AI_FEATURES}" \\ + && cp target/release/praxis-ai /usr/local/bin/praxis-ai +""" +Path(sys.argv[2]).write_text(builder + "\n" + runtime) +PYTHON +docker build --platform "${PRAXIS_BUILD_PLATFORM:-linux/amd64}" \ + --file "${containerfile}" --tag "${image_tag}" \ + --build-arg "PRAXIS_AI_FEATURES=${FEATURES}" \ + --label "org.opencontainers.image.revision=${SOURCE_REVISION}" \ + --label "dev.agentgateway.benchmark.praxis.features=${FEATURES}" \ + --label "dev.agentgateway.benchmark.praxis.cargo-lock-sha256=${LOCK_SHA256}" \ + "${source_dir}" +docker image inspect "${image_tag}" --format '{{json .}}' +echo "Built ${image_tag}. Push to your registry, record its digest, and set PRAXIS_IMAGE=repository@sha256:digest." >&2 diff --git a/inference/suites/llm-d-benchmark/scripts/render-scenario.py b/inference/suites/llm-d-benchmark/scripts/render-scenario.py index b1f6d5f..4194495 100644 --- a/inference/suites/llm-d-benchmark/scripts/render-scenario.py +++ b/inference/suites/llm-d-benchmark/scripts/render-scenario.py @@ -141,6 +141,7 @@ def parser() -> argparse.ArgumentParser: "agentgateway-standalone", "agentgateway-gateway", "envoy-standalone", + "praxis-standalone", ), required=True, ) @@ -279,6 +280,16 @@ def main() -> None: method["gateway"] = {"className": "epponly"} proxy = router.setdefault("proxy", {}) proxy["proxyType"] = args.gateway_implementation + if args.gateway_implementation == "praxis": + if not re.fullmatch(r"[^\s@]+@sha256:[0-9a-f]{64}", args.gateway_image): + raise ValueError("Praxis requires an immutable --gateway-image registry/repository@sha256:digest") + # v0.9.0 only recognizes envoy/agentgateway preset names. Use the + # generic Envoy container slot with every implementation field + # explicitly replaced by the Praxis overlay, not an Envoy binary. + proxy["proxyType"] = "envoy" + proxy["image"] = args.gateway_image + proxy["presets"] = {"envoy": None} + router.setdefault("epp", {}).setdefault("flags", {})["secure-serving"] = False if args.gateway_implementation == "agentgateway": proxy["args"] = ["-f", "/config/config.yaml"] proxy["httpTargetPort"] = "http" diff --git a/inference/suites/llm-d-benchmark/scripts/standalone-invariant.py b/inference/suites/llm-d-benchmark/scripts/standalone-invariant.py index 82c24d7..7baf15e 100644 --- a/inference/suites/llm-d-benchmark/scripts/standalone-invariant.py +++ b/inference/suites/llm-d-benchmark/scripts/standalone-invariant.py @@ -29,11 +29,17 @@ def normalize(document: dict[str, Any]) -> dict[str, Any]: # Proxy resources and deployment shape are comparison invariants. Image, # command, ports and implementation-specific presets are intentionally # omitted because they are intrinsic dataplane differences. + resources = {"requests": {"cpu": "4", "memory": "8Gi"}, "limits": {"memory": "16Gi"}} + for kind, values in proxy.get("resources", {}).items(): + resources.setdefault(kind, {}).update(values) router["proxy"] = { - key: copy.deepcopy(proxy[key]) - for key in ("enabled", "deploymentMode", "resources") - if key in proxy + "enabled": proxy.get("enabled", True), + "mode": proxy.get("mode", "sidecar"), + "resources": resources, } + # This version only controls the absent Gateway API controller. The + # standalone proxy image belongs to the intentionally omitted proxy data. + scenario.get("chartVersions", {}).pop("agentgateway", None) # agentgateway's standalone ext-proc connection is intentionally insecure # over localhost. Envoy's chart path may omit this implementation detail. diff --git a/inference/suites/llm-d-benchmark/scripts/verify-praxis-runtime.py b/inference/suites/llm-d-benchmark/scripts/verify-praxis-runtime.py new file mode 100644 index 0000000..d64be3d --- /dev/null +++ b/inference/suites/llm-d-benchmark/scripts/verify-praxis-runtime.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Verify the actual Praxis sidecar and retain its immutable runtime evidence.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def verify(document: dict, image: str, source_revision: str) -> dict: + matches = [] + for pod in document.get("items", []): + containers = pod.get("spec", {}).get("containers", []) + for container in containers: + if container.get("name") != "praxis-proxy": + continue + if container.get("image") != image: + raise ValueError("running Praxis image differs from PRAXIS_IMAGE") + if pod.get("status", {}).get("phase") != "Running": + raise ValueError("Praxis pod is not Running") + if not any(c.get("name") == "epp" for c in containers): + raise ValueError("Praxis must share a pod with the EPP") + if any(c.get("name") in ("envoy-proxy", "agentgateway-proxy") for c in containers): + raise ValueError("unexpected second proxy in the Praxis pod") + statuses = {c["name"]: c for c in pod.get("status", {}).get("containerStatuses", [])} + status = statuses.get("praxis-proxy", {}) + if not status.get("ready") or not status.get("imageID"): + raise ValueError("Praxis must be ready with an observed imageID") + epp_status = statuses.get("epp", {}) + if not epp_status.get("ready") or not epp_status.get("imageID"): + raise ValueError("EPP must be ready with an observed imageID") + matches.append({ + "pod": pod["metadata"]["name"], + "image": image, + "image_id": status["imageID"], + "epp_image_id": epp_status["imageID"], + "declared_source_revision": source_revision, + "required_build_features": "full,llmd-ext-proc", + "containers": [{k: c[k] for k in ("name", "image", "resources", "command", "args") if k in c} for c in containers], + }) + if len(matches) != 1: + raise ValueError(f"expected one ready Praxis/EPP pod, found {len(matches)}") + return matches[0] + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--input", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--image", required=True) + parser.add_argument("--source-revision", required=True) + args = parser.parse_args() + result = verify(json.loads(args.input.read_text()), args.image, args.source_revision) + args.output.write_text(json.dumps(result, indent=2) + "\n") + print(result["pod"]) + + +if __name__ == "__main__": + main() diff --git a/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh b/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh index 61d706e..a75184a 100644 --- a/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh +++ b/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh @@ -70,6 +70,24 @@ BENCHMARK_REFERENCE_PROFILE=smoke-gpu bash -c ' echo "campaign treatment tests passed" +BENCHMARK_TREATMENT=praxis-standalone \ +PRAXIS_IMAGE="example.invalid/praxis@sha256:$(printf '%064d' 1)" \ +BENCHMARK_CAMPAIGN_ID=test-praxis bash -c ' + source "$1" + validate_configuration + [[ "$BENCHMARK_GATEWAY_IMPLEMENTATION" == praxis ]] + [[ "$BENCHMARK_ROUTER_MODE" == standalone ]] + [[ "$GATEWAY_IMAGE" == "$PRAXIS_IMAGE" ]] +' _ "${RUNNER}" +if BENCHMARK_TREATMENT=praxis-standalone \ + PRAXIS_IMAGE=example.invalid/praxis:latest BENCHMARK_CAMPAIGN_ID=test-praxis \ + bash -c 'source "$1"; validate_configuration' _ "${RUNNER}"; then + echo "mutable Praxis image unexpectedly accepted" >&2 + exit 1 +fi + +echo "Praxis treatment validation tests passed" + ENDPOINT_TEST_DIR="$(mktemp -d)" mkdir -p "${ENDPOINT_TEST_DIR}/.venv/bin" ln -s "$(command -v python3)" "${ENDPOINT_TEST_DIR}/.venv/bin/python" @@ -88,6 +106,8 @@ LLM_D_BENCHMARK_DIR="${ENDPOINT_TEST_DIR}" bash -c ' JSON } [[ "$(resolve_internal_endpoint)" == "http://10.0.0.9:80" ]] + BENCHMARK_TREATMENT=praxis-standalone + [[ "$(resolve_internal_endpoint)" == "http://10.0.0.9:80" ]] ' _ "${RUNNER}" rm -r -- "${ENDPOINT_TEST_DIR:?}" diff --git a/inference/suites/llm-d-benchmark/tests/test_render_scenario.py b/inference/suites/llm-d-benchmark/tests/test_render_scenario.py index 1feb89f..ca9f0e9 100644 --- a/inference/suites/llm-d-benchmark/tests/test_render_scenario.py +++ b/inference/suites/llm-d-benchmark/tests/test_render_scenario.py @@ -1,6 +1,8 @@ from __future__ import annotations import importlib.util +import os +import shutil import subprocess import sys import tempfile @@ -107,6 +109,77 @@ def test_gateway_mode_does_not_retain_a_sidecar(self) -> None: self.assertNotIn("proxy", method["router"]) self.assertTrue(method["router"]["inferencePool"]["create"]) + def praxis(self, image: str | None = None) -> dict: + return self.render( + "--treatment", "praxis-standalone", + "--gateway-implementation", "praxis", + "--gateway-image", image or "example.invalid/praxis@sha256:" + "a" * 64, + "--overlay", str(BENCHMARKING / "scenarios/gateways/praxis/standalone.yaml"), + "--overlay", str(BENCHMARKING / "scenarios/routing/optimized-baseline.yaml"), + "--routing-policy", "optimized-baseline", + ) + + def test_praxis_requires_immutable_image_and_supported_processing_modes(self) -> None: + with self.assertRaises(subprocess.CalledProcessError): + self.praxis("example.invalid/praxis:latest") + router = self.praxis()["modelservice"]["router"] + proxy = router["proxy"] + self.assertEqual(proxy["command"], "praxis-ai") + self.assertEqual(proxy["presets"], {"envoy": None}) + self.assertFalse(router["epp"]["flags"]["secure-serving"]) + config = yaml.safe_load(proxy["configMap"]["data"]["config.yaml"]) + self.assertEqual(config["admin"]["address"], "127.0.0.1:9901") + self.assertNotIn("allow_public_admin", config["insecure_options"]) + filters = config["filter_chains"][0]["filters"] + self.assertEqual(filters[0]["processing_mode"]["response_body_mode"], "none") + self.assertEqual(filters[0]["processing_mode"]["request_trailer_mode"], "skip") + self.assertEqual(filters[0]["processing_mode"]["response_trailer_mode"], "skip") + self.assertEqual(filters[0]["processing_mode"]["response_header_mode"], "send") + self.assertTrue(filters[1]["required"]) + self.assertTrue(filters[1]["strip_header"]) + + @unittest.skipUnless(os.environ.get("BENCHMARK_ROUTER_CHART_DIR") and shutil.which("helm"), + "set BENCHMARK_ROUTER_CHART_DIR to the unpacked v0.9.0 chart for Helm validation") + def test_real_chart_renders_praxis_and_identical_epp(self) -> None: + praxis = self.praxis() + agentgateway = self.render( + "--treatment", "agentgateway-standalone", + "--gateway-implementation", "agentgateway", + "--gateway-image", "example.invalid/agentgateway:test", + "--overlay", str(BENCHMARKING / "scenarios/gateways/agentgateway/standalone.yaml"), + "--overlay", str(BENCHMARKING / "scenarios/routing/optimized-baseline.yaml"), + "--routing-policy", "optimized-baseline", + ) + manifests = [] + for scenario in (praxis, agentgateway): + router = scenario["modelservice"]["router"] + router["modelServers"] = {"matchLabels": {"app": "model"}} + router["inferencePool"]["targetPorts"] = [8000] + with tempfile.TemporaryDirectory() as directory: + values = Path(directory) / "values.yaml" + values.write_text(yaml.safe_dump({"router": router})) + rendered = subprocess.run([ + "helm", "template", "test", os.environ["BENCHMARK_ROUTER_CHART_DIR"], + "--values", str(values), + ], check=True, capture_output=True, text=True) + manifests.append(list(yaml.safe_load_all(rendered.stdout))) + pods = [next(d for d in docs if d["kind"] in ("Deployment", "StatefulSet")) + ["spec"]["template"]["spec"] for docs in manifests] + self.assertEqual([c["name"] for c in pods[0]["containers"]], ["praxis-proxy", "epp"]) + proxy = pods[0]["containers"][0] + self.assertEqual(proxy["image"], "example.invalid/praxis@sha256:" + "a" * 64) + self.assertEqual(proxy["command"], ["praxis-ai"]) + self.assertEqual(proxy["args"], ["--config", "/etc/praxis/config.yaml"]) + self.assertEqual(proxy["resources"], pods[1]["containers"][0]["resources"]) + self.assertEqual(pods[0]["containers"][1], pods[1]["containers"][1]) + epp_configs = [next(d for d in docs if d["kind"] == "ConfigMap" and + d["metadata"]["name"] == "test-epp")["data"] for docs in manifests] + self.assertEqual(epp_configs[0], epp_configs[1]) + praxis_config = next(d for d in manifests[0] if d["kind"] == "ConfigMap" and + d["metadata"]["name"] == "praxis") + self.assertEqual(set(praxis_config["data"]), {"config.yaml"}) + self.assertFalse(any(v.get("configMap", {}).get("name") == "envoy" for v in pods[0]["volumes"])) + def test_model_and_workload_storage_are_independent(self) -> None: scenario = self.render( "--treatment", diff --git a/inference/suites/llm-d-benchmark/tests/test_verify_praxis_runtime.py b/inference/suites/llm-d-benchmark/tests/test_verify_praxis_runtime.py new file mode 100644 index 0000000..9491776 --- /dev/null +++ b/inference/suites/llm-d-benchmark/tests/test_verify_praxis_runtime.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import copy +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/verify-praxis-runtime.py" +SPEC = importlib.util.spec_from_file_location("verify_praxis_runtime", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) +IMAGE = "example.invalid/praxis@sha256:" + "a" * 64 +REVISION = "b" * 40 + + +class VerifyPraxisRuntimeTest(unittest.TestCase): + def pod(self) -> dict: + return { + "metadata": {"name": "test-epp-0"}, + "spec": {"containers": [ + {"name": "praxis-proxy", "image": IMAGE}, + {"name": "epp", "image": "example.invalid/epp:v0.9.0"}, + ]}, + "status": {"phase": "Running", "containerStatuses": [ + {"name": "praxis-proxy", "ready": True, "imageID": IMAGE}, + {"name": "epp", "ready": True, "imageID": "example.invalid/epp@sha256:" + "c" * 64}, + ]}, + } + + def test_records_observed_image_and_declared_source_separately(self) -> None: + evidence = MODULE.verify({"items": [self.pod()]}, IMAGE, REVISION) + self.assertEqual(evidence["pod"], "test-epp-0") + self.assertEqual(evidence["image_id"], IMAGE) + self.assertEqual(evidence["declared_source_revision"], REVISION) + + def test_rejects_wrong_image_not_ready_missing_epp_and_extra_proxy(self) -> None: + for mutation in ("image", "ready", "epp_ready", "epp", "extra"): + with self.subTest(mutation=mutation): + pod = self.pod() + if mutation == "image": + pod["spec"]["containers"][0]["image"] = "praxis:latest" + elif mutation == "ready": + pod["status"]["containerStatuses"][0]["ready"] = False + elif mutation == "epp_ready": + pod["status"]["containerStatuses"][1]["ready"] = False + elif mutation == "epp": + pod["spec"]["containers"].pop() + else: + pod["spec"]["containers"].append({"name": "envoy-proxy"}) + with self.assertRaises(ValueError): + MODULE.verify({"items": [pod]}, IMAGE, REVISION) + + def test_rejects_missing_or_duplicate_praxis_pods(self) -> None: + for pods in ([], [self.pod(), copy.deepcopy(self.pod())]): + with self.assertRaises(ValueError): + MODULE.verify({"items": pods}, IMAGE, REVISION) + + +if __name__ == "__main__": + unittest.main() From 1b7e135ee4d4671825983639b76691f2df8f0e42 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Thu, 1 Oct 2026 18:19:28 -0700 Subject: [PATCH 2/2] Support v1.5.0 inference comparison campaigns Preserve historical profiles and validate one-round paired reports. Honor anonymous model downloads and correct repetition guidance. Signed-off-by: Daneyon Hansen --- inference/README.md | 12 +++++-- .../gke/tests/test-run-gke-campaign.sh | 15 ++++++++ inference/run-benchmark.sh | 29 ++++++++++----- inference/run-gke-campaign.sh | 11 +++++- ...seline-qwen3-32b-h100-v0.9-agw-v1.5.0.yaml | 36 +++++++++++++++++++ .../tests/test-run-benchmark.sh | 19 ++++++++++ 6 files changed, 109 insertions(+), 13 deletions(-) create mode 100644 inference/suites/llm-d-benchmark/references/optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0.yaml diff --git a/inference/README.md b/inference/README.md index 4bb8247..2d40c7b 100644 --- a/inference/README.md +++ b/inference/README.md @@ -168,7 +168,11 @@ BENCHMARK_TREATMENT= \ make benchmark ``` -The reference profile requires agentgateway v1.4.1. The wrapper verifies the +The historical reference profile requires agentgateway v1.4.1. +For v1.5.0, set `AGW_VERSION=v1.5.0` and +`BENCHMARK_REFERENCE_PROFILE=optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0`. +This separate profile preserves vLLM v0.23.0 and the same serving/EPP resources. +For standalone mode, set `AGW_IMAGE` to the release digest to pin the binary. The wrapper verifies the observed vLLM, EPP, and agentgateway pod images before starting traffic. `BENCHMARK_ENDPOINT_PATH=internal` explicitly supplies the proxy Service ClusterIP to the harness; this prevents Gateway status from selecting an @@ -434,8 +438,10 @@ not a billing quote. See the Google Cloud Each treatment temporarily needs about 17 GiB of local space while collecting raw per-request results. The three compressed per-request files total approximately 3 GiB, so copy long-lived evidence to durable object storage. -`BENCHMARK_REPETITION=3` runs every treatment three times and should be -budgeted at approximately 18 hours and USD 1,200 to 1,275. +`BENCHMARK_REPETITION` labels one campaign invocation; it does not repeat it. +Run the campaign separately with repetition IDs 1, 2, and 3 for three rounds, +reversing treatment order between rounds. Budget approximately 18 hours and +USD 1,200 to 1,275 for three rounds of the default historical matrix. ## Results diff --git a/inference/provisioning/gke/tests/test-run-gke-campaign.sh b/inference/provisioning/gke/tests/test-run-gke-campaign.sh index 25c733f..6773f75 100755 --- a/inference/provisioning/gke/tests/test-run-gke-campaign.sh +++ b/inference/provisioning/gke/tests/test-run-gke-campaign.sh @@ -163,3 +163,18 @@ for treatments in 'service praxis-standalone' 'service typo' 'service service'; done echo "GKE campaign orchestration tests passed" + +: >"${TEST_COMMAND_LOG}" +BENCHMARK_HF_TOKEN_REQUIRED=false TEST_SECRET_EXISTS=false \ +PRAXIS_IMAGE="example.invalid/praxis@sha256:$(printf '%064d' 1)" \ +BENCHMARK_TREATMENTS='service agentgateway-standalone praxis-standalone' \ +BENCHMARK_COMPARISONS='service:agentgateway-standalone agentgateway-standalone:praxis-standalone' \ +BENCHMARK_REPETITION=1 BENCHMARK_CAMPAIGN_ID=test-three-treatments \ + "${CAMPAIGN_SCRIPT}" >"${TEMP_DIR}/three-treatments.log" +[[ "$(grep -c '^target=benchmark ' "${TEST_COMMAND_LOG}")" == 3 ]] +grep -q '^target=benchmark-report .*comparisons=service:agentgateway-standalone agentgateway-standalone:praxis-standalone$' "${TEST_COMMAND_LOG}" +if grep -q '^kubectl ' "${TEST_COMMAND_LOG}"; then + echo 'anonymous campaign unexpectedly accessed secrets' >&2 + exit 1 +fi +echo "one-round paired reports and anonymous model tests passed" diff --git a/inference/run-benchmark.sh b/inference/run-benchmark.sh index a8b1a1f..ef7e690 100755 --- a/inference/run-benchmark.sh +++ b/inference/run-benchmark.sh @@ -535,7 +535,7 @@ validate_configuration() { *) log "unsupported BENCHMARK_ROUTING_POLICY: ${BENCHMARK_ROUTING_POLICY}"; return 2 ;; esac case "${BENCHMARK_REFERENCE_PROFILE}" in - ""|smoke-gpu|optimized-baseline-qwen3-32b-h100-v0.9|optimized-baseline-qwen3-32b-h100-v0.9-vllm-v0.27.1) ;; + ""|smoke-gpu|optimized-baseline-qwen3-32b-h100-v0.9|optimized-baseline-qwen3-32b-h100-v0.9-vllm-v0.27.1|optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0) ;; *) log "unsupported BENCHMARK_REFERENCE_PROFILE: ${BENCHMARK_REFERENCE_PROFILE}"; return 2 ;; esac case "${BENCHMARK_ENDPOINT_PATH}" in @@ -615,7 +615,8 @@ validate_configuration() { fi fi if [[ "${BENCHMARK_REFERENCE_PROFILE}" == "optimized-baseline-qwen3-32b-h100-v0.9" || - "${BENCHMARK_REFERENCE_PROFILE}" == "optimized-baseline-qwen3-32b-h100-v0.9-vllm-v0.27.1" ]]; then + "${BENCHMARK_REFERENCE_PROFILE}" == "optimized-baseline-qwen3-32b-h100-v0.9-vllm-v0.27.1" || + "${BENCHMARK_REFERENCE_PROFILE}" == "optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0" ]]; then # The published v0.9 report is a historical artifact. Its calibration # matrix records vLLM v0.23.0 even though the mutable v0.9 guide overlay # now selects v0.26.0. Reproducing the report therefore requires locking @@ -630,9 +631,13 @@ validate_configuration() { log "${BENCHMARK_REFERENCE_PROFILE} requires optimized-baseline, Qwen3-32B, 8 TP=2 H100 vLLM replicas, and router v0.9.0" return 2 } + local expected_agw_version=v1.4.1 + if [[ "${BENCHMARK_REFERENCE_PROFILE}" == *-agw-v1.5.0 ]]; then + expected_agw_version=v1.5.0 + fi if [[ "${BENCHMARK_TREATMENT}" == agentgateway-* && \ - "${AGW_VERSION}" != "v1.4.1" ]]; then - log "${BENCHMARK_REFERENCE_PROFILE} requires AGW_VERSION=v1.4.1" + "${AGW_VERSION}" != "${expected_agw_version}" ]]; then + log "${BENCHMARK_REFERENCE_PROFILE} requires AGW_VERSION=${expected_agw_version}" return 2 fi fi @@ -1246,11 +1251,17 @@ verify_praxis_runtime() { } verify_reference_runtime() { - local expected_vllm_version + local expected_vllm_version expected_agw_version=v1.4.1 + local expected_agw_image=cr.agentgateway.dev/agentgateway:v1.4.1 case "${BENCHMARK_REFERENCE_PROFILE}" in optimized-baseline-qwen3-32b-h100-v0.9) expected_vllm_version="v0.23.0" ;; + optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0) + expected_vllm_version="v0.23.0" + expected_agw_version=v1.5.0 + expected_agw_image="${AGW_IMAGE}" + ;; optimized-baseline-qwen3-32b-h100-v0.9-vllm-v0.27.1) expected_vllm_version="v0.27.1" ;; @@ -1290,9 +1301,9 @@ PY } fi if [[ "${BENCHMARK_TREATMENT}" == agentgateway-* ]]; then - grep -Fq $'\tcr.agentgateway.dev/agentgateway:v1.4.1' \ + grep -Fq "$(printf '\t%s' "${expected_agw_image}")" \ "${inventory}" || { - log "reference preflight did not observe agentgateway v1.4.1" + log "reference preflight did not observe ${expected_agw_image}" cat "${inventory}" >&2 return 1 } @@ -1303,9 +1314,9 @@ PY --namespace "${AGW_CONTROLLER_NAMESPACE}" \ -l app.kubernetes.io/name=agentgateway \ -o jsonpath='{range .items[*].spec.template.spec.containers[*]}{.image}{"\n"}{end}')" - grep -Fxq "cr.agentgateway.dev/controller:v1.4.1" \ + grep -Fxq "cr.agentgateway.dev/controller:${expected_agw_version}" \ <<<"${controller_images}" || { - log "reference preflight did not observe agentgateway controller v1.4.1" + log "reference preflight did not observe agentgateway controller ${expected_agw_version}" printf '%s\n' "${controller_images}" >&2 return 1 } diff --git a/inference/run-gke-campaign.sh b/inference/run-gke-campaign.sh index 8ee03d0..4ef4a2a 100755 --- a/inference/run-gke-campaign.sh +++ b/inference/run-gke-campaign.sh @@ -48,6 +48,11 @@ set_defaults() { : "${BENCHMARK_CAMPAIGN_ID:=optimized-baseline-qwen3-32b-h100-$(date -u +%Y%m%d-%H%M%S)}" : "${BENCHMARK_SECRET_NAMESPACE:=benchmark-secrets}" : "${BENCHMARK_HF_SECRET_NAME:=llm-d-hf-token}" + : "${BENCHMARK_HF_TOKEN_REQUIRED:=true}" + case "${BENCHMARK_HF_TOKEN_REQUIRED}" in + true|false) ;; + *) die "BENCHMARK_HF_TOKEN_REQUIRED must be true or false" ;; + esac : "${BENCHMARK_REPORT_FORMATS:=markdown,png,csv}" : "${BENCHMARK_TREATMENTS:=service agentgateway-standalone agentgateway-gateway}" @@ -95,7 +100,7 @@ set_defaults() { export BENCHMARK_REPLICAS BENCHMARK_TENSOR_PARALLELISM BENCHMARK_ENDPOINT_PATH export BENCHMARK_MODEL_STORAGE_PROFILE BENCHMARK_WORKLOAD_STORAGE_PROFILE export BENCHMARK_GPU_RELEASE_POLICY BENCHMARK_REPETITION BENCHMARK_CAMPAIGN_ID - export BENCHMARK_SECRET_NAMESPACE BENCHMARK_HF_SECRET_NAME + export BENCHMARK_SECRET_NAMESPACE BENCHMARK_HF_SECRET_NAME BENCHMARK_HF_TOKEN_REQUIRED export BENCHMARK_REPORT_FORMATS BENCHMARK_CLUSTER_PROVIDER=gke export BENCHMARK_TREATMENTS BENCHMARK_COMPARISONS @@ -106,6 +111,10 @@ set_defaults() { } ensure_hf_secret() { + if [[ "${BENCHMARK_HF_TOKEN_REQUIRED}" == false ]]; then + log "HF token disabled; model and workload must allow anonymous downloads" + return + fi kubectl --context "${BENCHMARK_KUBE_CONTEXT}" \ create namespace "${BENCHMARK_SECRET_NAMESPACE}" \ --dry-run=client -o yaml | diff --git a/inference/suites/llm-d-benchmark/references/optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0.yaml b/inference/suites/llm-d-benchmark/references/optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0.yaml new file mode 100644 index 0000000..4ee7601 --- /dev/null +++ b/inference/suites/llm-d-benchmark/references/optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0.yaml @@ -0,0 +1,36 @@ +# Agentgateway v1.5.0 comparison; preserves historical vLLM v0.23.0, +# model-server resources and EPP settings. Historical profiles stay unchanged. +scenario: + - name: optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0 + images: + vllm: + repository: docker.io/vllm/vllm-openai + tag: v0.23.0 + vllmCommon: + shmMemory: 20Gi + decode: + replicas: 8 + parallelism: + tensor: 2 + data: 1 + dataLocal: 1 + workers: 1 + resources: + limits: {memory: 128Gi, cpu: "16"} + requests: {memory: 96Gi, cpu: "8"} + vllm: + # Match the report-era optimized-baseline command surface. In + # particular, avoid adding the wrapper's newer block-size, + # max-model-len, and gpu-memory-utilization flags. + customCommand: | + vllm serve /model-cache/$MODEL_PATH \ + --host 0.0.0.0 \ + --served-model-name $MODEL_NAME \ + --port $VLLM_METRICS_PORT \ + --disable-access-log-for-endpoints=/health,/metrics,/v1/models \ + --tensor-parallel-size=$TP_SIZE + router: + epp: + resources: + limits: {memory: 16Gi} + requests: {memory: 8Gi, cpu: "4"} diff --git a/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh b/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh index a75184a..810c38f 100644 --- a/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh +++ b/inference/suites/llm-d-benchmark/tests/test-run-benchmark.sh @@ -452,3 +452,22 @@ grep -Fxq ' base_seed: 42' \ "${SCRIPT_DIR}/../workloads/deterministic-optimized-baseline.yaml.in" echo "workload rendering tests passed" + +# Current-release comparisons must not weaken the historical version guard. +for profile in optimized-baseline-qwen3-32b-h100-v0.9 optimized-baseline-qwen3-32b-h100-v0.9-agw-v1.5.0; do + for version in v1.4.1 v1.5.0; do + expected=v1.4.1 + [[ "${profile}" != *-agw-v1.5.0 ]] || expected=v1.5.0 + if BENCHMARK_TREATMENT=agentgateway-standalone \ + BENCHMARK_CAMPAIGN_ID=profile-test BENCHMARK_ACCELERATOR_TYPE=gpu \ + BENCHMARK_BACKEND_TYPE=vllm BENCHMARK_SCENARIO=optimized-baseline \ + BENCHMARK_ROUTING_POLICY=optimized-baseline BENCHMARK_REFERENCE_PROFILE="${profile}" \ + AGW_VERSION="${version}" \ + bash -c 'source "$1"; validate_configuration' _ "${RUNNER}"; then + [[ "${version}" == "${expected}" ]] || { echo 'wrong release accepted' >&2; exit 1; } + else + [[ "${version}" != "${expected}" ]] || { echo 'matching release rejected' >&2; exit 1; } + fi + done +done +echo "reference release guard tests passed"