Skip to content

connected: add incremental connectivity check - #2211

Open
spkrka wants to merge 1 commit into
gitgitgadget:masterfrom
spkrka:tree-diff-connectivity-v1-clean
Open

connected: add incremental connectivity check#2211
spkrka wants to merge 1 commit into
gitgitgadget:masterfrom
spkrka:tree-diff-connectivity-v1-clean

Conversation

@spkrka

@spkrka spkrka commented Aug 28, 2026

Copy link
Copy Markdown

This is a single-commit patch adding an incremental connectivity
check to check_connected(), gated behind
transfer.connectivityCheck=incremental (no expected changes unless
you opt in).

The intent here is to solve the problem of the connectivity
check slowing down as the number of reachable objects from
the boundary grows.

It relates to the RFC I sent out earlier:

[RFC] check_connected: toward incoming-proportional cost
https://lore.kernel.org/git/CAL71e4Nf=-zCrfN7ghEVGq11irajJhtdxYZgKe0Ycux0qs1ZvQ@mail.gmail.com/

Design

The verifier runs inside the same rev-list subprocess that
check_connected() already spawns, triggered by a new internal
flag --verify-trees-incremental. After get_revision() collects
the incoming commits, the verifier processes them in topological
order (ancestors before descendants).

The idea is to keep a set of trusted objects, shared across the
incoming commits, that grows over time. We visit the new/untrusted
commit trees and do a comparison walk over the trees of their
parents. Everything we visit from the parent side gets added
to the trusted set, and that helps us shortcut the object graph
traversal.

More algorithmic details are in the commit message.

Benchmarks

I'll just mention a short summary here, to avoid repeating
what's already in the commit message. The incremental mode is
faster than the full mode when there are few commits to verify
and when the active object tree is large. In the happy case,
the work tracks the changed paths and their comparison trees
rather than the full reachable object closure, which
substantially reduces the dependence on total repository size.
I've seen speedups up to around 20x for the synthetic perf
tests.

Running against a large real-world repo (skipping the commit
boundary search), I see a speedup of 67x for checking a single
commit and 6x for checking 100 commits.

There are also regression cases. With long incoming histories,
the extra parent-tree scans accumulate; in the synthetic fixture
incremental is about 1.7x slower at 10000 commits, and the
simple tree-scanning model approaches a 2x slowdown when
root-tree scanning dominates. Large per-commit changes can
similarly reduce the benefit.

I cannot prove that these cases are rare, though I expect they
are. It is at least encouraging that the regression appears
bounded in the cases I have tested; I have not proved such a
bound, but have not been able to provoke a larger regression.
My initial feeling is to accept that tradeoff behind the opt-in
config, though we could also add a simple heuristic based on
properties of the incoming changes and/or the repository.

Test coverage

Most correctness cases in t5412-connectivity-check.sh are run
in both full and incremental modes to check semantic equivalence.
Selected cases additionally assert trace2 tree/blob counts for
the incremental mode, to verify that unchanged portions of the
object graph are actually skipped. It covers:

  • Corruption detection: missing blobs, missing trees, type
    mismatches, malformed trees (unparseable, mid-tree
    corruption)
  • Tree optimization: trace2 assertions confirm unchanged
    subtrees are skipped, subtree moves, merge parent boundaries
  • Partial clones: missing promised blobs, missing promised
    trees, verification of local commits
  • Replacement objects (with and without GIT_NO_REPLACE_OBJECTS)
  • Shallow boundaries
  • Deepening fetches
  • Integration: real push, fetch, and clone

Unit-style tests call git rev-list directly with the
appropriate flags; integration tests exercise the full
check_connected() path through push, fetch, and clone.

Alternatives considered

My first prototype ran the verifier in-process inside
connected.c. This required a second rev-list subprocess just
for boundary finding, _nofetch variants of several
object-reading functions to prevent lazy fetches in partial
clones, explicit shallow-file plumbing, and careful avoidance
of die() in all code paths reachable from the verifier. The
result worked but was fragile and touched many files.

Moving the verifier into the rev-list subprocess eliminated
all of those problems: in partial clones the existing
--exclude-promisor-objects handling already disables lazy
fetching, die() is isolated by the process boundary, shallow
and replacement semantics are established before the verifier
runs, and error routing comes for free via stderr.

So while I liked the idea of being less reliant on checking within
a subprocess, making that work ended up being a lot more complex.

Next steps

This patch only addresses tree verification; the other
significant cost is finding the commit boundary, especially for
repos with many refs. I already have some prototypes for
optimizing that too, and if this ends up landing, that would be
something I would start polishing up.

Thanks,
Kristofer

cc: Patrick Steinhardt ps@pks.im
cc: Jeff King peff@peff.net
cc: Elijah Newren newren@gmail.com

@spkrka
spkrka marked this pull request as ready for review August 28, 2026 10:15
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch 10 times, most recently from 326b1e4 to 452d6b9 Compare September 1, 2026 18:03
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch 8 times, most recently from c029b3a to 0d19d97 Compare September 11, 2026 12:03
The connectivity check uses rev-list to find commits reachable
from the incoming tips but not from any local ref and then walks
their object closure.  Commit traversal stops at the connectivity
boundary, but trees and blobs reachable from that boundary still
need to be walked so they can be marked uninteresting, allocating
a struct object for each one.  On repositories where the boundary
commits have large trees, the connectivity check for small
incoming changes visits and tracks more objects than needed.

Add an alternative connectivity check that verifies incoming
commits incrementally against their parents.

Instead of traversing the full boundary closure, the new check
compares each new commit's tree with its parent trees.  Already
trusted entries are skipped, changed subtrees are descended into
recursively, and blobs are checked for existence.  This approach
thus avoids descending into untouched subtrees.

For example, consider a commit that changes one file under lib/
and also moves an unchanged subtree from src/ to dev/:

    Parent tree              New tree
    +-- src/   (aaa)         +-- dev/   (aaa)
    +-- lib/   (bbb)         +-- lib/   (ccc)
         +-- foo.c (ddd)          +-- foo.c (ddd)
         +-- bar.c (eee)          +-- bar.c (fff)

The verifier first scans the new root and collects aaa and ccc as
work items.  It then scans the parent root, publishing aaa and bbb
into the trusted sets and recording bbb as the comparison base for
ccc.

When the work list is revisited, aaa is now trusted and skipped
even though it appears at a different path.  The verifier descends
into ccc using bbb as its parent base.  Scanning bbb similarly
makes ddd and eee trusted, leaving only the new fff blob to be
checked for existence.

Thus neither the moved subtree nor any other unchanged subtree is
recursively explored; only the changed lib/ subtree is descended
into, and only the new bar.c blob needs an existence check.  The
root trees still need to be read and scanned as comparison bases.

New commits are processed with ancestors before descendants.
Parents outside the incoming commit set are on the already-connected
side of the boundary and provide the initial trusted bases.  Once
an incoming commit has been verified, its tree can in turn be used
as a trusted base for descendant commits.

The verifier distinguishes trusted trees from expanded trees.  A
trusted tree can be accepted without further verification.  An
expanded tree has additionally published its direct non-gitlink
entries into the trusted sets.  Expanded parent trees therefore
need not be read again for blob-only work, but may still be reread
when recursive verification needs same-path parent subtrees.

The implementation adds a --verify-trees-incremental flag to
rev-list, following the same pattern as --exclude-promisor-objects:
a pre-setup_revisions() scan sets the flag, the
post-setup_revisions() option loop skips it, and the main
traversal short-circuits into the incremental verifier after
collecting commits from the revision walk.

Because the incremental block eagerly consumes all commits via
get_revision(), the subsequent mark_edges_uninteresting() and
traverse_commit_list_filtered() calls naturally find no commit
work to do and only process any non-commit tips (trees, blobs)
left in revs.pending.

For partial clones, missing promisor objects (trees and blobs
promised by a promisor remote) are silently accepted during
verification instead of triggering errors.  The revision walk
receives --exclude-promisor-objects so promisor commits do not
enter the verification set.

Gate the new algorithm behind transfer.connectivityCheck=incremental,
keeping full as the default.  Fall back to the full algorithm for
deepening fetches to keep this commit easy to reason about, though
incremental mode could potentially handle them as well.

p5412 results (median of 3), scaling one dimension at a time.
Each modified file is in a different directory, with
directories chosen round-robin.

Scaling tree size (10 commits, 10 files/commit):

    files    full  incr.  full/incr
      5K    0.01s  0.01s    1.1x
     50K    0.04s  0.01s    2.2x
    200K    0.14s  0.02s    5.7x
    800K    0.60s  0.04s   14.8x

The full mode must traverse the boundary tree closure, which
grows with overall tree size.  Incremental still scans the
root trees, but avoids descending into unchanged subtrees,
so it grows much more slowly with repository size.

Scaling commit count (200K files, 10 files/commit):

    commits    full  incr.  full/incr
          1    0.14s  0.01s    7.5x
         10    0.15s  0.02s    6.8x
        100    0.16s  0.07s    2.2x
        500    0.29s  0.29s    0.9x
       3000    1.26s  1.67s    0.7x
       5000    1.95s  2.71s    0.7x
      10000    3.74s  6.02s    0.6x

With many commits the per-commit overhead of scanning both
the new and parent root trees accumulates and incremental
becomes slower.  Breakeven is around 500 commits and the
ratio stabilizes near 0.6x for this fixture.

Scaling files per commit (200K files, 10 commits):

    files/commit    full  incr.  full/incr
               1    0.15s  0.02s    7.5x
              10    0.15s  0.02s    6.8x
             100    0.15s  0.04s    3.1x
             500    0.26s  0.16s    1.6x
            1000    0.36s  0.35s    1.0x
            2000    0.66s  0.79s    0.8x

Breakeven is around 1000 files/commit.  At 2000 files/commit
(every directory touched), incremental is about 1.2x slower.

For small repositories both modes are fast enough that the
difference is difficult to measure reliably.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
@spkrka
spkrka force-pushed the tree-diff-connectivity-v1-clean branch from 0d19d97 to 3f4473e Compare September 11, 2026 12:35
@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Patrick Steinhardt ps@pks.im

@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Jeff King <peff@peff.net

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Patrick Steinhardt <ps@pks.im> has been added to the cc: list.

@spkrka

spkrka commented Sep 11, 2026

Copy link
Copy Markdown
Author

/cc Elijah Newren newren@gmail.com

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Jeff King <peff@peff.net> has been added to the cc: list.

@gitgitgadget

gitgitgadget Bot commented Sep 11, 2026

Copy link
Copy Markdown

User Elijah Newren <newren@gmail.com> has been added to the cc: list.

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