commit-reach: terminate merge-base walk when one side is exhausted#2149
commit-reach: terminate merge-base walk when one side is exhausted#2149spkrka wants to merge 10 commits into
Conversation
7d5b1bb to
3e1315e
Compare
|
/preview |
|
Preview email sent as pull.2149.git.1781946989.gitgitgadget@gmail.com |
|
/submit |
|
Submitted as pull.2149.git.1781951820.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This patch series was integrated into seen via git@418052d. |
|
/submit |
|
Submitted as pull.2149.v2.git.1782303254.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
|
This branch is now known as |
f574f35 to
4b9f192
Compare
| at most once per commit, the number of times a commit can be | ||
| re-enqueued is bounded by the number of flag transitions. | ||
|
|
||
| Termination |
There was a problem hiding this comment.
René Scharfe wrote on the Git mailing list (how to reply to this email):
On 6/26/26 3:08 PM, Kristofer Karlsson via GitGitGadget wrote:
>
> diff --git a/commit-reach.c b/commit-reach.c
> index f6a438550b..0f29b143bd 100644
> --- a/commit-reach.c
> +++ b/commit-reach.c
> @@ -97,6 +97,75 @@ static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
> return commit;
> }
>
> +/*
> + * Priority queue with per-side commit counters for paint_down_to_common().
> + * Each non-stale queued commit occupies exactly one bucket: PARENT1-only,
> + * PARENT2-only, or both (a pending merge-base candidate).
> + */
> +struct paint_state {
> + struct prio_queue queue;
> + int p1_count;
> + int p2_count;
> + int pending_merge_bases;
> +};
Can they become negative? Wouldn't size_t be a more natural fit,
matching nr from struct prio_queue?
And some bikeshedding:
Why abbreviate? parent1_count and parent2_count would be slightly
easier to read and associate with PARENT1 and PARENT2.
And pending_merge_bases is a counter as well. Why not call it
like that, pending_merge_base_count? Well, that's pretty long.
both_count? That's quite generic and nondescript. Call the other
counters parents1 and parents2? Nah. Or parent1s and parent2s?
Not sure why this inconsistency bothers me to begin with.
René
There was a problem hiding this comment.
Kristofer Karlsson wrote on the Git mailing list (how to reply to this email):
On Fri, 26 Jun 2026 at 23:13, René Scharfe <l.s.r@web.de> wrote:
>
> > +struct paint_state {
> > + struct prio_queue queue;
> > + int p1_count;
> > + int p2_count;
> > + int pending_merge_bases;
> > +};
> Can they become negative? Wouldn't size_t be a more natural fit,
> matching nr from struct prio_queue?
Negative would be a clear indication of a bug though that's
not checked right now anyway. And since it's not checked
we might as well use size_t instead - and it would technically
be more correct though I struggle to imagine a case where
the number of active elements in the frontier exceeds 2^31
or whatever a signed int would give.
I am happy to change to size_t.
> And some bikeshedding:
>
> Why abbreviate? parent1_count and parent2_count would be slightly
> easier to read and associate with PARENT1 and PARENT2.
>
> And pending_merge_bases is a counter as well. Why not call it
> like that, pending_merge_base_count? Well, that's pretty long.
> both_count? That's quite generic and nondescript. Call the other
> counters parents1 and parents2? Nah. Or parent1s and parent2s?
> Not sure why this inconsistency bothers me to begin with.
Fair point, I was thinking that the surrounding context is so small
that the naming almost doesn't matter - the terms don't
escape paint_down_to_common.
I am happy to change to something like:
parent1_count, parent2_count, mb_candidate_count
to make it more consistent.
It seems the mb_ prefix is already used for
merge bases in some files - best example is perhaps builtin/diff.c
I see in the codebase that we are using multiple styles,
perhaps depending on specific context.
- nr_ prefix: nr_objects, nr_paths_watching
- num_ prefix: num_commits, num_hashes, num_workers
- _count suffix: entry_count, max_count, skip_count
so I think _count suffix is a good choice at least - it matches
other usages where we typically just increment or decrement.
Thanks,
Kristofer|
User |
| */ | ||
| struct paint_state { | ||
| struct prio_queue queue; | ||
| size_t parent1_count; |
There was a problem hiding this comment.
Derrick Stolee wrote on the Git mailing list (how to reply to this email):
On 6/28/26 8:25 AM, Kristofer Karlsson via GitGitGadget wrote:
> From: Kristofer Karlsson <krka@spotify.com>
...> @@ -138,11 +140,23 @@ static void paint_queue_put(struct paint_state *state,
> static struct commit *paint_queue_get(struct paint_state *state)
> {
> struct commit *commit = prio_queue_get(&state->queue);
> + timestamp_t generation;
> > if (!commit)
> return NULL;
> > commit->object.flags &= ~ENQUEUED;
> + generation = commit_graph_generation(commit);
> +
> + if (state->min_generation && generation > state->last_gen)
> + BUG("bad generation skip %"PRItime" > %"PRItime" at %s",
> + generation, state->last_gen,
> + oid_to_hex(&commit->object.oid));
> + state->last_gen = generation;
> +
> + /* generation cutoff */
> + if (generation < state->min_generation)
> + return NULL;
...
> - if (min_generation && generation > last_gen)
> - BUG("bad generation skip %"PRItime" > %"PRItime" at %s",
> - generation, last_gen,
> - oid_to_hex(&commit->object.oid));
> - last_gen = generation;
> -
> - if (generation < min_generation)
> - break;
I'm just stopping in to say that this looks like a clean code move
in this version, without mutating this chunk in the previous patch.
LGTM.
-Stolee|
Derrick Stolee wrote on the Git mailing list (how to reply to this email): On 6/28/26 8:25 AM, Kristofer Karlsson via GitGitGadget wrote:
> commit-reach: terminate merge-base walk when one paint side is exhausted
> > Optimize paint_down_to_common() for merge-base queries that hit large
> one-sided histories.
> > When the walk from one side reaches a commit with a very low generation
> number that the other side never paints, the walk is forced to drain most of
> the graph. A common trigger is a repository import that grafts a separate
> history with its own root, but any merge that introduces a low-generation
> commit never painted by the other side has the same effect.
> Changes since v3:
> > * Fixed BUG assertion that was accidentally made unconditional in v3:
> restored the min_generation guard so it only fires when generation-based
> ordering is active.
> > * Moved generation cutoff and single-result termination conditions into the
> documentation in patch 1/8, since they describe existing behavior.
> > * Renamed paint_state counter fields for clarity: p1_count ->
> parent1_count, p2_count -> parent2_count, pending_merge_bases ->
> mb_candidate_count. Changed counter types from int to size_t. (Suggested
> by Rene Scharfe.)
I reviewed the v3 discussion, the range-diff, and reread patch 8. I think
that this version is good to go.
Thanks for your hard work!
-Stolee |
| static void clear_nonstale_queue(struct nonstale_queue *queue) | ||
| { | ||
| clear_prio_queue(&queue->pq); | ||
| queue->max_nonstale = NULL; |
There was a problem hiding this comment.
SZEDER Gábor wrote on the Git mailing list (how to reply to this email):
On Sun, Jun 28, 2026 at 12:25:44PM +0000, Kristofer Karlsson via GitGitGadget wrote:
> From: Kristofer Karlsson <krka@spotify.com>
>
> nonstale_queue_put_dedup() and nonstale_queue_get_dedup() became
> unused after the previous commit. The core nonstale_queue functions
> remain in use by ahead_behind().
Please squash this patch into the previous one. Since the last
callers of these static functions went away in that commit, it can't
be built with DEVELOPER=1:
commit-reach.c:91:23: warning: ‘nonstale_queue_get_dedup’ defined but not used [-Wunused-function]
91 | static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
| ^~~~~~~~~~~~~~~~~~~~~~~~
commit-reach.c:82:13: warning: ‘nonstale_queue_put_dedup’ defined but not used [-Wunused-function]
82 | static void nonstale_queue_put_dedup(struct nonstale_queue *queue,
| ^~~~~~~~~~~~~~~~~~~~~~~~
> Signed-off-by: Kristofer Karlsson <krka@spotify.com>
> ---
> commit-reach.c | 18 ------------------
> 1 file changed, 18 deletions(-)
>
> diff --git a/commit-reach.c b/commit-reach.c
> index 9ae306f60c..176ffd68d0 100644
> --- a/commit-reach.c
> +++ b/commit-reach.c
> @@ -79,24 +79,6 @@ static void clear_nonstale_queue(struct nonstale_queue *queue)
> queue->max_nonstale = NULL;
> }
>
> -static void nonstale_queue_put_dedup(struct nonstale_queue *queue,
> - struct commit *c)
> -{
> - if (c->object.flags & ENQUEUED)
> - return;
> - c->object.flags |= ENQUEUED;
> - nonstale_queue_put(queue, c);
> -}
> -
> -static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
> -{
> - struct commit *commit = nonstale_queue_get(queue);
> -
> - if (commit)
> - commit->object.flags &= ~ENQUEUED;
> - return commit;
> -}
> -
> /*
> * Priority queue with per-side commit counters for paint_down_to_common().
> * Each non-stale queued commit occupies exactly one bucket: PARENT1-only,
> --
> gitgitgadget
> There was a problem hiding this comment.
Kristofer Karlsson wrote on the Git mailing list (how to reply to this email):
On Mon, 29 Jun 2026 at 07:25, SZEDER Gábor <szeder.dev@gmail.com> wrote:
>
> On Sun, Jun 28, 2026 at 12:25:44PM +0000, Kristofer Karlsson via GitGitGadget wrote:
> > From: Kristofer Karlsson <krka@spotify.com>
> >
> > nonstale_queue_put_dedup() and nonstale_queue_get_dedup() became
> > unused after the previous commit. The core nonstale_queue functions
> > remain in use by ahead_behind().
>
> Please squash this patch into the previous one. Since the last
> callers of these static functions went away in that commit, it can't
> be built with DEVELOPER=1:
>
> commit-reach.c:91:23: warning: ‘nonstale_queue_get_dedup’ defined but not used [-Wunused-function]
> 91 | static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue)
> | ^~~~~~~~~~~~~~~~~~~~~~~~
> commit-reach.c:82:13: warning: ‘nonstale_queue_put_dedup’ defined but not used [-Wunused-function]
> 82 | static void nonstale_queue_put_dedup(struct nonstale_queue *queue,
> | ^~~~~~~~~~~~~~~~~~~~~~~~
>
Thanks, will squash for v5! It's unfortunate that this means the commit itself
becomes less clean, but I don't have any other good solution
-- and having each commit compile cleanly is more important.
- Kristofer|
User |
|
Kristofer Karlsson wrote on the Git mailing list (how to reply to this email): On Sun, 28 Jun 2026 at 17:16, Derrick Stolee <stolee@gmail.com> wrote:
>
> I reviewed the v3 discussion, the range-diff, and reread patch 8. I think
> that this version is good to go.
Thanks for all your reviews and feedback. However, I found one more
problem that needs to be resolved before this is good to go.
paint_down_to_common() has this fallback:
if (!min_generation && !corrected_commit_dates_enabled(r))
queue.pq.compare = compare_commits_by_commit_date;
When this fires, the queue uses commit-date ordering instead of
generation ordering. The side-exhaustion optimization and my older
patch for !FIND_ALL early exit both check for reaching the finite
generation, but with date ordering, that check is wrong --
a commit can have a finite topo level (it is in a v1 commit graph)
while the queue is not ordered by generation. This unfortunately
means there is a regression for the !FIND_ALL optimization that
I should fix before 2.55 is final. I will send a small patch for
that separately: add tests that demonstrate the problem, and disable
the !FIND_ALL early exit when generation ordering is not active.
I traced the history of this fallback. The queue was switched from
date ordering to generation ordering in 3afc679b (2018-05). Then in
091f4cf3 (2018-08) you added the date fallback after finding that v1
topo levels caused "git merge-base v4.8 v4.9" on the Linux kernel to
walk 636k commits instead of 167k -- a side branch with a low topo
level stayed in the queue behind a long chain, preventing early STALE
propagation. Later, 8d00d7c3 (2021-01) tightened the fallback to
only fire without corrected commit dates, since v2 does not have the
regression.
The problem that 091f4cf3 addresses looks closely related to what
side-exhaustion solves: the walk goes deep into a subgraph where
only one paint side has presence. With side-exhaustion, the walk
terminates as soon as one paint side is exhausted from the queue,
so the deep walk never happens regardless of queue ordering.
I benchmarked "git merge-base --all v4.8 v4.9" on the Linux kernel
(the same case from 091f4cf3) with three configurations:
master (--all) side-exhaust (--all, gen ordering)
no graph: 3212 ms 3268 ms
v1 graph: 188 ms 17 ms
v2 graph: 227 ms 17 ms
With side-exhaustion, the v1 case no longer shows a regression
compared to the date fallback -- if anything, it is slightly faster
since the walk terminates earlier. This suggests that the workaround
from 091f4cf3 may no longer be needed when side-exhaustion is
present.
It is also worth noting that commitGraph.generationVersion has
defaulted to 2 since 2021, so the v1 fallback path is rarely
exercised in practice. Any commit-graph rewrite produces v2 data,
and only repos that have not rewritten their commit graph in over
four years would still have v1-only data.
If that reasoning holds, the fix for v5 would be to remove the date
fallback entirely, always using compare_commits_by_gen_then_commit_date.
This would:
1. Fix the bug (finite generation always means generation-ordered
queue).
2. Remove corrected_commit_dates_enabled() which has no other
callers.
The alternative would be to keep the fallback and disable the
optimizations that depend on ordering (via a flag like
paint_state.gen_ordered).
Do you see any cases I might be missing where removing the fallback
could cause problems?
Thanks,
Kristofer |
|
Derrick Stolee wrote on the Git mailing list (how to reply to this email): On 6/29/2026 8:11 AM, Kristofer Karlsson wrote:
> On Sun, 28 Jun 2026 at 17:16, Derrick Stolee <stolee@gmail.com> wrote:
>>
>> I reviewed the v3 discussion, the range-diff, and reread patch 8. I think
>> that this version is good to go.
>
> Thanks for all your reviews and feedback. However, I found one more
> problem that needs to be resolved before this is good to go.
>
> paint_down_to_common() has this fallback:
>
> if (!min_generation && !corrected_commit_dates_enabled(r))
> queue.pq.compare = compare_commits_by_commit_date;
...> I traced the history of this fallback.
...> The problem that 091f4cf3 addresses looks closely related to what
> side-exhaustion solves: the walk goes deep into a subgraph where
> only one paint side has presence. With side-exhaustion, the walk
> terminates as soon as one paint side is exhausted from the queue,
> so the deep walk never happens regardless of queue ordering.
>
> I benchmarked "git merge-base --all v4.8 v4.9" on the Linux kernel
> (the same case from 091f4cf3) with three configurations:
>
> master (--all) side-exhaust (--all, gen ordering)
> no graph: 3212 ms 3268 ms
> v1 graph: 188 ms 17 ms
> v2 graph: 227 ms 17 ms
>
> With side-exhaustion, the v1 case no longer shows a regression
> compared to the date fallback -- if anything, it is slightly faster
> since the walk terminates earlier. This suggests that the workaround
> from 091f4cf3 may no longer be needed when side-exhaustion is
> present.
Thanks for digging into the history of this fallback and catching it
during review!
> If that reasoning holds, the fix for v5 would be to remove the date
> fallback entirely, always using compare_commits_by_gen_then_commit_date.
> This would:
>
> 1. Fix the bug (finite generation always means generation-ordered
> queue).
> 2. Remove corrected_commit_dates_enabled() which has no other
> callers.
I agree with your reasoning, data-backed discovery, and the course of
action to fix this. I'm happy that you're able to close the loop on
this long-standing performance issue even with v1 generation numbers.
> Do you see any cases I might be missing where removing the fallback
> could cause problems?
I don't see any other concerns here. You're right that if we were to
have a different mode that changes the priority-queue ordering, then
the side-exhaustion optimization cannot be trusted, but you will
remove this possibility.
It _may_ be worth mentioning this with a comment when initializing
the queue order for the paint_queue, because the use of the queue
requires topological ordering.
Thanks,
-Stolee
|
|
Kristofer Karlsson wrote on the Git mailing list (how to reply to this email): On Mon, 29 Jun 2026 at 14:40, Derrick Stolee <stolee@gmail.com> wrote:
>
> I agree with your reasoning, data-backed discovery, and the course of
> action to fix this. I'm happy that you're able to close the loop on
> this long-standing performance issue even with v1 generation numbers.
Sounds good, then I can continue with the approach of removing some code
(even though it will likely be a net addition in the end).
> > Do you see any cases I might be missing where removing the fallback
> > could cause problems?
> I don't see any other concerns here. You're right that if we were to
> have a different mode that changes the priority-queue ordering, then
> the side-exhaustion optimization cannot be trusted, but you will
> remove this possibility.
>
> It _may_ be worth mentioning this with a comment when initializing
> the queue order for the paint_queue, because the use of the queue
> requires topological ordering.
Yes my plan is to rewrite v5 in a few ways:
- update original documentation to note that infinite -> finite
generation does not always hold
- add a test (or more than one) for this problem
- don't introduce the bug at any point
- add a commit to replace the disabled optimization with
removal of the commit-date based ordering (+ doc update)
Thanks for helping with this,
Kristofer |
|
There was a status update in the "Cooking" section about the branch The merge-base computation has been optimized by stopping the walk early when one side's exclusive commits in the queue are exhausted, yielding significant speedups for queries with one-sided histories. Expecting a reroll. cf. <48bfdb11-2624-4aa6-8fbd-d3f894c33bcc@gmail.com> cf. <CAL71e4N92t8170UBW3rMA6B-rEUeOm-R_HSioB957mUKOpwRyQ@mail.gmail.com> source: <pull.2149.v4.git.1782649547.gitgitgadget@gmail.com> |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): "Kristofer Karlsson via GitGitGadget" <gitgitgadget@gmail.com>
writes:
> Changes since v4:
>
> * New patch 2/10: added test_trace2_data_singular helper to
> test-lib-functions.sh. Shows expected/actual values on assertion failure
> instead of a silent grep failure. Makes iterating on step counts much
> easier.
>
> * New patch 6/10: added clock-skew topologies (se-, se2-) that expose
> side-exhaustion bugs when the commit-date ordering fallback fires with a
> v1 commit graph. All topologies use a shared skew_commit helper. Includes
> step count assertions for edge-case tests from patch 3.
>
> * Folded the nonstale_queue dedup wrapper removal (previously separate
> patch 6/8) into the paint_state introduction in patch 7/10.
>
> * New patch 10/10: remove the commit-date ordering fallback in
> paint_down_to_common(). The fallback (091cf18e) was a performance
> optimization for v1 commit graphs, but it breaks the generation ordering
> invariant that both the side-exhaustion and single-result optimizations
> depend on. With side-exhaustion in place, the fallback is no longer
> needed. If kept, this supersedes the separate "commit-reach: fix
> !FIND_ALL early exit with v1 commit graph" topic.
I thought that the plan in
https://lore.kernel.org/git/CAL71e4P4GbYYv1LdarAbeodm06q841wj4gdGpn0QYADQjOB5gw@mail.gmail.com/
was to make this v5 on top of kk/commit-reach-find-all-fix topic.
I tried to prepare a merge of kk/commit-reach-find-all-fix into
v2.55.0 and then used "git am -3" to apply these patches on top,
but there were conflicts, and after resolving 7/10, t6600 stops
passing.
Perhaps it is best to ask you rebase these patches on top of a merge
of kk/commit-reach-find-all-fix into v2.55.0? |
|
Kristofer Karlsson wrote on the Git mailing list (how to reply to this email): On Wed, 1 Jul 2026 at 22:06, Junio C Hamano <gitster@pobox.com> wrote:
>
> I thought that the plan in
>
> https://lore.kernel.org/git/CAL71e4P4GbYYv1LdarAbeodm06q841wj4gdGpn0QYADQjOB5gw@mail.gmail.com/
>
> was to make this v5 on top of kk/commit-reach-find-all-fix topic.
>
> I tried to prepare a merge of kk/commit-reach-find-all-fix into
> v2.55.0 and then used "git am -3" to apply these patches on top,
> but there were conflicts, and after resolving 7/10, t6600 stops
> passing.
>
> Perhaps it is best to ask you rebase these patches on top of a merge
> of kk/commit-reach-find-all-fix into v2.55.0?
You are right, I am sorry about that -- I will wait for
kk/commit-reach-find-all-fix to land and then fix up a proper v6.
In the meantime, there are still some aspects of this v5 that would
benefit from some discussion and feedback -- specifically the new
test diagnostic helper (patch 2) and the commit-date ordering
fallback removal (patch 10). Both are new in this version and could
be seen as optional.
Thanks,
Kristofer |
|
There was a status update in the "Cooking" section about the branch The merge-base computation has been optimized by stopping the walk early when one side's exclusive commits in the queue are exhausted, yielding significant speedups for queries with one-sided histories. Needs review. passes t6600 standalone, breaks when merged to 'seen'. source: <pull.2149.v5.git.1782923832.gitgitgadget@gmail.com> |
|
Junio C Hamano wrote on the Git mailing list (how to reply to this email): Kristofer Karlsson <krka@spotify.com> writes:
> In the meantime, there are still some aspects of this v5 that would
> benefit from some discussion and feedback -- specifically the new
> test diagnostic helper (patch 2) and the commit-date ordering
> fallback removal (patch 10). Both are new in this version and could
> be seen as optional.
Sure, review comment on this iterations are welcome, of course, but
I'll punt on integrating it in 'seen'.
Thanks. |
|
There was a status update in the "Cooking" section about the branch The merge-base computation has been optimized by stopping the walk early when one side's exclusive commits in the queue are exhausted, yielding significant speedups for queries with one-sided histories. Expecting a reroll. cf. <CAL71e4PgcZDK-gJziJa_yjEqX9TE+PFMwZn0xbjAUzuUDDDBYA@mail.gmail.com> source: <pull.2149.v5.git.1782923832.gitgitgadget@gmail.com> |
|
There was a status update in the "Cooking" section about the branch The merge-base computation has been optimized by stopping the walk early when one side's exclusive commits in the queue are exhausted, yielding significant speedups for queries with one-sided histories. Expecting a reroll. cf. <CAL71e4PgcZDK-gJziJa_yjEqX9TE+PFMwZn0xbjAUzuUDDDBYA@mail.gmail.com> source: <pull.2149.v5.git.1782923832.gitgitgadget@gmail.com> |
Add a technical document describing the paint_down_to_common() algorithm used for merge-base computation, covering the paint walk, generation number regions, and termination conditions. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
test_trace2_data is a bare grep that silently exits on failure.
Add a more informative variant that verifies the event appears
exactly once and reports what went wrong: key not found, multiple
entries, or value mismatch. Diagnostics go to FD 4 like test_grep.
Before (value mismatch):
$ test_trace2_data status count/changed 999 <trace2.txt
$ echo $?
1
(no output)
After:
$ test_trace2_data_singular status count/changed 999 <trace2.txt
error: trace2 data 'status/count/changed'
expected: 999
actual: 0
Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add test cases to t6600-test-reach.sh that exercise edge cases in the side-exhaustion optimization for paint_down_to_common(): - in_merge_bases_many:self: commit is both A and one of the X inputs - get_merge_bases_many:duplicate-twos: duplicate entries in X list - get_merge_bases_many:pending-stale: STALE transition on an already-painted commit (ps-* diamond topology) - get_merge_bases_many:infinity-both-sides: both tips outside the commit-graph with non-monotonic dates (pi-* topology) Signed-off-by: Elijah Newren <newren@gmail.com> Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add t6099 to test the case where multiple merge-base candidates exist and one is an ancestor of another. This exercises the side-exhaustion optimization in paint_down_to_common together with the remove_redundant safety net in get_merge_bases_many_0. Add a mixed finite/INFINITY test to t6600 where one tip is outside the commit-graph (INFINITY generation) and the other is inside. This exercises the region transition: the walk starts in the INFINITY region where side-exhaustion is disabled, then crosses into the finite region where it can fire. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add a step counter and trace2_data_intmax() call so that the number of commits visited during the paint walk is observable via GIT_TRACE2_EVENT. This provides a way to measure the impact of future optimizations without relying on wall-clock benchmarks alone. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add topologies and tests exercising paint_down_to_common() under clock skew, where commit-date ordering (v1 commit-graph without corrected commit dates) violates the topological invariant that children are dequeued before parents: - se-*: side-exhaustion fires too early when one paint side fully drains from the queue while a low-date ancestor on the other side is still queued - se2-*: side-exhaustion returns a too-deep merge base because the correct (closer) base never receives both paint sides Also add step counts to the edge-case tests from the previous commit, a mixed finite/INFINITY generation topology exercising the transition from INFINITY-generation commits to graph-backed commits, and step counts for the grid-based merge-base test. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add a paint_state struct for use by paint_down_to_common() that wraps a prio_queue with per-side commit counters. Each non-stale queued commit occupies exactly one counter bucket based on its paint flags: PARENT1-only, PARENT2-only, or both sides (a pending merge-base candidate). The counters are maintained by paint_count_update() which adjusts the appropriate bucket by a signed delta. An exhaustive switch on the paint+stale bits documents all valid flag combinations in one place. Convert paint_down_to_common() to use paint_state. The loop now drains the queue via paint_queue_get() which returns NULL when all counters reach zero, replacing the old pointer-based termination (max_nonstale). This is equivalent behavior -- both conditions detect that no non-stale entries remain. paint_queue_get() uses a "pop first" form: it dequeues a commit, then checks the counters. This means the loop exits one iteration earlier than the old code in some topologies (the popped stale commit is never processed), so a few step counts drop by one. The existing nonstale_queue is left in place for ahead_behind(), though nonstale_queue_put_dedup() and nonstale_queue_get_dedup() became unused and are removed. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Add an early termination check to paint_down_to_common() using the
per-side counters introduced earlier. Once the walk enters the
finite-generation region, terminate early when one side's exclusive
count drops to zero -- no new merge-base can form without both paint
sides meeting.
The check also waits for pending_merge_bases to reach zero, ensuring
all merge-base candidates have been dequeued and recorded before
exiting.
The INFINITY gate ensures correctness: commits without a commit-graph
entry have GENERATION_NUMBER_INFINITY and are ordered by commit date,
which is not topologically reliable. The optimization only fires
once the walk enters the finite-generation region where ordering
guarantees hold.
Step counts measured with trace2 on git.git with commit-graph:
merge-base --all v2.0.0 v2.55.0-rc1:
before: 72264 steps after: 44589 steps
merge-base --all v2.55.0-rc1 v2.55.0-rc1~5:
before: 110 steps after: 7 steps
Helped-by: Derrick Stolee <stolee@gmail.com>
Helped-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Consolidate the min_generation termination condition into paint_queue_get(), alongside the existing stale-entry and side-exhaustion checks. Move last_gen into struct paint_state so that commit_graph_generation() is called exactly once per dequeued commit and the result is shared across all termination checks and the monotonicity BUG assertion. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
Remove the fallback that switched paint_down_to_common() from generation ordering to commit-date ordering when the commit-graph lacks corrected commit dates (v1 graph with topo levels only). The fallback was added in 091f4cf (commit: don't use generation numbers if not needed, 2018-08-30) to avoid a performance regression on the Linux kernel repo where v1 topo levels caused "git merge-base v4.8 v4.9" to walk 636k commits instead of 167k. A side branch with a low topo level stayed in the queue behind a long chain, preventing early STALE propagation. Side-exhaustion (added in the previous commits) solves this differently by terminating the walk as soon as one paint side empties from the queue, preventing the deep walk regardless of queue ordering. Benchmarks of "git merge-base --all v4.8 v4.9" on the Linux kernel repo show that side-exhaustion reduces the step count far below what the date-ordering fallback achieved: steps time no graph, baseline: 167,413 3.25 s v1 graph, baseline: 167,413 0.25 s v2 graph, baseline: 167,441 0.29 s v1 graph, this series: 5,725 0.02 s v2 graph, this series: 3,887 0.01 s With generation ordering always active, the existing min_generation check in paint_queue_get() correctly identifies when the walk has reached the finite generation region. The date ordering fallback broke this invariant: a commit could have a finite topo level while the queue was date-ordered, causing the early exit to fire before all merge bases were found. Also remove corrected_commit_dates_enabled() from commit-graph.c which has no remaining callers. Signed-off-by: Kristofer Karlsson <krka@spotify.com>
|
/submit |
|
Submitted as pull.2149.v6.git.1783776466.gitgitgadget@gmail.com To fetch this version into To fetch this version to local tag |
Optimize paint_down_to_common() for merge-base queries that hit
large one-sided histories.
When the walk from one side reaches a commit with a very low
generation number that the other side never paints, the walk is
forced to drain most of the graph. A common trigger is a
repository import that grafts a separate history with its own root,
but any merge that introduces a low-generation commit never painted
by the other side has the same effect.
A new merge-base candidate can only be discovered when exclusive
PARENT1 and PARENT2 paint meet. This series teaches
paint_down_to_common() to stop as soon as one side has no exclusive
commits left in the queue; once one side is exhausted, no further
candidates can appear.
In the RFC thread [1], Derrick Stolee provided a criss-cross
counterexample that sharpened the halt condition, and Elijah Newren
independently discovered the same optimization and shared an
implementation in PR #2150 [2]. Patch 3 incorporates test
cases from Elijah's branch.
This series implements the optimization only after the walk enters
the finite-generation region, where generation ordering guarantees
that paint on visited commits is final.
Patch 2 adds a test_trace2_data_singular helper to
test-lib-functions.sh that reports expected/actual values on
assertion failure instead of a silent grep exit. This was
invaluable during development for iterating on step counts
across the series, and should be valuable for repairing tests
after future algorithmic changes. Happy to drop it if it is
considered unnecessary infrastructure.
The final patch removes the commit-date ordering fallback
introduced by 091f4cf (commit: don't use generation numbers
if not needed, 2018-08-30). With side-exhaustion in place,
the fallback is no longer needed for performance, and removing it
ensures the queue is always generation-ordered regardless of graph
version, so every termination condition can rely on a single
ordering invariant. This patch can be dropped if
the scope is too broad for this series.
Benchmarks
Trace2 step counts are deterministic (measured via
trace2_data_intmax added in patch 5). Wall-clock times are
best-of-11 runs.
2.6M-commit monorepo with commit-graph:
git.git (88k commits, commit-graph):
This series is based on next (depends on kk/commit-reach-find-all-fix
and kk/commit-reach-optim) but is expected to merge cleanly once
kk/commit-reach-find-all-fix graduates to master.
[1] https://lore.kernel.org/git/CAL71e4Ps-2_0+uuZu43N9pFnXBemoAohPs_eyRJf8taXHJPAXQ@mail.gmail.com/T/#u
[2] #2150
Changes since v5:
Rebased on next, which now contains kk/commit-reach-find-all-fix.
The gen_ordered guard from that topic is carried through patches
7-9 via state.gen_ordered, then removed in patch 10 along with
the date-ordering fallback.
Minor documentation and test comment improvements.
Changes since v4:
New patch 2/10: added test_trace2_data_singular helper to
test-lib-functions.sh. Shows expected/actual values on
assertion failure instead of a silent grep failure. Makes
iterating on step counts much easier.
New patch 6/10: added clock-skew topologies (se-, se2-)
that expose side-exhaustion bugs when the commit-date ordering
fallback fires with a v1 commit graph. All topologies use a
shared skew_commit helper. Includes step count assertions for
edge-case tests from patch 3.
Folded the nonstale_queue dedup wrapper removal (previously
separate patch 6/8) into the paint_state introduction in
patch 7/10.
New patch 10/10: remove the commit-date ordering fallback in
paint_down_to_common(). The fallback (091cf18e) was a
performance optimization for v1 commit graphs, but it breaks
the generation ordering invariant that both the side-exhaustion
and single-result optimizations depend on. With
side-exhaustion in place, the fallback is no longer needed.
If kept, this supersedes the separate
"commit-reach: fix !FIND_ALL early exit with v1 commit graph"
topic.
Changes since v3:
Fixed BUG assertion that was accidentally made unconditional
in v3: restored the min_generation guard so it only fires
when generation-based ordering is active.
Moved generation cutoff and single-result termination
conditions into the documentation in patch 1, since they
describe existing behavior.
Renamed paint_state counter fields for clarity: p1_count ->
parent1_count, p2_count -> parent2_count, pending_merge_bases
-> mb_candidate_count. Changed counter types from int to
size_t. (Suggested by Rene Scharfe.)
Changes since v2:
New patch 9/10 (was 8/8): moved the min_generation termination
check and the last_gen monotonicity assertion into
paint_queue_get(), consolidating halt conditions.
commit_graph_generation() is now called once per dequeued
commit and shared across all checks.
Moved all halt conditions inside paint_queue_get() with the
"pop first" form: pop, check, then decrement counters. This
keeps the optimization commit's diff minimal (just inserting
the new checks between pop and decrement).
Shortened the doc comment on paint_queue_get() to describe
what it does rather than how. Inline comments on each
return NULL explain the specific halt condition.
Replaced the manual commit-graph setup in the step-count test
with run_all_modes, which now sets GIT_TRACE2_EVENT per mode
and produces trace-mode-{none,full,half,no-gdat}.txt files.
Added a test_paint_down_steps helper for concise 4-mode step
assertions with diagnostic output on mismatch (prints
"expected X, got Y" instead of a silent grep failure).
Added step-count assertions to the single-walk edge-case
tests: in_merge_bases_many:self, pending-stale,
infinity-both-sides, mixed-finite-infinity.
Included step counts alongside wall-clock times in the
benchmark tables.
Changes since v1:
Reordered patches: documentation first (describing the existing
algorithm), tests before code changes, so they demonstrate
passing with old logic first.
Dropped the ahead_behind decoupling patch. paint_state is now
a NEW struct alongside nonstale_queue instead of replacing it.
ahead_behind() is completely untouched.
Removed nonstale_queue_put_dedup() and
nonstale_queue_get_dedup() (dead code after the conversion) in
a separate commit.
Renamed: struct paint_queue -> paint_state, field pq -> queue,
paint_count_add/remove -> paint_count_update (single function
with signed delta parameter).
Split the old paint_count_transition (which handled both old
and new flags in one call) into separate remove/add calls with
a signed delta. This eliminates the need for the case 0
handler (which tracked "not in the queue") and allows an
exhaustive switch on (PARENT1 | PARENT2 | STALE) that
documents all valid flag combinations, with BUG() in default.
Added trace2_data_intmax() instrumentation to report the number
of commits visited per paint walk (separate commit), with
step-count assertions in tests for deterministic regression
detection.
cc: Derrick Stolee stolee@gmail.com
cc: Elijah Newren newren@gmail.com
cc: Kristofer Karlsson krka@spotify.com
cc: René Scharfe l.s.r@web.de
cc: SZEDER Gábor szeder.dev@gmail.com