티스토리 뷰


편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 3 - 1  
 
 
지금까지 2회에 걸쳐 최적화에 대한 이론과 함께 몇 가지 예를 들어 보았다. 코드를 제작하는 방법에도 여러 가지가 있듯이 최적화 방식에도 여러 가지가 있다. 다양한 코드에 알맞은 최적화 방식을 찾는다는 것은 많은 경험과 실험이 필요하다. 시작이 반이라고 했다. 이번 연재를 계기로 최적화의 시작에 한보를 더하자.  
 





지 난 연재에서 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 명령



반복분기에서의 최적화
어 떠한 프로그램이든지 중요한 연산의 가장 큰 기둥이라고 할 수 있고 대량의 연산을 하는 데에 있어 거의 필수적으로 사용되는 것이 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>의 수행속도 비교


출처: http://www.imaso.co.kr/?doc=bbs/gnuboard.php&bo_table=article&wr_id=380