컴파일러가 소스 코드에서 곧바로 기계어로 가지 않고 중간 표현(IR/IL)을 거치는 이유와, 백엔드·프런트엔드·최적화·가상 머신·에뮬레이터까지 이어지는 다양한 사례를 살펴본다.
URL: https://mortoray.com/why-we-use-intermediate-representations/
코드를 컴파일할 때, 소스 코드에서 곧바로 최종 기계어로 바로 변환되지는 않는다. 컴파일러는 의미(semantic) 타이핑과 분석을 수행한 뒤, 중간 표현(intermediate representation, IR) — 중간 언어(intermediate language, IL)라고도 불리는 — 를 출력한다. 이 ‘중간 단계’의 코드 형태는 유연성을 제공한다. 컴파일러가 여러 소스 언어를 지원할 수 있게 해주고, 또한 여러 대상 머신(타깃 아키텍처)을 지원하는 플랫폼을 제공한다.
우선 시스템 컴파일러에서 사용되는 IR을 중심으로 이야기하겠지만, 이 개념은 그보다 훨씬 넓다. 예를 들어 마이크로소프트의 CIL(Common Intermediate Language)을 알고 있을 수도 있는데, 이는 C#에서 타깃으로 컴파일되는 중간 언어다. 혹은 옛 비디오 게임을 실행하는 게임 콘솔 에뮬레이터를 사용해 본 적이 있을지도 모른다. 나중에 보겠지만, 그 경계는 점점 흐려진다.
중간 “언어(language)”와 중간 “표현(representation)” 사이의 명확한 차이를 규정한 자료는 찾지 못했다. 특정 플랫폼의 툴체인(toolchain)을 만든 사람의 취향에 달린 듯하다. 대부분의 참고 자료는 이 용어들을 서로 바꿔 쓰거나, 다른 방식으로 설명한다. 나중에 보겠지만 고수준 코드, 기계어, IR 사이의 경계도 흐릿하다. 분명 서로 다른 부류나 목적이 존재하긴 하지만, “언어”와 “표현”이라는 단어 자체가 그 차이를 구분해주지는 않는다.
기계어로 컴파일할 때 IR은 여러 백엔드를 지원하기 쉽게 하고, 서로 다른 언어 프런트엔드를 허용하며, 범용 최적화를 지원한다.
백엔드 사례가 아마 가장 이해하기 쉬울 것이다. x86-64 머신용으로 어떤 Fortran 코드를 컴파일한다고 해보자. 대략적인 단계는 다음과 같다.
이제 ARMv8용으로 컴파일하고 싶다고 하자. 그러면 일부 작업은 동일하다는 것을 알게 되므로 마지막 단계만 바꾼다: AST를 순회하며 ARMv8 명령을 출력한다.
여기서 두 가지를 깨닫게 된다.
어려운 이유를 보면, 대부분의 CPU 명령 집합은 매우 저수준이며 AST가 허용하는 표현력 수준과는 거리가 멀다. 언어가 70년대 C 정도로 단순하다면 그나마 괜찮을 수 있다. 하지만 언어는 복잡한 기능을 추가해 왔고, AST와 기계어 사이의 격차는 크게 커졌다. 따라서 단순화 단계가 필요하다. 즉 AST를 받아 새로운 형태로 만들고, 그 새로운 형태에서 기계어를 출력하는 과정이 필요하다.
최적화 측면에서는 AST 최적화가 타깃 아키텍처에 너무 강하게 묶여 있다는 점을 보게 된다. 그러면 플랫폼이 바뀌면 비슷한 최적화라도 재사용하기 어렵다. 또한 AST는 여전히 고수준이라 일부 최적화는 수행하기가 어렵다.
이 두 가지를 합치면, 컴파일 단계를 다음처럼 바꾸게 된다.
여기서 마지막 처리 단계는 이전의 “AST에서 곧바로 명령을 출력”하는 단계보다 훨씬 단순해진다. 따라서 x86-64용 또는 ARMv8용 emitter(코드 생성기)를 끼워 넣기가 훨씬 쉬워진다.
이 논의에서는 라이브러리와 함께 모든 기계어 조각을 모아 실행 파일을 만드는 어셈블(assembly) 단계를 무시하겠다. 어떤 툴체인은 이를 어셈블 단계와 링크(linking) 단계로 나눌 수도 있다. emitter를 구현한다면 중요하겠지만, 여기서의 논지를 바꾸지는 않는다.
IR과 AST에는 여전히 머신에 특화된 요소가 들어 있을 수 있다. 많은 시스템 언어가 특정 머신에 묶인 구문을 허용하기 때문이다. AST를 파싱하고 처리하는 동안 컴파일러는 이미 머신 특화 정보를 사용할 수 있다. 이는 사실상 새로운 상수를 정의하거나 컴파일러 플래그를 추가하는 것과 비슷하다. 컴파일러의 전체 아키텍처에 큰 영향을 주지는 않는다. 하지만 생성된 IR은 플랫폼 독립적이지 않다.
또 운영체제도 고려해야 한다. 커널과 시스템 라이브러리는 그들과 상호작용하는 특정한 방식이 있다. 호출 규약(calling convention), 예외 설정, 메모리 관리 등 많은 요소가 포함된다. 이 모든 것이 그 플랫폼의 애플리케이션 바이너리 인터페이스(ABI)를 이룬다.
Linux에서 동작하는 x86-64용으로 출력된 코드는, 같은 머신 위의 Windows용으로 출력된 코드와 다르게 보인다. 이것 또한 IR을 원하는 이유다. 최종 emitter가 이러한 세부 사항을 처리해주기 때문이다. 또한 이런 emitter들은 대개 모듈식이다. 운영체제와 머신을 플러그인처럼 조합해 끼워 맞출 수 있다(적어도 목표는 그렇다).
컴파일 단계를 보면 흥미로운 사실을 깨닫게 된다. 새로운 언어, 예를 들어 C++을 지원하고 싶다면, 우리가 해야 할 일은 IR을 만들어내는 것이다. 즉 코드 → AST → IR까지의 초기 단계만 새로 작성하면 된다.
하지만 문제에 부딪힐 것이다. IR이 C++의 예외 메커니즘 같은 언어 기능을 표현하지 못한다는 사실을 알게 된다. 혹은 가상 테이블(vtable) 디스패치 같은 기능을 효율적으로 표현할 수 없어, 그 결과로 비최적의 기계어를 출력하게 된다.
아키텍처 관점에서 이는 큰 장애는 아니다. 새로 추가된 언어를 수용하도록 IR을 조정하면 된다. IR은 지원하는 모든 언어가 필요로 하는 기능의 ‘상위 집합(superset)’이 된다. 언어의 주요 패러다임이 몇 가지로 제한되어 있고 요구 사항 대부분을 공유하므로, IR 하나로도 지나치게 많은 특수 기능 없이 여러 언어를 지원할 수 있다.
이런 범용 IR이 기존 IR에서 구현하기 어려운 기능을 언어 설계에서 피하게 만드는 식으로 영향을 주지 않을까 하는 걱정이 조금 있다.
또한 새 구문을 지원하도록 모든 기계어 emitter도 조정해야 한다. 다만 반드시 범용일 필요는 없다. 모든 명령 집합이 IR의 모든 기능을 지원할 필요는 없다. 기계어를 출력하는 컴파일러에서는 이런 것이 괜찮다. 개발자는 제한을 알게 될 것이고, 컴파일러 옵션으로 그런 코드를 금지하도록 설정해 쉽게 발견할 수 있다. 이는 특정 데이터 타입, 트래핑(trapping) 메커니즘, 혹은 레지스터/메모리의 단순한 제약일 수도 있다.
언어를 IR로 낮추는 일은 쉽지 않다. 정말 어렵다. Leaf를 만들 때 직접 변환이 문제를 일으켜서 IR 레이어를 하나 더 만들었다. 먼저 Leaf IR을 출력한 다음 이를 LLVM IR로 변환했다.
IR은 범용 최적화를 적용하기 좋은 지점이다. 언어마다 새로운 최적화기를 작성할 필요가 없다. 또한 프런트엔드 파서를 만드는 사람이 아닌 개발자들도 최적화기를 작성할 수 있다. 단계가 분리된 전체 아키텍처 덕분에 더 많은 개발자가 서로 다른 단계에 참여할 수 있다.
이렇게 일반화된 최적화에는 한 가지 문제가 있다. 언어 특화 최적화를 놓칠 수 있다는 점이다. IR은 지원하는 모든 언어가 요구하는 기능의 진정한 상위 집합이 될 수 없다. 어떤 언어는 IR이 잘 표현하지 못하는 기능을 갖고 있지만, 기계어로는 효율적으로 출력될 수 있다. 이는 ‘그 한계를 받아들일 것인가’와 ‘IR에 새 기능을 추가할 것인가’ 사이의 지속적인 압박을 만든다. 성숙한 컴파일러에서는 IR을 바꾸는 비용이 클 수 있는데, 많은 최적화기와 모든 emitter를 수정해야 할 수도 있기 때문이다.
다만 방대한 최적화의 수가 이를 어느 정도 상쇄할 수 있다. 어떤 지점에서는 최적화를 잃지만 다른 지점에서는 많은 최적화를 얻는다. 무엇이 최적인지 자체도 어느 정도 주관적이다.
Java나 C# 같은 언어에서 사용하는 가상 머신(VM)도 중간 언어를 사용한다. 컴파일러의 동작은 앞서 설명한 것과 대체로 동일하지만, 기계어를 출력하는 대신 중간 표현을 출력한다. 가상 머신은 그 형태를 사용해 실행한다. Java나 C# 프로그램을 다운로드한다는 것은 곧 IR을 다운로드하는 것이다.
가상 머신용 IR과 기계어 출력 과정의 한 단계로 쓰이는 IR 사이에는 중요한 차이가 있다. VM-IR은 플랫폼 독립적이어야 한다. Java VM이나 C#의 CLR의 목적은 하나의 프로그램을 여러 플랫폼에서 실행하는 것이므로, IR은 실행되는 모든 플랫폼과 호환되어야 한다.
이는 특정 OS+머신 조합 하나를 선택하고, 모든 IR이 그 플랫폼과 호환되도록 보장하는 것과 비슷하다. 그 플랫폼을 가상 머신이 제공한다. 이것이 추상 머신(abstract machine)이 된다.
이 타깃 머신이 더 ‘구체적’이기 때문에, IR에서 더 고수준 기능을 종종 발견하게 된다. 예를 들어 Java에서는 완전한 객체와 배열을 할당하는 방법이 IR에 존재할 것이다. 반면 시스템 언어의 IR은 힙(heap)을 전혀 모를 수도 있으며, 그럴 경우 직접 C 라이브러리 함수를 호출해서 해결해야 한다.
이것이 C 라이브러리가 대부분의 운영체제에서 핵심인 이유다. 프로그램이 C로 작성되기 때문이 아니라, 컴파일러가 IR에서 제공하지 않는 기능을 메우기 위해 C 라이브러리가 제공하는 표준 기능 세트를 사용하기 때문이다.
최적화된 형태라 해도 IR을 직접 실행하는 것은 기계어를 실행하는 것만큼 빠르지 않다. 그래서 VM은 IR의 일부를 기계어로 변환한다. VM이 기계어 컴파일의 마지막 단계를 수행하는 셈이다. 이 일이 런타임에 일어나므로, VM은 운영체제와 호스트를 알고 올바른 기계어를 출력할 수 있다.
이를 JIT(Just-in-time) 컴파일이라고 부른다. 부분적으로는 맞는 말이다. ‘just-in-time’은 최종 사용자의 머신에서만 기계어를 출력한다는 점을 의미한다. 또한 모든 코드를 기계어로 바꿀 필요도 없다. 덜 사용되는 코드는 VM 위에서 계속 실행될 수 있다. 즉 ‘제때’이면서 ‘필요할 때만’이다.
다만 ‘컴파일’이라는 측면은, 말하자면, 미묘하다. IR을 얻기까지 대부분의 컴파일 작업은 이미 끝났다. 기계어로 내보내는 일은 전체 구조에서 더 작은 부분이고, VM에서는 기계어 출력뿐 아니라 어셈블과 링크 작업까지 수행한다 — 필요한 라이브러리에 직접 연결하는 것이다. 어쩌면 ‘just-in-time assembling’이 더 정확할지도 모른다.
기술적으로는 어떤 언어든 중간 표현으로 사용할 수 있다. 예를 들어 컴파일러가 C 코드를 출력하고, 나머지는 C 컴파일러가 처리하도록 하는 방식은 드물지 않았다. C 컴파일러는 (지금도) 운영체제와 하드웨어 전반에 걸친 ‘보편 상수’ 같은 존재였다. 어떤 플랫폼용 C 컴파일러가 이미 있다면, 우리 자신이 기계어 emitter를 작성하는 대신 그것을 활용할 수 있다.
툴체인이 성숙하면서 이런 방식은 시간이 지날수록 덜 흔해졌고 덜 필요해졌다. 하지만 개발자들은 여전히 부트스트래핑(초기 컴파일러를 컴파일하는 것) 용도로 C 컴파일러를 사용한다. 다만 크로스 컴파일 지원이 있는 오늘날에는 많은 시스템에서 이것조차 필요하지 않다.
도메인 특화 언어(DSL)를 직접 만든다면 유용할 수 있다. AST를 완전히 처리하고 IR로 변환하는 것은 어렵다. 언어가 기존 언어와 충분히 비슷하다면 그 언어로 출력하는 것만으로도 많은 작업을 절약할 수 있다.
닌텐도 에뮬레이터 같은 머신 에뮬레이터는 실제로 기계어로 컴파일된 코드를 받아 그것을 IR처럼 취급한다. 닌텐도용 게임은 기계어로 컴파일되어 그 머신에서만 실행될 수 있었다. 에뮬레이터는 닌텐도와 일치하는 추상 머신을 구현하고, 가상 머신에서 그 코드를 실행한다.
한때 Transmeta라는 회사는 물리 하드웨어에서 x86 기계어를 IR로 취급했다. 그들의 CPU는 x86 코드를 더 단순한 네이티브 명령 집합으로 변환했다.
하지만 근본적으로 기계어는 IR로 작동할 수는 있어도, 이 목적으로 쓰기에는 문제가 될 수 있다. 머신 아키텍처의 미묘한 차이는 큰 성능 페널티를 만들 수 있다. 또한 기계어에는 IR이 보유하는 많은 정보가 없어서, 새 타깃 아키텍처에 맞게 제대로 최적화하기 어렵다. 에뮬레이터는 애플리케이션 코드가 의도한 동작이 아니라, 옛 머신의 구체적인 동작 자체를 걱정해야 한다.
Python은 완전한 컴파일과 VM 언어의 중간쯤 되는 모델을 갖고 있어 언급하고 싶다. Python은 IR이 아니라 소스 코드로 배포된다. 하지만 Python이 소스 코드를 가지고 하는 첫 번째 일은 “.pyc” 파일 형태의 자체 IR을 생성하는 것이다. Python에는 JIT 컴파일러가 없고, 대신 VM에서 모든 것을 실행한다(표준 Python 배포판 기준). 반면 PyPy 구현체는 JIT을 사용한다.
이와 관련해 CLR과 Java IR을 받아 기계어를 출력하는 컴파일러들도 있다. 어떤 것들은 IR을 받아 WebAssembly IR로 출력한 뒤, 브라우저에 배포하기도 한다. IR은 정말 ‘중간’이다. 성공적인 IR이 하나 나오면, 누군가는 그것을 가져다 다른 IR로 변환하거나 컴파일을 마무리해 버린다.
다만 앞서 말했듯 IR은 소스 언어의 미묘한 뉘앙스를 항상 포착하지 못하므로, 각 단계는 정보를 잃어 최적화에 제약을 줄 수 있다. 어떤 IR은 다른 VM과 호환되지 않도록 설계되기도 한다(예: 스캐닝 가비지 컬렉션 지원). 한 IR에서 다른 IR로 변환하거나, 혹은 기계어를 출력하는 작업은 매우 벅찰 수 있다.