Xbox 360 CPU에서 발견한 설계 결함
2018년 1월 7일 | Bruce Dawson
Meltdown과 Spectre 취약점이 공개되면서, 예전에 Xbox 360 CPU에서 찾아낸 비슷한 설계 결함이 생각났습니다. 새로 추가된 명령어 하나의 존재 자체가 위험했던 사건인데요.
그 당시 나는 CPU 담당자였다
2005년, 저는 Xbox 360 CPU 담당자였습니다. 정말 그 칩에 빠져 살았죠. 지금도 30cm 크기의 CPU 웨이퍼가 제 벽에 붙어 있고, CPU 레이아웃을 담은 4피트 크기의 포스터도 있습니다. CPU 파이프라인이 어떻게 동작하는지 이해하는 데 그렇게 많은 시간을 투자했던 덕분에, 나중에 설명할 수 없는 크래시들을 조사해달라는 요청을 받았을 때 직관적으로 설계 결함의 원인을 파악할 수 있었어요.
먼저 배경 정보부터 설명하겠습니다.
Xbox 360 CPU의 구조
Xbox 360 CPU는 IBM이 만든 3코어 PowerPC 칩입니다. 세 개의 코어가 각각 다른 영역에 배치되어 있고, 네 번째 영역에는 1MB의 L2 캐시가 들어가 있죠. 각 코어는 32KB 명령어 캐시와 32KB 데이터 캐시를 가지고 있습니다.
참고로 코어 0은 L2 캐시에 더 가깝게 배치되어 있어서, 실제로 L2 캐시 접근 지연 시간이 더 짧았습니다.
캐시 설계의 딜레마
Xbox 360 CPU는 전반적으로 높은 지연 시간을 가지고 있었는데, 메모리 지연이 특히 심했어요. 게다가 3코어용으로는 1MB의 L2 캐시가 꽤 작은 편이었습니다. 그래서 L2 캐시 공간을 아껴서 캐시 미스를 최소화하는 게 매우 중요했습니다.
CPU 캐시의 성능 향상은 두 가지 원리에 기반합니다. 공간 지역성(Spatial Locality)은 어떤 바이트를 사용하면 그 주변 바이트도 곧 사용할 가능성이 높다는 뜻이고, 시간 지역성(Temporal Locality)은 한 번 사용한 메모리는 가까운 미래에 다시 사용할 가능성이 높다는 의미입니다.
그런데 시간 지역성이 항상 발생하는 건 아닙니다. 프레임당 한 번씩만 처리하는 큰 배열 데이터가 있다면, 그 데이터가 다시 필요해질 때쯤이면 L2 캐시에서 완전히 제거되어 있을 거라는 걸 수학적으로 증명할 수 있어요. 공간 지역성의 이점을 누리려면 그 데이터가 L1 캐시에 있어야 하는데, L2 캐시에 들어 있다면 결국 다른 중요한 데이터를 밀어내게 되어 다른 두 코어의 성능을 떨어뜨릴 수 있습니다.
xdcbt: 위험한 최적화
보통은 이를 피할 수 없습니다. PowerPC CPU의 메모리 일관성 메커니즘은 L1 캐시의 모든 데이터가 L2 캐시에도 존재해야 한다고 요구했거든요. 메모리 일관성을 위해 MESI 프로토콜을 사용했는데, 한 코어가 캐시 라인에 쓰기를 하면 같은 캐시 라인의 복사본을 가진 다른 코어들이 그것을 폐기해야 합니다. L2 캐시가 어느 L1 캐시가 어느 주소를 캐싱하고 있는지 추적하는 역할을 했던 거죠.
그런데 이건 비디오 게임 콘솔이었고, 성능이 무엇보다 중요했습니다. 그래서 새로운 명령어가 추가됩니다. xdcbt인데요. 일반적인 PowerPC dcbt 명령어는 전형적인 프리페치 명령어입니다. 반면 xdcbt는 확장 프리페치 명령어로, L2를 건너뛰고 메모리에서 직접 L1 d-캐시로 데이터를 가져옵니다.
이는 메모리 일관성을 더 이상 보장할 수 없다는 뜻입니다. 하지만 게임 프로그래머들이니까 우리가 뭘 하고 있는지 알겠지, 괜찮겠지라는 생각이었어요.
큰 실수였습니다.
문제의 발단
저는 Xbox 360용 메모리 복사 루틴을 광범위하게 사용되도록 작성했는데, xdcbt를 선택적으로 사용할 수 있었습니다. 소스 데이터 프리페칭이 성능에 중요했는데, 보통은 dcbt를 사용하되 PREFETCH_EX 플래그를 넘기면 xdcbt로 프리페치하는 방식이었습니다. 잘 생각해서 만든 코드가 아니었어요. 프리페칭 로직은 대략 이렇습니다:
if (flags & PREFETCH_EX)
__xdcbt(src+offset);
else
__dcbt(src+offset);
이 함수를 사용하던 게임 개발자가 이상한 크래시를 보고했습니다. 힙 손상 크래시인데, 메모리 덤프에서 힙 구조들은 정상으로 보였어요. 크래시 덤프를 한참 들여다본 후, 제가 얼마나 큰 실수를 했는지 깨달았습니다.
xdcbt로 프리페치된 메모리는 '독성'을 가집니다. 다른 코어가 L1에서 플러시되기 전에 그 메모리에 쓰기를 하면, 두 코어가 메모리의 서로 다른 버전을 보게 되는데 두 버전이 수렴할 보장이 없어요. Xbox 360의 캐시 라인은 128바이트였고, 제 복사 루틴의 프리페칭이 소스 메모리의 끝까지 갔습니다. 즉, xdcbt가 적용된 캐시 라인 중 일부는 인접한 데이터 구조의 후반부까지 포함하고 있었어요. 보통은 힙 메타데이터였는데, 실제로 크래시가 발생한 곳이 바로 거기였습니다.
일관성 없는 코어는 (잠금을 신중히 사용했음에도) 오래된 데이터를 봤고, 크래시가 발생했습니다. 하지만 크래시 덤프가 RAM의 실제 내용을 써낼 때는 무슨 일이 일어났는지 볼 수 없었어요.
따라서 xdcbt를 안전하게 사용하는 유일한 방법은 버퍼 끝을 한 바이트라도 넘어 프리페치하지 않도록 매우 조심하는 것입니다. 저는 복사 루틴을 수정해서 너무 멀리 프리페치하지 않도록 했는데, 수정이 배포되기 전에 게임 개발자가 PREFETCH_EX 플래그를 넘기지 않도록 했고 크래시는 사라졌습니다.
진짜 버그
여기까지는 정상적인 스토리죠? 자만심에 찬 게임 개발자들이 불장난을 하다가, 태양에 너무 가까이 날아가다가, 어머니와 결혼했다가(농담입니다), 게임 콘솔이 크리스마스를 놓칠 뻔하는 이야기요.
그런데 우리가 시간 안에 잡았고, 운 좋게 넘어갔고, 게임과 콘솔을 출시하고 행복하게 집에 갈 준비가 되어 있었습니다.
그 시점에 같은 게임이 다시 크래시하기 시작했어요.
증상은 완전히 똑같았습니다. 다만 이번엔 게임이 xdcbt 명령어를 더 이상 사용하고 있지 않았습니다. 코드를 직접 스테ップ스루 해도 확인됐어요. 심각한 문제가 생겼습니다.