386 lines
16 KiB
Python
Executable File
386 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Repeatable Playback Lab emulator benchmark runner.
|
|
|
|
Only uses the standard library and the OpenHarmony hdc/uitest command line.
|
|
Metrics unavailable from the emulator are emitted as null, never inferred.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
PACKAGE = "com.velix.playbacklab"
|
|
DEFAULT_SERIAL = "127.0.0.1:5555"
|
|
DEFAULT_HDC = "/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc"
|
|
|
|
|
|
def command(argv: list[str], timeout: float = 30.0) -> str:
|
|
result = subprocess.run(argv, check=False, capture_output=True, text=True, timeout=timeout)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"command failed ({result.returncode}): {shlex.join(argv)}\n{result.stderr.strip()}")
|
|
return result.stdout.strip()
|
|
|
|
|
|
class Emulator:
|
|
def __init__(self, serial: str, hdc: str) -> None:
|
|
self.serial = serial
|
|
self.hdc = hdc
|
|
self.layout_counter = 0
|
|
|
|
def hdc_shell(self, text: str, timeout: float = 30.0) -> str:
|
|
return command([self.hdc, "-t", self.serial, "shell", text], timeout)
|
|
|
|
def click(self, x: int, y: int) -> None:
|
|
self.hdc_shell(f"uitest uiInput click {x} {y}", timeout=10)
|
|
|
|
def layout(self) -> dict[str, Any]:
|
|
self.layout_counter += 1
|
|
marker = self.hdc_shell(f"uitest dumpLayout -b {PACKAGE} -m true -d 0", timeout=10)
|
|
match = re.search(r"saved to:(\S+)", marker)
|
|
if not match:
|
|
raise RuntimeError(f"layout dump did not return a path: {marker}")
|
|
remote = match.group(1)
|
|
local = Path(tempfile.gettempdir()) / f"velix-layout-{os.getpid()}-{self.layout_counter}.json"
|
|
command([self.hdc, "-t", self.serial, "file", "recv", remote, str(local)], timeout=10)
|
|
with local.open(encoding="utf-8") as stream:
|
|
return json.load(stream)
|
|
|
|
def pid(self) -> int | None:
|
|
output = self.hdc_shell(f"pidof {PACKAGE}", timeout=10)
|
|
match = re.search(r"\b(\d+)\b", output)
|
|
return int(match.group(1)) if match else None
|
|
|
|
def process_sample(self) -> dict[str, Any]:
|
|
pid = self.pid()
|
|
sample: dict[str, Any] = {"pid": pid, "rssKb": None, "pssKb": None, "heapKb": None,
|
|
"virtualMemoryKb": None,
|
|
"threadCount": None, "fdCount": None, "cpuPermille": None,
|
|
"cpuSupported": False}
|
|
if pid is None:
|
|
return sample
|
|
status = self.hdc_shell(f"cat /proc/{pid}/status", timeout=10)
|
|
for key, field in (("rssKb", "VmRSS"), ("virtualMemoryKb", "VmSize"), ("threadCount", "Threads")):
|
|
match = re.search(rf"^{field}:\s+(\d+)", status, re.MULTILINE)
|
|
if match:
|
|
sample[key] = int(match.group(1))
|
|
fd_output = self.hdc_shell(f"ls /proc/{pid}/fd 2>&1 | wc -l", timeout=10)
|
|
if fd_output.isdigit() and fd_output != "0":
|
|
sample["fdCount"] = int(fd_output)
|
|
stat = self.hdc_shell(f"cat /proc/{pid}/stat", timeout=10)
|
|
match = re.match(r"^\d+ \(.+\) (.*)$", stat)
|
|
if match:
|
|
fields = match.group(1).split()
|
|
if len(fields) > 12:
|
|
sample["cpuJiffies"] = int(fields[11]) + int(fields[12])
|
|
return sample
|
|
|
|
def product_version(self) -> str | None:
|
|
try:
|
|
value = self.hdc_shell("param get const.product.software.version", timeout=10)
|
|
except RuntimeError:
|
|
return None
|
|
return value or None
|
|
|
|
|
|
def walk_nodes(node: dict[str, Any]) -> Iterable[dict[str, Any]]:
|
|
yield node
|
|
for child in node.get("children", []):
|
|
if isinstance(child, dict):
|
|
yield from walk_nodes(child)
|
|
|
|
|
|
def node_text(node: dict[str, Any]) -> str:
|
|
attrs = node.get("attributes", {})
|
|
return str(attrs.get("text") or attrs.get("originalText") or "")
|
|
|
|
|
|
def find_node(layout: dict[str, Any], text: str) -> dict[str, Any] | None:
|
|
for node in walk_nodes(layout):
|
|
if node_text(node) == text:
|
|
return node
|
|
return None
|
|
|
|
|
|
def center(bounds: str) -> tuple[int, int]:
|
|
values = [int(value) for value in re.findall(r"\d+", bounds)]
|
|
if len(values) != 4:
|
|
raise ValueError(f"invalid bounds: {bounds}")
|
|
return (values[0] + values[2]) // 2, (values[1] + values[3]) // 2
|
|
|
|
|
|
def click_text(emulator: Emulator, text: str) -> None:
|
|
node = find_node(emulator.layout(), text)
|
|
if node is None:
|
|
raise RuntimeError(f"UI node not found: {text}")
|
|
emulator.click(*center(node["attributes"]["bounds"]))
|
|
|
|
|
|
def all_texts(layout: dict[str, Any]) -> list[str]:
|
|
return [node_text(node) for node in walk_nodes(layout) if node_text(node)]
|
|
|
|
|
|
def text_matching(layout: dict[str, Any], prefix: str) -> str | None:
|
|
for value in all_texts(layout):
|
|
if value.startswith(prefix):
|
|
return value
|
|
return None
|
|
|
|
|
|
def parse_int(text: str | None, pattern: str) -> int | None:
|
|
if not text:
|
|
return None
|
|
match = re.search(pattern, text)
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def parse_state(layout: dict[str, Any]) -> str | None:
|
|
value = text_matching(layout, "state: ")
|
|
return value.split(": ", 1)[1] if value and ": " in value else None
|
|
|
|
|
|
def parse_run(layout: dict[str, Any]) -> dict[str, Any]:
|
|
startup = text_matching(layout, "startup=") or ""
|
|
buffer_line = text_matching(layout, "buffer enter=") or ""
|
|
napi_line = text_matching(layout, "napi calls=") or ""
|
|
render_line = text_matching(layout, "render updates=") or napi_line
|
|
error = text_matching(layout, "error=") or ""
|
|
timeline: dict[str, Any] = {}
|
|
for key, value in re.findall(r"(startup|firstSwap|seekRecovery|audioStart|audioCallback)=(-?\d+)", startup):
|
|
timeline[{"startup": "startupTotalMs", "firstSwap": "firstSwapMs", "seekRecovery": "seekRecoveryMs",
|
|
"audioStart": "audioRendererStartMs", "audioCallback": "firstAudioCallbackMs"}[key]] = int(value)
|
|
first_frame = parse_int(text_matching(layout, "dropped="), r"firstFrameMs=(-?\d+)")
|
|
if first_frame is None or first_frame < 0:
|
|
first_frame = timeline.get("firstSwapMs")
|
|
if first_frame is not None and first_frame < 0:
|
|
first_frame = None
|
|
startup_ms = timeline.get("startupTotalMs")
|
|
first_swap_ms = timeline.get("firstSwapMs")
|
|
seek_recovery_ms = timeline.get("seekRecoveryMs")
|
|
audio_start_ms = timeline.get("audioRendererStartMs")
|
|
audio_callback_ms = timeline.get("firstAudioCallbackMs")
|
|
if startup_ms is not None and startup_ms < 0:
|
|
startup_ms = None
|
|
if first_swap_ms is not None and first_swap_ms < 0:
|
|
first_swap_ms = None
|
|
if seek_recovery_ms is not None and seek_recovery_ms < 0:
|
|
seek_recovery_ms = None
|
|
if audio_start_ms is not None and audio_start_ms < 0:
|
|
audio_start_ms = None
|
|
if audio_callback_ms is not None and audio_callback_ms < 0:
|
|
audio_callback_ms = None
|
|
return {
|
|
"state": parse_state(layout),
|
|
"firstFrameMs": first_frame,
|
|
"renderCount": parse_int(text_matching(layout, "dropped="), r"renderCount=(-?\d+)"),
|
|
"startupTotalMs": startup_ms,
|
|
"firstSwapMs": first_swap_ms,
|
|
"seekRecoveryMs": seek_recovery_ms,
|
|
"audioRendererStartMs": audio_start_ms,
|
|
"firstAudioCallbackMs": audio_callback_ms,
|
|
"bufferingEnterCount": parse_int(buffer_line, r"buffer enter=(-?\d+)"),
|
|
"bufferingExitCount": parse_int(buffer_line, r"exit=(-?\d+)"),
|
|
"napiCalls": parse_int(napi_line, r"napi calls=(-?\d+)"),
|
|
"renderUpdateCalls": parse_int(render_line, r"updates=(-?\d+)/"),
|
|
"renderFrameUpdates": parse_int(render_line, r"updates=-?\d+/(-?\d+)"),
|
|
"coreSyncCalls": parse_int(render_line, r"sync=(-?\d+)/"),
|
|
"coreSyncSkipped": parse_int(render_line, r"sync=-?\d+/(-?\d+)"),
|
|
"error": error,
|
|
}
|
|
|
|
|
|
def wait_for_first_frame(emulator: Emulator, timeout: float = 10.0) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
deadline = time.monotonic() + timeout
|
|
samples: list[dict[str, Any]] = []
|
|
latest: dict[str, Any] = {}
|
|
next_sample = 0.0
|
|
while time.monotonic() < deadline:
|
|
latest = emulator.layout()
|
|
now = time.monotonic()
|
|
if now >= next_sample:
|
|
samples.append({"atMs": int((timeout - max(0.0, deadline - now)) * 1000),
|
|
**emulator.process_sample()})
|
|
next_sample = now + 1.0
|
|
run = parse_run(latest)
|
|
if run.get("startupTotalMs") is not None or run.get("state") == "ERROR":
|
|
return latest, samples
|
|
time.sleep(0.15)
|
|
raise TimeoutError(f"playback did not expose first-frame timeline: {parse_run(latest)}")
|
|
|
|
|
|
def stats(values: list[float | int]) -> dict[str, float | int | None]:
|
|
numbers = sorted(float(value) for value in values if value is not None and math.isfinite(float(value)))
|
|
if not numbers:
|
|
return {"count": 0, "min": None, "max": None, "avg": None, "median": None, "p90": None, "p95": None}
|
|
def percentile(fraction: float) -> float:
|
|
return numbers[max(0, min(len(numbers) - 1, math.ceil(fraction * len(numbers)) - 1))]
|
|
return {"count": len(numbers), "min": numbers[0], "max": numbers[-1],
|
|
"avg": sum(numbers) / len(numbers), "median": percentile(0.5),
|
|
"p90": percentile(0.9), "p95": percentile(0.95)}
|
|
|
|
|
|
def git_revision(path: Path) -> str | None:
|
|
try:
|
|
return command(["git", "-C", str(path), "rev-parse", "HEAD"], timeout=10)
|
|
except RuntimeError:
|
|
return None
|
|
|
|
|
|
def load_local(emulator: Emulator) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
click_text(emulator, "Load Local Test")
|
|
return wait_for_first_frame(emulator)
|
|
|
|
|
|
def startup_local(emulator: Emulator, repeat: int) -> list[dict[str, Any]]:
|
|
runs: list[dict[str, Any]] = []
|
|
for index in range(repeat):
|
|
began = time.monotonic()
|
|
click_text(emulator, "Load Local Test")
|
|
layout, samples = wait_for_first_frame(emulator)
|
|
run = parse_run(layout)
|
|
run.update({"runIndex": index + 1, "cacheClass": "cold-ish" if index == 0 else "warm",
|
|
"hostFirstFrameMs": int((time.monotonic() - began) * 1000),
|
|
"processSamples": samples})
|
|
runs.append(run)
|
|
return runs
|
|
|
|
|
|
def wait_for_seek_recovery(emulator: Emulator, timeout: float = 5.0) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
deadline = time.monotonic() + timeout
|
|
samples: list[dict[str, Any]] = []
|
|
latest: dict[str, Any] = {}
|
|
next_sample = 0.0
|
|
while time.monotonic() < deadline:
|
|
latest = emulator.layout()
|
|
now = time.monotonic()
|
|
if now >= next_sample:
|
|
samples.append({"atMs": int((timeout - max(0.0, deadline - now)) * 1000),
|
|
**emulator.process_sample()})
|
|
next_sample = now + 1.0
|
|
run = parse_run(latest)
|
|
if run.get("seekRecoveryMs") is not None and run.get("seekRecoveryMs") >= 0:
|
|
return latest, samples
|
|
if run.get("state") == "ERROR":
|
|
return latest, samples
|
|
time.sleep(0.1)
|
|
raise TimeoutError(f"seek did not expose recovery timeline: {parse_run(latest)}")
|
|
|
|
|
|
def seek_local(emulator: Emulator, repeat: int) -> list[dict[str, Any]]:
|
|
runs: list[dict[str, Any]] = []
|
|
for index in range(repeat):
|
|
click_text(emulator, "Load Local Test")
|
|
# The bundled clip is intentionally short and may finish before a
|
|
# multi-command text-input interaction completes. The native +/-10s
|
|
# action exercises the same seek API without that race.
|
|
time.sleep(0.05)
|
|
click_text(emulator, "+10s")
|
|
latest, samples = wait_for_seek_recovery(emulator)
|
|
run = parse_run(latest)
|
|
run.update({"runIndex": index + 1, "mode": "relative", "offsetSeconds": 10,
|
|
"processSamples": samples})
|
|
runs.append(run)
|
|
return runs
|
|
|
|
|
|
def stress_lifecycle(emulator: Emulator, repeat: int) -> list[dict[str, Any]]:
|
|
initial = emulator.layout()
|
|
load_center = center(find_node(initial, "Load Local Test")["attributes"]["bounds"])
|
|
stop_center = center(find_node(initial, "Stop")["attributes"]["bounds"])
|
|
runs: list[dict[str, Any]] = []
|
|
for index in range(repeat):
|
|
emulator.click(*load_center)
|
|
time.sleep(0.08)
|
|
emulator.click(*stop_center)
|
|
time.sleep(0.08)
|
|
layout = emulator.layout()
|
|
run = parse_run(layout)
|
|
run.update({"runIndex": index + 1, "processSample": emulator.process_sample()})
|
|
runs.append(run)
|
|
return runs
|
|
|
|
|
|
def play_pause(emulator: Emulator, repeat: int) -> list[dict[str, Any]]:
|
|
load_local(emulator)
|
|
initial = emulator.layout()
|
|
play_center = center(find_node(initial, "Play")["attributes"]["bounds"])
|
|
pause_center = center(find_node(initial, "Pause")["attributes"]["bounds"])
|
|
runs: list[dict[str, Any]] = []
|
|
for index in range(repeat):
|
|
emulator.click(*play_center)
|
|
emulator.click(*pause_center)
|
|
if index % 10 == 0:
|
|
time.sleep(0.05)
|
|
run = {"runIndex": index + 1, "processSample": emulator.process_sample()}
|
|
if index % 10 == 0 or index + 1 == repeat:
|
|
run.update(parse_run(emulator.layout()))
|
|
runs.append(run)
|
|
return runs
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("scenario", choices=["startup_local", "seek_local", "stress_lifecycle", "play_pause"])
|
|
parser.add_argument("--repeat", type=int, default=None)
|
|
parser.add_argument("--serial", default=os.environ.get("DEVECO_DEVICE", DEFAULT_SERIAL))
|
|
parser.add_argument("--hdc", default=os.environ.get("HDC", DEFAULT_HDC))
|
|
parser.add_argument("--output", type=Path)
|
|
args = parser.parse_args()
|
|
defaults = {"startup_local": 20, "seek_local": 50, "stress_lifecycle": 100, "play_pause": 200}
|
|
repeat = args.repeat if args.repeat is not None else defaults[args.scenario]
|
|
emulator = Emulator(args.serial, args.hdc)
|
|
if args.scenario == "startup_local":
|
|
runs = startup_local(emulator, repeat)
|
|
elif args.scenario == "seek_local":
|
|
runs = seek_local(emulator, repeat)
|
|
elif args.scenario == "stress_lifecycle":
|
|
runs = stress_lifecycle(emulator, repeat)
|
|
else:
|
|
runs = play_pause(emulator, repeat)
|
|
metric_names = ["firstFrameMs", "startupTotalMs", "firstSwapMs", "seekRecoveryMs", "audioRendererStartMs",
|
|
"firstAudioCallbackMs", "bufferingEnterCount", "bufferingExitCount", "hostFirstFrameMs", "hostTerminalMs"]
|
|
output = {
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"gitCommit": git_revision(ROOT),
|
|
"mpvCommit": git_revision(ROOT),
|
|
"ffmpegCommit": git_revision(ROOT.parent / "Velix-FFmpeg-Harmony"),
|
|
"deviceType": "emulator",
|
|
"deviceSerial": args.serial,
|
|
"harmonyVersion": emulator.product_version(),
|
|
"testScenario": args.scenario,
|
|
"mediaId": "sample.mp4",
|
|
"protocol": "file",
|
|
"codec": "H.264/AAC",
|
|
"resolution": None,
|
|
"audio": "ao_harmony",
|
|
"subtitle": "none",
|
|
"hwdec": "software",
|
|
"repeat": repeat,
|
|
"metrics": {name: stats([run.get(name) for run in runs]) for name in metric_names},
|
|
"runs": runs,
|
|
"errors": [run.get("error") for run in runs if run.get("error") and "error=NONE" not in run.get("error", "")],
|
|
"sampling": {"pss": "unsupported unless exposed by device", "cpu": "raw /proc stat only when available"},
|
|
}
|
|
output_path = args.output or ROOT / "benchmark" / "results" / f"{args.scenario}-{int(time.time())}.json"
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(json.dumps(output, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(output_path)
|
|
print(json.dumps(output["metrics"], ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|