Skip to content

ARC: optional cache-line isolation of the reference count#403

Draft
DTW-Thalion wants to merge 4 commits into
gnustep:masterfrom
DTW-Thalion:arc-refcount-isolation
Draft

ARC: optional cache-line isolation of the reference count#403
DTW-Thalion wants to merge 4 commits into
gnustep:masterfrom
DTW-Thalion:arc-refcount-isolation

Conversation

@DTW-Thalion

Copy link
Copy Markdown
Contributor

Stacked on #399 and depends on it; the diff includes #399's commit until it merges.

Reference counts of distinct objects that share a cache line ping-pong between cores when those objects are retained and released concurrently, so unrelated objects contend even though they never share a reference.

This adds an opt-in -DOBJC_ALLOC_ALIGN=64 build that rounds each allocation up to a cache line, removing that false sharing at the cost of memory on the smallest objects. The default alignment is unchanged.

Opening as a draft while #399 is in review.

Per issue gnustep#398.  The reference-count fast paths in arc.mm seeded their CAS
loops with __sync_fetch_and_add(refCount, 0), a full read-modify-write used
only to read the count word, and updated it with sequentially consistent
__sync_val_compare_and_swap.  The seed only needs a plain load, and the
exchanges do not need seq_cst.

View the count word through std::atomic<uintptr_t> and operate on it
directly.  A relaxed load seeds each loop; compare_exchange_weak is
acquire-release on the retain and weak-flag paths and release on the
decrement; the final release takes an acquire fence before running
-dealloc.  No functional or ABI change.

Isolating just the atomic pattern with no Objective-C involved, on a
32-core x86-64 host with clang 18.1.3:

  __sync seed + CAS, seq_cst        27.6 ns
  relaxed load seed + acq/rel CAS   14.4 ns

and a retain/release pair on a real object drops from about 31 ns to about
16 ns.

All 194 tests pass, along with an 8-thread retain/release balance stress
where the count returns to its exact starting value and an 8-thread weak
load/store/dealloc race stress.
Comment thread gc_none.c Outdated
// retaining/releasing adjacent small objects ping-pong a shared line
// (measured: distinct-object retain/release at 4 threads 127ns -> 24ns).
//
// The cost is memory: cache-line alignment rounds every small allocation up to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that this is specific to the malloc implementation used. Handling aligned allocations of things that are not a multiple of the alignment size is painful, so snmalloc rounds up the size.

I am also curious how much of the false-sharing overhead is an artefact of the allocation policy. Do you know how snmalloc affects your workload with and without this relative to the baseline?

@DTW-Thalion DTW-Thalion Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question - so yesterday I wrote a test to try to answer that and I sent you an e-mail on the results. Short answer: snmalloc does not remove the cliff. When the objects are allocated together it packs the 16-byte instances onto shared cache lines the same as glibc, and is in fact worse at high thread counts. Its size-class rounding does not spread these apart.

… CAS loop

The strong-retain and release fast paths spun a compare-exchange loop that
re-tried on every lost race, so under contention they wasted work that a single
read-modify-write instruction avoids.

A strong retain runs while the caller still owns a reference, so the object
cannot be at (or reach) the deallocating sentinel; its increment is therefore a
single fetch_add.  Release becomes a single fetch_sub, handling the
last-reference and saturation edges after the fact.  The weak-to-strong retain
keeps the compare-exchange loop, because it can race a concurrent final release
and has to check-and-increment atomically to avoid resurrecting a dying object.

Reserve the bit below the weak flag as a guard, so an optimistic increment can
never carry a saturating count into the weak flag.

FastRefCount.m mirrors the reference-count layout and is updated for the guard
bit; the saturation and weak-at-saturation cases it exercises still pass.

Measured on a 32-core machine: retain/release falls from 16.1 to 11.3 ns with no
contention, and a single shared object under 24 threads from 2143 to 1124 ns.
@DTW-Thalion
DTW-Thalion force-pushed the arc-refcount-isolation branch 2 times, most recently from 70cb22a to 79b1c79 Compare July 24, 2026 13:12
@DTW-Thalion
DTW-Thalion force-pushed the arc-refcount-isolation branch from 79b1c79 to e4a58ca Compare July 24, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants