Repository navigation
perf(gix-odb): move the index that answers a lookup to the front - #3186
Sebastian Thiel (Byron) merged 6 commits into
Conversation
A dynamic-store handle probes its loaded indices front to back. They start out biggest first, and `load_index.rs` leaves it "to the handle to sort by LRU": on a hit, `try_find()`, `contains()`, `location_by_oid()` and `try_header()` call `snapshot.indices.swap(0, idx)`. A swap moves the previous front index into the slot the hit came from. When lookups alternate between a few indices, these keep landing far back. A tree diff does that: it reads the old and the new side of each changed directory in turn, and with many packs the two sides often live in different ones. Lookups that mostly hit one big pack, with the rest spread over many small ones, have the same problem, as every hit in a small pack swaps the big one away. Move the index that answered to the front instead, and the ones it passed back by one slot, so recently used indices stay near the front. `Snapshot::move_to_front()` does that with adjacent swaps for the first 8 slots, which is cheaper than the `memmove()` behind `rotate_right()` when a repository has only a few packs, and with `rotate_right()` further back. - The first index in the handle's order that holds an object still answers, so results only differ for objects that more than one pack holds. Which copy answers already depended on the handle's lookup history. - Lookups that visit each pack once, in turn, probe more than before, as the indices that answered pile up at the front. A commit walk without a commit-graph over one pack per fetch does that. The new test builds ten packs of decreasing size that share some blobs. For a hit in slot 2 and in slot 9, it checks through each of the four lookups that the index that answered comes first and the previous front index second.
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b424ad5e4e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".
…ta base An object stored as a `REF_DELTA` whose base is in another pack, as in a thin pack kept on disk, makes `try_find()` and `try_header()` look up the base from the front again. That lookup moves the base's index to the front, and the indices it passed back by one slot. If the base's index came after the object's, the object's index is one slot further back afterwards. Looking the object up again then finds it there, but the slot it was first found in was moved to the front, which now holds another index. Remember the slot the object is found in again, and move that one. Lookups returned the right object either way; only the order in which later lookups probe the indices was off. On main, `swap(0, idx)` had this only for a hit in slot 0: swapping the base's index to the front put the object's index into the base's slot, where it stayed. Looking the object up again from the front could then reach a copy of it in a pack that wasn't loaded yet, and panic with "pack to still be available like just now". Moving indices instead of swapping them already avoids that. The new test writes the delta into a pack in slot 0 and in slot 1, and its base into a smaller pack, and checks through both lookups that the delta's index ends up first and the base's second. With the delta in slot 0, the pack in between also holds the object in full, which makes main panic.
There was a problem hiding this comment.
🟡 Changes recommended
Retry paths can use a stale index after external-base resolution, selecting a different object copy and causing decoding failures or panics.
1 open issue
What changed in this PR
This PR improves lookup locality in gix-odb by moving the answering pack index to the front while preserving the relative order of other indices. It targets workloads that alternate between packs, such as tree diffs in repositories with many packs.
Changes:
- Add move-to-front reordering using adjacent swaps near the front and rotation for later slots.
- Update all four lookup paths and slot tracking after external delta-base resolution.
- Add regression tests for index ordering and cross-pack delta lookups.
| File | Description |
|---|---|
gix-odb/tests/odb/store/dynamic.rs |
Tests reordering across lookup APIs and external delta-base resolution. |
gix-odb/tests/fixtures/make_overlapping_packs.sh |
Creates overlapping packs that make lookup order observable. |
gix-odb/src/store_impls/dynamic/load_index.rs |
Adds the shared move-to-front helper. |
gix-odb/src/store_impls/dynamic/header.rs |
Applies reordering and updates retry slot tracking for header lookups. |
gix-odb/src/store_impls/dynamic/find.rs |
Applies reordering to object, membership, and location lookups. |
Review effort: Max
…bases <!-- agent --> Review of the move-to-front lookup change exposed a retry that could select a different copy of the requested object after resolving an external `REF_DELTA` base. That copy could belong to an unopened multi-pack-index member, causing a panic, or require another base and fail decoding. Reacquire the original index by its `pack_id` in both `try_find` and `try_header`, preserving the selected representation and its offset. If recursion refreshed the snapshot, restart normal lookup and pack loading instead of relying on a potentially reused slot or an unloaded pack. Extend the regression fixture with full and delta duplicates in a MIDX, including a duplicate requiring a different external base. Cover both lookup APIs, both starting positions, and eager and lazy index loading using fresh stores and a cached read-only fixture. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra <codex@openai.com>
Removed the regression test, assuming that CI would find those issues one way or another. <!-- agent --> The `gix-odb` external-delta regression exposed an archive collision when SHA-1 tests were followed by SHA-256 tests. Generated directories already included the object hash, but both runs used the same Rust fixture tar. The SHA-256 run therefore extracted SHA-1 packs and found no indices. Include the selected hash in the shared Rust fixture name, which keys archives, generated directories, and locks. Suffix SHA-1 as well as SHA-256 so neither can reuse an ambiguous legacy archive or a directory already populated with the wrong hash. Keep this handling in `gix-testtools` instead of requiring hash-specific names from callers. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra <codex@openai.com>
<!-- agent --> The `internal-tools` coverage configuration test passed with `cargo test` but failed under `just nextest`: Nextest supplies `NEXTEST_PROFILE=default`, which the coverage helper correctly treats as an explicitly selected profile. The test expected no profile selection and inherited its runner's configuration instead. Run this test in an isolated subprocess and clear `NEXTEST_PROFILE` locally before exercising the default configuration. Explicit profile and custom script assertions keep their rejection covered. Assisted-by: GPT 6.0 Astra Co-authored-by: GPT 6.0 Astra <codex@openai.com>
f97f648 to
8f5b1d5
Compare
cdbaa15
into
GitoxideLabs:main

Why
A dynamic-store handle probes its loaded indices front to back, biggest first. On a hit,
try_find(),contains(),location_by_oid()andtry_header()callsnapshot.indices.swap(0, idx), which moves the previous front index into the slot the hit came from. Lookups that alternate between a few indices then keep finding them far back:Motivation: tree diffs and object lookups in repositories with many packs, such as after many fetches without a repack.
The swap came in with 6525847 in 2021, after the experiment in 2d6960f found reordering "isn't really faster at all". That experiment ran
contains()over every object in pack-iteration order, so consecutive lookups hit the same pack. After the first hit in a pack, any policy that moves it to the front probes one index, so that access pattern can't show what alternating between packs costs.flowchart LR subgraph swap["Before: swap with the front"] direction TB s0["A B C D E"] -- "find in E: 5 probes" --> s1["E B C D A"] s1 -- "find in A: 5 probes" --> s2["A B C D E"] s2 -- "find in E: 5 probes" --> s3["E B C D A"] end subgraph mtf["After: move to the front"] direction TB m0["A B C D E"] -- "find in E: 5 probes" --> m1["E A B C D"] m1 -- "find in A: 2 probes" --> m2["A E B C D"] m2 -- "find in E: 2 probes" --> m3["E A B C D"] endWhat changed
Snapshot::move_to_front()replaces the swap at all four sites. The index that answered moves to the front, and the ones it passed move back by one slot, so recently used indices stay near the front.rotate_right()madecontains()2–4% slower than the swap, because of itsmemmove()call. Adjacent swaps make it faster instead. Further back,rotate_right()is cheaper.location_by_oid()result and copies entries by pack id, so another copy changes at most which bytes are reused, not whether the pack is valid.Trade-off
Lookups that visit each pack once, in turn, probe more than before, as the indices that answered pile up at the front. A commit walk without a commit-graph, over one pack per commit range, does that: with 300 packs it is 12% slower (75.6 → 109.8 probes per commit). With 50 packs the difference is about 1%, and with a commit-graph the walk doesn't look up commits at all.
Tests
a_hit_moves_its_index_to_the_front_and_keeps_the_others_in_orderuses the newmake_overlapping_packs.sh: ten packs of decreasing size that share some blobs, so which pack answers for a blob shows the order of the indices. For a hit in slot 2 (adjacent swaps) and in slot 9 (rotate_right()), it checks through each of the four lookups that the index that answered comes first, and the previous front index second.just qa --stage lint --stage tests --stage doctests --stage docspasses. It ran fmt, clippy, the docs and the doctests, the native tests (5,456), and 86 feature cases (9,655 tests), which includegix-odbwith SHA-1, SHA-256 andparallel.Benchmark (synthetic)
gix, each on a fresh handle after all indices were loaded:contains()andfind_object()for every object of the newest tree, in breadth-first order;mainand this commit that took turns going first. The machine was an Apple M4 Pro, with a load average of 6–9. A second measurement, with both orders behind a runtime switch in one binary and the order alternating every run, agreed within a few percent.contains(), every object of the newest treecontains(), every object of the newest treefind_object(), every object of the newest treecontains(), every object of the newest treefind_object(), every object of the newest treecontains(), every object of the newest treefind_object(), every object of the newest treecontains(), every object of the newest treecontains(), every object of the newest treeWith one index, whether one pack or a multi-pack-index over all of them, the order never changes, so differences in those rows are noise. With a commit-graph, the commit walk makes no lookups in any layout.
This change and its description were prepared with the help of an AI coding agent.