Skip to content

Prepare the log walk once and page from it, commit search included - #479

Merged
jonassaa merged 6 commits into
mainfrom
perf/log-page-cache
Sep 17, 2026
Merged

jonassaa merged 6 commits into
mainfrom
perf/log-page-cache

Conversation

@jonassaa

Copy link
Copy Markdown
Owner

Both defects issue 473 measured, in the plain log and in commit search.

What was wrong

log_page built a revwalk for every page. In libgit2 1.9.7 a topologically
sorted walk is not incremental in any sense: git_revwalk_sorting sets
walk->limited, so prepare_walk runs limit_list over the whole reachable
graph and sort_in_topological_order materialises the COMPLETE ordered list
before the first oid comes out. Asking that walk for 500 commits and asking it
for all 1.5 million cost the same thing — so one walk per page paid the
identical price per page, and the cost was flat in depth: page one of
torvalds/linux was 15.95 s and page ten was 157.67 s, ten times one page.
collect_ref_map ran per page beside it.

The measurement the design rests on

Draining a revwalk that has already been prepared is very nearly free,
which is the opposite of what "just cache it" usually costs:

prepare + take(500) drain the whole rest of the order
deep (50,000 commits) 560.2 ms 1.9 ms (49,500 more)
torvalds/linux (1,482,923 commits) 31.8 s 63.4 ms (1,482,423 more)

0.2% more buys the entire order. So the walk is prepared once and its output
kept as a Vec<Oid>, and every later page is a slice of it. That is also why
it is a Vec<Oid> and not the prepared Revwalk: holding the walk would mean
holding a git2::Repository alive between IPC calls, outliving the lock
acquisition git/repo_locks.rs orders every access by, to save that 0.2%.

Two invalidation stories, different on purpose. A first page is keyed by
(refspec, start oids), so any ref that moves is a different key and misses. A
continuation is keyed by the frontier it was emitted with and does not consult
refs at all — resuming from a cursor never did, and the set a frontier reaches
is made of commits, which are immutable. The ref map is keyed by a fingerprint
of every ref name and target, so a git tag typed in a terminal invalidates it
exactly like one made in the app.

Commit search reads the same order

log_filtered_page had both defects, and is where they hurt most: a search
matching nothing recent walks a long way before it fills a page, and the next
page threw that walk away.

It deliberately does not go through page_plan. A filtered page visits far
more commits than it returns, so it can run off the end of an order capped at
MAX_ORDER mid-page — and a short page there is indistinguishable, to the user
and to the frontend, from "no more matches exist". cached_filtered_plan asks
WalkOrder::serves with usize::MAX, which admits any offset into a COMPLETE
order and none at all into a capped one; a miss falls back to the
revwalk-per-page the filtered log always had, so a history past the cap is no
worse off than before. It must also not PREPARE a walk to find that out —
that pays for the sort twice on exactly the repositories where it is most
expensive — hence LogCache::has_walk, which asks "has this walk been prepared
before" without counting a hit.

Both sources run one matches closure through a generic filtered_page,
because a search returning different matches depending on whether a walk
happened to be cached is the one bug this must not have.

Tests

Two files, same split, and the split is the point. Transparency drains the
same history (and the same search) once warm and once through a fresh backend
per page, and compares — the pre-change behaviour held against the new one.
Effect asserts on the cache counters, because every transparency test
passes just as well against a cache that never hits.

Six planted violations, each caught by a distinct test; the walk-cache five are
listed in log_walk_cache.rs, and serving every filtered page from its own
walk takes paging_a_search_prepares_one_walk from 1 to 4.

One behaviour is now visible that was always there: when two commits share a
second, which lane comes out first is not promised — libgit2 orders the topo
queue through a git_pqueue with an unstable comparator, so it depends on
insertion order, and a walk restarted from a cursor inserts differently from one
that ran straight through. The walk-per-page version had the same freedom and
used it at every page boundary. The same-second fixture asserts the actual
contract: every commit exactly once, no parent before its child.

Verified on this tree: 1378 Rust tests, 4178 frontend tests across 407 files,
clean tsc, and commit / history-ops / branches / history-diff e2e
green in Docker.

No after-numbers, deliberately

docs/dev/performance.md findings 2 and 3 now say what fixed them, and the
generated results block is untouched — still the "before" it always was.
Two sessions were benchmarking this repository at once and $PGBENCH_HOME is
one shared directory, so control rows moved 25–30% on operations neither change
touches (wide status 5.38 → 3.92 s, diff_commit 1.53 → 1.12 s). That is not
a result, it is two programs sharing a machine. The doc says so, and says to
re-measure on a quiet machine with one session running before publishing an
after.

What this does NOT fix

The first page on torvalds/linux, and so its first screen. That is the
preparation itself, which nothing here makes cheaper — the benchmark already
ruled out the commit-graph, which libgit2's revwalk does not read. That is the
Tier 3 ladder in issue 476. Not closing issue 473 from this PR, because its
title is about exactly that number; the two defects its body measures are both
fixed, so it is worth a look to see whether it should be retitled or closed.

🤖 Generated with Claude Code

jonassaa and others added 6 commits September 17, 2026 18:35
The paged log rebuilt its revwalk on every page, and in libgit2 1.9.7 a
topologically sorted walk is not incremental in any sense: setting a sort
order sets `walk->limited`, so `prepare_walk` runs `limit_list` over the
whole reachable graph and `sort_in_topological_order` materialises the
COMPLETE ordered list before the first oid comes out. Asking that walk for
500 commits and asking it for all 1.5 million cost the same thing — so one
walk per page paid the identical price per page.

It showed as a per-page cost flat in depth: on torvalds/linux page one was
15.95 s and page ten 157.67 s, ten times one page. git pays for that sort
once and then skips.

So the walk is prepared once and its output kept, and every later page is a
slice of it. The ref map goes the same way: `collect_ref_map` enumerated and
peeled every ref per page to decorate 500 rows, 16x git's own work on a
repository with 7,001 refs.

**Why:** nothing here can make the walk itself cheaper — the benchmark
already ruled out the commit-graph, which libgit2's revwalk does not read
(#473, #476). What was available was to stop throwing the finished sort away.

Two invalidation stories, and they are different on purpose. A first page is
keyed by (refspec, start oids), so any ref that moves is a different key and
misses. A continuation is keyed by the frontier it was emitted with and does
not consult refs at all — resuming from a cursor never did, and the set a
frontier reaches is made of commits, which are immutable. The ref map is
keyed by a fingerprint of every ref name and target, so a `git tag` typed in
a terminal invalidates it exactly like one made in the app.

The cache is a pure accelerator: everything in it is derivable from disk, a
poisoned mutex degrades to a miss rather than failing a page, and closing a
repository drops it. `log_walk_cache.rs` holds that property down by draining
the same history through one backend and through a fresh backend per page and
comparing the two, and asserts the counters separately — because every
transparency test passes just as well against a cache that never hits.

One behaviour is now visible that was always there: when two commits share a
second, which lane comes first is not something either walk promises.
libgit2 orders the topological queue by time through a binary heap with an
unstable comparator, so it depends on insertion order, and a walk restarted
from a cursor inserts differently from one that ran straight through. What
holds either way — every commit exactly once, no parent before its child — is
asserted against a fixture built entirely inside one second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…once

Two corrections to the ref-map half, both measured on the `refs` fixture
(7,001 loose refs, 2,000 commits).

**The peeling was not the expense.** #473 reads the per-page cost as
"enumerating and peeling every ref", and the first cut of this cache followed
that: validate by enumerating names and targets, skip the peel on a hit.
Measured, one enumeration is 113 ms and the peel inside it is 10 ms of that —
so validating cost 113 ms of a 117 ms page and the cache saved 13%.

So a FIRST page validates the decorations and a CONTINUATION reuses what its
first page saw. Once per refresh is proportionate: `branches` and `tags`,
beside it in the same eleven-read fan-out, pay 187 ms and 149 ms for the same
enumeration. Once per scroll is not. A scroll now shows one coherent snapshot
of the decorations rather than re-reading 7,001 refs to redraw the same pills,
and any refresh is a first page.

**And the cold path enumerated everything twice.** Asking for a fingerprint
before building the map means enumerating every ref for an answer that cannot
match anything, then enumerating again inside `collect_ref_map`. That made a
cold eleven-read fan-out SLOWER than the code this replaces — 219 ms against
364 ms — and the benchmark builds a fresh backend per `open_screen` sample, so
that path is always the cold one. `collect_ref_map` now returns the
fingerprint from the pass it was already making, and the validating pass runs
only when there is something cached to validate. Cold is one pass, unchanged
is one cheap pass, and only a ref that actually moved costs two.

**Why:** the failure mode of two fingerprints drifting apart is invisible.
Pagination stays correct, the decorations stay correct, and the cache simply
never hits again — so `RefFingerprint` is one definition used by both paths and
`the_two_fingerprint_paths_agree` asserts it through the only door there is: a
repository holding every shape of ref, read twice, must rebuild nothing the
second time. Planted a one-bit divergence and watched it go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pointer would not serve here. The rule a future change breaks silently is
that everything `git/log_cache.rs` holds is derivable from disk, so it has to
stay a pure accelerator — and that a third cached thing needs its own answer to
"what makes this wrong?", because the two already there answer it differently
on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
log_filtered_page had both of the defects log_page had, for the same reason
and in the same two lines: a revwalk built per page, and the ref map rebuilt
per page. The ref-map half was already fixed; this is the walk.

Search is where it hurt most. A search that matches nothing recent walks a
long way before it fills a page -- and then the next page threw that prepared
walk away and sorted all of history again.

**Why it cannot just call `page_plan`.** A filtered page VISITS far more
commits than it returns, so it can run off the end of an order capped at
MAX_ORDER in the middle of a page, and a short page there is
indistinguishable -- to the user and to the frontend -- from "no more matches
exist". So `cached_filtered_plan` asks `WalkOrder::serves` with `usize::MAX`,
which admits any offset into a COMPLETE order and none at all into a capped
one, and a miss falls back to the revwalk-per-page the filtered log always
had. A history past the cap is no worse off than before.

**And why it must not prepare one speculatively.** Preparing a walk and then
discovering it is capped pays for the topological sort twice, on exactly the
repositories where that sort is most expensive. `LogCache::has_walk` answers
"has this walk been prepared before" without counting a hit or reordering
anything -- a different question from `first_page`'s "can this serve the page
in hand". A filtered page prepares and files a walk in one case only: when
nothing is filed for that key at all. The page was going to pay for a walk
anyway, and a search should not depend on the log having been read first to
be fast, even though in the app it always has been.

Both sources run one `matches` closure through the generic `filtered_page`,
because a search returning different matches depending on whether a walk
happened to be cached is the one bug this must not have. The frontier is
filed against an offset ONLY when the page came out of a cached order: a page
that walked for itself ends somewhere no order has an index for.

`log_filtered_cache.rs` keeps the same split as `log_walk_cache.rs` --
transparency by draining one search warm and cold and comparing, effect by
asserting on the counters, because every transparency test here passes just
as well against a cache that never hits. Verified by planting: serving every
filtered page from its own walk takes `paging_a_search_prepares_one_walk`
from 1 to 4.

Refs #473

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
performance.md still described both defects as open. They are fixed, so the
two findings now say what fixed them -- and carry the measurement the design
rests on, which is the opposite of what "just cache it" usually costs:
draining an ALREADY-PREPARED revwalk is 1.9 ms for the deep fixture's
remaining 49,500 commits and 63.4 ms for the kernel's remaining 1,482,423,
against 560.2 ms and 31.8 s to prepare. 0.2% more buys the whole order, which
is why the cache holds a Vec<Oid> and not the prepared Revwalk.

Finding 3 keeps the two counter-intuitive things found on the way: the
PEELING is not the expense (one enumeration on `refs` is 113 ms, the peel
inside it 10 ms), and computing a ref fingerprint before building the map on
a cold cache enumerates every ref twice and made a cold fan-out SLOWER than
the code it replaced (219 -> 364 ms), which hides because open_screen builds
a fresh backend per sample.

**No after-numbers, deliberately, and the doc says so.** Two sessions were
benchmarking this repository at once and $PGBENCH_HOME is one shared
directory, so control rows moved 25-30% on operations neither change touches
(wide status 5.38 -> 3.92 s, diff_commit 1.53 -> 1.12 s). That is not a
result, it is two programs sharing a machine. The generated results block is
untouched and still the "before" it always was; re-measure on a quiet machine
with one session running before publishing an after.

Refs #473

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Re-measure on a quiet machine" was not enough of a warning. The renderer
publishes every result it finds in $PGBENCH_HOME/results, which is deliberate
-- bench.sh says so, and it is what stops `pnpm bench --fixture deep` from
silently deleting torvalds/linux from the published record -- but it also
means a one-fixture run publishes fresh numbers for that fixture and whatever
happens to be sitting beside it.

**Why:** that is the shape a reviewer cannot catch. test/benchmark.test.ts
checks the markdown against the JSON, so a record built from one fresh
fixture and three stale ones is wrong and self-consistent, and passes.

Refs #473

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonassaa
jonassaa merged commit 6796e95 into main Sep 17, 2026
17 checks passed
@jonassaa
jonassaa deleted the perf/log-page-cache branch September 17, 2026 17:10
jonassaa added a commit that referenced this pull request Sep 17, 2026
has_walk was inserted between insert's doc comment and insert in #479, so the
comment was swallowed: has_walk's rustdoc SUMMARY LINE -- the one line shown
in generated docs, an IDE hover and a symbol list -- read "File a freshly
prepared walk, evicting the least recently used one", which describes insert
and is the opposite of what has_walk does (it counts no hit and moves
nothing). insert, the function that actually evicts, was left undocumented.

One line moved, nothing else.

**Why it got through:** it compiles, every test passes, and no content was
lost -- both sentences were present, attached to the wrong functions. The only
tell is reading the file. It came from anchoring an insertion on `pub fn
insert` when the doc comment for that function sits ABOVE the line anchored
on; an insert anchored on a signature lands inside the preceding item's
documentation.

Refs #473

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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