LabHub
はじめる
배우기 러닝패스 코스

コンパイラ — 小さな言語を最初から最後まで作る

-O2 が何をしたかを読む

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

여기까지 만든 단계 — 파싱, 의미 분석, 중간 표현, 최적화, 레지스터 할당, 코드 생성 — 는 gcc·clang 안에도 그대로 있습니다. 그 출력(-S 의 어셈블리, -emit-llvm 의 LLVM IR, objdump -d 의 기계어)을 함수 단위로 잘라 세어 보면, 최적화가 무엇을 했는지 — 호출을 녹였는지, 반복문을 상수 하나로 접었는지, 나눗셈을 곱셈으로 바꿨는지, 재귀를 반복문으로 바꿨는지 — 가 숫자로 드러납니다.

왜 이게 필요했나

"-O2 로 빌드하면 빨라진다" 는 알아도, 무엇이 빨라졌는지 모르면 성능 문제 앞에서 추측만 하게 됩니다. 반대로 "-O2 에서만 결과가 이상하다" 는 버그를 만났을 때, 컴파일러를 의심하기 전에 확인할 수 있는 것이 있습니다 — 그 코드가 정의되지 않은 동작에 기대고 있지 않은가. 두 경우 모두 해답은 컴파일러가 낸 것을 직접 읽는 것입니다. 이 모듈은 그 읽는 눈을, 앞의 아홉 모듈에서 직접 만들어 본 것에 이어 붙입니다.

어떻게 동작하나

재료 /opt/fixtures/mini/real/opt.c 의 함수는 저마다 한 가지 변환을 노립니다.

함수 -O2 가 하는 일 어셈블리에서 보이는 것
sum_to 1부터 100까지 더하는 반복문을 통째로 계산 movl $5050, %eax · ret 두 줄
sum_squares square 호출을 녹여 넣음(인라인) call square 가 사라짐
div10 나눗셈(idiv, 수십 사이클)을 곱셈과 시프트로 movabsq $7378697629483820647 · imulq · sarq
fact 꼬리 재귀를 반복문으로 call fact 가 사라지고 뒤로 가는 점프가 생김
always x + 1 > x 를 늘 참으로(부호 있는 넘침은 없다고 가정) movl $1, %eax · ret
fib 재귀를 부분적으로 펼치고 레지스터를 모두 씀 명령 수가 -O0 의 열 배를 넘기도 한다

나눗셈의 마법의 수. x / 10x × 0x6666666666666667 ÷ 2^66 과 같습니다(음수 보정 한 번 더). 0x6666…67 은 2^66 / 10 을 올림한 수입니다. 곱셈은 몇 사이클이고 나눗셈은 수십 사이클이라, 나누는 수가 상수면 컴파일러는 거의 늘 이렇게 바꿉니다. 나누는 수를 바꿔 다시 컴파일하면 마법의 수도 바뀝니다 — 그래서 이 실습의 채점기는 변형을 만들어 봅니다.

정의되지 않은 동작과 최적화. always(int x) { return x + 1 > x; } 는 x 가 INT_MAX 일 때 넘치는데, C 에서 부호 있는 정수의 넘침은 정의되지 않은 동작입니다. 컴파일러는 "그런 일은 일어나지 않는다" 고 가정해도 되므로 식 전체를 1 로 접습니다. 부호 없는 uwrap 은 넘침이 감김으로 정의되어 있어 그렇게 할 수 없고, 실제 비교가 남습니다. 미니가 넘침을 '감긴다' 로 정의해 둔 이유가 이것입니다 — 정의해 두면 인터프리터·VM·네이티브 코드가 같은 답을 내야 하고, 최적화기는 그 답을 바꿀 수 없습니다.

LLVM IR 과 SSA. clang -S -emit-llvm 은 LLVM 의 중간 표현을 글자로 보여 줍니다. -O0 에서는 모든 지역 변수가 alloca(스택 칸) 하나씩이고, 쓸 때마다 load, 바꿀 때마다 store 입니다 — 8모듈의 코드 생성기와 같은 방식입니다. -O1 에서는 그 칸들이 사라지고(mem2reg·SROA), 두 길이 합쳐지는 곳에 phi 가 생깁니다 — 7모듈에서 지배 경계로 자리를 계산한 바로 그 φ 입니다.

-O0: fib              %2 = alloca i64          -O1: fib           %6 = phi i64 [ … ], [ … ]
                      store i64 %0, ptr %3                         (alloca·load·store 없음)
                      %4 = load i64, ptr %3

JIT 도 같은 일을 한다. JVM 의 JIT(HotSpot C2)와 브라우저의 V8 은 실행 중에 이 단계들을 돌립니다. 차이는 입력에 실행 중에 본 사실(이 호출 자리에는 늘 이 타입이 온다)이 더해진다는 것뿐이라, 이 모듈에서 읽는 변환 — 인라인, 상수 접기, 반복문 변환 — 이 JIT 로그에서도 그대로 보입니다.

현장에서 만나는 모습

다음 실습에서 할 것

readasm.py 에 gcc 어셈블리를 함수별 명령 목록으로 자르는 파서, 명령 수와 호출 목록, 상수 하나로 접힌 함수 찾기, 나눗셈의 마법의 수 뽑기, 뒤로 가는 점프(반복문의 흔적) 세기, LLVM IR 의 함수별 명령과 명령 종류 세기, -O0 과 -O1 의 메모리 명령·φ 비교를 만듭니다. 끝으로 opt.c 와 여러분이 8모듈에서 만든 코드 생성기의 fib 를 나란히 세어 보고서로 남깁니다.