Skip to content

perf(hash-persister): probe dirty packages before rdeps propagation - #20

Merged
honnix merged 15 commits into
mainfrom
honnix/probe-before-propagate
Sep 24, 2026
Merged

honnix merged 15 commits into
mainfrom
honnix/probe-before-propagate

Conversation

@honnix

@honnix honnix commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Summary

A BUILD.bazel edit marks its whole package dirty, and every label in that package then propagates reverse dependencies. In a high-fanout package the resulting closure covers most of the repository even when one target really changed. This adds a probe phase that decides which dirty labels actually changed before propagating from them.

How it works

After ComputeDirtySet, probe the dirty packages and prune:

  • Query the dirty packages with raw //pkg:* wildcards. The targets pattern excludes manual-tagged targets, and npm link targets, platform() rules and JS build internals are all manual-tagged, so applying it would leave most dirty labels unhashed.
  • Decide each dirty label: source files from the git diff, everything else by comparing its probe hash against the seed. Labels either side omits are conservatively treated as changed.
  • Propagate reverse dependencies only from the labels that changed.

For the probe to compare a label the seed has to carry its hash, so the seedable output additionally records hashes for edge-map labels that are not matching targets. These live under a separate dependency_hashes key: target_hashes is the target set diffing compares, and mixing them in makes them surface as added targets.

Source files are deliberately excluded from the seed. runSeeded already computes a git diff and a source-file label maps one to one onto a path, so git answers the question outright — hashing to rediscover it added twenty times more labels than the rule and generated labels that genuinely need one.

Also fixed

  • propagateFrom pre-seeded the result set with every dirty label and reused it as the BFS visited set, so propagation stopped at the first dirty label instead of passing through it and dropped everything behind. Usually masked, because a changed dependency normally changes its consumer's hash and makes it a root in its own right, but the failure mode is a missing impacted target.
  • The hash cache returns a zero-length sentinel rather than a hash for a label whose file is missing or is a directory (bazel#14611, #14678). Seed validation requires every hash to be sha256-sized, so persisting one rejected the entire seed. They are now omitted.

Measured

On a 13-file change touching high-fanout tooling packages:

before after
dirty* after pruning 280,520 13,082
scoped query + parse 1m48s 10.0s
targets rehashed 262,727 116
hash phase 25s 70ms
total 2m35s 59s

13,082 is the number of directly dirty labels, so propagation adds essentially nothing on top. The probe costs 23s of that. Output verified identical to the authoritative full hash.

Gains depend on the change: a PR touching one service component has a far smaller dirty set, while one that trips a fallback trigger (.bzl, MODULE.bazel, a lockfile) still runs full mode.

Test plan

  • Pruning eliminates unchanged reverse dependencies, preserves changed ones, and no-ops when everything changed
  • Source files judged by the git diff rather than by hash
  • Propagation traverses through unchanged dirty labels (verified failing without the fix)
  • Dependency hashes cover edge values, skip the empty-hash sentinel, and stay out of target_hashes
  • bazel test //pkg:pkg_test //hash-persister:all

@honnix
honnix force-pushed the honnix/probe-before-propagate branch from 4b23835 to f8dfa97 Compare September 23, 2026 07:24
When a BUILD.bazel changes, all targets in the package are marked dirty
and their reverse dependencies cascade through the graph. For high-fanout
packages like tools/binaries (which contains widely-used aliases), adding
a single new target inflates the dirty set to hundreds of thousands of
targets even though existing targets are unchanged.

Add a probe phase to runSeeded that queries only the dirty packages,
hashes those targets against seed dependency hashes, and compares with
the seed. Only targets whose hash actually changed propagate to reverse
dependencies. This dramatically reduces the scoped query size when
BUILD.bazel changes don't affect most existing targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@honnix
honnix force-pushed the honnix/probe-before-propagate branch from f8dfa97 to 69a0ab4 Compare September 23, 2026 07:58
honnix and others added 8 commits September 23, 2026 13:45
Temporary diagnostic to understand why 191K targets remain dirty after
probe pruning. Logs each changed target with its reason (new_target,
hash_differs, missing_from_probe).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Include hashes for edge-map-only targets (manual-tagged deps, platform()
rules, generated file outputs) in the seedable target_hashes. These
targets are already computed in the hash cache via recursive Hash() calls
but were previously not persisted, causing the probe to treat them as
"new" and over-propagate rdeps.

Also removes the temporary diagnostic logging from the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…atching targets

The probe query uses the same targets pattern that excludes manual-tagged
targets. ProbeHashesFromQueryResults only extracted hashes for matching
targets, missing dependency-only labels (platform, npm, generated files)
even though their hashes were computed transitively during PrefillCache.

Use ProbeHashesFromCache to extract all cached hashes, so the probe can
compare every label in the seed against its current hash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two gaps left the probe unable to compare most dirty-package labels, so
they were conservatively marked changed and their reverse dependencies
propagated anyway.

Seed side: AddDependencyHashes iterated only edge keys, missing leaf
labels that appear solely as dependency values (source files, npm /ref
targets). Measured on a real seed, 4417 of 13081 dirty labels were
missed this way, including //:service-info.yaml whose rdeps closure is
95k targets.

Probe side: the probe query applied the targets pattern, which excludes
manual-tagged targets. platform() rules, npm link targets and JS build
internals are all manual-tagged, so the probe hashed only 115 of 13081
dirty labels. Query the dirty packages raw with ":*" instead, which
covers manual-tagged rules and source files alike.

With both gaps closed every dirty label has a seed hash and a probe hash
to compare. On the measured case only 7 targets genuinely change, and
their combined rdeps closure is 7, so dirty* should fall from ~280k to
roughly the 13081 directly dirty labels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Consolidation pass over the probe work, no behaviour change:

- Drop ProbeHashesFromQueryResults, dead since the probe switched to
  reading the cache, and replace the package-level ProbeHashesFromCache
  with a TargetHashCache.ExtractHexHashes method next to ExtractHashes.
  It only ever touched the cache and could not fail, so the QueryResults
  parameter and error return were both noise.
- Index only the labels AddDependencyHashes actually needs instead of
  every cached hash, avoiding a transient map-of-maps over ~530k entries.
- Fix its doc comment to name the exported identifier, size the
  propagateFrom result map by the set it is actually filled from, and
  trim doc comments that restated their signatures.
- Cover the two behaviours that iteration left untested: dependency
  values reached only as edge values, and the probe pattern shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TargetHashCache returns a zero-length slice rather than a hash for a
source-file label whose file does not exist, or which is a directory
spuriously listed in srcs (bazelbuild/bazel#14611, #14678). ExtractHashes
only filters nil, so the sentinel passed through.

Persisting dependency values started surfacing those labels in the seed,
and seed validation requires every hash to be sha256-sized, so a single
one rejected the entire seed and fell the run back to full hashing.

Omit them instead. A label without a seed hash is treated as changed,
which is the safe direction.

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

Source files were being persisted into the seed so the probe could
compare their hashes. That was the wrong instrument: runSeeded already
computes a git diff, and a source-file label maps one to one onto a
path, so git answers the question outright. Hashing to rediscover it
added 485k of the 505k labels the previous commit put in the seed, more
than twenty times the 20k rule and generated labels that genuinely need
one, and dangling source-file labels are also the only source of the
empty-hash sentinel that rejected whole seeds.

The probe already queries every label in the dirty packages, so it
reports which of them are source files; those are judged by the git
diff and the rest by hash, and AddDependencyHashes skips them entirely.

Fixes a separate bug this exposed: propagateFrom pre-seeded the result
set with every dirty label and then used that same set as the BFS
visited set, so propagation stopped at the first dirty label instead of
passing through it and dropped everything behind. Usually masked,
because a changed dependency normally changes its consumer's hash and
makes it a root in its own right, but the failure mode is a missing
impacted target, so the sets are now kept apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dependency hashes were being written into TargetHashes. That map is the
target set diffing compares, so the seeded run merged them into its
output and the shadow comparison reported 81509 added targets, every one
of them a dependency label rather than a real target. Once the
incremental output feeds target selection directly the same labels would
become phantom impacted targets.

Persist them under a separate dependency_hashes key instead. Seeding
reads both, since either is equally reusable as a pre-computed hash, and
diffing keeps seeing TargetHashes alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@honnix
honnix marked this pull request as ready for review September 23, 2026 15:22
honnix and others added 3 commits September 23, 2026 17:43
…ng it

encoding/json marshals a whole value into one in-memory buffer before
writing any of it, growing that buffer by doubling. A seedable artifact
is over a gigabyte, so persisting one meant gigabytes of allocation and
copying and a peak resident size around twice the output. That phase
measured 41.5s against 3s for the compact artifact, tracking output size
rather than the work involved.

Emit the large maps entry by entry into a buffered writer so memory
stays flat. Labels and hex hashes never need escaping, so strings take a
straight copy and anything else defers to the stdlib rather than
reimplementing its escaping rules. Keys are sorted as encoding/json
sorts them, keeping the artifact reproducible.

The compact artifact keeps using the stdlib: it is two orders of
magnitude smaller, so the buffering costs little and hand-indenting
would not earn its keep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The persist phase is 39s for a seedable artifact against 3s for a
compact one, and an attempt to cut it by streaming the encode changed
nothing measurable, so the cost is not where it was assumed to be.

Time each sub-step separately, and split marshalling from the file
write, so the phase can be attributed to hash collection, edge
extraction, dependency hashes, formatting or disk rather than guessed
at. The split mirrors what json.Encoder already does internally, so the
output is unchanged.

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

@mattnworb mattnworb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things I think we should fix before merging. The probe error path currently panics instead of falling back, and the execution report keeps the pre-pruning dirty count. Details inline.

Comment thread hash-persister/hash-persister.go Outdated
Comment thread hash-persister/hash-persister.go
honnix and others added 3 commits September 23, 2026 19:35
Instrumenting the persist phase showed it is dominated by ExtractEdges
at 27.7s of 42.6s, not by formatting (5.2s) or the disk write (0.7s).

Rule inputs arrive as strings and leave as strings, but each of the 13.6
million occurrences was parsed into a Label and formatted back, then
inserted into a set keyed on the full ~100-byte string. Those
occurrences are only 1.21 million distinct labels, so canonicalise each
distinct string once and reuse it, and deduplicate by sorting and
compacting rather than by hashing every dependency into a set.

Shortcutting on the label's shape instead would be wrong: "//pkg:pkg"
canonicalises to "//pkg", so skipping the parse would record labels
inconsistent with how matching targets are written and make seed lookups
silently miss. Memoising still canonicalises, just once per string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probePruneDirtySet returns a nil result alongside its error, so
assigning it straight to dirtyResult set it to nil on the error path.
The code then logged that it was continuing with the unpruned set and
dereferenced nil on the next line, turning a recoverable probe failure
into a panic. Hold the result in a temporary and only adopt it on
success.

Also update the execution report's dirty target count after a successful
prune. It drives CI metrics, and reporting the pre-probe figure would
have shown 280k dirty targets for a run that actually used 13k.

Both found in review by mattbrown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@honnix
honnix merged commit e50b5c2 into main Sep 24, 2026
3 checks passed
@honnix
honnix deleted the honnix/probe-before-propagate branch September 24, 2026 13:06
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.

2 participants