项目文件夹

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

528 行
19 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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())