Skip to content

Instantly share code, notes, and snippets.

@OndraZizka
Created May 19, 2026 15:49
Show Gist options
  • Select an option

  • Save OndraZizka/b643d937f13d3f8cc4e9fba0aa3cbcb3 to your computer and use it in GitHub Desktop.

Select an option

Save OndraZizka/b643d937f13d3f8cc4e9fba0aa3cbcb3 to your computer and use it in GitHub Desktop.
Tuning HDD performance options in Linux

For long batches of unattended work performed by a single process, HDD access scheduling can be optimized to let the Linux kernel and the disk's driver save some HDD head movements when not necessary.

The script takes a number, where 0 are the system defaults, 1 is UI-oriented, and then 2 to 5 give gradually larger buffers and longer IO deadlines (to utilize caching to the max).

#!/bin/bash
# Tune I/O scheduler for sda
# Usage: tune_sda_iosched.sh [multiplier]
#
# Workload: pure large sequential I/O (photo reads, snapshotter video frame writes)
# DB has been moved to NVMe SSD - no more random I/O on this disk.
#
# Multiplier 0 = kernel defaults
# Multiplier 1 = base tuned values (recommended for normal use)
# Multiplier 2 = 2× base (snapshotter bulk writing sessions)
# etc. (non-integer multipliers accepted, e.g. 1.5)
#
# Parameters scaled:
# read_expire (ms) - base 750 (relaxed, sequential reads aren't latency-sensitive)
# write_expire (ms) - base 5000 (same as default, writes are already sequential)
# writes_starved - base 2 (no competing workloads, keep default)
# fifo_batch - base 32 (less reordering needed, I/O is already sequential)
# nr_requests - base 128 (no deep queue needed for sequential workload)
# read_ahead_kb - base 8192 (8MB readahead, key for large sequential reads)
# wbt_lat_usec - inversely scaled: mult 0=75000, 1=75000, >=2=0 (disabled)
# # front_merges - boolean, not scaled; confirmed 1 (enabled) - correct for HDD
DEFAULTS="500 5000 2 16 60 128 75000 1"
BASES="750 5000 2 32 128 8192 75000 1"
LABELS="read_expire write_expire writes_starved fifo_batch nr_requests read_ahead_kb wbt_lat_usec front_merges"
PATHS=(
/sys/block/sda/queue/iosched/read_expire
/sys/block/sda/queue/iosched/write_expire
/sys/block/sda/queue/iosched/writes_starved
/sys/block/sda/queue/iosched/fifo_batch
/sys/block/sda/queue/nr_requests
/sys/block/sda/queue/read_ahead_kb
/sys/block/sda/queue/wbt_lat_usec
/sys/block/sda/queue/iosched/front_merges
)
MULT=${1:-2}
if ! [[ "$MULT" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
echo "Usage: $0 [multiplier] (non-negative number, default: 2)"
echo " 0 = kernel defaults, 1 = base tuned, 2 = 2x base, etc."
exit 1
fi
read -r -a DEF_ARR <<< "$DEFAULTS"
read -r -a BASE_ARR <<< "$BASES"
read -r -a LBL_ARR <<< "$LABELS"
# Compute the values to apply
APPLY=()
for i in "${!BASE_ARR[@]}"; do
base=${BASE_ARR[$i]}
def=${DEF_ARR[$i]}
lbl=${LBL_ARR[$i]}
if [[ "$MULT" == "0" ]]; then
APPLY+=("$def")
elif [[ "$lbl" == "wbt_lat_usec" ]]; then
# Inverse: higher mult = lower latency cap; 0 = disabled
# mult 1 = 75000 (default), mult 2+ = 0 (fully disabled)
if (( $(echo "$MULT >= 2" | bc -l) )); then
val=0
else
val=$(echo "scale=0; $base / $MULT / 1" | bc)
fi
APPLY+=("$val")
elif [[ "$lbl" == "front_merges" ]]; then
# Boolean - always enabled (1) for HDD; multiplier 0 also keeps it 1
APPLY+=(1)
else
val=$(echo "scale=0; $base * $MULT / 1" | bc)
APPLY+=("$val")
fi
done
# Read current values
CUR=()
for path in "${PATHS[@]}"; do
CUR+=("$(cat "$path" 2>/dev/null || echo '?')")
done
# Print header
printf "%-20s %10s %10s %10s %10s\n" "parameter" "default" "current" "will-apply" "after"
printf "%-20s %10s %10s %10s %10s\n" "--------------------" "----------" "----------" "----------" "----------"
# Apply and read back
AFTER=()
for i in "${!PATHS[@]}"; do
path=${PATHS[$i]}
val=${APPLY[$i]}
echo "$val" > "$path" 2>/dev/null
AFTER+=("$(cat "$path" 2>/dev/null || echo '?')")
done
# Print all rows
for i in "${!LBL_ARR[@]}"; do
printf "%-20s %10s %10s %10s %10s\n" \
"${LBL_ARR[$i]}" "${DEF_ARR[$i]}" "${CUR[$i]}" "${APPLY[$i]}" "${AFTER[$i]}"
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment