2014년 8월 26일 화요일

Lock-free는 과연 빠른가? (3)

이전 글의 테스트 소스를 좀 더 실전 상황에 맞게 수정해 보았다.

먼저 아래와 같이 각 단위테스트마다 do_something 함수를 호출하여 일반 코드와 유사한 구조를 가지도록 하였다. 참고로 일반 상황에서는 간헐적으로 thread가 blocking 되거나 wait/sleep 상태에 돌입할 수 있는데, 이 테스트에서는 배제하였다. Lock-Free 로직을 통한 별렬성을 최대한 발휘할 수 있도록 고려한 것이다.
LONG gSomeData[1024];
static void do_something() {
for (int i = 0; i < 1000; i ++) {
gSomeData[i] += 1;
}
}
DWORD WINAPI test_lock_free(LPVOID param) {
int cntTest = (int)param;
for (int i = 0; i < TEST_LOOP; i++) {
volatile LONG* p = gSharedData;
for (int j = 0; j < cntTest; j++) {
::InterlockedIncrement(p);
p++;
}
do_something();
}
return 0;
}
또한, SpinLock 의 lock() 함수는 아래와 같이 단순화하였고,

void lock() { int cnt = cntSpin; while (!tryLock()) { if (-- cnt <= 0) { Sleep(1); cnt = cntSpin; } } }

SlowSpinLock 과 FastSpinLock 은 각각 아래와 같이 초기화하였다.
static SpinLock gSlowSpinLock(20); // cntSpin=20
static SpinLock gFastSpinLock(20000); // cntSpin=20000

테스트 결과는 아래와 같다.


=========== Threads = 1, Test loop = 640*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            359         374         374         359
      2            375         374         359         374
      3            375         358         375         374
      4            375         374         359         374
      5            390         375         374         374


=========== Threads = 2, Test loop = 320*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            250         359         359         265
      2            265         328         327         265
      3            266         358         375         265
      4            281         359         343         265
      5            281         327         359         281


=========== Threads = 4, Test loop = 160*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            234         358         359         219
      2            249         343         359         203
      3            265         343         328         203
      4            249         359         359         218
      5            250         359         359         202


=========== Threads = 8, Test loop = 80*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            219         343         343         219
      2            187         343         328         218
      3            187         343         344         218
      4            203         343         297         218
      5            187         343         328         234


=========== Threads = 16, Test loop = 40*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            187         312         297         218
      2            203         312         265         218
      3            203         312         265         219
      4            203         296         281         218
      5            187         312         281         234


=========== Threads = 32, Test loop = 20*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            203         296         250         234
      2            187         312         250         218
      3            203         296         234         219
      4            203         280         250         218
      5            203         297         234         234


=========== Threads = 64, Test loop = 10*1000 ===============
atomic_update | lock_free | slow_lock | fast_lock | crit_sect
      1            202         390         359         234
      2            203         452         359         250
      3            218         328         312         234
      4            203         343         312         234
      5            218         375         343         312

결론적으로 일반적인 상황에서 lock-free 와 critical-section 간의 차이는 거의 없으며, spin_lock의 효율성은 크지 않는 것으로 보인다.

한 편, 자바 코드의 실행결과는 어떻게 될까? 실은 그 의외의 결과 때문에 이 글을 올리게 되었다. 보시다시피 모든 실행결과가 C로 작성된 테스트코드보다 빠르다.
아래는 Linux 상에서 실행한 Java 코드 수행 결과이다. 굳이 리눅스 상에서 테스트를 한 이유는 pthread_mutex 테스트 결과가 매우 저조해서, 이를 내부적으로 사용하는 자바의 퍼포먼스를 직접 검증하기 위한 것이다. 편이를 위해 옆에 pthread_mutex 테스트 값을 추가하였다.


=========== Threads = 1, Test loop = 640*1000  ===============
atomic_update = 1 : java = 214   pthread = 1411
atomic_update = 2 : java = 214   pthread = 1402
atomic_update = 3 : java = 214   pthread = 1407
atomic_update = 4 : java = 216   pthread = 1402
atomic_update = 5 : java = 216   pthread = 1406
                                                               
                                                               
=========== Threads = 2, Test loop = 320*1000  ===============
atomic_update = 1 : java = 212   pthread = 2015
atomic_update = 2 : java = 214   pthread = 2176
atomic_update = 3 : java = 214   pthread = 1983
atomic_update = 4 : java = 213   pthread = 2079
atomic_update = 5 : java = 216   pthread = 2143
                                                               
                                                               
=========== Threads = 4, Test loop = 160*1000  ===============
atomic_update = 1 : java = 160   pthread = 2437
atomic_update = 2 : java = 172   pthread = 2455
atomic_update = 3 : java = 166   pthread = 2404
atomic_update = 4 : java = 172   pthread = 2415
atomic_update = 5 : java = 173   pthread = 2347
                                                               
                                                               
=========== Threads = 8, Test loop = 80*1000 ================
atomic_update = 1 : java = 158   pthread = 2498
atomic_update = 2 : java = 159   pthread = 2564
atomic_update = 3 : java = 153   pthread = 2518
atomic_update = 4 : java = 160   pthread = 2526
atomic_update = 5 : java = 156   pthread = 2438
                                                               
                                                               
=========== Threads = 16, Test loop = 40*1000  ===============
atomic_update = 1 : java = 169   pthread = 2634
atomic_update = 2 : java = 153   pthread = 2584
atomic_update = 3 : java = 159   pthread = 2543
atomic_update = 4 : java = 164   pthread = 2550
atomic_update = 5 : java = 161   pthread = 2536
                                                               
                                                               
=========== Threads = 32, Test loop = 20*1000  ===============
atomic_update = 1 : java = 164   pthread = 2650
atomic_update = 2 : java = 158   pthread = 2627
atomic_update = 3 : java = 163   pthread = 2483
atomic_update = 4 : java = 161   pthread = 2469
atomic_update = 5 : java = 163   pthread = 2535

                                               
=========== Threads = 64, Test loop = 10*1000 ===============
atomic_update = 1 : java = 164  pthread = 2668
atomic_update = 2 : java = 166  pthread = 2651
atomic_update = 3 : java = 164  pthread = 2711
atomic_update = 4 : java = 167  pthread = 2780
atomic_update = 5 : java = 163  pthread = 2741


테스트 결과, do_something 함수의 내부 루프가 100이하이고, 쓰레드 수가 32개 이하인 경우, 위에 사용된 spin-lock 코드가 가장 빠르며, 200~500 사이는 lock-free 가 가장 빠르다.
자바 코드는 내부 루프가 50 인 경우에도 쓰레드 수가 64개 이상이면 가장 빠르다. 이후 내부 루프가 800 이상이 되면 거의 모든 테스트 항목에서 Java 코드 수행 속도가 가장 빨라진다.
이는 단순히 Sleep()을 사용한 위의 spin-lock 코드보다, 자바 모니터의 락 검사 메카니즘과 Thread-scheduling이 훨씬 더 효율적임을 의미한다.

굳이 쓰레드간 충돌에 의해 스케쥴링이 발생해선 안되는 상황, 즉 병렬컴퓨팅이 필요한 상황이 아니더라도 변수 한 두개를 InterlockedXxx 함수를 써서 변경하는 것은 속도 여부를 떠나, 구현과 사후관리를 위해서도 권장할만한 사항일 것이다.
다만 그 이상의 복잡한 상황이라면 일단은 주저없이 CriticalSection과 java synchronized 를 쓰는 것이 바람직하다. 실행환경이 한정되고 고정적이지 않는 한, 시스템이 제공하는 동기화 API 이상의 퍼포먼스를 내는 것은 매우 어려울 뿐더러, 안정성도 심하게 떨어뜨린다. 관련 최적화는 시스템이 매우 안정화된 상태로 미루는 것이 맞을 것이다.

멀티쓰레드 쓰레드 코딩 시 가장 중요한 두 가지 이슈는 퍼포먼스와 안정성이라 하겠다. 위 테스트 결과가 관련 시스템 설계 및 구현 시에 도움이 되기를 바란다.

댓글 없음:

댓글 쓰기