restrict, 포인터-정수 캐스트, 그리고 Strict Provenance를 통해 최적화와 의미론이 충돌하는 사례를 분석하고, Rust와 C의 PNVI-ae-udi를 비교하며 Rust 메모리 모델에 대한 현실적인 해법과 방향을 제시한다.
이전의 포인터 출처(provenance) 글에서, 포인터를 신중히 다루지 않으면 내부적으로 일관성 없는 컴파일러를 만들 수 있음을 보였다. 고상하게 보이는 최적화들을 차례로 적용했을 뿐인데, 각 최적화는 따로 보면 직관적으로 옳아 보이지만 그 결과로 의도된 대로 잘 동작해야 할 프로그램이 오역(미스컴파일)되는 식이다. 그러므로 우리는 이러한 최적화들 중 적어도 하나를 제거하거나 제한해야 한다. 이번 글에서도 또 다른 예시로 같은 흐름을 이어가고, 최근의 Strict Provenance 제안과 그것이 Rust 전반에 줄 수 있는 의미, 그리고 C의 PNVI-ae-udi와의 비교에 대해 전반적인 생각을 정리한다. 마지막에는 Rust 메모리 모델에 관해 매우 희망적인 결말로 마무리 짓겠다. 정보가 많은 글이니, 편한 자세로 읽길 바란다. :)
“포인터 출처(provenance)”가 무슨 뜻인지 모른다면, 앞선 글이나 Strict Provenance 문서를 참고하라. 핵심은, 포인터는 단순히 메모리의 어떤 주소만을 담는 것이 아니라, 그 포인터가 어느 메모리를 언제 접근할 수 있는지 추적하는 추가 “그림자 상태”, 즉 출처를 함께 가진다는 것이다. 이것은 “해제 후 사용(use-after-free)은 UB다. 심지어 예전과 같은 주소에 새 할당이 생긴 것을 확인했더라도” 같은 제약을 이해하는 데 필요하다. CHERI 같은 아키텍처는 이 “그림자 상태”를 명시적으로 드러내어(포인터가 더 커져 허용된 메모리 영역을 추적) 지원한다. AMD64 같은 일반 CPU를 대상으로 컴파일하더라도, 컴파일러는 포인터가 이러한 추가 상태를 갖는 것처럼 동작한다. 이는 타깃 CPU의 일부가 아니라 명세, 즉 추상 기계의 일부다.
출처의 미묘함을 이해하는 데 핵심이 되는 요소는 C의 키워드 restrict다. 이는 특정 포인터 x가 x에서 파생되지 않은 어떤 포인터와도 에일리어싱(aliasing)하지 않는다는 약속이다.1 Rust의 &mut T가 유일함을 약속하는 것과 비슷하다. 다만 지난번과 마찬가지로, restrict와 정수-포인터 캐스트를 조합했을 때 최적화 컴파일러에 어떤 한계가 생기는지를 보고자 하므로, 실제로 고민해야 할 언어는 컴파일러의 IR이다. 여기서는 사례를 설명하기 쉽게 C 문법을 사용하지만, 이는 LLVM IR의 “자명한” 동등 함수에 대한 표기법일 뿐이며, restrict는 noalias로 표현된다. 물론 IR이 코드에 제한을 둬야 한다면, 이는 표면 언어에도 적용되므로 Rust, C, LLVM을 모두 오가며 이야기하게 될 것이다.
그럼 다음 프로그램을 보자:
#include <stdio.h>
#include <stdint.h>
static int uwu(int *restrict x, int *restrict y) {
*x = 0;
uintptr_t xaddr = (uintptr_t)x;
int *y2 = y-1;
uintptr_t y2addr = (uintptr_t)y2;
if (xaddr == y2addr) {
int *ptr = (int*)xaddr;
*ptr = 1;
}
return *x;
}
int main() {
int i[2] = {0, 0};
int res = uwu(&i[0], &i[1]);
// 항상 1을 출력한다.
printf("%d\n", res);
}
이 함수는 인자로 두 개의 restrict 포인터 x와 y를 받는다. 먼저 *x에 0을 쓴다. 그런 다음 y2를 *y 바로 이전의 int를 가리키게 만든 뒤, 그것과 x를 정수로 캐스팅한다. 두 주소가 같으면, xaddr을 다시 포인터로 캐스팅해 그 위치에 1을 쓴다. 마지막으로 *x에 저장된 값을 반환한다.
main은 단순히 배열의 처음 두 원소를 가리키는 두 포인터로 uwu를 호출한다. 특히, 이 경우 xaddr과 y2addr은 항상 같다! &i[1] - 1은 &i[0]과 같은 주소를 뜻한다.
이제 uwu에 몇 가지 겉보기엔 자명한 최적화를 적용해 보자:
uwu는 다음과 같이 바뀐다:
static int uwu(int *restrict x, int *restrict y) {
*x = 0;
int *y2 = y-1;
uintptr_t y2addr = (uintptr_t)y2;
int *ptr = (int*)y2addr; // <-- y2addr 사용
*ptr = 1;
return *x;
}
아직은 무해해 보일 수도 있다. 하지만 더 나아갈 수 있다! 이제 이 함수는 *x에 0을 저장한 뒤, x를 전혀 건드리지 않는 코드 뭉치가 나오고, 다시 *x에서 로드한다. x는 restrict 포인터이므로, 이 “x를 전혀 다루지 않는 코드”가 *x를 바꾸는 일은 절대로 없다. 그렇게 하면 restrict/noalias 보장이 깨지기 때문이다. 따라서 return *x를 return 0으로 최적화할 수 있다. 이 종류의 최적화는 애초 restrict 주석을 다는 주된 이유이므로 이견이 적을 것이다. 형식적으로 말하면: 오직 x에서 “파생된” 포인터만 *x에 접근할 수 있고, “파생”의 정의는 까다롭지만, x를 전혀 사용하지 않는 연산만으로 x에서 파생된 결과를 만들 수는 없다고 보는 것이 상식이다. (만약 그렇다면 restrict는 사실상 무가치해진다.)
이제 전체 프로그램은 이렇게 된다:
static int uwu(int *restrict x, int *restrict y) {
*x = 0;
int *y2 = y-1;
uintptr_t y2addr = (uintptr_t)y2;
int *ptr = (int*)y2addr;
*ptr = 1;
return 0; // <-- 상수 반환값
}
int main() {
int i[2] = {0, 0};
int res = uwu(&i[0], &i[1]);
// 이제 0을 출력한다!
printf("%d\n", res);
}
처음엔 항상 1을 출력하던 프로그램이, 최적화 후엔 항상 0을 출력하게 되었다. 나쁜 징조다. 최적화가 프로그램의 동작을 바꾸었다. 이래서는 안 된다! 무엇이 잘못됐을까?
본질적으로, 이는 이전 글과 같은 상황이다. 원 프로그램이 이미 UB였거나, (적어도) 최적화 중 하나가 틀렸음을 시사한다. 하지만 원 프로그램에서 수상해 보이는 부분이라곤 포인터-정수-포인터 왕복뿐이다. 정수를 포인터로 캐스팅하는 것이 허용된다면, 그 왕복이 당연히 동작해야 하지 않겠는가. 이하에서는 (uintptr_t)x로 x를 바꾸는 것이 언제나 허용된다고 가정한다. 그렇다면, 어떤 최적화가 잘못된 것일까?
앞서 restrict에 대해 말하며 ptr이 어떤 포인터에서 “파생되었는지”가 중요하다고 했다. 이 관점에서 보면, xaddr을 y2addr로 바꾼 최적화가 문제처럼 보일 수 있다. 이 변환 이후 ptr은 분명 y2(그리고 전이적으로 y)에서 파생되었고, x에서는 파생되지 않았다. 따라서 main에서 호출되는 uwu는 서로 다른 restrict 포인터에서 파생된 두 포인터로 같은 위치에 두 번 접근(적어도 한 번은 쓰기)하므로 잘못된 것처럼 보인다!
하지만 그 최적화는 포인터와는 무관하다. 그저 같은 정수를 다른 정수로 바꾸었을 뿐이다! 어떻게 그게 잘못일 수 있나?
이 예시는 “파생됨”이라는 개념이 최적화 컴파일러를 고려하면 별 의미가 없음을 보여준다.2 포인터-정수 왕복에서도 올바르게 동작하는 “파생됨” 개념을 만들 수는 있다. 그러나 그러려면 포인터뿐 아니라 정수에도 출처를 싣도록 하여, 포인터→정수 캐스트가 출처를 보존할 수 있게 해야 한다. 그러면 하나를 해결하고 여러 문제를 만든다. 예컨대 출처가 있는 정수에 대해, 출처가 없는 것임을 보장하지 않는 한, ==로 같은 정수를 다른 정수로 치환하는 최적화를 그만둬야 한다. (또는 그러한 정수의 == 비교를 UB로 만들 수도 있다. 하지만 포인터→정수 캐스트로 얻은 정수들을 ==로 비교하고 싶어하는 사람들을 위해 그건 옵션이 아니다.) 이는 최적화 이득을 보는 코드는 그 자체로 음흉한 짓을 하지 않는데, 포인터 조작 코드가 문제를 일으키는 셈이어서 손해가 크다. 목록은 여기서 끝나지 않는다. 이런 이유로 C 표준화는 출처가 정수에도 실리는 모델을 폐기하고, 결국 PNVI – 정수를 통한 출처 전달 없음 – 모델을 채택했다. Rust도 이 길을 따라야 한다고 본다.
그렇다면 xaddr을 y2addr로 바꾼 것이 아니라면, 무엇이 잘못된 최적화인가? 내 주장으로는, xaddr을 제거한 것이 틀렸다. 더 정확히 말해, (결과를 쓰지 않더라도) (uintptr_t)x 캐스트를 제거한 것이 잘못이다. 이 캐스트가 보존되었다면, 컴파일러는 “여기서 x의 restrict 보장은 끝난다”는 표식을 얻었을 것이고, uwu의 반환을 0으로 고정하는 최적화를 하지 않았을 것이다.
결과를 쓰지 않는 연산을 지우는 게 왜 안 될까? 한 발 물러나면 답은 간단하다. foo()가 전역 상태에 부작용(전역 변수 변경 등)이 있다면, 반환값을 무시해도 호출은 남겨야 한다. 그런데 여기서 문제의 연산은 (uintptr_t)x이고, 부작용이 없어 보이지 않는가?
아니다. 바로 이것이 이 예시가 주는 핵심 교훈이다. 포인터를 정수로 캐스팅하는 것은 부작용을 가진다. 그리고 그 결과를 사용하지 않더라도(여기서는 x와 y2가 같은 uintptr_t로 캐스팅됨을 알고 있었기 때문에) 그 부작용은 보존되어야 한다.
그 부작용을 설명하려면 포인터 출처 관점으로 깊이 들어가야 한다. x와 y는 포인터이므로, 어떤 메모리에 접근 권한이 있는지 추적하는 출처를 지닌다. 구체적으로 x는 main의 i[0]에, y는 i[1]에 접근 권한을 가진다.3 y2는 y의 권한을 그대로 상속한다.
그렇다면 ptr은 어떤 권한을 받는가? 정수는 출처를 갖지 않으므로, 포인터→정수 캐스트 중에 이 권한 정보는 사라진다. 그리고 정수→포인터 캐스트에서 어떻게든 ‘복원’되어야 한다. 바로 이 지점에서 문제가 시작된다. 원래 프로그램에서는 포인터→정수→포인터 왕복이 허용된다고(그것이 C 표준의 의도) 가정했다. 따라서 ptr은 x의 권한을 이어받아야 한다(그렇지 않으면 *ptr 쓰기가 UB가 된다. x는 restrict이고, 그 메모리에 접근할 수 있는 것은 x에서 파생된 것뿐). 하지만 최종 프로그램에서는 x가 ptr 계산에 전혀 관여하지 않는다! ptr이 x의 권한을 주워 간다고 말하는 것은 재앙이다. y 조작 코드를 다른 함수로 옮겼다고 해보자. 우리는 호출하는 임의의 함수가 캐스트만으로 x의 권한을 ‘훔칠’ 수 있다고 가정해야 하나? 그러면 restrict의 요지가 무너지고 noalias 최적화는 사실상 불가능해진다.
그렇다면 어떻게 원 프로그램에서는 ptr이 x의 권한을 받을 수 있고, 최종 프로그램에서는 안 된다고 할 수 있을까? 핵심 차이는 원 프로그램에서는 x가 정수로 캐스팅되었다는 점이다. 포인터를 정수로 캐스팅하면, 그 권한이 “주워 갈 수 있도록” 공개되었다고 선언하는 셈이다. 이후의 정수→포인터 캐스트는 그 권한을 포인터에 부여할 수 있다. 우리는 이를 권한이 “노출(exposed)”되었다고 부른다. 그리고 바로 그것이 (uintptr_t)x가 가진 부작용이다!
물론, 이는 몇몇 최적화를 잃게 만든다. 앞선 예시가 보여주듯, 일부 최적화는 반드시 포기해야 한다. 하지만 이전 절과의 중요한 차이는, 오직 포인터를 정수로 캐스팅하는 코드만 영향을 받는다는 것이다. 즉, 성능 비용을 포인터로 ‘까다로운 일’을 하는 코드에만 국한시킬 수 있다. 그런 코드는 컴파일러가 더 보수적으로 동작해야 하지만, 나머지 코드는 포인터-정수-포인터 왕복의 미묘함을 고려하지 않고도 최적화할 수 있다. (구체적으로, 포인터→정수 캐스트와 정수→포인터 캐스트 모두 불순(impure) 연산으로 다뤄야 하지만 이유는 다르다. 포인터→정수 캐스트는 방금 본 것처럼 부작용이 있다. 정수→포인터 캐스트는 비결정적이다. 동일 입력에도 다른 결과를 낼 수 있다. 이에 대한 논의는 아래 부록으로 옮겼다.)
이 이야기는 포인터 태깅(포인터의 최하위 비트에 플래그 저장) 같은 저수준 요령에 나쁜 소식처럼 들릴 수 있다. 이런 코드를 덜 최적화해야 하는가? 그렇지는 않다. 포인터→정수 캐스트에서 “노출” 부작용 없이 안전한 경우가 있다. 바로, 그 정수를 다시 포인터로 캐스팅할 의도가 전혀 없는 경우다! 이는 틈새적 사례처럼 보일 수 있지만, 사실 대부분의 경우 ‘맨몸’의 정수→포인터 캐스트를 피하고, 대신 새 포인터에 어떤 출처를 사용할지 명시하는 with_addr 같은 연산을 사용할 수 있다.4 이는 Gankra가 보여주었듯 포인터 태깅 같은 저수준 요령에 충분하다. Rust의 Strict Provenance 실험은 with_addr 같은 연산으로 사실상 모든 정수→포인터 캐스트를 대체할 수 있는지 검증하려 한다.
Strict Provenance의 일환으로, Rust에는 포인터를 정수로 캐스팅하되, 기저 포인터의 권한을 노출하지 않는 두 번째 방법 ptr.addr()이 생겼다. 이는 순수한 연산으로 다룰 수 있다 포인터의 정수 표현으로 온갖 조작을 하면서도, 맨몸 정수→포인터 캐스트에 기대지만 않는다면, 맛있는 최적화를 모두 누릴 수 있다. 덤으로, 128비트 usize 없이도 CHERI에서 깔끔히 동작하며 Miri에도 도움이 된다.
하지만 이 부분은 이 글의 초점이 아니다. 이 주제는 이미 Gankra가 충분히 다뤘다. 이 글에서는 포인터와 정수 사이 캐스트에서 무엇을 배웠는지만으로 만족하자. 우리는 예시가 드러낸 충돌을, 성능 비용(최적화 상실)을 진짜 모호한 코드에만 국한시키면서 해결하는 방법을 찾았다. 그리고 모호한 정수→포인터 캐스트를 대체할 수 있는 대안 API도 찾아냈다. 모두 잘 끝났나? 아쉽게도 아직 포인터 출처 악몽은 끝나지 않았다.
C나 Rust 같은 언어에서는 값의 표현을 다른 타입으로 다시 해석하는 것이 보통 허용된다. Rust에서는 이를 흔히 “transmutation”이라 부르고, C에서는 “type punning”이라 부른다. Rust에서는 mem::transmute 함수가 가장 쉽고, 대안으로 union을 사용하거나 *mut T를 *mut U로 캐스팅해도 된다. C에서는 서로 다른 타입 변수 간 memcpy가 가장 쉽고, union 기반 타입 퍼닝이 허용될 때도 있으며, 문자 타입 포인터로 임의 타입의 데이터를 읽는 일도 가능하다. (그 외 포인터 기반 타입 퍼닝은 C의 strict aliasing 규칙으로 금지되지만, Rust에는 그런 제약이 없다.) 이제 묻자: 포인터를 정수로 변환하면 어떤 일이 벌어질까?
기본적으로, 원 예시에서 두 캐스트(xaddr과 y2addr 계산)를 다음과 같은 함수 호출로 바꿔보자:
static uintptr_t transmute_memcpy(int *ptr) {
uintptr_t res;
memcpy(&res, &ptr, sizeof(uintptr_t));
return res;
}
또는
static uintptr_t transmute_union(int *ptr) {
typedef union { uintptr_t res; int *ptr; } Transmute;
Transmute t;
t.ptr = ptr;
return t.res;
}
동일한 최적화가 그대로 적용될까? 이를 위해서는 컴파일러가 memcpy나 union 필드 접근을 ‘통과해서 본다’는 의미의 분석을 할 수 있어야 하지만, 과도한 요구는 아닐 것이다. 그러나 다시 앞서의 모순과 마주하게 된다! 원 프로그램이 이미 UB였거나, 최적화 중 하나가 잘못되었다.
이전에는 (결과를 쓰지 않더라도) “죽은 캐스트” (uintptr_t)x를 제거한 것이 틀렸다고 해서 문제를 풀었다. 그 캐스트가 x의 권한을 “노출”하는 부작용이 있어서, 이를 기반으로 이후 정수→포인터 캐스트가 그 권한을 집어 갈 수 있기 때문이다. 같은 해법을 다시 쓰려면, 이번에는 union 접근(정수 타입)이나 정수로의 memcpy도 “노출” 부작용을 가질 수 있고, 결과가 사용되지 않아도 완전히 제거할 수 없다고 해야 한다. 그런데 이는 꽤 나쁘다! (uintptr_t)x는 포인터로 어려운 일을 하는 코드에서만 보이므로 컴파일러가 더 조심하고 덜 최적화하게 만드는 것이 합리적이다(그리고 최소한 Rust에서는 x.addr()로 이 부작용을 옵트아웃할 수도 있다). 하지만 union과 memcpy는 도처에 넘쳐난다. 이제 모든 union/memcpy를 부작용 있는 연산으로 취급해야 할까? Rust에서는 strict aliasing이 없기 때문에(또는 C에서 -fno-strict-aliasing) 상황은 더 나빠진다. 사실상 임의의 정수 로드가 포인터→정수 변환일 수 있으며 따라서 “노출” 부작용을 가질 수 있기 때문이다!
Rust 관점에서 보자면 이는 나쁜 생각이다. 우리는 Rust로 저수준 코드를 쉽게 쓰게 하고 싶다. 그 코드는 때로 포인터에게 못할 짓을 해야 한다. 하지만 그런 결정을 전체 생태계가 떠안아, 모든 곳의 원시 포인터 로드를 지우기 어렵게 만드는 비용을 치르게 하고 싶지는 않다! 대안은 무엇인가?
내 생각에, 대안은 원 프로그램(이를 Rust로 옮긴 버전)이 UB라고 보는 것이다. 사람들이 포인터를 정수로 변환하려는 이유는 대체로 두 가지다:
첫 번째 종류의 코드는 그냥 as 캐스트를 쓰면 된다. 린트 같은 수단으로 이런 코드를 찾아 캐스트로 바꾸도록 유도해야 한다.6 필요하다면 캐스트 규칙을 손봐 체이닝 필요를 줄이거나, expose_addr 같은 보조 메서드를 추가할 수 있다.
두 번째 종류의 코드는 정수를 쓰지 말아야 한다! 임의 데이터를 정수 타입에 넣는 것은 패딩 문제 때문에 이미 수상하다(LLVM이 제공하는 noundef 주석을 활용하려면, 패딩이 있는 데이터를 정수 타입으로 변환하는 것을 금지해야 한다). 임의 데이터를 담을 올바른 타입은 MaybeUninit이다. 예컨대 최대 1KiB의 임의 데이터를 담으려면 [MaybeUninit<u8>; 1024]가 적절하다. MaybeUninit은 포인터의 출처도 문제없이 담을 수 있다.
따라서 Rust에서는 포인터↔정수 변환(transmutation)을 권장하지 않거나, 더 나아가 금지하는 방향으로 가야 한다고 본다. 즉, 포인터를 정수로 바꾸는 유일하게 합법적인 방법은 캐스트여야 하며, 캐스트의 의미론은 앞서 다룬 것처럼 정리되어 있다. 최근 첫 조심스런 걸음이 있었다. mem::transmute 문서가 이제 포인터를 정수로 바꾸기 위해 이 함수를 쓰는 것에 대해 경고한다.
굵게(업데이트, 2022-09-14): 많은 추가 논의 끝에, 현재 UCG WG가 추구하는 모델은 포인터→정수 변환이 허용되지만, 출처를 노출하지 않고 단지 제거(strip)한다고 말한다. 즉, 캐스트를 변환으로 바꾼 프로그램은 UB다. 최종적으로 역참조하는 ptr이 잘못된 출처를 갖기 때문이다. 다만 변환 자체는 UB가 아니다. 본질적으로, 포인터→정수 변환은 addr 메서드와 동등하며, 그 모든 주의사항을 공유한다. 특히 포인터를 정수로 변환한 뒤 다시 포인터로 되돌리는 것은 addr 호출 뒤 ptr::invalid를 호출하는 것과 같다. 이는 손실적인(round-trip이 출처를 잃는) 왕복이다. 결과 포인터는 역참조가 유효하지 않다. 일반 정수→포인터 캐스트(또는 from_exposed_addr)를 통해 포인터로 되돌리더라도, 원래 출처가 노출되지 않았을 수 있으므로 여전히 손실적이다. 변환 자체를 UB로 선언하는 것과 비교해, 이 모델은 컴파일러 최적화(예: 불필요한 store-load 왕복 제거)에 도움이 되는 좋은 성질을 갖는다. 굵게(/업데이트)
복잡해 보이지만, 사실 나는 그 어느 때보다도 Rust에서 정밀한 메모리 모델과 강력한 최적화를 동시에 가질 수 있으리라는 희망을 갖게 되었다! 이 접근의 세 기둥은 다음과 같다:
이 셋을 합치면, Strict Provenance를 따르고, 출처를 “노출”하거나 정수→포인터 캐스트를 사용하지 않는 “착한” 코드를 완벽하게 최적화하는 한편, 포인터↔정수 왕복을 쓰는 코드도 안전히 지원할 수 있다. 가장 단순한 접근으로, 컴파일러는 포인터→정수 및 정수→포인터 캐스트를 불투명한 외부 함수 호출로 취급할 수 있다. 컴파일러가 포인터↔정수 왕복의 존재를 완전히 무시하더라도, 그런 코드를 여전히 올바르게 지원하게 된다!
이 접근은 컴파일러와 최적화기뿐 아니라 다른 이들에게도 이롭다. 내 큰 과업 중 하나는 Rust 에일리어싱 규칙의 정밀한 모델을 제공하는 것이다. 그리고 그 일이 믿을 수 없을 정도로 쉬워졌다. 나는 Stacked Borrows를 만들면서 포인터↔정수 왕복에 대해 엄청나게 신경 썼다. “untagged pointer” 혼란이 전부 그 때문이다.
이 새로운 세계에서는 Rust 메모리 모델을 설계할 때 포인터↔정수 왕복을 완전히 무시할 수 있다. 설계를 마친 뒤, 포인터↔정수 왕복 지원은 다음과 같이 덧붙이면 된다:
이 “추측”은 알고리즘으로 묘사할 필요가 없다. 천사적 비결정성(angelic non-determinism)이라는 마법을 통해, “추측은 프로그램 작성자에게 최대한 유리하게” 이뤄진다고 말할 수 있다. 즉, 어떤 출처 선택이든 프로그램을 잘 동작하게 만들 수 있다면, 그 출처가 새 포인터의 출처가 된다. 모든 선택이 UB로 이어지는 경우에만 프로그램이 잘못되었다고 본다. 속임수처럼 들릴 수 있지만, 정식 명세에서 허용되는 기술이다.
또, 여기서 복잡하게 만드는 것은 오직 정수→포인터 캐스트뿐임을 주목하라. 그것만 아니면 “노출” 장치 자체가 필요 없다. 포인터→정수 캐스트만으로는 문제가 없다! 그래서 addr+with_addr API는 메모리 모델 관점에서 아주 훌륭하다.7
이 접근에는 Miri 같은 도구가 명세에 정확히 일치하도록 구현하기 거의 불가능해진다는 단점이 있다. Miri가 이런 “추측”을 정확히 구현할 수는 없기 때문이다. 하지만 Strict Provenance 연산을 사용하는 코드를 제대로 검사할 수는 있으니, 이것이 프로그래머가 정수→포인터 캐스트를 버리고 Strict Provenance로 옮기게 만드는 또 하나의 동기(정밀한 명세와 더 나은 최적화 가능성 외에)가 되기를 바란다. 어쩌면, Miri가 이 모델을 꽤 가깝게 검사하게 만드는 영리한 방법이 있을지도 모른다. 완벽할 필요는 없다. 유용하면 된다.
내가 특히 좋아하는 점은 포인터↔정수 왕복을 완전히 국소적(local) 문제로 만든다는 것이다. Stacked Borrows의 “untagged pointer” 접근에서는 모든 메모리 연산이 그런 포인터를 어떻게 처리하는지 정의해야 했다. 복잡성이 전역적으로 증가한다. Strict Provenance 코드에 대해서조차 프로그램의 다른 부분에서 “untagged” 포인터가 있을 수 있다는 사실을 늘 염두에 둬야 했다. 반면, “최대한 당신에게 유리하게 추측”하는 접근은 완전히 국소적이다. 노출 포인터→정수 또는 정수→포인터 캐스트를 문법적으로 포함하지 않는 코드는, 그런 캐스트가 존재한다는 사실을 잊어버려도 된다. 이는 unsafe 코드를 생각하는 프로그래머와 최적화를 설계하는 컴파일러 작성자 모두에게 해당한다. 합성 가능성의 극치다!
지금까지는 Rust에 대한 내 비전을 이야기했다. 다른 언어는 어떨까? 알다시피, C는 PNVI-ae-udi를 C 메모리 모델 해석의 공식 권고로 만들려 하고 있다. 다양한 이해관계자와 방대한 레거시 코드가 있는 C 세계에서 이는 놀라운 성취다! 내가 위에서 말한 것들과 어떻게 비교될까?
먼저, 이름의 ae는 address-exposed를 뜻한다. 방금 설명한 메커니즘과 정확히 같다! 실제로 나는 그들의 용어를 차용했다. 이 점에서 Rust와 C는 같은 방향으로 나아가고 있어 아주 반갑다. (이제 LLVM만 같은 방향으로 가면 된다.) 다만 PNVI-ae-udi는 C의 restrict를 고려하지 않기에, 러스트의 메모리 모델보다 쉬운 문제를 푼다고 볼 수도 있다. Rust는 에일리어싱 제약에 관한 흥미로운 질문들을 피할 수 없기 때문이다. 하지만 restrict를 더 정밀히 다룬 C 모델이 나온다 해도, address-exposed 모델에서 물러나지는 않을 것이라고 본다. 오히려, 방금 논의했듯 이 모델은 포인터↔정수 왕복에 대해 생각하지 않고도 restrict를 명세하게 해준다.
udi는 user disambiguation을 의미하는데, C에서 정수→포인터 캐스트가 어떤 출처를 취할지 “추측”하는 메커니즘이다. 자세한 내용은 복잡하지만, 끝에서 끝까지의 효과는 내가 말한 “최대한 유리한 추측” 모델과 사실상 같다! 이 점에서도, Rust에 대한 내 비전은 C가 가는 방향과 잘 맞는다. (C에서는 wrapping_offset이 없고 restrict도 모델에 포함되지 않기 때문에, 가능한 추측의 집합이 훨씬 더 제한적이다. 그래서 그들은 실제로 추측 알고리즘을 제시할 수 있다. “천사적 비결정성” 같은 무시무시한 용어를 쓸 필요가 없지만, 결과는 동일하다. 그리고 그 결과가 천사적 비결정성과 동등하다는 사실이 이 접근이 타당한 의미론임을 정당화해 준다. 이를 구체 알고리즘으로 제시하는 것은 문체상의 선택일 뿐이다.) 이 해석을 열어 준 Michael Sammler에게 감사를 보낸다.
남은 질문은 포인터↔정수 변환을 어떻게 다루느냐인데, 여기서 길이 갈라진다. PNVI-ae-udi는 정수 타입의 union 필드에서의 로드가(포인터가 저장되어 있었다면) 그 포인터의 출처를 노출한다고 명시한다. 따라서 transmute_union 예시는 허용되며, “죽은” union 로드를 제거하는 최적화는 일반적으로 허용되지 않는다. transmute_memcpy도 마찬가지다. 반환값 ret를 uintptr_t 타입으로 읽으면, 그 포인터의 출처가 암묵적으로 다시 노출된다.
이 선택이 C에서는 타당하다고 생각하는 여러 이유가 있다. 하지만 Rust에는 적용되지 않는다:
반면, 이 선택은 최적화 손실 면에서 큰 비용을 부를 수 있다고 우려한다. 위 예시가 보여주듯, 컴파일러는 출처를 노출시킬 수 있는 어떤 연산이라도 제거할 때 매우 조심해야 한다. 뒤에 오는 정수→포인터 캐스트가 이를 의지할 수 있기 때문이다. (물론 GCC나 LLVM에 실제로 구현되기 전까지 실제 비용을 알기 어렵다.) 이런 이유로, Rust가 여기서는 다른 선택을 하는 것이 합리적이라고 본다.
긴 글이었지만 읽을 만했기를 바란다. :) 요약하자면, Rust에서의 구체적인 행동 강령은 다음과 같다:
이 작업은 크고 많은 노력이 필요하다! 하지만 이 길의 끝에는, 일관되고 잘 정의된 메모리 모델을 가지면서도, 포인터에게 못할 짓을 하더라도(추론이나 최적화 측면의) 비용을 착한 코드에 전가하지 않는 언어가 있다. 함께 그 미래로 나아가자. :)
같은 입력 정수로 두 캐스트가 서로 다른 포인터를 낼 수 있다는 의미에서, 정수→포인터 캐스트가 “불순”하다는 예를 약속했다:
static int uwu(int *restrict x, int *restrict y) {
*x = 0;
*y = 0;
uintptr_t xaddr = (uintptr_t)x;
int *y2 = y-1;
uintptr_t y2addr = (uintptr_t)y2;
assert(xaddr == y2addr);
int *xcopy = (int*)xaddr;
int *y2copy = (int*)y2addr;
int *ycopy = y2copy+1;
return *xcopy + *ycopy;
}
int main() {
int i[2] = {0, 0};
uwu(&i[0], &i[1]);
}
포인터↔정수 왕복을 무시하면, x와 xcopy는 i[0]을, y와 ycopy는 i[1]을 접근하므로 문제 없어 보인다. ycopy는 (y-1)+1로 계산되며, 이 부분에 이견은 없으리라. 그다음 포인터↔정수 왕복을 덧붙였을 뿐이다.
하지만 (int*)xaddr와 (int*)y2addr는 같은 정수를 입력으로 받는다! 컴파일러가 정수→포인터 캐스트를 순수하고 결정적인 연산으로 취급한다면, (int*)y2addr를 xcopy로 바꿀 수 있다. 하지만 그러면 xcopy와 ycopy는 같은 출처를 갖게 된다! 이 프로그램에는 i[0]과 i[1]을 모두 접근할 수 있는 출처가 존재하지 않는다. 따라서 캐스트가 본 적 없는 새로운 출처를 합성해야 하거나, 정수→포인터 캐스트에 공통 부분식 제거를 적용하는 것이 틀린다.
내 입장은 캐스트가 새로운 출처를 합성해서는 안 된다는 것이다. 그러면 포인터↔정수 왕복을 국소적 문제로 만든 장점이 사라진다. 왕복이 새로운, 전례 없는 출처를 만들어 낸다면, 메모리 모델의 나머지 부분도 그런 출처를 어떻게 다룰지 정의해야 한다. 우리는 이미 포인터→정수 캐스트를 부작용 있는 연산으로 다루지 않을 수 없다. 정수→포인터 캐스트에도 같은 태도를 취하자. 그러면 에일리어싱 규칙이 무엇이든 간에, 포인터↔정수 왕복이 있어도 잘 동작할 것이다.
그렇다 해도, 이 모델에서 정수→포인터 캐스트는 결과를 사용하지 않으면 제거해도 되는 의미에서 부작용은 없다. 따라서 어떤 상황에서는 정수→포인터 캐스트를 암묵적으로 수행하는 것도 가능하다. 예를 들어 출처가 없는 정수값이 포인터 연산(정수→포인터 변환 때문에)에서 사용될 때 말이다. 이는 로드 결합(load fusion) 같은 최적화를 깨지만(두 로드를 하나로 합치려면 매번 같은 출처를 선택했다고 가정해야 한다), 대부분의 최적화(특히 죽은 코드 제거)는 영향받지 않는다.
위에서는 Rust 비전과 C의 방향이 어떻게 맞물리는지 논했다. 그렇다면 LLVM 설계 공간에서는 무엇을 의미할까? LLVM에서의(잠재적) 미스컴파일을 고치고, C와/또는 Rust에 대해 이 아이디어들과 양립하려면 어떤 변화가 필요할까? 내가 아는 범위의 열린 과제는 다음과 같다:
지금까지는 LLVM이 Rust와 C의 백엔드로서 공통적으로 해당하므로, 마땅한 대안이 없다. 좋은 점은, inttoptr/ptrtoint를 이런 방식으로 다루면, 최근 LLVM의 “Full Restrict Support”가 포인터↔정수 왕복도 공짜로 다룰 수 있게 된다는 것이다!
with_addr/copy_alloc_id를 LLVM에 추가하는 것은 꼭 필요하지는 않다. getelementptr(inbounds 없이)로 구현할 수 있기 때문이다. 다만 최적화가 그 패턴을 항상 잘 다루는 것 같지는 않아서, 프리미티브 연산으로 넣는 것이 좋을 수도 있다.
더 미묘한 부분은 포인터↔정수 변환이다. LLVM이 ==로 같은 정수의 대체를 계속하고 싶다면(그럴 것이라 강하게 가정한다), 무엇인가를 포기해야 한다. 변환으로 바꾼 첫 번째 예시는 미스컴파일을 보이기 때문이다. 포인터 값을 i64로 로드하는 경우(예: transmute_union이 내는 LLVM IR, 또는 Rust에서의 포인터 기반 변환)에는 어떤 선택지가 있을까? 지금까지 본 옵션은 다음과 같다(물론 더 있을 수 있다):
첫 번째 옵션을 제외하면, 모두 내가 변환으로 바꾼 예시는 UB라고 말한다. 이로써 그 예시에서 드러난 최적화 문제를 피할 수 있다. Rust 비전에서는 괜찮지만, PNVI-ae-udi를 따르는 C에는 문제다. 첫 번째 옵션만 양립 가능하지만, 이는 결과를 쓰지 않는 로드를 완전히 제거하는 일조차 쉽지 않게 만든다! Rust에서는 그 비용을 피할 수 있기를 바란다.
또 다른 흥미로운 차이는 의미론이 출처에 대해 “단조(monotone)”한지 여부다. 값의 출처를 “증가”(더 많은 메모리를 접근 가능하게)시키는 것이 합법적 프로그램 변환인가? 마지막 두 옵션에서는 아니다. 출처가 없던 값에 출처를 추가하면 UB가 생길 수 있기 때문이다. 첫 두 옵션은 이런 의미에서 “단조”인데, 이는 좋은 성질로 보인다. (이는 의미론이 undef와 poison에 대해 “단조”한 것과 비슷하다. 둘을 고정 값으로 바꾸는 것은 합법적 프로그램 변환이다. undef/poison에서는 이 성질이 매우 중요하지만, 출처에서는 일종의 건강 검진 정도로 여겨진다.)
이 중 마지막을 제외하고는, 임의 데이터를(출처가 있는 포인터 포함) 출처를 잃지 않고 로드하기 위해 byte 타입 같은 것이 LLVM에 필요할 것이다.
정수→포인터 변환(정수로 된 값을 포인터 타입으로 로드)에서도 비슷한 질문이 생긴다:
LLVM은 일반적으로 UB를 최대한 미루는 쪽을 택하곤 하므로, 두 질문 모두에 대해 두 번째 옵션이 가장 “LLVM다움”처럼 느껴진다. 하지만 결국 이는 LLVM 커뮤니티가 해야 할 어려운 선택이다. 나는 설계 공간을 구조화하고 특정 결정의 필연적 귀결을 지적하는 방식으로 이러한 절충을 평가하는 데 도움을 줄 수 있다. 하지만 직접 LLVM에 기여해 본 적은 없다. 또한 어떤 최적화가 중요한지, 각 옵션의 성능 영향이 어떨지에 대한 전문성이 부족하므로, 이 논의에 그 배경을 가진 사람들이 참여하기를 바란다. 생태계 전체를 위해, 무엇보다 LLVM이 어떤 선택이든 해 주어 우리가 현재의 교착 상태에서 벗어나기를 바란다.
restrict의 정확한 의미론은 미묘하며, 형식적 정의를 알지 못한다. (안타깝게도 C 표준의 정의는, 이 예시에 적용해 보면 잘 맞지 않는다.) 내 이해는 다음과 같다: restrict는 이 포인터와, 이 포인터에서 파생된 모든 포인터가, 그 집합 밖의 포인터가 수행하는 접근과 충돌하지 않는 방식으로만 메모리 접근에 사용될 것임을 약속한다. “충돌”은 두 메모리 접근이 겹치고 그중 하나 이상이 쓰기일 때 발생한다. 이 약속은 인자 타입에 restrict가 나타날 때는 함수 호출 기간으로 범위가 한정된다. 다른 상황에서의 범위가 무엇인지는 잘 모르겠다.↩
이는 사실 흔한 문제다. consume 메모리 오더가 프로그래밍 언어에서 사실상 명세 불가능한 이유이기도 하다! ISA는 어떤 명령이 어떤 이전 명령에 “의존”하는지에 대해 매우 명시적인 규칙을 갖는 경우가 많지만, a + (b-a)를 b로 바꿔 의존성을 제거할 수 있는 언어에서는 그 notion을 합리화하기 어렵다.↩
앞 각주에서 말했듯, 이것이 restrict가 실제로 동작하는 방식은 아니다. 이 포인터들이 접근할 수 있는 위치의 정확한 집합은 동적으로 결정되며, 유일한 제약은 같은 위치를 동시에 접근할 수 없다는 것이다(둘 다 로드만 하는 경우 제외). 다만 이 예시는 이런 미묘함이 결과를 바꾸지 않도록 신중히 골랐다.↩
with_addr는 아주 최근에 Rust 표준 라이브러리에 불안정 상태로 추가되었다. 이런 연산은 Rust 커뮤니티에서 꽤 오래전부터 논의되어 왔고, copy_alloc_id라는 이름으로 학술 논문에도 실렸다. 언젠가 C 표준에도 들어갈지도 모른다. :)↩
변호사들이 말하길, 이는 모두 잠정적이며 addr과 다른 Strict Provenance 연산의 명세는 안정화될 때까지 바뀔 수 있다.↩
정말 절박하다면, mem::transmute::<*const T, usize>(*mut T도 마찬가지)을 구판 에디션에서만 특별 취급해, “노출” 부작용이 있다고 선언하는 방식도 고려할 수 있다. 앞으로 나아가려면 때로는 보기 싫은 일도 해야 한다. union이나 원시 포인터 기반 변환에는 적용되지 않는다.↩
더 정확히 말하면, 문제는 포인터↔정수 왕복의 일부인 정수→포인터 캐스트다. 특정 플랫폼에서 어떤 고정 메모리 영역이 있다는 이유로 정수 상수를 포인터로 캐스팅하는 정도라면, 그리고 그 메모리가 Rust 언어가 인지하는 전역/스택/힙 할당 범위 밖에 있다면, 우리는 여전히 친구다.↩