Benchmark Report — EtcFS vs. JuiceFS, GlusterFS, GFS2, and self-hosted NFS¶
2026-08-25
Summary¶
Five filesystems, each run against its own isolated 3-node cluster on AWS with a dedicated 1000-IOPS io2 Multi-Attach EBS volume, torn down after its run — scripts/bench/compare/ (bench-etcfs.sh, bench-juicefs.sh, bench-gluster.sh, bench-gfs2.sh, bench-nfs.sh, shared provisioning in compare-lib.sh).
Each backend ran in its own real deployment shape rather than being forced onto one uniform setup:
- EtcFS and GFS2 (Red Hat's shared-disk cluster filesystem, the closest real competitor to EtcFS's own model) both mount the cluster's raw Multi-Attach volume directly from all three nodes.
- NFS formats and serves the volume from one node.
- JuiceFS backs Redis metadata + a MinIO object store with it.
- GlusterFS — which replicates across independent per-node storage, not one shared device — got its own separate 1000-IOPS volume per node instead.
Single fio client (psync for the FUSE-based backends — EtcFS and JuiceFS — libaio for the kernel-native ones), 4 jobs, 30s randwrite then randread.
Results¶
All five backends measured in one session:
| Backend | randwrite IOPS | randwrite p99 (us) | randread IOPS | randread p99 (us) |
|---|---|---|---|---|
| gluster | 1041 | 288,768 | 8620 | 35,584 |
| gfs2 | 973 | 432,128 | 1007 | 242,688 |
| etcfs | 934 | 17,957 | 1016 | 11,076 |
| nfs | 675 | 244,318 | 44,504 | 8585 |
| juicefs | 389 | 61,080 | 69,243 | 87 |
* Those zeros were a reporting bug, now fixed, and they were hiding the most
interesting column on this page. gfs2 and gluster need the AL2 AMI, which ships
fio 2.14; that version reports latency percentiles under clat in microseconds
and has no clat_ns object at all, which is the only key the summariser read.
Both backends' p99 figures came out as 0 and were published as "missing data".
compare_p99_us (compare-lib.sh) now reads either dialect.
Reading these numbers¶
The p99 column above does not compare like with like. etcfs and juicefs ran
psync with four jobs at depth 1 (bench-etcfs.sh, bench-juicefs.sh), four
requests in flight; gfs2, gluster and nfs ran libaio with four jobs at depth 32
(bench-gfs2.sh and siblings), 128 in flight, against the same 1000-IOPS
ceiling. By Little's law that alone puts the deep-queue backends' mean latency
near 128 / 1000 s = 128 ms against etcfs's 4 ms, which is the gap the table
shows. The throughput tie stands; the tail comparison does not.
Matched load (2026-10-16/17)¶
Both backends re-run back to back on m5.large with an identical job: four
jobs, one 8 MiB file each, direct=1, 60 s per stanza, at both queue shapes.
| Backend | Job | write IOPS | write p99 (us) | read IOPS | read p99 (us) |
|---|---|---|---|---|---|
| gfs2 | psync, 4 × 1 | 1001 | 8096 | 1003 | 8096 |
| etcfs | psync, 4 × 1 | 1011 | 12386 | 1004 | 9895 |
| gfs2 | libaio, 4 × 32 | 1004 | 585728 | 1003 | 246784 |
| etcfs | libaio, 4 × 32 | — | — | — | — |
| gluster | psync, 4 × 1 | 1055 | 11072 | 1004 | 4512 |
| gluster | libaio, 4 × 32 | 1055 | 148480 | 1004 | 132096 |
| nfs | psync, 4 × 1 | 588 | 17957 | 20965 | 358 |
| nfs | libaio, 4 × 32 | 748 | 227541 | 63582 | 2867 |
The gluster and nfs rows were run the next day (2026-10-17) with the same job and instance class. At depth 1, GlusterFS's write tail (11.1 ms) is close to etcfs's (12.4 ms) and its read tail is tighter (4.5 ms); NFS's write tail is the widest (18.0 ms), and its reads come from the client cache, as in the table above. JuiceFS could not be re-run, because its MinIO binary is no longer downloadable.
At equal load both backends saturate the device and GFS2 has the tighter tail: 8.1 ms against 12.4 ms on writes, 8.1 ms against 9.9 ms on reads. At depth 32 GFS2's p99 reproduces the hundreds of milliseconds published above, which is queueing at the device rather than anything in GFS2.
etcfs has no depth-32 row because the job killed its FUSE daemon within
seconds, twice in two attempts: the mount answered ECONNABORTED, then
ENOTCONN, and etcfuse logged daemon exited with error -9 after writing
device: Bad file descriptor. The metadata daemon stayed up. The thread-exit
destructor for each worker's IPC connection (ipc_fd_destroy,
pkg/fuse/fuse.c) closed the thread-local value as stored, which is the
descriptor plus one, so a worker that exited closed an unrelated descriptor —
another worker's IPC socket or its cloned /dev/fuse handle. Bursty async I/O
is what makes libfuse spawn and retire extra workers, which is why only this job
hit it. The destructor now subtracts the offset (etcfs commit ddea391, covered
by the C unit test test_a_retiring_thread_closes_only_its_own_connection); the
depth-32 row awaits a rerun on a build with that fix.
The read column is not comparable across backends and should not be read as
one. nfs's 44.5k and juicefs's 69.2k read IOPS are client page-cache hits on a
working set that fits in RAM; etcfs's 1016 is the device, because direct=1 is
in every job and etcfs honours it. The next section is the experiment that
established that.
Follow-up: does EtcFS's page cache do anything under direct=1?¶
Update. The root cause given at the end of this section no longer holds: since commit 2e2b8bd (2026-08-15)
ec_createanswers with the same page-cache decision asec_open(handleCreatereturnscacheableOpen). The 2026-08-25direct=0row was measured after that change and still reads at the device ceiling, so the cause of the missing cache effect in this job is currently not established.
JuiceFS/GlusterFS/NFS's read-IOPS numbers above (67k/8k/48k, all far past the
1000-IOPS device) are a client-cache artifact, not real device throughput —
the working set (1G or 8M, time_based looping the same blocks for 30s) fits
entirely in RAM, so most reads never leave the client's page cache. EtcFS
showed none of that blow-up (1016 read IOPS, right at the device ceiling)
because direct=1 was in every job — O_DIRECT bypasses the kernel page
cache regardless of what the FUSE layer offers, and EtcFS never serves data
from anywhere else (internal/ipc/datapath.go's read path: "an O_DIRECT read
consults none [caches]").
Re-ran EtcFS's own randwrite/randread twice at direct=1 (matching the table
above) and twice with direct=0, same cluster shape, --page-cache at its
default (true):
| Run | write IOPS | write p99 (us) | read IOPS | read p99 (us) |
|---|---|---|---|---|
| direct=1, run 1 | 681 | 39059 | 1016 | 11207 |
| direct=1, run 2 | 1010 | 13828 | 1007 | 10027 |
| direct=0, run 1 | 878 | 19530 | 1016 | 11469 |
| direct=0, run 2 | 950 | 16318 | 1016 | 10813 |
| direct=1, 2026-08-25 | 934 | 17957 | 1016 | 11076 |
| direct=0, 2026-08-25 | 971 | 15794 | 1016 | 11600 |
Read IOPS is identical across both modes (~1010-1016, pinned to the device
ceiling) and write IOPS overlaps within run-to-run noise (681-1010 vs.
878-950) — no cache-driven blow-up like the other three backends got. Root
cause: the benchmark's fio job creates its test files fresh every run
(filename_format), and ec_create in pkg/fuse/ops.c:701-702 hardcodes
direct_io=1, keep_cache=0 unconditionally on every create() — unlike
ec_open (ops.c:640-641), which does condition caching on whether the
backend says it's safe. handleCreate (internal/ipc/handlers.go) never
takes the inode's lock and never calls cacheableOpen, so there is no
lock-holder state for create() to cache against; it falls back to the
always-safe default. The fd created at the start of the 30s run keeps that
setting for the run's whole duration, so removing fio's own direct=1 never
had anything to bypass to. Confirmed not a hard correctness requirement — a
freshly-created file is unambiguously owned by its creator at that instant —
just wiring create() never got extended to the caching protocol the way
open() was.