Skip to content

We upgraded from 16 cores to 64. Why did throughput fall?

The Machine Had More Resources. Why Was It Slower? · Part 3 of 6 · 6 min read

Read the worked explanation ↓

Interview question. A service maintains live device state in memory. Moving it from a 16-core server to a 64-core machine reduces throughput from 180,000 to 130,000 updates per second. CPU is busy, yet each update does very little computation. Investigate shared-state contention, distinguish it from scheduling and NUMA effects, and redesign the hot path.

The tempting answer is that the bigger machine has slower cores. It might. But I would first ask for a CPU profile showing where the additional time went. A server with four times the cores has more execution capacity only for work that can proceed independently. If each update waits for the same lock, adding threads mostly increases the number of people standing in front of that lock.

Suppose each device update needs 4 microseconds inside a global critical section to update a table and an aggregate. At most one thread executes it at a time. Ignoring overhead, the lock sets a ceiling near 250,000 critical-section executions per second. Real capacity is lower because of lock handoff, cache-coherence traffic, and work around the protected operation. No number of worker threads can make that serialized section run on multiple cores simultaneously.

A single global lock forces many workers through one critical section, while per-shard state provides independent ownership paths.
A single global lock forces many workers through one critical section, while per-shard state provides independent ownership paths.
Think first

A 64-core telemetry server receives updates for 3 million devices. A CPU profile shows 28% of sampled time near a global counter and Java Flight Recorder shows many blocked monitor acquisitions. Design two experiments to identify the bottleneck, then propose a sharded architecture preserving per-device order and describe its failure modes.

Take a few minutes to form an approach, then compare it with the worked explanation.

Reveal the worked explanation

What the additional cores are actually doing

When a lock is free, acquiring it may be a cheap atomic operation. Under contention, an implementation may spin briefly, wait in the kernel, or combine the two approaches. A Linux futex can support efficient blocking when an atomic user-space lock cannot be acquired. The precise behavior depends on the runtime and lock primitive. A Java synchronized monitor, a ReentrantLock, and a native mutex have different implementation details, so do not generalize from one wait profile.

Imagine 64 threads incrementing a shared counter. The cache line containing that counter must move among cores to preserve coherent reads and writes. Even with an atomic increment and no explicit mutex, that line can become a serialization point. A flame graph with a high share in locking, atomic operations, or their callers is more useful than a screenshot that says CPU is 95% busy.

I would compare elapsed time in application computation, runnable waiting, blocked waiting, and lock ownership. On Java, JFR monitor-blocking events and thread profiles can identify hot monitors. On native Linux programs, perf tools can help expose contention, subject to permissions and the instrumented lock type. A lock-wait histogram is often more revealing than an average: a few unlucky requests can wait behind long holders.

A lock owner can also be preempted while it holds a lock. On an overloaded machine, waiters then remain blocked even though the critical section would be short on an idle core. More runnable threads can amplify scheduling delay. This is distinct from an actual deadlock, where no forward progress occurs due to a cycle or unreleased resource.

Find the shared operation, not just the slow line

I want to know what the protected state represents. If the mutex protects the whole device map, but updates to different devices do not depend on each other, its scope is probably too broad. If it protects a global monotonic sequence that all updates must share, partitioning may change the product contract. Those require different designs.

An apparently innocent metrics counter can be the bottleneck. Forty workers successfully handle independent devices, then every request increments the same global statistic. Replacing the data structure while retaining the counter just moves the contention point. A good profile should show which cache lines or lock instances concentrate contention and which callers hold them longest.

One experiment is to fix the thread count at 16 on the new 64-core host. If throughput recovers, uncontrolled concurrency is a clue. Another is to replace the shared counter temporarily with per-thread counters in a nonproduction replay and observe whether lock wait falls. Neither experiment proves the final design, but they isolate the cause much better than a blind heap or thread-pool increase.

Divide ownership along the key that matters

For independent device updates, I would partition state by a stable device identifier into, say, 64 logical shards. Each shard can have one owner or a narrow lock. An update is routed to its owner, where the device-specific changes occur in order. An aggregate across devices can be computed asynchronously or with periodic snapshots, if the product tolerates that delay. This replaces one global contention point with many independent paths.

The nuance is ordering. If updates to the same device arrive through multiple network consumers, each device needs a consistent owner or sequencing rule. Striping locks across hash buckets improves parallelism but only if we preserve the constraints that originally motivated the lock. A lock-free queue or actor model does not magically make shared mutable state safe. Ownership simplifies the correctness argument, but it adds mailbox capacity, migration and hot-device considerations.

Batching is another option. Holding a lock once for a batch of ten updates can reduce acquisition overhead, though it may also increase lock hold time and harm latency. Measure throughput, fairness and p99 rather than assuming a larger batch is always superior.

Check the other possible bottlenecks

If threads are not waiting on locks, compare per-core performance, memory bandwidth, LLC misses and CPU placement. A 64-core machine with multiple NUMA nodes might turn local accesses into remote accesses. Power management and frequency scaling can also change single-thread speed. Those are legitimate alternatives, not excuses to ignore a profile that clearly shows global lock waiting.

My final interview recommendation is to keep the correctness invariant visible while narrowing the serialization domain. The number of cores is not a capacity target. The real capacity limit is the slowest resource that every update must visit.

Go deeper

Amdahl's law sets an idealized ceiling for parallel speedup when some work remains serial. Real lock contention can be worse than that ideal because handoffs, cache-coherence invalidations, scheduling and contention-management overhead grow with thread count. Atomic read-modify-write operations can also serialize access through a shared cache line without explicit lock waits. Profiling must distinguish on-CPU spinning from off-CPU blocking. Shared-state partitioning introduces new ownership migration and aggregation consistency questions, which belong in the design contract.

Check your reasoning

  • Profile actual lock owners, blocked or spinning waiters, and atomic hot spots.
  • Explain why adding cores cannot parallelize a globally serialized critical section.
  • Distinguish lock contention from CPU frequency and NUMA changes.
  • Design per-key or per-shard state ownership preserving ordering constraints.
  • Measure batch and aggregation consistency tradeoffs against p99 and throughput.

Common mistake: An atomic counter removes all serialization and cache coherence contention across cores.

A larger server shows shared-state contention. Which approach improves parallelism without silently weakening correctness?

Read the answer explanation

Profile contention and partition independent device state while specifying global invariant handling — An atomic counter may still serialize cache-line ownership, and some shared invariants remain.

Apply it elsewhere

A rate limiter tracks tokens for millions of API keys. Every request passes through a single lock because the product also demands a global emergency limit. Design a partitioned fast path and a defensible enforcement strategy for the global constraint.

Compare your approach

Separate lock acquisition and ownership time, cache-line bouncing, owner preemption, actual correctness invariants, per-key state ownership, aggregation tradeoffs and load-test evidence.

One more constraint

Does replacing a global mutex with an atomic counter guarantee that contention will disappear on a 64-core host?

Quick revision

More cores do not help when every request visits the same serialized state. Profile lock wait, atomic hot spots, runnable delay and cache-line contention before blaming the new hardware. A short global critical section can cap throughput and become slower under contention. Shard independent state by key, preserve per-key ordering, and redesign global aggregates if their consistency requirements allow it. Compare throughput and tails at fixed thread counts, and rule out NUMA or frequency differences as separate causes.

References