Accelerate "git merge-base --is-ancestor"
cover Accelerate "git merge-base --is-ancestor"
It was recently [1] reported (and not-so-recently [2] reported) that "git merge-base --is-ancestor" can be pretty slow. In fact, it is regularly slower than "git branch --contains" or "git tag --contains", which are answering a "harder" query.
[1] https://lore.kernel.org/git/[email protected]/
[2] https://lore.kernel.org/git/[email protected]/
The root cause is that the in_merge_base() implementation is skipping the fast can_all_from_reach() implementation and using paint_down_to_common() instead. Note that these are equivalent: a commit A is in the set of merge-bases between A and B if and only if B can reach A.
This fixes the issue, and makes the performance degradation reported by Szeder a non-issue.
Thanks, -Stolee
Derrick Stolee (2):
commit-reach: create repo_is_descendant_of()
commit-reach: use fast logic in repo_in_merge_base
commit-reach.c | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-)
base-commit: b3d7a52fac39193503a0b6728771d1bf6a161464
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-664%2Fderrickstolee%2Fmerge-base-is-ancestor-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-664/derrickstolee/merge-base-is-ancestor-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/664
Signature · 1 line
gitgitgadget
PATCH 1/2 commit-reach: create repo_is_descendant_of()
From: Derrick Stolee <[email protected]>
The next change will make repo_in_merge_bases() depend on the logic in is_descendant_of(), but we need to make the method independent of the_repository first.
--- commit-reach.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-)
commit-reach.c +9 -2
@@ -283,7 +283,9 @@ struct commit_list *repo_get_merge_bases(struct repository *r,/* * Is "commit" a descendant of one of the elements on the "with_commit" list? */ -int is_descendant_of (struct commit *commit,struct commit_list *with_commit) +static int repo_is_descendant_of (struct repository *r, +struct commit *commit, +struct commit_list *with_commit) {if (!with_commit)return 1 ;
@@ -301,13 +303,18 @@ int is_descendant_of(struct commit *commit, struct commit_list *with_commit) other = with_commit->item; with_commit = with_commit->next; -if (in_merge_bases (other, commit)) +if (repo_in_merge_bases (r, other, commit))return 1 ; }return 0 ; } } +int is_descendant_of (struct commit *commit,struct commit_list *with_commit) +{ +return repo_is_descendant_of (the_repository, commit, with_commit); +} +/* * Is "commit" an ancestor of one of the "references"? */
Signature · 1 line
gitgitgadget
Re: [PATCH 1/2] commit-reach: create repo_is_descendant_of()
Quoted 33 lines · Derrick Stolee via GitGitGadget
From: Derrick Stolee <[email protected]>
The next change will make
repo_in_merge_bases()depend on the logic inis_descendant_of(), but we need to make the method independent ofthe_repositoryfirst.Signed-off-by Derrick Stolee <[email protected]>--- commit-reach.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-)commit-reach.c +4
-2@@ -283,7 +283,9 @@ struct commit_list *repo_get_merge_bases(struct repository *r,/* * Is "commit" a descendant of one of the elements on the "with_commit" list? */ -int is_descendant_of (struct commit *commit,struct commit_list *with_commit) +static int repo_is_descendant_of (struct repository *r, +struct commit *commit, +struct commit_list *with_commit) {if (!with_commit)return 1 ;@@ -301,13 +303,18 @@ int is_descendant_of(struct commit *commit, struct commit_list *with_commit) other = with_commit->item; with_commit = with_commit->next; -if (in_merge_bases (other, commit)) +if (repo_in_merge_bases (r, other, commit))return 1 ;
commit-reach.c int is_descendant_of(struct commit *commit, struct commit_list *with_commit) · line 317
Quoted 10 lines
commit-reach.c +5
-0}return 0 ; } } +int is_descendant_of (struct commit *commit,struct commit_list *with_commit) +{ +return repo_is_descendant_of (the_repository, commit, with_commit); +} +
I don't think that it makes a big deal either way, but I wonder about moving 'repo_is_descendant_of' to the header file, and making 'is_descendant_of' be 'static inline int' as you defined it here.
Since this has already graduated up to master already, I don't think that it's worth going back just to shuffle this code around, but I was wondering if you had any specific reason for doing it this way.
Quoted 6 lines
commit-reach.c +0
-1/* * Is "commit" an ancestor of one of the "references"? */ --gitgitgadget
Thanks,
Taylor
Re: [PATCH 1/2] commit-reach: create repo_is_descendant_of()
Quoted 11 lines · Taylor Blau
commit-reach.c int is_descendant_of(struct commit *commit, struct commit_list *with_commit) · line 317
Quoted 5 lines · Derrick Stolee via GitGitGadget
commit-reach.c +5
-0+int is_descendant_of (struct commit *commit,struct commit_list *with_commit) +{ +return repo_is_descendant_of (the_repository, commit, with_commit); +} +I don't think that it makes a big deal either way, but I wonder about moving '
repo_is_descendant_of' to the header file, and making 'is_descendant_of' be 'static inline int' as you defined it here.
Quoted 3 lines
Since this has already graduated up to master already, I don't think that it's worth going back just to shuffle this code around, but I was wondering if you had any specific reason for doing it this way.
I have good news for you. [1]
[1] https://lore.kernel.org/git/[email protected]/
Thanks,
-Stolee
PATCH 2/2 commit-reach: use fast logic in repo_in_merge_base
In reply to Derrick Stolee via GitGitGadgetFrom: Derrick Stolee <[email protected]>
The repo_is_descendant_of() method is aware of the existence of the commit-graph file. It checks for generation_numbers_enabled() before deciding on using can_all_from_reach() or repo_in_merge_bases() depending on the situation. The reason here is that can_all_from_reach() uses a depth-first search that is limited by the minimum generation number of the target commits, and that algorithm can be very slow when generation numbers are not present. The alternative uses paint_down_to_common() which will walk the entire merge-base boundary, which is typically slower.
This method is used by commands like "git tag --contains" and "git branch --contains" for very fast results when a commit-graph file exists. Unfortunately, it is _not_ used in commands like "git merge-base --is-ancestor" which is doing an even simpler request.
This issue was raised recently [1] with respect to a change to how generation numbers are stored, but was also reported much earlier [2] before commit-reach.c existed to simplify these reachability queries.
[1] https://lore.kernel.org/git/[email protected]/
[2] https://lore.kernel.org/git/[email protected]/
The root cause is that builtin/merge-base.c has a method handle_is_ancestor() that calls in_merge_bases(), an older version of repo_in_merge_bases(). It would be better if we have every caller to in_merge_bases() use the logic in can_all_from_reach() when possible.
This is where things get a little tricky: repo_is_descendant_of() calls repo_in_merge_bases() in the non-generation numbers enabled case! If we simply update repo_in_merge_bases() to call repo_is_descendant_of() instead of repo_in_merge_bases_many(), then we will get a recursive call loop. Thankfully, this is caught by the test suite in the default mode (i.e. GIT_TEST_COMMIT_GRAPH=0).
The trick, then, is to make the non-generation number case for repo_is_descendant_of() call repo_in_merge_bases_many() directly, skipping the non-_many version. This allows us to take advantage of this faster code path, when possible.
The easiest way to measure the performance impact is to test the following command on the Linux kernel repository:
git merge-base --is-ancestor <A> <B>
| A | B | Time Before | Time After | |------|------|-------------|------------| | v3.0 | v5.7 | 0.459s | 0.028s | | v4.0 | v5.7 | 0.267s | 0.021s | | v5.0 | v5.7 | 0.074s | 0.013s |
Note that each of these samples return success. The old code performed the same operation when <A> and <B> are swapped. However, can_all_from_reach() will return immediately if the generation numbers show that <A> has larger generation number than <B>. Thus, the time for the swapped case is universally 0.004s in each case.
--- commit-reach.c | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-)
commit-reach.c +10 -2
@@ -303,7 +303,7 @@ static int repo_is_descendant_of(struct repository *r, other = with_commit->item; with_commit = with_commit->next; -if (repo_in_merge_bases (r, other, commit)) +if (repo_in_merge_bases_many (r, other,1 , &commit))return 1 ; }return 0 ;
@@ -355,7 +355,15 @@ int repo_in_merge_bases(struct repository *r,struct commit *commit, struct commit *reference) { - return repo_in_merge_bases_many(r, commit, 1, &reference); + int res; +struct commit_list *list =NULL ; +struct commit_list **next = &list; + + next =commit_list_append (commit, next); + res =repo_is_descendant_of (r, reference, list); +free_commit_list (list); + +return res; }struct commit_list *reduce_heads (struct commit_list *heads)
Signature · 1 line
gitgitgadget
Quoted 5 lines · Derrick Stolee via GitGitGadget
It was recently [1] reported (and not-so-recently [2] reported) that "git merge-base --is-ancestor" can be pretty slow. In fact, it is regularly slower than "git branch --contains" or "git tag --contains", which are answering a "harder" query.
Quoted 8 lines
[1] https://lore.kernel.org/git/[email protected]/
[2] https://lore.kernel.org/git/[email protected]/
The root cause is that the
in_merge_base()implementation is skipping the fastcan_all_from_reach()implementation and usingpaint_down_to_common()instead. Note that these are equivalent: a commit A is in the set of merge-bases between A and B if and only if B can reach A.
OK, so in short, this codepath was not taking advantage of the generation numbers and that was the difference?
Quoted 11 lines · Junio C Hamano
Quoted 9 lines · Derrick Stolee via GitGitGadget
It was recently [1] reported (and not-so-recently [2] reported) that "git merge-base --is-ancestor" can be pretty slow. In fact, it is regularly slower than "git branch --contains" or "git tag --contains", which are answering a "harder" query.
Quoted 7 lines
Quoted 4 lines
The root cause is that the
in_merge_base()implementation is skipping the fastcan_all_from_reach()implementation and usingpaint_down_to_common()instead. Note that these are equivalent: a commit A is in the set of merge-bases between A and B if and only if B can reach A.OK, so in short, this codepath was not taking advantage of the generation numbers and that was the difference?
Correct. We now use generation numbers more often, when available. All callers of in_merge_bases() will become faster, which includes callers in these builtins:
- branch
- fetch
- log
- merge-base
- receive-pack
and these libgit files:
- fast-import.c
- http-push.c
- merge-recursive.c (several!)
- pack-bitmap-write.c
Thanks,
-Stolee
Quoted 23 lines · Derrick Stolee via GitGitGadget
It was recently [1] reported (and not-so-recently [2] reported) that "git merge-base --is-ancestor" can be pretty slow. In fact, it is regularly slower than "git branch --contains" or "git tag --contains", which are answering a "harder" query.
[1] https://lore.kernel.org/git/[email protected]/
[2] https://lore.kernel.org/git/[email protected]/
The root cause is that the
in_merge_base()implementation is skipping the fastcan_all_from_reach()implementation and usingpaint_down_to_common()instead. Note that these are equivalent: a commit A is in the set of merge-bases between A and B if and only if B can reach A.This fixes the issue, and makes the performance degradation reported by Szeder a non-issue.
Thanks, -Stolee
Derrick Stolee (2):
commit-reach: createrepo_is_descendant_of()
commit-reach: use fast logic inrepo_in_merge_base
Quoted 10 lines
commit-reach.c | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-)base-commit:
b3d7a52fac39193503a0b6728771d1bf6a161464
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-664%2Fderrickstolee%2Fmerge-base-is-ancestor-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-664/derrickstolee/merge-base-is-ancestor-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/664Signature · 1 line
gitgitgadget
Wow! Thanks for investigating through the issue and following up. The performance numbers speak for themselves.
By applying this series on the commit-slab patch series, both are now just as fast (master: 0.048s, commit-slab: 0.050s).
Regards
Abhishek