Posted on
Artificial Intelligence

Artificial Intelligence Performance Baselines

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Artificial Intelligence Performance Baselines on Linux: A Bash-First Playbook

Ever noticed your model running 20–30% slower “this week” and no one can agree on why? Kernel updates, BLAS swaps, power profiles, or a sneaky driver change can crater AI performance without touching a single line of code. The fix is not guesswork; it’s reproducible, Linux-native baselining.

This post gives you a practical, Bash-first workflow to create and maintain AI performance baselines. You’ll install a minimal toolkit, fingerprint your machine, define stable metrics, run a real model benchmark, and store results for easy comparison.

Why baselines matter (and why Linux helps)

  • AI stacks are layered: kernel, drivers, compilers, BLAS, runtimes, frameworks, models. Any layer can shift performance.

  • Linux gives you first-class introspection and control: CPU affinity, NUMA locality, kernel counters, and standardized package management.

  • Baselines turn “feels slower” into “p50 latency regressed 12.4% after kernel 6.8.x; IPC dropped; L3 misses climbed 18%.” That’s how teams fix problems fast.

1) Install the minimal benchmarking toolkit

You’ll use:

  • hyperfine: quick, statistically sound wall-clock benchmarking

  • perf: CPU performance counters (cycles, instructions, cache, branch)

  • numactl: NUMA and CPU pinning

  • lm-sensors: temperature and power (where supported)

  • Python + pip: to run a simple ONNX Runtime model benchmark

  • git: to version your baseline data

  • podman: optional, for containerized, repeatable environments

Install with your package manager:

Debian/Ubuntu (apt):

sudo apt update
sudo apt install -y \
  python3 python3-venv python3-pip git hyperfine numactl lm-sensors podman \
  linux-tools-common linux-tools-generic linux-tools-$(uname -r)

Fedora/RHEL/CentOS (dnf):

sudo dnf install -y \
  python3 python3-pip git hyperfine numactl perf lm_sensors podman

openSUSE Leap/Tumbleweed (zypper):

sudo zypper install -y \
  python3 python3-pip git hyperfine numactl perf lm_sensors podman

First-time sensors setup (optional but useful):

sudo sensors-detect --auto
sensors

perf permissions (if you get “permission denied”):

# Temporarily lower restrictions (resets on reboot)
echo -1 | sudo tee /proc/sys/kernel/perf_event_paranoid

2) Freeze your environment and fingerprint the machine

Do this before you run a single benchmark; record it with your results.

Machine fingerprint:

# OS, kernel, CPU, memory, NUMA topology, microcode
cat /etc/os-release
uname -a
lscpu
free -h
numactl -H
dmesg | grep -i microcode | tail -n 1 || true

Pin CPU/NUMA placement for reproducibility:

# Example: pin to CPU cores 0–7 on NUMA node 0 (adjust to your system)
export CPUSET=0-7
export NUMANODE=0

Control threading (most AI libs respect these):

export OMP_NUM_THREADS=8
export MKL_NUM_THREADS=8
export OPENBLAS_NUM_THREADS=8
export NUMEXPR_NUM_THREADS=8
# Optional: stable thread placement for Intel MKL/OpenMP
export KMP_AFFINITY=granularity=fine,compact,1,0

Optional power stability (if allowed):

# Force "performance" scaling governor (root; may vary by distro)
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

3) Define your core metrics and a repeatable run command

Track at least:

  • Latency (p50, p90) and throughput (samples/sec)

  • CPU counters (cycles, instructions, IPC, cache-misses)

  • Peak memory and temperature (helps spot throttling)

Wrap your workload with hyperfine for wall-clock numbers:

hyperfine --warmup 3 --runs 10 --style basic 'taskset -c ${CPUSET} numactl -N ${NUMANODE} -m ${NUMANODE} ./run_workload.sh'

Add perf stat for micro-architectural insight:

perf stat -e cycles,instructions,cache-misses,branches,branch-misses \
  taskset -c ${CPUSET} numactl -N ${NUMANODE} -m ${NUMANODE} ./run_workload.sh

Quick thermal check while running:

watch -n 1 sensors

4) Real-world example: ONNX Runtime ResNet50 (CPU)

You’ll benchmark image inference on CPU using ONNX Runtime. Results generalize to other models/runtimes.

Create a Python virtual environment:

python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install onnxruntime numpy

Download a model and create a tiny runner:

curl -L -o resnet50-v1-12.onnx https://github.com/onnx/models/raw/main/vision/classification/resnet/model/resnet50-v1-12.onnx

Create bench_onnx.py:

#!/usr/bin/env python3
import argparse, time, statistics, os
import numpy as np
import onnxruntime as ort

def run(batch, iters, warmup):
    so = ort.SessionOptions()
    sess = ort.InferenceSession("resnet50-v1-12.onnx", sess_options=so, providers=["CPUExecutionProvider"])
    input_name = sess.get_inputs()[0].name
    x = np.random.rand(batch, 3, 224, 224).astype(np.float32)

    # Warmup
    for _ in range(warmup):
        sess.run(None, {input_name: x})

    times = []
    for _ in range(iters):
        t0 = time.perf_counter()
        sess.run(None, {input_name: x})
        times.append(time.perf_counter() - t0)

    lat_ms = [t*1000 for t in times]
    p50 = statistics.median(lat_ms)
    p90 = np.percentile(lat_ms, 90)
    thr = (batch * iters) / sum(times)
    print(f"batch={batch} iters={iters} p50_ms={p50:.2f} p90_ms={p90:.2f} thr_sps={thr:.2f}")

if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--batch", type=int, default=1)
    ap.add_argument("--iters", type=int, default=50)
    ap.add_argument("--warmup", type=int, default=10)
    args = ap.parse_args()
    # Optional: fixed seed for determinism of data shapes (not timing)
    np.random.seed(int(os.environ.get("BASELINE_SEED", "1234")))
    run(args.batch, args.iters, args.warmup)

Make it executable:

chmod +x bench_onnx.py

Baseline run (pin CPU/NUMA, set threads):

export CPUSET=0-7
export NUMANODE=0
export OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8

taskset -c ${CPUSET} numactl -N ${NUMANODE} -m ${NUMANODE} \
  ./bench_onnx.py --batch 1 --iters 100

Compare batches and thread counts with hyperfine:

hyperfine --warmup 3 --runs 10 --export-json baseline.json \
  'OMP_NUM_THREADS=4 MKL_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4 taskset -c 0-3 numactl -N 0 -m 0 ./bench_onnx.py --batch 1 --iters 50' \
  'OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 1 --iters 50' \
  'OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 8 --iters 50'

Add perf context to a single representative command:

perf stat -e cycles,instructions,cache-misses,branches,branch-misses \
  taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 8 --iters 200

Tip: Repeat the baseline after any of these change:

  • Kernel or microcode updates

  • BLAS/oneDNN/MKL/OpenBLAS updates

  • ONNX Runtime/PyTorch/TensorFlow upgrades

  • Power/thermal firmware changes

  • Docker/Podman base image updates

Optional: run in a container for stronger isolation:

podman run --rm -it -v "$(pwd)":/work -w /work docker.io/library/python:3.11-slim bash -lc '
  apt-get update && apt-get install -y --no-install-recommends numactl && \
  pip install --no-cache-dir onnxruntime numpy && \
  OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 \
  taskset -c 0-7 numactl -N 0 -m 0 python bench_onnx.py --batch 8 --iters 200
'

5) Store and compare results like code

Make a simple baseline directory that includes environment and results:

stamp=$(date +%Y%m%d_%H%M%S)
host=$(hostname -s)
mkdir -p baselines/${host}_${stamp}
# Fingerprint
{
  echo "# OS/KERNEL"
  cat /etc/os-release
  uname -a
  echo "# CPU/MEM/NUMA"
  lscpu
  free -h
  numactl -H
} > baselines/${host}_${stamp}/system.txt

# Metrics
hyperfine --warmup 3 --runs 10 --export-json baselines/${host}_${stamp}/hyperfine.json \
  'OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 1 --iters 100' \
  'OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 8 --iters 100'

# perf (single representative run)
perf stat -x, -o baselines/${host}_${stamp}/perf.csv \
  -e cycles,instructions,cache-misses,branches,branch-misses \
  taskset -c 0-7 numactl -N 0 -m 0 ./bench_onnx.py --batch 8 --iters 500

Track over time with git:

git init
git add baselines
git commit -m "Add first AI baseline ${host} ${stamp}"

Later, to see regressions, diff hyperfine JSONs (jq helps) or just re-run and compare p50/p90/throughput and perf counters. If p50 latency regresses while IPC drops and cache-misses climb, suspect memory locality or a library change. If temps spike, check throttling.

Extra: What about GPUs?

This post focused on CPU to stay distro-portable. For GPU baselines, keep the same structure:

  • Use vendor-recommended drivers and monitoring tools (nvidia-smi for NVIDIA, rocm-smi for AMD, intel_gpu_top for Intel).

  • Pin driver/runtime versions and collect GPU clocks, power, and memory bandwidth data during runs.

  • Keep CPU pinning and NUMA locality in mind; CPU-side input pipelines can bottleneck GPU throughput.

Because GPU driver installs vary by distro and vendor repos, follow your vendor’s official instructions and then integrate those tools into the same hyperfine/perf/sensors workflow.

Conclusion and next steps

Performance confidence is a feature. With a small, Linux-native toolkit and a few disciplined steps, you can detect regressions early, explain them with data, and ship with speed.

Your next move:

  • Copy the commands above into a baseline.sh script and run it today on your primary training/inference hosts.

  • Commit system fingerprints and results to git.

  • Re-run after each weekly update window or CI image refresh.

  • Extend the example to your real workloads (PyTorch, TensorFlow, tokenizers, data loaders) while keeping the same shell scaffolding.

If you want a hand turning this into a cron job or CI step, ask—and we’ll template it for your stack.