Allocate a ring's slots zeroed instead of writing every one (#88) - #89
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthrough
ChangesAllocation and storage changes
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The allocation changes preserve pool and queue capacity behavior while reducing idle memory use. No actionable merge-blocking risk remains. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Queue::new built each slot with map/collect, which writes state into all of them and makes the whole ring resident at construction. WorkPort sizes its ring from max_operations, and a host that legitimately sets that to 32 768 — one armed read per connection, for 32k connections — paid 3.25 MiB of resident memory in a process that may never submit a single blocking job. The capacity CANNOT simply be reduced: every blocking job holds a core operation credit until its result is delivered, so undelivered results really can reach max_operations, and push is an assert!. Sizing the ring by the pool's thread count would put a panic in a shipped binary; #88's original suggestion, mine, was wrong about this and is corrected there. This does not touch the capacity. It observes that Slot's empty state IS the all-zero bit pattern — state starts at 0, value is MaybeUninit — so the slots can come from alloc_zeroed with no writes at all, and a zeroed allocation that large is fresh pages the OS faults in lazily. Every bound that depends on capacity, the blocking pool's cannot-overflow invariant included, is unchanged; only the pages the ring has actually used are resident. Nothing is allocated at run time, so the zero-allocation operation contracts are untouched. Measured with a one-Loop-per-process probe (RSS is a high-water mark, so building several in one process makes every later reading inherit the earlier ones), RSS delta across Loop::new: a 64 x 16 KiB / 32 768-op host profile 4800 KiB -> 1472 KiB (-3.25 MiB) the same with pooled_buffers = 0 3792 KiB -> 448 KiB loom keeps the per-slot construction, since its AtomicUsize and UnsafeCell are instrumented types whose representation is not all-zero. Two tests cover the consequences rather than the argument: every slot usable, the bound still exactly at capacity, no unwritten slot readable as a value, and a partly filled ring dropping exactly its own values and not 1024 zero slots. Sabotage-checked by filling the allocation with 0xAB, which fails them and four existing tests. Refs #88.
1a30fa9 to
fcc8324
Compare
Refs #88. Does not close it — see "what is left" below.
The problem
WorkPortsizes its ring frommax_operations, andQueue::newbuilt each slot withmap/collect, which writesstateinto all of them. A host that legitimately setsmax_operationsto 32 768 — one armed read per connection, for 32k connections — paid 3.25 MiB of resident memory in a process that may never submit a single blocking job.What this does not do, and a correction
The capacity cannot simply be reduced, and my original analysis in #88 was wrong about this.
blocking.rssays so at the push site:A full ring is an
assert!, not a refusal. Every blocking job holds a core operation credit until its result is delivered, so undelivered results really can reachmax_operations. Sizing the ring by the pool's thread count, as I suggested in the issue, would put a panic in a shipped binary. That is corrected on the issue.What it does instead
It leaves the capacity alone and observes that
Slot's empty state is the all-zero bit pattern —statestarts at 0,valueisMaybeUninitfor which every pattern is valid. So the slots can come fromalloc_zeroedwith no writes at all, and a zeroed allocation that large is fresh pages the OS faults in lazily.Every bound that depends on capacity — the cannot-overflow invariant included — is unchanged. Only the pages the ring has actually used are resident. Nothing is allocated at run time, so the zero-allocation operation contracts are untouched.
loomkeeps per-slot construction, since itsAtomicUsizeandUnsafeCellare instrumented types whose representation is not all-zero.Measured
One
Loopper process — RSS is a high-water mark, so building several in one process makes every later reading inherit the earlier ones. RSS delta acrossLoop::new:pooled_buffers = 0Evidence
Two new tests check consequences rather than the argument: every slot usable, the bound still exactly at capacity, no unwritten slot readable as a value, and a partly filled ring dropping exactly its own values and not 1024 zero slots.
Sabotage-checked: filling the allocation with
0xABinstead of zeros fails both new tests and four existing ones, so the zero-pattern assumption is load-bearing and covered.What is left, and why it is not here
pooled_buffersis still a preallocation —BufferPool::newbuilds and zeroes every buffer, which is 4 MiB at the default 256 × 16 KiB. I had a commit making buffers mint on demand, measured at a further −0.97 MiB, and dropped it: it moves an allocation into the first read, which fails twoturnloop-contractallocation tests (file_readiness_survives_pool_backpressure_without_allocations_or_spin,extra_child_descriptor_traffic_allocates_nothing_after_spawn). Those tests are right — "operations allocate nothing" is a contract this project advertises, and warming the pool in the tests to get around it would be weakening a real gate.The correct fix there is a different shape: one contiguous
alloc_zeroedslab for the whole pool instead ofcountseparatevec![0; size]. A single large zeroed allocation is mmap-backed and lazily faulted, so the buffers stay preallocated (contract intact) while costing no RSS until used. That needsBufLeaseto hold a slab offset rather than aVec<u8>— itsVecis already private, so the public API does not change — and it is unsafe aliasing code in a hot path, which deserves its own PR and its own review rather than being tacked onto this one.