Prepare the log walk once and page from it, commit search included - #479
Merged
Merged
Conversation
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>
This was referenced Sep 17, 2026
Open
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Both defects issue 473 measured, in the plain log and in commit search.
What was wrong
log_pagebuilt a revwalk for every page. In libgit2 1.9.7 a topologicallysorted walk is not incremental in any sense:
git_revwalk_sortingsetswalk->limited, soprepare_walkrunslimit_listover the whole reachablegraph and
sort_in_topological_ordermaterialises the COMPLETE ordered listbefore 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/linuxwas 15.95 s and page ten was 157.67 s, ten times one page.collect_ref_mapran 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:
deep(50,000 commits)torvalds/linux(1,482,923 commits)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 whyit is a
Vec<Oid>and not the preparedRevwalk: holding the walk would meanholding a
git2::Repositoryalive between IPC calls, outliving the lockacquisition
git/repo_locks.rsorders 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 tagtyped in a terminal invalidates itexactly like one made in the app.
Commit search reads the same order
log_filtered_pagehad both defects, and is where they hurt most: a searchmatching 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 farmore commits than it returns, so it can run off the end of an order capped at
MAX_ORDERmid-page — and a short page there is indistinguishable, to the userand to the frontend, from "no more matches exist".
cached_filtered_planasksWalkOrder::serveswithusize::MAX, which admits any offset into a COMPLETEorder 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 preparedbefore" without counting a hit.
Both sources run one
matchesclosure through a genericfiltered_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 ownwalk takes
paging_a_search_prepares_one_walkfrom 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_pqueuewith an unstable comparator, so it depends oninsertion 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, andcommit/history-ops/branches/history-diffe2egreen in Docker.
No after-numbers, deliberately
docs/dev/performance.mdfindings 2 and 3 now say what fixed them, and thegenerated results block is untouched — still the "before" it always was.
Two sessions were benchmarking this repository at once and
$PGBENCH_HOMEisone shared directory, so control rows moved 25–30% on operations neither change
touches (
widestatus 5.38 → 3.92 s,diff_commit1.53 → 1.12 s). That is nota 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 thepreparation 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