Git development
 help / color / mirror / Atom feed
* [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases()
@ 2026-05-15 18:07 Kristofer Karlsson via GitGitGadget
  2026-05-15 21:14 ` Jeff King
  2026-05-16  9:16 ` [PATCH v2] commit-reach: use object flags " Kristofer Karlsson via GitGitGadget
  0 siblings, 2 replies; 8+ messages in thread
From: Kristofer Karlsson via GitGitGadget @ 2026-05-15 18:07 UTC (permalink / raw)
  To: git; +Cc: Kristofer Karlsson, Kristofer Karlsson

From: Kristofer Karlsson <krka@spotify.com>

tips_reachable_from_bases() walks the commit graph from a set of base
commits to find which tip commits are reachable.  The inner loop does
a linear scan over the tips array to check whether each visited commit
is a tip, making the overall cost O(C * T) where C is commits walked
and T is the number of tips.

Replace the linear scan with the decoration hash for lookups, reducing
the per-commit tip check from O(T) to O(1) and the overall cost from
O(C * T) to O(C + T).

This function is called by `git for-each-ref --merged` and
`git branch/tag --contains/--no-contains` via reach_filter() in
ref-filter.c.

Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):

  Command                           Before    After   Speedup
  for-each-ref --merged HEAD        6.64s     1.66s     4.0x
  for-each-ref --no-merged HEAD     6.75s     1.74s     3.9x
  branch --merged HEAD              0.68s     0.61s      10%
  branch --no-merged HEAD           0.65s     0.61s       8%
  tag --merged HEAD                 0.12s     0.12s       -

The large speedup for for-each-ref is because it checks all 10,000
refs as tips, making the O(T) inner loop expensive.  The branch
subcommand only checks local branches (fewer tips), so the improvement
is smaller.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
    commit-reach: use the decoration hash for tips_reachable_from_bases()
    
    This is a single small commit that replaces an O(C*T) linear scan in
    tips_reachable_from_bases() with an O(1) lookup using the decoration
    hash.
    
    The function is called by git for-each-ref --merged and git branch/tag
    --contains/--no-contains via reach_filter() in ref-filter.c. On a
    merge-heavy monorepo with 2.3M commits and 10,000 refs, git for-each-ref
    --merged HEAD goes from 6.6s to 1.7s (4x).
    
    The diff is intentionally minimal (+9/-6) to make the idea easy to
    discuss before polishing. Things I'm not fully happy about:
    
     * Extra block scope { } just to preserve indentation of the inner body
     * Hacking the array index into the decoration value as (void *)(i + 1)
       instead of storing a proper pointer
     * Relying on unsigned wraparound (- 1 on a size_t 0) to check for
       not-found via j < tips_nr
    
    Happy to clean all of these up in a follow-up commit if the approach
    makes sense.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2116%2Fspkrka%2Ftips-reachable-minimal-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2116/spkrka/tips-reachable-minimal-v1
Pull-Request: https://github.com/gitgitgadget/git/pull/2116

 commit-reach.c | 15 +++++++++------
 1 file changed, 9 insertions(+), 6 deletions(-)

diff --git a/commit-reach.c b/commit-reach.c
index d3a9b3ed6f..70b056eae0 100644
--- a/commit-reach.c
+++ b/commit-reach.c
@@ -1150,6 +1150,7 @@ void tips_reachable_from_bases(struct repository *r,
 	size_t min_generation_index = 0;
 	timestamp_t min_generation;
 	struct commit_list *stack = NULL;
+	struct decoration tip_index = { "tip_index" };
 
 	if (!bases || !tips || !tips_nr)
 		return;
@@ -1173,6 +1174,10 @@ void tips_reachable_from_bases(struct repository *r,
 	QSORT(commits, tips_nr, compare_commit_and_index_by_generation);
 	min_generation = commits[0].generation;
 
+	for (size_t i = 0; i < tips_nr; i++)
+		add_decoration(&tip_index, &commits[i].commit->object,
+			       (void *)(i + 1));
+
 	while (bases) {
 		repo_parse_commit(r, bases->item);
 		commit_list_insert(bases->item, &stack);
@@ -1183,14 +1188,11 @@ void tips_reachable_from_bases(struct repository *r,
 		int explored_all_parents = 1;
 		struct commit_list *p;
 		struct commit *c = stack->item;
-		timestamp_t c_gen = commit_graph_generation(c);
 
 		/* Does it match any of our tips? */
-		for (size_t j = min_generation_index; j < tips_nr; j++) {
-			if (c_gen < commits[j].generation)
-				break;
-
-			if (commits[j].commit == c) {
+		{
+			size_t j = (size_t)lookup_decoration(&tip_index, &c->object) - 1;
+			if (j < tips_nr) {
 				tips[commits[j].index]->object.flags |= mark;
 
 				if (j == min_generation_index) {
@@ -1232,6 +1234,7 @@ void tips_reachable_from_bases(struct repository *r,
 	}
 
 done:
+	clear_decoration(&tip_index, NULL);
 	free(commits);
 	repo_clear_commit_marks(r, SEEN);
 	commit_list_free(stack);

base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 8+ messages in thread

* Re: [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases()
  2026-05-15 18:07 [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget
@ 2026-05-15 21:14 ` Jeff King
  2026-05-16  8:23   ` Kristofer Karlsson
  2026-05-16  9:16 ` [PATCH v2] commit-reach: use object flags " Kristofer Karlsson via GitGitGadget
  1 sibling, 1 reply; 8+ messages in thread
From: Jeff King @ 2026-05-15 21:14 UTC (permalink / raw)
  To: Kristofer Karlsson via GitGitGadget; +Cc: git, Kristofer Karlsson

On Fri, May 15, 2026 at 06:07:43PM +0000, Kristofer Karlsson via GitGitGadget wrote:

> From: Kristofer Karlsson <krka@spotify.com>
> 
> tips_reachable_from_bases() walks the commit graph from a set of base
> commits to find which tip commits are reachable.  The inner loop does
> a linear scan over the tips array to check whether each visited commit
> is a tip, making the overall cost O(C * T) where C is commits walked
> and T is the number of tips.
> 
> Replace the linear scan with the decoration hash for lookups, reducing
> the per-commit tip check from O(T) to O(1) and the overall cost from
> O(C * T) to O(C + T).
> 
> This function is called by `git for-each-ref --merged` and
> `git branch/tag --contains/--no-contains` via reach_filter() in
> ref-filter.c.
> 
> Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):
> 
>   Command                           Before    After   Speedup
>   for-each-ref --merged HEAD        6.64s     1.66s     4.0x
>   for-each-ref --no-merged HEAD     6.75s     1.74s     3.9x
>   branch --merged HEAD              0.68s     0.61s      10%
>   branch --no-merged HEAD           0.65s     0.61s       8%
>   tag --merged HEAD                 0.12s     0.12s       -
> 
> The large speedup for for-each-ref is because it checks all 10,000
> refs as tips, making the O(T) inner loop expensive.  The branch
> subcommand only checks local branches (fewer tips), so the improvement
> is smaller.

Hmm, I couldn't reproduce the speedup on something like linux.git (~1.4M
commits) with a lot of synthetic branches. I'd think that old branches
would be the most expensive, so I did:

  old=$(git rev-list --reverse HEAD | head -n1)
  seq --format="update refs/heads/branch%g $old" 10000 |
  git update-ref --stdin

Running "git for-each-ref --no-merged HEAD" takes ~650ms with stock Git.
But with your patch, it goes to ~830ms!

So what am I missing about your repo that it is so slow in the first
place?

>      * Hacking the array index into the decoration value as (void *)(i + 1)
>        instead of storing a proper pointer

The decoration API is not the most generic option here. There's an
oidmap type, but you have to embed the hashmap bits into your struct,
which is a lot of boilerplate if you're just storing an int. You can
define a khash with a custom value type, and I think the existing
oid_pos uses an int, which might be enough. All of those will store an
extra copy of the oid, though for the sizes we're talking about that's
not the end of the world.

Since we're always mapping commits, you could define a commit-slab (each
commit struct gets a unique id which we then index into a big array).
See commit-slab.h for an example.

I'm not very familiar with this code, but I wonder if we actually need
to map at all. It looks like we are mostly interested in set inclusion,
so perhaps an oidset() would work. Or even a bit in the object-flags.

-Peff

^ permalink raw reply	[flat|nested] 8+ messages in thread

* Re: [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases()
  2026-05-15 21:14 ` Jeff King
@ 2026-05-16  8:23   ` Kristofer Karlsson
  2026-05-16 13:46     ` Derrick Stolee
  0 siblings, 1 reply; 8+ messages in thread
From: Kristofer Karlsson @ 2026-05-16  8:23 UTC (permalink / raw)
  To: Jeff King; +Cc: Kristofer Karlsson via GitGitGadget, git

Thanks for testing this, Jeff! You're right, the patch as posted
regresses on your synthetic test case.

The issue is that when multiple refs point to the same commit,
add_decoration overwrites earlier entries,
so only one index gets stored. The marking itself is correct (the flag
is on the shared commit object,
so all duplicates get marked), but the j == min_generation_index check
never fires for the minimum tip,
so early termination breaks. The DFS walks the entire graph instead of
stopping when all tips are found.

I have a fix for the early-termination bug (checking the flag at
min_generation_index instead of comparing indices),
but your suggestions about the API are well taken, I don't think the
decoration hash is the right tool here.
Since we only need set membership ("is this commit a tip?"), not a
mapping, an object-flags bit or commit-slab would
indeed be simpler and avoid the (void *)(i + 1) hack entirely.

I fixed it locally now for the linux test case and got a 4x speedup
there too - the problem was failing the early termination.
Some numbers when running against the linux repo on my machine:

Command          │ Baseline │     V1 (broken)     │     V2 (fixed)      │
--no-merged HEAD │ 1.33s    │ 2.01s (1.5x slower) │ 0.31s (4.3x faster) │
--merged HEAD    │ 1.35s    │ 1.96s (1.5x slower) │ 0.31s (4.3x faster) │

However, I'll still need to rethink the decoration map - I will come
back with a better patch shortly.

- Kristofer

On Fri, 15 May 2026 at 23:15, Jeff King <peff@peff.net> wrote:
>
> On Fri, May 15, 2026 at 06:07:43PM +0000, Kristofer Karlsson via GitGitGadget wrote:
>
> > From: Kristofer Karlsson <krka@spotify.com>
> >
> > tips_reachable_from_bases() walks the commit graph from a set of base
> > commits to find which tip commits are reachable.  The inner loop does
> > a linear scan over the tips array to check whether each visited commit
> > is a tip, making the overall cost O(C * T) where C is commits walked
> > and T is the number of tips.
> >
> > Replace the linear scan with the decoration hash for lookups, reducing
> > the per-commit tip check from O(T) to O(1) and the overall cost from
> > O(C * T) to O(C + T).
> >
> > This function is called by `git for-each-ref --merged` and
> > `git branch/tag --contains/--no-contains` via reach_filter() in
> > ref-filter.c.
> >
> > Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):
> >
> >   Command                           Before    After   Speedup
> >   for-each-ref --merged HEAD        6.64s     1.66s     4.0x
> >   for-each-ref --no-merged HEAD     6.75s     1.74s     3.9x
> >   branch --merged HEAD              0.68s     0.61s      10%
> >   branch --no-merged HEAD           0.65s     0.61s       8%
> >   tag --merged HEAD                 0.12s     0.12s       -
> >
> > The large speedup for for-each-ref is because it checks all 10,000
> > refs as tips, making the O(T) inner loop expensive.  The branch
> > subcommand only checks local branches (fewer tips), so the improvement
> > is smaller.
>
> Hmm, I couldn't reproduce the speedup on something like linux.git (~1.4M
> commits) with a lot of synthetic branches. I'd think that old branches
> would be the most expensive, so I did:
>
>   old=$(git rev-list --reverse HEAD | head -n1)
>   seq --format="update refs/heads/branch%g $old" 10000 |
>   git update-ref --stdin
>
> Running "git for-each-ref --no-merged HEAD" takes ~650ms with stock Git.
> But with your patch, it goes to ~830ms!
>
> So what am I missing about your repo that it is so slow in the first
> place?
>
> >      * Hacking the array index into the decoration value as (void *)(i + 1)
> >        instead of storing a proper pointer
>
> The decoration API is not the most generic option here. There's an
> oidmap type, but you have to embed the hashmap bits into your struct,
> which is a lot of boilerplate if you're just storing an int. You can
> define a khash with a custom value type, and I think the existing
> oid_pos uses an int, which might be enough. All of those will store an
> extra copy of the oid, though for the sizes we're talking about that's
> not the end of the world.
>
> Since we're always mapping commits, you could define a commit-slab (each
> commit struct gets a unique id which we then index into a big array).
> See commit-slab.h for an example.
>
> I'm not very familiar with this code, but I wonder if we actually need
> to map at all. It looks like we are mostly interested in set inclusion,
> so perhaps an oidset() would work. Or even a bit in the object-flags.
>
> -Peff

^ permalink raw reply	[flat|nested] 8+ messages in thread

* [PATCH v2] commit-reach: use object flags for tips_reachable_from_bases()
  2026-05-15 18:07 [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget
  2026-05-15 21:14 ` Jeff King
@ 2026-05-16  9:16 ` Kristofer Karlsson via GitGitGadget
  2026-05-16 15:59   ` [PATCH v3 0/2] " Kristofer Karlsson via GitGitGadget
  1 sibling, 1 reply; 8+ messages in thread
From: Kristofer Karlsson via GitGitGadget @ 2026-05-16  9:16 UTC (permalink / raw)
  To: git; +Cc: Jeff King, Kristofer Karlsson, Kristofer Karlsson,
	Kristofer Karlsson

From: Kristofer Karlsson <krka@spotify.com>

tips_reachable_from_bases() walks the commit graph from a set of base
commits to find which tip commits are reachable.  The inner loop does
a linear scan over the tips array to check whether each visited commit
is a tip, making the overall cost O(C * T) where C is commits walked
and T is the number of tips.

Use the RESULT object flag to mark tip commits, replacing the linear
scan with a single flag test per visited commit.  This reduces the
per-commit tip check from O(T) to O(1) and the overall cost from
O(C * T) to O(C + T).

When multiple refs point to the same commit, the shared object gets
the flag once, so all duplicates are handled automatically.  The
early-termination advancement loop checks the flag on the sorted
commits array directly, which naturally handles duplicates since the
flag is on the shared commit object.

This also removes the index field from struct commit_and_index, since
the indirection through the original tips array is no longer needed.

This function is called by `git for-each-ref --merged` and
`git branch/tag --contains/--no-contains` via reach_filter() in
ref-filter.c.

Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):

  Command                           Before    After   Speedup
  for-each-ref --merged HEAD        6.57s     1.59s     4.1x
  for-each-ref --no-merged HEAD     6.67s     1.66s     4.0x
  branch --merged HEAD              0.68s     0.61s      10%
  branch --no-merged HEAD           0.65s     0.61s       8%
  tag --merged HEAD                 0.12s     0.12s       -

On linux.git with 10,000 synthetic branches at the root commit (worst
case for the DFS walk):

  Command                           Before    After   Speedup
  for-each-ref --merged HEAD        1.35s     0.35s     3.9x
  for-each-ref --no-merged HEAD     1.82s     0.31s     5.9x

The large speedup for for-each-ref is because it checks all 10,000
refs as tips, making the O(T) inner loop expensive.  The branch
subcommand only checks local branches (fewer tips), so the improvement
is smaller.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
    commit-reach: use object flags for tips_reachable_from_bases()
    
    This replaces the O(C*T) linear scan in tips_reachable_from_bases() with
    an O(1) flag check using the RESULT object flag.
    
    The function is called by git for-each-ref --merged and git branch/tag
    --contains/--no-contains via reach_filter() in ref-filter.c.
    
    Benchmarks on a merge-heavy monorepo (2.3M commits, 10,000 refs):
    
     * for-each-ref --merged HEAD: 6.6s → 1.6s (4.1x)
     * for-each-ref --no-merged HEAD: 6.7s → 1.7s (4.0x)
    
    On linux.git with 10,000 synthetic branches at the root commit:
    
     * for-each-ref --merged HEAD: 1.35s → 0.35s (3.9x)
     * for-each-ref --no-merged HEAD: 1.82s → 0.31s (5.9x)
    
    v2 of this patch, addressing Jeff King's feedback:
    
     * Replaced the decoration hash with the RESULT object flag (simpler, no
       extra data structure, handles duplicate tips naturally)
     * Fixed early-termination bug when multiple refs point to the same
       commit (the decoration API overwrites on duplicate keys)
     * Removed the now-unused index field from struct commit_and_index
     * Diff is +11/-12 lines

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2116%2Fspkrka%2Ftips-reachable-minimal-v2
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2116/spkrka/tips-reachable-minimal-v2
Pull-Request: https://github.com/gitgitgadget/git/pull/2116

Range-diff vs v1:

 1:  992c0aff0e ! 1:  7399a12518 commit-reach: use the decoration hash for tips_reachable_from_bases()
     @@ Metadata
      Author: Kristofer Karlsson <krka@spotify.com>
      
       ## Commit message ##
     -    commit-reach: use the decoration hash for tips_reachable_from_bases()
     +    commit-reach: use object flags for tips_reachable_from_bases()
      
          tips_reachable_from_bases() walks the commit graph from a set of base
          commits to find which tip commits are reachable.  The inner loop does
     @@ Commit message
          is a tip, making the overall cost O(C * T) where C is commits walked
          and T is the number of tips.
      
     -    Replace the linear scan with the decoration hash for lookups, reducing
     -    the per-commit tip check from O(T) to O(1) and the overall cost from
     +    Use the RESULT object flag to mark tip commits, replacing the linear
     +    scan with a single flag test per visited commit.  This reduces the
     +    per-commit tip check from O(T) to O(1) and the overall cost from
          O(C * T) to O(C + T).
      
     +    When multiple refs point to the same commit, the shared object gets
     +    the flag once, so all duplicates are handled automatically.  The
     +    early-termination advancement loop checks the flag on the sorted
     +    commits array directly, which naturally handles duplicates since the
     +    flag is on the shared commit object.
     +
     +    This also removes the index field from struct commit_and_index, since
     +    the indirection through the original tips array is no longer needed.
     +
          This function is called by `git for-each-ref --merged` and
          `git branch/tag --contains/--no-contains` via reach_filter() in
          ref-filter.c.
     @@ Commit message
          Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):
      
            Command                           Before    After   Speedup
     -      for-each-ref --merged HEAD        6.64s     1.66s     4.0x
     -      for-each-ref --no-merged HEAD     6.75s     1.74s     3.9x
     +      for-each-ref --merged HEAD        6.57s     1.59s     4.1x
     +      for-each-ref --no-merged HEAD     6.67s     1.66s     4.0x
            branch --merged HEAD              0.68s     0.61s      10%
            branch --no-merged HEAD           0.65s     0.61s       8%
            tag --merged HEAD                 0.12s     0.12s       -
      
     +    On linux.git with 10,000 synthetic branches at the root commit (worst
     +    case for the DFS walk):
     +
     +      Command                           Before    After   Speedup
     +      for-each-ref --merged HEAD        1.35s     0.35s     3.9x
     +      for-each-ref --no-merged HEAD     1.82s     0.31s     5.9x
     +
          The large speedup for for-each-ref is because it checks all 10,000
          refs as tips, making the O(T) inner loop expensive.  The branch
          subcommand only checks local branches (fewer tips), so the improvement
     @@ Commit message
          Signed-off-by: Kristofer Karlsson <krka@spotify.com>
      
       ## commit-reach.c ##
     +@@ commit-reach.c: void ahead_behind(struct repository *r,
     + 
     + struct commit_and_index {
     + 	struct commit *commit;
     +-	unsigned int index;
     + 	timestamp_t generation;
     + };
     + 
      @@ commit-reach.c: void tips_reachable_from_bases(struct repository *r,
     - 	size_t min_generation_index = 0;
     - 	timestamp_t min_generation;
     - 	struct commit_list *stack = NULL;
     -+	struct decoration tip_index = { "tip_index" };
       
     - 	if (!bases || !tips || !tips_nr)
     - 		return;
     + 	for (size_t i = 0; i < tips_nr; i++) {
     + 		commits[i].commit = tips[i];
     +-		commits[i].index = i;
     + 		commits[i].generation = commit_graph_generation(tips[i]);
     + 	}
     + 
      @@ commit-reach.c: void tips_reachable_from_bases(struct repository *r,
       	QSORT(commits, tips_nr, compare_commit_and_index_by_generation);
       	min_generation = commits[0].generation;
       
      +	for (size_t i = 0; i < tips_nr; i++)
     -+		add_decoration(&tip_index, &commits[i].commit->object,
     -+			       (void *)(i + 1));
     ++		commits[i].commit->object.flags |= RESULT;
      +
       	while (bases) {
       		repo_parse_commit(r, bases->item);
     @@ commit-reach.c: void tips_reachable_from_bases(struct repository *r,
      -				break;
      -
      -			if (commits[j].commit == c) {
     +-				tips[commits[j].index]->object.flags |= mark;
      +		{
     -+			size_t j = (size_t)lookup_decoration(&tip_index, &c->object) - 1;
     -+			if (j < tips_nr) {
     - 				tips[commits[j].index]->object.flags |= mark;
     ++			if (c->object.flags & RESULT) {
     ++				c->object.flags |= mark;
       
     - 				if (j == min_generation_index) {
     +-				if (j == min_generation_index) {
     +-					unsigned int k = j + 1;
     ++				if (commits[min_generation_index].commit->object.flags & mark) {
     ++					unsigned int k = min_generation_index + 1;
     + 					while (k < tips_nr &&
     +-					       (tips[commits[k].index]->object.flags & mark))
     ++					       (commits[k].commit->object.flags & mark))
     + 						k++;
     + 
     + 					/* Terminate early if all found. */
      @@ commit-reach.c: void tips_reachable_from_bases(struct repository *r,
       	}
       
       done:
     -+	clear_decoration(&tip_index, NULL);
     ++	for (size_t i = 0; i < tips_nr; i++)
     ++		commits[i].commit->object.flags &= ~RESULT;
       	free(commits);
       	repo_clear_commit_marks(r, SEEN);
       	commit_list_free(stack);


 commit-reach.c | 23 +++++++++++------------
 1 file changed, 11 insertions(+), 12 deletions(-)

diff --git a/commit-reach.c b/commit-reach.c
index d3a9b3ed6f..82614d2409 100644
--- a/commit-reach.c
+++ b/commit-reach.c
@@ -1125,7 +1125,6 @@ void ahead_behind(struct repository *r,
 
 struct commit_and_index {
 	struct commit *commit;
-	unsigned int index;
 	timestamp_t generation;
 };
 
@@ -1165,7 +1164,6 @@ void tips_reachable_from_bases(struct repository *r,
 
 	for (size_t i = 0; i < tips_nr; i++) {
 		commits[i].commit = tips[i];
-		commits[i].index = i;
 		commits[i].generation = commit_graph_generation(tips[i]);
 	}
 
@@ -1173,6 +1171,9 @@ void tips_reachable_from_bases(struct repository *r,
 	QSORT(commits, tips_nr, compare_commit_and_index_by_generation);
 	min_generation = commits[0].generation;
 
+	for (size_t i = 0; i < tips_nr; i++)
+		commits[i].commit->object.flags |= RESULT;
+
 	while (bases) {
 		repo_parse_commit(r, bases->item);
 		commit_list_insert(bases->item, &stack);
@@ -1183,20 +1184,16 @@ void tips_reachable_from_bases(struct repository *r,
 		int explored_all_parents = 1;
 		struct commit_list *p;
 		struct commit *c = stack->item;
-		timestamp_t c_gen = commit_graph_generation(c);
 
 		/* Does it match any of our tips? */
-		for (size_t j = min_generation_index; j < tips_nr; j++) {
-			if (c_gen < commits[j].generation)
-				break;
-
-			if (commits[j].commit == c) {
-				tips[commits[j].index]->object.flags |= mark;
+		{
+			if (c->object.flags & RESULT) {
+				c->object.flags |= mark;
 
-				if (j == min_generation_index) {
-					unsigned int k = j + 1;
+				if (commits[min_generation_index].commit->object.flags & mark) {
+					unsigned int k = min_generation_index + 1;
 					while (k < tips_nr &&
-					       (tips[commits[k].index]->object.flags & mark))
+					       (commits[k].commit->object.flags & mark))
 						k++;
 
 					/* Terminate early if all found. */
@@ -1232,6 +1229,8 @@ void tips_reachable_from_bases(struct repository *r,
 	}
 
 done:
+	for (size_t i = 0; i < tips_nr; i++)
+		commits[i].commit->object.flags &= ~RESULT;
 	free(commits);
 	repo_clear_commit_marks(r, SEEN);
 	commit_list_free(stack);

base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 8+ messages in thread

* Re: [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases()
  2026-05-16  8:23   ` Kristofer Karlsson
@ 2026-05-16 13:46     ` Derrick Stolee
  0 siblings, 0 replies; 8+ messages in thread
From: Derrick Stolee @ 2026-05-16 13:46 UTC (permalink / raw)
  To: Kristofer Karlsson, Jeff King; +Cc: Kristofer Karlsson via GitGitGadget, git

On 5/16/26 4:23 AM, Kristofer Karlsson wrote:
> Thanks for testing this, Jeff! You're right, the patch as posted
> regresses on your synthetic test case.
> 
> The issue is that when multiple refs point to the same commit,
> add_decoration overwrites earlier entries,
> so only one index gets stored. The marking itself is correct (the flag
> is on the shared commit object,
> so all duplicates get marked), but the j == min_generation_index check
> never fires for the minimum tip,
> so early termination breaks. The DFS walks the entire graph instead of
> stopping when all tips are found.
> 
> I have a fix for the early-termination bug (checking the flag at
> min_generation_index instead of comparing indices),
> but your suggestions about the API are well taken, I don't think the
> decoration hash is the right tool here.
> Since we only need set membership ("is this commit a tip?"), not a
> mapping, an object-flags bit or commit-slab would
> indeed be simpler and avoid the (void *)(i + 1) hack entirely.
> 
> I fixed it locally now for the linux test case and got a 4x speedup
> there too - the problem was failing the early termination.
> Some numbers when running against the linux repo on my machine:
> 
> Command          │ Baseline │     V1 (broken)     │     V2 (fixed)      │
> --no-merged HEAD │ 1.33s    │ 2.01s (1.5x slower) │ 0.31s (4.3x faster) │
> --merged HEAD    │ 1.35s    │ 1.96s (1.5x slower) │ 0.31s (4.3x faster) │
> 
> However, I'll still need to rethink the decoration map - I will come
> back with a better patch shortly.

This is indeed an interesting case (multiple decorations) that we should
make sure is covered by a test case so we don't fall into this mistake
again.

Thanks,
-Stolee


^ permalink raw reply	[flat|nested] 8+ messages in thread

* [PATCH v3 0/2] commit-reach: use object flags for tips_reachable_from_bases()
  2026-05-16  9:16 ` [PATCH v2] commit-reach: use object flags " Kristofer Karlsson via GitGitGadget
@ 2026-05-16 15:59   ` Kristofer Karlsson via GitGitGadget
  2026-05-16 15:59     ` [PATCH v3 1/2] " Kristofer Karlsson via GitGitGadget
  2026-05-16 15:59     ` [PATCH v3 2/2] t6600: add tests for duplicate tips in tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget
  0 siblings, 2 replies; 8+ messages in thread
From: Kristofer Karlsson via GitGitGadget @ 2026-05-16 15:59 UTC (permalink / raw)
  To: git; +Cc: Jeff King, Kristofer Karlsson, Derrick Stolee, Kristofer Karlsson

This replaces the O(C*T) linear scan in tips_reachable_from_bases() with an
O(1) flag check using the RESULT object flag.

The function is called by git for-each-ref --merged and git branch/tag
--contains/--no-contains via reach_filter() in ref-filter.c.

Benchmarks on a merge-heavy monorepo (2.3M commits, 10,000 refs):

 * for-each-ref --merged HEAD: 6.6s → 1.6s (4.1x)
 * for-each-ref --no-merged HEAD: 6.7s → 1.7s (4.0x)

On linux.git with 10,000 synthetic branches at the root commit:

 * for-each-ref --merged HEAD: 1.35s → 0.35s (3.9x)
 * for-each-ref --no-merged HEAD: 1.82s → 0.31s (5.9x)

v2 of this patch, addressing Jeff King's feedback:

 * Replaced the decoration hash with the RESULT object flag (simpler, no
   extra data structure, handles duplicate tips naturally)
 * Fixed early-termination bug when multiple refs point to the same commit
   (the decoration API overwrites on duplicate keys)
 * Removed the now-unused index field from struct commit_and_index
 * Diff is +11/-12 lines

Kristofer Karlsson (2):
  commit-reach: use object flags for tips_reachable_from_bases()
  t6600: add tests for duplicate tips in tips_reachable_from_bases()

 commit-reach.c        | 23 +++++++++++-----------
 t/t6600-test-reach.sh | 45 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 56 insertions(+), 12 deletions(-)


base-commit: 59ff4886a579f4bc91e976fe18590b9ae02c7a08
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-2116%2Fspkrka%2Ftips-reachable-minimal-v3
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-2116/spkrka/tips-reachable-minimal-v3
Pull-Request: https://github.com/gitgitgadget/git/pull/2116

Range-diff vs v2:

 1:  7399a12518 = 1:  7399a12518 commit-reach: use object flags for tips_reachable_from_bases()
 -:  ---------- > 2:  4d11ebb79e t6600: add tests for duplicate tips in tips_reachable_from_bases()

-- 
gitgitgadget

^ permalink raw reply	[flat|nested] 8+ messages in thread

* [PATCH v3 1/2] commit-reach: use object flags for tips_reachable_from_bases()
  2026-05-16 15:59   ` [PATCH v3 0/2] " Kristofer Karlsson via GitGitGadget
@ 2026-05-16 15:59     ` Kristofer Karlsson via GitGitGadget
  2026-05-16 15:59     ` [PATCH v3 2/2] t6600: add tests for duplicate tips in tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget
  1 sibling, 0 replies; 8+ messages in thread
From: Kristofer Karlsson via GitGitGadget @ 2026-05-16 15:59 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Kristofer Karlsson, Derrick Stolee, Kristofer Karlsson,
	Kristofer Karlsson

From: Kristofer Karlsson <krka@spotify.com>

tips_reachable_from_bases() walks the commit graph from a set of base
commits to find which tip commits are reachable.  The inner loop does
a linear scan over the tips array to check whether each visited commit
is a tip, making the overall cost O(C * T) where C is commits walked
and T is the number of tips.

Use the RESULT object flag to mark tip commits, replacing the linear
scan with a single flag test per visited commit.  This reduces the
per-commit tip check from O(T) to O(1) and the overall cost from
O(C * T) to O(C + T).

When multiple refs point to the same commit, the shared object gets
the flag once, so all duplicates are handled automatically.  The
early-termination advancement loop checks the flag on the sorted
commits array directly, which naturally handles duplicates since the
flag is on the shared commit object.

This also removes the index field from struct commit_and_index, since
the indirection through the original tips array is no longer needed.

This function is called by `git for-each-ref --merged` and
`git branch/tag --contains/--no-contains` via reach_filter() in
ref-filter.c.

Benchmark on a merge-heavy monorepo (2.3M commits, 10,000 refs):

  Command                           Before    After   Speedup
  for-each-ref --merged HEAD        6.57s     1.59s     4.1x
  for-each-ref --no-merged HEAD     6.67s     1.66s     4.0x
  branch --merged HEAD              0.68s     0.61s      10%
  branch --no-merged HEAD           0.65s     0.61s       8%
  tag --merged HEAD                 0.12s     0.12s       -

On linux.git with 10,000 synthetic branches at the root commit (worst
case for the DFS walk):

  Command                           Before    After   Speedup
  for-each-ref --merged HEAD        1.35s     0.35s     3.9x
  for-each-ref --no-merged HEAD     1.82s     0.31s     5.9x

The large speedup for for-each-ref is because it checks all 10,000
refs as tips, making the O(T) inner loop expensive.  The branch
subcommand only checks local branches (fewer tips), so the improvement
is smaller.

Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
 commit-reach.c | 23 +++++++++++------------
 1 file changed, 11 insertions(+), 12 deletions(-)

diff --git a/commit-reach.c b/commit-reach.c
index d3a9b3ed6f..82614d2409 100644
--- a/commit-reach.c
+++ b/commit-reach.c
@@ -1125,7 +1125,6 @@ void ahead_behind(struct repository *r,
 
 struct commit_and_index {
 	struct commit *commit;
-	unsigned int index;
 	timestamp_t generation;
 };
 
@@ -1165,7 +1164,6 @@ void tips_reachable_from_bases(struct repository *r,
 
 	for (size_t i = 0; i < tips_nr; i++) {
 		commits[i].commit = tips[i];
-		commits[i].index = i;
 		commits[i].generation = commit_graph_generation(tips[i]);
 	}
 
@@ -1173,6 +1171,9 @@ void tips_reachable_from_bases(struct repository *r,
 	QSORT(commits, tips_nr, compare_commit_and_index_by_generation);
 	min_generation = commits[0].generation;
 
+	for (size_t i = 0; i < tips_nr; i++)
+		commits[i].commit->object.flags |= RESULT;
+
 	while (bases) {
 		repo_parse_commit(r, bases->item);
 		commit_list_insert(bases->item, &stack);
@@ -1183,20 +1184,16 @@ void tips_reachable_from_bases(struct repository *r,
 		int explored_all_parents = 1;
 		struct commit_list *p;
 		struct commit *c = stack->item;
-		timestamp_t c_gen = commit_graph_generation(c);
 
 		/* Does it match any of our tips? */
-		for (size_t j = min_generation_index; j < tips_nr; j++) {
-			if (c_gen < commits[j].generation)
-				break;
-
-			if (commits[j].commit == c) {
-				tips[commits[j].index]->object.flags |= mark;
+		{
+			if (c->object.flags & RESULT) {
+				c->object.flags |= mark;
 
-				if (j == min_generation_index) {
-					unsigned int k = j + 1;
+				if (commits[min_generation_index].commit->object.flags & mark) {
+					unsigned int k = min_generation_index + 1;
 					while (k < tips_nr &&
-					       (tips[commits[k].index]->object.flags & mark))
+					       (commits[k].commit->object.flags & mark))
 						k++;
 
 					/* Terminate early if all found. */
@@ -1232,6 +1229,8 @@ void tips_reachable_from_bases(struct repository *r,
 	}
 
 done:
+	for (size_t i = 0; i < tips_nr; i++)
+		commits[i].commit->object.flags &= ~RESULT;
 	free(commits);
 	repo_clear_commit_marks(r, SEEN);
 	commit_list_free(stack);
-- 
gitgitgadget


^ permalink raw reply related	[flat|nested] 8+ messages in thread

* [PATCH v3 2/2] t6600: add tests for duplicate tips in tips_reachable_from_bases()
  2026-05-16 15:59   ` [PATCH v3 0/2] " Kristofer Karlsson via GitGitGadget
  2026-05-16 15:59     ` [PATCH v3 1/2] " Kristofer Karlsson via GitGitGadget
@ 2026-05-16 15:59     ` Kristofer Karlsson via GitGitGadget
  1 sibling, 0 replies; 8+ messages in thread
From: Kristofer Karlsson via GitGitGadget @ 2026-05-16 15:59 UTC (permalink / raw)
  To: git
  Cc: Jeff King, Kristofer Karlsson, Derrick Stolee, Kristofer Karlsson,
	Kristofer Karlsson

From: Kristofer Karlsson <krka@spotify.com>

When multiple refs point to the same commit, the reachability check
must handle them correctly.  Add three tests:

 - duplicate tips, all reachable
 - duplicate tips, none reachable
 - duplicate tips at the minimum generation (exercises the
   early-termination advancement logic)

Suggested-by: Derrick Stolee <stolee@gmail.com>
Signed-off-by: Kristofer Karlsson <krka@spotify.com>
---
 t/t6600-test-reach.sh | 45 +++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 45 insertions(+)

diff --git a/t/t6600-test-reach.sh b/t/t6600-test-reach.sh
index dc0421ed2f..9486002866 100755
--- a/t/t6600-test-reach.sh
+++ b/t/t6600-test-reach.sh
@@ -612,6 +612,51 @@ test_expect_success 'for-each-ref merged:none' '
 		--format="%(refname)" --stdin
 '
 
+test_expect_success 'for-each-ref merged:duplicate, all reachable' '
+	git branch dup-a commit-3-3 &&
+	git branch dup-b commit-3-3 &&
+	cat >input <<-\EOF &&
+	refs/heads/commit-1-1
+	refs/heads/dup-a
+	refs/heads/dup-b
+	EOF
+	cat >expect <<-\EOF &&
+	refs/heads/commit-1-1
+	refs/heads/dup-a
+	refs/heads/dup-b
+	EOF
+	run_all_modes git for-each-ref --merged=commit-5-5 \
+		--format="%(refname)" --stdin
+'
+
+test_expect_success 'for-each-ref merged:duplicate, none reachable' '
+	cat >input <<-\EOF &&
+	refs/heads/dup-a
+	refs/heads/dup-b
+	refs/heads/commit-9-9
+	EOF
+	>expect &&
+	run_all_modes git for-each-ref --merged=commit-2-2 \
+		--format="%(refname)" --stdin
+'
+
+test_expect_success 'for-each-ref merged:duplicate at min generation' '
+	git branch dup-c commit-1-1 &&
+	git branch dup-d commit-1-1 &&
+	cat >input <<-\EOF &&
+	refs/heads/dup-c
+	refs/heads/dup-d
+	refs/heads/commit-5-5
+	EOF
+	cat >expect <<-\EOF &&
+	refs/heads/commit-5-5
+	refs/heads/dup-c
+	refs/heads/dup-d
+	EOF
+	run_all_modes git for-each-ref --merged=commit-5-5 \
+		--format="%(refname)" --stdin
+'
+
 # For get_branch_base_for_tip, we only care about
 # first-parent history. Here is the test graph with
 # second parents removed:
-- 
gitgitgadget

^ permalink raw reply related	[flat|nested] 8+ messages in thread

end of thread, other threads:[~2026-05-16 15:59 UTC | newest]

Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-05-15 18:07 [PATCH] commit-reach: use the decoration hash for tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget
2026-05-15 21:14 ` Jeff King
2026-05-16  8:23   ` Kristofer Karlsson
2026-05-16 13:46     ` Derrick Stolee
2026-05-16  9:16 ` [PATCH v2] commit-reach: use object flags " Kristofer Karlsson via GitGitGadget
2026-05-16 15:59   ` [PATCH v3 0/2] " Kristofer Karlsson via GitGitGadget
2026-05-16 15:59     ` [PATCH v3 1/2] " Kristofer Karlsson via GitGitGadget
2026-05-16 15:59     ` [PATCH v3 2/2] t6600: add tests for duplicate tips in tips_reachable_from_bases() Kristofer Karlsson via GitGitGadget

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox