Skip to content
Published on

[컴파일러] 17. 병렬성 검출과 루프 변환

Authors

개요

멀티코어 프로세서와 GPU가 보편화됨에 따라, 프로그램에서 병렬성을 찾아내고 활용하는 것이 매우 중요해졌습니다. 컴파일러는 프로그램을 분석하여 안전하게 병렬 실행할 수 있는 부분을 자동으로 검출하고, 루프 변환을 통해 병렬성과 지역성을 모두 향상시킬 수 있습니다.


1. 병렬성의 유형

1.1 데이터 병렬성 (Data Parallelism)

같은 연산을 서로 다른 데이터에 대해 동시에 수행합니다.

// 데이터 병렬: 각 반복이 독립적
for (i = 0; i < n; i++) {
    c[i] = a[i] + b[i];
}

// 4개 프로세서로 분할 실행:
// P0: i = 0..n/4-1
// P1: i = n/4..n/2-1
// P2: i = n/2..3n/4-1
// P3: i = 3n/4..n-1

1.2 태스크 병렬성 (Task Parallelism)

서로 다른 작업을 동시에 수행합니다.

// 태스크 병렬: 세 함수가 독립적
result1 = processA(data)    // 태스크 1
result2 = processB(data)    // 태스크 2
result3 = processC(data)    // 태스크 3
// 세 태스크를 동시에 실행 가능

1.3 파이프라인 병렬성

데이터를 단계별로 처리하며, 각 단계를 서로 다른 프로세서가 담당합니다.

// 3단계 파이프라인:
// Stage 1: read    -> P0
// Stage 2: process -> P1
// Stage 3: write   -> P2

// 시간:   1      2      3      4      5
// P0:   read1  read2  read3  read4  read5
// P1:          proc1  proc2  proc3  proc4
// P2:                 wrt1   wrt2   wrt3

2. 의존성 분석 (Dependence Analysis)

루프를 병렬화하려면 반복 간의 의존성을 분석해야 합니다.

2.1 루프 의존성의 종류

// 흐름 의존성 (진정한 의존성)
for (i = 1; i < n; i++) {
    a[i] = a[i-1] + 1;  // 반복 i가 반복 i-1의 결과에 의존
}
// 의존 거리(distance) = 1, 병렬화 불가

// 반의존성
for (i = 0; i < n-1; i++) {
    a[i] = a[i+1] + 1;  // 읽기 후 쓰기
}
// 순서를 역전하면 해결 가능

// 출력 의존성
for (i = 0; i < n; i++) {
    a[i % 2] = b[i];    // 같은 위치에 반복적으로 쓰기
}

2.2 의존 벡터와 의존 거리

다중 중첩 루프에서 의존성을 벡터로 표현합니다.

// 2중 루프에서의 의존 벡터
for (i = 1; i < n; i++) {
    for (j = 1; j < m; j++) {
        a[i][j] = a[i-1][j-1] + 1;
    }
}

// a[i][j]는 a[i-1][j-1]에 의존
// 의존 벡터: (1, 1)  (i방향 거리 1, j방향 거리 1)

2.3 배열 의존성 테스트

두 배열 접근이 같은 위치를 참조하는지 판별하는 테스트입니다.

GCD 테스트 (필요 조건):

// a[c1*i + c2]와 a[c3*j + c4]가 같은 위치를 참조하려면
// c1*i + c2 = c3*j + c4를 만족하는 정수 i, j가 존재해야 함
// -> gcd(c1, c3)이 (c4 - c2)를 나누어야 함

// 예시:
// a[2*i + 1]과 a[2*j]
// gcd(2, 2) = 2, c4 - c2 = 0 - 1 = -1
// 2는 -1을 나누지 못함 -> 의존성 없음! (병렬화 가능)

Banerjee 테스트 (더 정밀한 검사):

// 루프 범위를 고려하여 의존성을 검사
// a[i + 1]과 a[j] (0 <= i,j < n)
// i + 1 = j를 만족하는 (i,j) 쌍이 범위 내에 존재하는가?
// 0 <= i < n이고 0 <= i+1 < n이면 존재
// -> 의존성 있음

2.4 의존성 분석의 한계

// 정확한 의존성 분석이 어려운 경우:

// 1. 간접 배열 접근
a[b[i]] = ...  // b[i]의 값을 알 수 없음

// 2. 포인터를 통한 접근
*p = ...       // p가 가리키는 위치를 알 수 없음

// 3. 함수 호출
foo(a, i)      // foo가 a를 수정하는지 알 수 없음

// 이런 경우 보수적으로 의존성이 있다고 가정 (안전하지만 병렬화 불가)

3. 루프 변환

3.1 루프 교환 (Loop Interchange)

중첩 루프의 순서를 바꾸는 변환입니다. 병렬성 노출이나 메모리 지역성 향상에 사용됩니다.

// 원본: 행 우선 접근 (C에서는 이미 최적)
for (i = 0; i < n; i++)
    for (j = 0; j < m; j++)
        a[i][j] = 0;

// 교환 후: 열 우선 접근
for (j = 0; j < m; j++)
    for (i = 0; i < n; i++)
        a[i][j] = 0;

교환이 합법적인 조건: 의존 벡터의 사전식 순서가 유지되어야 합니다.

// 의존 벡터 (1, -1)인 경우:
// 교환 후 (-1, 1) -> 첫 성분이 음수 -> 불법!
//
// 의존 벡터 (1, 0)인 경우:
// 교환 후 (0, 1) -> 사전식 양수 -> 합법!

3.2 루프 타일링 / 블로킹 (Loop Tiling/Blocking)

루프를 작은 타일(블록)로 나누어 캐시 활용도를 높입니다.

// 원본: 행렬 곱셈
for (i = 0; i < n; i++)
    for (j = 0; j < n; j++)
        for (k = 0; k < n; k++)
            c[i][j] += a[i][k] * b[k][j];

// 타일링 후 (타일 크기 T):
for (ii = 0; ii < n; ii += T)
    for (jj = 0; jj < n; jj += T)
        for (kk = 0; kk < n; kk += T)
            for (i = ii; i < min(ii+T, n); i++)
                for (j = jj; j < min(jj+T, n); j++)
                    for (k = kk; k < min(kk+T, n); k++)
                        c[i][j] += a[i][k] * b[k][j];

타일 크기 T를 캐시 크기에 맞추면:

  • a, b, c의 T x T 부분 행렬이 캐시에 들어감
  • 캐시 미스가 O(n^3)에서 O(n^3/T)로 감소

3.3 루프 융합 (Loop Fusion)

같은 범위를 순회하는 여러 루프를 하나로 합칩니다.

// 원본: 두 개의 별도 루프
for (i = 0; i < n; i++)
    a[i] = b[i] + 1;

for (i = 0; i < n; i++)
    c[i] = a[i] * 2;

// 융합 후: 하나의 루프
for (i = 0; i < n; i++) {
    a[i] = b[i] + 1;
    c[i] = a[i] * 2;
}

융합의 장점:

  • 루프 오버헤드 감소
  • a[i]를 레지스터에 유지 가능 (메모리 접근 감소)
  • 데이터 지역성 향상

융합 조건: 두 루프 사이에 의존성 방향이 역전되지 않아야 합니다.

3.4 루프 분열 (Loop Fission/Distribution)

하나의 루프를 여러 루프로 나눕니다.

// 원본
for (i = 0; i < n; i++) {
    a[i] = b[i] + 1;        // 독립적 부분 1
    c[i] = c[i-1] + a[i];   // 의존적 부분 2
}

// 분열 후
for (i = 0; i < n; i++)
    a[i] = b[i] + 1;        // 병렬화 가능!

for (i = 0; i < n; i++)
    c[i] = c[i-1] + a[i];   // 순차 실행 필요

분열의 장점:

  • 병렬화 가능한 부분만 분리하여 병렬 실행
  • 각 루프의 작업 집합(working set)이 작아져 캐시 효율 향상

4. 아핀 변환 이론 기초

4.1 아핀 변환이란

루프의 반복 공간을 선형 변환하여 병렬성과 지역성을 동시에 최적화하는 통합 프레임워크입니다.

// 원본 반복 공간: (i, j)
for (i = 0; i < n; i++)
    for (j = 0; j < m; j++)
        S: a[i][j] = ...

// 아핀 변환: 새로운 반복 변수 (p, q) = T * (i, j)
// 예: (p, q) = (i+j, j) -> 기울어진 반복 공간

4.2 기울이기 변환 (Skewing)

의존성 방향을 변경하여 병렬성을 노출합니다.

// 원본: 의존 벡터 (0,1)과 (1,0) -> 두 차원 모두 순차적
for (i = 0; i < n; i++)
    for (j = 0; j < n; j++)
        a[i][j] = (a[i-1][j] + a[i][j-1]) / 2;

// 기울이기: p = i + j, q = j
// 변환 후 같은 p 값을 가진 반복들은 병렬 실행 가능

// 변환 후 의존 벡터: (1,1), (1,0) -> p(외부 루프)는 순차, q(내부 루프)는 병렬 가능

4.3 다면체 모델 (Polyhedral Model)

아핀 변환을 일반화한 프레임워크로, 현대 자동 병렬화 컴파일러(Polly, Pluto)에서 사용됩니다.

각 문장의 반복 도메인을 다면체로, 의존성을 아핀 관계로, 스케줄을 아핀 함수로 표현합니다.

// 다면체 표현:
// 문장 S의 반복 도메인: 0 <= i < n, 0 <= j < m
// 이는 2차원 다면체(직사각형)

// 스케줄 함수: theta(i,j) = (i, j)  (원본 순서)
//            또는 theta(i,j) = (i+j, j) (기울이기)
//            또는 theta(i,j) = (j, i)   (교환)

5. 지역성 최적화 (Locality Optimization)

5.1 시간적 지역성 (Temporal Locality)

최근에 접근한 데이터를 곧 다시 접근하는 성질입니다.

// 시간적 지역성이 좋은 코드
for (i = 0; i < n; i++) {
    sum += a[i];  // sum이 매 반복 재사용됨
}

// 시간적 지역성이 나쁜 코드 (타일링으로 개선 가능)
for (i = 0; i < n; i++)
    for (j = 0; j < n; j++)
        b[j] += a[i][j];  // b[j]가 외부 루프에서만 재사용

5.2 공간적 지역성 (Spatial Locality)

인접한 메모리 위치를 연속으로 접근하는 성질입니다.

// 공간적 지역성이 좋은 코드 (행 우선 접근, C 배열 레이아웃과 일치)
for (i = 0; i < n; i++)
    for (j = 0; j < n; j++)
        a[i][j] = 0;      // a[i][0], a[i][1], ... 순서로 접근

// 공간적 지역성이 나쁜 코드 (열 우선 접근)
for (j = 0; j < n; j++)
    for (i = 0; i < n; i++)
        a[i][j] = 0;      // a[0][j], a[1][j], ... 순서로 접근
                           // 매 접근마다 캐시 라인이 다름

5.3 지역성과 루프 변환의 관계

변환시간적 지역성공간적 지역성병렬성
루프 교환간접적직접적가능
루프 타일링직접적간접적간접적
루프 융합직접적간접적가능
루프 분열간접적직접적직접적

5.4 행렬 곱셈의 최적화 효과

// 최적화 없음: O(n^3) 캐시 미스
// 루프 교환: 공간적 지역성 개선
// 타일링 (B x B): O(n^3 / B) 캐시 미스
// 타일링 + 교환: 최적에 가까운 성능

// 실제 성능 차이 (n = 1000, L1 캐시 32KB 기준):
// 최적화 없음:   ~10초
// 루프 교환:     ~5초
// 32x32 타일링:  ~1초
// 성능 차이가 10배에 달할 수 있음!

6. 자동 병렬화의 실제

6.1 DOALL 루프

모든 반복이 독립적인 루프를 DOALL 루프라 합니다.

// DOALL: 완전 병렬화 가능
for (i = 0; i < n; i++)
    a[i] = b[i] + c[i];

// OpenMP로 자동 변환:
// #pragma omp parallel for
// for (i = 0; i < n; i++)
//     a[i] = b[i] + c[i];

6.2 DOACROSS 루프

반복 간에 의존성이 있지만 파이프라인 방식으로 병렬화할 수 있는 루프입니다.

// DOACROSS: 의존 거리 만큼의 병렬성 존재
for (i = 1; i < n; i++)
    a[i] = a[i-1] + b[i];  // 의존 거리 1

// 파이프라인 병렬화:
// P0: a[0], a[4], a[8], ...
// P1: (대기) a[1], a[5], a[9], ...
// P2: (대기) (대기) a[2], a[6], ...
// P3: (대기) (대기) (대기) a[3], a[7], ...

정리

개념설명
데이터 병렬성같은 연산을 서로 다른 데이터에 동시 수행
의존성 분석루프 반복 간의 데이터 의존 관계 파악
GCD 테스트배열 의존성의 필요 조건 검사
루프 교환중첩 루프의 순서 변경
루프 타일링루프를 작은 블록으로 분할하여 캐시 활용 극대화
루프 융합여러 루프를 하나로 합침
루프 분열하나의 루프를 여러 개로 분리
아핀 변환반복 공간의 선형 변환으로 병렬성과 지역성 최적화
지역성 최적화캐시 활용을 극대화하는 메모리 접근 패턴 최적화

병렬성 검출과 루프 변환은 멀티코어 시대의 핵심 컴파일러 기술입니다. 정확한 의존성 분석을 바탕으로 다양한 루프 변환을 적용하면, 프로그래머가 명시적으로 병렬화하지 않아도 컴파일러가 자동으로 병렬 코드를 생성할 수 있습니다. 다음 글에서는 프로시저 간 분석과 포인터 분석을 다루겠습니다.