Files
trycua--cua/tests/android_rps_benchmark.py
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / Resolve release version (push) Waiting to run
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / Distro compat summary (push) Blocked by required conditions
CI: Nix Linux Rust source / Nix / compositor build (push) Waiting to run
CI: Nix Linux Rust source / Nix / driver package (push) Waiting to run
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Waiting to run
CI: Rust Linux unit / Rust Linux unit and compile (push) Waiting to run
CI: Rust Windows unit / Rust Windows unit and compile (push) Waiting to run
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Waiting to run
CD: Docs MCP Server / build (linux/amd64) (push) Waiting to run
CD: Docs MCP Server / build (linux/arm64) (push) Waiting to run
CD: Docs MCP Server / merge (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

528 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Android VM fleet benchmark — measure max achievable RPS for a given sandbox count.
Provisions N Android sandboxes in parallel, then drives them as fast as possible
(no rate limiting). Reports achieved throughput and latency percentiles.
Usage:
python tests/android_rps_benchmark.py --sandboxes 2 --duration 30
python tests/android_rps_benchmark.py --sandboxes 8 --duration 60 --action step
"""
from __future__ import annotations
import argparse
import asyncio
import builtins
import functools
import math
import os
import statistics
import time
from dataclasses import dataclass, field
from cua_sandbox import Image, Sandbox
from cua_sandbox.transport.cloud import CloudTransport
print = functools.partial(builtins.print, flush=True)
# ── Data types ────────────────────────────────────────────────────────────────
@dataclass
class SandboxStats:
name: str
provision_time: float
requests: int = 0
errors: int = 0
latencies: list[float] = field(default_factory=list)
@dataclass
class BenchmarkResult:
achieved_rps: float
sandboxes: int
duration: float
total_requests: int
total_errors: int
provision_times: list[float]
all_latencies: list[float]
@property
def error_rate(self) -> float:
return self.total_errors / max(self.total_requests, 1)
@property
def mean_provision(self) -> float:
return statistics.mean(self.provision_times) if self.provision_times else 0.0
@property
def p50(self) -> float:
return _percentile(self.all_latencies, 50)
@property
def p95(self) -> float:
return _percentile(self.all_latencies, 95)
@property
def p99(self) -> float:
return _percentile(self.all_latencies, 99)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _percentile(data: list[float], p: float) -> float:
if not data:
return 0.0
s = sorted(data)
idx = (p / 100) * (len(s) - 1)
lo = int(idx)
hi = min(lo + 1, len(s) - 1)
return s[lo] + (idx - lo) * (s[hi] - s[lo])
# ── Sandbox lifecycle ─────────────────────────────────────────────────────────
async def _provision(image: Image, idx: int) -> tuple[Sandbox, SandboxStats]:
t0 = time.monotonic()
sb = await Sandbox.create(image)
elapsed = time.monotonic() - t0
name = getattr(sb, "name", None) or f"sb-{idx}"
print(f" [{idx + 1:>3}] {name} provisioned in {elapsed:.1f}s")
return sb, SandboxStats(name=name, provision_time=elapsed)
async def _provision_fleet(
image: Image, n: int, parallel: int = 2
) -> tuple[list[Sandbox], list[SandboxStats], list[float]]:
sandboxes, stats, provision_times = [], [], []
for batch_start in range(0, n, parallel):
batch = range(batch_start, min(batch_start + parallel, n))
tasks = [_provision(image, i) for i in batch]
raw = await asyncio.gather(*tasks, return_exceptions=True)
for i, res in zip(batch, raw):
if isinstance(res, Exception):
print(f" [!] sandbox {i + 1} failed to provision: {res}")
else:
sb, st = res
sandboxes.append(sb)
stats.append(st)
provision_times.append(st.provision_time)
return sandboxes, stats, provision_times
async def _destroy_fleet(sandboxes: list[Sandbox]) -> None:
results = await asyncio.gather(*[sb.destroy() for sb in sandboxes], return_exceptions=True)
for i, r in enumerate(results):
if isinstance(r, Exception):
print(f" [!] sandbox {i} destroy failed: {r}")
# ── Per-sandbox actions ───────────────────────────────────────────────────────
_t_bench_start: float = 0.0 # set when load test begins
def _ts() -> str:
"""Relative timestamp from load test start."""
return f"{time.monotonic() - _t_bench_start:>7.3f}s"
async def _do_screenshot(sb: Sandbox, name: str) -> None:
t0 = time.monotonic()
data = await sb.screenshot(format="jpeg")
elapsed = time.monotonic() - t0
print(f" {_ts()} {name} screenshot {elapsed*1000:.0f}ms {len(data)}B")
if len(data) < 500:
raise ValueError(f"screenshot too small ({len(data)} bytes)")
async def _do_step(sb: Sandbox, name: str) -> None:
"""Simulate one RL step: tap center of screen + screenshot."""
t_size0 = time.monotonic()
w, h = await sb.screen.size()
t_size = time.monotonic() - t_size0
print(f" {_ts()} {name} screen_size {t_size*1000:.0f}ms ({w}x{h})")
t_tap0 = time.monotonic()
await sb.mouse.click(w // 2, h // 2)
t_tap = time.monotonic() - t_tap0
print(f" {_ts()} {name} tap {t_tap*1000:.0f}ms")
t_ss0 = time.monotonic()
data = await sb.screenshot(format="jpeg")
t_ss = time.monotonic() - t_ss0
print(f" {_ts()} {name} screenshot {t_ss*1000:.0f}ms {len(data)}B")
if len(data) < 500:
raise ValueError(f"screenshot too small ({len(data)} bytes)")
_ACTION_FNS = {
"screenshot": _do_screenshot,
"step": _do_step,
}
# action fn signature: (sb, name) -> None
# ── Worker loop ───────────────────────────────────────────────────────────────
async def _worker(
sb: Sandbox,
stats: SandboxStats,
stop_event: asyncio.Event,
action: str,
) -> None:
"""Drive one sandbox as fast as possible until stop_event is set."""
fn = _ACTION_FNS[action]
while not stop_event.is_set():
t0 = time.monotonic()
try:
await fn(sb, stats.name)
elapsed = time.monotonic() - t0
stats.latencies.append(elapsed)
stats.requests += 1
except asyncio.CancelledError:
raise
except Exception as e:
stats.errors += 1
stats.requests += 1
if not stop_event.is_set():
print(f" [!] {stats.name}: {e}")
# ── Progress reporter ─────────────────────────────────────────────────────────
async def _progress_reporter(
all_stats: list[SandboxStats],
t_start: float,
duration: float,
stop_event: asyncio.Event,
) -> None:
interval = max(5.0, duration / 6)
while not stop_event.is_set():
await asyncio.sleep(interval)
if stop_event.is_set():
break
elapsed = time.monotonic() - t_start
total_reqs = sum(s.requests for s in all_stats)
total_errs = sum(s.errors for s in all_stats)
rps = total_reqs / elapsed if elapsed > 0 else 0.0
remain = max(0, duration - elapsed)
print(
f" t={elapsed:>5.1f}s {rps:>6.1f} RPS "
f"reqs={total_reqs} errs={total_errs} remain={remain:.0f}s"
)
# ── Benchmark orchestrator ────────────────────────────────────────────────────
async def run_benchmark(
n_sandboxes: int,
duration: float,
action: str,
image: Image,
parallel: int = 2,
) -> BenchmarkResult:
# ── Provision ──────────────────────────────────────────────────────────
print(f"\n── Provisioning {n_sandboxes} sandbox(es) ──")
sandboxes, all_stats, provision_times = await _provision_fleet(
image, n_sandboxes, parallel=parallel
)
if not sandboxes:
raise RuntimeError("All sandboxes failed to provision — aborting benchmark.")
actual = len(sandboxes)
print(
f" {actual}/{n_sandboxes} provisioned | "
f"mean={statistics.mean(provision_times):.1f}s "
f"max={max(provision_times):.1f}s"
)
# ── Load test ─────────────────────────────────────────────────────────
stop_event = asyncio.Event()
t_start = time.monotonic()
global _t_bench_start
_t_bench_start = t_start
print(f"\n── Load test: max RPS × {duration}s ──\n action={action} sandboxes={actual}")
workers = [
asyncio.create_task(_worker(sb, st, stop_event, action))
for sb, st in zip(sandboxes, all_stats)
]
reporter = asyncio.create_task(_progress_reporter(all_stats, t_start, duration, stop_event))
await asyncio.sleep(duration)
stop_event.set()
for w in workers:
w.cancel()
reporter.cancel()
await asyncio.gather(*workers, reporter, return_exceptions=True)
elapsed = time.monotonic() - t_start
# ── Cleanup ───────────────────────────────────────────────────────────
print(f"\n── Destroying {actual} sandbox(es) ──")
await _destroy_fleet(sandboxes)
# ── Aggregate ─────────────────────────────────────────────────────────
total_reqs = sum(s.requests for s in all_stats)
total_errs = sum(s.errors for s in all_stats)
all_latencies = [lat for s in all_stats for lat in s.latencies]
achieved_rps = total_reqs / elapsed if elapsed > 0 else 0.0
return BenchmarkResult(
achieved_rps=achieved_rps,
sandboxes=actual,
duration=elapsed,
total_requests=total_reqs,
total_errors=total_errs,
provision_times=provision_times,
all_latencies=all_latencies,
)
# ── Report ────────────────────────────────────────────────────────────────────
def print_report(result: BenchmarkResult) -> None:
bar = "─" * 58
print(f"\n{'═'*58}")
print(" Android VM Fleet Benchmark")
print(f"{'═'*58}")
print(f" {bar}")
print(" Throughput")
print(f" {'Achieved RPS':<24} {result.achieved_rps:.2f}")
print(f" {'RPS / sandbox':<24} {result.achieved_rps / max(result.sandboxes, 1):.2f}")
print(f" {'Sandboxes':<24} {result.sandboxes}")
print(f" {'Duration':<24} {result.duration:.1f}s")
print(f" {'Total requests':<24} {result.total_requests}")
print(f" {'Error rate':<24} {result.error_rate * 100:.1f}%")
print(f" {bar}")
print(" Provisioning")
print(f" {'Mean cold start':<24} {result.mean_provision:.1f}s")
if result.provision_times:
print(f" {'Max cold start':<24} {max(result.provision_times):.1f}s")
print(f" {bar}")
print(" Latency (successful requests)")
if result.all_latencies:
print(f" {'p50':<24} {result.p50 * 1000:.0f}ms")
print(f" {'p95':<24} {result.p95 * 1000:.0f}ms")
print(f" {'p99':<24} {result.p99 * 1000:.0f}ms")
else:
print(" (no successful requests)")
print(f"{'═'*58}\n")
# ── CLI ───────────────────────────────────────────────────────────────────────
_STATE_FILE = "/tmp/android_bench_sandboxes.txt"
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Measure max RPS for a fleet of Android sandboxes.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Full run (provision + load test + delete):
python tests/android_rps_benchmark.py --sandboxes 4 --duration 30
# Step-by-step:
python tests/android_rps_benchmark.py --sandboxes 4 --provision
python tests/android_rps_benchmark.py --continue
python tests/android_rps_benchmark.py --delete
""",
)
p.add_argument(
"--sandboxes", type=int, default=2, help="Number of Android sandboxes (default: 2)."
)
p.add_argument(
"--duration", type=float, default=30.0, help="Load-test duration in seconds (default: 30)."
)
p.add_argument(
"--action",
choices=list(_ACTION_FNS),
default="screenshot",
help="'screenshot' = observation only; 'step' = tap + screenshot. Default: screenshot.",
)
p.add_argument("--android-version", default="14", help="Android version (default: 14).")
p.add_argument(
"--parallel",
type=int,
default=4,
metavar="N",
help="Number of sandboxes to provision concurrently (default: 4). Use 1 for fully sequential.",
)
mode = p.add_mutually_exclusive_group()
mode.add_argument(
"--provision",
action="store_true",
help=f"Provision sandboxes and save names to {_STATE_FILE}. No load test or delete.",
)
mode.add_argument(
"--continue",
dest="cont",
action="store_true",
help=f"Load sandbox names from {_STATE_FILE}, run load test, then delete.",
)
mode.add_argument(
"--delete",
action="store_true",
help=f"Delete sandboxes listed in {_STATE_FILE}.",
)
return p.parse_args()
async def _main() -> None:
args = _parse_args()
image = Image.android(args.android_version)
# ── --delete ──────────────────────────────────────────────────────────
if args.delete:
try:
names = open(_STATE_FILE).read().split()
except FileNotFoundError:
print(f"No state file at {_STATE_FILE}")
return
print(f"── Deleting {len(names)} sandbox(es) from {_STATE_FILE} ──")
sandboxes = [Sandbox(CloudTransport(name=n), name=n, _ephemeral=False) for n in names]
await _destroy_fleet(sandboxes)
os.unlink(_STATE_FILE)
print("Done.")
return
# ── --continue ────────────────────────────────────────────────────────
if args.cont:
try:
names = open(_STATE_FILE).read().split()
except FileNotFoundError:
print(f"No state file at {_STATE_FILE} — run --provision first.")
return
print(f"── Reconnecting to {len(names)} sandbox(es) from {_STATE_FILE} ──")
sandboxes = []
all_stats = []
provision_times = []
async def _reconnect(name: str):
try:
sb = await Sandbox.connect(name=name)
print(f" [ok] {name}", flush=True)
return sb
except Exception as e:
print(f" [!] {name}: {e}", flush=True)
return None
results = await asyncio.gather(*[_reconnect(n) for n in names])
for name, sb in zip(names, results):
if sb is None:
continue
sandboxes.append(sb)
all_stats.append(SandboxStats(name=name, provision_time=0.0))
provision_times.append(0.0)
stop_event = asyncio.Event()
t_start = time.monotonic()
global _t_bench_start
_t_bench_start = t_start
print(
f"\n── Load test: max RPS × {args.duration}s ──\n action={args.action} sandboxes={len(sandboxes)}"
)
workers = [
asyncio.create_task(_worker(sb, st, stop_event, args.action))
for sb, st in zip(sandboxes, all_stats)
]
reporter = asyncio.create_task(
_progress_reporter(all_stats, t_start, args.duration, stop_event)
)
await asyncio.sleep(args.duration)
stop_event.set()
for w in workers:
w.cancel()
reporter.cancel()
await asyncio.gather(*workers, reporter, return_exceptions=True)
elapsed = time.monotonic() - t_start
total_reqs = sum(s.requests for s in all_stats)
total_errs = sum(s.errors for s in all_stats)
all_latencies = [lat for s in all_stats for lat in s.latencies]
result = BenchmarkResult(
achieved_rps=total_reqs / elapsed if elapsed > 0 else 0.0,
sandboxes=len(sandboxes),
duration=elapsed,
total_requests=total_reqs,
total_errors=total_errs,
provision_times=provision_times,
all_latencies=all_latencies,
)
print_report(result)
return
# ── --provision ───────────────────────────────────────────────────────
if args.provision:
# Resume: load existing names so we only provision what's missing
existing_names: list[str] = []
try:
existing_names = open(_STATE_FILE).read().split()
print(
f"Resuming: {len(existing_names)} already provisioned, "
f"targeting {args.sandboxes} total."
)
except FileNotFoundError:
pass
remaining = args.sandboxes - len(existing_names)
print("Android RL Fleet Benchmark")
print(
f" sandboxes={args.sandboxes} android={args.android_version} "
f"mode=provision-only remaining={remaining}"
)
if remaining <= 0:
print(f"Already have {len(existing_names)} sandboxes — nothing to provision.")
return
print(f"\n── Provisioning {remaining} more sandbox(es) (parallel={args.parallel}) ──")
new_sandboxes, _, _ = await _provision_fleet(image, remaining, parallel=args.parallel)
new_names = [
getattr(sb, "name", None) or f"sb-{len(existing_names) + i}"
for i, sb in enumerate(new_sandboxes)
]
all_names = existing_names + new_names
with open(_STATE_FILE, "w") as f:
f.write("\n".join(all_names) + "\n")
print(f"\nSaved {len(all_names)} sandbox name(s) to {_STATE_FILE}")
print("Run with --continue to start the load test.")
return
# ── normal (all-in-one) ───────────────────────────────────────────────
print("Android RL Fleet Benchmark")
print(
f" sandboxes={args.sandboxes} duration={args.duration}s action={args.action} android={args.android_version}"
)
result = await run_benchmark(
n_sandboxes=args.sandboxes,
duration=args.duration,
action=args.action,
image=image,
parallel=args.parallel,
)
print_report(result)
if __name__ == "__main__":
asyncio.run(_main())