#!/usr/bin/env bash # # Copyright (C) 2026 AuxXxilium # # This is free software, licensed under the MIT License. # See /LICENSE for more information. # # Arc Benchmark - storage (hdparm, fio), CPU (openssl) and GPU (ffmpeg via # VCRT) performance, scored so results are comparable between machines. # # One script, two front ends: # # - Run it from a terminal with no arguments and it asks the same questions # it always has, then prints the results and offers to submit them. # - Run it with flags and it is non-interactive: every choice is an option, # progress goes to stdout as it happens, and --json writes the results in # machine-readable form. This is how Arc Control drives it, and it is also # the way to script a run. # # The two modes share every test and, critically, every scoring constant, so a # score from the UI is comparable with one from the terminal and with the # entries in the public score database. # # Interactive mode is chosen only when stdin is a TTY and no options were # given: a piped or redirected run can not answer a prompt, and a prompt that # goes unanswered there would hang the run rather than fail it. VERSION="1.9.2" # cpu_display_name() - trim vendor boilerplate out of a CPU model name. # # /proc/cpuinfo gives the marketing string, not a useful one: "12th Gen # Intel(R) Core(TM) i5-1235U" and "AMD Ryzen(tm) 7 5825U with Radeon(tm) # Graphics" spend most of their length on trademark marks, a generation the # part number already encodes, and a clock speed that is not part of the model. # # This mirrors bench_display_name() in arc-web's scores.php, which is where the # canonical version lives: the score database cleans names the same way, so one # chip spelled differently by two kernels does not become two entries, and the # name shown here matches the name shown there. Keep the two in step - if a # case is added to that function, add it here. # # Inlined rather than sourced from a lib. Arc Control ships cpuname.lib.sh for # its own panels, but this script is also downloaded on its own to /root and # run from a terminal, where no lib sits beside it; a fallback for that case is # how "Intel(R) Xeon(R) D-1581" reached the score database with its marks still # attached. # # The (R) and glyph rules are spelled as alternations, not the shorter [RTM] / # [®™©] classes: busybox sed matches bytes, not characters, so a class over # multibyte glyphs strips their shared 0xc2 lead byte and corrupts unrelated # text that happens to use it - "45°C" comes out as an invalid sequence. cpu_display_name() { _cdn_out="$(printf '%s' "$1" | sed -E \ -e 's/[0-9]+(st|nd|rd|th)[[:space:]]+Gen(eration)?[[:space:]]*//Ig' \ -e 's/\((R|TM|C)\)//Ig' \ -e 's/®|™|©//g' \ -e 's/[[:space:]]*@[[:space:]]*[0-9.]+[[:space:]]*[GM]Hz//Ig' \ -e 's/[[:space:]]+([0-9]+|two|three|four|six|eight|ten|twelve|sixteen)[- ]core//Ig' \ -e 's/[[:space:]]+(with|w\/)[[:space:]]+.*$//I' \ -e 's/[[:space:]]+(CPU|Processor|APU)([[:space:]]|$)/\2/Ig' \ -e 's/[[:space:]]+/ /g' \ -e 's/^[[:space:]]+|[[:space:]]+$//g')" # A name that cleaned away to nothing means the patterns ate something they # should not have; showing the raw string beats showing an empty field. if [ -z "${_cdn_out}" ]; then printf '%s' "$1" else printf '%s' "${_cdn_out}" fi } # CPU scoring. These constants are the calibration: changing any of them makes # new scores incomparable with every score already in the database. # score = (raw / CPU_CAL)^CPU_EXP * CPU_REF^(1 - CPU_EXP) # sha512, not sha256: SHA-NI accelerates sha256 on newer chips but not sha512. # The exponent compresses the top of the range; a plain divisor fitted to NAS # hardware overshoots a fast desktop chip by ~37%. HASH_SECONDS=1 CPU_CAL=204 CPU_EXP=0.89 CPU_REF=390 # Multi-core damping, normalised to 1.0 at one thread: # efficiency(t) = 1 / (1 + (t / MT_DIV)^MT_EXP) # Gentler than measured efficiency because CPU_EXP already compresses the top; # fitting the damper on its own double-counts and puts wide CPUs ~33% low. MT_DIV=105 MT_EXP=1.35 # Fallback only, for boxes without openssl. LOOP_CAL_SINGLE=26000 LOOP_CAL_MULTI=26000 BENCH_VIDEO_URL="https://github.com/AuxXxilium/arc-utils/raw/refs/heads/main/bench/bench.mp4" SUBMIT_URL="https://arc.auxxxilium.tech/bench" FFMPEG_BIN="/var/packages/vcrt/target/bin/ffmpeg" STORAGE_BENCH="yes" CPU_BENCH="yes" GPU_BENCH="yes" VOLUME="/volume1" JSON_OUT="" SUBMIT="no" USERNAME="Anonymous" # Interactive unless told otherwise. Resolved after parsing: passing any option # means the caller has already made its choices, so prompting would be wrong. INTERACTIVE="" usage() { cat <&2; usage >&2; exit 2 ;; esac done # Prompting needs a terminal to prompt at: without one `read` returns # immediately at EOF, and the run would take every default in silence while # looking like it had asked. A caller that wants questions on a pipe has to # say so with --interactive. if [ -z "$INTERACTIVE" ]; then if [ "$_had_args" = "no" ] && [ -t 0 ]; then INTERACTIVE="yes" else INTERACTIVE="no" fi fi VOLUME="/${VOLUME#/}" # ---------------------------------------------------------------- JSON output # Results accumulate as "sectionlabelvalue" lines and are turned into # JSON at the end. Sections are emitted as ordered lists of rows rather than # objects because labels repeat - the storage section reports "IOPS" twice, once # for random read and once for random write, and as object keys the second would # silently replace the first. RESULT_ROWS="" add_row() { RESULT_ROWS="${RESULT_ROWS}${1} ${2} ${3} " } # Escapes a value for use inside a JSON string, newlines included: a curl error # body is typically multi-line, and a raw newline inside a string is invalid # JSON, which would make the whole results file unreadable to the caller. json_escape() { printf '%s' "$1" | tr -d '\r' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \ -e 's/\t/\\t/g' | awk '{ printf "%s%s", sep, $0; sep = "\\n" } END { printf "" }' } write_json() { [ -n "$JSON_OUT" ] || return 0 # Written to a temporary file and renamed, because the caller polls this # file every couple of seconds while the run is in progress: writing in # place would let a poll catch it truncated, mid-rewrite. rename is atomic # within a filesystem, so a reader sees either the old file or the new one. { printf '{\n' printf ' "version": "%s",\n' "$(json_escape "$VERSION")" printf ' "submitted": %s,\n' "$([ "$SUBMITTED" = "yes" ] && echo true || echo false)" [ -n "$SUBMIT_ERROR" ] && printf ' "submitError": "%s",\n' "$(json_escape "$SUBMIT_ERROR")" printf ' "results": [\n' printf '%s' "$RESULT_ROWS" | awk -F'\t' ' function esc(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); return s } NF < 3 { next } { if ($1 != section) { if (section != "") printf "\n ]\n },\n" printf " {\n \"section\": \"%s\",\n \"rows\": [", esc($1) section = $1; first = 1 } printf "%s\n {\"label\": \"%s\", \"value\": \"%s\"}", (first ? "" : ","), esc($2), esc($3) first = 0 } END { if (section != "") printf "\n ]\n }\n" } ' printf ' ]\n}\n' } > "${JSON_OUT}.tmp" && mv -f "${JSON_OUT}.tmp" "$JSON_OUT" } # ------------------------------------------------------------- GPU detection # Normalise a vendor string (or PCI vendor id) to NVIDIA / Intel / AMD. # Prints nothing and returns 1 for anything else. normalize_gpu_vendor() { case "$1" in *NVIDIA*|*nVidia*|*nvidia*|0x10de|10de) printf "NVIDIA" ;; *Intel*|*intel*|0x8086|8086) printf "Intel" ;; *AMD*|*amd*|*"Advanced Micro Devices"*|*ATI*|*ati*|0x1002|1002|0x1022|1022) printf "AMD" ;; *) return 1 ;; esac } # Strip the vendor prefix and the trailing "(rev xx)" from an lspci device name. # # The trademark marks go too, the way cpu_display_name() removes them from a # CPU name: integrated graphics inherit the CPU's marketing string, so lspci # reports names like "Xeon(R) E3-1200 v6/7th Gen Core Processor Integrated # Graphics" and the marks would otherwise reach the score database. Spelled as # an alternation rather than [RTM] or [®™©] for the same reason as there - # busybox sed matches bytes, and a class over multibyte glyphs corrupts any # other text sharing their 0xc2 lead byte. clean_gpu_model() { printf "%s" "$1" | sed -E -e 's/.*\[AMD\/ATI\] //' \ -e 's/.*Advanced Micro Devices[^]]*, Inc\.[[:space:]]*//' \ -e 's/.*NVIDIA Corporation[[:space:]]*//' \ -e 's/.*Intel Corporation[[:space:]]*//' \ -e 's/\((R|TM|C)\)//Ig' \ -e 's/®|™|©//g' \ -e 's/ \(rev[^)]*\)//' \ -e 's/[[:space:]]+/ /g' | xargs } # List every GPU as "||", one per line, deduplicated. # # No PCI class filter is used in pass 2: some boards expose GPUs under # unexpected classes, so sysfs is the primary source (its class codes are # authoritative and it works without lspci), lspci enriches the model names and # catches devices sysfs does not expose, and DRM render nodes are scanned last # so a GPU with a bound driver is found even if both earlier passes missed it. list_gpus() { { local dev slot class vendor_id vendor model name line for dev in /sys/bus/pci/devices/*; do [ -d "$dev" ] || continue slot="${dev##*/}" class=$(cat "$dev/class" 2>/dev/null) # Class 0x03xxxx is the display controller base class; accept every # subclass (VGA, 3D, display, and anything vendors invent later). [ "${class:0:4}" = "0x03" ] || continue vendor_id=$(cat "$dev/vendor" 2>/dev/null) vendor=$(normalize_gpu_vendor "$vendor_id") || continue model="" if command -v lspci >/dev/null 2>&1; then name=$(lspci -s "$slot" 2>/dev/null | head -1) name="${name#* }" name="${name#*: }" model=$(clean_gpu_model "$name") fi printf "%s|%s|%s\n" "$slot" "$vendor" "$model" done if command -v lspci >/dev/null 2>&1; then while IFS= read -r line; do [ -z "$line" ] && continue slot="${line%% *}" name="${line#* }" case "$name" in VGA*|3D*|Display*) ;; *) continue ;; esac name="${name#*: }" vendor=$(normalize_gpu_vendor "$name") || continue case "$slot" in *:*:*) ;; *) slot="0000:${slot}" ;; esac printf "%s|%s|%s\n" "$slot" "$vendor" "$(clean_gpu_model "$name")" done < <(lspci 2>/dev/null) fi for dev in /dev/dri/renderD*; do [ -e "$dev" ] || continue local pci_path pci_path=$(readlink -f "/sys/class/drm/${dev##*/}/device" 2>/dev/null) [ -n "$pci_path" ] && [ -r "$pci_path/vendor" ] || continue slot="${pci_path##*/}" vendor=$(normalize_gpu_vendor "$(cat "$pci_path/vendor" 2>/dev/null)") || continue model="" if command -v lspci >/dev/null 2>&1; then name=$(lspci -s "$slot" 2>/dev/null | head -1) name="${name#* }" name="${name#*: }" model=$(clean_gpu_model "$name") fi printf "%s|%s|%s\n" "$slot" "$vendor" "$model" done } | awk -F'|' ' # One entry per PCI slot, keeping whichever pass produced the most # descriptive model name. !($1 in line) || length($3) > length(model[$1]) { line[$1] = $0; model[$1] = $3 } END { for (slot in line) print line[slot] } ' | sort -t'|' -k1,1 } if [ "${LIST_GPUS_ONLY:-no}" = "yes" ]; then list_gpus exit 0 fi # ------------------------------------------------------------- interactive # Ask a yes/no question, defaulting to yes. Anything starting with n or N is a # no; an empty answer, and anything else, is the default. ask_yes_no() { local prompt="$1" answer read -r -p "$prompt" answer case "$answer" in [nN]*) return 1 ;; *) return 0 ;; esac } # The questions the terminal run has always asked, in the order it asked them. # # Each answer lands in the same variable the flags set, so everything below # this point is identical for both modes - the prompts are a front end onto # the options, not a second code path with its own behaviour to drift. run_prompts() { printf "This script will check your storage (hdparm, fio), CPU (openssl) and GPU\n" printf "(ffmpeg via VCRT) performance. Use at your own risk.\n\n" if [ "${STORAGE_SET:-no}" != "yes" ]; then if ask_yes_no "Run storage benchmark (y or n to skip) [default: y]: "; then if [ "${VOLUME_SET:-no}" != "yes" ]; then local input read -r -p "Enter volume path [default: $VOLUME]: " input [ -n "$input" ] && VOLUME="/${input#/}" fi else STORAGE_BENCH="no" fi fi if [ "${CPU_SET:-no}" != "yes" ]; then ask_yes_no "Run CPU benchmark (y or n to skip) [default: y]: " || CPU_BENCH="no" fi [ "${GPU_SET:-no}" = "yes" ] && return # The GPU question is only worth asking when there is a GPU to test and # something to test it with, so the answer is decided rather than asked in # every other case. local gpus=() gpu slot vendor model usable=0 while IFS= read -r gpu; do [ -n "$gpu" ] && gpus+=("$gpu") done < <(list_gpus) if [ ${#gpus[@]} -eq 0 ]; then printf "No compatible GPU detected.\n" GPU_BENCH="no" return fi if ! command -v "$FFMPEG_BIN" >/dev/null 2>&1; then printf "Compatible GPU detected but VCRT not found.\n" GPU_BENCH="no" return fi # NVIDIA without nvidia-smi cannot be benchmarked, but only skip the # question when that leaves nothing else to test: with several cards the # remaining ones are still worth a run. for gpu in "${gpus[@]}"; do IFS='|' read -r slot vendor model <<< "$gpu" if [ "$vendor" = "NVIDIA" ] && ! command -v nvidia-smi >/dev/null 2>&1; then printf "NVIDIA GPU detected (%s) but nvidia-smi is not available. It will be skipped.\n" "${model:-GPU}" continue fi usable=$((usable + 1)) done if [ "$usable" -eq 0 ]; then GPU_BENCH="no" return fi if [ ${#gpus[@]} -gt 1 ]; then printf "%d compatible GPUs detected and VCRT found:\n" "${#gpus[@]}" for gpu in "${gpus[@]}"; do IFS='|' read -r slot vendor model <<< "$gpu" printf " %s %s\n" "$vendor" "${model:-GPU}" done else printf "Compatible GPU detected and VCRT found.\n" fi ask_yes_no "Run GPU benchmark (y or n to skip) [default: y]: " || GPU_BENCH="no" } # Offer to submit, after the results have been printed. Asked at the end # rather than up front because the answer depends on what the run produced. prompt_submit() { local answer input [ "$SUBMIT" = "yes" ] && return if ! command -v jq >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then printf "\nNo upload possible (jq and curl are required).\n" return fi printf "\nNote: Submitted results are posted to the Discord Benchmark channel and\n" printf "the CPU/GPU scores are also added to the public score database at\n" printf "https://arc.xpenology.tech/scores (no username or hostname is stored).\n\n" read -r -p "Do you want to send the results to Discord Benchmark channel? (y/n): " answer case "$answer" in [yY]*) ;; *) printf "Results not sent.\n"; return ;; esac read -r -p "Enter your username: " input [ -n "$input" ] && USERNAME="$input" SUBMIT="yes" } # Resolve the DRM render node (/dev/dri/renderD*) belonging to a PCI slot. render_node_for_slot() { local slot="$1" node pci_path # lspci prints "00:02.0"; sysfs uses the full "0000:00:02.0" domain form. case "$slot" in *:*:*) ;; *) slot="0000:${slot}" ;; esac for node in /dev/dri/renderD*; do [ -e "$node" ] || continue pci_path=$(readlink -f "/sys/class/drm/${node##*/}/device" 2>/dev/null) [ "${pci_path##*/}" = "$slot" ] && printf "%s" "$node" && return 0 done return 1 } # Any render node not claimed by a specific slot, used as a last resort. any_render_node() { local node for node in /dev/dri/renderD*; do [ -e "$node" ] && printf "%s" "$node" && return 0 done return 1 } # ------------------------------------------------------------------- storage run_fio_test() { local test_name=$1 rw_mode=$2 blocksize=$3 iodepth=$4 direct_flag=$5 printf "Running %s...\n" "$test_name" >&2 fio --name=TEST --filename="$VOLUME/fio-tempfile.dat" \ --rw="$rw_mode" --size=16M --blocksize="$blocksize" \ --ioengine=libaio --fsync=0 --iodepth="$iodepth" --direct="$direct_flag" --numjobs="4" \ --group_reporting 2>/dev/null rm -f "$VOLUME/fio-tempfile.dat" 2>/dev/null } # Pull "