85.16 restrict 포인터

restrict 포인터는 메모리 접근에 관련된 최적화 기능입니다(C99 표준). 예를 들어 다음과 같이 포인터를 역참조하여 값을 증가시키는 코드가 있습니다.

increase.c

void increase(int *a, int *b, int *x) 
{
    *a += *x;    // x를 역참조하여 가져온 값만큼 *a를 역참조하여 값을 증가시킴
    *b += *x;    // x를 역참조하여 가져온 값만큼 *b를 역참조하여 값을 증가시킴
}

이 코드를 GCC에서 컴파일한 뒤 어셈블리를 살펴보면 보겠습니다.

$ gcc -g -std=c99 -O3 -c increase.c
$ objdump -S increase.o
void increase(int *a, int *b, int *x)
{
    *a += *x;
   0:   8b 02                   mov    (%rdx),%eax    // x를 역참조하여 가져온 값을 eax에 저장
   2:   01 07                   add    %eax,(%rdi)    // eax의 값만큼 a를 역참조하여 값을 증가시킴
    *b += *x;
   4:   8b 02                   mov    (%rdx),%eax    // x를 역참조하여 가져온 값을 eax에 저장
   6:   01 06                   add    %eax,(%rsi)    // eax의 값만큼 b를 역참조하여 값을 증가시킴
   8:   c3                      retq

%rdx는 세 번째 매개변수인데 (%rdx)처럼 괄호로 묶으면 %rdx에 저장된 메모리 주소로 접근한다는 뜻입니다. 그리고 mov (%rdx),%eaxmov 명령어로 (%rdx)의 값을 %eax로 복사한다는 뜻입니다. 즉, C 언어의 역참조 *x를 어셈블리에서는 저렇게 표현합니다.

그다음 줄의 add %eax,(%rdi)add 명령어로 (%rdi)에 %eax의 값만큼 더한다는 뜻입니다(%rdi는 첫 번째 매개변수고 여기서는 a). 즉, *a += %eax가 되죠.

마찬가지로 *b += *x;mov (%rdx),%eaxadd %eax,(%rsi)로 처리합니다(%rsi는 두 번째 매개변수고 여기서는 b).

어셈블리로 되어 있어서 좀 복잡하지만 핵심은 간단합니다. 다음과 같이 매개변수에 들어있는 메모리 주소 a, b, x가 같은 공간일 수도 있기 때문에 컴파일러는 매번 mov (%rdx),%eax와 같이 메모리에 접근하여 값을 가져옵니다. 왜냐하면 같은 메모리 공간일 경우 이전 명령어의 결과가 확실히 적용되고 난 다음에 값을 가져와야 하기 때문입니다.

int a = 1;
increase(&a, &a, &a);    // &a, &a, &a는 같은 공간을 가리킴
// a, b, x는 같은 공간을 가리킴
void increase(int *a, int *b, int *x) 
{
    *a += *x;    // 같은 공간의 값을 꺼내서 증가시킴
    *b += *x;    // x는 a와 같은 공간이므로 
                 // 이전의 연산 결과가 확실히 적용되고 난 다음에 값을 가져와야 함
}

이처럼 같은 메모리 공간을 가리키는 포인터를 에일리어스(alias)라고 부르는데, 메모리가 같은 공간에 접근하는지 확인하여 처리하고, 잘못 처리했을 경우 되돌리는 작업은 상당히 복잡하고 비용이 많이 듭니다(성능이 떨어짐). 여기서는 값을 더하는 단순한 작업이지만 실제로는 어셈블리가 매우 복잡해집니다. 그래서 포인터 에일리어스가 아닌 상황에서는 좀 더 최적화를 하기 위해 restrict 포인터라는 기능이 나왔습니다(포인터 에일리어스 상황에서 최적화를 하는 기능이 아님).

이제 restrict 포인터를 사용해보겠습니다.

  • Visual Studio 2022: *__restrict
  • GCC, Clang: *restrict, *__restrict, *__restrict__
void increase(int *restrict a, int *restrict b, int *restrict x)
{
    *a += *x;
    *b += *x;
}

int *restrict a와 같이 *에 restrict를 붙이면 restrict 포인터가 됩니다. 이 코드를 GCC에서 컴파일한 뒤 어셈블리를 살펴봅니다.

$ gcc -g -std=c99 -O3 -c increase.c
$ objdump -S increase.o
void increase(int *restrict a, int *restrict b, int *restrict x)
{
    *a += *x;
   0:   8b 02                   mov    (%rdx),%eax
   2:   01 07                   add    %eax,(%rdi)
    *b += *x;
   4:   01 06                   add    %eax,(%rsi)
   6:   c3                      retq

잘 보면 *b += *x;의 어셈블리 명령 1줄이 줄어든 것을 알 수 있습니다. 컴파일러가 속도 향상을 위해서 최적화를 한 것이죠.

restrict 포인터는 각 포인터가 서로 다른 메모리 공간을 가리키고 있고, 다른 곳에서 접근하지 않으니 컴파일러가 최적화를 하라는 뜻입니다. 여기서는 프로그래머가 알려준 대로 a, b, x가 서로 다른 메모리 공간을 가리키고 있다고 보고 x를 역참조하여 값을 가져오는 mov (%rdx),%eax 명령을 한 번 줄이게 됩니다. 즉, 다른 메모리 공간이므로 이전 명령어의 결과가 확실히 적용되는지는 알 필요 없이 값을 그대로 사용합니다.

int a = 1;
int b = 1;
int x = 1;
increase(&a, &b, &x);    // &a, &b, &x는 다른 공간을 가리킴
// a, b, x는 다른 공간을 가리킴
void increase(int *restrict a, int *restrict b, int *restrict x) 
{
    *a += *x;     // 다른 공간의 값을 가져와서 증가시킴
    *b += *x;     // x는 a와 다른 공간이므로 
                  // 이전의 연산 결과가 확실히 적용되는지는 알 필요 없이 값을 그대로 사용
}

restrict 포인터는 컴파일러에게 최적화를 하라고 알려주는 키워드입니다(메모리가 다른 공간을 가리킨다고 보장한다거나 메모리 공간을 검사하는 용도가 아닙니다). 만약 같은 메모리 공간을 가리키는 포인터에 restrict를 붙여서 컴파일하게 되면 최적화 때문에 잘못된 결과가 나올 수 있으니 주의해야 합니다. 따라서 포인터가 가리키는 메모리 공간을 프로그래머가 직접 확인한 뒤 다른 공간을 가리킬 때만 restrict를 사용해야 합니다.

내용이 다소 어렵지만 C 언어 표준 라이브러리에서는 restrict 포인터를 사용하여 최적화를 하고 있습니다.

/usr/include/string.h

extern void *memcpy (void *__restrict __dest, const void *__restrict __src,
                     size_t __n) __THROW __nonnull ((1, 2));

extern void *memmove (void *__dest, const void *__src, size_t __n)
                      __THROW __nonnull ((1, 2));

memcpyrestrict가 붙어있고 memmoverestrict가 없습니다. 여기서 memmove는 내부적으로 같은 메모리 공간을 가리키는지, 메모리가 겹치는지 모두 확인을 하기 때문에 성능이 떨어집니다. 만약 두 메모리 공간이 다른 공간을 가리키고 겹치지 않는다면 최적화된 memcpy를 사용하여 성능을 향상시킬 수 있습니다.

실제로 restrict 포인터를 사용하여 함수를 구현할 일은 많지 않을 것입니다. C 언어 표준 함수나 다른 사람이 만든 함수에 restrict가 붙어있다면 함수를 어떻게 사용해야 하는지만 이해하면 됩니다.