Files
speedtest-hd/README.md
T

16 KiB
Raw Blame History

speedtest-hd

A robust, CrystalDiskMarkstyle storage benchmark for Linux, built on fio by mReschke and my buddy Claude Opus.

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.


Table of contents


Features

  • CrystalDiskMarkstyle profileSEQ1M Q8T1, SEQ1M Q1T1, RND4K Q32T16, RND4K Q1T1 (CrystalDiskMark's default profile), each measured for Read and Write, reported in both MB/s and IOPS.
  • SLOG / syncwrite latency profile (--slog) — synchronous 4K writes at T1/T4/T8/T16 reporting IOPS, MB/s, and p50/p99 commit latency. This is the load a ZFS SLOG actually sees.
  • Autodetection — picks the fastest available IO engine (io_uringlibaioposixaiosync) and probes whether O_DIRECT works on the filesystem, falling back to buffered IO when it doesn't (e.g. older OpenZFS, some NFS mounts).
  • dd fallback — if fio isn't present, runs a basic write/read test so you still get a number.
  • Verbose mode--verbose dumps the full raw fio output for every run while keeping the summary table intact.

Requirements

  • python3 (3.7+) — the tool itself. It parses fio's JSON output using only the standard library (no thirdparty packages to install).
  • fio — recommended (apt install fio / pacman -S fio). Without it, the tool falls back to a basic dd test.
  • sudofio is invoked via sudo so it can use O_DIRECT and flush device caches.

Usage

./speedtest-hd.py <path> [options]
# or: python3 speedtest-hd.py <path> [options]

<path> is the directory (or mount) to benchmark. Use . for the current directory. The tool creates a single test file (default 1 GiB) on the target and removes it afterward, so ensure enough free space.

Modes

Invocation What it does
./speedtest-hd.py /mnt/disk Auto: uses fio if installed, else dd
./speedtest-hd.py /mnt/disk --fio Force the fio CrystalDiskMarkstyle profile
./speedtest-hd.py /mnt/disk --dd Force the basic dd test
./speedtest-hd.py /mnt/disk --slog SLOG / syncwrite latency profile

--fio, --dd, and --slog are mutually exclusive.

Tuning flags

Flag Effect
--engine {io_uring,libaio,posixaio,sync} Force a specific IO engine (default: auto)
--direct Force O_DIRECT (bypass page cache)
--buffered Force buffered IO (e.g. when O_DIRECT is unsupported)
--runtime SEC Seconds per run (default: 5, like CrystalDiskMark)
--size SIZE Test file size (default: 1g)
--verbose Also print the full fio output for every run (summary table unchanged)
-y, --yes Skip the confirmation prompt (for scripting/automation)

All flags accept either --flag value or --flag=value (argparse). --direct/--buffered are mutually exclusive.

Examples

# CrystalDiskMark-style test of the current directory
./speedtest-hd.py .

# Larger file, longer runs, on an NVMe pool
./speedtest-hd.py /mnt/nvmepool --runtime=10 --size=4g

# Buffered (e.g. an NFS share that doesn't support O_DIRECT)
./speedtest-hd.py /mnt/nfsshare --buffered

# SLOG / sync latency profile, 30s per run
./speedtest-hd.py /mnt/nvme-ultra-r10/vm-root --slog --runtime=30

# Unattended (no prompt), forcing a specific engine
./speedtest-hd.py /mnt/nvmepool --yes --engine io_uring

Tip: when running --slog against a ZFS dataset, watch the SLOG live in another shell:

zpool iostat -vl <pool> 1

Output

CrystalDiskMarkstyle profile

Representative output from a healthy local NVMe (your numbers will differ):

+------------------+----------------+----------------+----------------+----------------+
| Test             |    Read (MB/s) |   Write (MB/s) |    Read (IOPS) |   Write (IOPS) |
+------------------+----------------+----------------+----------------+----------------+
| SEQ1M  Q8T1      |        3650.00 |        3120.00 |           3482 |           2976 |
| SEQ1M  Q1T1      |        2680.00 |        2510.00 |           2556 |           2394 |
| RND4K  Q32T16    |        2950.00 |        2240.00 |         720215 |         546875 |
| RND4K  Q1T1      |          78.00 |          64.00 |          19043 |          15625 |
+------------------+----------------+----------------+----------------+----------------+

SLOG / syncwrite latency profile (--slog)

+------------------+--------------+--------------+--------------+--------------+
| Test             |         IOPS |         MB/s |  p50 lat(us) |  p99 lat(us) |
+------------------+--------------+--------------+--------------+--------------+
| 4K sync T1       |        10687 |        43.77 |         85.5 |        185.3 |
| 4K sync T4       |        29873 |       122.36 |        117.8 |        317.4 |
| 4K sync T8       |        52612 |       215.50 |        136.2 |        391.2 |
| 4K sync T16      |        77939 |       319.24 |        180.0 |        505.9 |
+------------------+--------------+--------------+--------------+--------------+

Understanding the tests

The CrystalDiskMark profile

Test Pattern Queue depth Threads
SEQ1M Q8T1 Sequential 1 MiB 8 1
SEQ1M Q1T1 Sequential 1 MiB 1 1
RND4K Q32T16 Random 4 KiB 32 16
RND4K Q1T1 Random 4 KiB 1 1

Q = queue depth (--iodepth), T = threads (--numjobs). Note that --iodepth only produces real queue depth when the IO is truly asynchronous (async engine and O_DIRECT). On filesystems where that isn't available, queue depth effectively collapses toward 1 and concurrency comes only from threads (T).

The SLOG profile

--slog forces synchronous 4K random writes (--sync=1O_SYNC) via the portable psync engine. Every write becomes a durable commit, so it traverses the ZFS ZIL / SLOG commit path exactly the way a sync=always dataset (NFS, iSCSI, VM storage) does — regardless of the dataset's own sync property. It sweeps thread counts (T1 → T4 → T8 → T16):

  • T1 is the headline singlestream latency (e.g. one database committing in a tight loop).
  • The sweep shows how the SLOG scales as concurrent sync writers pile on (multiple VMs / NFS clients) — usually the more important number for a virtualization host.

A healthy Optane SLOG (e.g. P1600X) singlestream target is roughly 1525k IOPS, p50 ~4065 µs. Much higher latency usually points at CPU Cstates / PCIe ASPM / a BIOS power profile throttling the host — see the case study below.


Case study: diagnosing a "slow" Optane SLOG on TrueNAS

A real investigation that this tool's --slog mode was built to support. Spoiler: the Optane SSD was healthy the entire time. The bottleneck was CPU power management.

The setup

Component Detail
Server Dell PowerEdge R630
CPU Intel Xeon E52680 v3 (HaswellEP, 12C/24T, 2.5 GHz base, 3.3 GHz turbo)
OS TrueNAS SCALE 25.10 (OpenZFS 2.3)
Pool nvme-ultra-r10 — 6× 4 TB KingSpec XG7000 NVMe in RAID10 (3 mirror vdevs)
SLOG Intel Optane P1600X
Dataset /mnt/nvme-ultra-r10/vm-root, sync=always

The symptom

The standard benchmark looked alarming — huge reads, tiny writes:

+------------------+------------------+------------------+
| Test             |      Read (MB/s) |     Write (MB/s) |
+------------------+------------------+------------------+
| SEQ1M  Q8T1      |          6873.00 |             9.30 |
| SEQ1M  Q1T1      |          1608.00 |            20.00 |
| RND4K  Q32T1     |           538.00 |            10.80 |
| RND4K  Q32T16    |           689.00 |           261.00 |
+------------------+------------------+------------------+

9.3 MB/s sequential write on an Optanebacked NVMe pool looks broken.

Note: this investigation was captured with an earlier test profile that used RND4K Q32T1 in place of today's RND4K Q1T1. The Q32T1 vs Q32T16 comparison below is exactly why the default later changed — see finding #3.

Investigation

1. The reads are RAM, not disk. With a 1 GiB test file on ZFS, reads come straight from ARC (RAM cache). The huge read/write asymmetry is the tell — ignore the read column for judging the disks.

2. sync=always makes writes latencybound. Every write must be durably committed to the ZIL before it's acknowledged, so throughput ≈ (block size) ÷ (percommit latency). Anything running at low effective concurrency looks slow regardless of raw device speed.

3. The Q8/Q32 labels were misleading. On this ZFS setup --iodepth didn't produce real queue depth, so most rows effectively ran at QD1. Proof: RND4K Q32T1 (10.8 MB/s) vs RND4K Q32T16 (261 MB/s) — the 24× jump came entirely from threads (numjobs=16), not queue depth.

4. Large sequential sync writes bypass the SLOG. With ZFS's default logbias=latency, writes larger than zfs_immediate_write_sz (32 KB) use an indirect ZIL record — the data goes straight to the main pool and only a pointer hits the Optane. So the SEQ1M write test was measuring the consumer KingSpec pool's forcedsync performance, not the SLOG. Only small (4K) sync writes exercise the Optane.

5. Confirm with zpool iostat -vl <pool> 1 during a 4K sync test. This was decisive:

  • The logs (Optane) vdev took all the sync writes (~2.32.6k ops, ~1820 MB/s); the data vdevs were idle between txg flushes. → SLOG configured correctly, logbias fine, not bypassed.
  • But the Optane's own disk_wait was ~90 µs (a P1600X should be ~1015 µs), and the fiolevel commit latency was ~328 µs — meaning ~238 µs was being spent above the device, in the host/ZFS/CPU path.

That "faster when busy, slow when idle" device latency plus huge host overhead is the classic signature of powersaving idle states on a latencybound, QD1 workload.

6. Find the throttle. Checking the CPU revealed the cores pinned at 1.2 GHz — the E52680 v3's minimum Pstate — on a chip rated for 2.53.3 GHz:

$ grep MHz /proc/cpuinfo | sort -u
cpu MHz : 1200.069
...

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_driver
intel_cpufreq                # = intel_pstate in passive mode
$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
schedutil                    # picks frequency from CPU utilization

The vicious loop: a QD1 sync workload spends each commit blocked waiting on the SLOG → the schedutil governor sees nearzero utilization → parks the cores at 1.2 GHz → the ZFS commit code path runs ~23× slower → latency climbs.

Root cause

CPU power management, in two layers — not the SSD, pool, or PCIe link:

  1. BIOS System Profile = "Performance Per Watt Optimized (DAPC)" — Dell Active Power Controller manages Cstates/Pstates in firmware and largely ignores the OS, keeping cores idling deep and clocked low.
  2. OS schedutil governor (TrueNAS SCALE default) — pinned cores at the 1.2 GHz floor for this bursty, IOblocked workload.

The fixes

Applied in order, biggest impact last:

1. BIOS System Profile → Performance (disables Cstates/C1E, raises Pstates):

# In BIOS (F2): System BIOS → System Profile Settings → System Profile → Performance
# Or via iDRAC:
racadm set BIOS.SysProfileSettings.SysProfile PerfOptimized
racadm jobqueue create BIOS.Setup.1-1
# reboot to apply

2. Kernel parameters (target PCIe/NVMe link power saving + residual Cstates).

Use the TrueNAS midctl command to add custom Kernel Boot Arguments, then reboot

midclt call system.advanced.config

midclt call system.advanced.update '{"kernel_extra_options": "intel_idle.max_cstate=1 processor.max_cstate=1 pcie_aspm=off nvme_core.default_ps_max_latency_us=0"}'

3. CPU governor → performance (the single biggest win):

grep MHz /proc/cpuinfo | sort -u
echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
grep MHz /proc/cpuinfo | sort -u

Make it persistent on TrueNAS — System → Advanced Settings → Init/Shutdown Scripts, add a Post Init Command:

echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor

⚠️ Without the Post Init script the governor reverts to schedutil on every reboot, silently dropping you back to slow numbers.

Results — before & after

4K synchronous random writes (--slog), per stage of the fix:

Stage T1 IOPS T1 p50 T1 MB/s T16 IOPS T16 MB/s T16 p99
DAPC (start) ~3,050 ~328 µs ~12.5 38 (regressing) thrashing
BIOS → Performance 5,849 160.8 µs 23.96 46,009 188.45 1057 µs
+ kernel parameters 6,217 150.5 µs 25.46 47,166 193.19 684 µs
+ performance governor 10,687 85.5 µs 43.77 77,939 319.24 506 µs

Full final result:

+------------------+--------------+--------------+--------------+--------------+
| Test             |         IOPS |         MB/s |  p50 lat(us) |  p99 lat(us) |
+------------------+--------------+--------------+--------------+--------------+
| 4K sync T1       |        10687 |        43.77 |         85.5 |        185.3 |
| 4K sync T4       |        29873 |       122.36 |        117.8 |        317.4 |
| 4K sync T8       |        52612 |       215.50 |        136.2 |        391.2 |
| 4K sync T16      |        77939 |       319.24 |        180.0 |        505.9 |
+------------------+--------------+--------------+--------------+--------------+

Net improvement: ~3.5× IOPS, ~3.8× lower latency at T1, and ~8.4× aggregate throughput at T16 — with the scaling regression eliminated entirely.

Lessons learned

  • An Optane SLOG showing high latency is usually a hostside powermanagement problem, not the device. Confirm where the time goes before blaming hardware.
  • zpool iostat -vl <pool> 1 is the key diagnostic — it shows whether the logs vdev is actually taking the writes and splits device latency (disk_wait) from host/ZFS overhead (total_wait).
  • Latencybound QD1 sync workloads are the worst case for power saving. The CPU looks idle (blocked on IO), so governors and firmware clock it down — which directly inflates the latency you're trying to measure.
  • On TrueNAS SCALE, the default schedutil governor cripples syncwrite latency. Set performance (and persist it).
  • Reads from a small test file measure ARC (RAM), not the disk. Watch the read/write asymmetry.
  • Large sync writes bypass the SLOG (indirect ZIL) — to actually test a SLOG, use small (4K) sync writes, which is exactly what --slog does.

~85 µs is roughly the floor here

The residual gap from raw Optane (~15 µs) is ZFS ZILcommit overhead plus the perop cost of a 2014era Haswell core. Closing it further would need a newer/faster CPU for sharply diminishing returns. For a virtualization host the aggregate (78k IOPS / 319 MB/s) is what the workload feels, and it's healthy.


Notes & caveats

  • sudo is used for fio so it can apply O_DIRECT and flush device write caches at the end of write runs (--end_fsync=1), so cached writes can't inflate results.
  • O_DIRECT is autodetected. If the banner shows O_DIRECT: DISABLED (buffered ...), results may reflect the page cache (RAM) rather than the device.
  • A single shared test file is reused across runs to keep the footprint to one file.
  • All profiles parse fio's JSON output (--output-format=json) with Python's standard library — robust, unitsafe metrics with no fragile text scraping.
  • The --slog profile forces synchronous IO and is intended for ZFS ZIL / SLOG and other syncwrite (NFS/iSCSI/VM) investigations.