Skip to content

perf(gix-odb): move the index that answers a lookup to the front - #3186

Merged
Sebastian Thiel (Byron) merged 6 commits into
GitoxideLabs:mainfrom
MarkBlum-Shopify:gix-odb-move-to-front
Nov 10, 2026
Merged

Sebastian Thiel (Byron) merged 6 commits into
GitoxideLabs:mainfrom
MarkBlum-Shopify:gix-odb-move-to-front

Conversation

@MarkBlum-Shopify

Copy link
Copy Markdown
Contributor

Why

A dynamic-store handle probes its loaded indices front to back, biggest first. On a hit, try_find(), contains(), location_by_oid() and try_header() call snapshot.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:

  • A tree diff reads the old and the new side of each changed directory in turn. With many packs, the two sides are often in different ones.
  • Most objects of a tree often live in one big pack, and the rest in many small ones. Every hit in a small pack swaps the big one away, and the next lookup probes up to wherever it went.

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"]
  end
Loading

What 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.

  • In the first 8 slots, it moves the index with adjacent swaps. With 4 packs, plain rotate_right() made contains() 2–4% slower than the swap, because of its memmove() call. Adjacent swaps make it faster instead. Further back, rotate_right() is cheaper.
  • 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, and which copy answered already depended on the handle's lookup history. Pack generation keeps the 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.
  • There is no API change.

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

  • New test. a_hit_moves_its_index_to_the_front_and_keeps_the_others_in_order uses the new make_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.
  • Mutations. Each of these fails the test:
    • reverting any one of the four call sites to the swap (the failure names that lookup);
    • a swap instead of the adjacent swaps, or instead of the rotation;
    • rotating one slot short, or stopping one adjacent swap short;
    • not reordering at all.
  • Existing suites. just qa --stage lint --stage tests --stage doctests --stage docs passes. It ran fmt, clippy, the docs and the doctests, the native tests (5,456), and 86 feature cases (9,655 tests), which include gix-odb with SHA-1, SHA-256 and parallel.

Benchmark (synthetic)

  • Repository. A generated history: a base commit with 500 directories of 8 files each, then 600 commits that each change 4 files in random directories, 12,785 objects in all.
  • Layouts. The same objects in 1 pack; in 4 packs of shrinking commit ranges (up to commit 400, 550, 590 and 600); or in 50 or 300 packs, the base commit alone and then consecutive commit ranges. Also 300 packs with a multi-pack-index over all of them, or over the oldest 250.
  • Workloads, through gix, each on a fresh handle after all indices were loaded:
    • a tree diff without rename tracking, from the oldest to the newest commit;
    • 100 diffs from each commit to its parent;
    • contains() and find_object() for every object of the newest tree, in breadth-first order;
    • a commit walk from the newest commit.
  • Measurement. A temporary counter, not part of this change, counted the probes. The times are medians of 11 rounds of 31 runs each, on release builds of main and 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.
layout workload lookups probes per lookup median time
1 pack diff, oldest to newest commit 1,048 1.0 → 1.0 2.13 ms → 2.12 ms (±0%)
1 pack contains(), every object of the newest tree 4,526 1.0 → 1.0 206 µs → 204 µs (−1%)
4 packs diff, oldest to newest commit 1,048 2.3 → 2.1 1.27 ms → 1.25 ms (−2%)
4 packs 100 diffs, each commit to its parent 1,954 1.6 → 1.4 4.71 ms → 4.67 ms (−1%)
4 packs contains(), every object of the newest tree 4,526 1.7 → 1.5 215 µs → 201 µs (−7%)
4 packs find_object(), every object of the newest tree 4,526 1.7 → 1.5 2.23 ms → 2.23 ms (±0%)
4 packs commit walk, no commit-graph 601 1.0 → 1.0 862 µs → 858 µs (±0%)
50 packs diff, oldest to newest commit 1,048 23.9 → 8.5 1.30 ms → 1.11 ms (−15%)
50 packs 100 diffs, each commit to its parent 1,954 15.3 → 4.6 3.11 ms → 2.79 ms (−10%)
50 packs contains(), every object of the newest tree 4,526 19.1 → 13.0 1.10 ms → 831 µs (−25%)
50 packs find_object(), every object of the newest tree 4,526 19.1 → 13.0 3.05 ms → 2.74 ms (−10%)
50 packs commit walk, no commit-graph 601 3.1 → 4.0 857 µs → 866 µs (+1%)
300 packs diff, oldest to newest commit 1,048 141.5 → 49.7 1.93 ms → 1.35 ms (−30%)
300 packs 100 diffs, each commit to its parent 1,954 112.9 → 28.0 4.17 ms → 3.03 ms (−27%)
300 packs contains(), every object of the newest tree 4,526 108.7 → 75.7 2.41 ms → 1.90 ms (−21%)
300 packs find_object(), every object of the newest tree 4,526 108.7 → 75.7 5.10 ms → 4.32 ms (−15%)
300 packs commit walk, no commit-graph 601 75.6 → 109.8 1.16 ms → 1.30 ms (+12%)
300 packs, all in a multi-pack-index diff, oldest to newest commit 1,048 1.0 → 1.0 1.02 ms → 1.02 ms (±0%)
300 packs, all in a multi-pack-index contains(), every object of the newest tree 4,526 1.0 → 1.0 206 µs → 201 µs (−2%)
300 packs, 250 in a multi-pack-index diff, oldest to newest commit 1,048 15.8 → 8.4 1.12 ms → 1.07 ms (−4%)
300 packs, 250 in a multi-pack-index 100 diffs, each commit to its parent 1,954 14.6 → 4.9 2.93 ms → 2.76 ms (−6%)
300 packs, 250 in a multi-pack-index contains(), every object of the newest tree 4,526 7.3 → 5.0 318 µs → 286 µs (−10%)
300 packs, 250 in a multi-pack-index commit walk, no commit-graph 601 3.1 → 4.2 889 µs → 888 µs (±0%)

With 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.

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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-09T14:38:32.404796Z b424ad5 PR opened
ℹ️ About Codex in GitHub

Your 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread gix-odb/src/store_impls/dynamic/find.rs Resolved
…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread gix-odb/src/store_impls/dynamic/find.rs Resolved
…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>
- reduce command invocations and amount of files in the pack fixture, and
  refactor tests a bit.

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>
@Byron
Sebastian Thiel (Byron) merged commit cdbaa15 into GitoxideLabs:main Nov 10, 2026
44 of 53 checks passed
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.

3 participants