Files
swung0x48 ee30726fde [Refactor, Test] (docs/ci, docs/Disaggregated, MG_IntegrationTest, MG_IntegrationTest/Harness, MG_IntegrationTest/Scenarios, MG_Remote/Server, MG_Test/Util, MG_Test/Wire, MG_Util/Metrics, scripts/ci, tools/trace_replay): delete the MOBILEGL_TEST_SERVER_COUNTERS knob
- CtWireScenario reads `ar` / `od` off the server's PipeStats line on every arm
- the serial == resets assertion could not fail and is dropped
- ServerVerbSink's unread BindContexts, Presents and LastPresentSerial go too
2026-10-18 10:02:04 -04:00

269 lines
13 KiB
Python

#!/usr/bin/env python3
# MobileGL - tools/trace_replay/paired_tcp_benchmark.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Reboot-clean, pinned Redmi TCP credit measurements with original-state restore.
This explicitly mutating session is separate from the ordinary benchmark tool.
Run only while this device has no other client. The service/idle exemption is
left available to its owner; CPU/GPU/fan settings are always restored here.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import time
import benchmark_tcp_credits as benchmark
from measure_transport_cpu import CASES, digest
from run_tcp_matrix import atomic_json, supervise
CPU = '/sys/devices/system/cpu/cpufreq'
GPU = '/sys/class/kgsl/kgsl-3d0'
FAN = '/sys/class/xm_power/hw_monitor/pwm_fan'
POLICIES = ('policy0', 'policy6')
RESTORE_NODES = [f'{CPU}/{policy}/{field}' for policy in POLICIES
for field in ('scaling_min_freq', 'scaling_max_freq', 'scaling_governor')]
RESTORE_NODES += [f'{GPU}/min_pwrlevel', f'{GPU}/max_pwrlevel', f'{FAN}/target_level']
READ_NODES = RESTORE_NODES + [f'{CPU}/{policy}/scaling_cur_freq' for policy in POLICIES]
READ_NODES += [f'{GPU}/gpuclk', f'{FAN}/real_speed', f'{FAN}/pwm_duty']
ORDERS = ((1, 2, 3), (2, 3, 1), (3, 1, 2))
class Device:
def __init__(self, serial):
self.adb = ['adb', '-s', serial]
def shell(self, argv, timeout=30):
return subprocess.run([*self.adb, 'shell', shlex.join(argv)], check=True,
capture_output=True, text=True, timeout=timeout).stdout.strip()
def su(self, script):
return self.shell(['su', '-c', script])
def boot_id(self):
return self.shell(['cat', '/proc/sys/kernel/random/boot_id'])
def observe(self):
statements = [f'printf "%s=%s\\n" {shlex.quote(node)} "$(cat {shlex.quote(node)})"'
for node in READ_NODES]
statements.append('for node in /sys/class/thermal/thermal_zone*; do '
'if [ "$(cat "$node/type")" = cpuss-0-0 ]; then '
'printf "thermal_path=%s\\nthermal_mc=%s\\n" "$node" "$(cat "$node/temp")"; '
'break; fi; done')
values = dict(line.split('=', 1) for line in self.su('; '.join(statements)).splitlines() if '=' in line)
if any(not values.get(node) for node in READ_NODES) or not values.get('thermal_mc'):
raise RuntimeError('incomplete device state; cannot establish or restore the thermal window')
for node, value in values.items():
if node.endswith('scaling_governor'):
if not re.fullmatch(r'[A-Za-z0-9_-]+', value):
raise ValueError('invalid governor observation')
elif node != 'thermal_path':
int(value)
return {'time_ns': time.time_ns(), **values}
def restore(self, original):
statements = []
for policy in POLICIES:
base = f'{CPU}/{policy}'
statements += [f'cat {base}/cpuinfo_min_freq > {base}/scaling_min_freq',
f'echo {int(original[base + "/scaling_max_freq"])} > {base}/scaling_max_freq',
f'echo {int(original[base + "/scaling_min_freq"])} > {base}/scaling_min_freq',
f'echo {shlex.quote(original[base + "/scaling_governor"])} > {base}/scaling_governor']
statements += [f'echo {int(original[node])} > {node}' for node in RESTORE_NODES if node.startswith((GPU, FAN))]
self.su('; '.join(statements))
after = self.observe()
differences = {node: {'expected': original[node], 'actual': after[node]}
for node in RESTORE_NODES if original[node] != after[node]}
return {'after': after, 'differences': differences, 'restored': not differences}
def wait_for_boot(device, old_boot_id):
subprocess.run([*device.adb, 'reboot'], check=True, timeout=30)
deadline = time.monotonic() + 300
while time.monotonic() < deadline:
try:
if device.shell(['getprop', 'sys.boot_completed']) == '1' and device.boot_id() != old_boot_id:
time.sleep(20)
return device.boot_id()
except (OSError, subprocess.SubprocessError):
pass
time.sleep(5)
raise RuntimeError('reboot did not establish a changed, fully booted device')
def pin(args, action, output):
result = subprocess.run(['bash', str(args.pin_copy), args.serial, action],
capture_output=True, text=True, timeout=30)
output.write_text(result.stdout + result.stderr, encoding='utf-8')
return result.returncode
def prepare_arm(args, device, directory):
directory.mkdir(parents=True)
if pin(args, 'pin', directory / 'pin-request.txt') != 0:
raise RuntimeError('pin did not take')
readings, consecutive = [], 0
deadline = time.monotonic() + args.cool_timeout
while time.monotonic() < deadline:
observation = device.observe()
readings.append(observation)
atomic_json(directory / 'cooldown.json', readings)
if int(observation['thermal_mc']) <= args.cool_max_mc:
consecutive += 1
if consecutive == 2:
break
else:
consecutive = 0
time.sleep(5)
if consecutive < 2:
raise RuntimeError('cooldown expired; no valid measurement window')
if pin(args, 'check', directory / 'pin-before.txt') != 0:
raise RuntimeError('frequency drift before measurement')
if int(readings[-1][FAN + '/target_level']) != 2 or int(readings[-1][FAN + '/real_speed']) <= 0:
raise RuntimeError('fan setting was not actually established')
return readings[-1]
def start_server(args, device):
script = Path(__file__).with_name('tcp_device_server.py')
command = [sys.executable, str(script), 'start', '--serial', args.serial,
'--package', args.package, '--listen', args.listen, '--token', args.token,
'--allow-idle', '--state-file', str(args.server_idle_state), '--env',
'MOBILEGL_PIPE_STATS=1;MOBILEGL_PIPE_STATS_PERIOD=1;'
'MOBILEGL_LOG_FILE_PATH=/data/data/' + args.package + '/files/mgl.log']
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=30)
(args.out / 'server-start.txt').write_text(result.stdout + result.stderr, encoding='utf-8')
port = args.endpoint.rsplit(':', 1)[-1]
for _ in range(20):
sockets = device.shell(['ss', '-ltn'])
if re.search(r':' + re.escape(port) + r'\s', sockets):
return
time.sleep(1)
raise RuntimeError('restarted supervisor is not listening')
def run_session(args):
device = Device(args.serial)
original = device.observe()
session = {'status': 'prepared', 'serial': args.serial, 'endpoint': args.endpoint,
'library_sha256': digest(args.library), 'runner_sha256': digest(args.runner),
'pin_script_sha256': digest(args.pin_script), 'original_state': original,
'boot_id_before': device.boot_id(), 'credit_orders': ORDERS,
'cool_max_mc': args.cool_max_mc, 'arms': [], 'started_at_ns': time.time_ns()}
state_path = args.out / 'session.json'
atomic_json(state_path, session) # Durable before the first mutation, including reboot.
args.pin_copy = args.out / 'pin_device.sh'
args.pin_copy.write_text(args.pin_script.read_text(), encoding='utf-8', newline='\n')
try:
session['boot_id'] = wait_for_boot(device, session['boot_id_before'])
session['rebooted_clean'] = True
device.su(f'echo 2 > {FAN}/target_level')
time.sleep(3)
host = args.endpoint.removeprefix('tcp://').rsplit(':', 1)[0]
wlan = device.shell(['ip', '-f', 'inet', 'addr', 'show', 'wlan0'])
session['wlan_after_reboot'] = wlan
if host not in wlan:
raise RuntimeError('device address changed; resolve host routing before a new performance session')
for _ in range(60):
names = device.shell(['ps', '-A', '-o', 'NAME'])
if not any('dex2oat' in name for name in names.splitlines()):
break
time.sleep(5)
else:
raise RuntimeError('dex2oat did not become idle')
start_server(args, device)
session['status'] = 'running'
atomic_json(state_path, session)
index = 0
for case in args.case or CASES:
for repeat, order in enumerate(ORDERS, 1):
for credit in order:
index += 1
directory = args.out / f'{index:02d}-{case}-credit{credit}-r{repeat}'
row = {'index': index, 'case': case, 'credit': credit, 'repeat': repeat, 'status': 'preparing'}
session['arms'].append(row)
atomic_json(state_path, session)
print(json.dumps(row), flush=True)
row['before'] = prepare_arm(args, device, directory)
try:
row.update(benchmark.measure(args, case, credit, directory / 'measurement'))
finally:
row['pin_after_rc'] = pin(args, 'check', directory / 'pin-after.txt')
row['after'] = device.observe()
atomic_json(state_path, session)
if row['status'] != 'passed' or row['pin_after_rc'] != 0:
row['status'] = 'invalid'
raise RuntimeError(f'arm {index} is invalid; preserve it and stop')
print(json.dumps({key: row.get(key) for key in ('index', 'case', 'credit', 'status', 'fps')}), flush=True)
directory = args.out / 'stage-burst'
burst = {'before': prepare_arm(args, device, directory)}
session['stage_burst'] = burst
script = Path(__file__).with_name('measure_tcp_stage_burst.py')
command = [sys.executable, str(script), '--library', str(args.library), '--endpoint', args.endpoint,
'--token', args.token, '--out', str(directory / 'measurement')]
def wake():
device.shell(['input', 'keyevent', 'KEYCODE_WAKEUP'])
try:
burst.update(supervise(command, dict(os.environ), str(args.inputs), directory,
directory / 'runner.log', wake=wake))
if burst['returncode'] != 0 or burst['remaining_processes'] or burst['leftovers_after_parent_exit']:
raise RuntimeError('Stage burst failed')
burst['measurement'] = json.loads((directory / 'measurement/result.json').read_text())
finally:
burst['pin_after_rc'] = pin(args, 'check', directory / 'pin-after.txt')
burst['after'] = device.observe()
if burst['pin_after_rc'] != 0:
raise RuntimeError('Stage burst pin drifted')
session['status'] = 'passed'
except BaseException as error:
session.update(status='failed', error=repr(error))
raise
finally:
try:
session['restore'] = device.restore(original)
if not session['restore']['restored']:
session['status'] = 'restore-failed'
except BaseException as error:
session.update(status='restore-failed', restore_error=repr(error))
session['completed_at_ns'] = time.time_ns()
atomic_json(state_path, session)
return 0 if session['status'] == 'passed' else 1
def main():
parser = argparse.ArgumentParser(description=__doc__)
for name in ('runner', 'library', 'inputs', 'out', 'pin-script', 'server-idle-state'):
parser.add_argument('--' + name, required=True, type=Path)
parser.add_argument('--serial', required=True, choices=('2f7cbe2e',), help='profile validated for this Redmi only')
parser.add_argument('--endpoint', required=True)
parser.add_argument('--listen', required=True)
parser.add_argument('--token', default=os.environ.get('MOBILEGL_IPC_TOKEN', ''))
parser.add_argument('--package', default='top.mobilegl.plugin.trace')
parser.add_argument('--cool-max-mc', type=int, default=36000)
parser.add_argument('--cool-timeout', type=int, default=300)
parser.add_argument('--case', action='append', choices=CASES)
parser.add_argument('--reboot', action='store_true', required=True)
args = parser.parse_args()
args.adb = 'adb'
if args.out.exists():
parser.error('choose a new output directory; paired sessions never resume across boots')
if not args.endpoint.startswith('tcp://') or args.cool_timeout <= 0:
parser.error('TCP endpoint and a positive cooldown timeout are required')
args.out.mkdir(parents=True)
return run_session(args)
if __name__ == '__main__':
raise SystemExit(main())