13 컴파일러 최적화 — 추상 기계
먼저 알아야 할 것
돌아보기
12장 끝에서 컴파일러를 “뜻만 지킨다면 무엇이든 뜯어고칠 권리를 가진 층”이라 불렀다. 그런데 5장에서 C는 “기계의 정직한 별명”이라 하지 않았나 — 내가 쓴 C 코드는 기계 명령에 그대로 대응하는 것 아니었나?
답. 그 시절에는 대체로 그랬고, 지금은 아니다. 오늘의 컴파일러는 직역가가 아니라 편집자다 — 뜻을 지키는 한에서 문장을 통째로 다시 쓴다. “그대로 대응”이라는 초기 C의 감각이 어떻게, 왜 무너졌는가가 이 장이고, 그 무너짐이야말로 C를 추상적인 언어로 만든 마지막 조각이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 멋대로 고쳐도 되는 근거가 무엇인가? 어디까지 고쳐도 “뜻을 지켰다”고 치는가?
- 최적화가 이렇게 위험하면, 그냥 끄고 살면 안 되는가?
13.1 편집자의 작업실 — 컴파일러가 하는 일
몇 가지 실제 편집 사례를 보면 감각이 잡힌다. 컴파일러는 일상적으로 이런 일을 한다.
미리 계산한다. 소스에 초 = 24 * 60 * 60이라 적혀 있으면, 곱셈 명령을 만들지 않고 그냥 86400을 적어 넣는다. 실행 시점에 곱할 이유가 없기 때문이다.
죽은 일을 지운다. 계산해 놓고 아무도 안 쓰는 값, 도달할 수 없는 갈림길 — 통째로 사라진다. 소스에 있는 코드가 실행 파일에는 존재하지 않을 수 있다.
순서를 바꾼다. 서로 상관없는 계산이라면, 파이프라인(12장)이 잘 차도록 앞뒤를 재배열한다. 소스의 줄 순서는 실행 순서의 약속이 아니게 된다.
메모리 왕복을 없앤다. 루프가 도는 내내 변수 하나를 매번 메모리에서 읽고 쓰는 대신, 레지스터(11장)에 올려 두고 끝날 때 한 번만 내려놓는다. 소스에는 천 번의 메모리 접근이 적혀 있어도 실제로는 두 번일 수 있다.
이 편집들이 겹치고 쌓이면, 실행 파일 속 기계 명령과 소스 코드는 문장 대 문장으로 대응하지 않는 별개의 문서가 된다. 디버거로 최적화된 프로그램을 들여다보면 “이 줄은 최적화로 사라졌음”이라는 안내를 만나는 것이 그래서다.
문. 멋대로 고쳐도 되는 근거가 무엇인가? 어디까지 고쳐도 “뜻을 지켰다”고 치는가?
답. 바로 그 경계선을 긋는 것이 표준의 일이고, 경계선의 이름이 관찰 가능한 결과다. 대략 이렇다 — 프로그램이 바깥세상과 주고받는 것(출력한 내용, 읽은 입력, 특별히 보호된 접근)만 약속대로면, 그 결과를 만들어 내는 속사정은 전부 컴파일러의 재량이다. 중간 계산을 몇 번 하는지, 변수가 메모리에 실제로 있는지, 어떤 순서로 일하는지 — 바깥에서 관찰되지 않는 한 아무래도 좋다. 겉보기만 같으면 속은 자유 — 이것이 계약의 문장이다.
13.2 추상 기계 — 계약서 속의 가상 기계
그러면 “겉보기”의 기준은 누가 정하는가. 실제 기계일 수는 없다 — 실제 기계는 수천 종이고 저마다 다르다. 그래서 표준은 문서 안에 가상의 기계를 하나 정의해 두었다. C 프로그램의 의미란 “이 가상 기계 위에서 이렇게 실행되는 것”이라고 못박는, 종이 위에만 존재하는 기계 — 추상 기계(abstract machine)다.
이제 세 층의 관계가 선명해진다.
- 프로그래머는 추상 기계를 상대로 코드를 쓴다. 코드의 의미는 추상 기계 위에서의 실행이 정한다.
- 컴파일러는 그 의미의 관찰 가능한 결과만 지키면서, 실제 기계에 맞는 가장 빠른 코드를 자유롭게 지어낸다.
- 실제 기계는 캐시가 몇 층이든 파이프라인이 몇 단이든(11·12장), 그 소용돌이를 겉으로 드러내지 않는다.
5장에서 “C는 기계의 정직한 별명”이라는 평판이 오늘날 절반만 맞다고 했던 것의 정답이 이것이다. C가 별명 노릇을 해 주는 대상은 이제 실제 기계가 아니라 추상 기계다. 실제 기계와의 대응은 컴파일러가 그때그때 지어내는, 겉보기만 보장된 번역이다.
13.3 눈에 보이는 편집 — 값을 언제 읽고 언제 쓰는가
편집자가 가장 즐겨 손대는 자리가 메모리 왕복이다. 11장에서 배운 대로 메모리는 느리고 레지스터는 즉시라서, 컴파일러는 값을 되도록 레지스터에 붙잡아 둔다. 두 장면으로 감을 잡는다.
장면 1 — 반복되는 읽기를 한 번으로. 이런 루프를 생각하면 된다:
for (int i = 0; i < n; i += 1) {
total += table[i] * scale; /* scale은 루프 내내 안 바뀐다 */
}소스대로라면 scale을 매 바퀴 메모리에서 읽어야 하지만, 컴파일러는 “이 루프 안에서 scale을 바꾸는 코드가 없다”고 판단해 루프 시작 전에 한 번 읽어 레지스터에 얹어 둔다(루프 불변 코드 이동). 천 번의 읽기가 한 번이 된다. total도 마찬가지로 레지스터에 두었다가 루프가 끝난 뒤 한 번만 메모리에 내려놓는다.
장면 2 — 쓰기를 미루고 순서를 바꾸기. 서로 무관한 두 변수에 값을 넣는 코드라면, 컴파일러는 파이프라인(12장)이 잘 차도록 순서를 바꾸거나 쓰기를 뒤로 미룰 수 있다. 소스의 줄 번호는 실행 시각의 약속이 아니다.
두 편집 모두 관찰 가능한 결과가 같으므로 계약 안이다. 문제는 — “관찰 가능”의 기준이 표준의 것이지 프로그래머의 기대가 아니라는 데서 생긴다.
실제 사례. 안 도는 루프 — “메모리를 곧이곧대로” 믿었을 때
임베디드 프로그래밍의 고전적 사고를 보자. 하드웨어의 상태 레지스터가 메모리의 어떤 주소에 나타난다고 하고(6장의 그 세계다), “준비될 때까지 기다리는” 코드를 이렇게 적었다고 하자:
int flag = *status; /* 장치가 바꿔 주는 값 */
while (flag == 0) { flag = *status; } /* 준비될 때까지 대기 */프로그래머의 그림은 “매 바퀴 메모리를 다시 읽는다”이다. 그러나 컴파일러의 눈에는 이 루프 안에서 *status를 바꾸는 코드가 하나도 없다 — 그러면 장면 1의 편집이 적용된다: 값을 한 번만 읽어 레지스터에 얹고, 이후로는 그 레지스터만 본다. 장치가 아무리 메모리를 바꿔도 프로그램은 영원히 옛 값을 보며 돈다. 무한 루프다.
이유는 계약에 있다. 표준의 추상 기계는 “이 프로그램 밖의 무언가가 메모리를 바꿀 수 있다”는 것을 알지 못한다 — 그런 사정이 있다면 프로그래머가 알려 주어야 하고, 그 표기가 volatile이다(“이 접근은 매번 실제로 하라”는 지시). 같은 무늬가 멀티코어 세계에서도 재현된다: 다른 코어가 바꾸는 값을 평범한 변수로 폴링하면 같은 이유로 굳는다 (그쪽의 정답은 volatile이 아니라 C11의 원자적 타입이다 — 아래 참조). 교훈은 하나다: 컴파일러는 소스에 적힌 것을 실행하지 않는다. 계약이 보장한 결과만 만든다.
13.4 계약의 정밀화 — C99, C11
C89가 계약서의 초판이었다면(12장), 이후의 표준들은 조항을 정밀하게 다듬어 온 개정판들이다. 기계와 컴파일러가 공격적으로 진화할수록 “어디 까지 고쳐도 되는가”를 더 날카롭게 그어야 했기 때문이다. 두 번의 큰 개정만 짚는다.
C99(1999)는 편집자에게 더 많은 재량 근거를 주는 쪽으로 언어를 다듬었다 — 대표적으로, 타입이 다른 이름끼리는 같은 기억을 가리키지 않는다고 전제해도 좋다는 규칙(엄격한 앨리어싱, strict aliasing)이 명문화됐다. 이 전제가 있어야 편집자가 “이 둘은 서로 무관하다”고 보고 재배열·캐싱을 할 수 있다. 대가는 프로그래머 쪽 의무다 — 그 전제를 깨는 코드(같은 기억을 다른 타입의 눈으로 읽는 일)는 계약 위반이 된다(48장에서 안전한 방법과 함께 재론한다).
C11(2011)은 12장의 멀티코어 시대에 대한 응답이다 — 일꾼이 여럿일 때 “한 코어의 쓰기가 다른 코어에 언제 보이는가”를 계약 조항으로 못박은 메모리 모델과, 그 통신에 쓰는 원자적 타입(_Atomic)이 표준에 들어 왔다. 그 전까지 다중 코어 위의 C는 표준 바깥의 관행에 기대던, 말 그대로 계약서 없는 동업이었다.
13.5 잘못된 신호 — 엄격한 앨리어싱 위반이 부르는 유령
엄격한 앨리어싱은 이 장에서 가장 조심스러운 조항이라, 무늬를 하나 보아 둔다. 규칙을 다시 적으면 — 타입이 다른 두 포인터는 같은 기억을 가리키지 않는다고 컴파일러가 전제해도 좋다(char 계열은 예외, 37장).
문제는 이 전제가 프로그래머의 코드에서 신호로 읽힌다는 데 있다. 부동소수점의 비트를 들여다보려고 이런 옛 기법을 썼다고 하자:
float f = 1.0f;
uint32_t bits = *(uint32_t *)&f; /* 계약 위반: float를 uint32의 눈으로 */프로그래머의 의도는 “같은 4바이트를 다른 눈으로 읽자”이지만, 컴파일러가 받은 신호는 정반대다 — “이 float*와 저 uint32_t*는 서로 다른 기억을 가리킨다(그렇게 전제해도 좋다)”. 그러면 편집자는 안심하고 f에 대한 쓰기를 레지스터에 붙잡아 둔 채 bits 읽기를 앞당길 수 있고 — 결과는 “쓰기 전의 옛 비트”가 읽히는, 소스만 봐서는 설명되지 않는 유령이 된다. 더 고약한 점은 이 유령이 최적화를 켰을 때만, 그것도 컴파일러 판에 따라 나타났다 사라진다는 것이다(“디버그 빌드에서는 되는데 릴리스에서만 틀려요”의 전형적 정체다).
옳은 방법은 두 가지다 — memcpy로 바이트를 복사하거나(현대 컴파일러는 이 복사를 알아보고 공짜로 최적화한다), 다음 부에서 배울 공용체를 쓰는 것이다(48장에서 이 시연을 정식으로 한다). 요점은 기법이 아니라 관점 이다: 계약 위반은 “조금 위험한 코드”가 아니라, 컴파일러에게 사실이 아닌 것을 사실이라고 알리는 행위다. 그래서 결과가 “조금 이상함”이 아니라 “무엇이든 가능함”이 된다 — 표준이 이런 상태에 붙인 이름이 정의되지 않은 동작(undefined behavior, UB)이고, 이 책의 52장이 그 정면 취급이다.
실제 사례. 사라진 지우개 — 최적화가 지운 보안 코드
“겉보기만 같으면 속은 자유”가 실제로 문 사고가 있다. 보안 프로그램들 은 비밀번호를 다 쓴 뒤 그 메모리를 0으로 덮어 지우는 관행이 있다 — 메모리 어딘가에 비밀이 남아 있지 않도록. 그런데 편집자의 눈에 이 지우기는 아무도 다시 읽지 않는 쓰기, 즉 죽은 일이다. 관찰 가능한 결과에 아무 기여가 없으므로 — 지워도 된다. 실제로 여러 컴파일러가 이 지우기를 조용히 삭제했고, 비밀번호가 메모리에 남은 채 배포된 소프트웨어들이 보안 권고의 대상이 됐다. 계약을 무시한 쪽은 컴파일러가 아니라, 계약서를 안 읽은 사람이었다 — 이후 표준과 플랫폼들은 “이 쓰기는 지우지 말라”고 명시하는 장치(memset_s 등)를 마련했다. 관찰 가능한 결과라는 조항 하나가 실무에서 얼마나 무거운지 보여 주는 사건이다.문. 최적화가 이렇게 위험하면, 그냥 끄고 살면 안 되는가?
답. 개발 중의 디버깅 빌드는 실제로 최적화를 낮춰서 소스와 실행의 대응을 살려 둔다. 그러나 배포에서 끄는 것은 답이 아니다 — 현대 소프트웨어의 속도는 상당 부분 편집자의 솜씨에서 나오고, 몇 배의 성능을 포기할 이유는 없다. 바른길은 하나다: 계약서를 알고, 계약 안에서 쓰는 것. “내 컴퓨터에서 돌았다”가 아니라 “추상 기계 위에서 옳다”가 기준이 되어야 한다 — 계약 밖의 코드에 무슨 일이 벌어지는지는 52장(정의되지 않은 동작)에서 정면으로 다룬다.
이제 재료가 다 모였다. 기억은 사다리이고(11장), 실행은 겹침과 짐작이며 (12장), 그 소용돌이 위에 편집자가 서서 겉보기만 보장한다(13장). 이 셋을 합치면 하나의 결론이 나온다 — C 프로그래머가 상대하는 것은 실제 기계가 아니라는 것. 다음 장에서 이 부의 논제를 완성하고, C가 결국 어느 기계의 언어인지 — CPU와 GPU의 대조까지 — 파노라마로 마무리한다.