Split out of #88, which is otherwise addressed by #89.
Problem
BufferPool::new builds and zeroes every buffer at construction:
inner: Rc::new(RefCell::new((0..count).map(|_| vec![0; size]).collect())),
So a Loop that has not opened a socket still pays for its whole read pool — 4 MiB at the default 256 × 16 KiB, 1 MiB at a 64-buffer host profile. Measured as an RSS delta across Loop::new, one Loop per process: setting pooled_buffers = 0 saves 1 008 KiB on a 64 × 16 KiB profile, so those pages really are resident.
It is the same shape slots.rs already names — "building every slot at construction makes that number a preallocation instead of a ceiling" — and the same one #75 fixed for handles and #89 fixed for ring slots.
The obvious fix is wrong
Minting buffers on demand up to count fails the allocation contract, and correctly so. I tried it (measured −0.97 MiB) and dropped it: it moves an allocation into the first read, which fails
turnloop-contract/tests/allocations.rs::file_readiness_survives_pool_backpressure_without_allocations_or_spin ("lease return storage", left: 1, right: 0)
turnloop-contract/tests/allocations.rs::extra_child_descriptor_traffic_allocates_nothing_after_spawn (left: 2, right: 0)
"Operations allocate nothing" is a contract this project advertises, and warming the pool inside those tests to get past it would be weakening a real gate rather than fixing anything.
Suggested fix
Keep the buffers preallocated — contract intact — but allocate the pool as one contiguous alloc_zeroed slab of count * size bytes instead of count separate vec![0; size].
The distinction that makes this work is allocator behaviour, not Rust semantics: vec![0u8; 16384] does reach alloc_zeroed, but a 16 KiB request is served from malloc's bins and has to be memset, so it is resident immediately. A single count * size request (1 MiB, 4 MiB) is large enough to be served by fresh mmap, which is zero-filled lazily by the OS — so the slab costs address space at construction and resident pages only as buffers are actually used.
Nothing is allocated at run time, so every allocation contract keeps passing unchanged.
Cost
BufLease would hold a slab offset rather than a Vec<u8>. Its data: Option<Vec<u8>> is already private and there is no into_vec, so the public API (as_slice, writable, set_len, release) does not change — but the pool and lease become unsafe aliasing code over a shared slab, on a hot path. That is why it is its own issue rather than part of #89: it wants its own review, and probably a loom model or at least a test that two live leases hand out disjoint, non-overlapping regions.
Why it is worth it
Perry's A/B against its previous tokio wait driver has turnloop winning every memory row except the idle floor, where it started 3.61 MiB above tokio. #89 takes that to +0.36 MiB. This last megabyte is what flips the remaining row, and it is the difference between "turnloop wins on memory above ~22 connections" and "turnloop wins on memory, full stop".
Split out of #88, which is otherwise addressed by #89.
Problem
BufferPool::newbuilds and zeroes every buffer at construction:So a
Loopthat has not opened a socket still pays for its whole read pool — 4 MiB at the default 256 × 16 KiB, 1 MiB at a 64-buffer host profile. Measured as an RSS delta acrossLoop::new, oneLoopper process: settingpooled_buffers = 0saves 1 008 KiB on a 64 × 16 KiB profile, so those pages really are resident.It is the same shape
slots.rsalready names — "building every slot at construction makes that number a preallocation instead of a ceiling" — and the same one #75 fixed for handles and #89 fixed for ring slots.The obvious fix is wrong
Minting buffers on demand up to
countfails the allocation contract, and correctly so. I tried it (measured −0.97 MiB) and dropped it: it moves an allocation into the first read, which failsturnloop-contract/tests/allocations.rs::file_readiness_survives_pool_backpressure_without_allocations_or_spin("lease return storage", left: 1, right: 0)turnloop-contract/tests/allocations.rs::extra_child_descriptor_traffic_allocates_nothing_after_spawn(left: 2, right: 0)"Operations allocate nothing" is a contract this project advertises, and warming the pool inside those tests to get past it would be weakening a real gate rather than fixing anything.
Suggested fix
Keep the buffers preallocated — contract intact — but allocate the pool as one contiguous
alloc_zeroedslab ofcount * sizebytes instead ofcountseparatevec![0; size].The distinction that makes this work is allocator behaviour, not Rust semantics:
vec![0u8; 16384]does reachalloc_zeroed, but a 16 KiB request is served from malloc's bins and has to be memset, so it is resident immediately. A singlecount * sizerequest (1 MiB, 4 MiB) is large enough to be served by freshmmap, which is zero-filled lazily by the OS — so the slab costs address space at construction and resident pages only as buffers are actually used.Nothing is allocated at run time, so every allocation contract keeps passing unchanged.
Cost
BufLeasewould hold a slab offset rather than aVec<u8>. Itsdata: Option<Vec<u8>>is already private and there is nointo_vec, so the public API (as_slice,writable,set_len,release) does not change — but the pool and lease become unsafe aliasing code over a shared slab, on a hot path. That is why it is its own issue rather than part of #89: it wants its own review, and probably aloommodel or at least a test that two live leases hand out disjoint, non-overlapping regions.Why it is worth it
Perry's A/B against its previous tokio wait driver has turnloop winning every memory row except the idle floor, where it started 3.61 MiB above tokio. #89 takes that to +0.36 MiB. This last megabyte is what flips the remaining row, and it is the difference between "turnloop wins on memory above ~22 connections" and "turnloop wins on memory, full stop".