Skip to content

perf(coverage): the DEBUG trap re-mangles the file path twice per executed line #1102

Closed

Description

@Chemaclass

Problem

With the report phase down to ~1s (#1088, #1090) and the buffer gone (#1101),
capture is what a --coverage run costs. record_line runs per executed line
and does two cache lookups — the tracked decision and the normalized path — and
each one rebuilds its key with

_BASHUNIT_COVERAGE_LOOKUP_KEY_OUT="$1${2//[^a-zA-Z0-9]/_}"

That pattern substitution is the whole cost, and it is not linear in path
length. Measured on Bash 3.2 arm64, 5000 mangles:

path length time
7 chars 39 ms
59 chars 653 ms
121 chars 2746 ms

So record_line costs 402 µs per line for a real path
(/Users/…/src/coverage/engine.sh), of which 328 µs is the two lookups and
only 29 µs the actual append. A project whose sources sit deeper pays
quadratically more, for nothing it can control.

Proposal

A one-entry memo for the last file seen. Consecutive executed lines come from
the same file almost always — that is what a function body is — so a single
string compare replaces both key rebuilds and both indirect reads:

10,000 iterations
mangle per call 1393 ms
same-file memo 56 ms

Expected: ~402 µs to ~75 µs per line, and the path-length sensitivity mostly
gone. The existing per-path caches stay for the miss path.

coverage::init has to clear the memo along with the other in-memory caches,
or a second run in the same shell inherits a stale decision.

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. Chemaclass commented on Aug 13, 2026

    @Chemaclass
    MemberAuthor

    Measured the proposal properly and the premise above is wrong. Recording
    the corrected picture.

    The memo barely helps. Implemented, it hits 75% of the time (4291 of 5718
    record_line calls) and buys ~4%: 7019ms → 6751ms. Not worth the state.

    Why the estimate was wrong. The 402 µs/line came from a micro-benchmark
    using a 59-character absolute path. The trap actually sees what bash sourced —
    ./src/config/env.sh, about 20 characters — where the mangle is far cheaper.
    Path length still matters super-linearly, but not at the lengths that occur.

    Where the time really goes. For tests/unit/assert/basic_test.sh against
    --coverage-paths src:

    no coverage 566 ms
    coverage, nothing tracked (trap fires, rejects) 612 ms
    coverage, src tracked 6716 ms

    So trap dispatch costs 46 ms — #1060 already fixed that — and ~5.1s is
    recording. Instrumenting it: normalize_path is called 494 times per run,
    and its body is

    echo "$(cd "$(dirname "$file")" && pwd)/$(basename "$file")"

    which is four forks — roughly 2000 forks per run. It recurs because the
    caches live in shell variables and the test body runs in a subshell, so every
    test re-pays for the files it touches.

    The fix that pays. Same semantics, one fork:

    local dir="${file%/*}" base="${file##*/}"
    printf '%s/%s' "$(cd "$dir" 2>/dev/null && pwd)" "$base"
    500 calls
    four forks (today) 2081 ms
    one fork 424 ms

    Identical output on relative, absolute and /tmp/../tmp/./x.sh — the cd+pwd
    stays, so symlink resolution is unchanged. Pure-bash string normalisation is
    not an option for exactly that reason: it would report /tmp/... where the
    current code reports /private/tmp/....

    Retitling this to the normalization fork; the memo is dropped.

  3. Chemaclass commented on Aug 13, 2026

    @Chemaclass
    MemberAuthor

    Done in #1105 (merged). The memo the issue originally proposed was measured and dropped; the fork reduction is what landed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions