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);
        }
    }
}

과도한 criticalbarrier, 작은 작업 단위, 스레드 간 부하 불균형, 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 루프, breakreturn을 포함한 비정형 제어 흐름은 그대로 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 금지
}
OpenMP병렬 프로그래밍공유 메모리멀티스레딩운영체제