mirror of
https://github.com/lotabout/skim.git
synced 2026-09-10 07:16:23 -04:00
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>
436 lines
14 KiB
Bash
Executable file
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
|