Workspace IndexAlgorithms › Day 39

False Sharing, Cache-Line Alignment, and NUMA Locality TODO

Algorithms · Day 39 / 100 · C. Concurrency & Performance Engineering (Day 36-51)

Concept

CPUs move memory in cache-line units (typically 64 bytes), so even two logically unrelated variables that happen to sit on the same line end up fighting over ownership of that line every time a different core writes to it. That's false sharing, and it shows up as a throughput collapse with no lock and no data race in sight. The fix is to align per-core counters or state on cache-line boundaries and separate them with padding. On NUMA systems there's an additional layer: access latency and bandwidth depend on which socket the memory is physically attached to, so locality — keeping a thread and the data it touches on the same node — matters. Neither problem shows up in algorithmic complexity; both only show up in measured scalability curves.

This is the classic reason throughput drops as you add more cores, and looking at a profiler's per-function time alone won't reveal the cause.

Code & Formula

# false sharing·캐시라인 정렬 — 무관한 변수가 같은 64바이트 캐시라인에 있으면 코어끼리 그 라인 소유권을 계속 뺏고 뺏긴다.
# 해결책은 코어별 카운터를 캐시라인 경계(보통 64B)로 패딩해 서로 다른 라인에 떨어뜨리는 것. (구조 시연 — 실측 타이밍은 생략)

import ctypes

CACHE_LINE = 64
NUM_CORES = 4

# 패딩 없는 버전: int64 카운터 4개가 한 캐시라인(64B = int64 8개)에 다 들어가 서로 겹친다.
class UnpaddedCounters(ctypes.Structure):
    _fields_ = [(f"c{i}", ctypes.c_int64) for i in range(NUM_CORES)]

# 패딩 버전: 카운터마다 캐시라인 크기만큼 자리를 배정해 서로 다른 라인에 놓는다.
class PaddedCounter(ctypes.Structure):
    _fields_ = [("value", ctypes.c_int64), ("_pad", ctypes.c_uint8 * (CACHE_LINE - 8))]

class PaddedCounters(ctypes.Structure):
    _fields_ = [(f"c{i}", PaddedCounter) for i in range(NUM_CORES)]

unpadded = UnpaddedCounters()
padded = PaddedCounters()

def cache_line_of(struct_instance, field_owner, field_name):
    base = ctypes.addressof(struct_instance)
    offset = field_owner.__dict__[field_name].offset
    return (base + offset) // CACHE_LINE

unpadded_lines = {cache_line_of(unpadded, UnpaddedCounters, f"c{i}") for i in range(NUM_CORES)}
padded_lines = {cache_line_of(padded, PaddedCounters, f"c{i}") for i in range(NUM_CORES)}

# 각 코어가 자기 카운터만 증가시키는 워크로드를 흉내낸다 (정오만 확인, 실측 타이밍은 재지 않음).
for i in range(NUM_CORES):
    setattr(unpadded, f"c{i}", i * 1_000_000)
    getattr(padded, f"c{i}").value = i * 1_000_000

print("struct size — unpadded:", ctypes.sizeof(unpadded), "bytes  padded:", ctypes.sizeof(padded), "bytes")
print("distinct cache lines — unpadded:", len(unpadded_lines), "/", NUM_CORES, "counters share", unpadded_lines)
print("distinct cache lines — padded  :", len(padded_lines), "/", NUM_CORES, "counters")
print("padding gives every core its own cache line:", len(padded_lines) == NUM_CORES)

Exercise

Allocate an array of counters, one per core, have each thread increment only its own index, and compare throughput between an unpadded version and a cache-line-aligned version.

Practical Connection

When parsing/validating chain data in parallel or splitting an order book into shards per core, if per-shard state isn't separated at cache-line granularity, the gains from parallelizing evaporate.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

false sharing·캐시라인 정렬·NUMA 지역성 TODO

Algorithms · Day 39 / 100 · C. 동시성·성능 엔지니어링 (Day 36–51)

개념

CPU는 메모리를 캐시라인 단위(보통 64바이트)로 주고받으므로, 논리적으로 무관한 두 변수라도 같은 라인에 있으면 서로 다른 코어의 쓰기가 그 라인의 소유권을 계속 뺏고 뺏기는 상태가 된다. 이것이 false sharing이며, 락도 없고 데이터 경쟁도 없는데 처리량만 급락하는 형태로 나타난다. 해결책은 코어별로 갱신되는 카운터나 상태를 캐시라인 경계에 정렬하고 패딩으로 분리하는 것이다. NUMA 시스템에서는 한 걸음 더 나아가, 메모리가 어느 소켓에 붙어 있느냐에 따라 접근 지연과 대역폭이 달라지므로 스레드와 그 스레드가 만지는 데이터를 같은 노드에 두는 지역성이 중요해진다. 두 문제 모두 알고리즘 복잡도에는 나타나지 않고 오직 실측 확장성 곡선에서만 드러난다.

코어를 늘렸는데 처리량이 오히려 떨어지는 전형적 원인이고, 프로파일러의 함수별 시간만 봐서는 원인이 보이지 않는다.

코드 · 수식

# false sharing·캐시라인 정렬 — 무관한 변수가 같은 64바이트 캐시라인에 있으면 코어끼리 그 라인 소유권을 계속 뺏고 뺏긴다.
# 해결책은 코어별 카운터를 캐시라인 경계(보통 64B)로 패딩해 서로 다른 라인에 떨어뜨리는 것. (구조 시연 — 실측 타이밍은 생략)

import ctypes

CACHE_LINE = 64
NUM_CORES = 4

# 패딩 없는 버전: int64 카운터 4개가 한 캐시라인(64B = int64 8개)에 다 들어가 서로 겹친다.
class UnpaddedCounters(ctypes.Structure):
    _fields_ = [(f"c{i}", ctypes.c_int64) for i in range(NUM_CORES)]

# 패딩 버전: 카운터마다 캐시라인 크기만큼 자리를 배정해 서로 다른 라인에 놓는다.
class PaddedCounter(ctypes.Structure):
    _fields_ = [("value", ctypes.c_int64), ("_pad", ctypes.c_uint8 * (CACHE_LINE - 8))]

class PaddedCounters(ctypes.Structure):
    _fields_ = [(f"c{i}", PaddedCounter) for i in range(NUM_CORES)]

unpadded = UnpaddedCounters()
padded = PaddedCounters()

def cache_line_of(struct_instance, field_owner, field_name):
    base = ctypes.addressof(struct_instance)
    offset = field_owner.__dict__[field_name].offset
    return (base + offset) // CACHE_LINE

unpadded_lines = {cache_line_of(unpadded, UnpaddedCounters, f"c{i}") for i in range(NUM_CORES)}
padded_lines = {cache_line_of(padded, PaddedCounters, f"c{i}") for i in range(NUM_CORES)}

# 각 코어가 자기 카운터만 증가시키는 워크로드를 흉내낸다 (정오만 확인, 실측 타이밍은 재지 않음).
for i in range(NUM_CORES):
    setattr(unpadded, f"c{i}", i * 1_000_000)
    getattr(padded, f"c{i}").value = i * 1_000_000

print("struct size — unpadded:", ctypes.sizeof(unpadded), "bytes  padded:", ctypes.sizeof(padded), "bytes")
print("distinct cache lines — unpadded:", len(unpadded_lines), "/", NUM_CORES, "counters share", unpadded_lines)
print("distinct cache lines — padded  :", len(padded_lines), "/", NUM_CORES, "counters")
print("padding gives every core its own cache line:", len(padded_lines) == NUM_CORES)

연습

코어 수만큼의 카운터를 배열로 두고 각 스레드가 자기 인덱스만 증가시키는 벤치를 만든 뒤, 패딩 없는 버전과 캐시라인 정렬 버전의 처리량을 비교해 보기.

실무 · Verex 연결

체인 데이터를 병렬로 파싱·검증하거나 오더북 샤드를 코어별로 나눌 때, 샤드별 상태를 캐시라인 단위로 분리하지 않으면 병렬화 이득이 그대로 증발한다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 38. RCU40. 브랜치 예측·프리페치·데이터 지향 설계 →