mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
764 lines
34 KiB
Python
764 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import datetime
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from trace_cases import (case_for_backend, case_with_defaults, ci_backends, ci_trace_cases,
|
|
load_trace_cases, split_trace_cases)
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
FIXTURES = ROOT / "tools" / "trace_replay" / "fixtures"
|
|
RESULT_ROOT = ROOT / ".trace-work" / "android-retrace-result"
|
|
FIXTURE_ROOT = ROOT / ".trace-work" / "android-retrace-fixture"
|
|
SUMMARY_DIR = ROOT / ".trace-work" / "android-retrace-summary"
|
|
SUMMARY_HTML = "mobilegl-android-retrace-overview.html"
|
|
DEFAULT_ANGLE_VARIANT = "ec889e6ea831"
|
|
BLISS_ANGLE_VARIANT = "90a62123d794"
|
|
BLISS_CASE = "minecraft-1.21.4-fabric-iris-bliss-in-world"
|
|
# BOTH OUTPUT DIRECTORIES, newest wins. The trace variant is assembled as a RELEASE build
|
|
# (`:app:assembleTraceRelease`) and Gradle puts it under apk/trace/release, but CI has historically
|
|
# staged the same artifact under apk/trace/debug - so a tree that has built locally has two, and
|
|
# looking in one of them silently retraces the other one's APK. That is the "ran the wrong binary
|
|
# and went green" shape, on the platform where it is hardest to notice: nothing about the result
|
|
# says which APK produced it.
|
|
TRACE_APK_DIRS = (
|
|
ROOT / "android-plugin" / "app" / "build" / "outputs" / "apk" / "trace" / "release",
|
|
ROOT / "android-plugin" / "app" / "build" / "outputs" / "apk" / "trace" / "debug",
|
|
)
|
|
TRACE_APK_DIR = TRACE_APK_DIRS[1]
|
|
|
|
BACKENDS = {
|
|
"DirectGLES": {
|
|
"package": "top.mobilegl.plugin.trace",
|
|
"use_angle": False,
|
|
"use_pbuffer": False,
|
|
},
|
|
"DirectVulkan": {
|
|
"package": "top.mobilegl.plugin.trace",
|
|
"use_angle": False,
|
|
"use_pbuffer": False,
|
|
},
|
|
}
|
|
|
|
# The trace APK's application id, overridable so a development build can be installed BESIDE an
|
|
# existing trace install instead of replacing it.
|
|
#
|
|
# WHY IT IS NEEDED: `-Pmobilegl.applicationIdSuffix=<x>` is the only way to put two trace APKs on one
|
|
# device, and two APKs signed by different keys cannot share an id at all (`adb install -r` fails
|
|
# with INSTALL_FAILED_UPDATE_INCOMPATIBLE). A machine that already carries a trace install under the
|
|
# canonical id - signed by whichever key was current when it was made - otherwise has to have that
|
|
# install destroyed before an A/B can run, which is destructive and, on a shared bench, rude.
|
|
#
|
|
# ABSENT MEANS THE CANONICAL ID, so every existing caller keeps the behaviour it had. What the
|
|
# override does NOT change: the Activity class, the intent action, the extras, the on-device app dir
|
|
# and the arm-proof markers all stay identical, so the lane under test is the real lane. The one
|
|
# thing that must move with it is `--package` in the same command, because trace-replay-ci.sh drives
|
|
# `am start`, `run-as` and `force-stop` off it. tools/device_bench/p6/ab_session.py sets both.
|
|
_trace_package_override = __import__("os").environ.get("MOBILEGL_TRACE_PACKAGE", "").strip()
|
|
if _trace_package_override:
|
|
for _backend in BACKENDS.values():
|
|
_backend["package"] = _trace_package_override
|
|
|
|
CASES = load_trace_cases()
|
|
|
|
|
|
def safe_case(name):
|
|
return "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name)
|
|
|
|
|
|
def is_lfs_pointer(path):
|
|
return path.exists() and path.read_bytes()[:80].startswith(b"version https://git-lfs.github.com/spec/v1")
|
|
|
|
|
|
def find_trace_apk():
|
|
# A preinstalled APK need not be the newest Gradle output in this tree.
|
|
# Let the caller pin the exact signed file used for the session so run.json
|
|
# does not present an unrelated local build's SHA as device provenance.
|
|
override = __import__("os").environ.get("MOBILEGL_TRACE_APK", "").strip()
|
|
if override:
|
|
path = Path(override).expanduser().resolve()
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"MOBILEGL_TRACE_APK does not exist: {path}")
|
|
return path
|
|
candidates = [
|
|
path
|
|
for directory in TRACE_APK_DIRS
|
|
for path in directory.glob("MobileGL-plugin-trace-release-*.apk")
|
|
]
|
|
return max(candidates, key=lambda path: path.stat().st_mtime) if candidates else None
|
|
|
|
|
|
# THE HOST SHELL, PER PLATFORM. This runner was written for Git Bash on Windows, where
|
|
# trace-replay-ci.sh has to be started through Git's bash.exe and every host path handed to it
|
|
# spelled /c/... . On Linux (WSL included - the device window's detachable `setsid nohup` runs
|
|
# live there) that bash.exe does not exist, so every case died with FileNotFoundError before the
|
|
# first adb call, and a /c/... spelling of a native path is not a path at all. trace-replay-ci.sh
|
|
# itself is POSIX shell (it converts with cygpath only when cygpath exists), so on a POSIX host
|
|
# the right answer is the system bash and the path unchanged.
|
|
def on_windows():
|
|
return os.name == "nt"
|
|
|
|
|
|
def bash_executable():
|
|
if on_windows():
|
|
return "C:/Program Files/Git/bin/bash.exe"
|
|
return shutil.which("bash") or "/bin/bash"
|
|
|
|
|
|
def bash_path(path):
|
|
path = Path(path).resolve()
|
|
if not on_windows():
|
|
return str(path)
|
|
drive = path.drive.rstrip(":").lower()
|
|
parts = path.parts[1:]
|
|
return "/" + drive + "/" + "/".join(parts)
|
|
|
|
|
|
def mark_skipped(case, backend, reason):
|
|
result_dir = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}"
|
|
result_dir.mkdir(parents=True, exist_ok=True)
|
|
result = {
|
|
"passed": False,
|
|
"statusCode": 2,
|
|
"message": reason,
|
|
"tracePath": str(FIXTURES / case["trace_archive"]),
|
|
"goldenPath": str(FIXTURES / case["golden"]),
|
|
"alternateGoldenPaths": [],
|
|
"matchedGoldenPath": "",
|
|
"actualPath": "",
|
|
"diffPath": "",
|
|
"backend": backend,
|
|
"angleVariant": (
|
|
BLISS_ANGLE_VARIANT if case["name"] == BLISS_CASE else DEFAULT_ANGLE_VARIANT
|
|
) if BACKENDS[backend]["use_angle"] else "",
|
|
"targetCall": case["target_call"],
|
|
"width": case["width"],
|
|
"height": case["height"],
|
|
"cropX": case["crop_x"],
|
|
"cropY": case["crop_y"],
|
|
"cropWidth": case["crop_width"],
|
|
"cropHeight": case["crop_height"],
|
|
"ssim": -1,
|
|
"ssimThreshold": float(case["ssim_threshold"]),
|
|
"mismatchPixels": -1,
|
|
}
|
|
(result_dir / "result.json").write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
|
|
|
|
def copy_goldens(case, backend):
|
|
result_dir = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}"
|
|
result_dir.mkdir(parents=True, exist_ok=True)
|
|
for key, suffix in (("golden", "golden"), ("alternate_golden", "alternate-golden")):
|
|
value = case.get(key)
|
|
if not value:
|
|
continue
|
|
source = FIXTURES / value
|
|
if source.exists() and source.stat().st_size > 0:
|
|
shutil.copyfile(source, result_dir / f"{safe_case(case['name'])}-{backend}-{suffix}.png")
|
|
|
|
|
|
def render_summary():
|
|
SUMMARY_DIR.mkdir(parents=True, exist_ok=True)
|
|
command = [
|
|
"node",
|
|
str(ROOT / "tools" / "trace_replay" / "render_retrace_summary.mjs"),
|
|
"--input",
|
|
str(RESULT_ROOT),
|
|
"--output-dir",
|
|
str(SUMMARY_DIR),
|
|
"--title",
|
|
"MobileGL Android retrace overview",
|
|
"--group-label",
|
|
"Android Device",
|
|
"--html",
|
|
SUMMARY_HTML,
|
|
]
|
|
subprocess.run(command, cwd=ROOT, check=True)
|
|
shutil.copyfile(SUMMARY_DIR / SUMMARY_HTML, SUMMARY_DIR / "index.html")
|
|
|
|
|
|
def run_case(case, backend, extra_args=None, timeout_seconds=None, env_overrides=None,
|
|
use_pbuffer=False):
|
|
backend_info = BACKENDS[backend]
|
|
apk = find_trace_apk()
|
|
trace_archive = FIXTURES / case["trace_archive"]
|
|
golden = FIXTURES / case["golden"]
|
|
alternate = FIXTURES / case["alternate_golden"] if case.get("alternate_golden") else None
|
|
if apk is None:
|
|
mark_skipped(case, backend, "SKIPPED_MISSING_APK: no trace APK found under "
|
|
+ " or ".join(str(directory) for directory in TRACE_APK_DIRS))
|
|
return 2
|
|
if not trace_archive.exists() or is_lfs_pointer(trace_archive):
|
|
mark_skipped(case, backend, "SKIPPED_LFS_POINTER: trace archive is missing or still an LFS pointer")
|
|
copy_goldens(case, backend)
|
|
return 2
|
|
if not golden.exists() or is_lfs_pointer(golden):
|
|
mark_skipped(case, backend, "SKIPPED_LFS_POINTER: golden image is missing or still an LFS pointer")
|
|
return 2
|
|
if alternate is not None and (not alternate.exists() or is_lfs_pointer(alternate)):
|
|
alternate = None
|
|
|
|
command = [
|
|
bash_executable(),
|
|
"android-plugin/trace-replay-ci.sh",
|
|
"--apk-file",
|
|
bash_path(apk),
|
|
"--package",
|
|
backend_info["package"],
|
|
"--backend",
|
|
backend,
|
|
"--result-root",
|
|
bash_path(RESULT_ROOT),
|
|
"--fixture-root",
|
|
bash_path(FIXTURE_ROOT),
|
|
"--case",
|
|
case["name"],
|
|
"--trace-archive",
|
|
bash_path(trace_archive),
|
|
"--trace-file",
|
|
case["trace_file"],
|
|
"--golden",
|
|
bash_path(golden),
|
|
"--target-call",
|
|
str(case["target_call"]),
|
|
"--width",
|
|
str(case["width"]),
|
|
"--height",
|
|
str(case["height"]),
|
|
"--ssim-threshold",
|
|
str(case["ssim_threshold"]),
|
|
"--crop-x",
|
|
str(case["crop_x"]),
|
|
"--crop-y",
|
|
str(case["crop_y"]),
|
|
"--crop-width",
|
|
str(case["crop_width"]),
|
|
"--crop-height",
|
|
str(case["crop_height"]),
|
|
"--timeout-seconds",
|
|
str(timeout_seconds if timeout_seconds is not None else case["timeout_seconds"]),
|
|
]
|
|
command.extend(extra_args or [])
|
|
if alternate is not None:
|
|
command[command.index("--target-call"):command.index("--target-call")] = ["--alternate-golden", bash_path(alternate)]
|
|
if backend_info["use_pbuffer"] or use_pbuffer:
|
|
command.append("--use-pbuffer")
|
|
if backend_info["use_angle"] and case["name"] == BLISS_CASE:
|
|
command.append("--avoid-angle-llvmpipe-sampler-mipmap-min-filter")
|
|
if backend_info["use_angle"] and case.get("avoid_angle_llvmpipe_explicit_lod_bias"):
|
|
command.append("--avoid-angle-llvmpipe-explicit-lod-bias")
|
|
if case.get("coherent_as_flush"):
|
|
command.append("--coherent-as-flush")
|
|
# Generic environment passthrough: --env MOBILEGL_FOO=1 needs no per-knob plumbing in
|
|
# this script, in trace-replay-ci.sh, in the Activity, in the JNI marshalling or in the
|
|
# runner - one extra carries them all.
|
|
if env_overrides:
|
|
command.extend(["--env", ";".join(env_overrides)])
|
|
env = dict(**__import__("os").environ)
|
|
# trace-replay-ci.sh reads its verdicts with "${PYTHON}". Git Bash on Windows has `python`
|
|
# on PATH; a POSIX host is only guaranteed the interpreter running this script.
|
|
env["PYTHON"] = "python" if on_windows() else sys.executable
|
|
env["MSYS2_ARG_CONV_EXCL"] = "/data/*"
|
|
if backend_info["use_angle"]:
|
|
env["MOBILEGL_ESPRYT_USE_ANGLE"] = "1"
|
|
env["MOBILEGL_TRACE_ANGLE_VARIANT"] = (
|
|
BLISS_ANGLE_VARIANT if case["name"] == BLISS_CASE else DEFAULT_ANGLE_VARIANT
|
|
)
|
|
else:
|
|
env.pop("MOBILEGL_ESPRYT_USE_ANGLE", None)
|
|
env.pop("MOBILEGL_TRACE_ANGLE_VARIANT", None)
|
|
result = subprocess.run(command, cwd=ROOT, env=env)
|
|
copy_goldens(case, backend)
|
|
return result.returncode
|
|
|
|
|
|
# -------------------------------------------------------------------------------------------
|
|
# P7-7 repeat archiving.
|
|
#
|
|
# WHY IT IS NOT OPTIONAL FOR E0. `.trace-work/android-retrace-result/<case>-<backend>/` is keyed
|
|
# by case and backend and by nothing else, so repeat 2 overwrites repeat 1 and the only artefact
|
|
# that survives a three-repeat run is the last one. That makes "is this case's divergence
|
|
# REPRODUCIBLE, and are the three repeats the same picture?" - the first question P7-7 E0 asks -
|
|
# unanswerable after the fact, and the device window is the one place where re-running to find
|
|
# out is expensive. --archive-dir copies each repeat out before the next one starts.
|
|
#
|
|
# WHAT IS ARCHIVED, and why each piece: result.json (the SSIM the gate scored and which golden
|
|
# it matched), the actual PNG (the only thing an actual-vs-actual comparison can be computed
|
|
# from), both role logs and transport-proof.json (which arm actually ran - a divergence measured
|
|
# on an arm that silently fell back to monolith is not a finding), and logcat.txt (a crash that
|
|
# still wrote a result). The golden is copied ONCE per arm, not per repeat.
|
|
# -------------------------------------------------------------------------------------------
|
|
ARCHIVED_PER_REPEAT = ("result.json", "mobilegl.log", "mobilegl.client.log", "mobilegl.server.log",
|
|
"transport-proof.json", "logcat.txt", "benchmark.json")
|
|
|
|
|
|
def file_sha256(path):
|
|
h = hashlib.sha256()
|
|
with Path(path).open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def archive_repeat(archive_dir, case, backend, repeat_index):
|
|
"""Copy one repeat's evidence out of the overwritten result root. Returns the destination."""
|
|
arm = f"{safe_case(case['name'])}-{backend}"
|
|
source = RESULT_ROOT / arm
|
|
destination = Path(archive_dir) / arm / ("repeat-%02d" % repeat_index)
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
for name in ARCHIVED_PER_REPEAT:
|
|
candidate = source / name
|
|
if candidate.is_file():
|
|
shutil.copyfile(candidate, destination / name)
|
|
for actual in sorted(source.glob("*-actual.png")):
|
|
shutil.copyfile(actual, destination / actual.name)
|
|
for diff in sorted(source.glob("*-diff.png")):
|
|
shutil.copyfile(diff, destination / diff.name)
|
|
# The goldens sit beside the repeats, once, so the archive is self-contained without
|
|
# carrying the same megabyte three times.
|
|
for golden in sorted(source.glob("*-golden.png")) + sorted(source.glob("*-alternate-golden.png")):
|
|
target = Path(archive_dir) / arm / golden.name
|
|
if not target.exists():
|
|
shutil.copyfile(golden, target)
|
|
return destination
|
|
|
|
|
|
# WHAT ONE REPEAT LEAVES IN THE RESULT ROOT, and why it is cleared before the next one starts.
|
|
# trace-replay-ci.sh refreshes the logs on every run, but it writes result.json and the actual /
|
|
# diff PNGs only when the app produced them - a repeat whose app died before result.json (the
|
|
# bsl-esc-menu scudo abort), or whose adb dropped, leaves the PREVIOUS repeat's files in place.
|
|
# archive_repeat then copies them into this repeat's directory, and compare_actuals.py reports a
|
|
# repeat that rendered nothing as a passing, bit-identical one: exactly the false green gate 3's
|
|
# "three passes bit-identical" would read. A stale adb-disconnected.txt would likewise make the
|
|
# next repeat look like a disconnect. Goldens are inputs, not outputs, and are kept.
|
|
PER_REPEAT_OUTPUTS = ARCHIVED_PER_REPEAT + ("retrace.log", "adb-disconnected.txt")
|
|
|
|
|
|
def clear_repeat_outputs(case, backend):
|
|
result_dir = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}"
|
|
if not result_dir.is_dir():
|
|
return
|
|
for name in PER_REPEAT_OUTPUTS:
|
|
(result_dir / name).unlink(missing_ok=True)
|
|
for pattern in ("*-actual.png", "*-diff.png"):
|
|
for path in result_dir.glob(pattern):
|
|
path.unlink()
|
|
|
|
|
|
def write_archive_manifest(archive_dir, args, backends, cases):
|
|
"""What this archive IS, recorded beside it rather than in the operator's shell history.
|
|
|
|
The APK hash in particular: `find_trace_apk` takes the newest of two output directories, so
|
|
"which binary produced these pictures" is a question the archive has to answer itself.
|
|
"""
|
|
apk = find_trace_apk()
|
|
manifest = {
|
|
"tool": "run_android_retrace_local.py",
|
|
"written_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
|
|
"argv": sys.argv[1:],
|
|
"backends": list(backends),
|
|
"transport": args.transport,
|
|
"use_pbuffer": bool(args.use_pbuffer),
|
|
"repeat": args.repeat,
|
|
"env": list(args.env),
|
|
"transport_env": transport_env(args),
|
|
"transport_proof_args": transport_proof_args(args),
|
|
"package": BACKENDS["DirectGLES"]["package"],
|
|
"cases": [case["name"] for case in cases],
|
|
"apk": str(apk) if apk else None,
|
|
"apk_sha256": file_sha256(apk) if apk else None,
|
|
}
|
|
path = Path(archive_dir) / "run.json"
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
return manifest
|
|
|
|
|
|
def read_benchmark(case, backend, run_index):
|
|
"""Reads the benchmark.json the run just pulled and files it under the run number."""
|
|
result_dir = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}"
|
|
source = result_dir / "benchmark.json"
|
|
if not source.exists():
|
|
return None
|
|
try:
|
|
report = json.loads(source.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as error:
|
|
print(f"failed to read {source}: {error}", file=sys.stderr)
|
|
return None
|
|
shutil.copyfile(source, result_dir / f"benchmark-run{run_index}.json")
|
|
return report
|
|
|
|
|
|
def series_median(values):
|
|
"""The median, by the rule SummarizeSeries uses on the device.
|
|
|
|
trace_replay_core.cpp's SeriesSummary takes the middle element of an odd window and the
|
|
AVERAGE of the two middle elements of an even one, so p50 has to be computed the same way or
|
|
the line would print a p50 next to a medianFrameCpuMs that disagreed with it for a reason
|
|
nobody could see. (It is the only one of the three that is not a nearest rank: the device's
|
|
p95 is.)
|
|
"""
|
|
if not values:
|
|
return -1.0
|
|
ordered = sorted(values)
|
|
middle = len(ordered) // 2
|
|
if len(ordered) % 2 == 1:
|
|
return ordered[middle]
|
|
return 0.5 * (ordered[middle - 1] + ordered[middle])
|
|
|
|
|
|
def nearest_rank_percentile(values, fraction):
|
|
"""Nearest-rank percentile, the rule SummarizeSeries uses on the device for p95.
|
|
|
|
Nearest rank rather than an interpolating percentile so that every number printed here is a
|
|
frame that was actually observed, and so that a p95 computed on this side agrees exactly with
|
|
the p95 the device reported for the same window. The device computes no p99 at all - that is
|
|
the whole reason benchmark.json carries the full series - so p99 is this rule extended, and
|
|
p50 is NOT computed here (see series_median).
|
|
"""
|
|
if not values:
|
|
return -1.0
|
|
ordered = sorted(values)
|
|
rank = math.ceil(fraction * len(ordered))
|
|
if rank < 1:
|
|
rank = 1
|
|
return ordered[rank - 1]
|
|
|
|
|
|
def cpu_tail(report):
|
|
"""The trailing window of the per-frame CPU series, or [] when the run collected none.
|
|
|
|
benchmark.json carries the WHOLE frameCpuTimesMs[] array precisely so that p99 - which the
|
|
device does not compute, and which the paired A/B publishes beside p50 - is a host-side
|
|
reduction over an artefact that already exists. The window is the same trailing tailFrames the
|
|
device summarised, so the numbers below sit beside the device's own without being about a
|
|
different set of frames; p50 is recomputed here by the device's own median rule, so it agrees
|
|
with medianFrameCpuMs on the same run rather than merely sitting next to it.
|
|
"""
|
|
series = report.get("frameCpuTimesMs") or []
|
|
if not series:
|
|
return []
|
|
tail = report.get("tailFrames", 0)
|
|
if not isinstance(tail, int) or tail <= 0 or tail > len(series):
|
|
tail = len(series)
|
|
return series[-tail:]
|
|
|
|
|
|
def format_benchmark(report):
|
|
line = (
|
|
f"frames={report.get('totalFrames', -1)}"
|
|
f" total={report.get('totalSeconds', -1):.1f}s"
|
|
f" tail={report.get('tailFrames', -1)}"
|
|
f" mean={report.get('meanFrameMs', -1):.3f}ms"
|
|
f" median={report.get('medianFrameMs', -1):.3f}ms"
|
|
f" p95={report.get('p95FrameMs', -1):.3f}ms"
|
|
f" fps={report.get('fps', -1):.1f}"
|
|
)
|
|
# The CPU half. It is what the disaggregation A/B is actually read on - wall time under
|
|
# --benchmark-no-finish still contains everything the retrace thread waited for - so it is
|
|
# printed on the same line rather than left to whoever remembers to open the JSON.
|
|
window = cpu_tail(report)
|
|
if window:
|
|
line += (
|
|
f" | cpu mean={report.get('meanFrameCpuMs', -1):.3f}ms"
|
|
f" p50={series_median(window):.3f}ms"
|
|
f" p95={report.get('p95FrameCpuMs', -1):.3f}ms"
|
|
f" p99={nearest_rank_percentile(window, 0.99):.3f}ms"
|
|
)
|
|
else:
|
|
# Not "cpu=0": a run with no per-thread CPU clock and a run that burned no CPU are
|
|
# different claims, and only one of them is possible.
|
|
line += " | cpu unavailable (no per-thread CPU clock in this run)"
|
|
return line
|
|
|
|
|
|
def run_benchmark_case(case, backend, args):
|
|
"""Runs the case as a frame-timing benchmark `--benchmark-repeats` times.
|
|
|
|
Only the first run installs the APK and pushes the trace; the repeats reuse what is
|
|
already on the device, so the numbers are not paying for an adb push each time.
|
|
"""
|
|
label = f"{case['name']} / {backend}"
|
|
reports = []
|
|
failures = 0
|
|
for run_index in range(1, args.benchmark_repeats + 1):
|
|
extra_args = [
|
|
"--benchmark",
|
|
"--benchmark-tail-frames",
|
|
str(args.benchmark_tail_frames),
|
|
"--benchmark-finish",
|
|
"0" if args.benchmark_no_finish else "1",
|
|
]
|
|
if run_index > 1:
|
|
extra_args.append("--reuse-fixture")
|
|
# The previous repeat's file would otherwise be read back as this run's result.
|
|
stale = RESULT_ROOT / f"{safe_case(case['name'])}-{backend}" / "benchmark.json"
|
|
if stale.exists():
|
|
stale.unlink()
|
|
rc = run_case(
|
|
case,
|
|
backend,
|
|
extra_args=extra_args,
|
|
timeout_seconds=args.benchmark_timeout_seconds,
|
|
env_overrides=args.env,
|
|
)
|
|
report = read_benchmark(case, backend, run_index)
|
|
if rc != 0 or report is None:
|
|
print(f"{label} run {run_index}/{args.benchmark_repeats}: FAILED (exit {rc})", flush=True)
|
|
failures += 1
|
|
continue
|
|
reports.append((run_index, report))
|
|
print(
|
|
f"{label} run {run_index}/{args.benchmark_repeats}: {format_benchmark(report)}",
|
|
flush=True,
|
|
)
|
|
|
|
def mean_frame_ms(entry):
|
|
# A run that recorded no frames reports -1; it must not win "best" by being smallest.
|
|
mean = entry[1].get("meanFrameMs", -1)
|
|
return mean if mean > 0 else float("inf")
|
|
|
|
if reports:
|
|
best_index, best = min(reports, key=mean_frame_ms)
|
|
print(
|
|
f"{label} best of {args.benchmark_repeats} (run {best_index}): {format_benchmark(best)}",
|
|
flush=True,
|
|
)
|
|
return 1 if failures else 0
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--case", action="append", dest="cases", help="Case name to run; may be repeated.")
|
|
parser.add_argument("--backend", action="append", choices=sorted(BACKENDS), help="Backend to run; may be repeated.")
|
|
parser.add_argument("--all", action="store_true", help="Run every case in the APK workflow matrix.")
|
|
parser.add_argument(
|
|
"--matrix",
|
|
action="store_true",
|
|
help="Run the CI SPLIT SUBSET - the cases the retrace-split lane runs - rather than "
|
|
"every case in the manifest. This is the set P7 exit gate 3 is scored on: --all "
|
|
"additionally includes the non-CI workloads (rd12), which ID-P7-4 excludes from "
|
|
"that denominator. Each case runs only the backends its own ci_backends names.",
|
|
)
|
|
parser.add_argument(
|
|
"--repeat",
|
|
type=int,
|
|
default=1,
|
|
metavar="N",
|
|
help="Replay each case/backend N times. With --archive-dir each repeat's result.json "
|
|
"and actual PNG are kept, so compare_actuals.py can compute actual-vs-actual SSIM "
|
|
"between repeats; without it the repeats overwrite one another and only the last "
|
|
"one survives.",
|
|
)
|
|
parser.add_argument(
|
|
"--archive-dir",
|
|
type=Path,
|
|
metavar="DIR",
|
|
help="Copy each repeat's evidence to DIR/<case>-<backend>/repeat-NN/ before the next "
|
|
"run overwrites the result root, and write DIR/run.json recording the APK hash, "
|
|
"the arm and the environment. Read it with "
|
|
"`python3 tools/trace_replay/compare_actuals.py summary DIR`.",
|
|
)
|
|
parser.add_argument("--keep-results", action="store_true", help="Do not clear the previous result root.")
|
|
parser.add_argument(
|
|
"--env",
|
|
action="append",
|
|
default=[],
|
|
metavar="KEY=VALUE",
|
|
help="Environment variable to set in the replay process, applied just before "
|
|
"libMobileGL.so is loaded; may be repeated. A KEY with no '=' unsets it. This "
|
|
"is the generic passthrough for MOBILEGL_* knobs that have no flag of their own.",
|
|
)
|
|
parser.add_argument(
|
|
"--benchmark",
|
|
action="store_true",
|
|
help="Replay each case end to end as a frame-timing benchmark instead of comparing "
|
|
"one frame against its golden.",
|
|
)
|
|
parser.add_argument(
|
|
"--benchmark-repeats",
|
|
type=int,
|
|
default=3,
|
|
help="Benchmark runs per case/backend; the best (lowest mean frame time) is reported.",
|
|
)
|
|
parser.add_argument(
|
|
"--benchmark-tail-frames",
|
|
type=int,
|
|
default=200,
|
|
help="Frames at the end of the run the statistics are computed over.",
|
|
)
|
|
parser.add_argument(
|
|
"--benchmark-no-finish",
|
|
action="store_true",
|
|
help="Do not glFinish at every frame boundary, so frame times measure CPU submission "
|
|
"only instead of GPU completion.",
|
|
)
|
|
parser.add_argument(
|
|
"--use-pbuffer",
|
|
action="store_true",
|
|
help="Render into a pbuffer instead of the Activity's window surface. REQUIRED with "
|
|
"--transport spawn (a windowed client uses MOBILEGL_IPC_SURFACE=server instead): an ANativeWindow* is a pointer into the CLIENT's "
|
|
"process and means nothing in the server's, so SetWindowHandle is refused by name "
|
|
"with Fatal{UnmigratedSurface, \"AndroidNativeWindow:use-MOBILEGL_IPC_SURFACE=server\"} (Rule H). The desktop "
|
|
"retrace has always run pbuffer and matches the same goldens.",
|
|
)
|
|
parser.add_argument(
|
|
"--transport",
|
|
choices=("monolith", "inproc", "spawn"),
|
|
default="monolith",
|
|
help="MOBILEGL_TRANSPORT for the replay. `spawn` runs the server role in a SECOND PROCESS "
|
|
"on the device, launched out of the APK's lib/<abi>/ - which requires an APK built "
|
|
"with -Pmobilegl.buildDisaggregated=ON, or ConfigLoader has no parser, accepts the "
|
|
"value and ignores it (CONTRACT-P5 rule 5) and the run is monolith under a name that "
|
|
"says otherwise. MOBILEGL_IPC_SERVER_PATH is NOT passed from here: nativeLibraryDir "
|
|
"carries an install-time hash, so only the app can spell it, and "
|
|
"TraceReplayActivity.resolveSpawnServerPath does.",
|
|
)
|
|
parser.add_argument(
|
|
"--benchmark-timeout-seconds",
|
|
type=int,
|
|
default=900,
|
|
help="Per-run timeout; a benchmark replays the whole trace, not just up to target_call.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def transport_proof_args(args):
|
|
"""The same arm-proof flag CI passes, so a local run cannot be greener than the lane.
|
|
|
|
trace-replay-ci.sh's --require-inproc / --require-spawn read the LIBRARY's own log and
|
|
refuse a run whose transport did not actually resolve - and for spawn, one whose server
|
|
role never left the process. Passing them here too is what stops "it works locally" from
|
|
meaning "it ran monolith locally".
|
|
"""
|
|
return {"inproc": ["--require-inproc"], "spawn": ["--require-spawn"]}.get(args.transport, [])
|
|
|
|
|
|
# The knobs .github/workflows/apk.yml hands BOTH acceptance lanes. They are kept here verbatim so
|
|
# a local run is the same run: the arm proof below reads the library's own `Config: IPC` line and
|
|
# requires strict/role-split-state/run-ahead to be 1, so a local invocation that quietly omitted
|
|
# them would fail a gate CI passes - or worse, pass a weaker one.
|
|
SPLIT_ACCEPTANCE_KNOBS = (
|
|
"MOBILEGL_IPC_ROLE_SPLIT_STATE=1",
|
|
"MOBILEGL_IPC_STRICT_ERRORS=1",
|
|
"MOBILEGL_IPC_RUN_AHEAD=1",
|
|
)
|
|
|
|
|
|
def transport_env(args):
|
|
"""The transport knob as env overrides, or nothing at all for monolith.
|
|
|
|
An EMPTY list on monolith rather than MOBILEGL_TRANSPORT=monolith, so the default arm's
|
|
environment is byte-identical to what it was before this option existed - the control arm has
|
|
to stay a control.
|
|
"""
|
|
if args.transport == "monolith":
|
|
return []
|
|
return [f"MOBILEGL_TRANSPORT={args.transport}", *SPLIT_ACCEPTANCE_KNOBS]
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
selected_backends = args.backend or list(BACKENDS)
|
|
selected_names = set(args.cases or [])
|
|
if args.matrix:
|
|
# The CI split subset, resolved by the manifest's own loader so that a case added later
|
|
# is in it unless it says otherwise (trace_cases.py explains why the key is an opt-out).
|
|
pool = split_trace_cases(ci_trace_cases(CASES))
|
|
else:
|
|
pool = CASES
|
|
selected_cases = [case_with_defaults(case) for case in pool
|
|
if args.all or args.matrix or case["name"] in selected_names]
|
|
if not selected_cases:
|
|
print("No cases selected. Use --matrix, --all or --case NAME.", file=sys.stderr)
|
|
return 2
|
|
if args.benchmark and args.benchmark_repeats < 1:
|
|
print("--benchmark-repeats must be at least 1.", file=sys.stderr)
|
|
return 2
|
|
if args.repeat < 1:
|
|
print("--repeat must be at least 1.", file=sys.stderr)
|
|
return 2
|
|
if args.benchmark and args.repeat != 1:
|
|
print("--repeat is the correctness lane's repeat count; use --benchmark-repeats.",
|
|
file=sys.stderr)
|
|
return 2
|
|
if args.benchmark and args.transport != "monolith":
|
|
# REFUSED RATHER THAN RUN UNDER A NAME THAT LIES. run_benchmark_case passes args.env
|
|
# alone, so --benchmark --transport spawn has always produced a MONOLITH run labelled
|
|
# spawn (tools/device_bench/p6/README.md records the same pitfall). The knob the
|
|
# benchmark path does honour is --env, so say that instead of silently ignoring this.
|
|
print("--benchmark ignores --transport; pass the arm explicitly, e.g. "
|
|
f"--env MOBILEGL_TRANSPORT={args.transport} "
|
|
"--env MOBILEGL_IPC_ROLE_SPLIT_STATE=1 --env MOBILEGL_IPC_STRICT_ERRORS=1 "
|
|
"--env MOBILEGL_IPC_RUN_AHEAD=1", file=sys.stderr)
|
|
return 2
|
|
if not args.keep_results and RESULT_ROOT.exists():
|
|
shutil.rmtree(RESULT_ROOT)
|
|
RESULT_ROOT.mkdir(parents=True, exist_ok=True)
|
|
if args.archive_dir:
|
|
write_archive_manifest(args.archive_dir, args, selected_backends, selected_cases)
|
|
failures = 0
|
|
for case in selected_cases:
|
|
for backend in selected_backends:
|
|
if backend not in ci_backends(case):
|
|
# A case restricted to one backend stays restricted, and the two ways of asking
|
|
# for it are different questions. A SWEEP skips it and says so - the
|
|
# DirectVulkan-only iris case is simply not part of a DirectGLES sweep. A case
|
|
# NAMED on the command line is a request, and silently doing nothing with a
|
|
# request is how an operator concludes an arm was measured when it was not.
|
|
message = (f"{case['name']} does not run {backend}: the case declares "
|
|
f"ci_backends = {', '.join(ci_backends(case))}")
|
|
if case["name"] in selected_names:
|
|
print(message, file=sys.stderr)
|
|
return 2
|
|
print("--- skipping " + message, flush=True)
|
|
continue
|
|
# The golden and threshold this backend is scored against (trace_cases.py's
|
|
# backend_overrides); the case unchanged when it declares none.
|
|
resolved = case_for_backend(case, backend)
|
|
if args.benchmark:
|
|
print(f"=== Android benchmark: {case['name']} / {backend} ===", flush=True)
|
|
# No SSIM verdicts to render here; the summary page is for the correctness lane.
|
|
failures += run_benchmark_case(resolved, backend, args)
|
|
continue
|
|
for repeat in range(1, args.repeat + 1):
|
|
print(f"=== Android retrace: {case['name']} / {backend} "
|
|
f"(repeat {repeat}/{args.repeat}) ===", flush=True)
|
|
extra = list(transport_proof_args(args))
|
|
if repeat > 1:
|
|
# The repeats measure the REPLAY, not the push: re-extracting and
|
|
# re-installing between them would put an installer inside the window and
|
|
# would not change a byte of the input.
|
|
extra.append("--reuse-fixture")
|
|
clear_repeat_outputs(resolved, backend)
|
|
rc = run_case(resolved, backend, extra_args=extra,
|
|
env_overrides=transport_env(args) + list(args.env),
|
|
use_pbuffer=args.use_pbuffer)
|
|
if args.archive_dir:
|
|
try:
|
|
kept = archive_repeat(args.archive_dir, resolved, backend, repeat)
|
|
print(f"archived repeat {repeat} to {kept}", flush=True)
|
|
except OSError as error:
|
|
print(f"failed to archive repeat {repeat}: {error}", file=sys.stderr)
|
|
failures += 1
|
|
if rc not in (0, 2):
|
|
failures += 1
|
|
try:
|
|
render_summary()
|
|
except Exception as error:
|
|
print(f"failed to render summary: {error}", file=sys.stderr)
|
|
failures += 1
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|