바이트코드 인터프리터

소개 및 개요

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

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

코드 객체는 소스 코드를 깔끔하게 전처리하여 구조화한 표현이며, 그 주된 내용은 바이트코드입니다. 저희는 CPython 2.7과 동일한 간결한 바이트코드 형식을 사용하며, 바이트코드 집합에는 약간의 차이가 있습니다. 저희 바이트코드 컴파일러는 유연한 패스들의 연쇄(토크나이저, 렉서, 파서, 추상 구문 트리 빌더, 바이트코드 생성기)로 구현되어 있습니다. 뒤쪽 패스들은 CPython 표준 라이브러리의 compiler 패키지를 기반으로 하며, 여러 가지 개선 사항과 버그 수정이 더해졌습니다. pypy/interpreter/astcompiler/ 아래에 있는 바이트코드 컴파일러는 이제 통합되어 PyPy의 나머지 부분과 함께 변환됩니다.

코드 객체에는 각 함수, 클래스, 모듈 본문 소스 코드에 대한 정보가 압축된 형태로 담겨 있습니다. 이러한 코드 객체를 해석한다는 것은 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는 스택 기반 가상 머신입니다. 즉, 레지스터를 두지 않고 대신 스택에 객체를 넣고 스택에서 객체를 꺼냅니다. 바이트코드 인터프리터는 제어 흐름을 구현하는 일과 이 값 스택에 블랙박스 객체를 넣고 꺼내는 일만 담당합니다. 바이트코드 인터프리터는 이러한 블랙박스(래핑된) 객체에 연산을 수행하는 방법을 알지 못하므로, 이러한 연산은 객체 공간(object space)에 위임합니다. 그러나 프로그램 실행 중에 조건 브랜치를 구현하려면 래핑된 객체에 대한 최소한의 정보는 얻어야 합니다. 따라서 각 객체 공간(object space)은 인터프리터 수준의 불리언 값을 반환하는 is_true(w_obj) 연산을 제공해야 합니다.

인터프리터의 내부 동작을 이해하려면 인터프리터 수준과 애플리케이션 수준 코드의 개념을 파악하는 것이 매우 중요합니다. 간단히 말해, 인터프리터 수준 코드는 기계에서 직접 실행되며, 애플리케이션 수준 함수를 호출하면 바이트코드 해석이라는 간접 단계를 거치게 됩니다. 하지만 예외와 관련해서는 특별한 주의가 필요합니다. 애플리케이션 수준 예외는 OperationErrors로 감싸지며, 이로써 일반적인 인터프리터 수준 예외와 구별되기 때문입니다. OperationErrors에 대한 자세한 내용은 애플리케이션 수준 예외를 참조하십시오.

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

바이트코드 인터프리터의 또 다른 역할은 기본 코드, 프레임, 모듈, 함수 객체를 애플리케이션 수준 코드에 노출하는 일을 맡는 것입니다. 이러한 런타임 인트로스펙션 및 수정 기능은 interpreter descriptors를 통해 구현됩니다(Python 디스크립터에 관한 Raymond Hettinger의 how-to guide for descriptors도 참고하십시오. PyPy는 이 모델을 광범위하게 사용합니다).

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

이러한 이유로 PyPy는 초기화 시점에 특화된 프레임 클래스와 함수를 생성하는 방식을 택합니다. 이렇게 하면 어노테이터는 함수 호출 시 이름-값 할당이 균일하게 이루어지는, 비교적 정적인 프로그램 흐름만 보게 됩니다.

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

프레임 클래스

프레임(Frame)이라는 개념은 프로그램 실행 전반, 특히 가상 머신에서 널리 쓰입니다. 프레임은 Code 객체의 실행에 관한 핵심 정보를 담고 있기 때문에 실행 프레임이라고 부르기도 합니다. Code 객체는 다시 Python Function과 직접 관련되는 경우가 많습니다. 프레임 인스턴스는 다음과 같은 상태를 가집니다:

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

게다가 프레임(Frame) 클래스 자체에는 코드 객체에 들어 있는 실제 바이트코드를 구현하는 여러 메서드가 있습니다. PyFrame 클래스의 메서드는 여러 파일에 나뉘어 추가됩니다:

Code 클래스

PyPy의 코드 객체에는 CPython의 코드 객체에 있는 것과 같은 정보가 들어 있습니다. 코드 객체는 소스 코드를 변경할 수 없는 형태로 나타낸 것일 뿐이며, 실행 상태나 Frames에 있는 실행 환경에 대한 참조를 담고 있지 않다는 점에서 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 바이트코드에 대응하는 줄 번호를 계산하기 위한 보조 테이블

함수(Function) 클래스와 메서드(Method) 클래스

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__ 디스크립터도 제공합니다. 마지막으로, Functions와 Methods는 모두 Arguments 클래스 인스턴스가 주어지면 함수를 실행하는 call_args() 메서드를 제공합니다.

인자 클래스

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

  • 위치 인자
  • 이름으로 지정한 키워드 인자
  • 함수 정의 시점에 정해지는 위치 인자의 기본값
  • 함수가 나머지 위치 인자를 받을 수 있게 해 주는 “star args”
  • “스타 키워드 인자(star keyword args)”를 사용하면 함수가 임의의 이름-값 바인딩을 추가로 받을 수 있습니다

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

모듈 클래스

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

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

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

게이트웨이 클래스

PyPy만의 고유한 특성은 인터프리트되는 코드와 기계 수준 코드 사이의 경계를 쉽게 넘나들 수 있다는 점입니다(흔히 인터프리터 수준과 애플리케이션 수준의 차이라고 부릅니다). 양방향으로 경계를 넘나드는 데 쓰이는 해당 코드(pypy/interpreter/gateway.py에 있음)는 다소 복잡하다는 점에 유의하십시오. 이는 주로 타입을 추론하는 어노테이터가 그 경계를 넘나드는 객체들의 타입을 추적해야 하기 때문입니다.

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

인터프리터 수준 함수를 애플리케이션 수준에서 사용할 수 있게 하려면 pypy.interpreter.gateway.interp2app(func)를 호출합니다. 이러한 함수는 보통 space 인자와 임의 개수의 위치 인자를 받습니다. 또한 이러한 함수는 unwrap_spec을 정의할 수 있으며, 이는 실제 인터프리터 수준 함수가 호출되기 전에 애플리케이션 수준에서 제공된 인자를 어떻게 언래핑(unwrap)해야 하는지 interp2app 로직에 알려 줍니다. 예를 들어, 모듈(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 메커니즘을 통해 이루어지며, 이 메커니즘은 보통 모듈 안에서 정의 시점에 호출합니다. 이 메커니즘은 공간(space) 하나와 임의 개수의 인자를 받는 인터프리터 수준 함수처럼 보이는 훅을 생성합니다. 인터프리터 수준에서 함수를 호출할 때, 호출자 쪽에서는 보통 호출한 함수가 PyPy 인터프리터를 통해 실행되는지 아니면 (변환 후에) 기계에서 직접 실행되는지 알 필요가 없습니다.

다음은 PyPy에서 Python 언어의 메타클래스 탐색 알고리즘을 어떻게 구현하는지 보여 주는 예입니다:

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.py에 있는 BUILD_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 파일 끝부분에 정의되어 있습니다. 애플리케이션 수준에서 보이는 인터프리터 클래스의 정확한 속성을 확인할 때 이 정의들을 참고 자료로 사용할 수 있습니다.