본문 바로가기

TIL

Just-In-Time 컴파일 방식

반응형

JIT 컴파일에 대해서 찾아보다가 재밌어서 작성한 글입니다. JIT 컴파일이 어쩌다 나오게 됐는지, 그리고 어떤 방식을 JIT 컴파일이라고 하는지에 대한 내용입니다. 글의 내용 대부분은 글 아래의 참고 자료에서 나온 것이며 아래 동영상을 보는 것도 추천드립니다.

링크

틀린 내용이 있다면 알려주시면 감사하겠습니다.

Ahead-of-Time Compile(AOT 컴파일)

최근이야 프로그래밍을 처음 배운다고 하면 파이썬이 1타로 나오지만 당장 5년전만 해도 C언어가 주로 첫 언어로 추천받았던 거 같다.

언어를 처음 배우면 하는 건 정해져있다. Hello World!를 화면에 띄워야 한다.

#include<stdio.h>
int main() {
  printf("Hello World!\n");
  return 0;
}

이런 소스코드를 까만 화면에다가 치라고 한다음에 이상한 커맨드를 치라고 하면 다시 또 까만 화면에 Hello World!가 출력된다.

대학교 처음 들어와서 수업에서 헬로 월드 하나 쳐보면서 교수님이 어떻게 이런 소스코드가 컴퓨터에서 프로그램으로 실행되는지를 설명해줬고 난 그냥 받아적었다. 당연히 뭔지는 몰랐다. 그 내용은 대략 아래와 같다.

터미널 상에서 gcc -o main main.c를치면 위 과정이 전부 진행되는 것이다. 그리고 이런 방식으로 실행파일을 뽑아내는 것을 정적 컴파일 혹은 Ahead-of-Time 컴파일이라고 부르는 듯 하다.

터미널 상에서 gcc -o main main.c를치면 위 과정이 전부 진행되는 것이다. 그리고 이런 방식으로 실행파일을 뽑아내는 것을 정적 컴파일 혹은 Ahead-of-Time 컴파일이라고 부르는 듯 하다.

이 방식의 골자는 컴파일러가 소스코드에서 실제로 실행되는 실행파일까지 전부 만들어준다는 점이다. 컴파일을 하는 시점과 실제로 프로그램이 실행되는 런타임으로 시점을 구분하면 아래와 같은 그림으로 나타낼 수 있다.

출처 : https://www.youtube.com/watch?v=sQTOIkOMDIw

컴파일 타임에서 컴파일러가 하는 일을 정말 간단히 적어둔 것이다. 소스코드를 분석하고 파싱하고 코드를 만들고 만든 코드를 최적화한 뒤에 실행파일을 만든다. 그렇게 나오는 결과물이 실행파일이다. 여기서 말하는 코드는 CPU가 바로 알아먹는 기계어(바이너리)다.

런타임 시에는 할 게 별로 없다. 그냥 CPU한테 이거 해달라고 하면서 메모리에 실행파일을 올리면 된다.

이런 컴파일 방식의 단점이 뭘까? 일단 컴파일러가 해야 되는 일이 너무 많다!

위 그림에 나와 있는 일들을 간단히 표현헸지만 저거 하나하나가 매우 큰 작업이 될 것이다. 그런데 이걸 컴파일러가 다 해야되는 일이다. 그리고 이는 정말 정말 정말 어렵다.

컴파일러의 제일 대표격인 GCC는 초기에 코드최적화를 잘 못했다고 한다. 그래서 최적화가 많이 필요한 부분은 프로그래머가 직접 어셈블리를 치는 게 당연했다고 한다. 학교수업에서 교수님이 풀었던 썰을 빌리자면 스트리트 파이터 2의 그래픽 작업을 프로그래머가 어셈블리를 직접 쳐가면서 했다고 한다.

어플리케이션 프로그래머 입장에선 내가 컴파일러 짤 건 아니니까 이 점은 넘긴다고 쳐보자. 이런 프로그래머 입장에서 현실적인 단점은 컴파일은 정말 오래 걸린다는 것이다. opencv나 kaldi나 gem5나 linux나 소스코드를 받은 뒤에 로컬에서 빌드를 해본 사람이 있다면 바로 공감할 수 있을 것이다.

이러한 대형 프로그램을 직접 빌드하는 것은 굉장히 스트레스를 유발한다. 일단 컴파일이 오래 걸린다. 머신의 성능 따라 다르겠지만 기본적으로 10분은 가볍게 넘긴다. 게다가 한 5분쯤 컴파일을 하다가 뭔가 에러를 뱉고 컴파일이 중단되고 알아보기도 힘든 에러 로그를 보며 그 에러를 고쳤다고 치자.

운 없으면 다시 처음부터 컴파일을 시작해야 한다. 오류나는 부분 한 줄 고쳤다고 말이다. 만약 빌드가 아니라 디버깅 중이었다고 하면 내가 한 줄 고쳤는데 프로그램을 전부 새로 컴파일해야될 수도 있다.

또 하나 애플리케이션 프로그래머 입장에서 현실적인 장애물이 존재한다. 배포 시의 문제다. 이러한 컴파일 방식은 CPU에게 바로 먹일 수 있는 실행파일이 최종 결과물이기 때문에 배포하려는 머신의 아키텍처별, OS별로 컴파일을 진행해야 한다. 만약에 powerpc, x86, x64, arm 머신에다가 ubuntu와 windows를 대상으로 배포를 진행할거라면 컴파일을 일단 8번은 해야 된다. 자칫하면 소스 코드를 8개 작성해야 될 수도 있다.

단점을 요약하자면 다음과 같다.

  1. 컴파일러는 만들기가 정말 어려운 프로그램이다.
  2. 소스 코드를 컴파일하는 과정은 정말 느리다.
  3. 아키텍처, OS에 의존하는 부분이 많아서 같은 기능을 하는 소스 코드를 여러번 작성하거나 컴파일을 여러번 해야 될 수도 있다.

이런 게 마음에 안든 사람들은 다른 방식으로 소스코드를 실행할 방법을 생각한다.

Interpreter (인터프리터)

인터프리터의 방식은 매우 간단하다. 컴파일을 안하겠다는 것이다. 런타임 시에 소스 코드를 분석하고 파싱하고 실행하겠다는 것이다.

출처 : https://www.youtube.com/watch?v=sQTOIkOMDIw

이와 같은 방식을 수행하기 위해서는 인터프리터라는 프로그램을 만들고 이 위에서 소스 코드를 실행한다고 생각하면 된다.

인터프리터 언어로 현재 제일 유명한 것은 아마 파이썬과 자바스크립트일 거 같다. 파이썬을 생각해보자.

main.py란 소스 코드를 작성하고 터미널에서 python main.py를 치면 내가 작성한 프로그램이 실행될 것이다. 이 커맨드를 위와 같은 방식으로 보자면 python은 파이썬 소스 코드를 해석하고 실행해주는 인터프리터 프로그램인 것이고 main.py는 내가 이 인터프리터에 먹일 소스코드인 것이다.

당연한 얘기겠지만 이 인터프리터 프로그램은 OS와 아키텍처에 의존하는 형식이 될 수 밖에 없을 것이다. 작성한 소스코드가 프로그램으로 실행되려면 결국은 바이너리로 바뀐 다음에 CPU한테 먹여야 하고 바이너리는 CPU 아키텍처에 의존적이니까 말이다.

그러나 어플리케이션 프로그래머 입장에선 내가 인터프리터 짤 것이 아니기 때문에 이러한 방식을 환영할 수 밖에 없다.

배포할 머신에 인터프리터만 존재하면 내가 작성한 소스 코드 하나로 모든 머신 위에서 돌아간다! 심지어 컴파일 오래 걸리는 그런 경험도 사라지게 된다.

이 인터프리터 방식도 만능은 아니다. 정말 치명적인 단점이 존재하는데 그것은 너무 느리다는 것이다. 진짜 느리다.

이 인터프리터 방식도 만능은 아니다. 정말 치명적인 단점이 존재하는데 그것은 너무 느리다는 것이다. 진짜 느리다.

인터프리터 방식이 느린 주요 이유는 크게 두 가지로 볼 수 있다.

  1. 런타임 중에 실제 소스코드를 분석하고 파싱한 다음에 실행한다.
  2. 컴파일러처럼 코드의 최적화를 진행하기가 힘들다.

이를 해결하기 위해서 사람들은 코드를 돌릴 가상머신을 만들고 해당 가상머신 위에서 돌아갈 코드의 형식으로 바이트코드라는 것을 채택한다.

그러면 이제 단계가 바뀌는 것이다. 소스코드를 작성하면 해당 소스코드를 바이트코드로 변환하고 이 바이트코드를 가상 머신에 먹이면 가상 머신은 이 바이트 코드를 읽어들인 뒤 CPU한테 먹인다. 즉, 런타임만 존재했던 인터프리터에서 바이트코드로 변환하는 과정(컴파일 타임)을 추가하는 것이다.

굳이 말하자면 가상 머신은 바이트코드의 인터프리터인 것이다.

대표적인 인터프리터 언어인 파이썬의 실행과정은 아래와 같은 그림으로 나타낼 수 있을 것이다.

출처 : https://www.youtube.com/watch?v=sQTOIkOMDIw

컴파일 언어의 문제점이 었던 소스코드와 컴파일러가 CPU에 의존적이라는 문제를 해결하면서 인터프리터의 문제점인 런타임 중에 실제 소스코드를 분석해서 느리다는 점과 컴파일러와 같은 코드 최적화가 거의 불가능하다는 점도 어느정도 해소하는 방식이다.

위와 같이 VM을 도입하면 방금 설명한 python main.py를 터미널에서 쳤을 때 일어나는 일에 한 스텝이 더 추가가 된다. main.py를 파이썬 가상머신의 바이트코드(.pyc)로 변환하고 이를 파이썬 가상머신에 먹여서 실행하는 것이다.

이걸로 이제 인터프리터 언어는 빨라졌다. 하지만 또 문제가 있다.

여전히 느리다. 왜? 가상 머신까지 썼는데?

느리다는 것은 여전히 컴파일된 실행파일에 비해서 느리다는 말이다.

소스코드를 바이트코드로 컴파일 한다지만 컴파일 언어로 작성된 소스코드에 대해 할 수 있는 최적화를 전부 적용하기에는 역시 무리가 있으며, 인터프리터가 런타임 중에 분석하고 파싱하는 대상이 소스코드에서 바이트코드가 된 것이지 런타임 중에 위 과정을 진행하는 것은 똑같다는 점이다.

Just-in-Time Compile(JIT 컴파일)

일단 왜 컴파일러가 할 수 있는 최적화를 바이트코드에 대해서는 못하는 것이 많을까?

생각해볼 수 있는 이유 중 하나로 컴파일러는 target 아키텍처를 알고 있기 때문에 해당 아키텍처에 의존적인 최적화를 진행할 수 있고 이런 최적화는 정말 강력해서 컴파일 언어의 속도에 큰 기여를 한다.

바이트 코드는 그런 아키텍처에 의존하지 않고 VM에서 돌아가는 코드이기 때문에 그런 최적화를 진행하기가 힘들 것이다.

어쩄든 인터프리터는 느리다. 컴파일은 컴파일시간이 너무 오래 걸리고 바이너리가 커지지만 런타임이 빠르다. 그래서 이 두가지 방식을 융합하자는 발상으로 나온 것이 Just In Time(JIT) 컴파일 방식이다.

가상 머신 위에서 바이트코드 인터프리터를 사용해서 프로그램을 실행하되 런타임 중에 컴파일도 하자는 것이다.

아래와 같은 그림으로 이제 런타임이 바뀐다.

런타임이라는 것은 바이너리가 CPU에 들어가는 시점이기 때문에 아키텍처에 대한 정보도 쓸 수 있고 어떤 코드가 몇 번 실행됐는지, 어떤 분기는 몇 번 True였는지 같은 런타임 정보 또한 사용이 가능하다. 이런 정보를 이용하면 컴파일 시에 할 수 있는 최적화 정보도 더 많아질것이고 좋지 않겠는가?

그러나, 당연한 얘기지만 코드 전체를 다 컴파일하면 프로그램의 런타임이 말도 안되게 느려질 것이기 때문에 어떤 코드를 컴파일 해서 들고 있을지 잘 정해야 한다.

어떤 코드를 컴파일해서 최적화해야 할지는 잘 정해야 한다. 프로그램 전체에서 딱 한 번 실행되는 500줄짜리 코드를 컴파일할지, 500000번 이상은 실행되는 10줄짜리 코드를 컴파일할지, 사람이 볼 땐 당연히 10줄짜리를 컴파일해줘야 겠지만 컴퓨터 입장에선 그런 컨텍스트 없이 그냥 코드를 받은 것 뿐이다.

위 예시에서 10줄짜리와 500줄짜리 코드를 전부 컴파일 했다고 쳐보자.

어거지지만 단순히 코드 라인 수가 실행시간을 나타낸다고 했을 때 500줄짜리는 실행시간이 500인거고 10줄짜리 코드의 총 실행시간은 5000000이다.

컴파일을 한다면 실행시간이 코드 라인 수당 0.7로 줄어든다고 해보자.

물론 컴파일에 걸리는 시간도 적지 않다. 컴파일에 드는 시간이 코드 라인당 5라고 한다면 500줄짜리는 2500, 10줄짜리는 50을 쓴다.

500줄짜리를 컴파일 했을 때 얻는 실행시간의 이득은 $500 - (500 \times 0.7 + 5 \times 500) = -2350$으로 사실 전혀 이득이 아니다.

10줄짜리를 컴파일 하면 $5000000 - (5000000 \times 0.7 + 5 \times 10) = 1499950$이다.

위는 단순히 내가 생각해낸 예시니까 어떤 코드를 컴파일할지 정하는 것이 중요하다 느낌만 가져갔으면 바란다.

어쩄든 JIT 컴파일 시에 이 선택을 위해서 어떤 코드의 중요성을 'hotness'라는 말로 표현한다. 위에서 보면 10줄짜리 코드가 hotness가 훨씬 높은 hot spot인거고 500줄짜리 코드는 cold spot이 되는 것이다.

코드를 실행하기 전에 이를 알 수 있으면 좋겠지만 그것은 불가능하기 때문에 JIT 컴파일을 하는 인터프리터의 가상 머신은 그 안에 hotness를 결정하기 위한 지표들을 런타임 중에 측정하며 저장한다.

제일 직관적인 측정방법은 해당 코드가 몇 번 실행되었는지를 측정하는 것이다. 위에서 500줄짜리는 함수 F라고 하고 10줄짜리를 G라고 하면 각 함수에 대한 카운터를 보관하다가 이 카운터가 특정 threshold를 넘어가면 그 때 컴파일을 진행하는 것이다.

컴파일을 한 뒤에 그 함수를 실행할 차례가 되면 이제 컴파일된 코드를 실행한다.

또 다른 방식으론 일정 시간 t마다 현재 어느 함수를 실행하고 있는지를 체크해서 그 함수를 t만큼 사용했다고 기록하는 것이다. 그리고 사용시간이 일정 threshold를 넘으면 컴파일을 하는 것이다.

JIT의 컨셉은 이게 전부이다.

그래서, 이거 성능 좋아요? 라고 물어본다면 적어도 기존 인터프리터 언어에다 이걸 추가하면 매우 유의미한 차이가 있다고 할 수 있다.

백준 온라인저지에서 파이썬으로 문제를 푸는 사람을 JIT이 뭔지 몰라도 엄청난 체감을 했을 것이다. Python3가 JIT 컴파일을 하지 않는 파이썬이고 pypy가 JIT을 하는 파이썬이기 때문이다.

JIT 컴파일을 사용하는 언어 중 제일 유명한 것은 JAVA이다. 자바 컴파일러는 자바 코드를 JVM의 바이트 코드로 바꿔주며 JVM은 바이트코드를 한 줄씩 인터프리터로 실행하다가 hot spot이 발견되면 그 때 해당 코드를 컴파일하며 JVM상의 코드 메모리에 올려놓고 쓴다.

지금까지 JIT 컴파일의 개념을 이해하기 위해서 정적 컴파일부터 인터프리터, 가상머신의 추가, JIT의 순서로 설명을 했다. Just in Time이라는 말이 영미권 사람한테는 직관적인 말일지 모르겠지만 그렇게 잘 나타낸 이름인지 모르겠다.

여전히 아무것도 모르다가 어제 갑자기 알고 싶어져서 찾아보고 재밌어서 나도 글로 작성한다. 이 글의 대부분 내용은 참고 자료에 있는 동영상에서 나온 것이다. 영어지만 영문 자막을 켜고 들으면 생각보다 들을만 하니 직접 보는 것도 추천한다.

참고 자료

https://www.youtube.com/watch?v=sQTOIkOMDIw

https://www.youtube.com/watch?v=yQ27DjKnxwo&t=3s

반응형