Skip to content

perf(thread_aware)!: partition relocation storage per affinity - #681

Draft
Sander Saares (sandersaares) wants to merge 26 commits into
mainfrom
u/sasaares/fix-thread-aware
Draft

perf(thread_aware)!: partition relocation storage per affinity#681
Sander Saares (sandersaares) wants to merge 26 commits into
mainfrom
u/sasaares/fix-thread-aware

Conversation

@sandersaares

@sandersaares Sander Saares (sandersaares) commented Aug 18, 2026

Copy link
Copy Markdown
Member

[Copilot speaking]

Motivation

thread_aware::Arc<T, S>::relocate is the hot path of the crate: a thread-per-core runtime calls it on every cross-core handoff, for every Arc<_, PerCore> reachable in the relocated object graph. It took an unconditional exclusive RwLock write 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:

shape before after
16 workers, distinct affinities 2.72 us / 5.8 Melem/s 200 ns / 80 Melem/s
32 workers, distinct affinities 5.50 us / 5.8 Melem/s 290 ns / 108 Melem/s

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 Arc moved 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 public storage::Storage handle exposes an affinity-keyed contract — build with Storage::new, seed slots with insert, read them back with get — 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 to Arc::from_storage. A Strategy must report the same slot count for every affinity that shares a storage; the built-in strategies do, since processor and memory-region counts are machine properties.

Benchmarks. A concurrent benchmark 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_path and miss_path cover the single-threaded branches under both Criterion and Callgrind.

Structure

Arc<T, S> clones ── share ──> Storage<T, S>            (public, opaque handle)
                                  |
                                  +-- SlotTable: [ CachePadded<RwLock<Option<Arc<T>>>> ; N ]
                                                   slot 0    slot 1   ...  slot N-1
                                                   (one independently-locked slot per affinity)

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
Copilot AI lite review requested due to automatic review settings August 18, 2026 14:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::count is consistent across affinities. Without this, a custom Strategy that varies count() 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.

Comment thread crates/thread_aware/src/cell/mod.rs Outdated
Comment thread crates/thread_aware/src/cell/storage.rs
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.0%. Comparing base (9877935) to head (5d17d48).
⚠️ Report is 10 commits behind head on main.

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     
Flag Coverage Δ
linux ?
linux-arm ?
scheduled ?
windows ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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
Copilot AI review requested due to automatic review settings August 18, 2026 14:24
@sandersaares Sander Saares (sandersaares) changed the title perf(thread_aware): partition relocation storage per affinity perf(thread_aware)!: partition relocation storage per affinity Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

✅ Version increments look sufficient

cargo semver-checks compared the 1 crate(s) this PR publishes against their previous version-bump commit in git history. Every version increment is sufficient for the detected API changes.

Crate Baseline Baseline commit This PR Minimum required Status
thread_aware 0.9.0 7c185b4 0.10.0 0.9.1 ✅ ok

This check is informational and does not block the merge.

View the check run

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_storage is public, but storage::Storage is effectively opaque outside the crate (its constructor and methods are pub(crate)), and Arc does not expose a way to obtain the backing Storage handle. As a result, downstream callers can’t practically use from_storage. Consider exposing a Storage handle accessor on Arc (or alternatively making Storage constructible/manipulable publicly, or making from_storage crate-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). Here RACERS is 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
Copilot AI review requested due to automatic review settings August 18, 2026 14:47
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

Comment thread crates/thread_aware/src/cell/mod.rs Outdated
Copilot AI review requested due to automatic review settings August 18, 2026 14:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_POISONED contract is documented as unconditional, but SlotTable<T, _> is generic and performs T: Clone operations while holding a slot lock (e.g., in get_clone / count_where). A panicking Clone would 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_count can underflow under concurrent relocation because it reads raw first and then counts internal references without any global synchronization; new internal references may be published between those reads, making internal > raw and wrapping the subtraction to a huge usize.

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
Copilot AI review requested due to automatic review settings August 18, 2026 15:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.

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
Copilot AI review requested due to automatic review settings August 18, 2026 15:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_count subtracts an internal-slot count from the raw Arc strong count. With the new per-slot locking, count_where is explicitly racy under concurrent relocation, so internal can transiently exceed the previously-captured raw, 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's many_cpus dependency is only enabled by the threads feature (not by std), and the bench target is currently declared with required-features = ["std"]. As written, the bench won't compile when built with only std. Prefer using std::thread::available_parallelism() (std-only) and drop the many_cpus dependency 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 using std::thread::available_parallelism() so the concurrent group still sizes its worker pool correctly without requiring the threads feature.
    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
Copilot AI review requested due to automatic review settings August 18, 2026 15:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when std is enabled without derive. Adding derive to required-features will 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 tests blocks with coverage(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 {

Comment thread crates/thread_aware/Cargo.toml
…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
Copilot AI review requested due to automatic review settings August 18, 2026 17:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sandersaares
Sander Saares (sandersaares) marked this pull request as draft August 19, 2026 05:59

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Copilot speaking]

Published 3 findings. No finding follows up on an existing discussion thread.

See diagnostics
Diagnostic Value
Cache Miss

Comment thread crates/thread_aware/src/cell/storage.rs Outdated
Comment thread crates/thread_aware/src/cell/tests.rs
Comment thread crates/thread_aware/benches/thread_aware_relocate.rs Outdated
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
Comment thread crates/thread_aware/src/cell/mod.rs Outdated
Comment thread crates/thread_aware/src/cell/mod.rs Outdated
Comment thread crates/thread_aware/src/cell/storage.rs Outdated
Comment thread crates/thread_aware/src/cell/storage.rs Outdated
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
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.

2 participants