Files
blomarchy/bin/blob-disk-speedtest
T

233 lines
7.1 KiB
Bash
Executable File

#!/bin/bash
# blob:summary=Measure live disk read and write speed
# blob:args=[target-dir]
set -e
if [[ -n ${1:-} && ! -d $1 ]]; then
echo "Usage: blob-disk-speedtest [target-dir]" >&2
exit 2
fi
target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/blob}"
phase_seconds=8
parallel=4
chunk_mb=4
file_mb=256
mkdir -p "$target_dir"
worker_pids=()
chunk_file=""
test_files=()
stop_workers() {
local pid
for pid in "${worker_pids[@]}"; do
[[ -n $pid ]] || continue
pkill -TERM -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
done
for pid in "${worker_pids[@]}"; do
[[ -n $pid ]] || continue
wait "$pid" 2>/dev/null || true
done
worker_pids=()
}
alive_workers() {
local pid count=0
for pid in "${worker_pids[@]}"; do
kill -0 "$pid" 2>/dev/null && count=$((count + 1))
done
echo "$count"
}
cleanup() {
# Unlink before stopping the workers, so even a cleanup cut short by an
# impatient SIGKILL has already taken the names off the filesystem. A live
# write worker's next dd pass recreates its file by name, so sweep again
# once they are gone.
rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}"
stop_workers
rm -f ${chunk_file:+"$chunk_file"} "${test_files[@]}"
}
# Armed before any scratch file exists, so a failed preflight check below
# cannot leak them.
trap cleanup EXIT
trap 'exit 143' TERM INT
# Exclusive per-invocation scratch files: predictable names could clobber a
# user's file, follow a planted symlink, or let overlapping runs delete each
# other's active files out from under the measurement. Each worker gets its
# own on-disk file so the phases run at a queue depth the device can actually
# stretch out on, like the network test's parallel curl workers.
#
# The files are marked NOCOW where the filesystem supports it (btrfs), which
# turns off copy-on-write, checksums, and compression for them. That is what
# makes O_DIRECT truly direct on btrfs -- with checksums on it silently falls
# back to the page cache -- and it makes every rewrite land in place instead
# of churning the extent allocator, which run-to-run reproducibility depends
# on.
chunk_file=$(mktemp /dev/shm/blob-disk-speedtest-XXXXXX.src)
for (( i = 0; i < parallel; i++ )); do
file=$(mktemp "$target_dir/disk-speedtest-XXXXXX.dat")
chattr +C "$file" 2>/dev/null || true
test_files+=("$file")
done
format_rate() {
awk -v value="$1" 'BEGIN {
if (value <= 0) print "0.0"
else if (value < 10) printf "%.1f\n", value
else printf "%.0f\n", value
}'
}
# Resolve the block device backing the target directory, so throughput can be
# sampled from its kernel I/O counters the same way the network speed test
# samples the interface counters.
source_dev=$(findmnt -no SOURCE --target "$target_dir" 2>/dev/null)
source_dev=${source_dev%%\[*} # Strip btrfs subvolume suffix: /dev/sda2[/@home]
if [[ $source_dev != /dev/* ]]; then
echo "Cannot find a disk behind $target_dir" >&2
exit 1
fi
dev=$(readlink -f "$source_dev")
dev=${dev##*/}
if [[ ! -r /sys/class/block/$dev/stat ]]; then
echo "No I/O statistics for $dev" >&2
exit 1
fi
available_mb=$(df --output=avail -m "$target_dir" | tail -1 | tr -d ' ')
if (( available_mb < parallel * file_mb * 2 )); then
echo "Need at least $((parallel * file_mb * 2))MB free on $target_dir" >&2
exit 1
fi
# Name the physical disk under test, walking dm-crypt/LVM layers and the
# partition table up to the whole device that carries the hardware model.
disk=$dev
while slave=$(ls "/sys/class/block/$disk/slaves" 2>/dev/null | head -1); [[ -n $slave ]]; do
disk=$slave
done
if [[ -f /sys/class/block/$disk/partition ]]; then
parent=$(readlink -f "/sys/class/block/$disk")
parent=${parent%/*}
disk=${parent##*/}
fi
model=$(lsblk -dno MODEL "/dev/$disk" 2>/dev/null | sed 's/^ *//; s/ *$//')
echo "disk ${model:-$disk}"
# The stress data must be incompressible so nothing between the write call
# and the flash can shrink it. Staging a urandom chunk in RAM also keeps the
# source out of the measurement -- reading tmpfs is a memcpy.
dd if=/dev/urandom of="$chunk_file" bs=${chunk_mb}M count=$((file_mb / chunk_mb)) status=none
# Workers loop only while the main script lives: if cleanup ever loses the
# race with a kill, an orphaned worker finishes its current pass and stops
# instead of hammering the disk forever.
write_worker() {
local file=$1
while kill -0 $$ 2>/dev/null; do
dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null || return
done
}
read_worker() {
local file=$1
while kill -0 $$ 2>/dev/null; do
dd if="$file" of=/dev/null bs=${chunk_mb}M iflag=direct status=none 2>/dev/null || return
done
}
device_sectors() {
local -a stats
read -r -a stats < "/sys/class/block/$dev/stat"
if [[ $1 == "read" ]]; then
echo "${stats[2]}"
else
echo "${stats[6]}"
fi
}
run_phase() {
local phase=$1
local file before after deadline rate alive samples=0
local baseline_sectors baseline_time end_time
for file in "${test_files[@]}"; do
"${phase}_worker" "$file" 2>/dev/null &
worker_pids+=("$!")
done
before=$(device_sectors "$phase")
deadline=$((SECONDS + phase_seconds))
while (( SECONDS < deadline )) && (( $(alive_workers) > 0 )); do
sleep 1
after=$(device_sectors "$phase")
end_time=$EPOCHREALTIME
rate=$(awk -v before="$before" -v after="$after" 'BEGIN {
if (after < before) print 0
else print (after - before) * 512 / 1000000
}')
echo "$phase $(format_rate "$rate")"
samples=$((samples + 1))
# The first second is warm-up -- governor ramp, crypt workers spinning
# up -- so the steady-state average starts after it.
if (( samples == 1 )); then
baseline_sectors=$after
baseline_time=$end_time
fi
before=$after
done
# The workers only stop on their own when dd fails (quota, I/O error, full
# disk), so any worker gone before the deadline is a failed measurement,
# not a finished one.
alive=$(alive_workers)
stop_workers
if (( alive < parallel )); then
echo "Disk $phase test failed before finishing" >&2
exit 1
fi
# The figure the dial settles on is the steady-state mean over the whole
# phase, not whatever rate the final second happened to catch.
if (( samples > 1 )); then
rate=$(awk -v before="$baseline_sectors" -v after="$after" -v start="$baseline_time" -v end="$end_time" 'BEGIN {
secs = end - start
if (secs <= 0 || after < before) print 0
else print (after - before) * 512 / 1000000 / secs
}')
echo "$phase $(format_rate "$rate")"
fi
}
# The read phase runs first, so its data must be staged before any measuring
# starts. Direct I/O leaves nothing in the page cache to serve reads from.
for file in "${test_files[@]}"; do
dd if="$chunk_file" of="$file" bs=${chunk_mb}M oflag=direct conv=notrunc status=none 2>/dev/null &
worker_pids+=("$!")
done
stage_failed=0
for pid in "${worker_pids[@]}"; do
wait "$pid" || stage_failed=1
done
worker_pids=()
if (( stage_failed )) || [[ ! -s ${test_files[0]} ]]; then
echo "Direct disk I/O is not available on $target_dir" >&2
exit 1
fi
run_phase read
run_phase write