
AI가 스레드 안전(Thread-Safe) 카운터를 작성했습니다. 하지만 CPU는 이를 5배나 느리게 만들었습니다.
요약
AI가 작성한 스레드 안전한 코드가 하드웨어의 캐시 라인 구조로 인해 성능이 5배 저하되는 현상을 분석합니다. 데이터가 물리적으로 인접할 때 발생하는 '거짓 공유(False Sharing)' 문제와 그 해결책을 다룹니다.
핵심 포인트
- AI가 작성한 논리적으로 완벽한 코드가 하드웨어 수준에서 성능 저하를 일으킬 수 있음
- 캐시 라인 단위의 데이터 이동으로 인해 발생하는 '거짓 공유(False Sharing)' 현상 설명
- 멀티 코어 환경에서 데이터 간 패딩(Padding)을 통한 성능 최적화의 중요성
- Apple Silicon 등 현대 CPU의 캐시 라인 크기(128바이트) 이해 필요
캐시 라인(cache line)을 만나보세요.
저는 AI 어시스턴트에게 간단한 것을 요청했습니다: **스레드별 카운터(per-thread counters)**입니다. 4개의 스레드가 각각 배열의 자신의 슬롯을 증가시키는 방식입니다. 공유 변수도 없고, 잠금(lock)도 필요 없습니다. AI가 작성한 코드는 깔끔하고 정확했습니다:
long[] counters = new long[threadCount];
// 스레드 t가 수행하는 작업: counters[t]++
모든 스레드는 오직 자신의 요소에만 기록합니다. 여기에는 경합 조건(race condition)이 없습니다. 어떤 코드 리뷰도 통과할 것이며, 모든 테스트도 통과할 것입니다.
그런 다음, _정확히 동일한 작업_을 수행하는 버전과 비교하여 측정해 보았고, 그 정확한 코드는 5배나 뒤처졌습니다.
수치
이것은 제 기기(Apple Silicon, .NET 10, 4개 스레드, 스레드당 2억 번의 증가)에서 측정된 결과입니다:
Round 1: 인접함 (하나의 캐시 라인): 464 ms | 패딩 처리됨 (스레드당 하나의 라인): 85 ms | 비율: 5.4x
Round 2: 인접함 (하나의 캐시 라인): 397 ms | 패딩 처리됨 (스레드당 하나의 라인): 85 ms | 비율: 4.7x
동일한 루프. 동일한 증가 횟수. 동일한 "각 스레드는 자신의 카운터만 건드린다"는 조건. 두 버전의 유일한 차이점은 _카운터가 메모리의 어디에 위치하느냐_입니다.
하드웨어가 실제로 하는 일
CPU는 메모리에서 단 1바이트만 읽지 않습니다. 데이터는 **캐시 라인(cache lines)**이라고 불리는 고정된 블록 단위로 이동합니다. x86에서는 64바이트, Apple Silicon에서는 128바이트입니다. 하나의 long을 요청하면, 그것이 포함된 블록 전체가 코어의 캐시로 이동합니다. 재료가 지하실 냉장고에 있는 요리사를 생각해 보세요. 아래층으로 내려가는 것은 비용이 많이 들기 때문에, 당근 하나만 들고 가지 않고 상자 전체를 들고 갑니다.
(128바이트라는 제 말을 그대로 믿지 마세요. M 시리즈 Mac에서 sysctl hw.cachelinesize를 실행해 보세요.)
보통 이런 방식은 사용자에게 유리하게 작용합니다. 배열 요소들이 나란히 붙어 있기 때문에 배열을 스캔하는 것이 빠릅니다. 요소 하나를 건드리면 다음 15개의 요소가 같은 상자에 담겨 공짜로 따라옵니다.
하지만 멀티 코어 환경에는 규칙이 있습니다: 캐시 라인에 쓰기 작업을 하려면, 코어가 해당 라인을 독점적으로 소유해야 합니다. 코어 1이 쓰는 순간, 해당 라인에 대한 다른 모든 코어의 복사본은 오래된 것(stale)으로 선언됩니다. 그리고 소유권의 단위는 당신의 변수가 아니라, 라인 전체입니다.
이제 제 네 개의 카운터를 보십시오. 각각 8바이트인 네 개의 long 값이 나란히 붙어 있어 총 32바이트입니다. 이들은 모두 _하나_의 캐시 라인 (cache line)에 들어갑니다. 스레드 1이 자신의 카운터를 증가시키면 해당 라인의 소유권을 가져갑니다. 1나노초 후 스레드 2가 자신만의 다른 카운터를 증가시키려 하면, 동일한 라인을 다시 빼앗아 와야 합니다. 매 쓰기(write) 작업마다 라인이 코어 사이를 탁구공처럼 왔다 갔다 합니다(ping-pongs). 코드상으로는 아무것도 공유하지 않는 네 개의 스레드가 하드웨어 상의 상자 하나를 두고 싸우고 있는 것입니다.
이것을 **거짓 공유 (false sharing)**라고 부릅니다. 실제로는 데이터가 공유되지 않기 때문에 '거짓(False)'이지만, 싸움 자체는 실재합니다.

하나의 캐시 라인에 있는 네 개의 카운터 vs 라인당 하나의 카운터 — 코드는 동일하지만, 5배의 차이가 발생함.
데모의 해결책은 속성 하나입니다
모든 스레드에 자신만의 캐시 라인을 부여하십시오. 다음 카운터가 다른 라인에서 시작되도록 각 카운터에 패딩 (padding)을 추가합니다:
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedCounter
{
...
이것이 464ms와 85ms 사이의 모든 차이를 만듭니다. 우리는 약간의 메모리 — 카운터당 8바이트 대신 128바이트 — 를 지불하는 대신, 이미 가지고 있다고 생각했던 병렬성 (parallelism)을 되찾게 됩니다. (제가 64가 아닌 128을 사용하는 데는 두 가지 이유가 있습니다. Apple Silicon의 라인은 128바이트이며, x86에서는 인접 라인 프리페처 (adjacent-line prefetcher)가 인접한 라인들을 함께 끌어오는 것을 좋아하기 때문입니다.)
직접 실행해 보세요
전체 데모는 약 70줄 정도이며, 프로젝트 파일은 필요하지 않습니다. .NET 10 SDK를 사용하면 단일 파일을 직접 실행할 수 있습니다:
dotnet run -c Release FalseSharingDemo.cs
// FalseSharingDemo.cs — .NET 10 파일 기반 앱
using System.Diagnostics;
using System.Runtime.InteropServices;
...
실제 코드에서 이것이 숨어 있는 곳
직장에서 루프 안에 네 개의 카운터를 작성하는 일은 없을 것입니다. 하지만 당신이 작성하거나, 혹은 AI가 당신을 위해 다음과 같은 코드를 작성할 것입니다:
캐시 통계 (Cache statistics). 메모리 내 캐시는 거의 모두 히트(hits), 미스(misses), 에비전(evictions)을 유지합니다. 자연스러운 구현 방식은 필드를 서로 나란히 배치하거나, 샤드(shard)당 하나의 슬롯을 가진 long[]를 사용하며, 모든 스레드가 Interlocked.Increment로 업데이트하는 것입니다. 필드를 나란히 배치한다는 것은 하나의 캐시 라인(cache line)을 의미합니다. 이것은 말 그대로 위의 데모에서 여러분의 프로덕션 서비스에서 실행되는 상황입니다.
샤딩된 카운터 (Sharded counters). 더 잔혹한 버전입니다: 병렬 처리를 위해 카운터를 샤드화했고, 이 샤드들을 하나의 배열에 넣었지만, 여전히 라인을 공유합니다. 여러분이 아키텍처 작업을 했음에도 불구하고 하드웨어가 조용히 그것을 무효화시킨 것입니다.
LRU 메타데이터 (LRU metadata). 캐시 슬롯당 압축된 long[] lastAccessTicks는 모든 캐시 *읽기(read)*가 뜨거운 공유 배열에 대한 *쓰기(write)*가 되게 만듭니다. 쓰기 때문에 느려지는 읽기 중심의 캐시는 디버깅하기 재미있는 문제입니다.
그리고 때로는 문제가 라이브러리 내부에서 발생합니다. 대부분의 자체 제작 .NET 캐시의 기반이 되는 ConcurrentDictionary는 내부적으로 각 락 스트라이프(lock stripe)당 카운터를 일반 배열(_countPerLock 소스 코드 참조)에 유지합니다. 매우 뜨거운 멀티 스레드 쓰기 작업이 발생할 경우, 이 인접한 필드들이 라인을 공유하게 될 수 있습니다. 저는 실제 환경의 영향을 벤치마킹하지는 않았지만, 레이아웃은 소스코드에 명확히 존재하며, 이제 여러분은 무엇을 찾아야 할지 알게 되었습니다.
신경 쓰지 않아도 되는 경우 (When you should NOT care)
솔직한 이야기입니다. 패스 트래킹(False sharing)은 쓰기 작업이 뜨거울 때—초당 수백만 건의 업데이트가 여러 스레드에서 발생할 때—문제가 됩니다. 만약 캐시 통계를 초당 천 번 정도 업데이트한다면, 여러분은 절대 알아차리지 못할 것이며, 모든 것을
이것이 AI 지원 코딩에서 바뀐 점이며, 하드웨어 기본 원리가 덜 중요해진 것이 아니라 더 중요해진 이유입니다. 모델은 스레드 안전 카운터(thread-safe counter), 샤드 캐시(sharded cache), LRU 제거 정책 등을 기꺼이 생성할 것입니다. 하지만 그 정확성 보장 중 어느 것도 캐시 라인(cache lines)에 대해서는 아무 말도 하지 않습니다. 정확성과 기계적 공감(mechanical sympathy)은 다른 계층입니다. 테스트는 전자를 잡아냅니다. 이해만이 후자를 잡아낼 수 있습니다.
캐시 크기를 외울 필요는 없습니다. 그 캐시가 존재한다는 것만 알면 됩니다. 함께 사용되는 데이터는 가깝게 유지하십시오. 다른 스레드에 의해 쓰여지는 데이터는 떨어뜨려 놓으십시오. 이 하나의 규칙, 양방향으로 읽으면, '캐시 인식(cache-aware)' 프로그래밍의 대부분입니다.
이제 도구들이 코드를 작성합니다. 왜 그것이 느린지 아는 것이 여전히 우리의 임무입니다.
원래 vasyl.blog에 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기