LabHub
学习 学习路径 课程

计算机组成

缓存行与伪共享 — 被邻座拖慢

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

한 줄 요약

캐시는 64바이트 라인 단위로 움직이고 코어들은 그 라인 단위로 소유권을 주고받으므로, 서로 다른 변수라도 같은 라인에 있으면 성능이 서로를 깎는다.

概念图: 캐시 일관성 프로토콜 · 단위가 변수가 아니라 캐시 라인 · 거짓 공유(false sharing) · 분할 잠금(split lock)

왜 이게 필요했나

멀티코어 시스템에서는 같은 메모리를 여러 코어가 각자의 캐시에 올려 둘 수 있다. 한 코어가 값을 바꿨는데 다른 코어가 옛 값을 계속 본다면 프로그램은 무너진다. 그래서 하드웨어는 캐시 일관성 프로토콜(MESI 계열)을 돌린다. 어떤 코어가 라인을 수정하려면 다른 코어들의 사본을 무효화하고 배타적 소유권을 얻어야 한다.

여기서 중요한 사실은 이 소유권 다툼의 단위가 변수가 아니라 캐시 라인이라는 점이다.

어떻게 동작하나

스레드 A 가 카운터 a 를, 스레드 B 가 카운터 b 를 각각 열심히 증가시킨다고 하자. 둘은 전혀 다른 변수이고 락도 필요 없다. 그런데 ab 가 구조체 안에 나란히 선언되어 같은 64바이트 라인에 들어갔다면, A 가 쓸 때마다 B 의 캐시에서 그 라인이 무효화되고, B 가 쓸 때마다 A 의 것이 무효화된다. 논리적으로는 공유가 없는데 하드웨어 수준에서는 계속 뺏고 뺏기는 상태다. 이것을 거짓 공유(false sharing) 라고 한다.

카운터 a 와 b 가 같은 64바이트 캐시 라인의 첫 두 칸에 들어가 두 코어가 라인 소유권을 서로 무효화하며 뺏고 뺏기는 모양과, 패딩을 넣어 둘을 다른 라인에 놓아 다툼이 사라진 모양의 비교

증상이 특징적이다. 스레드를 늘렸는데 처리량이 늘지 않거나 오히려 줄어든다. 락 경합을 아무리 찾아도 나오지 않는다. 락이 없기 때문이다.

대응은 단순하다. 코어별로 쓰는 데이터를 서로 다른 캐시 라인에 놓는다. 구조체에 패딩을 넣거나, 배열 원소를 라인 크기의 배수로 정렬한다.

비슷한 부류의 함정이 하나 더 있다. 분할 잠금(split lock) 이다. 원자 연산의 피연산자가 두 캐시 라인에 걸쳐 있으면 CPU 는 빠른 일관성 경로를 쓸 수 없어 외부 버스 잠금을 걸어야 한다. 800사이클을 넘고, 그동안 무관한 다른 코어들까지 멈춘다. 리눅스 커널은 이걸 감지하는 기능을 제공하며 부팅 파라미터 split_lock_detect 의 기본값은 warn 이다. 커널 로그에 관련 경고가 찍히고 있다면 실제 성능 문제일 가능성이 높다.

현장에서 만나는 모습

고성능 큐, 카운터 배열, 스레드별 통계 수집기가 전형적인 피해자다. 특히 "스레드마다 자기 슬롯에만 쓰니까 락이 필요 없다"고 설계한 배열이 위험하다. 슬롯 크기가 8바이트라면 여덟 개 슬롯이 한 라인에 들어가고, 여덟 스레드가 한 라인을 놓고 싸운다.

반대로 이 원리를 이용할 수도 있다. 함께 읽히는 필드를 한 라인에 모아 두면 한 번의 미스로 전부 가져온다. 읽기 전용으로 함께 쓰이는 값들은 붙이고, 서로 다른 코어가 쓰는 값들은 떼어 놓는다. 규칙은 이 한 줄로 요약된다.

다음 실습에서 할 것

라인 단위로 움직인다는 이 성질을 시간으로 재어 본다. 배열을 여러 보폭으로 훑어 라인 하나를 얼마나 쓰고 버리는지에 따라 읽기 비용이 어떻게 갈리는지 보고, 같은 행렬을 행 우선과 열 우선으로 순회해 접근 순서만으로 몇 배가 벌어지는지 확인한다. 마지막에는 순회를 조각으로 잘라 그 손해를 되돌린다.