12 속도의 기계장치 — 표준의 탄생
먼저 알아야 할 것
돌아보기
4장의 문답에서 “걸음 수(클록)가 전부는 아니다 — 한 걸음에 여러 일을 하는 요령이 현대 CPU의 진짜 무기”라고 예고했다. 한 걸음에 여러 일이란 어떻게 가능한가? 한 명령이 끝나야 다음 명령을 하는 것 아닌가?
답. “끝나야 시작한다”를 버리면 된다. 명령 하나의 처리를 여러 단계로 쪼개고, 단계가 다른 여러 명령을 동시에 진행하는 것이다 — 한 명령이 계산되는 동안 다음 명령을 가져오고, 그다음 명령을 해독한다. 이 장의 첫 장치, 파이프라인이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 코어마다 자기 캐시(책상)가 있다면, 두 코어가 같은 데이터를 만질 때는 무슨 일이 일어나는가?
- 그러면 회색지대를 써도 되는가, 안 되는가?
- 그러면 이런 자리는 어떻게 생각해야 하는가?
12.1 파이프라인 — 조립 라인 위의 명령들
세탁소를 떠올리면 된다. 세탁 30분, 건조 30분, 다림질 30분이라 할 때, 한 벌을 끝내고 다음 벌을 시작하면 세 벌에 270분이 걸린다. 그러나 첫 벌이 건조기로 넘어가는 순간 둘째 벌을 세탁기에 넣으면 — 장비 세 대가 서로 다른 벌을 동시에 처리하며, 흐름이 차오른 뒤에는 30분마다 한 벌씩 완성되어 나온다.
CPU가 명령을 처리하는 것도 똑같이 단계로 나뉜다 — 가져오고(fetch), 해독하고(decode), 계산하고(execute), 결과를 쓴다(write). 이 단계들을 조립 라인으로 만든 것이 파이프라인(pipeline)이다. 명령 하나하나가 빨라지는 것이 아니다 — 단위 시간에 완성되는 명령의 수가 는다. 현대 CPU의 파이프라인은 열 단계가 넘고, 여기에 라인을 여러 개 두어 박자당 여러 명령을 완성하는 데까지 나아갔다. 4장의 “한 박자에 한 걸음”은 이렇게 무너진다 — 겉보기 순서는 지키되, 속은 겹쳐 돌아간다.
같은 시대의 요령 하나를 여기 적어 둔다. 루프 한 바퀴마다 드는 관리 비용(횟수 세기, 갈림길 판단)을 아끼려고 한 바퀴에 여러 개씩 일을 처리하는 기법이 있다 — 루프 언롤링(loop unrolling)이라 한다. 1983년, Tom Duff라는 프로그래머는 이 기법을 C의 두 문법을 기괴하게 겹쳐 쓰는 방법으로 극한까지 밀어붙였고, 그 코드는 Duff’s device라는 이름의 전설이 됐다. 코드 자체는 아직 보여 줄 수 없다 — 이 책이 그 문법들을 아직 소개하지 않았기 때문이다. 재회를 예약해 둔다(32장). 지금은 한 문장이면 된다: 그 시절에는 사람이 이런 곡예로 기계의 박자를 짜냈다는 것, 그리고 오늘날 그 곡예는 컴파일러의 일이 됐다는 것(13장).
12.2 분기 예측 — 갈림길을 미리 짐작하다
파이프라인에는 아픈 곳이 있다. 갈림길이다. “결과가 0이면 저쪽으로 건너뛰어라” 같은 명령(분기)을 만나면, 어느 쪽 길의 명령을 라인에 실어야 할지 결과가 나오기 전에는 모른다. 라인을 세우고 기다리면 겹침의 이득이 사라진다.
그래서 CPU는 짐작한다. 단골 손님의 주문을 미리 만들어 두는 카페처럼, 지금까지의 이력으로 “이 갈림길은 대개 이쪽”을 학습해 그쪽 명령을 미리 라인에 싣는다 — 분기 예측(branch prediction)이다. 맞으면 공짜고, 틀리면 미리 만든 주문을 전부 버리고 라인을 비운 뒤 다시 시작한다. 이 벌금이 파이프라인 깊이만큼 크기 때문에, 현대 CPU는 예측기에 진지한 회로를 투자하고 적중률은 일상 코드에서 90%를 훌쩍 넘는다.
실제 사례. 정렬된 배열이 더 빨랐던 사건
프로그래머들 사이에 널리 회자된 실측이 있다 — 큰 배열에서 “값이 일정 기준보다 큰 것만 더하라”는 같은 코드가, 배열을 미리 정렬해 두면 몇 배 빨라진다는 관찰이다. 덧셈의 횟수는 같은데 왜인가. 답은 분기 예측이다. 뒤섞인 배열에서는 “크다/작다” 갈림길이 매번 무작위라 예측이 반타작으로 틀리고, 그때마다 라인을 비우는 벌금을 문다. 정렬된 배열에서는 갈림길의 답이 한동안 같은 쪽으로 이어져 예측이 거의 다 맞는다. 눈에 보이지 않는 짐작 회로 하나가, 같은 코드의 속도를 몇 배 가른 것이다 (31장에서 이 감각을 다시 쓴다 — “if 는 공짜가 아니다”).12.3 클록의 한계, 그리고 멀티코어
이 모든 겹침에도 불구하고, 2000년대 중반에 벽이 왔다. 클록을 더 올리면 전력과 발열이 감당할 수 없이 치솟는 물리적 한계였다. 수십 년 이어지던 “내년에는 더 빠른 클록”이 멈춘 것이다.
산업의 응답은 방향 전환이었다 — 한 일꾼을 더 빠르게 만드는 대신, 일꾼의 수를 늘린다. CPU 하나에 독립적으로 명령을 실행하는 엔진 — 코어(core) — 를 두 개, 네 개, 수십 개씩 넣기 시작했다. 오늘의 스마트폰조차 코어 여덟 개쯤은 예사다. 공짜 점심은 끝났다 — 프로그램이 저절로 빨라지던 시대가 끝나고, 여러 일꾼에게 일을 나누어 주는 문제가 프로그래머의 몫이 된 것이다(이 책의 범위 밖이지만, 지형은 알아 둔다).
그리고 일꾼이 여럿이 되자, 11장의 캐시가 뜻밖의 사고 현장이 된다.
문. 코어마다 자기 캐시(책상)가 있다면, 두 코어가 같은 데이터를 만질 때는 무슨 일이 일어나는가?
답. 하드웨어가 뒤에서 책상들 사이의 일관성을 맞춰 준다 — 한 코어가 값을 고치면 다른 코어의 책상에 놓인 사본을 무효로 만드는 식이다. 정확성은 이렇게 지켜지는데, 문제는 이 정리 정돈이 공짜가 아니라는 것, 그리고 그 단위가 변수 하나가 아니라 11장의 캐시 라인 상자라는 것이다. 여기서 기묘한 사고가 태어난다.
False sharing(거짓 공유)이라 불리는 사고다. 두 코어가 서로 다른 변수를 만지는데 — 공유한 것이 하나도 없는데 — 하필 두 변수가 같은 캐시 라인 상자에 나란히 놓여 있으면, 하드웨어의 눈에는 같은 상자를 두 코어가 다투는 것으로 보인다. 한쪽이 쓸 때마다 다른 쪽 책상의 상자가 무효가 되고, 상자가 두 책상 사이를 핑퐁처럼 오가며, 두 코어 모두 수십 배 느려진다. 코드만 보면 아무것도 공유하지 않으므로 원인이 보이지 않는다 — 기억이 상자 단위로 움직인다는 11장의 사실을 알아야만 진단이 되는, 현대적인 사고의 대표 사례다.
12.4 같은 시대, C는 계약서를 썼다 — 표준 이전의 C와 C89
기계가 이렇게 요동치던 시기, C의 세계에도 다른 종류의 요동이 있었다.
C에는 오랫동안 공식 표준이 없었다. 1978년에 나온 K&R의 책 “The C Programming Language”가 사실상의 표준 노릇을 했다 — 언어의 규격서가 아니라, 잘 쓰인 한 권의 책이 법전을 대신한 것이다. Unix와 함께 C가 대학과 업계로 번지자 회사마다 자기 컴파일러를 만들었고, 책이 못박지 않은 구석마다 방언이 자랐다. 그 시절의 C는 지금 눈에는 헐겁다 — 함수를 선언할 때 인자의 타입을 적지 않았고(원형이 없었다), 타입을 안 적은 이름은 슬그머니 int로 통했다. 어느 컴파일러에서 되던 코드가 다른 컴파일러에서 안 되는 일이 예사였고, 이식은 번번이 모험이었다.
견디다 못한 산업이 1983년 표준화 위원회(ANSI X3J11)를 꾸렸고, 6년의 격론 끝에 1989년 C89 — 최초의 공식 C 표준 — 가 나왔다. 함수 원형(인자 타입까지 적는 선언, 24장)을 들여 컴파일러가 호출의 실수를 잡을 수 있게 했고, 헐거운 구석들을 조였으며, 무엇보다 “프로그래머는 무엇을 약속받고, 구현은 어디까지 자유로운가”를 처음으로 문서로 못박았다. 8장의 IEEE 754(1985)와 같은 몇 해의 일이다 — 하드웨어의 수도, 언어의 문법도, 벤더마다 제멋대로이던 것을 계약서로 다스리기 시작한 “계약의 시대”였다.
12.5 계약서에 없는 자리 — 회색지대
계약서가 생기면 곧바로 따라오는 물음이 있다. 계약서에 적히지 않은 일은 어떻게 되는가?
표준이 하는 말은 세 갈래다 — 약속한다(어디서나 이렇게 된다), 구현이 정한다(구현마다 다르되 문서로 밝힌다), 그리고 아무 말도 하지 않는다. 셋의 정식 이름과 정확한 구별은 52장에서 다룬다.
그리고 실무에는 이름조차 없는 자리가 하나 더 있다 — 표준의 문장으로는 보장되지 않는데 모든 주요 구현에서 실제로 통하고, 널리 쓰여 사실상의 관행이 된 것이다. 이 책은 이 자리를, 그리고 앞의 세 갈래를 아울러 회색지대라 부른다.
흔한 오해. 「회색지대」는 C 표준의 용어가 아니다
못박아 둔다. 「회색지대」는 이 책이 편의상 붙인 이름이다. 표준 문서를 아무리 뒤져도 이 낱말은 나오지 않는다. 표준이 실제로 쓰는 낱말은 셋뿐이고, 그 셋에는 정확한 정의가 있다 — 구현 정의(implementation-defined), 미지정 (unspecified), 정의되지 않은 동작(undefined behavior). 52장이 그 셋을 정식으로 다룬다.
그러면 왜 다른 이름을 두는가. 표준의 셋만으로는 실무의 한 자리를 가리킬 말이 없기 때문이다 — 표준이 아무 약속도 하지 않았는데 모든 구현이 사실상 같은 일을 해 주는 자리다. 이 책은 그것까지 묶어 부르려고 「회색지대」라는 말을 쓴다.
영어로 옮길 때도 마찬가지다. 이 책의 영어판은 grey area라 적는데, 그 말은 영어에서 「규칙이 딱 잘라 정해 주지 않는 경우」를 뜻하는 흔한 관용구다 — 어색하지도, 다른 뜻으로 읽히지도 않는다. 다만 그 관용구가 가리키는 범위가 이 책의 뜻보다 조금 넓어서(표준이 모호한 경우까지 포함한다), 영어판도 같은 자리에서 뜻을 좁혀 못박는다.
그래서 이 낱말을 책 밖에서 쓸 때는 조심해야 한다. 표준 문서나 컴파일러 개발자와 이야기할 때는 정확한 낱말 — UB인가, 미지정인가, 구현 정의인가, 아니면 표준이 아예 다루지 않는가 — 로 옮겨서 말해야 대화가 된다. 이 책이 본문에서 「계약 밖」(정의되지 않은 동작)과 「회색지대」를 굳이 갈라 쓰는 이유도 같다.
| 이 책이 회색지대라 부르는 것 | 무엇이 아닌가 |
|---|---|
| 표준의 문장만으로는 보장되지 않는다 | 금지된 것은 아니다 — 「하지 마라」가 적혀 있지도 않다 |
| 그런데 주요 구현에서 실제로 통한다 | 운으로 되는 것이 아니다 — 대개 이유가 있다 |
| 널리 쓰여 사실상의 관행이 되었다 | 그렇다고 계약이 되지는 않는다 |
표 12.1
왜 이런 자리가 생기는가. 표준은 최소공통분모를 적는다 — 아주 특이한 기계 에서도 성립할 만큼만 약속한다. 실제 구현은 대개 그보다 넉넉히 준다. 그리고 커다란 코드베이스들이 그 넉넉함 위에 집을 지으면, 컴파일러 쪽에서도 그것을 쉽게 깨뜨리지 못하게 된다 — 깨는 순간 세상의 절반이 안 도니까.
이 책에서 만나게 될 회색지대의 예를 미리 몇 개 적어 둔다. 지금은 이름만 알아 두면 된다.
| 관행 | 어디서 다룬다 |
|---|---|
memset으로 포인터를 0으로 밀어 널로 삼기 | 6·36·46장 |
| 선언된 2차원 배열을 1차원으로 평탄하게 훑기 | 39장 |
offsetof를 빼서 바깥 구조체를 되찾기(container_of) | 47장 |
int rc = setjmp(env);처럼 표준이 정한 네 문맥 밖에서 쓰기 | 77장 |
#pragma pack으로 배치를 강제하기 | 47장 |
함수 포인터를 void *로 바꿔 인쇄하거나 넘기기 | 59장 — POSIX가 요구하는 자리 |
표 12.2
문. 그러면 회색지대를 써도 되는가, 안 되는가?
답. 판단은 그때그때 사람의 몫이다. 이 책은 어느 쪽이 옳다고 정해 주지 않는다 — 겨냥하는 기계가 하나뿐인 임베디드 프로젝트와, 어디서 컴파일될지 모르는 라이브러리는 답이 다르기 때문이다. 기준으로 삼을 만한 것은 넷이다.
| 물음 | 회색지대를 피할 이유가 커지는 쪽 |
|---|---|
| 이 코드가 어디서 컴파일되는가 | 모르는 곳에서도 컴파일된다 |
| 얼마나 오래 살 코드인가 | 오래 산다 — 컴파일러가 그동안 바뀐다 |
| 깨졌을 때 무엇이 무너지는가 | 조용히 틀린 값이 나온다(터지는 편이 낫다) |
| 대안의 비용이 얼마인가 | 대안이 몇 줄이면 된다 |
표 12.3
다만 어느 쪽을 고르든 반드시 해야 하는 것이 하나 있다 — 양쪽을 다 대비하는 것이다. 회색지대는 「지금은 되는 것」이지 「앞으로도 될 것」이 아니기 때문이다.
- 쓰기로 했다면 — 그 자리를 보이게 만들어 둔다. 무엇을 얼마나 두를지는 아래의 사다리대로 경중에 따라 정한다.
- 쓰지 않기로 했다면 — 대개 코드가 길어지고 느려지는 대가를 치른다. 그 대가를 알고 치르는 것과 모르고 피하는 것은 다르다.
| 단계 | 무엇을 두르는가 | 이 정도면 되는 자리 |
|---|---|---|
| 1 | 주석 한 줄 — 무엇에 기대는지, 왜 여기서는 괜찮은지 | 혼자 쓰는 도구, 곧 버릴 코드 |
| 2 | 문서 — 「이 모듈은 무엇을 전제한다」를 팀이 읽는 자리에 | 여럿이 고치는 코드 |
| 3 | 빌드가 알리게 — static_assert·기능 검사 매크로·컴파일 오류 | 전제가 깨지면 조용히 틀리는 경우 |
| 4 | 시험 — 전제가 성립하는지 확인하는 시험을 따로 둔다 | 오래 살 코드, 여러 플랫폼 |
| 5 | 격리 — 그 자리를 한 파일·한 함수로 몰아 두고 대안 구현을 준비 | 이식이 예정되어 있거나, 깨지면 큰 것이 무너지는 경우 |
표 12.4
사다리를 오르는 기준은 앞의 네 물음과 같다 — 어디서 컴파일되는가, 얼마나 오래 사는가, 깨지면 무엇이 무너지는가, 대안의 비용은 얼마인가. 혼자 쓰는 스크립트에 시험까지 붙이는 것은 과하고, 여러 플랫폼으로 나가는 라이브러리에 주석 한 줄만 남기는 것은 모자라다. 경중을 재는 일까지가 프로그래머의 몫이다.
최악은 셋째 태도다 — 회색지대인 줄 모르고 쓰는 것. 그때는 대비할 것이 있다는 사실조차 모른다. 이 책이 「계약 밖」과 「회색지대」를 굳이 갈라 부르는 이유가 이것이다.
12.6 C 밖의 표준이 대신 보장할 때
회색지대 중에는 성격이 조금 다른 것들이 있다. C 표준은 아무 말도 하지 않는데, 다른 표준이 그 자리를 대신 약속하는 경우다.
| C 표준의 태도 | 대신 약속하는 것 | 예 |
|---|---|---|
| 부동소수의 형식·반올림을 정하지 않는다 | IEEE 754 (= ISO/IEC 60559) | float이 32비트이고 반올림이 어떻게 되는지(8·50장) |
함수 포인터와 void *의 변환을 정하지 않는다 | POSIX(ISO/IEC 9945) | dlsym이 함수 주소를 void *로 돌려주므로(59장) |
| 파일 이름·경로·프로세스를 정하지 않는다 | POSIX | open·fork·경로 구분자(93장) |
| 로케일 이름의 문법을 정하지 않는다 | POSIX | ko_KR.UTF-8이라는 표기(68장) |
| 문자 집합을 정하지 않는다 | 유니코드(ISO/IEC 10646) | __STDC_ISO_10646__이 정의될 때(70장) |
표 12.5
여기서 「C 표준이 안 정했으니 아무렇게나 되는 것」이라고 읽으면 안 된다. 그 자리에 다른 계약서가 놓여 있고, 그 계약서도 나름의 신중함으로 쓰였다.
문. 그러면 이런 자리는 어떻게 생각해야 하는가?
답. 이 책의 판단을 적어 둔다. 세 문장이다.
첫째, 표준이 정하지 않은 데에는 대개 그럴 만한 이유가 있다. 위원회가 게을러서 비워 둔 자리가 아니다 — 세상의 어떤 기계에서도 성립하는 약속만 담으려니, 특정 기계에서만 참인 것을 뺄 수밖에 없었던 것이다. 부동소수를 IEEE 754로 못박지 않은 것도, 함수 포인터와 데이터 포인터를 같은 것으로 보지 않은 것도, 그렇게 하면 돌아가지 않는 기계가 실제로 있었기 때문이다(59장의 플랫폼 노트가 그 목록이다).
둘째, 그 자리를 메우는 다른 표준에도 나름의 이유가 있고, 둘이 부딪칠 수 있다. POSIX는 동적 라이브러리를 쓸 수 있게 하려고 함수 포인터와 void *의 변환을 요구했다 — 그것이 없으면 dlsym이 성립하지 않기 때문이다. C 표준의 신중함과 POSIX의 실용이 같은 자리에서 다른 답을 낸 것이고, 어느 쪽이 틀린 것이 아니다. 어느 계약서 아래에서 일하고 있는지가 다를 뿐이다.
셋째, 그러므로 프로그래머가 할 일은 고르는 것이 아니라 「분명히 하는 것」이다. 까다롭더라도 지금 밟고 선 자리가 어느 계약인지 알아야 하고, 고른 뒤에는 그 선택을 눈에 보이게 만들어 두어야 한다.
| 무엇으로 보이게 하는가 | 무엇을 적는가 |
|---|---|
| 문서·주석 | 「이 코드는 POSIX를 전제한다」처럼 어느 계약에 기대는지를 한 줄로 |
| 빌드 설정과 경고 | 전제가 깨지면 컴파일이 실패하거나 경고가 나게(예: static_assert, 기능 검사 매크로) |
| 시험 | 그 전제가 성립하는지 확인하는 시험 한 개(예: 왕복이 같은지, 크기가 같은지) |
표 12.6
이 셋이 있으면 상황이 바뀌어도 안전하게 바뀐다. 컴파일러를 판올림했을 때, 다른 플랫폼으로 옮길 때, 새 아키텍처가 들어왔을 때 — 무엇을 다시 확인해야 하는지가 코드 안에 이미 적혀 있기 때문이다. 반대로 셋이 없으면, 전제가 깨진 사실을 사고가 난 뒤에 알게 된다.
/* 이 파일은 POSIX 를 전제한다: 함수 포인터 ↔ void * 변환(dlsym). */
static_assert(sizeof(void (*)(void)) == sizeof(void *),
"함수 포인터와 데이터 포인터의 크기가 다른 플랫폼이다");이 계약이라는 관념이 왜 갈수록 중요해지는가 — 기계는 이 장에서 본 것처럼 갈수록 겹치고 짐작하고 나뉘는데, 프로그래머가 그 소용돌이를 일일이 알 수는 없다. 소용돌이와 프로그래머 사이에 또 하나의 층이 서 있기 때문이다. 소스 코드를 받아 기계의 말로 옮기면서, 뜻만 지킨다면 무엇이든 뜯어고칠 권리를 가진 층 — 컴파일러다. 다음 장의 주인공이다.