Skip to content
Published on

[컴파일러] 19. SSA 형식과 현대 컴파일러 최적화

Authors

개요

SSA(Static Single Assignment) 형식은 현대 컴파일러에서 가장 널리 사용되는 중간 표현(IR)입니다. GCC, LLVM, HotSpot JVM 등 거의 모든 주요 컴파일러가 SSA를 기반으로 최적화를 수행합니다.

SSA의 핵심 아이디어는 간단합니다: 각 변수가 프로그램에서 정확히 한 번만 정의된다. 이 단순한 성질이 많은 최적화를 간단하고 효율적으로 만들어 줍니다.


1. SSA 형식의 기본 개념

1.1 기존 코드의 문제

// 일반적인 세 주소 코드
x = 1
x = 2       // x가 두 번 정의됨!
y = x + 1   // 어떤 x를 사용하는가? -> 두 번째 정의

기존 표현에서는 같은 변수가 여러 번 정의될 수 있어, 정의-사용(def-use) 관계를 추적하기 위해 추가 분석이 필요합니다.

1.2 SSA 변환

각 정의에 고유한 버전 번호를 부여합니다:

// SSA 형식
x1 = 1
x2 = 2      // 다른 버전!
y1 = x2 + 1 // 명확하게 x2를 사용

이제 각 사용(use)이 정확히 하나의 정의(def)에 대응합니다. 별도의 도달 정의 분석 없이도 정의-사용 관계가 명확합니다.

1.3 직선 코드에서의 SSA

분기가 없는 코드의 SSA 변환은 간단합니다:

// 원본                    // SSA
a = x + y                 a1 = x1 + y1
b = a - 1                 b1 = a1 - 1
a = y + b                 a2 = y1 + b1
b = 4 * a                 b2 = 4 * a2

2. Phi 함수

2.1 분기에서의 문제

제어 흐름이 합류(merge)하는 지점에서는 어떤 경로에서 왔는지에 따라 변수의 값이 달라집니다.

// 원본 코드:
if (cond)
    x = 1;     // 경로 A
else
    x = 2;     // 경로 B
y = x + 1;     // x는 1인가 2인가?

2.2 Phi 함수의 도입

Phi 함수(phi-function)는 합류 지점에서 여러 경로의 값을 선택합니다:

// SSA 형식:
if (cond)
    x1 = 1;          // 경로 A
else
    x2 = 2;          // 경로 B
x3 = phi(x1, x2);   // 합류 지점: 경로에 따라 x1 또는 x2 선택
y1 = x3 + 1;

phi 함수는 실제 실행되는 명령어가 아닙니다. 의미적으로 "이 지점에 도달한 경로에 따라 값을 선택"하는 표기법입니다.

2.3 Phi 함수의 특성

// phi 함수는 합류 블록의 맨 앞에 위치
// 여러 phi 함수가 있으면 모두 "동시에" 실행된다고 간주

B3:
  x3 = phi(x1, x2)   // B1에서 오면 x1, B2에서 오면 x2
  y3 = phi(y1, y2)
  z1 = x3 + y3

2.4 루프에서의 Phi 함수

// 원본:
i = 0
loop:
  if (i >= n) goto exit
  i = i + 1
  goto loop
exit:

// SSA:
i1 = 0
loop:
  i2 = phi(i1, i3)    // 루프 진입 시 i1, 루프 반복 시 i3
  if (i2 >= n1) goto exit
  i3 = i2 + 1
  goto loop
exit:

3. SSA 구성 알고리즘

3.1 어디에 Phi 함수를 삽입할 것인가

naive하게 모든 합류 지점에 phi 함수를 넣으면 너무 많은 phi 함수가 생깁니다. **지배 경계(Dominance Frontier)**를 이용하면 필요한 곳에만 phi 함수를 삽입할 수 있습니다.

3.2 지배 경계와 Phi 함수

핵심 통찰: 변수 x가 블록 B에서 정의되면, B의 지배 경계에 있는 블록에 phi 함수가 필요합니다.

// 이유:
// B가 지배하는 영역 안에서는 x의 정의가 유일
// 지배 경계에서 다른 경로의 정의와 합류할 수 있음
// -> phi 함수가 필요한 곳 = 지배 경계

3.3 SSA 구성 알고리즘

알고리즘: SSA 구성 (2단계)

1단계: Phi 함수 삽입
  for (각 변수 v):
    W = v를 정의하는 블록들의 집합
    while W가 비어있지 않음:
      B = W에서 블록 하나를 꺼냄
      for (DF(B)의 각 블록 D):
        if D에 v에 대한 phi 함수가 없으면:
          D의 시작에 phi 함수 삽입: v = phi(v, v, ...)
          if D가 W에 없으면 W에 D 추가
          // (phi 함수도 v의 새로운 정의이므로)

2단계: 변수 이름 변경 (Renaming)
  지배자 트리를 깊이 우선으로 순회하면서:
    각 정의에 새 버전 번호 부여
    각 사용을 현재 유효한 버전으로 대체
    phi 함수의 인수를 적절한 선행 블록의 버전으로 설정

3.4 구성 예시

// CFG:
//   B1: x = 5
//        |
//   B2: if cond
//      /     \
//   B3: x=10  B4: x=20
//      \     /
//   B5: y = x

// 지배 경계:
// DF(B3) = {B5}
// DF(B4) = {B5}

// 1단계: B5에 phi 함수 삽입
//   B5: x = phi(x, x)

// 2단계: 이름 변경
//   B1: x1 = 5
//   B2: if cond
//   B3: x2 = 10
//   B4: x3 = 20
//   B5: x4 = phi(x2, x3)  // B3에서 오면 x2, B4에서 오면 x3
//       y1 = x4

4. SSA 기반 최적화

4.1 희소 조건부 상수 전파 (Sparse Conditional Constant Propagation)

SSA에서 상수 전파를 매우 효율적으로 수행할 수 있습니다.

// SSA 코드:
x1 = 3
y1 = 5
z1 = x1 + y1    // 상수 폴딩: z1 = 8
w1 = z1 * 2     // 상수 폴딩: w1 = 16
if (w1 > 10) goto L1  // 항상 true -> goto L1

// 각 변수가 한 번만 정의되므로:
// x1의 값 = 3 (확정)
// y1의 값 = 5 (확정)
// z1의 값 = 8 (확정)
// 도달 정의 분석 없이 바로 전파 가능!

SCCP (Sparse Conditional Constant Propagation) 알고리즘:

알고리즘: SCCP

격자: Top(미확인) > 상수값 > Bottom(비상수)

1. 모든 SSA 값을 Top으로 초기화
2. 진입 블록을 실행 가능으로 표시
3. 워크리스트에서 SSA 간선과 CFG 간선을 꺼내면서:
   - SSA 간선: 정의의 값이 변경되면 사용처 갱신
   - CFG 간선: 실행 가능한 간선만 고려
4. 수렴할 때까지 반복

핵심: 실행 불가능한 경로의 값은 고려하지 않음
-> phi(3, Top) = 3 (Top은 아직 도달하지 않은 경로)

4.2 죽은 코드 제거

SSA에서는 def-use 체인이 명시적이므로, 사용되지 않는 정의를 쉽게 찾을 수 있습니다.

// SSA 코드:
x1 = a1 + b1    // x1을 사용하는 곳이 없음 -> 죽은 코드
y1 = c1 * d1    // y1이 아래에서 사용됨
z1 = y1 + 1
return z1

// x1 제거 후:
y1 = c1 * d1
z1 = y1 + 1
return z1

공격적 죽은 코드 제거 (Aggressive DCE):

알고리즘: 공격적 DCE

1. 부수 효과가 있는 명령어를 "유용"으로 표시
   (return, store, 입출력, 함수 호출 등)
2. 유용한 명령어가 사용하는 SSA 값의 정의를 "유용"으로 표시
3. 반복: 새로 유용해진 명령어의 피연산자 정의를 추가 표시
4. 유용하지 않은 모든 명령어 제거

// 기존 DCE보다 강력: 불필요한 제어 흐름까지 제거 가능

4.3 전역 값 번호화 (Global Value Numbering)

같은 값을 계산하는 수식들을 찾아내어 중복을 제거합니다.

// SSA 코드:
a1 = x1 + y1
b1 = x1 + y1    // a1과 같은 값!

// 전역 값 번호화:
// x1 + y1에 값 번호 #1 부여
// a1의 값 번호 = #1
// b1의 값 번호 = #1 (같은 연산, 같은 피연산자)
// -> b1 = a1으로 대체

해시 기반 값 번호화:

알고리즘: SSA 기반 GVN

해시 테이블 유지: (연산자, 피연산자의 값 번호) -> 값 번호

for (지배자 트리의 깊이 우선 순서로 각 명령어):
    피연산자의 값 번호를 조회
    (연산자, 피연산자 값 번호)로 해시 테이블 검색
    if 발견:
        기존 값 번호를 이 명령어에 배정 (중복!)
    else:
        새 값 번호 부여하고 해시 테이블에 추가

예시:

// SSA:
//   B1: a1 = x1 + y1          // 값 번호: v1
//       |
//   B2: b1 = x1 + y1          // 해시 조회: (+ v(x1) v(y1)) -> v1 발견!
//       c1 = b1 * 2            // b1을 a1으로 대체 가능
//
// 최적화 후:
//   B1: a1 = x1 + y1
//   B2: c1 = a1 * 2            // b1 제거

4.4 SSA와 다른 최적화의 관계

// SSA가 도움이 되는 최적화들:
// 1. 상수 전파    -> def-use 체인으로 효율적 전파
// 2. 죽은 코드 제거 -> 사용처 없는 def를 즉시 식별
// 3. 값 번호화    -> 해시 기반으로 중복 계산 식별
// 4. 복사 전파    -> phi 함수를 통해 복사 관계 추적
// 5. 부분 중복 제거 -> SSA 간선을 통한 효율적 분석
// 6. 레지스터 할당 -> SSA의 활성 범위가 더 짧음

5. SSA에서 일반 코드로 복원

5.1 Phi 함수 제거

실제 기계에는 phi 함수가 없으므로, SSA에서 벗어날 때 phi 함수를 복사 명령어로 변환합니다.

// SSA:
//   B1: x1 = 5
//        |
//   B2: x2 = 10
//        |
//   B3: x3 = phi(x1, x2)

// phi 제거 후:
//   B1: x1 = 5
//       x3 = x1      // B3에서 B1 끝으로 이동
//        |
//   B2: x2 = 10
//       x3 = x2      // B3에서 B2 끝으로 이동
//        |
//   B3: (phi 제거됨, x3 사용)

5.2 병렬 복사 문제

여러 phi 함수가 동시에 실행되므로, 단순 변환 시 간섭이 발생할 수 있습니다.

// SSA:
//   B3: a2 = phi(a1, b1)
//       b2 = phi(b1, a1)
//   (swap 의미: B2에서 오면 a와 b를 교환)

// 잘못된 변환:
//   B2 끝: a2 = b1    // a2에 b1의 값
//          b2 = a1    // 올바름

// 올바른 변환 (임시 변수 사용):
//   B2 끝: temp = a1
//          a2 = b1
//          b2 = temp

5.3 복사 병합 (Copy Coalescing)

phi 제거로 생긴 복사 명령어를 최소화합니다.

// phi 제거 후:
//   B1: x1 = 5
//       x3 = x1     // 불필요한 복사

// 복사 병합 후:
//   B1: x3 = 5      // x1과 x3을 같은 변수로 합침

레지스터 할당 시 **병합(coalescing)**을 통해 이러한 복사를 제거합니다. x1과 x3이 동시에 활성이 아니면(간섭하지 않으면) 같은 레지스터에 배정할 수 있습니다.


6. 변형된 SSA 형식

6.1 최소 SSA (Minimal SSA)

지배 경계 기반으로 필요한 최소한의 phi 함수만 삽입한 형태입니다. 위에서 설명한 표준 구성 알고리즘이 최소 SSA를 생성합니다.

6.2 Pruned SSA

최소 SSA에서도 불필요한 phi 함수가 있을 수 있습니다. 활성 변수 정보를 이용하여 사용되지 않는 phi 함수를 미리 제거합니다.

// 최소 SSA:
x2 = phi(x1, x1)   // 두 인수가 같음 -> 불필요
y2 = phi(y1, ...)   // y2가 이후에 사용되지 않음 -> 불필요

// Pruned SSA: 위 phi 함수들을 삽입하지 않음

6.3 Gated SSA

phi 함수에 조건 정보를 추가한 확장입니다.

// 일반 SSA:
x3 = phi(x1, x2)

// Gated SSA:
x3 = gamma(cond, x1, x2)  // cond가 true이면 x1, false이면 x2
// 최적화에 더 유용한 정보 제공

정리

개념설명
SSA 형식각 변수가 정확히 한 번만 정의되는 중간 표현
Phi 함수합류 지점에서 여러 경로의 값을 선택
지배 경계phi 함수 삽입 위치를 결정하는 핵심 정보
SCCP실행 불가능한 경로를 고려한 희소 상수 전파
공격적 DCE유용한 코드에서 역추적하여 죽은 코드 제거
전역 값 번호화해시 기반으로 중복 계산을 식별
Phi 제거phi 함수를 복사 명령어로 변환
복사 병합불필요한 복사를 레지스터 할당 시 제거

SSA 형식은 현대 컴파일러 최적화의 표준 기반입니다. 각 변수가 한 번만 정의된다는 단순한 성질이 def-use 관계를 명시적으로 만들어, 상수 전파, 죽은 코드 제거, 값 번호화 등 다양한 최적화를 간단하고 효율적으로 구현할 수 있게 합니다. LLVM IR이 SSA 기반인 것은 우연이 아닙니다. 다음 글에서는 현대 컴파일러의 전체적인 발전 방향과 응용 분야를 살펴보겠습니다.