다음과 같은 코드가 있습니다. 그냥 for문 돌려서 CPU를 소모하게 하는 코드입니다.
테스트 코드 1void foo(int loopCnt) // 2000000000{ int cnt = loopCnt; for (int i = 0; i < cnt; i++); }
loopCnt의 값이 무지막지하게 큰 값(20억) 을 주고 상기 코드를 돌리면 CPU를 100% 먹게 됩니다. 물론 CPU가 4개라면 25%를 먹게 되죠. foo()를 실행하기 이전에 tick(시각)을 구하고 foo()를 실행하고 나서 tick을 구한 다음에 그 차이를 출력해 봅니다. 제 컴퓨터에서는 7.878초가 걸립니다. 대략 8초가 나왔다는 얘기입니다.
[thread 갯수 1개]
7878
자, 그럼 상기의 foo() 함수를 여러개의 thread를 이용해서 동시에 실행을 해 봅니다. 그러면 foo() 함수를 실행하는데 걸리는 시각은 다음과 같이 나옵니다.
테스트 결과 1
[thread 갯수 1개]
7878
[thread 갯수 2개]
8689
8689
[thread 갯수 3개]
8799
8830
8815
[thread 갯수 4개]
9095
9282
9345
9314
thread 2개를 돌리면 약 8.6초, thread 3개를 돌리며 약 8.8초 정도 걸리는군요. 즉 foo 함수 내부의 코드는 다른 thread와 자원과 공유하는 부분이 없어서, 각각의 thread 수행에 지장을 주지 않기 때문에 thread의 완료 시점은 thread의 갯수와는 별 상관이 없이 비슷하게 나오는 것으로 판단할 수 있습니다.
자, 이제는 다른 테스트를 해 보겠습니다.
테스트 코드 2for (int i = 0; i < callCnt; i++) // 1000000000 { foo(loopCnt); // 1 }
loopCnt값은 아주 작은 값(1)을 주고, callCnt에는 아주 큰 값(10억)을 주어 실행을 합니다. 물론 상기 코드도 thread를 동시에 여러개 수행하여 경과 시간을 측정을 해 봅니다.
테스트 결과 2
[thread 갯수 1개]
9080
[thread 갯수 2개]
12184
12215
[thread 갯수 3개]
16115
17129
17254
[thread 갯수 4개]
24555
24898
25132
25304
테스트 결과 1 과 테스트 결과 2를 비교해 보면 조금 다르게 나옵니다. 결과 1은 thread가 늘어 나도 수행 완료 시간에는 별 영향을 주지 않는 반면에, 결과 2는 thread의 갯수와 수행 완료 시간은 상관 관계에 있다는 것으로 결과가 나옵니다.
테스트 코드 2를 보면 각 thread간의 간섭 현상(자원을 공유한다든지 하는)이 없음에도 불구하고 thread가 늘어 남에 따라 수행 시간이 늘어 나는 것은 이해가 되지 않습니다. 원인을 도통 알 수가 없네요.
실행 환경
CPU : Intel(R) Core(TM) i5 CPU U 470 @ 1.33GHz
Memory : 3GB
OS : Windows 7 32bit
Microsoft Visual Studio 2005 / Release Mode / Optimization Disabled(/Od)
소스 및 실행 파일 첨부합니다. call_loop_thread_test.zip (for windows only) multi_platform_call_loop_thread_test.zip (for windows, linux and boost)
#include <conio.h>
#include <list>
#include <stdio.h>
#include <windows.h>
typedef struct _Param
{
int callCnt;
int loopCnt;
int threadCnt;
} Param;
void foo(int loopCnt)
{
int cnt = loopCnt;
for (int i = 0; i < cnt; i++);
}
DWORD __stdcall threadProc(LPVOID p)
{
Param* param = (Param*)p;
int callCnt = param->callCnt;
int loopCnt = param->loopCnt;
DWORD begTick = GetTickCount();
for (int i = 0; i < callCnt; i++)
{
foo(loopCnt);
}
DWORD endTick = GetTickCount();
printf("%d\n", endTick - begTick);
return 0;
}
void usage()
{
printf("call_loop_thread_test <call count> <loop count> <thread count>\n");
printf("example : call_loop_thread_test 100 100 2\n");
}
int main(int argc, char* argv[])
{
if (argc != 4)
{
usage();
return 0;
}
Param param;
param.callCnt = atoi(argv[1]);
param.loopCnt = atoi(argv[2]);
param.threadCnt = atoi(argv[3]);
std::list<HANDLE> threadList;
for (int i = 0; i < param.threadCnt; i++)
{
DWORD threadID;
HANDLE threadHandle =
CreateThread(
NULL,
0,
&threadProc,
¶m,
0,
&threadID);
threadList.push_back(threadHandle);
}
for (std::list<HANDLE>::iterator it = threadList.begin(); it != threadList.end(); it++)
{
HANDLE threadHandle = *it;
WaitForSingleObject(threadHandle, INFINITE);
}
threadList.clear();
return 0;
}
도대체 원인이 뭘까요? 정말 모르겠습니다. -_-;
만약 메모리 접근에 의한 bottle neck이 원인라고 한다면 테스트 결과 1에서 thread가 2개인 경우와 thread가 3개인 경우의 경과 시간이 거의 일치하는 것은 어떻게 설명할 수 있을 까요? 아래는 C 코드를 Assembly로 변환된 코드입니다.
; 16 : for (int i = 0; i < cnt; i++); mov DWORD PTR _i$74131[ebp], 0 jmp SHORT $LN3@foo $LN2@foo: mov ecx, DWORD PTR _i$74131[ebp] // 메모리 건드림. bottle neck add ecx, 1 // ECX 레지스터 연산만 함.
mov DWORD PTR _i$74131[ebp], ecx // 메모리 건드림. bottle neck
$LN3@foo: mov edx, DWORD PTR _i$74131[ebp] // 메모리 건드림. bottle neck
cmp edx, DWORD PTR _cnt$[ebp] // 메모리 건드림. bottle neck
jge SHORT $LN4@foo // Flag 레지스터만 검드림.
jmp SHORT $LN2@foo // Flag 레지스터만 건드림.
$LN4@foo:
오른쪽에 주석을 단 부분이 cnt 값에 따른 반복이 되는 부분입니다. Assembly code를 보면 bottle neck을 야기할 수 있는 코드가 많습니다(Memory의 접근이 bottle neck을 야기시킨다는 가정하에서). 그렇다면, 상기 테스트 결과 1에서 thread 2개와 thread 3개의 실행 결과가 거의 비슷하게 일치되어 나오는 부분은 설명이 되지 않습니다(실행 결과 차이가 확실히 나야 한다는 얘기임).
그리고 Memory Access bottle neck이 생긴다고 가정하면
4 CPU 환경에서
thread가 하나일 때 25%,
thread가 2개일 때 50%,
thread가 3개일 때 75%
thread가 4개일 때 100%
CPU를 차지하는 현상은 어떻게 설명할 수 있을까요?
bottle neck이 생긴다면, CPU 점유율은 떨어 져야 하지 않을까요?
페북에서 영훈군이 댓글을 단 내용
제가 오랫동안 이쪽 세상을 떠나 있어서 그냥 상상이지만. 메모리 L1/L2/L3 Cache Hit Ratio는 계산해보셨나요? 웬지 느낌상 2번 코드는 웬지 캐쉬히트가 떨어질것 처럼 보이는데요 ^^
코드
for (int i = 0; i < callCnt; i++) { foo(loopCnt); }
(1) i, callCnt 모두 auto variable. stack에 쌓임.
(2) foo()함수 내부에서 i와 cnt 모두 auto variable. stack에 쌓임.
(1), (2) 모두 근접한 메모리(Stack)에 위치하게 됨.
foo() 함수를 호출하는 과정에서 caller(threadProc)와 callee(foo)가 멀러 떨어질 확률도 거의 없음(코드상으로 아 아래로 붙어 있으니).
결론적으로 코드에 의해서 Cache Hit가 떨어질 확률은 낮다고 보여 짐(code 영역뿐 아니라 stack 영역도).
허니, 이는 길길이의 예상이고 정확한 답안은 아님. ^^
아닐 수도 있는데요.
혹시나 해서 적어봅니다.
쓰레드가 사용하는 Call stack 이 존재하는 메모리 부분이 프로세스에 할당된 메모리 자원을 공유해서 사용한다면 그렇게 되지 않을까요?
그냥 추측 입니다.
틀릴 가능성 90% 이상이긴 하지만 가설 정도라고 보시면 될듯. 증명은 실력이 없어서 못하겠네요.