개요
여기서는 여러개의 CPU 코어를 동시에 사용하는 병렬 프로그램을 만들기 위한 방법 중 하나인 OpenMP에 대해 알아봅시다. OpenMP는 여러 개의 명령문들이 동시에 실행되는 프로그램을 작성하기 위한 인터페이스 (API)로서, 여러 전처리기 및 함수들로 이루어져 있습니다.
OpenMP로 만들어진 병렬 프로그램은 메모리를 공유하는 여러 개의 스레드들을 통해 구동됩니다. 스레드는 간단히 말해서 직렬로 나열된 여러 개의 프로그램 명령문들의 묶음인데요. OpenMP에서는 이 스레드들을 여러 개 생성하여 동시에 실행시키게 됩니다. 결과적으로 한 개의 스레드 내에서는 명령문들이 순차적으로 실행되지만, 스레드가 여러개 있기 때문에 여러 개의 명령문들이 동시에 실행됩니다.
이 포스팅에서는 C언어 프로그램을 중점적으로 다루겠지만, OpenMP는 C++ 및 포트란 프로그램에서도 이용할 수 있습니다. 명령어들이 순차적으로 실행되는 기존의 직렬 프로그램에, 전처리문과 함수들을 몇 개 더해서 병렬 프로그램으로 재탄생시킬 수 있으므로 상당히 유용하죠.
OpenMP 라이브러리 설치
리눅스나 유닉스에서 GNU C/C++ 컴파일러를 사용하는 경우, OpenMP를 기본으로 지원합니다. 그래서 컴파일과 링크를 할 때 -fopenmp 옵션만 추가해주면, OpenMP 병렬 프로그램을 빌드할 수 있습니다. 비주얼 스튜디오의 경우 프로젝트 속성에 들어간 뒤에, C/C++ 항목의 언어 (Language) 설정에서 OpenMP 지원을 "Yes"로 설정해 주면 됩니다.
macOS의 경우 GNU 컴파일러 대신에 clang 컴파일러 및 LLVM 으로 넘어오면서, libomp (LLVM의 OpenMP 런타임 라이브러리)를 별도로 설치해야 합니다. 가장 쉬운 방법은 Homebrew를 이용하는 것인데요. brew install libomp 를 입력하여 설치할 수 있습니다. 라이브러리의 설치가 정상적으로 완료되었다면, brew list 를 실행했을 때 그 목록에서 libomp를 확인할 수 있습니다.
이렇게 라이브러리를 설치해도, clang 컴파일러로 프로그램을 빌드하려고 하면 라이브러리 파일을 찾을 수 없다면서 링크 오류가 나는 경우가 있는데요. 이때는 홈디렉토리에 있는 .bash_profile 파일을 열어서, 다음과 같이 LIBRARY_PATH 및 LD_LIBRARY_PATH 에다가 libomp 라이브러리가 설치된 경로를 추가해줘야 합니다.
export LIBRARY_PATH=[libomp 경로]/lib:$LIBRARY_PATH
export LD_LIBRARY_PATH=[libomp 경로]/lib:$LD_LIBRARY_PATH
여기서 [libomp 경로]라는것은 brew info libomp 를 실행했을 때 나오는 라이브러리의 위치입니다. 이렇게 하면 -fopenmp 옵션을 통해 clang 컴파일러로 OpenMP 병렬 프로그램을 빌드할 수 있게 되죠.
C언어나 C++ 에서 OpenMP 병렬 프로그램을 작성하기 위해서는, 소스코드에 omp.h 헤더파일을 포함시켜야 합니다.
#include<omp.h>
마지막으로 환경변수 OMP_NUM_THREADS의 값을 지정해주면, OpenMP 프로그램 내에서 생성되는 스레드의 총 갯수의 기본값이 설정됩니다.
병렬 프로그램의 구조
프로그램이 실행되는 동안 스레드의 총 갯수 및 각 스레드의 고유 번호 (ID)를 알면, 병렬 프로그램을 제어하는데 유용합니다. 이와 관련된 함수들은 스레드의 총 갯수를 지정하거나 알아내기 위한 omp_set_num_threads 및 omp_get_num_threads, 그리고 각 스레드에게 주어진 ID를 알아내기 위한 omp_get_thread_num 이 있습니다. 이 함수들은 OpenMP 라이브러리에서 다음과 같은 프로토타입으로 선언되어 있죠.
/* 스레드의 갯수 지정
* 인자 : 정수형 변수 */
void omp_set_num_threads(int);
// 현재 스레드의 총 갯수 리턴
int omp_get_num_threads();
// 현재 스레드의 ID 리턴
int omp_get_thread_num();
각 스레드에 할당되는 ID는 정수형 변수로서, 0부터 [스레드 총 갯수]-1 까지의 값을 가질 수 있습니다. 참고로 이 함수들은 병렬 처리를 하지 않고 단일 스레드로 실행되는 구간에서도 호출이 가능합니다만, 그렇게 하면 총 스레드의 갯수 omp_get_num_threads와 스레드 ID omp_get_thread_num은 각각 1과 0을 리턴할 것입니다.
OpenMP의 핵심인 병렬 프로그램을 구현하기 위해서는 다음과 같이 #pragma omp parallel 전처리문을 사용하면 됩니다. 만약 소스 코드 내에서 스레드의 총 갯수를 직접 지정해 주고 싶다면, #pragma omp parallel 전처리문 이전에 omp_set_num_threads 함수를 호출하는 방법이 있습니다. 스레드의 총 갯수를 별도로 지정하지 않는다면, 환경변수 OMP_NUM_THREADS에 저장된 값이 기본으로 사용되죠.
#pragma omp parallel
{ // 병렬 구간 시작
// ... 프로그램 명령문들 ...
} // 병렬 구간 끝
그러면 #pragma omp parallel 코드 블록이 시작되는 시점에 스레드들이 생성되어, 각 스레드들이 코드 블록 내의 명령문들을 실행하게 됩니다.
여러 개의 스레드들이 변수의 값을 공유하는지의 여부는 변수의 범위에 따라 달라지는데요. #pragma omp parallel 코드블록 내에서 선언된 변수라면, 스레드들이 각자의 변수를 가지며 그 값은 독립적입니다. 반면에 코드 블록 밖에서 선언된 변수라면 스레드들이 값을 공유하고, 변수의 값이 바뀌면 이는 모든 스레드에 적용됩니다.
프로그램의 가장 간단한 예시인 "Hello World!" 에다가 OpenMP를 적용해 봅시다.
#include<stdio.h>
#include<stdlib.h>
// OpenMP 헤더 파일
#include<omp.h>
int main(int argc, char *argv[]) {
/* 정수형 변수 sum 을 선언하고,
* 0 으로 초기화 */
int sum = 0;
#pragma omp parallel
{ // 병렬 구간 시작
// 각 스레드의 ID
int tid = omp_get_thread_num();
if (tid == 0) {
/* 스레드 ID가 0인 경우에 한해,
* 스레드의 총 갯수 출력 */
int n_thread = omp_get_num_threads();
fprintf(stdout,
" n_thread = %d\n", n_thread);
}
// 스레드 동기화
#pragma omp barrier
/* 스레드들이 서로 간섭하지 않도록
* 코드 블록을 격리 */
#pragma omp critical
{ // 격리 구간 시작
fprintf(stdout,
"[THREAD %d] : Hello World!\n", tid);
sum += 1;
fprintf(stdout,
"[THREAD %d] : sum now = %d\n",
tid, sum);
} // 격리 구간 끝
} // 병렬 구간 끝
// 변수 sum 의 최종값 출력
fprintf(stdout,
" sum final = %d\n", sum);
return 0;
}
맨 먼저 병렬 구간에서 스레드의 총 갯수를 출력하고 싶은데, 출력하는 구문만 넣게 되면 모든 스레드들이 이를 수행하게 될 것입니다. 이 메시지를 한 번만 보기 위해서 스레드의 ID가 저장되는 변수 tid의 값을 체크하는 조건문을 넣어 줬습니다. 그리고 다른 스레드가 "Hello World!" 메시지를 먼저 출력해버리는 것을 막기 위해 동기화를 해 줄 필요가 있는데요. #pragma omp barrier 전처리기를 추가해 주면, 모든 스레드가 여기에 도달할 때 까지 대기하게 됩니다.
추가적으로 예시를 들기 위한 정수형 변수 sum이 있습니다. 이 변수는 #pragma omp parallel 코드블록 밖에 있는 관계로, 모든 스레드들이 sum의 값을 공유합니다. 각 스레드가 "Hello World!"를 출력한 다음 sum에 1을 더하고 그 값을 출력하게 하고 싶은데, 그 도중에 다른 스레드가 끼어들어 "Hello World!"를 출력해버릴 가능성이 있습니다. 이를 방지하기 위해서 #pragma omp critical 전처리문을 이용해 소위 격리구간을 만들어 주면 좋습니다.
#pragma omp critical
{ // 격리 구간 시작
// ... 프로그램 명령문들 ...
} // 격리 구간 끝
하나의 스레드가 #pragma omp critical 코드 블록 내의 모든 명령문들을 실행할때까지 다른 스레드들은 대기하게 됩니다. 모든 명령문들을 실행한 뒤에 코드 블록을 나가고 나면, 대기하고 있던 다른 스레드가 코드 블록에 진입하여 명령문들을 실행하게 되죠.
프로그램을 빌드하고 실행시켜보면, 다음과 같은 결과를 얻을 수 있습니다.
환경변수 OMP_NUM_THREAD 에서 설정한 값 대로 4개의 스레드에서 "Hello World!" 메시지를 출력한 것을 볼 수 있습니다.
예시 : 원주율 계산 프로그램
위에 언급된 병렬 프로그래밍의 요소들을 응용해서 원주율을 구하는 프로그램을 만들어 봅시다. 원주율의 값은 C언어 표준 수학 함수 라이브러리에 정의되어 있습니다만, 정적분을 통해서도 구할 수 있습니다.
이는 역삼각함수인 아크탄젠트 함수의 도함수를 적분해서 원주율을 구하는 방법입니다. 자세한 사항이 궁금하시다면 다음 포스팅을 참고해 주세요.
이번에는 적분 구간을 여러 개로 나누고, 각 스레드에 할당해서 적분을 수행하는 방식으로 OpenMP 프로그램을 작성해 보겠습니다. 그 뒤에 스레드들의 기여분을 전부 합치면 원주율의 값이 나오겠죠.
C언어 소스 코드입니다.
test1_pi_omp.c [다운로드]
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<math.h>
// OpenMP 헤더 파일
#include<omp.h>
// 정밀도
double eps_precision = 1e-12;
// 적분 대상이 되는 함수
double func_integrand(double u);
int main(int argc, char *argv[]) {
/* 현재 단계 및 이전 단계에서의
* 원주율의 값 */
double pi_now = 0.;
double pi_prev;
/* 수렴 여부를 확인하기 위한 제어변수
* 0이 아닌값을 가지는 경우 수렴 */
int converging = 0;
#pragma omp parallel
{ // 병렬 구간 시작
// 스레드의 총 갯수
int n_thread = omp_get_num_threads();
// 각 스레드의 고유 ID
int tid = omp_get_thread_num();
if (tid == 0) {
fprintf(stdout,
"We have %d threads.\n", n_thread);
fprintf(stdout, "\n");
}
// 수치 정적분의 구간과 격자 갯수 및 간격
unsigned long int nbin_u = 1;
double u_min =
(double)tid / (double)n_thread;
double u_max =
u_min + 1. / (double)n_thread;
double delta_u = fabs(u_max - u_min);
// 원주율에 대한 각 스레드의 기여분
double pi_thread = 0.;
double pi_thread_prev;
int istep = 1;
while (converging == 0) {
if (tid == 0) {
/* 하나의 스레드에서만
* 원주율 값 업데이트 */
pi_prev = pi_now;
pi_now = 0.;
}
// 스레드 동기화
#pragma omp barrier
pi_thread_prev = pi_thread;
pi_thread = 0.;
unsigned long int iu;
/* 각 스레드의 기여분을 계산하기 위한
* 수치적분 수행 */
if (istep == 1) {
for (iu = 0; iu < nbin_u; iu++) {
double u0 = u_min + delta_u * (double)iu;
double u1 = u0 + delta_u;
pi_thread +=
0.5 * delta_u * (func_integrand(u0) +
func_integrand(u1));
}
} else {
pi_thread = 0.5 * pi_thread_prev;
for (iu = 0; iu <= nbin_u; iu++) {
if (iu % 2 == 0) {
continue;
}
double u_now =
u_min + delta_u * (double)iu;
pi_thread +=
delta_u * func_integrand(u_now);
}
}
/* 원주율 값에 대한 각 스레드의 기여분을
* 서로 간섭하지 않고 순차적으로 추가 */
#pragma omp critical
{ // 격리 구간 시작
pi_now += pi_thread;
} // 격리 구간 끝
// 스레드 동기화
#pragma omp barrier
if (tid == 0) {
fprintf(stdout,
" step %d : pi = %.12f\n", istep, pi_now);
/* 하나의 스레드에서만
* 수렴을 체크하고 제어변수 값 업데이트 */
if (fabs(pi_now - pi_prev) <
0.5 * eps_precision *
fabs(pi_now + pi_prev)) {
converging = 1;
}
}
// 스레드 동기화
#pragma omp barrier
/* 원하는 정밀도로 수렴하지 않은 경우
* 수치 정적분의 격자 갯수를 2배로 증가 */
if (converging == 0) {
istep += 1;
nbin_u = 2 * nbin_u;
delta_u = 0.5 * delta_u;
}
}
} // 병렬 구간 끝
fprintf(stdout, "\n");
fprintf(stdout, "pi from numerical integration\n");
fprintf(stdout, " > pi = %.12f\n", pi_now);
fprintf(stdout, "pi from C math library\n");
fprintf(stdout, " > pi = %.12f\n", M_PI);
return 0;
}
double func_integrand(double u) {
return 2. / (fabs(u * u) + fabs((1. - u) * (1. - u)));
}
원하는 정밀도를 실수형 변수 eps_precision을 통해 미리 설정한 다음, 원주율의 값이 그 정도로 정확해질때 까지 수치적분의 격자 갯수를 단계별로 늘려 나가는 방식의 프로그램입니다. 첫 번째로 눈여겨봐야 할 것은 현재 및 지난 단계에서의 원주율의 값을 저장하는 변수 pi_now와 pi_prev의 범위인데요. 이들은 #pragma omp parallel 코드블록 밖에 선언되어 있는 관계로, 모든 스레드들이 접근 및 수정을 할 수 있습니다. 그래서 모든 스레드들의 기여분을 취합하고 원주율의 값을 업데이트하는 일은 하나의 스레드에서 전담해야 혼선을 빚지 않겠죠. 각 스레드의 ID를 저장하기 위한 변수 tid와 관련된 조건문이 이러한 역할을 하고 있습니다.
각 스레드의 기여분을 전부 취합해서 원주율의 값을 구할때는, 서로 간섭하지 않도록 #pragma omp critical 코드 블록을 사용했습니다. 그리고 모든 기여분을 더한 다음에 수렴 체크를 해야 하는 등의 순서를 지켜야 하기 때문에, #pragma omp barrier 전처리문을 이용한 교통정리도 빠질 수 없습니다.
프로그램을 실행시켜보면, 다음과 같은 결과를 얻을 수 있습니다.
각 단계별로 원주율의 값이 더 정밀해지는 것을 볼 수 있고, 수학 함수 라이브러리에 정의된 값과 소수점 10번째 자리까지 동일합니다. 앞서 언급된 변수 eps_precision의 값을 줄이면 더 정밀한 계산이 가능합니다만, 그만큼 실행 시간은 늘어나겠죠.
이상으로 간단한 예제 프로그램들을 만들면서, OpenMP를 통한 병렬 프로그래밍의 뼈대를 이루는 요소들을 짚어보았습니다. 더 자세한 사항은 OpenMP 웹사이트에서 제공하는 튜토리얼들을 참고하면 좋습니다.
병렬 프로그램을 구현하는 또다른 방법으로는 MPI (Message Passing Interface)가 있는데요. OpenMP와는 달리 프로세스들이 메모리를 공유하지는 않지만, 서로 다른 노드 (컴퓨터)에 소속된 CPU들을 이용해서 병렬 프로그램을 만들 수 있는 장점이 있습니다. 자세한 사항은 다음 포스팅에 소개되어 있습니다.