Skip to content

Two Config ceilings are preallocations, not ceilings: WorkPort's ring and the buffer pool cost 4.2 MiB in an idle Loop #88

Description

@proggeramlug

Problem

slots.rs opens with the principle this project already committed to:

Index-addressed storage that allocates in pages, so a capacity ceiling costs nothing until it is used. […] Building every slot at construction makes that number a preallocation instead of a ceiling, which forces hosts to choose between refusing work and paying for a loop they mostly do not use.

turnloop#75 applied that to the handle table. Two other structures sized from Config never got the treatment, and together they are 4.2 MiB of resident memory in an idle Loop.

Measurement

One Loop per process (RSS is a high-water mark, so building several in one process makes every later row inherit the earlier ones — the first version of this probe did exactly that and read as noise). macOS/aarch64, turnloop 0.1.0-alpha.5, RSS delta across Loop::new:

config RSS delta vs baseline
Perry's net profile (max_handles 65 536, max_operations 32 768, pooled_buffers 64 × 16 KiB, post_capacity 256) 4 800 KiB
max_operations 32 768 → 1 024 1 648 KiB −3 152 KiB
max_operations 32 768 → 256 1 504 KiB −3 296 KiB
pooled_buffers 64 → 0 3 792 KiB −1 008 KiB
max_handles 65 536 → 1 024 4 880 KiB −0 (noise)
post_capacity 256 → 16 4 816 KiB −0 (noise)
all four minimised 496 KiB −4 304 KiB

max_handles costs nothing, exactly as #75 intended. The other two are the whole floor.

Cause 1 — WorkPort's ring is sized by max_operations (≈3.15 MiB)

driver.rs builds WorkPort::new(config.max_operations, …), and WorkPort::new builds Queue::new(capacity.max(2).next_power_of_two()). Queue::new collect()s every slot and writes state: AtomicUsize::new(0) into each one, so the pages are resident, not merely reserved.

The deeper point is that this ring should not be sized by max_operations at all. max_operations is the ceiling on outstanding I/O operations — a server holding 8 000 idle connections with a read armed on each legitimately needs it large. WorkPort carries results from the blocking pool, whose in-flight count is bounded by the pool's thread count, typically ncpu. A 32 768-slot result ring for a pool that can have ~8–16 jobs outstanding is pure waste, and it forces exactly the choice the slots.rs header says hosts should not have to make: Perry either accepts a connection ceiling or pays 3 MiB in every process that starts a loop.

Suggested fix: give the work port its own capacity — a separate Config field, or derive it from the blocking pool's thread count — rather than reusing max_operations. The ring can stay eagerly allocated (a turn must not allocate); it just needs to be sized by the thing it actually bounds.

Cause 2 — the buffer pool is preallocated (≈1.0 MiB)

pooled_buffers × pooled_buffer_size is materialised at construction: 64 × 16 KiB = 1 024 KiB, and the measurement shows 1 008 KiB. For a loop that never does I/O, that is a megabyte of resident memory for buffers nothing will lease.

Suggested fix: grow the pool on demand up to pooled_buffers instead of preallocating it. That keeps the DESIGN §10 rule 1 property — steady state allocates nothing, once the working set is warm — while making the ceiling a ceiling. It is the same trade #75 made for handles.

Why this matters concretely

Perry is measuring turnloop against its previous tokio-based wait driver. At 8 000 idle keep-alive connections turnloop wins decisively — 13 191 bytes per connection vs 30 894, −57.3 % — but it starts 3.61 MiB above tokio at zero connections, so the two arms only break even at ~214 connections. Cause 1 alone is 3.15 MiB of that 3.61 MiB; the two together turn the one remaining memory regression into a win at every connection count.

Neither fix changes any contract: max_operations keeps meaning what it means, the buffer pool keeps its ceiling, and steady-state allocation behaviour is unchanged.

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