본문 바로가기
프로그래밍/병렬프로그래밍

[OpenMP] Hello, OpenMP

by 별준 2021. 11. 20.

References

  • An Introduction to Parallel Programming

Contents

  • OpenMP
  • 기본 예제
  • 에러 체크

OpenMP

Pthreads와 같이 OpenMP는 공유 메모리 병렬 프로그래밍을 위한 API입니다. OpenMP에서 MP는 multi-processing을 의미합니다. OpenMP는 각 스레드나 프로세스가 제약없이 메모리를 액세스할 수 있도록 설계되어 있고, OpenMP로 프로그래밍할 때, 시스템을 CPU나 코어의 집합으로 바라볼 수 있도록 합니다.

이 시스템에 속한 모든 CPU나 코어는 다음 그림처럼 메인 메모리를 액세스할 수 있습니다.

 

OpenMP와 Pthreads 모두 공유 메모리 프로그래밍을 위한 API이지만, Pthreads는 개발자가 명시적으로 각 스레드의 형태를 설정해야하는 반면에 OpenMP는 개발자가 병렬로 실행될 코드의 블록만을 간단하게 설정하고, 각 스레드에 대한 보다 세밀한 제어는 컴파일이나 런타임 시스템에게 맡기는 방식입니다. 이 차이점이 Pthreads와 OpenMP의 가장 큰 차이점입니다. 또한, Pthreads는 MPI와 같이 C 프로그램에 링크해서 사용하는 함수의 라이브러리이며, 따라서, Pthreads 프로그램은 C 컴파일러를 사용하고 이러한 라이브러리를 Pthreads 라이브러리라고 합니다. 반면에 OpenMP는 몇 가지 동작을 위해서 컴파일러의 지원이 필요하며, OpenMP 프로그램을 컴파일할 수 없는 C 컴파일러를 마주할 수도 있습니다.

 

이러한 차이점은 공유 메모리 프로그램에서 두 가지 표준 API를 제공하는 이유이기도 한데, Pthreads는 low level이며 개발자가 thread의 실행 형태를 세밀하게 제어할 수 있도록 해줍니다. 그러나 이러한 자유에는 대가가 존재하며, 모든 스레드의 세밀한 동작은 전적으로 프로그래머의 책임 아래에 있습니다. OpenMP의 경우에는 컴파일러나 런타임 시스템이 스레드의 디테일한 동작을 제어하기 때문에 OpenMP를 사용하면 프로그램의 소스 코드를 좀 더 간단하게 만들 수 있습니다. (보통 low level 프로그래밍이 코드 작성하기 조금 더 어려운 것 같습니다.)

 

OpenMP는 Pthreads와 같은 API를 사용하여 고성능 프로그램을 작성하기가 어렵다고 생각한 프로그래머와 컴퓨터 공학자들에 의해 개발되었고, 공유 메모리 프로그램을 high level에서 개발할 수 있도록 OpenMP 스펙을 정의하였습니다. 실제로 OpenMP는 명시적으로 개발자가 기존의 시리얼 프로그램을 점차 병렬화할 수 있도록 설계되었고, 이는 MPI에서는 거의 불가능하며, Pthreads에서도 매우 어렵습니다.

 

이번 포스팅부터는 OpenMP를 사용하여 어떻게 프로그래밍하고, 어떻게 컴파일하고 어떻게 실행시키는지에 대해서 알아볼 예정입니다. 또한, OpenMP의 가장 강력한 기능 중의 하나인 소스 코드를 약간 수정하여 시리얼로 작성되어 있는 for 루프를 어떻게 병렬화할 수 있는지 알아볼 것입니다. 더 자세한 내용은 진행하면서 알아가보도록 하겠습니다 !

 


Hello, OpenMP

OpenMP는 directives-based(디렉티브 기반) 공유 메모리 API로 알려져 있습니다. 이는 C와 C++에서 pragma와 같은 특별한 전처리(preprocessor) 명령어를 의미합니다. pragma는 일반적으로 시스템이 기본 C 스펙에서는 제공되지 않는 동작을 할 수 있도록 해줍니다. 만약 컴파일러가 pragma를 지원하지 않는다면, 컴파일러는 pragma를 무시합니다. 따라서 pragma는 이를 지원하지 않는 플랫폼에서도 사용할 수 있습니다. 따라서 주의깊게 OpenMP 프로그램을 작성하면, OpenMP 지원 여부에 상관없이 C 컴파일러가 있는 어떠한 시스템에서도 컴파일하고 실행할 수 있습니다.

 

C와 C++에서 사용하는 pragma는 다음과 같이 시작합니다.

#pragma

일반적으로, 다른 전처리 지시자와 같이 '#' 기호를 처음에 사용하고 그 뒷부분은 코드에 따라 다릅니다.

 

가장 간단한 Hello, World 프로그램을 OpenMP를 사용하여 작성해보겠습니다.

#include <stdio.h>
#include <stdlib.h>
#include <omp.h>

/* thread function */
void Hello(void);

int main(int argc, char* argv[])
{
    int thread_count = strtol(argv[1], NULL, 10);

#pragma omp parallel num_threads(thread_count)
    Hello();

    return 0;
}

void Hello(void)
{
    int my_rank = omp_get_thread_num();
    int thread_count = omp_get_num_threads();

    printf("Hello from thread %d of %d\n", my_rank, thread_count);
}

- 전체코드 : 

https://github.com/junstar92/parallel_programming_study/blob/master/openMP/00_omp_hello.c

 

GitHub - junstar92/parallel_programming_study

Contribute to junstar92/parallel_programming_study development by creating an account on GitHub.

github.com

위의 코드를 gcc로 컴파일하려면 '-fopenmp' 옵션을 포함해야합니다.

$ gcc -Wall -fopenmp -o 00_omp_hello 00_omp_hello.c

컴파일 후, 4개의 스레드를 사용하도록 커맨드 인수에 4를 추가하여 실행하면,

위와 같은 출력을 얻을 수 있습니다.

 

각 스레드들이 stdout을 액세스하려고 경쟁하기 때문에 스레드의 rank 순서대로 출력된다고는 보장할 수 없습니다. 따라서, 실행할 때마다 다른 순서로 출력됩니다.

 

소스코드를 살펴보겠습니다.

디렉티브의 조합으로 되어 있는 OpenMP는 함수와 매크로로 구성된 라이브러리이며, 프로토타입과 매크로가 정의된 헤더파일을 include해야합니다. OpenMP의 헤더파일은 omp.h이며, line 3에서 include되고 있습니다. C언어에 익숙하시다면 line 12 전까지는 새로운 내용이 딱히 없습니다. 프로그램을 실행할 때, 입력된 인수가 thread_count에 저장됩니다.

하지만 line 12에서 처음보는 형태의 코드가 나오는데, 이것이 바로 OpenMP 디렉티브입니다. 이 라인의 의미는

#pragma omp parallel num_threads(thread_count)

프로그램이 몇 개의 스레드로 시작할 지 명시해주는 부분입니다. 포크(fork)되는 각 스레드는 Hello 함수를 실행하고, Hello의 함수가 리턴될 때 해당 스레드는 종료됩니다. 그리고 return 문을 실행하고 프로그램이 종료됩니다.

Pthreads에서는 각 스레드를 위해서 특별한 구조체를 위해 메모리 영역도 할당해야 했고, for 루프를 통해 스레드를 생성하고 join하여 스레드를 종료했습니다. 이 차이점은 OpenMP가 더 high level의 API라는 것을 보여줍니다.

 

OpenMP pragma는 항상 다음과 같이 사용합니다.

#pragma omp

 

00_omp_hello.c에서 처음 사용한 OpenMP 디렉티브는 parallel 디렉티브이며, 디렉티브 뒤에 이어지는 코드의 구조화된 블록(structured block)이 다중 스레드에 의해서 실행된다는 것을 명시한다고 추측할 수 있습니다. 구조화된 블록(structured block)은 하나의 C 구문이 될 수도 있고, 하나의 진입 포인트와 하나의 출구 포인트가 있는 복잡한 C 구문일 수도 있습니다.

 

가장 기본적인 parallel 디렉티브는 다음과 같이 사용합니다.

#pragma omp parallel

구조화된 블록을 몇 개의 스레드로 실행시킬지 여부는 런타임 시스템에 의해 결정되고, 사용되는 알고리즘은 상당히 복잡합니다. 만약 실행할 다른 스레드가 없다면, 시스템은 사용 가능한 코어마다 한 개의 스레드를 실행하게 됩니다.

 

위의 예제 코드에서 parallel 뒤의 num_threads(thread_count)를 지우고, 컴파일 후 실행해보세요.

int main(int argc, char* argv[])
{
    int thread_count = strtol(argv[1], NULL, 10);

#pragma omp parallel// num_threads(thread_count)
    Hello();

    return 0;
}

제 노트북의 경우에는 12개의 논리 프로세서가 존재하기 때문에 각 프로세서에서 하나의 스레드씩 실행된 것을 확인할 수 있었습니다.

 

물론 처음에 봤듯이 스레드의 개수를 명시적으로 나타낼 수 있고, parallel 디렉티브 뒤에 num_threads clause를 추가하여 스레드 개수를 설정할 수 있습니다. OpenMP에서 clause는 디렉티브를 제어하는 텍스트를 의미합니다. num_threads clause는 디렉티브에 추가하여 실행할 블록을 몇 개의 스레드로 실행할지 설정합니다.

 

프로그램이 시작될 때, 실행할 스레드의 개수에는 제한이 있을 수 있습니다. OpenMP 표준에서는 thread_count의 개수만큼 스레드를 실행한다고 보장하지는 않습니다. 하지만, 대부분의 시스템에서 수백 혹은 수천 개의 스레드를 실행할 수 있으며, 너무 많은 스레드를 실행하지만 않는다면 거의 대부분 원하는 만큼의 스레드를 실행할 수 있습니다.

 

프로그램이 parallel 디렉티브에 도달하면 실제로 어떤 일이 발생할까요? parallel 디렉티브를 만나기 전까지는 프로그램이 하나의 스레드만 사용합니다. 프로그램이 parallel 디렉티브를 만나면 원래의 스레드는 계속 실행하고, thread_count - 1개의 스레드가 추가로 실행됩니다. OpenMP에서는 스레드의 집합이 parallel 블록을 실행하며, 원래의 스레드와 추가된 스레드를 한 팀(team)이라고 합니다. 원래의 스레드를 마스터(master)라고 하며, 추가된 스레드는 슬레이브(slave)라고 합니다. 팀에 있는 스레드는 parallel 디렉티브 이후에 나오는 블록을 실행하며, 위 코드에서 각 스레드는 Hello 함수를 실행합니다.

 

코드 블록의 실행이 완료되면 스레드들은 Hello 함수로부터 리턴하는데, 여기에 암시적인 배리어(implicit barrier)가 있습니다. 즉, 코드 블록의 실행을 완료한 스레드는 한 팀에 속해 있는 나머지 스레드들이 블록을 완료할 때까지 대기하게 됩니다. 모든 스레드가 블록을 완료하면, 슬레이브 스레드는 종료하게 되고 마스터 스레드는 계속 다음 코드를 수행합니다. 위 예제에서 마스터 스레드는 return문을 실행하고 프로그램은 종료됩니다. 

각 스레드는 자신만의 스택을 갖고 있기 때문에 Hello 함수를 실행하는 스레드는 함수에서 사용하는 자신만의 private 변수나 지역 변수를 생성하게 됩니다. 위 예제에서 Hello 함수가 호출됬을 때, 각 스레드는 OpenMP 함수인 omp_get_thread_num과 omp_get_num_threads를 호출하여 스레드 rank와 총 스레드 개수를 얻게 됩니다. 이때, rank의 범위는 0부터 thread_count - 1까지 입니다.

int omp_get_thread_num(void);
int omp_get_num_threads(void);

각 스레드는 stdout을 공유하므로 printf 문장을 실행하여 출력할 수 있지만, stdout을 액세스할 때 순서를 스케쥴링할 수 없기 때문에 출력하는 순서는 랜덤합니다.

 


에러 체크

앞서 살펴본 예제에서는 에러를 체크하는 부분이 존재하지 않습니다. 사실 에러 체크가 없는 코드는 위험하고 실제 작업할 때는 예상되는 오류에 대해 해당 오류를 체크하는 코드를 반드시 넣는 것이 필요합니다. 위 예제 코드에서는 커맨드 라인의 인수의 존재 여부와 음수나 0이 아닌지를 체크해야합니다. 그리고 parallel 디렉티브에 의해 생성되는 스레드의 개수가 실제 우리가 지정한 thread_count와 같은지도 체크해야 합니다. 물론 위의 예제처럼 간단한 경우에 생성된 스레드의 개수가 큰 문제는 아닙니다.

 

두 번째 문제는 컴파일러입니다. 컴파일러가 OpenMP를 지원하지 않는다면, 컴파일러는 parallel 디렉티브를 무시합니다. 그러나 omp.h를 include하고 omp_get_thread_num과 omp_get_num_threads 함수를 호출하면 에러가 발생하게 됩니다. 이 문제를 해결하기 위해서는 전처리 매크로 _OPENMP가 정의되었는지를 체크하여 해결할 수 있습니다. 

따라서, 간단하게 omp.h를 include하는 것이 아니라,

#ifdef _OPENMP
#include <omp.h>
#endif

위 코드처럼 omp.h를 조건부로 include 해야합니다.

또한, OpenMP 함수를 바로 호출하는 대신 _OPENMP가 정의되어 있는지 먼저 체크해야 합니다.

#ifdef _OPENMP
    int my_rank = omp_get_thread_num();
    int actual_thread_count = omp_get_num_threads();
#else
    int my_rank = 0;
    int actual_thread_count = 1;
#endif

만약 OpenMP를 사용할 수 없는 환경이라면, Hello 함수는 하나의 스레드에서 실행되며, 싱글 스레드의 rank는 0, 스레드의 총 개수는 1로 설정됩니다.

일반적으로 위와 같이 컴파일하여, 실행할 수 있는데 -fopenmp를 사용할 수 없다고 가정하고 컴파일 후 실행하면,

위와 같이 출력됩니다.

OpenMP가 컴파일러 옵션으로 설정된 경우에는 리터럴 정수로 _OPENMP가 정의됩니다. 이 값은 OpenMP 사양의 날짜를 의미합니다.

 

소스 코드를 가능한 확실하게 작성하기 위해서는 에러 체크가 필요합니다만, 앞으로의 예제에서는 생략할 예정입니다. 실제 프로그램을 작성할 때는 주의해서 사용하면 될 것 같습니다!

 

다음 포스팅에서는 MPI에서 살펴봤던 사다리꼴 공식을 OpenMP로 작성해보도록 하겠습니다 !

댓글