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/concurrency-design.md

What gunnar's CPU per request is doing under concurrency: localised

Opened 2026-08-04, against origin/master at 71b7bc5. Companion to .nornir/performance-design.md and .nornir/transport-encoding-design.md: nothing here edits either file. New entries use the same block format and continue the numbering at P-023, so they merge unchanged.


The answer, first

The bake-off measured gunnar's CPU per served request rising 2.54× between 1 and 32 concurrent clients (0.418 → 1.063 CPU-seconds) while gitea's fell (0.283 → 0.256), with gunnar's throughput peaking at 16 clients and regressing at 32. It could not localise the cause.

The cause is not in gunnar's request path. It is where gunnar's threads are allowed to run.

One binary, one year corpus (28 893 objects), one 8-CPU budget, smart HTTP, oden 2026-08-04. The only difference between the two arms is how that identical budget is applied:

8 CPUs applied as CPU-s per request, 1 → 32 clients
cpu.max quota only, 32 CPUs in the affinity mask 0.42 → 0.94
the same quota, affinity pinned to 8 CPUs 0.34 → 0.35

Pinned, the curve is flat. gunnar's per-request cost does not in fact grow with concurrency. P-004's "concurrency comes from serving many requests at about one core each" is correct, and this document does not overturn it — it names the one thing P-004 did not anticipate, in P-023 below.

The bake-off's condition is podman run --cpus 8 on a 32-core host, which is quota-without-cpuset, so the measured ramp is the top row. It is a real and very common deployment; it is simply not a statement about gunnar's request path.


How it was measured

gunnar serve --http-bind provisioned exactly as bench/examples/forge/mod.rs provisions it — private repository, principal, grant, and a personal access token on every request — in a systemd --user scope whose cpu.max is read back out of the kernel, not out of the request that set it. Clients are stock git 2.53.0.

Two guards, both seen red, the same two the bake-off's concurrent.rs carries and for the same reason — clients that all fail instantly produce a magnificent ops/sec:

red switch what it breaks which guard fired
concfail one client of each batch clones a repository that does not exist the per-client exit check: client-0(rc=128: repository not found)
concshallow one client of each batch clones --depth 1 the per-client object count: client-0 landed 602 object(s), the fixture holds 28893

Two instruments in this work were blind and were caught, and both are worth recording because both looked exactly like results:

  1. Context switches read from /proc/<pid>/status. That file reports the main thread only, and gunnar's main thread sleeps in the accept loop, so the counter read a constant 0 however hard the server worked. Reading /proc/<pid>/task/*/status instead then produced a negative delta, because threads that exit take their counts out of the sum — which is how the second version was caught too.
  2. A parser test that could not fail. Cpus_allowed_list on oden is the single range 0-31, so a version of count_cpu_list that counted ranges and silently dropped singletons passed on this box while answering 0 allowed CPUs for a server pinned to one core — the condition read backwards. The test now runs on literals, not on this host.

And one measurement condition worth stating plainly: oden's load ran 6.8 → 99 inside this session under other lanes' work. Every timing figure here is therefore from an interleaved A/B — the two arms alternate inside each rung and the order swaps every round — and the two absolute levels quoted for the same comparison (2.7× on a quiet box, 1.35× at load ~40) differ because a loaded box starves the clients and they press the server less hard. Counts (futex syscalls, objects, bytes) are quoted in preference to times wherever a count would answer the question, because a count is a property of the server and a millisecond on this box was a property of the box.


P-023 — One served request is three threads that hand work to each other, and under a CPU quota every hand-off crosses a core

So at N concurrent transfers the scheduler has roughly 3N runnable threads. Given 32 CPUs of affinity and 8 CPUs of quota it spreads them over all 32, and a hand-off between two threads on two different cores is a futex.

Measured, perf record on the serving process at 32 clients:

share of the server's CPU
native_queued_spin_lock_slowpath — the kernel's futex hash-bucket lock 40.6%
reached from futex_wait 22.0%
reached from futex_wake 17.4%
zlib_rs::inflate (inflate + inflate_fast + inflate_table) 32.4%

Futex syscalls per clone scale with the object count, not with bytes and not with writes — which is what says the traffic is the per-object pipeline and not the socket:

clone objects pack bytes futex/clone writes/clone
full 28 893 25.4 MB 7 020 208
--depth 1 602 1.14 MB 132 51
ratio 48.0× 22.3× 53.2× 4.1×

With frame pointers the single largest call site resolves to one line: iter_from_counts's worker → entry_by_location → to_vec → Vec::try_allocate_in → glibc's lock. One heap allocation per object served, on a thread that is not the one the transfer runs on. That is ~50% of all futex calls; the tokio blocking pool's own task hand-off is most of the rest.

And the per-object hand-off vanishing did not buy anything measurable, which is the honest half. Process CPU per 12 186-object clone is flat (min of 9, taskset-pinned: 79.0 ms both), the 32-client wall moved 1.4% against a ~1.4× spread at loadavg 141, and wire bytes are byte-identical on all 19 corpus repositories × {clone, negotiated fetch}, both hash kinds — 38/38. The hypothesis this entry supports — that the cross-thread to_vec/free pair was buying the futex traffic and therefore the throughput gap — is not confirmed: removing the cross-thread half of it left CPU where it was. What the change is worth is P-004 becoming true of the code, and every per-thread instrument in the repository becoming honest (see below).

The resolve phase — which resolves every count to a pack location, and which P-025 found was inflating the whole repository — is already inline at Some(1), because in_parallel_if takes the serial branch when num_threads(thread_limit) is not > 1. MEASURED: the whole of P-025's saving landed on the calling thread's stage tally (pack_write exclusive CPU 61.84 → 16.73 ms on a 12 186-object clone), which it could not have done had that phase been on a worker. P-020's Some(1) does more than this entry credited it with.

Cost of the two threads, MEASURED oden 2026-08-04 under taskset -c 0-7: 64 µs of process CPU per spawn+join pair-of-two — 0.08% of a 12 186-object clone, ~7.1% of the smallest real fetch in the corpus (bifrost, 69 objects, 0.90 ms). Removing them from gunnar's side means not calling iter_from_counts at all and re-implementing ~90 lines of gix's counts sort, counts_range_by_pack_id index and thin-base resolution — which must reproduce gix's ordering exactly or the wire bytes move. Not done, on LAW 5 and on that ratio. The upstream patch, which costs gitoxide nothing because the serial Stepwise is already in the file, is written out ready to file in .nornir/upstream-gix-stepwise-serial.md.

The per-object hand-off this entry names as the largest futex call site — entry_by_location → to_vec — is the entry phase and survives P-025 untouched. It is now the largest single item left on the read path.

Superseded 2026-08-05, in both halves. The hand-off is gone (the entry phase is inline), and the to_vec that remains is not the largest item left on the read path. Profiled with perf record -F 4999 over 25 served clones of the corpus's nornir.git, now that single-threading makes the profile attributable:

share of the served clone
zlib_rs::deflate (longest_match + deflate_medium + flush_block_only) ~44%
zlib_rs::inflate (inflate_fast + inflate + inflate_table) ~9.8%
sha1::compress (the trailer) 5.9%
__memmove + kernel_init_pages + __memset — the whole to_vec story ~8%

Deflate on a path whose entire purpose is to copy already-deflated entries is the finding. MEASURED with packwrite-probe: a clone of that repository copies 12 150 of 12 186 entries and re-deflates 36 — 0.30% of the objects. Those 36 are deltas whose base is inside the pack but outside the clone's selected object set, which Entry::from_pack_entry cannot re-point and therefore explodes into a whole object.

A/B against the same 12 186 objects served out of a repository repacked so the selection is the pack (git clone --single-branch + git repack -adq):

corpus nornir.git tight repack
re-deflated 36 12
clone CPU 85.25 ms 60.10 ms
wire bytes 8 909 624 8 709 587

24 objects — 0.20% of the clone — were 29.5% of its CPU. That is roughly four times the entire allocation-and-copy budget the to_vec represents, and it is the next thing to take on the read path. The fix is the one git already makes and gix's Mode::PackCopyAndBaseObjects is named for: when a selected delta's base is in the pack but unselected, add the base to the selection rather than expand the delta. It is gunnar's own gunnar_wire::graph that chooses the set, so this needs no gix fork — but it changes wire bytes by construction, so it is a deliberate improvement to be measured against stock git, not a byte-neutral one.


P-024 — The smart-HTTP response body took 16 KiB bites out of a 256 KiB window, so the window controlled nothing

futex syscalls per clone peak RSS
16 KiB frame 10 936 · 10 144 · 10 708 · 10 788 176 MB
window-sized frame 9 032 · 8 268 · 7 307 · 7 882 193 MB

−25% syscalls for +10% peak RSS, four rounds each, and the two sets do not overlap. Both halves are the same trade in opposite directions, which is why it is one number: an operator for whom the memory ceiling binds can now lower stream_window and actually get fewer bytes in flight.

No throughput claim is made. Against a box whose load moved 6.8 → 99 the effect on ops/sec was inside the noise, and it is shipped as a defect fixed and not as a win.