Skip to content

Accumulate TimeTracker paths in-place instead of rebuilding the hash#343

Open
connorshea wants to merge 3 commits into
KnapsackPro:mainfrom
connorshea:perf-time-tracker-accumulate-paths
Open

Accumulate TimeTracker paths in-place instead of rebuilding the hash#343
connorshea wants to merge 3 commits into
KnapsackPro:mainfrom
connorshea:perf-time-tracker-accumulate-paths

Conversation

@connorshea

Copy link
Copy Markdown

Description

AI Disclosure: This change was generated using Claude Code, with Opus 5. I have manually reviewed and tested the change for correctness and to ensure it makes sense.

The example_group_finished method in the TimeTracker formatter used Hash#merge to add the finished test's paths into the accumulator, which meant building a whole new hash. This is run once per top-level group in RSpec, and so the work was being done repeatedly and scaled with the number of top-level describe blocks as well as how many paths have already been added to the hash. This PR modifies the accumulator in-place instead to avoid adding memory pressure / triggering GC.

I don't believe this change violates the architecture outlined in the PR template in any way (or at least it doesn't newly violate it, as the formatter already mixed purity and side-effects before this change).

These benchmarks were run on my M5 Pro Mac 24GB RAM w/ Ruby 3.4.9.

Per-file keys (if not splitting by example), 4 examples per file:

files old new speedup old malloc churn new
500 4.2 ms 3.8 ms 1.1×
1000 9.2 ms 7.5 ms 1.2×
2000 20.9 ms 15.1 ms 1.4× 74 MB 2.0 MB
4000 50.5 ms 30.3 ms 1.7×

Split-by-test-example (@paths keyed per example — the worst case, but only really gets hit if there are a lot of slow tests):

workload entries old new old malloc new
500 × 20 10k 32.4 ms 23.0 ms 103 MB 4.3 MB
1000 × 20 20k 74.1 ms 45.6 ms 404 MB 7.6 MB

Checks

  • I added the changes to the UNRELEASED section of the CHANGELOG.md, including the needed bump (i.e., patch, minor, major)
  • I followed the architecture outlined below for RSpec in Queue Mode:
    • Pure: lib/knapsack_pro/pure/queue/rspec_pure.rb contains pure functions that are unit tested.
    • Extension: lib/knapsack_pro/extensions/rspec_extension.rb encapsulates calls to RSpec internals and is integration and E2E tested.
    • Runner: lib/knapsack_pro/runners/queue/rspec_runner.rb invokes the pure code and the extension to produce side effects, which are integration and E2E tested.

connorshea and others added 3 commits July 25, 2026 08:05
… hash

`example_group_finished` merged the finished test file's paths into the
run-wide accumulator with `Hash#merge`, which builds a whole new hash.
It runs once per top level group, so the work is quadratic in the number
of test files a CI node executes.

Accumulate into the existing hash instead. The entries are private to the
formatter and their `:path` already equals the key they are stored under,
so mutating the recorded time in place is equivalent.

Driving the formatter with 2000 test files of 4 examples each: 21 ms and
74 MB of transient hash-table churn -> 15 ms and 2.0 MB. The merge is the
whole quadratic term; isolated, it costs 0.4/1.7/5.8/20.2 ms at
500/1000/2000/4000 files, roughly 4x per doubling. With split-by-test-example
the accumulator is keyed per example rather than per file, so it is worse
there: 1000 files x 20 examples goes from 74 ms and 404 MB to 46 ms and
7.6 MB. Object allocation counts barely move (`merge` allocates one hash
per call); the win is bytes and time, not object count.

`merge` is still used by `#queue`, which is called once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`record_example` right above already branches on `accumulator.key?(path)`.
The truthiness check was equivalent here, but only because `@paths` is a
plain hash whose values are always hashes: on a `Hash.new(0)` accumulator
(as `@time_all_by_group_id_path` is) a missing key returns `0`, which is
truthy, and `0[:time_execution] +=` would raise.

No measurable cost. On a collision-heavy workload (500 files x 20 top level
groups, so 9.5k of 10k merges take the collision path) the extra lookup is
lost in the noise: 81.0/81.1/81.5 ms before, 79.7/80.1/81.5 ms after, with
identical allocations and identical recorded times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry was missing the `(patch)` prefix the repo uses to mark the
needed version bump on unreleased entries.

Also correct the wording: the merge runs once per top-level example
group, not once per test file, so a spec file with several top-level
`describe` blocks rebuilt the collection several times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@connorshea

Copy link
Copy Markdown
Author

I think the failures are due to the tests needing to either be updated for simplecov 1.x, or we need to restrict the range of the simplecov gem

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant