Files
qbz/scripts/qt-batches.sh
T
vicrodh b136c9cf5d feat(qt): ambient background port, clip sweep and measurement tooling
Accumulated qt-frontend work from the 2026-08-11/12 sessions (cc),
uncommitted until now: the app-wide dynamic background in both modes
(AmbientField with GPU/Canvas arms, AppShell layering, ambient palette
pipeline), the wave-1 clip/batch sweep across the tree (alpha batches
449 -> 327, reproducible), the rounded-cover and icon-tint arms, and
scripts/qt-batches.sh (batches + presents/s sampler).

The GPU behaviour of these pieces was finalized in the follow-up pulse
commits; see qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/
GPU-COST-INVESTIGATION.md for the full campaign.

km
2026-08-13 13:01:32 -06:00

161 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Count Qt Quick scene-graph BATCHES for a scene you have driven to by hand.
#
# WHY THIS EXISTS. GPU utilisation is a terrible optimisation target: it moved by
# nothing across four different hypotheses (shader resolution, pane layering,
# animation tick, duplicated clocks) because none of them touched the thing that
# actually costs. QSG_RENDER_TIMING then showed render=2ms / sync=0 / polish=0 —
# the scene is cheap to COMPUTE — and QSG_RENDERER_DEBUG=render showed
# "Alpha: 533 nodes in 449 batches", i.e. 1.2 nodes per batch and ~28k draw calls
# a second. Batch count is the number that moves when the tree improves, and it
# is deterministic: same view, same batches. No thermal ramp, no measurement
# window to argue about, no 20% spread between consecutive readings.
#
# YOU DRIVE, THEN IT SAMPLES. The first cut of this script launched the app and
# killed it after a fixed timeout, which meant it sampled whatever was on screen
# during startup — and since the dynamic background only mounts once a track is
# loaded, the "reference scene" it measured was the one with the background OFF.
# It now waits for you: get to the view, start playback, let it settle, press
# Enter, and only then does the sample window open.
#
# Usage:
# ./scripts/qt-batches.sh # launch, you drive, Enter, 8s sample
# SECS=15 ./scripts/qt-batches.sh # longer sample window
# KEEP=1 ./scripts/qt-batches.sh # leave the app running afterwards
# ATLAS_OVERLAY=1 ./scripts/qt-batches.sh # tint atlased textures (diagnostic)
# ATLAS=8192 ./scripts/qt-batches.sh # force a bigger texture atlas
# BIN=crates/target/debug/qbz ./scripts/qt-batches.sh
#
# THE REFERENCE SCENE, so two runs are comparable: album page, NPB Large, the
# spectrum band up (the eye toggle on), a track PLAYING, dynamic background on.
# The count is per-scene — comparing Home against an album page tells you
# nothing, and comparing background-on against background-off tells you only
# that translucency moved the tree into the alpha pass.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.."
BIN="${BIN:-crates/target/release/qbz}"
SECS="${SECS:-8}"
LOG="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-XXXXXX.log")"
[[ -x "$BIN" ]] || { echo "no binary at $BIN — build first" >&2; exit 1; }
# ~1.2 MB/s of renderer chatter, so a long drive makes a big file. Harmless, but
# say so rather than let it be a surprise.
# ATLAS_OVERLAY=1 tints every texture that made it INTO the shared atlas.
# Untinted covers mean the atlas filled up: there is one Atlas instance, and
# once allocate() fails each subsequent image becomes a standalone texture with
# a unique comparisonKey() and is permanently unmergeable. That is a bigger
# lever than any clip, so it is worth ruling in or out before reading a delta.
# It is NOT on by default: the tint makes the UI unusable to look at, and this
# script's main job is the batch count.
atlas_env=()
if [[ "${ATLAS_OVERLAY:-0}" == 1 ]]; then
atlas_env+=(QSG_ATLAS_OVERLAY=1)
echo "[batches] ATLAS OVERLAY ON — tinted = in the atlas, untinted = its own texture"
fi
# ATLAS=8192 raises the atlas past the window-derived default, the actual fix
# if the overlay shows overflow.
if [[ -n "${ATLAS:-}" ]]; then
atlas_env+=(QSG_ATLAS_WIDTH="$ATLAS" QSG_ATLAS_HEIGHT="$ATLAS")
echo "[batches] atlas forced to ${ATLAS}x${ATLAS}"
fi
echo "[batches] launching $BIN (renderer debug is ~1 MB/s of log -> $LOG)"
env "${atlas_env[@]}" QSG_RENDERER_DEBUG=render QSG_RENDER_TIMING=1 "$BIN" > "$LOG" 2>&1 &
pid=$!
cleanup() { [[ "${KEEP:-0}" == 1 ]] || kill "$pid" 2>/dev/null || true; }
trap cleanup EXIT
echo
echo "[batches] Drive the app to the scene you want to measure:"
echo " album page · NPB Large · band ON · track PLAYING · background on"
echo "[batches] Let it settle, then press Enter to open the ${SECS}s sample window."
read -r _ || true
# Everything written before this instant is startup and navigation. The sample
# is only what lands after — that is the whole point of waiting for you.
mark=$(stat -c %s "$LOG" 2>/dev/null || stat -f %z "$LOG")
echo "[batches] sampling ${SECS}s..."
sleep "$SECS"
sample="$(mktemp "${TMPDIR:-/tmp}/qbz-batches-sample-XXXXXX.log")"
tail -c "+$((mark + 1))" "$LOG" > "$sample"
if ! kill -0 "$pid" 2>/dev/null; then
echo "[batches] WARNING: the app exited during the sample window — this is a" >&2
echo " real exit, not the script; check the tail of $LOG" >&2
fi
# The LAST block in the window: the settled tree, after any delegate churn from
# scrolling or a track change has finished.
opaque=$(grep -a 'Opaque:' "$sample" | tail -1 || true)
alpha=$(grep -a 'Alpha:' "$sample" | tail -1 || true)
if [[ -z "$alpha" ]]; then
echo "[batches] no renderer output in the window — did the scene stop repainting?" >&2
echo "[batches] (a paused player with no animation legitimately emits nothing)" >&2
echo "[batches] log: $LOG" >&2
exit 1
fi
echo
echo " $opaque"
echo " $alpha"
echo
# nodes-per-batch is the health metric: 1.0 means every node pays its own draw
# call, higher is better. Under ~2 says the tree is fighting the batcher, and
# clip state is the usual reason (Qt cannot merge across different clip roots).
python3 - "$alpha" "$opaque" <<'PY'
import re, sys
def parse(s):
m = re.search(r'(\d+)\s+nodes in\s+(\d+)\s+batches', s or '')
return (int(m.group(1)), int(m.group(2))) if m else None
a, o = parse(sys.argv[1]), parse(sys.argv[2])
if a:
print(f" alpha nodes/batch = {a[0]/max(a[1],1):.2f} (1.0 = no batching at all)")
if o:
print(f" opaque nodes/batch = {o[0]/max(o[1],1):.2f}")
if a and o:
print(f" TOTAL batches per frame = {a[1] + o[1]} <- the number to drive down")
PY
# PRESENT RATE — the number that decides whether hunting repaint sources is even
# the right hunt. Qt Quick redraws and presents the WHOLE window whenever
# anything dirties the scene, and on a hybrid laptop whose external monitor
# hangs off the dGPU every present is also a frame KWin composites and scans out
# there. So the GPU cost is presents/s x window area, and presents/s is
# MEASURABLE instead of guessable: `polishAndSync: elapsed since last call` is
# the frame period. Read this before proposing any mechanism. ~30/s = one
# source. ~60/s = two unsynchronised ones. 120+/s = something runs at display
# rate. Five hypotheses died in this campaign for want of this one number.
python3 - "$sample" <<'PYFPS'
import re, sys, statistics
txt = open(sys.argv[1], errors="ignore").read()
per = [int(m) for m in re.findall(r"elapsed since last call: (\d+) ms", txt)]
per = [p for p in per if p > 0]
if per:
med = statistics.median(per)
fast = sum(1 for p in per if p <= 8)
print(f" presents: {len(per)} sampled, median period {med:.0f} ms -> ~{1000/max(med,1):.0f}/s")
print(f" arriving <=8ms apart: {fast} ({100*fast/len(per):.0f}%) <- display-rate source if high")
else:
print(" presents: no timing lines (QSG_RENDER_TIMING not honoured?)")
PYFPS
# Per-frame composition of the last block, which is what a clip audit targets.
last_start=$(grep -an 'Rendering:' "$sample" | tail -1 | cut -d: -f1 || true)
if [[ -n "$last_start" ]]; then
block=$(tail -n "+$last_start" "$sample")
echo
printf ' last frame: clip=%s noclip=%s unmerged=%s\n' \
"$(grep -c '\[ clip\]' <<<"$block" || true)" \
"$(grep -c '\[noclip\]' <<<"$block" || true)" \
"$(grep -c '\[unmerged\]' <<<"$block" || true)"
fi
echo
echo " sample: $sample"
echo " full log: $LOG"