4c95b04539
* Improve retained desktop frame performance 1. Fix retained animation pumping and Windows frame wake scheduling. 2. Make latency and frame profiling monotonic, observable, and regression-tested. 3. Add physical-display-aware macOS and Windows performance gates. * Fix PR performance and package checks - Align NativeSdkViewInfo declarations with runtime GPU telemetry. - Calibrate hosted macOS animation budgets without weakening physical-device defaults. * Harden Windows performance sampling and shutdown - Correlate physical hover samples with their responding visual frames. - Stop due-frame callbacks immediately when the Windows host exits.
278 lines
13 KiB
Bash
Executable File
278 lines
13 KiB
Bash
Executable File
#!/bin/sh
|
|
# Percentile GPU performance check for examples/gpu-dashboard.
|
|
#
|
|
# The single-launch smoke (test-gpu-dashboard-smoke) proves correctness with one
|
|
# load-tolerant latency sample; this harness measures a distribution:
|
|
#
|
|
# 1. Cold start: launches the automation build N times (NATIVE_SDK_PERF_LAUNCHES,
|
|
# default 5), recording gpu_first_frame_latency_ns from the automation
|
|
# snapshot for each launch.
|
|
# 2. Steady state: on the final (warm) launch, drives M "Auto refresh" switch
|
|
# clicks (NATIVE_SDK_PERF_INTERACTIONS, default 5), recording gpu_input_latency_ns
|
|
# (input receipt -> first present) per interaction.
|
|
# 3. Prints every sample plus min/median/p90/max per series, and asserts each
|
|
# series' p90 under its budget:
|
|
# NATIVE_SDK_PERF_BUDGET_MS first-frame p90 budget, default 300
|
|
# NATIVE_SDK_PERF_INPUT_BUDGET_MS input-latency p90 budget, default 100
|
|
# 4. Starts one harness-controlled retained animation and requires a 60 Hz-class
|
|
# completion stream (frame count plus p90/max interval budgets).
|
|
# Defaults are deliberately generous (2x+ the smoke's 150 ms first-frame
|
|
# budget): this check exists to catch step-function regressions on shared
|
|
# CI/dev machines, not 10% drift on an idle box.
|
|
#
|
|
# p90 is nearest-rank: sorted[ceil(0.9 * n)]. At the default n=5 that IS the
|
|
# maximum of the five samples — with five samples the nearest-rank 90th
|
|
# percentile estimator selects the max, so "max of 5 under a generous budget"
|
|
# is not a shortcut but the estimator itself; raising NATIVE_SDK_PERF_LAUNCHES past 9
|
|
# starts discarding the worst outlier as expected.
|
|
#
|
|
# Usage: scripts/perf-gpu-dashboard.sh <native-sdk-cli>
|
|
# Expects examples/gpu-dashboard already built with -Dautomation=true
|
|
# (`zig build test-gpu-dashboard-perf` wires the build + this script).
|
|
set -eu
|
|
|
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
|
cli="${1:?usage: scripts/perf-gpu-dashboard.sh <native-sdk-cli>}"
|
|
case "$cli" in /*) ;; *) cli="$repo_root/$cli" ;; esac
|
|
cd "$repo_root/examples/gpu-dashboard"
|
|
app="zig-out/bin/gpu-dashboard"
|
|
[ -x "$app" ] || { echo "perf: $app is missing; build examples/gpu-dashboard with -Dautomation=true first" >&2; exit 1; }
|
|
automation_dir=".zig-cache/native-sdk-automation"
|
|
mkdir -p "$automation_dir"
|
|
log=".zig-cache/native-sdk-gpu-dashboard-perf.log"
|
|
|
|
require_positive_int() { # name value
|
|
case "$2" in ''|*[!0-9]*) echo "perf: $1 must be a positive integer: $2" >&2; exit 1 ;; esac
|
|
[ "$2" -gt 0 ] || { echo "perf: $1 must be a positive integer: $2" >&2; exit 1; }
|
|
}
|
|
|
|
launches="${NATIVE_SDK_PERF_LAUNCHES:-5}"
|
|
interactions="${NATIVE_SDK_PERF_INTERACTIONS:-5}"
|
|
first_frame_budget_ms="${NATIVE_SDK_PERF_BUDGET_MS:-300}"
|
|
input_budget_ms="${NATIVE_SDK_PERF_INPUT_BUDGET_MS:-100}"
|
|
animation_min_frames="${NATIVE_SDK_PERF_ANIMATION_MIN_FRAMES:-45}"
|
|
animation_p90_budget_ms="${NATIVE_SDK_PERF_ANIMATION_P90_MS:-20}"
|
|
animation_max_budget_ms="${NATIVE_SDK_PERF_ANIMATION_MAX_MS:-34}"
|
|
require_positive_int NATIVE_SDK_PERF_LAUNCHES "$launches"
|
|
require_positive_int NATIVE_SDK_PERF_INTERACTIONS "$interactions"
|
|
require_positive_int NATIVE_SDK_PERF_BUDGET_MS "$first_frame_budget_ms"
|
|
require_positive_int NATIVE_SDK_PERF_INPUT_BUDGET_MS "$input_budget_ms"
|
|
require_positive_int NATIVE_SDK_PERF_ANIMATION_MIN_FRAMES "$animation_min_frames"
|
|
require_positive_int NATIVE_SDK_PERF_ANIMATION_P90_MS "$animation_p90_budget_ms"
|
|
require_positive_int NATIVE_SDK_PERF_ANIMATION_MAX_MS "$animation_max_budget_ms"
|
|
first_frame_budget_ns=$((first_frame_budget_ms * 1000000))
|
|
input_budget_ns=$((input_budget_ms * 1000000))
|
|
|
|
dump_diagnostics() { # failure forensics for shared CI runners: app log + canvas line
|
|
echo "---- app log ($log) ----" >&2
|
|
cat "$log" >&2 2>/dev/null || true
|
|
echo "---- dashboard-canvas snapshot line ----" >&2
|
|
read_snapshot
|
|
printf '%s\n' "$snapshot" | grep 'view @w1/dashboard-canvas' >&2 || echo "(no dashboard-canvas line in snapshot)" >&2
|
|
}
|
|
|
|
pid=""
|
|
trap 'kill "$pid" >/dev/null 2>&1 || true; wait "$pid" >/dev/null 2>&1 || true' EXIT
|
|
stop_app() {
|
|
kill "$pid" >/dev/null 2>&1 || true
|
|
wait "$pid" >/dev/null 2>&1 || true
|
|
pid=""
|
|
}
|
|
|
|
bring_app_to_front() {
|
|
# A performance sample from an AppKit window deliberately throttled as
|
|
# occluded says nothing about visible animation quality. Dedicated macOS
|
|
# runners launch this script from ssh/CI, so make the measured app frontmost
|
|
# in the logged-in desktop when WindowServer permits it. Activation failure
|
|
# is non-fatal here; the explicit occlusion check below owns that diagnostic.
|
|
[ "$(uname -s)" = Darwin ] || return 0
|
|
/usr/bin/osascript -e "tell application \"System Events\" to set frontmost of first application process whose unix id is $pid to true" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
read_snapshot() {
|
|
snapshot="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
|
|
}
|
|
|
|
canvas_field() { # field-name -> value of gpu_<field>=N on the dashboard-canvas line, or empty
|
|
printf '%s\n' "$snapshot" | sed -n "s/.*view @w1\\/dashboard-canvas kind=gpu_surface.* $1=\\([0-9][0-9]*\\).*/\\1/p"
|
|
}
|
|
|
|
canvas_bool_field() { # field-name -> true/false on the dashboard-canvas line
|
|
printf '%s\n' "$snapshot" | sed -n "s/.*view @w1\\/dashboard-canvas kind=gpu_surface.* $1=\\([a-z][a-z]*\\).*/\\1/p"
|
|
}
|
|
|
|
profile_field() { # field-name -> value on the frame_profile line, or empty
|
|
printf '%s\n' "$snapshot" | sed -n "s/^frame_profile .* $1=\\([0-9][0-9]*\\).*/\\1/p"
|
|
}
|
|
|
|
ns_to_ms() { # ns -> ms with one decimal
|
|
awk "BEGIN { printf \"%.1f\", $1 / 1000000 }"
|
|
}
|
|
|
|
launch_and_measure_first_frame() {
|
|
rm -f "$automation_dir/snapshot.txt" "$automation_dir/accessibility.txt" "$automation_dir/windows.txt" "$automation_dir/command.txt"
|
|
"$app" > "$log" 2>&1 &
|
|
pid=$!
|
|
ready="$("$cli" automate wait 2>&1)"
|
|
case "$ready" in *"ready=true"*) ;; *) echo "perf: gpu-dashboard automation snapshot was not ready" >&2; dump_diagnostics; exit 1 ;; esac
|
|
bring_app_to_front
|
|
first_frame_latency=""
|
|
attempts=0
|
|
while [ "$attempts" -lt 100 ]; do
|
|
read_snapshot
|
|
case "$snapshot" in
|
|
*'view @w1/dashboard-canvas kind=gpu_surface'*'gpu_nonblank=true'*)
|
|
first_frame_latency="$(canvas_field gpu_first_frame_latency_ns)"
|
|
case "$first_frame_latency" in ''|*[!0-9]*|0) ;; *) return 0 ;; esac
|
|
;;
|
|
esac
|
|
attempts=$((attempts + 1))
|
|
sleep 0.1
|
|
done
|
|
echo "perf: dashboard GPU first frame latency was not recorded within 10s" >&2
|
|
dump_diagnostics
|
|
exit 1
|
|
}
|
|
|
|
# ---- series bookkeeping -----------------------------------------------------
|
|
|
|
first_frame_samples=""
|
|
input_samples=""
|
|
|
|
append_sample() { # list-var-name value
|
|
eval "current=\$$1"
|
|
if [ -z "$current" ]; then eval "$1=\$2"; else eval "$1=\"\$current
|
|
\$2\""; fi
|
|
}
|
|
|
|
p90_rank() { # n -> nearest-rank index for p90: ceil(0.9 * n)
|
|
echo $(( (9 * $1 + 9) / 10 ))
|
|
}
|
|
|
|
series_stat() { # newline-list rank -> value at 1-based rank of ascending sort
|
|
printf '%s\n' "$1" | sort -n | sed -n "${2}p"
|
|
}
|
|
|
|
report_series() { # label list budget_ns budget_ms -> prints summary, returns 1 on p90 overrun
|
|
label="$1"; list="$2"; budget_ns="$3"; budget_ms="$4"
|
|
n="$(printf '%s\n' "$list" | wc -l | tr -d ' ')"
|
|
rank="$(p90_rank "$n")"
|
|
min="$(series_stat "$list" 1)"
|
|
median="$(series_stat "$list" $(( (n + 1) / 2 )))"
|
|
p90="$(series_stat "$list" "$rank")"
|
|
max="$(series_stat "$list" "$n")"
|
|
echo "perf: $label summary over $n samples: min $(ns_to_ms "$min") ms, median $(ns_to_ms "$median") ms, p90 $(ns_to_ms "$p90") ms, max $(ns_to_ms "$max") ms (budget $budget_ms ms)"
|
|
if [ "$p90" -gt "$budget_ns" ]; then
|
|
echo "perf: $label p90 exceeded the $budget_ms ms budget: $p90 ns ($(ns_to_ms "$p90") ms)" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ---- cold start: N launches, first-frame latency ----------------------------
|
|
|
|
i=1
|
|
while [ "$i" -le "$launches" ]; do
|
|
launch_and_measure_first_frame
|
|
append_sample first_frame_samples "$first_frame_latency"
|
|
echo "perf: first-frame sample $i/$launches: $first_frame_latency ns ($(ns_to_ms "$first_frame_latency") ms)"
|
|
if [ "$i" -lt "$launches" ]; then stop_app; fi
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# ---- steady state: M widget clicks on the final (warm) launch ---------------
|
|
# Each click of the "Auto refresh" switch requests a GPU frame; the runtime
|
|
# records gpu_input_latency_ns = input receipt -> first present that consumed
|
|
# it. A sample is ready once the snapshot shows this click's input timestamp
|
|
# AND a frame timestamp at/after it (that frame is what recorded the latency).
|
|
|
|
read_snapshot
|
|
prev_input_ts="$(canvas_field gpu_input_timestamp_ns)"
|
|
case "$prev_input_ts" in ''|*[!0-9]*) prev_input_ts=0 ;; esac
|
|
|
|
i=1
|
|
while [ "$i" -le "$interactions" ]; do
|
|
read_snapshot
|
|
switch_id="$(printf '%s\n' "$snapshot" | sed -n 's/.*widget @w1\/dashboard-canvas#\([0-9][0-9]*\) role=switch name="Auto refresh".*/\1/p' | head -1)"
|
|
case "$switch_id" in ''|*[!0-9]*) echo "perf: dashboard auto refresh switch id was missing from snapshot" >&2; exit 1 ;; esac
|
|
"$cli" automate widget-click dashboard-canvas "$switch_id" >/dev/null 2>&1
|
|
input_latency=""
|
|
attempts=0
|
|
# 200 x 50ms nominal; each iteration also spawns several subprocesses, so
|
|
# the real window is 2-4x longer on a loaded shared runner. That headroom
|
|
# only delays a genuine failure — a healthy click lands in a few frames.
|
|
while [ "$attempts" -lt 200 ]; do
|
|
read_snapshot
|
|
input_ts="$(canvas_field gpu_input_timestamp_ns)"
|
|
frame_ts="$(canvas_field gpu_timestamp_ns)"
|
|
case "$input_ts" in ''|*[!0-9]*) input_ts=0 ;; esac
|
|
case "$frame_ts" in ''|*[!0-9]*) frame_ts=0 ;; esac
|
|
if [ "$input_ts" -gt "$prev_input_ts" ] && [ "$frame_ts" -ge "$input_ts" ]; then
|
|
input_latency="$(canvas_field gpu_input_latency_ns)"
|
|
case "$input_latency" in ''|*[!0-9]*|0) input_latency="" ;; *) break ;; esac
|
|
fi
|
|
attempts=$((attempts + 1))
|
|
sleep 0.05
|
|
done
|
|
if [ -z "$input_latency" ]; then
|
|
echo "perf: widget-click $i did not produce a presented frame with recorded input latency within the wait window" >&2
|
|
dump_diagnostics
|
|
exit 1
|
|
fi
|
|
prev_input_ts="$input_ts"
|
|
append_sample input_samples "$input_latency"
|
|
echo "perf: input-latency sample $i/$interactions: $input_latency ns ($(ns_to_ms "$input_latency") ms)"
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# ---- retained animation: self-scheduled completion cadence -----------------
|
|
# Reset immediately before the command so startup/interaction idle tails cannot
|
|
# contaminate interval_max. The automation-only dashboard command installs a
|
|
# harness-controlled loop even when the operator prefers reduced product
|
|
# motion; after that command the app must keep requesting frames with no driver
|
|
# input until the harness explicitly stops it.
|
|
|
|
"$cli" automate profile off >/dev/null 2>&1
|
|
"$cli" automate profile on >/dev/null 2>&1
|
|
read_snapshot
|
|
animation_start_frame="$(canvas_field gpu_frame)"
|
|
case "$animation_start_frame" in ''|*[!0-9]*) echo "perf: animation start frame was missing" >&2; exit 1 ;; esac
|
|
"$cli" automate native-command dashboard.perf-animation dashboard-canvas >/dev/null 2>&1
|
|
sleep 1.2
|
|
# GPU completions do not publish automation text on their own once their
|
|
# observable facts settle. Re-sending `profile on` leaves the live window
|
|
# intact (only off->on resets it) and wakes one publication carrying the
|
|
# accumulated cadence and latest gpu_frame count.
|
|
"$cli" automate profile on >/dev/null 2>&1
|
|
read_snapshot
|
|
animation_end_frame="$(canvas_field gpu_frame)"
|
|
animation_p50_us="$(profile_field interval_p50_us)"
|
|
animation_p90_us="$(profile_field interval_p90_us)"
|
|
animation_max_us="$(profile_field interval_max_us)"
|
|
animation_samples="$(profile_field interval_n)"
|
|
animation_occluded="$(canvas_bool_field gpu_occluded)"
|
|
for value in "$animation_end_frame" "$animation_p50_us" "$animation_p90_us" "$animation_max_us" "$animation_samples"; do
|
|
case "$value" in ''|*[!0-9]*) echo "perf: retained-animation telemetry was incomplete" >&2; dump_diagnostics; exit 1 ;; esac
|
|
done
|
|
animation_frames=$((animation_end_frame - animation_start_frame))
|
|
animation_profiled_frames=$((animation_samples + 1))
|
|
echo "perf: retained animation: profiled frames $animation_profiled_frames (frame-index delta $animation_frames), interval p50 $(ns_to_ms "$((animation_p50_us * 1000))") ms, p90 $(ns_to_ms "$((animation_p90_us * 1000))") ms, max $(ns_to_ms "$((animation_max_us * 1000))") ms"
|
|
"$cli" automate native-command dashboard.perf-animation-stop dashboard-canvas >/dev/null 2>&1
|
|
|
|
stop_app
|
|
|
|
# ---- summary + assertions ---------------------------------------------------
|
|
|
|
failures=0
|
|
report_series "first-frame latency" "$first_frame_samples" "$first_frame_budget_ns" "$first_frame_budget_ms" || failures=$((failures + 1))
|
|
report_series "input latency" "$input_samples" "$input_budget_ns" "$input_budget_ms" || failures=$((failures + 1))
|
|
if [ "$animation_occluded" = true ]; then
|
|
echo "perf: retained animation cadence skipped: host reports the test window is occluded"
|
|
else
|
|
[ "$animation_profiled_frames" -ge "$animation_min_frames" ] || { echo "perf: retained animation produced $animation_profiled_frames reset-scoped profiled frames (minimum $animation_min_frames)" >&2; failures=$((failures + 1)); }
|
|
[ "$animation_p90_us" -le "$((animation_p90_budget_ms * 1000))" ] || { echo "perf: retained animation p90 exceeded $animation_p90_budget_ms ms" >&2; failures=$((failures + 1)); }
|
|
[ "$animation_max_us" -le "$((animation_max_budget_ms * 1000))" ] || { echo "perf: retained animation max exceeded $animation_max_budget_ms ms" >&2; failures=$((failures + 1)); }
|
|
fi
|
|
[ "$failures" -eq 0 ] || exit 1
|
|
echo "gpu-dashboard perf ok"
|