편견이 깨지는 어셈블리 프로그래밍 - 최적화 강좌 1 - 2  
 
 





파이프 라인과 스캐일러스
파 이프 라인(pipe line)에 대해서 설명하기 전에 잠깐 인텔 80×86의 클럭 속도에 대해 이야기하겠다. <표 1>을 참고해 살펴보자. 인텔 80×86 계열의 CPU는 1978년 8086 발표 당시 8MHz의 클럭 속도로 시작한다. 15년이 지난 후 펜티엄이 발표되는 1993년, 클럭은 8086 시절보다 50MHz 정도의 증가를 보인다. 그럼 펜티엄이 발표된 지 어느덧 10년이 지난 현재 CPU의 클럭 속도는 어떠한가? 인텔 80×86의 발전 속도를 참고했을 때 현재의 펜티엄의 속도는 100MHz 정도를 겨우 넘기고 있을 것이라고 예상된다. 하지만 현재 펜티엄의 클럭은 GHz 대역을 넘은 지 오래 전이다. 언제부터 갑자기 속도가 증가했을까. <표 1>을 보면 인텔 486에서 펜티엄으로 넘어갈 때 소폭의 증가, 펜티엄에서 펜티엄 프로로 넘어가면서 갑작스런 속도의 증가를 보임을 알 수 있다. 그럼 갑자기 속도가 증가한 이유는 무엇일까?
어떠한 방법으로 클럭 속도가 급속으로 증가되었느냐를 알아보기 전에 그러면 ‘클럭의 속도 증가가 왜 중요한가’에 대해서도 알아보자. 클럭이란 사전적 의미로 시계란 의미가 있다. 즉 1초 동안 얼마만큼 처리할 수 있는가를 가늠하는 기준이 되는 것이다. 즉, 100MHz라 하면 1초 동안 100×100만, 즉 1억 번 진동한다는 것이다.
컴퓨터는 이러한 진동을 기본으로 해서 명령어를 처리한다. 명령어를 처리하는 데 걸리는 클럭 수를 ‘클럭 사이클’이라고 한다. CPU가 하나의 명령어를 처리할 수 있다고 할 때 하나의 명령어가 처리되는 동안 다른 명령들은 대기하고 있어야 된다. 그렇다면 클럭이 빨라지면 같은 시간 내에 많은 명령어를 처리할 수 있기 때문에 컴퓨터의 성능이 향상되는 건 당연한 애기 아닌가. 그렇다고 CPU의 클럭을 무작정 높이기에는 문제가 생긴다. 클럭이 높아지게 되면 발열량이 많아지게 된다. 이렇게 발생되는 열은 CPU 오작동의 원인이 된다.
다음 번 문제로 Hz란 단위에 대해서 생각해 보자. Hz는 1초당 진동수를 말한다. 그렇다면 Hz 값이 크다는 것 즉 컴퓨터 속도가 빠르다는 것은 같은 작업량을 더 적은 시간에 처리해야 된다는 뜻이다. 작업량은 같은데 속도를 빠르게 하는 것, 즉 작업 처리 시간을 줄이는 것도 한계가 있기 때문이다. 그렇다면 CPU 클럭에 한계가 있다고 하면 속도를 더 높일 수는 없을까? 그래서 우리는 이 문제의 대안인 파이프(pipe)와 스캐일러스(scalars)를 이야기하게 된다.






?<1> 인텔 80X 86 성능 계보



CISC RISC CRISC
범용 마이크로프로세서를 구성하는 요소에는 명령세트, 레지스터, 메모리 공간 등이 있다. 이중 명령세트는 RISC
(reduced instruction set computer)와 CISC(complex instruction set computer)의 2가지로 크게 분류할 수 있다
CISC란 소프트웨어 특히, 컴파일러 작성을 쉽게 하기 위해 하드웨어화 할 수 있는 것은 가능한 모두 하드웨어에게
맡긴다는 원칙 아래 설계된 컴퓨터이다. 반면 RISC는 실행 속도를 높히기 위해 가능한 한 복잡한 처리는 소프트웨
어에게 맡기는 방법을 택한 컴퓨터이다.
RISC의 특징을 CISC와 비교하여 알아보면 다음과 같다. 첫째, 명령의 대부분은 1머신 사이클에 실행되고, 명령
길이는 고정이며, 명령세트는 단순한 것으로 구성되어 있는데, 가령 메모리에 대한 액세스는 Load/Store 명령으로
한정되어 있다. 둘째, 어드레싱 모드가 적으며, 마이크로 프로그램에 의한 제어를 줄이고, 와이어드 로직을 많이
이용하고 있다. 반면에 레지스터수가 많으며 마이크로 프로그램을 저장하는 칩의 공간에 레지스터를 배치한다.
셋째, 어셈블러 코드를 읽기 어려울 뿐 아니라 파이프라인을 효과적으로 사용하기 위해서 일부 어셈블러 코드를
시계열로 나열하지 않은 부분이 존재하여 컴파일러의 최적화가 필요하다. 최적화를 하지 않으면 파이프라인을
유효하게 이용할 수 없고, RISC을 사용하는 의미가 없어진다. CISC방식에 바탕을 둔 펜티엄칩은 대신 잘게 자른
명령어를 동시에 처리하는 기술을 RISC방식에서 빌려왔다. 그래서 RISC와 CISC를 혼합한 CRISC방식이라고
부른다


파이프 라인
파 이프 라인은 같은 클럭 속도를 일반적인 방법으로 높이는 데 한계가 있어 클럭 당 실행되는 유닛의 수를 줄이는 방법을 이용해 클럭은 높이면서 CPU에 부담을 덜 주는 기술이다. 그렇다면 어떠한 파이프 라인이 어떻게 구성되어 있고 어떠한 방법으로 실행이 되는지 펜티엄을 기준으로 설명하겠다.
펜티엄의 파이프 라인은 모두 다섯 단계의 처리 과정을 거처 정수 명령을 실행한다. 명령어 가져오기(prefetch), 명령어 해석(decode1), 어드레스 생성(decode2), 실행(execute), 기록(writeback) 등이 그것이다. 하나의 명령어가 명령어 가져오기 단계를 지나 명령 해석 단계로 진입하는 동안에 그 파이프 라인은 다른 작업을 할 수 있다. 작업량이 많은 경우 펜티엄은 각각의 파이프 라인마다 1개씩, 총 2개의 명령을 동시에 제공할 수 있다. 펜티엄 Ⅱ, 펜티엄 Ⅲ, 펜티엄 4의 파이프 라인도 기본적인 개념은 펜티엄의 파이프 라인과 같다. 다만 버전이 올라갈수록 유닛의 개수만 증가된다. 그렇다면 파이프 라인을 적용한 CPU는 파이프 라인을 적용하지 않은 CPU와 어떠한 차이가 있을까.
<그림 3>에서 파이프 라인을 적용하지 않은 CPU1의 경우 한 마이크로 오퍼레이션으로 결과 값이 출력되지만 공정 하나에서 디코딩, 디스패치/실행, 결과 출력을 한 번에 해야 하므로 상당한 부하가 걸린다. 파이프 라인을 적용한 CPU2의 경우 한 명령이 4번의 공정을 거쳐야 하므로 실질적으로 마이크로 오퍼레이션이 1클럭만 소요된다고 가정한다면 4클럭이 걸려 처리되게 되는 것이다. 즉 한 명령을 실행하는 데 CPU1보다 공정 개수만큼 느리다는 뜻이다. 하지만 각 공정은 한 명령을 처리하고 나면 바로 다음 명령을 처리하므로 리셋된 상태에서는 초기 명령이 파이프 라인을 빠져 나오는 데 걸리는 시간을 제외하고는 이후부터는 CPU1과 같게 된다. 파이프 라인의 과정에 대해 더 자세히 살펴보자(<그림 4>).
맨 처음 초기 상태에서 파이프는 비어 있는 상태다. 매 클럭마다 명령어가 차례대로 파이프로 진입을 하며 한 클럭에 한 과정씩 처리하게 된다. ‘Prefetcher - decode - Excute - WriteBack’의 과정을 거쳐 4클럭이 되는 때에 맨 처음 들어갔던 code 1이 처리되어 나오게 된다. 결국 하나의 명령을 처리하는 데 소요되는 클럭은 무려 4클럭이나 된다는 뜻이다. 뭔가 이상하지 않은가? 파이프 라인을 적용하지 않은 CPU1이 파이프 라인을 적용한 CPU2보다 더 느리지 않은가? 이렇게 반문하는 독자도 있으리라고 본다. 파이프 라인을 적용한 CPU2가 더 느린 것을 보고 의아해 하는 독자는 그것은 파이프 라인이 지니는 목적을 이해하지 못했기 때문이다.
흔히 CPU 광고 및 속도 관련 개론이 나올 때 파이프 라인이라는 용어를 자주 접하기 때문에 파이프 라인을 적용하면 속도가 더 빨라지는 것으로 생각할 수도 있다. 하지만 이는 잘못된 생각이다. 파이프 라인은 ‘같은 클럭에서 적용시켜 명령 처리 효율을 높여주는’ 메커니즘이 아닌 ‘처리에 따른 부하를 분산시켜 클럭을 증가시켜도 견딜 수 있는’ 메커니즘인 것이다. 아무리 반도체 기술이 발전해도 속도를 무한히 높일 수 없기 때문이다. CPU 속도를 증가시키기 위해 가장 확실한 방법은 클럭을 높이는 것이다.
하지만 클럭이 높아질수록 그만큼 명령 처리 유닛이 한 클럭 내에 명령을 처리해 내야 한다는 부담이 생기 때문에 클럭을 높이기만 하는 데에는 한계가 있다. 그래서 파이프 라인은 명령의 처리를 각 공정으로 분산시켜 공정당 한 클럭에 처리하도록 하는 것이다. 각 공정으로 분산시키면 그만큼 하는 일이 단순해지기 때문에 견딜 수 있는 클럭 속도도 높아지게 된다.
그렇다고 무작정 파이프만 증가시킨다고 좋은가? 파이프 라인을 증가시킬수록 부하는 줄어들지만 한 명령 당 실행되는 공정이 늘어나므로 결국에는 명령어 처리 시간이 늘어나게 되는 것이다. 명령어 처리 시간이 늘어나게 되면 결과적으로 느려지느냐고 반문할 수 있겠지만 한 공정이 명령을 처리하면 처리된 것을 바로 다음 공정으로 넘기고 자신은 바로 다음 명령을 처리하므로 파이프 라인 없는 단일공정 CPU에 비해 파이프 라인 수만큼 시간적 손해를 볼 뿐 결과적으로 출력이 시작되면 단일공정 CPU와 같은 속도를 내게 된다. 즉, 어떻게 보면 클럭 수를 높이기 위한 고육지책 중 하나라고 볼 수 있는 것이다. 이런 파이프 기술에 의해 CPU의 클럭 속도는 1GHz대를 돌파할 수 있게 된 것이다.

스캐일러스
파 이프 라인으로 인해 우리는 더 빠른 클럭 속도를 사용할 수 있게 되었다. 하지만 CPU 제작사들은 이에 만족치 않고 좀더 처리속도를 증가시킬 수 있는 방안을 모색했는데, 그 중 하나가 바로 스캐일러스이다. 스캐일러스는 한 클럭에 처리할 수 있는 명령 수를 증가시킨 것이다. 즉, 파이프 개수를 늘리는 것이다. 한 CPU 안에 파이프 라인이 한 개가 아닌 두 개 이상의 구조를 가지는 것을 스캐일러스라고 한다. 실제로 펜티엄은 두 개의 정수 파이프 라인과 한 개의 실수 파이프 라인을 가지고 있고 펜티엄 Ⅱ 이상 버전으로 넘어 오면서 MMX 연산 등 여러 파이프가 추가된다.


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


편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 3 - 3  
 
 





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의 질문 게시판에 올려주기 바란다.

정리 | 위윤희 | iwish@korea.cnet.com


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

신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 3 - 2  
 
 


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

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

??<2> <리스트 2><리스트 3>의 실행 예?? * 단위: 클럭






??<표 3> 배열 제어 성능 측정



<리스트 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바이트 남짓한 문자열을 검사한 후 출력하는 방식이 과연 손해라 할 수 있을까?


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


신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 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


신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 2 - 4  
 


< 리스트 5>는 정렬된 메모리 블럭을 Win32 API(CopyMemory), DtCopyMemory, DtCopyMemoryMacro, 일반 복사 이렇게 네 가지 방법으로 복사를 한 후 각 방법별 소요 클럭 수를 출력한다.

<리스트 5> DummyCopy.cpp 각 함수들을 테스트하기 위한 메인 코드(정렬된 주소)

.... 생략 ....

void CopyTest (int iMaxLength)
{
.... 생략 ....
char *pSrc = new char [iMaxLength] ; // 정렬된 주소
char *pDest = new char [iMaxLength] ;
.... 생략 ....

// 일반적인 char 단위의 복사(Normal)
DtGetClockCount (&iFirstClockCount) ;
for (int i = 0 ; i < iMaxLength ; ++i)
{
*pDest = *pSrc ;
}
DtGetClockCount (&iSecondClockCount) ;
.... 생략 ....

// Win32 API (CopyMemory) 사용 복사
DtGetClockCount (&iFirstClockCount) ;
CopyMemory (pDest, pSrc, iMaxLength) ;
DtGetClockCount (&iSecondClockCount) ;
.... 생략 ....

// DtCopyMemory 함수 사용 복사
.... 생략 ....

// DtCopyMemoryMacro 함수 사용 복사
.... 생략 ....

// 결과화면 출력
printf ("Normal Char Copy (C Style) = %9d Clocks " , iNormalCharCopy) ;
printf ("Win32API CopyMemory = %9d Clocks " , iCopyMemAPICopy) ;
printf ("DtCopyMemory(TeamBase) = %9d Clocks " , iDtCopyMemCopy ) ;
printf ("DtCopyMemoryFast(TeamBase) = %9d Clocks " , iDtCopyMemCopyMacro ) ;
.... 생략 ....
}

void main ()
{
CopyTest (1024) ;
}

< 리스트 6>는 <리스트 5>와는 반대로 정렬이 안된 메모리 블럭을 Win32 API(CopyMemory), DtCopyMemory, DtCopyMemoryMacro, 일반 복사 이렇게 네 가지 방법으로 복사를 한 후에 각 방법별 소요 클럭 수를 출력한다. 코드를 살펴보면 인위적으로 메모리 포인터를 1씩 증가시킴으로써 정렬되지 않는 주소를 만들어냈다.

<리스트 6> DummyCopy.cpp 각 함수들을 테스트하기 위한 메인 코드(정렬되지 않은 주소)

.... 생략 ....

void CopyTest (int iMaxLength)
{
.... 생략 ....
char *pSrc = new char [iMaxLength] ; // 정렬된 주소
char *pDest = new char [iMaxLength] ;
.... 생략 ....

pSrc++; // 정렬을 인위적으로 어긋나게 함
pDest++;

// 일반적인 char 단위의 복사
.... 생략 ....

// Win32 API (CopyMemory) 사용 복사
.... 생략 ....

// DtCopyMemory 함수 사용 복사
.... 생략 ....

// DtCopyMemoryMacro 함수 사용 복사
.... 생략 ....

// 결과화면 출력
.... 생략 ....
}

void main ()
{
CopyTest (1024) ;
}




<그림 4> 정렬된 주소의 메모리 복사 평균 소요 클럭 수



<그림 5> 정렬이 안된 주소의 메모리 복사 평균 소요 클럭 수


<표1> 정렬된 주소의 메모리 복사 소요 클럭 수





?<1> 정렬된 주소의 메모리 복사 소요 클럭 수



<표 2> 정렬 안 된 주소의 메모리 복사 소요 클럭 수





?<표 2> 정렬 안 된 주소의 메모리 복사 소요 클럭 수



수 행시간을 측정한 결과를 보자. 일반적인 메모리 복사 방법을 사용한 함수의 수행 클럭 수와 DtCopyMemoryMacro 함수의 수행 클럭 수가 크게는 15배 정도 차이가 나는 것을 알 수 있다. 버스트 모드의 혜택을 받지 못하는 일반 메모리 복사의 경우, 버스트 모드 설명 시 언급했듯이 메모리를 읽을 때 마다 읽기위한 메모리 주소를 전송해야 하므로 버스트 모드의 혜택을 받는 Win32 CopyMemory API, DtCopyMemory, DtCopyMemoryMacro와는 현격히 다른 결과치를 보인다. CPU의 속도가 3GHz를 넘어가는 현 시점에서는 메모리의 최대 속도인 333MHz는 턱없이 느린 속도에 불과하므로 한번의 메모리 엑세스를 위해서 CPU의 입장에서는 오래 기다리게 되고 그 만큼 복사 효율이 떨어진다. 그러면 버스트 모드의 혜택을 받는 함수들 중 유일하게 다른 함수와 구별되게 최고의 효율을 보인 DtCopyMemoryMacro 함수를 살펴보도록 하자. 아마도 필자가 이러한 언급을 하지 않더라도 이미 수행시간 측정의 결과치를 본 대부분의 독자들은 이 함수에 대한 호기심을 감출 수 없을 것이다.

우선 버스트 모드의 혜택을 받는 함수간 클럭 수 차이의 원인을 보자. 앞의 측정 결과에서 Win32 CopyMemory API나 DtCopyMemory 함수가 DtCopyMemoryMacro 함수보다 2~3배 정도 속도가 느리다. 그 이유는 두 함수 공히 함수 내부에서 소스 주소와 대상 주소가 4바이트 단위로 정렬되어 있지 않은 경우, 시작 주소부터 첫 번째 4의 배수 주소까지 일반 복사를 한다. 그 이후부터 마지막 4의 배수 주소까지는 버스트 모드의 혜택을 얻어 복사하고 남은 몇 개의 바이트는 일반 복사로 채우는 코드가 들어가 있다.
함수가 이렇게 작성된 이유는 프로그래머에게 소스 주소와 대상 주소 또한 복사할 데이터 블럭 크기의 4바이트 정렬 여부에 상관없이 나름대로 합리적인 퍼포먼스를 제공하기 위한 코드라 할 수 있다. 그래서 일반적인 메모리 복사에는 Win32 API인 CopyMemory를 권장하는 것이다. 하지만 최고의 퍼포먼스와 효율을 추구하는 멀티미디어 환경에서는 이러한 미세한 차이도 엄청나게 크게 부각될 수밖에 없다.

만일 메모리 생성 시 VirtualAlloc을 사용한다면 생성되는 메모리는 4의 배수 위치에 크기가 페이지 단위(x86에서 4K = 4의 배수)로 할당되기에 이러한 메모리 블럭 간 복사에서는 CopyMemory API나 DtCopyMemory에서 구현된 버스트 모드 적용을 위한 코드가 오히려 퍼포먼스를 떨어뜨리는 결과를 가져온다. 일반적으로 많이 쓰는 new나 malloc 같은 메모리 할당함수의 경우에도 메모리를 4의 배수 위치에 할당한다. 그러므로 이 역시 CopyMemory API나 DtCopyMemory를 사용한다면 필요 없는 비교 및 분기 코드가 실행되게 되는 것이다.

그럼 여기서 버스트 모드에 적합한 환경에서 최고의 효율을 낼 수 있는 <리스트 4>의 DtMemoryCopyMacro 함수를 보도록 하자. 구현 코드는 상당히 적다. esi에는 4의 배수 주소를 가진 소스의 주소를 넣고 edi에는 4의 배수 주소를 가진 목적지 주소를 넣는다. 그리고 ecx에 복사가 일어날 횟수를 넣어주고 cld를 이용하여 복사 시 주소의 방향을 증가 방향으로 셋팅한 후, rep movsd로 dword(4바이트)씩 한번에 복사한다. 이렇기 때문에 함수 호출시 인자를 4로 나눠 준 것이다.

<리스트 4>의 코드를 쓰면 <그림 4>, <표 1>에서 볼 수 있듯이 버스트 모드의 혜택을 받기 위한 분기, 비교 등의 구문이 없으므로 최고의 퍼포먼스를 낼 수 있다. 하지만 주소 정렬되지 않은 메모리 블럭에서 DtCopyMemoryMacro 함수를 쓰면 당연히 이러한 혜택을 받지 못한다. 더구나 CopyMemory API나 DtCopyMemory 함수와 같은 버스트 모드 혜택을 위한 코드가 없기 때문에 결국 <그림 5>, <표 2>에서 보다시피 두 함수보다 못한 결과를 보이게 된다.

이러한 점을 염두에 두고 DtCopyMemoryMacro 함수를 사용할 수 있는 환경이 어떤 때인가를 잘 판단하여야 할 것이다. <표 1, 2>를 보면 중간중간 수행 클럭 수가 갑자기 높아지는 경우가 종종 보이는데, 이것은 OS의 멀티쓰레드 지원으로 인한 컨텍스트 스위칭(context switching)이 일어나기 때문이다. 수행 중 수행 권한이 다른 프로세스나 쓰레드로 이동 후 되돌아 온 경우, DtGetClockCount 함수는 rdtsc를 이용해 클럭 수를 얻어오기 때문에 클럭 수가 높은 결과를 보이게 되는 것이다. rdtsc를 사용할 때도 이러한 것을 염두에 두고 갑자기 결과치가 터무니없이 높게 나오더라도 놀라지 말기를 바란다. 그럴 때는 여러 번 실행해 평균치를 사용하는 게 좋을 것이다.

이론과 실제의 차이
이번 호에서는 버스의 구조와 데이터 흐름의 최적화에 대해 중점적으로 알아보면서 한 클럭의 속도라도 빠르게 만들려고 노력하는 개발자에게 도움이 될 만한 실무적인 내용을 언급했다. 이번 연재를 통해 간단히 데이터를 옮겨오기 위한 이론과 실제의 차이를 보여주고 싶었다. 객체지향 언어와 각종 고급화된 툴이 자웅을 겨루는 이 시기에 근본을 보자고 외치는 것이 공허하게 들릴 수 있다. 하지만 그것이 우리가 작성할 프로그램의 시작과 끝임을 알기에 오늘도 그 근본을 이해하려 노력하고 있다.
다음 호에서는 지금까지 알아본 CPU 관련 최적화, 버스 관련 최적화를 이용해 어떻게 실무에 응용될 수 있는지를 알아보겠다. 잘못된 부분이나 궁금한 점은 필자의 전자우편이나 http://http://myhome.hitel.net/~DAMGI/의 질문 게시판에 써주길 바란다.

정리 | 위윤희 | iwish@korea.cnet.com



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


신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 2 - 3  
 
 





버스트 모드
버 스트 모드(burst mode)란 한 마디로 블럭 단위로 된 일정량의 데이터를 전송 완료 시점까지 중단 없이 고속으로 전송하는 방식을 말한다. CPU와 노스 브릿지 그리고 메모리 사이에서 일어나는 버스트 모드는 메모리의 시작번지와 요구하는 데이터 블럭의 크기를 명시하면 전송 완료 때까지 중단 없이 데이터를 고속으로 전송하는 방식이다. 즉, 메모리로부터 데이터를 요구할 때마다 그 데이터의 주소를 전달하지 않는다는 말이다(<그림 2>).

<그림 2>는 일반적인 데이터 읽기이다. 일반적으로 데이터를 읽기 위해서는 데이터를 읽을 위치 즉 메모리 주소를 알려주면 그 메모리에서 데이터를 받을 수 있다. 버스트 모드는 어떻게 차이가 날까. 버스트 모드를 살펴보자.



<그림 2> 일반적인 데이터 읽기



<그림 3> 버스트 모드 데이터 읽기


< 그림 3>은 버스트 모드를 사용한 데이터 읽기이다. 우선 버스트 모드임을 알리고 필요한 데이터의 시작 주소를 주면 메모리는 CPU에게 32바이트씩 데이터를 전달하게 된다(데이터를 펜티엄에서는 한번에 8바이트씩 전송하므로 8바이트 × 4번 = 32바이트이다). <그림 2, 3>처럼 일반적으로 전송할 경우 8번의 작업이 필요하던 공정이 버스트 모드를 씀으로서 5번의 작업으로 줄어드는 것이다. 그렇다면 데이터 전송을 할 때마다 버스트 모드를 사용하지 못할까. 이처럼 버스트 모드는 순간적으로 빠른 데이터 전송 환경을 제공하지만 제한된 시간과 특정 조건에서만 가능하기 때문에 장기간 일정하게 이용하기에는 부적합하며 주로 대용량의 데이터 전송에 많이 쓰이게 된다.

버스트 모드에 연관되는 것은 연속되는 주소의 순서와 주소의 위치인데 일반적으로 주소의 순서는 movs 명령이 쓰기에 적합하게 되어 있다. 그러나 주소의 위치의 경우 기본적으로 4의 배수의 주소에서만 동작하게 되어있기 때문에 스트링의 중간을 복사해 오거나 하는 작업 등에서는 이 주소가 4의 배수가 아닐 확률도 크기 때문에 항상 연속된 데이터 이동이 버스트 모드의 해택을 받는 것은 아니다. 그래서 실제로 Win32 API인 CopyMemory를 보면 4의 배수 정렬을 위한 코드가 삽입되어 있는 것을 알 수 있다. 메모리 정렬을 통해 버스트 모드를 사용할 수 있게 한 것이다. 실질적으로 적은 메모리 공간이라면 별 이득이 없겠지만 버스트 모드의 잇점은 그런 조건 분기에서 걸리는 시간을 감수하고도 더 빠를 수 있다는 것이기 때문에 대용량의 메모리 복사에서는 최적화에 필수적이다.

이제 우리가 버스트 모드를 사용해 볼 시간이다. 어떻게 하면 버스트 모드를 사용할 수 있는지 코드를 통해 알아보고, 또한 일반 메모리 복사 코드와 버스트 모드를 적용한 코드와의 비교를 통하여 버스트 모드의 장점을 깊이 새겨 보자.

실전! 최적화
최 적화 테스트를 위해 작성한 두 개의 함수 <리스트 3, 4>의 Win32 API인 CopyMemory, 그리고 일반적인 C-Style의 바이트 복사 방식 등 이렇게 4가지 방법으로 동일한 크기의 메모리 블럭의 4바이트 정렬시 복사 작업과, 비정렬시 복사 작업을 실행하고 소요 클럭 수를 산출해봄으로써 어떤 함수가 어떠한 상황에서 최적의 퍼포먼스를 나타내는 지 알아보도록 하자.

우선 <리스트 3, 4>의 함수인 DtCopyMemory와 DtCopyMemoryMacro를 살펴보자. 두 함수는 동일하게 지정한 메모리 영역을 복사하는 기능을 구현한다. 다만 그 내부적인 로직에는 약간의 차이가 있다. <리스트 3>의 DtCopyMemory 함수는 내부적으로 함수의 인자인 목적지 주소, 소스 주소의 주소가 4의 배수인지를 검사해 4의 배수가 아닐 경우에 버스트 모드의 잇점을 최대한 활용하기 위하여 4의 배수까지 일반 바이트 복사를 한다. 4의 배수부터 마지막 4의 배수 주소까지 버스트 모드의 혜택을 받는 복사를 하고 그리고 마지막 데이터의 일반 복사를 하는 작업이 들어가 있다. 물론 그 이외에 정방향 복사일 때와 역방향 복사일 때를 구분해 처리하는 로직도 존재한다.

<리스트 3> DtCopyMemory.asm 버스트 모드를 이용한 메모리 복사 함수 구현
.model flat, c
.stack
.data
.code

;==========================================================================================
;
; Name : DtCopyMemory
; Function : 지정한 메모리 영역을 복사
; Parameter : 1. A_source:DWORD - 목적지 주소
; 2. A_dest:DWORD - 소스 주소
; 3. A_len:DWORD - 복사할 영역의 크기
; Return Value : 복사된 byte수
;
;==========================================================================================
DtCopyMemory proc uses ecx ebx edi esi A_source:DWORD,A_dest:DWORD,A_len:DWORD

mov esi, A_source
mov edi, A_dest

mov ecx, A_len

mov edx, esi
add edx, ecx

.if (esi < edi) && ( edi <= edx ) ; A_source < A_dest <= A_source + A_len
std ; 역방향 복사

.... 생략 ....

.if ecx > 4
mov edx, edi ; 주소 정렬
and edx, 3

.if !zero? ; 4의 배수일 경우 byte 복사할 필요가 없다.

.... 생략 ....

.endif
.else
cld ; 정방향 복사
.if ecx > 4

.... 생략 ....

.if ecx > 4
push ecx ; copy ecx
shr ecx,2 ; div by 4 for DWORD
rep movsd ; copy as DWORD
pop ecx
and ecx,3
.endif
.endif

rep movsb ; copy as BYTE
pop eax

ret

DtCopyMemory endp

end

< 리스트 4>의 DtCopyMemoryMacro 함수는 메모리 블럭이 4의 배수로 정렬이 되어있다는 가정 하에 주소 값을 검사하는 코드조차 존재하지 않는다. 다만 무조건 rep movsd를 통해 4바이트씩 복사하는 루틴이 들어가 있다. 만일 실제로 메모리 블럭이 4의 배수로 정렬이 되어 있다면 버스트 모드의 혜택을 받을 것이고, 메모리 블럭이 4의 배수로 정렬이 안 되어있다면 버스트 모드의 혜택을 받지 못할 것이다.

<리스트 4> DtCopyMemoryMacro.asm 버스트 모드를 이용한 메모리 복사 함수(주소가 정렬되어 있을 때)

.586
.model flat, c
.stack
.data
.code

;==========================================================================================
;
; Name : DtCopyMemoryMacro
; Function : 4의 배수로 정렬된 메모리 영역을 4바이트씩 복사
; Parameter : 1. A_source:DWORD - 목적지 주소
; 2. A_dest:DWORD - 소스 주소
; 3. A_cnt:DWORD - 복사할 횟수
; Return Value : 복사된 byte수
;
;==========================================================================================
DtCopyMemoryMacro proc uses ecx edi esi A_source:DWORD,A_dest:DWORD,A_cnt:DWORD

mov ecx, A_cnt
mov edi, A_dest
mov esi, A_source

cld
rep movsd

ret

DtCopyMemoryMacro endp


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


신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 2 - 2  
 
 





데이터 흐름에 의한 최적화
언 젠가 컴퓨터를 연구하는 개발자들이 컴퓨터의 동작에서 제일 높은 빈도로 사용되는 동작(operation)이 어떤 것인 지를 확인해 본적이 있다 한다. 그로 인해 컴퓨터에서 제일 많이 하는 동작은 데이터 이동이라는 것을 알게 되었고, 이것으로 인해 RISC 프로세서와 같은 것이 생겨났다. 이 뜻은 데이터 흐름을 최적화하는 것이 컴퓨터 동작의 많은 부분을 최적화시켰다는 것과 같은 말이 된다. 여기서는 데이터 흐름을 최적화하는 방법을 구체적으로 생각해보자.

주소 정렬의 필요성
486 까지는 CPU와 메모리간 데이터 라인이 32비트였기 때문에 데이터를 4바이트씩 읽어왔다. CPU의 속도가 빨라지면서 버스의 속도가 CPU의 처리 속도를 따라오지 못하는 상황이 연출되었고 결국 펜티엄부터는 CPU와 메모리간 데이터 라인이 64비트로 결정되었다. CPU의 처리 속도를 버스가 감당하지 못하므로 결국 방법은 버스의 속도를 높이든지 아니면 한 번에 가져올 수 있는 데이터의 크기를 늘리면 될 것이다. 인텔은 데이터 라인을 64비트로 늘리는 안을 채택함으로서 버스와 CPU의 원활한 동작을 꾀했다.
32비트 데이터 라인을 가진 486 버스 구조에서는 CPU가 메모리로부터 데이터를 읽어올 때 4의 배수로 시작되는 주소로부터 4바이트(32비트)씩 읽어오기 때문에 4의 배수로 주소가 정렬되어 있으면 4바이트를 읽을 경우 한 번에 데이터를 읽어올 수 있지만, 그렇지 않을 경우 4바이트를 읽기 위해서는 메모리로부터 데이터를 더 많이 읽어야 한다. 그러기 때문에 4의 배수 정렬이 안 되었을 경우 속도가 느린 것이다.

64비트 데이터 라인을 가진 펜티엄도 마찬가지이다. CPU는 메모리로부터 8의 배수로 시작되는 주소로부터 8바이트씩의 데이터를 읽어온다. 만일 읽어오려고 하는 데이터가 8의 배수로 정렬이 안 되어 있다면 역시 한 번에 읽어오지 못할 것이다. 앞으로 설명할 버스트 모드는 4나 8의 배수로 정렬된 데이터 라인 크기의 데이터를 메모리로부터 읽을 때 데이터를 더 효율적으로 전송하기 위해 지원된 것이다. 노스 브릿지의 MMU(Memory Management Unit)에서 지원한다.

구조체 정렬
프로그램 내에서 구조체를 사용할 경우 MSVC++ 컴파일러는 CPU가 구조체의 맴버 접근을 최대한 빠르게 할 수 있도록 지원하기 위해 구조체가 실제적으로 메모리(스택이나 힙)에 생성될 때 구조체 내 맴버들의 메모리 배치를 버스의 구조에 맞게 자동으로 정렬한다. 펜티엄 CPU에서부터는 CPU와 메모리간 데이터 라인이 64비트이므로 한번에 8바이트씩 데이터를 읽어올 수 있고 또한 그러한 버스 구조를 지원하기 위해 맴버들을 8바이트 주소로 정렬하여 메모리에 할당해 준다.

<리스트 1>은 구조체 정렬(structure align)을 생각하지 않고 선언한 구조체들이 실제 개발자가 의도한 것과 다른 크기를 갖게 되는 경우를 보여준다. _ST4 구조체를 보도록 하자. 1바이트 char와 4바이트 integer로 구조체가 선언되어 있다. 피상적으로 생각하기에 구조체의 크기는 5바이트(1+4)가 돼야 한다. 하지만 실제로 예제 코드의 실행 결과를 보면 메모리에 생성된 구조체의 크기가 8바이트로 잡히는 것을 알 수 있다. 이것은 앞쪽에 선언된 1바이트 char가 실질적으로는 뒤쪽에 더미 바이트(dummy byte)인 3바이트를 포함해 4바이트의 공간을 차지하기 때문이다. 왜냐하면 int는 4바이트 크기를 가졌으므로 시작 주소가 4바이트로 정렬된 경우에 가장 빨리 맴버 접근을 지원할 수 있기 때문이다. 그래서 결국 구조체의 총 사이즈가 8바이트(4+4)로 되는 것이다.

_ST8 구조체도 원리는 같다. 4바이트 integer와 8바이트 double의 구조체인 경우 8바이트의 double 변수는 8의 배수의 메모리 주소에 정렬돼야 CPU-메모리간 64비트의 데이터 라인의 최대 장점을 이용할 수 있다. 따라서 integer 4바이트 뒤쪽에 더미 4바이트를 넣어 double의 시작 주소를 8바이트의 배수로 맞추게 되고, 구조체의 총 크기가 16바이트가 된다.

◆ 구조체의 맴버 위치
맴버 변수의 크기에 따른 주소 정렬(예를 들면 integer는 4바이트이므로 4의 배수 주소에 위치해야 한다)

◆ MSVC++에서 구조체 정렬 값의 의미
구조체 맴버 크기에 따른 정렬의 한계 값을 설정한다. 예를 들면, __int64는 8바이트 크기이므로 8의 배수 주소에 위치해야 하지만 구조체 정렬을 4로 했을 때는 주소 정렬 크기가 4로 제한되므로 4의 배수 주소에 위치한다.

<리스트 1> 구조체 정렬 예제
#include

struct _ST4
{
char a ;
int b ;
};

struct _ST8
{
int a ;
double d ;
};

void main ()
{
_ST4 st4 ;
_ST8 st8 ;

printf ("sizeof _ST4 = %d, &st4 = %p ", sizeof (st4), &st4) ;
printf ("sizeof _ST8 = %d, &st8 = %p ", sizeof (st8), &st8) ;
}


<리스트 2> 구조체 정렬 예제 결과
sizeof _ST4 = 8, &st4 = 0012FF78
sizeof _ST8 = 16, &st8 = 0012FF68
Press any key to continue


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


신고

편견이 깨지는 어셈블리 프로그래밍 : 최적화 강좌 2 - 1  
 
 
멀티미디어는 나날이 고급화되는 사용자의 요구에 맞춰 고품질화되고 있다. 고급화된 멀티미디어 데이터로 인해 컴퓨터는 더욱 많은 데이터 처리량을 요구받게 되었고, 이로 인해 CPU와 버스는 많은 성능 향상이 이뤄졌다. 지난 호에서는 CPU에 대해 알아보았다. 이번 호에서는 데이터 흐름을 주관하는 버스의 운용을 이해하고 이를 이용한 최적화의 가능성을 엿보도록 하자.  
 





버 스(bus)는 우리가 일반적으로 생각해 볼 때 무언가를 이동한다는 의미를 가지고 있음을 미리 짐작할 수 있다. 그럼 무엇을 이동하는 것인가. 키보드로 글을 입력하거나 마우스로 컴퓨터에 어떠한 명령을 내린다. 또한 CD-ROM에 미디어를 넣어 데이터를 검색하기도 하고 음악을 듣기도 한다. 이런 사용자의 요구사항이 CPU로 전달되고 CPU에서 처리된 사항이 다시 유저에게 돌아오기 위해 주변기기와 CPU간에 데이터를 전달해 줄 장치(규격/프로토콜)가 필요하다. 이것이 바로 버스이다. 즉, 버스는 PC에서 사용되는 각종 하드웨어(장치) - CPU, 메모리, 그래픽카드, 하드디스크, CD-ROM - 수많은 기기간의 데이터 전송을 담당한다.

이종의 기기가 서로 통신을 하기 위해서는 기기간의 인터페이스가 물리적, 논리적, 전기적으로 호환돼야 하고 신호를 주고받는 타이밍이 잘 맞아야 한다. 핀의 개수 및 논리적 위치, 데이터의 구분을 위한 알맞은 전압과 전류, 정해진 클럭 등 데이터 통신을 위한 규칙(프로토콜)이나 약속이 필요하다. 이렇듯 버스는 PC의 여러 부분들 간 통신하는 방법을 정의한다. 다시 말해 버스는 PC의 기능적인 요소들 간에 신호가 흘러 다니는 통로이다. CPU가 컴퓨터의 머리라고 한다면 버스는 이 머리를 받쳐주는 역할을 하는 것이다.

사운드카드를 예로 들어보자. 우리가 음악이나 음성을 듣기 위해 사용하는 장치가 사운드카드이다. 음악을 듣고자 한다면 우리는 음악을 재생하는 프로그램을 실행하게 될 것이다. 이 데이터가 사운드카드에 전달되어 귀로 들을 수 있는 소리로 변환되는 것이다. 그렇다면 사운드카드는 이 데이터를 무엇을 통해 받을 수 있는 것일까? 컴퓨터를 한 번 열어보자. 사운드카드가 하얀 슬롯 위에 꽂혀 있는 것을 볼 수 있을 것이다. 바로 이 슬롯이 컴퓨터 내에 데이터를 전달해 주는 장치중 하나인 PCI 버스이다. 이처럼 컴퓨터 내 거의 모든 장치들은 데이터를 주고받기 위하여 버스가 필요하다.

PC 내에서 데이터 흐름
모든 주변기기의 데이터들이 메인보드를 거쳐 이동하고 메인보드는 버스들로 이뤄진 집합체이다. 이와 같은 메인보드를 살펴보면 다음과 같이 구성돼 있다.

◆ 노스 브릿지(north bridge) : 메인보드에서 CPU와 메모리에 가까이 있는 커다란 칩이 노스 브릿지이다. 이 칩은 연결된 장치들 간에 데이터를 연결해 주는 다리 역할을 한다.

◆ 사우스 브릿지(suth bidge) : 사우스 브릿지는 노스 브릿지와 연결되어 PCI 슬롯, USB, FDD 등 기타 저속 주변기기 제어와 노스 브릿지에 데이터를 전달한다.

이 브릿지들의 역할은 여러 개의 주변기기 간에 오고가는 데이터의 흐름이 원활히 할 수 있도록 교통정리를 하는 것이다. 컴퓨터의 성능이 CPU나 메모리, 그래픽카드에 많은 영향을 받듯이 이들의 데이터 흐름을 관리하는 노스브릿지 역시 컴퓨터 성능에 중요한 역할을 한다. <그림 1>에서 노스 브릿지의 위쪽에 위치한 CPU의 속도를 보면 2GHz대를 넘어서 이제 3GHz대를 바라본다. 오른쪽의 메모리는 100MHz였던 초기 버전이 이젠 133, 266을 넘어 333MHz를 바라본다. 이게 전부인가? 아니다. 왼쪽의 AGP를 보자. 32비트 버스인 AGP 1X는 66MHz의 클럭 속도를 가진다. 기술의 발전으로 2X, 4X, 8X까지 지원한다. 이론상 66MHz × 4(32비트)바이트 × 8(배속) = 2112MB라는 엄청난 수치가 나온다.



<그림 1> 메인보드 내 데이터의 흐름


이 렇게 많은 데이터들의 흐름들 중간에 위치한 장치인 노스 브릿지의 중요성은 아무리 강조해도 지나치지 않는다. 물론 노스 브릿지의 하위에서 좀더 느린 장치들의 데이터 흐름을 관리하는 사우스 브릿지도 중요하다. 어쨌든 컴퓨터 기술이 발전되고 노스 브릿지가 처리해야 될 데이터의 양은 계속 늘어가는 추세이다. 처리량이 늘어난다는 것은 속도가 빨라진다는 것이고 이것은 발열량과 연관이 있다. 그래서 근래 시중에 나오는 메인보드를 보면 노스 브릿지에 방열판이나 쿨러가 달려 나오는 메인보드들도 심심치 않게 볼 수 있다. 절대 멋있으라고 달아 놓은 것이 아니다


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


신고

편견이 깨지는 어셈블리 프로그래밍 - 최적화 강좌 1 - 6  
 
 





어셈블리와 고급 언어의 결합
어셈블리로 만들어진 루틴을 고급 언어와 연결할 수 있는 방법으로는 다음과 같은 방법들이 있다.

인라인 어셈블리
대 부분 저수준을 함께 지원하는 비주얼 C 및 기타 언어에서는 소스 중간에 바로 어셈블리 코드를 삽입시킬 수 있는 인라인 어셈블리라는 기능이 있다. 비주얼 C의 인라인 어셈블리(in-line assambly)는 DB, DW, DD 등의 변수 선언 명령어를 제외한 거의 모든 명령어를 사용할 수 있다. 다름의 간단한 인라인 어셈블리 예제를 보자.

__asm
{
mov ecx , 38
xor eax , eax
Tl_Loop1:
inc eax

loop TL_Loop1
}

사용 방법은 비주얼 C에서 인라인 어셈블리는 함수 안에다가 __asm이라고 적어준 후 어셈블리 명령을 서술해 주면 된다.

__asm mov eax , 80
이와 같이 바로 서술해도 되고 다음과 같이 블럭을 이용해 서술해도 된다, 또한, 인라인 어셈블리에서도 VC++의 변수를 참조하거나 제어할 수 있다.

__asm
{
mov eax , 80
}

다른 연결 방법
앞 에서 소개한 인라인 어셈블리 말고도 다른 연결 방법이 있는데 DLL( Dynamic Linking Library)로 만들어 연결하거나 LIB로 만들어 연결하는 것이다. 이 두 방법은 고급 언어 간에도 자주 사용하는 방법이기도 하고 지면 관계상 넘어가기로 하겠다.

최적화를 위한 기본
우리는 지금까지 몇 가지 CPU의 특성과 어셈블리에 대해서 알아보았다. 이를 바탕으로 몇 가지 최적화를 위한 방법을 정리하면 다음과 같다.

① 분기명령을 최대한 줄인다.
② 명령은 최대한 작은 것으로 선택해 쓴다.
명령들을 잘 보면 개발자가 원하는 방향으로 같은 기능을 가진 명령어들이 여러 개 있는 것을 발견할 수 있을 것이다. 단순히 0인지 아닌지를 비교하기 위해 cmp 명령 대신 or 명령을 썼던 것과 같다.
③ 연산은 되도록 레지스터 내에서 한다.
이 부분은 매우 예민하고도 힘든 부분이다. 메모리에서의 연산은 레지스터에서의 연산보다 느리다. 따라서 레지스터를 적극 활용하는 것을 권장한다. 하지만 계획성 없는 레지스터의 사용으로 인해 코드가 오히려 길어지는 경우가 생긴다. 우리는 이것을 경계해야 한다.
④ 파이프 라인의 룰을 따른다.
많은 생각을 할 부분이다. 파이프 라인의 특성은 CPU마다 약간씩 차이가 있지만 일반적으로 연계된 레지스터나 메모리의 사용이 있는 코드들이 가까이 모여 있지 않고 분산되어 있을수록 파이프 라인과 스캐일러스의 효율이 증가한다.

최 적화란 단원 자체가 만만찮은 영역인지라 이번 호에서는 주로 CPU의 특성과 어셈블리의 고급 기능에 대하여 알아보았다. 다음 호부터는 실질적인 코딩에 들어가도록 하겠다. 아직은 하드웨어 성능이 사용자가 원하는 만큼 충분히 내주지 못하기 때문에 약간의 속도라도 짜내기 위해 노력하는 많은 개발자들을 보았다. 특히 게임이나 멀티미디어 분야에서 이런 노력을 많이 봐 온 필자로서는 작은 경험이나마 이런 독자들에게 도움이 되었으면 하는 바람이다.
다음 호에서는 버스에 대해 간략히 알아보고 데이터에 대한 최적화 부분을 다루겠다. 참고로, 각 연재마다 필자가 속해 있는 프로젝트 팀인 ‘Team BASE’의 사람들이 분담해 원고를 진행할 예정임을 밝혀둔다. 잘못된 부분이나 궁금한 사항은 http://www.ultrasys.co.kr의 잡담 게시판에 남겨주기 바란다(임시로 사용하는 게시판임을 양해 바람).


[ Calling Conventions ]
Calling Conventions란 간단하게 말하면 함수에서 인자를 어떻게 전달하느냐 하는 방식을 나타내는 것이다. <그림 1>에서 보듯이 인자를 전달하는 순서에 따라서 크게 두 가지 방식으로 나뉜다. 어떻게 차이가 나는지 알아보자.


◆ C
C 언어 계열의 컴파일러에서 주로 쓰는 스택 프레임으로써 처음 인자를 맨 마지막 스택프레임에 저장한다. 따라서 첫 번째 인자는 복귀 주소 바로 위에 위치한다(<그림 1>).

◆ StdCall
파스칼 계열의 컴파일러에서 많이 쓰는 스택 프레임으로써 호출에 나타난 순서대로 인자를 전달한다(<그림 2>).


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


신고

편견이 깨지는 어셈블리 프로그래밍 - 최적화 강좌 1 - 4  
 
 





어셈블리
앞 에서도 이야기했지만 고급 언어들의 컴파일러들은 많은 노력과 기술의 발전으로 상당한 최적화가 이뤄졌다. 하지만 CPU에 특성화되지 않는 범용 언어이기 때문에 최적화에는 한계가 있다. 이러한 부족함을 보안하기 위해 우리는 어셈블리(assembly)의 필요성을 느끼게 된다. 하지만 어셈블리로 프로그램 하나를 전체적으로 만든다는 것은 어렵고 시간이 걸리는 일이다. 이에 우리는 새로워진 매크로 어셈블리와 여러 가지 기법을 통해 이에 대한 해결책을 제시하고자 한다.

매크로 어셈블러
여러 다른 언어들이 그동안 계속 발전했듯이 어셈블리도 계속 발전해 왔다. 그 중에서 필자는 매크로 어셈블러(macro assembler)를 소개할까 한다. 매크로 어셈블러는 마이크로소프트에서 제작한 것으로 모듈화를 위한 매크로(macro) 기능이 돋보이는 어셈블러이다. 매크로 어셈블러를 대표하는 MASM 5.0에서 MASM 6.0 세대로 넘어오면서(다른 고급 언어에 비해 어셈블러는 버전 업이 굉장히 느리다) 프로그래밍 작업을 편리하게 만드는 여러 기능을 추가했는데 그 중에서 하나가 고급 명령 지원이다.
한 가지 알아 둘 것은 이 고급 문법은 CPU에서 지원해주는 명령어는 아니다. 즉 컴파일시 하나의 Opcode로 변환되는 것이 아니라 몇 개의 기존의 명령어 조합으로 바뀐다. 바꿔 말하면 컴파일러에서 사용자 편의를 위해 지원해 주는 매크로인 것이다. 그러면 코드를 통해서 MASM 6.0 이상의 버전은 그 이전 버전의 MASM과 어떻게 다르며 무엇이 쉬워졌는지 알아보자

프로시저 - invoke, proto, proc
어셈블러에서는 타언어의 함수 호출을 할 수 있게 일일이 함수 타입을 맞춰 스택에 파라미터들을 Push해주곤 해야 했다. 하지만 MASM이 6.0대에 들어서면서 이런 불편들을 해소하기 위해 PROC 명령어를 확장하고 INVOKE와 PROTO를 추가함으로써 ONE PASS 컴파일러의 함수 호출의 면모를 보여 주기 시작했다. 그럼 간단하게 예를 들어 설명해보자(<리스트 1, 2>).

<리스트 1> MASM5.0에서 작성한 stack과 call명령어를 사용한 함수 작성
.586
.model flat, stdcall
option casemap:none

.data
V_Temp dd (0)

.code
start:

Main proc

push 3
push 2
push 1
call Fun_AddThreeValue
;invoke Fun_AddThreeValue, 1, 2, 3
mov V_Temp,eax

Main endp

;-------------------------------------------------------;
; ;
; 함수 이름 : Fun_AddThreeValue ;
; 동 작 : 3개의 dword값을 받아 들여서 더한다 ;
; 반 환 값 : 더한값 ;
; ;
;-------------------------------------------------------;
Fun_AddThreeValue proc
push ebp
mov ebp,esp
push ebx

mov ebx, dword ptr [ebp+4]
add ebx, dword ptr [ebp+8]
add ebx, dword ptr [ebp+12]
mov eax, ebx

push ebx
mov esp,ebp
pop ebp

ret

Fun_AddThreeValue endp

end start

<리스트 2> MASM7.0에서 작성한 Invoke를 사용한 함수 작성
.586
.model flat, stdcall
option casemap:none

Main proto
Fun_AddThreeValue proto A_ValueOne:dword, A_ValueTwo:dword, A_ValueThree:dword

.data
V_Temp dd (0)

.code
start:

Main proc

................생략 ...........

push 3
push 2
push 1
call Fun_AddThreeValue (00401040)

................생략 ...........

Main endp

;-------------------------------------------------------;
; ;
; 함수 이름 : Fun_AddThreeValue ;
; 동 작 : 3개의 dword값을 받아 들여서 더한다 ;
; 반 환 값 : 더한값 ;
; ;
;-------------------------------------------------------;

Fun_AddThreeValue proc A_ValueOne:dword, A_ValueTwo:dword, A_ValueThree:dword
push ebx

mov ebx, A_ValueOne
add ebx, A_ValueTwo
add ebx, A_ValueThree
mov eax, ebx

push ebx

ret

Fun_AddThreeValue endp

end start

함 수를 구성할 때 ‘Invoke, Proto, Proc’ 명령어를 사용함으로써 문법이 단순해지는 것을 볼 수 있다. 몇 가지 특징을 볼 수 있는데 우선 함수를 사용하려면 헤더를 선언해야 되는데, 이 함수의 프로토 타입을 선언해 주는 명령이 ‘proto’이다. 여기서 보면 proc와 연계해 사용하는데 proc와 연계되지 않고 proto 타입만 선언되어 있을 경우 컴파일러는 자동적으로 외부 함수 호출로 받아들인다. 고급 언어의 함수 선언과 다를 바 없지 않은가?
여기서 invoke와 proto를 사용한 함수 선언 및 지정은 아규먼트 전달에 스택을 사용하기 때문에 몇몇 어셈블리를 사용하는 독자는 속도 향상 없이 어셈블리답지 않고 어셈블리의 맛이 없다고 얘기하는 경우도 있을 것이다. 하지만 우리가 만약 한 클럭이라도 아끼고자 코딩해야 되는 상황이라면 당연히 그래야겠지만 기본적으로 함수 호출과 같이 어쩔 수 없이 브랜치가 발생하는 상황에서 파라미터 설정 정도의 클럭을 아끼는 것보다 대량의 연산 등과 같은 부분에서의 최적화가 훨씬 도움이 된다는 것을 생각하면 앞으로 코딩을 하면서 무엇을 버리고 무엇을 남겨 두는 것이 프로젝트의 완성도를 높여 주는 것인지를 생각하는데 도움이 될 것이다. 즉, 클럭을 아끼는 것도 중요하겠지만 큰 흐름을 생각한다면 충분히 접어 둘 수 있는 사항인 것 같다.


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


신고

+ Recent posts