From: Toon Claes <toon@iotcl.com>
To: Jeff King <peff@peff.net>, Gusted <gusted@codeberg.org>
Cc: git@vger.kernel.org, Taylor Blau <me@ttaylorr.com>
Subject: Re: git-last-modified(1) slower than git-log(1)?
Date: Thu, 16 Jul 2026 13:42:04 +0200 [thread overview]
Message-ID: <87se5jf9f7.fsf@emacs.iotcl.com> (raw)
In-Reply-To: <20260716042808.GA1151612@coredump.intra.peff.net>
Jeff King <peff@peff.net> writes:
> On Tue, Jul 14, 2026 at 08:33:59PM +0200, Gusted wrote:
>
>> The repository I'm currently using to evaluate the performance is
>> https://codeberg.org/ziglang/zig
>>
>> Reproduction steps:
>> 1. `git clone https://codeberg.org/ziglang/zig $(mktemp -d)`
>> 2. cd to tmp directory.
>> 3. `git commit-graph write --changed-paths`. As git-last-modified(1)
>> makes good use of the bloom filters.
>> 4. `hyperfine 'git last-modified -z -t --max-depth=0
>> 80d06578ac66bce3aa0a21e9610cdb782b9a0593 -- doc/langref/' 'git log
>> --name-status -c "--format=commit%x00%H %P%x00" --parents --no-renames
>> -t -z 80d06578ac66bce3aa0a21e9610cdb782b9a0593 -- ":(literal)doc/langref"'`
>
> Thanks for this concrete reproduction. I can see the same problem
> here.
As I mentioned, I was aware of that issue, but never felt the need (and
didn't have the deep Bloom filter knowledge) to fix it.
> Interestingly, if we turn off changed-paths, we get very different
> results.
>
> Without a commit graph at all, last-modified wins (this is using the zig
> repo and the commands above):
>
> - log: 150ms
> - last-modified: 79ms
>
> But with a graph and no changed-paths, they're about equal:
>
> - log: 61ms
> - last-modified: 61ms
>
> And then with changed-paths, the log command gets much faster but
> last-modified gets slower!
>
> - log: 20ms
> - last-modified: 64ms
>
> I think there's a tradeoff in the way that last-modified uses the bloom
> filters. It makes a key for every path we're interested in, and then for
> each commit, we check each key to say "is this in the commit's filter?".
>
> So if you have a subdirectory with a non-trivial number of entries (like
> doc/langref here which has 290), but most commits don't touch that path
> at all (only 120 out of ~39k in this case), we'll spend a lot of time
> checking each key against each filter. We save ourselves opening the
> trees, but at the cost of 290*39k filter comparisons).
>
> Whereas in the git-log case, we make a filter key out of the single
> pathspec we're given, and then check each commit against that. So we
> only do a single filter check for each commit to narrow it down to those
> 120 that matter (modulo a few filter false positives).
Oh, that's very useful of you to explain this. Thank you.
> But I don't see any reason that last-modified couldn't _also_ do that:
> pre-filter the commits with a commit matching the original pathspec, and
> discard most commits with a single filter check.
>
> The hacky patch below does this, and brings my last-modified runtime
> down to 16ms (a 4x improvement, and just a bit faster than git-log).
Funny, I was toying around with my AI agent, and they came with a
similar solution, but I'm working on a cleaner solution.
> It tries to reuse the logic from revision.c, so it's doing the exact
> same filtering that git-log would do. I think there are other ways to do
> it. E.g., we could make our own "root" bloom key that contains all of
> the paths and pre-filter with that. But it seemed to be a little slower
> when I tried it (~24ms). I'd guess that the problem is that because the
> bloom filter is probabilistic, if you shove too many items into a single
> key you'll end getting more and more false positives. So putting all 290
> entries into one key is too much, and we are better off just considering
> the shared prefix.
>
> Anyway, here's the patch. Toon, I'm not planning to take it further
> immediately, but you may be interested in poking at it. It probably
> needs at least:
>
> - some light refactoring of revision.c
Agreed.
> - tests? We don't seem to cover last-modified with changed-paths at
> all, and just rely on the test-vars CI job which sets
> GIT_TEST_COMMIT_GRAPH_CHANGED_PATHS. It did pass for me with that
> flag, so surely I didn't introduce any bugs. :)
Fair of you calling that out. Thanks for checking.
> - more timing exploration; e.g., might it make things worse if
> doc/langref were touched in 99% of the commits? Probably not, but it
> might be nice to check timings against a few repo shapes and request
> depths.
Maybe, I tried a few things.
On gitlab-org/gitlab (our Rails monolith), there seems to be a noticable
improvement when running for `app/`:
Benchmark 1: old
Time (mean ± σ): 435.5 ms ± 9.8 ms [User: 369.5 ms, System: 64.4 ms]
Range (min … max): 425.3 ms … 450.9 ms 5 runs
Benchmark 2: new
Time (mean ± σ): 278.5 ms ± 32.3 ms [User: 208.0 ms, System: 69.3 ms]
Range (min … max): 246.2 ms … 314.8 ms 5 runs
Summary
new ran
1.56 ± 0.18 times faster than old
The app/ directory is touched by roughly 35% of the commits.
In gitlab-org/gitaly, I ran a benchmark on `internal/`, which is touched
in about 58% of the commits:
Benchmark 1: old
Time (mean ± σ): 13.2 ms ± 1.3 ms [User: 10.4 ms, System: 2.5 ms]
Range (min … max): 11.3 ms … 14.8 ms 10 runs
Benchmark 2: new
Time (mean ± σ): 9.9 ms ± 0.4 ms [User: 7.3 ms, System: 2.4 ms]
Range (min … max): 9.6 ms … 10.8 ms 10 runs
Summary
new ran
1.33 ± 0.14 times faster than old
(although Gitaly a lot less commits, ~23k commits vs ~530k in our Rails
monolith)
And also --recursive it's faster:
Benchmark 1: old
Time (mean ± σ): 204.6 ms ± 4.4 ms [User: 193.2 ms, System: 10.5 ms]
Range (min … max): 199.9 ms … 213.2 ms 10 runs
Benchmark 2: new
Time (mean ± σ): 181.5 ms ± 2.7 ms [User: 170.9 ms, System: 9.7 ms]
Range (min … max): 177.0 ms … 187.1 ms 10 runs
Summary
new ran
1.13 ± 0.03 times faster than old
Personally I'm not too worried any use-case would be at least equally
fast.
> - Not all pathspecs can support bloom filters (e.g., "*.c" would not).
> So in theory:
>
> git last-modified HEAD -- "*.c"
>
> could work, but wouldn't be optimized. I don't think it _does_ work
> now, because last-modified's max-depth logic complains. So it might
> be a non-issue.
>
> But I think it is solvable if we really wanted. Rather than
> traversing looking for "*.c", we actually expand the pathspec in the
> tip commit to a set of literal paths, and then as we traverse we
> look for those paths. So we could collect all of "*.c" and then
> add bloom keys for the shared prefixes. I think this does get tricky
> in the general case, though. If you have "a/b/c" and "a/b/d",
> looking for "a/b" is reasonable. But what if you also have "a/e"?
> Should you just have a key for "a/", or both "a/b" and "a/e"?
> There are some tradeoffs between how often uninteresting things in
> "a/" will give us a false positive, versus the cost of checking
> extra keys.
>
> So maybe an interesting area, but given that in practice most people
> will feed a single pathspec to last-modified, it's a lot easier to
> just use that.
Yeah, I rather not deal with that right now.
> - I know that last-modified was derived from GitHub's blame-tree
> implementation (which I originally wrote, but stopped paying
> attention to well before it learned about changed-path filters). I
> don't know if the problem was solved separately there, but it would
> be worth checking. +cc Taylor
>
> -Peff
>
> ---
> diff --git a/builtin/last-modified.c b/builtin/last-modified.c
> index 5478182f2e..c07169258f 100644
> --- a/builtin/last-modified.c
> +++ b/builtin/last-modified.c
> @@ -254,6 +254,29 @@ static void pass_to_parent(struct bitmap *c,
> bitmap_set(p, pos);
> }
>
> +/*
> + * revision.c already has this functionality, but it is not public
> + * and it looks up the filter itself. But probably some refactoring
> + * could make it available at the right level?
I assume you're talking about check_maybe_different_in_bloom_filter()?
I was working on a fix to simply make it public and call it, but that's
a very valid point you're making. I'll change my plans.
> + */
> +static bool filter_contains_keyvec(const struct bloom_filter *filter,
> + struct rev_info *rev)
> +{
> ... [snip]
--
Cheers,
Toon
next prev parent reply other threads:[~2026-07-16 11:42 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-14 18:33 git-last-modified(1) slower than git-log(1)? Gusted
2026-07-16 4:28 ` Jeff King
2026-07-16 11:42 ` Toon Claes [this message]
2026-07-16 9:26 ` Toon Claes
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=87se5jf9f7.fsf@emacs.iotcl.com \
--to=toon@iotcl.com \
--cc=git@vger.kernel.org \
--cc=gusted@codeberg.org \
--cc=me@ttaylorr.com \
--cc=peff@peff.net \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox