SungJin Kang

SungJin Kang

hour30000@gmail.com

© 2024

Dark Mode

캐시 일관성에 대해 ( 번역 )

이 글은 Cache coherency에 대한 글의 번역본이다.
의역이나 약간의 부정확한 번역이 있을 수 있으니 양해바람.
그리고 중요한 용어 같은 것들은 번역을 하지 않고 영어 그대로 썼다.

또한 이해를 돕기위해 필자 나름 추가적인 설명을 써두었다.

그리고 이 글을 읽은 후 이 글들도 읽어보기 바란다
글 1
글 2

그 동안 잘못 이해하고 있던 것들이 이 글을 통해서 한방에 정리된 느낌이다.

아래부터는 해당 글의 번역본이다.


나는 멀티 코어 상황에서 데이터 구조에 대해 글을 쓸 계획을 하고 있다. 나는 그에 대한 첫 글을 쓰기 시작해고 곧바로 내가 먼저 다뤄야할 몇가지 기초 지식들이 있다는 것을 깨달았다. 나는 그것들을 이 글에서 소개할 것이다.

캐시

이것은 CPU 캐시에 대한 기본적인 내용들이다. 나는 너가 기본적인 개념 정도는 알 것이라고 생각한다, 그러나 너는 몇몇 디테일한 것들은 잘 알지 못할 것이다. ( 만약 너가 안다면 그 부분은 넘겨도 된다. )

현대 CPU의 모든 ( 거의 대부분 ) 메모리 접근은 캐시 계층 ( Cache hierarchy )을 통해 이루어진다. 이러한 이러한 과정의 몇몇을 우회하는 메모리-mapped IO, write-combined 메모리와 같은 예외인 것들이 있지만 이것들은 희귀한 경우이다. 그래서 이 글에서 그것들은 그냥 무시하겠다.

CPU 코어의 Load/Store ( 명령어 fetch ) 유닛들은은 메모리에 직접적으로 접근할 수 없다. 그것이 물리적으로 불가능하다는 것이다. 필수적인 선들 조차 존재하지 않는다. 대신 그들은 그것을 다루는 그들의 L1 캐시들에 접근(Talk)한다. 그리고 약 20여년 전 L1 캐시들은 메모리에 직접적으로 접근을 했다. 현재는 대개 몇몇 캐쉬 레벨들이 더 관여된다. 이것은 L1 캐시도 직접적으로 메모리에 접근을 하지 않는다는 것이다. 대신 L2 캐시에 얘기한다. 그리고 그 L2 캐시가 메모리에 접근하거나 혹은 L3 캐시에 접근한다. 너는 대충 어떤 느낌이제 이제 알 것이다.

캐시는 32(older ARMs, 90s/early 2000s x86s/PowerPCs) 혹은 64(newer ARMs and x86s) 혹은 128(newer Power ISA machines) 바이트의 정렬된(Aligned) 블록들에 상응하는 라인들로 구성되어있다. 각각의 캐시 라인은 그것의 범위에 상응하는 물리적 메모리 주소가 어디인지를 안다. 그리고 이 글에서 나는 물리적 캐시라인과 그것에 상응하는 메모리, 둘 사이를 구분하지는 않을 것이다. 이것은 엉성하다(sloppy), 그러나 일반적으로 쓰인다, 그래서 그냥 그것에 익숙해져라. 특히 나는 메모리 내에 바이트들의 정렬된 그룹을 의미하는 “캐시 라인”에 대해 얘기할 것이다. 이 바이트들이 현재 캐시되었는 지, 아닌지는 중요하지 않다.

CPU 코어가 메모리 Load 명령어를 볼 때 그것은 그 주소를 L1 데이터 캐시(혹은 L1D$라 하는 데 캐시가 현찰을 뜻하는 “cash”와 똑같이 발음되기 때문에 $ 표시를 쓴다)에 전달한다. 그 L1D$는 그 주소에 상응하는 캐시 라인을 가지고 있는지를 확인한다, 만약 가지고 있지 않다면 그 전체 캐시 라인을 메모리에서 가져온다. ( 혹은 존재한다면 다음 단계의 캐시 레벨에서 가져온다). 맞다, 그 전체 캐시 라인을 말이다; 메모리 접근들이 지역화(localized)되어 있다는 것을 생각했을 때, 우리가 메모리에 몇몇 바이트에 접근한다면, 우리는 곧 그것의 주위 바이트에 접근할 가능성이 높다. 일단 해당 캐시 라인이 L1 캐시에 존재한다면 Load 명령어는 곧 바로 실행되고 그것의 메모리 읽기를 수행한다.

우리가 읽기(read)-only 접근을 다루는 한 그것은 매우 간단하다. 왜냐면 모든 캐시 레벨들이 아래와 같은 규칙을 따르기 때문이다.

기본 불변성 : 어떤 캐시 레벨에 존재하는 모든 캐시 라인들의 데이터는 메모리 내 상응하는 주소에 있는 데이터들과 항상(!) 똑같다(일치한다, 일관된다).

우리가 메모리 쓰기 같은 저장(store)을 허용하기 시작하면 복잡해지기 시작한다. 두가지 기본적인 접근법이 존재한다. write-through와 write-back이다.

Write-through는 상대적으로 쉬운 접근법이다. 이 방법은 그냥 저장(store, write)을 다음 캐시 레벨(혹은 메모리)에 즉시(!) 전달하는 것이다. 만약 우리가 상응하는 캐시된 라인을 이미 가지고 있다면 우리의 복사본(우리의 이미 캐시된 라인)을 업데이트할 것이다(혹은 심지어 그것을 버리기도 한다), 그것이 다(전부)이다. 이것은 위에서 말한바(기본 불변성)와 같이 동일한 불변성을 보장한다. 만약 한 캐시 라인이 캐시에 존재한다면 그것의 데이터(내용물)은 메모리와 항상(!) 일치한다.

Write-back 방법은 약간 더 복잡하다. 캐시는 쓰기(writes)를 즉시 전달하지 않는다. 대신 그러한 변화(write, store)들이 캐시된 데이터에만 지역적으로 적용된다. 그리고 상응하는 캐시라인들은 dirty로 표시된다. Dirty로 표시된 캐시라인은 write-back을 유발할 수 있는 데, 그것은 그것의 데이터(contents)가 메모리나 다음 캐시 레벨에 쓰인다는 것을 의미한다. write-back 후에 dirty로 표시된 캐시라인은 clean으로 다시 표시된다. dirty 캐시 라인이 축출(evicted) 됬을 때( 대걔 해당 캐시 내에 다른 것을 위해 공간을 확보할 때 축출된다.), 그것은 항상 write-back을 우선적으로 수행한다. write-back 캐시에 대한 불변성은 약간 개념이 다르다.

Write-back 불변성 : 모든 dirty 캐시 라인들을 write-back한 후(!) 모든 캐시 라인의 데이터(contents)들은 상응하는 주소의 메모리 값들과 일치한다.

다시 말해 write-back 캐시에서는 우리는 “항상”이라는 수식어를 잃는다, 대신 약한 조건으로 대체한다. 그 캐시의 데이터들은 메모리에 일치(상응, 일관)되거나 메모리에 write back될 필요가 있는 값을 가지고 있는 것이다.(write through가 항상 메모리에 일치된다는 것과 구분된다).

Write-through 캐시가 더 간단하다, 그치만 write-back은 몇몇 이점을 가지고 있다. 그것은 같은 주소로의 반복되는 쓰기 작업(store, write)을 거를 수 있다. 그리고 만약 write-back에서 다수의 캐시 라인들이 변할 때 그것은 하나의 커다란 메모리 전송을 실행할 수 있다 ( Write Combined Bufffer! - https://stackoverflow.com/questions/47512527/simd-intrinsic-and-memory-bus-size-how-cpu-fetches-all-128-256-bits-in-a-singl ). Write-through의 경우에는 매번 메모리에 전송하다 보니 조그만한 데이터 전송을 여러번 해야한다. 그에 반해 Write-back 방법은 한번의 커다란 데이터 전송을 하므로 훨씬 효율적이다.

몇몇 ( 대부분 오래된 ) CPU들은 write-through 캐시를 사용한다. 몇몇은 write-back 캐시를 사용하고. 몇몇은 L1캐시에서는 write-through를 L2캐시에서는 write-back을 사용한다. 이것은 L1캐시와 L2캐시 사이의 불필요한 정체를 유발하지만 메모리 혹은 캐시 레벨로의 전송을 낮추는(절약하는) Write-back 방식의 이점을 가져갈 수 있다. 내가 강조하고 싶은 것은 그 사이 적절한 균형 지점이 존재한다는 것이다 다양한 아키텍쳐들이 다양한 해결방법들을 사용한다. 모든 레벨에서 캐시라인의 사이즈가 같아야 할 필요도 없다. L1캐시에서는 32바이트의 캐시라인을 L2에서는 128바이트의 캐시라인을 가진 CPU도 있다.

이 섹션에서는 간략한 글을 쓰기 위해 빠진 것들이 몇가지 있다. cache associativity/sets; write-allocate or not (I described write-through without write-allocate and write-back with, which is the most common usage); unaligned accesses; virtually-addressed caches 이 그것들이다. 흥미있으면 찾아봐도 좋지만, 이 글에서 다루지는 않을 것이다.

Coherency Protocols ( 일관성 프로토콜 )

시스템에 하나의 CPU 코어만 있다면 위의 모든 것들은 제대로 동작할 것이다. 그렇지만 몇몇 CPU 코어를 더 더하고 그들 각각에 캐시들을 더하면 문제가 생긴다. 만약 다른 코어가 우리 캐시에 있는 데이터 중 하나의 데이터를 수정하면 어떻게 될까?

답은 매우 간단하다. 아무일도 일어나지 않는다. 그리고 우리는 우리가 복사본을 가지고 있는 메모리를 다른 코어가 수정했을 때 무언가가 발생하기를 원하기 때문에 아무일도 일어나지 않는 것은 좋은 일이 아니다. 일단 우리가 여러개의 캐시를 가지면 우리는 그들 캐시 모두가 서로 일관되기를 원하지만 우리는 공유된 메모리(shared memory) 시스템을 가지고 있지 않다.

우리가 ‘여러개의 코어를 가지고 있다는 것’이 아니라 ‘여러개의 캐시를 가지고 있다는’ 것이 진짜 문제이다. 우리는 모든 코어간의 모든 캐시를 공유함으로써 이 문제를 해결할 수 있다. 오직 하나의 L1 캐시가 존재하고 모든 코어(프로세서)들이 그것을 공유해야하는 것이다. 각각의 CPU 사이클 동안 L1 캐시는 여러 코어 중 하나의 운 좋은 코어를 선택하고 그 선택받은 코어가 해당 사이클 동안 메모리 작업을 수행하는 것이다.

이것은 그럭저럭 동작은 한다. 문제는 느리다는 것이다. 왜냐면 코어들이 그들 시간의 대부분을 L1 캐시로 부터 선택받기만을 기다리면서 시간을 보내기 때문이다(그리고 모든 코어, 프로세서들은 이것들을 많이한다, 적어도 Load/Store 명령어에서 한번은 이것을 한다). 나는 이 부분을 강조하고 싶은데, 그것이 ‘멀티 코어’가 문제가 아니라 ‘멀티 캐시’가 문제라는 것을 잘 보여주기 때문이다. 우리는 캐시들 중 하나는 잘 작동하는 것을 알지만 그것은 매우 느리다. 그래서 다음으로 할 수 있는 최선의 선택은 여러개의 캐시를 가지고 그들이 마치 하나의 캐시인 것 처럼 행동하게 만드는 것이다. 이것이 바로 우리가 말할 Coherency Protocol이 하는 것이다. 이름이 말해주듯 그들은 여러개의 캐시의 데이터(contents)가 서로 일관(coherent)되게 보장해준다.

Coherency protocls에는 다양한 종류가 있지만 당신이 다루는 대다수의 기계들은 “snooping”이라는 종류의 protocol을 사용하고 우리는 이것에 대해 얘기해 볼 것이다. ( 대안으로 directory-based system이라는 것도 있지만 지연시간이 더 높다, 그렇지만 코어가 많은 시스템에서 사용하기도 한다)

“Snooping(감시, 관찰)”의 기본적인 아이디어는 모든 메모리 전송이 공유된 버스(모드가 함께 쓰는)에서 발생하고 모든 코어들은 그것을 볼 수 있다는 것이다. 코어가 그 전송을 보는 것 자체는 코어별로 독립되어 있지만, 메모리가 공유된 자원이기 때문에 코어 간의 중재(조정)이 필요하다. 오직 하나의 캐시만이 어떠한 사이클에서 메모리로의 전송, 읽기를 할 수 있다. Snooping 프로토콜의 기본 아이디어는 캐시가 메모리 전송을 하고자 할 때 직접적으로 캐시가 버스와 상호작용을 하지 않는다는 것이다. 대신 각각의 캐시는 다른 캐시들이 무엇을 하는지 추적하기 위해 지속적으로 공유된 버스의 트래픽(전송) snooping(관찰)한다. 그래서 만약 한 캐시가 그것의 코어를 대신하여 메모리로의 읽기 또는 쓰기를 수행하려 한다면 다른 코어들은 모두 그걸 알게 되고 이는 다른 코어들이 그들 캐시의 일관성을 유지하게 한다. 한 코어가 메모리에 쓰기를 수행하자마자 다른 코어들은 그 메모리 위치에 상응하는 캐시 라인이 stale(신선하지 않은), invalid(유효하지 않는)하다는 것을 알게된다.

( 필자가 조금 더 덧붙이자면 snoop 시스템에서는 모든 캐시들은 버스를 통하는 모든 버스 트랜젝션을 감시하고 있다. 그리고 위에서 말한대로 모든 코어는 물리적 메모리상의 모든 캐시 라인에 대해 자기 자신 코어의 상태 ( MESI )를 저장하고 있다가 버스 트랜젝션에 종류에 따라 대응한다. )
( 이러한 snoop 시스템은 두가지 방면에서 작동을 하는데 하나는 코어와 캐시간의 버스 트랜젝션에 대한 감시, 다른 하나는 메모리 컨트롤러에 의해 메모리에 대해 수행되는 버스 트랜젝션, 이 둘을 감시한다. )

Wrtie-through 캐시를 가졌다면 전송이 발생하자 마자 해당 코어는 메모리로의 write를 곧 바로 실행하기 때문에 이는 매우 간단할 것이다. 그러나 만약 write-back 캐시가 쓰인다면 이는 그렇게 동작하지 않을 것이다. 메모리로의 물리적 write-back이 해당 코어가 store을 실행한 후 오랜 시간 후에 발생하기 때문에(write-back은 캐시에 데이터를 쓴 후 바로 메모리에 데이터를 쓰지 않고 그냥 dirty로 표시해둔 후 후에 메모리에 데이터를 쓴다), 그 메모리에 데이터를 쓰기 전에 다른 코어는 그 사실을 알지 못한다 심지어 만약 다른 코어들이 같은 메모리 위치에 데이터를 쓰려한다면 코어들 사이 데이터간 불일치, 충돌이 발생할 것이다. 그래서 write-back 모델로는 메모리로의 쓰기를 알리는 것은 충분하지 않다(왜냐면 바로 메모리에 쓰지 않고 자기 캐시에 썼다가 나중에 메모리에 쓰기 때문이다). 만약 충돌을 피하고 싶다면 우리는 한 코어가 해당 코어의 local copy(아마 캐시를 말하는 것 같다)에서의 어떠한 변화(데이터 쓰기)를 시작하기 전에 쓰려고 한다는 것을 다른 코어에 알릴 필요가 있다. 디테일하게 들어가 보자면 가장 쉬운 해결책은 흔히 “MESI protocol”이라는 것이 있다. ( Write - Through 캐시의 경우에는 곧 바로 메모리에도 데이터를 쓰므로 각 코어들은 Snooping을 통해서 자신이 가지고 있는 캐시가 Stale한지 아닌지를 바로 알 수 있다, Write - Through에서는 메모리만 봐서는 자신의 Cache가 Stale한지를 알 수 없음 )

MESI and friends ( MESI와 친구들 )

이 섹션은 MESI가 coherency protocol과 관련된 것들을 모두 소환하기 때문에 “MESI와 친구들”이라고 지었다. 기본적인 것부터 시작해보자. MESI는 멀티 코어 시스템에서 여러 코어들의 4가지 어떠한 “상태”에 대한 4가지 이니셜이다. 나는 역순으로 다루는 것이 더 낫기 때문에 그것들을 역순으로(I -> S -> E -> M)으로 다룰 것이다. ( 참고로 각 상태는 각 코어에서 캐시 내 캐시 라인들의 상태를 나타낸다. )

I : 유효하지 않은(Invalid) 라인은 캐시에 존재하지 않거나, 데이터가 stale(신선하지 않은)한 데이터를 가진 캐시라인을 의미한다. 캐시의 목적에 맞게 이것들은 무시된다. 캐시 라인이 무효화(invalidated)되면 그것은 애초에 캐시에 없었던 것 처럼 간주된다.

S : 공유된(Shared) 라인들은 메인 메모리 데이터의 깨끗한(stale하지 않은) 복사본이다. 공유된 상태의 캐시 라인들은 읽기(read)는 가능하지만 쓰기(write)는 불가능하다. 여러 캐시들은 이름과 같이 공유된 상태에 있는 동일한 메모리 위치의 데이터의 복사본을 동시간에 가질 수 있다.

E : 독점적(Exclusive) 라인들 또한 공유된(Shared) 라인 메인 메모리 데이터의 깨끗한(stale하지 않는, M 상태와 다른 점) 복사본이다. 차이는 한 코어만이 E 상태의 캐시라인을 가질 수 있다는 것이다. 다른 코어는 그 캐시라인을 가지고 있을 수 없다 그래서 독점적이라 하는 것이다. 즉 다른 코어에서의 해당 캐시라인은 I 상태여야 한다는 것이다.

M : 수정된(Modified) 라인은 dirty이다. 그들은 지역적으로(각 코어의 캐시에서만) 수정되었다. 만약 한 캐시라인이 M 상태에 있다면 다른 코어에서 해당 캐시라인은 I 상태여야 한다(이건 E와 똑같다). 더하여, 수정된 캐시라인은 그 캐시 라인이 축출(evicted)되거나 무효화(invalidated)될 때 메모리에 write-back될 필요가 있다.( 이점은 write-back 캐시에서 일반 dirty 상태의 경우와 같다 ).

만약 이걸 싱글 코어에서의 write-back 캐시의 경우와 비교해본다면, 당신은 I, S, M 상태는 이미 같은 것을 가지고 있다는 것을 알 것이다. invalid/not presend, clean, dirty 캐시 라인 각각 말이다.(여기서 말한 I,S, M 상태가 싱글 코어에서 각각 invalid/not presend, clean, dirty 캐시라인과 같은 역할을 한다는 것이다, 기능적으로 비슷). 그래서 주목해야 할 것은 “E” 상태, 독점적 접근이다. 이 상태는 “우리가 메모리를 수정하기 전 다른 코어에 미리 말해야한다”는 문제를 해결해준다. 각각의 코어는 그들의 캐시 라인이 E와 M 상태에만 있는 경우에만 캐시 라인에 쓰기를 할 것이다. 그들이 독점적으로 소유된 경우를 생각해보자. 만약 한 코어가 그것이 쓰기를 하려는 캐시 라인에 독점적인 접근을 하려고 한다면 버스를 통해 다른 코어에 “난 이 캐시라인에 독점적인 접근 원해”라고 알려야한다. 이것은 다른 코어들에게 만약 해당 캐시라인의 복사본을 가지고 있다면 그 캐시라인을 무효화(invalidate)하라고 말하는 것이다(I 상태로 만들어라). 그리고 오직 독점적인 접근이 주어졌을 때만 해당 코어가 데이터를 수정(write)하기를 시작한다. 그 시점에서 해당 코어는 수정하려는 캐시라인의 복사본이 오직 해당 코어의 캐시만 가지고 있다는 것을 알 것이고(다른 코어에게 I상태로 만들라고 하고 I 상태로 만든 후이다) 그 의미는 어떠한 충돌도 생기지 않는 다는 것이다.

그 후 다른 코어들이 해당 캐시라인에서 데이터를 읽기를 원하면(우리는 버스를 snooping(감시)하고 있기 때문에 그 사실을 즉시 알 것이다, 정확히 얘기하면 Write-back Cache에서는 메모리버스를 snooping해서는 알 수 없고 한 코어가 다른 코어에게 invalidate를 요청해야한다), 위에 데이터를 수정한 코어의 독점적(E 상태), 수정된(M 상태) 캐시라인은 다시 공유된(S 상태) 상태로 돌아 갈 것이다. 해당 캐시라인이 수정된(dirty, M 상태) 상태인 경우에는 우선 해당 코어가 데이터를 메인 메모리에 쓸 것이다.(다른 캐시는 수정된 최신 데이터를 가지고 있지 않기 때문이다)

MESI protocol은 코어에서 들어오는 요청과 버스에서의 메세지에 대응하는 적절한 상태 머신이다. 나는 모든 상태에 대한 다이어그램, 상태 변환의 여러 종류 등 디테일한 부분까지는 다루지 않을 것이다. 만약 당신이 관심이 있다면 하드웨어 아키텍쳐에 대한 책에서 디테일한 부분에 대해 쉽게 찾을 수 있을 것이다, 그렇지만 여기서 다루기에는 과하다. 소프트웨어 개발자로서 당신은 두가지 점은 잘 알고 있을 것이다.

첫째로 멀티 코어 시스템에서 캐시 라인에의 읽기(read) 접근은 다른 코어에게 알리는 것을 수반하고, 그리고 그 다른 코어들이 메모리 전송을 수행하도록(dirty한 경우) 할 것이다.
캐시 라인에 쓰는 것(write)은 여러 과정이 필요하다. 어떤 데이터를 쓰기 전 당신은 처음 해당 캐시라인의 독점적(E 타입) 소유권을 획득하고 그것의 현재 데이터 복사본을 얻는 작업이 필요하다(이른바 “소유권을 위한 읽기” 요청이라 불린다.)

두번째로 우리는 추가적으로 몇몇 작업을 해야하지만, 그 결과는 실제로 몇몇 “강력한 보장”을 제공한다.

MESI 불변성 : 모든 dirty(M 상태)상태의 캐시라인을 메모리에 write-back 한 후에는 모든 캐시 단계 내의 모든(!) 캐시 라인의 데이터들은 상응하는 위치의 메모리 내의 데이터와 일치(상응, 일관, coherency)할 것이다. 더하여, 한 메모리 위치가 한 코어에 의해 독점적으로 캐시된(E 혹은 M 상태) 상태일 때, 해당 캐시라인은 다른 코어의 어떠한 캐시 단계에도 항상(!) 존재하지 않는다(I 상태이다). 

알아야할 것은 이것은 그냥 우리가 추가적인 독점 교칙 없이 봤던 write-back 불변성과 같다는 것이다. 내가 말하고 싶은 것은 MESI 혹은 여러 코어의 존재가 우리의 메모리 모델을 무조건 약화시키지는 않는다는 것이다.

좋다, 오직 순수한 MESI를 다루어 보자(ARM의 CPU가 이걸 사용한다). 다른 프로세서들은 확장된 버전을 사용한다. 유명한 확장 버전의 특징은 E상태와 비슷한 O(Owned, 소유된) 상태인데, 그것 dirty 캐시라인을 메모리에 처음 write-back하는 것 없이 공유하는 것을 허용해주는 상태인데, 한 코어를 해당 캐시라인으로의 읽기 요청에 대한 지정 응답자로 만든다. 여러 코어들이 공유된 상태(S 상태)의 캐시라인을 가지고 있을 때, S상태의 해당 캐시라인을 보유중인 모든 코어가 아니라 오직 지정된 응답자(R, F 상태의 캐시라인을 가지고 있는)만이 해당 읽기 요청에 응답한다. 이것은 버스 통행량(트래픽, traffic)을 줄인다. 물론 당신은 R, F와 O 상태 둘 다를 더할 수도 있다. 이 모든 것들이 최적화들이지만 그들 어느 것도 프로토콜에 의해 만들어진 보장성 혹은 기본 불변성을 바꾸지는 못한다.

나는 이 주제에 대해서는 전문가가 아니라 대안으로 오직 약한 보장성만을 제공하는 다른 프로토콜들이 존재할 가능성도 있지만 나는 알지 못한다. 우리에 목적에 맞게 우리는 일관성 프로토콜(coherency protocol)이 캐시 일관성(coherent)를 보장한다고 가정할 것이다. 대략적인 일관성 혹은 변화 후 짦은 기회를 제외한 일관성이 아닌 ‘적절한 일관성’ 말이다. 그 단계에서 하드웨어를 조작한 경우를 제외하고는 항상 메모리의 현재 상테에 대해서는 일관성(일치성)이 존재한다. 기술적 관점에서 MESI 그리고 모든 그것의 변화는 이론적으로 완전한 순차적 일관됨(sequential consistency), C++11 메모리 모델에서 말하는 가장 강력한 메모리 순서 보장을 제공한다. 그럼 왜 우리는 약한 메모리 모델을 가지고 있고, ‘어디서 그것이 발생하는지’라는 질문이 따른다.

Memory Models ( 메모리 모델들 )

다양한 아키텍쳐들은 다양한 메모리 모델들을 제공한다. 이 글에서는 ARM 그리고 POWER 아키텍쳐 머신들은 상태적으로 ‘약한’ 메모리 모델들을 가지고 있다. 여기서는 CPU 코어가 제약(이 라인에서는 메모리 동작 순서를 바꾸지 말라는 등의)을 두는 프로그램에 의해 사용될 수 있는 메모리 장벽(memory barrier)와 함께 멀티 코어 환경에서의 프로그램들의 문맥을 바꿀 수 있는 읽기(load), 저장(store) 명령어에서의 상당한 재량권을 가지고 있다. 반면에 x86은 상당히 강한 메모리 모델을 가지고 있다.

나는 메모리 모델들에 대한 디텔인한 부분까지는 여기서 다루지 않을 것이다. 그것은 상당히 기술적인 부분이고 이 글의 주제를 벗어난다. 그러나 나는 “어떻게 그것들이 발생하는지”에 대해서는 조금 얘기해보려 한다. 즉 약화된 보장(MESI에서 얻을 수 있는 완전한 순차적 일관됨(sequential consistency)이 어디서 오고 왜 오는지 말이다. 일반적으로 그것은 전부 성능으로 귀결된다.

당신은 아래와 같은 경우 완전한 순차적 일관성을 얻을 것이다. a) 캐시가 버스 이벤트를 받은 그 사이클 바로 직후의 사이클에서 해당 버스 이벤트에 응답한다. b) 해당 코어 프로그램 순서대로 성실하게 그 코어의 캐시에 각각의 메모리 동작을 다음 메모리 동작을 보내기 전 해당 메모리 동작이 완료되기 까지 기다린다. 당연히도 현대 CPU들은 이것들 중 어떠한 것도 하지 않는다.

  • 캐시들은 버스 이벤트에 즉시 응답하지 않는다. 만약 캐시가 무언가 다른 것(예를 들면 해당 코어에 데이터를 전송하는)을 바쁘게 하는 동안 캐시 라인 무효화(invalidation)을 유발하는 버스 메세지를 받으면, 해당 캐시는 무효화 버스 메세지를 해당 사이클에 처리하지 못할 것이다. 대신 해당 무효화 버스 메세지는 ‘무효화 큐(invalidation queue)’에 들어갈 것이다. 해당 버스 메세지는 캐시가 그것을 처리할 시간을 가질 때 까지 그 무효화 큐에 머물 것이다.

  • 대개 코어는 메모리 동작을 전송할 때 프로그램의 순서를 엄격하게 준수하지 않는다(순차적 일관성을 보장 않음). 이것은 Out-of-Order 방식을 채택하는 코어의 경우에 특히 해당되는건 데, 심지어는 In-Order 방식을 채택한 CPU의 경우에도 다소 약한 순서 보장(not sequentially)을 가지고 있다. ( 예를 들어보면 하나의 캐시가 전체 코어의 즉각적인 정리를 유발하지 않는다. )

  • 특히 저장(Store)는 특별한데, 그것이 2 단계의 동작이기 때문이다. 우리는 저장(Store) 과정 전 해당 캐시 라인의 독점적인 소유권(E 단계)을 보유 할 필요가 있다. 그리고 만약 우리가 독점적인 소유권을 가지고 있지 않다면 우리는 다른 코어에게 말할 필요하고 있는 데 이 과정이 어느 정도 시간이 필요하다. 이 시간 동안 코어를 idle 상태로 만들고 기다리는 것은 그렇게 좋은 방법이 아니다. 대신 코어는 독점적 소유권을 얻는 과정을 시작 한 후 해당 저장(store) 동작을 ‘store buffers(저장 버퍼)’라고 불리는 큐에 넣는다. 해당 저장 동작은 캐시가 저장 동작을 수행할 준비가 될 때 까지 저장 버퍼에 머물고, 캐시가 준비가 완료되면 store buffer에서 저장 동작을 빼내진다. 해당 저장 버퍼는 새로운 대기가 필요한 새로운 저장 동작을 저장는 데 재활용된다.

이러한 모든 것들이 의미하는 것은 기본적으로 읽기(load)는 stale(clean하지 않은)한 데이터를 전송받을 수 있고(만약 상응하는 무효화(invalidation) 요청이 무효화 큐에 있는 경우), 저장(store)은 실질적으로는 실제 코드에서의 위치보다 늦게 완료되게 되고, 그리고 Out-Of-Order 방식이 채택된 경우 모든 것은 더욱 더 모호해 질 것이다(순차적 불일관성이 더욱 커진다는 의미 같다). 다시 메모리 모델로 돌아가서 메모리 모델에는 두가지 진영이 존재하다.

약한 메모리 모델을 가진 아키텍쳐들은 코어에서 최소한의 필요한 작업만을 하는데 이는 소프트웨어 개발자들에게 올바른 코드를 작성한다. 명령어 순서 재배치 그리고 다양한 버퍼링 단계들이 공식적으로 허용된다. 보장성이 없다. 만약 당신이 보장(순차적 일관성)이 필요하다면 당신은 코드에 적절한 메모리 장벽(memory barrier)을 삽입할 수 있다. 메모리 장벽은 명령어 순서 재배치를 막고, 해당 위치에서 즉시 대기 중인 동작(operation)을 빼내 실행한다.

강력한 메모리 모델을 가진 아키텍쳐들은 내부적으로 훨씬 더 많은 것들을 한다. 예를 들면 x64 프로세서들은 MOB(메모리 순서 버퍼, memory ordering buffer)라고 불리는 칩 내부 데이터 구조에서 아직 끝나지 않은 모든 대기중인 메모리 동작들을 추적한다. Out-Of-Order 인프라 구조의 일부로서 x86 코어들은 문제가 생긴 경우 아직 끝나지 않는 동작들을 되돌릴 수 있다. 여기서 말하는 문제는 분기 예측 실패나 페이지 폴트 같은 예외를 말한다. 나는 세부적인 내용 뿐만 아니라 메모리 하위 시스템과의 몇몇 상호작용들을 앞서 쓴 글에서 다루었다. 그 글의 요점은 x86 프로세서들은 아직 끝나지는 않았지만 이미 실행된 동작들의 결과를 소급하여 무효화하는 외부 이벤트를 적극적으로 관찰한다. 즉 x86 프로세서들은 그것의 메모리 모델 규칙에 맞지 않는 이벤트가 발생하였을 때 가장 최근에 메모리 모델 규칙에 맞았던 때로 머신 상태를 되돌린다. 이것은 내가 또 다른 앞서 쓴 글에서 얘기했던 “메모리 순서 머신 초기화”이다. 결과적으로 x86 프로세서들은 메모리 동작에 대한 매우 강력한 보장을 제공한다. 그러나 여기서 말하는 강력한 보장이 완전한 순차적 일관성(sequential consistency)은 아니다.

그래서 약한 메모리 모델들은 간단한 코어(낮은 파워의)에 활용된다. 강력한 메모리 모델은 코어의 디자인(그리고 메모리 하위시스템)을 더욱 복잡하게 만들고 코드를 작성하기도 더 쉽다. 이론적으로 약한 메모리 모델들은 더 많은 스케쥴링(scheduling) 자유를 허용하고 더 빠를 수 있다(강력한 메모리 모델 보다). 실제로는 x86 프로세서도 메모리 동작의 성능 면에서 나쁘지 않은 성능(시간)을 가지고 있는 것 같다. 그래서 두 메모리 모델 중 무엇이 승자인지는 말하기 쉽지 않다. 소프트 웨어 개발자로서 나는 x86의 더욱 강력한 메모리 모델을 가지고 있다는 점에 행복하다.