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
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:
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:
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:
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_dtableswhere a class can be seen as installed against the newuninstalled_dtablepointer 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_pointeraddress 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