Skip to content

Allocate a ring's slots zeroed instead of writing every one (#88) - #89

Merged
proggeramlug merged 1 commit into
mainfrom
fix/88-ceilings-not-preallocations
Sep 17, 2026
Merged

proggeramlug merged 1 commit into
mainfrom
fix/88-ceilings-not-preallocations

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Refs #88. Does not close it — see "what is left" below.

The problem

WorkPort sizes its ring from max_operations, and Queue::new built each slot with map/collect, which writes state into all of them. A host that legitimately sets max_operations 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.

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.rs says so at the push site:

// One reserved core operation credit per job remains held until delivery.
// Thus this separate queue (>= max_operations) cannot overflow.
assert!(self.queue.push(result).is_ok(), "blocking completion credit invariant");

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 reach max_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 — state starts at 0, value is MaybeUninit for which every pattern is valid. 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 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.

loom keeps per-slot construction, since its AtomicUsize and UnsafeCell are instrumented types whose representation is not all-zero.

Measured

One Loop per process — 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:

config before after
a 64 × 16 KiB / 32 768-op host profile 4 800 KiB 1 472 KiB
the same with pooled_buffers = 0 3 792 KiB 448 KiB

Evidence

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 0xAB instead 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_buffers is still a preallocation — BufferPool::new builds 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 two turnloop-contract allocation 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_zeroed slab for the whole pool instead of count separate vec![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 needs BufLease to hold a slab offset rather than a Vec<u8> — its Vec is 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.

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 39b5a527-6695-4489-9aa9-e46faefcb5dc

📥 Commits

Reviewing files that changed from the base of the PR and between bbe0105 and 1a30fa9.

📒 Files selected for processing (4)
  • crates/turnloop/src/buffer.rs
  • crates/turnloop/src/portable_tests.rs
  • crates/turnloop/src/queue.rs
  • crates/turnloop/src/sync.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

BufferPool now allocates buffers on demand up to its configured ceiling. Non-loom Queue storage now uses zeroed slots, while loom builds retain explicit construction. Tests cover buffer reuse and queue capacity, reuse, delivery, and drop behavior.

Changes

Allocation and storage changes

Layer / File(s) Summary
Lazy BufferPool allocation
crates/turnloop/src/buffer.rs, crates/turnloop/src/portable_tests.rs
BufferPool::new no longer preallocates buffers. acquire mints buffers until the ceiling, and the test warms the pool before checking steady-state reuse.
Zeroed queue storage and validation
crates/turnloop/src/queue.rs, crates/turnloop/src/sync.rs
Non-loom queues allocate zeroed slots, while loom queues keep explicit construction. Tests cover untouched slots, capacity, reuse, delivery, and drops. The non-loom UnsafeCell::new method is marked unused.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 1a30f

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)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #88 requires lazy buffer-pool growth, preserved limits, reuse, exhaustion, and backpressure. BufferPool now mints buffers in acquire up to the shared unminted ceiling and reuses released b…
Out of Scope Changes check ✅ Passed The changes stay within issue #88. The sync.rs annotation supports the zeroed-slot implementation, and the added tests verify the required buffer-pool and ring behavior. No unrelated production beha…
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 4 files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the ring-slot zero-initialization change, which is a primary part of the pull request. It is concise, specific, and related to the stated objectives.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/88-ceilings-not-preallocations

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.
@proggeramlug
proggeramlug force-pushed the fix/88-ceilings-not-preallocations branch from 1a30fa9 to fcc8324 Compare September 17, 2026 07:44
@proggeramlug proggeramlug changed the title Make pooled_buffers and a ring's slots ceilings rather than preallocations (#88) Allocate a ring's slots zeroed instead of writing every one (#88) Sep 17, 2026
@proggeramlug
proggeramlug merged commit 21c2897 into main Sep 17, 2026
39 checks passed
@proggeramlug
proggeramlug deleted the fix/88-ceilings-not-preallocations branch September 17, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant