Continuous benchmarks for multi-device CoW filesystems (btrfs, ZFS, bcachefs): snapshots, aging, compression, redundancy layouts
See the codeContinuous benchmarks for multi-device, copy-on-write filesystems — btrfs, ZFS, bcachefs — measuring the things single-device ext4-style benchmarks (Phoronix et al.) never touch: redundancy layouts, snapshot aging and scaling, transparent compression, encryption (native vs LUKS), reflinks, fsync tail latency, degraded operation and rebuild, corruption self-healing, and near-full/ENOSPC behavior — with ext4/xfs over md/LVM as the classic-stack baselines.
Classic filesystem benchmarks run fio on one device with default mkfs options. That says nothing about what modern filesystems are actually deployed for. This suite benchmarks the machinery:
| Phase | What it measures |
|---|---|
| host calibration | fio on the runner's own disk before any filesystem exists — a VM-noise anchor |
| seq / rand write, rand read | baseline throughput on the chosen redundancy layout |
| trivial-op latency under load | "how long until my prompt comes back": a 4k write+fsync every 200ms (shell history, editor swap), p99 and worst case — idle, then while a 1M streaming writer floods the filesystem; CoW commit storms live here |
| source-tree ops | create / cold cp -r / rm -rf of a 20k-small-file tree — the "copy a kernel tree" test |
| large-directory scalability | create 100k empty files in one directory, enumerate names cold, stat every entry cold and warm, then delete — directory indexing and inode-cache behavior that tree-shaped workloads miss (LARGEDIR_FILES=1000000 reproduces the million-file variant) |
| parallel random read | same cold-cache read with 4 concurrent threads — a mirror can only serve from both copies under concurrency, so this is where replica read-scaling shows (on real hardware; CI loop devices share one disk and physically can't) |
| fsync tail latency | p99 / p99.9 fdatasync completion latency from the random-write phase — CoW transaction commits (ZFS txg, btrfs commit interval) spike periodically in ways the IOPS average hides |
| snapshot aging | random-overwrite bandwidth as snapshots accumulate (CoW fragmentation cost) — 100 snapshots where the technology allows; ZFS at 128K recordsize pins ~the whole file per snapshot so its default-recordsize layouts run 10, and old-style LVM snapshots amplify every origin write per snapshot so lvm layouts run 8 (both caps are findings, not shortcuts) |
| snapshot create | metadata cost of taking a snapshot |
| snapshot delete + reclaim | delete latency, foreground write bandwidth while background cleaning runs, time until the space actually returns |
| compression | zstd ratio + write throughput on 75%-compressible data |
| reflink | cp --reflink=always of a large file |
| clone divergence | the unshare penalty: the same 4k-overwrite workload into a plain file, a fresh reflink clone (btrfs/bcachefs/ZFS/xfs), and a freshly-snapshotted file (CoW filesystems and LVM) |
| degraded + rebuild | fail one device: IO while degraded, then time the rebuild onto a spare |
| snapshot-count scaling | 500 snapshots with no churn between them: create latency at the tail, snapshot-list time, remount time, bulk delete (native-snapshot filesystems) |
| near-full / ENOSPC | on a fresh small array of the same layout: write throughput near 95% and 99% full, then fill to hard ENOSPC — can you still delete (CoW needs free space to delete), and does deleting make the fs writable again? Caveat: btrfs hits its chunk-allocation wall before df crosses the target on small devices (1G data chunks are a big fraction of a CI-sized array — on multi-TB disks the same wall sits at 99.9%), so its probes run at the wall; the actual fullness at each probe is recorded in the JSON (nearfull*_pct) |
| corruption + scrub | write 2G of garbage onto one device behind the filesystem's back, scrub, verify the data: CoW filesystems detect and repair from checksums; md/lvm only count mismatches and may silently serve the corrupted copy |
Results are published as a dashboard: https://bartosz.fenski.pl/modern-fs-benchmark/
— per-metric charts sorted best-first, aging curves, and trends across runs,
filterable by filesystem family and layout class (e.g. "btrfs vs bcachefs,
multi-device only"), with linear/log scale switching and a sortable table.
Run history lives on the results-data branch.
Every result records the exact tools and kernel-module versions tested — essential for ZFS and bcachefs, which are out-of-tree, where the kernel version alone doesn't identify what actually ran. Shown in the dashboard table, stored in the JSON.
Default matrix — 26 configurations (4 devices, plus baselines; the
authoritative list is the matrix in .github/workflows/bench.yml):
ext4 single — one device, the "what does any of this cost" anchor
ext4 on md raid10 — the classic layered stack
ext4 on LVM raid10 — layered stack with block-layer CoW snapshots, so the snapshot-aging phase is comparable with the native-CoW filesystems
xfs single / on md raid10 / on LVM raid10 — the same three stacks again; XFS additionally has reflink, unlike ext4
btrfs / bcachefs / ZFS single-device — the CoW filesystems without
redundancy, head-to-head with ext4/xfs single: the pure cost (and features)
of CoW itself. btrfs uses -m single: mkfs defaults to DUP metadata on a
single device, which would double its metadata writes vs every other
single-device row (community catch)
Encryption variants — ZFS native per-dataset AES-256-GCM
(mirror-enc), bcachefs native whole-fs ChaCha20/Poly1305
(replicas2-enc), btrfs over one LUKS layer per device
(raid1-luks — no native option, so every replica is encrypted
separately), and ext4 over a single LUKS layer on top of md
(md-raid10-luks — the classic stack encrypts once, above the raid).
Compression runs on all of them, so encrypt-after-compress vs
opaque-blocks falls out of the existing zstd phase
btrfs — -d raid1 -m raid1
Single-parity — zfs raidz1, plus raidz1-enc with native encryption
on top (community request)
Dual-parity (raid6-class) — zfs raidz2 (and raidz2-enc,
community request), btrfs -d raid6 -m raid1c3
(parity metadata is discouraged — write hole), ext4 on md raid6, and
bcachefs --erasure_code --replicas=3 (stable since 1.37; write-hole-free
by design — writes replicate first, background reconcile stripes them)
(community request, incl. the correction that EC is no longer experimental)
xfs on a ZFS zvol — the Franken-stack people actually run: XFS semantics on top; ZFS snapshots (fsfreeze-consistent), self-healing, and compression underneath (community request)
xfs on LVM raid10 + dm-integrity (--raidintegrity y) — per-sector
checksums give the classic stack detection AND correction: the fairest
classic-vs-CoW comparison in the corruption phase, with the performance
tax quantified (community request)
ZFS — striped mirror pairs (raid10-like), at the default 128K recordsize
and again at recordsize=8k — one-variable proof of how much of ZFS's
small-random-write cost is configuration, not design
bcachefs — --replicas=2 (kernel module built via DKMS from
apt.bcachefs.org since bcachefs left mainline in 6.17)
If you want to try bcachefs as a working storage system rather than just benchmark it, NASty is a NixOS-based NAS appliance built around it and a practical place to start.
Benchmark charts invite "which is fastest". For long-term storage that is the wrong question — the right one is which stack tells you the truth about your data, and it's why this suite exists (the corruption phase re-proves it every couple of hours):
--raidintegrity y (dm-integrity) is the first classic layout to pass
our corruption phase — but almost nobody runs it, and the performance
tax is measurable (that's the xfs/lvm-raid10-int row).Speed matters and we measure it honestly. But if you keep data you care about — photos, archives, the family's one copy of anything — on a non-checksumming stack, no benchmark number compensates for corruption you won't discover until years later. That risk is invisible in every classic filesystem benchmark; here it's a first-class result (corruption + scrub on the dashboard).
Every push/2-hourly cron builds each filesystem across 4 loop devices backed by
sparse files, runs the suite, and publishes a results table in the job summary
plus JSON artifacts. Each job's artifact also contains a full command trace
(raw/<config>-trace.log) — every command executed, arguments fully expanded,
with source file and line — so "what exactly was run" is never a question.
(BENCH_TRACE=1 mirrors it into the live log instead.)
Interpret CI numbers carefully. Runners are shared VMs and all "devices" live on one virtual disk, so absolute MB/s is meaningless and RAID striping gains are fiction. Matrix jobs also run in parallel, each on its own ephemeral VM — so comparing filesystem A against filesystem B compares two different machines. Mitigations, from strongest signal to weakest:
CALIB_MIN_*, ~25% of runners' normal disk speed margin) fail fast
and are automatically rerun on a fresh runner (up to 3 attempts) —
junk numbers from an unlucky VM never enter the results.The same scripts take real block devices — this is where absolute numbers become valid:
sudo BENCH_DEVICES="/dev/sdb /dev/sdc /dev/sdd /dev/sde" BENCH_WIPE=1 \
scripts/run-bench.sh btrfs raid1
Safety: devices must be unmounted, and anything carrying a filesystem
signature is refused unless BENCH_WIPE=1. Listed devices are wiped.
For an unmanaged hardware run, invoke scripts/run-bench.sh directly with
BENCH_DEVICES, BENCH_SPARE_DEVICE, and BENCH_WIPE=1. The dedicated
self-hosted GitHub workflow instead requires the NixOS module below; it never
accepts device paths from workflow inputs. Workload sizes can be adjusted with
SEQ_SIZE, AGING_SIZE, AGING_ITERS, and the other documented environment
variables. CI defaults are sized for 4×16 GB loop files.
This repository is also a flake with a reusable NixOS module and benchmark
package. Cluster configurations can import nixosModules.modern-fs-benchmark;
the module installs a dedicated Actions
runner, the filesystem tools and matching out-of-tree modules for the
cluster-selected kernel, and a restricted root wrapper with fixed device paths.
It deliberately does not select a kernel or configure machine-wide boot,
networking, users, or partitioning.
{
inputs.modern-fs-benchmark.url =
"github:fenio/modern-fs-benchmark";
# In the target node's modules list:
services.modern-fs-benchmark = {
enable = true;
repository = "https://github.com/fenio/modern-fs-benchmark";
tokenFile = "/run/secrets/modern-fs-benchmark-runner";
runnerName = "farm3";
hardwareProfile = "farm3";
runnerLabels = [ "fs-benchmark" ];
devices = [
"/dev/disk/by-partlabel/fsbench-nvme0-a"
"/dev/disk/by-partlabel/fsbench-nvme1-a"
"/dev/disk/by-partlabel/fsbench-nvme0-b"
"/dev/disk/by-partlabel/fsbench-nvme1-b"
];
spareDevice = "/dev/disk/by-partlabel/fsbench-nvme0-spare";
zfsSingleDevice = "/dev/disk/by-partlabel/fsbench-nvme0-zfs-single";
};
}
The four member devices and spare must each be exactly 16 GiB. The dedicated
zfsSingleDevice must be exactly 32 GiB, matching the hosted-runner matrix.
For an unregistered manual run, the same immutable package is available as
nix run .#manual -- <fs> <layout>; provide the documented BENCH_*
environment variables and run it as root.
Set BENCH_HARDWARE_RANDOM_SCALING=1 to include the optional 8- and 16-worker
random read/write measurements and the 4/8/16-worker shard-aware write series
that the managed hardware wrapper enables.
The master cluster flake owns the node assignment and deploy-rs deployment, so
the runner can move to another machine without changing benchmark code. The
dedicated bench-real-hw.yml workflow targets the fs-benchmark label and
uses only the module's fixed devices. It publishes the farm3 history to
results-real-hw and the dashboard under /real-hw/; the existing bench.yml
workflow remains hosted-only and continues publishing results-data at the
root dashboard. Hardware runs can be dispatched manually. The weekly schedule
is enabled only when the repository variable ENABLE_HARDWARE_BENCHMARKS is
set to true. The token file should contain a fine-grained PAT because
ephemeral runners re-register after every job.
The rotational sas-hdd profile is routed independently through the
fs-benchmark-sas-hdd runner label. Its workflow, enable variable, history,
and dashboard are respectively bench-real-hw-sas-hdd.yml,
ENABLE_SAS_HDD_BENCHMARKS, results-real-hw-sas-hdd, and
/sas-hdd/. Every result carries hardware_profile: "sas-hdd", and
publication rejects a missing or mismatched profile so results from different
machines cannot enter the same trend series.
The plan is bigger than loop devices. CI is the regression-tracking harness; the goal is to gather dedicated hardware and run the REAL tests there — including the tiered topologies these filesystems were built for and that no publication benchmarks today: NVMe cache/metadata in front of rotational data disks (bcachefs foreground/background targets, ZFS special/log/cache vdevs, LVM dm-cache with writeback and writethrough), mixed-rotational RAID, and how each setup behaves degraded and while rebuilding. Same suite, same JSON, same dashboard — only the device lists and topology descriptions change.
Regular mixed-media runs depend on access to a machine whose benchmark devices may be wiped. A suitable dedicated server currently costs approximately €70 per month, depending on availability and its exact disk configuration. If recurring sponsorship covers that cost, the server specification, configuration, raw results, and command traces will all remain public.
I am also open to hardware support in other forms:
If a suitable remote server does not become available, I may eventually build and host a runner myself as the budget allows. Financial support is available through GitHub Sponsors and Ko-fi. To offer hardware or discuss a topology, open an issue. Sponsors and hardware providers can be acknowledged if they wish, but do not receive editorial control over the methodology or results.
sudo scripts/install-deps.sh btrfs
sudo scripts/run-bench.sh btrfs raid1
scripts/summarize.sh results/result-*.json
scripts/run-bench.sh orchestrates the phases, emits results/result-<fs>-<layout>.json
scripts/lib/common.sh device layer (loop files or BENCH_DEVICES), LUKS helpers,
corruption injection, fio helpers, default fs hooks
scripts/lib/layered.sh shared md/LVM assembly, snapshots, degrade/repair (ext4 + xfs)
scripts/fs/<fs>.sh per-filesystem backend
scripts/install-deps.sh Debian/Ubuntu package setup per filesystem
scripts/summarize.sh JSON results → markdown table (job summaries)
scripts/make-dashboard.py results history → the static dashboard page
scripts/audit-results.py anomaly scan over the results history — impossible
orderings, self-healing failures, ENOSPC regressions,
unexpected nulls (daily via the results-audit workflow)
scripts/result-schema.json machine-readable result keys, types, capabilities, and display metadata
scripts/result_schema.py shared result schema loading and validation
scripts/validate-result.py validates result JSON against that contract
Result documents carry schema_version; historical unversioned documents are
treated as version 1 so new metrics do not invalidate the stored history.
Adding a filesystem = one file in scripts/fs/ implementing fs_setup,
fs_snapshot, and fs_teardown; everything else (fs_setup_compression,
fs_compress_ratio, fs_snapshot_delete_all, fs_remount, fs_snap_list,
fs_snapscale_delete, fs_degrade, fs_rebuild, fs_scrub, fs_version,
fs_drop_caches, fs_free_bytes) has safe defaults in lib/common.sh and is
optional — unimplemented hooks simply record null for their metrics.
This suite is deliberately open-ended — if you have opinions on what to test and how, please open an issue or PR:
nodatacow, …scripts/fs/)Tuned variants sit next to the defaults in the same matrix (see
zfs mirror-8k), so every suggestion becomes a directly comparable row.
CoW-specific phases (the behaviors nothing mainstream benchmarks):
Infrastructure:
Copyright 2026 Bartosz Fenski.
Source code, configuration, workflows, and documentation are licensed under the
Apache License 2.0. Published benchmark result datasets, including
the results-data, results-real-hw, and results-real-hw-sas-hdd history
branches, are licensed under
Creative Commons Attribution 4.0 International.
137 commits
Python
58.2%
Shell
38.6%
Nix
3.1%
Continuous benchmarks for multi-device CoW filesystems (btrfs, ZFS, bcachefs): snapshots, aging, compression, redundancy layouts
See the codeContinuous benchmarks for multi-device, copy-on-write filesystems — btrfs, ZFS, bcachefs — measuring the things single-device ext4-style benchmarks (Phoronix et al.) never touch: redundancy layouts, snapshot aging and scaling, transparent compression, encryption (native vs LUKS), reflinks, fsync tail latency, degraded operation and rebuild, corruption self-healing, and near-full/ENOSPC behavior — with ext4/xfs over md/LVM as the classic-stack baselines.
Classic filesystem benchmarks run fio on one device with default mkfs options. That says nothing about what modern filesystems are actually deployed for. This suite benchmarks the machinery:
| Phase | What it measures |
|---|---|
| host calibration | fio on the runner's own disk before any filesystem exists — a VM-noise anchor |
| seq / rand write, rand read | baseline throughput on the chosen redundancy layout |
| trivial-op latency under load | "how long until my prompt comes back": a 4k write+fsync every 200ms (shell history, editor swap), p99 and worst case — idle, then while a 1M streaming writer floods the filesystem; CoW commit storms live here |
| source-tree ops | create / cold cp -r / rm -rf of a 20k-small-file tree — the "copy a kernel tree" test |
| large-directory scalability | create 100k empty files in one directory, enumerate names cold, stat every entry cold and warm, then delete — directory indexing and inode-cache behavior that tree-shaped workloads miss (LARGEDIR_FILES=1000000 reproduces the million-file variant) |
| parallel random read | same cold-cache read with 4 concurrent threads — a mirror can only serve from both copies under concurrency, so this is where replica read-scaling shows (on real hardware; CI loop devices share one disk and physically can't) |
| fsync tail latency | p99 / p99.9 fdatasync completion latency from the random-write phase — CoW transaction commits (ZFS txg, btrfs commit interval) spike periodically in ways the IOPS average hides |
| snapshot aging | random-overwrite bandwidth as snapshots accumulate (CoW fragmentation cost) — 100 snapshots where the technology allows; ZFS at 128K recordsize pins ~the whole file per snapshot so its default-recordsize layouts run 10, and old-style LVM snapshots amplify every origin write per snapshot so lvm layouts run 8 (both caps are findings, not shortcuts) |
| snapshot create | metadata cost of taking a snapshot |
| snapshot delete + reclaim | delete latency, foreground write bandwidth while background cleaning runs, time until the space actually returns |
| compression | zstd ratio + write throughput on 75%-compressible data |
| reflink | cp --reflink=always of a large file |
| clone divergence | the unshare penalty: the same 4k-overwrite workload into a plain file, a fresh reflink clone (btrfs/bcachefs/ZFS/xfs), and a freshly-snapshotted file (CoW filesystems and LVM) |
| degraded + rebuild | fail one device: IO while degraded, then time the rebuild onto a spare |
| snapshot-count scaling | 500 snapshots with no churn between them: create latency at the tail, snapshot-list time, remount time, bulk delete (native-snapshot filesystems) |
| near-full / ENOSPC | on a fresh small array of the same layout: write throughput near 95% and 99% full, then fill to hard ENOSPC — can you still delete (CoW needs free space to delete), and does deleting make the fs writable again? Caveat: btrfs hits its chunk-allocation wall before df crosses the target on small devices (1G data chunks are a big fraction of a CI-sized array — on multi-TB disks the same wall sits at 99.9%), so its probes run at the wall; the actual fullness at each probe is recorded in the JSON (nearfull*_pct) |
| corruption + scrub | write 2G of garbage onto one device behind the filesystem's back, scrub, verify the data: CoW filesystems detect and repair from checksums; md/lvm only count mismatches and may silently serve the corrupted copy |
Results are published as a dashboard: https://bartosz.fenski.pl/modern-fs-benchmark/
— per-metric charts sorted best-first, aging curves, and trends across runs,
filterable by filesystem family and layout class (e.g. "btrfs vs bcachefs,
multi-device only"), with linear/log scale switching and a sortable table.
Run history lives on the results-data branch.
Every result records the exact tools and kernel-module versions tested — essential for ZFS and bcachefs, which are out-of-tree, where the kernel version alone doesn't identify what actually ran. Shown in the dashboard table, stored in the JSON.
Default matrix — 26 configurations (4 devices, plus baselines; the
authoritative list is the matrix in .github/workflows/bench.yml):
ext4 single — one device, the "what does any of this cost" anchor
ext4 on md raid10 — the classic layered stack
ext4 on LVM raid10 — layered stack with block-layer CoW snapshots, so the snapshot-aging phase is comparable with the native-CoW filesystems
xfs single / on md raid10 / on LVM raid10 — the same three stacks again; XFS additionally has reflink, unlike ext4
btrfs / bcachefs / ZFS single-device — the CoW filesystems without
redundancy, head-to-head with ext4/xfs single: the pure cost (and features)
of CoW itself. btrfs uses -m single: mkfs defaults to DUP metadata on a
single device, which would double its metadata writes vs every other
single-device row (community catch)
Encryption variants — ZFS native per-dataset AES-256-GCM
(mirror-enc), bcachefs native whole-fs ChaCha20/Poly1305
(replicas2-enc), btrfs over one LUKS layer per device
(raid1-luks — no native option, so every replica is encrypted
separately), and ext4 over a single LUKS layer on top of md
(md-raid10-luks — the classic stack encrypts once, above the raid).
Compression runs on all of them, so encrypt-after-compress vs
opaque-blocks falls out of the existing zstd phase
btrfs — -d raid1 -m raid1
Single-parity — zfs raidz1, plus raidz1-enc with native encryption
on top (community request)
Dual-parity (raid6-class) — zfs raidz2 (and raidz2-enc,
community request), btrfs -d raid6 -m raid1c3
(parity metadata is discouraged — write hole), ext4 on md raid6, and
bcachefs --erasure_code --replicas=3 (stable since 1.37; write-hole-free
by design — writes replicate first, background reconcile stripes them)
(community request, incl. the correction that EC is no longer experimental)
xfs on a ZFS zvol — the Franken-stack people actually run: XFS semantics on top; ZFS snapshots (fsfreeze-consistent), self-healing, and compression underneath (community request)
xfs on LVM raid10 + dm-integrity (--raidintegrity y) — per-sector
checksums give the classic stack detection AND correction: the fairest
classic-vs-CoW comparison in the corruption phase, with the performance
tax quantified (community request)
ZFS — striped mirror pairs (raid10-like), at the default 128K recordsize
and again at recordsize=8k — one-variable proof of how much of ZFS's
small-random-write cost is configuration, not design
bcachefs — --replicas=2 (kernel module built via DKMS from
apt.bcachefs.org since bcachefs left mainline in 6.17)
If you want to try bcachefs as a working storage system rather than just benchmark it, NASty is a NixOS-based NAS appliance built around it and a practical place to start.
Benchmark charts invite "which is fastest". For long-term storage that is the wrong question — the right one is which stack tells you the truth about your data, and it's why this suite exists (the corruption phase re-proves it every couple of hours):
--raidintegrity y (dm-integrity) is the first classic layout to pass
our corruption phase — but almost nobody runs it, and the performance
tax is measurable (that's the xfs/lvm-raid10-int row).Speed matters and we measure it honestly. But if you keep data you care about — photos, archives, the family's one copy of anything — on a non-checksumming stack, no benchmark number compensates for corruption you won't discover until years later. That risk is invisible in every classic filesystem benchmark; here it's a first-class result (corruption + scrub on the dashboard).
Every push/2-hourly cron builds each filesystem across 4 loop devices backed by
sparse files, runs the suite, and publishes a results table in the job summary
plus JSON artifacts. Each job's artifact also contains a full command trace
(raw/<config>-trace.log) — every command executed, arguments fully expanded,
with source file and line — so "what exactly was run" is never a question.
(BENCH_TRACE=1 mirrors it into the live log instead.)
Interpret CI numbers carefully. Runners are shared VMs and all "devices" live on one virtual disk, so absolute MB/s is meaningless and RAID striping gains are fiction. Matrix jobs also run in parallel, each on its own ephemeral VM — so comparing filesystem A against filesystem B compares two different machines. Mitigations, from strongest signal to weakest:
CALIB_MIN_*, ~25% of runners' normal disk speed margin) fail fast
and are automatically rerun on a fresh runner (up to 3 attempts) —
junk numbers from an unlucky VM never enter the results.The same scripts take real block devices — this is where absolute numbers become valid:
sudo BENCH_DEVICES="/dev/sdb /dev/sdc /dev/sdd /dev/sde" BENCH_WIPE=1 \
scripts/run-bench.sh btrfs raid1
Safety: devices must be unmounted, and anything carrying a filesystem
signature is refused unless BENCH_WIPE=1. Listed devices are wiped.
For an unmanaged hardware run, invoke scripts/run-bench.sh directly with
BENCH_DEVICES, BENCH_SPARE_DEVICE, and BENCH_WIPE=1. The dedicated
self-hosted GitHub workflow instead requires the NixOS module below; it never
accepts device paths from workflow inputs. Workload sizes can be adjusted with
SEQ_SIZE, AGING_SIZE, AGING_ITERS, and the other documented environment
variables. CI defaults are sized for 4×16 GB loop files.
This repository is also a flake with a reusable NixOS module and benchmark
package. Cluster configurations can import nixosModules.modern-fs-benchmark;
the module installs a dedicated Actions
runner, the filesystem tools and matching out-of-tree modules for the
cluster-selected kernel, and a restricted root wrapper with fixed device paths.
It deliberately does not select a kernel or configure machine-wide boot,
networking, users, or partitioning.
{
inputs.modern-fs-benchmark.url =
"github:fenio/modern-fs-benchmark";
# In the target node's modules list:
services.modern-fs-benchmark = {
enable = true;
repository = "https://github.com/fenio/modern-fs-benchmark";
tokenFile = "/run/secrets/modern-fs-benchmark-runner";
runnerName = "farm3";
hardwareProfile = "farm3";
runnerLabels = [ "fs-benchmark" ];
devices = [
"/dev/disk/by-partlabel/fsbench-nvme0-a"
"/dev/disk/by-partlabel/fsbench-nvme1-a"
"/dev/disk/by-partlabel/fsbench-nvme0-b"
"/dev/disk/by-partlabel/fsbench-nvme1-b"
];
spareDevice = "/dev/disk/by-partlabel/fsbench-nvme0-spare";
zfsSingleDevice = "/dev/disk/by-partlabel/fsbench-nvme0-zfs-single";
};
}
The four member devices and spare must each be exactly 16 GiB. The dedicated
zfsSingleDevice must be exactly 32 GiB, matching the hosted-runner matrix.
For an unregistered manual run, the same immutable package is available as
nix run .#manual -- <fs> <layout>; provide the documented BENCH_*
environment variables and run it as root.
Set BENCH_HARDWARE_RANDOM_SCALING=1 to include the optional 8- and 16-worker
random read/write measurements and the 4/8/16-worker shard-aware write series
that the managed hardware wrapper enables.
The master cluster flake owns the node assignment and deploy-rs deployment, so
the runner can move to another machine without changing benchmark code. The
dedicated bench-real-hw.yml workflow targets the fs-benchmark label and
uses only the module's fixed devices. It publishes the farm3 history to
results-real-hw and the dashboard under /real-hw/; the existing bench.yml
workflow remains hosted-only and continues publishing results-data at the
root dashboard. Hardware runs can be dispatched manually. The weekly schedule
is enabled only when the repository variable ENABLE_HARDWARE_BENCHMARKS is
set to true. The token file should contain a fine-grained PAT because
ephemeral runners re-register after every job.
The rotational sas-hdd profile is routed independently through the
fs-benchmark-sas-hdd runner label. Its workflow, enable variable, history,
and dashboard are respectively bench-real-hw-sas-hdd.yml,
ENABLE_SAS_HDD_BENCHMARKS, results-real-hw-sas-hdd, and
/sas-hdd/. Every result carries hardware_profile: "sas-hdd", and
publication rejects a missing or mismatched profile so results from different
machines cannot enter the same trend series.
The plan is bigger than loop devices. CI is the regression-tracking harness; the goal is to gather dedicated hardware and run the REAL tests there — including the tiered topologies these filesystems were built for and that no publication benchmarks today: NVMe cache/metadata in front of rotational data disks (bcachefs foreground/background targets, ZFS special/log/cache vdevs, LVM dm-cache with writeback and writethrough), mixed-rotational RAID, and how each setup behaves degraded and while rebuilding. Same suite, same JSON, same dashboard — only the device lists and topology descriptions change.
Regular mixed-media runs depend on access to a machine whose benchmark devices may be wiped. A suitable dedicated server currently costs approximately €70 per month, depending on availability and its exact disk configuration. If recurring sponsorship covers that cost, the server specification, configuration, raw results, and command traces will all remain public.
I am also open to hardware support in other forms:
If a suitable remote server does not become available, I may eventually build and host a runner myself as the budget allows. Financial support is available through GitHub Sponsors and Ko-fi. To offer hardware or discuss a topology, open an issue. Sponsors and hardware providers can be acknowledged if they wish, but do not receive editorial control over the methodology or results.
sudo scripts/install-deps.sh btrfs
sudo scripts/run-bench.sh btrfs raid1
scripts/summarize.sh results/result-*.json
scripts/run-bench.sh orchestrates the phases, emits results/result-<fs>-<layout>.json
scripts/lib/common.sh device layer (loop files or BENCH_DEVICES), LUKS helpers,
corruption injection, fio helpers, default fs hooks
scripts/lib/layered.sh shared md/LVM assembly, snapshots, degrade/repair (ext4 + xfs)
scripts/fs/<fs>.sh per-filesystem backend
scripts/install-deps.sh Debian/Ubuntu package setup per filesystem
scripts/summarize.sh JSON results → markdown table (job summaries)
scripts/make-dashboard.py results history → the static dashboard page
scripts/audit-results.py anomaly scan over the results history — impossible
orderings, self-healing failures, ENOSPC regressions,
unexpected nulls (daily via the results-audit workflow)
scripts/result-schema.json machine-readable result keys, types, capabilities, and display metadata
scripts/result_schema.py shared result schema loading and validation
scripts/validate-result.py validates result JSON against that contract
Result documents carry schema_version; historical unversioned documents are
treated as version 1 so new metrics do not invalidate the stored history.
Adding a filesystem = one file in scripts/fs/ implementing fs_setup,
fs_snapshot, and fs_teardown; everything else (fs_setup_compression,
fs_compress_ratio, fs_snapshot_delete_all, fs_remount, fs_snap_list,
fs_snapscale_delete, fs_degrade, fs_rebuild, fs_scrub, fs_version,
fs_drop_caches, fs_free_bytes) has safe defaults in lib/common.sh and is
optional — unimplemented hooks simply record null for their metrics.
This suite is deliberately open-ended — if you have opinions on what to test and how, please open an issue or PR:
nodatacow, …scripts/fs/)Tuned variants sit next to the defaults in the same matrix (see
zfs mirror-8k), so every suggestion becomes a directly comparable row.
CoW-specific phases (the behaviors nothing mainstream benchmarks):
Infrastructure:
Copyright 2026 Bartosz Fenski.
Source code, configuration, workflows, and documentation are licensed under the
Apache License 2.0. Published benchmark result datasets, including
the results-data, results-real-hw, and results-real-hw-sas-hdd history
branches, are licensed under
Creative Commons Attribution 4.0 International.
137 commits
Python
58.2%
Shell
38.6%
Nix
3.1%