Skip to content

Commit 7a5bc6d

Browse files
committed
rn-138: add article about new merge base algorithm
1 parent 51a551a commit 7a5bc6d

1 file changed

Lines changed: 286 additions & 2 deletions

File tree

rev_news/drafts/edition-138.md

Lines changed: 286 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,293 @@ This edition covers what happened during the months of July and August 2026.
2121
### General
2222
-->
2323

24-
<!---
2524
### Reviews
26-
-->
25+
26+
+ [[PATCH/RFC 0/6] commit-reach: terminate merge-base walk when one side is exhausted](https://lore.kernel.org/git/pull.2149.git.1781951820.gitgitgadget@gmail.com)
27+
28+
Kristofer Karlsson had a monorepo of around 2.6 million commits in
29+
which a very ordinary operation was painfully slow: computing the
30+
merge base between a pull request branch and the mainline took several
31+
seconds. He had described the problem in
32+
[an earlier RFC discussion](https://lore.kernel.org/git/CAL71e4Ps-2_0+uuZu43N9pFnXBemoAohPs_eyRJf8taXHJPAXQ@mail.gmail.com/),
33+
and in June he followed it up with actual code, writing that he
34+
expected "the design to still be scrutinized, but that may be easier
35+
with actual code to look at".
36+
37+
The culprit is `paint_down_to_common()` in `commit-reach.c`, the
38+
function underlying `git merge-base`, `git merge-tree`, and every
39+
command that needs to know where two histories diverged. Kristofer's
40+
cover letter included an ASCII diagram of the shape that triggers the
41+
pathology: a repository import that grafts a separate history with its
42+
own root commit. When the walk from one side reaches a commit with a
43+
very low generation number that the other side never paints, the walk
44+
is forced to drain nearly the whole graph before it can convince itself
45+
that it is done. Any merge that introduces a low-generation commit
46+
never painted by the other side has the same effect.
47+
48+
The key observation, and the whole idea behind the series, is that a
49+
new merge-base candidate can only be discovered when exclusive
50+
`PARENT1` and `PARENT2` paint meet. In the initial numbers Kristofer
51+
reported, this turned a 4.293 second `git merge-base --all` across the
52+
import into 8 milliseconds, a 537x improvement, and a 5.345 second
53+
`git merge-tree` into 13 milliseconds.
54+
55+
## Some background on the paint walk and generation numbers
56+
57+
`paint_down_to_common()` is a single traversal driven by one priority
58+
queue, which holds the frontier of commits waiting to be visited. It
59+
paints the first commit with the flag `PARENT1` and each of the other
60+
commits with `PARENT2`, puts them all in the queue, and then repeats
61+
one step until it can stop: pop the commit with the highest generation
62+
number, and pass its flags to its parents, putting each parent in the
63+
queue if it gained a flag it did not already have.
64+
65+
Painting and enqueuing are therefore the same operation: a commit is
66+
painted precisely when one of its children passes it a flag, and
67+
nothing else can change its paint. A commit holding both `PARENT1` and
68+
`PARENT2` has been reached from both sides, making it a merge-base
69+
candidate. A third flag, `STALE`, then spreads to its ancestors, which
70+
cannot be merge bases themselves.
71+
72+
A generation number, stored in the commit-graph file, records how far
73+
a commit is from a root, and a child's is always greater than its
74+
parent's. Since each pop returns the highest generation left in the
75+
queue, and every child of the popped commit has a higher generation
76+
still, no child of it can ever be popped later, so its paint is
77+
final. That is the property the new optimization depends on: it means
78+
that when one side has no exclusive commits left in the queue, none
79+
can ever reappear, so no new merge-base candidate can turn up and the
80+
walk can stop there.
81+
82+
## The first round of review
83+
84+
Derrick Stolee reviewed the RFC thoroughly and set the tone for
85+
everything that followed: "Overall, I believe that this implementation
86+
is functionally correct and everything I have to say is about
87+
presentation and data gathering."
88+
89+
His most consequential structural objection was about the first two
90+
patches. Kristofer had moved `ahead_behind()` off the shared
91+
`nonstale_queue` abstraction in order to replace that abstraction with
92+
a new one. Stolee argued this was "essentially recreating its logic
93+
in a more disjointed way here, leaving this code in a worse state",
94+
and asked for a *new* data structure to be introduced alongside the
95+
existing one rather than replacing something that already worked for
96+
multiple callers. Kristofer agreed to leave `ahead_behind()`
97+
untouched.
98+
99+
Stolee also asked for the switch statements tracking paint transitions
100+
to be reformatted per the coding guidelines, questioned whether the
101+
`pq` field name was wise when it could stand for either `prio_queue` or
102+
`paint_queue`, and made a suggestion that shaped the rest of the
103+
series: rather than testing the termination condition in the loop body,
104+
`paint_queue_get()` should return NULL when it detects that no further
105+
merge base can be found, so that the loop has a single exit. He
106+
preferred `!count` over summing counters and comparing to zero, too.
107+
108+
Separately, Elijah Newren had independently discovered the same
109+
optimization and had an implementation of his own in
110+
[gitgitgadget PR #2150](https://github.com/gitgitgadget/git/pull/2150).
111+
Rather than compete, the two combined efforts: Elijah's test cases were
112+
folded into the series as a patch authored by him, and Elijah's
113+
criss-cross counterexample from the earlier RFC thread, along with
114+
Stolee's, had already sharpened the halt condition.
115+
116+
When Kristofer wondered how to benchmark reliably given the noise from
117+
commit parsing, Stolee pointed him at
118+
[hyperfine](https://github.com/sharkdp/hyperfine) and showed the exact
119+
invocation for comparing two builds.
120+
121+
## Measuring the walk instead of the clock
122+
123+
[Version 2](https://lore.kernel.org/git/pull.2149.v2.git.1782303254.gitgitgadget@gmail.com)
124+
reordered the series so that the documentation came first, describing
125+
the algorithm as it already existed, and the tests came before the code
126+
changes so they could be shown passing with the old logic. The
127+
`ahead_behind()` patch was dropped, the new struct was renamed from
128+
`paint_queue` to `paint_state`, and all termination conditions moved
129+
into `paint_queue_get()` as Stolee had asked.
130+
131+
The most useful addition was one Stolee had implicitly asked for by
132+
requesting better "data gathering": a `trace2_data_intmax()` call
133+
reporting how many commits the paint walk visited. Because step counts
134+
are deterministic, unlike wall-clock times, they can be asserted in the
135+
test suite. Stolee was enthusiastic saying "This is great data", and
136+
suggested going further by reordering the instrumentation patch before
137+
the new tests, so that those tests would carry step-count assertions
138+
from birth and visibly update when the implementation changed. He also
139+
noticed that Kristofer had omitted step counts for one benchmark row.
140+
Kristofer confessed "I will have to attribute to laziness I suppose :)"
141+
and filled in the table.
142+
143+
Junio Hamano, the Git maintainer, read the new technical document and
144+
called it a "Great write-up that very clearly and concisely explains
145+
what goes on inside the merge-base computation. Thanks for a pleasant
146+
read."
147+
148+
## A self-reported breakage, and a rename
149+
150+
[Version 3](https://lore.kernel.org/git/pull.2149.v3.git.1782479286.gitgitgadget@gmail.com)
151+
consolidated the `min_generation` check and the generation-monotonicity
152+
`BUG()` assertion into `paint_queue_get()` as well, so that
153+
`commit_graph_generation()` is called exactly once per dequeued commit.
154+
Stolee worked through the change out loud, initially doubting that
155+
`last_gen` belonged in the struct and then concluding "This is an
156+
appropriate use of this value. My concerns are no longer valid. Thanks
157+
for letting me think out loud."
158+
159+
This round also broke `t6600`, and Junio ejected it from `seen`.
160+
Kristofer had spotted the mistake himself and self-reported it, but
161+
only in a reply to the individual patch rather than to the cover
162+
letter. Junio was relaxed about it: "Mistakes happen, and do not need
163+
to rush, as collaboration is asynchronous around here anyway, and we
164+
may read our e-mails in different order ;-)", adding that "It would
165+
have been a more troubling experience if only my set-up were seeing the
166+
issue".
167+
168+
René Scharfe reviewed the new `paint_state` struct and asked whether
169+
its counters could ever go negative, suggesting `size_t` to match `nr`
170+
from `struct prio_queue`, and then, in his words, indulged in "some
171+
bikeshedding" about the field names: why abbreviate `p1_count` when
172+
`parent1_count` reads more easily and pairs visibly with the `PARENT1`
173+
flag? [Version 4](https://lore.kernel.org/git/pull.2149.v4.git.1782649547.gitgitgadget@gmail.com)
174+
adopted both suggestions, renaming the counters to `parent1_count`,
175+
`parent2_count` and `mb_candidate_count` and switching them to `size_t`.
176+
177+
## Growing scope: a test helper and an eight-year-old workaround
178+
179+
SZEDER Gábor made a brief but useful appearance on version 4, pointing
180+
out that the patch removing the now-unused `nonstale_queue_put_dedup()`
181+
and `nonstale_queue_get_dedup()` wrappers had to be squashed into the
182+
preceding one: because the last callers disappeared in that earlier
183+
commit, the tree could no longer be built with `DEVELOPER=1` without
184+
tripping `-Wunused-function`. Kristofer agreed, noting it was
185+
"unfortunate that this means the commit itself becomes less clean, but
186+
I don't have any other good solution -- and having each commit compile
187+
cleanly is more important."
188+
189+
[Version 5](https://lore.kernel.org/git/pull.2149.v5.git.1782923832.gitgitgadget@gmail.com)
190+
grew the series to ten patches with two notable additions. The first
191+
was a `test_trace2_data_singular()` helper for
192+
`t/test-lib-functions.sh`: the existing `test_trace2_data()` is a bare
193+
`grep` that fails silently, whereas the new helper reports whether the
194+
key was missing, appeared more than once, or simply held the wrong
195+
value. Kristofer offered to drop it, calling it possibly "unnecessary
196+
infrastructure", but it survived. New test topologies with deliberate
197+
clock skew were also added, to exercise precisely the cases where
198+
date ordering would break the optimization.
199+
200+
The second addition was much bolder: removing the commit-date ordering
201+
fallback introduced by 091f4cf3 (commit: don't use generation numbers
202+
if not needed, 2018-08-30). That fallback existed because v1
203+
commit-graphs, which store topological levels rather than corrected
204+
commit dates, could make `git merge-base v4.8 v4.9` on the Linux
205+
kernel walk 636k commits instead of 167k. Side exhaustion solves the
206+
same problem far better. Kristofer measured the step count for that
207+
query dropping to 5,725 on a v1 graph and 3,887 on a v2 graph. And
208+
removing the fallback means the queue is always generation-ordered, so
209+
every termination condition can rely on a single invariant. He noted
210+
that if this patch were kept, his separate
211+
`kk/commit-reach-find-all-fix` topic would become unnecessary.
212+
213+
## Waiting for reviewers
214+
215+
[Version 6](https://lore.kernel.org/git/pull.2149.v6.git.1783776466.gitgitgadget@gmail.com)
216+
prompted a process aside. Kristofer had written that the series was
217+
"rebased on next", and Junio responded firmly: "As always, do *not*
218+
base your patches on 'next'. I cannot apply such a patch series to my
219+
tree, as merging the resulting topic down to 'master' will pull _all_
220+
the other topics, including those that are not ready", recommending
221+
instead a synthetic base built by merging only the topics actually
222+
depended upon. Kristofer explained he had merely *verified* against
223+
`next` and had in fact prepared exactly such a synthetic base, and that
224+
he should have said so more clearly. Junio replied "Well that is how I
225+
wiggled the series in my tree after all ;-)".
226+
227+
Then the topic stalled for several weeks, and Junio asked the list
228+
plainly: "we really need to get somebody take a look at these patches
229+
to move them forward. Any takers?". Kristofer, unfazed, offered to
230+
shrink the series, dropping the date-ordering cleanup, dropping the
231+
test helper, or squashing test commits, while wondering whether "this
232+
is simply the time of year where people take more vacation and are
233+
thus spending less time on code reviews". Elijah answered the call: "I
234+
started looking at the series and left a couple comments. I'll
235+
continue looking at it on Monday."
236+
237+
## Naming the region
238+
239+
Elijah's review of
240+
[version 7](https://lore.kernel.org/git/pull.2149.v7.git.1786013982.gitgitgadget@gmail.com)
241+
242+
was detailed and warm, and it repeatedly praised the series structure
243+
rather than just the code. Of the `paint_state` patch, he wrote "Ooh,
244+
I like this setup for what comes later; it sets the stage perfectly
245+
for the key insight behind the optimization". Of the optimization
246+
patch itself, he said "...this is the insight behind this
247+
optimization, which the previous patch set up so nicely."
248+
249+
His substantive concern was about a case the series had glossed over.
250+
With v1 commit-graphs, generation numbers *saturate* at
251+
`GENERATION_NUMBER_V1_MAX`, so many commits at genuinely different
252+
depths share one value, breaking ordering guarantees in exactly the
253+
same way as `GENERATION_NUMBER_INFINITY` does. Elijah asked "What
254+
about `GENERATION_NUMBER_V1_MAX`?" and objected that the documentation
255+
mentioned the problem while the code at that point in the series only
256+
gated on infinity, so patch 8 was internally inconsistent. He also
257+
pushed on vocabulary: rather than "finite-generation region", why not
258+
call it the "reliably-ordered region"? Kristofer liked the coinage,
259+
replying that he would rewrite the documentation to speak of ordered
260+
versus unordered regions, "I think you coined it in one of the other
261+
emails, and I quite prefer that over infinite/finite", and confessed
262+
that "I've always found it easier to write correct code than useful
263+
documentation, so now the real work starts". Version 7 had already
264+
introduced a `topo_ceiling` (`V1_MAX` for v1 graphs, `INFINITY` for
265+
v2) that the early-exit gates compare against, so saturated commits
266+
are treated as unordered.
267+
268+
Junio also spotted that one test patch's commit message described
269+
changes to `t6600` that the diffstat showed happening two patches
270+
later. Kristofer traced it to a reorganisation back at version 5 and
271+
fixed it. Junio, apologising for "nitpicking", added "Maybe others can
272+
give more serious reviews on the topic. This gives us an important
273+
optimization."
274+
275+
## Conclusion
276+
277+
[Version 8](https://lore.kernel.org/git/pull.2149.v8.git.1786440533.gitgitgadget@gmail.com)
278+
moved `topo_ceiling` into the patch where the side-exhaustion gate
279+
first needs it, so V1 saturation is handled correctly at every commit
280+
in the series, and renamed the "finite/INFINITY region" to
281+
"ordered/unordered region" throughout. Elijah's response was
282+
unreserved:
283+
284+
> I am quite pleased with how this series has turned out. Not only does
285+
> it provide nice speedups, I think the structure of the series is
286+
> particularly nicely set up in a way that helps guide the discovery of
287+
> the idea behind the optimization for others to read, documents and
288+
> tests everything logically and thoroughly, and was a pleasant read.
289+
290+
He added a `Reviewed-by:` trailer, and then a mock complaint that
291+
Kristofer had normalised some double spaces after periods in the
292+
documentation: "Don't think for a second that I didn't notice you
293+
murdering these double spaces. You villain! ;-)" That prompted
294+
Kristofer to wonder whether the project should codify a preference.
295+
Junio ruled that this "is a thing that is best left for 'match the
296+
surrounding area' rule". Junio then asked "We can declare victory and
297+
mark the topic for 'next' now?", and both author and reviewer agreed.
298+
299+
The topic was merged to `master` on 23 August 2026 and should be
300+
released as part of Git 2.56.0 around the end of September 2026.
301+
302+
Beyond the speedups themselves, which will be most visible to anyone
303+
working in a large monorepo or a repository with a grafted import, the
304+
discussion left Git with three lasting artefacts: a new
305+
`Documentation/technical/paint-down-to-common.adoc` explaining an
306+
algorithm that had never been written down, trace2 step-count
307+
instrumentation that makes future work on the paint walk measurable
308+
without benchmarking wall-clock time, and one fewer special case, now
309+
that the 2018 commit-date ordering fallback has been removed and the
310+
merge-base queue is always generation-ordered.
27311

28312
<!---
29313
### Support

0 commit comments

Comments
 (0)