Proven C Book←↑→

14 컴파일러 최적화 — 추상 기계

먼저 알아야 할 것

13장 속도의 기계장치 · 표준이 왜 필요했는가
3장 워드와 주소 · 주소와 값의 관계

돌아보기

13장 끝에서 컴파일러를 “뜻만 지킨다면 무엇이든 뜯어고칠 권리를 쥔 층”이라 불렀다. 그런데 2장에서는 초기의 C가 그 시절 기계의 특성이 그대로 비치는 언어라고 하지 않았나 — 내가 쓴 C 코드는 기계 명령에 그대로 대응하는 것 아니었나?

답. 그 시절에는 대체로 그랬고, 지금은 아니다. 오늘의 컴파일러는 직역가가 아니라 편집자다 — 뜻을 지키는 한에서 문장을 통째로 다시 쓴다. 이 장은 “그대로 대응”이라는 초기 C의 감각이 어떻게, 왜 무너졌는지를 본다. 그 무너짐이 C를 추상적인 언어로 만든 마지막 조각이다.

이 장의 필요성과 맥락

하드웨어가 마음대로 겹쳐 도는 것을 보았으니, 이제 컴파일러도 마음대로 고쳐 쓴다는 것을 받아들일 차례다. 순서가 중요하다 — 기계의 자유를 먼저 보아야 컴파일러의 자유가 「배신」이 아니라 같은 계약의 다른 면으로 읽힌다. 이 장에서 세우는 추상 기계(abstract machine)가 15장의 결론이고, 그 결론이 이 책 나머지의 사고방식이다.

이 장이 끝나면

컴파일러가 소스 코드를 “그대로” 번역하지 않는다는 것, 곧 뜻만 지키면 마음껏 재배열하고 지우고 바꿔 쓴다는 것을 알게 된다. 그 자유의 근거인 “관찰 가능한 결과”라는 개념과, 표준 문서 속의 가상 기계인 추상 기계를 만난다. C99·C11이 이 계약을 어떻게 정밀하게 다듬었는지도 본다.

이 장에서 답할 질문

  1. 멋대로 고쳐도 되는 근거가 무엇인가? 어디까지 고쳐도 “뜻을 지켰다”고 치는가?
  2. 최적화가 이렇게 위험하면, 그냥 끄고 살면 안 되는가?

14.1 편집자의 작업실 — 컴파일러가 하는 일#

몇 가지 실제 편집 사례를 보면 감각이 잡힌다. 컴파일러는 일상적으로 이런 일을 한다.

미리 계산한다. 소스에 초 = 24 * 60 * 60이라 적혀 있으면, 곱셈 명령을 만들지 않고 그냥 86400을 적어 넣는다. 실행 시점에 곱할 이유가 없기 때문이다.

죽은 일을 지운다. 계산해 놓고 아무도 안 쓰는 값, 도달할 수 없는 갈림길 — 통째로 사라진다. 소스에 있는 코드가 실행 파일에는 존재하지 않을 수 있다.

순서를 바꾼다. 서로 상관없는 계산이라면, 파이프라인(13장)이 잘 차도록 앞뒤를 재배열한다. 소스의 줄 순서는 실행 순서의 약속이 아니게 된다.

메모리 왕복을 없앤다. 루프가 도는 내내 변수 하나를 매번 메모리에서 읽고 쓰는 대신, 레지스터(12장)에 올려 두고 끝날 때 한 번만 내려놓는다. 소스에는 천 번의 메모리 접근이 적혀 있어도 실제로는 두 번일 수 있다.

이 편집들이 겹치고 쌓이면, 실행 파일 속 기계 명령과 소스 코드는 문장 대 문장으로 대응하지 않는 별개의 문서가 된다. 디버거로 최적화된 프로그램을 들여다보면 “이 줄은 최적화로 사라졌음”이라는 안내를 만나는 것이 그래서다.

문. 멋대로 고쳐도 되는 근거가 무엇인가? 어디까지 고쳐도 “뜻을 지켰다”고 치는가?

답. 그 경계선은 표준이 긋는다. 경계선의 이름은 관찰 가능한 결과다. 대략 이렇다 — 프로그램이 바깥세상과 주고받는 것(출력한 내용, 읽은 입력, 특별히 보호된 접근)만 약속대로면, 그 결과를 만들어 내는 속사정은 전부 컴파일러의 재량이다. 중간 계산을 몇 번 하는지, 변수가 메모리에 실제로 있는지, 어떤 순서로 일하는지 — 바깥에서 관찰되지 않는 한 아무래도 좋다. 겉보기만 같으면 속은 자유 — 이것이 계약의 문장이다.

14.2 추상 기계 — 계약서 속의 가상 기계#

그러면 “겉보기”의 기준은 누가 정하는가. 실제 기계일 수는 없다 — 실제 기계는 수천 종이고 저마다 다르다. 그래서 표준은 문서 안에 가상의 기계를 하나 정의해 두었다. C 프로그램의 의미란 “이 가상 기계 위에서 이렇게 실행되는 것”이라고 못박는, 종이 위에만 존재하는 기계 — 추상 기계(abstract machine)다.

이제 세 층의 관계가 선명해진다.

2장에서 「초기의 C는 기계의 특성이 그대로 비치는 언어였다」는 평판이 오늘날 절반만 맞다고 했다. 그 까닭이 이것이다. C에 비치는 것은 이제 실제 기계가 아니라 추상 기계다. 실제 기계와의 대응은 컴파일러가 그때그때 지어내는, 겉보기만 보장된 번역이다.

14.3 눈에 보이는 편집 — 값을 언제 읽고 언제 쓰는가#

편집자가 가장 즐겨 손대는 자리가 메모리 왕복이다. 12장에서 배운 대로 메모리는 느리고 레지스터는 즉시라서, 컴파일러는 값을 되도록 레지스터에 붙잡아 둔다. 두 장면으로 감을 잡는다.

장면 1 — 반복되는 읽기를 한 번으로. 이런 루프를 생각하면 된다:

for (int i = 0; i < n; i += 1) {
    total += table[i] * scale;   /* scale은 루프 내내 안 바뀐다 */
}

소스대로라면 scale을 매 바퀴 메모리에서 읽어야 하지만, 컴파일러는 “이 루프 안에서 scale을 바꾸는 코드가 없다”고 판단해 루프 시작 전에 한 번 읽어 레지스터에 얹어 둔다(루프 불변 코드 이동). 천 번의 읽기가 한 번이 된다. total도 마찬가지로 레지스터에 두었다가 루프가 끝난 뒤 한 번만 메모리에 내려놓는다.

장면 2 — 쓰기를 미루고 순서를 바꾸기. 서로 무관한 두 변수에 값을 넣는 코드라면, 컴파일러는 파이프라인(13장)이 잘 차도록 순서를 바꾸거나 쓰기를 뒤로 미룰 수 있다. 소스의 줄 번호는 실행 시각의 약속이 아니다.

두 편집 모두 관찰 가능한 결과가 같으므로 계약 안이다. 문제는 — “관찰 가능”의 기준이 표준의 것이지 프로그래머의 기대가 아니라는 데서 생긴다.

실제 사례. 끝나지 않는 루프 — “메모리를 곧이곧대로” 믿었을 때

임베디드 프로그래밍의 고전적 사고를 보자. 하드웨어의 상태 레지스터가 메모리의 어떤 주소에 나타난다고 하고(4장의 그 세계다), “준비될 때까지 기다리는” 코드를 이렇게 적었다고 하자:

int flag = *status;                 /* 장치가 바꿔 주는 값 */
while (flag == 0) { flag = *status; }   /* 준비될 때까지 대기 */

프로그래머의 그림은 “매 바퀴 메모리를 다시 읽는다”이다. 그러나 컴파일러의 눈에는 이 루프 안에서 *status를 바꾸는 코드가 하나도 없다 — 그러면 장면 1의 편집이 적용된다: 값을 한 번만 읽어 레지스터에 얹고, 이후로는 그 레지스터만 본다. 장치가 아무리 메모리를 바꿔도 프로그램은 영원히 옛 값을 보며 돈다. 무한 루프다.

이유는 계약에 있다. 표준의 추상 기계는 “이 프로그램 밖의 무언가가 메모리를 바꿀 수 있다”는 것을 알지 못한다 — 그런 사정이 있다면 프로그래머가 알려 주어야 하고, 그 표기가 volatile이다(“이 접근은 매번 실제로 하라”는 지시). 같은 꼴이 멀티코어 세계에서도 재현된다: 다른 코어가 바꾸는 값을 평범한 변수로 폴링하면 같은 이유로 굳는다 (그쪽의 정답은 volatile이 아니라 C11의 원자적 타입이다 — 아래 참조). 교훈은 하나다: 컴파일러는 소스에 적힌 것을 실행하지 않는다. 계약이 보장한 결과만 만든다.

14.4 계약의 정밀화 — C99, C11#

C89가 계약서의 초판이었다면(13장), 이후의 표준들은 조항을 정밀하게 다듬어 온 개정판들이다. 기계와 컴파일러가 공격적으로 진화할수록 “어디까지 고쳐도 되는가”를 더 날카롭게 그어야 했기 때문이다. 두 번의 큰 개정만 짚는다.

C99(1999)는 편집자에게 더 많은 재량 근거를 주는 쪽으로 언어를 다듬었다 — 대표적으로, 타입이 다른 이름끼리는 같은 기억을 가리키지 않는다고 전제해도 좋다는 규칙, 곧 엄격한 앨리어싱(strict aliasing)이 유효 타입(effective type)이라는 개념으로 더 엄밀하게 다듬어졌다(규칙 자체는 C89에도 있었다). 이 전제가 있어야 편집자가 “이 둘은 서로 무관하다”고 보고 재배열·캐싱을 할 수 있다. 대가는 프로그래머 쪽 의무다 — 그 전제를 깨는 코드(같은 기억을 다른 타입의 눈으로 읽는 일)는 계약 위반이 된다(49장에서 안전한 방법과 함께 재론한다).

C11(2011)은 13장의 멀티코어 시대에 대한 응답이다 — 일꾼이 여럿일 때 “한 코어의 쓰기가 다른 코어에 언제 보이는가”를 계약 조항으로 못박은 메모리 모델과, 그 통신에 쓰는 원자적 타입(_Atomic)이 표준에 들어왔다. 그 전까지 다중 코어 위의 C는 표준 바깥의 관행에 기대던, 말 그대로 계약서 없는 동업이었다.

14.5 컴파일러가 받는 잘못된 신호 — 엄격한 앨리어싱 위반이 부르는 유령#

엄격한 앨리어싱은 이 장에서 가장 조심스러운 조항이라, 이 조항을 어기는 패턴을 하나 보아 둔다. 규칙을 다시 적으면 — 타입이 다른 두 포인터는 같은 기억을 가리키지 않는다고 컴파일러가 전제해도 좋다(char 계열은 예외, 38장).

문제는 이 전제 아래에서 프로그래머의 코드가 컴파일러에게 신호로 읽힌다는 데 있다. 부동소수점의 비트를 들여다보려고 이런 옛 기법을 썼다고 하자:

float f = 1.0f;
uint32_t bits = *(uint32_t *)&f;   /* 계약 위반: float를 uint32의 눈으로 */

프로그래머의 의도는 “같은 4바이트를 다른 눈으로 읽자”이지만, 컴파일러가 받은 신호는 정반대다 — “이 float*와 저 uint32_t*는 서로 다른 기억을 가리킨다(그렇게 전제해도 좋다)”. 그러면 편집자는 안심하고 f에 대한 쓰기를 레지스터에 붙잡아 둔 채 bits 읽기를 앞당길 수 있고 — 결과는 “쓰기 전의 옛 비트”가 읽히는, 소스만 봐서는 설명되지 않는 유령이 된다. 더 고약한 점은 이 유령이 최적화를 켰을 때만, 그것도 컴파일러 판에 따라 나타났다 사라진다는 것이다(“디버그 빌드에서는 되는데 릴리스에서만 틀려요”의 전형적 정체다).

옳은 방법은 두 가지다 — memcpy로 바이트를 복사하거나(현대 컴파일러는 이 복사를 알아보고 공짜로 최적화한다), 뒤에서 배울 공용체를 쓰는 것이다(49장에서 이 시연을 정식으로 한다). 요점은 기법이 아니라 관점이다: 계약 위반은 “조금 위험한 코드”가 아니라, 컴파일러에게 사실이 아닌 것을 사실이라고 알리는 행위다. 그래서 결과가 “조금 이상함”이 아니라 “무엇이든 가능함”이 된다 — 표준이 이런 상태에 붙인 이름이 정의되지 않은 동작(undefined behavior, UB)이고, 이 책은 56장에서 이것을 정면으로 다룬다.

실제 사례. 사라진 지우개 — 최적화가 지운 보안 코드

“겉보기만 같으면 속은 자유”가 실제로 문 사고가 있다. 보안 프로그램들은 비밀번호를 다 쓴 뒤 그 메모리를 0으로 덮어 지우는 관행이 있다 — 메모리 어딘가에 비밀이 남아 있지 않도록. 그런데 편집자의 눈에 이 지우기는 아무도 다시 읽지 않는 쓰기, 즉 죽은 일이다. 관찰 가능한 결과에 아무 기여가 없으므로 — 지워도 된다. 실제로 여러 컴파일러가 이 지우기를 조용히 삭제했고, 비밀번호가 메모리에 남은 채 배포된 소프트웨어들이 보안 권고의 대상이 됐다. 계약을 무시한 쪽은 컴파일러가 아니라, 계약서를 안 읽은 사람이었다 — 이후 표준과 플랫폼들은 “이 쓰기는 지우지 말라”고 명시하는 장치(memset_s 등)를 마련했다. 관찰 가능한 결과라는 조항 하나가 실무에서 얼마나 무거운지 보여 주는 사건이다.

문. 최적화가 이렇게 위험하면, 그냥 끄고 살면 안 되는가?

답. 개발 중의 디버깅 빌드는 실제로 최적화를 낮춰서 소스와 실행의 대응을 살려 둔다. 그러나 배포에서 끄는 것은 답이 아니다 — 현대 소프트웨어의 속도는 상당 부분 편집자의 솜씨에서 나오고, 몇 배의 성능을 포기할 이유는 없다. 바른길은 하나다: 계약서를 알고, 계약 안에서 쓰는 것. “내 컴퓨터에서 돌았다”가 아니라 “추상 기계 위에서 옳다”가 기준이 되어야 한다 — 계약 밖의 코드에 무슨 일이 벌어지는지는 56장(정의되지 않은 동작)에서 정면으로 다룬다.

이제 재료가 다 모였다. 기억은 사다리이고(12장), 실행은 겹침과 짐작이며 (13장), 그 위에 편집자가 서서 겉보기만 보장한다(14장). 이 셋을 합치면 하나의 결론이 나온다 — C 프로그래머가 상대하는 것은 실제 기계가 아니라는 것. 다음 장에서 이 부의 논제를 완성하고, C가 결국 어느 기계의 언어인지 — CPU와 GPU의 대조까지 — 파노라마로 마무리한다.