바이트코드 인터프리터

소개 및 개요

이 문서는 PyPy의 바이트코드 인터프리터와 관련 가상 머신 기능들의 구현을 설명합니다.

PyPy의 바이트코드 인터프리터는 CPython의 가상 머신을 연상시키는 구조를 가지고 있습니다: 이는 Python 소스 코드로부터 파싱되고 컴파일된 코드 객체를 처리합니다. 이는 pypy/interpreter/ 디렉터리에 구현되어 있습니다. CPython 구현에 익숙한 사람들은 그곳에서 유사한 개념을 쉽게 알아볼 수 있을 것입니다. 주요 차이점은 객체에 대한 연산을 수행하기 위한 객체 공간(object space) 간접화(indirection)의 전반적인 사용과, 내장 모듈의 구성 방식(여기에서 설명됨)입니다.

코드 객체는 소스 코드를 잘 전처리하여 구조화한 표현이며, 그 주요 내용은 바이트코드입니다. 우리는 CPython 2.7과 동일한 압축 바이트코드 형식을 사용하며, 바이트코드 집합에 약간의 차이가 있습니다. 우리의 바이트코드 컴파일러는 토크나이저, 렉서, 파서, 추상 구문 트리 빌더, 바이트코드 생성기로 이어지는 유연한 패스들의 연쇄로 구현되어 있습니다. 후자의 패스들은 CPython 표준 라이브러리의 compiler패키지를 기반으로 하며, 다양한 개선 사항과 버그 수정이 적용되었습니다. 바이트코드 컴파일러(pypy/interpreter/astcompiler/에 위치)는 이제 통합되어 PyPy의 나머지 부분과 함께 번역(translation)됩니다.

코드 객체는 각각의 함수, 클래스, 모듈 본문 소스 코드에 대한 압축된 정보를 담고 있습니다. 이러한 코드 객체를 인터프리트한다는 것은 Frame class를 인스턴스화하고 초기화한 다음 그 frame.eval()메서드를 호출하는 것을 의미합니다. 이 주요 진입점은 적절한 네임스페이스를 초기화한 다음 각 바이트코드 명령어를 인터프리트합니다. Python의 표준 라이브러리에는 가상 머신의 바이트코드 명령어를 검사할 수 있게 해주는 lib-python/2.7/dis.py 모듈이 포함되어 있습니다:

>>> import dis
>>> def f(x):
...     return x + 1
>>> dis.dis(f)
2         0 LOAD_FAST                0 (x)
          3 LOAD_CONST               1 (1)
          6 BINARY_ADD
          7 RETURN_VALUE

CPython과 PyPy는 스택 기반 가상 머신입니다. 즉, 레지스터를 가지지 않고 대신 스택에 객체를 밀어 넣고 스택에서 객체를 꺼냅니다. 바이트코드 인터프리터는 오직 제어 흐름을 구현하고 이 값 스택에 블랙박스 객체를 밀어 넣고 꺼내는 일만을 담당합니다. 바이트코드 인터프리터는 이러한 블랙박스(감싸진) 객체에 대한 연산을 수행하는 방법을 알지 못하며, 이를 객체 공간에 위임합니다. 하지만 프로그램 실행에서 조건 분기를 구현하려면, 감싸진(wrapped) 객체에 대한 최소한의 지식을 얻어야 합니다. 따라서 각 객체 공간(object space)은 인터프리터 수준의 불리언 값을 반환하는 is_true(w_obj) 연산을 제공해야 합니다.

인터프리터의 내부 동작을 이해하려면 인터프리터 레벨과 애플리케이션 레벨 코드라는 개념을 이해하는 것이 중요합니다. 간단히 말해, 인터프리터 레벨은 기계에서 직접 실행되며, 애플리케이션 레벨 함수를 호출하면 바이트코드 해석이라는 간접 단계를 거치게 됩니다. 그러나 예외에 관해서는 특별한 주의가 필요한데, 애플리케이션 레벨 예외는 OperationErrors로 감싸지기 때문에 일반 인터프리터 레벨 예외와는 구분됩니다. OperationErrors에 대한 더 자세한 정보는 애플리케이션 레벨 예외를 참고하십시오.

인터프리터 구현은 호출자가 특정 함수 호출이 바이트코드 해석으로 이어지는지, 아니면 인터프리터 수준에서 직접 실행되는지를 알 필요가 없도록 하는 메커니즘을 제공합니다. 두 가지 기본적인 게이트웨이 클래스는 인터프리터 수준 함수를 애플리케이션 수준 실행에 노출하거나(interp2app), 애플리케이션 수준 헬퍼를 인터프리터 수준에서 투명하게 호출할 수 있도록(app2interp) 합니다.

바이트코드 인터프리터의 또 다른 임무는 기본적인 코드, 프레임, 모듈, 함수 객체를 애플리케이션 레벨 코드에 노출하는 일을 담당하는 것입니다. 이러한 런타임 자기 성찰(introspection) 및 수정 기능은 interpreter descriptors를 통해 구현됩니다(Raymond Hettinger의 Python how-to guide for descriptors도 참고하시기 바랍니다. PyPy는 이 모델을 광범위하게 사용합니다).

function argument parsing에는 상당한 복잡성이 있습니다. 언어로서 Python은 특정 함수 호출에 대해 인자를 제공하고 받는 유연한 방법을 제공합니다. 이를 올바르게 처리하기 위해 특별한 주의가 필요할 뿐만 아니라, 함수 호출 전반에 걸쳐 흐르는 모든 값의 타입을 추론하기 위해 바이트코드 인터프리터, 인자 파싱, 게이트웨이 코드에 대한 전체 프로그램 분석을 수행하는 annotation pass에도 어려움을 줍니다.

바로 이런 이유로 PyPy는 애노테이터(annotator)가 함수 호출 시 동질적인 이름-값 할당을 갖는 비교적 정적인 프로그램 흐름만을 보도록 하기 위해, 초기화 시점에 특수화된 프레임 클래스와 함수를 생성하는 방식에 의존합니다.

바이트코드 인터프리터 구현 클래스

프레임 클래스

Frame이라는 개념은 프로그램 실행 전반, 특히 가상 머신에서 널리 쓰입니다. 이들은 Code객체의 실행에 관한 중요한 정보를 담고 있기 때문에 때때로 execution frame이라고 불리는데, 이 객체는 다시 Python Function과 직접적으로 관련되는 경우가 많습니다. Frame 인스턴스는 다음과 같은 상태를 가집니다:

  • 이름-값 바인딩을 보관하는 지역 스코프로, 보통 래핑된 객체의 배열인 “고속 스코프(fast scope)”를 통해 구현됩니다
  • 함수의 제어 흐름(while이나 try 구문 등)에 관한 (중첩된) 정보를 담고 있는 블록스택
  • 바이트코드 해석이 객체를 꺼내 오고 결과를 넣는 값 스택입니다. (locals_stack_w는 실제로 지역 스코프와 값 스택을 모두 포함하는 단일 리스트입니다.)
  • 모듈 수준의 이름-값 바인딩을 담고 있는 globals 딕셔너리에 대한 참조
  • 트레이스백을 위해 현재 줄 번호와 파일 위치를 구성할 수 있는 디버깅 정보

더욱이 Frame 클래스 자체는 코드 객체에서 발견되는 실제 바이트코드를 구현하는 다수의 메서드를 가지고 있습니다. PyFrame클래스의 메서드는 여러 파일에 추가됩니다:

Code 클래스

PyPy의 코드 객체는 CPython의 코드 객체에서 찾을 수 있는 것과 동일한 정보를 담고 있습니다. 이것들은 소스 코드의 불변 표현일 뿐이며, 실행 상태나 Frames에서 찾을 수 있는 실행 환경에 대한 참조를 포함하지 않는다는 점에서 Function 객체와 다릅니다. Frame과 Function은 코드 객체에 대한 참조를 가지고 있습니다. 다음은 Code 속성 목록입니다:

  • co_flags는 이 코드 객체가 중첩된 스코프/제너레이터 등을 가지고 있는지 여부를 나타냅니다.
  • co_stacksize 코드를 실행하는 동안 스택이 도달할 수 있는 최대 깊이
  • co_code는 실제 바이트코드 문자열
  • co_argcount은 이 코드 객체가 기대하는 인자의 개수
  • co_varnames는 이 코드 객체에 전달되는 모든 인자 이름의 튜플입니다
  • co_nlocals는 지역 변수의 개수
  • co_names는 코드 객체에서 사용된 모든 이름의 튜플입니다
  • co_consts 코드 객체에서 사용되는 미리 만들어진 상수 객체(“리터럴”)의 튜플입니다.
  • co_cellvars는 중첩된 스코프에서의 접근을 위한 값을 담은 Cell들의 튜플입니다
  • co_freevars는 “위쪽” 스코프에서 온 셀(Cell) 이름들의 튜플입니다
  • co_filename는 이 코드 객체가 컴파일된 소스 파일입니다
  • co_firstlineno는 소스 파일에서 코드 객체의 첫 번째 줄 번호입니다
  • co_name코드 객체의 이름(주로 함수 이름)
  • co_lnotab는 바이트코드에 대응하는 줄 번호를 계산하기 위한 보조 테이블입니다

함수와 메서드 클래스

PyPy의 Function 클래스(pypy/interpreter/function.py에 있음)는 Python 함수를 나타냅니다. Function은 다음과 같은 주요 속성을 가집니다:

  • func_doc은 독스트링(또는 None)
  • func_name는 함수의 이름입니다
  • func_code는 함수 소스 코드를 나타내는 Code 객체입니다
  • func_defaults는 함수의 기본값(함수 정의 시점에 생성됨)
  • 추가(사용자 정의) 함수 속성을 위한 func_dict 딕셔너리
  • 전역 딕셔너리를 가리키는 func_globals 참조
  • func_closure는 Cell 참조들의 튜플입니다

Functions클래스는 인스턴스나 클래스에 대한 바인딩을 갖는 Method 객체를 생성하는 __get__ 디스크립터도 제공합니다. 마지막으로, FunctionsMethods는 모두 Arguments클래스 인스턴스가 주어지면 함수를 실행하는 call_args() 메서드를 제공합니다.

Arguments 클래스

Argument 클래스(pypy/interpreter/argument.py에 있음)는 함수에 전달된 인자를 파싱하는 역할을 담당합니다. Python은 상당히 복잡한 인자 전달 개념을 가지고 있습니다:

  • 위치 인수
  • 이름으로 지정된 키워드 인자
  • 함수 정의 시점에 정의되는, 위치 인자에 대한 기본값
  • 함수가 나머지 위치 인자를 받을 수 있게 하는 “star args”(별표 인자)
  • “star keyword args”는 함수가 임의의 추가 이름-값 바인딩을 받아들일 수 있게 합니다

게다가, Function 객체는 클래스나 인스턴스에 바인딩될 수 있으며, 이 경우 기반 함수의 첫 번째 인자가 바인딩된 객체가 됩니다. Arguments는 이러한 인자 파싱을 모두 처리할 수 있는 수단을 제공하며, 오류 보고도 담당합니다.

모듈 클래스

Module인스턴스는 일반적으로 모듈의 소스 파일을 실행하여 구성되는 실행 상태를 나타냅니다. 이러한 모듈의 전역 __dict__딕셔너리 외에도 다음과 같은 애플리케이션 수준 속성을 가집니다:

  • __doc__은 모듈의 독스트링입니다
  • __file__ — 이 모듈이 인스턴스화된 소스 파일명
  • 상대 임포트에 사용되는 __path__상태

애플리케이션 레벨 파일을 임포트하는 데 사용되는 기본 Module 외에도, 애플리케이션 레벨과 인터프리터 레벨 양쪽에서 이름-값 바인딩을 정의할 수 있게 해주는 더 정교한 MixedModule 클래스(pypy/interpreter/mixedmodule.py 참조)가 있습니다. 예를 들어 __builtin__ 모듈의 pypy/module/__builtin__/__init__.py 파일을 참조하고, 더 상위 수준의 내용은 코딩 가이드의 모듈 챕터를 참조하십시오.

게이트웨이 클래스

PyPy만의 독특한 특성 중 하나는 인터프리트된 코드와 머신 레벨 코드 사이의 장벽을 쉽게 넘나들 수 있다는 점입니다(흔히 인터프리터 레벨과 애플리케이션 레벨의 차이라고 언급됩니다). 양방향으로 장벽을 넘나드는 데 사용되는 해당 코드(pypy/interpreter/gateway.py에 있음)는 다소 복잡하다는 점에 유의하십시오. 이는 주로 타입을 추론하는 애노테이터가 이 장벽을 넘나드는 객체의 타입을 추적해야 하기 때문입니다.

인터프리터수준 함수를 애플리케이션수준에서 사용할 수 있도록 만들기

인터프리터 수준 함수를 애플리케이션 수준에서 사용할 수 있게 하려면 pypy.interpreter.gateway.interp2app(func)를 호출합니다. 이러한 함수는 보통 space 인자와 임의 개수의 위치 인자를 받습니다. 또한 이러한 함수는 실제 인터프리터 수준 함수가 호출되기 전에 애플리케이션 수준에서 제공된 인자들이 어떻게 언랩(unwrap)되어야 하는지를 interp2app 로직에 알려주는 unwrap_spec을 정의할 수 있습니다. 예를 들어, Module 인스턴스를 할당하고 생성하는 Module.__new__ 메서드와 같은 interpreter descriptors는 이러한 코드로 정의됩니다:

Module.typedef = TypeDef("module",
    __new__ = interp2app(Module.descr_module__new__.im_func,
                         unwrap_spec=[ObjSpace, W_Root, Arguments]),
    __init__ = interp2app(Module.descr_module__init__),
                    # module dictionaries are readonly attributes
    __dict__ = GetSetProperty(descr_get_dict, cls=Module),
    __doc__ = 'module(name[, doc])\n\nCreate a module object...'
    )

위의 __new__ 키워드 인자에서 참조되는 실제 Module.descr_module__new__ 인터프리터 레벨 메서드는 다음과 같이 정의됩니다:

def descr_module__new__(space, w_subtype, __args__):
    module = space.allocate_instance(Module, w_subtype)
    Module.__init__(module, space, None)
    return space.wrap(module)

정리하면, interp2app메커니즘은 애플리케이션 수준의 접근이나 호출을 디스크립터를 통해 내부 인터프리터 수준 객체로 적절히 라우팅하며, 타입을 추론하는 애노테이터가 만족할 만큼 충분한 정밀도와 힌트를 제공합니다.

인터프리터 레벨에서 애플리케이션 레벨 코드 호출하기

애플리케이션 레벨 코드가 종종 선호됩니다. 따라서 인터프리터 레벨에서 애플리케이션 레벨 코드를 호출하는 것을 종종 선호합니다. 이는 모듈에서 보통 정의 시점에 호출하는 Gateway의 app2interp 메커니즘을 통해 이루어집니다. 이는 객체 공간(object space)과 임의 개수의 인자를 받는 인터프리터 레벨 함수처럼 보이는 훅(hook)을 생성합니다. 인터프리터 레벨에서 함수를 호출할 때, 호출하는 쪽에서는 호출된 함수가 PyPy 인터프리터를 통해 실행되는지, 아니면 (변환 이후) 기계 위에서 직접 실행되는지 보통 신경 쓸 필요가 없습니다.

다음은 PyPy에서 Python 언어의 메타클래스(Metaclass) 탐색 알고리즘을 구현하는 방법을 보여주는 예시입니다:

app = gateway.applevel(r'''
    def find_metaclass(bases, namespace, globals, builtin):
        if '__metaclass__' in namespace:
            return namespace['__metaclass__']
        elif len(bases) > 0:
            base = bases[0]
            if hasattr(base, '__class__'):
                    return base.__class__
            else:
                    return type(base)
        elif '__metaclass__' in globals:
            return globals['__metaclass__']
        else:
            try:
                return builtin.__metaclass__
            except AttributeError:
                return type
''', filename=__file__)

find_metaclass  = app.interphook('find_metaclass')

find_metaclass인터프리터 수준 훅은 pypy/interpreter/pyopcode.pyBUILD_CLASS 옵코드 구현에서 다섯 개의 인자와 함께 호출됩니다:

def BUILD_CLASS(f):
    w_methodsdict = f.valuestack.pop()
    w_bases       = f.valuestack.pop()
    w_name        = f.valuestack.pop()
    w_metaclass = find_metaclass(f.space, w_bases,
                                 w_methodsdict, f.w_globals,
                                 f.space.wrap(f.builtin))
    w_newclass = f.space.call_function(w_metaclass, w_name,
                                       w_bases, w_methodsdict)
    f.valuestack.push(w_newclass)

나중에 인터프리터 레벨에서 find_metaclass 구현을 다시 작성하더라도 호출하는 측을 전혀 수정할 필요가 없다는 점에 유의하십시오.

인트로스펙션과 디스크립터

Python은 전통적으로 바이트코드 인터프리터 관련 객체에 대해 매우 광범위한 인트로스펙션 모델을 가지고 있습니다. PyPy와 CPython 모두에서 이러한 객체에 대한 읽기 및 쓰기 접근은 디스크립터(descriptor)로 라우팅됩니다. 물론 CPython에서는 이 디스크립터들이 C로 구현되어 있고, PyPy에서는 인터프리터 수준의 Python 코드로 구현되어 있습니다.

Function, Code, Frame 또는 Module클래스의 모든 인스턴스는 W_Root인스턴스이기도 하며, 이는 애플리케이션 레벨에서 표현될 수 있음을 의미합니다. 요즘 PyPy 객체 공간(object space)은 인터프리터 레벨 객체에 대한 접근을 마주쳤을 때 기본적인 디스크립터 조회를 처리해야 합니다: 객체 공간은 getclass 메서드를 통해 래핑된 객체에 그 타입을 물어본 다음, 디스크립터 함수를 받기 위해 그 타입의 lookup(name) 함수를 호출합니다. PyPy의 내부 객체 디스크립터 대부분은 pypy/interpreter/typedef.py의 끝부분에 정의되어 있습니다. 이 정의들을 애플리케이션 레벨에서 보이는 인터프리터 클래스의 정확한 속성에 대한 참조로 사용할 수 있습니다.