본문 바로가기

[ IT Column ]/Programming

병렬 프로그래밍

10억 개의 트랜지스터로 만들어진 CPU는 이미 나온 지 몇 년이 되었다. 2011년에 나오는 인텔의 차세대 샌디 브릿지(Sandy Bridge)는 10억 개에 거의 육박한 9억9,000만 개의 트랜지스터가 가로 2cm, 세로 1cm의 칩에 있으니 첫 번째 예상, 즉 단위 면적당 집적 가능한 반도체의 수가 18개월마다 2배로 증가한다는 무어의 법칙은 현재까지 정확히 맞았다. 이 10억 개가 들어있는 2011년의 CPU는 20~30만원이면 살 수 있다. 이미 AMD와 엔비디아의 GPU는 20~30억 개의 트랜지스터가 들어간 칩을 만들고 있기도 하다.

“2011년에는 10GHz CPU가 나올 것”
그런데 10GHz의 클럭 속도는 달성되었는가? 오버클럭을 하면 4GHz의 속도도 볼 수 있지만, 2011년에 등장하는 고성능 데스크톱/서버 CPU의 작동 속도는 보통 2~3GHz이다. 그러니 10GHz라는 수치와는 크게 달라 두 번째 예측은 보기 좋게 빗나갔다. 왜 10GHz에 도달하지 못했을까? 바로 10GHz와 같은 고 클럭은 엄청난 전력과 발열이 뒤따르기 때문이다.

사실 클럭 속도는 CPU에서 매우 중요한 요소다. 컴퓨터와 CPU의 성능은 주어진 작업을 얼마나 빠르게 처리할 수 있느냐(레이턴시)와 단위 시간당 얼마나 많은 작업을 할 수 있느냐(쓰루풋)로 측정할 수 있다. 일반인이 쓰는 데스크톱/노트북 컴퓨터는 빠르게 응답하는 조건, 즉 더 낮은 레이턴시가 더욱 중요하다. 이것은 싱글스레드 혹은 싱글코어의 처리 속도에 달려 있다. 그래서 클럭 속도가 과거, 특히 90년대에는 CPU의 모델명이 될 정도로 컴퓨터 성능의 척도였다. 모든 컴퓨터 회사가 클럭 속도를 높이고자 치열하게 경쟁했다. 실제로 동일한 내부 구조라면 클럭 속도만큼 시스템 성능도 비례해 증가한다. 반도체 제조 공정이 미세화되면 전자가 파이프라인 한 단계를 거치는 데 걸리는 시간도 줄어 클럭 속도를 올릴 수 있었다.

그러나 높은 클럭 속도에는 대가가 따른다. 전자 회로에서 작동 주파수가 증가하면 전력은 비례해서 증가한다. 그런데 높은 주파수에서 CPU가 정확하게 작동하려면 높은 전압이 필요한데, 소비 전력은 이 전압의 제곱에 비례한다. 결국, 클럭을 많이 높이려면 거의 세제곱에 비례하는 전력이 증가하는 셈이다. 이 문제가 90년 대 초반까지는 그렇게 심각하게 대두되지 않았다. 그 당시 컴퓨터는 원체 적은 전력만 사용해서 거의 히트싱크만 있거나 작은 쿨러만 있어도 문제가 없었다. 그러나 현재의 고성능 CPU는 최대 작업 시 90W가 넘는 전력을 쓴다. GPU는 이보다 훨씬 심해서 150W를 넘기기도 한다. 손가락 마디만한 칩에서 백열등에 해당하는 열이 뿜어져 나오니 요즘 컴퓨터는 그렇게 뜨겁고 시끄러운 것이다.

그렇지만 무어의 법칙은 여전히 유효해서 쓸 수 있는 트랜지스터의 수는 꾸준히 증가하고 있다. 과거에는 이 트랜지스터의 여유를 싱글코어의 성능을 높이는 데 사용했다. 캐시의 크기를 늘리고 파이프라인을 더 복잡하게 했다. 그리고 전력에도 여유가 있었다. 이 시절에는 1%의 성능을 끌어 올리는 데 3%의 전력을 희생할 수 있었다. 그러나 이러한 고전력 고성능의 CPU가 90년대 말부터 심각한 한계에 부딪혔다. 지금은 인텔에 흡수된 DEC의 Alpha 칩에는 싱글코어의 성능을 높이기 위해 상당히 진보적인 기능이 많이 구현되었다. 1996년에 나온 Alpha 21264라는 칩은 1.3GHz의 속도에서 80W의 소비 전력을 보였다. 이후에 나온 21364는 150W에 달했다. 요즘 출시되는 고성능 그래픽카드의 소비 전력이 이 정도인데 발열을 감당하기 위해 어마어마한 쿨러와 히트싱크가 내장되는 것을 보면 고성능 고클럭 프로세서의 전력 소비는 이미 오래전부터 심각한 문제였다. 이런 고전력 문제는 결국 2000년대 초 인텔 펜티엄4에서 너무 심각해졌고, CPU의 설계 방향 그리고 학계 및 업계의 연구 흐름도 에너지 효율을 최선으로 하는 방향으로 바뀌게 되었다. 여기서 찾은 대안이 멀티코어로의 진화였다. 이것이 가능했던 이유는 쓸 수 있는 트랜지스터의 수는 꾸준히 늘고 있었기 때문이다.

2005, 2006년을 기점으로 데스크톱/서버 CPU의 멀티코어화가 본격화되었으며. 더 이상 고클럭만 추구하는 설계 방식은 찾아보기가 어렵다. 예를 들어, 2011년의 인텔 샌디 브릿지 i5-2400은 과거 Alpha 21264와 비슷한 최대 95W를 쓰지만, 3.1GHz의 속도로 작동하는 코어가 4개나 있다. 앤디 그로브의 예상대로 2011년에 10GHz의 속도가 가능했다면 1996년의 컴퓨터보다 1,000배 이상 빠른 싱글코어를 가졌을지도 모른다. 그러나 비록 그런 속도에는 도달하지 못했더라도 선형적으로라도 증가된 클럭 속도와 훨씬 개선된 에너지 효율성(단위 전력당 처리 성능) 그리고 무엇보다 멀티코어가 구현되었다. 여기에 칩 내부에는 내장 GPU까지 있다. 그리고 이런 CPU를 20만 원대에 구입할 수 있다. 여전히 CPU에서 클럭 속도는 매우 중요하지만, 그보다는 에너지 효율성이 더욱 중요해졌고, 멀티코어로 CPU 성능 향상을 꾀하고 있다.

병렬화 - 멀티코어 CPU를 잘 쓰기 위한 유일한 방법
한때, 듀얼코어 CPU가 등장했을 때, 황당한 컴퓨터 광고가 있었다. 코어 하나가 3GHz로 작동하니 이것이 2개 있으므로 6GHz의 속도를 가진다는 내용이었다. 멀티코어가 되면 동시에 할 수 있는 일이 많아지는 것이지 어떤 작업을 더 빨리 마칠 수 있는 것은 아니므로 이 이야기는 터무니없는 말이다. 그런데 이런 착시 현상은 사실 멀티코어 CPU에서 매우 중요한 문제를 대변한다. 클럭 속도가 2배씩 뛰던 그 시절에는 소프트웨어가 가만히 있더라도 소프트웨어의 처리 속도도 저절로 함께 빨라졌다(다만 문제는 똑같은 일을 하더라도 이제는 더 풍부한 그래픽 효과나 부가 기능이 추가되어 실질적으로 사용자가 할 수 있는 일이 크게 는 것은 아니다). 그런데 3GHz 듀얼코어 CPU가 있다고 해서 어떤 프로그램이 2배 빠르게 작업을 마칠 수 있는 것은 아니다. 이 듀얼코어로 2배의 처리 효율을 가지려면, 먼저 (1) 동시에 작업할 수 있는 일감을 찾아야 하며, (2) 그 일감을 정확하고 효율적으로 동시에 처리할 수 있도록 프로그램을 새로 작성해야 한다. 그래야만 멀티코어 CPU의 능력을 제대로 활용할 수 있다.

위에서 제기한 두 문제 중 첫 번째는 처리하고자 하는 문제에서 다양한 종류의 병렬성을 찾아야 한다는 것이고, 두 번째는 기존의 싱글스레드인 프로그램을 멀티스레드화 혹은 병렬화해야 하는 문제이다(이 글에서는 병렬/병행/멀티스레드 프로그래밍을 같은 의미로 사용한다). 예를 들어, 모바일 장치에서 가장 중요한 일 중 하나인 웹 브라우징을 병렬화해 처리 속도를 개선하려면, 먼저 동시에 처리할 수 있는 일이 있는지를 찾아야 한다. 명시적으로 보이지 않을 때는 문제를 약간 변형하면 찾을 수도 있다. 웹 문서 같으면 스타일 시트에서 동시에 파싱할 수 있는 부분을 찾을 수 있다는 연구 결과가 있다[2]. 물론 특정 과학/수학/신호처리처럼 병렬성이 아주 풍부한 문제도 많다. 이런 병렬성을 찾았으면 이제 실제 병렬로 수행하는 코드를 작성해야 한다. 간단한 멀티스레드 프로그램도 정확하게 작성하기가 어렵다는 사실을 보면 이 병렬화 작업은 매우 난해한 작업이다. 더군다나 버그 없이 작성하는 것도 어렵지만 최적화하는 것은 더욱 어렵다. 그런데 만약 지금 10GHz의 CPU가 있다면 힘들여 병렬화할 필요가 거의 없다.

병렬 프로그래밍은 얼마나 이뤄지고 있는가?
그렇다면 병렬 프로그래밍이 얼마나 이뤄지고 있는지 알아보자. 멀티코어 CPU, 엄밀히 말해 칩 하나에 여러 프로세서가 탑재된 CPU의 등장 이전에도 이미 여러 컴퓨터를 연결한 클러스터나 수퍼컴퓨터가 주로 수학/과학 기반의 프로그램을 병렬처리하고 있었다. 병렬 프로그래밍의 역사는 사실 컴퓨터공학의 탄생부터 그 역사를 함께 한다고 볼 수 있다. 수퍼컴퓨터는 수천 대의 컴퓨터를 고속의 네트워크로 연결한 것이므로 근본적으로 병렬 컴퓨터, 거대한 매니코어 CPU라 볼 수도 있다. 다만 차이가 있다면, 프로그래밍 관점에서 주소 공간이 공유되느냐, 캐시 모델이 어떠한가, 각 코어 간의 통신 레이턴시가 어떠한가와 같은 것이 있다. 그러나 본질적으로 수퍼컴퓨터나 멀티코어 CPU의 개념은 동일하다. 그런데 과학 분야의 계산은 대부분 행렬 계산처럼 연산 자체에 풍부한 병렬성이 있어 병렬화가 용이한 편이다. 병렬화 후 성능 향상도 비교적 매우 높은 편이다. 또, 이런 문제는 수많은 박사급 고급 인력이 오랜 시간에 걸쳐 작업하기도 했다. 서버 응용프로그램 역시 여러 클라이언트의 접속을 보통 동시에 처리할 수 있으므로 병렬처리가 자연스럽다. 대량의 문서를 서버가 계산하는 것도 보통 서로 의존성 없이 병렬처리가 가능할 때가 많다. 맵리듀스 같은 병렬처리 프레임워크가 대표적인 예라 할 수 있다.

그렇지만 앞서 소개했듯이 이제 데스크톱도 모두 듀얼/쿼드코어가 기본이고 올해에는 모바일 기기도 듀얼코어화되었다. 대부분의 프로그래머는 모바일/데스크톱 시장을 목표로 하기에 이 분야에서의 병렬화가 얼마나 이뤄지고 있는지 살펴보는 것은 중요하다. 먼저 시장 규모나 팔리는 기계수를 보면 서버/수퍼컴퓨팅 분야보다 데스크톱/노트북 시장이 훨씬 더 크고, 모바일은 이보다도 훨씬 더 크다. 그렇지만 <그림 1>에서 보듯이 병렬화에 대한 연구는 오히려 그 반대로 서버/과학 분야가 압도적으로 많았다[3].

<그림 1> 과학/서버, 데스크톱/노트북 분야의 비교

이렇게 시장 규모는 데스크톱/모바일이 훨씬 더 크지만 병렬화에 대한 연구는 아직까지도 미비하다. 여기서 2000년과 2010년의 각종 프로그램의 멀티코어 활용도를 비교한 연구 결과를 살펴보자[4]. 이 연구는 어떤 프로그램이 평균적으로 동시에 사용하는 스레드 개수, 스레드 수준의 병렬성(TLP, Thread Level Parallelism)을 여러 데스크톱 프로그램에 걸쳐 정량적으로 측정했다. 예를 들어, TLP가 2라면 프로그램이 수행될 때 평균적으로 2개의 스레드가 늘 바쁘게 작동했다는 이야기이다. 자명하게 이 값이 크면 클수록 보다 많은 병렬성을 잘 활용한다는 뜻이므로 멀티코어에 적합하다.

<그림 2>는 2000, 2010년에 쓰이는 주요 프로그램의 스레드 수준 병렬성(TLP)을 나타낸다[3]. 한눈에 보더라도 특정 프로그램 군을 제외하고는 2010년이 되어도 TLP 값이 1~2 수준에 머무르고 있음을 볼 수 있다. TLP가 특히 높은 분야는 쉽게 예상하듯 비디오 인코딩 분야로서 평균 6.6에 해당한다. 2000년도의 프로그램에 비해 큰 발전을 이뤘다. 그러나 오피스 프로그램은 거의 차이가 없고, 게임 역시 다소 실망스러운데 평균 TLP 값은 2가 되지 않았다. 게임이 아직까지도 멀티코어 지원이 잘 안 된다는 사실은 익히 잘 알려져 있다[5]. 웹 브라우저도 큰 폭의 진전은 없다고 볼 수 있는데, 최근 주요 브라우저가 멀티 프로세스 구조로 변경됨에 따라 TLP가 다소 상승되었고 점차 증가할 것이다.

결과적으로 10년이 지났고 멀티코어가 대중화된 지 3년 이상이 지났지만, 비디오 인코딩 같은 분야를 제외하면, 여전히 소프트웨어는 멀티코어를 제대로 활용하지 못하고 있다. 또한 이 연구 결과는 GPU의 사용률도 측정했는데 역시 대부분의 데스크톱 프로그램은 낮은 사용률을 보였다(그러나 최근에는 웹 브라우저의 GPU 가속이 본격화되었다). 이런 사실은 그렇게 놀랍지는 않다. 데스크톱의 대부분의 프로그램은 원래 병렬화하기가 어렵고, 무엇보다 처리하는 일감에 병렬성이 보통 부족하다. 예를 들어, 오피스 같은 프로그램은 많은 스레드를 만들지만 대부분이 복잡한 연산을 동시에 하기보다는 사용성이나 반응 속도를 개선하고자 스레드를 사용한다. 그래서 듀얼코어 이상만 되어도 충분하기는 하다. 그렇지만 그림이 잔뜩 있는 문서나 수많은 계산을 해야 하는 스프레드시트라면 사실 얼마든지 멀티코어를 잘 활용해서 성능을 높일 수 있다. 아직까지 이 부분은 구현되지 않고 있다.

이런 데크스톱의 낮은 TLP는 CPU 설계에도 중요한 의미가 있다. 비록 멀티코어화가 중요하지만 데스크톱에서 8코어 이상의 멀티코어는 큰 효과가 없음을 얘기한다. 인텔의 샌디 브릿지를 예로 들면 10억 개의 트랜지스터로 얼마든지 6코어/8코어를 만들 수 있지만, 데스크톱을 대상으로 한 Core i3/5/7 모델군은 쿼드코어로 하고 남는 트랜지스터로 GPU를 넣어 전체적인 시스템 성능 향상을 추구하고 있다. AMD도 역시 비슷한 전력을 데스크톱에서 취하고 있다. 한편, 여전히 상당수의 프로그램이 병렬화되지 않았으므로 데스크톱에서는 클럭 속도가 늘 중요한 문제가 된다.

프로그래머의 관심과 교육 수준도 아직까진 낮다. 대표적인 프로그래밍 사이트인 stackoverflow의 태그 정보로부터 병렬 프로그래밍 관련 질문 답변의 개수를 얻어 보았는데 생각보다 무척 낮았다[6]. 학교에서의 병렬 프로그래밍 교육도 아직 걸음마 수준이다. 비록 멀티스레드 프로그래밍은 오래 전부터 했지만 제대로 된 병렬 프로그래밍 과목을 2005년에야 필자는 처음 접할 수 있었다.이렇듯 현재의 병렬 프로그래밍 수준, 특히 데스크톱/모바일 분야는 아직 널리 퍼진 상태는 아니다. 

그럼에도 불구하고 필자는 듀얼/쿼드코어 정도는 얼마든지 데스트톱/모바일 응용프로그램이 잘 활용할 수 있는 여지가 있다고 믿고 있다. 이를 위해, 보다 쉬운 병렬 프로그래밍을 가능케 하고자 많은 도구와 언어 지원이 잇따르고 있다. 필자가 지난 호까지 3회에 걸쳐 소개한 패러럴 스튜디오도 이런 노력 중 하나이다. 그리고 무엇보다 앞으로는 프로세서 차원에서 보다 쉬운 병렬 프로그래밍을 위해 여러 지원도 반드시 필요하다고 생각한다.

현재의 병렬 프로그래밍 방법론
간략하게 현재의 병렬 프로그래밍 방법론을 열거해 보자. 병렬 프로그래밍 방법론은 대상 컴퓨터의 구조에 따라, 프로그래밍 모델에 따라 여러 가지가 있다.

먼저, 컴퓨터 구조적 관점에서 병렬 프로그래밍 방법론은 주소 공간이 공유되느냐 나뉘어 있느냐에 따라 크게 달라진다. 일반적인 멀티코어는 여러 코어가 하나의 공유된 주소 공간을 공유하고, 캐시 역시 CPU가 지원하는 코히런시로 동일하게 유지된다. 반면, 수퍼컴퓨터나 수백 대 이상의 컴퓨터가 묶여 있는 시스템에서는 하나의 공유된 주소 공간을 두기 어려우므로 독립적인 메모리를 각 노드가 가진다. 전자의 공유 메모리 구조에서 병렬 프로그래밍은 일반적인 멀티스레드 프로그래밍 방법론과 동일하다. 가장 낮은 수준에서는 각 운영체제가 지원하는 스레드 API, Pthread 혹은 Win32 API를 이용하는 방법이 있다. 이를 보다 추상화시켜 간편하게 병렬화할 수 있는 길도 여럿 있다. 라이브러리 차원에서는 TBB(Thread Building Block)와 TPL(Task Parallel Library)을 들 수 있고, 컴파일러 혹은 언어적인 차원에서는 OpenMP, Cilk, X10, Go가 대표적이다. Erlang, Haskell 같은 함수형 언어에서도 병렬성을 보다 쉽게 처리할 수 있는 방법을 제공한다. 반면, 분산 메모리 구조에서는 주소 공간이 공유되지 않아 명시적으로 데이터를 주고받아야 하므로 사뭇 다른 병렬 프로그로그래밍 방법론이 필요하다. 대표적으로 MPI (Message Passing Interface)가 있다. 또한 GPGPU를 위한 CUDA와 OpenCL도 그래픽카드의 메모리와 CPU가 쓰는 메모리가 서로 독립적으로 있는 모델을 취하고 있으므로 명시적으로 데이터를 교환하는 과정이 필요하다.

<그림 2> 2000년과 2010년의 주요 프로그램의 스레드 병렬성의 비교

한편, 병렬 프로그래밍 방법론은 명시적인(explicit) 방법론과 암묵적인(implicit) 방법론으로 구분할 수 있다. 위에서 열거한 수많은 방법론은 대부분 명시적 병렬 프로그램 방법론에 속한다. 말 그대로 프로그래머가 명시적으로 병렬성을 찾아 병렬 코드를 작성하는 것이다. 반면, 암시적인 방법은 컴파일러, 언어, 하드웨어가 자동적으로 병렬성을 추출해 병렬 실행을 한다. 대표적인 예로 MATLAB을 들 수 있는데 수학 계산에는 병렬성이 풍부하므로 프로그래머가 직접적으로 병렬처리를 지시하지 않아도 자동으로 수행할 수 있다. 인텔의 Concurrent Collection(CnC)은 병렬 프로그래밍 작업을 도메인 전문가(domain expert)와 튜닝 전문가(tuning experT)로 나누는 방법을 채택하고 있다. 도메인 전문가는 오직 알고리즘의 논리적인 관계를 명시적인 병렬성 표현 없이 CnC가 제시하는 표현 방법으로 기술한다. 예를 들어, 분자생물학자가 프로그램을 만든다면 OpenMP와 같은 언어를 모른 채 자신이 구현하고자 하는 알고리즘을 CnC로 구현한다. 그러면 이제 CnC는 병렬처리 전문가가 비록 분자생물 알고리즘을 잘 모르더라도, 보다 높은 성능을 위해 최적화할 수 있도록 돕는다. 마이크로소프트도 최근 Axum이라는 병렬 프로그래밍 언어를 내놓았다.

마지막으로 활용하는 병렬성의 종류에 따라서도 나눌 수 있다. 일반적인 프로그래머가 접하는 병렬성은 스레드 수준의 병렬성이다. 명시적이든 암묵적이든 작업이 여러 스레드로 나눠 동시에 처리된다. OpenMP, TBB 등 대부분의 라이브러리는 이 스레드 병렬성을 주목적으로 한다. 그러나 GPGPU는 스레드 병렬성보다는 벡터 수준의 병렬성 혹은 데이터 수준 병렬성에 더 비중을 둔다. 이미 CPU에도 x86의 SSE 같은 SIMD 명령어로 어느 정도 벡터 병렬성을 지원하고 있었는데, 최근의 GPGPU는 이 벡터의 폭이 보통 512비트로 기존 CPU의 2~4배에 달해 상당량의 벡터 병렬성을 제공한다. 기존의 x86 SSE 프로그래밍은 프로그래머가 직접 자료구조를 벡터 폭에 맞춰야만 했다. 그러나 현재 엔비디아의 GPU는 512비트 벡터 연산을 지원하는데, CUDA 프로그래밍 모델은 SSE 프로그래밍과 달리 프로그래머가 이런 벡터 연산 폭을 모른 채 코드를 작성한다. 보다 추상화된 일반적인 벡터 폭을 위한 프로그래밍 모델이라 할 수 있다. 참고로 CUDA에서는 하나의 스레드가 SIMD 연산 중 한 원소 연산만 담당하므로 멀티코어 CPU의 스레드에 비해 상당히 가벼운 구조이고, 개념이 다소 다르다. CUDA 런타임은 자동으로 주어진 코드를 적절히 GPU에 배치해 SIMD 연산을 수행한다.

한편 인텔은 코드네임 라라비(Larrabee)로 멀티코어 기반에 512비트 벡터 연산을 추가한 GPGPU를 개발했는데, 기대에 못 미치는 그래픽 성능으로 현재는 이 구조를 순수 고성능 GPGPU 계산용 제품, MIC(Many-Integrated Core)라는 이름으로 탈바꿈시켰다. 라라비/MIC는 엔비디아의 GPU와는 상당히 다른 전통적인 멀티코어에 기초한 구조로(x86 CPU를 여러 개 묶은 형태) 기존의 CPU 기반의 프로그래밍에 익숙했다면 오히려 손쉽게 접근할 수 있는 장점이 있다. 엔비디아 CUDA는 보다 추상화된 모델을 제공하지만 프로그래밍에 익숙해지려면 다소 시간이 걸린다. 그러나 라라비/MIC는 스레드 수준의 병렬성(다수의 x86 CPU 코어로부터)과 벡터 병렬성(512비트 벡터 연산으로부터)이 서로 혼재되어 있어 최적의 성능을 내려면 이를 동시에 잘 활용해야 하는 부담이 있다. 인텔은 최근 ArBB(Array Building Block)라는 라이브러리로 하드웨어에 덜 종속적인 벡터 프로그래밍 방법론을 내놓았다.

어려운 병렬 프로그래밍
병렬 프로그래밍이 어렵다는 것은 새삼 말할 필요도 없다. 동시에 이뤄지는 일을 그리는 것은 일반적인 인간의 사고보다 훨씬 어렵다. 이런 작업을 프로그래밍 언어로 표현하는 것은 아직까지 매우 난해하다. 그런데 병렬화를 힘들게 했는데도 제대로 안 돌아가거나 비효율적인 예를 어렵지 않게 찾을 수 있다. 몇 가지 사례를 소개한다[11].

세계적인 가전 회사에서 만든 HD 캠코더를 위한 영상 재생 프로그램이 있다. 이 프로그램은 HD 캠코더로 찍은 영상을 분석해 포함된 사람의 얼굴을 추출하는 기능이 있다. 수많은 동영상 파일이 있다면 멀티코어에서 병렬처리를 하는 것이 좋다. 1,000개 정도의 동영상 파일을 분석시켜 봤다. 쿼드코어 컴퓨터에서 2개 정도의 CPU를 쓰기는 하는데 그래도 효율적으로 작동되는 것 같지 않았다. Process Explorer라는 유틸리티로 얼마나 많은 스레드가 제대로 돌아가는지 살펴보니 무려 1,000개가 넘는 스레드가 만들어져 있었는데, 바쁘게 돌아가는 스레드는 많지 않았다. vcomp.dll이라는 비주얼 스튜디오의 OpenMP 런타임 dll을 쓰고 있는 것으로 보아 분명 OpenMP로 작성한 코드였다. 그러나 의미 있는 병렬처리는 이뤄지지 않았다. 최악으로 스레드 개수가 점점 늘어나더니 프로그램은 죽고 말았다.

요즘 웹 브라우저는 안정성을 도모하고자 멀티 프로세스 구조로 바뀌었다. 보통 웹페이지를 파싱 분석하는 작업과 화면에 그리는 작업을 두 프로세스로 나눠서 처리한다. 전형적인 생산자-소비자 구조이다. 그런데 한 유명 브라우저는 어떤 작업을 처리하는 데 있어 분명 이 두 프로세스가 동시에 돌아갈 수 있음에도 실질적인 효율이 싱글코어 수준밖에 되지 않았다. 조사해 보니 두 프로세스가 서로 직렬화되어 병렬성을 전혀 얻지 못하고 있었다. 그러나 다른 웹 브라우저는 이 경우 두 프로세스를 제대로 활용해 듀얼코어 이상에서 더 나은 성능을 보여줬다.

연구 결과[2]는 파이어 폭스에서 CSS(캐스케이딩 스타일 시트)의 파싱 부분을 병렬화해 보다 웹페이지 렌더링 시간을 단축했다. 저자의 노력이 한 눈에 느껴질 정도로 세심히 문제를 분석하고 구현해 실험했다. 그런데 듀얼코어에서 최고 1.6배까지 빨라지는 웹페이지도 있었지만 평균은 1.1~1.2배 정도였고, 무엇보다 약 40%는 병렬화로 인한 성능 향상이 없거나 오히려 하락했다. 만약 모바일 기기였다면 40%의 경우는 듀얼코어로 전력만 낭비하는 셈이 된다. 당연히 추가 연구로 이런 문제를 수정할 수 있으리라 믿지만, 전력 효율까지 생각하는 병렬화가 매우 어렵다는 것을 단적으로 보여주는 사례이다.

낡은 하드웨어 - 병렬 프로그래밍을 더 어렵게 하는 범인
병렬 프로그래밍이 더욱 더 어려운 것은 현재의 하드웨어가 병렬 프로그래밍에 매우 적합하지 않기 때문이다. 컴퓨터 구조의 발전은 성능 향상과 프로그래밍 용이성의 끊임없는 타협이라 볼 수 있다. 프로그래밍을 쉽게 하면 할수록 일반적으로 성능이 희생되므로 CPU 아키텍트는 적절한 타협점을 찾는다. 대표적인 예로 가상 메모리가 있다. 지금은 이 기능이 CPU 차원에서 지원되어 대부분의 프로그래머는 현재 내 컴퓨터의 메모리가 어떤 구조로 얼마나 있는지 크게 개의치 않고 작성할 수 있다. 그러나 이 가상 메모리를 처리하려면 그 만큼의 CPU 자원이 필요하고 부가 연산이 필요하다. 메모리 정렬도 예로 들 수 있다. 과거 x86 SIMD 명령어나 임베디드 장치를 쓰려면 데이터는 반드시 정렬되어야만 했다. 그러나 CPU가 발전하면서 이런 프로그래밍 제한이 지속적으로 완화되거나 사라졌다.

그런데 병렬 프로그래밍에서 하드웨어의 지원은 사실 매우 척박하다. CPU의 태생이 병렬 프로그래밍을 고려하지 않았고, 그 이후에 덧붙이는 방식으로 더해진 병렬 구조는 여러 문제점을 낳아 프로그래머를 괴롭힌다. 특히 병렬 프로그래밍을 쉽게 하는 대표적인 기능을 구현하려면 하드웨어의 성능 손실이 상당해 이런 문제가 좀처럼 쉽게 해결되지 못하고 있다. 대표적인 예 두 가지를 살펴보자.

<리스트 1> 전형적인 원자성 위반

/* 스레드 1 */
void LoadScript(nsSpt* aspt) {
  Lock(l);
  gCurrentScript = aspt;           /* 스레드 2 */
  LaunchLoad(aspt);                 ...
  Unlock(l);                         Lock (l);
}                                      gCurrentScript = NULL;
void OnLoadComplete() {            Unlock(1);
  /* LaunchLoad의 콜백 함수 */       ...
  Lock(l);
  gCurrentScript->compile();
  Unlock(l);
}



<리스트 1>은 실제 모질라의 nsXULDocument.cpp에서 발견된 버그다[7]. 스레드 1과 스레드 2는 공유 변수 gCurrentScript를 동시에 접근할 수 있다. 그런데 공유 변수를 두 스레드 이상이 동시에 읽거나 쓰면 경쟁 상태(race condiction)가 일어나고 이는 프로그램을 죽일 수도 있다. 그래서 뮤텍스, 임계 영역 같은 객체로 공유 변수를 보호해야 한다. 그런데 <리스트 1>의 코드는 이 공유 변수가 적절하게 락으로 보호되고 있어 경쟁 상태는 없다. 그래서 정확하게 돌아가야 하지만 실제로 이 코드는 문제를 일으킬 수 있다. 스레드 2의 코드가 스레드 1의 두 함수, LoadScript와 OnLoadComplete 사이에 호출된다면, OnLoadComplete에서 gCurrentScript 접근 시 NULL로 되어 있어 죽고 만다. 이 코드는 비록 경쟁 상태는 없더라도 얼마든지 병렬 실행 시 문제가 될 수 있음을 보여준다. 이는 원자성 위반(atomic violation)으로 골치 아픈 병행성 버그 중 하나이다. 그런데 하드웨어가 아주 똑똑하다면 이런 경쟁 상태나 원자성 위반을 근원적으로 없애거나 획기적으로 그 처리를 간단하게 할 수 있다[9]. 

병렬 프로그래밍에서 가장 어려운 문제 중 하나를 꼽으라면 단연코 메모리 모델(memory model)을 들 수 있다[8]. 간단한 예로 이 문제를 설명할 수 있다.

<리스트 2> x, y가 모두 처음 0이었다면 r1, r2가 가질 수 있는 값은?

/* 스레드 1*/      /* 스레드 2*/
1: x  = 1;         a: y  = 1;
2: r1 = y;         b: r2 = x;



<리스트 2>에서 초기 x, y가 모두 0이고 두 스레드가 이 코드를 실행시킨다면 r1, r2는 어떤 값이 가능할까? 답을 구하려면 4 명령어(1, 2, a, b)의 실행 순서에 따른 값을 추적하면 된다. 예를 들어, 1 → 2 → a → b 순서대로 코드가 실행되었다면 r1 = 0, r2 = 1이 될 것이다. 만약, 1 → a → b → 2 순서라면 r1, r2 모두 1이 되고, a → b → 1 → 2라면 r1 = 1, r2 = 0이 된다. 그렇다면 r1 = r2 = 0은 가능할까? 이것이 가능하려면 2 → b → 1 → a와 같은 순서로 실행되어야 한다. 그러나 이 순서는 2번 또는 a번 명령이 1번 또는 b번 명령보다 앞서 실행되므로 말이 되지 않는 것처럼 보인다. 한 스레드 내의 명령어는 반드시 순차적으로 실행되어야 하기 때문이다. 그러나 실제 최신 병렬 구조의 컴퓨터는 성능을 높이려고 명령어 실행 순서를 뒤바꾸기도 하고, 캐시 성능을 높이려고 각종 버퍼를 두기도 한다. 이런 복잡한 여러 이유로 1, a번 명령이 미리 실행을 시작했어도 2, b번 명령보다 늦게 완료될 수 있다. 컴파일러 역시 다른 하나의 스레드만 가정하고 코드를 최적화하므로 명령 2를 명령 1에 앞서 실행하도록 코드 재배치를 할 수도 있다.

이러한 문제는 실제 멀티코어 컴퓨터에서 일어나며 프로그래머가 예상하기 매우 어려운 문제를 만든다. 이 문제를 메모리 컨시스턴시(memory consistency) 문제라고 부른다. 메모리 모델은 프로그래머와 하드웨어 사이의 규칙으로 공유 변수에 관한 여러 규칙으로 구성되어 있다. 예를 들어, 메모리 읽기를 다른 메모리 쓰기에 앞서 실행할 수 있는가와 같은 규칙을 정의한다. 가장 직관적인 모델은 순차 컨시스턴시(sequential consistency)로 <리스트 2>를 예로 들면 r1, r2가 모두 0이 되는 일이 일어나지 않는, 즉 상대적인 메모리 읽기와 쓰기의 순서가 보존되는 모델이다. 그런데 지금의 멀티코어 컴퓨터에서 순차 컨시스턴시를 보장하려면 매번 공유 변수 접근 시 메모리 배리어(barrier)를 호출해야 하므로 성능이 매우 저하된다. 그래서 어쩔 수 없이 CPU 아키텍트는 몇몇 경우 메모리 읽기/쓰기를 재배치할 수 있고 이를 문서화해 프로그래머에게 알린다. 성능을 위해 프로그래밍 용이성을 크게 후퇴시킨 부분이다. 이 문제는 사실 너무 어려워 자바나 C++에서는 비교적 최근에 와서야 명확히 문제가 이해되었고 해법이 제시되고 있다[8].

2021년의 병렬 프로그래밍은 어떤 모습일까?
컴퓨터 기술이 매우 혁신적으로 자주 바뀌는 것처럼 보이지만 사실은 그러하지 않고 오히려 매우 보수적인 면이 많다. 각종 인터넷 서비스가 정신없이 흥망성쇠를 이룰 뿐 CPU 구조, 운영체제, 프로그래밍 언어 같은 기반 기술은 쉽게 바뀌지 못한다. 바꾸는 데 너무나 큰 비용이 들기 때문이다.

2021년의 병렬 프로그래밍의 모습을 예측하려면 CPU의 발전 방향을 먼저 생각해야 한다. 아주 혁신적인 발전이 없는 이상 여전히 싱글코어 성능은 과거와 달리 선형적으로 증가할 것이다. 그러므로 현재의 멀티코어 흐름이 유지될 것이다. 그러나 데스크톱에서는 연구 결과에서도 봤듯이 지나치게 많은 코어는 의미가 없다. 또, 코어 개수가 8개보다 많아지면 또 다른 심각한 문제가 발생하므로 서버 분야를 제외하고는 코어의 개수가 8개, 12개 정도면 한계에 다다를 것이다. 대신 다른 공간에 이미 본 것처럼 GPU, 그 외 여러 가속 기능을 탑재할 것이다.

여기서 필자가 주장하고 싶은 것은 어려운 병렬 프로그래밍의 근본 원인 중 하나가 낡은 컴퓨터 구조에 있으므로 CPU, 운영체제, 프로그래밍 언어가 모두 보다 쉬운 병렬 프로그래밍을 위해 보다 과감한 선택을 할 필요가 있다는 것이다. 예를 들어, 앞서 소개한 메모리 모델을 근원적으로 해결하려면 새로운 컴퓨터 구조가 필요한데, 이런 구조는 기존의 CPU와는 너무 달라 회사에서 직접 채택하기에는 굉장한 모험이 따른다. 이 메모리 모델 문제나 골치 아픈 원자성 문제를 해결할 수 있는 컴퓨터 구조 중 벌크(Bulk) 구조가 있다[9]. 아이디어 및 시뮬레이터 수준에서만 검증된 이 아이디어는 프로그램을 자동으로 명령어 1,000~2,000개 정도의 덩어리(chunk)로 나눠 프로그래머가 모르게 내부적으로 처리한다. 이 덩어리는 한번에 원자적으로 완료되는 특징이 있다. 이때 공유 변수의 충돌이 있는가를 검증하고 있다면 다시 이 청크를 실행한다. 자세한 설명을 적기에는 벅차지만, 이러한 구조로 순차 컨시스턴시를 성능 손실을 최소화하며 구현할 수 있다는 큰 장점이 있다. 또한 이 구조는 병렬 프로그래밍 버그의 발생 원인인 비결정적인(non-deterministic) 상황을 최소화하고, 병렬 프로그래밍 버그를 잡기 위해 필수적인 재생 기능(deterministic replay)을 비교적 쉽게 구현할 수 있게 한다. 이 구조는 또한 트랜잭셔널 메모리(transactional memory) 같은 기능의 플랫폼이 될 수 있다. 그렇지만 이 아이디어는 아직 실제 칩으로 만들어진 적이 없어 실제로 얼마나 우수한 전력 대비 성능을 낼지는 예측하기 어렵다. 그래도 이러한 종류의 아이디어가 꾸준히 연구되고 검증된다면 미래의 병렬 프로그래밍은 혁명적으로 바뀔 수 있다.

이런 극단적인 구조 변경 외에 현재의 구조에서 병렬 프로그래밍을 쉽게 할 수 있는 대표적인 하드웨어 기능으로는 잠깐 언급한 트랜잭셔널 메모리가 있다. 이 기능은 뮤텍스로 인한 여러 문제를 덜어줄 수 있는 기능인데, 수많은 연구 결과가 있지만 아직까지 주류 CPU에 구현되지는 못했다. 썬의 Rock 프로세서에서 기본적인 기능이 구현되었지만, 추후 프로세서 연구가 중단되었고 초기 실험 결과도 아직 부족한 점이 많다. x86에서는 AMD가 트랜잭셔널 메모리를 위한 기본적인 명령어를 디자인하고 실험 결과를 발표하기도 했다[10]. 10년 뒤에는 실제 CPU에 부디 이 기능이 탑재되어 프로그래머의 고통을 조금이나마 덜어주기를 기원한다.

프로그래밍 언어와 도구의 지원도 더욱 강화되어야 한다. 이미 멀티스레드 버그를 진단하는 여러 도구와 연구 결과는 나왔다. 이제 정말 실제로도 쓸 수 있을 만큼 성능이 좋은 도구가 10년 내에 꾸준히 나올 것이다. 병렬화 지원 도구의 발전도 계속될 것이다. 마지막으로 대학교에서도 병렬 프로그래밍 교육을 강화하고 프로그래머도 점차 지식을 공유한다면, 분명 2021년의 프로그래머는 지금보다 행복하게 병렬 프로그래밍을 하고 있을 것이다.


참고자료

1. “2011년에는 10 GHz CPU가 나올 것이다”, http://minjang.egloos.com/2738790
2. C. Badea, M. R. Haghighat, A. Nicolau and A. V. Veidenbaum. Towards Parallelizing the Layout Engine of Firefox. 2nd USENIX Workshop on Hot Topics in Parallelism (HotPar ‘10), June 2010.
3. Geoffrey Blake의 2010년 ISCA 발표 자료: 시장 규모는 IDC와 가트너의 2009년 자료, 연구 논문은 ISCA 2005~2009년에 발표된 것을 기준.
4. G. Blake, R. G. Dreslinski, T. Mudge, and K. Flautner. Evolution of thread-level parallelism in desktop applications. In Proceedings of the 37th annual international symposium on Computer architecture (ISCA ‘10), 2010.
5. http://www.tomshardware.com/reviews/starcraft-ii-radeon-geforce,2728-8. html
6. stackoverflow의 태그로 보는 프로그래밍 추이, http://minjang.egloos.com/2729492
7. S. Lu, J. Tucek, F. Qin, and Y. Zhou.  AVIO: detecting atomicity violations via access interleaving invariants. SIGPLAN Not. 41, 11, Nov. 2006.
8. S. V. Adve and H.-J. Boehm. Memory Models: A case for rethinking parallel languages and hardware. Communications of the ACM, Aug. 2010.
9. J. Torrellas, L. Ceze, J. Tuck, C. Cascaval, P. Montesinos, W. Ahn, and M. Prvulovic, The Bulk Multicore Architecture for Improved Programmability. Communications of the ACM, Dec. 2009.
10. J. Chung, L. Yen, S. Diestelhorst, M. Pohlack, M. Hohmuth, D. Grossman, D. Christie. ASF: AMD64 Extension for Lock-free Data Structures and Transactional Memory. Proceedings of the 43rd IEEE/ACM International Symposium on Microarchitecture (MICRO), Dec. 2010.
11. 기껏 병렬화를 했는데, http://minjang.egloos.com/2744746