|
#!/usr/bin/env bash |
|
# |
|
# sdbench.sh — compare SD cards on a Raspberry Pi. |
|
# |
|
# Runs each card one at a time (mount it, e.g. via a USB reader), then prints |
|
# a side-by-side table. Uses fio with --direct=1 so the page cache is bypassed |
|
# and the numbers reflect the card, not the Pi's RAM. |
|
# |
|
# Usage: |
|
# ./sdbench.sh run <mountpoint> <label> # benchmark a mounted card |
|
# ./sdbench.sh compare # print table of all saved runs |
|
# ./sdbench.sh caps <mountpoint> <label> # (optional) fake-capacity check via f3 |
|
# |
|
# Example: |
|
# sudo mount /dev/sda1 /mnt/card # card A in a USB reader |
|
# ./sdbench.sh run /mnt/card sandisk-A |
|
# # swap cards, remount... |
|
# ./sdbench.sh run /mnt/card kingston-B |
|
# ./sdbench.sh compare |
|
# |
|
set -euo pipefail |
|
|
|
RESULTS_DIR="${SDBENCH_RESULTS:-./sdbench-results}" |
|
SIZE="${SIZE:-1G}" # test-file size; keep > Pi RAM-ish and < free space |
|
RAMP="${RAMP:-2}" # seconds to ramp before measuring (settle the card) |
|
RUNTIME="${RUNTIME:-30}" # seconds per random test (time-capped so slow cards |
|
# don't take 20+ min on the 4K random-write pass) |
|
|
|
die() { echo "error: $*" >&2; exit 1; } |
|
|
|
need_fio() { |
|
command -v fio >/dev/null 2>&1 || die "fio not installed. Run: sudo apt install -y fio" |
|
} |
|
|
|
run_bench() { |
|
local target="$1" label="$2" |
|
[ -n "$target" ] && [ -n "$label" ] || die "usage: $0 run <mountpoint> <label>" |
|
[ -d "$target" ] || die "$target is not a directory / not mounted" |
|
[ -w "$target" ] || die "$target is not writable (mount it or use sudo)" |
|
need_fio |
|
|
|
mkdir -p "$RESULTS_DIR" |
|
local testfile="$target/.sdbench.tmp" |
|
local out="$RESULTS_DIR/$label" |
|
# shellcheck disable=SC2064 |
|
trap "rm -f '$testfile'" EXIT |
|
|
|
echo "==> Benchmarking '$label' at $target (test file: $SIZE)" |
|
echo " This writes/reads a $SIZE file. Give it a few minutes." |
|
|
|
# 1) sequential write — creates the file |
|
echo " [1/5] sequential write (1M blocks)..." |
|
fio --name=seqwrite --filename="$testfile" --size="$SIZE" --bs=1M \ |
|
--rw=write --direct=1 --ioengine=libaio --iodepth=8 \ |
|
--ramp_time="$RAMP" --end_fsync=1 --output-format=json > "$out.seqwrite.json" |
|
|
|
# 2) sequential read |
|
echo " [2/5] sequential read (1M blocks)..." |
|
fio --name=seqread --filename="$testfile" --size="$SIZE" --bs=1M \ |
|
--rw=read --direct=1 --ioengine=libaio --iodepth=8 \ |
|
--ramp_time="$RAMP" --output-format=json > "$out.seqread.json" |
|
|
|
# 3) random 4K read — the number that decides boot/app responsiveness |
|
echo " [3/5] random 4K read (IOPS, ${RUNTIME}s)..." |
|
fio --name=randread --filename="$testfile" --size="$SIZE" --bs=4k \ |
|
--rw=randread --direct=1 --ioengine=libaio --iodepth=32 \ |
|
--ramp_time="$RAMP" --runtime="$RUNTIME" --time_based \ |
|
--output-format=json > "$out.randread.json" |
|
|
|
# 4) random 4K write — the A1/A2 application-class number (slowest test) |
|
echo " [4/5] random 4K write (IOPS, ${RUNTIME}s)..." |
|
fio --name=randwrite --filename="$testfile" --size="$SIZE" --bs=4k \ |
|
--rw=randwrite --direct=1 --ioengine=libaio --iodepth=32 \ |
|
--ramp_time="$RAMP" --runtime="$RUNTIME" --time_based \ |
|
--output-format=json > "$out.randwrite.json" |
|
|
|
# 5) mixed 4K random 70/30 read/write — closest to a real running OS |
|
echo " [5/5] mixed 4K random 70/30 read/write (IOPS, ${RUNTIME}s)..." |
|
fio --name=randrw --filename="$testfile" --size="$SIZE" --bs=4k \ |
|
--rw=randrw --rwmixread=70 --direct=1 --ioengine=libaio --iodepth=32 \ |
|
--ramp_time="$RAMP" --runtime="$RUNTIME" --time_based \ |
|
--output-format=json > "$out.randrw.json" |
|
|
|
rm -f "$testfile"; trap - EXIT |
|
echo "==> Done. Results saved to $RESULTS_DIR/$label.*.json" |
|
echo |
|
compare |
|
} |
|
|
|
compare() { |
|
[ -d "$RESULTS_DIR" ] || die "no results in $RESULTS_DIR yet — run a benchmark first" |
|
python3 - "$RESULTS_DIR" <<'PY' |
|
import glob, json, os, sys |
|
d = sys.argv[1] |
|
labels = sorted({os.path.basename(p).split('.')[0] for p in glob.glob(os.path.join(d,'*.json'))}) |
|
if not labels: |
|
sys.exit("no results found") |
|
|
|
def load(label, job, section): |
|
p = os.path.join(d, f"{label}.{job}.json") |
|
if not os.path.exists(p): return None |
|
j = json.load(open(p))["jobs"][0][section] |
|
return j["bw_bytes"]/1e6, j["iops"] # MB/s, IOPS |
|
|
|
rows = [ |
|
("Seq read (MB/s)", "seqread", "read", "bw"), |
|
("Seq write (MB/s)", "seqwrite", "write", "bw"), |
|
("Rand 4K read (IOPS)", "randread", "read", "iops"), |
|
("Rand 4K write (IOPS)", "randwrite", "write", "iops"), |
|
("Mixed 70/30 rd (IOPS)", "randrw", "read", "iops"), |
|
("Mixed 70/30 wr (IOPS)", "randrw", "write", "iops"), |
|
] |
|
|
|
w = max(22, *(len(l) for l in labels)) + 2 |
|
print("SD card comparison\n" + "="*60) |
|
hdr = "Metric".ljust(24) + "".join(l.ljust(w) for l in labels) |
|
print(hdr); print("-"*len(hdr)) |
|
for name, job, sec, kind in rows: |
|
line = name.ljust(24) |
|
vals = [] |
|
for l in labels: |
|
r = load(l, job, sec) |
|
vals.append(None if r is None else (r[0] if kind=="bw" else r[1])) |
|
for v in vals: |
|
line += ("n/a" if v is None else f"{v:,.1f}" if kind=="bw" else f"{v:,.0f}").ljust(w) |
|
# mark the winner |
|
good = [v for v in vals if v is not None] |
|
if len(good) > 1 and len(set(good)) > 1: |
|
best = max(vals, key=lambda x: -1 if x is None else x) |
|
line += " ← " + labels[vals.index(best)] |
|
print(line) |
|
print("="*60) |
|
print("Higher is better on every row. Random 4K IOPS matter most for OS") |
|
print("responsiveness/boot time; sequential MB/s for big file copies.") |
|
PY |
|
} |
|
|
|
caps() { |
|
local target="$1" label="${2:-card}" |
|
[ -d "$target" ] || die "usage: $0 caps <mountpoint> [label]" |
|
command -v f3write >/dev/null 2>&1 || die "f3 not installed. Run: sudo apt install -y f3" |
|
echo "==> Fake-capacity / integrity check on '$label' ($target)" |
|
echo " Fills the card, then verifies. Slow but catches counterfeit cards." |
|
f3write "$target" && f3read "$target" |
|
} |
|
|
|
cmd="${1:-}"; shift || true |
|
case "$cmd" in |
|
run) run_bench "${1:-}" "${2:-}" ;; |
|
compare) compare ;; |
|
caps) caps "${1:-}" "${2:-}" ;; |
|
*) cat >&2 <<EOF |
|
sdbench.sh — compare SD cards on a Raspberry Pi |
|
|
|
$0 run <mountpoint> <label> benchmark a mounted card, save results |
|
$0 compare print side-by-side table of saved runs |
|
$0 caps <mountpoint> [label] fake-capacity / integrity check (needs f3) |
|
|
|
Env: SIZE=$SIZE (test-file size) RESULTS in $RESULTS_DIR |
|
EOF |
|
exit 1 ;; |
|
esac |