티스토리 뷰



이번 주제는 < 멀티코어를 왜 이용하기가 쉽지 않은가 > 쪽으로 잡았다.

(세줄 요약은 중간쯤에 있고, 그 밑에는 추가로 덧붙여 놓은 내용)


요즘은 기술이 좋아져서 코어도 여러개가 나오고 좋다 (사실은 클럭수 높이는데에 한계가 온것도 있었고...)

하지만 코어가 아무리 많이 달린게 나와도 요놈을 전부 써먹을 수 있도록 프로그래밍하기는 어려운데,

이건 작업을 동시에 실행가능한(즉, 병렬화가 가능한) 조그마한 작업으로 얼마나 쉽게 분할할 수 있느냐에 따라 달렸다.



예를 들어 보자.


Ex1) 0부터 100억까지 루프를 돌며 더하는 프로그램을 작성하려고 한다.

이 연산을 4개 CPU를 모두 사용해서 계산하려고 한다면, 1개의 CPU당 평균 25억씩 루프를 돌면 된다.

CPU0: 1 ~ 25억

CPU1: 25억 ~ 50억

CPU2: 50억 ~ 75억

CPU3: 75억 ~ 100억


이렇게 4개로 나눈 작업을 4개의 스레드로 각 CPU에게 균등하게 나눠준다면, 작업을 분산시켜 좀더 빨리 처리할 수 있다.

(*스레드: 운영체제에서 실행가능한 작업의 최소 단위. 1개의 프로세스 내에는 다수 개의 스레드가 돌아갈 수 있다.)


하지만 실제로의 응용은 그렇게 쉽게 이루어지지 않는데, 그 이유는 모든 작업이 위처럼 이상적으로 나눠지지는 않기 때문이다.

Ex2) 실제로는 사용자로부터 어떠한 입력을 받고 나면(a), 디스크로부터 데이터를 읽고(b), 계산을 한 뒤(c), 화면에 뿌려주는 것(d)과 같은 매우 복합적인 작업이 요구된다.


여기에서 입력을 받거나(a), 디스크로부터 데이터를 읽는 류의 작업(b) 은 I/O (입출력) 작업이다.

대체로, 우리가 장치와 통신을 할 때 이루어지는 절차는 아래와 같은데...


1. 응용 프로그램은 장치와 통신하기 위해서 운영 체제에게 도움을 요청한다.

2. 운영 체제는 장치에게 어떠한 데이터를 보낸다.

3. 운영 체제는 장치로부터 결과를 받을 수 있을 때까지 기다린다.

4. 장치는 결과를 반환해줄 준비가 되면 어떠한 특정 신호를 보낸다.

(IRQ를 일으키거나, 또는 특정 레지스터의 Busy Bit을 0으로 Clear 하는 등 이는 장치에 따라서 모두 다르다.

 물론, 운영 체제는 이를 감지할 수 있어야 한다.)

5. 기다림이 끝나면, 운영 체제는 결과를 장치로부터 읽어들인다.

6. 운영 체제는 결과를 응용 프로그램에게 반환한다.


이러한 류의 작업(a, b)은 (병렬화가 가능한) 더 작은 단위로의 분할이 불가능하다(또는 매우 어렵다)고 보면 된다. 이유는 1부터 6까지의 모든 절차가 서로 연계되어 있기 때문이다.

다시 말해서, 2는 1의 실행이 없으면 실행될 수 없으며, 3은 2의 실행이 없으면 실행될 수 없고 ... 6은 5의 실행이 없으면 실행될 수 없다. 대충 이런 거다.


제일 첫번째의 예제인 Ex1) 에서 작업을 여러 개로 분할할 수 있었던 이유는,

정수의 합만을 구하는 것이 목표이므로 합을 구하는 순서가 바뀌어도 실행 결과값에는 전혀 영향을 미치지 않기 때문이다.


즉 Ex2) 에서는 (c) 또는 (d)에서 병렬화가 가능하다.

물론 이들도 절차에 따른 연계가 되어 있는 부분이 많을수록 쪼개기 어려워진다.

다시 말하자면, 실질적으로 멀티코어를 사용할 수 있는 부분이 예상외로 그렇게 많지는 않다는 것이다.

(그래도 그래픽 작업이나 렌더링 같은곳에는 많이 사용되고 있다.)



이젠 다시 Ex1) 로 돌아가서 테스트를 해 보자.

실험을 위해서, 0부터 n까지 더하는 프로그램을 작성하고 테스트해보았다.

테스트 시 사용된 n=60억 (6,000,000,000) 이고, CPU는 i5-2450M CPU @ 2.50 GHz (2 Physical Processors with HT Enabled) 이다.

(=하이퍼스레딩 적용해서 논리코어는 4개)




1. 0부터 n까지 더하기, 싱글코어


그냥 쭉 더해서 합을 내는 것 외에는 볼만한 것이 없다.


콤파일 후 테스트.

약 4.6초의 시간이 걸렸다. (여러 번 실행해봤을 때, 대체적으로 4.5~4.7초 사이였다.)




2. 0부터 n까지 더하기, 멀티코어


이번은 싱글코어용보다는 조금 복잡하다.

계산할 영역을 4개로 분할한 뒤 (논리 코어수가 4개이므로), 계산하기 위해서 스레드를 4개 생성한다.

그리고 모든 스레드가 계산을 끝내고 종료할 때까지 기다린다.

각 스레드들의 계산이 끝나면, 4개의 부분합을 합쳐서 최종합을 얻는다.


콤파일 후 테스트를 해보니, 

약 3초의 시간이 걸렸다. (여러번 테스트했을 때는 2.9 ~ 3.1 초 사이였다.)




그리고 이 결과를 통해서 확인할 수 있는 건, 계산시간의 차가 예상외로 크지는 않다는 것이다.

싱글코어 : 약 4.6초

멀티코어 : 약 3초

계산해 보면 위의 테스트에서는 약 53% 밖에 빨라지지 않았다는 것을 알 수 있다.


이와 마찬가지로, 아래처럼 3.6GHz 의 코어가 8개 달렸다고 해서 실제 얻을 수 있는 속도가 8배가 되지는 않는다.

(단순히 이 예제만을 가지고 이야기하는 것이 아니다.)


코어 수와 성능과의 관계가 선형(Linear)이 아니게 되는 이유는 여러 가지가 있지만

(자주 언급되는 True Sharing/False Sharing 같은 캐시 또는 캐시 프로토콜 문제, Bus bandwidth, 필연적으로 사용할 수 밖에 없는 동기화, 

 운영체제 또는 응용 프로그램의 내부 설계적 결함 등)

여러 개의 복합적인 이유로 일어나므로 하나로 지목해서 말하기는 매우 어렵다...



그나마 다행인 건, 이러한 병렬 프로그래밍을 보다 쉽게 할 수 있도록 만들어진 OpenMP라는 것이 있는데...

이걸 이용하면 일일히 스레드를 생성할 필요도 없고 (내부적으로 알아서 해 주니까), 당연히 코드 라인수도 대폭 줄일 수 있다.



그럼에도 불구하고, 멀티코어를 고려해서 프로그래밍을 하는 것이 싱글코어에 비해서 훨씬 복잡하다는 것은 변하지 않는다.

잘못 사용할 경우 성능이 오히려 싱글코어보다도 못한 경우가 생기니까, 프로그래밍할때 잘 고려해야겠지?




세줄요약:


1. 멀티코어가 있어도, 모든 작업을 완전히 병렬화하기는 매우 어렵다.

2. 만일 병렬화가 가능하다고 해도, (여러 가지의 복합적인 문제로 인해서) 얻을 수 있는 성능이 예상보다 크지는 않다. (코어 수에 정비례하지 않음)

3. 위의 설명에서 언급하지는 않았지만, 복잡한 코드의 경우 디버깅도 더럽게 어려워서 개발도 쉽지 않다.





하이퍼스레딩이 적용된 채로 테스트했는데 이렇다면, 하이퍼스레딩이 적용되지 않은 상태에서는 어떨까?

갑자기 생긴 호기심에 테스트를 해보기로 했다.


하이퍼스레딩이 적용된 2개의 논리코어를 완전한 2개의 CPU로 보기에는 어려운데,

바로 아래와 같이 Architectural state (ex. CPU registers) 은 각각 가지게 되는 데에 반해*, execution engine과 캐시, 버스 등등은 물리 코어 내에 있는것을 공유해서 사용하기 때문이다.

(*메뉴얼에는 duplicated 라고 나와 있는데, 이게 완전히 독립된 상태는 아닌 것 같고... Sliding window처럼 관리하나? 이거에 대한 자세한 부분은 아직 모르겠다.)



그래서, 2가지를 실행해보기로 했다.

첫번째는 물리코어 내의 자원을 공유하지 않는 경우  (=물리코어 갯수당 1개의 논리코어만을 사용),

두번째는 물리코어 내의 자원을 공유하는 경우 (=1개 물리코어 안에 있는 2개의 논리코어만을 사용).

어떤 방법을 사용하던 논리코어는 2개만을 사용하기로 했다.



실행 결과는...

이게 첫번째 결과다. (=물리코어 갯수당 1개의 논리코어만을 사용)

약 3.3초가 나왔다. (여러 번 실행해보니 3.2~3.4 사이로 나왔다.)



두번째 결과 (=1개 물리코어 안에 있는 2개의 논리코어만을 사용).

약 5.5초가 나왔다. (여러 번 테스트했을 경우는 5.5~5.6 사이가 나왔다.)

심지어 코어 1개만 사용했을 때보다도 실행이 오래 걸렸네



뭔가 이상해서, 코드를 수정해서 1개의 코어에서 2개의 스레드를 돌려 봤다.

그런데 이건 5.7초가 나오는게 아니겠나



 빠른 순으로 나열하자면,

1위: 4개 논리코어 모두 사용 (약 3초)

2위: 2개의 논리코어만 사용(서로 다른 물리 코어에 존재) (약 3.3초)

3위: 1개의 논리코어만 사용 (약 4.6초)

4위: 2개의 논리코어만 사용(동일 물리 코어의 자원을 공유) (약 5.5초)

5위: 1개 코어만 사용, 하지만 2개 스레드 돌리기 (약 5.7초)


※ 이건 어디까지나 위 예제에 관한 경우다. 한 가지의 연산만으로는 모든 성능을 평가할 수는 없으며 연산 자체가 달랐다면 위의 순서가 변할 수도 있다.


3위와 5위와의 차는 cache miss에서 오는 것이 아닐까도 생각했는데, 이것만 가지고는 솔직히 잘 모르겠다.

댓글