컴파일러가 정의되지 않은 동작을 기반으로 최적화할 때 왜 경고를 내기 어려운지 설명하고, 성능 이점을 유지하면서 놀라움을 줄이기 위해 LLVM/Clang이 제공하는 기능과 도구, 그리고 더 안전한 C 방언 및 컴파일러 플래그를 소개한다.
시리즈 1편에서는 C의 정의되지 않은 동작(undefined behavior, UB)을 살펴보고, 이것이 “안전한” 언어들보다 C를 더 고성능으로 만들 수 있는 몇 가지 사례를 보였습니다. 2편에서는 이로 인해 발생하는 놀라운 버그들과 많은 프로그래머들이 C에 대해 가지고 있는 널리 퍼진 오해를 다뤘습니다. 이 글에서는 컴파일러가 이러한 함정들에 대해 경고를 제공하려 할 때 직면하는 도전과제들을 살펴보고, 성능 이점은 유지하면서 놀라움을 덜어내기 위해 LLVM과 Clang이 제공하는 기능과 도구들을 이야기합니다.
번역: 일본어
사람들은 종종 컴파일러가 정의되지 않은 동작을 활용해 최적화를 수행할 때, 그 사실을 경고로 알려주지 않는 이유를 묻습니다. 그런 경우가 실제로 사용자 코드의 버그일 수도 있기 때문이죠. 이 접근이 어려운 이유는 1) 실제로 버그가 없는 상황에서도 이러한 최적화는 항상 발생하므로 유용성보다 훨씬 많은 경고를 양산하기 쉽고, 2) 사람들이 원할 때에만 이런 경고를 내도록 만드는 것이 정말 까다롭고, 3) 일련의 최적화가 결합되어 어떻게 해당 최적화 기회가 드러났는지(사용자에게) 표현할 마땅한 방법이 없기 때문입니다. 차례로 살펴보겠습니다:
실제로 유용하게 만드는 것은 ‘정말 어렵다’
예를 들어 봅시다. 비록 타입 변환 버그들이 타입 기반 별칭 분석(Type Based Alias Analysis, TBAA)에 의해 자주 드러나긴 하지만, 시리즈 1편의 “zero_array”를 최적화할 때 “최적화기는 P와 P[i]가 별칭이 아니라고 가정한다”라는 경고를 내는 것은 유용하지 않을 것입니다.
float *P;
void zero_array() {
int i;
for (i = 0; i < 10000; ++i)
P[i] = 0.0f;
}
이런 ‘거짓 양성’ 문제를 넘어서, 실제로 최적화기는 합리적인 경고를 생성하기에 충분한 정보를 갖고 있지 않습니다. 우선, 최적화기는 이미 C와 상당히 다른 추상화된 표현(LLVM IR) 위에서 동작하고 있고, 둘째로, 컴파일러는 매우 계층화되어 있어서 “루프 밖으로 P에서의 로드를 끌어올리려는” 최적화는 포인터 별칭 질의를 해결한 것이 TBAA 분석이었다는 사실을 모릅니다. 네, 이것이 바로 ‘컴파일러 개발자의 푸념’ 부분이긴 합니다만 :) 정말로 어려운 문제입니다.
사람들이 원할 때에만 이런 경고를 내도록 만드는 것도 어렵다
Clang은 “x << 421”과 같은 범위를 벗어난 시프트처럼 단순하고 명백한 형태의 정의되지 않은 동작에 대해 수많은 경고를 구현하고 있습니다. 이것이 단순하고 자명한 일이라고 생각할 수도 있지만, 사람들은 죽은 코드에 있는 정의되지 않은 동작에 대한 경고를 원치 않는다는 사실이 드러났습니다(관련 중복 이슈들도 보세요).
이러한 죽은 코드는 여러 형태를 띱니다. 상수를 넘겼을 때 이상하게 확장되는 매크로, 혹은 switch 문에 대한 제어 흐름 분석을 수행해야만 도달 불가능함을 증명할 수 있는 경우에 경고를 낸다고 불평을 듣기도 했습니다. C의 switch 문은 반드시 제대로 구조화되어 있지 않다는 사실도 문제를 키웁니다.
Clang에서의 해결책은 “런타임 동작” 경고를 처리하기 위한 인프라를 키워 가고, 이후 해당 블록이 실행 불가능함을 나중에 알게 되면 이를 가지치기(prune)하여 보고하지 않도록 하는 것입니다. 그러나 이것은 프로그래머들과의 일종의 ‘군비 경쟁’과도 같습니다. 항상 우리가 예상치 못한 관용구들이 있고, 이런 일을 프론트엔드에서 수행하면 사람들이 잡아주길 바라는 모든 경우를 포착하지 못하기 때문입니다.
기회를 노출시킨 일련의 최적화를 설명하기
프론트엔드가 좋은 경고를 내는 데 어려움이 있다면, 차라리 이를 ‘최적화기에서’ 생성할 수 있을까요? 여기서 유용한 경고를 만드는 데 가장 큰 문제는 데이터 추적입니다. 컴파일러 최적화기는 코드가 통과할 때마다 이를 정규화하고(또는 바람직하게는 더 빠르게 만들기 위해) 변경하는 수십 개의 최적화 패스를 포함합니다. 인라이너가 함수를 인라인하기로 결정하면, 예컨대 “X*2/2”를 없애는 다른 최적화 기회가 드러날 수 있습니다.
이러한 최적화를 보여주기 위해 상대적으로 단순하고 독립적인 예시들을 들었지만, 실제로 이런 최적화가 작동하는 대다수 경우는 매크로 전개, 인라이닝, 그리고 컴파일러가 수행하는 기타 추상화 제거 활동에서 비롯됩니다. 현실적으로 사람들은 그런 ‘엉뚱한’ 코드를 직접 쓰지 않습니다. 경고의 관점에서 이는, 사용자 코드로 문제를 되돌려 전달하려면, 최적화기가 현재 작업 중인 중간 코드가 어떻게 만들어졌는지 정확히 복원해야 함을 의미합니다. 우리는 다음과 같이 말할 수 있어야 할 것입니다:
경고: 3단계 인라이닝(링크 타임 최적화로 파일 경계를 넘었을 수도 있음)과 몇 번의 공통 부분식 제거를 거쳐, 어떤 것을 루프 밖으로 끌어올리고 13개의 포인터가 서로 별칭이 아님을 증명한 뒤에, 당신이 정의되지 않은 작업을 수행하는 경우를 발견했습니다. 이는 당신의 코드에 버그가 있어서일 수도 있고, 매크로와 인라이닝 때문에 유효하지 않은 코드가 동적으로는 도달 불가능하지만 우리가 그것이 죽은 코드임을 증명할 수 없기 때문일 수도 있습니다.
안타깝게도, 우리는 이런 메시지를 만들어낼 내부 추적 인프라가 없고, 설령 있다 하더라도 컴파일러에는 이를 프로그래머에게 잘 전달할 만큼 좋은 사용자 인터페이스가 없습니다.
궁극적으로 정의되지 않은 동작이 최적화기에 가치 있는 이유는 “이 연산은 유효하지 않다 — 그러니 이것은 결코 발생하지 않는다고 가정하라”고 말해주기 때문입니다. “*P” 같은 경우에는 P가 NULL일 수 없다고 추론할 능력을 최적화기에 제공합니다. “*NULL”(예컨대 상수 전파와 인라이닝 후) 같은 경우에는 해당 코드가 도달 불가능함을 최적화기가 알 수 있게 합니다. 중요한 비틀림은, 컴파일러는 정지 문제를 풀 수 없기 때문에, 코드가 실제로 죽어 있는지(C 표준이 요구하듯) 아니면 (잠재적으로 긴) 일련의 최적화가 노출한 버그인지 알 수 없다는 점입니다. 둘을 일반적으로 구분할 좋은 방법이 없기 때문에, 생성될 경고의 거의 전부는 거짓 양성(잡음)이 될 것입니다.
정의되지 않은 동작과 관련해 우리가 처한 난감한 상황을 고려할 때, Clang과 LLVM이 이를 개선하기 위해 무엇을 하고 있는지 궁금할 것입니다. 앞서 몇 가지를 언급했습니다: Clang 정적 분석기, Klee 프로젝트, 그리고 -fcatch-undefined-behavior 플래그는 이런 버그의 일부 클래스를 추적하는 데 유용합니다. 문제는 이러한 도구들이 컴파일러만큼 널리 사용되지는 않는다는 것입니다. 따라서 컴파일러에서 직접 할 수 있는 일은 다른 도구에서 하는 일보다 더 큰 효용을 제공합니다. 다만, 컴파일러는 동적 정보를 갖고 있지 않고, 컴파일 시간 폭증 없이 할 수 있는 일에 제한이 있음을 명심해야 합니다.
Clang이 세상의 코드를 개선하기 위한 첫걸음은 다른 컴파일러들보다 기본적으로 훨씬 더 많은 경고를 켜는 것입니다. 일부 개발자들은 예를 들어 “-Wall -Wextra”로 빌드하는 규율을 갖고 있지만, 많은 사람들은 이런 플래그를 모르거나 전달하지 않습니다. 기본 경고를 더 많이 켜면 더 자주 더 많은 버그를 잡을 수 있습니다.
둘째로, Clang은 코드에서 명백한 많은 종류의 정의되지 않은 동작(널 역참조, 과도한 시프트 등)에 대해 경고를 생성하여 흔한 실수를 일부 잡아냅니다. 위에서 언급한 주의점들이 있긴 하지만, 실제로는 꽤 잘 동작합니다.
셋째로, LLVM 최적화기는 일반적으로 할 수 있는 것보다 정의되지 않은 동작을 덜 적극적으로 활용합니다. 표준은 정의되지 않은 동작의 어떤 사례든 프로그램에 완전히 제한 없는 효과를 줄 수 있다고 말하지만, 이는 개발자 친화적이지도, 특히 유용하지도 않습니다. 대신, LLVM 최적화기는 다음과 같은 몇 가지 방식으로 이러한 최적화를 처리합니다(링크는 C가 아니라 LLVM IR의 규칙을 설명합니다, 양해 바랍니다):
일부 정의되지 않은 동작 사례는 좋은 방법이 있을 때 묵묵히 ‘암묵적으로 트랩하는 연산’으로 변환됩니다. 예를 들어, Clang으로 다음 C++ 함수가:
int *foo(long x) {
return new int[x];
}
다음 X86-64 머신 코드로 컴파일됩니다:
__Z3fool:
movl $4, %ecx
movq %rdi, %rax
mulq %rcx
movq $-1, %rdi # 오버플로 시 크기를 -1로 설정
cmovnoq %rax, %rdi # 그러면 'new'가 std::bad_alloc을 던짐
jmp __Znam
반면 GCC가 생성하는 코드는 다음과 같습니다:
__Z3fool:
salq $2, %rdi
jmp __Znam # 오버플로 시 보안 버그!
차이는, 잠재적으로 심각한 정수 오버플로 버그(버퍼 오버플로와 익스플로잇으로 이어질 수 있음)를 막기 위해 몇 사이클을 투자하기로 했다는 점입니다(대개 operator new는 꽤 비싸므로 이 오버헤드는 거의 눈에 띄지 않습니다). GCC 측은 적어도 2005년부터 이를 알고 있었지만, 이 글을 쓰는 시점에는 아직 수정하지 않았습니다.
정의되지 않은 값으로 연산을 수행하는 산술은, 정의되지 않은 동작을 일으키는 대신 ‘정의되지 않은 값’을 산출한다고 간주됩니다. 차이점은, 정의되지 않은 값은 하드 디스크를 포맷하거나 그 밖의 바람직하지 않은 효과를 내지 않는다는 것입니다. 유용한 정제는, 정의되지 않은 값의 가능한 모든 인스턴스에 대해 동일한 출력 비트를 내는 경우에 일어납니다. 예를 들어, 최적화기는 “undef & 1”의 결과가 상위 비트는 0이고 하위 1비트만 정의되지 않았다고 가정합니다. 이는 LLVM에서 ((undef & 1) >> 1)이 정의되지 않았다고 보기보다 0으로 정의된다는 뜻입니다.
실행 중에 정의되지 않은 연산(예: 부호 있는 정수 오버플로)을 수행하는 산술은 논리적 트랩 값을 생성합니다. 이 값은 이를 기반으로 하는 모든 계산을 ‘오염’시키지만, 프로그램 전체를 망가뜨리지는 않습니다. 예를 들어, 이것이 최적화기가 초기화되지 않은 변수를 조작하는 코드를 지워 버리는 이유입니다.
널로의 저장(store)과 널 포인터를 통한 호출(call)은 __builtin_trap() 호출로 바뀝니다(x86에서는 “ud2” 같은 트랩 명령으로 변환). 이런 일은 최적화된 코드에서(인라이닝과 상수 전파 같은 다른 변환의 결과로) 자주 발생하며, 우리는 예전엔 이것들을 “명백히 도달 불가능”하다고 보고 해당 블록들을 그냥 지워 버리곤 했습니다.
법 조문을 중시하는 언어 변호사 관점에서는 이는 엄밀히 맞습니다. 그러나 사람들은 가끔 널 포인터를 역참조하며, 코드 실행이 그냥 다음 함수의 맨 위로 떨어지면 문제를 이해하기가 매우 어렵습니다. 성능 관점에서 가장 중요한 점은, 이를 드러내면 이후의 코드를 억제(squash)할 수 있다는 것입니다. 이러한 이유로, Clang은 이것들을 런타임 트랩으로 바꿉니다. 실제로 이런 지점이 동적으로 도달되면 프로그램은 즉시 멈추고 디버깅할 수 있습니다. 대가는, 이런 연산과 그것을 제어하는 조건(프레디킷)을 보관해야 하므로 코드가 약간 비대해진다는 것입니다.
프로그래머의 의도가 명백해 보이는 경우(예: P가 float인데 “(int*)P”를 하는 코드)에는 최적화기가 ‘올바른 일’을 하도록 어느 정도 노력합니다. 이는 많은 흔한 경우에 도움이 되지만, 여기에 의존하고 싶지는 않을 것이며, 여러분이 ‘명백하다’고 생각하는 예시들 중에도 코드에 긴 일련의 변환이 적용된 뒤에는 더 이상 명백하지 않은 경우가 많습니다.
위의 어느 범주에도 속하지 않는 최적화들, 예컨대 1편의 zero_array와 set/call 예시는 사용자에게 아무런 표시 없이 설명한 대로 조용히 최적화됩니다. 우리는 유용하게 말해줄 것이 없고, (버그가 있는) 실제 코드가 이러한 최적화들로 인해 깨지는 일은 매우 드물기 때문에 이렇게 합니다.
트랩 삽입과 관련해 우리가 개선할 수 있는 중요한 영역이 하나 있습니다. 최적화기가 트랩 명령을 생성할 때마다 경고하도록 하는(기본 비활성) 경고 플래그를 추가하는 것은 흥미로울 것입니다. 어떤 코드베이스에서는 극도로 시끄럽겠지만, 다른 곳에서는 유용할 수 있습니다. 첫 번째 제약은 최적화기가 경고를 생성하도록 하는 인프라 작업입니다. 디버그 정보가 켜져 있지 않으면 유용한 소스 코드 위치 정보를 갖고 있지 않기 때문입니다(하지만 이는 고칠 수 있습니다).
더욱 중대한 제약은, 그 경고가 ‘추적’ 정보를 갖지 못해, 어떤 연산이 루프를 세 번 언롤하고 네 단계의 함수 인라이닝을 거쳐 나온 것임을 설명하지 못한다는 것입니다. 최선의 경우, 원래 연산의 파일/줄/열 위치를 가리킬 수 있을 텐데, 가장 단순한 경우에는 유용하겠지만 다른 경우에는 극도로 혼란스러울 수 있습니다. 어쨌든, 이것을 구현하는 것이 우리에게 높은 우선순위가 되지 않았던 이유는 a) 좋은 경험을 제공할 가능성이 낮고 b) 기본으로 켤 수 없으며 c) 구현 작업량이 많기 때문입니다.
‘궁극의 성능’에 관심이 없다면, 정의되지 않은 동작들을 제거하는 C의 다양한 방언을 활성화하도록 여러 컴파일러 플래그를 사용할 수 있습니다. 예를 들어, -fwrapv 플래그는 부호 있는 정수 오버플로에서 비롯되는 정의되지 않은 동작을 제거합니다(하지만 잠재적 정수 오버플로 보안 취약점을 제거하지는 않습니다). -fno-strict-aliasing 플래그는 타입 기반 별칭 분석을 비활성화하므로, 이러한 타입 규칙을 무시해도 됩니다. 수요가 있다면, Clang에 모든 지역 변수를 암묵적으로 0으로 초기화하는 플래그, 가변 시프트 카운트를 갖는 각 시프트 전에 “and” 연산을 삽입하는 플래그 등을 추가할 수도 있습니다. 불행히도, ABI를 깨뜨리고 성능을 완전히 망가뜨리지 않고서는 C에서 정의되지 않은 동작을 완전히 제거할 실용적인 방법이 없습니다. 또 다른 문제는, 그렇게 되면 더 이상 C를 작성하는 것이 아니라 비슷하지만 이식 불가능한 C의 방언을 작성하게 된다는 점입니다.
이식 불가능한 C 방언으로 코드를 작성하고 싶지 않다면, -ftrapv와 -fcatch-undefined-behavior 플래그(그리고 앞서 언급한 다른 도구들)가 이런 종류의 버그를 추적하는 무기가 될 수 있습니다. 디버그 빌드에서 이를 활성화하는 것은 관련 버그를 일찍 찾는 훌륭한 방법이 될 수 있습니다. 또한 보안에 치명적인 애플리케이션을 빌드한다면, 프로덕션 코드에서도 유용할 수 있습니다. 물론 모든 버그를 찾아준다는 보장은 없지만, 유용한 부분집합은 찾아줍니다.
궁극적으로, 진짜 문제는 C가 ‘안전한’ 언어가 아니라는 점이며, (성공과 인기에도 불구하고) 많은 사람들이 언어가 어떻게 작동하는지 실제로 이해하지 못한다는 점입니다. 1989년 표준화 이전 수십 년 동안, C는 “PDP 어셈블리 위에 아주 얇게 얹힌 저수준 시스템 프로그래밍 언어”에서 “많은 사람들의 기대를 깨뜨림으로써 그럴듯한 성능을 제공하려는 저수준 시스템 프로그래밍 언어”로 변모했습니다. 한편으로 이러한 C의 ‘꼼수’는 거의 항상 잘 작동하고, 덕분에 코드는 일반적으로 더 빠르며(어떤 경우에는 훨씬 더 빠르기도 합니다), 다른 한편으로 C가 꼼수를 쓰는 지점들은 사람들에게 가장 놀라운 곳이자, 보통 최악의 타이밍에 덮칩니다.
C는 이식 가능한 어셈블러 그 이상이며, 때로는 매우 놀라운 방식으로 그렇습니다. 이 논의가 컴파일러 구현자의 관점에서라도 C에서의 정의되지 않은 동작 뒤에 있는 몇 가지 문제를 설명하는 데 도움이 되었기를 바랍니다.