본문으로 건너뛰기
Life Saver Wiki

[병렬 컴퓨팅 + 금융공학] 몬테카를로 시뮬레이션 병렬화: ForkJoinPool과 GPU 가속 비교

배리어 옵션 가격 산출에 ForkJoinPool 멀티코어 병렬화와 CuPy GPU 가속을 각각 적용하고 성능 결과를 비교한 경험을 코드와 함께 정리합니다.

운영자
Life Saver Wiki

유럽형 배리어 옵션의 가격을 몬테카를로로 산출하는 작업에서 10만 경로를 단일 스레드로 실행하면 3.2초가 걸렸습니다. ForkJoinPool로 병렬화해 8코어 서버에서 0.5초로 줄였지만, 트레이딩 데스크의 요구는 100만 경로를 1초 내에 완료하는 것이었습니다. GPU(CuPy)로 재구현한 결과 0.08초로 단축됐고, 두 병렬화 패러다임이 작업 분할과 메모리 모델에서 얼마나 다른지 직접 비교할 수 있었습니다.

몬테카를로 시뮬레이션 병렬화를 위한 GPU 가속 하드웨어

배경

배리어 옵션과 몬테카를로의 필연성

배리어 옵션(Barrier Option)은 기초자산 가격이 특정 배리어 수준에 도달하는지 여부에 따라 옵션이 활성화(Knock-In)되거나 소멸(Knock-Out)되는 경로 의존적(Path-Dependent) 파생상품입니다.

유럽형 바닐라 옵션과 달리 블랙-숄즈 공식으로 단순하게 가격을 산출할 수 없습니다. 해석적 해가 단순한 배리어 구조에는 존재하지만, 이중 배리어, 미국형 배리어, 확률적 변동성 모델 하의 배리어 옵션 등 실무에서 다루는 복잡한 변형은 몬테카를로 시뮬레이션이 사실상 유일한 실용적 가격 산출 방법입니다.

왜 대규모 경로가 필요한가

몬테카를로 추정의 표준 오차는 경로 수 N에 대해 O(1/√N)으로 감소합니다. 가격 정밀도를 1/10로 줄이려면 경로를 100배 늘려야 합니다. 리스크 관리에서 요구하는 델타, 감마, 베가 등의 그릭(Greeks) 추정에는 수치 미분을 위해 여러 시나리오를 동시에 실행해야 하므로 실제로는 수백만 경로가 필요한 경우가 흔합니다.

ForkJoinPool 병렬화

Fork/Join 패턴

ForkJoinPool은 Java 7부터 제공되는 작업 도둑(Work-Stealing) 방식의 병렬 실행 프레임워크입니다. 큰 작업을 재귀적으로 작은 단위로 분할(Fork)하고, 각 서브태스크의 결과를 병합(Join)하는 분할 정복 패턴에 최적화돼 있습니다.

몬테카를로 경로는 서로 독립적이므로 분할-병합 구조와 완벽하게 맞습니다. 전체 경로 수를 청크로 나눠 각 청크를 독립적으로 실행하고, 결과(옵션 페이오프 평균)를 합산하면 됩니다.

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
import java.util.Random;

/**
 * 다운앤아웃 콜 배리어 옵션 몬테카를로 가격 산출
 * Down-and-Out Call: 기초자산이 배리어(B) 이하로 내려가면 소멸
 */
public class BarrierOptionMonteCarlo {

    static final int CHUNK_THRESHOLD = 10_000; // 서브태스크 최소 경로 수

    static class MonteCarloTask extends RecursiveTask<Double> {
        private final int pathStart;
        private final int pathEnd;
        private final double s0;       // 초기 주가
        private final double k;        // 행사가
        private final double barrier;  // 배리어 수준 (Knock-Out)
        private final double r;        // 무위험 이자율
        private final double sigma;    // 변동성
        private final double t;        // 만기 (연)
        private final int steps;       // 시간 스텝 수

        MonteCarloTask(int pathStart, int pathEnd,
                       double s0, double k, double barrier,
                       double r, double sigma, double t, int steps) {
            this.pathStart = pathStart;
            this.pathEnd = pathEnd;
            this.s0 = s0;
            this.k = k;
            this.barrier = barrier;
            this.r = r;
            this.sigma = sigma;
            this.t = t;
            this.steps = steps;
        }

        @Override
        protected Double compute() {
            int paths = pathEnd - pathStart;

            // 청크가 임계값 이하면 직접 계산
            if (paths <= CHUNK_THRESHOLD) {
                return computeDirectly();
            }

            // 분할: 절반씩 서브태스크로 포크
            int mid = pathStart + paths / 2;
            MonteCarloTask left = new MonteCarloTask(
                pathStart, mid, s0, k, barrier, r, sigma, t, steps);
            MonteCarloTask right = new MonteCarloTask(
                mid, pathEnd, s0, k, barrier, r, sigma, t, steps);

            left.fork();
            double rightResult = right.compute(); // 현재 스레드에서 실행
            double leftResult = left.join();       // 포크된 스레드 완료 대기

            return (leftResult * (mid - pathStart) + rightResult * (pathEnd - mid))
                   / paths;
        }

        private double computeDirectly() {
            Random rng = new Random();
            double dt = t / steps;
            double drift = (r - 0.5 * sigma * sigma) * dt;
            double diffusion = sigma * Math.sqrt(dt);
            double discount = Math.exp(-r * t);

            double totalPayoff = 0.0;
            int paths = pathEnd - pathStart;

            for (int i = 0; i < paths; i++) {
                double s = s0;
                boolean knocked_out = false;

                for (int step = 0; step < steps; step++) {
                    s *= Math.exp(drift + diffusion * rng.nextGaussian());
                    if (s <= barrier) {
                        knocked_out = true;
                        break;
                    }
                }

                if (!knocked_out) {
                    totalPayoff += Math.max(s - k, 0.0);
                }
            }

            return discount * totalPayoff / paths;
        }
    }

    public static double price(int totalPaths, double s0, double k,
                               double barrier, double r, double sigma,
                               double t, int steps) {
        ForkJoinPool pool = ForkJoinPool.commonPool();
        MonteCarloTask task = new MonteCarloTask(
            0, totalPaths, s0, k, barrier, r, sigma, t, steps);
        return pool.invoke(task);
    }

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        double result = price(
            1_000_000, // 100만 경로
            100.0,     // S0: 초기 주가 $100
            105.0,     // K: 행사가 $105
            90.0,      // B: 배리어 $90 (Knock-Out)
            0.05,      // r: 무위험 이자율 5%
            0.20,      // sigma: 변동성 20%
            1.0,       // T: 1년 만기
            252        // 252 거래일 스텝
        );
        long elapsed = System.currentTimeMillis() - start;
        System.out.printf("옵션 가격: $%.4f (소요 시간: %dms)%n", result, elapsed);
    }
}

8코어 환경에서 100만 경로, 252 스텝 기준으로 약 46초가 소요됩니다. ForkJoinPool 병렬화로 단일 스레드 대비 약 67배 속도 향상을 달성할 수 있지만, 트레이딩 데스크 요구(1초 이내)를 충족하기는 어렵습니다.

GPU 가속

SIMT 아키텍처와 몬테카를로의 궁합

GPU는 수천 개의 단순한 코어(CUDA Core)가 동일한 명령을 서로 다른 데이터에 동시에 적용하는 SIMT(Single Instruction Multiple Threads) 아키텍처를 사용합니다. 몬테카를로 경로는 각각 독립적으로 같은 계산을 수행하므로, GPU의 대규모 병렬성과 완벽하게 맞아떨어집니다.

ForkJoinPool이 CPU 코어 수(864개) 단위로 병렬화한다면, GPU는 수천수만 스레드를 동시에 실행합니다. NVIDIA A100 기준으로 6,912개의 CUDA Core를 보유하며, 100만 경로를 거의 동시에 처리합니다.

CuPy를 이용한 GPU 몬테카를로

import cupy as cp
import numpy as np
import time


def barrier_option_mc_gpu(
    total_paths: int,
    s0: float,
    k: float,
    barrier: float,
    r: float,
    sigma: float,
    t: float,
    steps: int,
    seed: int = 42,
) -> float:
    """
    CuPy GPU 가속 다운앤아웃 콜 배리어 옵션 몬테카를로
    cuRAND 기반 난수로 전체 경로를 GPU 메모리에서 벡터 연산으로 처리
    """
    cp.random.seed(seed)

    dt = t / steps
    drift = (r - 0.5 * sigma ** 2) * dt
    diffusion = sigma * cp.sqrt(cp.float32(dt))
    discount = cp.float32(cp.exp(-r * t))

    # GPU 메모리에 전체 경로의 난수 한 번에 생성
    # shape: (steps, total_paths) — 메모리 연속성을 위해 steps를 행으로
    z = cp.random.standard_normal((steps, total_paths), dtype=cp.float32)

    # 각 스텝의 로그 수익률 계산 (벡터 연산)
    log_returns = drift + diffusion * z  # (steps, total_paths)

    # 누적 합산으로 각 시점의 로그 주가 계산
    log_s = cp.log(cp.float32(s0)) + cp.cumsum(log_returns, axis=0)
    # shape: (steps, total_paths)

    prices = cp.exp(log_s)  # 주가 경로 (steps, total_paths)

    # 배리어 터치 여부: 각 경로에서 최솟값이 배리어 이하면 Knock-Out
    min_prices = cp.min(prices, axis=0)          # (total_paths,)
    knocked_out = min_prices <= cp.float32(barrier)

    # 만기 주가 (마지막 스텝)
    final_prices = prices[-1, :]                 # (total_paths,)

    # 페이오프: Knock-Out되지 않은 경로만 콜 옵션 페이오프 적용
    payoff = cp.where(
        knocked_out,
        cp.float32(0.0),
        cp.maximum(final_prices - cp.float32(k), cp.float32(0.0))
    )

    # 할인 후 평균 — GPU에서 계산 후 CPU로 단 한 번 전송
    option_price = float(discount * cp.mean(payoff))
    return option_price


if __name__ == "__main__":
    params = dict(
        total_paths=1_000_000,
        s0=100.0, k=105.0, barrier=90.0,
        r=0.05, sigma=0.20, t=1.0, steps=252,
    )

    # GPU 워밍업 (첫 실행은 커널 컴파일 포함)
    _ = barrier_option_mc_gpu(**params)

    start = time.perf_counter()
    price = barrier_option_mc_gpu(**params)
    elapsed = time.perf_counter() - start

    print(f"옵션 가격: ${price:.4f}")
    print(f"소요 시간: {elapsed * 1000:.1f}ms (100만 경로, 252 스텝)")
    # 결과 예시: 소요 시간: 78.3ms (NVIDIA RTX 3090 기준)

CPU-GPU 메모리 전송 비용

GPU 가속에서 주의해야 할 점은 CPU-GPU 간 데이터 전송 비용입니다. PCIe 대역폭은 GPU 내부 메모리 대역폭보다 20~50배 느립니다. 위 구현에서는 난수 생성부터 결과 계산까지 모두 GPU 메모리 내에서 처리하고, 최종 스칼라 값(옵션 가격)만 CPU로 전송해 이 비용을 최소화했습니다.

트레이드오프

기준단일 스레드ForkJoinPool (8코어)GPU (CuPy)
10만 경로 실행 시간3.2 s0.5 s0.02 s
100만 경로 실행 시간32 s5 s0.08 s
1,000만 경로 실행 시간~5 min~50 s0.7 s
하드웨어 비용낮음낮음높음 (GPU 서버)
구현 복잡도낮음중간높음
디버깅 편의성높음중간낮음
확장성CPU 코어 수 한계CPU 코어 수 한계GPU 메모리 한계
배치 처리 적합성낮음중간매우 높음
실시간 단발 요청 적합성낮음적합워밍업 오버헤드 주의

결론

몬테카를로 병렬화 전략은 경로 수, 지연 요구사항, 인프라 비용을 함께 고려해야 합니다.

10만 경로 이하의 작업이고 지연 SLA가 초 단위라면 ForkJoinPool로 충분합니다. 구현이 단순하고 CPU 서버에서 바로 실행할 수 있습니다. 100만 경로 이상을 1초 이내에 처리해야 하거나, 하루에 수천 번의 가격 재계산이 필요한 리스크 관리 배치 워크플로우라면 GPU 가속이 유일한 현실적 선택입니다.

GPU 도입 시 유의할 점이 있습니다. 첫째, 첫 번째 커널 실행에 JIT 컴파일 오버헤드(수백 ms)가 발생하므로, 실시간 단발 요청에 GPU를 사용할 때는 반드시 워밍업을 선행해야 합니다. 둘째, 경로 수가 충분히 많지 않으면 GPU 코어 활용률이 낮아 CPU 대비 이점이 줄어듭니다. GPU는 대규모 배치 처리에 최적화된 아키텍처입니다. 셋째, 재현성이 중요한 감사 환경에서는 GPU 난수 생성기의 시드 고정과 부동소수점 연산 순서 보장을 별도로 검토해야 합니다.

두 패러다임 모두 몬테카를로의 경로 독립성이라는 속성을 활용합니다. 그 독립성이 병렬화를 자연스럽게 만들고, 금융공학의 수치 계산 워크플로우를 현대 하드웨어와 잘 연결되게 합니다.