최적화 친화적인 코드를 작성하는 방법
최적화 기능을 지니는 컴파일러는 작고 빠른 코드를 생성하려고 합니다. 따라서 반복적으로 소스 프로그램에 일련의 변화를 가합니다. 대부분의 최적화는 적절한 이론적인 기반을 바탕으로 하는, 수학 또는 논리적인 규칙을 따릅니다. 그 외, 휴리스틱스를 바탕으로 전환을 시키는 경우도 있습니다. 이를 통해서 좋은 코드가 생성되기도 하고, 추가적인 최적화를 진행할 수 있는 길을 열어주기도 합니다.
그러나 최적화의 비결은 흑마법이 아닙니다. 코드에 최적화를 적용할 수 있는가의 여부를 결정하는 것은 결국 소스 코드를 어떻게 작성하는가에 달려 있는 것입니다. 어떨 때에는 코드에 약간의 변화를 가하는 것만으로 컴파일러에서 생성되는 코드의 효율에 큰 영향이 발생하기도 합니다.
이번 시간에는 코드를 작성할 때에 몇가지 명심해야 하는 부분을 알아 보겠습니다. 하지만 그 전에 먼저 한가지 확실하게 해 두어야 할 것이 있습니다. ?: 이나 보수(postincrement), 콤마 표현식 등의 연산자를 사용해 코드의 행수를 가급적 줄이고자 하는 방식이 있는데, 이러한 코드는 하나의 표현식 만으로도 많은 부작용을 불러 일으킬 수가 있으며, 컴파일러를 통해 생성되는 코드조차도 효율성이 높아진다거나 하지 않습니다. 오히려 소스 코드를 읽기만 힘들어지며, 관리도 어려워지며, 복잡한 표현식 중간에 위치하는 보수 또는 할당을 놓치기 쉬워 집니다. 코드의 작성은 읽기 쉽게 하는 것이 가장 중요한 것입니다.
루프문
이렇게 간단한 루프문 하나에서 무슨 문제가 발생할 것이 있겠습니까?
for (i = 0; i != n; ++i)
{
a[i] = b[i];
}
문제랄 것은 없겠지만, 컴파일러에서 생성되는 코드의 효율성에 악영향이 발생할 수가 있습니다.
인덱스 변수의 종류는 포인터에 맞는 것이라야 합니다.
a[i]와 같은 행렬 표현식은*(&a[0]+i*sizeof(a[0])을 의미합니다. 이것을 쉬운 말로 풀어 쓰자면, i:th의 차감분(offset)을 a의 첫번째 항목을 지시하는 포인터에 더하는 것입니다. 포인터 산술의 경우는 인덱스 표현식이 포인터와 동일한 크기를 지니도록 하는 것이 좋습니다(단, __far 포인터의 경우, 포인터와 인덱스 표현식의 크기가 서로 다릅니다.). 만일 인덱스 표현식의 타입이 포인터 타입보다 크기가 작은 경우, 인덱스 표현식은 포인터에 더하기 전에 먼저 정확한 크기로 맞추어야 합니다. 만일 코드의 용량보다는 스택 공간 절약이 더 중요한 문제인 경우, 인덱스 변수로 좀 더 작은 타입의 것을 선택하는 것이 더 유리합니다. 하지만 이 경우 그 대가로 코드의 용량이 커지고, 실행 시간도 길어집니다. 뿐만 아니라, 이 경우 몇가지 루프 최적화 기능을 사용할 수 없게 되기도 합니다.
루프의 조건 역시 중요한 요소입니다. 루프 최적화는 반복의 횟수를 루프에 들어가기 전에 먼저 계산할 수 있을 때에만 가능한 경우가 많습니다. 불행히도, 이것은 최종값으로부터 초기값을 빼고, 증가치로 이를 나누는 것처럼 간단하지가 않습니다. 그러나 i가 unsigned char이고, n은 int이면서 그 값이 1000이라면 어떨까요? 변수 i는 1000에 도달하기 훨씬 전에 오버플로우 할 것입니다. b에서 a로 256 개의 항을 반복적으로 복사하는 무한의 루프를 만들어내고자 하는 프로그래머는 없을 것입니다. 하지만 컴파일러가 프로그래머의 마음을 읽어 낼 수는 없는 노릇입니다. (컴파일러는) 최악의 상황을 상정해야 하며, 루프에 들어가기 전 반속 횟수를 계산하지 않으면 안되는 형식의 최적화는 하나도 적용할 수가 없게 됩니다. 관계형 연산자인 <= 및 >= 을 최종값이 변수인 루프 조건에서 사용하는 것도 피해야 합니다. 만일 루프의 조건이 i <= n 인 경우, n이 해당 타입에서 가장 높은 값을 지니고 있을 가능성이 있습니다. 그러므로 컴파일러는 이것이 잠재적으로 무한 루프라고 간주해 버리고 마는 것입니다.
앨리어싱(Aliasing)
글로벌 변수를 사용하는 것은 대개의 경우 좋지 않은 방식인 경우가 많습니다. 글로벌 변수는 프로그램 어디에서든 수정이 가능하며, 그 값에 의존하는 내용은 프로그램 어디에든 위치할 수가 있습니다. 이로 인해 복잡한 의존 관계가 생성될 수가 있으며, 이러한 관계는 프로그램의 이해를 어렵게 합니다. 또한 글로벌 변수의 값이 바뀔 경우 프로그램의 어느 부분이 영향을 받게 되는지도 파악하기 어렵습니다. 최적화 기능의 관점에서 보면, 더 심각한 문제가 발생합니다. 포인터를 통해 저장이 이루어질 경우 잠재적으로 어떠한 글로벌 변수의 값도 바꿀 수가 있기 때문입니다. 변수에 접근하는 방법을 복수로 만드는 것을 앨리어싱이라고 합니다(aliasing). 앨리어싱이 발생한 코드는 최적화 하기가 어렵습니다.
char *buf
void clear_buf()
{
int i;
for (i = 0; i < 128; ++i)
{
buf[i] = 0;
}
}
프로그래머가 buf가 가리키는 버퍼에 쓰기가 변수 자체를 절대 바꾸지 않는다는 것을 알더라도, 컴파일러는 최악의 상황을 상정해야 하므로 루프가 반복될 때마다 메모리로부터 buf를 리로드합니다.
이때, 글로벌 변수 대신 버퍼의 어드레스를 아규먼트로 하여 투입할 경우, 앨리어싱을 제거할 수가 있습니다:
void clear_buf(char *buf)
{
int i;
for (i = 0; i < 128; ++i)
{
buf[i] = 0;
}
}
이 약간의 변동이 있은 후, 포인터 buf는 포인터를 통한 저장에도 불구하고 영향을 받지 않습니다. 포인터 buf는 루프는 루프 내에서는 변동되지 않으며, 그 값은 반복 시 마다 다시 로드하는 대신 루프 전에 한 번 로드 될 수가 있습니다.
글로벌 변수는 서로 caller/callee 관계를 공유하지 않는 코드의 섹션 간에 정보를 주고 받는 데에 유용합니다. 이를 사용할 시에는 꼭 정보를 전달하는 용도로만 사용해야 합니다. 연산이 많이 이루어져야 하는 부분에 대해서는 항상 자동 변수를 사용하는 것이 좋습니다. 특히 연산 과정에 포인터 조작이 포함되는 경우는 더욱 그렇습니다.
postincreement 및 postdecrement는 가급적 피하도록 합니다.
다음에서 postincrement에 적용되는 내용은 모두 postdecrement에도 적용됩니다. C 표준에서는 postincrement의 시멘틱스에 대해 다음과 같이 언급하고 있습니다. “접미어 ++ 연산의 결과는 피연산자의 값이 된다. 결과를 입수한 다음에는 피연산자의 값이 증가한다.” 마이크로컨트롤러에서는 불러오기 또는 저장 동작 후에 포인터의 값을 증가시키는 어드레싱 모드를 사용하는 경우가 많습니다. 하지만 동일한 효율로 다른 타입의 postincrement를 처리할 수 있는 마이크로컨트롤러는 거의 없습니다. 이러한 표준의 내용을 준수하기 위해서는 컴파일러가 피연산자를 임시 변수에 먼저 복사한 다음 이를 증가시키는 연산을 수행합니다. 스트레이트 라인 코드(straight line code)의 경우, 증분을 표현 구문으로부터 제거하여 표현 구문 뒤에 위치하도록 할 수가 있습니다. 예를 들어, 다음과 같은 표현 구문을 생각할 수가 있습니다.
foo = a[i++];
그리고 다음과 같이 실행할 수가 있습니다.
foo = a[i];
i = i + 1;
그러나 while 조건문 내의 조건문에 postincrement이 포함되어 있다면 어떻게 될까요? 조건문 부분 이후에는 값을 증가시키는 부분을 삽입할 수 있는 공간이 없습니다. 따라서 증가 코드는 반드시 논리 테스트 전에 위치해야 합니다.
간단한 루프문을 작성해 보면 다음과 같습니다.
i = 0;
while (a[i++] != 0)
{
...
}
이를 실행하면 다음과 같습니다.
loop:
temp = i; /* save the value of the operand */
i = temp + 1; /* increment the operand */
if (a[temp] == 0) /* use the saved value */
goto no_loop;
...
goto loop;
no_loop:
또는
loop:
temp = a[i]; /* use the value of the operand */
i = i + 1; /* increment the operand */
if (temp == 0)
goto no_loop;
...
goto loop;
no_loop:
루프 문 다음의 i 값이 해당이 안 된다면, 증가 코드를 루프 본문 내에 위치시키는 것이 더 낫습니다. 이와 유사한 코드를 작성해 보면 다음과 같습니다.
i = 0;
while (a[i] != 0)
{
++i;
...
}
임시 변수가 없이 실행이 가능합니다.
loop:
if (a[i] == 0)
goto no_loop;
i = i + 1;
...
goto loop;
no_loop:
최적화 컴파일러의 개발자들은 postincrement로 인하여 얼마나 복잡한 문제가 발생하는지를 잘 알고 있습니다. 이러한 패턴을 식별하고, 임시 변수를 아무리 많이 없애기 위해 아무리 많은 노력을 기울인다고 하더라도 효율적인 코드를 만들어 내기가 어려운 상황은 반드시 발생합니다. 특히 루프 조건이 위의 루프 조건보다 더 복잡해 지는 경우는 더욱 그렇습니다. 복잡한 표현식은 복수의 쉬운 표현 식으로 나누는 것이 좋은 경우가 많습니다. 위의 루프 조건도 논리 테스트 부분과 증가 코드 부분으로 나누어져 있지요.
preincrement와 postincrement 중 어느 것을 선택하는가의 문제는 C++의 경우 훨씬 더 중요해 집니다. 연산자 ++ 및 연산자 --은 접두사와 접미사 형태 모두에 있어 과부하가 걸릴 수가 있습니다. 클래스 오브젝트로 인해서 연산자에 과부하가 걸리게 되면 기본 타입을 대상으로 하는 연산자의 행태를 에뮬레이션할 필요성은 사라지지만, 가급적이면 기본 타입과 비슷한 행태를 유지하도록 하는 것이 바람직합니다. 그러므로 본질적으로 오브젝트의 값을 증가 시키거나 감소 시키는 클래스들의 경우, 예를 들어 iterator의 경우 통상적으로 접두사 형식(operator++() 및 operator--()) 및 접미사 형식(operator++(int) 및 operator--(int))을 모두 지니는 것이 보통입니다.
기본적인 형식을 위한 접두사 ++의 행태를 에뮬레이션 하기 위해, operator++()은 객체를 수정하고 참조값을 리턴하여 오브젝트를 수정하도록 할 수가 있습니다. 그러면 기본적인 타입을 위한 접미사++의 행태를 에뮬레이션 하는 것은 어떨까요? 기억하십니까? “접미사 ++ 연산자의 결과는 피연산자의 값이다. 결과를 입수한 다음에는 피연산자의 값이 증가한다.” 위와 같은 non-straightline code의 경우와 마찬가지로 operator++(int)를 구현하기 위해서는 원래의 객체를 복사한 다음의 이를 수정하고, 복사한 것을 값으로 리턴해야 합니다. 이러한 복사 행위로 인하여 operator++(int)은 operator++()과 비교해 더 많은 부담을 발생시킵니다.
기본형 타입을 위해 최적화 프로그램은 불필요한 복사 행위를 제거할 수가 있습니다. 단, 이를 위해서는 i++의 결과를 무시해야 합니다. 그러나 최적화 프로그램은 하나의 과부하 연산자를 대상으로 하는 호출을 다른 것을 대상으로 하는 호출로 변경시킬 수는 없습니다. 만일, 습관적으로 ++i 대신 i++라고 코드를 작성하는 경우, 값 증가 연산자에 따른 부담이 더 증가하게 되는 것입니다.
postincrement의 사용은 가급적 지양하도록 설명을 드려 왔지만, postincrement가 완전히 쓸모가 없는 것은 아니라는 점도 기억해야 합니다. 만일 변수에 대해 postincrement을 함으로써 원하는 결과를 정확하게 얻어낼 수가 있는 경우에는 그대로 postincrement를 쓰시면 됩니다. 그러나 표현식 내에서 단순히 값을 증가시키기 위해 별도의 코드를 쓰는 것이 싫어서 postincrement를 쓰는 것은 지양해야 합니다. 불필요한 postincrement를 루프 조건문, if 조건문, 그리고 switch 문에 추가할 때마다, 그리고 ?:- 표현식, 또는 함수 호출 아규먼트에 이를 추가할 때 마다, 컴파일러에서 생성되는 코드가 불필요하게 용량을 잡아 먹으며, 속도도 느려질 위험이 있습니다. 혹시 기억할 내용이 너무 많으신가요? 오늘부터 새로운 습관을 들여봅시다! 증가된 값 자체를 사용하지 않는 경우에는 항상 i++ 대신 ++i을 쓰도록 하십시오. 그리고 증가된 결과물 값을 사용해야 하는 경우, 다음 선언문으로 증가를 시킬 수가 없는 것인지 어떤 지 스스로에게 물어 보십시오.