컴파일러 최적화가 언제 합법적인지, C의 정의되지 않은 동작(UB)이 어떻게 그 근거가 되는지, 그리고 Rust에서는 unsafe 코드에 대한 UB 규칙을 어떻게 정립하고 도구화할 것인지에 대해 논한다.
지난해, 저는 제가 속해 있는 Rust unsafe 코드 가이드라인 스트라이크 팀이 꾸려졌다고 알렸습니다. :-) 드디어 1년이 지난 지금, 이 글은 그 팀의 목적에 대한 저의 생각을 정리한 것입니다. 경고: 이 글에는 의견이 포함되어 있을 수 있습니다. 경고했습니다.
현재 우리는 안전한(safe) Rust가 의도하는 동작에 대해 꽤 잘 이해하고 있습니다. 즉, 연산들이 수행되는 순서와 각 연산이 무엇을 하는지에 대해 (몇 가지 버그를 제외하면) 대체로 합의가 이루어져 있습니다.
하지만 unsafe Rust에 대해서는 상황이 매우 다릅니다. 여기에 여러 이유가 있는데, 특히 성가신 하나는 rustc/LLVM이 이미 수행하고 있거나 앞으로 수행하고자 하는 컴파일러 최적화와 관련이 있습니다. 다음의 간단한 함수를 생각해 봅시다:
fn simple(x: &mut i32, y: &mut f32) -> i32 {
*x = 3;
*y = 4.0;
*x
}
컴파일러가 이 두 저장(store) 연산을 프로그램의 동작을 바꾸지 않고 재배치 할 수 있기를 바랍니다. 결국 x와 y는 둘 다 가변 참조이며, 타입 시스템은 이들이 고유 포인터임을 보장하므로 서로 에일리어싱(alias)할 수 없습니다(즉, 가리키는 메모리 범위가 겹칠 수 없습니다). 이 변환을 거치면 코드에는 *x = 3; *x가 남고, 이는 *x = 3; 3으로 더 최적화되어 메모리 접근을 한 번 줄일 수 있습니다. 컴파일러는 어떤 연산들이 서로 독립적인지 판단하고, 그런 가정을 바탕으로 코드를 이리저리 옮겨 일정 연산을 완전히 제거하거나(예: x를 다시 읽는 로드 제거), 현대 CPU 코어의 명령어 수준 병렬성을 잘 활용하는 영리한 스케줄링으로 실행을 더 빠르게 만드는 데서 큰 성능 향상을 이끌어냅니다(여기서 말하는 병렬성은 코어 내부의 병렬성이지, 멀티코어에서 발생하는 병렬성이 아닙니다).
저장 연산의 재배치 같은 최적화는 컴파일러가 코드에 대해 어떤 가정 을 세우고, 그 가정을 근거로 프로그램 변환의 정당성을 확보하는 데 기반을 둡니다. 이번 경우 가정은 두 저장 연산이 결코 같은 주소에 영향을 주지 않는다는 것입니다. 보통 컴파일러가 이런 가정을 하고 싶다면, 가능한 모든 프로그램 실행에서 그 가정이 실제로 성립함을 증명 하기 위해 정적 분석을 수행해야 합니다. 결국 어느 한 실행이라도 그 가정이 깨진다면, 그 최적화는 잘못될 수 있고 – 프로그램의 동작을 바꿔버릴 수 있습니다!
그런데, 정밀한 별칭 정보(aliasing information)를 얻는 것이 종종 정말 어렵다는 사실이 드러납니다. 이쯤에서 게임은 끝난 듯 보입니다. 별칭 정보가 없다면, 가정을 검증할 방법도 없고, 최적화도 못 합니다.
하지만 컴파일러 개발자들은 이러한 최적화가 충분히 중요하다고 판단했고, 대안적 해법을 고안했습니다. 바로 컴파일러가 이런 가정을 직접 검증하는 대신, 그 책임을 프로그래머에게 전가 하는 것입니다.
예를 들어, C 표준은 메모리 접근이 올바른 “유효 타입(effective type)”으로 이루어져야 한다고 말합니다. 어떤 데이터가 float 포인터로 저장되었다면, 이를 int 포인터로 읽어서는 안 됩니다. 이 규칙을 어기면 프로그램은 정의되지 않은 동작(Undefined Behavior, UB)을 갖게 되는데 – 이는 곧 프로그램이 실행될 때 무엇이든 일어날 수 있음을 의미합니다. 이제 컴파일러가 우리 예제에서 두 저장 연산을 재배치하는 변환을 하고 싶다면 다음과 같이 논증할 수 있습니다. 주어진 함수의 임의의 특정 실행에서, x와 y는 에일리어싱을 하거나 하지 않습니다. 에일리어싱을 하지 않는다면, 두 쓰기의 재배치는 전혀 문제가 없습니다. 하지만 에일리어싱을 한다면, 이는 유효 타입 제한을 위반하므로 코드 전체가 UB가 됩니다 – 그러면 컴파일러는 무엇이든 해도 됩니다. 특히, 두 쓰기의 재배치를 해도 됩니다. 보시다시피 가능한 두 경우 모두에서 재배치는 정당하므로, 컴파일러는 이 변환을 자유롭게 수행할 수 있습니다.
정의되지 않은 동작은 이 최적화의 정당성에 대한 증명 부담을 컴파일러에서 프로그래머에게로 옮깁니다. 위 예에서 “유효 타입” 규칙이 실제로 뜻하는 바는, float를 읽는 모든 메모리 읽기에는 하나의 증명 의무 가 따라붙는다는 것입니다. 즉, 이 메모리에 대한 마지막 쓰기가 실제로 float 포인터를 통해 일어났음을 프로그래머가 보여줘야 합니다(유니언과 문자 포인터에 관한 몇 가지 예외는 있습니다). 비슷하게, 부호 있는 정수 오버플로는 정의되지 않은 동작이다라는 (악명 높은) 규칙은 부호 있는 정수에 대한 모든 산술 연산이 “이 연산은 결코 오버플로가 발생하지 않는다”는 증명 의무를 동반함을 의미합니다. 컴파일러는 프로그래머가 실제로 그 노력을 들여 이 사실을 확인했다는 가정 하에 최적화를 수행합니다.
컴파일러가 그리 똑똑할 수만은 없다는 점을 고려하면, 이는 원래라면 수행하기 어렵거나 불가능했을 최적화를 정당화하는 훌륭한 방법입니다. 안타깝게도, 어떤 프로그램에 UB가 있는지 여부를 말하는 일은 종종 쉽지 않습니다 – 결국 그런 분석이 어렵기 때문에 컴파일러가 최적화를 위해 UB에 의존하는 것이니까요. 게다가 C 컴파일러는 특정 프로그램에 UB가 있다 는 사실은 기꺼이 이용하지만, 프로그램 실행이 UB를 유발하지 않는다 는 것을 검사할 방법은 제공하지 않습니다. 또한 프로그래머의 직관은 종종 컴파일러의 동작과 일치하지 않습니다. 이 때문에 (프로그래머 눈에는) 오컴파일이 발생하고, 때로는 보안 취약점으로 이어지기도 합니다. 결과적으로 UB는 상당히 나쁜 평판을 얻게 되었죠. (보기에는 무고한 + 연산이 오버플로와 관련된 미묘한 증명 의무를 수반하리라 대부분 예상하지 못한다는 점도 한몫합니다. 다시 말해, 이것은 API 설계 문제이기도 합니다.)
프로그램을 실행하면서 UB를 감지하려는 다양한 새니타이저가 있지만, 가능한 모든 UB의 원인을 잡아내지는 못합니다. 이렇게 어려운 이유 중 일부는 표준이 이러한 새니타이저를 염두에 두고 작성되지 않았기 때문입니다. 이 최근 블로그 글은 상황을 훨씬 더 자세히 논의합니다. 예컨대 우리가 위에서 논의한 유효 타입 제한(“엄격 별칭(strict aliasing)” 또는 “타입 기반 별칭 분석(type-based alias analysis)”이라고도 불림)의 경우, 완화책 – 즉, 프로그램이 그 영향을 받지 않도록 확인하거나 보장하는 방법 – 은 해당 최적화에 의존하는 기능을 꺼버리는 것입니다. 그다지 만족스럽지 않죠.
Rust로 돌아와서, 우리는 어디에 와 있을까요? 안전한 Rust는 UB로부터 자유롭습니다. 하지만 여전히 unsafe Rust에 대해서는 걱정해야 합니다. 예를 들어, unsafe 코드가 에일리어싱하는 가변 참조 둘(안전한 Rust에서는 금지됨)을 만들어 우리 simple 함수에 전달한다면 어떨까요? 이는 우리가 두 쓰기를 재배치할 때 세운 가정을 깨뜨립니다. 이런 최적화를 허용하려면(우리는 허용하길 원합니다!), 이것이 프로그램 동작을 바꾸지 않음을 논증해야 합니다. unsafe Rust 코드가 simple에 에일리어싱 포인터를 넘기는 것은 금지되어야 하며; 그렇게 하면 UB가 발생해야 합니다. 따라서 우리는 Rust 코드가 언제 UB가 되는지에 대한 규칙을 마련해야 합니다. 이것이 unsafe 코드 가이드라인 스트라이크 팀이 하려는 일입니다.
물론 C가 하는 방식을 그대로 베낄 수도 있겠지만, 그것이 훌륭한 해법은 아니라는 점을 제가 설득했기를 바랍니다. Rust의 UB를 정의할 때, 우리는 C보다 더 잘할 수 있기를 바랍니다. 저는 프로그래머의 직관이 표준과 컴파일러가 정한 규칙과 일치하도록 노력해야 한다고 생각합니다. 그 결과 최적화에 대해 조금 더 보수적이어야 한다면, 컴파일된 프로그램에 대한 확신을 높이기 위한 대가로서 치를 가치가 있다고 봅니다.
또한 UB를 탐지 하는 도구는 지극히 중요하며, 직관을 형성하는 데 도움을 줄 수 있고 어쩌면 우리가 덜 보수적으로 행동할 수 있게 해줄 수도 있다고 생각합니다. 이를 위해 사양은 그런 도구가 실현 가능하도록 작성되어야 합니다. 사실, 동적 UB 검사기의 명세를 작성하는 것은 UB 자체를 명세하는 매우 좋은 방법입니다! 그런 명세는 실행 시점에 추가로 필요한 상태가 무엇인지, 그리고 각 연산에서 UB에 빠지는지 여부를 어떻게 검사 할지를 기술하게 됩니다. 이러한 관점을 염두에 두고 저는 예전에 실행 가능한 명세로서의 miri에 대해 글을 썼습니다.
다음 화 예고: 저는 현재 인턴십 동안 이런 종류의 명세 작업을 하고 있습니다. 이제 아이디어가 충분히 구체화되어 초안을 작성할 수 있게 되었고, 세상에 공개해 의견을 들어보려 합니다.
업데이트: 글로 옮겼습니다.
업데이트: “책임 전가(Shifting Responsibility)”를 명확히 했습니다.