본 내용은 프라우드넷에 국한된 내용이 아닌 일반적인 프로그래밍의 이야기입니다. 어쨌건 프라우드넷을 개발하다가 튀어나온 이슈인지라 여기에 적어봅니다.

 

프라우드넷 오래전 버전 내부에서는 shared_ptr을 거의 쓰지를 않았습니다. 내부적으로는 처리속도를 우선하기 위해 shared_ptr이나 여타 smart ptr 객체를 안 썼습니다. 그 댓가로, 직접 포인터를 관리하는 방식이었죠. 네. 수동 new delete 말입니다.

하지만 내부 코드를 유지보수하는 것이 힘이 들대로 들어서, shared_ptr을 중간에 도입을 했었습니다.

 

그런데 이렇게 하고 났더니, 스트레스 테스트 결과 성능이 평소보다 다섯배 이상으로 하락하는 것이 발견됐었습니다. 결국 해결했습니다... 어떻게 해결했는지 소개하겠습니다.

문제의 원인은 shared_ptr의 복사 연산자였습니다.

 

코드로 예를 들게요.

 

shared_ptr<X> a = xxx;

shared_ptr<X> b = a; // [1]

 

여기서 [1]의 속도가 어마무시하게 느렸다는거죠.

이 코드가 항상 느린 것은 아닙니다. 일반적인 경우 느리게 작동 하지 않습니다. 둘 이상의 스레드가 [1]에서 경합할 때만 엄청나게 느렸죠.

 

프라우드넷은 리눅스, 윈도 모두 멀티코어 활용을 전제로 하고 있습니다. 포인터가 가리키는 객체에 대한 액세스 즉 off the pointer는 격리를 시키지만 정작 이 객체들을 가리키는 포인터 변수에 자체에 대해서는 격리되어 있지 않는 경우들이 많습니다. (멀티스레드 플밍하면 당연한 얘기죠.)

 

결국 이 문제는 두가지로 해결했습니다.

  1. move semantics.
  2. 파라메터 전달을 할 때 byval 대신 const byref를 하기.

 

이 두가지를 적용하고 나니 원래 속도에 근접하게 되돌아왔습니다. 물론 shared_ptr을 쓰면서 발생하는 약간의 성능 저하는 그냥 감수하기로 했고요. shared_ptr을 도입한 후부터 유지보수하기가 훨씬 좋아졌는데 어떻게 버리겠어요. ㅎㅎ

 

문제의 원인은 shared_ptr의 복사 연산자 내부에서 atomic operation을 하는 부분이 심각하게 느리다는데 있었습니다. 네. InterlockedIncrement나 gcc builtin atomic operations 자체가 엄청나게 느렸다는데 있었죠.

 

shared_ptr의 복사 연산자 안에서는 reference count를 1 증가시킵니다. 그리고 shared_ptr 변수가 사라질 때는 1 감소하고요. 이 증가와 감소 연산 말고도 다른 뭔가를 하는 것들이 있습니다.

shared_ptr 안에서는 이 증가와 감소를 atomic operation으로 하고 있습니다. 기계어로 풀어보면

lock xchg xxx xxx

딱 한줄입니다.

 

이 기계어 명령 한줄은 경합이 없으면 non atomic보다 2배 느린 수준에서 끝납니다. 그러나  경합이 있으면 이 기계어 한줄은 40배 정도 느려집니다.

 

그런데 byref나 move semantics를 쓰면 atomic op 자체를 건너뜁니다. 따라서 저 극악의 성능 저하가 예방되는거죠.

 

mutex나 critical section을 lock 하는 것보다 아토믹 연산이 훨씬 빠르다고 알려져 있습니다. 그러나 아토믹 연산도 결국 경합 앞에서는 엄청나게 느립니다. 다만 소프트웨어로 경합을 중재하는 것보다 훨씬 빠를 뿐 경합이 전혀 없을 때 보다는 훨씬 느리게 작동을 합니다. 안타깝게도 이 문제는 하드웨어 엔지니어 선에서 해결해야 합니다.

 

글만 쭈욱 적었는데, 예시 코드로 보여드립니다.

 

void func1()

{

shared_ptr<X> x;

func2(x);

func3(x);

}

 

void func2(shared_ptr<X> a) // byval

{

a->xxx();

}

 

void func3(const shared_ptr<X>& a) // const byref

{

a->xxx();

}

 

func2와 func3의 속도 차이는 평소에는 별로 없습니다. func2와 func3을 여러 스레드에서 병렬 실행을 해도 별 차이가 안납니다. 그러나 둘 이상의 스레드 안에서 동일한 shared_ptr 객체에 대해 func2를 동시에 실행할때와 func3를 동시에 실행하게 되면, func2 쪽이 훨씬 느리게 작동합니다. 이게 shared_ptr에 대한 byval과 byref의 차이입니다.

 

 

ref : http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Proudnet_Lec&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=headnum&desc=asc&no=14

반응형

+ Recent posts