Script

#!/usr/bin/env bash
set -euo pipefail

# fio_test.sh
# 修复版 v2: 修复了 set -e 导致的意外退出问题 (read pipe crash)
# 增加了更稳健的错误处理和进度显示

BS_LIST=("4k" "64k" "512k" "1m")

: "${FIO_SIZE:=2G}"
: "${FIO_IODEPTH:=32}"
: "${FIO_NUMJOBS:=1}"
: "${FIO_RUNTIME:=30}"
: "${FIO_RAMP_TIME:=2}"
: "${FIO_DIRECT:=1}"
: "${FIO_IOENGINE:=libaio}"
: "${FIO_TARGET:=}"

usage() {
  cat <<EOF
Usage:
  sudo $0

Optional environment variables:
  FIO_TARGET=/mnt/storage    (skip menu and test this mountpoint directly)
  FIO_SIZE=2G
  FIO_IODEPTH=32
  FIO_NUMJOBS=1
  FIO_RUNTIME=30             (Seconds per test)
  FIO_DIRECT=1
  FIO_IOENGINE=libaio
  ALLOW_ROOTFS=1
EOF
}

need_cmd() { command -v "$1" >/dev/null 2>&1; }

install_fio_if_missing() {
  if need_cmd fio; then return 0; fi
  if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
    echo "ERROR: fio not found. Install with: sudo apt-get update && sudo apt-get install -y fio" >&2
    exit 1
  fi
  apt-get update -y >/dev/null
  apt-get install -y fio >/dev/null
}

make_tmpdir() {
  local base="$1"
  local d="$base/.fio_tmp_$(date +%s)_$$"
  mkdir -p "$d"
  echo "$d"
}

cleanup() {
  if [[ -n "${TMPDIR_PATH:-}" && -d "${TMPDIR_PATH:-}" ]]; then
    rm -rf "${TMPDIR_PATH:?}"
  fi
}
trap cleanup EXIT INT TERM

tty_print() {
  if [[ -e /dev/tty ]]; then
    printf "%s" "$*" > /dev/tty
  else
    printf "%s" "$*" >&2
  fi
}
tty_println() { tty_print "$*"$'\n'; }
tty_readline() {
  local __varname="$1"
  local line=""
  if [[ -r /dev/tty ]]; then
    IFS= read -r line < /dev/tty || true
  else
    IFS= read -r line || true
  fi
  printf -v "$__varname" "%s" "$line"
}

is_writable_dir() {
  local d="$1"
  [[ -n "$d" && -d "$d" && -w "$d" ]]
}

lsblk_choices_from_f() {
  if ! need_cmd lsblk; then
    echo "ERROR: lsblk not found (util-linux)." >&2
    exit 1
  fi
  local out
  out="$(LC_ALL=C lsblk -fnr -o NAME,FSTYPE,FSVER,SIZE,MOUNTPOINTS 2>/dev/null || true)"
  if [[ -z "$out" ]]; then
    echo "ERROR: lsblk returned no output." >&2
    exit 1
  fi

  local idx=0
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    local name fstype fsver size mps
    name="$(awk '{print $1}' <<<"$line")"
    fstype="$(awk '{print $2}' <<<"$line")"
    fsver="$(awk '{print $3}' <<<"$line")"
    size="$(awk '{print $4}' <<<"$line")"
    mps="$(cut -d' ' -f5- <<<"$line" | sed 's/^[[:space:]]*//')"
    if [[ -n "$mps" ]]; then
      idx=$((idx + 1))
      printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$idx" "$name" "${fstype:-}" "${fsver:-}" "${size:-}" "$mps"
    fi
  done <<<"$out"

  if (( idx == 0 )); then
    echo "ERROR: No mounted filesystems found in lsblk -f output." >&2
    exit 1
  fi
}

select_target_mountpoint() {
  mapfile -t choices < <(lsblk_choices_from_f)

  tty_println "Detected mounted targets (select by number):"
  tty_println ""

  local line idx name fstype fsver size mps
  for line in "${choices[@]}"; do
    IFS=$'\t' read -r idx name fstype fsver size mps <<<"$line"
    [[ -z "$fstype" ]] && fstype="-"
    [[ -z "$fsver" ]] && fsver="-"
    tty_println "  $(printf "%2d" "$idx")) $(printf "%-8s" "$name")  $(printf "%-6s" "$fstype")  $(printf "%-6s" "$fsver")  $(printf "%-8s" "$size")  $mps"
  done

  tty_println ""
  tty_print "Select a target by number (1-${#choices[@]}): "

  local sel
  tty_readline sel

  if [[ ! "$sel" =~ ^[0-9]+$ ]] || (( sel < 1 || sel > ${#choices[@]} )); then
    echo "ERROR: Invalid selection: ${sel:-<empty>}" >&2
    exit 1
  fi

  IFS=$'\t' read -r idx name fstype fsver size mps <<<"${choices[$((sel - 1))]}"
  local mp
  mp="$(awk '{print $1}' <<<"$mps")"

  if ! is_writable_dir "$mp"; then
    echo "ERROR: Selected mountpoint is not writable: $mp (NAME: $name)" >&2
    exit 1
  fi

  if [[ "$mp" == "/" && "${ALLOW_ROOTFS:-0}" != "1" ]]; then
    echo "ERROR: Refusing to run on '/' by default." >&2
    echo "      If you are sure, set ALLOW_ROOTFS=1 and rerun." >&2
    exit 1
  fi

  tty_println ""
  tty_println "Selected target: NAME=${name}, mountpoint=${mp}, fstype=${fstype:-}, size=${size:-}"
  tty_println ""

  echo "$mp"
}

fio_run_terse() {
  local rw="$1"
  local bs="$2"
  local filename="$3"
  local err_file="$4"

  local out=""
  if [[ "${FIO_RUNTIME}" =~ ^[0-9]+$ ]] && (( FIO_RUNTIME > 0 )); then
    out="$(fio \
      --name=fio_test \
      --filename="${filename}" \
      --rw="${rw}" \
      --bs="${bs}" \
      --size="${FIO_SIZE}" \
      --ioengine="${FIO_IOENGINE}" \
      --direct="${FIO_DIRECT}" \
      --iodepth="${FIO_IODEPTH}" \
      --numjobs="${FIO_NUMJOBS}" \
      --group_reporting \
      --randrepeat=0 \
      --norandommap=1 \
      --invalidate=1 \
      --time_based=1 \
      --runtime="${FIO_RUNTIME}" \
      --ramp_time="${FIO_RAMP_TIME}" \
      --output-format=terse \
      --terse-version=3 \
      2> "${err_file}" || true)"
  else
    out="$(fio \
      --name=fio_test \
      --filename="${filename}" \
      --rw="${rw}" \
      --bs="${bs}" \
      --size="${FIO_SIZE}" \
      --ioengine="${FIO_IOENGINE}" \
      --direct="${FIO_DIRECT}" \
      --iodepth="${FIO_IODEPTH}" \
      --numjobs="${FIO_NUMJOBS}" \
      --group_reporting \
      --randrepeat=0 \
      --norandommap=1 \
      --invalidate=1 \
      --time_based=0 \
      --output-format=terse \
      --terse-version=3 \
      2> "${err_file}" || true)"
  fi

  local line
  line="$(printf "%s\n" "$out" | sed '/^[[:space:]]*$/d' | head -n1 || true)"

  if [[ -z "$line" ]]; then
    # Return empty implies error handled by caller or exit
    return 1
  fi

  echo "$line"
}

# Python parsing script - Left aligned to avoid heredoc indentation errors
parse_terse_v3_script() {
cat <<'PY_SCRIPT'
import sys
try:
    line = sys.stdin.read().strip()
    if not line:
        sys.exit(0) # Empty input is handled by caller

    parts = line.split(';')
    def fnum(i, default=0.0):
        try:
            return float(parts[i])
        except Exception:
            return float(default)

    # index 6=read_bw_kib, 7=read_iops
    # index 47=write_bw_kib, 48=write_iops
    read_bw_kib = fnum(6, 0.0)
    read_iops = fnum(7, 0.0)
    write_bw_kib = fnum(47, 0.0)
    write_iops = fnum(48, 0.0)

    # Output: read_bytes read_iops write_bytes write_iops
    print(f"{read_bw_kib * 1024.0} {read_iops} {write_bw_kib * 1024.0} {write_iops}")
except Exception:
    # On python error, output zeros so bash doesn't crash on read
    print("0 0 0 0")
PY_SCRIPT
}

parse_terse_v3() {
  python3 -c "$(parse_terse_v3_script)"
}

format_cell() {
  local bw_bytes="$1"
  local iops="$2"
  python3 -c "
import sys
try:
    bw = float(sys.argv[1])
    iops = float(sys.argv[2])
    GiB = 1024**3
    MiB = 1024**2
    if bw >= GiB:
        bw_s = f'{bw/GiB:.2f} GB/s'
    else:
        bw_s = f'{bw/MiB:.2f} MB/s'
    
    if iops >= 1000:
        iops_s = f'{iops/1000:.1f}k'
    else:
        iops_s = f'{iops:.0f}'
    print(f'{bw_s}  ({iops_s})')
except:
    print('N/A')
" "$bw_bytes" "$iops"
}

main() {
  if [[ $# -ne 0 ]]; then
    usage
    exit 1
  fi

  install_fio_if_missing
  need_cmd python3 || { echo "ERROR: python3 not found." >&2; exit 1; }

  local target_dir=""
  if [[ -n "$FIO_TARGET" ]]; then
    target_dir="$FIO_TARGET"
  else
    target_dir="$(select_target_mountpoint)"
  fi

  if ! is_writable_dir "$target_dir"; then
    echo "ERROR: Target is not a writable directory: $target_dir" >&2
    exit 1
  fi

  TMPDIR_PATH="$(make_tmpdir "$target_dir")"
  local testfile="${TMPDIR_PATH}/fio_testfile.bin"

  tty_println "---------------------------------------------------"
  tty_println "Initializing: Creating ${FIO_SIZE} test file on ${target_dir}..."
  tty_println "(This writes real data, please wait...)"

  local prefill_err="${TMPDIR_PATH}/prefill.err"
  if ! fio \
      --name=prefill \
      --filename="${testfile}" \
      --rw=write \
      --bs=1m \
      --size="${FIO_SIZE}" \
      --ioengine="${FIO_IOENGINE}" \
      --direct="${FIO_DIRECT}" \
      --iodepth="${FIO_IODEPTH}" \
      --numjobs="${FIO_NUMJOBS}" \
      --group_reporting \
      --invalidate=1 \
      >/dev/null 2>"${prefill_err}"; then
    echo "ERROR: fio prefill failed." >&2
    echo "---- fio stderr (prefill) ----" >&2
    cat "${prefill_err}" >&2
    echo "Hints:" >&2
    echo "  - Try: FIO_DIRECT=0" >&2
    echo "  - Try: FIO_IOENGINE=sync" >&2
    exit 1
  fi

  # Store results per bs
  declare -A RB RI WB WI

  tty_println "Starting benchmarks (Runtime limit: ${FIO_RUNTIME}s per test)..."

  for bs in "${BS_LIST[@]}"; do
    tty_print " -> Testing Block Size: ${bs}... "

    local rerr="${TMPDIR_PATH}/randread_${bs}.err"
    local werr="${TMPDIR_PATH}/randwrite_${bs}.err"

    # --- RANDREAD ---
    local rline
    rline="$(fio_run_terse "randread" "${bs}" "${testfile}" "${rerr}" || true)"
    
    if [[ -z "$rline" ]]; then
       tty_println "FAILED."
       echo "ERROR: RandRead failed for ${bs}. Check ${rerr}" >&2
       cat "${rerr}" >&2
       exit 1
    fi

    # Safer read: parse to variable first, then read from string
    local r_parsed
    r_parsed="$(printf "%s" "$rline" | parse_terse_v3)"
    local rr_bw rr_iops
    read -r rr_bw rr_iops _ _ <<< "${r_parsed:-0 0 0 0}"

    # --- RANDWRITE ---
    local wline
    wline="$(fio_run_terse "randwrite" "${bs}" "${testfile}" "${werr}" || true)"

    if [[ -z "$wline" ]]; then
       tty_println "FAILED."
       echo "ERROR: RandWrite failed for ${bs}. Check ${werr}" >&2
       cat "${werr}" >&2
       exit 1
    fi

    local w_parsed
    w_parsed="$(printf "%s" "$wline" | parse_terse_v3)"
    local ww_bw ww_iops
    read -r _ _ ww_bw ww_iops <<< "${w_parsed:-0 0 0 0}"

    RB["$bs"]="$rr_bw"; RI["$bs"]="$rr_iops"
    WB["$bs"]="$ww_bw"; WI["$bs"]="$ww_iops"
    
    tty_println "Done."
  done

  # Output generation
  tty_println ""
  echo "---------------------------------"
  echo "Block Size | 4k            (IOPS) | 64k           (IOPS)"
  echo "  ------   | ---            ----  | ----           ---- "

  local r4 r64 w4 w64 t4 t64
  r4="$(format_cell "${RB[4k]:-0}"  "${RI[4k]:-0}")"
  r64="$(format_cell "${RB[64k]:-0}" "${RI[64k]:-0}")"
  w4="$(format_cell "${WB[4k]:-0}"  "${WI[4k]:-0}")"
  w64="$(format_cell "${WB[64k]:-0}" "${WI[64k]:-0}")"

  local tb4 tb64
  tb4="$(python3 -c "print(float(${RB[4k]:-0}) + float(${WB[4k]:-0}))")"
  tb64="$(python3 -c "print(float(${RB[64k]:-0}) + float(${WB[64k]:-0}))")"
  local ti4 ti64
  ti4="$(python3 -c "print(float(${RI[4k]:-0}) + float(${WI[4k]:-0}))")"
  ti64="$(python3 -c "print(float(${RI[64k]:-0}) + float(${WI[64k]:-0}))")"

  t4="$(format_cell "$tb4" "$ti4")"
  t64="$(format_cell "$tb64" "$ti64")"

  printf "Read       | %-20s | %-20s\n" "$r4" "$r64"
  printf "Write      | %-20s | %-20s\n" "$w4" "$w64"
  printf "Total      | %-20s | %-20s\n" "$t4" "$t64"
  echo "           |                      |                     "
  echo "Block Size | 512k          (IOPS) | 1m            (IOPS)"
  echo "  ------   | ---            ----  | ----           ---- "

  local r512 r1m w512 w1m t512 t1m
  r512="$(format_cell "${RB[512k]:-0}" "${RI[512k]:-0}")"
  r1m="$(format_cell "${RB[1m]:-0}"   "${RI[1m]:-0}")"
  w512="$(format_cell "${WB[512k]:-0}" "${WI[512k]:-0}")"
  w1m="$(format_cell "${WB[1m]:-0}"   "${WI[1m]:-0}")"

  local tb512 tb1m ti512 ti1m
  tb512="$(python3 -c "print(float(${RB[512k]:-0}) + float(${WB[512k]:-0}))")"
  tb1m="$(python3 -c "print(float(${RB[1m]:-0}) + float(${WB[1m]:-0}))")"
  ti512="$(python3 -c "print(float(${RI[512k]:-0}) + float(${WI[512k]:-0}))")"
  ti1m="$(python3 -c "print(float(${RI[1m]:-0}) + float(${WI[1m]:-0}))")"

  t512="$(format_cell "$tb512" "$ti512")"
  t1m="$(format_cell "$tb1m" "$ti1m")"

  printf "Read       | %-20s | %-20s\n" "$r512" "$r1m"
  printf "Write      | %-20s | %-20s\n" "$w512" "$w1m"
  printf "Total      | %-20s | %-20s\n" "$t512" "$t1m"
}

Disk I/O Benchmark Script – User Manual

1. Overview

This Bash script serves as an automated wrapper for the Flexible I/O Tester (fio). It is designed to benchmark the Random Read and Random Write performance of storage devices across multiple block sizes (4k, 64k, 512k, 1m). The script simplifies the benchmarking process by handling dependency checks, temporary file creation, target selection, and result parsing, ultimately presenting the data in a concise, formatted table.

2. Prerequisites

Before executing the script, ensure the following requirements are met:

  • Operating System: Linux (Bash shell required).
  • Permissions: Root privileges are required (sudo) to install dependencies and perform direct I/O operations effectively.
  • Dependencies:
    • Python 3: Required for parsing logs and formatting the output table.
    • util-linux: Specifically the lsblk command (standard on most Linux distributions).
    • fio: The script attempts to automatically install this via apt-get if missing. On non-Debian/Ubuntu systems, please install fio manually.

3. Installation

  1. Save the script content to a file, for example, fio_benchmark.sh.
  2. Grant execution permissions to the file:
    chmod +x fio_benchmark.sh

4. Usage

4.1. Interactive Mode (Default)

Run the script with sudo. The script will detect mounted filesystems and prompt you to select a target.

sudo ./fio_benchmark.sh

Steps:

  1. The script lists all detected, writable mount points.
  2. Enter the number corresponding to the desired target drive.
  3. The script creates a temporary test directory, generates a test file, and begins benchmarking.
  4. Upon completion, a results table is displayed, and temporary files are automatically deleted.

4.2. Automated / Non-Interactive Mode

To skip the selection menu, define the FIO_TARGET environment variable pointing to the desired directory.

sudo FIO_TARGET=/mnt/data ./fio_benchmark.sh

4.3. Testing the Root Filesystem

By default, the script prevents testing on the root directory (/) to avoid accidental system impact. To override this safety feature, set ALLOW_ROOTFS=1.

sudo ALLOW_ROOTFS=1 FIO_TARGET=/ ./fio_benchmark.sh

5. Configuration

You can customize the benchmark parameters by setting environment variables before running the script.

VariableDefaultDescription
FIO_SIZE2GThe size of the test file created.
FIO_RUNTIME30Duration (in seconds) for each block size test.
FIO_IODEPTH32Number of I/O units to keep in flight against the file.
FIO_NUMJOBS1Number of clones (processes/threads) of this job.
FIO_DIRECT1If 1, use non-buffered I/O (O_DIRECT). Usually best for hardware benchmarking.
FIO_IOENGINElibaioDefines how the job issues I/O to the file.
FIO_RAMP_TIME2Warm-up time (in seconds) before statistics are logged.

Example: Running a longer, larger test

sudo FIO_SIZE=10G FIO_RUNTIME=60 ./fio_benchmark.sh

6. Output Interpretation

The final output is a matrix displaying throughput and IOPS (Input/Output Operations Per Second).

  • Block Size: The size of the data chunk read/written in a single operation. Smaller sizes (4k) test random access performance (IOPS), while larger sizes (1m) test sequential/throughput performance.
  • Read / Write: The specific metrics for read or write operations.
  • Total: The sum of Read and Write performance (relevant if mixed workloads were configured, though this script tests them sequentially per block size).

Example Output:

Block Size | 4k            (IOPS) | 64k           (IOPS)
  ------   | ---            ----  | ----           ----
Read       | 50.00 MB/s    (12.8k)| 450.00 MB/s   (7.2k)
Write      | 48.00 MB/s    (12.2k)| 420.00 MB/s   (6.7k)

7. Safety and Cleanup

  • Temporary Files: The script creates a hidden directory named .fio_tmp_<timestamp>_<pid> inside the target directory.
  • Automatic Cleanup: A trap function is registered to automatically delete this temporary directory upon script exit, interruption (Ctrl+C), or termination.
  • Data Safety: The script creates a new file (fio_testfile.bin) inside the temporary directory. It does not overwrite existing user data, provided the script is pointed to a valid directory.

8. Troubleshooting

Error: “fio not found”

  • Cause: fio is not installed, and the script could not install it automatically (likely running on RHEL/CentOS/Fedora or without network).
  • Solution: Install manually: sudo yum install fio or sudo pacman -S fio.

Error: “python3 not found”

  • Cause: The system is missing the Python 3 interpreter.
  • Solution: Install Python 3 via your package manager.

Error: “Refusing to run on ’/’ by default”

  • Cause: You selected the root partition without the override flag.
  • Solution: See section 4.3 above.

Script exits immediately after “Initializing”

  • Cause: The prefill stage failed, often due to file system permissions or lack of free space.
  • Solution: Check the error message printed to stderr. Ensure the target drive has enough free space (default 2GB) and is writable. Try setting FIO_DIRECT=0 if the filesystem does not support direct I/O (e.g., zfs/tmpfs in some configurations).

Auto Fio Test Tool

Author

Shayne Wong

Publish Date

01 - 23 - 2026

License

Shayne Wong

Avatar
Shayne Wong

All time is no time when it is past.