feat(plurality): MultiPool, an object pool for values of any type - #660
feat(plurality): MultiPool, an object pool for values of any type#660Sander Saares (sandersaares) wants to merge 45 commits into
Conversation
Adds a heterogeneous object pool that accepts values of any type, dropping the element type parameter from the pool and moving it to the allocation. DESIGN.md gains a chapter covering the user-visible contract: the router over per-layout pools, exact-layout routing, byte-target chunk sizing, the two growth caps, the relaxed Send bound, the two-tier introspection surface, and a feature comparison against infinity_pool's blind pool family. IMPLEMENTATION.md is new and covers the internals: extracting slot geometry behind a provider trait so the typed pool keeps its compile-time constants, the crate-private LayoutPool, the router's reentrancy protocol, fallible metadata allocation, the cost model, benchmarks, verification and staging. The design rests on plurality's reclamation half already being layout-driven rather than type-driven: a handle recovers its slot, chunk and pool by arithmetic over the value's own size and alignment. The router is therefore consulted only on allocation and never on free, which keeps blind handles one pointer wide and destructor-reentrancy safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
…esign docs Distinguish exact layout from slot geometry where the blind pool's growth caps are described: routing is by exact layout, and geometry is a strictly coarser partition that never divides anything. Frame the pool as externalising its lock rather than lacking one. A caller-owned Mutex around allocation, with drops running unlocked, is the ordinary multithreaded deployment, and the comparison with infinity_pool now contrasts lock ownership rather than implying single-threaded use. State the Send bounds accurately: a pool object never owns a value, so pool mobility is independent of value mobility. The typed pool's T: Send bound is a conservative choice rather than a soundness requirement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
A pool object owns no values. Every safely reachable value is owned through a handle, the pool exposes no iteration or drain, and teardown deallocates chunks without ever reading or dropping element storage. A thread that receives a pool therefore has no route to a value another thread placed in it; it can only draw free slots, which hold nothing live. Thread mobility for values is carried by the handles, each of which already imposes its own bound. Pool<T, A> is consequently Send whenever A is, for any T. Pool remains !Sync, so allocation is still confined to one thread at a time. Add tests/send_bound_probe.rs, which holds this reasoning to account. A compile-time assertion rejects reintroduction of the bound, values record the thread that built them and assert in Drop that they were not destroyed elsewhere, and the scenarios move pools of a thread-bound type across thread boundaries while values, slot reuse, teardown, and a concurrent free-versus- allocate hand-off are exercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
The document is written in target-state voice throughout; a preamble that describes what the document is adds nothing and misleads about scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
…ents Restructure the design documentation as a high-level hub plus five area documents covering handles, concurrency, memory, allocation, and the blind pool. Behavioural statements corrected and added in the process: - The invariant list records that the pool object neither yields nor drops pooled values, which is what licenses a pool being Send on the strength of its allocator alone, and constrains future API accordingly. - The reentrancy invariant covers the pool's allocator as well as the global allocator, and scopes itself to allocator callbacks so that the unrestricted reentrancy of value destructors and construction closures stands unqualified. - The blind pool documents its per-layout clamping of chunk size and chunk cap, and that the effective figures are observable through per-layout queries. - The blind pool documents that its memory is monotonic per layout. - The layout cap is documented as optional and unbounded by default, and the memory bound is stated conditionally on both caps being set. - Blind-to-typed pool conversion is attributed to distinct instantiations with distinct teardown hooks and sizing policies rather than to differing slot geometry, which both forms derive identically. - The infinity_pool comparison names the version it describes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
…ocuments The implementation guide covers the whole crate: the module map, the core data structures, the slot lifecycle, chunk management, pointer recovery, the two reference counts, teardown, handles, the blind pool, performance and verification. It is organised as a hub plus one document per area. The blind-pool delivery sequence moves to TODO.md, where a plan belongs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
`!Sync` is the sole barrier keeping allocation on one thread at a time now that no `T: Send` bound stands behind it, so assert it. The concurrent probe shared a `&Pool` across threads through the `AssertSend` shim, which asserts `Sync` rather than `Send` and so exercised neither the relaxation nor a property the crate offers. Move the pool by value instead, which is what the relaxation licenses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
The pool and the handle must agree exactly on where a slot's refcount, index and chunk header sit, but the handle cannot ask the pool because finding the pool is the point of the walk. Both now evaluate the same formulas over the same size and alignment, so agreement is structural rather than maintained by hand in two places. A typed provider answers with compile-time constants and a runtime provider answers with stored fields, which is what will let one pool body serve a layout known only at run time. The typed provider carries a const cross-check against the compiler's own layout of the slot struct, forced from its accessors so no instantiation can route around it. Ref: docs/implementation/geometry.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
The pool body manages chunk memory without ever reading or dropping value storage, so it needs the element type only for its layout numbers. It now takes those from a geometry provider and carries no element type of its own, which is what lets the same body serve a layout known only at run time. The provider also owns the slot and header pointer arithmetic. The typed provider expresses it as indexing over the compiler's own slot array while the runtime provider multiplies out the stride; the const cross-check proves the two land on the same addresses, and the typed pool keeps the addressing modes it had. Instruction counts are unchanged across the benchmark suite. Ref: docs/implementation/geometry.md, docs/implementation/pool-body.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Share the slot machinery between pool flavours. `alloc_slot`, `grow` and the directory walk move onto `PoolInner<A, G>`, where they address slots through the geometry provider rather than through a `SlotCell<T>` type, and the occupancy helpers move onto `PoolCore`, which is the only state they touch. The typed pool keeps its own thin wrappers over both. `LayoutPool` is that body driven by `RuntimeGeometry`: a pool whose value layout is fixed at construction rather than by a type parameter. Its allocation entry point is unchecked, because its only caller selects it by matching layouts. Sizing is clamped rather than asserted -- one blind-pool-wide configuration meets many layouts -- and its metadata allocation is fallible, so a first allocation for an unseen layout can report failure instead of aborting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
`BlindPool` moves the element type from the pool to the allocation, so one pool object backs a heterogeneous working set. It routes each request by `Layout::of::<T>()` to a `LayoutPool` serving that exact layout, creating one on first sight, and layers the typed pool's full handle surface over that router. Chunks are sized by a byte target rather than a slot count, so layouts spanning several orders of magnitude commit comparable memory per growth step. Growth is bounded per layout and, optionally, in the number of layouts. Introspection splits into aggregate queries over the whole pool and per-layout queries named for a type; the latter report the effective sizing after clamping and never create a layout pool. The router's cold path releases control to user code at several points — a construction closure, a rejected value's destructor, `A::clone`, the global allocator — so it builds the new layout pool before touching the directory, reserves after constructing, re-scans and re-checks the cap, and publishes the pool before its key. No directory borrow is ever live across user code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Add the blind-pool benchmark rows to both harnesses: allocate-and-free against a pool holding one layout and against one holding sixteen, which separates the per-entry directory scan from the rest of the routing cost, plus a trait-object row that lines up with the owning fat-pointer comparison. Outline layout-pool creation behind a cold, non-generic call so that the allocation path pays only for the scan that finds an existing pool. Assert with the allocation tracker that a warmed blind pool reuses slots across a mix of layouts without touching the system allocator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Cover the blind pool: heterogeneous mixes, types sharing one layout, zero-sized and over-aligned values, coercion, handles outliving the pool, both capacity limits, the sizing clamps, allocator failure on the chunk and metadata paths, panic safety, and reentrancy at every point the cold path releases control — including from the allocator's own `Clone`. A chunk header's pool pointer frees the pool when the last handle outlives the pool object, so it cannot be derived from a `&self` borrow: such a pointer permits only reads and interior-mutable writes, and Miri rejects the deallocation. Each pool now records its own address at construction, from the pointer its allocation was created with. Generalise the allocator-failure message: it also reports the metadata allocation a first-seen layout needs, so naming chunks was wrong. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Cover the closure, uninitialized and pinned allocation entry points, which until now had no test reaching them, and assert that they all land in the one layout pool their layout selects. Route `free_slot_erased` through `SlotGeometry::header_of` rather than recomputing the header offset from the stride, removing the duplicated formula its own comment admitted to. Mark the two hand-written `Clone` impls that exist only because deriving would demand `A: Clone` as uninstrumented, since both forward to `Copy`. Move the blind-pool statistics assertions to `tests/stats.rs`, where the `stats` feature's tests live. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Kills the mutants that survived the blind pool's first pass and corrects the implementation notes that promised more than the code delivered. Three mutants survived because nothing exercised the paths they broke: the directory reservation's failure arm, a chunk cap of zero, and the layout pool's destructor. Each now has a test. The directory reservation is provoked by letting the layout pool's own allocation through and refusing the next one, which is the reservation, so the deny gate grows an allowance budget. The destructor is proven by an allocator that counts live bytes and must reach zero. A chunk cap of zero previously floored to one, which silently contradicted the typed pool, where zero means the pool can never allocate. It now means the same thing in both. The chunk-size clamp's lower bound is promoted from a debug assertion to a real one: a zero-slot chunk divides by zero and underflows the slot mask, which is not a debug-only concern. The geometry formulas are asserted against `Layout::extend` and `pad_to_align`, which is `core`'s own `repr(C)` field placement, over the same spread of types. Comparing the two providers against each other proved little, since they share the formulas; comparing the formulas against a second implementation of the placement rules proves the formulas. A property test drives three layouts through one blind pool and asserts that values drop exactly once, that the pool empties, and that no slot address is ever served for two different layouts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
There was a problem hiding this comment.
Pull request overview
Adds heterogeneous object pooling through BlindPool, sharing core allocation machinery with typed pools while preserving handle behavior and reclamation guarantees.
Changes:
- Adds layout-routed blind pools, builders, and runtime slot geometry.
- Fixes pool teardown pointer provenance and relaxes
Pool<T>’sSendbound. - Expands tests, benchmarks, performance reports, and architecture documentation.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
src/blind_pool.rs |
Implements layout routing and allocation APIs. |
src/blind_builder.rs |
Adds blind-pool configuration. |
src/layout_pool.rs |
Adds runtime-layout pool storage. |
src/geometry.rs |
Centralizes slot geometry calculations. |
src/pool.rs |
Generalizes pool internals and fixes teardown provenance. |
src/builder.rs |
Constructs typed geometry and records pool address. |
src/chunk.rs |
Delegates layout arithmetic to geometry providers. |
src/error.rs |
Generalizes allocator-failure wording. |
src/lib.rs |
Exports the new public API. |
tests/blind_pool.rs |
Covers blind-pool behavior and failure paths. |
tests/bolero_blind_pool.rs |
Adds heterogeneous property testing. |
tests/send_bound_probe.rs |
Exercises relaxed Send semantics. |
tests/stats.rs |
Tests aggregate blind-pool statistics. |
tests/smart_ptr.rs |
Pins the pool’s !Sync contract. |
tests/pool.rs |
Updates allocator error expectations. |
tests/alloc_tracking.rs |
Checks allocation-free steady-state reuse. |
benches/criterion/main.rs |
Registers Criterion blind-pool benchmarks. |
benches/criterion/ops.rs |
Defines Criterion workloads. |
benches/gungraun/linux.rs |
Registers instruction-count benchmarks. |
benches/gungraun/ops.rs |
Defines Gungraun workloads. |
scripts/perf_report.rs |
Adds blind-pool report rows. |
docs/PERF.md |
Records updated performance results. |
docs/TODO.md |
Documents potential follow-up work. |
docs/DESIGN.md |
Restructures the architecture overview. |
docs/IMPLEMENTATION.md |
Adds the implementation hub. |
docs/design/allocation.md |
Documents allocation and failure behavior. |
docs/design/blind-pool.md |
Describes the blind-pool design. |
docs/design/concurrency.md |
Describes concurrency guarantees. |
docs/design/handles.md |
Documents handle semantics. |
docs/design/memory.md |
Documents memory organization. |
docs/implementation/blind-pool.md |
Explains routing and reentrancy. |
docs/implementation/geometry.md |
Explains geometry derivation. |
docs/implementation/handles.md |
Describes handle implementation. |
docs/implementation/performance.md |
Documents the cost model. |
docs/implementation/pool-body.md |
Documents shared pool internals. |
docs/implementation/verification.md |
Describes verification strategy. |
README.md |
Regenerates crate-level documentation. |
CHANGELOG.md |
Records the feature and soundness fix. |
Suppressed comments (1)
crates/plurality/docs/implementation/handles.md:94
- This explanation is based on the nonexistent
_not_send_syncfield above. In the implementation, thePhantomData<&Pool<T, A>>marker both carries the lifetime/type parameters and deniesSendbecausePoolis!Sync.
`_pool` carries the borrow lifetime and mentions both type parameters, which a
struct must do for every parameter it declares. `_not_send_sync` denies `Send`
and `Sync` explicitly, using the same marker type `Rc` uses. The denial has to
be explicit: a bare `PhantomData<&'pool ()>` supplies the lifetime but is
`Send` and `Sync`, which would silently make the bound owner thread-mobile
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #660 +/- ##
========================================
Coverage 100.0% 100.0%
========================================
Files 503 508 +5
Lines 57407 58023 +616
========================================
+ Hits 57407 58023 +616
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Applies rustfmt and drops the spellings the workspace dictionary rejects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Nine claims in the guides described a design the code does not have. Each is corrected to what the implementation does. The typed geometry's compile-time check is forced from the constructor, which is the only way to obtain the type, not from each accessor. The layering diagram had `PoolInner`'s generic parameters reversed. The bound owner carries one phantom that both ties it to the pool borrow and denies `Send` and `Sync` through the pool's own thread affinity, not a separate marker field. The typed builder and the layout pool obtain their metadata block by different routes, because one must abort and the other must report. `BlindPool`'s caps bound memory in chunks rather than in bytes, since the chunk byte target is a target and not a ceiling. The verification guide claimed the source tree carries no unit tests, which the geometry module contradicts; the exception is now stated along with why it exists. That module also gains the workspace's coverage exclusion, so its test-only lines stop counting against the production coverage gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
The builder's chunk sizing and the blind pool's `Send` justification described themselves by pointing at the typed pool, which forces the reader to look elsewhere to learn what the blind pool does. Both now make their own argument. `Debug` names types with `type_name`, matching the workspace convention and surviving renames. The geometry module's two lint suppressions move from the file to the four methods that need them, so a later addition cannot inherit a justification that does not apply to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Replace the file-level `multiple_unsafe_ops_per_block` suppressions with per-block expectations. A file-level suppression silently covers code written later that its justification was never meant to excuse; a per-block attribute names the invariant or the indivisible step that puts those particular operations in one block, and the compiler rejects it where it does not apply. Describe the blind pool without reference to the typed pool, so its contract stands on its own rather than sending the reader elsewhere to assemble it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Audit every checkable claim the design and implementation guides make about the source, rather than only the ones a reviewer happened to name. The corrections span reference-count ownership, thread mobility, mutable access under pinning, teardown's role, chunk-size clamp rationale, metadata failure behavior, and the public surface listing. Two claims described verification that does not exist: loom models for the blind pool, and an allocation check over every pooled benchmark body. The first is withdrawn — the router is `!Sync` and its layout pools run the same protocol the typed models already explore. The second is made true instead, by covering the blind pool's fat-pointer body alongside the typed one. Spell in US English throughout, matching the sources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
The crate had no runnable examples. Five programs now cover the scenarios the API documentation can only gesture at: - pool_basic: the four handle flavors, address stability, slot reuse. - pool_across_threads: a Mutex-shared pool, slots reclaimed from worker threads, and a non-Send value in a Send pool. - blind_pool_basic: values of unrelated types in one pool, layout routing, per-layout capacity. - blind_pool_dyn_dispatch: a pipeline of differently sized trait objects backed by a single pool. - blind_pool_tuning: chunk sizing, capacity bounds, graceful exhaustion. The crate documentation indexes them, so the generated README carries the list too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Sander Saares (sandersaares)
left a comment
There was a problem hiding this comment.
[Copilot speaking]
Published 20 findings. 2 findings follow up on existing discussion threads.
See diagnostics
| Diagnostic | Value |
|---|---|
| Cache | Hit |
`clippy::useless_borrows_in_formatting` fires on the newer toolchain CI runs. The format machinery takes its arguments by reference already, so the explicit borrow adds nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Growth and directory reservation both hold state that a reentrant allocation could observe mid-update: growth derives a chunk's base slot index from a count it publishes only afterwards, and directory reservation holds `&mut` over a vector while calling the global allocator. Both windows are now latched. A nested fallible allocation is refused as an allocator failure; a nested introspection read panics, because introspection has no error channel. Also addresses the review findings on the blind pool work: borrow-free introspection through `LayoutPoolRef`, source-neutral allocation failure wording, a corrected slot-count bound, and documentation and benchmark consistency fixes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
`clamp_chunk_size` asserted that a one-slot chunk always has a representable layout. That holds on a 64-bit target, where the compiler caps an object at far below the `Layout` size ceiling, but not on a narrow-pointer target, where the largest object comes within the slot metadata of the ceiling. The assertion would then fire from inside `try_alloc_*`, which promises to report rather than panic. The floor is now reported. `LayoutPool::new` returns an allocator failure for a layout no chunk can hold, and the per-layout sizing queries report zero for it, which is also the cap query's answer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/plurality/docs/implementation/verification.md:31
- This test-organization claim is already false:
src/layout_pool.rs:281-318also contains a unit-test module for sizing layouts that cannot be reached through the public API. Please document both exceptions so the verification guide matches the suite.
Tests are external integration targets, with one exception. That is a
deliberate constraint: it keeps the tests honest about the public surface.
The exception is `src/geometry.rs`, which carries a unit test module. The
geometry formulas take a size and an alignment, not a type, and the property
The verification guide claimed a single exception to the external-target rule. The sizing floor tests in `layout_pool.rs` are a second one, so both are now named along with the reason each cannot be driven from outside the crate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/plurality/src/lib.rs:33
- This describes routing as exact alignment matching, but
routing_keywidens alignments below the slot-metadata alignment, so types such as[u8; 8]and[u16; 4]intentionally share a layout pool. Describe the routing key as slot geometry to keep the public crate documentation consistent with the API's actual capacity-sharing behavior.
crates/plurality/src/multi_pool.rs:79 - The routing key is not the value's exact alignment: alignments narrower than the slot metadata are widened and may share a pool. Calling this “exact size and alignment” contradicts
routing_keyand thelayouts()documentation; use slot geometry here instead.
crates/plurality/src/geometry.rs:55 - This reference points to a nonexistent “Routing” heading in
docs/design/multi-pool.md, so readers cannot find the cited rationale. The relevant section is “Exact sizes, no size classes.”
The crate and type documentation described routing as exact size and alignment matching, which contradicts the routing key: alignments below the slot metadata's width are widened, so distinct layouts may share a pool. The `routing_key` rationale also cited a heading that does not exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 54 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (10)
crates/plurality/tests/pool.rs:692
- Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
crates/plurality/tests/pool.rs:713 - Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
crates/plurality/src/geometry.rs:533 - Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
crates/plurality/src/layout_pool.rs:315 - Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
crates/plurality/src/multi_pool.rs:85 - This public description says every handle supports unsizing, but
Allocis sized-only and has no coercion API. Limit the coercion claim to the three detachable handle types so the documented surface matches the implementation.
crates/plurality/tests/send_bound_probe.rs:189 - The name says teardown occurs on the far thread, but this test explicitly verifies that the retained handle causes teardown on the originating thread. Rename it to match the scenario and distinguish it from the following far-thread teardown test.
crates/plurality/tests/pool.rs:656 - Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
This issue also appears in the following locations of the same file:
- line 692
- line 713
crates/plurality/tests/multi_pool.rs:1758
- Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
crates/plurality/src/geometry.rs:505 - Test code in this repository uses
unwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
This issue also appears on line 533 of the same file.
crates/plurality/src/layout_pool.rs:294
- Test helpers in
#[cfg(test)]code should useunwrap()rather than customexpect()messages because the backtrace already identifies the failed setup.
This issue also appears on line 315 of the same file.
The multi pool's type documentation attributed unsizing coercion to every handle, but the bound owner is sized-only. A send-bound probe was also named for far-thread teardown while it asserts the opposite: teardown deferred back to the originating thread. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Test code takes `unwrap` rather than `expect`, since the backtrace already identifies the failed setup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
Force the routing helper inline: measured out of line, the call it saves costs more than the routing it wraps. Re-derive the routed intercept from the current build, separating the benchmark harness's own overhead from the 25 instructions routing actually costs a caller, and correct the claim that a loaded stride is free. Assert the directory reservation in `PoolCore::grow`, matching the assertion the multi pool already makes at the structurally identical push. Drop the `Pool::occupy_local` forwarder, which used no `self`, in favour of the free function it wrapped. Describe the reservation's growth policy accurately in the reentrancy guide. Record the packed directory key as future work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
…ool-design # Conflicts: # crates/plurality/README.md # crates/plurality/docs/DESIGN.md # crates/plurality/docs/PERF.md # crates/plurality/docs/TODO.md # crates/plurality/scripts/perf_report.rs
The performance report prices capabilities, but type erasure appeared in it only as one row inside the fat-pointer comparison, where it is confounded with unsizing and a virtual call. Add a section that measures it directly, against the typed pool doing the same work, at one layout and at sixteen. Wall clock does not resolve the directory scan: sixteen layouts lands within a percent of one, because the scan is a predictable walk over a contiguous vector that the processor overlaps with the pool's own pointer chasing, and what remains is below the swing that heap and code placement produce. The report therefore prices type erasure at the step from the typed pool rather than at the difference between layout counts, and the implementation guide and the packed-key backlog entry record why the scan is judged in instructions instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6085a7e-d13f-4f5d-a980-86c36499eb7e
[Copilot speaking]
Motivation
plurality::Pool<T>serves one element type. Callers holding a heterogeneous mix of values — a scheduler's task payloads, a state machine's per-state data, anything reached through a trait object — either need one pool per type, with the bookkeeping and the wasted slack that implies, or they give up pooling.This adds
MultiPool: the same pool, with the element type moved from the pool to the allocation call.What changed
MultiPool<A>andMultiPoolBuilder. A pool that accepts values of any type. It hands out the same four handle flavors asPool<T>(Box,Alloc,Arc,Rc) with the same guarantees, the same handle size, and the same reclamation cost — a handle from a multi pool is indistinguishable from a handle from a typed pool, including forunsizecoercion.Internally it is a router. Each allocation derives a routing key from
Layout::new::<T>()and dispatches to a crate-privateLayoutPoolserving that key, creating one on first sight:Routing is by slot geometry, not by type: the key is the value's size paired with its alignment widened to hold the trailing refcount and index words, which is precisely what the slot's stride and offsets are a function of. Every type that lays out an identical slot shares a layout pool, so
u64andi64land in the same one, and so do[u8; 8]and[u16; 4]. Sizes never merge. This is what makes the multi pool's memory behaviour predictable rather than proportional to the number of types in play.The directory is a linear scan over first-seen order, not a sorted structure. Programs present few distinct geometries, and a scan over a contiguous array beats a search that has to maintain order — the benchmarks vary directory size specifically to keep that assumption honest.
One pool body, two geometry providers.
Pool<T>'s allocation, free-list, and chunk logic is now parameterised over aSlotGeometryprovider instead of hard-codingSlotCell<T>.TypedGeometry<T>answers withconstexpressions, so the typed pool folds to exactly the code it emitted before;RuntimeGeometryanswers with loaded fields, which is what lets one body serve a layout known only at run time. The typed pool's measured instruction counts are unchanged.A relaxed
Sendbound onPool<T, A>. The pool requiredT: Sendto beSenditself, which was overstated. A pool object owns no values: every safely reachable value is owned through a handle, there is no iteration or drain, and teardown deallocates chunks without reading or dropping element storage. A thread receiving a pool can only draw free slots, which hold nothing live.Pool<T, A>is thereforeSendwheneverAis, for anyT, and thread mobility for values is carried entirely by the handles, which impose their own bounds.Poolremains!Sync, so allocation is still confined to one thread at a time.This is what lets
MultiPool— which cannot name its element types, let alone bound them — beSend, and lets one multi pool hold values of types with different thread affinities at once.tests/send_bound_probe.rsholds the reasoning to account: a compile-time assertion rejects reintroducing the bound, and the scenarios move pools of a thread-bound type across threads while exercising values, slot reuse, teardown, and a concurrent free-versus-allocate hand-off.A teardown soundness fix. Freeing a pool through its last surviving handle deallocated through a pointer derived from a shared borrow, which is undefined behaviour. Miri caught it as a borrow-stack violation once the erased free path exercised it. Fixed for
Pool<T>as well as the new code.Allocator reentrancy made safe. An allocator that allocates from the pool it serves corrupted the pool: chunk growth derived a new chunk's base slot index from a count it published only after calling the allocator, so a nested allocation claimed the same slot indices, and directory reservation held
&mutover a vector across a global-allocator call. Both predate this work. Growth now allocates first and reads the count after, re-checks the chunk cap so reentry cannot overshoot it, and reserves directory room without a live borrow, deferring the free of the displaced buffer until the reservation has been consumed. A pool that can no longer grow hands out any slot a nested allocation left free rather than reporting exhaustion. Reentry is supported from every direction —allocate,deallocate,Clone::cloneon a multi pool's allocator, pooled values' destructors, and construction closures — so the pools place no obligation on the allocator at all. That covers both a reentrant pool allocatorAand a reentrant#[global_allocator]; the two reach different paths, since directory growth calls the global allocator and neverA, and they carry different evidence: theApaths are checked under Miri with Tree Borrows, while the global-allocator path is checked under the ordinary test runner, which Miri's allocator model cannot host.docs/implementation/reentrancy.mdrecords the ordering the code relies on and which door each argument covers.Documentation restructured.
docs/design.mdanddocs/implementation.mdwere single documents covering a crate that has outgrown that shape; they are now hubs linking to area documents underdocs/design/anddocs/implementation/. The implementation guide now describes the whole implementation, not only the parts this work touches.Cost
Measured with Callgrind, instructions per operation:
alloc+ drop, 32-byte valueBox<dyn Trait>round trip, 32-byte valueRoughly 6 instructions of the difference are the directory scan, per entry examined. The remainder is a fixed cost, and about half of it is the scan itself; the rest is reaching the pool body, threading the value through the routing helper, and one instruction for addressing a slot from a loaded stride rather than a folded constant.
docs/implementation/performance.mdattributes it line by line. In wall clock the gap is narrower than the instruction counts suggest — about 1.17× for an allocate-and-free, and a sixteen-layout directory does not separate from a one-layout one, because the scan overlaps with the pool's own pointer chasing. For comparison,std::boxed::Boxcosts 175 on the trait-object benchmark andinfinity_pool's equivalentBlindPoolcosts 294. Wall-clock figures and the rest of the comparison field are indocs/PERF.md.Verification
MultiPoolis covered to 100% lines and functions, and the four modules involved survive no mutants (422 tested). Miri runs the full suite, including the cross-layout property test that asserts no slot address is ever served for two different layouts.