Files
MapLibreParticleLab/tools/particle_android_validation/benchmark_step119_cpu.py
tei 32ac795fc4
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
test: benchmark particle-only C FastPath CPU across R layouts
2026-10-11 18:02:33 +09:00

193 lines
9.4 KiB
Python
Raw 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.
#!/usr/bin/env python3
"""STEP-119 / RCRR CPU 对照。只启动已安装测试场景并读取真机CPU/Native计数,不编译/修改24 Gate。"""
import argparse
import json
import statistics
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import verify_step113_fast_path as verifier
BENCH_SCENARIOS = (
"RCRR_FULL_CONTROL_NO_TIDE",
"RCRR_C_NARROW_NO_TIDE",
"RCR_PARTICLE_ONLY_NO_TIDE",
"RCRRR_PARTICLE_ONLY_NO_TIDE",
)
def parse_proc_stat(raw: str):
"""Linux /proc/PID/stat: comm may contain whitespace or ')'; indices are post-comm."""
close = raw.rfind(")")
if close <= 0 or not raw.startswith(raw.split(" ", 1)[0] + " ("):
raise ValueError("invalid /proc/PID/stat header")
rest = raw[close + 2:].split()
if len(rest) < 20:
raise ValueError("truncated /proc/PID/stat")
# Fields 14/15 utime/stime and field 22 starttime; rest starts at field 3.
return int(rest[11]) + int(rest[12]), int(rest[19])
def collect_result(samples, cpu_start, cpu_end, clock_ticks, elapsed):
native_start, native_end = samples
fast_delta = native_end["fastRendered"] - native_start["fastRendered"]
full_delta = native_end["fullRendered"] - native_start["fullRendered"]
interval = (native_end["sampleElapsedMs"] - native_start["sampleElapsedMs"]) / 1000
if interval <= 0 or fast_delta < 0 or full_delta < 0:
raise ValueError("invalid Native window/counters")
cpu_sec = (cpu_end["ticks"] - cpu_start["ticks"]) / clock_ticks
if cpu_sec < 0:
raise ValueError("CPU counter reset")
fps = (fast_delta + full_delta) / interval
cpu_cores = cpu_sec / elapsed
return {
"cpuSeconds": cpu_sec,
"cpuCoreEquivalent": cpu_cores,
"cpuPercentOfSingleCore": 100 * cpu_cores,
"nativeWindowSeconds": interval,
"nativeWindowFullFrames": full_delta,
"nativeWindowFastFrames": fast_delta,
"nativeObservedFps": fps,
"fastFraction": fast_delta / (fast_delta + full_delta) if fast_delta + full_delta else None,
"estimatedCpuMsPerNativeFrame": 1000 * cpu_cores / fps if fps else None,
"caveat": ("CPU时间为整个App进程,含UI、日志和其他线程;"
"帧数由邻近原生摘要窗口得出;CPU毫秒/帧为近似值,不是原生逐帧计时。"),
}
def run(args):
adb = args.adb or str(Path.home() / "Library/Android/sdk/platform-tools/adb")
info, sha, head, _ = verifier.inspect(verifier.APK, adb, args.serial)
def device(*argv):
return verifier.call([adb, "-s", args.serial, *argv])
expected_full = args.scenario == "RCRR_FULL_CONTROL_NO_TIDE"
clock_ticks = int(device("shell", "getconf", "CLK_TCK"))
if clock_ticks <= 0:
raise RuntimeError("invalid CLOCK_TICKS")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out = verifier.ARTIFACTS / "step119-rcrr-cpu" / f"{stamp}-{args.scenario.lower()}"
out.mkdir(parents=True, exist_ok=False)
launch = device("shell", "am", "start", "-S", "-n", verifier.ACTIVITY,
"--es", "step113_scenario", args.scenario)
(out / "launch.txt").write_text(launch + "\n", encoding="utf-8")
pid = None
for _ in range(12):
time.sleep(1)
pid_tokens = verifier.call([adb, "-s", args.serial, "shell", "pidof",
verifier.PACKAGE], check=False).split()
if pid_tokens:
pid = int(pid_tokens[0])
break
if pid is None:
raise RuntimeError("APP_PID_MISSING")
print(f"SCENARIO={args.scenario} PID={pid} SHA_MATCHED={sha} SOURCE={head}", flush=True)
time.sleep(args.warmup)
warm_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S")
warm_samples = verifier.extract(warm_log, pid, args.scenario)
if not any(s.get("sceneReady") and not s.get("blockedReason") for s in warm_samples):
(out / "warmup_logcat.txt").write_text(warm_log, encoding="utf-8")
raise RuntimeError("scene not ready after warmup (saved log)")
def cpu_sample():
raw = device("shell", "sh", "-c", f"cat /proc/{pid}/stat; cat /proc/uptime")
lines = [line for line in raw.splitlines() if line.strip()]
if len(lines) != 2:
raise RuntimeError(f"unexpected /proc sample: {raw[:120]}")
ticks, born = parse_proc_stat(lines[0])
uptime = float(lines[1].split()[0])
return {"ticks": ticks, "startTimeTicks": born, "deviceElapsedMs": uptime * 1000,
"hostMonotonic": time.monotonic()}
observations = [cpu_sample()]
deadline = observations[0]["hostMonotonic"] + args.seconds
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
time.sleep(min(args.interval, remaining))
sample = cpu_sample()
if sample["startTimeTicks"] != observations[0]["startTimeTicks"]:
raise RuntimeError("process PID reused/restarted: cannot compare")
observations.append(sample)
first_cpu, last_cpu = observations[0], observations[-1]
elapsed = last_cpu["hostMonotonic"] - first_cpu["hostMonotonic"]
raw_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S")
(out / "logcat.txt").write_text(raw_log + "\n", encoding="utf-8")
native = verifier.extract(raw_log, pid, args.scenario)
ready = [s for s in native if s.get("sceneReady") and not s.get("blockedReason")
and isinstance(s.get("sampleElapsedMs"), (float, int))]
def closest(device_time):
if not ready:
raise RuntimeError("no timestamped Native summary")
return min(ready, key=lambda s: abs(s["sampleElapsedMs"] - device_time))
a, b = closest(first_cpu["deviceElapsedMs"]), closest(last_cpu["deviceElapsedMs"])
offsets = [abs(a["sampleElapsedMs"] - first_cpu["deviceElapsedMs"]),
abs(b["sampleElapsedMs"] - last_cpu["deviceElapsedMs"])]
parity = all(verifier.scene_parity_valid(s, args.scenario) for s in (a, b))
same_lifecycle = (a.get("sceneGeneration"), a.get("lifecycle")) == (
b.get("sceneGeneration"), b.get("lifecycle"))
control_match = all(s.get("commands", {}).get("forceFullControl") == expected_full
for s in (a, b))
metrics = collect_result((a, b), first_cpu, last_cpu, clock_ticks, elapsed)
loads = []
for old, new in zip(observations, observations[1:]):
dt = new["hostMonotonic"] - old["hostMonotonic"]
if dt > 0:
loads.append((new["ticks"] - old["ticks"]) / clock_ticks / dt)
metrics["medianCpuCoreEquivalentPerInterval"] = statistics.median(loads) if loads else None
status = (
"INVALID_PARITY_OR_LIFECYCLE" if not parity or not same_lifecycle or not control_match else
"SUMMARY_ALIGNMENT_LIMIT" if max(offsets) > 9000 else
"DECISION_MISMATCH" if b.get("decisionMismatch") != 0 else
"FULL_CONTROL_OBSERVED" if expected_full and metrics["nativeWindowFullFrames"] > 0
and metrics["nativeWindowFastFrames"] == 0 else
"FAST_DOMINANT" if not expected_full and
metrics["fastFraction"] is not None and metrics["fastFraction"] >= 0.95 else
"MIXED_OR_NO_FAST" if not expected_full else "NO_EXPECTED_FRAMES"
)
data = {
"schema": "step119-rcrr-cpu-benchmark-v1",
"status": status, "sourceCommit": head, "apkSha256": sha,
"device": args.serial, "pid": pid, "scenario": args.scenario,
"fullControl": expected_full, "durationSecondsRequested": args.seconds,
"durationSecondsObserved": elapsed, "warmupSeconds": args.warmup,
"clockTicksPerSecond": clock_ticks, "nativeCount": len(ready),
"nativeCpuBoundaryOffsetMs": offsets, "sameLifecycle": same_lifecycle,
"parityValid": parity, "controlMatched": control_match,
"nativeStart": a, "nativeEnd": b,
"cpuObservations": observations, "metrics": metrics,
"performanceQualification": ("进程级CPU测量仅可与同APK/相同Style相机负载的对照比较;"
"应取Full-Fast-Full或交叉顺序重复,排除热机、缓存热度和帧率不同造成的误判;"
"不能用此数据宣称GPU时间、省电或逐帧CPU精确耗时。"),
}
(out / "result.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(json.dumps({"status": status, "scenario": args.scenario, "metrics": metrics,
"evidence": str(out / "result.json")}, ensure_ascii=False), flush=True)
return 0 if status in ("FAST_DOMINANT", "FULL_CONTROL_OBSERVED") else 2
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--serial", default="192.168.200.139:5555")
parser.add_argument("--adb", default=None)
parser.add_argument("--scenario", choices=BENCH_SCENARIOS, required=True)
parser.add_argument("--seconds", type=int, default=90)
parser.add_argument("--warmup", type=int, default=35)
parser.add_argument("--interval", type=float, default=6.0)
args = parser.parse_args()
if args.seconds < 75 or args.warmup < 20 or args.interval < 1:
parser.error("观察至少75秒、Warmup至少20秒、采样间隔至少1秒")
try:
return run(args)
except (RuntimeError, OSError, ValueError, KeyError) as e:
print(f"STEP119_CPU_BENCH_BLOCKED: {e}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())