198 lines
9.8 KiB
Python
198 lines
9.8 KiB
Python
#!/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():
|
||
# Send one shell command string. Separate sh/-c args are not safely
|
||
# quoted by all ADB versions and previously blocked the first run.
|
||
raw = device("shell", 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()}
|
||
|
||
(out / "warmup_logcat.txt").write_text(warm_log + "\n", encoding="utf-8")
|
||
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")
|
||
# Preserve early summaries when Android logcat rotates during sampling.
|
||
# Extract combines records by exportId so repeated chunks do not count twice.
|
||
native = verifier.extract(warm_log + "\n" + 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())
|