PyJitPl5¶
이 문서는 RPython JIT 생성기의 5세대에 대해 설명합니다.
JIT의 구현¶
JIT의 이론은 원칙적으로는 훌륭하지만, 실제 코드는 다른 이야기입니다. 이 절에서는 RPython의 JIT가 어떻게 구현되는지에 대한 고수준 개요를 제공하고자 합니다. 소스 코드를 파헤치기 전에 RPython 번역(translation) 툴체인이 어떻게 동작하는지 이해해 두면 도움이 됩니다.
거의 모든 JIT 특화 코드는 rpython/jit 하위 디렉터리에 있습니다. 번역(translation) 시점 코드는 codewriter 디렉터리에 있습니다. metainterp 디렉터리는 트레이서와 최적화기를 포함한 플랫폼 독립적인 코드를 담고 있습니다. backend 디렉터리의 코드는 머신 코드 생성을 담당합니다.
JIT 힌트¶
인터프리터에 JIT을 추가하려면, RPython은 대상 인터프리터에 단 두 개의 힌트만 추가하면 됩니다. 이는 jit_merge_point와 can_enter_jit입니다. jit_merge_point는 opcode 디스패치의 시작 부분에 위치해야 합니다. 이를 통해 JIT은 실행 중인 머신 코드가 더 이상 적합하지 않을 경우 인터프리터로 되돌아갈 수 있습니다. can_enter_jit은 애플리케이션 레벨 루프의 끝에 위치합니다. Python 인터프리터에서 이는 JUMP_ABSOLUTE 바이트코드입니다. Python 인터프리터는 기본 인터프리터 루프의 몇몇 오버라이드된 메서드 안에서, pypy/module/pypyjit/interp_jit.py 파일에 해당 힌트들을 정의합니다.
RPython JIT generator를 사용하려는 인터프리터는 green 변수 목록과 red 변수 목록을 정의해야 합니다. green 변수는 루프 상수입니다. 이는 현재 루프를 식별하는 데 사용됩니다. Red 변수는 실행 루프에서 사용되는 그 외의 모든 것을 위한 것입니다. 예를 들어, Python 인터프리터는 코드 객체와 명령어 포인터를 green으로, 프레임 객체와 실행 컨텍스트를 red로 전달합니다. 이 객체들은 JIT 힌트의 위치에서 JIT에 전달됩니다.
JIT 생성¶
번역(translation)의 RTyping 단계, 즉 고수준 Python 연산을 백엔드용 저수준 연산으로 바꾸는 단계를 거친 후, 번역 드라이버는 metainterp/warmspot.py의 apply_jit()를 호출하여 현재 번역(translation) 중인 인터프리터에 JIT 컴파일러를 추가합니다. apply_jit()는 어떤 어셈블러 백엔드를 사용할지 결정한 다음, 나머지 작업을 WarmRunnerDesc 클래스에 위임합니다. WarmRunnerDesc는 함수 그래프에서 두 개의 JIT 힌트를 찾습니다. 이는 jit_merge_point 힌트를 포함하는 그래프(포털 그래프라고 불립니다)를 재작성하여, JIT를 벗어날 때 인터프리터에 특수한 조건을 알리는 특수 JIT 예외를 처리할 수 있도록 합니다. can_enter_jit 힌트의 위치는 현재 루프가 “핫(hot)”한지, 그래서 컴파일되어야 하는지를 검사하는 함수인 warmstate.py의 maybe_compile_and_run 호출로 대체됩니다.
다음으로, 포털 그래프(portal graph)에서 시작하여, codewriter/*.py는 인터프리터의 그래프를 JIT 바이트코드로 변환합니다. 이 바이트코드는 최종 바이너리에 저장되므로, 빠르기보다는 간결하게 설계되었습니다. 바이트코드 codewriter는 인터프리터의 모든 부분을 “보지”는 않습니다(무엇을 보는지는 JIT의 정책에 의해 정의됩니다). 이런 경우에는, 단순히 불투명한(opaque) 호출을 삽입합니다.
마지막으로, 인터프리터의 바이트코드를 최종 바이너리에 포함하는 것을 비롯하여 번역(translation)이 완료되고, 인터프리터는 JIT의 런타임 구성 요소를 사용할 준비를 갖추게 됩니다.
트레이싱¶
JIT가 활성화된 인터프리터에서 실행되는 애플리케이션 코드는 평소와 같이 시작됩니다. 즉, 일반적인 평가 루프 위에서 인터프리트됩니다. 애플리케이션 루프가 닫힐 때(can_enter_jit 힌트가 있던 지점), 인터프리터는 WarmEnterState의 maybe_compile_and_run() 메서드를 호출합니다. 이 메서드는 현재 green 변수와 연관된 카운터를 증가시킵니다. 이 카운터가 특정 수준에 도달하면(일반적으로 애플리케이션 루프가 여러 번 실행되었음을 나타냄), JIT는 트레이싱 모드로 진입합니다.
트레이싱은 애플리케이션 레벨 코드를 인터프리트하는 인터프리터의, 번역(translation) 시점에 생성된 바이트코드를 JIT가 인터프리트하는 것입니다. 이를 통해 애플리케이션 레벨 루프를 구성하는 정확한 연산들을 볼 수 있습니다. 트레이싱은 metainterp/pyjitpl.py의 MetaInterp 및 MIFrame 클래스에 의해 수행됩니다. maybe_compile_and_run()은 MetaInterp를 생성하고 그 compile_and_run_once() 메서드를 호출합니다. 이는 루프의 입력 인자, jit_merge_point 힌트에서 전달된 적색 및 녹색 변수에 대해 MIFrame을 초기화하고, portal 그래프의 바이트코드를 인터프리트하기 시작하도록 설정합니다.
해석을 시작하기 전에, 루프 입력 인자들은 박스에 감싸집니다. 박스(metainterp/history.py에 정의됨)는 JIT가 해석하는 프로그램에서 어떤 값의 값과 타입을 감쌉니다. 박스에는 크게 두 가지 종류인 상수 박스(constant box)와 일반 박스(normal box)가 있습니다. 상수 박스는 트레이싱 동안 알려져 있다고 가정되는 값에 사용됩니다. 이 값들이 반드시 컴파일 타임 상수(compile time constant)인 것은 아닙니다. 최적화 목적으로 JIT가 상수로 간주하는, 즉 “프로모트(promoted)”된 모든 값도 상수 박스에 저장됩니다. 일반 박스는 루프가 실행되는 동안 변경될 수 있는 값을 담습니다. 일반 박스에는 BoxInt, BoxPtr, BoxFloat의 세 가지 종류가 있고, 상수 박스에는 ConstInt, ConstPtr, ConstFloat, ConstAddr의 네 가지 종류가 있습니다. (ConstAddr는 번역(translation) 툴체인의 한계를 우회하기 위해서만 사용됩니다.)
메타 인터프리터는 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는 기계어 코드를 더 빠르게 실행하기 위해 새롭고 오래된 여러 기법을 사용합니다.
가상 객체(Virtuals)와 가상화 가능 객체(Virtualizables)¶
가상(virtual) 값은 루프 동안 생성되며, 호출을 통해 루프를 벗어나지 않고 루프가 끝난 후에도 오래 유지되지 않는 배열, 구조체, 또는 RPython 레벨 인스턴스입니다. JIT에서만 사용되기 때문에 이 값은 “최적화되어 제거”될 수 있습니다. 즉, 이 값을 전혀 할당할 필요가 없으며, 그 필드들은 메모리에서 역참조하는 대신 일급 값(first class value)으로 저장될 수 있습니다. 가상(virtual) 값은 인터프리터의 임시 객체를 언랩(unwrap)할 수 있게 해줍니다. 예를 들어, PyPy 인터프리터의 W_IntObject는 해당 객체가 머신 코드를 벗어나지 않는다는 것이 알려져 있는 한, 단순히 정수 값으로 언랩(unwrap)될 수 있습니다.
virtualizable은 그 구조가 머신 코드에서 최적화되어 제거된다는 점에서 virtual과 유사합니다. 하지만 virtualizable은 JIT의 제어를 받는 코드에서 벗어날 수 있습니다.
기타 최적화¶
JIT의 옵티마이저 대부분은 하위 디렉터리 metainterp/optimizeopt/에 들어 있습니다. 더 자세한 내용은 그곳을 참고하십시오.
추가 자료¶
현재 JIT에 대한 더 자세한 문서는 최초로 발표된 논문에서 확인할 수 있습니다:
Antonio Cuni의 박사 학위 논문의 5장과 6장에는 트레이싱 JIT가 일반적으로 어떻게 작동하는지에 대한 개요와 PyPy JIT의 구체적인 사례에 대한 추가 정보가 담겨 있습니다.
JIT 태그가 붙은 블로그 게시물에도 추가 정보가 있을 수 있습니다.