OpenMP로 공유 메모리 병렬 프로그램 구성하기
OpenMP의 공유 메모리 모델과 컴파일러 지시문, 데이터 범위, 스케줄링, 런타임 함수로 병렬 프로그램을 구성하는 방법
2026-08-14 · 최초 발행 2025-12-28
순차 코드와 스레드 관리 사이의 간극
Pthread처럼 저수준 스레드 라이브러리를 직접 다루면 스레드 생성, 동기화, 작업 배분을 코드에서 관리해야 한다. OpenMP는 이 부담을 컴파일러 지시문으로 옮긴 공유 메모리 병렬 프로그래밍 API다. 기존 순차 코드에 병렬 영역을 조금씩 적용할 수 있으며, C, C++, Fortran에서 사용할 수 있다.
OpenMP는 1997년 OpenMP Architecture Review Board(ARB)에서 표준화됐다. 구성은 컴파일러 지시문, 런타임 라이브러리 루틴, 환경 변수로 나뉜다. 병렬 영역에 들어갈 때 스레드 팀이 만들어지고, 영역이 끝나면 다시 합류하는 포크-조인 모델을 따른다.
하나의 주소 공간을 여러 스레드가 다룰 때
공유 메모리 모델에서는 모든 스레드가 같은 주소 공간을 본다. 데이터 교환은 메모리 읽기와 쓰기로 이뤄지므로 통신 오버헤드는 낮지만, 동시에 같은 데이터에 접근하면 Race Condition이 생길 수 있다. 그래서 동기화와 변수의 범위를 명확히 정해야 한다.
전역 변수와 정적 변수는 기본적으로 공유 데이터가 된다. 각 스레드의 지역 변수와 명시적으로 지정한 변수는 전용 데이터로 다룬다. shared, private, firstprivate, lastprivate, reduction 절이 이 데이터 환경을 표현한다.
지시문으로 병렬 영역을 선언한다
OpenMP 지시문은 병렬 영역, 작업 공유, 데이터 환경, 스케줄링 정책을 코드 가까이에 선언한다.
#pragma omp directive-name [clause[[,] clause]...] [newline]
structured-block
directive-name은 병렬 영역이나 작업 공유 방식을 지정하고, clause에는 데이터 환경이나 스케줄링 옵션을 둔다. structured-block이 실제로 병렬화할 코드 블록이다.
병렬 영역과 반복 공간 분배
parallel은 스레드 팀을 만들고 블록이 끝날 때 조인한다.
#pragma omp parallel
{
// 모든 스레드가 이 블록을 실행
int tid = omp_get_thread_num();
printf("Hello from thread %d\n", tid);
}
독립적인 반복은 for로 나눌 수 있다. 반복 사이에 의존성이 있으면 이 방식으로 병렬화할 수 없다.
int sum = 0;
#pragma omp parallel for reduction(+:sum)
for(int i = 0; i < N; i++) {
sum += array[i];
}
서로 다른 작업을 스레드에 맡길 때는 sections를 사용한다.
#pragma omp parallel sections
{
#pragma omp section
{
task_A(); // Thread 1이 실행
}
#pragma omp section
{
task_B(); // Thread 2가 실행
}
#pragma omp section
{
task_C(); // Thread 3이 실행
}
}
한 스레드만 실행해야 하는 코드
single은 병렬 영역 안에서 한 스레드만 실행하게 한다. I/O처럼 모든 스레드가 수행할 필요 없는 작업에 쓸 수 있다.
#pragma omp parallel
{
// 모든 스레드가 실행
do_work();
#pragma omp single
{
// 한 스레드만 실행 (주로 I/O)
printf("Work completed\n");
}
// 모든 스레드가 실행
continue_work();
}
master는 마스터 스레드인 Thread 0만 실행한다. 암묵적 배리어가 없다는 차이가 있다.
#pragma omp parallel
{
#pragma omp master
{
// Thread 0만 실행
initialize_resources();
}
// 암묵적 배리어 없음! 주의
}
경쟁 상태를 막는 동기화
critical은 한 번에 하나의 스레드만 블록에 들어가도록 한다.
int counter = 0;
#pragma omp parallel for
for(int i = 0; i < N; i++) {
#pragma omp critical
{
counter++; // 상호 배제 보장
}
}
단순 연산에는 atomic을 적용할 수 있다. critical보다 빠르며 lock-free 방식이지만, 적용할 수 있는 연산은 제한된다.
int counter = 0;
#pragma omp parallel for
for(int i = 0; i < N; i++) {
#pragma omp atomic
counter++; // 하드웨어 atomic 명령어 사용
}
단계 간 선행 작업이 끝나야 한다면 barrier로 스레드를 대기시킨다.
#pragma omp parallel
{
phase1_work();
#pragma omp barrier // 모든 스레드 대기
phase2_work();
}
변수의 소유권을 선언하는 방법
shared로 지정한 변수는 스레드가 함께 접근한다. 공유 변수를 갱신할 때는 별도의 동기화가 필요할 수 있다.
int shared_var = 0;
#pragma omp parallel shared(shared_var)
{
// 모든 스레드가 같은 shared_var 접근
#pragma omp atomic
shared_var++;
}
private는 스레드마다 별도 복사본을 만든다. 복사본은 초기화되지 않는다.
int global_var = 100;
#pragma omp parallel private(global_var)
{
// 각 스레드는 자신만의 global_var 복사본 소유
// 초기화되지 않음!
global_var = omp_get_thread_num();
printf("Thread %d: %d\n", omp_get_thread_num(), global_var);
}
// global_var는 여전히 100
초기값이 필요한 전용 변수에는 firstprivate를 쓴다.
int init_value = 100;
#pragma omp parallel firstprivate(init_value)
{
// 각 스레드의 init_value가 100으로 초기화됨
init_value += omp_get_thread_num();
}
반복의 마지막 값을 병렬 영역 밖으로 복사하려면 lastprivate를 사용한다.
int last_iter;
#pragma omp parallel for lastprivate(last_iter)
for(int i = 0; i < N; i++) {
last_iter = i;
}
// last_iter는 N-1
집계 연산은 reduction으로 각 스레드의 로컬 복사본에서 수행한 뒤 결합한다.
int sum = 0;
int product = 1;
#pragma omp parallel for reduction(+:sum) reduction(*:product)
for(int i = 1; i <= N; i++) {
sum += i;
product *= i;
}
// sum = 1+2+...+N
// product = 1*2*...*N
지원 연산자는 +, *, -, &, |, ^, &&, ||, min, max다. 집계 과정에서 Race Condition을 피할 수 있다.
작업량에 맞춰 반복을 배분한다
schedule 절은 반복 공간을 스레드에 나누는 방식을 정한다.
#pragma omp parallel for schedule(type [, chunk_size])
for(int i = 0; i < N; i++) {
work(i);
}
작업 시간이 균일하면 static이 적합하다. 분배가 컴파일 타임에 결정되고 오버헤드가 작다.
#pragma omp parallel for schedule(static, 4)
for(int i = 0; i < 16; i++) {
work(i);
}
// Thread 0: 0,1,2,3, 8,9,10,11
// Thread 1: 4,5,6,7, 12,13,14,15
작업 시간이 불균등하면 dynamic이 런타임 작업 큐에서 청크를 할당한다. 먼저 끝난 스레드가 다음 청크를 처리하지만 오버헤드가 있다.
#pragma omp parallel for schedule(dynamic, 2)
for(int i = 0; i < 16; i++) {
work(i); // 작업 시간이 불균등
}
// 런타임에 2개씩 동적 할당
// 먼저 끝난 스레드가 다음 청크 처리
guided는 처음에는 큰 청크를 배정하고 뒤로 갈수록 청크 크기를 줄인다.
#pragma omp parallel for schedule(guided, 4)
for(int i = 0; i < 100; i++) {
work(i);
}
// 초기: 큰 청크 (예: 25개)
// 중간: 중간 청크 (예: 12개)
// 말미: 작은 청크 (예: 4개, 최소 크기)
런타임에 스케줄을 바꾸고 싶다면 runtime을 사용하고 OMP_SCHEDULE 환경 변수로 결정한다.
#pragma omp parallel for schedule(runtime)
for(int i = 0; i < N; i++) {
work(i);
}
// 환경 변수 OMP_SCHEDULE로 결정
// export OMP_SCHEDULE="dynamic,4"
런타임에서 확인하고 제어할 항목
omp.h의 런타임 함수는 스레드 수, 현재 스레드 ID, 병렬 영역 여부, 사용 가능한 프로세서 수를 다룬다.
#include <omp.h>
// 스레드 수 설정
omp_set_num_threads(8);
// 현재 스레드 ID 조회
int tid = omp_get_thread_num();
// 전체 스레드 수 조회
int num_threads = omp_get_num_threads();
// 병렬 영역 여부 확인
int in_parallel = omp_in_parallel();
// 사용 가능한 프로세서 수
int num_procs = omp_get_num_procs();
동적 스레드 수 조절과 중첩 병렬화는 다음 함수로 활성화할 수 있다.
// 동적 스레드 수 조절 활성화
omp_set_dynamic(1);
// 중첩 병렬화 활성화
omp_set_nested(1);
경과 시간은 omp_get_wtime()으로 측정한다.
double start = omp_get_wtime();
// 병렬 작업 수행
#pragma omp parallel for
for(int i = 0; i < N; i++) {
work(i);
}
double end = omp_get_wtime();
printf("Execution time: %f seconds\n", end - start);
임계 영역을 명시적인 락으로 제어할 수도 있다.
omp_lock_t lock;
omp_init_lock(&lock);
#pragma omp parallel
{
omp_set_lock(&lock);
// 임계 영역
critical_section();
omp_unset_lock(&lock);
}
omp_destroy_lock(&lock);
환경 변수는 실행 시점에 스레드 수, 스케줄링, 대기 정책, CPU 친화도를 조정하는 수단이다.
# 기본 스레드 수 설정
export OMP_NUM_THREADS=8
# 스케줄링 정책 설정
export OMP_SCHEDULE="dynamic,4"
# 동적 스레드 조절
export OMP_DYNAMIC=TRUE
# 중첩 병렬화
export OMP_NESTED=TRUE
# 스레드 대기 정책
export OMP_WAIT_POLICY=ACTIVE # or PASSIVE
# CPU 친화도 설정
export OMP_PROC_BIND=true
반복 계산과 작업 분할 예시
행렬-벡터 곱셈처럼 각 행의 계산이 독립적이면 바깥 반복문을 병렬화할 수 있다.
// 순차 버전
for(int i = 0; i < N; i++) {
C[i] = 0;
for(int j = 0; j < N; j++) {
C[i] += A[i][j] * B[j];
}
}
// OpenMP 병렬 버전
#pragma omp parallel for
for(int i = 0; i < N; i++) {
C[i] = 0;
for(int j = 0; j < N; j++) {
C[i] += A[i][j] * B[j];
}
}
몬테카를로 방식에서는 각 스레드가 표본을 계산하고 로컬 카운트를 공유 카운트에 반영한다.
#include <omp.h>
#include <stdlib.h>
double estimate_pi(long num_samples) {
long count = 0;
#pragma omp parallel
{
unsigned int seed = omp_get_thread_num();
long local_count = 0;
#pragma omp for
for(long i = 0; i < num_samples; i++) {
double x = (double)rand_r(&seed) / RAND_MAX;
double y = (double)rand_r(&seed) / RAND_MAX;
if(x*x + y*y <= 1.0) {
local_count++;
}
}
#pragma omp atomic
count += local_count;
}
return 4.0 * count / num_samples;
}
히스토그램은 스레드별 로컬 배열을 만든 뒤 전역 히스토그램에 병합하는 방식으로 구성할 수 있다.
#define NUM_BINS 256
void compute_histogram(int* data, int size, int* histogram) {
// 초기화
for(int i = 0; i < NUM_BINS; i++) {
histogram[i] = 0;
}
// 병렬 히스토그램 계산
#pragma omp parallel
{
int local_hist[NUM_BINS] = {0};
#pragma omp for
for(int i = 0; i < size; i++) {
local_hist[data[i]]++;
}
// 로컬 히스토그램을 전역 히스토그램에 병합
#pragma omp critical
{
for(int i = 0; i < NUM_BINS; i++) {
histogram[i] += local_hist[i];
}
}
}
}
서로 다른 단계를 분리할 수 있는 처리에는 sections를 이용한 파이프라인 구성이 가능하다.
#define STAGES 3
#define ITEMS 100
void pipeline_processing() {
int buffer[STAGES][ITEMS];
#pragma omp parallel sections
{
#pragma omp section
{
// Stage 1: 데이터 로드
for(int i = 0; i < ITEMS; i++) {
buffer[0][i] = load_data(i);
}
}
#pragma omp section
{
// Stage 2: 데이터 처리
for(int i = 0; i < ITEMS; i++) {
#pragma omp flush(buffer)
buffer[1][i] = process_data(buffer[0][i]);
}
}
#pragma omp section
{
// Stage 3: 결과 저장
for(int i = 0; i < ITEMS; i++) {
#pragma omp flush(buffer)
save_result(buffer[1][i]);
}
}
}
}
성능을 깎는 경계 조건
스레드마다 인접한 메모리를 갱신하면 False Sharing으로 캐시 일관성 오버헤드가 발생할 수 있다. 패딩을 둬서 이를 피하는 방법이 있다.
// 잘못된 예: False Sharing 발생
int counters[NUM_THREADS]; // 캐시 라인 공유
#pragma omp parallel
{
int tid = omp_get_thread_num();
for(int i = 0; i < N; i++) {
counters[tid]++; // 캐시 일관성 오버헤드
}
}
// 올바른 예: 패딩 추가
struct padded_counter {
int count;
char padding[60]; // 캐시 라인 크기만큼 패딩
};
struct padded_counter counters[NUM_THREADS];
#pragma omp parallel
{
int tid = omp_get_thread_num();
for(int i = 0; i < N; i++) {
counters[tid].count++;
}
}
청크가 너무 작으면 작업 분배 자체의 오버헤드가 커진다.
// 너무 작은 청크: 오버헤드 증가
#pragma omp parallel for schedule(dynamic, 1)
for(int i = 0; i < 1000000; i++) {
simple_work(i);
}
// 적절한 청크 크기
#pragma omp parallel for schedule(dynamic, 1000)
for(int i = 0; i < 1000000; i++) {
simple_work(i);
}
단순 합계에는 critical보다 reduction이 적합하다.
// 비효율적: critical 사용
int sum = 0;
#pragma omp parallel for
for(int i = 0; i < N; i++) {
#pragma omp critical
sum += array[i];
}
// 효율적: reduction 사용
int sum = 0;
#pragma omp parallel for reduction(+:sum)
for(int i = 0; i < N; i++) {
sum += array[i];
}
반복마다 병렬 영역을 새로 만들기보다 하나의 병렬 영역 안에서 작업 공유를 반복할 수 있다.
// 비효율적: 병렬 영역을 여러 번 생성
for(int iter = 0; iter < 10; iter++) {
#pragma omp parallel for
for(int i = 0; i < N; i++) {
work(i);
}
}
// 효율적: 병렬 영역을 한 번만 생성
#pragma omp parallel
{
for(int iter = 0; iter < 10; iter++) {
#pragma omp for
for(int i = 0; i < N; i++) {
work(i);
}
}
}
과도한 critical과 barrier, 작은 작업 단위, 스레드 간 부하 불균형, False Sharing, 메모리 대역폭 포화는 병렬 성능을 낮출 수 있다.
컴파일과 실행
컴파일러별 OpenMP 옵션은 다음과 같다.
# GCC
gcc -fopenmp program.c -o program
# Intel C Compiler
icc -qopenmp program.c -o program
# Clang (macOS)
clang -Xpreprocessor -fopenmp -lomp program.c -o program
# MSVC (Windows)
cl /openmp program.c
스레드 수는 환경 변수로 지정한 뒤 실행할 수 있다.
# 스레드 수 설정 후 실행
export OMP_NUM_THREADS=4
./program
# 또는 한 줄로
OMP_NUM_THREADS=4 ./program
병렬화 전에 확인할 제약
루프 간 RAW 의존성, 가변 종료 조건의 while 루프, break나 return을 포함한 비정형 제어 흐름은 그대로 omp for로 병렬화할 수 없다.
// 루프 간 의존성
for(int i = 1; i < N; i++) {
A[i] = A[i-1] + B[i]; // RAW 의존성
}
// 가변 종료 조건
while(condition) { // while 루프는 병렬화 불가
work();
}
// 비정형 제어 흐름
for(int i = 0; i < N; i++) {
if(condition) break; // break/return 금지
}