병렬분산컴퓨팅 - OPENMP
병렬 분산 컴퓨팅 - OPENMP
1. 개요
Shared memory parallel 응용프로그램을 만드는 표준 API이다. (일반적으로 Data parallel model을 사용)
기본적으로 C와 C++, 포트란에서 명령어 세트를 지원하며, GCC에는 기본적으로 포함되어있다.
OPENMP는 컴파일러 지시어(compiler directives), 런타임 루틴(runtime routines), 환경 변수(environment variables)로 구성되어있다.
기본적으로 Fork-Join 모델로 프로그램 실행중 마스터 Thread가 히위 Thread 들을 생성(Fork)하고 병렬 작업이 끝나면 하나의 작업으로 합쳐지는(Join) 방식을 따르며 기본적으로 순차 프로그램에 병렬화 지시어를 추가하는 형태로 구성됨으로써 병렬성을 점진적으로 도입할 수 있다.
OPENMP 대신 Pthread를 이용해서 구현이 가능하긴하나 좀 더 높은 수준의 추상화를 제공함으로써 좀 더 읽고 쓰기에 편한 특징이 있다.
2. 기본 문법
특정 영역을 병렬화 하고 싶다면 아래의 문법을 따른다.
1
#pragma omp [construct] [clause [clause]...]
여기서 #pragma는 특별한 전처리 지시자로 컴파일 이전에 컴파일러미 해당 명령어를 보고 전처리를 하며 [construct] 부분과 clause 부분에 뭐가 들어가느냐에 따라서 구동방식이 달라진다.
3. construct & clause
OPENMP는 기본적으로 아래의 카테고리를 지원한다.
1) Parallel Regions
#pragma omp parallel 지시어를 사용하여 Thread 들을 생성하고 병렬 실행 블록을 정의한다.
아래의 예시를 보자.
1
2
3
4
5
6
7
8
9
10
11
12
...
#include<omp.h> // OPENMP를 쓰기 위해서 필수로 추가해야하는 헤더
...
double A[1000];
omp_set_num_threads(3); // OPENMP 사용시 Thread를 3개 사용
#pragma omp parallel // 아래의 소괄호 영역에 포함된 내용을 Thread 3개를 사용하여 동일하게 실행시킴
{
int id = omp_get_thread_num();
test(id,A);
} // 해당 중괄호까지 Thread가 진행되면 다른 Thread들이 모두 도달할때까지 대기함, 이를 implicit barrier라고 함
printf("all done\n");
2) Work-sharing
Work-Sharing Construct에는 총 4가지가 있다.
기본적으로 Parallel Regions을 정의한 아래에 사용한다.
a. for
아래와 같이 사용한다.
1
2
3
4
5
6
7
#pragma omp parallel // Parallel Regions 정의
#pragma for // Work-sharing
{
for(int i=0;i<1000;i++) {
c[i] = a[i] + b[i];
}
}
for 뒤에 nowait을 붙이며 다른 thread 완료까지 기다리지 않는다. 즉, implicit barrier가 무효화된다. 그 뒤에 별도로 schedule이라는 Clause가 붙는다 이 schedule은 for와 같은 반복문을 어떻게 Thread 단위로 분할할 것인가에 대한 정책이다. 이 schedule이라는 clause는 schedule (type, chunksize) 형태로 사용한다. 세부 type에 대한 내용은 아래와 같다.
ⓐ static
미리 for 문의 iteration 돌 개수대로 나누는 것이다. 다만, static 뒤에 붙는 chunksize에 따라 각 Thread에 할당하는 양이 달라진다. 아래와 같이 사용한다.
1
2
3
4
5
6
7
#pragma omp parallel // Parallel Regions 정의
#pragma for schedule(staic, 1) // Work-sharing
{
for(int i=0;i<9;i++) {
c[i] = a[i] + b[i];
}
}
chunksize를 생략하면 loop count / Thread 개수로 나눠서 할당한다. 위와 같이 schedule (static, 1) 이고 Thead가 3개로 0,1,2라면 아래와 같이 할당된다.
- Thread 0 : 0, 3, 6
- Thread 1 : 1, 4, 7
- Thread 2 : 2, 5, 8
ⓑ dynamic
static은 아예 고정된 형태로 Thread에 할당하는 방식이라면, dynamic은 잘라둔 loop들을 이미 작업 끝난 Thread에 동적으로 할당해주는 방식이다. 아래와 같이 사용한다.
1
2
3
4
5
6
7
#pragma omp parallel // Parallel Regions 정의
#pragma for schedule(dynamic, 1) // Work-sharing
{
for(int i=0;i<9;i++) {
c[i] = a[i] + b[i];
}
}
뒤에 chunksize가 붙으면 해당 개수만큼의 loop를 잘라주며, 잘게 자를수록 load balance는 좋아지지만 스케줄링 오버헤드가 커진다. chunksize를 생략하면 1개씩 분배한다.
ⓒ guided
잘게 잘라서 동적으로 분배하면 오버헤드가 크기 때문에 고안된 방법이다. 각 스레드는 자신에게 할당된 청크(chunk)를 실행하고, 실행이 끝나면 런타임 시스템에 새로운 청크를 요청한다. 이때 가이드 스케줄링의 핵심은 청크가 완료될 때마다 새로 할당되는 청크의 크기가 점차 줄어든다는 점이다.
초기 청크 크기는 “전체 루프 반복 횟수 / Thread 수”로 시작해서 다음 청크 크기는 “남은 루프 반복 횟수 / Thread 수”로 계산하여 각 Thread에 할당한다.
chunksize를 주면 해당 chunksize 크기보다는 줄어들지 않으며 일반적으로 dynamic 보다는 오버헤드가 적다.
ⓓ auto
그냥 컴파일러에게 맡기는 방식이다.
ⓔ runtime
시스템의 환경 변수인 OMP_SCHEDULE의 값을 참조하여 루프 반복을 어떻게 나눌지 결정한다. 코드에는 아래와 같이 적는다
1
2
3
4
5
6
7
#pragma omp parallel // Parallel Regions 정의
#pragma for schedule(runtime) // Work-sharing
{
for(int i=0;i<9;i++) {
c[i] = a[i] + b[i];
}
}
위와 같이 지정해놓고 아래와 같이 환경변수로 조정한다.
1
setenv OMP_SCHEDULE "guided, 4" // guided 방식으로 chunksize 4
b. sections
아래와 같이 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
#pragma omp parallel // Parallel Regions 정의
{
#pragma omp sections
{
#pragma omp section
section1();
#pragma omp section
section2();
#pragma omp section
section3();
}
}
위와 같이 구동하면 section1~3는 모두 병렬로 구동되며 implicit barrier에 걸려서 모든 thread가 완료될때까지 기다린다.
c. single
아래와 같이 사용한다.
1
2
3
4
5
6
7
8
#pragma omp parallel // Parallel Regions 정의
{
function1();
#pragma omp single
{
function2();
}
}
function1은 Thread 개수만큼 실행되지만 function2는 먼저 도착한 thread 하나만 실행한다.
d. master
아래와 같이 사용한다.
1
2
3
4
5
6
7
8
9
10
#pragma omp parallel // Parallel Regions 정의
{
function1();
#pragma omp master
{
function2();
}
#pragma omp barrier
function3();
}
function1은 Thread 개수만큼 실행되지만 function2는 maste thread만 실행하며 나머지는 thread는 master thread의 종료를 기다리지않으나 #pragma omp barrier 써서 막아줄 수 있다.
3) Data Environment
OpenMP에서 변수는 크게는 Private 변수와 Shared 변수로 나뉜다.
각 변수에 대해서 명시적인 선언을 하려면 예를 들어 아래와 같이 할 수 있다.
1
2
#pragma omp parallel for default(none)\
private(i) shared(n, data) firstprivate(x) lastprivate(idx)
여기서 default는 shared 나 none을 지정할 수 있는데, shared로 지정시 openmp 블록안에 별도로 지정하지 않은 모든 변수를 기본적으로 공유 변수로 사용하겠다는 뜻이고, none의 경우에는 개발자가 명시적으로 지정하겠다는 뜻인데, 만약 별도로 지정하지 않는다면 compiler가 알아서 처리해버린다.
a. Private
#pragma omp parallel for 문에서 for 안에서 변수를 선언시 별도의 선언이 없다면 private로 선언된다. 여기서 말하는 private 변수는 local copy 본으로 각 스레드에서 개별로 정의되어 공유하지 않는 변수를 말한다.
private도 종류에 따라서 초기화나 사용 방식이 다르다.
private
초기화되지 않는 값이며, for loop 바깥에 동일한 이름의 변수가 있어도 loop 내에서는 private한 값을 별도로 쓴다.firstprivate
for loop 바깥에 동일한 이름의 변수가 있다면 해당 값을 복사해서 초기값으로 쓰며 loop 내에서는 private한 값을 별도로 쓴다.lastprivate
loop 내에서는 private한 값을 별도로 쓰는데 for loop 바깥에 동일한 이름의 변수가 있다면 private한 값을 loop 바깥의 변수에 복사한다.
b. Shared
Shared 변수는 말 그대로 해당 변수를 Thread간에 공유해서 쓰겠다는 선언이다.
만약 sum이라는 변수를 공유 변수로 쓰고 싶다면 아래와 같이 선언하면 된다.
1
2
3
//...
#pragma omp parallel for shared(sum)
//...
c. Protect shared data
그냥 공유해서 쓰면 race condition 문제가 생긴다. 따라서 이러한 공유 변수를 사용할 때 여러가지 기법으로 해당 값을 접근할때 race condition이 발생하지 않도록 제어해주어야한다.
아래의 코드를 보자
1
2
3
4
5
6
7
8
float dot_product(float * a, float * b, int N) {
float sum = 0.0;
#pragma omp parallel for shared(sum)
for (int i =0;i<N;i++){
// ------------------------ [1]
sum += a[i] * b[i];
}
}
위의 코드를 실행하면 sum 변수에 대해서 race condition이 생긴다. 각 Thread에서 sum값을 register에 넣고 연산 해버리면 다른 Thread에서 값을 처리할 수가 없는 등 문제가 생긴다.
따라서 위와 같은 코드에서 [1] 부분에 아래와 같은 코드를 넣음으로써 해결 할 수가 있다.
#pragma omp critical(name)해당 범위를 critical section 처리해버리면 된다. 그러면 openmp에서 자동으로 race condition을 방지해준다.
위 문을 쓸 때는 name을 지정해주어야하는데, 해당 name이 같은 밤위는 같은 critical section으로 간주하며, critical section이 커질 수록 성능은 매우 감소하기 때문에 작게 쪼개주는게 좋다.
ex) 예시 코드, my와 your는 별개의 critical section으로 분류 된다.1 2 3 4 5 6 7 8 9 10 11
int sum1 = 0; int sum2 = 0; for (int i=0;i<20;i++){ #pragma omp critical(my) sum1 += i; #pragma omp critical(your) { sum2 -= i; sum2 += i; } }
#pragma omp atomic
해당 범위의 연산을 atomic하게 바꿔준다. 효율적이나 General하지 않다. 하드웨어에서 지원하는 atomic한 연산을 기반으로 제공하는 것이기 때문이다. critical 처리보다는 좀 더 효율적이긴하다. 다음과 같은 형태의 문에만 사용할 수 있다.var = var op expr, var op = expr, var ++, var--만약 함수 연산한 값을 반환하여 어떤 변수에 넣은 뒤 atomic을 단다면 제대로 작동을 안한다.expr에 대해서는 atomic함을 보장하지 않기 때문이다.
d. reduction
GPU 프로그래밍할때보던 그 reduction 맞다. 아예 openmp에서는 reduction을 지원한다. 문법은 아래와 같다.
1
2
3
// ...Other code
#pragma omp parallel for reduction(op:list)
// ...Other code
list는 해당 사용할 변수 list이며 op는 실행할 operation으로 아래의 목록을 지원한다.
| Operand | Initial Value |
| + | 0 |
| * | 1 |
| - | 0 |
| ^ | 0 |
| & | ~0 |
| | | 0 |
| && | 1 |
| || | 0 |
4) Synchronization
동기화처리 관련해서 아래의 목록은 이미 봤다.
critical sectionsatomicbarriers
그외의 것들에 대해서 서술하도록 하겠다.
a. Ordered
아래와 같은 코드가 있다.
1
2
3
4
5
#pragma omp parallel for
for(i = 0;i<50;i++) {
int res = comp(i);
printf("comp : %d = %d \n", i, res);
}
위 코드를 실행시키면 순서대로 실행되지 않는다. 만약 순서대로 실행시키고 싶을때 아래와 같이 코드를 변경하면 된다.
1
2
3
4
5
6
#pragma omp parallel for
for(i = 0;i<50;i++) {
int res = comp(i);
#pragma omp ordered
printf("comp : %d = %d \n", i, res);
}
위와 같이 실행시키면 0,1,2,3 … 과 같이 순서대로 실행된다.
b. Explicit locking
라이브러리 형태로 제공되는 함수 중에 lock 관련된 함수이다. 아래와 같은 함수들이 있다.
1
2
3
4
5
6
void omp_func_lock(omp_lock_t *lck);
void omp_init_lock();
void omp_destory_lock();
void omp_set_lock();
void omp_unset_lock();
void omp_test_lock();
※ Memory model
Multi-thread 환경에서 프로세서들에 각 local storage가 있는 경우, 두 가지를 고려해야한다.
Visibility
어떤 thread가 변수 a에 변경을 가했을 때 다른 thread가 변수 a가 변한 것을 관측가능한 시점이 언제일 것이냐에 대한 부분이다.Ordering
흡사 Modern superscalar out-of-order processor 방식과 같이 compiler가 성능을 위해서 의존성 없는 명령어들의 실행 순서를 멋대로 바꿀 수 있게 허용하느냐에 대한 부분이다.
위 두 가지에 따라 여러가지 종류의 Memory model이 있는데, 크게 나누자면 아래의 두 가지로 나뉜다.
Sequential consistency
Visibility 면에서는 어떤 Thread가 작업하자마자 바로 관측가능한 형태이고, Ordering도 명령어 순서 변경을 허용하지 않는 strict한 형태의 Memory model이다.Relaxed consistency
Visibility 면에서 별도의 Synchronization points외에는 관측 가능성이 보장되지 않고, Compiler의 명령어 순서 변경을 허용해주는 형태이다.
OPENMP의 경우 Relaxed consistecny를 차용하고 있으며 이는 Relaxed consistency가 성능적으로 좀 더 효율적이기 때문이다.
※ Synchronization point
위에서 설명했듯이 OPENMP에서 메모리 일관성을 보장해주는 Synchronization point가 있는데 이 point들은 아래와 같다.
명시적 및 암시적 배리어(Barriers):
#pragma omp parallel영역이 끝나는 지점
작업 공유 지시어인 parallel for, parallel sections, single 구문이 끝나는 지점 단, master 구문은 암시적인 배리어가 없으므로 동기화 지점이 생성되지 않는다.임계 영역(Critical Regions) 및 락(Lock) 루틴:
#pragma omp critical영역에 진입하거나 빠져나올 때
OpenMP 라이브러리에서 제공하는 명시적 락 루틴(lock routines)을 사용할 때Flush 연산:
#pragma omp flush지시어를 사용하여 명시적으로 메모리 일관성을 강제하는 지점
이는 다른 API의 펜스(fence) 연산과 유사하며, 이 지점 이전의 모든 읽기/쓰기가 완료되었음을 보장한다. 아래에서 추가적으로 설명할 것이다.
c. Flush
위에서 간략하게 설명했던 Flush 연산에 대한 좀 더 자세한 설명이다.
아래와 같이 Thread 0,1에 대한 코드가 있다고 해보자.
1
2
3
4
5
6
7
8
9
x = y = 0;
// Thread 0
int r1 = x;
y = 10;
// Thread 1
int r2 = y;
x = 20;
OPENMP를 통해 처리 후 Thread 0,1이 완료되고 난 뒤에 r1과 r2를 출력한다고 했을 때 아래와 같은 결과가 가능하다.
| r1 | r2 | 이유 |
| 0 | 0 | Thread 0과 1이 같이 실행 되어서 둘 다 첫 줄 부터 실행된 경우 |
| 20 | 0 | Thread 1부터 실행되고 Thread 0가 실행된 경우 |
| 0 | 10 | Thread 0부터 실행되고 Thread 1이 실행된 경우 |
| 20 | 10 | Re-ordering으로 인해 명령어 순서가 둘 다 뒤바뀌었을 경우 |
위와 같이 결과를 예측할 수 없기 때문에 메모리 동기화가 필요하다.
따라서 이를 처리해주려면 flush 명령어를 사용해주어야하며 문법은 아래와 같다.
1
#pragma omp flush [(list)]
예시를 통해 살펴보겠다. 아래는 flush가 적용되지 않은 코드이다.
1
2
3
4
5
6
7
8
9
10
// Thread 0
a = foo();
flag = 1;
// Thread 1
myflag = flag;
while(!myflag) {
myflag = flag;
}
b = a;
그냥 코드대로 해석해보면 Thread 0을 먼저 실행시키고 Thread 1을 그 다음 실행시키고 싶은 것이다. 하지만, 위에서 언급했던 Visibility와 Ordering 때문에 예상했던 대로 돌아가지 않는다.
따라서 아래와 같이 코드를 짜주어야 원하는대로 구동된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Thread 0
a = foo();
#pragma omp flush // foo 함수 실행 후 a에 대입후 flag에 1 세팅 보장
#pragma omp atomic write // write를 atomic하게 함으로써 flag에 race condition 방지
flag = 1;
#pragma omp flush // flag에 1 세팅 후 메모리에 적게끔 보장
// Thread 1
#pragma omp flush // flag 값을 메모리에서 읽어오게끔 보장
#pragma omp atomic read // flag에 read할때 race condition 방지
myflag = flag;
while(!myflag) {
#pragma omp flush // flag 값을 메모리에서 읽어오게끔 보장
#pragma omp atomic read // flag에 read할때 race condition 방지
myflag = flag;
}
#pragma omp flush // 순서 변경 방지
b = a;
5) Runtime library routines
그외의 Openmp에서 제공하는 여러 함수에 대한 예시이다.
omp_set_dynamic(int), omp_get_dynamic(): 동적으로 thread를 운용하게 허가하는 함수와 동적 Thread가 사용가능한지 확인하는 함수이다.omp_set_num_threads(int), omp_get_num_threads(): Thread 개수를 지정하는 함수와 사용중인 Thread 개수를 반환하는 함수이다.omp_get_num_procs(): 현재 프로세서가 몇개나 사용가능한지 반환하는 함수이다.
그 외에도 많다.
6) Environment variables
OMP_NUM_THREADS
Thread 개수의 상한을 지정하는 환경 변수이다.OMP_SCHEDULE
for schedule의 인자로 들어갈 수 있는 환경 변수이다.OMP_DYNAMIC
Thread 개수를 동적으로 지정해서 구동하는것에 대해서 허용할지 말지를 정하는 환경 변수이다.
그외에도 많다.
참고자료
- 서강대학교 박성용 교수님 강의자료 - 병렬 분산 컴퓨팅
원문 참고자료들
- Peter S. Pacheco, An Introduction to Parallel Programming, Elsevier Inc. (Morgan Kaufmann), 2011, ISBN 978-0-12-374260-5
- Gerassimos Barlas, Multicore and GPU Programming – An Integrated Approach, Elsevier Inc. (Morgan Kaufmann), 2015, ISBN 978-0-12-417137-4.
- G. Coulouria, J. Dollimore, T. Kindberg, and G. Blair, Distributed Systems: Concepts and Design, 5 th Edition, Pearson, 2012, ISBN 978-0-273-76059-7
- M. van Steen and A. S. Tanenbaum, Distributed Systems, 3 rd Edition, 2017
- Martin Kleppmann, Designing Data-Intensive Applications, 1 st Edition, O’Reilly Media, 2017, ISBN 978-1491903070 (또는 2nd Edition in February 2026)