Skip to content

Commit 87aadbe

Browse files
authored
perf: P0 performance optimizations (#598)
1 parent d1c5a84 commit 87aadbe

6 files changed

Lines changed: 202 additions & 77 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020

2121
### Changed
2222
- Split Windows CI test jobs into parallel chunks to avoid timeouts
23+
- Optimize clock: prioritize EPOCHREALTIME over subprocess-based fallbacks
24+
- Cache function discovery to avoid duplicate pipeline per test file
25+
- Reduce subshells in test execution hot path
26+
- Batch coverage recording with in-memory buffering
2327

2428
### Fixed
2529
- JUnit XML report now conforms to the standard schema

src/clock.sh

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,58 +8,59 @@ function bashunit::clock::_choose_impl() {
88
local attempts_count=0
99
local attempts
1010

11-
# 1. Try Perl with Time::HiRes
11+
# 1. Try native shell EPOCHREALTIME (fastest - no subprocess, Bash 5.0+)
12+
attempts[attempts_count]="EPOCHREALTIME"
13+
attempts_count=$((attempts_count + 1))
14+
if shell_time="$(bashunit::clock::shell_time)"; then
15+
_BASHUNIT_CLOCK_NOW_IMPL="shell"
16+
return 0
17+
fi
18+
19+
# 2. Unix date +%s%N (no subprocess overhead on supported systems)
20+
attempts[attempts_count]="date"
21+
attempts_count=$((attempts_count + 1))
22+
if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then
23+
local result
24+
result=$(date +%s%N 2>/dev/null)
25+
local _re='^[0-9]+$'
26+
if [[ "$result" != *N ]] && [[ "$result" =~ $_re ]]; then
27+
_BASHUNIT_CLOCK_NOW_IMPL="date"
28+
return 0
29+
fi
30+
fi
31+
32+
# 3. Try Perl with Time::HiRes
1233
attempts[attempts_count]="Perl"
1334
attempts_count=$((attempts_count + 1))
1435
if bashunit::dependencies::has_perl && perl -MTime::HiRes -e "" &>/dev/null; then
1536
_BASHUNIT_CLOCK_NOW_IMPL="perl"
1637
return 0
1738
fi
1839

19-
# 2. Try Python 3 with time module
40+
# 4. Try Python 3 with time module
2041
attempts[attempts_count]="Python"
2142
attempts_count=$((attempts_count + 1))
2243
if bashunit::dependencies::has_python; then
2344
_BASHUNIT_CLOCK_NOW_IMPL="python"
2445
return 0
2546
fi
2647

27-
# 3. Try Node.js
48+
# 5. Try Node.js
2849
attempts[attempts_count]="Node"
2950
attempts_count=$((attempts_count + 1))
3051
if bashunit::dependencies::has_node; then
3152
_BASHUNIT_CLOCK_NOW_IMPL="node"
3253
return 0
3354
fi
34-
# 4. Windows fallback with PowerShell
55+
56+
# 6. Windows fallback with PowerShell
3557
attempts[attempts_count]="PowerShell"
3658
attempts_count=$((attempts_count + 1))
3759
if bashunit::check_os::is_windows && bashunit::dependencies::has_powershell; then
3860
_BASHUNIT_CLOCK_NOW_IMPL="powershell"
3961
return 0
4062
fi
4163

42-
# 5. Unix fallback using `date +%s%N` (if not macOS or Alpine)
43-
attempts[attempts_count]="date"
44-
attempts_count=$((attempts_count + 1))
45-
if ! bashunit::check_os::is_macos && ! bashunit::check_os::is_alpine; then
46-
local result
47-
result=$(date +%s%N 2>/dev/null)
48-
local _re='^[0-9]+$'
49-
if [[ "$result" != *N ]] && [[ "$result" =~ $_re ]]; then
50-
_BASHUNIT_CLOCK_NOW_IMPL="date"
51-
return 0
52-
fi
53-
fi
54-
55-
# 6. Try using native shell EPOCHREALTIME (if available)
56-
attempts[attempts_count]="EPOCHREALTIME"
57-
attempts_count=$((attempts_count + 1))
58-
if shell_time="$(bashunit::clock::shell_time)"; then
59-
_BASHUNIT_CLOCK_NOW_IMPL="shell"
60-
return 0
61-
fi
62-
6364
# 7. Very last fallback: seconds resolution only
6465
attempts[attempts_count]="date-seconds"
6566
attempts_count=$((attempts_count + 1))

src/coverage.sh

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE="${_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE:-
1313
# File to store which tests hit each line (for detailed coverage tooltips)
1414
_BASHUNIT_COVERAGE_TEST_HITS_FILE="${_BASHUNIT_COVERAGE_TEST_HITS_FILE:-}"
1515

16+
# In-memory buffer for coverage data (reduces file I/O)
17+
_BASHUNIT_COVERAGE_BUFFER=""
18+
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
19+
_BASHUNIT_COVERAGE_BUFFER_LIMIT=100
20+
_BASHUNIT_COVERAGE_HITS_BUFFER=""
21+
22+
# In-memory caches for hot-path lookups (avoids grep + subshells)
23+
_BASHUNIT_COVERAGE_TRACK_CACHE=""
24+
_BASHUNIT_COVERAGE_PATH_CACHE=""
25+
_BASHUNIT_COVERAGE_IS_PARALLEL=""
26+
1627
# Auto-discover coverage paths from test file names
1728
# When no explicit coverage paths are set, find source files matching test file base names
1829
# Example: tests/unit/assert_test.sh -> finds src/assert.sh, src/assert_*.sh
@@ -82,6 +93,14 @@ function bashunit::coverage::init() {
8293
: >"$_BASHUNIT_COVERAGE_TRACKED_CACHE_FILE"
8394
: >"$_BASHUNIT_COVERAGE_TEST_HITS_FILE"
8495

96+
# Reset in-memory caches and buffers
97+
_BASHUNIT_COVERAGE_BUFFER=""
98+
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
99+
_BASHUNIT_COVERAGE_HITS_BUFFER=""
100+
_BASHUNIT_COVERAGE_TRACK_CACHE=""
101+
_BASHUNIT_COVERAGE_PATH_CACHE=""
102+
_BASHUNIT_COVERAGE_IS_PARALLEL=""
103+
85104
export _BASHUNIT_COVERAGE_DATA_FILE
86105
export _BASHUNIT_COVERAGE_TRACKED_FILES
87106
export _BASHUNIT_COVERAGE_TRACKED_CACHE_FILE
@@ -105,6 +124,8 @@ function bashunit::coverage::enable_trap() {
105124
function bashunit::coverage::disable_trap() {
106125
trap - DEBUG
107126
set +T
127+
# Flush any remaining buffered coverage data
128+
bashunit::coverage::flush_buffer
108129
}
109130

110131
# Normalize file path to absolute
@@ -171,31 +192,86 @@ function bashunit::coverage::record_line() {
171192
# Skip if coverage data file doesn't exist (trap inherited by child process)
172193
[[ -z "$_BASHUNIT_COVERAGE_DATA_FILE" ]] && return 0
173194

174-
# Skip if not tracking this file (uses cache internally)
175-
bashunit::coverage::should_track "$file" || return 0
195+
# Fast in-memory should_track cache (avoids grep + file I/O per line)
196+
case "$_BASHUNIT_COVERAGE_TRACK_CACHE" in
197+
*"|${file}:0|"*) return 0 ;;
198+
*"|${file}:1|"*) ;;
199+
*)
200+
# Not cached yet — run full check and cache result
201+
if bashunit::coverage::should_track "$file"; then
202+
_BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:1|"
203+
else
204+
_BASHUNIT_COVERAGE_TRACK_CACHE="${_BASHUNIT_COVERAGE_TRACK_CACHE}|${file}:0|"
205+
return 0
206+
fi
207+
;;
208+
esac
176209

177-
# Normalize file path using cache (must match tracked_files for hit counting)
178-
local normalized_file
179-
normalized_file=$(bashunit::coverage::normalize_path "$file")
210+
# Fast in-memory path normalization cache (avoids cd + pwd subshell per line)
211+
local normalized_file=""
212+
case "$_BASHUNIT_COVERAGE_PATH_CACHE" in
213+
*"|${file}="*)
214+
# Extract cached value
215+
normalized_file="${_BASHUNIT_COVERAGE_PATH_CACHE#*"|${file}="}"
216+
normalized_file="${normalized_file%%"|"*}"
217+
;;
218+
*)
219+
normalized_file=$(bashunit::coverage::normalize_path "$file")
220+
_BASHUNIT_COVERAGE_PATH_CACHE="${_BASHUNIT_COVERAGE_PATH_CACHE}|${file}=${normalized_file}|"
221+
;;
222+
esac
180223

181-
# In parallel mode, use a per-process file to avoid race conditions
224+
# Buffer the coverage data in memory
225+
_BASHUNIT_COVERAGE_BUFFER="${_BASHUNIT_COVERAGE_BUFFER}${normalized_file}:${lineno}
226+
"
227+
# Also buffer test hit data if in a test context
228+
if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && \
229+
-n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then
230+
_BASHUNIT_COVERAGE_HITS_BUFFER="${_BASHUNIT_COVERAGE_HITS_BUFFER}${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}
231+
"
232+
fi
233+
234+
_BASHUNIT_COVERAGE_BUFFER_COUNT=$((_BASHUNIT_COVERAGE_BUFFER_COUNT + 1))
235+
236+
# Flush buffer to disk when threshold is reached
237+
if [[ $_BASHUNIT_COVERAGE_BUFFER_COUNT -ge \
238+
$_BASHUNIT_COVERAGE_BUFFER_LIMIT ]]; then
239+
bashunit::coverage::flush_buffer
240+
fi
241+
}
242+
243+
function bashunit::coverage::flush_buffer() {
244+
[[ -z "$_BASHUNIT_COVERAGE_BUFFER" ]] && return 0
245+
246+
# Determine output files (parallel-safe)
182247
local data_file="$_BASHUNIT_COVERAGE_DATA_FILE"
183248
local test_hits_file="$_BASHUNIT_COVERAGE_TEST_HITS_FILE"
184-
if bashunit::parallel::is_enabled; then
249+
250+
# Cache the parallel check to avoid function calls
251+
if [[ -z "$_BASHUNIT_COVERAGE_IS_PARALLEL" ]]; then
252+
if bashunit::parallel::is_enabled; then
253+
_BASHUNIT_COVERAGE_IS_PARALLEL="yes"
254+
else
255+
_BASHUNIT_COVERAGE_IS_PARALLEL="no"
256+
fi
257+
fi
258+
259+
if [[ "$_BASHUNIT_COVERAGE_IS_PARALLEL" == "yes" ]]; then
185260
data_file="${_BASHUNIT_COVERAGE_DATA_FILE}.$$"
186261
test_hits_file="${_BASHUNIT_COVERAGE_TEST_HITS_FILE}.$$"
187262
fi
188263

189-
# Record the hit (only if parent directory exists)
190-
if [[ -d "$(dirname "$data_file")" ]]; then
191-
echo "${normalized_file}:${lineno}" >>"$data_file"
264+
# Write buffered data in a single I/O operation
265+
printf '%s' "$_BASHUNIT_COVERAGE_BUFFER" >>"$data_file"
192266

193-
# Also record which test caused this hit (if we're in a test context)
194-
if [[ -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE:-}" && -n "${_BASHUNIT_COVERAGE_CURRENT_TEST_FN:-}" ]]; then
195-
# Format: source_file:line|test_file:test_function
196-
echo "${normalized_file}:${lineno}|${_BASHUNIT_COVERAGE_CURRENT_TEST_FILE}:${_BASHUNIT_COVERAGE_CURRENT_TEST_FN}" >>"$test_hits_file"
197-
fi
267+
if [[ -n "$_BASHUNIT_COVERAGE_HITS_BUFFER" ]]; then
268+
printf '%s' "$_BASHUNIT_COVERAGE_HITS_BUFFER" >>"$test_hits_file"
198269
fi
270+
271+
# Reset buffer
272+
_BASHUNIT_COVERAGE_BUFFER=""
273+
_BASHUNIT_COVERAGE_HITS_BUFFER=""
274+
_BASHUNIT_COVERAGE_BUFFER_COUNT=0
199275
}
200276

201277
function bashunit::coverage::should_track() {

src/runner.sh

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,15 @@ function bashunit::runner::load_test_files() {
101101
bashunit::runner::restore_workdir
102102
continue
103103
fi
104+
local _cached_fns="$functions_for_script"
104105
if bashunit::parallel::is_enabled; then
105-
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter" 2>/dev/null &
106+
bashunit::runner::call_test_functions \
107+
"$test_file" "$filter" "$tag_filter" \
108+
"$exclude_tag_filter" "$_cached_fns" 2>/dev/null &
106109
else
107-
bashunit::runner::call_test_functions "$test_file" "$filter" "$tag_filter" "$exclude_tag_filter"
110+
bashunit::runner::call_test_functions \
111+
"$test_file" "$filter" "$tag_filter" \
112+
"$exclude_tag_filter" "$_cached_fns"
108113
fi
109114
bashunit::runner::run_tear_down_after_script "$test_file"
110115
bashunit::runner::clean_set_up_and_tear_down_after_script
@@ -343,36 +348,48 @@ function bashunit::runner::call_test_functions() {
343348
local filter="$2"
344349
local tag_filter="${3:-}"
345350
local exclude_tag_filter="${4:-}"
351+
local cached_functions="${5:-}"
346352
local IFS=$' \t\n'
347-
local prefix="test"
348-
# Use cached function names for better performance
349-
local filtered_functions
350-
filtered_functions=$(bashunit::helper::get_functions_to_run \
351-
"$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS")
352353
local -a functions_to_run=()
353354
local functions_to_run_count=0
354-
local _fn
355-
while IFS= read -r _fn; do
356-
[[ -z "$_fn" ]] && continue
357-
functions_to_run[functions_to_run_count]="$_fn"
358-
functions_to_run_count=$((functions_to_run_count + 1))
359-
done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions")
360355

361-
# Apply tag filtering if --tag or --exclude-tag was specified
362-
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
363-
local -a tag_filtered=()
364-
local tag_filtered_count=0
365-
local _tf_fn
366-
for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
367-
local fn_tags
368-
fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script")
369-
if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then
370-
tag_filtered[tag_filtered_count]="$_tf_fn"
371-
tag_filtered_count=$((tag_filtered_count + 1))
372-
fi
356+
if [[ -n "$cached_functions" ]]; then
357+
# Use pre-computed function list from load_test_files (already tag-filtered)
358+
local _fn
359+
for _fn in $cached_functions; do
360+
[[ -z "$_fn" ]] && continue
361+
functions_to_run[functions_to_run_count]="$_fn"
362+
functions_to_run_count=$((functions_to_run_count + 1))
373363
done
374-
functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}")
375-
functions_to_run_count=$tag_filtered_count
364+
else
365+
# Fallback: compute function list (for direct calls without cache)
366+
local prefix="test"
367+
local filtered_functions
368+
filtered_functions=$(bashunit::helper::get_functions_to_run \
369+
"$prefix" "$filter" "$_BASHUNIT_CACHED_ALL_FUNCTIONS")
370+
local _fn
371+
while IFS= read -r _fn; do
372+
[[ -z "$_fn" ]] && continue
373+
functions_to_run[functions_to_run_count]="$_fn"
374+
functions_to_run_count=$((functions_to_run_count + 1))
375+
done < <(bashunit::runner::functions_for_script "$script" "$filtered_functions")
376+
377+
# Apply tag filtering if --tag or --exclude-tag was specified
378+
if [ -n "$tag_filter" ] || [ -n "$exclude_tag_filter" ]; then
379+
local -a tag_filtered=()
380+
local tag_filtered_count=0
381+
local _tf_fn
382+
for _tf_fn in "${functions_to_run[@]+"${functions_to_run[@]}"}"; do
383+
local fn_tags
384+
fn_tags=$(bashunit::helper::get_tags_for_function "$_tf_fn" "$script")
385+
if bashunit::helper::function_matches_tags "$fn_tags" "$tag_filter" "$exclude_tag_filter"; then
386+
tag_filtered[tag_filtered_count]="$_tf_fn"
387+
tag_filtered_count=$((tag_filtered_count + 1))
388+
fi
389+
done
390+
functions_to_run=("${tag_filtered[@]+"${tag_filtered[@]}"}")
391+
functions_to_run_count=$tag_filtered_count
392+
fi
376393
fi
377394

378395
if [[ "$functions_to_run_count" -le 0 ]]; then
@@ -541,10 +558,10 @@ function bashunit::runner::run_test() {
541558
else
542559
bashunit::state::reset_current_test_interpolated_function_name
543560
fi
544-
local current_assertions_failed="$(bashunit::state::get_assertions_failed)"
545-
local current_assertions_snapshot="$(bashunit::state::get_assertions_snapshot)"
546-
local current_assertions_incomplete="$(bashunit::state::get_assertions_incomplete)"
547-
local current_assertions_skipped="$(bashunit::state::get_assertions_skipped)"
561+
local current_assertions_failed="$_BASHUNIT_ASSERTIONS_FAILED"
562+
local current_assertions_snapshot="$_BASHUNIT_ASSERTIONS_SNAPSHOT"
563+
local current_assertions_incomplete="$_BASHUNIT_ASSERTIONS_INCOMPLETE"
564+
local current_assertions_skipped="$_BASHUNIT_ASSERTIONS_SKIPPED"
548565

549566
# (FD = File Descriptor)
550567
# Duplicate the current std-output (FD 1) and assigns it to FD 3.
@@ -655,8 +672,24 @@ function bashunit::runner::run_test() {
655672

656673
bashunit::runner::parse_result "$fn_name" "$test_execution_result" "$@"
657674

658-
local total_assertions="$(bashunit::state::calculate_total_assertions "$test_execution_result")"
659-
local test_exit_code="$(bashunit::state::get_test_exit_code)"
675+
local test_exit_code="$_BASHUNIT_TEST_EXIT_CODE"
676+
677+
# Extract assertion counts directly via parameter expansion
678+
# instead of spawning grep subprocesses
679+
local _te_failed="${test_execution_result##*##ASSERTIONS_FAILED=}"
680+
_te_failed="${_te_failed%%##*}"
681+
local _te_passed="${test_execution_result##*##ASSERTIONS_PASSED=}"
682+
_te_passed="${_te_passed%%##*}"
683+
local _te_skipped="${test_execution_result##*##ASSERTIONS_SKIPPED=}"
684+
_te_skipped="${_te_skipped%%##*}"
685+
local _te_incomplete="${test_execution_result##*##ASSERTIONS_INCOMPLETE=}"
686+
_te_incomplete="${_te_incomplete%%##*}"
687+
local _te_snapshot="${test_execution_result##*##ASSERTIONS_SNAPSHOT=}"
688+
_te_snapshot="${_te_snapshot%%##*}"
689+
local total_assertions=$(( \
690+
${_te_failed:-0} + ${_te_passed:-0} + ${_te_skipped:-0} + \
691+
${_te_incomplete:-0} + ${_te_snapshot:-0} \
692+
))
660693

661694
local encoded_test_title
662695
encoded_test_title="${test_execution_result##*##TEST_TITLE=}"

0 commit comments

Comments
 (0)