diff --git a/README.md b/README.md index bfb9b6e..4fe8fa7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ A robust, CrystalDiskMark‑style storage benchmark for Linux, built on [`fio`]( It runs the same four tests CrystalDiskMark does, plus a dedicated **SLOG / sync‑write latency profile** for diagnosing ZFS ZIL performance (NFS / iSCSI / VM sync workloads). It auto‑detects 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 diff --git a/speedtest-hd.py b/speedtest-hd.py index 40dbc60..043ab2b 100755 --- a/speedtest-hd.py +++ b/speedtest-hd.py @@ -32,6 +32,7 @@ import glob import json import os import shutil +import socket import statistics import subprocess import sys @@ -179,6 +180,19 @@ def step(message: str) -> None: 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: """A clear, ruled header for one --verbose fio section -> stderr.""" width = 78 @@ -367,10 +381,41 @@ def run_fio( return _aggregate(data["jobs"], _direction_of(rw)) except (json.JSONDecodeError, KeyError, ValueError): # 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() +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: """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) ) 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)}")) for line_ in extra: print(_meta_line(line_)) @@ -694,23 +741,48 @@ def main(argv: Optional[Sequence[str]] = None) -> int: 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. mode = cfg.mode if mode in ("cdm", "slog") and not have_fio: - print(ERR.paint("ERROR:", BOLD, RED) - + " --fio/--slog require fio (apt install fio / pacman -S fio).", - file=sys.stderr) + if fio_broken: + print(ERR.paint("ERROR:", BOLD, RED) + + 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 if mode is None: if have_fio: mode = "cdm" 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)) + if fio_broken: + print(OUT.paint(f"\nfio is installed but {fio_broken} -- falling back " + "to the basic dd test.", YELLOW)) + 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" confirm(cfg)