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
161 lines
7.4 KiB
Bash
Executable File
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"
|