Buckets:

freddyaboulton's picture
download
raw
21.3 kB
from __future__ import annotations
import argparse
import asyncio
import json
import os
import random
import subprocess
import sys
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
import httpx
"""Load testing orchestrator for Gradio apps.
Usage:
GRADIO_PROFILING=1 python scripts/benchmark/load_runner.py \
--app scripts/benchmark/apps/echo_text.py \
--tiers 1,10,100 \
--requests-per-user 10 \
--output-dir benchmark_results
# Wave mode (staggered arrivals instead of simultaneous burst)
GRADIO_PROFILING=1 python scripts/benchmark/load_runner.py \
--app scripts/benchmark/apps/echo_text.py \
--tiers 1,10,100 \
--mode wave \
--output-dir benchmark_results
"""
def wait_for_server(url: str, timeout: float = 60.0) -> bool:
"""Wait for server to become available."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
resp = httpx.get(f"{url}/gradio_api/info", timeout=2.0)
if resp.status_code == 200:
return True
except (httpx.ConnectError, httpx.ReadTimeout):
pass
time.sleep(0.5)
return False
async def resolve_fn_info(
app_url: str, api_name: str | None = None
) -> tuple[int, list]:
"""Resolve the fn_index and build a data template for the given api_name.
Returns (fn_index, data_template) where data_template has the right number
of elements (string for textbox-like inputs, None for state/hidden inputs).
"""
async with httpx.AsyncClient() as client:
# If no api_name specified, get the first named endpoint from /info
if api_name is None:
try:
info_resp = await client.get(f"{app_url}/gradio_api/info", timeout=5.0)
info = info_resp.json()
named = info.get("named_endpoints", {})
if named:
api_name = list(named.keys())[0] # e.g. "/echo_stream"
except Exception:
pass
# Get config to find fn_index and input component IDs
config_resp = await client.get(f"{app_url}/config", timeout=5.0)
config = config_resp.json()
deps = config.get("dependencies", [])
components = {c["id"]: c for c in config.get("components", [])}
# Find the right dependency by matching api_name
target_dep = None
if api_name:
search_name = api_name.lstrip("/")
for dep in deps:
if dep.get("api_name") == search_name:
target_dep = dep
break
if target_dep is None:
# Fallback: first dependency
target_dep = deps[0] if deps else None
if target_dep is None:
return 0, ["hello"]
fn_index = target_dep["id"] if "id" in target_dep else deps.index(target_dep)
# Build data template based on input components
data_template = []
for comp_id in target_dep.get("inputs", []):
comp = components.get(comp_id, {})
comp_type = comp.get("type", "")
if comp_type in ("textbox", "textarea"):
data_template.append("hello")
elif comp_type in ["state", "chatbot"]:
data_template.append(None)
else:
data_template.append("hello")
if not data_template:
data_template = ["hello"]
return fn_index, data_template
def load_prompts(app_path: str) -> list[str] | None:
"""Load prompts from a sidecar .prompts.json file if it exists."""
prompts_path = Path(app_path).with_suffix(".prompts.json")
if prompts_path.exists():
return json.loads(prompts_path.read_text())
return None
async def run_httpx_tier(
app_url: str,
num_users: int,
requests_per_user: int,
fn_index: int = 0,
data_template: list | None = None,
mode: str = "burst",
on_round_complete: Callable | None = None,
prompts: list[str] | None = None,
) -> list[dict]:
"""Run a tier using httpx via /queue/join + /queue/data.
Modes:
burst: All N users fire simultaneously per round (asyncio.Barrier).
wave: Each user waits a random delay (0 to 500ms) before firing,
simulating staggered real-world arrivals.
"""
if data_template is None:
data_template = ["hello"]
latencies = []
# Wall-clock timeout per request. The httpx timeout only triggers when no
# data arrives, but SSE heartbeats can keep a dead connection alive forever.
# Scale with num_users since all requests queue behind concurrency_limit.
request_timeout = max(120.0, num_users * 5.0)
async def _do_request(
client: httpx.AsyncClient, user_id: int, req_id: int, session_hash: str
) -> dict:
"""Send a single request and return the latency result."""
data = []
for item in data_template:
if isinstance(item, str):
if prompts:
data.append(random.choice(prompts))
else:
data.append(f"hello from user {user_id} req {req_id}")
else:
data.append(item)
start = time.monotonic()
try:
resp = await client.post(
f"{app_url}/gradio_api/queue/join",
json={
"data": data,
"fn_index": fn_index,
"session_hash": session_hash,
},
timeout=120.0,
)
if resp.status_code != 200:
raise Exception(
f"POST /queue/join failed: {resp.status_code} {resp.text[:200]}"
)
completed = False
async with client.stream(
"GET",
f"{app_url}/gradio_api/queue/data",
params={"session_hash": session_hash},
timeout=120.0,
) as stream:
deadline = start + request_timeout
async for line in stream.aiter_lines():
if "process_completed" in line:
completed = True
break
if time.monotonic() > deadline:
break
if not completed:
raise TimeoutError(
f"Request did not complete within {request_timeout:.0f}s"
)
duration_ms = (time.monotonic() - start) * 1000
return {
"user_id": user_id,
"request_id": req_id,
"latency_ms": duration_ms,
"success": True,
}
except Exception as e:
duration_ms = (time.monotonic() - start) * 1000
error_type = type(e).__name__
return {
"user_id": user_id,
"request_id": req_id,
"latency_ms": duration_ms,
"success": False,
"error": f"{error_type}: {e}" if str(e) else error_type,
}
# Overall timeout for an entire round. If the server deadlocks,
# individual request timeouts may not fire (e.g. stuck in C-level
# socket reads). This ensures the benchmark always makes progress.
round_timeout = request_timeout + 30
for req_id in range(requests_per_user):
async with httpx.AsyncClient() as client:
if mode == "burst":
barrier = asyncio.Barrier(num_users)
async def burst_request(
uid: int, rid: int = req_id, b: asyncio.Barrier = barrier
):
session_hash = f"bench_{uid}_{rid}_{id(b)}"
await b.wait()
return await _do_request(client, uid, rid, session_hash)
try:
results = await asyncio.wait_for(
asyncio.gather(*[burst_request(i) for i in range(num_users)]),
timeout=round_timeout,
)
except (asyncio.TimeoutError, TimeoutError):
print(
f" WARNING: Round {req_id} timed out after {round_timeout:.0f}s "
f"(server may be deadlocked)"
)
results = [
{
"user_id": i,
"request_id": req_id,
"latency_ms": round_timeout * 1000,
"success": False,
"error": f"Round timed out after {round_timeout:.0f}s",
}
for i in range(num_users)
]
else:
# wave mode: random jitter per user (0–500ms)
async def wave_request(uid: int, rid: int = req_id):
jitter = random.uniform(0, 0.5)
await asyncio.sleep(jitter)
session_hash = f"bench_{uid}_{rid}_{time.monotonic_ns()}"
return await _do_request(client, uid, rid, session_hash)
try:
results = await asyncio.wait_for(
asyncio.gather(*[wave_request(i) for i in range(num_users)]),
timeout=round_timeout,
)
except (asyncio.TimeoutError, TimeoutError):
print(
f" WARNING: Round {req_id} timed out after {round_timeout:.0f}s "
f"(server may be deadlocked)"
)
results = [
{
"user_id": i,
"request_id": req_id,
"latency_ms": round_timeout * 1000,
"success": False,
"error": f"Round timed out after {round_timeout:.0f}s",
}
for i in range(num_users)
]
latencies.extend(results)
if on_round_complete is not None:
successful = sum(1 for r in results if r.get("success"))
on_round_complete(req_id + 1, requests_per_user, successful, num_users)
return latencies
async def fetch_profiling_data(app_url: str) -> tuple[list, dict]:
"""Fetch traces and summary from the profiling endpoints."""
async with httpx.AsyncClient() as client:
try:
traces_resp = await client.get(
f"{app_url}/gradio_api/profiling/traces", timeout=10.0
)
traces = traces_resp.json() if traces_resp.status_code == 200 else []
except Exception:
traces = []
try:
summary_resp = await client.get(
f"{app_url}/gradio_api/profiling/summary", timeout=10.0
)
summary = summary_resp.json() if summary_resp.status_code == 200 else {}
except Exception:
summary = {}
return traces, summary
async def clear_profiling_data(app_url: str):
"""Clear profiling data between tiers."""
async with httpx.AsyncClient() as client:
try:
await client.post(f"{app_url}/gradio_api/profiling/clear", timeout=10.0)
except Exception:
pass
def compute_client_summary(latencies: list[dict]) -> dict:
"""Compute p50/p90/p95/p99 from client latencies."""
import numpy as np
successful = [r["latency_ms"] for r in latencies if r.get("success")]
if not successful:
return {"count": 0, "success_rate": 0}
arr = np.array(successful)
total = len(latencies)
return {
"count": total,
"success_count": len(successful),
"success_rate": len(successful) / total if total > 0 else 0,
"p50": float(np.percentile(arr, 50)),
"p90": float(np.percentile(arr, 90)),
"p95": float(np.percentile(arr, 95)),
"p99": float(np.percentile(arr, 99)),
"mean": float(np.mean(arr)),
"min": float(np.min(arr)),
"max": float(np.max(arr)),
}
async def run_benchmark(
app_path: str,
tiers: list[int],
requests_per_user: int,
output_dir: str,
port: int = 7860,
api_name: str | None = None,
concurrency_limit: int | None = 1,
mode: str = "burst",
):
app_url = f"http://127.0.0.1:{port}"
# Launch the target app
env = os.environ.copy()
env["GRADIO_PROFILING"] = "1"
env["GRADIO_SERVER_PORT"] = str(port)
cl_str = "none" if concurrency_limit is None else str(concurrency_limit)
env["GRADIO_CONCURRENCY_LIMIT"] = cl_str
env["PYTHONUNBUFFERED"] = "1"
print(f"Launching app: {app_path}")
proc = subprocess.Popen(
[sys.executable, app_path],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
import threading
def _stream_app_output(process):
"""Stream app stdout/stderr line by line with a prefix."""
for line in iter(process.stdout.readline, b""):
print(f" [app] {line.decode(errors='replace').rstrip()}")
output_thread = threading.Thread(
target=_stream_app_output, args=(proc,), daemon=True
)
output_thread.start()
try:
if not wait_for_server(app_url):
print("ERROR: Server did not start in time")
proc.terminate()
output_thread.join(timeout=5)
return
print(f"Server ready at {app_url}")
# Resolve fn_index and data template
fn_index, data_template = await resolve_fn_info(app_url, api_name)
print(f"Using fn_index={fn_index}, data_template={data_template}")
# Load prompts from sidecar file if available
prompts = load_prompts(app_path)
if prompts:
print(f"Loaded {len(prompts)} prompts from sidecar file")
# Warmup
print("Running warmup...")
await clear_profiling_data(app_url)
try:
await run_httpx_tier(
app_url,
2,
3,
fn_index=fn_index,
data_template=data_template,
mode=mode,
prompts=prompts,
)
except Exception:
pass
await clear_profiling_data(app_url)
# Create output directory
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_dir = Path(output_dir) / timestamp
base_dir.mkdir(parents=True, exist_ok=True)
all_tier_results = []
for tier in tiers:
print(f"\n--- Tier: {tier} concurrent users ---")
await clear_profiling_data(app_url)
tier_dir = base_dir / f"tier_{tier}"
tier_dir.mkdir(exist_ok=True)
from tqdm import tqdm
pbar = tqdm(
total=requests_per_user, desc=f" Tier {tier}", unit="round", leave=True
)
def on_round_complete(round_num, total_rounds, successful, num_users):
failed = num_users - successful
pbar.set_postfix(ok=successful, fail=failed, refresh=True)
pbar.update(1)
# Run the tier
start = time.monotonic()
client_latencies = await run_httpx_tier(
app_url,
tier,
requests_per_user,
fn_index=fn_index,
data_template=data_template,
mode=mode,
on_round_complete=on_round_complete,
prompts=prompts,
)
pbar.close()
elapsed = time.monotonic() - start
# Fetch server profiling data
traces, server_summary = await fetch_profiling_data(app_url)
# Compute client summary
client_summary = compute_client_summary(client_latencies)
tier_result = {
"tier": tier,
"requests_per_user": requests_per_user,
"total_requests": tier * requests_per_user,
"elapsed_seconds": elapsed,
"client_summary": client_summary,
"server_summary": server_summary,
}
all_tier_results.append(tier_result)
# Save tier data
with open(tier_dir / "client_latencies.jsonl", "w") as f:
for lat in client_latencies:
f.write(json.dumps(lat) + "\n")
with open(tier_dir / "traces.jsonl", "w") as f:
for trace in traces:
f.write(json.dumps(trace) + "\n")
# Print tier summary
print(f" Elapsed: {elapsed:.1f}s")
cp50 = client_summary.get("p50")
cp90 = client_summary.get("p90")
cp99 = client_summary.get("p99")
if cp50 is not None:
print(f" Client p50={cp50:.1f}ms p90={cp90:.1f}ms p99={cp99:.1f}ms")
else:
print(" Client: no successful requests")
if server_summary.get("phases"):
total = server_summary["phases"].get("total", {})
sp50 = total.get("p50")
if sp50 is not None:
print(
f" Server total p50={sp50:.1f}ms "
f"p90={total.get('p90', 0):.1f}ms "
f"p99={total.get('p99', 0):.1f}ms"
)
# Save overall summary
summary = {
"app": app_path,
"timestamp": timestamp,
"tiers": all_tier_results,
}
with open(base_dir / "summary.json", "w") as f:
json.dump(summary, f, indent=2)
# Generate summary table
_write_summary_table(base_dir, all_tier_results)
print(f"\nResults saved to {base_dir}")
finally:
exit_code = proc.poll()
if exit_code is not None:
print(f"\nWARNING: App process already exited with code {exit_code}")
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
output_thread.join(timeout=5)
def _write_summary_table(base_dir: Path, tier_results: list[dict]):
"""Write a human-readable summary table."""
lines = []
lines.append(
f"{'Tier':>8} | {'Reqs':>8} | {'Client p50':>12} | {'Client p90':>12} | "
f"{'Client p99':>12} | {'Success%':>9} | {'Server p50':>12} | {'Server p90':>12}"
)
lines.append("-" * 110)
for r in tier_results:
cs = r.get("client_summary", {})
ss = r.get("server_summary", {}).get("phases", {}).get("total", {})
lines.append(
f"{r['tier']:>8} | {r['total_requests']:>8} | "
f"{cs.get('p50', 0):>10.1f}ms | {cs.get('p90', 0):>10.1f}ms | "
f"{cs.get('p99', 0):>10.1f}ms | {cs.get('success_rate', 0):>8.1%} | "
f"{ss.get('p50', 0):>10.1f}ms | {ss.get('p90', 0):>10.1f}ms"
)
table = "\n".join(lines)
with open(base_dir / "summary_table.txt", "w") as f:
f.write(table + "\n")
print(f"\n{table}")
def main():
parser = argparse.ArgumentParser(description="Gradio Load Testing Tool")
parser.add_argument("--app", required=True, help="Path to the Gradio app to test")
parser.add_argument(
"--tiers",
default="1,10,100",
help="Comma-separated concurrency tiers (default: 1,10,100)",
)
parser.add_argument(
"--requests-per-user",
type=int,
default=10,
help="Requests per virtual user / number of rounds (default: 10)",
)
parser.add_argument(
"--output-dir",
default="benchmark_results",
help="Output directory (default: benchmark_results)",
)
parser.add_argument(
"--mode",
choices=["burst", "wave"],
default="burst",
help="Load pattern: 'burst' fires all requests simultaneously per round, "
"'wave' staggers arrivals with random jitter (default: burst)",
)
parser.add_argument(
"--port",
type=int,
default=7860,
help="Port for the Gradio app (default: 7860)",
)
parser.add_argument(
"--api-name",
default=None,
help="API endpoint name (auto-detected if not specified)",
)
parser.add_argument(
"--concurrency-limit",
default="1",
help="Concurrency limit for the app (default: 1, use 'none' for unlimited)",
)
args = parser.parse_args()
tiers = [int(t.strip()) for t in args.tiers.split(",")]
cl = None if args.concurrency_limit == "none" else int(args.concurrency_limit)
asyncio.run(
run_benchmark(
app_path=args.app,
tiers=tiers,
requests_per_user=args.requests_per_user,
output_dir=args.output_dir,
port=args.port,
api_name=args.api_name,
concurrency_limit=cl,
mode=args.mode,
)
)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
21.3 kB
·
Xet hash:
4dfd7e69874f71ecf95fe5acb0d24ed921db04bd494bb8dd283c3b48995657b0

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.