Skip to content

perf(coverage): the string-scan caches cost 26ms per lookup at 121 files #1056

Description

@Chemaclass

Problem

Three coverage caches store their entries in one long string and look entries up with a leading-* glob. On Bash 3.2 that scan is quadratic in the number of entries, and at real file counts the cache costs more than the work it saves.

  • _BASHUNIT_COVERAGE_STATS_LOOKUP (src/coverage/stats.sh:104 get_cached_stats), report path
  • _BASHUNIT_COVERAGE_TRACK_CACHE (src/coverage/engine.sh:226), capture hot path, scanned per traced line
  • _BASHUNIT_COVERAGE_PATH_CACHE (src/coverage/engine.sh:242), capture hot path, scanned per traced line

Measured on Bash 3.2 arm64, macOS. report_text uses the stats cache and does nothing else expensive, yet its per-file cost grows faster than the file count:

tracked files report_text per file
11 0.03s 3ms
40 0.16s 4ms
80 0.85s 11ms
121 3.12s 26ms

Isolated micro-benchmark of the lookup alone, 121 entries, 8,254-byte lookup string, 605 lookups: 26.0 ms per lookup. 121 x 26ms = 3.1s, which is the whole measured report_text time. The cache is the cost.

The two capture-path caches use the same shape and are scanned once per traced line, so the same curve applies to every executed line of every tracked file.

Proposal

Replace string-scan lookup with Bash's own variable table, the associative-array emulation this repo already uses for spy state (_BASHUNIT_SPY_${variable}_TIMES_FILE, src/doubles/spy.sh):

# key -> a variable name in the reserved namespace
_bu_key="_BASHUNIT_COVERAGE_TRACK_${file//[^a-zA-Z0-9]/_}"
eval "$_bu_key=1"
decision="${!_bu_key:-}"

${!name} is Bash 2+, ${var//pattern/} is Bash 3.0+, and the paths being sanitized are short, so the substitution stays off the quadratic path that perf-fork-budget.md warns about for large strings.

For the stats cache there is a simpler fix that removes the lookup entirely: precompute_file_stats already walks get_tracked_files in order and fills the _BASHUNIT_COVERAGE_STATS_* indexed arrays. report_text should iterate those arrays instead of re-reading the tracked list and looking each file up by name.

Where to change

  • src/coverage/stats.sh:66-116 the stats cache and get_cached_stats
  • src/coverage/report_text.sh:39-62 iterate the precomputed arrays
  • src/coverage/paths.sh:5-7 cache declarations
  • src/coverage/engine.sh:215-273 record_line, both hot-path caches
  • src/coverage/config.sh:90-103 cache reset in init

Acceptance criteria

  • Coverage totals, per-file rows and LCOV output are byte-identical to the current implementation for the same run
  • report_text cost per file stops growing with file count (measure at 11, 40, 80, 121 tracked files)
  • A file whose sanitized key collides with another file's key is handled, or collisions are proven impossible for the sanitizer chosen
  • Cache state is reset by bashunit::coverage::init so a second run in the same shell cannot inherit stale decisions
  • Works under --parallel, where each worker builds its own caches
  • Both engines produce the same numbers (BASHUNIT_COVERAGE_ENGINE=trap and xtrace)

Repo checklist (agent)

  • TDD: RED then GREEN then REFACTOR. Write the failing test first.
  • Bash 3.0+ only: no printf -v, no += append, no declare -A, no [[ ]], no ${var,,}, no &>>, no ${arr[-1]}. Expanding a possibly-empty array under set -u needs ${arr[@]+"${arr[@]}"}.
  • Dynamic scope: everything written through eval must stay in the _BASHUNIT_* namespace, see .claude/rules/bash-style.md.
  • Gates: make sa, make lint, ./bashunit tests/, ./bashunit --parallel tests/. Never run shfmt -w.
  • CHANGELOG.md: one line under ## Unreleased (performance entry with the measured factor).
  • One issue = one PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions