주요 컴파일러의 기본 최적화가 ‘제약 없는’ UB에 의존한다는 사례를 통해 플랫폼별 UB 해석의 한계를 지적하고, 언어와 도구 설계를 통해 현실적인 중간지대를 모색한다.
나는 최근에 정의되지 않은 동작(Undefined Behavior, 이하 UB)이 사실 그렇게 나쁜 아이디어가 아니라는 내용의 블로그 글을 올렸다. 공교롭게도, 이는 Victor Yodaiken의 논문이 발표된 지 몇 주 뒤였다. 그 논문은 요컨대 정의되지 않은 동작(줄여서 UB)이 C의 핵심 사용자층 중 하나인 OS 개발자들에게 C를 쓸 수 없게 만들었다고 주장한다. 여기서 내가 말하는 UB는 현대의 전형적인 해석을 가리킨다. 즉, 컴파일러가 신뢰할 수 있는 가정이며, 그 가정이 깨졌을 때 무엇이 일어나는지에 대한 제한이 없다. 그 논문은 많은 좋은 지적을 하지만, 이러한 종류의 UB를 완전히 없애야 한다는 결론은 목욕물과 함께 아기까지 내버리는 격이라고 생각한다. 이 글의 요지는, 모든 컴파일러가 수행하는 가장 기본적인 최적화조차도 이러한 광범위한 의미의 UB를 전제한다는 점을 보여줌으로써 우리가 정말로 UB를 필요로 한다는 것을 주장하는 데 있다.
모호함을 피하기 위해, 나는 위에서 말한 의미의 UB를 “제약 없는 UB(unrestricted UB)”라 부르겠다. Yodaiken이 옹호하는 대안적 해석은 “플랫폼별 UB(platform-specific UB)”라고 부를 수 있겠다. 이는 정의되지 않은 동작이 있는 프로그램조차도 일관된 방식으로 동작해야 함을 요구한다. 예를 들어, 경계를 벗어난(out-of-bounds) 쓰기의 결과가 ‘예측 불가능’할 수는 있지만, 실제로 쓰기가 발생한다면 대상 플랫폼에서 해당 주소에 쓰기를 수행한 것과 일관되게 프로그램이 동작해야 한다는 것이다. (적어도 내가 이해한 바는 그렇다. 그들의 입장을 오해하지 않기를 바란다. 그 논문은 상황을 어떻게 개선할 수 있는지에 대한 상세한 논의는 많지 않지만, “컴파일러가 소스 연산을 가상 또는 실제 기계의 잘 정의된 명령어 시퀀스에 매핑하고, 그로부터 컴파일러 최적화가 관측 가능한 차이를 만들어내지 않도록 하는” 제안들을 언급한다.)1
그렇다면 플랫폼별 UB의 문제는 무엇일까? 첫째, 이는 주요 컴파일러들이 실제로 하는 일을 반영하지 못한다. 과거에 GCC와 LLVM만이 제약 없는 UB를 활용한다는 주장도 보았지만, 이는 사실이 아니다. 다음은 ICC가 그러한 최적화를 수행하는 예시다(Yodaiken의 예제 코드를 바탕으로 함):
#include <stdlib.h>
#include <stdio.h>
int main () {
int *i = malloc(sizeof(int));
*i = 1;
int *j = malloc(sizeof(int));
*j = 1;
int *k = malloc(sizeof(int));
*k = 1;
int *x = j+(32/4);
*x = 40;
printf("*i=%d (%p) *j=%d (%p) *k=%d (%p) *x=%d (%p)", *i, i, *j, j, *k, k, *x, x);
}
이 프로그램은 몇 개 포인터의 값과 주소를 출력한다. 구체적인 주소는 실행할 때마다 다르지만 패턴은 항상 같다:
*i=1 (0x1aef2a0) *j=1 (0x1aef2c0) *k=1 (0x1aef2e0) *x=40 (0x1aef2e0)
k와 x가 같은 주소(이 실행에서는 0x1aef2e0)를 가리키지만, 서로 다른 값을 담고 있는 것처럼 보인다는 점에 주목하라. 이는 “플랫폼별 UB”하에서는 불가능하다. 대상 플랫폼의 연산 시퀀스가 동일한 주소에 서로 다른 두 값을 담는 상황을 만들어낼 수는 없다.2 이 예시는 -O1을 사용한 ICC조차도 이미 제약 없는 UB를 필요로 함을 보여준다. (완전성을 위해, GCC에 대한 유사한 예시도 있다. 이 글을 쓰는 시점에는 i와 x가 같은 주소를 갖지만 다른 값을 가진다. 또한 clang/LLVM에 대한 예시도 있는데, 이번에는 다시 k와 x가 일관되지 않게 동작한다. godbolt는 MSVC를 지원하지만 생성된 프로그램을 실행해주지는 않는 듯하다. 그러나 이 컴파일러에 대해서도 유사한 예시를 찾을 수 있으리라 의심치 않는다.)
그렇다면 신뢰성을 목표로 만든 틈새 컴파일러들은 어떨까? Yodaiken은 논문에서 검증된 C 컴파일러인 CompCert가 “정의되지 않은 동작에 기반한 최적화는 하지 않는다”(각주: “객체들이 메모리에서 겹치지 않는다고 가정하는 것을 제외하고”)고 주장한다. 정확히 무엇을 의미하는지는 잘 모르겠지만, 이는 사실이 아니다. 첫째, CompCert는 정확성에 대한 증명을 제공하므로, 사용자에게 무엇을 보장하는지 그 명세를 직접 확인할 수 있다. 그리고 그 명세는 소스 프로그램에 UB가 있으면 컴파일된 프로그램이 임의의 결과를 내도 된다고 명시하는, 명백한 “제약 없는 UB” 접근을 따른다. 둘째, CompCert의 최적화기가 매우 제한적이긴 하지만, 실제로 UB 프로그램에서 일관성 없는 동작을 보여줄 만큼은 충분히 강력하다:
#include <stdio.h>
int y, x;
int f(void)
{
y = 0;
*(&x + 1) = 1;
return y;
}
int main()
{
int eq = (&x+1 == &y);
if (eq) {
printf("%d ", f());
printf("%d\n", y);
}
return 0;
}
(비교 결과를 지역 변수 eq에 담는 것은 CompCert가 전체 조건문을 통째로 지워버리지 못하게 하기 위함이다.) CompCert로 컴파일한 뒤 이 프로그램은 “0 1”을 출력한다. 다시 말해 같은 것을 두 번, 이 경우 y에 저장된 값을 두 번 출력하는데 서로 다른 결과가 나온다. CompCert는 기반 기계에서는 “불가능”해야 할 상황을 만들 정도로 UB를 이용했다.
이 두 예시는 모두 “플랫폼별 UB”의 근본적인 문제를 부각한다. 어떤 경계 밖 쓰기라도 잠재적으로 다른 어떤 변수(적어도 메모리에 주소를 가진 어떤 변수든지)를 수정할 수 있다는 것이다. 이는 레지스터 할당처럼 고품질 코드 생성의 가장 기초적인 부분조차도 까다롭거나 불가능하게 만든다. 주소가 취해진 변수는 경계 밖 쓰기가 일어났을지도 모르는 모든 시점마다 그 주소에서 다시 로드해야 한다. 그 쓰기가 공교롭게도 그 변수의 값을 바꿀 수 있는 주소를 맞췄을지도 모르기 때문이다. 이는 첫 번째 예시가 보여주듯, 그 주소가 아직 외부 세계로 유출되지 않았더라도 마찬가지로 적용된다. 아마도 이 때문에 플랫폼별 UB 해석을 따르는 컴파일러가 거의 없는 것일 것이다. (“거의 없다”고 말하긴 했지만 반례를 알고 있는 것은 아니다. 다만 고신뢰 임베디드 코드를 위한 일부 컴파일러는 너무 단순해서 플랫폼별 UB만으로도 충분할 수는 있겠다. 하지만 그것이 C가 실제로 사용되는 방식의 대표는 아니다. 그리고 CompCert 사례에서 보았듯, 일부 고신뢰 컴파일러조차도 제약 없는 UB에 의존한다.)
나는 솔직히 UB에 대한 다른 해석을 바탕으로 고도로 최적화하는 컴파일러를 시도해보는 것이 가치 있는 실험이라고 생각한다. 우리는 UB를 활용함으로써 성능 이득이 실제로 얼마나 큰지에 대한 데이터가 매우 부족하다. 그러나 그 결과가 오늘날 가장 널리 쓰이는 컴파일러들과 견줄 만한 수준에 도달할 것이라고는 강하게 의심한다. 그렇게 큰 성능 손실을 감수할 수 있는 프로그래머라면 애초에 C를 쓰지 않을 가능성이 높다. 확실히, 컴파일러가 UB 활용을 억제하도록 ‘요구’하는 어떤 제안이라도, C가 성능 민감한 코드에 여전히 실용적인 언어로 남을 수 있도록 하면서 그것이 가능한지에 대한 근거를 제시해야 한다.
결론적으로, 나는 Yodaiken이 지적했듯 C에 문제가 있다는 데 전적으로 동의한다. 그리고 UB를 피하기가 너무나 어려워서 C를 신뢰성 있게 작성하는 일이 엄청나게 어려워졌다는 점도 그렇다. C에서 UB를 유발할 수 있는 요소들의 양을 줄이고, 엄격 별칭(strict aliasing) 위반 같은 더 고급 형태의 UB를 탐지하는 실용적 도구를 개발하는 일은 분명 가치가 있다. 또한 엄격 별칭이 저수준 프로그래밍 패턴과 더 잘 양립하도록 만들 수 있을지, 아니면 C가 프로그래머에게 restrict와 같은 대체적인 별칭 제어 수단을 제공해야 할지(그 명세 자체에도 문제는 있지만, restrict처럼 옵트인 방식의 메커니즘이 기존 코드와의 호환성을 목표로 할 때 근본적으로 더 잘 맞는 듯하다)도 궁금하다.
하지만 이 문제가 플랫폼별 UB 해석으로 해결될 것이라고는 생각하지 않는다. 그렇게 하면 가장 기초적인 C 컴파일러를 제외한 나머지는 모두 비준수로 낙인찍히게 된다. 우리는 컴파일러가 코드를 의미 있게 최적화할 수 있도록 허용하면서, 동시에 프로그래머가 표준을 준수하는 프로그램을 실제로 작성할 수 있게 해주는 중간 지대를 찾아야 한다. 나는 C 쪽에서 진행되는 작업에는 관여하지 않지만, Rust의 경우 우리가 정말로 필요한 UB의 범위를 신중히 관리하고, 프로그래머가 자신이 작성한 코드가 요구하는 UB 제약을 인지하기 쉽게 언어와 API를 설계하며, 코드가 UB를 보이는지 아닌지 판단하는 데 도움을 주는 도구를 제공하는 방식의 조합으로 이 목표를 달성할 수 있다고 생각한다.
그 논문은 C 위원회 제안 N2769도 인용한다. 하지만 N2769는 a + 1 < a를 여전히 false로 최적화할 수 있다고 명시하는데, Yodaiken은 이를 바람직하지 않은 최적화로 언급한다. 사실 N2769는 “UB의 부재를 가정”하는 것이 괜찮고 “대단히 가치 있다”고 말한다. 나는 N2769가 “UB의 부재를 가정”하는 것과 “UB의 결과에 대해 가정”하는 것을 어떻게 구분하는지 완전히 이해하지는 못하지만, Yodaiken은 UB 기반 최적화를 제한하는 데 있어서 N2769보다 더 멀리 나아가는 듯하다.↩
우리 예제 프로그램의 결과에 대해 N2769 역시 만족하지 않을 것이라고 생각한다.↩