Background:
Do you want to test and benchmark your hard disks HD, SSD, NVMe or even ZFS, LVM storage media in your latest Ubuntu system? Here, I will let you know how to create simple script to benchmark your disks just like how CrystalDiskMark works in Windows system
Prerequisites:
To run the script on Ubuntu 24.04 LTS, you need to install the following packages:
fio: The core benchmarking tool used to perform the actual I/O tests. Without this, the script cannot execute the storage tests (SEQ1M,RND4K, etc.).jq: A lightweight command-line JSON processor used by the script to parse and extract bandwidth results from thefiooutput. The script requestsfioto output results in JSON format.jqis used to pull specific numbers (likebwfor bandwidth) out of that complex data so it can be averaged and displayed.
Installation Command
Open your terminal and run the following command to ensure everything is installed:
sudo apt update && sudo apt install fio jq
How to:
#!/bin/bash
# 1. Root & Dependency Check
[[ $EUID -ne 0 ]] && echo "Please run with sudo to clear system caches for accurate results." && exit 1
for pkg in fio jq; do
command -v $pkg &> /dev/null || { echo "Error: $pkg missing. Please run: sudo apt install $pkg -y"; exit 1; }
done
# 2. Colors & Globals
CYAN='\033[0;36m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
TEMP_FILE=""
# 3. Cleanup Trap Function ----
cleanup() {
echo -e "\n\n${YELLOW}[!] Benchmark aborted.${NC}"
if [[ -f "$TEMP_FILE" ]]; then
echo "[*] Removing temporary test file..."
rm -f "$TEMP_FILE"
fi
echo -e "${CYAN}Cleanup complete. Goodbye!${NC}"
exit 1
}
trap cleanup SIGINT
echo -e "\n${CYAN}===== Universal Disk FIO Benchmark =====${NC}\n"
# 4. Runs & Size Inputs
read -p "$(echo -e "Number of runs [1-5] (${CYAN}Default: 2${NC}): ")" RUNS
RUNS=${RUNS:-2}
# Quick validation: if input is not 1-5, default to 2
if [[ ! "$RUNS" =~ ^[1-5]$ ]]; then
echo -e "[!] $RUNS is invalid input. Defaulting to 2 runs."
RUNS=2
fi
# ---- Size Selection -----
echo -e "\nSelect test file size:"
printf " 1) 64MB 2) 128MB 3) 256MB 4) 512MB 5) ${CYAN}1GB (Default)${NC}\n"
printf " 6) 2GB 7) 4GB 8) 8GB 9) 16GB 10) 32GB\n"
read -p "$(echo -e "\nChoose [1-10] (Default: 5): ")" SIZE_OPT
# If user hits Enter (empty), or types anything else, we handle it in the case
case "${SIZE_OPT:-5}" in
1) SIZE="64M" ;; 2) SIZE="128M" ;; 3) SIZE="256M" ;; 4) SIZE="512M" ;;
5) SIZE="1G" ;; 6) SIZE="2G" ;; 7) SIZE="4G" ;; 8) SIZE="8G" ;;
9) SIZE="16G" ;; 10) SIZE="32G" ;;
*)
# This catches anything else not in 1-10
echo -e "[!] $SIZE_OPT is invalid input. Defaulting to 1GB test file size."
SIZE="1G" ;; # Fallback for invalid input or Enter
esac
echo -e "This disk benchmark tool will run ${CYAN}$RUNS${NC} time(s) for selected ${CYAN}$SIZE${NC} temporary file size .\n"
# 5. Directory Selection
while true; do
read -p "$(echo -e "\nDirectory to test [${YELLOW}Default: . (current)${NC}]: ")" TEST_DIR
TEST_DIR=${TEST_DIR:-.}
if [[ -d "$TEST_DIR" ]] && touch "$TEST_DIR/.fio_test" 2>/dev/null; then
rm "$TEST_DIR/.fio_test"
TEST_DIR=$(realpath "$TEST_DIR")
break
else
echo -e "${YELLOW}[!] Error: Invalid path or no write permission.${NC}"
fi
done
# 6. Media Detection
echo -ne "\n[*] Detecting storage media for $TEST_DIR... "
# 1. Check for ZFS (Check filesystem type)
FS_TYPE=$(df -T "$TEST_DIR" | tail -1 | awk '{print $2}')
if [[ "$FS_TYPE" == "zfs" ]]; then
MEDIA_TYPE="3"
MEDIA_LABEL="ZFS Pool"
else
# 2. Identify the device and check for LVM (Check if device is on mapper or type is lvm)
DEV_PATH=$(df "$TEST_DIR" | tail -1 | awk '{print $1}')
# Check if the device is a dm (device mapper) or explicitly labeled as LVM
IS_LVM=$(lsblk -no TYPE "$DEV_PATH" | head -1)
if [[ "$IS_LVM" == "lvm" || "$DEV_PATH" == /dev/mapper/* ]]; then
MEDIA_TYPE="3" # LVM uses the same "optimized" profile as ZFS in your script
MEDIA_LABEL="LVM Logical Volume"
else
# 3. Detect physical media (NVMe vs SSD vs HD)
DISK_NAME=$(lsblk -no pkname "$DEV_PATH" | head -1)
[[ -z "$DISK_NAME" ]] && DISK_NAME=$(basename "$DEV_PATH")
if [[ "$DISK_NAME" == nvme* ]]; then
MEDIA_TYPE="2"
MEDIA_LABEL="NVMe SSD"
else
IS_ROTA=$(cat "/sys/block/$DISK_NAME/queue/rotational" 2>/dev/null)
if [[ "$IS_ROTA" == "0" ]]; then
MEDIA_TYPE="2"
MEDIA_LABEL="SATA SSD"
else
MEDIA_TYPE="1"
MEDIA_LABEL="Hard Drive (Mechanical)"
fi
fi
fi
fi
echo -e "\033[1;32m$MEDIA_LABEL Detected\033[0m"
# 6. Optimization and Profiles
if [[ "$MEDIA_TYPE" == "2" ]]; then
echo -e "\n[*] Optimizing NAND with TRIM..."
# fstrim needs the mount point, so we find it based on your TEST_DIR
MOUNT_POINT=$(df --output=target "$TEST_DIR" | tail -1)
if fstrim -v "$MOUNT_POINT" &>/dev/null; then
echo "[+] TRIM completed successfully on $MOUNT_POINT."
else
echo "[!] Warning: TRIM failed or not supported on this mount/device."
fi
# Give the controller a moment to reorganize cells
sleep 3
fi
# Define Profiles based on User Selection
if [ "$MEDIA_TYPE" == "1" ]; then
# HD Profile Label | ReadMode | WriteMode | BlockSize | Depth | Threads
tests=("SEQ1M_Q8T1 read write 1M 8 1" "SEQ1M_Q1T1 read write 1M 1 1" "RND4K_Q32T1 randread randwrite 4k 32 1" "RND4K_Q1T1 randread randwrite 4k 1 1")
elif [ "$MEDIA_TYPE" == "2" ]; then
# SSD/NVMe Profile (High Queue Depths)
tests=("SEQ1M_Q8T1 read write 1M 8 1" "SEQ128K_Q32T1 read write 128k 32 1" "RND4K_Q32T16 randread randwrite 4k 32 16" "RND4K_Q1T1 randread randwrite 4k 1 1")
else
# ZFS/LVM Profile (Testing overhead and pool throughput)
echo "[!] Note: For ZFS, ensure SIZE ($SIZE) > System RAM to bypass ARC."
tests=("SEQ1M_Q32T1 read write 1M 32 1" "SEQ128K_Q32T1 read write 128k 32 1" "RND4K_Q32T8 randread randwrite 4k 32 8" "RND4K_Q1T1 randread randwrite 4k 1 1")
fi
echo -e "\nStarting Benchmark...\n"
echo -e "Press ${CYAN}Ctrl-C${NC} at any time to abort and cleanup."
echo -e "Using $SIZE test file per pass.\n"
# 7. Extraction & Execution
run_fio() {
local MODE=$1 BS=$2 QD=$3 T=$4
# 1. Unique filename for every single test pass
TEMP_FILE="$TEST_DIR/fio_test_$(date +%s%N).tmp"
# 2. Clear System Caches
sync && echo 3 > /proc/sys/vm/drop_caches && sleep 3
# 3. Execute FIO
# refill_buffers prevents the drive or OS from compressing the data
fio --name=benchmark \
--filename="$TEMP_FILE" \
--rw="$MODE" \
--bs="$BS" \
--iodepth="$QD" \
--numjobs="$T" \
--direct=1 \
--invalidate=1 \
--ioengine=libaio \
--size="$SIZE" \
--runtime=20 \
--refill_buffers \
--time_based \
--group_reporting \
--output-format=json > "$TEST_DIR/fio_res.json" 2>/dev/null
# 4. Cleanup test file
local OUT=$(cat "$TEST_DIR/fio_res.json")
rm -f "$TEST_DIR/fio_res.json"
# Clean up the large test file immediately
rm -f "$TEMP_FILE"
TEMP_FILE=""
# 5. Output
echo "$OUT"
}
# ---- Extract bandwidth (KiB/s to MB/s) ----
# Fixed JQ path: jobs[0] is the standard for group_reporting
get_bw_read() { echo "$1" | jq -r '.jobs[0].read.bw / 1024' 2>/dev/null || echo "0"; }
get_bw_write() { echo "$1" | jq -r '.jobs[0].write.bw / 1024' 2>/dev/null || echo "0"; }
declare -A READ_SUM; declare -A WRITE_SUM
printf "\n%-18s %12s %12s\n" "Test" "Read (MB/s)" "Write (MB/s)"
printf "%-18s %12s %12s\n" "------------------" "------------" "------------"
for ((i=1; i<=RUNS; i++)); do
echo "**Running $i/$RUNS**"
for t in "${tests[@]}"; do
set -- $t
NAME=$1; R_MODE=$2; W_MODE=$3; BS=$4; QD=$5; THREAD=$6
# Set the filename HERE so the trap knows it exists BEFORE fio starts
TEMP_FILE="$TEST_DIR/fio_test_$(date +%s%N).tmp"
# Run Read Pass
JSON_R=$(run_fio "$R_MODE" "$BS" "$QD" "$THREAD")
READ=$(get_bw_read "$JSON_R")
# Reset for write pass
TEMP_FILE="$TEST_DIR/fio_test_$(date +%s%N).tmp"
# Run Write Pass
JSON_W=$(run_fio "$W_MODE" "$BS" "$QD" "$THREAD")
WRITE=$(get_bw_write "$JSON_W")
# Accumulate totals for averaging
READ_SUM[$NAME]=$(awk "BEGIN {print ${READ_SUM[$NAME]:-0} + $READ}")
WRITE_SUM[$NAME]=$(awk "BEGIN {print ${WRITE_SUM[$NAME]:-0} + $WRITE}")
printf "%-18s %12.2f %12.2f\n" "$NAME" "$READ" "$WRITE"
done
done
# ---- Final Averages ----
echo -e "\n========= Average Results (Final) ========="
printf "%-18s %12s %12s\n" "Test" "Read (MB/s)" "Write (MB/s)"
printf "%-18s %12s %12s\n" "------------------" "------------" "------------"
for t in "${tests[@]}"; do
NAME=$(echo $t | awk '{print $1}')
AVG_R=$(awk "BEGIN {printf \"%.2f\", ${READ_SUM[$NAME]} / $RUNS}")
AVG_W=$(awk "BEGIN {printf \"%.2f\", ${WRITE_SUM[$NAME]} / $RUNS}")
printf "%-18s %12s %12s\n" "$NAME" "$AVG_R" "$AVG_W"
done
echo -e "\nDone. Benchmark completed."
echo -e "Goodbye!"