Files
qbz/scripts/measure-cpu-window.sh
T
vicrodh 03af983a1b chore(scripts): add measure-cpu-window for structured perf sampling
Samples /proc/PID/stat directly (no pidstat dependency) for the qbz binary
and any WebKit child processes. Outputs mean/p50/p95/max per process plus
appends a CSV row to /tmp/qbz-cpu-measurements.csv for cross-run comparison.

Usage:
  ./scripts/measure-cpu-window.sh <label> [duration_seconds]

Built during the 2026-05-11 perf debug session to replace seat-of-the-pants
"does it feel faster?" with comparable numbers across changes.

cc
2026-05-11 20:43:49 -06:00

216 lines
7.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# measure-cpu-window.sh — sample CPU% of QBZ + WebKit children over a window
#
# Usage:
# ./scripts/measure-cpu-window.sh [label] [duration_seconds]
#
# Examples:
# ./scripts/measure-cpu-window.sh blur-on # 30s sample, labelled "blur-on"
# ./scripts/measure-cpu-window.sh blur-off 60 # 60s sample, labelled "blur-off"
# ./scripts/measure-cpu-window.sh # auto-label with timestamp
#
# Reads /proc/PID/stat directly (no pidstat dependency). Samples per second.
# CPU% is normalized per-core (100% = one fully busy core), matching the
# convention used in `top` and in the bug report at issue #414/#415.
#
# Output:
# - Live one-line update per second (qbz + webkit + total)
# - At end: mean, p50, p95, max for the whole window
# - CSV row appended to /tmp/qbz-cpu-measurements.csv for cross-run comparison
#
# To compare runs:
# 1. Pick a stable window state (e.g. 4K full-screen, home view, track playing)
# 2. Run script, do NOT touch the window during the sample
# 3. Change the variable (e.g. resize, toggle backdrop-filter), repeat
# 4. cat /tmp/qbz-cpu-measurements.csv
set -euo pipefail
LABEL="${1:-run-$(date +%H%M%S)}"
DURATION="${2:-30}"
LOG_CSV="/tmp/qbz-cpu-measurements.csv"
CLK_TCK="$(getconf CLK_TCK)"
if [[ ! "$DURATION" =~ ^[0-9]+$ ]] || (( DURATION < 5 )); then
echo "error: duration must be a positive integer >= 5" >&2
exit 2
fi
# Find QBZ main process — match both legacy binary name (qbz-nix, dev builds)
# and the production binary (qbz). Prefer the largest-RSS match to dodge
# wrapper shells.
find_qbz_pid() {
local candidates pid best_rss=0 best_pid=""
candidates="$(pgrep -f 'target/(debug|release)/qbz(-nix)?$|/usr/(local/)?bin/qbz$|^qbz$' 2>/dev/null || true)"
if [[ -z "$candidates" ]]; then
candidates="$(pgrep -x qbz 2>/dev/null || true)"
fi
if [[ -z "$candidates" ]]; then
candidates="$(pgrep -f 'qbz-nix' 2>/dev/null || true)"
fi
for pid in $candidates; do
[[ -r "/proc/$pid/status" ]] || continue
local rss
rss="$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status" 2>/dev/null || echo 0)"
if (( rss > best_rss )); then
best_rss=$rss
best_pid=$pid
fi
done
[[ -n "$best_pid" ]] && echo "$best_pid"
}
QBZ_PID="$(find_qbz_pid || true)"
if [[ -z "${QBZ_PID:-}" ]]; then
echo "error: no qbz process found. Is the app running?" >&2
exit 1
fi
# All WebKit child processes. WebKitWebProcess is the one that does CSS
# layout/paint, so its CPU% is the most relevant for backdrop-filter cost.
# WebKitNetworkProcess does I/O — usually low CPU.
find_webkit_pids() {
pgrep -f 'WebKit(Web|Network|GPU)Process' 2>/dev/null || true
}
WEBKIT_PIDS=()
mapfile -t WEBKIT_PIDS < <(find_webkit_pids)
read_jiffies() {
# Field 14 (utime) + field 15 (stime) of /proc/PID/stat. Pre-paren tokens
# in field 2 (comm) can contain spaces, so split off comm carefully.
local pid="$1" stat content rest
[[ -r "/proc/$pid/stat" ]] || { echo 0; return; }
content="$(cat "/proc/$pid/stat" 2>/dev/null || echo "")"
[[ -z "$content" ]] && { echo 0; return; }
rest="${content#* (}"
rest="${rest#*) }"
read -r _state _ppid _pgrp _sid _tty _tpgid _flags _minflt _cminflt _majflt _cmajflt utime stime _rest <<< "$rest"
echo $(( utime + stime ))
}
read_window_size() {
# Best-effort: read the persisted window size from the qbz log. Not used in
# math, just printed as context in the CSV.
local logf="$HOME/.local/share/qbz/qbz.log"
if [[ -r "$logf" ]]; then
grep -oE 'size=[0-9]+x[0-9]+' "$logf" 2>/dev/null | tail -1 | cut -d= -f2 || echo "unknown"
else
echo "unknown"
fi
}
printf '%s\n' "=== qbz CPU sampler ==="
printf 'label: %s\n' "$LABEL"
printf 'qbz pid: %s\n' "$QBZ_PID"
if (( ${#WEBKIT_PIDS[@]} > 0 )); then
printf 'webkit: %s\n' "${WEBKIT_PIDS[*]}"
else
printf 'webkit: (none found — is the GUI window open?)\n'
fi
printf 'duration: %ss\n' "$DURATION"
printf 'window: %s (persisted)\n' "$(read_window_size)"
printf 'log: %s\n' "$LOG_CSV"
printf '\nSampling — leave the window UNTOUCHED for the full window.\n\n'
# Take initial snapshot
prev_qbz="$(read_jiffies "$QBZ_PID")"
declare -A prev_wk
for pid in "${WEBKIT_PIDS[@]:-}"; do
[[ -z "$pid" ]] && continue
prev_wk[$pid]="$(read_jiffies "$pid")"
done
prev_ts="$(date +%s.%N)"
samples_qbz=()
samples_wk_total=()
samples_total=()
printf '%4s %8s %8s %8s\n' "t/s" "qbz%" "webkit%" "total%"
printf '%4s %8s %8s %8s\n' "---" "----" "-------" "------"
for (( t=1; t<=DURATION; t++ )); do
sleep 1
now_ts="$(date +%s.%N)"
dt="$(awk -v a="$now_ts" -v b="$prev_ts" 'BEGIN{printf "%.4f", a-b}')"
prev_ts="$now_ts"
cur_qbz="$(read_jiffies "$QBZ_PID")"
d_qbz=$(( cur_qbz - prev_qbz ))
prev_qbz="$cur_qbz"
pct_qbz="$(awk -v j="$d_qbz" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
wk_total=0
for pid in "${WEBKIT_PIDS[@]:-}"; do
[[ -z "$pid" ]] && continue
if [[ ! -r "/proc/$pid/stat" ]]; then
continue
fi
cur="$(read_jiffies "$pid")"
d=$(( cur - ${prev_wk[$pid]:-$cur} ))
prev_wk[$pid]="$cur"
(( d < 0 )) && d=0
wk_total=$(( wk_total + d ))
done
pct_wk="$(awk -v j="$wk_total" -v dt="$dt" -v hz="$CLK_TCK" 'BEGIN{ if (dt<=0) print 0; else printf "%.1f", (j/hz)/dt*100 }')"
pct_total="$(awk -v a="$pct_qbz" -v b="$pct_wk" 'BEGIN{ printf "%.1f", a+b }')"
samples_qbz+=("$pct_qbz")
samples_wk_total+=("$pct_wk")
samples_total+=("$pct_total")
printf '%4d %8s %8s %8s\n' "$t" "$pct_qbz" "$pct_wk" "$pct_total"
done
# Aggregate
summarize() {
local name="$1"
shift
local n="$#"
local sorted
sorted="$(printf '%s\n' "$@" | sort -n)"
local mean p50 p95 max
mean="$(printf '%s\n' "$@" | awk '{s+=$1} END{ if (NR>0) printf "%.1f", s/NR; else print 0 }')"
p50="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.50)+1 || (NR==1 && n==1) {print; exit}')"
p95="$(printf '%s\n' "$sorted" | awk -v n="$n" 'NR==int(n*0.95)+1 || (NR==n) {print; exit}')"
max="$(printf '%s\n' "$sorted" | tail -1)"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "$name" "$mean" "$p50" "$p95" "$max"
echo "$mean,$p50,$p95,$max"
}
echo ""
echo "=== Summary ==="
summary_qbz="$(summarize "qbz" "${samples_qbz[@]}" | tail -1)"
summarize "qbz" "${samples_qbz[@]}" >/dev/null
read -r mean_qbz p50_qbz p95_qbz max_qbz <<< "$(echo "$summary_qbz" | tr ',' ' ')"
mean_wk=0 p50_wk=0 p95_wk=0 max_wk=0
if (( ${#samples_wk_total[@]} > 0 )); then
summary_wk="$(summarize "webkit" "${samples_wk_total[@]}" | tail -1)"
read -r mean_wk p50_wk p95_wk max_wk <<< "$(echo "$summary_wk" | tr ',' ' ')"
fi
summary_total="$(summarize "total" "${samples_total[@]}" | tail -1)"
read -r mean_total p50_total p95_total max_total <<< "$(echo "$summary_total" | tr ',' ' ')"
# Re-print summaries to stdout (the summarize calls above piped them out)
{
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "qbz" "$mean_qbz" "$p50_qbz" "$p95_qbz" "$max_qbz"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "webkit" "$mean_wk" "$p50_wk" "$p95_wk" "$max_wk"
printf ' %-10s mean=%5s%% p50=%5s%% p95=%5s%% max=%5s%%\n' "total" "$mean_total" "$p50_total" "$p95_total" "$max_total"
}
# CSV header if first run
if [[ ! -f "$LOG_CSV" ]]; then
echo "timestamp,label,duration_s,window_size,qbz_mean,qbz_p50,qbz_p95,qbz_max,webkit_mean,webkit_p50,webkit_p95,webkit_max,total_mean,total_p50,total_p95,total_max" > "$LOG_CSV"
fi
WINDOW="$(read_window_size)"
echo "$(date -Iseconds),$LABEL,$DURATION,$WINDOW,$mean_qbz,$p50_qbz,$p95_qbz,$max_qbz,$mean_wk,$p50_wk,$p95_wk,$max_wk,$mean_total,$p50_total,$p95_total,$max_total" >> "$LOG_CSV"
echo ""
echo "Appended to $LOG_CSV"
echo "Compare runs: cat $LOG_CSV | column -t -s,"