lotabout.skim/bench.sh
LoricAndre a08f6ac9b3
feat!: interactive pty preview & concurrency optimizations (#952)
This PR has grown beyond its initial scope due to me over-optimizing everything, but it leads to:

    Paving the way for future actually interactive previews
    Consistently better performance than fzf in our bench thanks to thread and concurrency optimizations as well as the use of kanal for the items channels

Given the scope, I'm marking this as breaking because:

    setting wrap in the preview window layout disables the pty since we don't want to manipulate the raw buffer to word-wrap it manually
    kanal channels work slightly differently and might break library usage, even though switching to them did not require any modifications of the examples so it's unlikely that users will see anything break


* fix: force cwd for preview

* fix: correctly set cwd & kill pty child in the right order

* fix: use std threads & reopen new pty for each preview

* feat: use tui-term for displaying

* feat: scroll in pty

* fix: make nested skim previews work

* fix: clippy mistake

* feat: reactive preview triggering

* chore: generate completions & manpage

* chore: optimizations & thread cleanup

* chore: use kanal for faster channels

* fix: tests

* fix: only send items if the matcher hasn't been killed in the meantime (#947)

* tests: add coverage

* tests: fix bin path with coverage

* tests: upload tests to codecov

* chore: make pty opt-in through preview-window

* chore: generate completions & manpage

---------

Co-authored-by: Skim bot <skim-bot@skim-rs.github.io>
2026-02-09 21:43:04 +00:00

436 lines
14 KiB
Bash
Executable file

#!/usr/bin/env bash
# Benchmark script to measure ingestion + matching rate in skim interactive mode
# This measures how fast skim can ingest items and display matched results
#
# Usage: bench.sh [BINARY_PATH] [-n|--num-items NUM] [-q|--query QUERY] [-r|--runs RUNS]
# [-f|--file FILE] [-g|--generate-file FILE] [-- EXTRA_ARGS...]
#
# Arguments:
# BINARY_PATH Path to binary (default: ./target/release/sk)
# -n, --num-items NUM Number of items to generate (default: 1000000)
# -q, --query QUERY Query string to search (default: "test")
# -r, --runs RUNS Number of benchmark runs to average (default: 1)
# -f, --file FILE Use existing file as input instead of generating
# -g, --generate-file FILE Generate test data to file and exit
# -- Pass remaining arguments to the binary
#
# Examples:
# ./bench.sh # Use defaults
# ./bench.sh ./target/release/sk -n 500000 -q foo
# ./bench.sh -n 1000000 -q test -- --no-sort --exact
# ./bench.sh -r 5 # Run 5 times and show average
# ./bench.sh -f input.txt -q search # Use existing file
# ./bench.sh -g testdata.txt -n 2000000 # Generate file and exit
set -e
export SHELL="/bin/sh"
unset HISTFILE
# Default values
BINARY_PATH="./target/release/sk"
NUM_ITEMS=1000000
QUERY="test"
RUNS=1
INPUT_FILE=""
GENERATE_FILE=""
EXTRA_ARGS=""
# Parse arguments
ARGS=()
FOUND_SEP=0
for arg in "$@"; do
if [ "$arg" = "--" ]; then
FOUND_SEP=1
elif [ $FOUND_SEP -eq 0 ]; then
ARGS+=("$arg")
else
EXTRA_ARGS="$EXTRA_ARGS $arg"
fi
done
# Parse named arguments
i=0
while [ $i -lt ${#ARGS[@]} ]; do
arg="${ARGS[$i]}"
case "$arg" in
-n | --num-items)
i=$((i + 1))
NUM_ITEMS="${ARGS[$i]}"
;;
-q | --query)
i=$((i + 1))
QUERY="${ARGS[$i]}"
;;
-r | --runs)
i=$((i + 1))
RUNS="${ARGS[$i]}"
;;
-f | --file)
i=$((i + 1))
INPUT_FILE="${ARGS[$i]}"
;;
-g | --generate-file)
i=$((i + 1))
GENERATE_FILE="${ARGS[$i]}"
;;
-*)
echo "Unknown option: $arg" >&2
exit 1
;;
*)
# First non-option argument is binary path
BINARY_PATH="$arg"
;;
esac
i=$((i + 1))
done
# Trim leading space from EXTRA_ARGS
EXTRA_ARGS=$(echo "$EXTRA_ARGS" | sed 's/^ *//')
# Validate conflicting options
if [ -n "$INPUT_FILE" ] && [ -n "$GENERATE_FILE" ]; then
echo "Error: Cannot use both --file and --generate-file" >&2
exit 1
fi
# Function to generate test data
generate_test_data() {
local output_file="$1"
local num_items="$2"
awk -v num="$num_items" 'BEGIN {
srand()
words[1]="home"; words[2]="usr"; words[3]="etc"; words[4]="var"; words[5]="opt"
words[6]="tmp"; words[7]="dev"; words[8]="proc"; words[9]="sys"; words[10]="lib"
words[11]="bin"; words[12]="sbin"; words[13]="boot"; words[14]="mnt"; words[15]="media"
words[16]="src"; words[17]="test"; words[18]="config"; words[19]="data"; words[20]="logs"
words[21]="cache"; words[22]="backup"; words[23]="docs"; words[24]="images"; words[25]="videos"
words[26]="audio"; words[27]="downloads"; words[28]="uploads"; words[29]="temp"; words[30]="shared"
for (i = 1; i <= num; i++) {
depth = int(rand() * 9) + 2 # 2-10 depth
path = ""
for (j = 1; j <= depth; j++) {
word_idx = int(rand() * 30) + 1
path = path words[word_idx]
if (j < depth) path = path "/"
}
print path "_" i
}
}' >"$output_file"
}
# Handle --generate-file mode
if [ -n "$GENERATE_FILE" ]; then
echo "Generating $NUM_ITEMS items to $GENERATE_FILE..."
generate_test_data "$GENERATE_FILE" "$NUM_ITEMS"
echo "Generated $NUM_ITEMS items successfully"
exit 0
fi
echo "=== Skim Ingestion + Matching Benchmark ==="
echo "Binary: $BINARY_PATH | Items: $NUM_ITEMS | Query: '$QUERY' | Runs: $RUNS"
[ -n "$INPUT_FILE" ] && echo "Input file: $INPUT_FILE"
[ -n "$EXTRA_ARGS" ] && echo "Extra args: $EXTRA_ARGS"
# Arrays to store results from multiple runs
ELAPSED_TIMES=()
RATES=()
PEAK_MEMS=()
PEAK_CPUS=()
MATCHED_COUNTS=()
COMPLETED_COUNT=0
# Prepare test data file
STATUS_FILE=$(mktemp)
CLEANUP_INPUT=0
if [ -n "$INPUT_FILE" ]; then
# Use provided input file
if [ ! -f "$INPUT_FILE" ]; then
echo "Error: Input file '$INPUT_FILE' not found" >&2
exit 1
fi
TMP_FILE="$INPUT_FILE"
# Count lines in the file to determine NUM_ITEMS
NUM_ITEMS=$(wc -l <"$INPUT_FILE")
echo "Using input file with $NUM_ITEMS items"
else
# Generate test data to temporary file
TMP_FILE=$(mktemp)
CLEANUP_INPUT=1
echo "Generating test data..."
generate_test_data "$TMP_FILE" "$NUM_ITEMS"
fi
trap "rm -f $STATUS_FILE; [ $CLEANUP_INPUT -eq 1 ] && rm -f $TMP_FILE || true" EXIT
# Run benchmark multiple times
for RUN in $(seq 1 $RUNS); do
if [ $RUNS -gt 1 ]; then
echo ""
echo "=== Run $RUN/$RUNS ==="
fi
SESSION_NAME="skim_bench_$$_$RUN"
# Create a new tmux session in the background
tmux new-session -s "$SESSION_NAME" -d
# Unset HISTFILE in the tmux session to prevent command from appearing in shell history
tmux send-keys -t "$SESSION_NAME" "unset HISTFILE" Enter
tmux send-keys -t "$SESSION_NAME" "unset FZF_DEFAULT_OPTS" Enter
tmux send-keys -t "$SESSION_NAME" "unset SKIM_DEFAULT_OPTIONS" Enter
sleep 0.1
# Prepare to capture the start time as close to data ingestion as possible
# Run skim with the query already set, and measure until matcher completes
tmux send-keys -t "$SESSION_NAME" "cat $TMP_FILE | $BINARY_PATH --query '$QUERY' $EXTRA_ARGS" Enter
# Record start time
START=$(date +%s%N)
# Wait a bit for skim to actually start
sleep 0.2
# Find skim PID for resource monitoring
SK_PID=""
for i in 1 2 3 4 5; do
sleep 0.5
SK_PID=$(pgrep -lf "$BINARY_PATH" | grep -E "sk|fzf" | head -1 | cut -d' ' -f1)
if [ -n "$SK_PID" ]; then
break
fi
done
if [ -n "$SK_PID" ]; then
# Start background monitoring of CPU and RAM
MONITOR_LOG="/tmp/skim-monitor-$SK_PID.log"
rm -f "$MONITOR_LOG"
(
PEAK_MEM=0
PEAK_CPU=0
while kill -0 "$SK_PID" 2>/dev/null; do
MEM=$(ps -p "$SK_PID" -o rss= 2>/dev/null | tr -d ' ')
CPU=$(ps -p "$SK_PID" -o %cpu= 2>/dev/null | tr -d ' ')
if [ -n "$MEM" ] && [ "$MEM" -gt "$PEAK_MEM" ]; then
PEAK_MEM=$MEM
fi
if [ -n "$CPU" ]; then
CPU_INT=$(echo "$CPU" | cut -d. -f1)
PEAK_CPU_INT=$(echo "$PEAK_CPU" | cut -d. -f1)
if [ "$CPU_INT" -gt "$PEAK_CPU_INT" ]; then
PEAK_CPU=$CPU
fi
fi
echo "$MEM $CPU" >>"$MONITOR_LOG"
sleep 0.1
done
echo "PEAK:$PEAK_MEM:$PEAK_CPU" >>"$MONITOR_LOG"
) &
MONITOR_PID=$!
else
MONITOR_PID=""
fi
# Monitor for matcher completion by checking status line
# Wait for matched count to stabilize for 10 seconds to ensure matching is truly complete
COMPLETED=0
MATCHED_COUNT=0
TOTAL_INGESTED=0
PREV_MATCHED_COUNT=-1
STABLE_START_TIME=0
REQUIRED_STABLE_DURATION_NS=10000000000 # 10 seconds in nanoseconds
MAX_WAIT=60
CHECK_INTERVAL=0.05 # 50ms for <0.05s precision
ELAPSED_CHECKS=0
MAX_CHECKS=$((MAX_WAIT * 20)) # 60 seconds * 20 checks per second = 1200 checks
END=0
while [ $ELAPSED_CHECKS -lt $MAX_CHECKS ]; do
sleep $CHECK_INTERVAL
ELAPSED_CHECKS=$((ELAPSED_CHECKS + 1))
# Capture and check status using bench.sh's method
tmux capture-pane -b "status-$SESSION_NAME" -t "$SESSION_NAME" 2>/dev/null || true
tmux save-buffer -b "status-$SESSION_NAME" "$STATUS_FILE" 2>/dev/null || true
if [ -f "$STATUS_FILE" ]; then
# Skim status line format is typically: " > query matched/total"
# We need to find the last occurrence of the pattern matched/total
# The first number is matched items, second is total ingested items
STATUS_LINE=$(grep -oE '[0-9]+/[0-9]+' "$STATUS_FILE" 2>/dev/null | head -1 || echo "")
if [ -n "$STATUS_LINE" ]; then
MATCHED_COUNT=$(echo "$STATUS_LINE" | cut -d'/' -f1)
TOTAL_INGESTED=$(echo "$STATUS_LINE" | cut -d'/' -f2)
# Check if ingestion is complete
if [ "$TOTAL_INGESTED" = "$NUM_ITEMS" ]; then
# Check if matched count has changed
if [ "$MATCHED_COUNT" != "$PREV_MATCHED_COUNT" ]; then
# Count changed, reset stability timer and mark end time
PREV_MATCHED_COUNT=$MATCHED_COUNT
STABLE_START_TIME=$(date +%s%N)
END=$STABLE_START_TIME
else
# Count is same as before, check if we've been stable long enough
if [ $STABLE_START_TIME -gt 0 ]; then
CURRENT_TIME=$(date +%s%N)
STABLE_NS=$((CURRENT_TIME - STABLE_START_TIME))
if [ $STABLE_NS -ge $REQUIRED_STABLE_DURATION_NS ]; then
COMPLETED=1
break
fi
fi
fi
fi
fi
fi
done
# If we didn't capture an end time, set it now
if [ $END -eq 0 ]; then
END=$(date +%s%N)
fi
# Exit skim
tmux send-keys -t "$SESSION_NAME" Escape
sleep 0.1
# Wait for monitor to finish if it was started
if [ -n "$MONITOR_PID" ]; then
wait "$MONITOR_PID" 2>/dev/null || true
fi
# Clean up session
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
ELAPSED_NS=$((END - START))
ELAPSED_SEC=$(awk "BEGIN {printf \"%.3f\", $ELAPSED_NS / 1000000000}")
RATE=$(awk "BEGIN {printf \"%.0f\", $NUM_ITEMS / $ELAPSED_SEC}")
# Extract peak CPU and RAM usage
PEAK_MEM=0
PEAK_CPU=0
if [ -n "$MONITOR_PID" ] && [ -f "$MONITOR_LOG" ]; then
PEAK_LINE=$(grep "^PEAK:" "$MONITOR_LOG" 2>/dev/null || echo "")
if [ -n "$PEAK_LINE" ]; then
PEAK_MEM=$(echo "$PEAK_LINE" | cut -d: -f2)
PEAK_CPU=$(echo "$PEAK_LINE" | cut -d: -f3)
fi
rm -f "$MONITOR_LOG"
fi
# Store results
ELAPSED_TIMES+=("$ELAPSED_SEC")
RATES+=("$RATE")
MATCHED_COUNTS+=("$MATCHED_COUNT")
PEAK_MEMS+=("$PEAK_MEM")
PEAK_CPUS+=("$PEAK_CPU")
if [ $COMPLETED -eq 1 ]; then
COMPLETED_COUNT=$((COMPLETED_COUNT + 1))
fi
# Print individual run results
if [ $RUNS -gt 1 ]; then
echo "Status: $(if [ $COMPLETED -eq 1 ]; then echo 'COMPLETED'; else echo 'TIMEOUT'; fi)"
echo "Items matched: $MATCHED_COUNT / $NUM_ITEMS"
echo "Total time: ${ELAPSED_SEC}s"
echo "Items/second: ${RATE}"
if [ -n "$PEAK_MEM" ] && [ "$PEAK_MEM" -gt 0 ]; then
echo "Peak memory usage: $((PEAK_MEM / 1024)) MB"
echo "Peak CPU usage: ${PEAK_CPU}%"
fi
fi
done
# Calculate and display average results
echo ""
echo "=== Results ==="
if [ $RUNS -gt 1 ]; then
echo "Completed runs: $COMPLETED_COUNT / $RUNS"
# Calculate averages
AVG_TIME=$(awk -v times="${ELAPSED_TIMES[*]}" 'BEGIN {
n = split(times, arr, " ")
sum = 0
for (i = 1; i <= n; i++) sum += arr[i]
printf "%.3f", sum / n
}')
AVG_RATE=$(awk -v rates="${RATES[*]}" 'BEGIN {
n = split(rates, arr, " ")
sum = 0
for (i = 1; i <= n; i++) sum += arr[i]
printf "%.0f", sum / n
}')
AVG_MATCHED=$(awk -v counts="${MATCHED_COUNTS[*]}" 'BEGIN {
n = split(counts, arr, " ")
sum = 0
for (i = 1; i <= n; i++) sum += arr[i]
printf "%.0f", sum / n
}')
AVG_MEM=$(awk -v mems="${PEAK_MEMS[*]}" 'BEGIN {
n = split(mems, arr, " ")
sum = 0
count = 0
for (i = 1; i <= n; i++) {
if (arr[i] > 0) {
sum += arr[i]
count++
}
}
if (count > 0) printf "%.0f", sum / count
else print "0"
}')
AVG_CPU=$(awk -v cpus="${PEAK_CPUS[*]}" 'BEGIN {
n = split(cpus, arr, " ")
sum = 0
count = 0
for (i = 1; i <= n; i++) {
if (arr[i] > 0) {
sum += arr[i]
count++
}
}
if (count > 0) printf "%.1f", sum / count
else print "0"
}')
# Calculate min/max for several metrics so we can show them alongside averages
read -r MIN_TIME MAX_TIME <<<"$(awk -v times="${ELAPSED_TIMES[*]}" 'BEGIN { n=split(times,a," "); min=a[1]; max=a[1]; for(i=1;i<=n;i++){ if(a[i]<min) min=a[i]; if(a[i]>max) max=a[i]; } printf "%.3f %.3f", min, max }')"
read -r MIN_RATE MAX_RATE <<<"$(awk -v rates="${RATES[*]}" 'BEGIN { n=split(rates,a," "); min=a[1]; max=a[1]; for(i=1;i<=n;i++){ if(a[i]<min) min=a[i]; if(a[i]>max) max=a[i]; } printf "%.0f %.0f", min, max }')"
read -r MIN_MATCHED MAX_MATCHED <<<"$(awk -v counts="${MATCHED_COUNTS[*]}" 'BEGIN { n=split(counts,a," "); min=a[1]; max=a[1]; for(i=1;i<=n;i++){ if(a[i]<min) min=a[i]; if(a[i]>max) max=a[i]; } printf "%.0f %.0f", min, max }')"
# For memory and CPU, ignore zero entries (meaning not measured)
read -r MIN_MEM MAX_MEM <<<"$(awk -v mems="${PEAK_MEMS[*]}" 'BEGIN { n=split(mems,a," "); min=1e18; max=0; found=0; for(i=1;i<=n;i++){ if(a[i] > 0){ if(a[i]<min) min=a[i]; if(a[i]>max) max=a[i]; found=1 } } if(found) printf "%.0f %.0f", min, max; else print "0 0" }')"
read -r MIN_CPU MAX_CPU <<<"$(awk -v cpus="${PEAK_CPUS[*]}" 'BEGIN { n=split(cpus,a," "); min=1e18; max=0; found=0; for(i=1;i<=n;i++){ if(a[i] > 0){ if(a[i]<min) min=a[i]; if(a[i]>max) max=a[i]; found=1 } } if(found) printf "%.1f %.1f", min, max; else print "0 0" }')"
echo "Average items matched: $AVG_MATCHED / $NUM_ITEMS (min: $MIN_MATCHED, max: $MAX_MATCHED)"
echo "Average time: ${AVG_TIME}s (min: ${MIN_TIME}s, max: ${MAX_TIME}s)"
echo "Average items/second: ${AVG_RATE} (min: ${MIN_RATE}, max: ${MAX_RATE})"
if [ "$AVG_MEM" != "0" ] && [ -n "$AVG_MEM" ]; then
echo "Average peak memory usage: $((AVG_MEM / 1024)) MB (min: $((MIN_MEM / 1024)) MB, max: $((MAX_MEM / 1024)) MB)"
echo "Average peak CPU usage: ${AVG_CPU}% (min: ${MIN_CPU}%, max: ${MAX_CPU}%)"
fi
else
# Single run - display results
echo "Status: $(if [ $COMPLETED_COUNT -eq 1 ]; then echo 'COMPLETED'; else echo 'TIMEOUT'; fi)"
echo "Items matched: ${MATCHED_COUNTS[0]} / $NUM_ITEMS"
echo "Total time: ${ELAPSED_TIMES[0]}s"
echo "Items/second: ${RATES[0]}"
if [ "${PEAK_MEMS[0]}" != "0" ] && [ -n "${PEAK_MEMS[0]}" ]; then
echo "Peak memory usage: $((${PEAK_MEMS[0]} / 1024)) MB"
echo "Peak CPU usage: ${PEAK_CPUS[0]}%"
fi
fi