Pointer Aliasing은 서로 다른 포인터가 같은 메모리 영역을 가리킬 가능성이 있는 상황을 의미한다.
- alias 가능성이 있으면 한 포인터를 통한 쓰기가 다른 포인터를 통한 읽기에 영향을 줄 수 있다.
- 그래서 컴파일러는 프로그램의 의미를 보존하기 위해 더 보수적으로 최적화해야 한다.
- 지역 변수를 쓰거나 restrict를 붙이면 이 가능성을 배제할 수 있다.
Pointer Aliasing이란?
int x = 10;
int *p = &x;
int *q = &x;위 코드에서 p와 q는 서로 다른 포인터지만 둘 다 x를 가리킨다.
p ───┐
↓
[ x ]
↑
q ───┘
따라서 p를 통해 메모리를 수정하면 q를 통해 읽는 값도 변경된다.
*p = 20;
printf("%d\n", *q); // 20이러한 특성은 프로그램을 작성할 때는 자연스럽지만, 컴파일러 최적화 관점에서는 중요한 제약이 될 수 있다.
컴파일러는 두 포인터가 독립이라고 함부로 가정할 수 없다
void sum(int *result, int *a, int n)
{
for (int i = 0; i < n; i++)
*result += a[i];
}사람이 코드를 보면 단순하게 생각할 수 있다. a의 원소들을 읽어서 *result에 계속 더하면 된다. 하지만 컴파일러는 result와 a가 서로 완전히 독립된 메모리를 가리킨다고 함부로 가정할 수 없다.
예를 들어 다음과 같은 호출도 가능하다.
sum(&a[3], a, 100);이 경우 result는 a 배열 내부의 a[3]을 가리킨다.
result
│
▼
a → [ ][ ][ ][ a[3] ][ ][ ] ...
즉 *result를 변경하는 것은 동시에 a[3]을 변경하는 것이기도 하다.
레지스터에 올려두는 최적화가 왜 안 되는가
어차피 *result에 계속 더하는 것이라면 처음에 한 번 읽어서 레지스터에 보관하면 되지 않을까? 예를 들어 컴파일러가 다음처럼 바꿀 수 있을 것처럼 보인다.
int tmp = *result;
for (int i = 0; i < n; i++)
tmp += a[i];
*result = tmp;이렇게 하면 루프마다 *result를 메모리에서 읽고 쓸 필요가 없다. 하지만 원래 프로그램과 항상 같은 동작을 한다는 보장이 없다.
result == &a[3]인 상황에서, 원래 코드는 매 반복마다 *result가 실제 메모리에 반영된다. 따라서 앞선 반복에서 *result에 저장한 값이 이후의 a[i]를 읽는 결과에 영향을 줄 수 있다.
*result에 쓰기
│
▼
a[3] 변경
│
▼
나중에 a[3]을 읽는 결과도 변경
반면 바꾼 코드는 루프가 끝날 때까지 *result의 변경 사항이 실제 a[3]에 반영되지 않는다. 그러면 i == 3일 때 읽는 a[3]의 값이 원래 프로그램과 달라질 수 있다.
컴파일러가 걱정하는 핵심
핵심은 *result로의 메모리 쓰기가 a[i]가 읽는 메모리에 영향을 줄 수 있는지 여부다. 만약 컴파일러가 두 영역이 다르다는 사실을 확실하게 알고 있다면 두 메모리 접근은 독립적이고, 다음과 같은 최적화를 훨씬 자유롭게 수행할 수 있다.
이유
- 여기서 최적화란 순서를 바꾸거나 메모리 접근을 생략하는 것인데
- 안전하려면 바꿔도 결과가 같다는 것을 보장되어야 함
- alias 가능성은 이 보장을 깨는 것
메모리 접근 감소
레지스터에 값 유지
명령어 재배치
루프 최적화
SIMD / Vectorization
지역 변수는 메모리 접근의 의미를 더 명확하게 만든다
프로그래머가 처음부터 다음과 같이 작성했다면 프로그램의 의미 자체가 달라진다.
void sum(int *result, int *a, int n)
{
int tmp = *result;
for (int i = 0; i < n; i++)
tmp += a[i];
*result = tmp;
}루프가 실행되는 동안에는 *result를 변경하지 않고, 계산 결과를 마지막에 한 번만 저장한다고 명시적으로 작성한 것이다.
컴파일러는 tmp가 함수 내부의 지역 변수라는 사실을 알고 있다. 그리고 tmp의 주소가 외부로 노출되지 않았다면 일반적으로 다른 포인터를 통한 메모리 접근이 tmp를 변경한다고 걱정할 필요가 없다.
Memory CPU Register
*result ── load ────────────→ tmp
│
a[0] ───────────────────────→ +
a[1] ───────────────────────→ +
a[2] ───────────────────────→ +
... │
▼
*result ←──── store ───────── tmp
즉, 지역 변수의 사용은 단순히 문법적인 차이가 아니라 프로그램이 요구하는 메모리 접근의 의미 자체를 더 명확하게 만들어 줄 수 있다.
restrict: aliasing이 없다는 정보를 컴파일러에게 제공한다
C에서는 포인터 사이의 aliasing이 없다는 정보를 프로그래머가 컴파일러에게 제공하기 위해 restrict라는 키워드를 제공한다.
void sum(int * restrict result,
int * restrict a,
int n)
{
for (int i = 0; i < n; i++)
*result += a[i];
}restrict를 사용하면 프로그래머는 해당 포인터들을 통한 객체 접근이 restrict의 규칙에 맞게 서로 겹치지 않는다는 것을 보장해야 한다. 따라서 컴파일러는 alias 가능성을 배제할 수 있는 경우 더 공격적인 최적화를 수행할 수 있다.