Representative interview topic

False Sharing Interview: How Do You Diagnose and Fix Cache-Line Contention?

GeneralHard
Offer.cc Editorial TeamPublished Updated

Question

A C++17 metrics collector stores 8 atomic counters contiguously. Eight threads pinned to different physical cores each perform 50 million relaxed increments on a distinct counter. The total is correct, but throughput falls as threads are added, and a profiler maps many HITM events to the single 64-byte cache line containing those counters. Explain the cause, prove that it is false rather than true sharing or scheduling, design a fix, and show how you would validate both the gain and the space cost.

Problem and Applicable Scenario

A C++17 metrics collector stores 8 atomic counters contiguously. Eight threads pinned to different physical cores each perform 50 million relaxed increments on a distinct counter. On the measured target, each counter occupies 8 bytes and the object starts on a 64-byte boundary, so all eight counters occupy one 64-byte cache line. The final total is the correct 400 million, but throughput gets worse as threads are added. perf c2c or an equivalent profiler maps many HITM events to that line.

This problem tests multicore cache coherence, data layout, performance evidence, and experiment design. It applies to C++, infrastructure, low-latency, database-kernel, and performance-engineering roles. The core skill crosses language, operating-system, and hardware boundaries, so the category is general. A relaxed operation weakens memory-order constraints; it does not remove the coherence traffic caused by an atomic write.

Treat 64 bytes as a measured property of this target, not a universal constant. A fix should prefer the implementation's destructive-interference size or a layout validated on the supported target, while preserving comparable packed and fixed benchmarks.

What the Interviewer Evaluates

First, can the candidate separate correctness from scalability? The threads write distinct atomic objects, so no updates are lost. The processor maintains coherence at cache-line granularity, so independent addresses can still invalidate one another.

Second, can the candidate explain write ownership? Before one core modifies any counter in the line, it needs a writable copy. When another core modifies a different counter in that line, it invalidates the previous core's copy. The line travels among cores and creates serialization unrelated to an application data dependency.

Third, can the candidate build an evidence chain? A strong answer does not jump from “multithreading is slower” to false sharing. It compares one and many threads, pins cores, maps addresses and field offsets, locates HITM hotspots, observes the isolated layout, and rules out true sharing, locks, CPU migration, NUMA, and memory bandwidth.

Fourth, can the candidate choose the lowest-cost fix? Separating hot writers by destructive-interference boundaries fixes the layout. If the total is read only after the job, thread-local ordinary counters plus one reduction are better because they remove most shared writes. A live-read requirement changes the choice.

Fifth, can the candidate state the space boundary? On this target, expanding an 8-byte slot to 64 bytes makes a million slots eight times as large and can increase cache and TLB pressure. Padding every field without measurement is not a sound optimization.

Questions to Clarify Before Answering

  • Does each counter truly have one exclusive writer? If several threads update one object, that is true sharing; separating adjacent fields cannot remove ownership contention on the same object.
  • How fresh must reads be? A value read only after join can use a thread-local ordinary integer. Online scraping may require atomic shards and read-time summation.
  • Do the threads run on different physical cores? Same-core time slicing, migration, oversubscription, or SMT changes the result. Pin the reproduction and record topology.
  • What are the target's interference size and actual layout? Inspect the implementation constant, sizeof, alignof, array stride, and addresses rather than trusting source order.
  • Do HITM samples map to different field offsets? HITM at one address suggests true sharing. Different writers touching different offsets in one line support false sharing.

30-Second Answer Framework

“The correct result shows that atomicity works; the scaling failure comes from cache-line ownership. The eight threads write eight addresses, but those addresses occupy one coherence line. Each write can invalidate copies held by other cores, and the next writer must reacquire ownership, so the line keeps moving. memory_order_relaxed removes cross-object ordering, but it is still a write and cannot bypass coherence.

I would pin threads to separate physical cores, keep the workload fixed, measure throughput from one through eight threads, and use perf c2c to map HITM hotspots to object addresses and field offsets. If different threads hit different counters in one line, and separating slots by the implementation's destructive-interference size reduces both HITM and elapsed time, that is evidence of false sharing.

I would first reduce sharing: use a thread-local counter and publish once when live reads are unnecessary. For live reads, use cache-line-separated atomic shards and sum them on read. I would verify that the total remains 400 million, work per thread is identical, the speedup repeats, and the eightfold slot-space cost does not create a larger cache or TLB problem.”

Step-by-Step Deep Dive

Step 1: Explain the bottleneck in cache-line terms

Cache coherence tracks lines. Several cores can hold read-only copies at once. Before a core writes even one byte in a line, it must obtain a state that permits modification and invalidate copies in other cores. The next core writing another byte in the same line repeats the transfer.

Each thread in this problem writes only its own counter, so the program has no semantic contention on a shared variable. The hardware sees repeated writes to one coherence unit. The sharing is “false” because it comes from physical layout rather than an algorithmic dependency. Atomics protect each value; they do not promise that several adjacent atomic objects scale independently.

Read-only sharing normally permits shared copies. Frequent writes drive the ownership transfers, so look for different cores writing one line rather than labeling all commonly read data as a problem.

Step 2: Prove the diagnosis instead of guessing with padding

Build four groups of evidence:

  1. Measure 1, 2, 4, and 8 threads with the same work, reporting increments per second and time per operation.
  2. Pin threads to separate physical cores and record CPU migration, context switches, and NUMA placement.
  3. Print every slot address and offset, confirming different writers, different addresses, and one line.
  4. Collect cache-to-cache transfers and map them to source and data objects.

On Linux, a reproducible benchmark can use:

bash
perf c2c record -g -- ./counter-bench packed
perf c2c report --call-graph none

HITM means a load hit a modified line in another cache. It supports the claim that modified-line transfer occurred, but it does not prove false sharing by itself. Inspect address, offset, and writer. If all threads update one counter, that is true sharing. A lock next to protected data can produce a similar pattern.

Step 3: Separate hot writer slots through layout

C++17 exposes an implementation-defined destructive-interference size. The following array elements have that alignment, and each element's size is at least the same interval, keeping neighboring counters from being packed into one destructive-interference region:

cpp
#include <array>
#include <atomic>
#include <cstdint>
#include <new>

struct PackedCounter {
  std::atomic<std::uint64_t> value{0};
};

struct alignas(std::hardware_destructive_interference_size) SeparatedCounter {
  std::atomic<std::uint64_t> value{0};
};

static_assert(
  sizeof(SeparatedCounter) >= std::hardware_destructive_interference_size
);

std::array<PackedCounter, 8> packed;
std::array<SeparatedCounter, 8> separated;

The implementation supplies this constant, so the build toolchain and execution target still need to match. If the target library lacks it, derive the layout policy from validated properties of supported platforms and verify addresses and performance. Hard-coding 64 as a universal value confuses a correct observation on one machine with portability.

For an array, inspect three things: first-element alignment, element stride, and the hot field's offset inside every element. Aligning only the array's first address while retaining an 8-byte stride does not separate counters. Ad hoc trailing padding can also break when fields change.

Step 4: Prefer removing shared writes

Line separation still performs 400 million atomic read-modify-writes. If the total is needed only after work finishes, each thread can count in a register or stack-local ordinary integer, publish one partial result before exit, and let the main thread reduce after join. Shared publication falls from 50 million operations per thread to one.

If monitoring must scrape a near-live value, retain per-thread or per-core shards in noninterfering slots. The reader sums eight shards. This adds read amplification and a briefly inconsistent snapshot. A strictly linearizable total is simpler with one atomic, but that reintroduces true sharing; the answer should state whether consistency or write throughput wins.

Batching is a middle ground. A worker accumulates locally and periodically applies fetch_add to a global counter. It reduces ownership transfers but lets the visible total lag by at most one batch per writer. Choose the batch from the permitted staleness and measurements.

Step 5: Compare space, locality, and maintenance costs

With the measured 8-byte object and 64-byte interference interval in this problem, a separated slot is eight times the compact slot. Eight worker slots are cheap. Padding a counter for each of a million entities would expand the working set and increase cache and page-table pressure.

Separate only fields proven to be frequent writers on different cores. Fields read together and written rarely can remain compact. Low-frequency statistics can publish in batches. Large entity sets can shard by thread rather than pad by entity. The optimization target is measured ownership transfer, not a struct's appearance.

Protect against layout regressions. Adding fields, changing inheritance, replacing an allocator, or changing the compilation target can alter stride. Layout assertions, address checks, and a focused performance benchmark are more durable than a comment claiming that a structure is 64 bytes.

Step 6: Use counterfactual experiments to exclude other bottlenecks

Test at least three versions: a packed atomic array, a separated atomic array, and thread-local counting followed by reduction. If only the latter two scale and cache-line transfers fall with them, the causal case is much stronger.

If separation remains slow, inspect a shared control variable, the throughput limit of atomic instructions, remote NUMA memory, CPU migration, a workload too small for thread-start and synchronization costs, and saturated memory bandwidth. False sharing can coexist with those bottlenecks.

Do not report the single fastest run. Warm up, repeat, and report the median and spread while holding compiler flags, frequency policy, thread topology, and input constant. Every performance result also needs a correctness check: the counters or reduced value must still equal exactly 400 million.

High-Quality Sample Answer

“I would separate correctness from scalability first. Eight threads update eight distinct atomic objects, so relaxed atomics can preserve every counter. The objects sit in one cache line, however, and hardware grants write ownership by line. After core 0 modifies its 8 bytes, core 1 still needs ownership of the whole line to modify a different 8 bytes and invalidates core 0's copy. As writers alternate, the line travels among cores and physical layout serializes independent counters. Relaxed removes ordering guarantees across operations, not cache coherence.

I would not conclude from the scaling curve alone. I would pin 1, 2, 4, and 8 threads to separate physical cores, preserve 50 million operations per thread, and record throughput, migration, and topology. Then I would use perf c2c to map HITM to array-element offsets. Different writers at different offsets in one line indicate false sharing. The same offset suggests true sharing, while locks and NUMA need separate checks.

For live reads, I would align each shard to std::hardware_destructive_interference_size, ensure the array stride is at least that value, and sum shards on read. If reads happen only when the job finishes, thread-local ordinary integers with one publication and a post-join reduction are better because they remove sharing from the hot path.

I would benchmark packed, separated, and local-reduction versions repeatedly on the same machine and build. All totals must remain 400 million; the counter line's HITM and elapsed time should fall together after separation, while local reduction should remove more atomic cost. I would also record the space cost: on this target, a slot grows from 8 bytes to at least 64, an eightfold increase that should not be applied indiscriminately to a million cold counters.”

Common Mistakes

  • Claiming that atomics cannot false-share → atomics make object operations indivisible but do not change coherence granularity → separate correctness from line ownership.
  • Claiming that relaxed disables coherence → it weakens language-level ordering while writes remain coherent among cores → distinguish atomicity, ordering, and hardware coherence.
  • Declaring false sharing from HITM alone → true sharing and lock fields also transfer modified lines → map addresses, offsets, and writers.
  • Aligning only the array base → 8-byte elements can still occupy the same line → control both element alignment and stride.
  • Always hard-coding 64 bytes → interference size depends on implementation and target → use an implementation value or a validated platform policy and recheck layout.
  • Padding every field → working-set, cache, and TLB costs can exceed the gain → separate only proven hot cross-core writers.
  • Comparing one timing run → frequency, migration, and warm-up create noise → pin topology, repeat, and check HITM plus correctness.
  • Ignoring local reduction → padding improves layout but retains atomic work → reduce shared writes according to freshness needs.

Follow-up Questions and Responses

Follow-up 1: Does replacing memory_order_relaxed atomics with ordinary integers fix it?

If every thread permanently owns a distinct element, ordinary integers do not create a data race, but adjacent elements can still false-share. Thread-local ordinary integers are best when reading occurs after join. If another thread reads the shared array concurrently, you must reestablish synchronization and visibility rather than merely deleting atomics.

Follow-up 2: Why might HITM remain nonzero after separation?

The program can still have true sharing in a start barrier, work queue, lock, allocator metadata, or global progress variable, and the reader touches shards. First confirm that the original counter line's hotspot fell, then inspect remaining addresses. The goal is to remove transfers without a business dependency, not to promise zero HITM everywhere.

Follow-up 3: How can thread-local reduction support live metrics?

Have each worker publish its local delta to a separated shard once per batch, and let the scraper sum shards. Larger batches reduce write traffic but make observations staler; smaller batches improve freshness but increase contention. State the maximum acceptable staleness, choose a batch from that bound, and measure it.

Follow-up 4: Why not use one global atomic counter?

It uses the least space, is simple to read, and provides one modification order, but every writer modifies the same object, creating true sharing. It may be right for a low update rate or a strong consistency requirement. High-frequency statistics usually favor sharding and read-time aggregation. Fixing false sharing cannot remove true sharing deliberately required by the contract.

Follow-up 5: What if deployment machines have a different cache-line size from the build machine?

The standard-library value is an implementation-defined build-time property. A single binary intended for heterogeneous hardware needs a verified ABI and interference interval across supported targets. Choose a conservative layout covering those targets or build per target, then run address and performance validation on every machine class. A 64-byte observation on one development host cannot establish every deployment layout.

Public sources

Related questions