Search

C99 restrict 키워드와 memcpy & memmove 함수

Created
2020/12/23
tag
C
씹어먹는 C 언어
restrict
memcpy
memmove

1. restrict 키워드

우선 restrict라고 하는 키워드가 무엇인지부터 알아보자. GeeksforGeeks에 의하면 restrict라고 하는 키워드는 C99 이후의 버전에서 사용이 가능하며, 프로그래머가 컴파일러에게 restrict 기능을 수행하여 메모리 접근에 대한 최적화 해달라고 알려주는 역할을 담당한다고 한다.
프로그래밍에서 restrict 키워드와 같이 메모리 접근에 대한 최적화가 필요한 이유는 Alias Rule과 관련이 있다. Alias Rule이란 이름 그대로 별칭 지정에 대한 규칙이다. GeeksforGeeks에서 Aliasing이라함은 메모리 상에서 같은 곳을 나타내는 공간을 서로 다른 이름으로 접근할 수 있도록 하는 것을 말한다. 이 때 변수의 선언은 무조건 메모리 상에서 다른 곳을 나타내므로 Aliasing은 포인터를 이용하여 동일한 위치를 나타내는 것을 의미하는 것으로 유추할 수 있다. 따라서 Alias Rule이라고 하는 것은 이런 포인터 파라미터들에 대해서 생기는 컴파일링 규칙을 의미하게 된다.
Alias Rule과 메모리 최적화의 연관성을 살펴보자. 포인터를 역참조하여 연산을 수행하기 전에 대체적으로 먼저 수행해야하는 과정이 있다. 2개 이상의 포인터들 중에서 같은 메모리 공간을 가리키는 포인터들이 존재한다면, 데이터의 무결성을 확인하기 위해서 사용하려는 포인터가 현재 사용되고 있는 포인터와 같은 공간을 가리키는지 명령어 Fetch 이전에 확인을 해주어야 한다. 만일 포인터들이 모두 다른 공간을 가리키는 것이 명확하다면, 위와 같은 연산 과정을 거치는 것은 꽤나 큰 효율 저하로 이어질 수 있다. 
잠깐 옆길로 새서... CPU의 동작은 일련의 과정 (Fetch - Decode - Execute - Store)으로 처리가 되는데, 이 때 CPU는 각 명령어 처리에 대해서 효율을 높이기 위해 파이프라이닝 (Pipelining)을 하게 된다. 이런 파이프라이닝 기법과 마찬가지로 "포인터들이 모두 다른 공간을 가리키는 것이 명확한 상황"에서 사용하려는 포인터가 현재 사용되고 있는 포인터와 같은 공간을 가리키는지 확인하는 과정을 생략하는 것이 restrict 키워드의 핵심이다.
우선 restrict라는 키워드는 restrict로 표기된 포인터들에 대해서는 다른 restrict 변수들과 같은 공간을 가리키지 않는다고 컴파일러에게 알려주는 것인데, 다음의 코드를 통하여 실제로 restrict가 어떻게 동작을 하는지 확인해보자. (increase 함수restrict 키워드가 없는 채로 작성이 되었고, increase_r 함수restrict 키워드가 붙은 채로 작성이 된 상태이다.)
#include <stdio.h> void increase(int *a, int *b, int *x) { *a += *x; *b += *x; } void increase_r(int *restrict a, int *restrict b, int *restrict x) { *a += *x; *b += *x; } int main(void) { int a; a = 1; increase(&a, &a, &a); printf("%d\n", a); a = 1; increase_r(&a, &a, &a); printf("%d\n", a); return (0); }
C

진행에 사용된 명령어

gco -g -c -std=c99 -O3 test.c objdump -S test
gco 라는 명령어는 alias gco="gcc -Wall -Werror -Wextra -o test" 로 처리 되어 있다. objdump를 통해 어셈블리 코드로 확인할 것인데, 각 어셈블리 코드에 매칭되는 C 언어 구문 확인을 위해 objdump-S 옵션을 주고 gco에도 -g 옵션을 준다.
C99로 실험을 진행했기 때문에 std=c99 옵션을 주었고, restrict는 컴파일러에게 최적화를 하라고 알려주는 키워드 이므로 컴파일러가 실행파일을 만들어 낼 때 최적화를 수행해야 올바른 결과를 확인할 수 있다. (실제로 최적화 옵션을 주지 않고 실행하는 경우 원하는 결과가 나오지 않는다.) 따라서 최적화 레벨을 조정하는 -O 옵션을 주었다. (-O3이라고 하는 것은 가장 높은 레벨의 최적화를 의미한다.)

increase 함수

increase 함수부터 어떤 과정으로 명령어들이 수행되는지 어셈블리 코드를 예측해보자.
*a += *x;
C
위 구문을 처리하기 위해선 x의 역참조 값을 메모리로 부터 읽어온 후 해당 값을 레지스터에 저장한 뒤, a라고 하는 공간에 있는 값에 레지스터에 저장한 값을 더하면 될 것이다. 이 2가지 과정을 어셈블리 코드로 작성하면 다음과 같다.
MOV (%rdx), %eax ADD %eax, (%rdi)
WebAssembly
그렇다면 아래 인용구를 통해 그 다음 라인은 어떻게 수행될지 예상해보자.
데이터의 무결성을 확인하기 위해서 사용하려는 포인터가 현재 사용되고 있는 포인터와 같은 공간을 가리키는지 명령어 Fetch 이전에 확인을 해주어야 한다.
MOV (%rdx), %eax ADD %eax, (%rsi)
WebAssembly

increase_r 함수

increase 함수는 매 덧셈 할당 연산자 마다 x를 역참조하여 MOV 연산을 수행한다고 했다면, increase_r 함수처럼 restrict 키워드가 붙은 경우에는 어떻게 진행되는지 살펴보자.
*a += *x;
C
위 구문은 increase 함수와 마찬가지로 x를 메모리에서 참조하여 값을 받아와 레지스터에 담을 것이고, 레지스터의 값을 a 메모리 역참조 한 값에 더해줄 것이다.
MOV (%rdx), %eax ADD %eax, (%rdi)
WebAssembly
여기까지는 increase 함수와 동일하다.
*b += *x;
C
위 라인을 처리할 때 x를 한 번 더 사용하는 것을 볼 수 있는데, increase 함수대로면 위의 x에 대해서도 데이터의 무결성을 위해서 한 번 더 MOV로 레지스터에 그 값을 읽어올 것이다. 하지만 restrict 키워드가 붙은 경우에는 그 과정을 생략하고 (동일한 공간을 가리키지 않는 것을 가정 했으므로) 바로 덧셈 할당 연산자를 수행할 것이다. 따라서 increase 함수와 달리 아래와 같이 한 번의 연산만 수행될 것이다.
ADD %eax, (%rsi)
WebAssembly

restrict를 붙인 것이 더 좋지 않은가?

만일 두 함수가 동일한 결과를 내놓는다면 어셈블리 코드가 하나 줄은 restrict 키워드가 당연 더 성능이 좋을 것이다. 그렇다면 restrict를 안 쓸 이유가 있을까.. 그냥 쓰면 좋은 것이 아닌가라고 생각할 수 있을텐데 main문에 주어진 상황처럼 restrict 포인터가 동일한 메모리 공간을 가리킨다면 문제가 될 수 있다. 이에 대해서 살펴보자.

increase 함수의 main문

우선 increase 함수에 대해서 main문에서 주어진대로 실행을 하게 되면, a의 초기 값은 1인 상태로
*a += *x;
Plain Text
를 만나기 때문에 x의 역참조 값은 1이고 a의 역참조 값 역시 1이므로, a의 역참조 값은 2가 될 것이다. 그리고 a가 가리키는 공간과 x가 가리키는 공간은 동일하기 때문에 x가 가리키는 공간의 값도 2가 되어 있을 것이다.
*b += *x;
Plain Text
x가 가리키는 공간의 값이 2가 된 상태에서 위 구문을 만났을 때 x의 역참조가 2를 정상적으로 사용할 수 있을까? 예상한대로, MOV 연산을 통해 x의 메모리 공간을 한 번 더 참조하기 때문에 b의 역참조 값을 누적하는 값은 2가 되어 b의 역참조 값은 4가 된다.

increase_r 함수의 main문

초기엔 increase 함수와 마찬가지로 a의 초기 값은 1인 상태로 진행된다.
*a += *x;
Plain Text
위 문장을 만나게 되면, x의 역참조 값은 1이고 a의 역참조 값은 1인 상태이다. 따라서 a의 역참조 값에 덧셈 할당 연산자를 수행하고 나면 a의 역참조 값은 2가 된다. 이 때, x가 가리키는 공간의 값은 increase 함수 때와 마찬가지로 2가 되어 있을 것이다.
*b += *x;
Plain Text
그렇다면 x가 가리키는 공간의 값이 2가 되어 있는 상태에서 위 구문을 수행 했을 때 과연 x의 역참조 값은 2로 인식이 될까? 그렇지 않다. 어셈블리 코드대로라면 restrict 키워드가 붙은 경우, 동일한 메모리 공간에 대해서 MOV로 한 번 더 메모리 참조를 하는 과정을 생략하기 때문에 x가 가리키는 공간의 값이 2임에도 x의 역참조 값은 1로 이용이 된다.
따라서 최종적인 b의 역참조 값이 3이 되어버리고, 의도치 않은 결과로 이어지는 현상이 나타날 수 있음을 예상할 수 있게 해준다.
increase 함수는 4 / increase_r 함수는 3

실제 어셈블리 코드

우리가 예측한대로 결과가 나왔기 때문에 아마 예상한 어셈블리 코드대로 돌아간 것이 맞다는 것을 짐작할 수 있다. 실제로 변환된 어셈블리 코드를 확인해보고 우리가 예측한 어셈블리 코드와 일치하는지 살펴보자.
각 코드 라인에 해당하는 어셈블리 코드들이 명시된 것을 볼 수 있는데, 예측한 어셈블리 코드와 일치하는 것을 확인할 수 있다.

결론

restrict라는 키워드는 컴파일러 최적화를 통해 더 빠른 성능을 낼 수 있도록 해준다. 하지만 동일한 메모리 공간에 대한 접근이 없다는 것이 보장되지 않으면 Unexpected Behavior를 맞닥뜨릴 수 있기 때문에, 반드시 동일한 메모리 공간에 대한 접근이 없다는 것이 보장되었을 때 사용하도록 한다.

2. memcpy & memmove 함수

memcpy 함수 원형 및 간단한 설명

#include <string.h> void* memcpy(void* restrict dest, const void* restrict src, size_t size);
C
src가 가리키는 곳부터 size 바이트만큼 dest에 복사한다.
dest를 리턴한다.

memmove 함수 원형 및 간단한 설명

#include <string.h> void* memmove(void* dest, const void* src, size_t size);
C
src 가 가리키는 곳 부터 size 바이트 만큼 dest 가 가리키는 곳으로 옮긴다.
버퍼를 이용하므로 dest 와 src 가 겹쳐도 문제 없다.
dest를 리턴한다.

비교

두 함수의 원형을 보면, 위에서 다룬 restrict 키워드 차이 외에 기능적으로 별 차이가 없는 것을 확인할 수 있다. 따라서 restrict 키워드 사용할 때 확인했던 점과 같이, memcpy를 이용할 때는 동일한 공간에 대한 겹침 문제 (Overlapping)만 피할 수 있다면 조금 더 빠른 성능으로 이용할 수 있고 memmove를 이용할 때는 동일한 공간에 대한 겹침 문제가 발생하더라도 안전하게 복사가 가능함을 유추할 수 있다.
restrict 키워드를 이용했을 때 겹침 문제가 발생하더라도 별도의 에러나 경고 문구는 발생하지 않으므로 이에 대한 책임은 프로그래머에게 있음을 명시한채로 써야한다.
구현 쪽으로 얘기를 돌려보면, memcpy의 경우 별도의 체킹 없이 destsrc 앞 부분부터 그대로 값을 옮기면 된다. 반면 memmove의 경우에는 destsrc의 주소 크기 중 더 큰 값을 찾아낸 후 앞에서 부터 복사할지 뒤에서부터 복사할지 정하면 된다.
별도의 버퍼를 두고서 복사를 하는 것은 느리므로 버퍼를 두지 않은채로 안전한 방법을 강구하기 위해서이다.
우선 위와 같은 방법을 이용하려고 할 때, memmove에서 겹침 문제가 발생하지 않았다면 아무런 문제가 없다. memmove에서 겹침 문제가 발생한 상태에서 dest의 주소가 src의 주소보다 더 큰 경우에는 dest에 복사할 때 뒤에서부터 복사를 하게 되면 문제를 피할 수 있다. 또한 src의 주소가 dest의 주소보다 더 크게 되어 있는 경우에는 앞에서부터 복사를 하게 되면 문제가 없다.
앞에서부터 복사는 memcpy 구현부와 같으므로 동일한 코드를 사용해도 되고, memcpy를 호출해도 된다.
// memcpy void *memcpy(void *dst, const void *src, size_t n) { int i; i = -1; if (dst != src && n) while (++i < (int)n) *((unsigned char *)dst + i) = *((unsigned char *)src + i); return (dst); }
C
// memmove void *memmove(void *dst, const void *src, size_t len) { int i; i = dst > src ? len : -1; if (dst != src && n) { if (dst > src) while (--i >= 0) *((unsigned char *)dst + i) = *((unsigned char *)src + i); else while (++i < (int)len) *((unsigned char *)dst + i) = *((unsigned char *)src + i); } return (dst); }
C

3. Reference