Files
MobileGL/tools/device_bench/bench.sh
T
swung0x48 b1c37699b1 [Fix] (Bench, Trace, CI): let only the profile answer for itself, name an unreadable profile, and describe the CI step by the mechanism the tree has
- The verified-profile guard read the process environment as well as the profile: the test ran
  after the source, so PROFILE_VERIFIED=1 exported in an operator's shell re-opened the fail-open
  hole for every profile that says nothing. Both scripts now set PROFILE_VERIFIED=0 immediately
  before sourcing, so the file is the only thing that can answer.
- A --device path that cannot be sourced was diagnosed as an unverified profile, because both
  scripts cd to their own directory first and neither checked readability. The path is now also
  tried relative to the directory the script was invoked from (which is what a repo-root-relative
  --device means), and an unreadable one is reported as unreadable, naming both places tried.
- Verified: exported PROFILE_VERIFIED=1 + an unverified profile -> rc 2; exported 1 + a profile
  with no key -> rc 2; a repo-root-relative path -> resolved, then refused for its own reason;
  a missing file -> "cannot read the device profile"; odinlite.env -> past the guard;
  --allow-unverified-profile -> the three warnings, then proceeds.
- test.yml's new step described a mechanism the tree does not have. G6's and G10's entries are
  registered in the pull build too - they must be, for G2's name-for-name comparison - and skip
  inside their bodies. The step's value is unchanged and its comment now says the true thing: the
  `test` job runs those names as a column of skips, and this is the first CI job that unpacks a
  build which compiled the assertions.
- trace_benchmark takes the wall baseline before the CPU baseline, the order OnFrameBoundary
  already reads them in, so frame 0 stops reporting a CPU delta biased upward against its own
  wall delta; and it includes <time.h> rather than <ctime> for the POSIX names it uses.
2026-09-07 23:18:10 -04:00

270 lines
12 KiB
Bash

#!/usr/bin/env bash
# In-game FPS benchmark for MobileGL on a real device, driven through FCL.
#
# Prerequisites (one-time, manual):
# - FCL fordebug flavor installed (com.tungsten.fcl.mgdebug.debug); its splash
# screen auto-launches the selected profile/version into the prepared world.
# - FCL in-game menu "show FPS" toggle enabled (menu_setting.json showFps=true):
# the FPS overlay thread logs one "FCLFPS: <n>" logcat line per second.
# - The benchmark world saved with a deterministic state (mob spawning /
# daylight cycle / weather gamerules off) and the desired options.txt
# (renderDistance, vsync off, maxFps high).
# - Rooted device (frequency pinning + GPU utilization sampling).
#
# Usage:
# bench.sh --device devices/odinlite.env --backend magma [--samples 30]
# [--warmup 180] [--label mylabel] [--no-pin]
# [--allow-unverified-profile]
# backend: magma | espryt | mobileglues (reference)
#
# Output: one JSON line on stdout (also appended to results/results.jsonl) with
# mean/median/min/max FPS, GPU busy%, temperatures, and pin-integrity flags.
# Screenshots (pre/post measurement) land in results/<timestamp>-<label>/.
set -u -o pipefail
# Remembered BEFORE the cd, so a --device path written relative to the caller's directory (the
# repo root, most of the time) still resolves. Without it, `tools/device_bench/bench.sh --device
# tools/device_bench/devices/odinlite.env` from the repo root sourced nothing and then blamed the
# profile for not being verified - a true refusal for a false reason.
INVOKED_FROM=$PWD
cd "$(dirname "$0")"
# Git Bash: stop MSYS from rewriting /sys/... arguments into C:/Program Files/...
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
PKG=com.tungsten.fcl.mgdebug.debug
ACTIVITY=$PKG/com.tungsten.fcl.activity.SplashActivity
RENDERER_ESPRYT=5e273ee2-baca-4c81-8e48-b63feefb9ba8
RENDERER_MAGMA=2be0dc10-1eef-4ce2-b512-b266dd33fd9e
RENDERER_MOBILEGLUES=com.fcl.plugin.mobileglues
DEVICE_ENV=""
BACKEND=""
SAMPLES=30
WARMUP=180
LABEL=""
DO_PIN=1
ALLOW_UNVERIFIED_PROFILE=0
WORLD_LOAD_TIMEOUT=420
while [ $# -gt 0 ]; do
case "$1" in
--device) DEVICE_ENV=$2; shift 2 ;;
--backend) BACKEND=$2; shift 2 ;;
--samples) SAMPLES=$2; shift 2 ;;
--warmup) WARMUP=$2; shift 2 ;;
--label) LABEL=$2; shift 2 ;;
--no-pin) DO_PIN=0; shift ;;
--allow-unverified-profile) ALLOW_UNVERIFIED_PROFILE=1; shift ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$DEVICE_ENV" ] && [ -n "$BACKEND" ] || { echo "need --device and --backend" >&2; exit 2; }
case "$DEVICE_ENV" in
/*) ;;
*) [ -r "$DEVICE_ENV" ] || [ ! -r "$INVOKED_FROM/$DEVICE_ENV" ] || DEVICE_ENV="$INVOKED_FROM/$DEVICE_ENV" ;;
esac
[ -r "$DEVICE_ENV" ] || {
echo "cannot read the device profile: $DEVICE_ENV" >&2
echo "(tried it relative to $(pwd) and to $INVOKED_FROM)" >&2
exit 2
}
# The profile, and ONLY the profile, gets to say whether it has been verified. This is set to the
# refusing value BEFORE the source, so a PROFILE_VERIFIED=1 left exported in the operator's shell
# cannot answer for a profile that says nothing - which would be the same fail-open hole the
# guard below closes, entered through the environment instead of through the file.
PROFILE_VERIFIED=0
# shellcheck disable=SC1090
. "$DEVICE_ENV"
# A device profile that has not been read off its device yet is refused here rather than acted
# on. The failure it prevents is silent and expensive: the pin path below is MediaTek-specific
# (/proc/ppm, /proc/gpufreq), `su -c 'echo ... > /proc/...'` fails without a non-zero exit, and a
# run against a profile whose nodes do not exist reports numbers it believes were taken under a
# frequency pin. The pin-integrity fields sampled at window end are the only clue, and they are
# read after the run rather than before it.
#
# PROFILE_VERIFIED=1 means: somebody read the cpufreq policies, the GPU OPP and the thermal zone
# TYPE off THIS device, ran one pinned window, and checked big_cur/little_cur/gpu_cur_khz in the
# result JSON against the pins. Nothing else earns it.
require_verified_profile() {
# The default is UNVERIFIED. A profile that simply omits the key is a profile nobody has
# confirmed against its device, and defaulting it to "verified" would hand exactly the
# fail-open behaviour this guard exists to prevent to the most likely way a new profile is
# written - by copying an existing one and editing the serial. The variable is reset to 0
# immediately before the profile is sourced, so this test reads the FILE and not the shell.
if [ "${PROFILE_VERIFIED:-0}" = "1" ]; then return 0; fi
if [ "$ALLOW_UNVERIFIED_PROFILE" = "1" ]; then
echo "[warn] $DEVICE_ENV does not carry PROFILE_VERIFIED=1 and --allow-unverified-profile was passed:" >&2
echo "[warn] the frequency pins and the thermal gate in it are UNCONFIRMED, so any number this" >&2
echo "[warn] run produces is not comparable with a pinned one." >&2
return 0
fi
echo "$DEVICE_ENV does not carry PROFILE_VERIFIED=1 (it says 0, or says nothing at all): its" >&2
echo "sysfs nodes and OPPs have not been read off the device, so pinning would fail silently" >&2
echo "and the run would look pinned but not be." >&2
echo "Fill in the TODO_VERIFY_ON_DEVICE fields, confirm one pinned window, set PROFILE_VERIFIED=1 -" >&2
echo "or pass --allow-unverified-profile to measure anyway and label the result unpinned." >&2
exit 2
}
require_verified_profile
case "$BACKEND" in
espryt) RENDERER=$RENDERER_ESPRYT ;;
magma) RENDERER=$RENDERER_MAGMA ;;
mobileglues) RENDERER=$RENDERER_MOBILEGLUES ;;
*) echo "unknown backend: $BACKEND" >&2; exit 2 ;;
esac
ADB="adb -s $DEVICE_SERIAL"
STAMP=$(date +%Y%m%d-%H%M%S)
RUNLABEL="${STAMP}-${BACKEND}${LABEL:+-$LABEL}"
OUTDIR="results/$RUNLABEL"
mkdir -p "$OUTDIR"
log() { echo "[bench] $*" >&2; }
# Quote the whole su invocation for the DEVICE shell, or redirects run unprivileged.
sushell() { $ADB shell "su -c '$*'"; }
read_temp() {
$ADB shell "for tz in /sys/class/thermal/thermal_zone*; do
if [ \"\$(cat \$tz/type)\" = \"$THERMAL_ZONE_TYPE\" ]; then cat \$tz/temp; break; fi; done" | tr -d '\r'
}
# MTK: plain cpufreq sysfs writes are reverted by the vendor boost/PowerHAL within
# seconds — pin through ppm hard_userlimit instead (cluster indices: 0=little, 1=big).
pin_freqs() {
log "pinning CPU big=$CPU_BIG_FREQ little=$CPU_LITTLE_FREQ gpu=${GPU_PIN_KHZ}kHz (ppm)"
sushell "echo 1 $CPU_BIG_FREQ > /proc/ppm/policy/hard_userlimit_max_cpu_freq;
echo 1 $CPU_BIG_FREQ > /proc/ppm/policy/hard_userlimit_min_cpu_freq;
echo 0 $CPU_LITTLE_FREQ > /proc/ppm/policy/hard_userlimit_max_cpu_freq;
echo 0 $CPU_LITTLE_FREQ > /proc/ppm/policy/hard_userlimit_min_cpu_freq" >/dev/null
sushell "echo $GPU_PIN_KHZ > /proc/gpufreq/gpufreq_opp_freq" >/dev/null
}
unpin_freqs() {
log "unpinning frequencies (restore DVFS)"
sushell "echo 1 -1 > /proc/ppm/policy/hard_userlimit_max_cpu_freq;
echo 1 -1 > /proc/ppm/policy/hard_userlimit_min_cpu_freq;
echo 0 -1 > /proc/ppm/policy/hard_userlimit_max_cpu_freq;
echo 0 -1 > /proc/ppm/policy/hard_userlimit_min_cpu_freq" >/dev/null
sushell "echo 0 > /proc/gpufreq/gpufreq_opp_freq" >/dev/null
}
cleanup() {
$ADB shell am force-stop $PKG >/dev/null 2>&1
[ "$DO_PIN" = 1 ] && unpin_freqs
$ADB shell svc power stayon false >/dev/null 2>&1
}
trap cleanup EXIT
# --- 1. Wake, unlock, keep screen on, fan to sport ---------------------------
$ADB shell input keyevent KEYCODE_WAKEUP >/dev/null
$ADB shell input keyevent 82 >/dev/null
$ADB shell svc power stayon true >/dev/null
$ADB shell settings put global fan_mode 3 2>/dev/null
wakefulness=$($ADB shell dumpsys power | grep -o 'mWakefulness=[A-Za-z]*' | head -1)
log "screen: $wakefulness"
# --- 2. Thermal gate ----------------------------------------------------------
log "thermal gate: waiting for $THERMAL_ZONE_TYPE <= $THERMAL_START_MAX_MC"
for i in $(seq 1 60); do
T=$(read_temp)
[ "$T" -le "$THERMAL_START_MAX_MC" ] && break
log " temp=$T, cooling... ($i)"
sleep 10
done
TEMP_START=$(read_temp)
log "start temp: $TEMP_START"
# --- 3. Select renderer (device-side sed, proven under run-as) ---------------
for known in $RENDERER_ESPRYT $RENDERER_MAGMA $RENDERER_MOBILEGLUES; do
[ "$known" = "$RENDERER" ] && continue
$ADB shell run-as $PKG sed -i "s/$known/$RENDERER/g" files/config.json
done
log "renderer now: $($ADB shell run-as $PKG grep renderer files/config.json | tr -d '\r' | tr -s ' ' | sort -u | tr '\n' ' ')"
# --- 4. Pin frequencies -------------------------------------------------------
[ "$DO_PIN" = 1 ] && pin_freqs
# --- 5. Launch, wait for world (retry: the JVM occasionally dies with
# "exited due to signal 34" right after JLI_Launch on this device) -------------
MCLOG="/storage/emulated/0/FCL/.minecraft/versions/*/logs/latest.log"
WORLD_UP=0
for attempt in 1 2 3; do
$ADB shell am force-stop $PKG
sleep 5
$ADB shell "rm -f $MCLOG /sdcard/MG/latest.log" 2>/dev/null
$ADB logcat -c 2>/dev/null
log "launching $ACTIVITY (attempt $attempt)"
$ADB shell am start -n $ACTIVITY >/dev/null
for i in $(seq 1 $((WORLD_LOAD_TIMEOUT / 5))); do
sleep 5
if $ADB shell "grep -l -e 'logged in with entity id' -e 'Preparing spawn area: 100' $MCLOG" >/dev/null 2>&1; then
WORLD_UP=1; break
fi
if ! $ADB shell pidof $PKG >/dev/null; then
sleep 3
if ! $ADB shell pidof $PKG >/dev/null; then
log "attempt $attempt: game process died; retrying"
break
fi
fi
done
[ "$WORLD_UP" = 1 ] && break
done
if [ "$WORLD_UP" != 1 ]; then
log "world did not load within ${WORLD_LOAD_TIMEOUT}s"
$ADB exec-out screencap -p > "$OUTDIR/failed-load.png" 2>/dev/null
echo "{\"label\":\"$RUNLABEL\",\"error\":\"world-load-timeout\"}"
exit 1
fi
log "world is up; warmup ${WARMUP}s"
sleep "$WARMUP"
# --- 6. Measure ---------------------------------------------------------------
$ADB exec-out screencap -p > "$OUTDIR/pre.png" 2>/dev/null
TEMP_MID=$(read_temp)
GPU_BUSY_SAMPLES=""
FPS_FILE="$OUTDIR/fps.txt"
: > "$FPS_FILE"
log "sampling $SAMPLES FPS values (1/s) + GPU busy"
$ADB logcat -v raw -s FCLFPS:I > "$OUTDIR/fclfps.log" &
LOGCAT_PID=$!
for i in $(seq 1 "$SAMPLES"); do
sleep 1
B=$(sushell "cat $GPU_UTIL_NODE" | tr -d '\r' | awk '{print $1}')
GPU_BUSY_SAMPLES="$GPU_BUSY_SAMPLES $B"
done
kill $LOGCAT_PID 2>/dev/null
wait $LOGCAT_PID 2>/dev/null
grep -E '^[0-9]+$' "$OUTDIR/fclfps.log" | tail -n "$SAMPLES" > "$FPS_FILE"
$ADB exec-out screencap -p > "$OUTDIR/post.png" 2>/dev/null
TEMP_END=$(read_temp)
# Pin integrity: sample live freqs right at the end of the window (game still hot).
BIG_CUR=$($ADB shell cat /sys/devices/system/cpu/cpufreq/$CPU_BIG_POLICY/scaling_cur_freq | tr -d '\r')
LITTLE_CUR=$($ADB shell cat /sys/devices/system/cpu/cpufreq/$CPU_LITTLE_POLICY/scaling_cur_freq | tr -d '\r')
GPU_CUR=$(sushell "cat $GPU_CURFREQ_NODE" | tr -d '\r' | awk '{print $NF}')
# --- 7. Stats -----------------------------------------------------------------
STATS=$(sort -n "$FPS_FILE" | awk '
{ v[NR]=$1; s+=$1 }
END {
if (NR==0) { print "0 0 0 0 0 0"; exit }
mean=s/NR; med=v[int((NR+1)/2)];
for(i=1;i<=NR;i++) ss+=(v[i]-mean)^2;
sd=(NR>1)?sqrt(ss/(NR-1)):0;
printf "%d %.1f %d %d %d %.1f", NR, mean, med, v[1], v[NR], sd
}')
set -- $STATS
N=$1 MEAN=$2 MED=$3 MIN=$4 MAX=$5 SD=$6
GPU_BUSY_MEAN=$(echo "$GPU_BUSY_SAMPLES" | tr ' ' '\n' | grep -E '^[0-9]+$' | awk '{s+=$1;n++} END{if(n) printf "%.0f", s/n; else print 0}')
RESULT=$(printf '{"label":"%s","backend":"%s","samples":%s,"fps_mean":%s,"fps_median":%s,"fps_min":%s,"fps_max":%s,"fps_sd":%s,"gpu_busy_mean":%s,"temp_start_mc":%s,"temp_mid_mc":%s,"temp_end_mc":%s,"big_cur":%s,"little_cur":%s,"gpu_cur_khz":%s,"pinned":%s,"warmup_s":%s}' \
"$RUNLABEL" "$BACKEND" "$N" "$MEAN" "$MED" "$MIN" "$MAX" "$SD" "$GPU_BUSY_MEAN" \
"$TEMP_START" "$TEMP_MID" "$TEMP_END" "$BIG_CUR" "$LITTLE_CUR" "${GPU_CUR:-0}" "$DO_PIN" "$WARMUP")
mkdir -p results
echo "$RESULT" >> results/results.jsonl
echo "$RESULT"