ARC: optional cache-line isolation of the reference count - #403
Conversation
| // 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.
79b1c79 to
e4a58ca
Compare
| _aligned_malloc(size, 32); | ||
| addr = _aligned_malloc(size, OBJC_ALLOC_ALIGN); | ||
| memset(addr, 0, size); | ||
| #elif OBJC_ALLOC_ALIGN > 16 |
There was a problem hiding this comment.
The 16 here is hard coding an ABI detail. This probably should be if constexpr (OBJC_ALLOC_ALIGN > alignof(std::max_align_t))
8662de9 to
26aae97
Compare
… 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.
26aae97 to
6636c05
Compare
6636c05 to
07503fb
Compare
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.