OpenMP와 MPI로 설계하는 운영체제 병렬 처리
OpenMP와 MPI의 실행 모델, 메모리 구조, 통신 방식, NUMA와 운영체제 최적화 관점을 정리한다.
2026-08-14 · 최초 발행 2026-01-16
병렬화 방식은 메모리 경계에서 갈린다
단일 코어 성능의 정체와 멀티코어 프로세서의 보편화로 병렬 처리는 시스템 설계의 기본 조건이 됐다. 같은 연산을 데이터 조각에 나눠 적용할 수도 있고, 성격이 다른 작업을 동시에 돌릴 수도 있으며, 처리 단계를 이어 파이프라인으로 구성할 수도 있다. SIMD(Single Instruction Multiple Data)와 AVX512 같은 벡터 연산도 데이터 병렬성의 한 형태다.
공유 메모리와 분산 메모리는 이 선택을 가르는 핵심 구조다.
UMA와 NUMA는 공유 메모리 환경에 속하고 OpenMP가 주로 다루는 영역이다. 네트워크로 연결한 클러스터와 MPP(Massively Parallel Processor)는 프로세스별 주소 공간을 전제로 하며, MPI가 통신을 맡는다.
병렬화의 상한은 Amdahl의 법칙으로 설명할 수 있다. 전체 실행 시간은 순차 부분과 병렬화 가능한 부분으로 나뉘며, 이론적 속도 향상은 1 / (순차 비율 + 병렬 비율 / 코어 수)다. 순차 부분이 10%, 병렬 부분이 90%이고 10코어를 사용하면 속도 향상은 5.3배로, 이론치인 10배에 미치지 못한다. 코어 수를 계속 늘려도 순차 구간은 병목으로 남는다.
실행 환경에서는 이론식 밖의 비용도 발생한다. 스레드 생성과 소멸에는 컨텍스트 스위칭 비용이 따르고, Lock과 Barrier는 대기 시간을 만든다. MPI 메시지 통신은 전송 지연과 대역폭 제한을 갖는다. 작업량이 고르지 않으면 일부 스레드는 일을 마친 뒤 대기하며, False Sharing과 캐시 무효화 트래픽은 캐시 일관성 비용으로 이어진다.
OpenMP는 공유 메모리를 스레드로 나눈다
OpenMP는 Open Multi-Processing의 약자다. #pragma omp parallel 같은 pragma 기반 지시어를 통해 병렬 영역을 선언하며, GCC·Clang·Intel·MSVC 컴파일러가 이를 지원한다. 병렬 영역에 들어갈 때 스레드를 만들고, 영역을 벗어날 때 합류하는 Fork-Join 모델을 사용한다. 스레드 수는 OMP_NUM_THREADS 환경 변수로 조정할 수 있다.
다음 코드는 병렬 영역에서 스레드 식별자를 출력하고, 이어지는 루프를 여러 스레드에 분배한다.
#include <omp.h>
int main() {
#pragma omp parallel num_threads(4)
{
int tid = omp_get_thread_num();
printf("Hello from thread %d\n", tid);
}
// 병렬 for 루프
#pragma omp parallel for
for (int i = 0; i < 1000; i++) {
array[i] = compute(i);
}
}
OpenMP 지시어는 작업 형태와 동기화 범위에 따라 선택한다. parallel은 병렬 영역을 만들고, for는 루프 반복을 스레드에 분배한다. sections는 코드 블록마다 스레드를 할당해 태스크 병렬성을 표현한다. single은 한 스레드만 실행하게 하며, master는 Master 스레드만 실행한다. 공유 상태를 보호해야 한다면 critical로 한 번에 하나의 스레드만 진입시키거나, Lock 오버헤드를 줄일 수 있는 atomic 연산을 쓴다.
변수의 데이터 공유 속성도 병렬 영역의 결과를 좌우한다. shared는 모든 스레드가 함께 접근하며 기본 속성이다. private는 스레드별 복사본을 만들고, firstprivate는 Master 값을 복사해 초기화한다. lastprivate는 마지막 반복의 값을 Master로 복사한다. reduction은 각 스레드의 결과를 합산하거나 병합하는 용도다.
int sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < N; i++) {
sum += array[i];
}
// 각 스레드가 로컬 sum 계산 후, 최종 합산
루프 비용이 균일한지, 반복마다 편차가 큰지에 따라 스케줄링도 달라진다.
static은 컴파일 타임에 반복을 균등하게 나누므로 예측 가능하다. dynamic은 런타임에 청크 단위로 반복을 할당한다. guided는 큰 청크로 시작해 점차 크기를 줄이며, auto는 컴파일러가 선택한다. schedule(static, 10)은 10개 반복 단위로 청크를 배정한다.
NUMA 환경에서는 어느 스레드가 어느 메모리에 먼저 접근하는지가 중요하다. First-Touch Policy는 처음 접근한 스레드의 로컬 메모리를 할당한다. OMP_PROC_BIND=close로 스레드 친화성을 조정할 수 있고, OMP_PLACES=cores 또는 OMP_PLACES=threads로 배치 단위를 지정할 수 있다. 노드와 메모리를 명시적으로 묶는 방법도 있다.
numactl --cpunodebind=0 --membind=0 ./a.out
OpenMP 4.0+에서는 #pragma omp task로 비정형 병렬성을 표현할 수 있다. depend(in:x) depend(out:y)는 태스크 의존성을 기술한다. #pragma omp simd는 벡터화 힌트로 쓰이며, Target Offloading은 GPU나 Accelerator에 작업을 오프로드한다.
MPI는 주소 공간 사이에서 메시지를 주고받는다
MPI는 Message Passing Interface의 약자다. MPI-1, MPI-2, MPI-3, MPI-4 표준 API가 있으며 OpenMPI, MPICH, Intel MPI, IBM MPI 같은 구현체가 있다. 각 프로세스는 독립 주소 공간을 가지므로, 데이터를 함께 참조하지 않고 Send/Receive 및 Collective Operations로 통신을 명시한다.
프로세스는 초기화 뒤 자신의 Rank와 전체 프로세스 수를 확인한다. 아래 예시는 Rank 0이 Rank 1로 정수를 전송하고, Rank 1이 이를 수신하는 흐름이다.
#include <mpi.h>
int main(int argc, char** argv) {
MPI_Init(&argc, &argv);
int rank, size;
MPI_Comm_rank(MPI_COMM_WORLD, &rank);
MPI_Comm_size(MPI_COMM_WORLD, &size);
if (rank == 0) {
int data = 100;
MPI_Send(&data, 1, MPI_INT, 1, 0, MPI_COMM_WORLD);
} else if (rank == 1) {
int data;
MPI_Recv(&data, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
printf("Received: %d\n", data);
}
MPI_Finalize();
return 0;
}
점대점 통신에서 MPI_Send는 버퍼 복사가 완료될 때까지 기다리는 블로킹 송신이고, MPI_Recv는 메시지가 도착할 때까지 기다리는 블로킹 수신이다. MPI_Isend와 MPI_Irecv는 비블로킹으로 즉시 반환하며, 이후 MPI_Wait로 완료를 기다리거나 MPI_Test로 완료 여부를 폴링한다.
여러 프로세스가 함께 수행하는 통신은 집합 통신으로 표현한다.
MPI_Bcast는 루트에서 모든 프로세스로 데이터를 보낸다. MPI_Reduce는 각 프로세스 값을 루트에 합산하거나 최대값 등으로 축약한다. MPI_Scatter는 루트가 데이터를 분배하고, MPI_Gather는 각 프로세스의 데이터를 루트로 모은다. MPI_Allreduce는 Reduce와 Broadcast를 함께 수행한다.
int local_sum = compute_partial_sum(rank);
int global_sum;
MPI_Reduce(&local_sum, &global_sum, 1, MPI_INT, MPI_SUM, 0, MPI_COMM_WORLD);
if (rank == 0) {
printf("Total sum: %d\n", global_sum);
}
통신 모드는 버퍼와 수신 확인 방식에 따라 구분된다. Standard Mode는 시스템 버퍼를 사용하며 버퍼링 정책은 구현에 의존한다. Synchronous Mode는 수신 완료 확인 뒤 반환한다. Buffered Mode는 사용자 버퍼를 명시하고 즉시 반환하며, Ready Mode는 수신 준비가 된 경우에만 송신하는 최적화 방식이다.
MPI_COMM_WORLD는 모든 프로세스를 포함하는 기본 통신자다. Custom Communicator는 프로세스 부분 집합을 위한 통신자이며, MPI_Comm_split은 조건에 따라 통신자를 나눈다. Cartesian와 Graph 토폴로지는 프로세스 관계를 정의할 때 사용한다.
성능을 조정할 때는 메시지 크기와 연산의 겹침을 함께 봐야 한다. 작은 메시지는 지연 시간이 지배하고 큰 메시지는 대역폭이 지배한다. 비블로킹 통신과 연산을 겹치면 Latency Hiding을 시도할 수 있다. 집합 통신은 Tree, Binomial, Ring 알고리즘 선택의 영향을 받고, Derived Datatypes는 비연속 데이터를 효율적으로 전송한다. 반복 통신에는 Persistent Communication으로 초기화 오버헤드를 없앨 수 있다.
노드 안과 노드 밖을 함께 쓰는 하이브리드 구성
MPI와 OpenMP를 결합하면 노드 간에는 MPI 프로세스 통신을 사용하고, 노드 내부에서는 OpenMP 스레드로 병렬화한다.
이 방식은 메모리를 절약하고 통신을 줄일 수 있다. 초기화에는 MPI_Init_thread(MPI_THREAD_FUNNELED)를 사용한다.
동기화 오류와 성능 병목을 찾는 방법
병렬 프로그램의 오류는 실행 순서에 따라 드러나는 경우가 많다. Race Condition은 공유 변수에 동시에 접근하면서 발생하며 Helgrind(Valgrind)로 탐지할 수 있다. Deadlock은 상호 대기로 이어지고 Intel Inspector가 관련 도구다. OpenMP에서는 shared 변수를 보호하지 않은 Data Race를 특히 주의해야 한다. ThreadSanitizer(TSan), Intel Thread Checker도 동기화 문제를 확인하는 데 사용된다.
MPI 프로그램은 Rank별 출력을 먼저 확인할 수 있다.
printf("[Rank %d] ...", rank);
여러 프로세스를 GDB로 실행하는 방법은 다음과 같다.
mpirun -np 4 xterm -e gdb ./app
TotalView는 MPI와 OpenMP를 지원하는 상용 병렬 디버거다. MUST(MPI Runtime Error Detection)는 MPI 호출을 검증한다.
프로파일링 도구는 CPU 사용률, MPI 통신, 대기 시간, 이벤트 흐름처럼 서로 다른 병목을 드러낸다.
속도 향상(Speedup)은 순차 실행 시간을 병렬 실행 시간으로 나눈 값이고, 효율성(Efficiency)은 속도 향상을 프로세서 수로 나눈 값이다. 확장성(Scalability)은 프로세서가 증가할 때 성능이 늘어나는 비율을 뜻한다. 강한 확장성(Strong Scaling)은 문제 크기를 고정하고, 약한 확장성(Weak Scaling)은 프로세서당 작업량을 고정한다.
병렬성이 필요한 워크로드
HPC에서는 CFD(Computational Fluid Dynamics) 기반 유체역학 시뮬레이션, LAMMPS와 GROMACS를 이용한 분자 동역학, 지구 시스템 모델을 사용하는 기후 모델링, N-body 시뮬레이션 같은 천체물리학 계산이 병렬 처리 대상이 된다.
데이터 분석에서는 MPI 기반 빅데이터 처리로 MapReduce 대안을 구성할 수 있다. ScaLAPACK은 병렬 선형대수에 쓰이고, Horovod는 MPI 기반 분산 학습을 지원한다. Pregel 모델 구현도 그래프 분석에 연결된다.
멀티미디어 워크로드에서는 FFmpeg와 OpenMP를 결합한 동영상 인코딩, OpenCV 병렬화, Ray Tracing 기반 3D 렌더링, 오디오 필터의 병렬 적용이 해당한다.
작업을 나누고 합치는 설계 방식
Fork-Join은 작업을 분할한 뒤 병렬 실행하고 결과를 합병하는 패턴이다. OpenMP의 기본 패턴이며 배열 합산과 행렬 곱셈이 예시다.
Master-Worker에서는 Master가 작업을 배분하고 결과를 수집하며, Worker는 작업을 수행한 뒤 결과를 돌려준다. 동적 부하 분산과 비정형 작업에 유리하다. MPI로 구현할 때 Rank 0은 Master, 나머지는 Worker가 된다.
Pipeline은 각 처리 단계를 독립 스레드나 프로세스에 배치한다.
Pipeline은 처리량을 늘리지만 지연 시간은 누적된다. 비디오 처리의 디코드 → 필터 → 인코드 흐름이 예시다.
Data Parallel 패턴은 데이터 부분집합에 같은 연산을 적용한다. OpenMP에서는 parallel for로, MPI에서는 Scatter → 연산 → Gather로 구성할 수 있다. CUDA와 OpenCL을 사용하는 GPU 처리도 같은 범주다.
운영체제가 병렬 실행을 뒷받침하는 지점
리눅스의 CFS(Completely Fair Scheduler)는 기본 스케줄러다. 실시간 작업에는 SCHED_FIFO, SCHED_RR을 사용할 수 있다. sched_setaffinity()와 taskset은 CPU Affinity를 지정하고, cgroup은 CPU 시간 할당량을 제한한다.
메모리 계층에서는 THP(Transparent Huge Pages)가 2MB 페이지를 자동 할당한다. NUMA 정책에는 numactl과 numa_alloc_onnode()가 쓰이며, mmap()과 MAP_POPULATE는 페이지 프리페칭에 해당한다. jemalloc과 tcmalloc은 멀티스레드에 최적화된 메모리 할당자다.
네트워크에서는 TCP Offload Engine(TOE)이 네트워크 처리를 하드웨어로 오프로드한다. RDMA(Remote Direct Memory Access)는 CPU를 우회해 메모리를 전송한다. InfiniBand는 HPC용으로 낮은 지연인 1-2μs를 제공하고, RoCE(RDMA over Converged Ethernet)는 이더넷에서 RDMA를 사용한다.
파일 I/O는 MPI-IO와 HDF5 병렬 읽기·쓰기로 병렬화할 수 있다. Lustre와 GPFS는 병렬 파일 시스템이며, Direct I/O는 페이지 캐시를 우회한다. 비동기 I/O에는 io_uring과 libaio가 있다.
OpenMP와 MPI 중 어느 하나만으로 병렬 처리를 설명하기는 어렵다. 공유 메모리의 작업 분할과 분산 메모리의 통신 구조를 구분하고, Fork-Join·Master-Worker·Pipeline 같은 패턴을 워크로드에 맞춰 선택해야 한다. Amdahl의 법칙이 보여주는 순차 구간의 한계를 전제로, 스레드 스케줄링, NUMA, RDMA, 파일 I/O까지 운영체제 차원의 조건을 함께 다루는 것이 병렬 시스템 설계의 기반이다.