#!/bin/bash # omarchy:summary=Measure live disk read and write speed # omarchy:args=[target-dir] set -e if [[ -n ${1:-} && ! -d $1 ]]; then echo "Usage: omarchy-disk-speedtest [target-dir]" >&2 exit 2 fi target_dir="${1:-${XDG_CACHE_HOME:-$HOME/.cache}/omarchy}" 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/omarchy-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