perf(thread_aware)!: partition relocation storage per affinity - #681
perf(thread_aware)!: partition relocation storage per affinity#681Sander Saares (sandersaares) wants to merge 26 commits into
Conversation
Arc<T, S>::relocate took an exclusive lock on the slot table on every call, even though the overwhelmingly common outcome is a read-only lookup of an already-materialized slot. Every relocation of every clone of a value was therefore serialized on a single writer. Relocation now probes the destination slot under a shared lock and only escalates to an exclusive lock when the slot has to be materialized. The slot is re-probed after the escalation because the lock is released between the stages. Adds a Criterion and Callgrind benchmark suite covering the hit path, the miss path, and two multithreaded shapes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
A single descheduled worker moved the whole sample when the round was summarized by its mean, leaving the 250-thread case with a confidence interval too wide to read. The median over workers narrows it by an order of magnitude. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
…benchmarks The storm group gained a saturation point between the single-threaded and oversubscribed cases, so that lock contention can be told apart from the queueing that appears once threads outnumber processors. It also switched to flat sampling, because contention is a property of a round rather than of its length. The handoff group gained transport-only controls. A channel round trip costs an order of magnitude more than a relocation, so the relocation cost is only readable as the difference between a variant and its control. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
…ntion The storm shape timed each worker's own window and reported the median across workers. When a worker's slice of work is shorter than a scheduler timeslice, the first batch of workers runs to completion and parks before the rest are awake, so the shape reported uncontended timings under a contended name. Measured overlap was around six percent of the advertised thread count. Workers that close their timing window now keep relocating until every worker has closed its own, so late starters still meet a loaded machine, and each round asserts that its workers genuinely overlapped. The thread count is now a small multiple of the processor count rather than a fixed few hundred. Barrier release costs about a millisecond per few threads and lands inside the measured round, so at a few hundred threads the shape measured thread wake-up rather than relocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Releasing a barrier is not instantaneous relative to the work in a Criterion sample, so the workers woken first timed a machine that had not yet reached the advertised thread count. The tail load only covered the end of the round and could not repair that. Workers now relocate untimed until every worker is awake before starting the clock, so the timed region is bracketed by full load on both sides rather than by the ragged edges of barrier release. Mean worker overlap at one thread per processor rises from 13.6 to 15.2 of 16. With the starts aligned, the overlap assertion no longer needs a wall-clock threshold calibrated against one machine. It applies whenever the timed work outlasts the residual spread in start times, a ratio the round measures for itself. The storm group now measures only the object tree. A bare Arc takes one lock acquisition per message and collides too rarely for its run-to-run spread to resolve any difference between locking policies, so it reported noise; the single-threaded groups still measure both subjects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
The storm benchmark timed a single relocation per worker and reported a per-operation latency. Against a barrier release and thread wake-up measured in tens of microseconds, a handful-of-nanoseconds operation was unmeasurable, which is what drove the lead-in load, the tail load, the overlap assertion, and the machine-calibrated gates. Replace all of it with one benchmark. A reused worker pool relocates a large batch behind a single release, and the round's wall-clock time is fit against the batch size, so the fixed release cost lands in the regression intercept and the reported per-iteration time is the cost of one relocation under contention. Throughput per worker is reported too, so the group also prints aggregate relocations per second. Readiness is proven before the clock starts, so timing excludes barrier arrival skew, and there is no per-operation synchronization inside the batch. The group sweeps one worker per processor and two per processor; the uncontended cost stays in hit_path. This measures 250 workers as easily as 32 — the earlier ~500 ms "barrier release" was the old harness's busy lead-in starving unwoken workers, not an intrinsic cost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Every clone of an Arc<T, S> shared one RwLock over the whole slot table, so relocations into different affinities serialized against each other even though they touch disjoint slots. A fanout that hands work to every core therefore funnelled every relocation through one lock. Give each affinity its own cache-line-padded RwLock. The slot array is sized once, on first use, to the strategy's fixed slot count, so there is no growth path and no table-wide lock; reaching a slot is an atomic load of a OnceLock pointer. Relocations into different affinities now touch different locks on different cache lines and never contend. The two-stage probe (shared read, escalate to exclusive on a miss) is now scoped to the destination slot. The source slot is restored afterwards under its own lock, never held together with the destination lock, so two threads relocating in opposite directions cannot deadlock. T stays ?Sized. On the concurrent-relocation benchmark (distinct destination per worker, five-layer tree) this lifts throughput from ~5.8 Melem/s, flat regardless of thread count, to ~82 Melem/s at 16 threads and ~108 Melem/s at 32 — throughput now scales with cores instead of flat-lining on one lock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
…andle The per-affinity storage was exposed through rom_storage, whose signature named the internal slot-table type. Wrap it in an opaque SharedStorage<T, S> handle: the slot table and its per-slot locking are now crate-private, and the public surface is a handle that reveals nothing about how relocation is synchronized, so the representation can change without breaking callers. The Strategy trait stays public, since it is the S parameter of the public Arc<T, S>. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
… bench The crate-level allow suppressed a lint that nothing in the file triggers: every black_box result is either consumed as a relocate argument or returned as the benchmark's output. The bench compiles clean without it on Linux, where Gungraun expands the real body. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
…benches Audited every allow we introduced by removing it and checking what fires. Removed the ones nothing triggers: missing_docs, clippy::missing_panics_doc and clippy::std_instead_of_core from the Criterion bench (only unwrap_used actually fires there), and clippy::needless_pass_by_value from the Callgrind bench (its inputs are consumed by destructuring, so the lint never fires). Kept clippy::unwrap_used (Criterion), missing_docs (Callgrind, raised by the Gungraun macro expansion), and the shared Gungraun expect block that every other _cg bench carries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Address review feedback on implementation.md: - Do not assume an Arc's storage lives for the whole process; describe the hot path in terms of an already-populated affinity instead. - Trim the re-probe explanation to the correctness point, dropping the background on how a reader-writer lock queues waiters. - Introduce the source-slot write as what it is — preserving the value the relocation moves away from — rather than an unexplained "restoration". - Present the object-tree subject as one more realistic shape for coverage, not the definitive one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
… storage Address review feedback on the storage module: - Never let a slot lock become poisoned. The only code run while a slot lock is held that could panic is the relocation factory (caller code) on the miss path; relocate now runs it under catch_unwind and drops the guard before resuming the unwind, so the lock is released cleanly. count_where clones each handle out from under its lock and applies the predicate afterwards, so no caller code runs while a lock is held. Every other guarded operation only clones or stores an Arc, which cannot unwind. The lock-acquisition expect messages now assert this invariant, and a new test proves a panicking factory leaves the slot lock usable. - Rename the internal slot-table type from Storage to SlotTable, and the public handle from SharedStorage to Storage, to cut down on the Storage/Strategy/SharedStorage name soup. - Drop implementation detail (per-slot locking, ?Sized rationale) from the handle's API documentation; the ?Sized reasoning moves to implementation.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
…ctory writes Address review feedback on the cell module: - materialize now returns the value plus an Option: Some only when the factory actually changes (the closure factory, on an Arc's first relocation, when it records its source affinity). The stateless factory kinds return None, so relocate no longer replaces self.factory with an identical clone every miss. - Explain why relocation touches the source slot at all: it records the value the Arc moved away from, which belongs to the source affinity and would otherwise be lost on a first relocation. - Document what happens after a factory panic: the destination slot is left empty and the next relocation into that affinity re-materializes. - Replace "carry forward" with an explanation of the returned factory update. - Reword NEVER_POISONED: a panic under the lock can happen, it is just caught and the lock released before unwinding, rather than "never panics". - Tighten from_storage's "may panic" to "Panics", and drop the module prefix from Storage::new() in the constructors. The removed strong_count missing_panics_doc expect is deliberately not restored: it no longer acquires a lock directly, so the lint does not fire and the expect would be unfulfilled. The unreachable-panic reasoning now lives at the actual expect site, count_where's NEVER_POISONED message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
There was a problem hiding this comment.
Pull request overview
This PR optimizes thread_aware::Arc<T, S>::relocate by replacing the single global lock with per-affinity, cache-line-padded slot locks, reducing cross-core contention on the hot relocation path. It also adds documentation and benchmark coverage to validate and track the new behavior/perf characteristics.
Changes:
- Replace the shared storage lock with an affinity-partitioned
SlotTable(OnceLock<Box<[CachePadded<RwLock<...>>]>>) and update relocation logic to use a read-probe + write-on-miss per-slot scheme. - Add concurrency/panic-safety tests to validate “materialize once” behavior and that slot locks are not left poisoned after a factory panic.
- Add implementation documentation and new Criterion/Callgrind benchmarks for hit/miss and concurrent relocation scenarios.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/thread_aware/src/cell/mod.rs | Updates Arc to use the new opaque storage handle and revises relocation to two-stage per-slot locking with unwind safety. |
| crates/thread_aware/src/cell/storage.rs | Introduces SlotTable and new Storage wrapper (per-affinity slot locks, OnceLock sizing, counting helpers). |
| crates/thread_aware/src/cell/tests.rs | Updates tests to match new storage shape; adds new concurrency and panic/unpoisoning tests. |
| crates/thread_aware/docs/implementation.md | New design/locking and benchmarking rationale documentation. |
| crates/thread_aware/Cargo.toml | Adds crossbeam-utils dependency and registers new benchmark targets. |
| crates/thread_aware/benches/thread_aware_relocate.rs | New Criterion wall-clock benchmark suite for relocation (hit/miss/concurrent). |
| crates/thread_aware/benches/thread_aware_relocate_cg.rs | New Callgrind benchmark suite for relocation hit/miss paths. |
| Cargo.toml | Adds workspace dependency entry for crossbeam-utils. |
| Cargo.lock | Locks crossbeam-utils for the workspace. |
Suppressed comments (1)
crates/thread_aware/src/cell/storage.rs:82
- Because the slot array is initialized once, it’s worth asserting (at least in debug) that
Strategy::countis consistent across affinities. Without this, a customStrategythat variescount()could cause a hard-to-diagnose panic on indexing later.
/// Returns the slot array, sizing it on first use to hold every affinity.
fn slots(&self, affinity: Affinity) -> &[Slot<T>] {
self.slots.get_or_init(|| {
(0..S::count(affinity))
.map(|_| CachePadded::new(RwLock::new(None)))
.collect::<Vec<_>>()
.into_boxed_slice()
})
}
💡 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 #681 +/- ##
=========================================
Coverage 100.0% 100.0%
=========================================
Files 503 543 +40
Lines 57406 60474 +3068
=========================================
+ Hits 57406 60474 +3068
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:
|
A miss records the value an Arc moved away from into the source affinity's slot. Nothing exercised the source != destination guard on that write, so mutating it to == went undetected. Add a test that relocates away and back and asserts the source affinity keeps its original value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Arc::from_storage now takes the opaque storage::Storage handle instead of Arc<RwLock<Storage<..>>>, and the former public storage::Storage slot table is now the private SlotTable. That is a breaking change to a released 0.9.0, so the minor component bumps under Cargo's 0.x SemVer rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
✅ Version increments look sufficient
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/thread_aware/src/cell/mod.rs:536
Arc::from_storageis public, butstorage::Storageis effectively opaque outside the crate (its constructor and methods arepub(crate)), andArcdoes not expose a way to obtain the backingStoragehandle. As a result, downstream callers can’t practically usefrom_storage. Consider exposing aStoragehandle accessor onArc(or alternatively makingStorageconstructible/manipulable publicly, or makingfrom_storagecrate-private if it’s intended to be internal-only).
/// Creates a new `Arc` from the given storage and the current affinity.
///
/// If the resulting `Arc` is transferred to an affinity which does not have data in the storage,
/// it will behave like a [`sync::Arc`].
///
/// # Panics
/// Panics if the storage does not contain data for the current affinity.
pub fn from_storage(storage: sync::Arc<Storage<T, S>>, current_affinity: Affinity) -> Self {
let value = storage.get_clone(current_affinity).expect("No data found for the current affinity");
crates/thread_aware/src/cell/tests.rs:764
- In test code, prefer
.unwrap()over.expect(...)(the backtrace is typically sufficient, and it keeps tests consistent with the repo’s guidance). HereRACERSis a const so the message isn’t adding useful diagnostics.
let values = racers.into_iter().map(|racer| racer.join().unwrap()).collect::<Vec<_>>();
let (first, rest) = values.split_first().expect("RACERS is nonzero");
Expose `Storage::new`, `Storage::insert` and `Storage::get` (plus a `Default` impl) so an external caller can build a storage, seed per-affinity values, and hand it to `Arc::from_storage`. Previously every `Storage` method was crate-private, leaving `from_storage` with no public construction path. A doctest on `from_storage` demonstrates the workflow. Also document the `Strategy::count` consistency contract (the slot count must be identical across the affinities that share a storage) and guard it with a `debug_assert` on the indexing path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Repo guidance prefers `.unwrap()` in test code; the backtrace is sufficient and `RACERS` is a const so the message added no diagnostic value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/thread_aware/src/cell/storage.rs:40
- The
NEVER_POISONEDcontract is documented as unconditional, butSlotTable<T, _>is generic and performsT: Cloneoperations while holding a slot lock (e.g., inget_clone/count_where). A panickingClonewould poison the lock, contradicting the docs.
If the intent is that this guarantee is relied on only for Storage (which stores sync::Arc<T>, whose clone doesn’t panic), consider scoping the documentation accordingly so it stays accurate for SlotTable’s generic use.
/// A slot lock is never left poisoned, so acquiring it never fails.
///
/// Poisoning happens when a thread panics while holding the lock and the guard is
/// dropped during unwinding. The operations run under a slot lock — cloning,
/// storing and comparing the reference-counted handle it holds — cannot unwind,
crates/thread_aware/src/cell/mod.rs:595
strong_countcan underflow under concurrent relocation because it readsrawfirst and then counts internal references without any global synchronization; new internal references may be published between those reads, makinginternal > rawand wrapping the subtraction to a hugeusize.
Since strong_count is inherently racy, prefer making it robust by taking the internal count first and using saturating_sub (or checked_sub with a fallback) to avoid wraparound.
pub fn strong_count(this: &Self) -> usize {
let raw = sync::Arc::strong_count(&this.value);
let internal = this.storage.count_where(|stored| sync::Arc::ptr_eq(stored, &this.value));
raw - internal
Restores the crate to full line coverage after the new public API: a test exercises `Storage::default`, and a debug-only `should_panic` test trips the `debug_assert` in `SlotTable::slot` with a deliberately inconsistent `Strategy`, covering the guard's failure path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
The post-publish invariant check ran while the destination slot's write lock was held, so a panic there would have poisoned the lock and broken the never-poison guarantee. Capture the outcome, release the lock, then verify with a debug-only assertion that release builds skip entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/thread_aware/src/cell/mod.rs:596
Arc::strong_countsubtracts an internal-slot count from the rawArcstrong count. With the new per-slot locking,count_whereis explicitly racy under concurrent relocation, sointernalcan transiently exceed the previously-capturedraw, causing an integer underflow and returning a huge number. Use a saturating subtraction (or otherwise guard against underflow) so this remains safe under races.
pub fn strong_count(this: &Self) -> usize {
let raw = sync::Arc::strong_count(&this.value);
let internal = this.storage.count_where(|stored| sync::Arc::ptr_eq(stored, &this.value));
raw - internal
}
crates/thread_aware/benches/thread_aware_relocate.rs:46
- This benchmark imports
many_cpus::SystemHardware, but the crate'smany_cpusdependency is only enabled by thethreadsfeature (not bystd), and the bench target is currently declared withrequired-features = ["std"]. As written, the bench won't compile when built with onlystd. Prefer usingstd::thread::available_parallelism()(std-only) and drop themany_cpusdependency here.
use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main};
use many_cpus::SystemHardware;
use thread_aware::affinity::{Affinity, pinned_affinities};
use thread_aware::{Arc, PerCore, ThreadAware, Unaware};
crates/thread_aware/benches/thread_aware_relocate.rs:428
- After removing
many_cpus, compute the processor count usingstd::thread::available_parallelism()so theconcurrentgroup still sizes its worker pool correctly without requiring thethreadsfeature.
let saturated = SystemHardware::current().processors().len();
…ion race `strong_count` samples the raw `Arc` count and the internal slot count separately, not from one snapshot. A concurrent relocation can publish the value into another slot between the two reads, letting the internal count exceed the stale raw count and underflow the subtraction. Saturate instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/thread_aware/Cargo.toml:140
- This bench also uses
#[derive(ThreadAware)]in its Linux-only module, so it won’t compile on Linux whenstdis enabled withoutderive. Addingderivetorequired-featureswill make the target auto-skip in that feature set.
[[bench]]
name = "thread_aware_relocate_cg"
harness = false
required-features = ["std"]
crates/thread_aware/src/cell/storage.rs:230
- The unit-test module here is missing
#[cfg_attr(coverage_nightly, coverage(off))]. This repo uses a 100% coverage gate and consistently marks#[cfg(test)] mod testsblocks withcoverage(off)so the test harness itself doesn’t count against coverage (e.g.,crates/thread_aware/src/registry.rs:218-220).
#[cfg(test)]
mod tests {
…rive feature The relocation benches and the derive integration tests use `#[derive(ThreadAware)]`, whose re-export is behind the `derive` feature, but their targets required only `std`. Building an additive `std`-without-`derive` configuration with `--all-targets` therefore failed to compile. Add `derive` to the required features of every target that uses the derive so all additive feature combinations build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/thread_aware/src/cell/storage.rs:19
- Grammar in the public trait docs: use “an affinity-aware manner” (vowel sound) instead of “a affinity-aware manner”.
/// A strategy for storing data in a affinity-aware manner.
Sander Saares (sandersaares)
left a comment
There was a problem hiding this comment.
[Copilot speaking]
Published 3 findings. No finding follows up on an existing discussion thread.
See diagnostics
| Diagnostic | Value |
|---|---|
| Cache | Miss |
Add docs/design.md describing the user-visible behavior and tenets of Arc and Storage, and clarify in implementation.md why the affinity-partitioned slot uses a reader-writer lock rather than an atomic swap: a direct swap of `Arc<T>` would force `T: Sized`; `?Sized` could be kept only via an extra thin handle, and the deref hot path never touches a slot lock anyway, so a lock-free slot read buys little. Tighten over-broad claims across the docs, code comments, and benchmark prose: - Describe cache-line padding as a target-specific false-sharing mitigation, not guaranteed physical isolation or pinned cache residency. - Speak in terms of slots rather than affinities where a strategy maps several affinities to one slot (PerNuma, PerProcess), and stop implying PerProcess relocation is a no-op that shares one value. - State strong_count as an approximate, saturating estimate. - Scope the concurrent benchmark to what it measures: hit-path relocations into distinct, pre-materialized slots (no shared-lock contention), reported as batch makespan per relocation with aggregate throughput. - Narrow the re-probe race test's comment to what it proves deterministically (single materialization). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
A relocation whose source and destination resolve to the same storage slot is not a cross-slot move: the value the Arc carries already belongs to that slot. The miss path nevertheless materialized a fresh value and discarded the carried one, so under PerProcess — where every affinity shares one slot — holders that should share a single process-wide value diverged on their first relocation (and a PerCore self-relocation reset its value). Short-circuit when `S::index(source) == S::index(destination)`: keep the carried value and seed the empty slot with it. The source-slot record likewise now compares slots rather than affinity identity, so it runs only on genuine cross-slot moves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
The miss benchmarks now prime the shared slot table with a throwaway relocation into a dedicated affinity before timing, so the timed call is a genuine cross-slot miss into an already-sized table rather than also paying the one-time table allocation. The timed relocation now supplies a source affinity, so it exercises the source-slot write that a real cross-slot miss performs. The Callgrind header and implementation guide are aligned to the operation actually measured. Also address review comments: reword the oversubscription note so it no longer implies exclusive-lock contention it never exercises; clarify that strong_count is inherently stale rather than de-synced by separate sampling; simplify the materialize doc; report the strategy type in the slot-index guard message; move the count_where locking detail from contract doc to an inline comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
An out-of-range slot index from a misbehaving Strategy now falls back to the first slot in release builds rather than panicking, while debug builds still trap the anomaly via a debug assertion. The Strategy trait docs drop the over-broad cross-affinity consistency requirement, which is outside any single implementation's control. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
Replace the slot table's `RwLock<Vec<Box<Slot>>>` with a concurrent append-only `boxcar::Vec<Slot>`. A slot lookup is now a single lock-free indexed read that scales across cores, instead of a shared acquire on one table-wide lock whose reader-count atomic ping-ponged between every relocating core. The table still grows on demand to cover any index a strategy hands out, so a strategy whose reported count varies across an Arc's relocations is served by growth rather than overflow. boxcar keeps each element at a stable address for the life of the vector, so the design also drops the `Box` indirection and the unsafe pointer lifetime extension the previous growable table needed to hand back a `&Slot` outliving the lookup guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 51577fb8-3dfc-468d-bdf2-9ae4d711649d
[Copilot speaking]
Motivation
thread_aware::Arc<T, S>::relocateis the hot path of the crate: a thread-per-core runtime calls it on every cross-core handoff, for everyArc<_, PerCore>reachable in the relocated object graph. It took an unconditional exclusiveRwLockwrite lock on every call, even though the overwhelmingly common case is a read-only lookup of an already-populated slot. Every relocation of every clone of a value therefore serialized on one lock, turning a read-only lookup into a process-wide serialization point. (Azure DevOps 7747020.)Changes
Per-affinity partitioning. The slot table is no longer guarded by a single lock. Each affinity owns its own cache-line-padded
RwLock, and the table is sized once to the strategy's fixed slot count, so there is no growth path and no table-wide lock. Relocations into different affinities now touch different locks on different cache lines and never contend: the hit path is a shared read of one slot, and a miss takes only that slot's exclusive lock. The two-stage probe (shared read, escalate to exclusive on a miss) is retained per slot.Before, a fanout that handed work to every core funnelled every relocation through one lock and throughput was flat regardless of core count. After, it scales with cores:
The subject is a five-layer object tree, the realistic case where a message reaches several thread-aware nodes. Single-threaded relocation and the deref/use path are unchanged; the latter never touched the storage lock in any design.
Deadlock-safe source handling. A miss also records the value the
Arcmoved away from into the source affinity's slot, so a later relocation back into that affinity finds it instead of re-materializing. That write happens under its own lock and never while the destination lock is held, so two threads relocating in opposite directions cannot each wait for the lock the other holds.Slot locks never poison. The only caller code run while a slot lock is held is the relocation factory on the miss path. It runs under
catch_unwind, which drops the guard before resuming the unwind, so a panicking factory leaves the slot empty and still usable rather than poisoned.Constructible storage handle. The per-affinity table is crate-private (
SlotTable); the publicstorage::Storagehandle exposes an affinity-keyed contract — build withStorage::new, seed slots withinsert, read them back withget— without revealing the locking or slot layout, so the representation can change without breaking callers. This lets a caller prepare per-affinity values up front and hand the storage toArc::from_storage. AStrategymust report the same slotcountfor every affinity that shares a storage; the built-in strategies do, since processor and memory-region counts are machine properties.Benchmarks. A
concurrentbenchmark measures the cost of one relocation while every core relocates at once, batching many relocations behind a single barrier release so the fixed synchronization cost falls into the regression intercept.hit_pathandmiss_pathcover the single-threaded branches under both Criterion and Callgrind.Structure