ZapretKVN-android/scripts/measure-startup-frames.sh
loop-uh a0511c375a
Some checks failed
Android CI / build (push) Has been cancelled
Baseline Profile: правила запуска и скрипты записи и замеров
- app/src/main/baseline-prof.txt: 29 641 правило (4 344 — код приложения),
  записано на эмуляторе по обходу Главной, Серверов, Маршрутов и Настроек;
  AGP сам кладёт его в assets/dexopt/baseline.prof и переносит через R8.
- scripts/generate-baseline-profile.sh: сборка build type profileGen (release
  без R8, подпись одноразовым ключом), обход UI через uiautomator/input,
  сброс профиля SIGUSR1, profman -> отсортированный HRF.
- scripts/measure-startup-frames.sh: холодный старт и gfxinfo до/после,
  docs/PERFORMANCE.md с методом и честными цифрами (эмулятор, не телефон).
- verify-project.sh проверяет файл профиля, verify-release-candidate.sh
  требует baseline.prof(m) в релизном APK; release build type не менялся.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:16:30 +03:00

219 lines
9.9 KiB
Shell
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Сравнивает два release-APK (например, без правил приложения в Baseline Profile и с ними)
# на эмуляторе: время холодного старта (`am start -W -S`, TotalTime) и кадры
# (`dumpsys gfxinfo`) на прокрутке списка серверов и переключении вкладок.
#
# Использование:
# scripts/measure-startup-frames.sh --serial emulator-5560 \
# --before before.apk --after after.apk [--runs 10] [--rounds 2] [--journeys 3] [--out DIR]
#
# Оба APK должны быть подписаны одним ключом (скрипт всё равно переустанавливает приложение
# с нуля перед каждым состоянием и заново импортирует тестовые серверы). Профиль применяется
# так же, как это делает Macrobenchmark: ProfileInstallReceiver кладёт встроенный профиль
# в профиль приложения, затем `cmd package compile -m speed-profile -f`.
# `--verify-baseline` добавляет третье состояние: `compile -m verify` (вообще без AOT).
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PACKAGE=io.github.zapretkvn.android
SERIAL="${ZAPRET_SERIAL:-}"
BEFORE_APK="" AFTER_APK=""
RUNS=10 ROUNDS=2 JOURNEYS=3
OUT_DIR="$PROJECT_ROOT/build/perf"
VERIFY_BASELINE=false
while (($#)); do
case "$1" in
--serial) SERIAL="$2"; shift 2 ;;
--before) BEFORE_APK="$2"; shift 2 ;;
--after) AFTER_APK="$2"; shift 2 ;;
--runs) RUNS="$2"; shift 2 ;;
--rounds) ROUNDS="$2"; shift 2 ;;
--journeys) JOURNEYS="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
--verify-baseline) VERIFY_BASELINE=true; shift ;;
*) echo "Неизвестный аргумент: $1" >&2; exit 2 ;;
esac
done
if [[ -z "${ANDROID_HOME:-}" && -f "$PROJECT_ROOT/local.properties" ]]; then
ANDROID_HOME="$(sed -n 's/^sdk\.dir=//p' "$PROJECT_ROOT/local.properties" | tail -n 1)"
fi
: "${ANDROID_HOME:?ANDROID_HOME должен указывать на Android SDK}"
ADB="${ZAPRET_ADB:-$ANDROID_HOME/platform-tools/adb}"
[[ "$SERIAL" =~ ^emulator-[0-9]+$ ]] || { echo "Нужен --serial эмулятора." >&2; exit 2; }
[[ -f "$BEFORE_APK" && -f "$AFTER_APK" ]] || { echo "Нужны --before и --after (файлы APK)." >&2; exit 2; }
command -v python3 >/dev/null
mkdir -p "$OUT_DIR"
cleanup() { rm -rf "${UI_HELPER_DIR:-}" "${WORK_DIR:-}"; }
trap cleanup EXIT
WORK_DIR="$(mktemp -d -t zapret-kvn-measure-XXXXXXXX)"
# shellcheck disable=SC1091
source "$PROJECT_ROOT/scripts/lib/adb-ui.sh"
adb_dev wait-for-device
adb_dev root >/dev/null 2>&1 || true
adb_dev wait-for-device
[[ "$(adb_dev shell id -u | tr -d '\r')" == 0 ]] || { echo "Нужен root на эмуляторе (google_apis)." >&2; exit 1; }
ui_read_screen
python3 -I - > "$WORK_DIR/servers.txt" <<'PY'
import uuid
countries = [("DE", "Германия", "185.12.3"), ("NL", "Нидерланды", "94.20.5"),
("FI", "Финляндия", "95.216.7"), ("US", "США", "104.18.9"),
("SE", "Швеция", "77.88.1"), ("PL", "Польша", "51.68.2")]
for i in range(60):
code, name, prefix = countries[i % len(countries)]
user = uuid.uuid5(uuid.NAMESPACE_DNS, f"baseline-profile-{i}")
print(f"vless://{user}@{prefix}.{i + 10}:443?security=tls&sni=example.org&type=ws"
f"&host=example.org&path=%2Fws&fp=chrome&encryption=none#{code} {name} {i + 1}")
PY
RESULTS="$OUT_DIR/results.csv"
echo "state,round,kind,value" > "$RESULTS"
record() { echo "$1,$2,$3,$4" >> "$RESULTS"; }
# Состояние: установка с нуля, тестовые серверы, затем нужный режим компиляции.
prepare_state() {
local apk="$1" mode="$2"
adb_dev uninstall "$PACKAGE" >/dev/null 2>&1 || true
adb_dev install --no-incremental "$apk" >/dev/null
adb_dev shell pm grant "$PACKAGE" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
adb_dev shell am start -W -n "$PACKAGE/io.github.zapretkvn.android.MainActivity" >/dev/null
ui_wait "Главная" 30
journey_import_servers "$WORK_DIR/servers.txt"
sleep 2
case "$mode" in
profile)
local result
result="$(adb_dev shell am broadcast -a androidx.profileinstaller.action.INSTALL_PROFILE \
-n "$PACKAGE/androidx.profileinstaller.ProfileInstallReceiver" | tr -d '\r')"
echo " ProfileInstaller: $(grep -o 'result=[0-9]*' <<<"$result" | head -n 1)" >&2
sleep 3
adb_dev shell am force-stop "$PACKAGE"
profile_entries "$PACKAGE" > "$WORK_DIR/entries"
adb_dev shell cmd package compile -m speed-profile -f "$PACKAGE" >/dev/null
;;
verify)
adb_dev shell am force-stop "$PACKAGE"
adb_dev shell cmd package compile --reset "$PACKAGE" >/dev/null
adb_dev shell cmd package compile -m verify -f "$PACKAGE" >/dev/null
;;
esac
adb_dev shell am force-stop "$PACKAGE"
}
# Число записей (классы + методы) в профиле приложения на устройстве.
profile_entries() {
local apk
apk="$(adb_dev shell pm path "$1" | tr -d '\r' | sed -n 's/^package://p' | head -n 1)"
adb_dev shell "profman --dump-classes-and-methods --profile-file=/data/misc/profiles/cur/0/$1/primary.prof \
--apk=$apk --dex-location=$apk 2>/dev/null | wc -l" | tr -d '\r'
}
odex_size() {
adb_dev shell "stat -c %s \$(ls /data/app/*/$PACKAGE-*/oat/*/base.odex 2>/dev/null | head -n 1) 2>/dev/null" \
| tr -d '\r' | head -n 1
}
cold_starts() {
local state="$1" round="$2" i total
adb_dev shell am start -W -S -n "$PACKAGE/io.github.zapretkvn.android.MainActivity" >/dev/null # прогрев, не считается
for ((i = 1; i <= RUNS; i++)); do
adb_dev shell am force-stop "$PACKAGE"
adb_dev shell "sync; echo 3 > /proc/sys/vm/drop_caches"
sleep 1.5
total="$(adb_dev shell am start -W -S -n "$PACKAGE/io.github.zapretkvn.android.MainActivity" \
| tr -d '\r' | sed -n 's/^TotalTime: *//p')"
record "$state" "$round" start_ms "$total"
sleep 1
done
}
frame_stats() {
local state="$1" round="$2" j dump
for ((j = 1; j <= JOURNEYS; j++)); do
adb_dev shell am force-stop "$PACKAGE"
adb_dev shell am start -W -n "$PACKAGE/io.github.zapretkvn.android.MainActivity" >/dev/null
sleep 4
adb_dev shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
journey_frames
sleep 1
dump="$(adb_dev shell dumpsys gfxinfo "$PACKAGE" | tr -d '\r')"
python3 -I -c '
import re, sys
state, rnd, journey, out = sys.argv[1:5]
text = sys.stdin.read()
def grab(pattern, cast=float):
m = re.search(pattern, text)
return cast(m.group(1)) if m else None
rows = {
"frames_total": grab(r"Total frames rendered: (\d+)", int),
"frames_janky": grab(r"Janky frames: (\d+)", int),
"p50_ms": grab(r"50th percentile: (\d+)ms"),
"p90_ms": grab(r"90th percentile: (\d+)ms"),
"p95_ms": grab(r"95th percentile: (\d+)ms"),
"p99_ms": grab(r"99th percentile: (\d+)ms"),
}
with open(out, "a") as f:
for key, value in rows.items():
if value is not None:
f.write(f"{state},{rnd},{key},{value}\n")
' "$state" "$round" "$j" "$RESULTS" <<<"$dump"
done
}
measure_state() {
local state="$1" apk="$2" mode="$3" round="$4"
echo "== Раунд $round, состояние $state ($mode)"
prepare_state "$apk" "$mode"
record "$state" "$round" odex_bytes "$(odex_size || echo 0)"
[[ -s "$WORK_DIR/entries" ]] && record "$state" "$round" profile_entries "$(cat "$WORK_DIR/entries")"
: > "$WORK_DIR/entries"
cold_starts "$state" "$round"
frame_stats "$state" "$round"
}
for ((round = 1; round <= ROUNDS; round++)); do
# Порядок чередуется, чтобы дрейф нагрузки хоста не доставался одному состоянию.
if ((round % 2 == 1)); then
measure_state before "$BEFORE_APK" profile "$round"
measure_state after "$AFTER_APK" profile "$round"
else
measure_state after "$AFTER_APK" profile "$round"
measure_state before "$BEFORE_APK" profile "$round"
fi
if [[ "$VERIFY_BASELINE" == true ]]; then
measure_state noprofile "$AFTER_APK" verify "$round"
fi
done
adb_dev uninstall "$PACKAGE" >/dev/null 2>&1 || true
python3 -I - "$RESULTS" <<'PY'
import csv, statistics, sys
from collections import defaultdict
data = defaultdict(lambda: defaultdict(list))
with open(sys.argv[1]) as f:
for row in csv.DictReader(f):
data[row["state"]][row["kind"]].append(float(row["value"]))
states = list(data)
print("| метрика | " + " | ".join(states) + " |")
print("|---|" + "---|" * len(states))
def line(label, fn):
print(f"| {label} | " + " | ".join(fn(data[s]) for s in states) + " |")
def med(kind):
return lambda d: f"{statistics.median(d[kind]):.0f}" if d[kind] else "-"
line("холодный старт, медиана, мс", med("start_ms"))
line("холодный старт, min–max, мс", lambda d: f"{min(d['start_ms']):.0f}–{max(d['start_ms']):.0f}")
line("выборка стартов", lambda d: str(len(d["start_ms"])))
line("кадров всего", lambda d: f"{sum(d['frames_total']):.0f}")
line("janky-кадры, %", lambda d: f"{100 * sum(d['frames_janky']) / max(1, sum(d['frames_total'])):.1f}")
for p in ("p50", "p90", "p95", "p99"):
line(f"{p}, мс (медиана по прогонам)", med(p + "_ms"))
line("записей в профиле на устройстве", med("profile_entries"))
line("размер base.odex, байт", med("odex_bytes"))
PY
echo "Сырые значения: $RESULTS"