C++ 이야기 서른번째: boost::shared_ptr performance

Posted at 2009.06.27 21:44 // in S/W개발/C++ 이야기 // by 김윤수


최근 제가 진행하던 프로젝트에서 boost::shared_ptr를 상당히 많이 사용해 왔었는데, 성능이 좋게 나오질 않아서 혹시나 해서 오늘 성능을 간단하게 측정해 봤더니 속도 차이가 상당히 많이 나는군요. 성능이 느리겠거니 했는데 상상 이상이었습니다.(실은 이 문제 때문에 한바탕 홍역을 치뤘죠. ㅠ.ㅠ)

다음은 테스트 프로그램입니다.

$ cat shared.cpp
#include <iostream>
#include <boost/shared_ptr.hpp>

using namespace std;
using namespace boost;

class AClass
{
public:
    void Op() const
    {
        ++i;
    }

    int Get() const
    {
        return i;
    }

private:
    mutable int i;
};

#ifdef SHARED_PTR
void Foo(const shared_ptr<AClass>& p)
{
    p->Op();
}
#elif defined(SHARED_PTR_COPY)
void Foo(shared_ptr<AClass> p)
{
    p->Op();
}
#else
void Foo(AClass* p)
{
    p->Op();
}
#endif

int
main(int argc, char* argv[])
{
#if defined(SHARED_PTR) || defined(SHARED_PTR_COPY)
    shared_ptr<AClass> p(new AClass());
#else
    AClass* p = new AClass();
#endif

    int maxCnt = 1000000000;
    if (argc >= 2)
        maxCnt = atoi(argv[1]);

    for (int i = 0; i < maxCnt; ++i)
    {
        Foo(p);
    }

    cout << p->Get() << endl;

    return 0;
}

이 테스트 프로그램은 세 가지 경우를 테스트하기 위한 것입니다.

1. raw pointer를 사용하는 경우: SHARED_PTR  또는 SHARED_PTR_COPY 가 정의되지 않은 경우입니다.
2. shared_ptr을 사용하는 경우: SHARED_PTR 이 정의된 경우입니다. Foo()를 호출할 때, const shared_ptr reference를 넘기기 때문에 복사가 일어나질 않습니다.
3. shared_ptr을 쓰면서 복사를 하는 경우: SHARED_PTR_COPY가 정의된 경우입니다. Foo()를 호출할 때, shared_ptr 객체를 넘기기 때문에 복사가 한 번 발생하고 이때, shared_ptr의 복사 생성자가 수행되면서 내부적으로 reference count를 증가시키고, 다시 함수에서 리턴될 때, reference count를 감소시키게 됩니다. 그리고 reference count 증가 및 감소는 보통 atomic operation으로 수행되기 때문에 보통의 증가/감소보다는 속도가 느린 걸로 알고 있습니다.

이 세가지에 대해 컴파일러 최적화 옵션 및 BOOST 옵션을 약간 달리 하면서 실행을 해 보았습니다. 다음은 테스트를 위한 Makefile 입니다.

$ cat Makefile
all: shared1 shared2 shared3 shared_copy1 shared_copy2 shared_copy3 plain1 plain2 plain3

#CFLAGS=-pg -g
#LDFLAGS=-pg -g
# CASE #1
CFLAGS1=
# CASE #2
CFLAGS2=-O3
# CASE #3
CFLAGS3=-O3 -DBOOST_SP_DISABLE_THREADS
LDFLAGS=

shared1: shared1.o
    g++ $(LDFLAGS) -o shared1 shared1.o

shared_copy1: shared_copy1.o
    g++ $(LDFLAGS) -o shared_copy1 shared_copy1.o

plain1: plain1.o
    g++ $(LDFLAGS) -o plain1 plain1.o

shared1.o: shared.cpp
    g++ $(CFLAGS1) -c -o shared1.o -DSHARED_PTR shared.cpp

shared_copy1.o: shared.cpp
    g++ $(CFLAGS1) -c -o shared_copy1.o -DSHARED_PTR_COPY shared.cpp

plain1.o: shared.cpp
    g++ $(CFLAGS1) -c -o plain1.o -DPLAIN shared.cpp

shared2: shared2.o
    g++ $(LDFLAGS) -o shared2 shared2.o

shared_copy2: shared_copy2.o
    g++ $(LDFLAGS) -o shared_copy2 shared_copy2.o

plain2: plain2.o
    g++ $(LDFLAGS) -o plain2 plain2.o

shared2.o: shared.cpp
    g++ $(CFLAGS2) -c -o shared2.o -DSHARED_PTR shared.cpp

shared_copy2.o: shared.cpp
    g++ $(CFLAGS2) -c -o shared_copy2.o -DSHARED_PTR_COPY shared.cpp

plain2.o: shared.cpp
    g++ $(CFLAGS2) -c -o plain2.o -DPLAIN shared.cpp

shared3: shared3.o
    g++ $(LDFLAGS) -o shared3 shared3.o

shared_copy3: shared_copy3.o
    g++ $(LDFLAGS) -o shared_copy3 shared_copy3.o

plain3: plain3.o
    g++ $(LDFLAGS) -o plain3 plain3.o

shared3.o: shared.cpp
    g++ $(CFLAGS3) -c -o shared3.o -DSHARED_PTR shared.cpp

shared_copy3.o: shared.cpp
    g++ $(CFLAGS3) -c -o shared_copy3.o -DSHARED_PTR_COPY shared.cpp

plain3.o: shared.cpp
    g++ $(CFLAGS3) -c -o plain3.o -DPLAIN shared.cpp

test: test1 test2 test3

test1: shared1_test shared1_copy_test plain1_test

shared1_test: shared1
    time ./shared1 $(CNT)

shared1_copy_test: shared_copy1
    time ./shared_copy1 $(CNT)

plain1_test: plain1
    time ./plain1 $(CNT)

test2: shared2_test shared2_copy_test plain2_test

shared2_test: shared2
    time ./shared2 $(CNT)

shared2_copy_test: shared_copy2
    time ./shared_copy2 $(CNT)

plain2_test: plain2
    time ./plain2 $(CNT)

test3: shared3_test shared3_copy_test plain3_test

shared3_test: shared3
    time ./shared3 $(CNT)

shared3_copy_test: shared_copy3
    time ./shared_copy3 $(CNT)

plain3_test: plain3
    time ./plain3 $(CNT)

clean:
    rm -rf *.o *.dSYM shared1 shared2 shared3 shared_copy1 shared_copy2 shared_copy3 plain1 plain2 plain3 core* gmon.*

1. 테스트 케이스 1: 컴파일러 옵션으로 아무것도 주지 않을 경우 --> CFLGAS1 사용
2. 테스트 케이스 2: 컴파일러 옵션으로 -O3 를 준 경우 --> CFLAGS2 사용
3. 테스트 케이스 3: 컴파일러 옵션으로 -O3 -DBOOST_SP_DISABLE_THREADS 사용. BOOST_SP_DISABLE_THREADS를 켜면 reference count 값을 변경시킬 때, atomic operation을 사용하지 않게 됩니다.

이렇게 테스트 프로그램을 작성하고, 테스트를 해 봤더니 다음과 같은 결과가 나오더군요.

$ make
$ make test
time ./shared1 1000000000
1000000000
       15.66 real        15.52 user         0.04 sys
time ./shared_copy1 1000000000
1000000000
       93.33 real        92.58 user         0.24 sys
time ./plain1 1000000000
1000000000
        8.54 real         8.48 user         0.02 sys
time ./shared2 1000000000
1000000000
        2.81 real         2.79 user         0.00 sys
time ./shared_copy2 1000000000
1000000000
       37.86 real        37.62 user         0.08 sys
time ./plain2 1000000000
1000000000
        0.66 real         0.66 user         0.00 sys
time ./shared3 1000000000
1000000000
        2.84 real         2.79 user         0.00 sys
time ./shared_copy3 1000000000
1000000000
        5.74 real         5.70 user         0.01 sys
time ./plain3 1000000000
1000000000
        0.66 real         0.66 user         0.00 sys

(위 테스트는 looping overhead, function call overhead 를 정확히 측정하지 않았기 때문에 아주 정확한 수치라고는 주장할 수 없음을 미리 밝힙니다. 정확한 수치를 알아내기 위해서는 좀 더 잘 설계된 테스트 케이스를 사용해야 할 것입니다. 다만 위 테스트 결과를 사용하더라도 이 글에서 주장하고자 하는 바를 위한 충분한 근거가 되리라고 생각합니다.)

우선 테스트 케이스1와 테스트 케이스2, 테스트 케이스 3에서 공히 shared_copy > shared > plain 결과가 나온다는 걸 확인할 수 있습니다. BOOST_SP_DISABLE_THREADS 를 켜게 되면 shared_copy에만 영향을 미친다는 걸 알 수 있습니다. 이건 아까 말씀드린 대로 atomic operation이 disable되기 때문인 것으로 추측됩니다. 이상 결과로 보건대 shared_ptr을 쓰실 때는 다음과 같은 점에 유의하셔야 할 것 같습니다.

1.  No free lunch. 좋은 게 있으면 그에 대한 비용이 있기 마련이라는 것이죠. shared_ptr는 일반 포인터에 비해 overhead가 있다는 점을 염두해 두셔야겠습니다.
2. 단순 접근하는 overhead도 2.81/0.66(테스트 케이스 2)으로 상당합니다.
3. 복사 생성자 overhead는 37.86/0.66(테스트 케이스 2)으로 더 크다는 것을 확인할 수 있습니다. BOOST_SP_DISABLE_THREADS를 켜더라도 5.74/0.66으로 여전히 크다는 것을 알 수 있습니다. 따라서 될 수 있으면 복사를 shared_ptr도 복사는 피하셔야 하겠습니다.

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

$ uname -a
Darwin MyMac.local 9.7.0 Darwin Kernel Version 9.7.0: Tue Mar 31 22:52:17 PDT 2009; root:xnu-1228.12.14~1/RELEASE_I386 i386
$ g++ --version
i686-apple-darwin9-g++-4.2.1 (GCC) 4.2.1 (Apple Inc. build 5564)

여러분 환경에서는 어떤 결과가 나오는지 실험해 보시고, 트랙백 한 번 남겨 주시면 감사하겠습니다. ^^

소스 코드 첨부합니다(looping overhead를 측정하기 위해 본문 소스에서 약간 더 수정했습니다).


신고
  1. 파다기

    2009.06.30 09:37 신고 [수정/삭제] [답글]

    좋은거 하나 배우고 갑니다. :)

  2. eslife

    2009.07.01 06:32 신고 [수정/삭제] [답글]

    아는대로 써 보는 거랑, 실제 프로파일링 등으로 눈으로 성능을 확인하는거랑 상당히 차이가 나는 경우가 많더라구요.
    저 같은 경우 STL map 이 의외로 성능이 안 좋아서 많이 놀랬던 적이 있습니다.(VC 에서지만요) 랩핑도 가려 써야겠네요.. 에고 참 신경 써야 할 게 많습니다.

    • 김윤수

      2009.07.02 05:17 신고 [수정/삭제]

      그러게요. 저도 쓰면서 성능이 안 좋으면 어떡하지 하고 있었는데... 아니나 다를까 성능 차이가 꽤 되더라구요. 근데 그게 몇 번 불리지 않는거라면 괜찮지만 워낙 C++에서 포인터를 많이 쓰다 보니 자연스레 전체 실행시간에서 차지하는 시간이 또 상당하더라구요. 저도 이번에 좋은 거 하나 배웠습니다.

  3. lolized

    2009.07.08 06:49 신고 [수정/삭제] [답글]

    역시 뭐든지 트레이드 오프로군요. 포인터가 흘러가는 범위가 프로그래머가 감당 가능한 수준이라면 가급적 일반 포인터와 레퍼런스로 버티는게 좋을 것 같네요.

    이에 관련해서 궁금한게 있는데, 자바나 C#의 가비지 컬렉팅과 부스트의 shared_ptr를 비교하면 성능적으로 어느 정도의 차이가 나는지 궁금합니다.

  4. 김정환

    2009.08.23 19:37 신고 [수정/삭제] [답글]

    감사합니다. 항상 잘 보고 있습니다. :)

  5. xylosper

    2009.09.03 05:07 신고 [수정/삭제] [답글]

    말씀하신 프로젝트에서, boost::shared_ptr 을 제거하는 것으로 성능문제가 해결되셨나요?
    적으신 결과를 보면, 배율로 따지자면 최대 150배정도의 차이지만, 시간으로 따지자면 10억번 루프 돌려서 100초 미만의 차이가 나고있습니다.
    돌아가는 동안 포인터 복사하고 참조하는 연산을 10억번 하는 프로그램이라면, 전체 런타임에서 100초는 미미한 시간이 아닌가 하는 생각이 들어서 질문드립니다.

    • 김윤수

      2009.09.04 02:29 신고 [수정/삭제]

      shared_ptr이 성능에 영향을 미친다는 것을 알아낸 건 gprof 결과를 보고 알아낸 것이기 때문에 shared_ptr 을 안쓰고 나서 성능이 개선되긴 했습니다. 그런데 전체 소프트웨어로 봤을 때는 shared_ptr만 영향을 미친게 아니라 다른 것들도 영향을 미치고 있었기 때문에 문제가 해결된 것이라고 보기는 힘들구요. 그리고 제가 작성한 library 및 그걸 기반으로한 app이 shared_ptr 및 vector<shared_ptr>을 많이 쓸 수 밖에 없는 구조이기 때문에 당연히 성능 향상은 있었습니다. 그런데 shared_ptr을 intrusive_ptr 이란 걸로 교체해서 성능을 향상 시켰습니다. 아예 없애진 않구요.

    • xylosper

      2009.09.04 04:54 신고 [수정/삭제]

      vector에 넣고 쓸정도로 많으면 문제가 될수도 있겠네요.
      감사합니다. 덕분에 intrusive_ptr 도 알게 되었네요.

  6. sleepy

    2012.08.28 07:44 신고 [수정/삭제] [답글]

    좋은 글 감사합니다. 평소에 이 곳에 게시된 글들 보면서 많이 공부하고 있습니다.
    시일이 많이 지난 글이지만 의문사항이 있어 질문드립니다.

    위의 코드를 vs2008 sp1으로 돌려보았고 일단 말씀하신 것과 유사한 결과가 나왔습니다.

    그런데 제가 컴파일한 환경의 경우는 속도차의 주 원인이
    원시 포인터의 경우 (별다른 의존성이 없는 단순 ++문이라 그런지;)
    컴파일러가 for문 전체를 바로 상수 1000000으로 치환해버리는 최적화를 했기 때문이었습니다.

    shared_ptr의 경우는 foo함수의 단순 인라인화만 일어났구요.

    그래서 AClass::i 를 volatile로 선언해 보았는데 (shared_ptr의 reference 사용)
    이 경우 유의미한 속도 차이는 없었고 오히려 shared_ptr쪽이 근소하게 빨랐습니다.
    원자적 연산 제거같은 옵션은 따로 건드리지 않았습니다.

    테스팅 하신 환경에서도 혹시나 이와 같은 원인이 작용하진 않는지,
    그게 아니면 컴파일러 마다의 차이인지 의문이 들어 질문드립니다.

댓글을 남겨주세요.

티스토리 툴바