More robust error handling if fio fails

This commit is contained in:
2026-06-21 22:35:59 -06:00
parent 4f0bb2f613
commit 706baff65c
2 changed files with 86 additions and 9 deletions
+5
View File
@@ -4,6 +4,11 @@ A robust, CrystalDiskMarkstyle storage benchmark for Linux, built on [`fio`](
It runs the same four tests CrystalDiskMark does, plus a dedicated **SLOG / syncwrite latency profile** for diagnosing ZFS ZIL performance (NFS / iSCSI / VM sync workloads). It autodetects the best IO engine and whether `O_DIRECT` works on the target, and falls back to a basic `dd` test when `fio` isn't installed. It runs the same four tests CrystalDiskMark does, plus a dedicated **SLOG / syncwrite latency profile** for diagnosing ZFS ZIL performance (NFS / iSCSI / VM sync workloads). It autodetects the best IO engine and whether `O_DIRECT` works on the target, and falls back to a basic `dd` test when `fio` isn't installed.
## Quick Install
```bash
wget https://git.mreschke.net/mreschke/speedtest-hd/raw/branch/master/speedtest-hd.py && chmod a+x speedtest-hd.py
```
--- ---
## Table of contents ## Table of contents
+81 -9
View File
@@ -32,6 +32,7 @@ import glob
import json import json
import os import os
import shutil import shutil
import socket
import statistics import statistics
import subprocess import subprocess
import sys import sys
@@ -179,6 +180,19 @@ def step(message: str) -> None:
log(f" {ERR.paint('', BOLD, CYAN)} {ERR.paint(message, DIM)}") log(f" {ERR.paint('', BOLD, CYAN)} {ERR.paint(message, DIM)}")
# Messages already shown by warn(); fio tends to fail the same way on every run,
# so we de-duplicate to avoid printing the identical error 8 times.
_warned: set[str] = set()
def warn(message: str) -> None:
"""A de-duplicated warning line -> stderr (shown even without --verbose)."""
if message in _warned:
return
_warned.add(message)
log(f" {ERR.paint('!', BOLD, YELLOW)} {ERR.paint(message, YELLOW)}")
def vsection(title: str) -> None: def vsection(title: str) -> None:
"""A clear, ruled header for one --verbose fio section -> stderr.""" """A clear, ruled header for one --verbose fio section -> stderr."""
width = 78 width = 78
@@ -367,10 +381,41 @@ def run_fio(
return _aggregate(data["jobs"], _direction_of(rw)) return _aggregate(data["jobs"], _direction_of(rw))
except (json.JSONDecodeError, KeyError, ValueError): except (json.JSONDecodeError, KeyError, ValueError):
# fio failed or produced no parseable JSON; report zeros rather than # fio failed or produced no parseable JSON; report zeros rather than
# crashing the whole run (verbose mode above shows what went wrong). # crashing the whole run, but surface *why* so an all-zero table isn't
# silent. fio writes its real error to stderr (engine not loadable,
# permission denied, io_uring disabled, etc.).
detail = proc.stderr.strip() or proc.stdout.strip() or "(no output)"
first = detail.splitlines()[0] if detail else "(no output)"
warn(f"fio failed (exit {proc.returncode}): {first}")
return FioResult.zero() return FioResult.zero()
def fio_health() -> tuple[bool, str]:
"""Check that fio can actually *run* on this host before we rely on it.
Some packaged/Homebrew fio builds are compiled for a newer CPU baseline
(e.g. x86-64-v3 / AVX2) than the machine has, so fio dies with SIGILL
("illegal hardware instruction") the instant it starts -- which would
otherwise show up as a silent table of zeros. ``fio --version`` exercises
that same startup path without needing sudo or touching the disk.
Returns ``(ok, detail)``; ``detail`` is fio's version on success or a short
failure description (a negative return code means killed by signal N).
"""
try:
proc = subprocess.run(["fio", "--version"], capture_output=True, text=True)
except OSError as exc: # e.g. ENOEXEC on a bad binary
return False, str(exc)
if proc.returncode == 0:
return True, proc.stdout.strip() or "ok"
if proc.returncode < 0: # killed by a signal
sig = -proc.returncode
names = {4: "SIGILL (illegal instruction)", 6: "SIGABRT", 11: "SIGSEGV"}
return False, f"crashed: {names.get(sig, f'signal {sig}')}"
detail = (proc.stderr.strip() or proc.stdout.strip() or "").splitlines()
return False, f"exit {proc.returncode}" + (f": {detail[0]}" if detail else "")
def fio_probe(path: str, engine: str, direct: bool) -> bool: def fio_probe(path: str, engine: str, direct: bool) -> bool:
"""Throwaway 1s fio job to see if an (engine, O_DIRECT) combo works here. """Throwaway 1s fio job to see if an (engine, O_DIRECT) combo works here.
@@ -442,6 +487,8 @@ def banner(title: str, cfg: Config, extra: Sequence[str] = ()) -> None:
+ OUT.paint("", BOLD, CYAN) + OUT.paint("", BOLD, CYAN)
) )
print(OUT.paint("" + "" * inner + "", BOLD, CYAN)) print(OUT.paint("" + "" * inner + "", BOLD, CYAN))
print(_meta_line(f"Host : {OUT.paint(socket.gethostname(), CYAN)}"))
print(_meta_line(f"Date : {OUT.paint(time.strftime('%Y-%m-%d %H:%M:%S %Z'), CYAN)}"))
print(_meta_line(f"Target : {OUT.paint(cfg.path, CYAN)}")) print(_meta_line(f"Target : {OUT.paint(cfg.path, CYAN)}"))
for line_ in extra: for line_ in extra:
print(_meta_line(line_)) print(_meta_line(line_))
@@ -694,23 +741,48 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
benchfile=os.path.join(path, "speedtest-hd.bench"), benchfile=os.path.join(path, "speedtest-hd.bench"),
) )
have_fio = shutil.which("fio") is not None fio_path = shutil.which("fio")
have_fio = fio_path is not None
# fio may be installed yet non-functional (e.g. a build compiled for AVX2 on
# a CPU without it dies with SIGILL). Verify it actually runs, so we fall back
# or error clearly instead of emitting a silent table of zeros.
fio_broken = ""
if have_fio:
ok, detail = fio_health()
if not ok:
have_fio = False
fio_broken = detail
warn(f"fio is installed ({fio_path}) but {detail}.")
warn("This is usually an fio build compiled for a newer CPU than this")
warn("host (often a Homebrew/AVX2 binary on an older Atom/Celeron). Check")
warn("'which fio'; prefer your distro's package (dnf/apt install fio).")
# Resolve the mode: explicit flag wins; otherwise fio if available, else dd. # Resolve the mode: explicit flag wins; otherwise fio if available, else dd.
mode = cfg.mode mode = cfg.mode
if mode in ("cdm", "slog") and not have_fio: if mode in ("cdm", "slog") and not have_fio:
print(ERR.paint("ERROR:", BOLD, RED) if fio_broken:
+ " --fio/--slog require fio (apt install fio / pacman -S fio).", print(ERR.paint("ERROR:", BOLD, RED)
file=sys.stderr) + f" --fio/--slog need a working fio, but fio {fio_broken} on this"
+ " host. Reinstall fio, or use --dd for the dependency-free test.",
file=sys.stderr)
else:
print(ERR.paint("ERROR:", BOLD, RED)
+ " --fio/--slog require fio (dnf install fio / apt install fio).",
file=sys.stderr)
return 1 return 1
if mode is None: if mode is None:
if have_fio: if have_fio:
mode = "cdm" mode = "cdm"
else: else:
print(OUT.paint("\nfio is not installed -- falling back to basic dd test.", if fio_broken:
YELLOW)) print(OUT.paint(f"\nfio is installed but {fio_broken} -- falling back "
print(OUT.paint("Install fio for the full CrystalDiskMark-style benchmark.", "to the basic dd test.", YELLOW))
DIM)) else:
print(OUT.paint("\nfio is not installed -- falling back to basic dd test.",
YELLOW))
print(OUT.paint("Install fio for the full CrystalDiskMark-style benchmark.",
DIM))
mode = "dd" mode = "dd"
confirm(cfg) confirm(cfg)