MMX 지원이 없는 고전 펜티엄에서 빠른 memcpy()를 구현하기 위해 다음과 같은 코드를 쓰던 적이 있었다.

L0:     fild    QWORD PTR [esi]
        fistp   QWORD PTR [edi]
        add     esi,8
        add     edi,8
        sub     ecx,8
        ja      L0
이 코드는 MMX에 비해 느리지만, 한꺼번에 8바이트를 복사하는 효과는 똑같은 코드이다. 물론, 언롤링을 조금 더 해서 64번까지 언롤링할 수 있는 것도 MMX와 마찬가지이다.

그런데, 정수를 FPU 레지스터로 읽어들이는 fild는 부동소수점 실수를 읽어들이는 fld보다 느리다. 메모리로 쓰는 fistp 역시 fstp보다 느리다. 고전 펜티엄에서는 그 차이가 두드러진다. 어셈블리 초보의 입장에서 보면, 64비트 정수나 배정도 실수나 모두 64비트 크기를 가지는 자료이니, 조금 더 빠른 fld/fstp의 조합을 쓰는 것이 당연하게 보인다. 그런 생각을 실제로 벤치마크로 보여주는 코드도 웹에 떠돌아 다닌다.

링크된 벤치마크 코드를 돌려봐도 잘만 돌아가는데, 왜 이것이 초보나 저지르는 실수인가? 그것은, 코드자체에 있는 것이 아니라, 부동소수점 자료의 표현때문이다. fld/fstp를 쓰는 memcpy() 구현은 보통때에는 별 이상없이 작동하지만, 복사하는 내용이 특정한 값이면 프로그램 실행이 멎어버리는 경우가 있다. 인텔 매뉴얼 1권의 용어로는, SNaN 값이 복사대상이면 그런 일이 벌어진다. (엄밀하게 말하자면, 실행이 멈출지 아닐지는 프로그램에서 예외처리를 어떻게 하는가와 운영체제의 예외처리 정책에 따른다. 프로그램에서 FPU 예외처리를 하지 않고 운영체제의 기본 예외처리를 따른다면, 운영체제의 정책에 따라 실행이 멎을 수도 있고 계속 실행할 수도 있다. 프로그램에서 FPU 예외처리를 하면, 실행은 계속 할 수 있겠지만, NaN 값을 FPU 레지스터로 읽어들이는 경우에는 보통 실수 값을 읽을 때보다 한참 느리게 작동한다. 그러므로, 예외발생/예외처리/NaN처리의 과정을 거치면 엄청난 속도상의 손실이 발생한다.)

이것을 직접 확인해 볼 수 있는 예로서, 위의 링크에 있는 파일을 받아서 begin() 함수를 다음과 같이 약간 고쳐서 실행해 볼 수 있다. (빨간 글씨로 되어 있는 부분을 추가한다.)

void begin( LPBYTE mem1, LPBYTE mem2, int size, const char* text ) {
	memset(mem1,0x55,size);
	memset(mem2,0xff,size); // 0xAA를 0xff로 바꾸고 다음 두 줄을 추가.
	for (int i = 0; i < size; i += 8)
	    mem2[i+6] = 0xf0;
	printf(text);
}
윈도우즈 XP에서 실행하면, 예외처리가 운영체제 수준에서 이루어지며 프로그램의 실행이 멎지는 않는다. 그러나, 프로그램을 이렇게 고쳤을 때에는 고치지 않았을 때와 비교해서 매우 오랜 시간이 걸리며, 복사도 제대로 이루어지지 않는 것을 쉽게 볼 수 있다. 이 상태에서 memfpu()fldfild로, fstpfistp로 바꾸어 주면 제대로 작동하는 것을 또한 바로 볼 수 있다.

memcpy 구현 연재 목록
[1] 가장 단순한 구현
[2] 주소 정렬
[3] MMX 이용
[4] SSE로 확장
[5] 그외의 고려사항들
[6] 초보가 빠지기 쉬운 함정
Posted by movsd
,