A single shared atomic counter incremented on every request becomes a throughput bottleneck on a many-core server, and adding cores makes it worse rather than better. Explain the mechanism and describe the techniques that relieve it, with their costs.
answer
- atomic = exclusive cache line = serialisation
- line ping-pong; more cores, less throughput
- stripe into padded per-line cells, sum on read
- thread-local accumulate + batch publish
- you trade an exact instantaneous total
basics
~20 sEvery atomic update needs exclusive ownership of one cache line, so all cores serialise on transferring it and the line ping-pongs. Fixes: split the counter into per-thread or striped cells summed only on read; batch updates locally; back off on retry; or sample instead of counting exactly. Costs are memory, read-time summation, and loss of an exact instantaneous value.
solid answer
~1 minThe counter is one cache line, and an atomic read-modify-write requires that line in an exclusive state. With many cores updating it, the line migrates continuously and each update pays an interconnect round trip while others stall. Throughput saturates and then declines as cores are added — the hardware serialises what looks like parallel code. Relief, roughly in order of effectiveness: - **Stripe or shard.** Keep an array of cells, each padded to its own cache line, and have each thread update the cell chosen by its identity. Reads sum the cells. Contention drops by roughly the stripe count; costs are memory, a summing read, and the fact that a read is not an instantaneous snapshot. - **Per-thread accumulation.** Increment thread-local state with no atomics at all, and publish periodically or at the end. Cheapest by far when exact live totals are not needed. - **Batch.** Count locally and apply one atomic add per batch of N, cutting atomic traffic by N. - **Back off** on retry loops so losers stop stealing the line, and skip no-op updates with a cheap read first. - **Sample or approximate** when the metric tolerates it. All of them trade exactness or freshness of the total for scalability.
code
text · 10 linescells[N] # each cell padded to a full cache line
increment():
i = threadIndex() % N
fetch_add(cells[i].value, 1) # threads rarely collide
read():
sum = 0
for c in cells: sum += c.value.load()
return sum # accurate-ish, not an instantaneous snapshotgo deeper
Know that atomic updates need exclusive access to one cache line, so many threads hitting one counter end up queuing.
Describe striping into padded per-thread cells summed on read, and note the read is approximate.
Explain the coherence traffic mechanism, why throughput can fall as cores increase, and combine batching, unconditional primitives, and backoff appropriately.
Start from what the number is for: statistics tolerate approximation, limits do not — shard the resource rather than the counter for enforcement, and require profiling before adding any of this complexity.
## The mechanism: one line, exclusive ownership An atomic read-modify-write is not magic; it is implemented by the cache-coherence protocol. To perform it, a core must hold the target cache line in an exclusive/modified state, which requires invalidating that line in every other core's cache and pulling it across the interconnect. That is an operation measured in tens to hundreds of nanoseconds, versus about a nanosecond for a local cache hit. When many cores update the same counter, the line can only be in one place at a time, so it bounces from core to core — the classic ping-pong. Every update waits for a transfer, and each transfer invalidates the line for everyone else. The result is that a workload with no logical dependency between requests becomes serialised on a single memory location, and adding cores adds contenders rather than throughput: the curve rises, flattens, and then declines, because coherence traffic grows faster than useful work. If the counter uses a compare-and-swap loop rather than an unconditional add, it is worse still: losing attempts also acquire the line exclusively, so wasted work actively slows the eventual winner. ## Fix 1 — stop sharing the line (striping) The structural answer is to convert one contended location into many uncontended ones. Keep an array of cells; each thread maps to a cell by an identity-derived index; each update touches only that cell. Reading the total sums all cells. Three details make or break it: - **Pad each cell to its own cache line.** If two cells share a line, the cores updating them still fight over it even though the variables are logically independent — you have kept all the contention while adding memory. - **Choose the stripe count sensibly.** Roughly the number of cores that update concurrently; more stripes cost memory and make reads slower. - **Accept the read semantics.** Summing N cells that are being mutated concurrently gives a value that was never simultaneously true — it is consistent with *some* recent interleaving, not with any single instant. For metrics and statistics this is fine. For anything enforcing a limit, it is not. This is the idea behind striped-counter abstractions that platforms provide for high-contention statistics. ## Fix 2 — don't share at all If each worker keeps a plain, non-atomic local count and publishes it periodically or at shutdown, the hot path has zero atomic operations and zero coherence traffic. This is the cheapest option whenever a slightly delayed total is acceptable, which covers most counting. The cost is staleness proportional to the publish interval and the need to handle threads that die without publishing. ## Fix 3 — batch Accumulate locally and perform one atomic add per N events. Atomic traffic falls by a factor of N; the shared total lags by at most N per thread. This composes with striping and is often the simplest change to an existing hot loop. ## Fix 4 — reduce the damage of contention you keep When the shared update must stay: - **Prefer an unconditional primitive** (fetch-and-add) over a compare-and-swap loop, since it cannot fail and needs one operation per update. - **Back off** between retries — exponential with jitter — so losers stop stealing the line while a winner completes. - **Read before writing.** If the update is frequently a no-op (setting a flag already set, raising a maximum already higher), a cheap shared read avoids the exclusive acquisition entirely; shared reads can be replicated in many caches at once. - **Separate hot fields onto their own lines** so unrelated variables do not drag the line around. ## Fix 5 — relax the requirement Often the cheapest fix is to question the requirement. Do you need an exact count, or a rate? Sampling one in K events and multiplying, or using an approximate counting scheme, removes most of the traffic. Similarly, a limit enforced approximately per shard (each shard gets a quota) removes the global counter altogether at the price of imprecise global enforcement. ## What you give up Every technique here trades the same thing: a single exact, instantaneous, globally visible number. Striping and batching give you an eventually-accurate total; per-thread counters give you a delayed one; sampling gives you an estimate. So the design question is always "what is this number for?" A metric or a statistic tolerates all of it. A semaphore, a quota, or an admission limit does not — those need a genuinely shared decision point, and there the honest answer is to shard the *resource* so each shard has its own limit, rather than to weaken the counter. And measure first: a counter that is only touched a few thousand times a second is not a bottleneck, and striping it is complexity for nothing.
- Why must each stripe be padded onto its own cache line?Coherence works at cache-line granularity, so two counters in the same line are, to the hardware, the same contended resource. Cores updating logically independent stripes would still invalidate each other's copies and transfer the line back and forth, leaving the contention untouched while consuming extra memory. Padding each cell to a full line is what actually converts one hot location into several cold ones.
- When is striping the wrong answer?When the counter enforces a limit rather than reporting a statistic. Summing stripes yields a value that was never simultaneously true and is stale by the time you act on it, so it cannot safely gate admission, permits, or quotas. In those cases either keep a single shared decision point and accept the contention, or shard the resource itself so each shard enforces its own smaller quota independently.
One shared clipboard everyone must physically hold to write a tally. Give each person their own sheet and add them up at the end; the running total is no longer instantly knowable, but nobody queues.
saying these in an interview costs you the question
- Striping without padding, so the cells still share a cache line
- Assuming atomics are cheap because no lock is involved
- Using a summed striped counter to enforce a hard limit
- Reaching for backoff tuning before reducing sharing
- Optimising a counter that profiling never showed to be hot