Skip to content

pooled_buffers is still a preallocation: one zeroed slab instead of N zeroed Vecs #90

Description

@proggeramlug

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".

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions