4. 워커별 카운터의 false sharing
난이도 상해설 — 워커별 카운터의 false sharing
난이도: 상
요약
워커들은 논리적으로 서로 다른 카운터를 갱신하지만, WorkerStat이 24바이트밖에 안 돼 여러 워커의 통계가 같은 캐시라인(보통 64바이트)에 올라탄다. 한 워커가 자기 카운터를 쓸 때마다 CPU 캐시 일관성 프로토콜(MESI)이 그 라인을 공유하는 다른 코어의 캐시를 무효화(invalidate) 한다. 실제 데이터 충돌은 없는데도 캐시라인 단위로 핑퐁이 일어나는 false sharing이다. 그래서 락도 공유도 없는데 스레드를 늘릴수록 라인 쟁탈로 느려진다. 해법은 각 카운터를 캐시라인 경계에 맞춰 패딩/정렬하는 것. (false sharing은 C++/C# 등 언어 무관한 하드웨어 현상이지만, C#에선 배열 원소 정렬을 직접 제어하기 어려워 [StructLayout] 패딩이나 분리 배열로 풀어야 한다.)
문제점
(A) WorkerStat이 24바이트 — 한 라인에 여러 워커가 공존 (분류: 성능/동시성)
- 증상:
workers를 늘려도 throughput이 선형 증가하지 않고, 어느 지점부터 오히려 감소. - 재현조건: 멀티코어에서 각 스레드가 인접한 인덱스(
_stats[t])를 고빈도로 쓸 때. 코어 수가 많을수록 심해진다. - 근본원인: 캐시 일관성은 바이트가 아니라 캐시라인(64B) 단위로 동작한다.
_stats[0](24B),_stats[1],_stats[2]의 일부가 같은 64바이트 라인에 들어간다. 코어 0이_stats[0].Packets를 쓰면, 그 라인을 캐시에 가진 코어 1·2는 MESI에 의해 라인이 Invalid가 되고, 자기 카운터를 쓰려면 라인을 다시 가져와야 한다(RFO, Read-For-Ownership). 코어들이 같은 라인의 소유권을 계속 뺏고 빼앗긴다 → "캐시라인 핑퐁".
(B) WorkerStat[] 연속 배열 — 패딩/정렬 부재 (분류: 성능)
- 증상: (A)의 직접 원인. 배열이 빽빽해 인접 워커가 물리적으로 한 라인을 공유.
- 근본원인: C#의 값 타입 배열은 원소를
sizeof(WorkerStat)(여기선 24바이트) 간격으로 촘촘히 인라인 저장한다(object[]처럼 참조 배열이 아니다). 캐시라인 정렬·패딩이 없으므로 false sharing이 구조적으로 발생한다. 게다가 배열 자체의 시작 주소도 64바이트 정렬이 보장되지 않아 경계가 어긋날 수 있다.
핵심: 이건 정확성 버그가 아니라 순수 성능 버그다. 결과값(TotalPackets)은 맞지만 확장성이 죽는다. ETW/
dotnet-trace+ 하드웨어 카운터(또는 Linuxperf c2c)로 보면 캐시 미스/HITM 이벤트가 폭증한다.
수정안
각 워커 통계를 캐시라인 크기로 패딩해 한 워커당 한 라인을 독점하게 한다. C#에선 [StructLayout]로 구조체 크기를 64바이트로 강제한다.
using System.Runtime.InteropServices;
// (A)(B) 구조체 자체를 캐시라인 크기로 패딩
[StructLayout(LayoutKind.Explicit, Size = 64)]
public struct WorkerStat
{
[FieldOffset(0)] public long Packets;
[FieldOffset(8)] public long Bytes;
[FieldOffset(16)] public long Errors;
// 나머지 17..63 바이트는 패딩으로 비워둔다(Size=64가 보장)
}
public sealed class ThroughputCounters
{
private readonly WorkerStat[] _stats;
public ThroughputCounters(int n) => _stats = new WorkerStat[n];
public void OnPacket(int t, long sz)
{
_stats[t].Packets += 1; // 이제 다른 워커 라인과 독립
_stats[t].Bytes += sz;
}
public long TotalPackets()
{
long sum = 0;
for (int i = 0; i < _stats.Length; i++) sum += _stats[i].Packets;
return sum;
}
}
주의:
[StructLayout(LayoutKind.Explicit, Size = 64)]로WorkerStat이 64바이트가 되어 인접 원소가 다른 라인에 놓인다(배열 시작 주소 정렬이 64의 배수가 아니면 첫 원소가 라인 경계에 안 맞을 수 있으나, 원소 간 간격이 64라 적어도 두 워커가 한 라인을 공유하진 않는다).- 카운터가 단일 스레드 전용이면(워커가 자기 슬롯만 쓰면)
Interlocked가 필요 없다. 모니터 스레드가 읽는 값은 통계 용도라 약간의 stale을 허용하면 OK. 엄밀한 가시성이 필요하면Interlocked.Read/Volatile.Read로 읽되, 라인이 분리돼 있으면 쓰기 경합은 없다.
대안 — 더 견고한 패딩(.NET이 구조체 정렬을 더 보수적으로 다룰 때):
// 라인 절반 앞뒤로 패딩을 둬서 인접 라인 프리페치(128B 페어링)까지 방어
[StructLayout(LayoutKind.Explicit, Size = 128)]
public struct PaddedCounter
{
[FieldOffset(64)] public long Value; // 값을 중앙에 배치
}
측정 방법
- 수정 전후로
workers=1,2,4,8에서Mops/s를 측정. 수정 후엔 코어 수에 거의 선형 증가해야 한다. - BenchmarkDotNet으로 워커 수별 처리량 회귀 측정. Linux면
perf stat -e cache-misses,L1-dcache-load-misses,perf c2c(cache-to-cache, false sharing 전용 진단). Windows면dotnet-trace+ ETW 또는 VTune "Memory Access" 분석에서 HITM(modified line을 다른 코어에서 가져옴) 카운트가 급감하는지 확인.
더 나은 설계
-
스레드 로컬 누적 + 머지(권장): 카운터를
[ThreadStatic]/ThreadLocal<T>또는 워커 스택 지역 변수에 두고 종료/주기마다 합산하면 공유 자료구조 접근 자체가 사라진다. false sharing 원천 차단. 트레이드오프: 모니터가 실시간 합계를 보려면 워커가 주기적으로 공유 영역에 flush 필요. -
PaddedReference<T>/패딩 구조체: 매직넘버 64 대신 의도를 드러내는 패딩 타입을 두고 재사용. 일부 CPU는 L2 프리페처가 인접 두 라인(128B)을 쌍으로 가져와 false sharing 영향 범위가 128B인 경우가 있어, 보수적으로 128B 패딩을 쓰기도 한다(Intel 일부). 트레이드오프: 메모리 사용량. -
분리 배열(SoA) 주의:
long[] packets; long[] bytes;로 종류별 분리하면 오히려 인접 워커의 동종 카운터가 한 라인에 몰려 false sharing이 더 심해질 수 있다. 이 경우엔 워커별 패딩 구조(AoS + 64B 패딩)가 정답. -
읽기 측 일관성: 모니터가 정확한 스냅샷이 필요하면
Volatile.Read/Interlocked.Read로 읽되, 라인 분리로 쓰기 경합은 없게 유지.
면접 포인트
- False sharing이 무엇이며 true sharing과 어떻게 다른가? MESI 프로토콜의 Invalid/RFO/HITM 관점에서 설명하라. C#에서 값 타입 배열이 왜 false sharing에 취약한가(인라인 저장)?
- C#에서 캐시라인 패딩을 어떻게 강제하나?
[StructLayout(LayoutKind.Explicit, Size=64)]vsSequential+ 더미 필드의 차이는? 왜 64가 아니라 128로 패딩하는 경우가 있나(프리페처/라인 페어링)? - 카운터가 워커 전용인데도
Interlocked/Volatile이 필요한 경우와 불필요한 경우는? 스레드 로컬 누적이 false sharing을 어떻게 원천 제거하는가?
해설 — 워커별 카운터의 false sharing
난이도: 상
요약
워커들은 논리적으로 서로 다른 카운터를 갱신하지만, WorkerStat이 24바이트밖에 안 돼 여러 워커의 통계가 같은 캐시라인(보통 64바이트)에 올라탄다. 한 워커가 자기 카운터를 쓸 때마다 CPU 캐시 일관성 프로토콜(MESI)이 그 라인을 공유하는 다른 코어의 캐시를 무효화(invalidate) 한다. 실제 데이터 충돌은 없는데도 캐시라인 단위로 핑퐁이 일어나는 false sharing이다. 그래서 락도 공유도 없는데 스레드를 늘릴수록 라인 쟁탈로 느려진다. 해법은 각 카운터를 캐시라인 경계에 맞춰 패딩/정렬하는 것.
문제점
(A) WorkerStat이 24바이트 — 한 라인에 여러 워커가 공존 (분류: 성능/동시성)
- 증상:
workers를 늘려도 throughput이 선형 증가하지 않고, 어느 지점부터 오히려 감소. - 재현조건: 멀티코어에서 각 스레드가 인접한 인덱스(
stats_[t])를 고빈도로 쓸 때. 코어 수가 많을수록 심해진다. - 근본원인: 캐시 일관성은 바이트가 아니라 캐시라인(64B) 단위로 동작한다.
stats_[0](24B)과stats_[1],stats_[2]의 일부가 같은 64바이트 라인에 들어간다. 코어 0이stats_[0].packets를 쓰면, 그 라인을 캐시에 가진 코어 1·2는 MESI에 의해 라인이 Invalid가 되고, 자기 카운터를 쓰려면 라인을 다시 가져와야 한다(RFO, Read-For-Ownership). 코어들이 같은 라인의 소유권을 계속 뺏고 빼앗긴다 → "캐시라인 핑퐁".
(B) std::vector<WorkerStat> 연속 배열 — 패딩/정렬 부재 (분류: 성능)
- 증상: (A)의 직접 원인. 배열이 빽빽해 인접 워커가 물리적으로 한 라인을 공유.
- 근본원인:
vector는 원소를sizeof(WorkerStat)(여기선 24, 8정렬) 간격으로 촘촘히 배치한다. 캐시라인 정렬·패딩이 없으므로 false sharing이 구조적으로 발생한다. 게다가vector의 시작 주소도 64바이트 정렬이 보장되지 않아 경계가 어긋날 수 있다.
핵심: 이건 정확성 버그가 아니라 순수 성능 버그다. 결과값은 맞지만 확장성이 죽는다. 프로파일러로 보면 캐시 미스/
MEM_LOAD_RETIRED.L3_HIT·HITM 이벤트가 폭증한다.
수정안
각 워커 통계를 캐시라인 크기로 정렬·패딩해 한 워커당 한 라인을 독점하게 한다.
#include <new> // std::hardware_destructive_interference_size
#include <cstddef>
// 표준 상수가 없으면 64로 가정
#ifdef __cpp_lib_hardware_interference_size
constexpr std::size_t kCacheLine = std::hardware_destructive_interference_size;
#else
constexpr std::size_t kCacheLine = 64;
#endif
struct alignas(kCacheLine) WorkerStat // (A)(B) 라인 정렬
{
std::uint64_t packets = 0;
std::uint64_t bytes = 0;
std::uint64_t errors = 0;
// alignas 때문에 sizeof(WorkerStat) == kCacheLine 로 자동 패딩됨.
// 명시하고 싶으면:
char pad[kCacheLine - 3 * sizeof(std::uint64_t)];
};
static_assert(sizeof(WorkerStat) % kCacheLine == 0, "stat must own its line(s)");
std::vector는 alignas된 타입의 정렬을 C++17 이후 보장하므로(over-aligned new), 위 정의만으로 각 원소가 라인 경계에 놓인다. 더 확실히 하려면 정렬 할당자를 쓰거나 std::aligned_alloc로 직접 배열을 잡는다.
class ThroughputCounters
{
public:
explicit ThroughputCounters(int n) : stats_(n) {}
inline void OnPacket(int t, std::uint64_t sz)
{
stats_[t].packets += 1; // 이제 다른 워커 라인과 독립
stats_[t].bytes += sz;
}
std::uint64_t TotalPackets() const
{
std::uint64_t sum = 0;
for (auto& s : stats_) sum += s.packets;
return sum;
}
private:
std::vector<WorkerStat> stats_; // 각 원소가 64B 정렬/패딩됨
};
주의:
- 카운터가 단일 스레드 전용이면
std::atomic이 필요 없다(여기처럼 워커가 자기 슬롯만 쓰면 일반 변수로 충분). 모니터 스레드가 읽는 값은 통계 용도라 약간의 stale을 허용하면 OK. 엄밀한 가시성이 필요하면std::atomic<uint64_t>+memory_order_relaxed로 읽기/쓰기(atomic이어도 라인이 분리돼 있으면 경합 없음). errors까지 합치면 24B → alignas로 64B로 패딩. 메모리는 워커당 +40B 늘지만, 확장성 대비 무시할 비용.
측정 방법
- 수정 전후로
workers=1,2,4,8에서Mops/s를 측정. 수정 후엔 코어 수에 거의 선형 증가해야 한다. - Linux
perf stat -e cache-misses,L1-dcache-load-misses또는perf c2c(cache-to-cache, false sharing 전용 진단), Intel VTune의 "Memory Access" 분석에서 HITM(modified line을 다른 코어에서 가져옴) 카운트가 급감하는지 확인.
더 나은 설계
-
스레드 로컬 누적 + 머지(권장): 카운터를 워커 스택/
thread_local에 두고 종료/주기마다 합산하면 공유 자료구조 접근 자체가 사라진다. false sharing 원천 차단. 트레이드오프: 모니터가 실시간 합계를 보려면 워커가 주기적으로 공유 영역에 flush 필요. -
alignas(hardware_destructive_interference_size): 매직넘버 64 대신 표준 상수 사용. 단, 일부 CPU는 L2 프리페처가 인접 두 라인(128B)을 쌍으로 가져와 false sharing 영향 범위가 128B인 경우가 있어, 보수적으로 128B 패딩을 쓰기도 한다(Intel 일부). 트레이드오프: 메모리 사용량. -
AoS → SoA 검토: 다만 SoA(packets[], bytes[] 분리 배열)는 같은 종류끼리 모으므로 오히려 인접 워커의 동종 카운터가 한 라인에 몰려 false sharing이 더 심해질 수 있다. 이 경우엔 워커별 패딩 구조(AoS + 정렬)가 정답.
-
읽기 측 일관성: 모니터가 정확한 스냅샷이 필요하면 atomic + relaxed로 읽되, 라인 분리로 쓰기 경합은 없게 유지.
면접 포인트
- False sharing이 무엇이며 true sharing과 어떻게 다른가? MESI 프로토콜의 Invalid/RFO/HITM 관점에서 설명하라.
std::hardware_destructive_interference_size와..._constructive_...의 차이는? 왜 64가 아니라 128로 패딩하는 경우가 있나(프리페처/라인 페어링)?- 카운터가 워커 전용인데도
std::atomic이 필요한 경우와 불필요한 경우는?memory_order_relaxed로 충분한 이유는?