Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/storage-git-gix/parked-gix/.nornir/upstream-gix-odb-miss-yield.md

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


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:

Recorded here so the next person to measure this path finds the 586 ns already named and does not spend the afternoon on it.