코세가 자꾸 unaligned access 갖고 시비를 걸어서 이 참에 정리를 좀 해본다.


1. 서론


왜 c/c++에서 unaligned access가 좋지 않은가? 일단 기본적으로 c/c++ 표준에 따르면 unaligned access는 undefined behavior야. 자세한 설명은 http://stackoverflow.com/questions/32589328/unaligned-access-through-reinterpret-cast 를 참고.


unaligned access에 대해 컴파일러의 입장에서 가능한 행동은

 1) 모든 포인터가 align되어 있다고 가정하고 코드를 생성한다.

 2) 포인터가 align되어 있다는 확신이 없으면 성능을 희생해 unaligned access가 발생하지 않는 코드를 생성한다.

이 두가지 정도가 reasonable한 행동이 될텐데, arm-gcc에서는 -mno-unaligned-access 옵션으로 지정할 수 있어.(https://gcc.gnu.org/onlinedocs/gcc-5.3.0/gcc/ARM-Options.html#ARM-Options) 

x86은 cpu가 unaligned access를 지원하니까 관련 옵션이 없을 듯 한데, 지금 슬쩍 gcc 메뉴얼을 찾아봤는데 역시 없는것 같다.(내가 x86은 안다뤄봐서 확실히는 잘 모르겠다) x86에서의 unaligned access에 대한 내용은 Intel® 64 and IA-32 Architectures Optimization Reference Manual(http://www.intel.com/content/www/us/en/architecture-and-technology/64-ia-32-architectures-optimization-manual.html) 의 3.6.4 Alignment에 설명되어 있는데 좆나 그냥 첫줄부터 'Misaligned data access can incur significant performance penalties.'라고 써놨네.


2. 실험 방법


간단하게 memcpy를 짜서 테스트해본다. 단순히 byte-to-byte 버전과 aligned access만 사용하는 버전, 그리고 unaligned access를 그냥 쓰는 버전으로 작성하고, glibc와 musl libc의 memcpy랑 성능 비교를 해볼거야. 글고 시스템에서 제공하는 std::memcpy랑도 비교해보자.


3. 실험 환경


하도 인텔인텔 그래서 적당히 굴러다니는 리눅스 머신으로 세팅했어. 커널 버전 4.1.20의 젠투 리눅스 시스템이고, Intel(R) Core(TM) i7-4770K CPU @ 3.50GHz, 램은 12G야. 진짜 암것도 없이 딱 커널만 올라갔고 X윈도는 설치도 안했다. gcc 버전은 5.3.0이고, clang 버전은 3.5.2야.


4. 기타사항


4.1. endianness


aligned access만을 이용하는 버전은 리틀엔디언인지 빅엔디언인지 구분할 필요가 있는데, 런타임 체크는 귀찮기도 하고 크리티컬한 부분도 아니고 해서 그냥 __BYTE_ORDER__ 매크로 썼어. 평소엔 표준표준거리다가 왜 엔디언은 __BYTE_ORDER__ 쓰냐!!라고 물으면 뭐 역시 귀찮아서... 어차피 런타임에 바이트오더가 바뀌는것도 아니고 이 부분은 걍 넘어가자. 


4.2. -ftree-loop-distribute-patterns


gcc는 기본적으로 memcpy, memmove, memset, memcmp를 요구해.(심지어는 freestanding 환경에서조차도!) https://gcc.gnu.org/onlinedocs/gcc-5.3.0/gcc/Standards.html#Standards

그래서 최적화를 하다가 저 네가지 함수를 사용할 만한 상황이다 싶으면 저 함수를 콜하는 코드를 생성하는데, 지금 하려는데 memcpy를 구현하는거잖아? 근데 컴파일러가 코드를 딱 보고 '이건 memcpy부르면 되겠네' 라고 판단하면 memcpy가 memcpy를 콜하게 돼서 무한루프에 빠지게 돼. 리포트된지 좀 된 버그인데(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=56888), 이 현상을 막기 위해 -fno-tree-loop-distribute-patterns 옵션을 썼어.


4.3. glibc & musl libc


glibc 소스코드는 https://sourceware.org/git/?p=glibc.git;a=blob;f=string/memcpy.c;h=e4aa4dd2089cb8627f37fdc1f5c839a8b458fd80;hb=HEAD에서 가져왔고,

musl libc 소스코드는 http://git.musl-libc.org/cgit/musl/tree/src/string/memcpy.c에서 가져왔다.

c++로 컴파일하려고 헤더파일 몇가지랑 glibc 내부적으로 사용하는 throw attribute 매크로 같은거 손 좀 봤어.



5. 결과


5.1. g++ march=native -O2 -NDEBUG -fno-tree-loop-distribute-patterns


간단하게 O2 레벨 최적화에 디버그 루틴 끄고 memcpy 관련 옵션 준게 다야.


nice -n -20 ./compare $((100*1024*1024) 1 2 500 으로, 100메가 복사하는데, dest는 1byte misalign, src는 2byte misalign나도록 해서 500번 반복하도록 했다.



byte-to-byte 복사는 역시 좀 느리지? musl이나 glibc나 my_memcpy나 unaligned 버전이나 성능은 뭐 비슷비슷해. 일단 std::memcpy가 왜 압도적으로 높은가하면, c/c++에서의 unaligned access를 비교하는게 목적이라 musl이나 glibc나 generic 버전으로 가져온건데, 실제 glibc는 memcpy와 같은 루틴은 어셈블리로 직접 최대한 높은 성능을 뽑아내도록 따로 구현돼. glibc 소스코드의 sysdeps/x86_64/memcpy.S 나 sysdeps/arm/memcpy.S 를 보면 어케 구현되어있는지 볼 수 있다.


여기서 'aligned 버전이나 unaligned 버전이나 성능은 거의 비슷비슷한데? 그럼 걍 unaligned 버전 쓰는게 코드도 깔끔하고 좋지 않음?'이라고 생각할 수 있는데, 워드를 쪼개고 비트쉬프팅해서 합치는 걸 cpu 레벨에서 해주기 때문에 memcpy에서의 성능은 비슷한게 맞아. 근데 내가 하고자 하는 얘기는 뒤에 나오니까 뒤에서 설명할게.


5.2. clang -march=native -O2 -NDEBUG


clang도 같은 레벨의 최적화 옵션으로 빌드했어.



clang이 좆나 웃긴게 byte-to-byte 버전 성능이 다른 버전과 비슷하게 나왔는데, 깜짝 놀라서 코드 열어보니 대충 loop unrolling까지 되어 있네.. gcc가 memcpy를 콜하는 것마냥 clang도 똑같은 짓을 하는데 얘는 그냥 지가 구현한 memcpy를 그냥 인라이닝시켜버리는게 아닌가 의심스럽다. clang은 그냥 비교군으로 쓰는 편이었는데 생각보다 최적화가 잘 되는것 같네?


5.3. g++ march=native -O3 -NDEBUG -fno-tree-loop-distribute-patterns


그럼 이제 최적화 레벨을 높여보자. 사실 여기서 조마조마했는데 unaligned access가 컴파일러에 따라서 별 영향이 없을 줄 알았는데 역시나 segfault가 뜨더라구. 일단 비교부터.


gcc도 최적화 레벨을 최대로 주니까 byte_only 버전도 최적화해주는것 같네.


근데 unaligned 버전은 그냥 세그폴트가 떠. 간단하게 gdb를 떠보면 vmovdqa 인스트럭션에서 뜨는데, 컴파일러한테 얼라인먼트를 무시하고 엑세스하라고 코드를 넘겼고, 그래서 컴파일러는 얼라인먼트가 되어 있다고 가정하고 코드를 생성했고, 실제로는 얼라인먼트가.. ㅠㅠ.


내가 undefined behavior니까 unaligned access는 쓰지 말자고 하는 이유가 바로 이거야. 컴파일러마다 동작이 다르거나 옵션에 따라서도 행동이 달라질 수 있어. 그야말로 'undefined' behavior니까.



6. 결론


gcc가 구려서 그렇다는 개소리를 할까봐 노파심에 하는 소린데, undefined behavior는 컴파일러가 어떠한 행동을 해도 표준에 어긋나는 것이 아니기 때문에 gcc의 문제가 아냐.


UB는 앵간하면 쓰지 말자. 물론 특정 컴파일러가 어떻게 코드를 생성하는지 좆나 완벽하게 알고 있으면 써도 되는데, 논리적인 결함이 아닌 버그가 생길 가능성이 높고 그런 버그는 디버깅하기도 좆나 힘들어.


성능은 내가 잘못 알고 있었던것 같다. 난 unaligned access 뜨면 좆나게 느려질 줄 알았는데 그건 아니었네. 근데 코세가 말한것처럼 비트쉬프트해서 잘라 붙인다고 unaligned보다 느려지는것도 아니니. atomic operation으로 빡세게 테스트해보면 어떨까 싶은데 테스트방법이 떠오르질 않는다. 근데 어찌됐든 UB니까 쓰지 마.



7. 참고


gcc나 clang은 undefined behavior sanitizer를 제공하는데, 런타임에 UB가 발생할때마다 에러 메시지로 알려줘. 그니까 괜히 디버깅하느라 고생하지 말고 자주 사용해주는게 좋음. (g++ -fsanitize=undefined이나 clang++ -fsanitize=undefined로 컴파일)


memcpy.cpp:78:18: runtime error: load of misaligned address 0x7ff8a29d7012 for type 'const word', which requires 8 byte alignment

0x7ff8a29d7012: note: pointer points here

 00 00  00 00 00 01 02 03 04 05  06 07 08 09 0a 0b 0c 0d  0e 0f 10 11 12 13 14 15  16 17 18 19 1a 1b

.                      ^ 

memcpy.cpp:78:18: runtime error: store to misaligned address 0x7ff8a8dd8011 for type 'word', which requires 8 byte alignment

0x7ff8a8dd8011: note: pointer points here

 00 00 00  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  00 00 00 00 00
.                      ^