PyJitPl5

이 문서는 RPython JIT 생성기의 5세대에 대해 설명합니다.

JIT 구현

JIT의 이론은 원칙적으로는 훌륭하지만, 실제 코드는 전혀 다른 이야기입니다. 이 절에서는 RPython의 JIT가 어떻게 구현되어 있는지 개략적으로 설명합니다. 소스 코드를 깊이 살펴보기 전에 RPython 변환 툴체인이 어떻게 동작하는지 이해해 두면 도움이 됩니다.

JIT 관련 코드는 거의 모두 rpython/jit 하위 디렉터리에 있습니다. 변환 시점에 쓰이는 코드는 codewriter 디렉터리에 있습니다. metainterp 디렉터리에는 트레이서와 옵티마이저를 비롯한 플랫폼 독립적인 코드가 들어 있습니다. backend 디렉터리, 즉 백엔드 디렉터리의 코드는 기계어 코드 생성을 담당합니다.

JIT 힌트

인터프리터에 JIT를 추가하려면 RPython에서는 대상 인터프리터에 힌트 두 개만 추가하면 됩니다. 바로 jit_merge_point와 can_enter_jit입니다. jit_merge_point는 옵코드 디스패치의 시작 부분에 위치해야 합니다. 이 힌트를 통해 JIT는 기계어 코드를 계속 실행하는 것이 더 이상 적합하지 않을 때 인터프리터로 되돌아갈 수 있습니다. can_enter_jit는 애플리케이션 수준 루프의 끝에 둡니다. Python 인터프리터에서는 JUMP_ABSOLUTE 바이트코드가 이에 해당합니다. Python 인터프리터는 pypy/module/pypyjit/interp_jit.py에서 기본 인터프리터 루프의 메서드 몇 개를 오버라이드하여 자신의 힌트를 정의합니다.

RPython JIT 생성기를 사용하려는 인터프리터는 green 변수 목록과 red 변수 목록을 정의해야 합니다. green 변수는 루프 상수입니다. 이 변수들은 현재 루프를 식별하는 데 사용됩니다. red 변수는 실행 루프에서 사용되는 나머지 모든 것을 위한 변수입니다. 예를 들어, Python 인터프리터는 코드 객체와 명령어 포인터를 green으로, 프레임 객체와 실행 컨텍스트를 red로 전달합니다. 이 객체들은 JIT 힌트가 있는 위치에서 JIT에 전달됩니다.

JIT 생성

변환 과정 중 고수준 Python 연산을 백엔드용 저수준 연산으로 바꾸는 RTyping 단계가 끝나면, 변환 드라이버는 metainterp/warmspot.py에 있는 apply_jit()를 호출하여 현재 변환 중인 인터프리터에 JIT 컴파일러를 추가합니다. apply_jit()는 사용할 어셈블러 백엔드를 결정한 다음 나머지 작업을 WarmRunnerDesc 클래스에 위임합니다. WarmRunnerDesc는 함수 그래프에서 두 개의 JIT 힌트를 찾습니다. WarmRunnerDesc는 jit_merge_point 힌트가 들어 있는 그래프(포털 그래프라고 부릅니다)를 다시 작성하여 특수 JIT 예외를 처리할 수 있게 합니다. 이 예외는 JIT에서 빠져나올 때 인터프리터에 특수한 상황을 알리는 역할을 합니다. can_enter_jit 힌트가 있던 위치는 warmstate.py에 있는 maybe_compile_and_run 함수 호출로 대체됩니다. 이 함수는 현재 루프가 “핫(hot)”하여 컴파일해야 하는지 확인합니다.

다음으로, codewriter/*.py는 포털 그래프(portal graph)부터 시작하여 인터프리터의 그래프들을 JIT 바이트코드로 변환합니다. 이 바이트코드는 최종 바이너리에 저장되므로 빠르기보다는 간결하도록 설계되었습니다. 바이트코드 codewriter가 인터프리터의 모든 부분을 “보는” 것은 아닙니다(무엇을 보는지는 JIT의 정책이 정의합니다). 이러한 경우에는 단순히 불투명한(opaque) 호출을 삽입합니다.

마지막으로 변환이 완료되면서 인터프리터의 바이트코드가 최종 바이너리에 포함되고, 인터프리터는 JIT의 런타임 구성 요소를 사용할 준비를 마칩니다.

트레이싱

JIT가 활성화된 인터프리터에서 실행되는 애플리케이션 코드는 평소와 같이 시작되며, 일반적인 평가 루프 위에서 해석됩니다. 애플리케이션 루프가 닫히면(can_enter_jit 힌트가 있던 위치) 인터프리터는 WarmEnterState의 maybe_compile_and_run() 메서드를 호출합니다. 이 메서드는 현재 green 변수에 연관된 카운터를 증가시킵니다. 이 카운터가 일정 수준에 도달하면 JIT는 트레이싱 모드로 진입합니다. 이는 보통 애플리케이션 루프가 여러 번 실행되었음을 뜻합니다.

트레이싱(Tracing)은 애플리케이션 수준 코드를 해석하는 인터프리터의 바이트코드, 즉 변환 시점에 생성된 바이트코드를 JIT가 해석하는 단계입니다. 이를 통해 JIT는 애플리케이션 수준 루프를 구성하는 연산들을 정확히 볼 수 있습니다. 트레이싱은 metainterp/pyjitpl.py에 있는 MetaInterp 클래스와 MIFrame 클래스가 수행합니다. maybe_compile_and_run()은 MetaInterp를 생성하고 그 객체의 compile_and_run_once() 메서드를 호출합니다. 이 메서드는 루프의 입력 인자, 즉 jit_merge_point 힌트에서 전달된 red 변수와 green 변수에 대해 MIFrame을 초기화하고, MIFrame이 포털 그래프의 바이트코드를 해석하기 시작하도록 설정합니다.

해석을 시작하기 전에 루프 입력 인자는 박스로 감싸집니다. 박스(metainterp/history.py에 정의됨)는 JIT가 해석하고 있는 프로그램 안의 값에 대해 그 값과 타입을 감쌉니다. 박스에는 크게 상수 박스와 일반 박스의 두 가지 종류가 있습니다. 상수 박스는 트레이싱 중에 알려져 있다고 가정되는 값에 사용됩니다. 이 값들이 반드시 컴파일 타임 상수인 것은 아닙니다. “승격된(promoted)” 모든 값, 즉 JIT가 최적화를 위해 상수라고 가정하는 값도 상수 박스에 저장됩니다. 일반 박스는 루프가 실행되는 동안 바뀔 수 있는 값을 담습니다. 일반 박스에는 BoxInt, BoxPtr, BoxFloat의 세 종류가 있고, 상수 박스에는 ConstInt, ConstPtr, ConstFloat, ConstAddr의 네 종류가 있습니다. (ConstAddr는 변환 툴체인의 제약을 우회하는 용도로만 사용됩니다.)

메타 인터프리터는 JIT 바이트코드를 해석하기 시작합니다. 각 연산은 실행된 뒤 트레이스(trace)라고 부르는 연산 목록에 기록됩니다. 연산에는 연산 대상이 되는 박스(box)의 목록, 즉 인자가 있을 수 있습니다. 일부 연산(GETFIELD, GETARRAYITEM 등)에는 인자가 메모리에 어떻게 배치되어 있는지 기술하는 특수 객체도 있습니다. 트레이싱으로 생성될 수 있는 모든 연산은 metainterp/resoperation.py에 나열되어 있습니다. 트레이싱 도중 JIT가 바이트코드를 가지고 있는 함수에 대한 (인터프리터 수준의) 호출이 일어나면, 스택에 MIFrame이 하나 더 추가되고 같은 히스토리로 트레이싱이 계속됩니다. 이렇게 하면 호출 경계를 넘어 연산 목록이 평탄화됩니다. 가장 중요한 점은 이로써 옵코드 디스패치 루프가 풀린다는 것입니다. 해석은 can_enter_jit 힌트를 만날 때까지 계속됩니다. 이 시점에는 애플리케이션 수준 루프의 한 번의 반복 전체를 관찰하고 기록한 상태입니다.

반복을 한 번만 기록했기 때문에 JIT는 루프 안의 코드 경로를 하나만 알고 있습니다. 예를 들어, 다음과 같은 if 문 구조가 있다면:

if x:
    do_something_exciting()
else:
    do_something_else()

그리고 JIT가 트레이싱을 수행할 때 x가 참이면, do_something_exciting 코드 경로만 트레이스에 추가됩니다. 이후 실행에서 이 경로가 여전히 유효한지 확인하기 위해, 가드(guard) 연산이라는 특수한 연산이 트레이스에 추가됩니다. 가드(guard)는 JIT가 트레이싱 중에 세운 가정이 여전히 참인지 확인하는 작은 테스트입니다. 위 예제에서는 do_something_exciting을 실행하기 전에 x에 대한 GUARD_TRUE 가드(guard)가 생성됩니다.

메타 인터프리터는 루프를 트레이싱했음을 확인하고 나면, 얻은 결과를 어떻게 컴파일할지 결정합니다. 이 두 작업 사이에는 선택적인 최적화 단계가 있으며, 이에 대해서는 이 페이지 뒷부분에서 다룹니다. 백엔드는 트레이스 연산을 해당 머신에 맞는 어셈블리로 변환합니다. 그런 다음 컴파일된 루프를 프런트엔드로 다시 넘깁니다. 다음에 애플리케이션 코드에서 그 루프를 만나면, 일반 인터프리터 대신 최적화된 어셈블리를 실행할 수 있습니다.

최적화

JIT는 기계어 코드를 더 빠르게 실행하기 위해 오래된 기법과 새로운 기법을 여러 가지 사용합니다.

가상 객체(Virtual)와 가상화 가능 객체(Virtualizable)

가상(virtual) 값은 루프 안에서 생성되며, 호출을 통해서든 루프가 끝난 뒤까지 살아남아서든 루프 밖으로 탈출하지 않는 배열, 구조체 또는 RPython 수준 인스턴스입니다. 이 값은 JIT만 사용하므로 “최적화로 제거”될 수 있습니다. 즉, 값을 할당할 필요가 전혀 없으며, 필드를 메모리에서 역참조하는 대신 일급 값으로 저장할 수 있습니다. 가상 값을 사용하면 인터프리터의 임시 객체를 언래핑할 수 있습니다. 예를 들어, PyPy 인터프리터의 W_IntObject는 객체가 기계어 코드 밖으로 탈출하지 않는다는 것이 알려져 있는 한 정수 값만 남도록 언래핑될 수 있습니다.

virtualizable은 기계어 코드에서 그 구조가 최적화되어 제거된다는 점에서 virtual과 비슷합니다. 하지만 virtualizable은 JIT가 제어하는 코드 밖으로 빠져나갈(escape) 수 있습니다.

기타 최적화

JIT 옵티마이저의 대부분은 metainterp/optimizeopt/ 하위 디렉터리에 들어 있습니다. 자세한 내용은 해당 디렉터리를 참고하십시오.

추가 자료

현재 JIT에 관한 자세한 문서는 처음으로 발표된 다음 논문에서 확인할 수 있습니다:

Antonio Cuni의 박사 학위 논문의 5장과 6장에는 트레이싱 JIT가 일반적으로 어떻게 동작하는지에 대한 개요와 PyPy JIT라는 구체적인 사례에 대한 추가 정보가 담겨 있습니다.

JIT 태그가 붙은 블로그 게시물에도 추가 정보가 있을 수 있습니다.