Upstream report, ready to file: gix-odb calls sched_yield on every membership miss
FILED 2026-08-04: https://github.com/GitoxideLabs/gitoxide/issues/2878 (as Ignalina). An issue rather than a PR on purpose: the cost is measured, but whether the yield can go is a question for whoever owns the slot-map protocol.
Filed under the Ignalina account — same rule as .nornir/upstream-gix-base-size.md (filed 2026-08-03 as https://github.com/GitoxideLabs/gitoxide/issues/2868).
Found 2026-08-04 decomposing gunnar's have-lookup miss. gunnar is not blocked on it and carries no patch: it is ~260 ns on a path gunnar takes 16 times per negotiation, which is under 1 % of a fetch. It is filed because it is unconditional, it is on a pure-read path, and gitoxide's own source already carries a TODO on the loop in question.
Title
Handle::contains performs a sched_yield syscall on every miss, including under RefreshMode::Never
Body
A membership test that misses walks every loaded index, then every loose database, then asks load_one_index whether there is more to load. In steady state — every index already loaded, nothing being loaded concurrently — that lands in load_next_index's Err(_nothing_more_to_load) arm:
// gix-odb/src/store_impls/dynamic/load_index.rs
Err(_nothing_more_to_load) => {
// …
// TODO: potentially hot loop - could this be a condition variable?
// …
std::thread::yield_now();
while index.num_indices_currently_being_loaded.load(Ordering::SeqCst) != 0 {
std::thread::yield_now();
}
break 'retry_with_next_slot_index;
}
The first yield_now() is unconditional. It runs before anything has been observed about num_indices_currently_being_loaded, and the comment above it says why: it is a timing fix for the window in which another thread has taken a slot but not yet incremented the counter.
The consequence is that a steady-state lookup — no loading, no refreshing, no contention, RefreshMode::Never — issues a sched_yield(2) per miss.
Measured
A single-threaded process asking Handle::contains for 20 000 object ids that are not present, against a 12 328-object repository with one pack and no loose objects. strace -c -f:
| syscall | calls | per miss |
sched_yield |
20 001 | 1.00 |
statx |
20 046 (20 002 ENOENT) |
1.00 |
The same binary asked 12 328 ids that are present issues neither: both are miss-only.
Removing just the unconditional first yield_now() and keeping the wait loop, minimum of nine runs of 200 000 misses each, release build, x86-64:
| arm | ns per miss |
| stock | 1045.1 |
first yield_now() removed |
783.8 |
261 ns per miss, 25 % of the whole cost of a miss. (The statx is the loose database answering honestly and is not a defect — see the note below. With both gone the miss costs 197.9 ns, so the two syscalls together are 81 % of it.)
The question, rather than a patch
The wait loop is doing real work and should stay. The question is only about the yield_now() in front of it: once num_indices_currently_being_loaded has been observed to be zero, is the extra unconditional yield still buying anything, or could the arm be
while index.num_indices_currently_being_loaded.load(Ordering::SeqCst) != 0 {
std::thread::yield_now();
}
with the widened observation window achieved some other way — the condition variable the TODO already proposes, or a load-acquire pairing with the increment? This report deliberately does not claim the removal is safe: the comment describes a real race and the person who wrote it knows the slot-map protocol better than the person filing this. What is being reported is the cost, which is a syscall on the hottest read path gix-odb has, and the fact that it is paid by every caller of contains that misses, whether or not any loading is happening.
Environment
gix-odb0.83.0,gix-pack0.73.0,gix-hash0.26.0- Linux 6.x, x86-64
- reached through
gix_odb::Cache<Handle>withrefresh_never()and aset_pack_cache, i.e. the configuration a read-only server holds
The second half, which is NOT a defect: the loose-database statx
loose::Store::contains is:
// gix-odb/src/store_impls/loose/find.rs
pub fn contains(&self, id: &gix_hash::oid) -> bool {
debug_assert_eq!(self.object_hash, id.kind());
hash_path(id, self.path.clone()).is_file()
}
That is one PathBuf heap clone and one statx per miss per loose database, and it is correct: a loose object can appear at any time and there is no index to consult. MEASURED at 586 ns per miss, 56 % of the cost of a miss — the largest single term.
Two things could be said about it upstream and neither is filed here, because neither is a bug:
hash_path(id, self.path.clone())clones the rootPathBufon every call. A&Pathplus a reused scratch buffer would remove an allocation from the hot path without changing behaviour. Small, and worth someone's time only if the allocation shows up in their profile too.- A store that is known to hold no loose objects — an appliance that only ever writes packs, which is gunnar's case — pays this for nothing forever. There is no API to say so, and inventing one for a single consumer is not obviously right.
Recorded here so the next person to measure this path finds the 586 ns already named and does not spend the afternoon on it.