델파이 최적화 가이드라인 (1) – 일반 가이드라인

아래 글은 원래
2002년에  Robert Lee의 홈페이지인 http://www.optimalcode.com 라는 사이트에 실렸던 “Delphi Optimization Guideline”이라는 글의 일부입니다. 지금 이 사이트는 없어진 상태이고 원래의 필자도 전혀 연락이 안되는 상태입니다. 하지만 최근에 예전의 컨텐츠들을 http://delphitools.info 에서 되살렸습니다. 델파이 개발자분들께 도움이 될 부분이 많을 것 같아서 번역을 시작해봅니다.
전체는 4개의 시리즈로 되어 있으며, 이 글은 그중 첫번째인 General Guideline 입니다. 개인적으로 볼 때 이 필자의 글은 애매하거나 너무 어렵거나 딱 적당하지 않은 단어를 쓴 경우가 너무 많아서 번역에 애를 많이 먹었습니다. 그럼에도 아직도 완전히 명확하지 않은 부분들이 꽤 남아있는 점을 양해 부탁드립니다. 

원문:  http://delphitools.info/OptimalCode/general.htm


사용자 삽입 이미지
Delphi Optimization Guidelines

여기에서 제시되는 가이드라인은 상당히 일반적인 내용이지만, 델파이 측면에서만 초점을 두었습니다. 또 최근의 인텔 CPU들(펜티엄 및 펜티엄 II)에 초점을 맞추었습니다. AMD K6-2 등의 다른 CPU들도 좋은 선택이며 여기서 거론되는 최적화로 좋은 결과를 낼 수 있을 것입니다. 하지만 다른 CPU들은 공개적으로 문서화가 잘 되어 있지 않아 확실하지는 않습니다.

이 가이드라인들은 두 부분으로 나뉘어집니다. 1) 코딩 스타일과 2) 특정 최적화 테크닉 입니다. 코딩을 진행하는 데는 몇가지 방법들이 있을 것입니다. 이 방법들 중 일부는 일반적으로 더 빠른 코드를 만들어냅니다. 이런 코딩을 소극적인 최적화라고 부를 수 있을 것입니다. 이런 부분을 설명한 부분이 “코딩 스타일” 파트입니다. 반대로, 성능 병목을 갖고 있는 특정 루틴들의 경우 이런 방법만으로 충분하지 않습니다. 이런 경우들에는 루틴들을 적극적으로 최적화할 필요가 있습니다. “최적화 테크닉” 파트가 이런 경우에 대한 것입니다. 또한, 전체 가이드라인은 주제에 따라 네 개의 아티클로 나눠져 있습니다.

(1) 일반 가이드라인
(2) 정수 가이드라인
(3) 문자열 가이드라인
(4) 부동소수점 가이드라인

(1) 일반 최적화 가이드라인

스타일 가이드라인

코딩 스타일 vs. 효율성

‘최적화’란 코드 자체의 속도 뿐만 아니라 여러분의 코드를 만들고 디버그하는 속도와도 관련된 문제입니다. 이것은 빠르지만 이해할 수 없는 코드를 만드는 것은 스스로 도움이 되지 않는다는 것을 의미합니다. 다행히도, 델파이에서 최적화된 코드를 만들었을 때 코드가 지저분해지는 경우는 흔치 않습니다. 사실, 최적화된 코드가 곧 우아한 코드인 경우가 많습니다. 덧붙여서, 동일한 애플리케이션 내에서 종종 같은 종류의 테크닉들이 사용되는 경향이 있습니다. 따라서, 여러분은 근본적으로 최고의 성능을 내는 방식으로 코딩 스타일을 정할 수 있습니다.

단순하게 유지하라

델파이 옵티마이저를 고려할 때, 복잡성은 치명적입니다. 루틴들은 단순하게 만들어야 합니다(5~8개 정도 이상의 변수가 동작하 않도록). 단일 루프 내에서 너무 많은 작업을 하지 마십시오. 루프에서 너무 많은 작업을 하면 매번 반복될 때마다 변수 주소들, 배열 인덱스들과 같은 것들이 다시 로드됩니다. 루프 자체의 오버헤드는 상당히 적기 때문에, 복잡한 루프를 여러 개의 루프로 나누거나 가장 안쪽의 루프 한두 개를 별도의 루프로 빼내는 것이 더 효율적인 경우가 종종 있습니다. 제대로만 하면 여러분의 코드의 가독성(readability)까지도 높여줄 수도 있습니다.

로컬 변수를 선호하라

지역 변수들은 루틴에 전달되는 파라미터들에 더해 루틴에서 선언되는 변수입니다. 지역 변수들만이 레지스터 변수로 변환이 가능하며, 레지스터 변수들은 모두 동일하게 빠릅니다. 따라서, 데이터를 사용하기 전에 지역 변수로 복사하는 것이 나은 경우가 종종 있습니다. 일반적으로 변수가 루프 내에서 사용될 경우에는 가장 유리합니다. 복사된 데이터를 더 빠르게 재사용할 수 있으므로 데이터를 복사하는 오버헤드는 충분히 상쇄됩니다. 이 최적화 방법은 타이트한 루프 안에서 클래스 멤버들이 사용될 경우 특히 유용합니다. 델파이는 루프 내에서 포인터와 클래스 멤버들이 사용되기 직전에 가서야 읽어들이는 경향이 있는데, 이것은 많은 불필요한 오버헤드를 발생시킵니다.

이 규칙에는 한가지 예외가 있습니다. 단순 타입의 요소들을 가진 배열의 경우입니다. 고정된 크기의 상수 데이터의 배열이 있다면, 전역 변수로 만들면 연산 중에 레지스터를 아낄 수 있습니다. 레지스터 하나를 아끼는 것은 큰 가치가 없으므로, 이 방법은 변환 테이블 등의 상수 구조에만 사용해야합니다.

파라미터 수를 적게 하라

작지만 대단히 자주 사용되는 루틴들은 3개 이상의 파라미터를 가져선 안됩니다. 3개는 레지스터를 통해 전달할 수 있는 최대치입니다. 이 규칙을 따르면 레지스터의 사용을 극대화할 수 있고 델파이 옵티마이저가 여러분의 코드를 더 개선할 수 있게 됩니다. 클래스의 메소드는 숨겨진 파라미터 Self가 있어 항상 내부적으로 전달되므로 이런 경우 2개의 파라미터만 남는다는 것도 알아두십시오.

중첩 루틴을 사용하지 말라

중첨 루틴(nested routine; 다른 루틴 내의 루틴, “로컬 프로시저”라고도 부름)은 바깥쪽 루틴의 변수가 안쪽 루틴에서도 보일 수 있도록 하기 위해 특별한 스택 조작을 필요로 합니다. 이것은 상당한 오버헤드를 일으킵니다. 중첩 대신에 프로시저를 유닛 스코프 레벨로 옮기고 필요한 변수들을 전달하도록 바꿉니다. 바이 레퍼런스(by reference) 전달이 필요하다면 var 키워드를 이용하거나 변수를 유닛 스코프의 전역 변수로 바꾸면 됩니다.

포인터 변수

중요한 테크닉 하나는 포인터를 활용하는 것입니다. 많은 프로그래머들이 액세스 바이올레이션, 메모리 누수, 다른 저수준 문제들의 잠재적인 위험을 이유로 포인터 사용을 꺼립니다. 하지만, 포인터는 델파이에서 코드를 최적화하는 데 있어 중요한 도구입니다. 다행히도, 이것은 여러분의 모든 데이터 참조를 포인터로 바꿔야 한다는 의미는 아닙니다. 여러분의 데이터에 대한 임시 참조로서 포인터를 활용하라는 것입니다. 이런 임시 변수는 일반적으로 레지스터 변수로 최적화됩니다. 결과적으로, 여러분은 실제로 머신 코드를 추가하지는 않지만 컴파일러가 중간 주소를 계속 유지할 단서를 제공하게 됩니다. 여러분은 포인터를 with 문을 사용하던 것과 아주 비슷한 방식으로 사용할 수 있습니다. 그것은, 복잡한 데이터 구조의 복잡하거나 헝크러진 주소 지정을 단순화하기 위해 포인터를 사용하라는 것입니다. with 문의 경우, 이런 작업은 컴파일러가 내부적으로 하는 일과 정확하게 일치합니다. 예를 들자면,

위와 같은 코드는, 컴파일러 레벨에서는 다음과 같이 됩니다.

 

링크드 리스트 vs. 배열

링크드 리스트와 배열 사이에서 적당한 방법을 찾아내는 것은 설계에 있어 고전적인 문제입니다. 구형 컴퓨터들(펜티엄과 그 이전)에서는 정수 곱셈은 느린 작업이었습니다. 배열을 액세스할 때 곱셈이 핵심이기 때문에, 일부 케이스들에서는 링크드 리스트가 성능상 유리하기도 했습니다. 랜덤 액세스냐 순차 액세스냐? 여러분이 정말로 랜덤 액세스가 필요하다면 5개 가량 이상의 요소를 가진 모든 데이터에서 배열이 나은 선택입니다(이것은 실험에 근거한 경험 규칙입니다). 순차 액세스이거나 유사 순차 액세스인 경우 짧은 답은, 요소 타입이 단순 타입일 경우 배열이, 더 큰 타입일 경우에는 링크드 리스트가 낫다는 것입니다. 펜티엄 II 이후에서의 곱셈은 이전보다 아주 아주 빨라졌습니다. 따라서, 배열 액세스가 항상 더 빠릅니다.

배열의 종류

델파이에서 배열은 여러 종류가 있습니다. 정적, 동적, 포인터, 오픈 배열입니다. 정적 배열은 전통적인 파스칼 배열 타입입니다(A: array[0..100] of Byte). 동적 배열은 델파이 4에서 도입된 것입니다(A: array of Byte). 포인터 배열은 정적 배열에 대한 포인터이지만, 실제 요소들의 갯수는 배열의 경계와 일치하지 않을 수도 있습니다. 마지막으로 오픈 배열은 동적 배열과 비슷해보이지만 오직 루틴의 파라미터로만 사용되는 것입니다. 이 각각의 배열들의 내부 구현은 상당히 크게 다릅니다. 효율성의 관점에서는 정적 배열과 포인터 배열이 최고의 선택이고 다음으로는 오픈 배열과 동적 배열입니다. 하지만, 정적 배열은 종종 너무 유연성이 없고 포인터 배열은 관리 문제들이 까다롭습니다. 다행히, 이 다양한 배열 타입들은 서로 변환 가능합니다. 고정된 크기가 없는 배열에 대해서 현재로서 최고의 선택은, 동적 배열로 관리하고 필요할 경우 포인터 베열로 변환하는 것입니다.

동적 배열은 변수가 실제로는 배열의 첫번째 요소에 대한 포인터라는 점에서 큰 문자열(AnsiString)과 아주 비슷합니다. 그러므로, 동적 배열을 포인터 배열로 변환하는 것은 실제로는 단순한 대입일 뿐입니다. 동적 배열의 길이(크기)는 첫번째 요소의 바로 앞에 저장되며, 동적 배열에 대해 High나 Length를 사용하면 배열로부터 길이를 뽑아내는 “컴파일러 매직” 대신 실제로 함수 호출이 일어납니다(High를 사용하면 Length가 호출됩니다). 따라서, 반복적으로 이런 함수들을 사용해서 배열의 크기를 알아내려고 하지 마십시오. 루틴에서 한번 알아낸 후 저장해둡시다.

동적 배열과 오픈 배열은 모두 파라미터로 사용할 때 const나 var로 지정하지 않으면 대단히 많은 오버헤드를 일으킵니다. 클래스 파라미터에서처럼, 동적 배열에 const를 지정하는 것은 그 내용의 변경을 막는 것이 아니라 전체 배열에 대한 변경만을 막는다는 것을 기억해둡시다.

다양한 배열 타입들을 변환하는 예제로 마무리합니다.

예외(exception)

코드 부분을 탈출하기 위한 목적만으로, 혹은 입력 오류에 대해 무조건 catch하기 위해 예외를 사용하지 마십시오. 이들은 오버헤드를 발생시키며, try..finally 블럭과 예외 자체를 throw하는 것도 마찬가지입니다. 통상적이지 않은 흐름 제어를 위해서는 break, continue, exit를 사용하고, 입력 내용에 대한 검증은 가능한 일찍, 하지만 루프의 바깥에서 해야 합니다.

absolute보다는 타입 캐스트를 사용하라

타입 캐스트를 피하기 위해 사용되는 테크닉들 중 하나는 absolute 키워드를 사용하여 한 변수를 다른 타입의 변수에 덮어쓰는 것입니다. 하지만 이 방법은 변수가 빠른 레지스터 변수가 되는 것을 막습니다. 타입 캐스트를 하고 원래의 값을 새 변수에 저장하는 것이 낫습니다. 예를 들면,

 

위의 코드는 다음과 같이 바꿉니다.

 

집합 다루기

Include와 Exclude라는 두 가지 컴파일러 매직 함수가 있는데, 단일 요소를 집합(set)에 추가하거나 제거할 때 아주 효율적입니다. 그러므로, “s := s + [a];” 같은 문장 대신 이런 함수를 사용해야 합니다. 사실, 하나의 요소 뿐만 아니라 몇번 정도는 Include나 Exclude를 반복적으로 호출하는 것이 앞서의 코드와 같이 + 연산자를 쓰는 것보다는 더 효율적입니다.

펜티엄 II 병목

문득, 여기서 제시한 테크닉들 중 많은 것들이 펜티엄 II 프로세서 병목에 기반하고 있지만 이 프로세서가 어떻게 동작하는지 설명한 적이 없다는 것을 깨달았습니다. 인텔의 문서들과 Agner Fogs Pentium Optimization Guide에서 길고 상세한 설명을 찾아볼 수 있습니다만, 여기서 저는 델파이의 컴파일러 결과물에 관련된 부분만 가지고 짧게 설명하겠습니다. 이 프로세스에 대한 이해는 최적화가 필요한 코드가 어떤 것인지 판단하는 데 도움을 줄 것입니다.

우선, 펜티엄 II는 비순차적 실행 기능을 갖춘 수퍼스칼라 파이프라인 프로세서입니다. 기본적으로 이것은 각 명령어들이 단계별로 실행되고 몇개의 서로 다른 채널들 중에서 하나로 진행하게 됩니다. 특히, 각 명령어는 로드 및 실행 단계들 사이에서 “대기실” 역할을 하는 비순차적 버퍼에서 로드되고, 실행되고, 리타이어(retire)되어야 합니다. 이것은 간단할 것 같지만 다중 채널 부분이 추가되면 복잡해지기 시작합니다. 모든 채널이 모든 명령을 처리할 수 있는 것은 아니기 때문입니다. 프로세서의 3개의 로딩 채널이 있으며, 그중 하나는 어떤 명령어이든 처리할 수 있지만 나머지 2개는 단순한 명령어만을 처리할 수 있습니다. 5개의 실행 채널(인텔에서는 포트라고 부름)이 있는데, 하나는 범용의 정수, 다른 하나는 범용 정수 및 실수, 세번째는 주소 연산, 네번째와 다섯번째는 데이터를 로드하고 저장합니다. 리타이어 단계도 3개의 채널을 가지고 있습니다. 여기에는 지연시간의 이슈도 있습니다. 실행에 1 사이클 이상이 걸리는 명령어들이 여럿 있기 때문입니다.

그럼, 이런 것들이 의미하는 게 뭘까요? 병목은 수많은 서로 다른 곳에서 발생할 수 있다는 것입니다. 기본적으로, 특정 유닛을 필요로 하는 명령들을 너무 많이 몰려오면 어떤 단계의 어떤 채널이든 병목이 될 수 있습니다. 따라서, 이론적으로 CPU가 사이클당 3개의 명령어를 처리할 수 있다고 하더라도, 실제로는 현재 실행되고 있는 명령어들의 조합에 따라 2개나 1개, 심지어는 그 이하로도 제한될 수 있다는 것입니다. 비순차적 “대기실”은 이런 상황에서 특정 포트를 기다리고 있는 명령어들 중 현재 실행중인 작업에 영향을 받지 않는 명령어들이 우회해서 다른 포트에서 실행될 수 있도록 해줍니다. 이런 방식은 특정 포트에 일시적인 백업이 있을 경우 작은 병목에 도움이 됩니다만, 대규모 백업에는 아무런 도움이 되지 않습니다. 예를 들어, 복잡한 수학 식에 대한 루프의 경우처럼 대량의 부동소수점 연산은 부동소수 계산이 가능한 포트가 하나 뿐이라는 제한에 부딛히게 됩니다. 따라서 처리 속도는 사이클당 1 명령어로 떨어지게 됩니다. 루프 자체와 관련된 오버헤드(루프 변수를 증가하고 점프하는) 명령어도 있는데, 이들 명령어들은 백업된 부동소수점 포트에 맞기 때문에 기본적으로 0 사이클을 소모합니다.

파스칼의 관점에서 보면, 타이트하고 반복적인 작업이 작업 전체에서 단 하나의 상태에서 제한될 수 있다는 것입니다. 그것은 위에서 예로 든 것처럼 부동소수점일 수도 있고, 정수 연산이나 메모리 주소 지정일 수도 있습니다. 어떤 경우이든, 효과가 있을 수 있는 유일한 최적화는 주요 측면을 쫓아가는 것입니다. 부수적인 코드를 쳐내려고 하는 것은 효과가 없습니다.

for 문의 내부

for 문을 구현하는 것은 컴파일러가 처리해야 하는 작업들 중 복잡한 축에 속합니다. 컴파일러는 정수 곱셈을 피하기 위해 많은 희생을 하는데, 이 정수 곱셈은 펜티엄 II 이전에는 상당히 느린 작업이었습니다. 이런 관계로 for 루프는 다음과 같은 수도코드로 분해됩니다.

 

위와 같은 원래의 루프는 아래와 같이 됩니다.

다른 설정도 있지만 이런 방식이 가장 흔하며, 또한 이런 방식이 문제를 일으키게 됩니다. 문제는 변수 i가 분해된 버전의 코드 어디서도 나타나지 않는다는 것 때문입니다. 하지만, 이 코드를 디버거에서 스텝 단위로 디버깅하면서 i 변수의 값을 추적하면 i에 가장 가까운 값인 counter를 보여주게 될 것입니다. 이것은 많은 프로그래머들이 자신의 루프가 역방향으로 실행되고 있다고 불평하게 만들었습니다. 물론 사실은 그렇지 않습니다. 단지 디버거가 잘못된 정보를 알려주고 있는 것입니다.

위 예제는 또한 for 루프에서 발생하는 상당한 오버헤드를 보여줍니다. 세 변수가 각각의 반복마다 증가되어야 하며, 초기화 코드들도 있습니다. 어떤 경우에는 이 오버헤드가 적어지기도 합니다. 예를 들어 m과 n이 컴파일 타임 상수일 경우, 또는m=0 이고 A와 B가 코드에서 더는 사용되지 않는 포인터일 경우, 오버헤드 코드는 줄어듭니다.

인터페이스

여기서는 인터페이스 사용의 영향에 대한 기본적인 내용들을 살펴보겠습니다. 기본적으로, 인터페이스는 스트링과 클래스를 섞어놓은 것과 비슷합니다. 스트링이 생성하거나 복사할 때마다 레퍼런스 카운트되는 것처럼, 인터페이스에서도 인터페이스 변수가 스코프 바깥으로 나갈 때마다 일정한 오버헤드가 발생합니다. 따라서, 인터페이스 변수는 객체 변수를 다루는 방식이 아니라 스트링 변수를 다루는 방식과 비슷하게 다루어야 합니다. (가능하면 const로 전달하고, 너무 많은 임시 변수를 사용하지 않도록 주의하는 등) 내부적으로, 인터페이스는 버추얼 메소드만 가진 객체와 비슷하게 동작하지만 더 나쁩니다. 사실 두 단계의 간접 참조가 일어납니다. 따라서 그에 맞게 다루십시오.

인터페이스 관련의 다른 노트.
Hallvard Vassbotn: 모든 전역 인터페이스 변수에 대해, 컴파일러는 자동으로 첫번째 변수의 주소를 가지는 전역 변수를 하나 더 추가합니다. 외부 유닛에서 그 변수를 액세스하면 이 암시적인 포인터 변수를 통해 접근하게 됩니다. 이렇게 한 이유는 패키지를 지원하기 위한 것이지만 패키지를 사용하지 않는 경우에도 동일하게 동작합니다. (더 자세히 알아보려면 The Delphi Magazine 이슈 43 아티클을 참고하십시오)

 

최적화 테크닉

 

열린 마음을 유지하라

최적화는 탑-다운 방식으로 접근하는 것이 가장 좋은 접근 방식입니다. 최적화에서 가장 강력한 컨셉은, “답을 알아내기 어려울 정도로 너무 오래 걸리면, 질문을 바꿔라” 입니다. 최고의 성능 개선은 설계와 알고리즘 레벨의 변경에서 나옵니다. 코딩의 세세한 부분까지 내려가면 여러분의 선택 가능한 방법은 상당히 제한되게 됩니다. 불행히도, 이런 하이 레벨 최적화를 멋지게 규칙들로 정리해내기는 상당히 어렵습니다. 그렇기는 해도, 성능 개선이 필요한 경우 가장 먼저 해야 할 일은 전체 문제를 모두 준비해놓고 가장 위에서부터 아래로 살펴가며 최적화를 해나가는 것입니다.

코드의 시간을 측정하라

코드 시간 측정은 보통 “프로파일링”이라고 부릅니다. 여러분의 코드의 성능을 개선시키고 싶다면, 먼저 그 성능이 무엇인지 정확히 알아야 할 필요가 있습니다. 그리고 여러분의 코드의 각각의 변경을 다시 측정해야 합니다. 애플리케이션이 정확하게 어디에서 시간을 소모하고 있는지 분석해서 결정하기 전에는 성능을 개선하려고 코드를 만지작거리지 마십시오. 이건 아무리 강조해도 충분치 않습니다.

코드의 배치

여러분의 코드의 정확한 위치와 실행 모듈 내에서의 배치가 실행 시간에 영향을 미칠 수 있다는 것을 알아두십시오. 그 이유는 최적이 아닌 주소 배치로 점프할 때 불리한 경우가 있기 때문입니다. 이런 코드 배치는 링커 작업이기 때문에 여러분이 이에 대해 잘 알고 있지 못하다면 여기에 영향을 주기 위해 할 수 있는 것은 매우 적습니다. 공간을 비워두는 코드를 추가하는 것은 가능하지만, 32비트 델파이는 4바이트(DWord) 경계에만 배치를 하기 때문에 여러분이 배치를 위해 노력을 하더라도 그것이 계속 유지되기는 어렵습니다. 따라서, 다음에 현재 루틴 위쪽의 루틴이나 유닛을 변경하면 여러분의 코드가 이동될 수 있습니다. 이 문제는 타이트한 루프에서는 최대 30%까지 속도 불이익을 일으킬 수 있지만, 더 일반적인 루프에서는 상당히 적습니다. 또한 이 문제는 전혀 무해한 코드를 이동시켰는데도 성능에 영향을 미치게 되므로 코드 시간 측정과 최적화를 어렵게 만든다는 점을 알아둘 필요가 있습니다. 결론적으로, 여러분이 성능을 높일 것으로 알고 있는 변경을 했는데 기대한 결과가 나오지 않았다면, 코드 배치의 이동 때문에 여러분 코드의 개선을 덮어버렸을 가능성이 있습니다.

CPU 윈도우를 활용하라

CPU 윈도우를 이용하기 위해 어셈블러 프로그래머가 될 필요는 없습니다. 아주 최소한, CPU 윈도우는 여러분에게 각 코드 문장들과 관계된 내부적인 복잡도에 대한 아이디어를 줄 수 있습니다. 단순히 특정 작업에서 생성된 명령어의 수를 세어보기만 해도 특정 최적화 테크닉의 효과를 추정할 수 있는 경우가 아주 흔합니다. 예를 들어, 루프 안에서 EBP 레지스터에 대한 참조가 아주 많다면(mov eax,[ebp-$04] 와 같은 명령), 변수들이 끊임없이 다시 로드되고 있다는 것을 의미합니다. 이런 리로드는 불필요하며 따라서 최적화의 최우선 대상이 됩니다.

델파이 4 이후의 버전에는 CPU 윈도우가 메인 메뉴에 있어 쉽게 찾을 수 있습니다. 하지만 델파이 2와 3 버전에도 숨겨진 CPU 윈도우가 있습니다. 이 숨겨진 CPU 윈도우를 사용하려면 레지스트리 에디터를 이용하여 레지스트리에 새 항목을 추가해야 합니다.

 

 

 

작은 루프를 언롤하라

루프 언롤(unroll)은 고전적인 최적화 테크닉이며, 델파이에서 아주 쉽게 할 수 있습니다. 하지만 이 방법은 상당히 작은 루프에서만 할 만한 가치가 있습니다. 언롤은 본질적으로 여러번의 반복 작업을 한번으로 줄이는 것입니다. 이것은 상대적인 루프 오버헤드를 줄여줍니다. 펜티엄 II CPU의 분기 예측 메커니즘은 매우 타이트한 루프에서는 그다지 잘 동작하지 않기 때문에 언롤을 하는 것이 유리할 수 있습니다.

델파이에서 언롤을 하는 가장 좋은 방법은 보통 while 루프에 하는 것입니다. 예를 들면,

 

 

위와 같은 코드를 아래와 같이 바꾸면 됩니다.


루프 언롤에서 주의할 점은, Count가 배수 2로 나눠지지 않을 경우를 고려해야 한다는 것입니다. 이 문제에 대해 다음과 같은 방식으로 처리할 수 있습니다.

여러분은 원하는 만큼의 배수로 언롤을 할 수 있지만, 언롤링의 배수를 계속 높였을 때 코드가 점점 더 복잡해지면서 일어나는 한계수익 체감의 이슈 때문에, 4 배수 이상으로 언롤링을 하는 경우는 그다지 흔하지 않습니다.

루프 내의 조건을 제거하라

루프 내의 if 문에서 조건식이 루프 인덱스에 기반하는 경우가 흔히 있습니다. 이것은 루프를 언롤하거나 한 루프를 두 개의 루프로 나눔으로써 없앨 수 있는 경우가 많습니다. 전자의 예로는문장이 매 2회마다 실행되어야 하는 경우가 있습니다. 또한 후자의 예로는 문장이 특정 반복 번째에서 실행되는 경우입니다.

루프 조건문의 수를 줄여라

어떤 조건이 true이고 루프 인덱스가 어떤 값보다 작은 동안 반복하는 루프는 흔히 볼 수 있는 코딩 구조입니다. 루프가 작다면(루프가 루프 인덱스를 증가시키는 코드만으로 구성된 경우처럼), 루프의 실행 시간의 대부분은 루프 조건문을 확인하는 데 소모됩니다. 어떤 때는 한 조건이 발생할 때 다른 조건도 충족하도록 하여 이런 조건의 수를 줄이는 것이 가능한 경우도 있습니다. 스트링 내에서 특정 문자를 찾는 예를 살펴봅시다.

스트링의 가장 마지막에 검사할 문자를 두면 조건식 두 개를 하나로 합칠 수 있습니다.

이렇게 코드를 바꾸면 속도가 거의 2배 가까이 개선됩니다. 이 최적화를 위해서는 데이터의 마지막에 빈 공간이 있는지에 대한 깊은 고려가 필요합니다. 스트링과 PChar에는 항상 마지막에 사용할 수 있는  null이 있습니다. 또한 이 테크닉은 스캔되는 데이터를 변경시키기 때문에 멀티 쓰레드 환경에서는 부작용이 발생할 수도 있습니다. 이 테크닉은 부분 반복을 필요로 하는 것과 관련된 문제들을 간단하게 만들어주기 때문에 언롤과도 잘 어울립니다. 이 테크닉에 대한 더 자세한 예는 FindMax 예제를 참고하시기 바랍니다.

점프를 없애라

이 테크닉은 오래되어 현재는 큰 의미가 없습니다만, 여전히 약간의 참고를 할 필요는 있습니다. 원래의 이유(점프 자체가 너무 오래 걸린다는)는 현재는 더 이상 문제가 아닙니다. 현재의 문제는 코드 배치와 분기 예측과 더 관계되어 있습니다. 배치의 측면은, 점프를 하지 않으면 배치의 문제도 발생하지 않는다는 것입니다. 분기 예측 측면은, 한번 지나가지 않으면 아예 예측을 하려고 시도조차 하지 않는다는 것입니다.

break, exit, continue를 활용하라

이들 흐름 제어문들을 많이 쓰면 종종 “잘못된 프로그래밍”으로 비웃음을 받습니다. 하지만 이런 흐름 제어문들도 쓰일 필요가 있을 때가 있고, 특히 성능 최적화에 더욱 그렇습니다. 이런 문들이 필요한 경우는 보통 루프의 중간에서 어떤 조건이 결정되지 않을 때입니다. 이럴 때 보통 boolean 변수를 추가하고 조건을 확인하는 코드를 넣어서 이런 상황을 피할 수 있습니다. 하지만 이 방식을 썼을 때의 추가 시간 비용은 코드를 간단하게 만드는 대신 더 복잡하게 만드는 경우가 많습니다.

어셈블러 사용을 자제하라

펜티엄 II 이상의 CPU에서는 성능 개선을 위해 어셈블러를 사용하려고 시도하지 마십시오. 여기에는 논란이 있긴 하지만, 보통은 상당히 좋은 경험 법칙입니다. 펜티엄 II 이후 CPU의 비순차 실행 기능은
알고리즘을 어셈블러로 기술했을 때의 장점을 대부분 제거해버립니다. 테스트 결과 저는 어셈블러와 최적화 코딩을 한 파스칼 코드의 성능 차이가 10% 이상 차이가 나는 경우를 거의 보지 못했습니다. 물론 항상 예외는 있고(예를 들면 알파 블렌딩 코드처럼), 어셈블러가 파스칼보다 확실히 더 깔끔한 경우도 있지만, 중요한 점은 코드가 너무 느리다고 느꼈다고 해서 바로 어셈블러로 직행해서는 안된다는 것입니다.

다른 한편으로, 펜티엄 CPU는 어셈블러 코드를 제대로 활용하는 경우가 많습니다. 하지만 펜티엄 프로세서에 대해 최적화된 코드가 다른 프로세서에서 덜 최적화 결과를 보여주는 경우가 적지 않습니다. 특히 이런 경우는 부동소수점 코드에서도 마찬가지입니다. 어셈블러 방식을 고수하려 한다면, 먼저 Agner Fog의 어셈블러 매뉴얼(http://www.agner.org/optimize/)을 제대로 공부해본 후 하는 것이 좋겠습니다.

for 루프 vs. while 루프

고정된 횟수의 반복을 하는 루프는 for 혹은 while 어느 쪽으로도 구현할 수 있습니다. 통상적으로, for 루프가 더 많이 선택됩니다. 하지만 for 루프의 기반 구현은 일부 사례에서 while 루프만큼 휴율적이지 못합니다(위의 “for 문의 내부”를 참고). 루프의 내용에 배열과 관계가 없거나 혹은 1, 2, 4, 8 바이트의 크기를 가지는 요소들을 가진 1차원 배열과만 관계가 있을 경우, while 루프는 같은 내용의 for 루프보다 더 효율적이고 깔끔합니다. 반대로, 다차원 배열이나 위에서 나열한 사이즈와 다른 크기의 요소 크기를 가지는 배열을 다루는 코드는 for 루프의 효율이 더 높습니다. for 루프와 while 루프를 서로 변환하는 것이 가능한 경우가 많고, 보통은 포인터를 이용하면 됩니다. 이 접근 방법은 코드의 효율성을 높일 가능성이 높습니다.

덧붙여서, while 루프를 사용하면 복잡도를 낮출 수 있습니다. 따라서 루틴 분할을 피하기 위해 for 루프를 대신할 수도 있습니다. 이런 경우의 예는 가우스 소거법의 로우 리덕션(row reduction)입니다. for 루프의 최적화된 설정은 가장 안쪽의 두 루프를 별개의 루틴으로 뽑아내는 것입니다. 하지만 while 루프에서는 세 루프 모두 그대로 유지할 수 있습니다.

물론 예외도 있습니다. 루프 내에서 인덱스가  전혀 사용되지 않는다면 보통 for가 더 나은 선택입니다. 또 루프의 시작-끝이 모두 컴파일 타임 상수일 경우에도 for를 시도해보십시오.

while 루프가 최대한의 효율을 내려면, 루프의 조건이 최대한 단순해야 한다는 점을 알아두십시오. 이것은 while 루프는 for 루프와 달리 반복 횟수에 대한 모든 계산을 while 루프의 바깥으로 내보내고 임시 변수를 사용해야 한다는 것을 의미합니다.

대규모 메모리 요구 문제

펜티엄 II CPU에서는 최적화에 있어 캐시나 메모리 병목이 가장 큰 문제가 되는 경우가 자주 있으며, 특히 다루는 데이터가 클 경우엔 더 그렇습니다. 이런 경우에는 완전히 다른 전략이 필요합니다. 메모리 소요량을 줄이고 데이터 통과의 수를 줄이는 데에 초점을 맞추십시오. 이것은 이 가이드라인에서 제시된 다른 제안들 몇가지와 상반될 수도 있지만, 다른 구현과 실험해보고 프로파일링 해봄으로써 어떤 것이 더 결정적인 요소인지 결정하는 것이 필요합니다. 캐시나 메모리 병목이 지배적인 요인인지 알아챌 수 있는 좋은 신호는, 분명하게 코드를 개선했는데도 성능이 개선되지 않은 경우입니다.

case 문 최적화

case 문은 다음과 같이 구현됩니다. 먼저, 컴파일러는 나열된 값들과 범위들을 정렬합니다. 이것은 case 문 내에서 각 값들의 위치는 무관하다는 의미입니다. 다음으로, 컴파일러는 일종의 2진 검색 트리 전략과 각 케이스들을 테스트하기 위한 점프 테이블을 사용합니다. 점프 테이블과 비교 트리 사이의 결정은 나열된 값들의 “밀도”에 따릅니다. 밀도가 충분히 높다면 점프 테이블이 만들어지게 됩니다. 밀도가 너무 낮다면 리스트는 약 절반으로 나눠집니다(값들의 수가 걸쳐진 갯수가 아니라 리스트에서 1 요소로서의 갯수 범위로). 다음으로 각 하위 분기로 다시 시작합니다. 즉, 밀도 체크를 한 후 점프 테이블을 생성하거나 혹은 분할하는 것입니다. 이것은 모든 값들이 처리될 때까지 계속됩니다.

그럼, 어떤 최적화 기회가 있을까요? 기본적으로 case 문은 상당히 최적화가 잘 되지만, 완벽하지는 않습니다. 2진 비교 트리로의 분할은 어색한 위치에서 일어날 수 있습니다. 따라서, 중간이 빈 연속적인 값들이 있다면, 각 연속되는 범위의 값들을 각각의 case 문으로 만들고 다음으로 전체의 case 문을 만드는 게 더 낫습니다. 이것은 범위는 분할되지 않지만 연속적인 값들은 분할되기 때문입니다. 이렇게 하면 각각의 하위 범위 전체를 커버하는 점프 테이블을 만들 수 있게 됩니다. 예를 들면,

최적화 전:

최적화 후:

 

또한, case 문은 실행의 빈도에 따라 대비가 되어 있지 않습니다. 어떤 case가 다른 case보다 더 자주 실행된다는 것을 알고 있다면, 이 정보를 실행 속도를 높이기 위해 이용할 수 있습니다. 그렇게 하려면, if 문과 case 문을 다단계로 배치하여 검색 순서에 우선순위를 주면 됩니다. 예를 들면,

최적화 전:


 

최적화 후:


Move와 FillChar

델파이에서 메모리를 옮기고 0 값으로 채우는 내장된 기본 방법은 각각 Move와 FillChar입니다. 이 루틴들은 rep movsd 및 rep stosd 어셈블러 명령에 기반하고 있으며, 상당히 효율적입니다. 하지만, 이 루틴들에는
효율성을 떨어뜨릴 수 있는 추가적인 클린업 코드가 있으며, 특히 소량의 메모리를 다룰 때 더욱 그렇습니다. 또한, 펜티엄 II CPU에는 특수한 데이터 배열 방식이 있어 상당한 영향을 미치게 됩니다.

이 문제에 대한 1차적인 해결책은 간단하게 단순한 루프를 사용하는 것입니다. 이것은 다루는 데이터 요소가 32비트나 64비트일 경우, 혹은 구조가 부분적으로만 제로/이동될 때(예를 들면 행렬의 서브섹션) 특히 효과가 있습니다. 하지만 루프 방법은 큰 레코드들이나 바이트, 워드와 같은 작은 요소를 가진 배열에는 효과가 줄어듭니다. 루프를 언롤하고 타입캐스트로 상황을 더 개선할 수는 있지만, 이렇게 하면 코드가 상당히 더 복잡해지게 되고 향상 효과는 그다지 크지 않습니다.

이제 더 전문화된 루틴에 대해 생각해봅시다. 첫번째 이슈는 구조의 경계(boundary) 크기입니다. 32비트 크기는 항상 가장 빠른 방식입니다. 하지만 구조의 크기가 4바이트로 고르게 나눠지지 않는다면 문제가 될 수 있습니다. Move와 FillChar에서 사용된 해결책은 dword(4바이트) 경계 단위로 잘라내고 나머지는 별도로 복사하는 것입니다. 위에서 언급한 것처럼, 작은 구조에 대해서는 이 추가적인 오버헤드가 상당히 커지게 됩니다. 하지만 사실 의외로 많은 구조들이 고르게 나눠집니다. 모든 메모리 할당은 다음 dword 경계에 맞춰 반올림됩니다. 따라서 2문자의 스트링은 실제로는 4바이트 길이를 갖습니다. 보통 이런 추가 데이터를 함께 카피 혹은 제로 시키는 것이 피하는 것보다 더 빠릅니다. 이 방법을 사용할 때는 주의를 기울여야 하고 잘 문서화해두어야 합니다.

데이터 정렬(alignment) 이슈를 다루는 것은 좀 더 복잡하며, 큰 구조와만 관련이 있습니다. 자세한 설명은 생략하고 대신 코드를 보여드립니다.

이 코드에서는  데이터의 일부를 스킵함으로써 16바이트 경계로 정렬하고 있습니다. 스킵된 부분도 물론 제대로 처리되어야 하므로 FillChar를 호출했습니다. FillChar를 두번 호출하는 오버헤드가 있으므로 이것이 가장 빠른 방식은 아닙니다. 최고의 속도를 바란다면, 어셈블러를 이용하는 다음의 방식이 그중 하나입니다.

개선된 Move의 경우도 매우 비슷합니다.

전역 데이터를 검토하라

전역 데이터 구조를 사용하는 것이 특별하게 유리한 경우가 있습니다. 단순 타입(simple type)의 2차원 배열의 두 인덱스 모두에 대해 비순차적 액세스를 할 경우입니다. 이런 데이터 구조를 전역으로 만들면 데이터 구조에 액세스할 때 두 인덱스가 동시에 적용되므로, 인덱스들을 조합하기 위한 추가적인 명령들을 피할 수 있습니다.

while 루프를 검토하라

배열 작업을 하는 while 루프에 적용할 수 있는 다른 테크닉으로서 CPU 레지스터를 절약하는 방법 하나는, 모든  인덱스 변수 사용을 음수에서 시작해서 0까지 카운트하도록 바꾸는 것입니다.
이렇게 하면 반복 횟수에 사용되었을 레지스터들을 비워둘 수 있습니다. 예를 들면,

 

 

 

위 코드는 다음과 같이 바꿀 수 있습니다.

 

 

포인터 변수를 검토하라

앞서 포인터를 이용해서 실행시 참조 해석을 줄일 수 있다는 장점을 설명했는데, 그 외에도 포인터 변수를 사용하면 기존의 포인터 변수에 대한 우선순위를 높일 수도 있습니다. 스트링의 일부를 치환하는 루틴에서 가져온 아래 코드에서, PChar 변수 pSub1는 루프 내에서 다시 로드됩니다(
CPU 윈도우에서 확인할 수 있습니다). pSub1을 pTemp에 대입하고 루프 내에서 pTemp를 사용하면 로드는 루프 바깥쪽에서 이루어지게 되며, 따라서 명령 사이클을 줄일 수 있습니다.

 

Assigned로 메소드 포인터를 체크하는 것을 피하라

메소드 포인터가 nil인지 확인하는 것은 흔한 작업으로써, 일반적으로 이벤트 호출과 관련되어 있습니다. 불행히도, if assigned(FSomeEvent) then … 은 메소드 포인터의 코드 주소의 상위 워드에 대해 16비트 비교를 수행합니다. 이것은 상당히 이상한 일이고 완전히 불필요한 것인데, 저로서는 16비트 델파이 1 버전에서부터 내려온 것이 아닌가 추측하고 있습니다. 코드 주소를 직접 체크하는 데 대한 회피책으로 TMethod를 사용할 수 있습니다.

이런 코드는 좀 보기가 안좋은 것은 사실이므로, 여러분은 특별히 시간에 크리티컬한 부분에서만 사용할 수도 있습니다.

열거 타입의 크기를 조절하라

여러분이 열거 타입을 사용한다면(TSuits = (Diamonds, Hearts, Clubs, Spades) 처럼), 열거 변수가 32비트가 되도록 {$MinEnumSize 4} 혹은 {$Z4} 지시어를 사용하십시오. 호환성 문제가 우려될 경우라면 필요한 타입 선언에서만 이 지시어를 사용할 수도 있습니다. 예를 들면,

요소가 256개가 넘는 열거 타입에서 이 지시어를 사용할 경우 특히 더 효과적입니다. 256개 이상의 요소를 가진 열거 타입은 word 크기 변수가 되는데 상당히 느립니다.

버추얼 메소드

스태틱 메소드에 비해 버추얼 메소드가 더 많은 오버헤드를 발생시키는 것은 잘 알려져 있습니다. 버추얼 메소드를 호출하면 포인터 참조 해석이 두 번 일어나고 간접 호출이 되며, 전체 호출 오버헤드를 두배로 만듭니다. 하지만, 잠재적으로 훨씬 더 심각한 불이익이 있을 수 있습니다. 간접 호출은 펜티엄 II CPU에서 상당히 골치아픈 문제인 잘못된 분기 예측을 일으킬 수 있습니다. 이 문제는 호출의 대상이 바뀔 때마다 매번 발생합니다. 따라서, 매번 반복 때마다 바뀔 수 있는 버추얼 메소드를 루프 내에서 호출하는 것은 상당히 큰 문제를 일으킬 수 있습니다. 이에 대한 회피책은 메소드 호출을 정렬하는 것입니다.

예를 들면,

호출들을 정렬하는 것은 약간 복잡합니다. 아래와 같습니다.

이 자체로서는 실망스럽게도 호출당 겨우 1 클럭 사이클을 아낄 수 있을 뿐이지만, TMethod를 이용하여 호출되는 코드들을 배열로 정렬하여 분기 예측 실패를 최소화할 수 있습니다. 덧붙여서, 베이스 클래스 메소드가 아무 것도 하지 않는 루틴일 경우 프로시저 리스트에서 완전히 제거해버릴 수도 있습니다.

이 방법은 용기 없는 개발자들을 위한 것은 아니며, 또한 특정 상황에서만 유용합니다. 하지만, 많은 객체들이 있지만 메소드들 중 몇개의 버전들만이 있고 메소드가 상대적으로 작거나 비어 있는 경우 큰 시간을 절약할 수 있습니다.

5 comments for “델파이 최적화 가이드라인 (1) – 일반 가이드라인

  1. 아래 글은 원래 2002년에 Robert Lee의 홈페이지인 http://www.optimalcode.com 라는 사이트에 실렸던 “Delphi Optimization Guideline”이라는 글의 첫 파트입니다. 지금 이 사이트는 없어진 상태이고 원래의 필자도 전혀 연락이 안되는 상태입니다. 하지만 최근에 예전의 컨

  2. 위에 “while 루프를 검토하라”에서는 CPU Index 레지스터를 이용하는 게 더 빠릅니다.
    그래서 포인터를 하나 더 사용해서 Loop를 돌리면 더 빠르겠죠.

    type
    TRef = array[0..0] of TheSameThingAsData;
    PRef = ^TRef;
    var
    data : array[0..100] of TheSameThingAsData;
    RefStart, RefEnd: PRef;
    begin
    RefStart := @Data[0]; // 포인터 시작
    RefEnd := RefStart;
    Inc(RefEnd, Count); // 포인터 종료값 세트

    while RefStart RefEnd do
    begin
    RefStart^ := RefStart^ + 1;
    Inc(RefStart);
    end;

    • 위 While문에서 같지않다가 표시가 안되어서 추가. While 문에서 RefStart 와 RefEnd가 다를때까지 입니다.

    • 위 제 설명이 잘못된 부분이 있어 수정합니다.
      위 소스는 base 레지스터만 증가시켜 작업하는 것입니다.
      배열과 첨자를 사용하면, base 레지스터값과 Index레지스터값을 더하여 실제 주소를 찾아야 하기 때문에 CPU 사이클이 많아 지겠죠… 하지만 Base 레지스터값만 증가시키니, 연산이 좀 줄어들겠죠.

답글 남기기

이메일 주소는 공개되지 않습니다.