Skip to content

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

Merged
davidchisnall merged 3 commits into
gnustep:masterfrom
DTW-Thalion:arc-refcount-isolation
Aug 2, 2026
Merged

ARC: optional cache-line isolation of the reference count#403
davidchisnall merged 3 commits into
gnustep:masterfrom
DTW-Thalion:arc-refcount-isolation

Conversation

@DTW-Thalion

@DTW-Thalion DTW-Thalion commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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 share no reference.

The reference-count word sits immediately before the object, so aligning each allocation to a cache line puts each object's count on a line of its own. Distinct-object retain and release at four threads goes from 127 to 24 ns. The cost is memory: the smallest instances round up, roughly doubling their footprint, so the alignment is a build setting, OBJC_ALLOC_ALIGN, and a build can trade the scaling back for the memory.

The larger alignment is requested only where it is actually larger than the alignment the plain allocator already gives, tested against alignof(max_align_t) rather than against a written-out number, and a value that is not a power of two is rejected at compile time rather than failing every allocation at run time.

On the question of the allocator: snmalloc does not remove the cliff. Allocated together, it packs 16-byte instances onto shared lines much as glibc does, and is worse at high thread counts; its size-class rounding does not spread them apart.

#399 has merged, so the diff here is this change alone.

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.

@DTW-Thalion
DTW-Thalion force-pushed the arc-refcount-isolation branch 3 times, most recently from 79b1c79 to e4a58ca Compare July 24, 2026 13:24
Comment thread gc_none.c Outdated
_aligned_malloc(size, 32);
addr = _aligned_malloc(size, OBJC_ALLOC_ALIGN);
memset(addr, 0, size);
#elif OBJC_ALLOC_ALIGN > 16

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.

The 16 here is hard coding an ABI detail. This probably should be if constexpr (OBJC_ALLOC_ALIGN > alignof(std::max_align_t))

@DTW-Thalion
DTW-Thalion force-pushed the arc-refcount-isolation branch 2 times, most recently from 8662de9 to 26aae97 Compare July 28, 2026 03:17
… 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 from 26aae97 to 6636c05 Compare July 28, 2026 03:23
@DTW-Thalion
DTW-Thalion force-pushed the arc-refcount-isolation branch from 6636c05 to 07503fb Compare July 28, 2026 11:28
@DTW-Thalion
DTW-Thalion marked this pull request as ready for review July 29, 2026 22:51
@davidchisnall
davidchisnall merged commit 597d04a into gnustep:master Aug 2, 2026
87 of 91 checks passed
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