Skip to content

git: blame, Avoid repeated tree and blob lookups - #2585

Open
Muhammad-Bin-Ali wants to merge 6 commits into
go-git:mainfrom
Muhammad-Bin-Ali:blame-lookups
Open

Muhammad-Bin-Ali wants to merge 6 commits into
go-git:mainfrom
Muhammad-Bin-Ali:blame-lookups

Conversation

@Muhammad-Bin-Ali

@Muhammad-Bin-Ali Muhammad-Bin-Ali commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Relates to #14. Depends on #2571, which adds the benchmarks; only the last two commits are new here. I'll rebase onto main once #2571 merges.

Why

For each parent of each commit it visited, Blame looked the blamed file up once in the commit and up to three times in the parent. Each lookup decoded the trees from the root and read the blob, often only for its hash. In a profile of go-git-unchanged, about three quarters of the CPU time under Blame was in EncodedObject, mostly pack-index lookups that every read pays, even on a cache hit.

What + Future Intention

  1. Keep each queued file's blob hash on the queue item, instead of looking it up again for every parent.
  2. Look up the blamed path once per parent. A parent has the file when the path names a blob in it, as in Git's fill_blob_sha1_and_mode. The parent's blob is read only when its hash differs from the child's, which is also when the diff needs its contents.

Blame's output is unchanged. Follow-ups: #2584 (type changes, which this PR leaves as they are), rename detection without building full patches, and Bloom filters (#2529).

Verification

Benchmarks ran on an Apple M3 Pro, with main and both commits run in alternating rounds. BenchmarkBlameRepository blames this repository at 12b788e.

Case Warm Cold Trees read Blobs read
go-git-unchanged 12.8 → 7.3 ms (−43%) 29.8 → 24.3 ms (−18%) 1,334 → 462 699 → 45
this repo, blame.go 270 → 141 ms (−48%) 475 → 344 ms (−28%) 15.8k → 5.3k 15.8k → 194
this repo, plumbing/object/commit.go 472 → 215 ms (−54%) 736 → 482 ms (−35%) 45.6k → 15.1k 16.2k → ~514

Across all cases, warm runs are 25–55% faster and cold runs 10–35% faster. The number of commits read is unchanged.

benchstat: main vs this PR
                                                  │     main      │               this PR               │
                                                  │    sec/op     │   sec/op     vs base                │
Blame/spinnaker-script/cold-11                       29.35m ±  1%   24.21m ± 7%  -17.50% (p=0.000 n=10)
Blame/spinnaker-script/warm-11                       18.17m ±  2%   12.94m ± 7%  -28.78% (p=0.000 n=10)
Blame/spinnaker-binary/cold-11                       30.42m ±  6%   20.53m ± 4%  -32.51% (p=0.000 n=10)
Blame/spinnaker-binary/warm-11                       21.69m ± 13%   11.75m ± 3%  -45.83% (p=0.000 n=10)
Blame/go-git-most-edited/cold-11                     20.61m ±  5%   16.36m ± 4%  -20.64% (p=0.000 n=10)
Blame/go-git-most-edited/warm-11                    10.900m ±  4%   6.578m ± 2%  -39.65% (p=0.000 n=10)
Blame/go-git-renamed/cold-11                         35.21m ±  5%   31.62m ± 2%  -10.19% (p=0.000 n=10)
Blame/go-git-renamed/warm-11                        12.042m ±  6%   8.996m ± 3%  -25.30% (p=0.000 n=10)
Blame/go-git-unchanged/cold-11                       29.77m ±  5%   24.34m ± 2%  -18.25% (p=0.000 n=10)
Blame/go-git-unchanged/warm-11                      12.841m ± 12%   7.305m ± 6%  -43.12% (p=0.000 n=10)
BlameRepository/blame.go/cold-11                     474.7m ± 12%   343.6m ± 2%  -27.62% (p=0.002 n=6)
BlameRepository/blame.go/warm-11                     269.6m ±  3%   141.1m ± 2%  -47.66% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/cold-11    736.1m ±  3%   481.7m ± 2%  -34.56% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/warm-11    472.2m ±  3%   214.9m ± 9%  -54.48% (p=0.002 n=6)
geomean                                              49.76m         33.30m       -33.07%

                                                  │     main     │               this PR               │
                                                  │   trees/op   │  trees/op    vs base                │
Blame/spinnaker-script/cold-11                       1047.0 ± 0%    345.0 ± 0%  -67.05% (p=0.000 n=10)
Blame/spinnaker-script/warm-11                       1047.0 ± 0%    345.0 ± 0%  -67.05% (p=0.000 n=10)
Blame/spinnaker-binary/cold-11                       2760.0 ± 0%    927.0 ± 0%  -66.41% (p=0.000 n=10)
Blame/spinnaker-binary/warm-11                       2760.0 ± 0%    927.0 ± 0%  -66.41% (p=0.000 n=10)
Blame/go-git-most-edited/cold-11                      790.0 ± 0%    245.0 ± 0%  -68.99% (p=0.000 n=10)
Blame/go-git-most-edited/warm-11                      790.0 ± 0%    245.0 ± 0%  -68.99% (p=0.000 n=10)
Blame/go-git-renamed/cold-11                          830.0 ± 0%    315.0 ± 0%  -62.05% (p=0.000 n=10)
Blame/go-git-renamed/warm-11                          830.0 ± 0%    315.0 ± 0%  -62.05% (p=0.000 n=10)
Blame/go-git-unchanged/cold-11                       1334.0 ± 0%    462.0 ± 0%  -65.37% (p=0.000 n=10)
Blame/go-git-unchanged/warm-11                       1334.0 ± 0%    462.0 ± 0%  -65.37% (p=0.000 n=10)
BlameRepository/blame.go/cold-11                    15.773k ± 0%   5.250k ± 0%  -66.72% (p=0.002 n=6)
BlameRepository/blame.go/warm-11                    15.773k ± 0%   5.250k ± 0%  -66.72% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/cold-11    45.56k ± 0%   15.05k ± 0%  -66.97% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/warm-11    45.56k ± 0%   15.05k ± 0%  -66.97% (p=0.002 n=6)
geomean                                              2.922k         985.2       -66.28%

                                                  │     main     │              this PR               │
                                                  │   blobs/op   │  blobs/op   vs base                │
Blame/spinnaker-script/cold-11                       1068.0 ± 0%   126.0 ± 0%  -88.20% (p=0.000 n=10)
Blame/spinnaker-script/warm-11                       1068.0 ± 0%   126.0 ± 0%  -88.20% (p=0.000 n=10)
Blame/spinnaker-binary/cold-11                       921.00 ± 0%   12.00 ± 0%  -98.70% (p=0.000 n=10)
Blame/spinnaker-binary/warm-11                       921.00 ± 0%   12.00 ± 0%  -98.70% (p=0.000 n=10)
Blame/go-git-most-edited/cold-11                     789.00 ± 0%   66.00 ± 0%  -91.63% (p=0.000 n=10)
Blame/go-git-most-edited/warm-11                     789.00 ± 0%   66.00 ± 0%  -91.63% (p=0.000 n=10)
Blame/go-git-renamed/cold-11                          452.5 ± 0%   210.0 ± 0%  -53.59% (p=0.000 n=10)
Blame/go-git-renamed/warm-11                          452.5 ± 0%   210.0 ± 0%  -53.59% (p=0.000 n=10)
Blame/go-git-unchanged/cold-11                       699.00 ± 0%   45.00 ± 0%  -93.56% (p=0.000 n=10)
Blame/go-git-unchanged/warm-11                       699.00 ± 0%   45.00 ± 0%  -93.56% (p=0.000 n=10)
BlameRepository/blame.go/cold-11                    15834.0 ± 0%   194.0 ± 1%  -98.77% (p=0.002 n=6)
BlameRepository/blame.go/warm-11                    15834.0 ± 0%   194.0 ± 1%  -98.77% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/cold-11   16223.0 ± 0%   513.5 ± 1%  -96.83% (p=0.002 n=6)
BlameRepository/plumbing/object/commit.go/warm-11   16223.0 ± 0%   513.5 ± 1%  -96.83% (p=0.002 n=6)
geomean                                              1.808k        99.11       -94.52%
  • New tests: 9 in TestBlameSuite, covering merges, a reverted edit, a file replacing or moved over a directory, a mode change and renames. Each expected result matches git blame, and I checked that each test fails when the mistake it targets is applied.
  • Same output: Blame's output is byte-identical before and after each commit on 14 files: 9 from this repository, including renamed files, and the 5 benchmark fixtures.
  • Checks: go test passes for the root package and ./plumbing/object/..., TestBlameSuite passes with -race, and golangci-lint reports 0 issues.

AI assistance

Developed with Claude Code (Claude Opus 5.5), which was used to profile Blame, write the changes and tests, and run the verification. The commits carry Assisted-by trailers.

🤖 Generated with Claude Code

There was no benchmark for `Blame`, so there was no way to measure
changes to it. `BenchmarkBlame` blames five fixture files, each chosen
to stress a different part of the history walk: a long script, a binary
file, the most edited file in a repository, a renamed file and a file
left unchanged for most of the history.

The fixture packfiles are written to a repository on disk, as `Blame` is
used on a cloned repository. Each file is benchmarked cold, with a new
storage per iteration and the read of the starting commit timed along
with `Blame`, so loading the pack indexes counts towards the result, and
warm, with one storage reused across iterations.

Assisted-by: Claude Opus 5.5
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
Timings vary between runs and machines, which makes it hard to tell why
a change helped. `BenchmarkBlame` now also reports the number of
commits, trees and blobs a `Blame` call reads, as `commits/op`,
`trees/op` and `blobs/op`. They are counted once per file by wrapping
the storage in a `countingObjectStorer`, so the timed loops read from
the storage directly.

Assisted-by: Claude Opus 5.5
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
The fixtures have short histories, so `BenchmarkBlame` cannot show how
`Blame` behaves on a real project. `BenchmarkBlameRepository` blames
files of a repository on disk, set through environment variables:

	GOGIT_BLAME_BENCH_REPO   path to the repository
	GOGIT_BLAME_BENCH_FILES  comma-separated files to blame
	GOGIT_BLAME_BENCH_REV    revision to blame at, HEAD by default

It is skipped when `GOGIT_BLAME_BENCH_REPO` is not set, so it never runs
in CI. It shares the cold and warm runs and object counts of
`BenchmarkBlame`.

Assisted-by: Claude Opus 5.5
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
For each parent of each commit it visits, `Blame` looked up the blob of
the blamed file in that commit to compare it with the parent's. Every
lookup decoded the commit's trees from the root and read the blob,
although the blob cannot change once the commit is queued: it is the
file `Blame` started from, or the parent's blob it was compared with
when the commit was queued.

Keep that hash on the queued item and compare against it, instead of
looking it up again.

With `BenchmarkBlame`, and `BenchmarkBlameRepository` blaming files of
this repository at 12b788e, `Blame` reads 29-33% fewer trees and
18-33% fewer blobs, and warm runs take 11-24% less time on an Apple M3
Pro:

                             trees/op           blobs/op         warm
  go-git-unchanged         1334 ->    898     699 ->    481      -20%
  blame.go               15.77k -> 10.56k  15.84k -> 10.62k      -20%
  object/commit.go       45.56k -> 30.56k  16.22k -> 10.98k      -24%

Add tests for merges and for an edit that is later reverted. The
latter fails if the child's hash is queued in place of the parent's.

Assisted-by: Claude Opus 5.5
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
`Blame` looked up the blamed path in each parent up to three times:
`parentsContainingPath` read it to check that the parent has the
file, `blobHash` read it again for its hash, and the contents were
read a third time when the hash differed. Each lookup decoded the
parent's trees from the root and read the blob.

Look the path up once in the parent's tree. A parent has the file
when the path names a blob in it, as Git's `fill_blob_sha1_and_mode`
requires [1]. An entry with the child's hash names the child's blob,
which `Blame` has already read as a blob, so the parent's blob is only
read when its hash differs, and that read gives the contents the diff
needs.
Renamed files are read from the same tree, at their old path.

The result is unchanged. In particular, a directory or a submodule at
the path still sends `Blame` to rename detection, as reading it as a
blob fails.

Measured as for the previous commit, `Blame` reads 46-55% fewer trees
and 43-98% fewer blobs than after it, and warm runs take 16-40% less
time:

                             trees/op           blobs/op         warm
  go-git-unchanged          898 ->    462     481 ->     45      -29%
  blame.go               10.56k ->  5.25k  10.62k ->    194      -35%
  object/commit.go       30.56k -> 15.05k  10.98k ->   ~514      -40%

Some blob counts vary by a few between runs, as they did before this
change; the table shows medians.

[1]: https://github.com/git/git/blob/v2.47.1/blame.c#L1240-L1254

Assisted-by: Claude Opus 5.5
Signed-off-by: Muhammad Bin Ali <muhammadbinali706@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant