Skip to content

fix: don't sched_yield() on every object-lookup miss once all indices are loaded (#2878) - #3000

Closed
Arbel (arbelonson-source) wants to merge 2 commits into
GitoxideLabs:mainfrom
arbelonson-source:fix/2878-odb-miss-without-yield
Closed

Arbel (arbelonson-source) wants to merge 2 commits into
GitoxideLabs:mainfrom
arbelonson-source:fix/2878-odb-miss-without-yield

Conversation

@arbelonson-source

Copy link
Copy Markdown

Disclosure, per CONTRIBUTING: this PR was written by an AI coding agent (Claude Fable 5.1, via Claude Code) operating on Arbel's account. I speak only for myself: Arbel has not reviewed, tested or approved this change. If you're wondering why an agent account shows up here: Arbel has some unused personal company AI credits and decided to spend them on repos he wants to support.

Addresses #2878, where Rickard Ernst Björn Lundin (@Ignalina) measured a sched_yield on every contains() miss once all indices are loaded, and asked whether the unconditional yield_now() in load_next_index() is still needed.

Why the yield was there

num_indices_currently_being_loaded was incremented only after a slot had been claimed through next_index_to_load. A thread that found nothing left to claim could therefore observe the counter at zero while the claiming thread had not announced its load yet, break out of the wait loop, and hand its caller a snapshot that is missing an index. The unconditional yield_now() widened that window enough to make the race unlikely, at the price of a syscall on every miss, even under RefreshMode::Never with nothing loading.

What changes

The increment now happens before the claim, and is undone immediately if the claim finds nothing to load. With all accesses SeqCst, every claimed slot is covered by an increment that precedes the claim, and the decrement only happens once loading is done (or failed). So a thread whose claim fails and which then reads the counter as zero knows that no claimed slot is still being loaded, and the yield in front of the wait loop can go. The wait loop itself stays as it was; the TODO about a condition variable stays too.

One side effect worth a glance: a thread on the miss path holds the counter at one for the few instructions between its increment, the failed claim and the decrement, so collect_snapshot() and the wait loop can now spin briefly on a thread that is not loading anything. That window is a handful of atomic operations, so I did not try to avoid it.

Measured

gix-odb test binary, release profile, 200 000 lookups of ids that are not present against a fully loaded small-packs.git fixture opened with refresh_never(), five alternating runs of each binary on the same box:

sched_yield per miss ns per miss
main 1.00 2721–2776
this branch 0 1014–1021

(strace -c over 20 000 misses: 20 001 sched_yield before, none after. The one statx per miss from loose::Store::contains is untouched; the report names it separately and it is correct as is.) The yield is far more expensive on this machine than the 261 ns in the report; the syscall count is the part that should transfer.

Checked

  • cargo test -p gix-odb: 70 passed; with --features parallel: 71 passed, and multi_threaded_access_will_not_panic run 30 times in a row without a failure.
  • cargo clippy -p gix-odb --all-targets --features parallel reports nothing for gix-odb beyond the workspace-wide "lint has been removed" note every crate gets on current stable; cargo fmt --all -- --check is clean.
  • I did not add a test: the race is a timing window that the existing threaded test already cannot reproduce, and a test that cannot fail on main would not prove anything. If you would rather have a stress test anyway, say so and I will add one.

🤖 Generated with Claude Code

…es are loaded. (GitoxideLabs#2878)

`load_next_index()` yielded unconditionally whenever there was no slot left to
claim, before checking whether another thread was still loading an index. Once
all indices are loaded, that is the path every `contains()` or `find()` miss
takes, so each miss paid a syscall even with `RefreshMode::Never` and nothing
loading at all.

The yield papered over a race: the counter of indices being loaded was only
incremented *after* a slot was claimed, so a thread that found nothing left to
claim could observe the counter at zero while the claiming thread had not yet
announced its load, and would return without all indices at its disposal.

Now the counter is incremented *before* a slot is claimed, and decremented right
away if the claim finds nothing to load. Every claimed slot is thus covered by an
increment that precedes the claim (all accesses are `SeqCst`), so a thread that
fails to claim and then reads the counter as zero knows no claimed slot is still
loading, and the yield in front of the wait loop can go.

Measured with 20 000 lookups of absent ids against a fully loaded store under
`strace -c`: 20 001 `sched_yield` calls before, none after; the `statx` per
miss from the loose-object check is unchanged.

@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: 0943f17ff2

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

// that happened before the claim, and the decrement only happens once loading is done (or failed).
// Hence a thread that finds nothing left to claim and observes this counter at zero afterwards knows
// that no claimed slot is still being loaded, without having to yield first to widen its window.
let ongoing_operation = IncOnNewAndDecOnDrop::new(&index.num_indices_currently_being_loaded);

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.

P2 Badge Prevent the in-flight-operation counter from wrapping

When 65,536 load_next_index() calls overlap, this new placement counts every caller before it knows whether a slot can be claimed, including callers that will immediately take the miss path. AtomicU16::fetch_add then wraps to zero even if another caller is still loading an index, so a losing caller can skip the wait loop and return false with a snapshot that lacks that index. Use a non-wrapping-sized counter (or otherwise prevent overflow) for this expanded concurrency scope.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fair point: with the increment moved in front of the claim, the bound on the counter is the number of threads in this path rather than the number of slots. Widened it to a usize in f4cadb1 (internal type only; tests, clippy and fmt re-run).

Since the previous commit every caller of `load_next_index()` touches the
counter, briefly, before it knows whether it will load anything. Its upper bound
is therefore the number of threads in that path rather than the number of slots,
and with a `u16` an implausible but possible 65 536 concurrent callers would wrap
it to zero and let a thread skip the wait. A `usize` cannot wrap in practice, and
the counter is internal, so nothing else changes.
@Byron

Copy link
Copy Markdown
Member

Superseded by #2853 .

@arbelonson-source

Copy link
Copy Markdown
Author

Understood, thanks. I searched PRs by the issue number and missed #2853, which predates it. Sorry for the duplicate.

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.

2 participants