Files
mergerfs/tools/bench-splice.sh
Antonio S. MusumeciandClaude Sonnet 5 8eed256747 Restore splice zero-copy I/O as runtime options
Reinstate the splice read/write/move paths removed in 84592a9f, now
gated behind runtime options with negotiated pipe sizing and fallback
logging.

- Transport: msgbuf-piped splice receive, fd-referenced splice reply
  (vmsplice + splice), splice-move pipe-bounce, and restored
  fuse_buf_copy/fuse_buf_flags plumbing for copy fallbacks
- Negotiation: new pipe-max-size option (auto-raised at INIT when
  possible; the sysctl read/write is factored into src/pipe_max_size.cpp
  so fuse_init.cpp and option_parser.cpp share one implementation);
  fuse-msg-size clamped to what the pipe can hold; splice is disabled
  at INIT with a warning if the sysctl can't fit even the minimum,
  retracting the FUSE_CAP_SPLICE_* bits already advertised to the
  kernel in that case
- Read replies and splice-move are gated at 128 KiB (below that the
  syscalls cost more than the copy they avoid); every fallback path
  degrades to the copy path and logs once per process via SysLog
- msgbuf pipe cache is process-global and mutex-guarded (mutex_lockguard)
  so pipes can move between read and worker threads under
  process-thread mode
- API simplified after review: collapsed the three per-axis options
  (splice-read/write/move) into a single splice boolean (default on);
  no-splice-read/write/move and the bare splice-read/write/move
  spellings stay registered as accepted no-ops so existing mount
  options don't fail the mount, but they do NOT alter the splice
  knob - upgraders with no-splice-* in their config get splice
  enabled and must set splice=false explicitly (see
  deprecated_options.md)
- Fixed several correctness issues found in parallel review: a
  dropped write_buf registration that silently corrupted writes, a
  dirty-pipe reuse bug in msgbuf_free, an iov double-send in
  fuse_reply_data's splice-move fallback, and pipe-capacity math that
  assumed a 4096-byte page size
- Modernized the transport/negotiation code to this codebase's C++
  style: RAII (unique_ptr/DEFER) instead of malloc/free and manual fd
  close(), std::from_chars/to_chars instead of stoul/snprintf,
  _ReplyPipe's pipe ops as methods instead of free functions closing
  over a thread_local global, and vendored/libfuse/lib/buffer.cpp (a
  near-verbatim port of upstream's buffer.c) brought in line with the
  rest of the fork
- Documented splice and pipe-max-size options (mkdocs, FAQ,
  deprecated-options list); added tools/bench-splice.sh
- Benchmarked on tmpfs: seq_write +21%, seq_read +52%, small_write
  +5%, small_read +12%; validated with a 1h soak (~271 GiB
  hash-verified I/O, zero integrity failures), a live-mount round-trip
  of I/O above the splice threshold, and the existing unit/smoke
  suites at their pre-existing baseline

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:24:16 -04:00

416 lines
14 KiB
Bash
Executable File

#!/bin/bash
# bench-splice.sh -- A/B benchmark of mergerfs splice
# against the mergefs OFF baseline and a pristine-HEAD baseline binary.
#
# Run: bash tools/bench-splice.sh [REPS]
# Env: BASE_REPO (path to pristine HEAD build dir), SIZE_MIB, REPS
# note: requires `unshare -Urm` permissions for an unprivileged FUSE mount.
# Resolve a working `stat -c %s` once: this host's unshare PATH can
# front a non-GNU stat (aurora) that rejects -c.
STAT_BIN="$(command -v stat)"
if ! "${STAT_BIN}" -c %s /dev/null >/dev/null 2>&1; then
STAT_BIN=/usr/bin/stat
fi
set -euo pipefail
export LC_ALL=C # dd prints locale-formatted numbers; parse_mibs needs C
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="${REPO}/build/mergerfs"
BASE_REPO="${BASE_REPO:-/tmp/mfs-base}"
BASE_BIN="${BASE_REPO}/build/mergerfs"
B=/dev/shm/bench-splice
REPS="${REPS:-${1:-5}}"
# --- OOM guard: pick SIZE_MIB so mergefs-blob fits comfortably into MemAvailable
mem_available_kib() { awk '/^MemAvailable:/{print $2}' /proc/meminfo; }
pick_size_mib() {
local want=(256 128 64)
local w
for w in "${want[@]}"; do
local need=$(( w * 2 * 1024 )) # blob on branch + mergefs page cache
local have; have=$(mem_available_kib)
if (( have >= need )); then echo "$w"; return; fi
done
echo "INSUFFICIENT-MEMORY" >&2
return 1
}
SIZE_MIB="${SIZE_MIB:-$(pick_size_mib)}"
# --- helpers -----------------------------------------------------------
die() { echo "bench-splice: $*" >&2; exit 1; }
need_binary() { [[ -x "$1" ]] || die "missing binary: $1"; }
need_splice_binary() {
# --version comes from src/version.hpp, which incremental makes do not
# refresh; assert the binary actually contains the code under test.
# NB: consume strings fully - grep -q closes the pipe early, and with
# pipefail the resulting SIGPIPE would fail the check spuriously.
local marker="$2"
local out
out=$(strings "$1" | grep "${marker}" || true)
[[ -n "${out}" ]] \
|| die "binary $1 lacks marker '${marker}' (stale build? run make)"
}
ensure_unshared() {
# Re-exec under unshare -Urm if we're not already root in a user ns
# (mount requires CAP_SYS_ADMIN in this host's delegate).
if (( EUID != 0 )) && [[ -z "${BENCH_INNS:-}" ]]; then
BENCH_INNS=1 exec unshare -Urm -- "$0" "$@"
fi
}
# --- mergefs process management -----------------------------------------
declare -g MNT="" BR1="" BR2="" MPID=""
mfs_stop() {
if [[ -n "${MPID}" ]]; then
kill "${MPID}" 2>/dev/null || true
wait "${MPID}" 2>/dev/null || true
MPID=
fi
pkill -9 -f "$BIN .*${B}/mnt" 2>/dev/null || true
pkill -9 -f "$BASE_BIN .*${B}/mnt" 2>/dev/null || true
if [[ -n "${MNT}" ]] && grep -qs "${B}/mnt" /proc/mounts; then
fusermount3 -u "${MNT}" >/dev/null 2>&1 || umount "${MNT}" 2>/dev/null || true
fi
}
cleanup() {
mfs_stop
payload_src_fini
rm -rf "${B}"
}
trap cleanup EXIT
trap 'cleanup; exit 1' INT TERM
mfs_start() {
local binary="$1"; shift
local opts="$1"; shift
BR1="${B}/b1"; BR2="${B}/b2"; MNT="${B}/mnt"
mkdir -p "${BR1}" "${BR2}" "${MNT}"
"${binary}" -f "${BR1}:${BR2}" "${MNT}" -o "${opts}" >/dev/null 2>&1 &
MPID=$!
# wait for mount
local t=0
while ! grep -qs " ${MNT} " /proc/mounts; do
sleep 0.2
t=$(( t + 1 ))
if (( t > 50 )); then
die "mount did not come up for ${binary}"
fi
done
sleep 0.5 # let it warm INIT
}
# --- one experiment ------------------------------------------------------
# run_4phases <binary> <mount-opts> <label>
# The four phases: seq_write, seq_read, small_write, small_read
# Writes results rows "label test rep mibs" to $RESULTS.
RESULTS=${RESULTS:-/tmp/bench-splice.$(date +%Y%m%d%H%M%S).results}
: > "${RESULTS}"
parse_mibs() {
# dd's final stderr line: 'NNN bytes (X biB) copied, T s, R MB/s' where its
# "MB/s" is decimal. Recompute MiB/s exactly from bytes and seconds.
tail -n 1 "$1" | awk '
{ for (i = 1; i <= NF; i++) {
if ($i == "bytes") bytes = $(i-1)
if ($i == "copied,") secs = $(i+1)
}
if (bytes && secs > 0) printf "%.3f", bytes / 1048576.0 / secs
}'
}
phase_run() {
local label="$1" test="$2" rep="$3"; shift 3
local lav tmpf rc
tmpf=$(mktemp)
# Explicitly capture dd's exit status: a `;`-joined
# "dd ...; parse_mibs" (the previous form) ran parse_mibs
# unconditionally, so a dd that failed partway (e.g. EIO from a
# splice fallback) could still leave a plausible-looking partial
# "N bytes ... copied" summary line in ${tmpf} for parse_mibs to
# extract, recording a corrupt-transport measurement as if it were
# a valid one instead of aborting the run.
rc=0
"$@" 2>"${tmpf}" >/dev/null || rc=$?
if [[ ${rc} -ne 0 ]]; then
cat "${tmpf}" >&2
rm -f "${tmpf}"
die "dd failed (exit ${rc}) for $label/$test/rep$rep"
fi
lav="$(parse_mibs "${tmpf}" || true)"
rm -f "${tmpf}"
[[ -n "${lav}" ]] || die "failed to parse MiB/s from dd ($label/$test/rep$rep)"
echo "$label $test $rep $lav" >> "${RESULTS}"
}
wipe_targets() {
rm -f "${MNT}/blob" "${BR1}/blob" "${BR2}/blob"
sync
}
check_integrity() {
# verify the file on disk matches what's seen through the mount, and
# that both carry the full expected size (agreement on a truncated
# blob is NOT integrity).
# (set -e + pipefail safe: substitutions must never abort the script)
local expected="$1"
local mhash bhash msize bsize b_path
mhash=$(sha256sum "${MNT}/blob" 2>/dev/null | awk '{print $1}') || true
msize=$("${STAT_BIN}" -c %s "${MNT}/blob" 2>/dev/null) || msize=""
bhash=""
bsize=""
b_path=""
if [[ -f "${BR1}/blob" ]]; then b_path="${BR1}/blob"; fi
if [[ -z "${b_path}" && -f "${BR2}/blob" ]]; then b_path="${BR2}/blob"; fi
if [[ -n "${b_path}" ]]; then
bhash=$(sha256sum "${b_path}" 2>/dev/null | awk '{print $1}') || true
bsize=$("${STAT_BIN}" -c %s "${b_path}" 2>/dev/null) || bsize=""
fi
[[ -n "${bhash}" ]] || die "blob not found on any branch (integrity)"
if [[ "${bsize}" != "${expected}" || "${msize}" != "${expected}" ]]; then
die "blob size ${bsize}/${msize} != expected ${expected} (truncated write, integrity)"
fi
if [[ "${mhash}" != "${bhash}" ]]; then
die "sha256 mismatch branch ${b_path} vs mount (integrity check)"
fi
}
drop_caches() {
# Best effort - and normally a no-op here: vm sysctls are not
# namespaced, so writing drop_caches from inside unshare -Urm
# requires CAP_SYS_ADMIN in the INITIAL userns and fails with EPERM
# (masked below). Read phases on tmpfs branches are therefore
# cache-warm; they measure the transport of cached pages, not cold
# disk. sync still runs.
sync
echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true
}
# Non-zero payload source: the integrity gate must be able to see
# stale-pool-memory corruption (correct size, wrong bytes). An
# all-zero payload makes stale pool contents indistinguishable from
# the requested data, so the sha256 gate passes green over corrupted
# transport. One urandom file per bench run, reused across phases.
PAYLOAD_SRC=""
payload_src_init() {
# Sized to the LARGEST payload any arm writes: SIZE_MIB for the
# main arms, and SIZE_MIB_4K MiB for the 4K arm (RUN_4K defaults
# on and its size is independent of SIZE_MIB). A short pattern
# file would make the write dd run short and check_integrity die
# 'truncated write'. Lives in /dev/shm (same as B) so the read
# source costs memory speed, not disk.
local need
need=$(( SIZE_MIB > ${SIZE_MIB_4K:-64} ? SIZE_MIB : ${SIZE_MIB_4K:-64} ))
PAYLOAD_SRC=$(mktemp /dev/shm/bench-splice-payload.XXXXXX 2>/dev/null || mktemp /tmp/bench-splice-payload.XXXXXX)
dd if=/dev/urandom of="${PAYLOAD_SRC}" bs=1M count="${need}" status=none
}
payload_src_fini() {
# Under `set -e`, "[[ cond ]] && cmd" as a function's only statement
# returns the (nonzero) test result when cond is false - e.g. when
# this runs from the EXIT trap before payload_src_init ever set
# PAYLOAD_SRC (an early die() for a missing/stale binary). That
# would make cleanup() exit right here and skip its own later
# `rm -rf "${B}"`, leaking the whole scratch tree. Always return 0.
[[ -n "${PAYLOAD_SRC}" ]] && rm -f "${PAYLOAD_SRC}"
return 0
}
# Write (+integrity check) then drop-caches+read one (name,bs,count) pair
# against ${MNT}/blob. Shared by arm_one_rep (seq/small, bs=1M/128K) and
# arm_4k_rep (tiny, bs=4K). NOT used by arm_native: that arm targets the
# raw branch with no mergefs running, so it has nothing to check
# integrity against and no page cache pass-through worth dropping (see
# its own comment) - forcing it through this helper would need those
# checks toggled off by flag, trading a clear structural difference for
# a harder-to-audit boolean.
run_pair() {
local label="$1" name="$2" bs_arg="$3" count="$4" bytes_per_unit="$5" rep="$6"
local mnt="${MNT}"
wipe_targets
phase_run "${label}" "${name}_write" "${rep}" \
dd if="${PAYLOAD_SRC}" of="${mnt}/blob" bs="${bs_arg}" count="${count}" conv=fdatasync oflag=nocache
check_integrity $(( count * bytes_per_unit ))
drop_caches
phase_run "${label}" "${name}_read" "${rep}" \
dd if="${mnt}/blob" of=/dev/null bs="${bs_arg}" iflag=nocache
}
arm_one_rep() {
local label="$1" rep="$2"
run_pair "${label}" seq 1M "${SIZE_MIB}" 1048576 "${rep}"
run_pair "${label}" small 128K "$(( SIZE_MIB * 8 ))" 131072 "${rep}"
rm -f "${MNT}/blob"
sync
}
# Same arm at bs=4K. Uses SIZE_MIB_4K (default 64) so iteration count
# doesn't run 10x the main arm for equal signal. Writes are capped
# proportionally to SIZE_MIB_4K, reads also capped.
arm_4k_rep() {
local label="$1" rep="$2"
local sz="${SIZE_MIB_4K:-64}"
run_pair "${label}_4k" tiny 4K "$(( sz * 256 ))" 4096 "${rep}"
rm -f "${MNT}/blob"
sync
}
# native baseline: same 4 dd patterns against raw branch directly, once
arm_native() {
local b="${BR1}"
[[ -d "${b}" ]] || die "no branch dir"
phase_run native seq_write 1 \
dd if="${PAYLOAD_SRC}" of="${b}/blob" bs=1M count="${SIZE_MIB}" conv=fdatasync oflag=nocache
phase_run native seq_read 1 \
dd if="${b}/blob" of=/dev/null bs=1M iflag=nocache
rm -f "${b}/blob"; sync
phase_run native small_write 1 \
dd if="${PAYLOAD_SRC}" of="${b}/blob" bs=128K count=$(( SIZE_MIB * 8 )) conv=fdatasync oflag=nocache
phase_run native small_read 1 \
dd if="${b}/blob" of=/dev/null bs=128K iflag=nocache
rm -f "${b}/blob"; sync
}
# --- main ---------------------------------------------------------------
ensure_unshared "$@"
need_binary "${BIN}"
need_binary "${BASE_BIN}"
need_splice_binary "${BIN}" "reply-fd-splice"
mkdir -p "${B}" "${B}/b1" "${B}/b2" "${B}/mnt"
payload_src_init
# header
{
echo "== bench-splice =="
echo "kernel: $(uname -r)"
echo "binary: $(${BIN} --version 2>/dev/null | head -1 || echo mergerfs)"
echo "baseline_binary: $(${BASE_BIN} --version 2>/dev/null | head -1 || echo mergerfs)"
echo "nproc: $(nproc)"
echo "mem_available_kib: $(mem_available_kib)"
echo "size_mib: ${SIZE_MIB}"
echo "reps: ${REPS}"
echo
} | tee /dev/stderr
# native first (no mergefs running); BR1/BR2/MNT point at bench branches
# before any mergefs is started so arm_native can dd directly.
BR1="${B}/b1"; BR2="${B}/b2"; MNT="${B}/mnt"
arm_native
# baseline binary (HEAD): splice options are meaningless to it
mfs_start "${BASE_BIN}" "defaults,minfreespace=0"
for rep in $(seq 1 "${REPS}"); do
arm_one_rep baseline "${rep}"
done
mfs_stop
# interleave off/on arms
for rep in $(seq 1 "${REPS}"); do
mfs_start "${BIN}" "minfreespace=0,splice=false"
arm_one_rep off "${rep}"
mfs_stop
# splice=on (current tree, options ON)
mfs_start "${BIN}" "minfreespace=0,splice=true"
arm_one_rep on "${rep}"
mfs_stop
done
# optional 4K side arm, separate label so rows never aggregate with the
# main on/off pools. Defaults: RUN_4K=1 REPS_4K=3 SIZE_MIB_4K=64.
if [[ "${RUN_4K:-1}" == "1" ]]; then
for rep in $(seq 1 "${REPS_4K:-3}"); do
mfs_start "${BIN}" "minfreespace=0,splice=false"
arm_4k_rep off "${rep}"
mfs_stop
mfs_start "${BIN}" "minfreespace=0,splice=true"
arm_4k_rep on "${rep}"
mfs_stop
done
fi
echo
echo "== raw results (label test rep MiB/s) =="
cat "${RESULTS}"
echo
# min/median/max per label x test
echo "== min / median / max per (label,test) =="
awk '
{ x[$1, $2] = x[$1, $2] " " $4 }
END {
for (k in x) {
split(k, kk, SUBSEP)
n = split(x[k], v, " ")
# bubble sort small
for (i = 1; i <= n; i++)
for (j = i+1; j <= n; j++)
if (v[i]+0 > v[j]+0) { t=v[i]; v[i]=v[j]; v[j]=t }
mn = v[1]; mx = v[n]
if (n % 2 == 1) md = v[(n+1)/2]
else md = (v[n/2]+0 + v[n/2+1]+0) / 2
printf "%-10s %-12s n=%d min=%0.1f med=%0.1f max=%0.1f\n", kk[1], kk[2], n, mn, md, mx
}
}' "${RESULTS}" | sort -k1,1 -k2,2
echo
echo "== delta table (median of PAIRED per-rep deltas: on vs off, on vs baseline) =="
python3 - "${RESULTS}" <<'PY'
import sys, statistics
from collections import defaultdict
rows=[]
with open(sys.argv[1]) as f:
for line in f:
label,test,rep,val=line.split()
rows.append((label,test,int(rep),float(val)))
# (label,test) -> {rep: val}
by_rep=defaultdict(dict)
for label,test,rep,val in rows:
by_rep[(label,test)][rep]=val
def med(l,t):
d=by_rep.get((l,t))
return statistics.median(d.values()) if d else None
# paired per-rep deltas: join two labels by rep number, take the
# difference only where BOTH arms have that rep. The bench interleaves
# off/on per rep precisely so slow drift in host load cancels within a
# pair; pooled medians discard that pairing and were measured 20%+ off
# on this shared host for identical code.
def paired_deltas(a,b,t):
da=by_rep.get((a,t),{}); db=by_rep.get((b,t),{})
common=sorted(set(da)&set(db))
return [da[r]-db[r] for r in common], len(common)
tests=["seq_write","seq_read","small_write","small_read"]
print(f"{'test':<12} {'native':>10} {'baseline':>10} {'off':>10} {'on':>10} {'on/off':>7} {'on/base':>7} pairs")
for t in tests:
n=med("native",t); b=med("baseline",t); o=med("off",t); on=med("on",t)
d_on_off,no_=paired_deltas("on","off",t)
d_on_base,nb=paired_deltas("on","baseline",t)
def pctp(d,base):
# per-column guard: on/base must not depend on the off arm
if not d or not base: return " n/a "
return f"{100.0*statistics.median(d)/base:+.1f}%"
p1=pctp(d_on_off,o)
p2=pctp(d_on_base,b)
print(f"{t:<12} {n if n else 0:>10.1f} {b if b else 0:>10.1f} {o if o else 0:>10.1f} {on if on else 0:>10.1f} {p1:>7} {p2:>7} {no_}/{nb}")
PY