Last active 5 days ago

Hard drive speed test using fio or dd

Revision ff28788f29f5f004187777af3475244215634fdb

speedtest-hd.py Raw
1#!/usr/bin/env python3
2"""speedtest-hd — a CrystalDiskMark-style storage benchmark for Linux, on fio.
3
4This is the Python successor to ``speedtest-hd.sh``. It measures storage the way
5CrystalDiskMark does, and adds a dedicated **SLOG / sync-write latency profile**
6for diagnosing ZFS ZIL performance (NFS / iSCSI / VM sync workloads).
7
8Three profiles
9--------------
10* **cdm** — the four CrystalDiskMark default tests (``SEQ1M Q8T1``, ``SEQ1M
11 Q1T1``, ``RND4K Q32T16``, ``RND4K Q1T1``), each measured for read *and*
12 write, reported in MB/s and IOPS.
13* **slog** — synchronous 4K random writes swept across thread counts, reporting
14 IOPS, MB/s and p50/p99 *commit latency*. This is the load a ZFS SLOG sees.
15* **dd** — a dependency-free fallback when ``fio`` isn't installed.
16
17Why fio JSON?
18-------------
19Every measurement asks fio for ``--output-format=json`` and parses it with the
20standard library. That's the whole reason this is Python and not bash: robust,
21unit-safe parsing of bandwidth / IOPS / latency percentiles with no fragile
22text scraping.
23
24See README.md for the full case study (diagnosing a "slow" Optane SLOG that
25turned out to be CPU power management, not the disk).
26"""
27
28from __future__ import annotations
29
30import argparse
31import glob
32import json
33import os
34import shutil
35import statistics
36import subprocess
37import sys
38import time
39from dataclasses import dataclass, field
40from typing import Callable, Optional, Sequence
41
42# --------------------------------------------------------------------------- #
43# Color / styling (standard library only -- raw ANSI escape codes)
44# --------------------------------------------------------------------------- #
45
46# SGR escape sequences. We stick to the basic 16-color set so output respects
47# the user's terminal theme instead of hard-coding RGB.
48RESET = "\033[0m"
49BOLD = "\033[1m"
50DIM = "\033[2m"
51ITALIC = "\033[3m"
52RED = "\033[31m"
53GREEN = "\033[32m"
54YELLOW = "\033[33m"
55BLUE = "\033[34m"
56MAGENTA = "\033[35m"
57CYAN = "\033[36m"
58WHITE = "\033[37m"
59BRIGHT_CYAN = "\033[96m"
60
61
62def supports_color(stream) -> bool:
63 """Decide per-stream whether to emit ANSI codes.
64
65 Honors the de-facto ``NO_COLOR`` / ``FORCE_COLOR`` conventions and otherwise
66 colors only when the stream is an interactive terminal.
67 """
68 if os.environ.get("NO_COLOR"):
69 return False
70 if os.environ.get("FORCE_COLOR"):
71 return True
72 if os.environ.get("TERM") == "dumb":
73 return False
74 return bool(getattr(stream, "isatty", None)) and stream.isatty()
75
76
77class Painter:
78 """Wraps text in SGR codes, or returns it untouched when color is off."""
79
80 def __init__(self, enabled: bool):
81 self.enabled = enabled
82
83 def paint(self, text: str, *codes: str) -> str:
84 if not self.enabled or not codes:
85 return text
86 return "".join(codes) + text + RESET
87
88
89# stdout carries results (banner + tables); stderr carries progress + verbose
90# fio dumps. Each gets its own enable flag so piping one but not the other works.
91OUT = Painter(supports_color(sys.stdout))
92ERR = Painter(supports_color(sys.stderr))
93
94
95# --------------------------------------------------------------------------- #
96# Constants
97# --------------------------------------------------------------------------- #
98
99# Preference order for the IO engine. io_uring is the modern, lowest-overhead
100# Linux engine; libaio is older (and only truly async with O_DIRECT); posixaio
101# and sync are the portable fallbacks (e.g. some NFS mounts).
102ENGINE_CANDIDATES = ("io_uring", "libaio", "posixaio", "sync")
103
104# Binary unit multipliers (fio treats "1g" as 1 GiB, so we match that).
105_SIZE_UNITS = {"k": 1024, "m": 1024**2, "g": 1024**3, "t": 1024**4}
106
107
108@dataclass(frozen=True)
109class CdmTest:
110 """One CrystalDiskMark test. ``seq`` picks sequential vs random patterns."""
111
112 label: str
113 bs: str
114 iodepth: int
115 numjobs: int
116 seq: bool
117
118 @property
119 def read_rw(self) -> str:
120 return "read" if self.seq else "randread"
121
122 @property
123 def write_rw(self) -> str:
124 return "write" if self.seq else "randwrite"
125
126
127# The four CrystalDiskMark default tests, as data, in CrystalDiskMark's own
128# display order. Q = queue depth (iodepth), T = threads (numjobs).
129CDM_TESTS: tuple[CdmTest, ...] = (
130 CdmTest("SEQ1M Q8T1", bs="1m", iodepth=8, numjobs=1, seq=True),
131 CdmTest("SEQ1M Q1T1", bs="1m", iodepth=1, numjobs=1, seq=True),
132 CdmTest("RND4K Q32T16", bs="4k", iodepth=32, numjobs=16, seq=False),
133 CdmTest("RND4K Q1T1", bs="4k", iodepth=1, numjobs=1, seq=False),
134)
135
136# Thread counts for the SLOG sweep. T1 is the headline single-stream latency;
137# the higher counts show how the SLOG scales as concurrent sync writers pile on.
138SLOG_THREADS: tuple[int, ...] = (1, 4, 8, 16)
139
140
141# --------------------------------------------------------------------------- #
142# Configuration
143# --------------------------------------------------------------------------- #
144
145
146@dataclass
147class Config:
148 """Resolved run settings. ``engine`` and ``direct`` may start unset and get
149 filled in by :func:`detect_io_settings`."""
150
151 path: str
152 mode: Optional[str] # "cdm" | "slog" | "dd" | None (auto)
153 engine: Optional[str] # None => auto-detect
154 direct: Optional[bool] # None => auto-detect
155 runtime: int
156 size: str
157 verbose: bool
158 assume_yes: bool
159 benchfile: str = field(default="")
160
161 @property
162 def direct_flag(self) -> int:
163 """fio's --direct takes 0/1; default to 0 until detection runs."""
164 return 1 if self.direct else 0
165
166
167# --------------------------------------------------------------------------- #
168# Small helpers
169# --------------------------------------------------------------------------- #
170
171
172def log(message: str) -> None:
173 """Progress/diagnostic line -> stderr, so stdout stays pure results."""
174 print(message, file=sys.stderr, flush=True)
175
176
177def step(message: str) -> None:
178 """A single colored progress line ('measuring ...') -> stderr."""
179 log(f" {ERR.paint('', BOLD, CYAN)} {ERR.paint(message, DIM)}")
180
181
182def vsection(title: str) -> None:
183 """A clear, ruled header for one --verbose fio section -> stderr."""
184 width = 78
185 head = f"─── {title} "
186 head += "" * max(3, width - len(head))
187 log("")
188 log(ERR.paint(head, BOLD, CYAN))
189
190
191def parse_size_bytes(size: str) -> int:
192 """Turn an fio-style size ("4k", "1g", "512m", "2048") into bytes."""
193 s = size.strip().lower()
194 if s and s[-1] in _SIZE_UNITS:
195 return int(float(s[:-1]) * _SIZE_UNITS[s[-1]])
196 return int(s)
197
198
199def render_table(
200 headers: Sequence[str],
201 rows: Sequence[Sequence[str]],
202 aligns: Optional[Sequence[str]] = None,
203 col_styles: Optional[Sequence[Optional[str]]] = None,
204) -> str:
205 """Render a bordered ASCII table whose columns auto-size to their content.
206
207 ``aligns`` is a per-column "<" (left) or ">" (right); by default the first
208 column is left-aligned (the label) and the rest are right-aligned (numbers).
209 ``col_styles`` is an optional per-column ANSI style applied to body cells
210 (header is always bold, borders dim). Widths are computed on the *plain*
211 text and color is applied after padding, so escape codes never skew layout.
212 """
213 ncols = len(headers)
214 if aligns is None:
215 aligns = ["<"] + [">"] * (ncols - 1)
216 if col_styles is None:
217 col_styles = [None] * ncols
218
219 # Widest cell (header or any row) sets each column's width.
220 widths = [
221 max([len(str(headers[c]))] + [len(str(r[c])) for r in rows])
222 for c in range(ncols)
223 ]
224
225 bar = OUT.paint("|", DIM)
226 sep = OUT.paint(" | ", DIM)
227
228 def rule() -> str:
229 return OUT.paint("+" + "+".join("-" * (w + 2) for w in widths) + "+", DIM)
230
231 def line(cells: Sequence[str], *, header: bool = False) -> str:
232 parts = []
233 for c in range(ncols):
234 padded = f"{str(cells[c]):{aligns[c]}{widths[c]}}"
235 if header:
236 parts.append(OUT.paint(padded, BOLD))
237 elif col_styles[c]:
238 parts.append(OUT.paint(padded, col_styles[c]))
239 else:
240 parts.append(padded)
241 return f"{bar} " + sep.join(parts) + f" {bar}"
242
243 out = [rule(), line(headers, header=True), rule()]
244 out += [line(r) for r in rows]
245 out.append(rule())
246 return "\n".join(out)
247
248
249# --------------------------------------------------------------------------- #
250# fio: results, invocation, parsing
251# --------------------------------------------------------------------------- #
252
253
254@dataclass(frozen=True)
255class FioResult:
256 """Parsed metrics for one direction (read or write) of an fio run.
257
258 Latencies are completion latency (clat) in microseconds; for synchronous
259 writes that is effectively the durable-commit latency.
260 """
261
262 bw_mbps: float # decimal MB/s, matching CrystalDiskMark's SI figure
263 iops: float
264 p50_us: float
265 p99_us: float
266 mean_us: float
267
268 @classmethod
269 def zero(cls) -> "FioResult":
270 return cls(0.0, 0.0, 0.0, 0.0, 0.0)
271
272
273def _direction_of(rw: str) -> str:
274 """fio reports 'read' and 'write' sub-objects; pick the active one."""
275 return "read" if "read" in rw else "write"
276
277
278def _aggregate(jobs: list[dict], direction: str) -> FioResult:
279 """Combine per-job fio JSON stats into a single :class:`FioResult`.
280
281 We deliberately run fio *without* --group_reporting and aggregate ourselves,
282 which sidesteps fio's group-merge quirks: sum throughput, average the median
283 latency, and take the worst-case tail (p99) across jobs.
284 """
285
286 def section(job: dict) -> dict:
287 return job.get(direction, {})
288
289 bw_mbps = sum(section(j).get("bw_bytes", 0) for j in jobs) / 1e6
290 iops = sum(section(j).get("iops", 0.0) for j in jobs)
291
292 def percentiles(key: str) -> list[float]:
293 vals = []
294 for j in jobs:
295 pct = section(j).get("clat_ns", {}).get("percentile", {})
296 v = pct.get(key)
297 if v is not None:
298 vals.append(v)
299 return vals
300
301 p50_ns = percentiles("50.000000")
302 p99_ns = percentiles("99.000000")
303 mean_ns = [section(j).get("clat_ns", {}).get("mean", 0.0) for j in jobs]
304
305 return FioResult(
306 bw_mbps=bw_mbps,
307 iops=iops,
308 p50_us=(statistics.mean(p50_ns) / 1000.0) if p50_ns else 0.0, # avg of medians
309 p99_us=(max(p99_ns) / 1000.0) if p99_ns else 0.0, # worst tail
310 mean_us=(statistics.mean(mean_ns) / 1000.0) if mean_ns else 0.0,
311 )
312
313
314def run_fio(
315 cfg: Config,
316 *,
317 rw: str,
318 bs: str,
319 iodepth: int,
320 numjobs: int,
321 engine: Optional[str] = None,
322 direct: Optional[bool] = None,
323 sync: bool = False,
324 end_fsync: bool = False,
325) -> FioResult:
326 """Run a single fio job against the shared bench file and parse its JSON.
327
328 ``engine``/``direct`` default to the detected config values; the SLOG path
329 overrides them (psync + buffered + O_SYNC). ``end_fsync`` flushes the device
330 cache at the end of a write run so cached writes can't inflate the result.
331 """
332 engine = engine if engine is not None else cfg.engine
333 direct = direct if direct is not None else cfg.direct
334
335 cmd = [
336 "sudo", "fio",
337 "--name=speedtest",
338 f"--filename={cfg.benchfile}",
339 f"--ioengine={engine}",
340 f"--direct={1 if direct else 0}",
341 f"--rw={rw}",
342 f"--bs={bs}",
343 f"--size={cfg.size}",
344 f"--numjobs={numjobs}",
345 f"--iodepth={iodepth}",
346 "--time_based",
347 f"--runtime={cfg.runtime}",
348 "--output-format=json",
349 ]
350 if sync:
351 cmd.append("--sync=1") # O_SYNC: every write is a durable commit
352 if end_fsync:
353 cmd.append("--end_fsync=1") # flush device cache once at the end
354
355 proc = subprocess.run(cmd, capture_output=True, text=True)
356
357 if cfg.verbose:
358 sync_tag = " sync=1" if sync else ""
359 vsection(f"fio · {rw} bs={bs} iodepth={iodepth} numjobs={numjobs}{sync_tag}")
360 log(" " + ERR.paint("$ " + " ".join(cmd[1:]), DIM, ITALIC))
361 log(ERR.paint(proc.stdout.strip() or "(no stdout)", DIM))
362 if proc.stderr.strip():
363 log(ERR.paint(proc.stderr.strip(), YELLOW))
364
365 try:
366 data = json.loads(proc.stdout)
367 return _aggregate(data["jobs"], _direction_of(rw))
368 except (json.JSONDecodeError, KeyError, ValueError):
369 # fio failed or produced no parseable JSON; report zeros rather than
370 # crashing the whole run (verbose mode above shows what went wrong).
371 return FioResult.zero()
372
373
374def fio_probe(path: str, engine: str, direct: bool) -> bool:
375 """Throwaway 1s fio job to see if an (engine, O_DIRECT) combo works here.
376
377 This is how we stay accurate *and* portable across ext4/xfs/btrfs/ZFS/NFS
378 without the caller needing to know what each filesystem supports.
379 """
380 cmd = [
381 "sudo", "fio", "--name=probe",
382 f"--directory={path}",
383 f"--ioengine={engine}",
384 "--rw=write", "--bs=4k", "--size=1m",
385 f"--direct={1 if direct else 0}",
386 "--time_based", "--runtime=1",
387 ]
388 proc = subprocess.run(cmd, capture_output=True, text=True)
389 for leftover in glob.glob(os.path.join(path, "probe*")):
390 try:
391 os.remove(leftover)
392 except OSError:
393 pass
394 return proc.returncode == 0
395
396
397def detect_io_settings(cfg: Config) -> None:
398 """Fill in ``cfg.engine`` and ``cfg.direct`` if the user didn't force them.
399
400 O_DIRECT bypasses the OS page cache so we measure the device, not RAM. Not
401 every filesystem supports it (older OpenZFS, some NFS), so we probe and fall
402 back to buffered rather than erroring out.
403 """
404 if cfg.engine is None:
405 for engine in ENGINE_CANDIDATES:
406 if fio_probe(cfg.path, engine, direct=False):
407 cfg.engine = engine
408 break
409 else:
410 cfg.engine = "sync"
411
412 if cfg.direct is None:
413 cfg.direct = fio_probe(cfg.path, cfg.engine, direct=True)
414
415
416# --------------------------------------------------------------------------- #
417# Profiles
418# --------------------------------------------------------------------------- #
419
420
421def _meta_line(text: str) -> str:
422 """Color the 'Label :' prefix of a banner metadata line, leave value as-is.
423
424 The value may already contain its own ANSI codes (e.g. a red O_DIRECT
425 warning); since these lines aren't width-aligned that's harmless.
426 """
427 key, sep, val = text.partition(":")
428 if not sep:
429 return " " + text
430 return " " + OUT.paint(key + ":", BOLD, BRIGHT_CYAN) + val
431
432
433def banner(title: str, cfg: Config, extra: Sequence[str] = ()) -> None:
434 full = f"speedtest-hd · {title}"
435 inner = len(full) + 4
436
437 print()
438 print(OUT.paint("" + "" * inner + "", BOLD, CYAN))
439 print(
440 OUT.paint("", BOLD, CYAN)
441 + " " + OUT.paint(full, BOLD, WHITE) + " "
442 + OUT.paint("", BOLD, CYAN)
443 )
444 print(OUT.paint("" + "" * inner + "", BOLD, CYAN))
445 print(_meta_line(f"Target : {OUT.paint(cfg.path, CYAN)}"))
446 for line_ in extra:
447 print(_meta_line(line_))
448 print()
449
450
451def cdm_profile(cfg: Config) -> None:
452 """The CrystalDiskMark-style profile: 4 tests x (read, write), MB/s + IOPS."""
453 detect_io_settings(cfg)
454
455 if cfg.direct:
456 direct_label = OUT.paint("enabled (device)", GREEN)
457 else:
458 direct_label = OUT.paint(
459 "DISABLED (buffered -- may reflect RAM cache!)", BOLD, RED
460 )
461
462 banner(
463 "CrystalDiskMark-style storage benchmark",
464 cfg,
465 extra=[
466 f"Engine : {cfg.engine} O_DIRECT: {direct_label}",
467 f"Profile : size={cfg.size.upper()} runtime={cfg.runtime}s/run (8 runs)",
468 ],
469 )
470
471 rows: list[list[str]] = []
472 for test in CDM_TESTS:
473 step(f"measuring {test.label.strip()} ...")
474 r = run_fio(cfg, rw=test.read_rw, bs=test.bs,
475 iodepth=test.iodepth, numjobs=test.numjobs)
476 w = run_fio(cfg, rw=test.write_rw, bs=test.bs,
477 iodepth=test.iodepth, numjobs=test.numjobs, end_fsync=True)
478 rows.append([
479 test.label,
480 f"{r.bw_mbps:.2f}", f"{w.bw_mbps:.2f}",
481 f"{r.iops:.0f}", f"{w.iops:.0f}",
482 ])
483
484 _cleanup(cfg)
485
486 print()
487 print(render_table(
488 ["Test", "Read (MB/s)", "Write (MB/s)", "Read (IOPS)", "Write (IOPS)"],
489 rows,
490 col_styles=[CYAN, GREEN, YELLOW, GREEN, YELLOW],
491 ))
492 print()
493
494
495def slog_profile(cfg: Config) -> None:
496 """SLOG / sync-write latency profile.
497
498 Forces synchronous 4K random writes (O_SYNC) via the portable psync engine,
499 so every write is a ZIL commit -- exercising a ZFS SLOG exactly the way a
500 sync=always dataset (NFS/iSCSI/VM) does, regardless of the dataset's own
501 sync property. Engine/direct detection is only for the banner here.
502 """
503 detect_io_settings(cfg)
504
505 banner(
506 "SLOG / sync-write latency profile",
507 cfg,
508 extra=[
509 "Method : fio randwrite bs=4k --sync=1 (O_SYNC), psync engine",
510 f"Profile : runtime={cfg.runtime}s/run size={cfg.size.upper()}",
511 "Note : every write is a synchronous ZIL commit -- the load your",
512 " SLOG actually sees. Watch it live in another shell with:",
513 " zpool iostat -vl <pool> 1",
514 ],
515 )
516
517 # Measure everything first (progress -> stderr), then draw the whole table,
518 # so the "measuring..." lines can't interleave into the middle of it.
519 rows: list[list[str]] = []
520 for threads in SLOG_THREADS:
521 step(f"measuring 4K sync randwrite T{threads} ...")
522 res = run_fio(
523 cfg, rw="randwrite", bs="4k", iodepth=1, numjobs=threads,
524 engine="psync", direct=False, sync=True,
525 )
526 rows.append([
527 f"4K sync T{threads}",
528 f"{res.iops:.0f}", f"{res.bw_mbps:.2f}",
529 f"{res.p50_us:.1f}", f"{res.p99_us:.1f}",
530 ])
531
532 _cleanup(cfg)
533
534 print()
535 print(render_table(
536 ["Test", "IOPS", "MB/s", "p50 lat(us)", "p99 lat(us)"],
537 rows,
538 col_styles=[CYAN, GREEN, GREEN, YELLOW, MAGENTA],
539 ))
540 print()
541 print(OUT.paint(" Healthy Optane SLOG (eg P1600X) single-stream (T1) target:", BOLD))
542 print(OUT.paint(" ~15-25k IOPS, p50 latency ~40-65us.", GREEN)
543 + OUT.paint(" Much higher latency usually means", DIM))
544 print(OUT.paint(" CPU C-states / PCIe ASPM / BIOS power profile (eg Dell DAPC) throttling.", DIM))
545 print()
546
547
548def dd_profile(cfg: Config) -> None:
549 """Dependency-free fallback when fio isn't installed.
550
551 Far cruder than fio: a single sequential stream, and the cached-read figure
552 will reflect RAM. Use it only as a rough sanity check.
553 """
554 count_mib = max(1, parse_size_bytes(cfg.size) // (1024**2))
555 nbytes = count_mib * 1024**2
556
557 banner(
558 "basic dd benchmark (fio not installed)",
559 cfg,
560 extra=[f"Profile : {count_mib} MiB sequential stream"],
561 )
562
563 def timed_dd(args: list[str]) -> float:
564 start = time.monotonic()
565 subprocess.run(args, capture_output=True, text=True)
566 elapsed = time.monotonic() - start
567 return (nbytes / elapsed / 1e6) if elapsed > 0 else 0.0 # decimal MB/s
568
569 step("measuring sequential write ...")
570 write_mbps = timed_dd([
571 "dd", "if=/dev/zero", f"of={cfg.benchfile}",
572 "bs=1M", f"count={count_mib}", "conv=fdatasync,notrunc",
573 ])
574
575 step("dropping caches for uncached read ...")
576 subprocess.run(["sudo", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"],
577 capture_output=True, text=True)
578
579 step("measuring uncached read ...")
580 uncached_mbps = timed_dd([
581 "dd", f"if={cfg.benchfile}", "of=/dev/null", "bs=1M", f"count={count_mib}",
582 ])
583
584 step("measuring cached read ...")
585 cached_mbps = timed_dd([
586 "dd", f"if={cfg.benchfile}", "of=/dev/null", "bs=1M", f"count={count_mib}",
587 ])
588
589 _cleanup(cfg)
590
591 print()
592 print(render_table(
593 ["Test", "MB/s"],
594 [
595 ["Sequential write", f"{write_mbps:.2f}"],
596 ["Uncached read", f"{uncached_mbps:.2f}"],
597 ["Cached read (RAM)", f"{cached_mbps:.2f}"],
598 ],
599 col_styles=[CYAN, GREEN],
600 ))
601 print()
602
603
604def _cleanup(cfg: Config) -> None:
605 """Remove the shared bench file."""
606 try:
607 os.remove(cfg.benchfile)
608 except OSError:
609 pass
610
611
612# --------------------------------------------------------------------------- #
613# CLI
614# --------------------------------------------------------------------------- #
615
616
617def build_parser() -> argparse.ArgumentParser:
618 p = argparse.ArgumentParser(
619 prog="speedtest-hd.py",
620 description="CrystalDiskMark-style storage benchmark built on fio.",
621 epilog=(
622 "Examples:\n"
623 " speedtest-hd.py .\n"
624 " speedtest-hd.py /mnt/nvmepool --runtime=10 --size=4g\n"
625 " speedtest-hd.py /mnt/nfsshare --buffered\n"
626 " speedtest-hd.py /mnt/nvme-ultra-r10/vm-root --slog --runtime=30\n"
627 ),
628 formatter_class=argparse.RawDescriptionHelpFormatter,
629 )
630 p.add_argument("path", help="directory/mount to benchmark ('.' for cwd)")
631
632 mode = p.add_mutually_exclusive_group()
633 mode.add_argument("--fio", action="store_const", const="cdm", dest="mode",
634 help="force the fio CrystalDiskMark-style profile")
635 mode.add_argument("--dd", action="store_const", const="dd", dest="mode",
636 help="force the basic dd fallback test")
637 mode.add_argument("--slog", action="store_const", const="slog", dest="mode",
638 help="SLOG / sync-write latency profile (ZFS ZIL)")
639
640 direct = p.add_mutually_exclusive_group()
641 direct.add_argument("--direct", action="store_const", const=True, dest="direct",
642 help="force O_DIRECT (bypass page cache)")
643 direct.add_argument("--buffered", action="store_const", const=False, dest="direct",
644 help="force buffered IO (e.g. if O_DIRECT unsupported)")
645
646 p.add_argument("--engine", choices=ENGINE_CANDIDATES,
647 help="force a specific IO engine (default: auto-detect)")
648 p.add_argument("--runtime", type=int, default=5, metavar="SEC",
649 help="seconds per run (default: 5, like CrystalDiskMark)")
650 p.add_argument("--size", default="1g", metavar="SIZE",
651 help="test file size (default: 1g)")
652 p.add_argument("--verbose", action="store_true",
653 help="also print the full fio output for every run")
654 p.add_argument("-y", "--yes", action="store_true", dest="assume_yes",
655 help="skip the confirmation prompt")
656
657 p.set_defaults(mode=None, direct=None)
658 return p
659
660
661def confirm(cfg: Config) -> None:
662 """Guard prompt -- we're about to write a multi-GB file to the target."""
663 if cfg.assume_yes:
664 return
665 print(OUT.paint("NOTICE:", BOLD, YELLOW)
666 + f" {cfg.size.upper()} free space on "
667 + OUT.paint(f"'{cfg.path}'", CYAN)
668 + " is required to perform the benchmark.")
669 answer = input(f"Are you ready to start a storage benchmark against "
670 f"'{cfg.path}' ? ")
671 if not answer.strip().lower().startswith("y"):
672 print(OUT.paint("Ok, cancelled!", YELLOW))
673 sys.exit(0)
674 print(OUT.paint("Great! Starting benchmark now!", BOLD, GREEN))
675
676
677def main(argv: Optional[Sequence[str]] = None) -> int:
678 args = build_parser().parse_args(argv)
679
680 path = os.getcwd() if args.path == "." else args.path
681 if not os.path.exists(path):
682 print(f"Path {path} does not exist", file=sys.stderr)
683 return 1
684
685 cfg = Config(
686 path=path,
687 mode=args.mode,
688 engine=args.engine,
689 direct=args.direct,
690 runtime=args.runtime,
691 size=args.size,
692 verbose=args.verbose,
693 assume_yes=args.assume_yes,
694 benchfile=os.path.join(path, "speedtest-hd.bench"),
695 )
696
697 have_fio = shutil.which("fio") is not None
698
699 # Resolve the mode: explicit flag wins; otherwise fio if available, else dd.
700 mode = cfg.mode
701 if mode in ("cdm", "slog") and not have_fio:
702 print(ERR.paint("ERROR:", BOLD, RED)
703 + " --fio/--slog require fio (apt install fio / pacman -S fio).",
704 file=sys.stderr)
705 return 1
706 if mode is None:
707 if have_fio:
708 mode = "cdm"
709 else:
710 print(OUT.paint("\nfio is not installed -- falling back to basic dd test.",
711 YELLOW))
712 print(OUT.paint("Install fio for the full CrystalDiskMark-style benchmark.",
713 DIM))
714 mode = "dd"
715
716 confirm(cfg)
717
718 # Dictionary dispatch: map each mode name to its handler function (the values
719 # are the functions themselves -- no parentheses), look up the one for `mode`,
720 # then call it with (cfg). Equivalent to an if/elif chain over `mode`. `mode`
721 # is always one of these three keys by now, so the lookup can't KeyError.
722 {"cdm": cdm_profile, "slog": slog_profile, "dd": dd_profile}[mode](cfg)
723 return 0
724
725
726if __name__ == "__main__":
727 try:
728 sys.exit(main())
729 except KeyboardInterrupt:
730 print(ERR.paint("\nInterrupted.", YELLOW), file=sys.stderr)
731 sys.exit(130)
732