ARC: optional cache-line isolation of the reference count#403
ARC: optional cache-line isolation of the reference count#403DTW-Thalion wants to merge 4 commits into
Conversation
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.
| // 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
70cb22a to
79b1c79
Compare
79b1c79 to
e4a58ca
Compare
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.