Skip to content

perf(coverage): the trap caches die with every test subshell and get rebuilt #1104

Closed

Description

@Chemaclass

Problem

record_line caches two decisions per file — whether it is tracked, and its
normalized path — in shell variables. The test body runs in a subshell, so
those variables are a copy that dies with it, and the next test rebuilds them
for every file it touches.

Instrumenting one run of tests/unit/assert/basic_test.sh against
--coverage-paths src: 5718 record_line calls, 1427 of them cache misses
over only 13 distinct files. Thirteen files, eleven hundred redundant
misses.

A miss is the expensive path (Bash 3.2 arm64, per 500 calls):

should_track 1130 ms
normalize_path 1843 ms (424 ms after #1102)
lookup_put x2 62 ms

Proposal

Warm the caches in the parent, before the first test subshell, from the
tracked list seed_tracked_files already builds (#1053). A subshell inherits
the parent's variables, so every worker starts with the decisions already
made and the miss path runs about thirteen times per run instead of 1427.

The spelling matters: the trap keys on whatever BASH_SOURCE holds, which is
how the file was sourced — often ./src/x.sh rather than the absolute path
the seed list carries. Warming both spellings covers the common cases, and a
miss still falls through to the current path, so nothing breaks when a file
arrives spelled some third way.

Related

#1102 makes each miss ~4x cheaper; this removes most of them entirely. The two
compose.

Activity

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

    @Chemaclass
    MemberAuthor

    Found the concrete mechanism, and it is cheaper to fix than warming.

    should_track already has a disk cache, and it reads it like this:

    cached_decision=$(grep "^${file}:" "$cache_file" 2>/dev/null | head -1) || true

    That is a command substitution plus grep plus head — three processes per
    lookup
    — and it runs on every in-memory miss, which is 1427 times per run of
    one test file because the in-memory cache dies with each test subshell.

    Measured on Bash 3.2 arm64, a 13-entry cache, 500 lookups:

    grep | head per lookup 1015 ms
    read the file once, match in memory 32 ms

    2 ms versus 0.064 ms. At 1427 lookups that is ~2.85s per run — essentially all
    of the recording time left after #1102.

    Same anti-pattern #1076 removed from the stats cache: a cache that costs more
    than the computation it replaces.

    Plan

    Read cache.dat once per process into parallel arrays on first use, then match
    in memory; append to the file as new decisions are made, as today. That keeps
    the cross-process sharing that the file exists for (parallel workers), removes
    the per-lookup forks, and needs no knowledge of the tracked list up front —
    which matters because seeding happens at report time (#1053), so the parent has
    no file list to warm from when the trap starts.

  3. Chemaclass commented on Aug 13, 2026

    @Chemaclass
    MemberAuthor

    Done in #1109 (merged).

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