Files
2026-07-13 13:17:40 +08:00

375 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Load testing script for the Ray Serve video API.
Usage:
python -m client.load_test --video s3://bucket/path/to/video.mp4 --concurrency 4
python -m client.load_test --video s3://bucket/video.mp4 --concurrency 8 --url http://localhost:8000
python -m client.load_test --video s3://bucket/video.mp4 --concurrency 4 --token YOUR_TOKEN
Press Ctrl+C to stop and save results to CSV.
"""
import argparse
import asyncio
import csv
import signal
import sys
import time
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Optional
import httpx
@dataclass
class RequestResult:
"""Result of a single request."""
request_id: str
stream_id: str
start_time: float
end_time: float
latency_ms: float
success: bool
status_code: int
error: Optional[str] = None
# Server-side timing (if successful)
s3_download_ms: Optional[float] = None
decode_video_ms: Optional[float] = None
encode_ms: Optional[float] = None
decode_ms: Optional[float] = None
server_total_ms: Optional[float] = None
# Response data
num_chunks: Optional[int] = None
video_duration: Optional[float] = None
num_scene_changes: Optional[int] = None
@dataclass
class LoadTestStats:
"""Aggregated statistics for the load test."""
total_requests: int = 0
successful_requests: int = 0
failed_requests: int = 0
total_latency_ms: float = 0.0
min_latency_ms: float = float('inf')
max_latency_ms: float = 0.0
latencies: list = field(default_factory=list)
def add_result(self, result: RequestResult):
self.total_requests += 1
if result.success:
self.successful_requests += 1
self.total_latency_ms += result.latency_ms
self.min_latency_ms = min(self.min_latency_ms, result.latency_ms)
self.max_latency_ms = max(self.max_latency_ms, result.latency_ms)
self.latencies.append(result.latency_ms)
else:
self.failed_requests += 1
@property
def avg_latency_ms(self) -> float:
if not self.latencies:
return 0.0
return self.total_latency_ms / len(self.latencies)
@property
def p50_latency_ms(self) -> float:
return self._percentile(50)
@property
def p95_latency_ms(self) -> float:
return self._percentile(95)
@property
def p99_latency_ms(self) -> float:
return self._percentile(99)
def _percentile(self, p: float) -> float:
if not self.latencies:
return 0.0
sorted_latencies = sorted(self.latencies)
idx = int(len(sorted_latencies) * p / 100)
idx = min(idx, len(sorted_latencies) - 1)
return sorted_latencies[idx]
class LoadTester:
def __init__(
self,
video_path: str,
url: str,
concurrency: int,
num_frames: int = 16,
chunk_duration: float = 10.0,
timeout: float = 300.0,
token: Optional[str] = None,
):
self.video_path = video_path
self.url = url
self.concurrency = concurrency
self.num_frames = num_frames
self.chunk_duration = chunk_duration
self.timeout = timeout
self.token = token
self.results: list[RequestResult] = []
self.stats = LoadTestStats()
self.running = True
self.start_time: Optional[float] = None
self.request_counter = 0
self._lock = asyncio.Lock()
self._semaphore: asyncio.Semaphore = None
def _build_payload(self) -> dict:
"""Build the request payload."""
stream_id = uuid.uuid4().hex[:8]
return {
"stream_id": stream_id,
"video_path": self.video_path,
"num_frames": self.num_frames,
"chunk_duration": self.chunk_duration,
"use_batching": False,
}
async def _make_request(self, client: httpx.AsyncClient, request_id: str) -> RequestResult:
"""Make a single request and return the result."""
payload = self._build_payload()
stream_id = payload["stream_id"]
start = time.perf_counter()
start_timestamp = time.time()
headers = {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
try:
response = await client.post(f"{self.url}/analyze", json=payload, headers=headers)
end = time.perf_counter()
latency_ms = (end - start) * 1000
if response.status_code == 200:
data = response.json()
timing = data.get("timing_ms", {})
return RequestResult(
request_id=request_id,
stream_id=stream_id,
start_time=start_timestamp,
end_time=time.time(),
latency_ms=latency_ms,
success=True,
status_code=response.status_code,
s3_download_ms=timing.get("s3_download_ms"),
decode_video_ms=timing.get("decode_video_ms"),
encode_ms=timing.get("encode_ms"),
decode_ms=timing.get("decode_ms"),
server_total_ms=timing.get("total_ms"),
num_chunks=data.get("num_chunks"),
video_duration=data.get("video_duration"),
num_scene_changes=data.get("num_scene_changes"),
)
else:
return RequestResult(
request_id=request_id,
stream_id=stream_id,
start_time=start_timestamp,
end_time=time.time(),
latency_ms=latency_ms,
success=False,
status_code=response.status_code,
error=response.text[:200],
)
except Exception as e:
end = time.perf_counter()
latency_ms = (end - start) * 1000
return RequestResult(
request_id=request_id,
stream_id=stream_id,
start_time=start_timestamp,
end_time=time.time(),
latency_ms=latency_ms,
success=False,
status_code=0,
error=str(e)[:200],
)
async def _request_task(self, client: httpx.AsyncClient, request_id: str):
"""Execute a single request and record the result. Releases semaphore when done."""
try:
result = await self._make_request(client, request_id)
async with self._lock:
self.results.append(result)
self.stats.add_result(result)
# Print progress
status = "✓" if result.success else "✗"
print(
f"{status} {result.request_id} | "
f"{result.latency_ms:7.1f}ms | "
f"Total: {self.stats.total_requests} "
f"(OK: {self.stats.successful_requests}, Fail: {self.stats.failed_requests})"
)
finally:
self._semaphore.release()
async def run(self):
"""Run the load test until interrupted."""
self.start_time = time.time()
self._semaphore = asyncio.Semaphore(self.concurrency)
pending_tasks: set[asyncio.Task] = set()
print("=" * 70)
print("🚀 Starting Load Test")
print("=" * 70)
print(f" Video: {self.video_path}")
print(f" URL: {self.url}")
print(f" Concurrency: {self.concurrency}")
print(f" Chunk dur: {self.chunk_duration}s")
print(f" Frames/chunk:{self.num_frames}")
print()
print("Press Ctrl+C to stop and save results...")
print("-" * 70)
async with httpx.AsyncClient(timeout=self.timeout) as client:
while self.running:
# Block until a slot is available
await self._semaphore.acquire()
if not self.running:
self._semaphore.release()
break
# Spawn task (it will release semaphore when done)
self.request_counter += 1
request_id = f"req_{self.request_counter:06d}"
task = asyncio.create_task(self._request_task(client, request_id))
pending_tasks.add(task)
task.add_done_callback(pending_tasks.discard)
# Wait for in-flight requests
if pending_tasks:
print(f"\n⏳ Waiting for {len(pending_tasks)} in-flight requests...")
await asyncio.gather(*pending_tasks, return_exceptions=True)
def stop(self):
"""Stop the load test."""
self.running = False
def save_results(self, output_path: str):
"""Save results to CSV."""
if not self.results:
print("No results to save.")
return
fieldnames = [
"request_id", "stream_id", "start_time", "end_time", "latency_ms",
"success", "status_code", "error",
"s3_download_ms", "decode_video_ms", "encode_ms", "decode_ms", "server_total_ms",
"num_chunks", "video_duration", "num_scene_changes"
]
with open(output_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for result in self.results:
writer.writerow(asdict(result))
print(f"📁 Results saved to: {output_path}")
def print_summary(self):
"""Print summary statistics."""
if not self.results:
print("No results to summarize.")
return
duration = time.time() - self.start_time if self.start_time else 0
throughput = self.stats.total_requests / duration if duration > 0 else 0
print()
print("=" * 70)
print("📊 Load Test Summary")
print("=" * 70)
print(f" Duration: {duration:.1f}s")
print(f" Total requests: {self.stats.total_requests}")
print(f" Successful: {self.stats.successful_requests}")
print(f" Failed: {self.stats.failed_requests}")
print(f" Success rate: {self.stats.successful_requests / self.stats.total_requests * 100:.1f}%")
print(f" Throughput: {throughput:.2f} req/s")
print()
print("⏱️ Latency (successful requests):")
if self.stats.latencies:
print(f" Min: {self.stats.min_latency_ms:8.1f} ms")
print(f" Max: {self.stats.max_latency_ms:8.1f} ms")
print(f" Avg: {self.stats.avg_latency_ms:8.1f} ms")
print(f" P50: {self.stats.p50_latency_ms:8.1f} ms")
print(f" P95: {self.stats.p95_latency_ms:8.1f} ms")
print(f" P99: {self.stats.p99_latency_ms:8.1f} ms")
else:
print(" (no successful requests)")
print("=" * 70)
def main():
parser = argparse.ArgumentParser(description="Load test the Ray Serve video API")
parser.add_argument("--video", type=str, required=True, help="S3 URI: s3://bucket/key")
parser.add_argument("--url", type=str, default="http://127.0.0.1:8000", help="Server URL")
parser.add_argument("--concurrency", type=int, default=4, help="Number of concurrent workers")
parser.add_argument("--num-frames", type=int, default=16, help="Frames per chunk")
parser.add_argument("--chunk-duration", type=float, default=10.0, help="Chunk duration in seconds")
parser.add_argument("--timeout", type=float, default=300.0, help="Request timeout in seconds")
parser.add_argument("--output", type=str, default=None, help="Output CSV path (default: load_test_<timestamp>.csv)")
parser.add_argument("--token", type=str, default=None, help="Bearer token for Authorization header")
args = parser.parse_args()
# Generate output path if not provided
if args.output is None:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
args.output = f"load_test_{timestamp}_{args.concurrency}.csv"
tester = LoadTester(
video_path=args.video,
url=args.url,
concurrency=args.concurrency,
num_frames=args.num_frames,
chunk_duration=args.chunk_duration,
timeout=args.timeout,
token=args.token,
)
# Track interrupt count for force exit
interrupt_count = 0
# Handle Ctrl+C gracefully
def signal_handler(sig, frame):
nonlocal interrupt_count
interrupt_count += 1
if interrupt_count == 1:
print("\n\n🛑 Stopping load test (press Ctrl+C again to force exit)...")
tester.stop()
else:
print("\n\n⚠️ Force exiting...")
tester.print_summary()
tester.save_results(args.output)
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
asyncio.run(tester.run())
except KeyboardInterrupt:
pass
finally:
tester.print_summary()
tester.save_results(args.output)
if __name__ == "__main__":
main()