TECH 으로 돌아가기
TECH HACKER NEWS 오늘 6분 읽기 30 READS

스핀락을 5.7배 빠르게: 캐시 라인과 메모리 순서가 만드는 성능 격차

스핀락을 5.7배 빠르게: 캐시 라인과 메모리 순서가 만드는 성능 격차
SOURCE IMAGE · HACKER NEWS

멀티스레드 코드에서 임계 구역을 보호할 때 대부분은 std::mutex를 쓴다. 뮤텍스는 락을 얻지 못하면 스레드를 스케줄러에 양보해 잠들게 한다. 반면 스핀락은 잠들지 않는다. 락이 풀릴 때까지 스레드가 CPU 위에 머물며 계속 재시도하는 방식이라 시스템 콜도, 컨텍스트 스위치도 발생하지 않는다. 문제는 순진하게 구현한 스핀락이 경쟁 상황에서 놀라울 만큼 느리고 전력도 많이 먹는다는 점이다. 데이비드 알바레스 로사는 공유 카운터를 여러 스레드가 증가시키는 벤치마크를 통해, 단계적 최적화만으로 같은 스핀락을 5.7배 빠르게, 그리고 5.4배 적은 전력으로 동작시키는 과정을 보여준다.

경쟁이 만드는 병목

출발점은 atomic과 exchange 루프로 짠 가장 기본적인 형태다. exchange는 원자적으로 true를 쓰고 이전 값을 돌려주는데, false가 나오면 락이 비어 있었다는 뜻이고 true면 다른 스레드가 쥐고 있다는 뜻이라 재시도한다. 경쟁이 없을 때는 3.14ns에 끝나지만, 두 스레드가 붙으면 61.5ns로 20배 가까이 느려지고 네 스레드에서는 246ns까지 치솟는다. 원인은 캐시 일관성에 있다. 코어가 캐시 라인에 값을 쓰려면 그 라인을 배타적으로 소유해야 하는데, 대기 중인 스레드들이 서로에게서 라인을 빼앗는다. 실제로 L1 데이터 캐시 미스율은 스레드 하나일 때 1.27%에서 넷일 때 61.73%로 뛰고, 분기 8개 중 1개가 잘못 예측된다. exchange의 성공 여부를 다른 코어가 결정하니 분기 예측기가 학습할 근거가 없기 때문이다. 전력도 마찬가지여서 네 스레드에서 패키지 전체가 64.92J을 소비한다. 고빈도 매매(HFT) 업계처럼 거래소 코로케이션 전력에 요금을 매기고 상한(NYSE의 경우 32kW)이 걸린 환경에서는 이 수치가 곧 비용이다.

메모리 순서부터 조인다

첫 번째 개선은 코드 한 줄도 아니고 메모리 순서 지정에서 나온다. 기본값인 seq_cst는 락이 실제로 필요로 하는 것보다 강하다. 락은 진입할 때 acquire, 빠져나올 때 release만 보장하면 된다. 기본 순서에서는 unlock이 lock의 원자적 읽기-수정-쓰기(RMW)에 더해 또 하나의 잠긴 RMW를 추가하는데, memory_order_release를 쓰면 unlock이 그냥 평범한 저장 명령으로 바뀐다. 원자 연산이 둘에서 하나로 줄어든 결과, 경쟁 없는 경우가 3.14ns에서 1.57ns로, 네 스레드가 246ns에서 131ns로 절반 수준이 됐다. 캐시 미스율도 61.73%에서 21.16%로, 전력은 34.45J로 떨어졌다.

다음 병목은 exchange가 실패할 때조차 캐시 라인에 값을 쓴다는 사실이다. 대기자들이 쓰기를 멈춰야 한다. 그래서 exchange는 한 번만 시도하고, 그다음에는 읽기 전용 load로 락 상태를 감시한다. 이때 _mm_pause 명령을 넣으면 해당 루프가 스핀 대기임을 코어에 알려 코어가 놀게 만든다. 실패하는 읽기는 임계 구역을 정렬하지 않으므로 relaxed로 둬도 되며, 순서를 보장하는 것은 성공한 exchange뿐이다. 이 변경으로 두 스레드는 32.5ns에서 21.3ns로 3분의 1이 줄었고, 읽기 전용 스핀은 예측이 쉬워 분기 오예측률이 7.43%에서 3.72%로 내려갔다.

함께 깨어나지 않게 하기

남은 문제는 모든 대기자가 똑같은 시간만큼 멈추기 때문에 락이 풀리는 순간 다 같이 깨어나 다시 경쟁한다는 점이다. 인텔 최적화 매뉴얼(Example 2-10, 증가하는 백오프)이 제시하는 해법은 라운드를 거듭할수록 대기 시간을 두 배씩 늘리되 상한을 두는 지수 백오프다. 이렇게 하면 대기자마다 물러나는 시간이 달라져 동시에 깨어나는 현상이 사라진다. 네 스레드가 120ns에서 43.0ns로 떨어지고, 전력은 11.92J까지 내려가 최초의 순진한 버전 대비 5.4배 적은 소비량을 기록했다.

실무적으로 기억할 점은 이 모든 최적화가 특수한 조건에서만 의미가 있다는 것이다. 대부분의 코드에서는 여전히 std::mutex가 옳은 기본 선택이다. 스핀락은 스레드가 전용 코어에 고정(pinning)되어 있을 때, 그리고 반드시 실측을 거친 뒤에만 고려할 대상이다. 읽기가 많고 쓰기가 하나뿐인 패턴이라면 스핀락 대신 seqlock이 더 나은 대안이 될 수 있다. 이 사례가 주는 진짜 교훈은 특정 락 구현이 아니라, 성능이 알고리즘보다 캐시 일관성 프로토콜과 메모리 순서 같은 하드웨어 계층의 세부에서 갈린다는 사실, 그리고 그 차이는 프로파일러와 전력 카운터로 측정해야만 보인다는 점이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://david.alvarezrosa.com/posts/optimizing-a-spin-lock/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...