basic-benchmark/run-benchmarks.sh

894 lines
36 KiB
Shell
Raw Normal View History

#!/usr/bin/env bash
#
# basic-benchmark / run-benchmarks.sh
# Run the SAME benchmark set before and after the CPU/platform upgrade.
#
# The suite is split in two parts so it can be driven from another device:
#
# ./run-benchmarks.sh before headless # no display needed -> run over SSH
# ./run-benchmarks.sh before headed # needs the KDE session -> run locally
# ./run-benchmarks.sh before # legacy: headless + headed (if display)
#
# The part is appended to the tag so the two parts never overwrite each other:
#
# before headless -> results/{power,phases}-before-headless.csv
# before headed -> results/{power,phases}-before-headed.csv
#
# Compare:
# python3 power-report.py --tags before-headless after-headless
# python3 power-report.py --tags before-headless before-headed after-headless after-headed
#
# Primary cross-platform score: PassMark PerformanceTest (cpubenchmark.net).
# GPU tests use glmark2/vkmark.
#
# Env options:
# INSTALL_DEPS=1 auto-install missing tools via sudo dnf (fresh Fedora)
# default is 'ask' (prompts); 0 disables
# RUN_GPU=0 skip the headed GPU tests in 'all' mode
# TOOLS=/path where PassMark lives (default ./tools)
# FIOFILE=/path fio scratch file (must be on the target filesystem)
# FIO_SIZE=1G fio file size
# FIO_RUNTIME=30 seconds per fio job
# GPU_PCI=0000:03:00.0 pin GPU tests to this PCI device (auto-detected)
# GPU_VD=0x1002:0x73df vendor:device hex for Vulkan selection (auto-detected)
# LLAMA_MODEL=/path GGUF for the LLM test (default: bundled model)
# LLAMA_NGL=0 GPU layers to offload (0 = CPU/RAM, the default)
# RUN_APPS=1 app workloads: LibreOffice, GEGL, Inkscape, GIMP
# CORPUS=/path where the sample inputs are cached (default ./tools/corpus)
# CORPUS_URL=... base URL the sample corpora are fetched from
# GOVERNOR=powersave CPU governor to pin (default performance); an explicit
# value is appended to the tag, e.g. after-powersave-headless
# GLMARK2_DURATION=10 seconds for the glmark2 scene (headed part)
# VKMARK_DURATION=10 seconds for the vkmark scene (headed part)
# VKMARK_BENCH=texture vkmark scene to run (e.g. texture, shading, vertex)
#
# Run the headless part with a TTY so sudo can prompt for power logging:
# ssh -t <target> '~/basic-benchmark/run-benchmarks.sh before headless'
set -u
# User-space tools (e.g. a source-built tool) live here; llama.cpp ships prebuilt.
export PATH="$HOME/.local/bin:$PATH"
# PassMark is a curses app; it needs a real terminal type even when piped. 'dumb'
# and unset both fail ("cannot initialize terminal type"), so force xterm then.
case "${TERM:-}" in ''|dumb|unknown) export TERM=xterm ;; esac
usage() {
printf 'usage: %s <tag> [headless|headed|all]\n' "$(basename "$0")" >&2
printf ' e.g. %s before headless # over SSH, no display needed\n' "$(basename "$0")" >&2
printf ' %s before headed # at the machine, in a graphical session\n' "$(basename "$0")" >&2
printf ' %s before # both parts (all)\n' "$(basename "$0")" >&2
printf ' %s doctor # check readiness without benchmarking\n' "$(basename "$0")" >&2
}
TAG_BASE="${1:-}"
PART="${2:-all}"
DOCTOR=0
[[ "$TAG_BASE" == "doctor" || "$TAG_BASE" == "--check" ]] && DOCTOR=1
if [[ "$DOCTOR" == "0" ]]; then
if [[ -z "$TAG_BASE" || "$TAG_BASE" == "-h" || "$TAG_BASE" == "--help" ]]; then
usage
[[ "$TAG_BASE" == "-h" || "$TAG_BASE" == "--help" ]] && exit 0
exit 1
fi
case "$PART" in
headless|headed|all) ;;
*) printf 'bad part: %s\n' "$PART" >&2; usage; exit 1 ;;
esac
fi
DIR="$(cd "$(dirname "$0")" && pwd)"
RES="$DIR/results"
mkdir -p "$RES"
cd "$RES" || exit 1
# CPU governor to pin for the timed runs (the original is restored on exit).
# Set GOVERNOR=... to choose one; an explicit choice is appended to the tag so
# several profiles never overwrite each other:
# GOVERNOR=powersave ./run-benchmarks.sh after headless -> after-powersave-headless
# GOVERNOR=performance ./run-benchmarks.sh after headless -> after-performance-headless
# ./run-benchmarks.sh after headless -> after-headless (performance)
GOVERNOR_SET=0
[[ -n "${GOVERNOR:-}" ]] && GOVERNOR_SET=1
GOVERNOR="${GOVERNOR:-performance}"
TAG="$TAG_BASE"
[[ "$GOVERNOR_SET" == "1" ]] && TAG="$TAG-$GOVERNOR"
[[ "$PART" != "all" ]] && TAG="$TAG-$PART"
RUN_GPU="${RUN_GPU:-1}"
TOOLS="${TOOLS:-$DIR/tools}"
if [[ -z "${PT:-}" ]]; then
for cand in "$TOOLS/pt/pt_linux_x64" \
"$TOOLS/pt/PerformanceTest/PerformanceTest_Linux_x86-64"; do
[[ -x "$cand" ]] && { PT="$cand"; break; }
done
fi
PT="${PT:-$TOOLS/pt/pt_linux_x64}"
# Bundled LLM bench (self-contained under tools/llama/): token generation on CPU/RAM.
LLAMA_DIR="$TOOLS/llama"
LLAMA_BIN="$LLAMA_DIR/bin/llama-bench"
LLAMA_MODEL="${LLAMA_MODEL:-$LLAMA_DIR/models/MiniCPM5-2B-Q8_0.gguf}"
LLAMA_NGL="${LLAMA_NGL:-0}" # 0 = CPU/RAM only (no GPU offload)
LLAMA_TG="${LLAMA_TG:-128}" # generated tokens
LLAMA_REPS="${LLAMA_REPS:-2}"
# App workloads: stock distro applications run over a
# fixed sample corpus, fetched once and checksum-pinned, then cached under
# CORPUS and reused by every run so both sides process identical input bytes.
RUN_APPS="${RUN_APPS:-1}"
CORPUS="${CORPUS:-$TOOLS/corpus}"
CORPUS_URL="${CORPUS_URL:-http://phoronix-test-suite.com/benchmark-files}" # sample-file CDN
SAMPLER="$DIR/powerlog.py"
REPORT="$DIR/power-report.py"
PHASES="$RES/phases-$TAG.csv"
FIOFILE="${FIOFILE:-$HOME/.cache/basic-benchmark-fio.bin}"
FIO_SIZE="${FIO_SIZE:-1G}"
FIO_RUNTIME="${FIO_RUNTIME:-30}"
section() { printf '\n\033[1m=== %s ===\033[0m\n' "$*"; }
have() { command -v "$1" >/dev/null 2>&1; }
phase() { printf '%s,%s\n' "$(date +%s)" "$1" >> "$PHASES"; }
# --- dependency provisioning (Fedora / dnf) -----------------------------------
# The kit targets a fresh Fedora 44 with internet. Map each benchmark tool to the
# Fedora package that provides it; on request, install what's missing. Tools that
# are not packaged (or already bundled) are listed separately.
PKG_FOR_TOOL_CMD="7z:7zip openssl:openssl fio:fio stress-ng:stress-ng \
sensors:lm_sensors turbostat:kernel-tools cpupower:cpupowerutils \
glmark2:glmark2 vkmark:vkmark glxinfo:mesa-demos vulkaninfo:vulkan-tools \
sysbench:sysbench"
missing_tools() { # prints tool names whose command is absent
local pair cmd
for pair in $PKG_FOR_TOOL_CMD; do
cmd="${pair%%:*}"
have "$cmd" || printf '%s\n' "$cmd"
done
}
pkgs_for_missing() { # prints the packages needed for the missing commands
local pkgs="" pair cmd
for pair in $PKG_FOR_TOOL_CMD; do
cmd="${pair%%:*}"; pkg="${pair#*:}"
have "$cmd" || pkgs="$pkgs $pkg"
done
python3 -c 'import matplotlib' >/dev/null 2>&1 || pkgs="$pkgs python3-matplotlib"
# PassMark needs the ncurses compat libs (rpm check, these aren't commands)
if [ -x "$PT" ]; then
rpm -q ncurses-libs >/dev/null 2>&1 || pkgs="$pkgs ncurses-libs"
rpm -q ncurses-compat-libs >/dev/null 2>&1 || pkgs="$pkgs ncurses-compat-libs"
fi
printf '%s' "$pkgs"
}
install_deps() { # INSTALL_DEPS=1 (or 'ask')
local mode="${INSTALL_DEPS:-ask}" pkgs
pkgs="$(pkgs_for_missing)"
if [ -z "${pkgs// /}" ]; then printf 'all dnf-provided tools present\n'; return 0; fi
printf 'missing (dnf):%s\n' "$pkgs"
case "$mode" in
0|no|off) printf 'INSTALL_DEPS=%s -> not installing; some tests will skip\n' "$mode"; return 0 ;;
ask)
if [ -t 0 ]; then
printf 'install now with sudo dnf? [y/N] '
read -r ans; case "$ans" in [yY]*) ;; *) printf 'skipped\n'; return 0 ;; esac
else
printf 'non-interactive; set INSTALL_DEPS=1 to auto-install, else tests skip\n'; return 0
fi ;;
esac
if ! have dnf; then printf 'dnf not found (non-Fedora?); install manually\n'; return 0; fi
if ! sudo -n true 2>/dev/null; then
printf 'need sudo for dnf; run in a terminal (ssh -t)\n'; return 0
fi
section "installing missing tools (dnf)"
# shellcheck disable=SC2086
sudo dnf install -y $pkgs || printf 'dnf install failed; tests may skip\n'
}
# Idle baseline: let the machine settle and log >=15 s of true idle so the
# report has a real idle window (the 'env' phase alone was only ~2 s).
run_idle() {
local secs="${IDLE_SECONDS:-15}"
section "IDLE baseline (${secs}s, CPU: $CPU_MODEL)"
phase "idle"
# settle: give the governor/load from startup time to calm down
sleep 3
# keep the phase open for at least $secs of sampling
sleep "$secs"
}
# Model string for titles (env capture, graphs, stdout headers).
cpu_model() {
local m=""
m="$(sed -n 's/^model name[[:space:]]*:[[:space:]]*//p' /proc/cpuinfo | head -1)"
[ -z "$m" ] && m="$(lscpu 2>/dev/null | sed -n 's/^Model name:[[:space:]]*//p' | head -1)"
[ -z "$m" ] && m="$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver 2>/dev/null)"
printf '%s' "${m:-unknown CPU}"
}
CPU_MODEL="$(cpu_model)"
# Register one score (phase,value) into scores-<tag>.csv for the efficiency table.
reg_score() { # $1 = phase name (must match a phase), $2 = numeric score
[ -n "${2:-}" ] || return 0
printf '%s,%s\n' "$1" "$2" >> "scores-$TAG.csv"
}
# 7-Zip "Avr:" row = "Avr: <speed> <usage> <R/U> <rating> | <decomp...>"; the
# compressing MIPS is the Rating, field 5 (the "Tot:" row's first field is usage %).
7z_mips() { awk '/^Avr:/{print $5; exit}' "$1" 2>/dev/null; }
# sysbench prints "events per second: <N>".
sysbench_eps() { awk -F: '/events per second/{gsub(/ /,"",$2); print $2; exit}' "$1" 2>/dev/null; }
# sysbench memory prints "... MiB transferred (<N> MiB/sec)".
sysbench_mbs() { sed -n 's/.*(\([0-9.]*\) MiB\/sec).*/\1/p' "$1" 2>/dev/null | head -1; }
# openssl speed ends each algorithm with a "...k" row; take its last (largest) value.
openssl_gbs() { awk 'NF && $NF ~ /k$/ {gsub(/k$/,"",$NF); v=$NF} END{ if (v!="") printf "%.3f", v/1000000 }' "$1" 2>/dev/null; }
# fio JSON: read one number from the first job.
fio_json() { python3 -c 'import json,sys; j=json.load(open(sys.argv[1]))["jobs"][0]; print((j.get(sys.argv[2]) or {}).get(sys.argv[3]) or "")' "$1" "$2" "$3" 2>/dev/null; }
fio_reg() { # $1=job $2=read|write $3=bw_bytes|iops
local v; v="$(fio_json "fio-$1-$TAG.json" "$2" "$3")"
[ -n "$v" ] || return 0
if [ "$3" = "bw_bytes" ]; then
reg_score "fio-$1" "$(awk -v v="$v" 'BEGIN{printf "%.1f", v/1000000}')"
else
reg_score "fio-$1" "$(awk -v v="$v" 'BEGIN{printf "%.0f", v}')"
fi
}
# glmark2/vkmark print a final "<tool> Score: <N>".
gpu_score() { awk -v tool="$1" 'index($0, tool " Score:"){print $NF; exit}' "$2" 2>/dev/null; }
# PassMark's .txt is a curses screen capture (unreadable); the numbers live in
# a results_*.yml file. Write scores-<tag>.csv so power-report.py can compute
# score/W efficiency. Each PassMark suite is its own phase (PassMark-cpu /
# PassMark-mem) so the power window matched to a score is the suite that made it.
# The single-thread mark rides in the CPU suite and shares its window.
parse_passmark() { # $1 = yml, $2 = phase name, $3 = suite ("cpu"|"mem")
local yml="$1" ph="$2" suite="$3"
[ -f "$yml" ] || return 0
local out="scores-$TAG.csv"
awk -v out="$out" -v ph="$ph" -v suite="$suite" '
/SUMM_CPU:/ { cpu = $2 }
/SUMM_ME:/ { mem = $2 }
/CPU_SINGLETHREAD:/{ st = $2 }
END {
if (suite == "cpu" && cpu != "") {
print ph "," cpu > out
if (st != "") print ph "-single," st >> out
}
if (suite == "mem" && mem != "") print ph "," mem >> out
}' "$yml"
}
# --- app workloads: fixed corpora + timing ------------------------------------
# Each app benchmark runs the stock command-line tool over a fixed input set and
# reports a rate (items/s, higher is better) so the score/W table stays meaningful.
fetch_corpus() { # $1=file $2=url $3=sha256 -> ensures $CORPUS/<file> is present
local f="$CORPUS/$1" sha
if [ -s "$f" ]; then
sha="$(sha256sum "$f" 2>/dev/null | cut -d' ' -f1)"
[ "$sha" = "$3" ] && return 0
fi
mkdir -p "$CORPUS"
if have curl; then
curl -fsSL --retry 2 -o "$f.part" "$2" || { rm -f "$f.part"; return 1; }
elif have wget; then
wget -q -O "$f.part" "$2" || { rm -f "$f.part"; return 1; }
else
printf 'need curl or wget to fetch %s\n' "$1" >&2; return 1
fi
mv -f "$f.part" "$f"
sha="$(sha256sum "$f" | cut -d' ' -f1)"
if [ "$sha" != "$3" ]; then
printf 'checksum mismatch for %s (got %s)\n' "$1" "$sha" >&2
rm -f "$f"; return 1
fi
printf 'fetched %s -> %s\n' "$1" "$f"
}
extract_zip() { # $1=zip $2=destdir
mkdir -p "$2"
if have unzip; then unzip -oq "$1" -d "$2"
elif have 7z; then 7z x -y -o"$2" "$1" >/dev/null
else printf 'need unzip or 7z to extract %s\n' "$1" >&2; return 1
fi
}
setup_corpus() { # $1=zip $2=url $3=sha256 $4=subdir -> extracted once under $CORPUS/$4
local d="$CORPUS/$4"
fetch_corpus "$1" "$2" "$3" || return 1
if [ -z "$(ls -A "$d" 2>/dev/null)" ]; then
extract_zip "$CORPUS/$1" "$d" || return 1
fi
}
now() { date +%s.%N; }
secs() { awk -v a="$1" -v b="$2" 'BEGIN{printf "%.3f", b-a}'; }
per_s() { awk -v n="$1" -v s="$2" 'BEGIN{ if (s>0) printf "%.3f", n/s }'; }
# --- sudo: acquire once, then keep the timestamp alive for the whole run ------
SUDO=""
KEEPID=""
acquire_sudo() {
if sudo -n true 2>/dev/null; then SUDO="sudo"; return 0; fi
if [[ -t 0 || -t 1 ]]; then
if sudo -v 2>/dev/null; then SUDO="sudo"; return 0; fi
fi
SUDO=""
}
start_sudo_keepalive() {
[[ -n "$SUDO" ]] || return 0
( while :; do sudo -n true 2>/dev/null || exit 0; sleep 50; done ) &
KEEPID=$!
}
# --- CPU governor: pin and restore -------------------------------------------
GOV_ORIG=""
pin_governor() {
[[ -n "$SUDO" ]] || { printf 'governor not pinned (no sudo)\n'; return 0; }
GOV_ORIG="$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || true)"
section "pin CPU governor: ${GOVERNOR} (was ${GOV_ORIG:-unknown})"
$SUDO cpupower frequency-set -g "${GOVERNOR}" >/dev/null 2>&1 || \
for f in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
printf '%s' "${GOVERNOR}" | $SUDO tee "$f" >/dev/null 2>&1
done
}
restore_governor() {
[[ -n "$SUDO" && -n "$GOV_ORIG" && "$GOV_ORIG" != "$GOVERNOR" ]] || return 0
$SUDO cpupower frequency-set -g "$GOV_ORIG" >/dev/null 2>&1 || \
for f in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
printf '%s' "$GOV_ORIG" | $SUDO tee "$f" >/dev/null 2>&1
done
printf 'governor restored to %s\n' "$GOV_ORIG"
}
# --- discrete GPU detection & pinning (for the headed GL/Vulkan tests) --------
# Picks the GPU that owns no connected display connector (the headless dGPU on
# this box: the RX 6700 XT, while both monitors hang off the Cezanne iGPU).
detect_dgpu() {
local c pci conn v d best_pci="" best_vd=""
for c in /sys/class/drm/card[0-9]*; do
[[ "$(basename "$c")" =~ ^card[0-9]+$ ]] || continue
[[ -r "$c/device/vendor" && -r "$c/device/device" ]] || continue
conn=0
for s in "$c"/card*-*/status; do
[[ -e "$s" ]] || continue
[[ "$(cat "$s" 2>/dev/null)" == "connected" ]] && conn=$((conn + 1))
done
v="$(cat "$c/device/vendor" 2>/dev/null)"
d="$(cat "$c/device/device" 2>/dev/null)"
pci="$(basename "$(readlink -f "$c/device")")"
if [[ "$conn" -eq 0 ]]; then
best_pci="$pci"; best_vd="$v:$d"; break
fi
[[ -z "$best_pci" ]] && { best_pci="$pci"; best_vd="$v:$d"; }
done
GPU_PCI="${GPU_PCI:-$best_pci}"
GPU_VD="${GPU_VD:-$best_vd}"
}
pin_gpu_env() {
detect_dgpu
if [[ -n "${GPU_PCI:-}" ]]; then export DRI_PRIME="pci-$GPU_PCI"; fi
if [[ -n "${GPU_VD:-}" ]]; then
local vd="${GPU_VD//0x/}"
export MESA_VK_DEVICE_SELECT="$vd"
export VK_LOADER_DEVICE_SELECT="$vd"
fi
printf 'GPU pinning: DRI_PRIME=%s MESA_VK_DEVICE_SELECT=%s\n' \
"${DRI_PRIME:-<none>}" "${MESA_VK_DEVICE_SELECT:-<none>}"
}
# --- readiness check (no benchmarks run) -------------------------------------
doctor() {
section "basic-benchmark doctor ($(hostname))"
printf 'kit: %s\n' "$DIR"
printf 'user: %s\n' "$(id -un)"
printf 'TERM: %s\n' "${TERM:-<unset>}"
printf 'display: %s\n' "${WAYLAND_DISPLAY:-}${DISPLAY:-<none (headed part would refuse)>}"
if sudo -n true 2>/dev/null; then printf 'sudo: cached (ok)\n'
else printf 'sudo: will prompt - run the headless part as: ssh -t <target> ...\n'; fi
detect_dgpu
printf 'dGPU: pci=%s vd=%s\n' "${GPU_PCI:-<none>}" "${GPU_VD:-<none>}"
printf 'RAPL: %s\n' "$(ls -d /sys/class/powercap/intel-rapl:* 2>/dev/null | tr '\n' ' ')"
printf 'PassMark: %s\n' "$([ -x "$PT" ] && echo "$PT" || echo "MISSING ($PT)")"
printf 'llama: %s\n' "$([ -x "$LLAMA_BIN" ] && echo "$LLAMA_BIN" || echo "MISSING ($LLAMA_BIN)")"
printf 'model: %s\n' "$([ -f "$LLAMA_MODEL" ] && echo "$LLAMA_MODEL ($(du -h "$LLAMA_MODEL" 2>/dev/null | cut -f1))" || echo "MISSING ($LLAMA_MODEL)")"
printf 'tools:\n'
for t in 7z openssl sysbench fio stress-ng sensors turbostat cpupower \
glmark2 vkmark glxinfo vulkaninfo nproc; do
printf ' %-22s %s\n' "$t" "$(command -v "$t" || echo MISSING)"
done
printf 'apps:\n'
for t in libreoffice inkscape gegl gimp; do
printf ' %-22s %s\n' "$t" "$(command -v "$t" || echo MISSING)"
done
printf 'corpus: %s\n' "$CORPUS"
printf ' install apps: sudo dnf install -y libreoffice-writer libreoffice-calc inkscape gegl04-tools gimp\n'
printf 'governor: %s (pinned for timed runs; set GOVERNOR=... to choose)\n' "$GOVERNOR"
local need; need="$(pkgs_for_missing)"
if [ -z "${need// /}" ]; then
printf 'dnf: all installable tools present\n'
else
printf 'dnf: missing:%s\n' "$need"
printf ' install with: INSTALL_DEPS=1 sudo dnf install -y%s\n' "$need"
fi
printf 'headless part needs: sudo (power/governor/turbostat) + a GGUF model for the LLM test.\n'
printf 'headed part needs: a graphical session + glmark2/vkmark.\n'
}
# --- sampler + cleanup --------------------------------------------------------
SPID=""
start_sampler() {
if [[ -n "$SUDO" ]] && have python3 && [[ -f "$SAMPLER" ]]; then
section "start power/thermal sampler (powerlog.py)"
$SUDO python3 "$SAMPLER" --tag "$TAG" --interval 1 &
SPID=$!
sleep 1
printf 'sampler pid %s -> results/power-%s.csv\n' "$SPID" "$TAG"
else
printf 'power sampler skipped (needs sudo + python3; use "ssh -t" over SSH)\n'
fi
}
stop_sampler() {
[[ -n "$SPID" ]] || return 0
kill -INT "$SPID" 2>/dev/null || $SUDO pkill -INT -f "powerlog.py --tag $TAG" 2>/dev/null
wait "$SPID" 2>/dev/null
SPID=""
}
CLEANED=0
cleanup() {
[[ "$CLEANED" == "1" ]] && return 0
CLEANED=1
stop_sampler
[[ -n "$KEEPID" ]] && { kill "$KEEPID" 2>/dev/null; wait "$KEEPID" 2>/dev/null; KEEPID=""; }
restore_governor
rm -f "$FIOFILE" 2>/dev/null
}
on_signal() { cleanup; exit 130; }
trap 'on_signal' INT TERM
trap 'cleanup' EXIT
# --- test groups --------------------------------------------------------------
run_env() {
section "environment (tag: $TAG)"
phase "env"
{
printf 'CPU_MODEL=%s\n' "$CPU_MODEL"
date
uname -a
lscpu
free -h
lsblk
have stress-ng && stress-ng --version
have glxinfo && glxinfo -B 2>/dev/null
have vulkaninfo && vulkaninfo --summary 2>/dev/null | head -70
printf 'DRI_PRIME=%s MESA_VK_DEVICE_SELECT=%s VK_LOADER_DEVICE_SELECT=%s\n' \
"${DRI_PRIME:-}" "${MESA_VK_DEVICE_SELECT:-}" "${VK_LOADER_DEVICE_SELECT:-}"
[ -x "$PT" ] && sha256sum "$PT"
printf 'LLAMA_BIN=%s\nLLAMA_MODEL=%s\n' "$LLAMA_BIN" "$LLAMA_MODEL"
printf 'MEM/CAPACITY: MemTotal=%s VRAM(dGPU)=%s\n' \
"$(awk '/MemTotal/{printf "%.1f GiB", $2/1048576}' /proc/meminfo)" \
"$(cat /sys/class/drm/card*/device/mem_info_vram_total 2>/dev/null | awk 'BEGIN{m=0}{if($1>m)m=$1}END{if(m)printf "%.1f GiB",m/1073741824; else print "?"}')"
printf 'MODELS:\n'
find "$HOME/models" -iname '*.gguf' -printf '%s\t%p\n' 2>/dev/null \
| sort -n | awk '{printf " %.2f GiB\t%s\n", $1/1073741824, $2}'
} > "env-$TAG.txt" 2>&1
printf 'wrote results/env-%s.txt\n' "$TAG"
}
# --- LLM inference (llama.cpp, bundled) ---------------------------------------
# Self-contained: the llama-bench runtime + a small model live under tools/llama/.
# Targets token GENERATION on CPU/RAM (ngl=0), which is memory-bound, so the tg
# number reflects how well this machine's RAM bandwidth serves an LLM.
run_llama() {
if [ ! -x "$LLAMA_BIN" ]; then
printf 'skip (missing %s)\n' "$LLAMA_BIN"; return 0
fi
if [ ! -f "$LLAMA_MODEL" ]; then
printf 'skip (missing model %s)\n' "$LLAMA_MODEL"; return 0
fi
local size; size="$(du -h "$LLAMA_MODEL" 2>/dev/null | cut -f1)"
printf 'bench: %s\nmodel: %s (%s)\nngl=%s (CPU/RAM) tg=%s reps=%s\n' \
"$LLAMA_BIN" "$LLAMA_MODEL" "${size:-?}" "$LLAMA_NGL" "$LLAMA_TG" "$LLAMA_REPS"
(
cd "$LLAMA_DIR/bin" || exit 1
LD_LIBRARY_PATH="$PWD" ./llama-bench \
-m "$LLAMA_MODEL" -ngl "$LLAMA_NGL" -p 0 -n "$LLAMA_TG" -r "$LLAMA_REPS"
) 2>&1 | tee "llama-$TAG.txt"
local tg
tg="$(awk -F'|' '/ tg[0-9]/{gsub(/ /,"",$8); print $8; exit}' "llama-$TAG.txt" | sed 's/±.*//')"
if [ -z "$tg" ]; then
printf 'llama: no t/s parsed — check %s (missing libs?)\n' "llama-$TAG.txt"
return 0
fi
reg_score "llama-tg" "$tg"
}
# --- app workloads (stock apps + fixed corpora) -------------------------------
# Phases and score names are the benchmark names: libreoffice gegl inkscape gimp.
# Each registers a rate (items/s) so higher stays better for the score/W table.
run_libreoffice() {
section "APP: LibreOffice — documents to PDF"
phase "libreoffice"
if ! have libreoffice; then
printf 'skip (libreoffice missing) — sudo dnf install -y libreoffice-writer libreoffice-calc\n'
return 0
fi
setup_corpus lo-sample-documents-1.zip "$CORPUS_URL/lo-sample-documents-1.zip" \
a1e92da67999d8ed91ad6330b47828207285c146a3a4ebd7b95ad39cb2d62dcd libreoffice \
|| { printf 'skip (corpus unavailable)\n'; return 0; }
local d="$CORPUS/libreoffice" out="$CORPUS/libreoffice-out" log="libreoffice-$TAG.txt"
local n t0 t1 el r
n=$(find "$d/documents" -type f 2>/dev/null | wc -l)
[ "$n" -gt 0 ] || { printf 'skip (no documents in corpus)\n'; return 0; }
rm -rf "$out"; mkdir -p "$out"
{ libreoffice --version; printf 'convert %s documents -> PDF (headless, default filters)\n' "$n"; } > "$log" 2>&1
t0=$(now)
libreoffice --headless --convert-to pdf --outdir "$out" "$d"/documents/* >> "$log" 2>&1
t1=$(now)
el="$(secs "$t0" "$t1")"; r="$(per_s "$n" "$el")"
cat "$log"
printf 'libreoffice: %s docs in %ss = %s docs/s\n' "$n" "$el" "$r"
reg_score "libreoffice" "$r"
}
run_gegl() {
section "APP: GEGL — image operations"
phase "gegl"
if ! have gegl; then
printf 'skip (gegl missing) — sudo dnf install -y gegl04-tools\n'
return 0
fi
setup_corpus sample-photo-6000x4000-1.zip "$CORPUS_URL/sample-photo-6000x4000-1.zip" \
cadcd434abcdc82a220e7022c749b6fa5f46bb7a32ec14423ca8aab067b0e989 gegl \
|| { printf 'skip (corpus unavailable)\n'; return 0; }
local d="$CORPUS/gegl" img log="gegl-$TAG.txt" t0 t1 el r op
img="$(find "$d" -maxdepth 1 -iname '*.jpg' 2>/dev/null | head -1)"
[ -n "$img" ] || { printf 'skip (no JPEG in corpus)\n'; return 0; }
local -a ops=( "rotate-on-center degrees=90" "scale-size x=400 y=400" "antialias" \
"cartoon" "color-enhance" "crop x=100 y=100 width=1920 height=1080" \
"wavelet-blur" "reflect" "tile-glass tile-width=20 tile-height=20" )
{ gegl --version 2>&1 | head -1; printf '%s ops x 1 image (%s)\n' "${#ops[@]}" "$(basename "$img")"; } > "$log"
t0=$(now)
for op in "${ops[@]}"; do
printf -- '-- %s --\n' "$op" >> "$log"
# shellcheck disable=SC2086
gegl -i "$img" -o "$d/out.png" -- $op >> "$log" 2>&1
done
t1=$(now)
el="$(secs "$t0" "$t1")"; r="$(per_s "${#ops[@]}" "$el")"
cat "$log"
printf 'gegl: %s ops in %ss = %s ops/s\n' "${#ops[@]}" "$el" "$r"
reg_score "gegl" "$r"
}
run_inkscape() {
section "APP: Inkscape — SVG to PNG"
phase "inkscape"
if ! have inkscape; then
printf 'skip (inkscape missing) — sudo dnf install -y inkscape\n'
return 0
fi
setup_corpus svg-test-files-1.zip "$CORPUS_URL/svg-test-files-1.zip" \
c373998bebff0a88f0c4e6fec383eaa62690f544c6eb2b1ad7c55b1547e4db08 inkscape \
|| { printf 'skip (corpus unavailable)\n'; return 0; }
local d="$CORPUS/inkscape" out="$CORPUS/inkscape-out" log="inkscape-$TAG.txt"
local n t0 t1 el r f
n=$(find "$d" -maxdepth 1 -name '*.svg' 2>/dev/null | wc -l)
[ "$n" -gt 0 ] || { printf 'skip (no SVG in corpus)\n'; return 0; }
rm -rf "$out"; mkdir -p "$out"
{ inkscape --version; printf 'export %s SVG files -> PNG\n' "$n"; } > "$log" 2>&1
t0=$(now)
for f in "$d"/*.svg; do
inkscape --export-filename="$out/$(basename "${f%.svg}").png" "$f" >> "$log" 2>&1
done
t1=$(now)
el="$(secs "$t0" "$t1")"; r="$(per_s "$n" "$el")"
cat "$log"
printf 'inkscape: %s SVGs in %ss = %s images/s\n' "$n" "$el" "$r"
reg_score "inkscape" "$r"
}
# GIMP batch Script-Fu. GIMP 3 renamed/reshaped much of the PDB, so pick the
# script for the installed major version. GIMP 3 support is best-effort: verify
# the log if the op count or timing looks off.
gimp_script_v2() {
cat > "$1" <<'SCM'
(define (batch-op op pattern)
(let* ((filelist (cadr (file-glob pattern 1))))
(while (not (null? filelist))
(let* ((filename (car filelist))
(image (car (gimp-file-load RUN-NONINTERACTIVE filename filename)))
(drawable (car (gimp-image-get-active-layer image))))
(cond ((string=? op "unsharp-mask")
(plug-in-unsharp-mask RUN-NONINTERACTIVE image drawable 15.0 0.6 0))
((string=? op "resize") (gimp-image-scale image 600 400))
((string=? op "rotate") (gimp-image-rotate image 0))
((string=? op "auto-levels") (gimp-levels-stretch drawable)))
(gimp-file-save RUN-NONINTERACTIVE image drawable filename filename)
(gimp-image-delete image))
(set! filelist (cdr filelist)))))
SCM
}
gimp_script_v3() {
cat > "$1" <<'SCM'
(script-fu-use-v3)
(define (batch-op op pattern)
(let* ((filelist (file-glob #:pattern pattern #:filename-encoding TRUE)))
(while (not (null? filelist))
(let* ((filename (car filelist))
(image (gimp-file-load RUN-NONINTERACTIVE filename))
(drawable (vector-ref (gimp-image-get-selected-drawables image) 0)))
(cond ((string=? op "unsharp-mask")
(plug-in-unsharp-mask RUN-NONINTERACTIVE image drawable 15.0 0.6 0))
((string=? op "resize") (gimp-image-scale image 600 400))
((string=? op "rotate") (gimp-image-rotate image 0))
((string=? op "auto-levels") (gimp-levels-stretch drawable)))
(gimp-file-save RUN-NONINTERACTIVE image filename)
(gimp-image-delete image))
(set! filelist (cdr filelist)))))
SCM
}
run_gimp() {
section "APP: GIMP — batch image operations"
phase "gimp"
if ! have gimp; then
printf 'skip (gimp missing) — sudo dnf install -y gimp\n'
return 0
fi
setup_corpus sample-photo-6000x4000-1.zip "$CORPUS_URL/sample-photo-6000x4000-1.zip" \
cadcd434abcdc82a220e7022c749b6fa5f46bb7a32ec14423ca8aab067b0e989 gimp \
|| { printf 'skip (corpus unavailable)\n'; return 0; }
local d="$CORPUS/gimp" src log="gimp-$TAG.txt" w scm gv t0 t1 el r op
src="$(find "$d" -maxdepth 1 -iname '*.jpg' 2>/dev/null | head -1)"
[ -n "$src" ] || { printf 'skip (no JPEG in corpus)\n'; return 0; }
w="$CORPUS/gimp-work"; rm -rf "$w"; mkdir -p "$w"
scm="$w/bench.scm"
gv="$(gimp --version 2>&1 | grep -oE '[0-9]+' | head -1)"; gv="${gv:-3}"
if [ "$gv" -ge 3 ] 2>/dev/null; then gimp_script_v3 "$scm"; else gimp_script_v2 "$scm"; fi
{ gimp --version 2>&1 | head -1; printf '4 ops x 1 image (v%s script)\n' "$gv"; } > "$log"
t0=$(now)
for op in unsharp-mask resize rotate auto-levels; do
cp -f "$src" "$w/sample.JPG"
printf -- '-- %s --\n' "$op" >> "$log"
if [ "$gv" -ge 3 ] 2>/dev/null; then
GIMP3_DIRECTORY="$w/cfg" gimp -i --batch-interpreter=plug-in-script-fu-eval \
-b "(load \"$scm\")" -b "(batch-op \"$op\" \"$w/*.JPG\")" -b '(gimp-quit 0)' >> "$log" 2>&1
else
GIMP2_DIRECTORY="$w/cfg" gimp -i \
-b "(load \"$scm\")" -b "(batch-op \"$op\" \"$w/*.JPG\")" -b '(gimp-quit 0)' >> "$log" 2>&1
fi
done
t1=$(now)
el="$(secs "$t0" "$t1")"; r="$(per_s 4 "$el")"
cat "$log"
printf 'gimp: 4 ops in %ss = %s ops/s\n' "$el" "$r"
reg_score "gimp" "$r"
}
run_apps() {
section "APP WORKLOADS (LibreOffice / GEGL / Inkscape / GIMP)"
run_libreoffice
run_gegl
run_inkscape
run_gimp
}
run_headless() {
section "PASSMARK PERFORMANCETEST LINUX — CPU suite (cpubenchmark.net)"
phase "PassMark-cpu"
if [ -x "$PT" ]; then
"$PT" -r 1 -d 2 </dev/null | tee "passmark-cpu-$TAG.txt"
for cand in results_cpu.yml "$TOOLS/pt/results_cpu.yml" \
"$(dirname "$PT")/results_cpu.yml"; do
[ -f "$cand" ] && { mv -f "$cand" "passmark-cpu-$TAG.yml"; break; }
done
if [ -s "passmark-cpu-$TAG.yml" ]; then
parse_passmark "passmark-cpu-$TAG.yml" "PassMark-cpu" "cpu"
else
printf 'WARNING: PassMark CPU suite produced no results_cpu.yml — is %s a real\n' "$PT"
printf ' binary? (a placeholder/stub here silently breaks the score)\n'
fi
else
printf 'skip (not found: %s) — see README section 4\n' "$PT"
fi
section "PASSMARK PERFORMANCETEST LINUX — Memory suite"
phase "PassMark-mem"
if [ -x "$PT" ]; then
"$PT" -r 2 -d 2 </dev/null | tee "passmark-mem-$TAG.txt"
for cand in results_memory.yml "$TOOLS/pt/results_memory.yml" \
"$(dirname "$PT")/results_memory.yml"; do
[ -f "$cand" ] && { mv -f "$cand" "passmark-mem-$TAG.yml"; break; }
done
if [ -s "passmark-mem-$TAG.yml" ]; then
parse_passmark "passmark-mem-$TAG.yml" "PassMark-mem" "mem"
else
printf 'WARNING: PassMark Memory suite produced no results_memory.yml (see above)\n'
fi
else
printf 'skip (not found: %s) — see README section 4\n' "$PT"
fi
[ -s "scores-$TAG.csv" ] && printf 'wrote results/scores-%s.csv (PassMark)\n' "$TAG"
section "CPU: 7zip"
phase "7zip"
if have 7z; then
7z b -mmt"$(nproc)" | tee "7z-$TAG.txt"
reg_score "7zip" "$(7z_mips "7z-$TAG.txt")"
else
printf 'skip (7z missing)\n'
fi
section "CPU: 7zip 1 thread (isolated single-core)"
phase "7zip-1t"
if have 7z; then
7z b -mmt1 | tee "7z-1t-$TAG.txt"
reg_score "7zip-1t" "$(7z_mips "7z-1t-$TAG.txt")"
else
printf 'skip (7z missing)\n'
fi
section "CPU: openssl aes-256-gcm / sha256"
phase "openssl"
if have openssl; then
openssl speed -multi "$(nproc)" -evp aes-256-gcm 2>&1 | tail -20 | tee "openssl-aes-$TAG.txt"
openssl speed -multi "$(nproc)" -evp sha256 2>&1 | tail -20 | tee "openssl-sha-$TAG.txt"
reg_score "openssl-aes" "$(openssl_gbs "openssl-aes-$TAG.txt")"
reg_score "openssl-sha" "$(openssl_gbs "openssl-sha-$TAG.txt")"
fi
section "CPU: sysbench 1 thread / all threads"
phase "sysbench-1t"
if have sysbench; then
sysbench cpu --threads=1 run | tee "sysbench-1t-$TAG.txt"
reg_score "sysbench-1t" "$(sysbench_eps "sysbench-1t-$TAG.txt")"
phase "sysbench-nt"
sysbench cpu --threads="$(nproc)" run | tee "sysbench-nt-$TAG.txt"
reg_score "sysbench-nt" "$(sysbench_eps "sysbench-nt-$TAG.txt")"
phase "sysbench-mem"
sysbench memory --threads="$(nproc)" run | tee "sysbench-mem-$TAG.txt"
reg_score "sysbench-mem" "$(sysbench_mbs "sysbench-mem-$TAG.txt")"
else
printf 'skip (sysbench missing) — sudo dnf install -y sysbench\n'
fi
section "LLM INFERENCE: llama.cpp (estimate model capacity & tok/s)"
phase "llama"
run_llama
if [[ "$RUN_APPS" == "1" ]]; then
run_apps
else
section "APP WORKLOADS: skipped (RUN_APPS=0)"
fi
section "STORAGE: fio (same drive, $FIOFILE)"
phase "fio"
if have fio; then
mkdir -p "$(dirname "$FIOFILE")"
for job in "seqread read 1M 16" "seqwrite write 1M 16" "randread randread 4k 32" "randwrite randwrite 4k 32"; do
set -- $job
printf '\n-- fio %s --\n' "$1"
phase "fio-$1"
fio --name="$1" --rw="$2" --bs="$3" --numjobs=1 --iodepth="$4" \
--size="$FIO_SIZE" --direct=1 --time_based --runtime="$FIO_RUNTIME" \
--filename="$FIOFILE" --group_reporting \
--output-format=json --output="fio-$1-$TAG.json"
case "$1" in
seqread) fio_reg "$1" read bw_bytes ;;
seqwrite) fio_reg "$1" write bw_bytes ;;
randread) fio_reg "$1" read iops ;;
randwrite) fio_reg "$1" write iops ;;
esac
rm -f "$FIOFILE"
done
else
printf 'skip (fio missing)\n'
fi
section "THERMALS / POWER / SUSTAINED CLOCKS"
phase "thermal-load"
if have stress-ng; then
stress-ng --cpu 0 --cpu-method matrixprod --timeout 300 --metrics-brief > "stress-ng-$TAG.txt" 2>&1 &
LOADPID=$!
sleep 120
have sensors && sensors > "sensors-load-$TAG.txt"
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq >> "sensors-load-$TAG.txt" 2>/dev/null
if have turbostat && [[ -n "$SUDO" ]]; then
# this turbostat has no --timeout; bound it with coreutils timeout instead
timeout 30 $SUDO turbostat --interval 5 --quiet \
--show Core,CPU,Busy%,Bzy_MHz,PkgWatt,PkgTmp \
| tee "turbostat-load-$TAG.txt"
fi
wait "$LOADPID" 2>/dev/null
else
printf 'skip (stress-ng missing)\n'
fi
section "DESKTOP / SYSTEM"
phase "systemd"
if have systemd-analyze; then
systemd-analyze | tee "systemd-analyze-$TAG.txt"
systemd-analyze blame | head -20 >> "systemd-analyze-$TAG.txt"
fi
}
run_headed() {
section "GPU: glmark2 / vkmark (pinned to ${GPU_PCI:-default})"
# One representative scene each by default (fast, comparable). Override the
# scene/options with GLMARK2_BENCH / VKMARK_BENCH, or the seconds with
# GLMARK2_DURATION / VKMARK_DURATION.
phase "glmark2"
if have glmark2; then
glmark2 -b "terrain:duration=${GLMARK2_DURATION:-10}" 2>&1 | tail -25 | tee "glmark2-$TAG.txt"
reg_score "glmark2" "$(gpu_score glmark2 "glmark2-$TAG.txt")"
else
printf 'skip (glmark2 missing) — sudo dnf install -y glmark2\n'
fi
phase "vkmark"
if have vkmark; then
vkmark -b "${VKMARK_BENCH:-texture}:duration=${VKMARK_DURATION:-10}" 2>&1 | tail -30 | tee "vkmark-$TAG.txt"
reg_score "vkmark" "$(gpu_score vkmark "vkmark-$TAG.txt")"
else
printf 'skip (vkmark missing) — sudo dnf install -y vkmark\n'
fi
have glxinfo && glxinfo -B | tee "glxinfo-$TAG.txt"
have vulkaninfo && vulkaninfo --summary 2>/dev/null | tee "vulkan-$TAG.txt" >/dev/null
}
require_display() {
if [[ -z "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then
printf 'ERROR: no display (WAYLAND_DISPLAY/DISPLAY unset).\n' >&2
printf 'The "headed" part must run inside the KDE session (e.g. Konsole),\n' >&2
printf 'not over a plain SSH command. Use "ssh -X"/"ssh -t" only for "headless".\n' >&2
exit 2
fi
}
# --- main ---------------------------------------------------------------------
TITLE="$TAG [$PART]"
printf '\033[1mbasic-benchmark — %s\033[0m\n' "$TITLE"
if [[ "$DOCTOR" == "1" ]]; then doctor; exit 0; fi
# Fail fast: the headed part is defined by having a display.
[[ "$PART" == "headed" ]] && require_display
acquire_sudo
if [[ -n "$SUDO" ]]; then start_sudo_keepalive; else
printf 'WARNING: no sudo — power logging, governor and turbostat will be skipped\n'
printf ' run over "ssh -t" (allocate a TTY) to let sudo prompt.\n'
fi
: > "$PHASES"
: > "scores-$TAG.csv"
printf '\033[1mCPU under test: %s\033[0m\n' "$CPU_MODEL"
printf '%s\n' "$CPU_MODEL" > "cpu-$TAG.txt"
install_deps
pin_gpu_env
pin_governor
start_sampler
run_env
run_idle
case "$PART" in
headless) run_headless ;;
headed) run_headed ;;
all)
run_headless
if [[ "$RUN_GPU" == "1" ]] && [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]]; then
run_headed
else
section "GPU: skipped (no desktop session or RUN_GPU=0)"
fi
;;
esac
stop_sampler
section "POWER / THERMAL REPORT"
if have python3 && [[ -f "$REPORT" ]]; then
python3 "$REPORT" --tags "$TAG" || printf 'report failed\n'
fi
section "DONE ($TAG)"
printf 'results in: %s\n' "$RES"
printf 'score file (optional, for efficiency): %s/scores-%s.csv\n' "$RES" "$TAG"
printf 'compare: python3 power-report.py --tags before-headless after-headless\n'
printf 'remember to back up results/ before a reinstall.\n'