Skip to content

Is multicore ARC scaling in scope for libobjc2? some retain/release and weak-reference contention findings #398

Description

@DTW-Thalion

We run a number of GNUstep-based services at Thalion on a high-throughput server farm, and we have been trying to squeeze more performance out of the runtime under that kind of load (many cores, heavily multithreaded, long-lived processes). Before we take this any further or propose anything concrete, I wanted to check with you whether this is even a direction the project cares about, because how much of it is worth doing depends a lot on what a typical libobjc2 workload actually looks like.

My impression is that a large share of GNUstep use is GUI or otherwise single-threaded, where the runtime already performs very well. The things we are running into only really show up under sustained multicore contention. So the first question is: do you consider the many-core, heavily-threaded case to be in scope for libobjc2, or are most users single-threaded enough that improving multicore scaling would be effort spent on a case few people would benefit from? If the answer is "most workloads are single-threaded", so be it, what follows may not be worht inclusion in the master repo.

To make the question concrete, here is what we found: We profiled the hot paths under 1/2/4-thread scaling. The first thing worth saying is that dispatch is already excellent and we do not think it should be touched: it is lock-free, it scales flat across cores, and polymorphic dispatch measures the same as monomorphic (about 3.0 vs 3.2 ns), so the trie is genuinely O(1) with no cache-thrash penalty. Swapping in objc4-style per-class hash caches would be a lateral move at best and would give up the never-miss property.

The operations that scale badly are all in ARC and the metadata locks, not dispatch.

The one finding that helps even single-threaded callers is the retain/release atomics. A retain/release pair is about 31 ns, and an isolated micro-test (no Objective-C, just the atomic pattern) shows that nearly all of that is the atomic pattern itself:

libobjc2-style pair  (fetch_add(p,0) seed + CAS, seq_cst)   27.6 ns
relaxed-load seed    + acq/rel CAS                          14.4 ns
plain fetch_add/fetch_sub (relaxed)                          8.1 ns

The __sync_fetch_and_add(refCount, 0) used to seed the CAS loop is a full read-modify-write barrier where a plain relaxed load would do, and the whole loop runs under sequential consistency. Seeding with a relaxed load, using release ordering on the decrement, and taking an acquire fence before -dealloc took the pair from about 31 ns to about 17 ns on our host (a bit under 2x), and as far as we can tell it preserves the ordering the code needs (the CAS loop already tolerates a stale seed). This one is attractive precisely because it is not a multicore-only concern: it is the single most frequent ARC operation and the win shows up on one thread.

The bigger multicore item is the weak-reference table. Every weak load/store/destroy takes one process-global mutex, and because it is a plain mutex rather than a reader-writer lock, concurrent weak loads serialise against each other too. In our measurements weak store goes from about 87 ns/op on one thread to about 714 ns/op on four:

storeWeak pairs, distinct slots    1T 87    2T 478    4T 714 ns/op
loadWeakRetained, distinct slots   1T 62    2T 220    4T 444 ns/op

We prototyped sharding the weak table by object address, along the lines of objc4's SideTable model, which took storeWeak at four threads from about 714 ns down to about 36 ns and made the scaling flat (roughly 36 ns at one, two and four threads). I want to be honest that this is the least trivial of the changes: our first cut had a use-after-free (a reader picked its shard from a control block that another thread was concurrently freeing), which we fixed by making the control blocks type-stable per shard so the shard index is immutable and safe to read without a lock. That experience is part of why I am asking rather than just sending a patch. The global lock may well be a deliberate simplicity/robustness tradeoff on your part, and I would rather hear your view on whether the added complexity is worth it before writing it up properly.

There is also a false-sharing effect on the refcount word that is invisible in single-threaded profiling. Because the count sits immediately before the object body, two small objects allocated close together put their refcount words on the same cache line, so independent threads retaining independent objects ping-pong that line:

distinct objects, unpadded     1T 31   2T 83   4T 127 ns/op
distinct objects, 128B padded  1T 31   2T 31   4T  31 ns/op

Cache-line-isolating the refcount word removes the cliff (it took the distinct-object case from about 89 ns back to 16 ns at four threads for us), but it roughly doubles the memory footprint of the smallest objects (we measured 32 to 64 bytes for a 16-byte instance), so we would only ever see that as an opt-in build flag, never a default.

Separately, while reading the dtable code we noticed two ordering things that look like they could bite on weakly-ordered targets (aarch64 in particular): a few dtable publication points use plain stores where a release store looks intended, and there is a narrow window in objc_resize_dtables where a class can be seen as installed against the new uninstalled_dtable pointer before it has been rewritten, which could route a message to forwarding without running +initialize. These are independent of the performance work and I would rather raise them on their own if they are of interest, so I am only mentioning them here for completeness.

So the questions, in order:

Is the multicore/many-core case something you want libobjc2 to be good at, or is single-threaded use enough the norm that this is low priority for the project?

If nothing else, the relaxed-atomics change looks like a clean, low-risk, single-threaded win. Would you welcome a PR for just that on its own?

For the weak-table sharding, would you want that as a PR, or is the single global lock a tradeoff you would rather keep? If you would take it, do you have a preference on approach (reuse the existing lock_for_pointer address striping, versus a dedicated shard array)?

All the numbers above are from a development machine 32-core x86-64 host with clang 18.1.3, measured against a fresh master build with and without the changes; absolute values vary by machine, but the scaling shapes (flat versus collapsing) are the part we are relying on and those are microarchitecture-independent. All 194 upstream tests pass with the changes applied, and the retain/release and weak-reference stress tests we wrote pass too. Happy to share the benchmark harnesses.

Thanks!

cc @davidchisnall @rfm @gcasa

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions