IT_Programming/Assembly

편견이 깨지는 어셈블리 프로그래밍 (with VC++)

JJun ™ 2007. 7. 3. 12:09
LONG
배열대신 포인터를!
반복분기 작업이 필요하도록 만드는 것 중 하나가 배열일 것이다. 배열로 인해 고급 언어들은 프로그래머가 여러 개의 데이터를 처리하기 편하도록 만들고 있다. 하지만 조금이라도 속도를 늘리기를 원한다면 우리는 코드를 작성하는 손을 조금 더 번거롭게 해야 할 필요가 있다.

<리스트 4>와 <리스트 5>는 정수 3개의 요소가 들은 12바이트 크기의 구조체의 배열을 제어하는 예이다. <리스트 5>는 <리스트 4>에서 구조체를 포인터를 이용해 제어하도록 변경한 것이다. 이 둘의 성능을 테스트했다(<표 3>).

  <2> <리스트 2><리스트 3>의 실행 예   * 단위: 클럭
횟수 1 1 2 3 4 5 6 7 8 9 10 평균
리스트 2 12864 12852 12926 12912 12936 12956 12912 12900 12888 12896 12904.2
리스트 3 11706 11705 11705 11693 11687 11760 11849 11693 11693 11693 1718.4

  <표 3> 배열 제어 성능 측정
횟수 1 1 2 3 4 5 6 7 8 9 10 평균
리스트 4 3651 3633 3554 3543 3579 3669 3717 3633 3681 3654 3631.4
리스트 5 2666 2762 2588 2654 2684 2636 2617 2720 2582 2606 2651.5



<리스트 4> 일반적인 배열 작업
void TestDimPtr( )
{
    //local ---------------------
    int Tv_Wk;
    //code ----------------------

    for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
    {
        V_TestDumy2[Tv_Wk].test1 = 0;
        V_TestDumy2[Tv_Wk].test2 = 1;
        V_TestDumy2[Tv_Wk].test3 = 2;
    }

}

<리스트 5> 포인터를 이용한 배열 작업
void TestDimPtr2( )
{
    //local ---------------------
    int Tv_Wk;
    Ptr_TestStc Tv_WkPtr;
    //code ----------------------

    Tv_WkPtr = (Ptr_TestStc)&V_TestDumy2;
    for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
    {
        Tv_WkPtr->test1 = 0;
        Tv_WkPtr->test2 = 1;
        Tv_WkPtr->test3 = 2;

        Tv_WkPtr++;
    }

}

결과를 보고 의아해하는 독자도 있을 것이다. 코드 길이나 간결도를 봐도 <리스트 5>가 더 느려보이기 때문이다. 만약 차이가 나도 약간 나야 할 것이다. 그럼 <리스트 4>와 <리스트 5>의 디스어셈블(dis-assembly)된 코드를 보면서 이야기하자.

<리스트 6> <리스트 4>의 디스어셈블
75: for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
00402E58 mov dword ptr [ebp-4],0
00402E5F jmp TestDimPtr+2Ah (00402e6a)
00402E61 mov eax,dword ptr [ebp-4]
00402E64 add eax,1
00402E67 mov dword ptr [ebp-4],eax
00402E6A cmp dword ptr [ebp-4],64h
00402E6E jge TestDimPtr+62h (00402ea2)
76: {
77: V_TestDumy2[Tv_Wk].test1 = 0;
00402E70 mov ecx,dword ptr [ebp-4]
00402E73 imul ecx,ecx,0Ch
00402E76 mov dword ptr V_TestDumy2 (00418bb0)[ecx],0
78: V_TestDumy2[Tv_Wk].test2 = 1;
00402E80 mov edx,dword ptr [ebp-4]
00402E83 imul edx,edx,0Ch
00402E86 mov dword ptr V_TestDumy2+4 (00418bb4)[edx],1
79: V_TestDumy2[Tv_Wk].test3 = 2;
00402E90 mov eax,dword ptr [ebp-4]
00402E93 imul eax,eax,0Ch
00402E96 mov dword ptr V_TestDumy2+8 (00418bb8)[eax],2
80: }
00402EA0 jmp TestDimPtr+21h (00402e61)
81:
82: }

<리스트 7> <리스트 5>의 디스어셈블
92: Tv_WkPtr = (Ptr_TestStc)&V_TestDumy2;
00402EE8 mov dword ptr [ebp-8],offset V_TestDumy2 (00418bb0)
93: for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
00402EEF mov dword ptr [ebp-4],0
00402EF6 jmp TestDimPtr2+31h (00402f01)
00402EF8 mov eax,dword ptr [ebp-4]
00402EFB add eax,1
00402EFE mov dword ptr [ebp-4],eax
00402F01 cmp dword ptr [ebp-4],64h
00402F05 jge TestDimPtr2+5Fh (00402f2f)
94: {
95: Tv_WkPtr->test1 = 0;
00402F07 mov ecx,dword ptr [ebp-8]
00402F0A mov dword ptr [ecx],0
96: Tv_WkPtr->test2 = 1;
00402F10 mov edx,dword ptr [ebp-8]
00402F13 mov dword ptr [edx+4],1
97: Tv_WkPtr->test3 = 2;
00402F1A mov eax,dword ptr [ebp-8]
00402F1D mov dword ptr [eax+8],2
98:
99: Tv_WkPtr++;
00402F24 mov ecx,dword ptr [ebp-8]
00402F27 add ecx,0Ch
00402F2A mov dword ptr [ebp-8],ecx
100: }
00402F2D jmp TestDimPtr2+28h (00402ef8)
101:
102: }

<리스트 6>의 배열 제어하는 부분과 <리스트 7>의 구조체 제어 부분을 비교해 보자. <리스트 5>에서는 배열을 제어할 때마다 배열요소의 주소를 항상 계산하게 된다.

77: V_TestDumy2[Tv_Wk].test1 = 0;
00402E70 mov ecx,dword ptr [ebp-4] // 배열의 인덱스 가져옴
00402E73 imul ecx,ecx,0Ch // 배열주소 계산
00402E76 mov dword ptr V_TestDumy2 (00418bb0)[ecx],0// 배열제어

즉, 배열의 요소를 제어할 때마다 배열의 인덱스에 배열요소의 크기를 곱하여 제어한다는 뜻이다. 반면 <리스트 7>의 포인터를 이용한 제어의 경우 다음과 같다.

95: Tv_WkPtr->test1 = 0;
00402F07 mov ecx,dword ptr [ebp-8] // 배열의 주소 가져옴
00402F0A mov dword ptr [ecx],0 // 배열제어

이미 제어할 배열 위치의 포인터, 즉 주소가 계산되어 있기 때문에 제어 시에는 배열의 주소를 계산할 필요가 없는 것이다. 이 이유만은 아니다. 배열 주소를 계산하기 위해서는 배열의 인덱스에 배열 요소의 크기를 곱해야 한다. 반면 포인터를 이용한 연산에서는 매번 배열의 요소 크기만큼 더해주기만 한다. 80×86 계열 CPU에서는 곱셈
명령은 덧셈 명령보다 많은 작업을 요한다. 이런 느린 명령이 여러 번 쓰이니 속도차가 크게 날 수밖에 없는 것이다. 하지만 이 중에서 몇 가지 예외는 있다. 80×86 계열 CPU가 32비트로 들어서면서 2, 4, 8의 배수의 크기를 갖는 배열의 경우 주소 연산이 필요 없이 바로 제어할 수 있는 것이다. 따라서 요소의 크기가 2, 4, 8의 배수인 배열의 경우 그대로 배열로서 제어하는 것이 더 빠르다.

GUI에서 최적화
윈도우처럼 그래픽을 이용해 유저가 제어하도록 하는 방식을 GUI(Graphic User Interface)라 한다. 이 말은 사용자가 제어할 수 있도록 윈도우 애플리케이션은 많은 그래픽 컨트롤을 사용한다는 말이기도 하다. 이 그래픽 컨트롤들을 사용하는 방법에 있어서도 최적화가 존재한다. 그 중 한 가지가 컨트롤의 검사 후 출력 방식이다.
MFC에서 많이 쓰는 컨트롤 중 하나가 바로 CStatic이다. CStatic은 주로 결과를 표시하거나 그래픽을 표시할 때 쓰인다. 어떤 배열의 값을 출력한다고 하자. 이때 일반적으로 배열의 값을 출력할 때 <리스트 8>과 같은 방식으로 표시할 것이다. 하지만 만약 <리스트 9>와 같이 수정해 준다면 조금 더 나은 성능을 보여 줄 수 있다.

<리스트 8> 일반적인 배열 값의 CStatic 출력
void TestDimPtr2( )
{
    //local ---------------------
    int Tv_Wk;
    char Tv_StrTmp[32];
    //code ----------------------

    for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
    {
        sprintf(Tv_StrTmp,"%d",V_TestDumy[Tv_Wk] );
        m_LblValue.SetWindowText( Tv_StrTmp );
    }

}



<리스트 9> 원활한 수행을 위해 코드를 추가한 예
void TestDimPtr2( )
{

    //local ---------------------
    int Tv_Wk;
    char Tv_StrTmp[32];
    char Tv_StrTmp2[32];
    //code ----------------------

    for(Tv_Wk = 0;Tv_Wk < 100;Tv_Wk ++)
    {
        sprintf(Tv_StrTmp,"%d",V_TestDumy[Tv_Wk] );
        m_LblValue.GetWindowText( Tv_StrTmp2 );
        if(strcmp( Tv_StrTmp, Tv_StrTmp2 ) != 0)
        {
            m_LblValue.SetWindowText( Tv_StrTmp );
        }
    }

}


<리스트 8>보다 오히려 작업만 늘어나 버린 <리스트 9>가 더 최적화된 코드라는 것에 의아해 할 것이다. <리스트 9>는 <리스트 8>에서 스트링 내용을 비교해 출력 여부를 판단하는 작업이 추가된 것 일뿐이기 때문이다. 여기서는 윈도우의 글자 출력에 대한 부분을 먼저 알아야 한다.

윈도우는 화면에 글자를 출력하기 위해 폰트를 읽어 비디오 메모리에 출력한다. 일반 비트맵 폰트도 있지만 더욱 매끄럽게 글자를 출력하기 위해 트루 타입 폰트를 사용한다. 트루 타입 폰트는 초기 벡터 폰트(vector font)라 불리던 방식으로 그림으로 된 폰트를 화면에 복사해주던 방식과 달리 선, 면, 곡선 등을 그리는 명령을 이용해 아무리 확대해도 모양이 거칠어지지 않도록 하는 글자 출력 체계이다. 이런 복잡한 방식을 이용하기 때문에 윈도우 컨트롤의 내용을 한꺼번에 갱신하거나 하면 화면출력이 어색해지거나 CPU 부하를 많이 필요로 하는 상황이 오게 된다. 그래서 내용이 같은지 틀린지를 비교해 출력하는 것이다. 물론 중복되는 내용이 거의 없다면 <리스트 9>와 같이 비교해 출력하는 방식은 쓸데없는 코드와 속도 낭비를 하는 것일 수도 있다. 하지만 이렇게 생각해보자. 폭이 100픽셀이고 높이가 20픽셀인 윈도우 컨트롤을 갱신한다. 비트맵을 제어해 본 독자라면 알겠지만 픽셀은 하나가 메모리 하나를 차지하는 일종의 데이터이다. 이 데이터를 출력한다는 것은 100×20으로 2000개의 명령을 최소 사용한다는 말과 동일하다. 거기다 트루 타입과 같은 복잡한 연산이 들어가는 출력 방식이라면 그 이상을 필요로 할 수 있다. 저런 큰 연산을 막기 위해 30바이트 남짓한 문자열을 검사한 후 출력하는 방식이 과연 손해라 할 수 있을까?

 

---------------------------------------------------------------------------------------------

 

VC++의 최적화
VC++나 델파이와 같은 고급 언어들의 컴파일 옵션에 보면 거의 포함되어 있는 메뉴가 최적화 메뉴다. 한때에는 고급 언어들의 성능 및 코드 생성 효율이 당시 하드웨어의 성능을 원활하게 끌어내기에는 부족했기 때문에 어셈블리어로 프로그램을 제작하기도 했었다. 하지만 현재는 높아진 하드웨어 성능과 많아진 메모리와 더불어 눈부시게 발전한 고급 컴파일러의 최적화 알고리즘에 의해 어셈블리어로 손수 최적화 코딩을 할 필요는 거의 없어졌다. 여기서는 지면관계상 고급 언어들을 다 다룰 수는 없고 이들 중 VC++를 선택해 최적화 기능의 예를 보여주려 한다.

<리스트 10> 간단한 테스트 예제
int TestOpt( int A_Test )
{

    int Tv_Test1;

    Tv_Test1 = A_Test + 2;

    int Tv_Test2;

    Tv_Test2 = Tv_Test2 * A_Test;

    return Tv_Test2;

}

<리스트 10>의 소스를 최적화 옵션을 사용하지 않은 상태와 사용한 상태 두 가지로 컴파일해 디스어셈블해 보았다.

<리스트 11> <리스트 10>의 최적화 사용 안한 상태
00402FD0 push ebp
00402FD1 mov ebp,esp
00402FD3 sub esp,48h

107:
108: int Tv_Test1;
109:
110: Tv_Test1 = A_Test + 2;
00402FE8 mov eax,dword ptr [ebp+8]
00402FEB add eax,2
00402FEE mov dword ptr [ebp-4],eax
111:
112: int Tv_Test2;
113:
114: Tv_Test2 = Tv_Test2 * A_Test;
00402FF1 mov ecx,dword ptr [ebp-8]
00402FF4 imul ecx,dword ptr [ebp+8]
00402FF8 mov dword ptr [ebp-8],ecx
115:
116: return Tv_Test2;
00402FFB mov eax,dword ptr [ebp-8]
117:
118: }

<리스트 12> <리스트 10>의 최적화 옵션 사용한 상태
; 107 :
; 108 : int Tv_Test1;
; 109 :
; 110 : Tv_Test1 = A_Test + 2;
; 111 :
; 112 : int Tv_Test2;
; 113 :
; 114 : Tv_Test2 = Tv_Test2 * A_Test;

mov eax, DWORD PTR _Tv_Test2$[esp-4]
imul eax, DWORD PTR _A_Test$[esp-4]

; 115 :
; 116 : return Tv_Test2;
; 117 :
; 118 : }

ret 0

어느 정도 어셈블리를 경험했던 독자는 <리스트 11>과 <리스트 12>의 차이를 보고 놀랄 것이다. 최적화 옵션을 끈 경우 컴파일러는 정석대로 지역변수 할당을 위한 스택 프레임을 만든다.

00402FD0 push ebp
00402FD1 mov ebp,esp
00402FD3 sub esp,48h

스택 프레임을 만든 후 ebp 레지스터를 스택 프레임의 기준 위치로 잡아 지역변수와 파라미터를 제어할 것이다. 하지만 최적화 옵션을 최대로 했을 경우 VC++는 스택 프레임을 만들지 않는다. 대신 esp 레지스터로 현재 스택 사용량을 내부적으로 계산해 가며 지역변수와 파라미터를 제어한다. 이는 사람이 직접 어셈블리어로 코딩하기에는 힘들고 위험한 방법이다. 그뿐만 아니다. VC++는 해당 루틴의 지역변수 사용량을 검사해 가능한 레지스터만으로 처리가 가능한 연산일 경우 지역변수를 사용하지 않고 레지스터만으로 처리한다. 이처럼 오늘날의 고급 컴파일러의 최적화 성능의 향상으로 인해 어지간한 어셈블리로 만든 루틴보다 고급 컴파일러로 만든 코드의 성능이 더 나은 경우도 많다. 물론 컴파일러도 사람이 프로그래밍해 놓은 상황에 맞춰 최적화시키는 방식이다 보니 복잡하거나 컴파일러가 예상치 못한 상황의 경우 어셈블리어보다 효율이 떨어질 수 있다. 하지만 매우 특별한 상황이 아니라면 고급 컴파일러의 최적화 기능은 프로그래머가 요구하는 성능을 충분히 끌어 낼 수 있다.

최적화하려면 정확한 측정도 중요하지만 어떤 곳에서 데이터의 흐름이 많은 지에 대한 분석 및 통계도 중요하다. 현재 이런 것에 대한 지원 툴로는 인텔의 V-TUNE나 디벨로퍼 스튜디오가 있다. 이런 툴들의 사용법을 정확히 알고 코드를 수정하는 것 역시 좋은 최적화의 길이라 할 수 있다.

성능 측정에서 주의할 점
RDTSC 명령은 CPU 클럭 값을 보여주는 매우 중요한 측정 도구다. 하지만 이 명령으로 측정한 결과가 항상 정확하다고 할 수 없다. 이는 RDTSC가 부정확하게 동작함을 말하는 것이 아니다. 수행시간에 영향을 미치는 다른 요소들이 존재하기 때문이다. 윈도우 애플리케이션의 수행 속도에 영향을 미치는 요소들은 다음과 같다.

컨텍스트 스위칭
윈도우는 선점형 멀티태스크를 지원하는 OS다. 이 말은 애플리케이션의 함수가 수행중일지라도 OS에서 지정한 양의 시간이 지나면 자동으로 다른 프로세스/쓰레드로 전이해 실행한다는 뜻이다. 이를 컨텍스트 스위칭(context swiching)이라 한다. 만일 많은 수행시간을 요하는 연산을 측정할 때에는 다른 프로세스 및 쓰레드의 실행시간까지 같이 합산된 결과가 나온다는 뜻이다. 때문에 매우 긴 연산을 측정할 때에는 가급적 다른 애플리케이션들은 실행하지 않아야 한다.

캐시
CPU보다 상대적으로 느린 메모리 및 주변기기의 속도를 보완하기 위하여 만든 것이 바로 캐시(Cache)이다. 캐시는 매우 빠른 메모리에 사용할 데이터들을 보관하면서 CPU에 빠른 속도로 데이터를 건네주는 역할을 한다. 처음 실행하는 함수의 경우 제어할 데이터가 캐시에 준비가 안 되는 경우가 많기 때문에 함수의 실험은 첫 회의 경우 무시하고 두 번째 클럭 값부터 적용하는 것이 좋다.

가상 메모리의 물리 페이지로 맵핑
윈도우의 경우 설치된 시스템보다 많은 메모리를 사용할 수 있게 하기 위하여 가상메모리(virtual memory)를 지원한다. 사용 빈도가 낮은 데이터는 하드디스크에 보관했다가 실제로 사용할 경우 데이터를 하드디스크로부터 읽어 실제 메모리에 올려 사용한다는 개념이다. 하드디스크의 속도는 메모리의 속도보다 훨씬 느리다. 때문에 처음 실행하는 함수의 경우 사용할 메모리 영역이 실제 메모리에 올라와 있지 않을 확률이 크다. 이런 경우 함수 실행 시 하드디스크로부터 메모리 영역을 읽어오는 시간으로 인해 매우 많은 클럭 수를 소요하는 것으로 결과가 나올 수 있다. 이는 바로 앞에서 언급한 캐시로 인한 시간손실과는 비교도 안 될 정도로 큰 수치이다. 이런 현상을 방지하는 방법 역시 첫 회의 실험으로 얻은 결과치는 무시하고 여러 번 반복 실험하여 얻은 결과치의 평균을 얻는 방법이다.

그 외의 요소들로는 하드웨어 인터럽트, 버스마스터 기기로 인한 사용 가능한 버스의 대역폭 감소 등이 있다. 이러한 요소로 인한 오차를 줄일 수 있는 방법은 테스트 프로그램만을 실행하고 여러 번 실행하여 평균 값을 내는 것이다.

어셈블리를 안다는 것
연재를 마치면서 한 가지 말하고 싶은 것은 최적화 실험으로 나온 결과는 자신의 하드웨어 및 소프트웨어적 환경을 기준으로 실험한 결과이기 때문에 자신의 루틴을 더욱 효율적으로 만들기 위한 도구는 되어도 다른 환경의 사람과 루틴의 성능을 비교하기 위한 절대적인 잣대는 아니라는 것이다.

지금까지 3회 연재 동안에 간단한 이론 및 몇 가지 최적화 방법과 실험을 해보았다. 필자는 제품 개발 시 고급 언어를 주로 쓰고 어셈블리어로는 서브루틴 정도밖에 만들지 않는다. 하지만 어셈블리를 앎으로서 고급 언어로 개발할 때보다 빠르고 안정된 프로그램으로 만들 수 있는 도구로서 어셈블리를 사용하기를 바랄 뿐이다. 문의 내용은 http://myhome.hitel.net/~DAMGI의 질문 게시판에 올려주기 바란다.

ARTICLE

 

  저 자 : 송민호
  출판일 : 2003년 7월호

  지난 연재에서 CPU 내부와 버스와 메모리의 동작, 그리고 그에 따른 최적화의 예를 간단히 보았다. 왜 최적화가 필요한지에 대해서도 이야기했다. 이번 호에서는 자신이 직접 만든 루틴의 속도를 간단하게 측정하는 방법과 일반적인 사칙연산, GUI의 관리 배열과 클래스의 최적화, 그리고 마지막으로 VC++가 최적화하는 방식을 알아보는 것으로 연재를 마치고자 한다. 최적화가 많은 프로그래머로부터 관심 있는 분야인 만큼 이미 자료를 접해봤을 것이다. 하지만 복잡한 테스트 툴 사용의 번거로움이나 시간 부족으로 테스트하기 힘들거나 최적화 툴의 원리를 알고 싶어하는 독자를 위해 내용을 구성했다.

어떻게 속도를 측정할 것인가
두 번째 강좌를 본 독자들은 함수들의 수행시간을 비교하기 위해 80×86 명령어 중에서 RDTSC라는 명령을 사용해 측정하는 것을 보았다. 인텔에서는 펜티엄을 발표하면서부터 칩 내부에 성능을 측정할 수 있는 기능을 내장하기 시작했다. 그러한 기능 중 하나가 퍼포먼스 모니터링 카운터(perfo rmance-monitoring counters)와 타임 스탬프 레지스터(time stamp register)이다. 여기서 퍼포먼스 모니터링 카운터는 CPU 내의 각종 동작에 대한 통계를 얻기 위한 기능이고 타임 스탬프 레지스터는 CPU로 입력된 클럭 카운트를 얻기 위한 기능이다. 알다시피 우리가 측정하고자 하는 것은 실행 부분의 속도이다. 특정 부분의 실행속도를 측정하기에 좋은 기능이 바로 타임 스탬프 레지스터인 것이다.

일반적으로 어떤 루틴의 성능을 측정하려면 작업을 수행하는데 걸린 시간을 잰다. 100미터 달리기를 할 때 시간을 재는 것과 비슷하다. 그렇다면 윈도우에서 지원하는 시간 측정 API인 GetLocalTime이나 타임 틱(tick)을 카운트하는 GetTickCount 같은 것으로 하면 되지 않겠는가? 아쉽게도 이들 루틴들은 최소 측정단위가 1/1000초이고 정확도 역시 0.15~0.3초 이상 차이가 난다. 저 정도면 충분하지 않겠는가라고 생각하는 독자가 있을지 몰라 실제 계산 예를 들어 보겠다.

우리가 보통 사용하는 CPU가 1GB 이상의 클럭을 사용하고 있다. 즉, 어지간한 명령들은 1초에 10억 번을 실행할 수 있다는 뜻이다. 우리가 만드는 연산함수들의 길이를 보자. 일반적으로 함수 하나에 200줄 정도의 라인으로 구성되어 있다고 가정할 때 라인 하나당 OP 코드(CPU 명령어)로 10줄이 든다고 해도 2000개 명령어 정도로 구성된다는 뜻이다. 이 2000개의 명령어를 1GB 속도의 CPU가 연산하는데 걸리는 시간은 0.000002초이다(물론 캐시 상황과 명령어의 종류에 따라 달라질 수 있다). 즉 해당 명령어(시간 측정 API)들로 측정하기에는 힘들다는 뜻이다. 그래서 사용하는 것이 타임 스탬프 레지스터이다. 타임 스탬프 레지스터는 CPU로 입력되는 클럭의 카운터를 세는 카운터이다. 즉, CPU가 1GHz라면 1초에 카운트가 1억까지 증가하는 매우 고속의 카운터이다. 단, 이 카운터는 말 그대로 CPU 클럭 수를 재는 것이기 때문에 CPU마다 카운터의 속도가 틀리다. 따라서 시간 측정이라기보다는 상대적으로 루틴이 빠른지 느린지를 비교하기 위해 사용한다.

<리스트 1>에서 보는 것과 같이 함수가 실행하는데 걸린 클럭 수를 측정하기 위해서는 함수 실행하기 전에 한번 타임 스탬프 레지스터의 값을 읽은 후, 측정이 종료된 후에 다시 한번 읽어 그 차를 출력하는 방식이다. 이와 같이 간단한 방법을 사용하여 상대적인 수행 시간을 구하여 자신이 만든 루틴의 성능을 구할 수가 있다. 하지만 몇 가지 변칙적인 요소때문에 성능평가 툴들에 비해 정확도가 떨어지는데 그 이유는 후반부에 좀더 자세하게 설명하겠다.

<리스트 1> 간단한 타임 스탬프 레지스터 사용 예
{
//local ---------------------
    __int64 Tv_StClock;
    __int64 Tv_EdClock;
    char Tv_StrRslt[32];
//code ----------------------
// 연습함수 실행
    Tv_StClock = GetTimeStamp();
    TestDimPtr2(); // 측정할 함수
    Tv_EdClock = GetTimeStamp();
    Tv_EdClock -= Tv_StClock;
    sprintf(Tv_StrRslt,"%d",Tv_EdClock);
    m_Lbl_PtrRslt.SetWindowText( Tv_StrRslt ); //strcmp

}

코드의 진법, 배치에 의한 최적화
삼국지를 읽어 본 적이 있는가? 가끔 고전 전쟁사나 고대의 전쟁을 소재로 한 게임을 보다보면 ‘진법’이라는 단어가 종종 보일 것이다. 고대의 군인들을 보면 창병, 보병, 기병, 철기병, 노병, 상병 등 다루는 무기와 역할에 따라 다양하게 나뉜다. 이 다양한 군인들을 공격력을 최대한 높이면서 방어 역시 원활히 할 수 있도록 효율적인 배치를 하는 것, 이것을 진법이라 한다.

프로그램도 마찬가지다. 연산하는 명령, 비교하는 명령, 분기하는 명령, 시스템을 제어하는 명령 등 여러 종류의 명령이 있다. 이 역시 ‘어떻게 배치를 하는가’만으로도 성능에 큰 차이를 보일 수 있고, 더 나은 코드를 얻을 수 있는 것이다. 이제 함수의 수행성능을 측정할 준비는 끝났다. 이번 단원에서는 코드를 어떻게 배치하는가에 따라 수행성능이 어떻게 변하는지 직접 실험해보기로 한다.

 <1> RDTSC 명령
 구분  내용
 명령  rdtsc
 입력  none
 출력  edx : eax (64bit)


반복분기에서의 최적화
어떠한 프로그램이든지 중요한 연산의 가장 큰 기둥이라고 할 수 있고 대량의 연산을 하는 데에 있어 거의 필수적으로 사용되는 것이 if, while, repate for 등의 반복 및 분기 명령어일 것이다. 일반적으로 루프(loop)라고 부르는 이 반복분기는 나열된 데이터의 처리, 적분 및 기타 반복 작업에 동원되는 매우 비중 있는 작업이다. 독자들은 프로그램을 만들면서 간단한 작업이 아닌 긴 연산, 또는 쓰레드에 넣어야 할 정도의 긴 연산에 반복분기가 쓰이는 것을 보았을 것이다. 이런 반복분기를 최적화시킨다는 것은 실질적으로 부하가 많이 걸리는 작업을 최적화하는 것과 같다. 이 반복분기를 공략해보자.

분기의 횟수를 줄여라
첫 회 주제였던 ‘CPU를 공략하라’에서 언급된 내용이지만 우리는 분기에 의해 파이프라인이 초기화된다는 것을 알았다. 파이프라인의 초기화에 의한 시간 손실은 그리 크지 않지만 반복분기에 의해 누적될 경우 그 양은 결코 무시할만한 것은 아니다.
반복분기에 대한 실험을 하기 위해 1000개의 요소를 가지는 정수 배열에 1부터 999까지 기록하는 함수를 만들어 보았다. <리스트 2>는 일반적으로 분기해 코드를 구성한 예이고 <리스트 3>은 내부에 작업을 두 번해 분기 횟수를 반으로 줄인 예이다. <표 2>는 이 둘의 성능을 측정한 결과이다.

<리스트 2> 일반적인 반복분기 작업
void TestLoop_1(int* A_PtrDumy)
{
    //local ---------------------
    int Tv_Wk;
    int* Tv_PtrWk;
    //code ----------------------

    Tv_PtrWk = A_PtrDumy;

    for(Tv_Wk = 0;Tv_Wk < 1000;Tv_Wk++)
    {
        *Tv_PtrWk = Tv_Wk;
        Tv_PtrWk ++;
    }
}


<리스트 3> 내부에 작업을 두 번 해 분기 횟수를 반으로 줄인 예
void TestLoop_2(int* A_PtrDumy)
{
    //local ---------------------
    int Tv_Wk;
    int* Tv_PtrWk;
    //code ----------------------

    Tv_PtrWk = A_PtrDumy;

    for(Tv_Wk = 0;Tv_Wk < 1000;Tv_Wk += 2)
    {
        *Tv_PtrWk = Tv_Wk;
        Tv_PtrWk ++;
        *Tv_PtrWk = Tv_Wk + 1;
        Tv_PtrWk ++;
    }

}

참고로 VC++의 최적화 기능으로 인해 잘못된 결과가 나오지 않도록 하기 위하여 최적화 옵션은 꺼놓고 했다. <표 2>를 보면 평균 소요된 클럭 수가 <리스트 2>보다 <리스트 3>이 더 적게 소요됨을 알 수 있다. 그러므로 여기서 분기횟수를 줄이는 것으로 파이프라인의 초기화를 막아 최적화의 이득을 얻을 수 있다는 것을 알게 된다. 이렇다고 무작정 내부 작업양을 늘려 분기횟수를 줄이려고 하는 것은 바람직하지 않다. 지나친 내부 작업양의 증대는 코드의 크기를 증가시키며 이로 인해 캐시 메모리의 효율을 떨어뜨릴 수 있기 때문이다. 참고로 ‘이달의 디스켓’에는 분기횟수를 1/4로 줄여 테스트한 예도 있으므로 관심있는 독자는 실험해 보기 바란다.



<그림 1> <리스트 2>와 <리스트 3>의 수행속도 비교

 

---------------------------------------------------------------------------------------------