C++ 이야기 서른두번째: 예외가 성능에 미치는 영향

Posted at 2010. 6. 21. 00:01 // in S/W개발/C++ 이야기 // by 김윤수


2009/09/25 - [S/W개발/C++ 이야기] - C++ 이야기 서른한번째: 왜 예외를 쓰는 게 좋을까요? 에서 왜 예외를 쓰는 것이 좋은지에 대해 얘기를 해 봤습니다. 정리해서 말씀드리자면 결함내성 특성(fault-tolerant)을 가진 소프트웨어를 작성하기 쉽게 해주기 때문에 예외를 쓰는 것이 좋다라고 말씀드렸습니다.

그런데 폴리비님께서 다음과 같은 댓글을 남겨 주셨습니다.
예외 처리기능이 C++에 들어가서 좋지만, 속도 저하가 약간 있어서 꺼리는 경우도 있어요.
스트롭 할아버지는 별로 속도 저하 없으니 꼭 쓰라고 하지만 RTTI처럼 속도 때문에 꺼려지는 점이 있긴 하죠!
STL도 vector에 대해 at 메소드는 예외처리를 하지만, 완전 동일한 기능인 [] 연산자에서는 예외처리를 하지만, 많은 반복 예상되는 단순한 코드같은 경우 예외 처리가 꼭 필요하지는 않으면서도 예외 처리를 할 경우 성능을 떨어뜨리는 경우도 있습니다.

예외 처리 기능을 활용하면 성능을 떨어뜨리니 무조건 쓰는 건 좋지 않다라는 말씀이겠죠. 이 댓글을 보고 예외가 성능에 도대체 어느 정도나 영향을 미치는지 알아보고 싶어서 실험을 해 봤습니다.

예외 처리가 성능에 미치는 영향을 측정하기 위해서는 어떻게 실험해야할까요? 예외 처리 기능을 사용하는 프로그램과 사용하지 않는 프로그램의 실행 시간을 비교해봐야겠다는 생각이 자연스럽게 떠오릅니다. 그렇지만 이 둘만 비교해서는 예외가 성능에 미치는 영향을 정확히 파악할 수 없을 것입니다. 왜냐하면, 단순히 try/catch 문을 사용했을 경우와 실제로 예외가 발생한 경우에 발생하는 overhead가 다를 것이기 때문입니다. try/catch 문만을 사용했을 경우에는 예외 처리를 위한 준비과정만 부가적인 overhead가 되겠지만, 예외가 실제로 발생한 경우에는 예외를 처리하는 과정(대표적으로 stack unwinding)이 overhead가 될 것이기 때문입니다.

예외가 발생하는 경우도 호출 깊이(call depth)가 어느 정도이냐에 따라 overhead가 달라질 수 있을 것이므로 호출 깊이도 달리 하면서 실험할 필요가 있을 것입니다. 마지막으로 호출 깊이가 깊어지는 경우 중간에 예외 처리기가 여러번 중첩될 수도 있고, 이런 예외 처리기에서 예외를 재발생시키거나(rethrow) 다른 예외로 매핑하는 경우도 있을 것입니다. 따라서 이러한 경우도 분리해서 테스트할 필요가 있을 것입니다.

정리하자면 다음 네 가지 경우에 대해 호출 깊이를 달리하여 테스트하면 어느 정도 예외가 성능에 미치는 영향에 대해 윤곽을 잡을 수 있을 것 같습니다.

1. 예외 처리를 사용하지 않는 프로그램
2. 예외 처리를 사용하나 예외를 발생시키지 않는 프로그램
3. 예외 처리를 사용하고 예외를 발생시키나 재발생시키지는 않는 프로그램
4. 예외 처리를 사용하고 예외를 발생시키며 재발생시키기도 하는 프로그램

호출 깊이를 달리하는 것은 동일한 함수를 재귀 호출하는 회수를 달리하면 될 것입니다.

이 네 가지 경우를 다음과 같이 별도의 함수로 작성하였습니다.

먼저, 1. 예외 처리를 사용하지 않는 프로그램입니다.

void NoTry(long long& r, int& rcnt) {
shared_ptr<Foo> p(new Foo());
r += (rand() % 100);
if (--rcnt)
NoTry(r, rcnt);
}

Foo 라는 클래스는 class Foo {}; 라고 비어 있는 클래스로 정의했습니다. rcnt 는 호출 깊이를 조절하기 위한 변수로 0이 되면 더 이상 재귀적으로 호출하지 않습니다. 함수 본문에서는 shared_ptr 로 Foo 라는 객체를 할당했다가 함수에서 리턴할 때 자동해제 되도록 하였고, r 이라는 참조 변수를 rand() % 100 결과값만큼 증가시키도록 하여 혹시 있을지도 모르는 컴파일러 최적화를 피하도록 했습니다. 더불어 함수 본문에서 어느 정도 시간이 걸리도록 하여 단순 함수 호출 overhead가 실행시간의 대부분을 차지하지 않도록 해 보았습니다.

그 다음은 2. 예외 처리를 사용하나 예외를 발생시키지 않는 프로그램입니다.

void TryNoException(long long& r, int& rcnt) {
try {
shared_ptr<Foo> p(new Foo());
r += (rand() % 100);
if (--rcnt)
TryNoException(r, rcnt);
}
catch (const Ex&) {
}
}

NoTry와 거의 동일하나 함수 본문을 try/catch로 둘러 쌓았다는 점만 다릅니다. 실제로 예외는 발생하지 않으므로 try/catch 로 인한 overhead만을 측정할 수 있을 것입니다. 예외 클래스 Ex 는 Foo와 비슷하게 class Ex {}; 로 비어 있는 클래스로 정의했습니다.

다음은 3. 예외 처리를 사용하고 예외를 발생시키나 재발생시키지는 않는 프로그램입니다.

void TryException(long long& r, int& rcnt) {
try {
shared_ptr<Foo> p(new Foo());
r += (rand() % 100);
if (--rcnt)
TryException(r, rcnt);
else
throw Ex();
}
catch (const Ex&) {
}
}

2번 프로그램과 거의 비슷하지만, 재귀적으로 계속 호출하다가 제일 마지막에 Ex() 예외를 발생시킵니다. 그리고, catch 문에서 Ex 예외를 잡고 있으니 가장 깊은 호출 스택에서 예외 처리기가 동작하여 예외를 처리한 후, 그 다음부터는 단순한 함수 리턴과정을 거치게 될 것입니다. 정리하자면, 호출 깊이가 깊어지더라도 예외 처리로 인한 overhead는 한 번만 발생하게 될 것입니다.

마지막으로 4. 예외 처리를 사용하고 예외를 발생시키며 재발생시키기도 하는 프로그램입니다.

int rethrowCnt = 0;

void TryRethrowException(long long& r, int& rcnt) {
try {
shared_ptr<Foo> p(new Foo());
r += (rand() % 100);
if (--rcnt)
TryRethrowException(r, rcnt);
else
throw Ex();
}
catch (const Ex&) {
if (--rethrowCnt)
throw;
}
}

3.번하고 거의 동일한데, 예외 처리기 안에서 rethrowCnt 값을 하나씩 감소시키면서 예외를 재발생시키고 있습니다. 이렇게 하면 호출 경로상의 가장 상위 스택에서는 예외를 처리하게 되지만 다른 스택에서는 예외를 처리하기도 하고, 재발생시키기도 하게 될 것입니다. 이렇게 할 경우 예외를 발생시키고 처리하는 overhead가 호출 깊이 만큼 발생하게 됩니다. 이를 위해서는 전역변수인 rethrowCnt가 호출 깊이와 동일한 값으로 설정되어야겠지요.

이 함수들 실행시간을 비교해야 하는데, 한 번만 수행할 경우 너무 짧은 시간에 실행이 끝나 버릴테니, 여러번 수행한 후, CPU 사용량을 측정해야 할 것입니다. 실행 시작전 시각과 실행 완료 후 시각의 차로 실행시간을 측정할 경우에는 중간에 OS가 다른 프로그램을 스케쥴링하면서 실제 이 함수들에 의한 실행시간 외에 다른 시간이 끼어들 수 있으므로 CPU 사용량을 측정해야 합니다. CPU 사용량을 측정하기 위해서 time 이라는 시스템 유틸리티를 사용했습니다.

위 함수들을 여러 번 수행하기 위해 다음과 같은 함수를 작성했습니다.

typedef void (*F)(long long&, int&);

long long RunLoop(long long n, F f, int recursionCnt) {
long long r = 0;
for (; n >= 0; --n) {
int rcnt = recursionCnt;
rethrowCnt = recursionCnt;
f(r, rcnt);
}
return r;
}

주어진 함수 f를 n 번만큼 수행하는 함수입니다. 원하는 재귀 호출 회수도 인자로 받아서 f 함수에 넘겨 줍니다. 그리고, 전역변수인 rethrowCntrecursionCnt 값으로 설정해 줍니다. 이는 TryRethrowException() 함수가 각 호출 단계에서 예외를 재발생시키고 최상위 호출 단계에서는 예외를 처리하도록 하기 위한 것입니다.

RunLoop를 활용하여 각 함수를 여러번 수행해 주는 main 함수는 다음과 같이 작성했습니다.

void Usage() {
cout << "Please, specify test case number and call depth" << endl;
cout << "1 means no try/catch block" << endl;
cout << "2 means try/catch block but no exception" << endl;
cout << "3 means try/catch block and exception" << endl;
cout << "4 means try/catch block and rethrow exception" << endl;
}

const string noTryCase = "1";
const string tryNoExCase = "2";
const string tryExCase = "3";
const string tryRethrowCase = "4";

int main (int argc, char * const argv[]) {

if (argc != 3) {
Usage();
return -1;
}

srand((unsigned)time(0));
F f = 0;
if (noTryCase == argv[1]) {
f = NoTry;
}
else if (tryNoExCase == argv[1]) {
f = TryNoException;
}
else if (tryExCase == argv[1]) {
f = TryException;
}
else if (tryRethrowCase == argv[1]) {
f = TryRethrowException;
}
else {
Usage();
return -1;
}

int callDepth = atoi(argv[2]);
if (callDepth <= 0)
callDepth = 1;

return RunLoop(10000000, f, callDepth);
}

보시는 것처럼 명령행 인자로부터 test case 번호와 호출 깊이를 받도록 하였습니다. 이렇게 할 경우, 순수하게 함수를 수행하는 시간 외에 명령행 인자를 해석하고 필요한 변수를 초기화하는 overhead가 끼어들긴 하겠지만, 각 함수를 수행하는 회수를 충분히 크게 해주면-위 예제는 10000000번-이 overhead 가 차지하는 시간이 아주 작아지게 될 것입니다.

그리고, 각 test case를 한번씩만 수행할 경우, 실행 당시 시스템 상태에 따라 실행 시간이 달라질 수 있으므로, 다음과 같은 shell script 를 이용하여 각 test case를 10번씩 수행하고 그 평균 값을 서로 비교하였습니다.

$ cat test.sh
#!/bin/sh

run_test() {
    test_case=$1
    test_run=$2

    while [ $test_run -gt 0 ]; do
        echo
        echo "time ./extest $test_case $3"
        time ./extest $test_case $3
        test_run=`expr $test_run - 1`
        echo
    done
}

call_depth=1
while [ $call_depth -le 5 ]; do
    run_test 1 10 $call_depth
    call_depth=`expr $call_depth + 1`
done

call_depth=1
while [ $call_depth -le 5 ]; do
    run_test 2 10 $call_depth
    call_depth=`expr $call_depth + 1`
done

call_depth=1
while [ $call_depth -le 5 ]; do
    run_test 3 10 $call_depth
    call_depth=`expr $call_depth + 1`
done

call_depth=1
while [ $call_depth -le 5 ]; do
    run_test 4 10 $call_depth
    call_depth=`expr $call_depth + 1`
done

그럼 실제 실험 결과를 보실까요?


결과에서 확인하실 수 있는 것처럼 try/catch 를 사용하지 않는 함수와 try/catch를 사용하되 예외를 발생하지 않는 경우는 거의 차이가 없음을 확인할 수 있습니다. 그래프에서 보면 TryNoException이 초록색인데 NoTry 파란색에 가려 전혀 보이질 않고 있습니다. 이 결과는 저로서도 약간 의외였는데요. 아무리 최적화된 try/catch를 구현하더라도 예외 처리 준비 작업이 어느 정도 필요하기 때문에 overhead가 어느 정도는 있을 줄 알았는데 실제로는 거의 차이가 없음을 확인할 수 있었습니다. 이것은 제가 사용하고 있는 컴파일러가 상당히 최적화된 예외 처리 메커니즘을 구현하고 있기 때문인 것 같습니다. C++ 철학중 하나인 수익자 부담의 원칙을 잘 따르고 있는 컴파일러라고 생각이 됩니다.

다음으로 TryException 과 NoTry, TryNoException 케이스를 비교하면 호출 깊이에 따라 실행시간이 늘어나는 비율(기울기)은 거의 비슷한데, 위 아래로 간격이 상당히 떨어져 있음을 확인할 수 있습니다. TryException의 경우 한 번 예외가 발생하여 처리된 이후에는 계속해서 TryNoException과 거의 동일한 overhead만 발생할 것이기 때문에, 이것은 한 번 발생한 예외로 인한 overhead가 상당히 크다는 것을 증명한다고 해석해야 할 것입니다. 이것은 TryRethrowException 케이스에 의해 더 확실해집니다. TryRethrowException의 경우 각 호출 단계에서 예외를 발생시키고 처리하기 때문에 호출 깊이에 비례해서 예외 발생 및 처리 overhead가 발생하기 때문입니다. 그래서 다른 세가지 케이스에 비해 기울기가 훨씬 가파른 것을 확인할 수 있습니다.

실제 제가 위 네 가지 경우에 대해 선형 회귀식을 구했을 때 다음과 같은 결과가 나왔습니다.

NoTry: y = 5.0897x - 1.9361, R^2 = 0.9984
TryNoException: y = 4.7766x - 1.3758, R^2 = 0.999
TryException: y = 5.2183x + 48.001, R^2 = 0.9971
TryRethrowException: y = 130.36x - 79.22, R^2 = 09985

예외가 한 번 발생했을 때, 처리하는 overhead가 상당한 것을 확인할 수 있습니다.

이상을 종합해 보면 다음과 같이 결론 내릴 수 있을 것 같습니다.

1. 단순히 예외 처리를 위해 try/catch 구조를 사용한다고 하여 overhead가 발생하지는 않는다.
2. 예외 발생/처리 overhead는 상당히 크다.

이 결론은 우리에게 시사하는 바가 상당히 크다고 생각합니다.

1) 예외가 성능을 많이 저하시킨다는 막연한 생각에 아예 예외를 쓰지 않는 것도 피해야 할 것이고, 2) 예외를 예외 답지 않게 사용하는 것-즉, 에러 상황이 아닌 상당히 자주 발생할 수 있는 상황을 예외로 처리하는 경우-도 피해야 할 것이고, 3) 실시간성이 중요한 프로그램에서 호출 경로상 예외가 발생했을 경우 실시간성 검증 없이 예외를 사용하는 경우도 피해야 할 것입니다.

제가 테스트한 환경은 다음과 같습니다.

$ uname -a
Darwin MyMac.local 9.8.0 Darwin Kernel Version 9.8.0: Wed Jul 15 16:55:01 PDT 2009; root:xnu-1228.15.4~1/RELEASE_I386 i386
$ g++ --version
i686-apple-darwin9-g++-4.2.1 (GCC) 4.2.1 (Apple Inc. build 5564)
Copyright (C) 2007 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

컴파일 할 때는 다음과 같은 옵션을 주었습니다.

$ g++ -O3 -o extest -I /usr/local/include/boost-1_36 main.cpp

여러분들도 무작정 예외 처리 기능을 쓰지 않거나 혹은 쓰는 것이 아니라 자신이 개발하는 환경에서 실제 테스트를 해 보신 후에 데이터를 기반으로 예외 처리 기능을 사용할 것인지 말 것인지를 의사결정한다면 함께 일하는 분들 간에 동일한 생각으로 일할 수 있을 것입니다.

여러분도 자신의 개발 환경에서 테스트해 보신 후에 결과를 트랙백으로 한 번 연결해 주시면 감사하겠습니다.

(테스트 함수를 구현한 소스와 shell script를 첨부합니다. 그리고 한 가지, TryRethrowException의 경우 실험 시간이 너무 오래 걸려 천만번이 아닌 백만번만 수행한 결과를 단순 10배 한 결과를 서로 비교하였다는 것을 알려 드려야 할 것 같습니다. 철저하게 하려면 실제로 천만번 수행한 후에 결과를 비교해야겠지만 블로그 글감으로 쓰기에는 이런 정도로 해도 될 것 같아서 이렇게 결과를 올립니다)